Init Repo

This commit is contained in:
2026-06-03 09:00:11 +08:00
commit 9be48d8b9e
155 changed files with 14827 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-30
@@ -0,0 +1,71 @@
## Context
The app currently has a single `ViewportPanel` class that owns one `ViewportCamera`, one `UsdSceneRenderer`, and draws a full-window render with toolbars + overlays. Selection propagates through `OnPrimPicked` / `OnPrimsPickedRect` callbacks to `SceneHierarchyPanel` and `PropertyPanel`. The `UsdSceneRenderer` owns a `GlfDrawTarget` FBO; the render loop calls `m_renderer.Render()` then renders overlays (axis, bbox, gizmo, manipulator toolbar) into the same FBO or directly to the screen via ImGui draw lists.
The `ViewportCamera` and `UsdSceneRenderer` classes are already designed as independent units — they can be instantiated per-viewport without modification. The key architectural challenge is sharing the selection state across N viewport tiles while ensuring only the focused tile renders the transform gizmo.
## Goals / Non-Goals
**Goals:**
- Support 1, 2 (horizontal/vertical split), and 4-quadrant viewport layouts
- Each viewport tile has its own camera, renderer, and per-viewport settings (grid, AA, bbox mode, background color, render delegate)
- Single shared selection: picking in any tile updates all tiles and propagates to panels
- Transform manipulator (gizmo) renders only in the focused tile
- Drag-based dividers to resize tiles within a layout
- Backward-compatible `Application` API: `m_viewportPanel` stays as the entry point
- Space-key maximize/restore: pressing Space over the hovered tile temporarily fills the entire viewport area; pressing Space again restores the previous layout (Maya-style)
**Non-Goals:**
- Not adding tear-off/undockable viewport windows (ImGui docking handles that separately)
- Not adding per-viewport layer visibility overrides (future feature)
- Not supporting more than 4 tiles (can be extended later)
- Not adding viewport sync (playblasting) or timeline scrubbing across tiles
## Decisions
1. **Extract `ViewportTile` from `ViewportPanel` rather than subclassing.**
- The existing 1100-line `ViewportPanel::Render()` method contains everything: toolbar rendering, camera resolution, scene rendering, overlay drawing, context menu, and manipulator. Extracting a `ViewportTile` class that owns a camera + renderer + local per-viewport settings + the subset of overlay logic that is per-tile keeps the container clean.
- `ViewportPanel` becomes the container: it owns `std::vector<std::unique_ptr<ViewportTile>>`, manages layout geometry, and delegates `Render()` calls to each tile.
- Alternative considered: having `ViewportPanel` contain N inline structs. This was rejected because tiles need their own camera/resolution/overlay lifecycle, which calls for a proper class.
2. **Selection lives on the container (`ViewportPanel`), not on any tile.**
- Each tile still calls `m_renderer.SetSelectedPaths()` and `m_renderer.AddSelected()` so its `UsdImagingGLEngine` highlights the correct prims, but the authoritative `m_selectedSdfPaths` / `m_selectedPrimPath` vector lives on `ViewportPanel`.
- When a tile receives a pick hit, it calls back into the container to update the shared selection, and the container broadcasts it to all tiles and to the `OnPrimPicked`/`OnPrimsPickedRect` callbacks.
- Alternative considered: each tile owns its selection and syncs via signals. Rejected because it adds complexity for a single-authority selection model.
3. **Manipulator ownership moves to `ViewportPanel` (container), not per tile.**
- The `TransformManipulator` is a singleton — there should never be two gizmos active. The container owns it and only passes it to the focused tile for rendering.
- The focused tile is the one that last received a `ImGui::IsWindowHovered()` click. Tile tracks `m_isFocused` set by container during `HandleInput()`.
4. **Layout state stored as simple enum + divider positions, no external dependency.**
- Layout enum values: `Single`, `HSplit` (2 tiles side-by-side), `VSplit` (2 tiles top-bottom), `Quad` (4 tiles).
- Divider positions stored as normalized floats (`0.01.0`). Dragging a divider updates the normalized position, then `ViewportPanel::Render()` recomputes tile rectangles.
- No XML/JSON config for layout — state is ephemeral (could be persisted via future settings system).
- Alternative considered: `ImGui::Splitter()` based approach from ImGui demos. We'll implement a lightweight version directly since we need precise control over tile rectangle subdivision.
5. **Render delegate can differ per tile.**
- Each tile's `UsdSceneRenderer` independently calls `SetRendererPlugin()`. This means each tile FBO can use e.g. Storm vs HdEmbree independently.
- This is useful for comparing renderers side-by-side and is already supported by `UsdSceneRenderer`'s per-instance design.
6. **Space-key maximize stored as a simple toggle on the container, not on individual tiles.**
- `ViewportPanel` stores `m_maximizedTileIndex` (-1 = not maximized) and `m_layoutBeforeMaximize` (the `LayoutMode` enum value before Space was pressed).
- When Space is pressed (with tile hovered and `m_maximizedTileIndex == -1`): save `m_layoutBeforeMaximize`, set `m_maximizedTileIndex` to the hovered tile, and render only that tile filling the entire viewport area (no dividers, no layout menu).
- When Space is pressed again (or Escape): restore `m_maximizedTileIndex` to -1 and re-render the saved layout.
- Keyboard shortcuts (F, A, Q/W/E/R) continue to target the maximized tile as the de facto focused tile.
- Alternative considered: pushing a separate layout mode `Maximized`. Rejected because it conflates layout state with a transient display mode; storing the pre-maximize layout explicitly is cleaner and avoids state machine complexity.
7. **Per-tile settings stored on `ViewportTile`, not on a settings struct singleton.**
- Grid/AA/bg-color/bbox-mode/render-delegate are all fields on `ViewportTile`. The compact toolbar in each tile reads/writes these directly.
- For layout persistence (future), tiles could serialize their settings.
## Risks / Trade-offs
| Risk | Mitigation |
|---|---|
| N renderers means N FBO draw targets, each at full viewport resolution. For a 4K monitor with Quad layout, each tile is ~960×540 → 4× FBO allocations at that size. GPU memory may spike. | Cap layout to 4 tiles. Add a `m_renderScale` per tile (default 1.0) if perf issues arise. OpenUSD's `UsdImagingGLEngine` already has `renderParams` for resolution scaling. |
| Input routing: multiple tiles competing for Alt+LMB, Alt+RMB, scroll, etc. | The container dispatches `HandleInput()` only to the **hovered** tile each frame. The focused tile's manipulator is the only one that can consume input. |
| Right-click context menu: which tile owns it? | The context menu appears on the hovered tile. The popup (`ViewportContextMenuN`) is opened per-tile using unique IDs. |
| Divider drag area is thin — hard to click on high-DPI. | Dividers are 6px wide with a 2px visual line in the centre. Cursor changes to resize cursor on hover. |
| Keyboard shortcuts (F=frame, A=frame all, Q/W/E/R=manipulator) need to target the focused tile. | Shortcuts in the container delegate to the focused tile's camera/manipulator. If no tile is focused (e.g. user just clicked a panel outside the viewport), shortcuts are ignored. |
| Space key might conflict with ImGui docking or text input. | Guard with `!io.WantTextInput` (same as existing F/A shortcuts). Space is not a default ImGui docking shortcut, so no conflict with docking. |
| User might accidentally press Space when no tile is hovered (e.g. focus is on a panel). | Space is a no-op when no tile is hovered and viewport is not maximized. If already maximized, Space always restores regardless of hover state. |
@@ -0,0 +1,37 @@
## Why
Multi-viewport support is a standard feature in DCC tools (Maya, Houdini, Blender) that lets users view the same scene from different camera angles simultaneously. Currently the app has a single `ViewportPanel` — users cannot compare views (e.g., free camera + a USD camera prim, or top/orthographic + perspective) without switching back and forth. This limits scene inspection, layout work, and side-by-side comparison during shading and lighting iteration.
## What Changes
- Introduce a **viewport layout system** that manages 14 viewport tiles within a single ImGui window area
- Each tile has its own `ViewportCamera`, `UsdSceneRenderer`, selection highlight set, and per-viewport settings (grid, AA, background color, bounding box mode)
- Selection is **shared** across all viewports (pick in any viewport, all viewports highlight)
- The main `ViewportPanel` becomes a **container** that holds N child viewports (N=1 default, up to 4)
- **Layout presets**: single, 2-split horizontal/vertical, 4-quadrant
- Per-viewport camera toolbar remains (camera selector, render delegate, view options) but is **compact** (icon-only) to conserve space
- The manipulator gizmo is only active in the **focused** viewport (the one that last received a click)
- **Space-key maximize**: pressing Space while hovering over a tile temporarily maximizes that tile to fill the entire viewport area, hiding dividers and other tiles. Pressing Space again restores the previous multi-tile layout (identical to Maya's viewport maximize behavior)
- **BREAKING**: `Application`'s `m_viewportPanel` member type changes (it remains `ViewportPanel` but `ViewportPanel` switches from a single renderer to a container)
## Capabilities
### New Capabilities
- `viewport-layout`: Layout management — splitting, merging, preset switching, drag-resize dividers, Space-key maximize/restore, persistence of layout choice
- `per-viewport-camera`: Each tile has an independent `ViewportCamera`, camera selector dropdown, and free/USD camera switching
- `per-viewport-settings`: Per-tile grid toggle, AA, background color, bounding box mode, render delegate selection (independent per viewport)
- `shared-selection`: Single selection model across all viewports — picking in any tile updates all others and propagates to SceneHierarchyPanel / PropertyPanel
- `focused-viewport-manipulator`: Transform gizmo only renders and accepts input in the most recently clicked viewport tile
### Modified Capabilities
- *(None — `imgui-docking` is purely an ImGui infrastructure change; the new viewport system builds on top of it and does not alter its spec)*
## Impact
- **`src/ui/ViewportPanel.h/.cpp`**: Rewritten from a single-viewport class to a container that owns N `ViewportTile` instances. Public API surface changes — `SetSelectedPrimPath`, `SetStage`, `GetCamera`, `GetRenderer` remain but are forwarded to the active/focused tile or broadcast to all tiles.
- **`src/core/UsdSceneRenderer.h/.cpp`**: No changes needed (each tile owns its own `UsdSceneRenderer` already).
- **`src/core/ViewportCamera.h/.cpp`**: No changes needed (each tile owns its own `ViewportCamera` already).
- **`src/ui/Application.h/.cpp`**: Wire up `OnPrimPicked` / `OnPrimsPickedRect` as before on the container; the container relays selection to the `SceneHierarchyPanel` / `PropertyPanel`.
- **New files**: `src/ui/ViewportTile.h/.cpp` — the per-tile rendering logic extracted from `ViewportPanel::Render()`.
- **New files**: `src/ui/ViewportLayout.h/.cpp` — layout splitting/merge logic, divider drag, layout persistence.
- Memory: N `UsdSceneRenderer` instances means N FBO draw targets. For N≤4 and typical 1080p viewports this is ~200 MB worst-case — acceptable for a DCC tool.
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Transform manipulator renders only in the focused viewport tile
The transform gizmo (manipulator) SHALL render and accept input only in the tile that was most recently clicked. All other tiles SHALL NOT display the gizmo.
#### Scenario: Focused tile shows gizmo, other tile does not
- **WHEN** the user clicks in tile A (making it focused), then selects a prim
- **THEN** the transform gizmo appears over the selection in tile A only; tile B shows no gizmo
#### Scenario: Move focus to another tile
- **WHEN** the user clicks in tile B while tile A was focused
- **THEN** the gizmo disappears from tile A and appears in tile B over the selection
### Requirement: Manipulator mode and space are global
The manipulator tool mode (Select/Move/Rotate/Scale) and transform space (World/Object) SHALL be global settings shared across all tiles, owned by the `ViewportPanel` container.
#### Scenario: Change manipulator mode in focused tile
- **WHEN** the user presses W (Move) while tile A is focused
- **THEN** the mode switches to Move globally, and if the user clicks in tile B, tile B's gizmo is in Move mode
### Requirement: Keyboard shortcuts route to focused tile
Keyboard shortcuts for the manipulator (Q/W/E/R), frame selection (F), frame all (A), and maximize toggle (Space) SHALL only apply to the focused tile.
#### Scenario: Frames selection in focused tile only
- **WHEN** the user presses F while tile A is focused
- **THEN** tile A's camera frames the selection; other tiles' cameras remain unchanged
#### Scenario: Tool shortcut changes global mode
- **WHEN** the user presses W while tile A is focused
- **THEN** the global manipulator mode changes to Move, and the focused tile (A) shows the Move gizmo if a prim is selected
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Independent camera per viewport tile
Each viewport tile SHALL own an independent `ViewportCamera` instance. Camera operations (orbit, pan, dolly, zoom, frame) in one tile SHALL NOT affect any other tile's camera.
#### Scenario: Orbit in one tile, other tiles unchanged
- **WHEN** the user Alt+LMB drags to orbit in tile A
- **THEN** tile A's camera rotates, while tiles B, C, D remain at their previous camera positions
#### Scenario: Frame selection in one tile
- **WHEN** the user presses F in a tile
- **THEN** that tile's camera frames the current selection, other tiles are unaffected
### Requirement: Per-tile camera selector dropdown
Each tile SHALL have a compact camera selector (icon + dropdown) showing "Free Camera" and all USD camera prims on the stage. Selecting a USD camera in one tile SHALL NOT affect other tiles.
#### Scenario: Tile A uses Free Camera, Tile B uses a USD camera prim
- **WHEN** the user selects a USD camera prim in tile B's camera dropdown
- **THEN** tile B switches to USD camera mode and renders from that camera's view, while tile A continues in free camera mode
#### Scenario: Both tiles use the same USD camera
- **WHEN** the user selects the same USD camera prim in both tile A and tile B
- **THEN** both tiles render from that camera's view but can navigate independently when one tile goes into free camera mode
### Requirement: Camera list refreshes on dropdown open
Each tile SHALL refresh its camera prim list from the stage whenever its camera dropdown is opened.
#### Scenario: New camera added, then dropdown opened
- **WHEN** the user adds a new UsdGeomCamera prim to the stage and opens a tile's camera dropdown
- **THEN** the dropdown includes the newly added camera prim
@@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: Per-tile grid toggle
Each viewport tile SHALL have an independent grid visibility setting. Toggling the grid in one tile SHALL NOT affect other tiles.
#### Scenario: Grid on in Tile A, off in Tile B
- **WHEN** the user toggles grid ON in tile A and OFF in tile B
- **THEN** tile A shows the ground grid and tile B does not
### Requirement: Per-tile anti-aliasing toggle
Each viewport tile SHALL have an independent line-AA setting. Toggling AA in one tile SHALL NOT affect other tiles.
#### Scenario: AA on in Tile A, off in Tile B
- **WHEN** the user toggles AA ON in tile A and OFF in tile B
- **THEN** tile A renders line overlays with GL_LINE_SMOOTH, tile B renders without
### Requirement: Per-tile background color
Each viewport tile SHALL have an independent background color. Changing the background color in one tile SHALL NOT affect other tiles.
#### Scenario: Two tiles show different background colors
- **WHEN** the user sets tile A's background to "Black" and tile B's background to "Dark Gray"
- **THEN** tile A renders with a black background and tile B with a dark gray background
### Requirement: Per-tile bounding box display
Each viewport tile SHALL have an independent bounding box display mode (None / Per Object / All Selection). Changing the bbox mode in one tile SHALL NOT affect other tiles.
#### Scenario: BBox mode differs between tiles
- **WHEN** the user sets tile A to "Per Object" bbox mode and tile B to "None"
- **THEN** tile A draws bounding boxes on each selected prim, tile B draws none
### Requirement: Per-tile render delegate
Each viewport tile SHALL support an independent render delegate selection. Changing the render delegate in one tile SHALL NOT affect other tiles.
#### Scenario: Storm in Tile A, HdEmbree in Tile B
- **WHEN** the user selects "HdStorm" for tile A and "HdEmbree" for tile B
- **THEN** tile A renders via HdStorm and tile B via HdEmbree
### Requirement: Compact per-tile toolbar
Each tile's toolbar SHALL use compact icon-only buttons (16×16) arranged in a single horizontal row above the rendered image, consuming no more than 32px height. Tooltips SHALL be available on hover.
#### Scenario: Toolbar renders compactly in a small tile
- **WHEN** a tile is 300px wide in an HSplit layout
- **THEN** the toolbar fits within the tile width without clipping, using a horizontal scroll or overflow dropdown if necessary
@@ -0,0 +1,26 @@
## ADDED Requirements
### Requirement: Shared selection across all viewport tiles
Selection SHALL be shared across all viewport tiles. When a prim is picked in any tile (single click or rect drag), all tiles SHALL update their selection highlight display to show the same set of selected prims.
#### Scenario: Pick in Tile A, Tile B shows highlight
- **WHEN** the user single-clicks a prim in tile A
- **THEN** tile A and tile B both highlight that prim with the selection overlay
#### Scenario: Rect select in Tile B, Tile A updates
- **WHEN** the user rect-drags over multiple prims in tile B
- **THEN** both tile A and tile B highlight the same set of prims
### Requirement: Selection change propagates to SceneHierarchyPanel and PropertyPanel
When selection changes via any viewport tile, the `OnPrimPicked` / `OnPrimsPickedRect` callbacks SHALL fire from the `ViewportPanel` container, just as they do today.
#### Scenario: Pick in viewport updates hierarchy
- **WHEN** the user picks a prim in any tile
- **THEN** the SceneHierarchyPanel and PropertyPanel update to show the selected prim's properties
### Requirement: Shift+click additive selection across tiles
Shift+click SHALL work consistently across all tiles. Prim picked in any tile is added to or removed from the shared selection set.
#### Scenario: Shift+click in Tile A, then Tile B
- **WHEN** the user selects prim P1 in tile A, then Shift+clicks prim P2 in tile B
- **THEN** both P1 and P2 are in the shared selection, highlighted in both tiles
@@ -0,0 +1,55 @@
## ADDED Requirements
### Requirement: Layout presets
The viewport container SHALL support switching between four layout presets at any time: Single (1 tile), HSplit (2 tiles side-by-side), VSplit (2 tiles top-bottom), and Quad (4 tiles in a 2×2 grid).
#### Scenario: Switch layout from Single to HSplit
- **WHEN** the user selects "Horizontal Split" from the layout menu
- **THEN** the viewport splits into two equal-sized tiles side-by-side
#### Scenario: Switch layout from HSplit to Single
- **WHEN** the user selects "Single" from the layout menu
- **THEN** the two tiles merge back into a single tile, and the focused tile's camera/settings are preserved
#### Scenario: Switch from HSplit to Quad
- **WHEN** the user switches from HSplit to Quad
- **THEN** four tiles are created in a 2×2 grid, filling the viewport area
### Requirement: Draggable dividers
The divider line between tiles SHALL be draggable to resize adjacent tiles. Dividers SHALL be 6px wide with visual hover feedback.
#### Scenario: Drag horizontal divider
- **WHEN** the user clicks and drags the divider between two tiles
- **THEN** the divider follows the mouse, and tiles resize proportionally
#### Scenario: Drag divider to edge
- **WHEN** the user drags a divider to within 10% of the viewport edge
- **THEN** the divider snaps back to the 10% boundary to prevent tiles from becoming too small (minimum 100px per dimension)
### Requirement: Layout applies to the entire viewport area
When the layout changes, all tiles SHALL be redistributed to fill the entire available viewport panel content region. No gaps between tiles.
#### Scenario: Resize main window with Quad layout
- **WHEN** the user resizes the main application window
- **THEN** all four tiles resize proportionally to maintain their relative sizes and fill the viewport content area
### Requirement: Space-key maximize restores previous layout
When maximized, pressing Space again SHALL restore the exact layout and divider positions from before maximization.
#### Scenario: Maximize and restore preserves layout
- **WHEN** the user has an HSplit layout with divider at 0.3, then hovers tile B and presses Space, then presses Space again
- **THEN** the viewport returns to HSplit layout with both tiles visible and the divider at 0.3
### Requirement: Escape restores maximized viewport
When maximized, pressing Escape SHALL restore the previous layout (same behavior as Space toggle).
#### Scenario: Escape during maximize
- **WHEN** the viewport is maximized and the user presses Escape
- **THEN** the previous multi-tile layout is restored
### Requirement: Space requires tile hover in multi-tile layouts
In Single layout (only 1 tile), Space SHALL be a no-op — there is nothing to maximize.
#### Scenario: Space ignored in Single layout
- **WHEN** the viewport is already in Single layout and the user presses Space
- **THEN** the viewport remains unchanged in Single layout
@@ -0,0 +1,46 @@
## 1. Extract ViewportTile class
- [x] 1.1 Create `src/ui/ViewportTile.h` with class declaration — owns `ViewportCamera`, `UsdSceneRenderer`, per-tile settings (grid, AA, bbox mode, bg color, render delegate, camera index), and a pointer to the shared selection + manipulator
- [x] 1.2 Create `src/ui/ViewportTile.cpp` — extract per-tile rendering logic from `ViewportPanel::Render()`: camera resolution, scene rendering, overlay rendering (axis, bbox, camera wireframes), DrawSelectionRect, RenderManipulatorOverlay, RenderContextMenu, HandleInput
- [x] 1.3 Verify tile renders standalone by temporarily instantiating one ViewportTile in Application
## 2. Refactor ViewportPanel into container
- [x] 2.1 Rewrite `ViewportPanel` header to own `std::vector<std::unique_ptr<ViewportTile>>`, `TransformManipulator`, shared selection state (`m_selectedSdfPaths`, `m_selectedPrimPath`), layout enum (`LayoutMode`), divider normalized positions, and maximize state (`m_maximizedTileIndex`, `m_layoutBeforeMaximize`)
- [x] 2.2 Rewrite `ViewportPanel::Render()` to compute tile rectangles from layout state, iterate tiles calling `ViewportTile::Render()` + `ViewportTile::HandleInput()`, and draw dividers. When maximized, render only the maximized tile at full viewport area (no dividers)
- [x] 2.3 Add `ViewportPanel::SetLayout(LayoutMode)` — creates/destroys tiles as needed, preserves focused tile's camera/settings when possible. Clear `m_maximizedTileIndex` on explicit layout change
- [x] 2.4 Add divider-drag handling in `ViewportPanel::Render()` — hit-test divider regions, update normalized positions on drag. Skip divider hit-test when maximized
- [x] 2.5 Add `ViewportPanel::SetLayoutMenu()` — a layout menu in the viewport title bar or right-click context menu
- [x] 2.6 Add Space-key maximize/restore: detect Space key press (guarded by `!io.WantTextInput` and `LayoutMode != Single`), save pre-maximize layout, set `m_maximizedTileIndex` to the hovered tile's index. On second Space or Escape, restore `m_maximizedTileIndex` to -1 and reload saved layout. Add visual indicator (border glow or text label) on maximized tile
- [x] 2.7 Expose `ViewportPanel::GetFocusedTileIndex()` and wire up `OnPrimPicked` / `OnPrimsPickedRect` from the focused tile to the shared selection callbacks. When maximized, the maximized tile is implicitly the focused tile
## 3. Implement shared selection
- [x] 3.1 Store `m_selectedSdfPaths` and `m_selectedPrimPath` on `ViewportPanel` (container)
- [x] 3.2 On pick in any tile, update shared selection, then broadcast `m_renderer.SetSelectedPaths()` to all tiles
- [x] 3.3 Fire `OnPrimPicked` / `OnPrimsPickedRect` from the container (not tiles) so downstream panels see a single source of truth
## 4. Implement focused-viewport manipulator
- [x] 4.1 Track `m_focusedTileIndex` on `ViewportPanel` — updated on LMB click in any tile
- [x] 4.2 Only call `m_manipulator.Render()` and `m_manipulator.HandleInput()` for the focused tile
- [x] 4.3 Wire keyboard shortcuts (Q/W/E/R) through the container to the global manipulator, and F/A to the focused tile's camera. Ensure Space maximize toggle works correctly when a tile is focused (not consumed by manipulator)
## 5. Add per-tile compact toolbar
- [x] 5.1 Implement compact icon-only toolbar in `ViewportTile::RenderToolbar()` — camera selector + render delegate button + grid/AA/bbox icons, all 16×16 with tooltips
- [ ] 5.2 Add overflow handling: if toolbar exceeds tile width, collapse into a "..." dropdown menu
## 6. Wire up Application integration
- [x] 6.1 Update `Application::Initialize()``m_viewportPanel` still works, `SetStage` broadcasts to all tiles, `SetSelectedPrimPath` updates shared selection
- [x] 6.2 Update `Application::RenderUI()` — ensure the Viewport window renders correctly with the new container layout
- [ ] 6.3 Test all existing interaction flows: stage open/close, camera switching, selection sync, manipulator tools, context menu, frame shortcuts
## 7. Build and test
- [x] 7.1 Configure with `cmake --preset default`
- [x] 7.2 Build Release with `cmake --build build --config Release` — fix any compilation errors
- [x] 7.3 Install with `cmake --install build --config Release`
- [ ] 7.4 Run `App.exe` and visually verify: single viewport works, switch to HSplit/VSplit/Quad, camera independence, shared selection, manipulator in focused tile only, divider dragging, Space-key maximize/restore with correct layout preservation, Escape restore, maximize in Quad layout, Space no-op in Single layout
- [ ] 7.5 Run CTest: `ctest --test-dir build -C Release` — fix any test failures