diff --git a/src/AcDream.App/Studio/PanelFbo.cs b/src/AcDream.App/Studio/PanelFbo.cs new file mode 100644 index 00000000..91270d97 --- /dev/null +++ b/src/AcDream.App/Studio/PanelFbo.cs @@ -0,0 +1,128 @@ +using System; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.App.UI; +using Silk.NET.OpenGL; + +namespace AcDream.App.Studio; + +/// +/// Renders a into an off-screen FBO each frame and +/// returns the color texture handle for display in ImGui. +/// +/// Pattern lifted verbatim from : +/// RGBA8 color texture + Depth24Stencil8 renderbuffer, lazily (re)created on +/// size change. The entire 2-D UI pass is sealed in a +/// so it cannot disturb the surrounding ImGui GL state. +/// +/// FBO origin is bottom-left (GL convention). The caller must flip V when +/// displaying the texture in ImGui (pass uv0=(0,1), uv1=(1,0) to ImGui.Image) +/// so the image appears right-side-up in ImGui's top-left coordinate system. +/// +public sealed unsafe class PanelFbo : IDisposable +{ + private readonly GL _gl; + + // Off-screen target — lazily (re)created when the requested size changes. + private uint _fbo; + private uint _colorTex; + private uint _depthRbo; + private int _fbW; + private int _fbH; + + public PanelFbo(GL gl) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + } + + /// + /// Render (a full draw pass) into a + /// private FBO at × pixels. + /// Returns the GL color texture handle (0 on failure). The texture is valid until + /// the next call to with a different size, or until . + /// + public uint Render(int width, int height, UiHost host) + { + if (width <= 0 || height <= 0 || host is null) return 0u; + + EnsureFramebuffer(width, height); + if (_fbo == 0) return 0u; + + // Seal the entire pass: GLStateScope saves + restores every GL state the + // UI draw touches (viewport, blend, FBO binding, etc.) so ImGui's own state + // — set up by BeginFrame and expected intact by Render — is untouched. + using var scope = new GLStateScope(_gl); + + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); + _gl.Viewport(0, 0, (uint)width, (uint)height); + _gl.Disable(EnableCap.ScissorTest); + _gl.ClearColor(0.18f, 0.18f, 0.18f, 1f); // dark bg matching the old direct draw + _gl.ClearDepth(1.0); + _gl.DepthMask(true); + _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); + + host.Draw(new Vector2(width, height)); + + // FBO stays bound here; GLStateScope.Dispose() restores the previous binding. + return _colorTex; + } + + // ── FBO lifecycle (mirrors PaperdollViewportRenderer.EnsureFramebuffer) ────── + + private void EnsureFramebuffer(int width, int height) + { + if (_fbo != 0 && width == _fbW && height == _fbH) return; + DeleteFramebuffer(); + + _fbW = width; + _fbH = height; + + _colorTex = _gl.GenTexture(); + _gl.BindTexture(TextureTarget.Texture2D, _colorTex); + _gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8, + (uint)width, (uint)height, 0, + PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, + (int)TextureMinFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, + (int)TextureMinFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, + (int)TextureWrapMode.ClampToEdge); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, + (int)TextureWrapMode.ClampToEdge); + _gl.BindTexture(TextureTarget.Texture2D, 0); + + _depthRbo = _gl.GenRenderbuffer(); + _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo); + _gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer, + InternalFormat.Depth24Stencil8, (uint)width, (uint)height); + _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0); + + _fbo = _gl.GenFramebuffer(); + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); + _gl.FramebufferTexture2D(FramebufferTarget.Framebuffer, + FramebufferAttachment.ColorAttachment0, + TextureTarget.Texture2D, _colorTex, 0); + _gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer, + FramebufferAttachment.DepthStencilAttachment, + RenderbufferTarget.Renderbuffer, _depthRbo); + + var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer); + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); + if (status != GLEnum.FramebufferComplete) + { + Console.WriteLine($"[studio] PanelFbo incomplete: {status} ({width}x{height})"); + DeleteFramebuffer(); + } + } + + private void DeleteFramebuffer() + { + if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; } + if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; } + if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; } + _fbW = _fbH = 0; + } + + public void Dispose() => DeleteFramebuffer(); +} diff --git a/src/AcDream.App/Studio/StudioInspector.cs b/src/AcDream.App/Studio/StudioInspector.cs new file mode 100644 index 00000000..43cc03b0 --- /dev/null +++ b/src/AcDream.App/Studio/StudioInspector.cs @@ -0,0 +1,158 @@ +using System.Numerics; +using AcDream.App.UI; +using ImGuiNET; + +namespace AcDream.App.Studio; + +/// +/// Three-pane ImGui IDE for the acdream UI Studio: +/// +/// Canvas — shows the panel FBO texture; click-to-inspect returns the +/// panel-local pixel coordinate of the click. +/// Tree — recursive ImGui tree of the element hierarchy; clicking a node +/// sets . +/// Properties — shows the element's geometry, +/// anchors, and z-order. +/// +/// +/// Coordinate mapping for the canvas: +/// The FBO is rendered at the same logical size as the window, so a click at image-local +/// pixel (ix, iy) maps directly to panel coord (ix, iy) — no scale factor needed when +/// the image is drawn 1:1. We draw it 1:1 if it fits; if the Canvas ImGui window is +/// smaller we let ImGui clip it (the click coordinates are still image-local pixels so +/// no scale correction is needed in v1). +/// +/// V-flip: the FBO origin is bottom-left (GL convention), but ImGui images use +/// top-left. We pass uv0=(0,1), uv1=(1,0) to ImGui.Image, which flips V so +/// the panel renders right-side-up in the canvas. Because V is flipped, image-local +/// pixel y = 0 is the TOP of the panel, matching the UiRoot's top-left origin — so +/// click y maps directly without further inversion. +/// +public sealed class StudioInspector +{ + /// Currently selected element (set by tree-click or canvas-click). + public UiElement? Selected { get; set; } + + // ── Canvas ──────────────────────────────────────────────────────────────────── + + /// + /// Draw the "Canvas" ImGui window containing the panel FBO texture. + /// If the user left-clicks inside the image, returns the panel-local pixel + /// coordinate of the click (origin top-left, matching UiRoot's space); else null. + /// + /// The texture is drawn with uv0=(0,1) / uv1=(1,0) to flip V, correcting + /// the GL bottom-left FBO origin to the ImGui top-left convention. + /// + public (int x, int y)? DrawCanvas(nint panelTex, int width, int height) + { + ImGui.Begin("Canvas"); + + var imageSize = new Vector2(width, height); + // V-flip: FBO origin is bottom-left; ImGui images expect top-left. + // uv0 = bottom-left of texture = top of the panel in screen space. + // uv1 = top-right of texture = bottom of the panel in screen space. + var uv0 = new Vector2(0f, 1f); + var uv1 = new Vector2(1f, 0f); + ImGui.Image(panelTex, imageSize, uv0, uv1); + + (int x, int y)? result = null; + + // Detect a left-click on the image. + if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + // Image item rect: top-left of the drawn image in screen (display) coords. + var rectMin = ImGui.GetItemRectMin(); + var mousePos = ImGui.GetMousePos(); + + // Panel-local pixel = mouse offset from image top-left. + // Because we flipped V (uv0.Y=1, uv1.Y=0), image row 0 IS the top of the panel, + // so y needs no further inversion. + int ix = (int)(mousePos.X - rectMin.X); + int iy = (int)(mousePos.Y - rectMin.Y); + // Clamp to image bounds. + if (ix >= 0 && ix < width && iy >= 0 && iy < height) + result = (ix, iy); + } + + ImGui.End(); + return result; + } + + // ── Tree ────────────────────────────────────────────────────────────────────── + + /// Draw the "Tree" ImGui window. Clicking a node sets . + public void DrawTree(UiElement root) + { + ImGui.Begin("Tree"); + DrawTreeNode(root); + ImGui.End(); + } + + private void DrawTreeNode(UiElement el) + { + // Label: EventId (hex) + C# type name, e.g. "0x10000001 [UiDatElement]" + string label = $"0x{el.EventId:X8} [{el.GetType().Name}]"; + + bool isSelected = ReferenceEquals(el, Selected); + bool hasChildren = el.Children.Count > 0; + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.OpenOnArrow + | ImGuiTreeNodeFlags.SpanAvailWidth; + if (!hasChildren) + flags |= ImGuiTreeNodeFlags.Leaf; + if (isSelected) + flags |= ImGuiTreeNodeFlags.Selected; + + bool open = ImGui.TreeNodeEx(label, flags); + + // Click on the node label (not the arrow) selects it. + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + Selected = el; + + if (open) + { + foreach (var child in el.Children) + DrawTreeNode(child); + ImGui.TreePop(); + } + } + + // ── Properties ─────────────────────────────────────────────────────────────── + + /// Draw the "Properties" ImGui window for . + public void DrawProperties() + { + ImGui.Begin("Properties"); + + var el = Selected; + if (el is null) + { + ImGui.TextUnformatted("(nothing selected)"); + ImGui.End(); + return; + } + + ImGui.TextUnformatted($"Id (EventId): 0x{el.EventId:X8}"); + ImGui.TextUnformatted($"Type: {el.GetType().Name}"); + ImGui.TextUnformatted($"Name: {el.Name ?? "(null)"}"); + ImGui.Separator(); + ImGui.TextUnformatted($"Rect: ({el.Left}, {el.Top}, {el.Width} x {el.Height})"); + ImGui.TextUnformatted($"Anchors: {el.Anchors}"); + ImGui.TextUnformatted($"ZOrder: {el.ZOrder}"); + ImGui.Separator(); + ImGui.TextUnformatted($"Visible: {el.Visible}"); + ImGui.TextUnformatted($"Enabled: {el.Enabled}"); + ImGui.TextUnformatted($"ClickThrough: {el.ClickThrough}"); + ImGui.TextUnformatted($"Draggable: {el.Draggable}"); + ImGui.TextUnformatted($"Resizable: {el.Resizable}"); + ImGui.TextUnformatted($"IsDragSource: {el.IsDragSource}"); + ImGui.TextUnformatted($"HandlesClick: {el.HandlesClick}"); + ImGui.TextUnformatted($"Opacity: {el.Opacity:F2}"); + ImGui.Separator(); + var sp = el.ScreenPosition; + ImGui.TextUnformatted($"ScreenPos: ({sp.X:F1}, {sp.Y:F1})"); + ImGui.TextUnformatted($"Children: {el.Children.Count}"); + + ImGui.End(); + } +} diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs index d55064b4..b77d1420 100644 --- a/src/AcDream.App/Studio/StudioWindow.cs +++ b/src/AcDream.App/Studio/StudioWindow.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.UI; using DatReaderWriter; using DatReaderWriter.Options; using Silk.NET.Input; @@ -17,6 +18,15 @@ namespace AcDream.App.Studio; /// /// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path] /// +/// Task 3 adds an ImGui IDE on top of the panel FBO: +/// +/// Canvas pane — the panel rendered off-screen via . +/// Tree pane — the element hierarchy; click-to-select. +/// Properties pane — geometry/anchors/flags of the selected element. +/// Click-to-inspect — a left-click in the canvas selects the topmost +/// element under the cursor via . +/// +/// /// The window is intentionally thin: no game world, no physics, no streaming — /// just GL + UiHost + the layout under test, identical to how the panel /// appears inside GameWindow. @@ -31,6 +41,12 @@ public sealed class StudioWindow : IDisposable private RenderStack? _stack; private LayoutSource? _source; + // Task 3 additions. + private AcDream.UI.ImGui.ImGuiBootstrapper? _imgui; + private PanelFbo? _panelFbo; + private StudioInspector? _inspector; + private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree) + public StudioWindow(StudioOptions opts) { _opts = opts ?? throw new ArgumentNullException(nameof(opts)); @@ -99,10 +115,16 @@ public sealed class StudioWindow : IDisposable // Load the panel described by options and add it to the UI tree. _source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont); var root = _source.Load(_opts); + _panelRoot = root; if (root is not null) _stack.UiHost.Root.AddChild(root); else Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}"); + + // Task 3: ImGui IDE. The studio is always "devtools" — no gate flag needed. + _imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input); + _panelFbo = new PanelFbo(gl); + _inspector = new StudioInspector(); } private double _dt; @@ -114,18 +136,61 @@ public sealed class StudioWindow : IDisposable private void OnRender(double dt) { - if (_stack is null) return; + if (_stack is null || _imgui is null || _panelFbo is null || _inspector is null) + return; var gl = _stack.Gl; - gl.ClearColor(0.18f, 0.18f, 0.18f, 1f); + int w = _window!.Size.X; + int h = _window!.Size.Y; + + // 1. Tick the UI widgets. + _stack.UiHost.Tick(_dt); + + // 2. Render the panel into the off-screen FBO; get the color texture. + // The FBO is the same logical size as the window, so element rects map 1:1 to + // FBO pixels — no scale factor needed when displaying the canvas at full size. + uint panelTex = _panelFbo.Render(w, h, _stack.UiHost); + + // 3. Clear the window back-buffer (the dark ImGui background shows behind panes). + gl.ClearColor(0.1f, 0.1f, 0.1f, 1f); gl.Clear(ColorBufferBit | DepthBufferBit); - _stack.UiHost.Tick(_dt); - _stack.UiHost.Draw(new Vector2(_window!.Size.X, _window!.Size.Y)); + // 4. Begin the ImGui frame. + _imgui.BeginFrame((float)dt); + + // 5. Canvas pane — show the FBO texture; detect clicks. + (int x, int y)? click = null; + if (panelTex != 0) + click = _inspector.DrawCanvas((nint)panelTex, w, h); + + // 6. If the user clicked inside the canvas, hit-test the panel tree and select. + if (click is { } c && _panelRoot is not null) + { + // UiElement.HitTest is internal — accessible from AcDream.App.Studio (same assembly). + // We call it on the panel root with coords relative to its top-left. + var screenPos = _panelRoot.ScreenPosition; + var hit = _panelRoot.HitTest(c.x - screenPos.X, c.y - screenPos.Y); + if (hit is not null) + _inspector.Selected = hit; + } + + // 7. Element tree pane. + if (_panelRoot is not null) + _inspector.DrawTree(_panelRoot); + + // 8. Properties pane. + _inspector.DrawProperties(); + + // 9. Finalise ImGui and flush draw data to the window. + _imgui.Render(); } private void OnClosing() { + _imgui?.Dispose(); + _panelFbo?.Dispose(); + _imgui = null; + _panelFbo = null; _stack?.DrawDispatcher.Dispose(); _stack?.MeshAdapter.Dispose(); _stack?.TextureCache.Dispose(); @@ -139,6 +204,10 @@ public sealed class StudioWindow : IDisposable public void Dispose() { + _imgui?.Dispose(); + _panelFbo?.Dispose(); + _imgui = null; + _panelFbo = null; _window?.Dispose(); _window = null; // If OnClosing wasn't called (e.g. exception before Run()), clean up anyway.