Skip to content

[feat] roadmap #16: menus, grid & scene cameras (P1-P6) - #86

Merged
AlexZ005 merged 19 commits into
fix/roadmap15-q-menufrom
fix/roadmap16-menus-cameras
Aug 5, 2026
Merged

[feat] roadmap #16: menus, grid & scene cameras (P1-P6)#86
AlexZ005 merged 19 commits into
fix/roadmap15-q-menufrom
fix/roadmap16-menus-cameras

Conversation

@AlexZ005

@AlexZ005 AlexZ005 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Roadmap #16 — the 2026-08-04 feedback drops. Stacked on #85 (base = fix/roadmap15-q-menu); retarget to release/next once #85 lands. Lane ../theprototype-lane-ui @5186. Plan + as-built notes: cloud repo docs/plans-core/roadmap-16-menus-grid-cameras.md.

First drop — P1…P6

P6 [fix] deselect releases the peer lock; Selected menu follows the set — a deselect broadcast nothing and lock only ever replaces a peer's set, so clicking empty space left objects highlighted + "locked by X" on every other peer. "Selected ▸" also lingered (it was gated on the STICKY primary), and Configure Scene's active row dropped its inset accent bar.

P1 [feat] context menu: filter hidden until typing, arrow-key navigation — the filter input stays mounted + focused (that's what swallows app shortcuts) but collapses to zero height until you type. ↑/↓ walk the level, Enter runs or opens a submenu, Esc unwinds; ←/→ stay with the caret. New $lib/menuFilter.js holds the flatten + ranking.

P2 [feat] node editor: search runs inside the context menu — the private #node-search-box is gone; "Search nodes…" reveals the shared filter. Found a real bug: a menu opened near the bottom grew off-screen while filtering (place() only ran on open) — now re-places via ResizeObserver. Two stale phase-124 assertions repaired; node-search is green again.

P3 [feat] grid + snapping settings; menu marks the active choice — Configure Scene ▸ Grid and ▸ Snapping; replaced by a bold-on-tint checked style; the snapping submenu is sectioned with a live hint.

P4 [feat] camera section; named bookmarks with lenses — unlimited NAMED bookmarks storing FOV + clip planes, managed inline; new Camera section (lens, clip, orbit speeds, damping, invert-Y, Frame scene, Reset view).

P5 [feat] scene camera objects — perspective/orthographic markers carrying userData.camera, scene-root frustum wireframes, a true camera-swap preview, Control (WASD fly, one undo), framing guide, Capture, replicated preview presence.

Second drop — the follow-up items

[fix] camera control: no view jump, no orbit-controls leak — pressing Control snapped the view to the origin (a fresh OrbitControls targets 0,0,0 and its update() ends with lookAt); the controls are now seated behind the camera and the pose is re-synced from the marker first (drift 0.0000). And after exiting a preview, dragging the gizmo also orbited the camera: both control sets were bound to one store and threlte clears a bound ref on unmount, so every suppression wrote to nothing. The preview publishes its own previewOrbit; a derived activeOrbit drives Scene's six suppression sites and its navigation call.

[feat] context menu: sticky search, remembered cursor, fixed anchor — stepping out of a submenu returns to the row you came from; deleting the query keeps the search box (the list widens to every action) and only Esc leaves search; the menu no longer re-decides its side as the list grows, so it stays anchored at the click and scrolls internally; "Search nodes…" is excluded from its own results.

[feat] panel deep links, grid look-at follow, snap values, themed checkboxes — the three "…settings" rows open Configure Scene, expand the named section and scroll to it (they used to close an already-open panel); grid follow is Off / Look-at / Camera, horizontal and cell-snapped; Scale gained 0.25 and a custom step from the panel now appears in the menu, marked; the Physics section's raw checkboxes are themed; adding from the Add menu opens the new object's properties.

[feat] one numeric field everywhere — DragRow is THE numeric control: drag to scrub, type with LIVE updates, ↑/↓ step one minor unit (Ctrl ×10, Shift ×100; integers 1/10/100), Esc reverts. Two delegation traps found: Svelte's onkeydown/onpointerdown attributes are delegated and the panels swallow both on the way up — Esc did nothing and a drag jumped by the pointer's absolute X. Both are direct element listeners now.

[feat] camera preview window (PiP) + Capture row — a small live view bottom-right when a camera is selected, rendered as a scissored inset of the same renderer (no second WebGL context), right-drag/touch-hold to move, clamped, parked clear of an open panel, per-camera on/off; Capture moved to its own row with an icon.

Verification

New suites: deselect-unlock (9, two-peer), grid-snapping (15), camera-bookmarks (14), camera-objects (27, two-peer), camera-preview-control (9), panel-deeplinks (16), number-fields (13), camera-pip (18). Updated: context-menu-redesign, node-search, viewport-selection, inspector.
Green regressions: context-menu-v2/overflow, add-menu-cursor, object-search, mobile-hud, multiselect-menu, packs-explorer, camera-clipping, roadmap-13-viewport-camera, collider-viz, inspector, object-properties, physics-colliders, geometry-params, particles(+impact), environment(+v2), vr-snap-menu.
Known pre-existing failures, confirmed identical with these changes stashed: add-menu (its documented right-tap flake) and view-mode (shadow-catcher check).
npm run build green; svelte-check 419/62 held throughout.

Third + fourth drops — Q5 / Q6

[fix] gizmo drags no longer orbit the view — the reported bug, at the root: nothing in the app ever disabled OrbitControls during a gizmo drag. Threlte's TransformControls does it against its own context slot, and a camera preview unmounts + remounts the editor's controls, after which that slot points at an instance that no longer drives the view. Scene's dragging-changed hook now suppresses orbiting itself through activeOrbit, and the preview's controls are disposed on hand-back. Proven by an A/B real-mouse drag on the real gizmo arrow: 0.21 rotation before the fix, 0.00000 after, with the object moving the identical 1.04 units both times.

[fix] look-at survives a preview round trip — the remounted editor controls came back with their prop default target.y = 1.5, recentring the view on the world origin. The target is snapshotted at hand-over and copied onto the fresh instance ((6, 2, −3) in → (6, 2, −3) out).

Menu placement, to spec: open at the cursor preferring downward; when the content doesn't fit below, shift the whole menu up so its bottom stays inside (never flip to the other side of the pointer); a scrollbar only when the content is taller than the window. Searching keeps the opened top, bounds the list height, and offers a corner grip — whose height is now remembered per menu kind (viewport / nodes / object).

Gridcamera follow uses threlte's own followCamera (smooth panning; the shader keeps lines at world positions); only look-at snaps the centre to whole cells.

Deep links — deterministic measure → scroll → re-measure → correct, scroller found by real scrollability, instant scroll (a smooth one was being cancelled by the reflow of the section it had just expanded). All three land 8–20px under the sticky filter header even from a bottom-scrolled panel. The earlier check passed vacuously ("somewhere below the header" is true without any scrolling at all) and was replaced with a tight band from a forcing start state.

Numeric fields — drags quantize to the field's own precision (0.7999999999999999 is gone) and no longer drag a caret through the digits (focus is granted on release, selection suppressed while scrubbing).

Snapping rows align on a label | chips | field grid, with chips tightened so four presets stay on one line.

Camera PiP — the title bar drags with the left button, the transform gizmo is hidden for the inset draw (three keeps its gizmo visuals in a separate helper object, so hiding the controls wasn't enough), the frame sits at z-index 2 so panels and HUD cover it, and it parks clear of the round HUD buttons.

Extra suite: gizmo-orbit-leak (9 checks). Extended: camera-preview-control, panel-deeplinks, context-menu-redesign, number-fields, camera-pip. Final sweep: 11 suites green in one run, build green, svelte-check 419/62.

🤖 Generated with Claude Code

AlexZ005 and others added 19 commits August 4, 2026 11:13
Roadmap #16 P6.

- LOCK LEAK: `applySelectionSet` broadcasts a `lock` message, but a deselect
  broadcast nothing - and `lock` only ever REPLACES the sender's set
  (lockGeometry ignores an empty list), so clicking empty space left the object
  highlighted and "locked by X" on every other peer until the deselecting peer
  happened to select something else. `broadcastSelectionRelease(uuids)` now
  sends one `unlock` per released uuid from all three release paths
  (deselectObject, applySelectionSet([]), and switching to a locked-VIEW).
  No new message type - `unlock`/`applyUnlock` already existed, and a peer that
  had asked us for control now gets it, which is right.
- "Selected" SUBMENU: the viewport menu gated it on `selectedObject`, which is
  STICKY by design (it keeps the last object so the open inspector has something
  to bind to), so the submenu lingered after a deselect. It now gates on the
  live selection SET, keeping the one legitimate empty-set state - VIEWING a
  peer-locked object - which retains its view-only actions.
- Configure Scene's active nav row drops the `inset 2px` accent bar (it read as
  a stray border); the tint + accent text stay.

Verification: new tests/e2e/deselect-unlock.test.cjs (two-peer, 9 checks:
lock replace, release on deselect / on applySelectionSet([]) / on locked-view,
sticky primary preserved) + viewport-selection extended with the menu gating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 P1. The 15-Q filter row was always visible on dense menus and the
menu had no keyboard model beyond Enter; both are fixed without changing any
action.

- HIDDEN UNTIL TYPING: the filter input stays MOUNTED and FOCUSED (that is what
  swallows app shortcuts while a menu is open - the input-focus guard) but
  collapses to height 0 / opacity 0 until there is a query. Not display:none or
  hidden: the input must remain focusable to own the keyboard. Revealed, it is a
  normal menu ROW - same padding and font-size as an item (verified 26px vs 26px
  in the suite) with a tint + bottom rule instead of the old inset pill.
- EVERY menu now carries it, so the >=8-leaf threshold is gone: small menus show
  no filter row (it is collapsed) but still get arrow navigation.
- KEYBOARD: Up/Down walk the current level skipping disabled rows (wrapping),
  Enter runs the highlighted row or OPENS its submenu and moves the cursor
  inside, Esc unwinds one step at a time (query -> open submenu -> close the
  menu). Left/Right stay with the textbox caret as asked; with an EMPTY box (no
  caret to move) they double as open/close submenu.
- ONE highlight for mouse and keyboard: hovering a row moves the cursor, so
  Enter always acts on the row you are looking at. The open submenu keeps its
  150ms close-intent delay while the cursor moves - that delay is what lets you
  reach a submenu diagonally, so pointer hover deliberately does NOT close it
  immediately (hence openPath for rendering vs navPath for the cursor).
- Clicking a row no longer steals focus from the filter (mousedown default
  prevented) - opening a submenu by click used to hand the keyboard back to the
  app's global shortcuts.
- Submenus now anchor to their own row element instead of a captured mouse rect,
  so a keyboard-opened submenu places itself exactly like a hovered one (and the
  stale-rect risk is gone).
- New `$lib/menuFilter.js` holds the flatten + ranking (label prefix > path
  prefix > substring > fuzzy subsequence, 40-row cap) so there is ONE matcher -
  P2 retires the node editor's private search box onto it.

Verification: context-menu-redesign extended (hidden-then-revealed row, row
height parity, Up/Down/Enter/Esc, highlight lands INSIDE the submenu);
context-menu-v2, context-menu-overflow, add-menu-cursor, object-search,
mobile-hud green. add-menu fails at its documented pre-existing flake
(right-tap does not open the menu, line 105) - identical with these changes
stashed. Build green, svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 P2. The flow pane menu carried its OWN search popup
(#node-search-box, a separate fixed box that replaced the menu at the same
coordinates, with its own ranking, its own arrow-key handling and its own
drag-to-move) and, since 15-Q, the shared filter row on top of it - visibly
duplicated. There is now ONE implementation.

- Deleted the popup and everything only it needed: openSearch, searchEntries,
  searchResults, its private subsequence ranker, pickResult, onSearchKeydown,
  scrollHighlightIntoView, savedMenu, the window keydown that jumped into
  search, and the rightDragMove/inputContextMenu imports (the box's right-drag
  move goes with it - the menu opens at the click).
- "Search nodes..." stays as the first row but is now a `revealFilter` item: it
  reveals the shared filter row WITHOUT closing the menu, so the grouped list
  stays as the browse view it always was. Typing anywhere in the menu does the
  same (the filter input is always focused), which is how it already behaved.
- Matches render as "Group > Node" - the same information the old box showed as
  "group . label" - ranked by the shared matcher, with arrow-key navigation and
  shortcut interception for free.
- Esc unwinds one step at a time: query -> revealed filter -> submenu -> close.

FIX found by the suite: a menu opened near the bottom edge (flipped up) grew
straight off the screen when filtering changed its height, because `place()` only
ran on open and on window resize. It now re-places on any size change via a
ResizeObserver, guarded against a maxHeight write loop.

Also repaired two STALE assertions in node-search (a known-failing suite): they
asserted context menus never scroll and are never height-capped, which the later
cap-and-scroll change deliberately reversed - the suite is fully green now.

Verification: node-search rewritten against the shared filter (20 checks, ALL
PASS - previously on the known-failure list); context-menu-redesign,
context-menu-v2, context-menu-overflow, multiselect-menu, object-search,
packs-explorer green. Build green, svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 P3.

CONFIGURE SCENE gains two sections (LOCAL per-device prefs, like the clip planes
and the render mode that already live there - a grid is a view aid, not scene
content, so nothing here replicates):
- GRID: show, cell size, "match snapping step" (the drawn cell tracks the
  translate snap step, so the grid you see is the grid objects land on), major
  line every N cells, cell + major colours, fade auto/fixed with radius and edge
  softness, infinite vs a finite extent, follow-camera, and origin axes. Reset
  restores the defaults. New `$lib/gridSettings.js` holds it (cameraClip idiom,
  unknown keys fall back to defaults so old payloads keep working);
  extensions/Grid.svelte reads it and keeps the I4 no-lerp auto-fade math.
  The axes helper renders in the world rig, never under objectsGroup - anything
  there would enter GLTF sync and duplicate for peers.
- SNAPPING: enable, position/rotation/scale steps as preset chips PLUS a free
  number each (custom steps were menu-only before), and surface snap.

CONTEXT MENU: new `checked` item flag renders the active choice of a group as
BOLD WHITE on a brand-tinted pill, replacing the '● ' label prefix that shifted
labels sideways as it appeared. The text itself is deliberately not accent-tinted:
the app's accent is a salmon (#fe795d), which would sit too close to the red
`danger` rows in the same menu.

SNAPPING SUBMENU is sectioned (Position / Rotation / Scale / Surface) instead of
one flat run of ten steps, the parent row's hint column shows the live setup
("0.5 · 15° · 0.1", or "off"), and a last row jumps to Configure Scene for custom
values. The View submenu gets the same treatment (Show grid as a checked toggle +
a grid-settings jump).

Verification: new tests/e2e/grid-snapping.test.cjs (15 checks: sections, checked
marks, no bullets, live hint, click-applies-and-mark-follows, panel toggles reach
the store, AxesHelper outside objectsGroup, match-snap-step, persistence across a
reload, custom step); context-menu-redesign, context-menu-overflow, vr-snap-menu
green. Build green, svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 P4.

BOOKMARKS were five anonymous slots where a sixth save silently pushed the
oldest out, labelled by save TIME, storing only position + target.
- Unlimited NAMED entries (default "View N"), each storing the LENS it was
  saved with (FOV + near/far); recall restores the whole look, near/far through
  cameraClip so the orbit zoom clamp stays paired with the far plane.
- Management in Configure Scene: rename inline, re-shoot from the current view
  (keeps the name), reorder (order IS the Shift+N mapping), delete, plus Save
  current view. Shift+1..5 still recall the first five.
- Legacy payloads normalize on read: no name -> "View N", no lens -> recall
  leaves your lens alone. The viewport menu lists them by name with a ⇧N hint
  and a "Manage saved views…" jump.

CAMERA SETTINGS were a "Camera lens" sub-label buried inside the View section,
and the orbit feel was hardcoded in Scene.svelte's <OrbitControls>. Now one
Camera section: lens presets + FOV, near/far clip, rotate/zoom/pan speed,
damped orbiting, invert vertical orbit (the sign rides rotateSpeed - three has
no flag), Frame scene (fit everything along the current view direction), Reset
view (the mount defaults), Reset feel, and the saved-view manager. Orbit prefs
are LOCAL and re-apply whenever the controls remount (VR exit, spectator mode).

Verification: new tests/e2e/camera-bookmarks.test.cjs (14 checks: legacy
normalization, lens capture + restore, no cap, panel rename/overwrite/reorder/
delete, Shift+2, orbit prefs reach the live controls + reset); camera-clipping
and roadmap-13-viewport-camera green. Build green, svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 P5. Perspective and orthographic CAMERA objects you can place, aim,
look through and shoot from.

DATA: a camera is a normal replicated marker MESH (`/create Camera` /
`/create CameraOrtho`, body from customGeometries - a box with a lens cone down
-Z, three's camera convention) whose settings live on `userData.camera`. That is
the userData.physics trick, so replication, undo, sessions, autosave, prefabs
and GLTF extras all work with ZERO new plumbing, and peers agree because the
same /create stamps the same deterministic defaults. Markers are never physics
bodies and never cast shadows. Add > Camera > Perspective / Orthographic.

WRITES go through `setCameraFor(uuid, patch)` - one props history entry, one
`objectParameters` message, one poke that rebuilds viz + preview (the
setPhysicsFor precedent); the 'props' history kind and the objectParameters
applier both learned `camera`.

FRUSTUM VIZ (`cameraHelpers.js`, the colliderHelpers pattern): a wireframe
frustum per camera, built from the spec, living at the SCENE ROOT so it stays
out of GLTF sync, following its marker per frame, hidden in wireframe view mode
and for the camera you are currently inside. On by default with a local
`showCameraFrustums` toggle.

PREVIEW is a TRUE camera swap: `CameraPreview.svelte` mounts a real
Perspective/OrthographicCamera as threlte's default, so an ortho camera renders
genuinely orthographic and fov/near/far are exact. Two things this needed:
- Outline.svelte re-points its EffectComposer (setMainCamera + N8AO's own camera
  reference) whenever the active camera changes - every pass baked
  `camera.current` at construction, so without this you keep seeing the editor
  view. It tracks `$camera` (the STORE): threlte's CurrentWritable exposes
  `.current` as a plain property, so reading it inside an $effect registers no
  dependency and the effect runs exactly once.
- the previewed marker hides itself locally (you stand inside its body mesh
  otherwise), the way spectator mode hides the avatar you watch from.

CONTROL (banner button): hands the camera to the normal viewport navigation -
WASD + mouse, because the preview camera owns the OrbitControls and Scene's
existing per-frame nav call drives whatever is active. Every controlled frame
writes the pose back onto the marker (throttled ~10Hz `move` broadcasts), and
ending control leaves exactly ONE undo entry for the whole ride (the possess
"one undo per ride" precedent).

Also: framing guide (letterbox bars for 16:9 / 4:3 / 1:1 / 2.39:1 while
previewing), Capture (renders one offscreen frame at the framing aspect and
downloads it), Set from current view / Align view to camera, an Inspector Camera
section, the two headline actions in the object context menu, and REPLICATED
preview presence (`campreview`) - the Users popover shows "previewing <camera>"
per peer with a click to join, cleared on disconnect and shared on handshake.

Verification: new tests/e2e/camera-objects.test.cjs (27 checks, two-peer:
defaults, replication, write+undo replication, scene-root frustums that follow,
ortho preview really orthographic, banner, own-frustum suppression, peers seeing
the preview, flying writes the marker + replicates + one undo entry, exit
restores your camera, Capture at 640x360). collider-viz, inspector,
object-properties, viewport-selection, roadmap-13-viewport-camera green;
view-mode fails a pre-existing shadow-catcher check (identical with these
changes stashed). Build green, svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs in 16-P5's Control mode, both reported.

1. Pressing Control SNAPPED the view. A fresh OrbitControls targets the world
   origin and its update() ends with camera.lookAt(target), so mounting it
   rotated the preview camera the moment Control was pressed. The controls are
   now seated behind the camera first - the target goes on the camera's own
   forward axis, making that lookAt a no-op - and because OrbitControls already
   ran one update() during construction (before any effect could run), the camera
   is re-synced from the MARKER first, so the mount rotation cannot get baked into
   the pose. Measured drift is now 0.0000.

2. After exiting a preview, dragging the transform gizmo also ORBITED the camera.
   Both sets of controls were bound to the same `orbitControls` store, and
   threlte clears a bound ref on unmount - so the preview's teardown could land
   after the editor's controls remounted and leave the store EMPTY. Every
   suppression site (`$orbitControls.enabled = false` on gizmo/marquee drags) then
   wrote to nothing, for the rest of the session. The preview camera now publishes
   its own `previewOrbit` store, a derived `activeOrbit` says which controls are
   actually steering, and Scene routes its six suppression sites plus the
   per-frame navigation call through it.

Verification: new tests/e2e/camera-preview-control.test.cjs (9 checks: no drift
on Control, target seated ahead, ownership handover, editor controls restored
live and attached to the active camera, suppression reaches them, two cycles stay
clean). svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 second drop, batch Q1 — three reported annoyances in the menu.

- SUBMENU CURSOR is remembered per level: stepping out of a submenu lands back on
  the row you came from instead of jumping to the top. Pointer hovers record the
  same memory, so mouse and keyboard agree about where you were.
- SEARCH IS STICKY. Deleting the query used to snap you back to the grouped menu
  mid-thought; now the box stays and the list simply widens to EVERY action
  (capped at 200 rows, scrolling) - the browse list the retired node-search box
  used to be. Only Esc leaves search, so Esc now unwinds query -> search ->
  submenu -> menu, one step per press.
- THE MENU KEEPS ITS ANCHOR. It picked the roomier side of the click on EVERY
  re-place, so a long match list re-decided and teleported the menu to the top of
  the screen. The side is chosen once, the anchored edge stays on the click, and
  anything that doesn't fit is capped and scrolls (verified: top 140 -> 140 while
  the list grows from 2 rows to 59).
- "Search nodes…" no longer appears among its own search results (`revealFilter`
  rows are excluded from the leaf collection).

Verification: context-menu-redesign +9 checks (sticky search, anchor stability,
internal scroll, three-step Esc, cursor memory into and out of a mid-list
submenu); node-search updated for the new Esc semantics +1 check for the
exclusion; context-menu-v2 and context-menu-overflow green. svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ckboxes

Roadmap #16 second drop, batch Q2 — five reported items.

- DEEP LINKS: "More snapping settings…", "Grid & axes settings…" and "Manage
  saved views…" now open Configure Scene, EXPAND the named section (even one you
  had collapsed) and scroll to it. They went through `showSidebar('scene')`,
  which TOGGLES - so clicking one while the panel was already open closed it. New
  `openSceneSection(label)` only ever opens, and Section.svelte watches an
  `inspectorScrollTo` store, expands, scrolls and clears it.
- GRID FOLLOW is a three-way choice: Off / Look-at / Camera. "Follow the camera"
  tracked your POSITION, which is the wrong thing while you are looking somewhere
  else; Look-at centres the grid under the orbit target. Both modes stay
  HORIZONTAL (y = 0 - it is the ground plane, not a flying sheet) and snap the
  centre to whole cells so the lines keep agreeing with world coordinates instead
  of sliding under objects. The old boolean migrates to Look-at.
- SNAPPING STEPS: Scale gained the missing 0.25 (and Rotation 90°), and a value
  typed in Configure Scene now joins the presets in the menu, sorted and marked
  as active - custom steps used to be invisible and unreachable there.
- PHYSICS CHECKBOXES: the Sensor, freeze-axis and show-collider boxes were raw
  <input type="checkbox"> rendering as native controls; they use the themed
  component now, like everything around them.
- ADD opens the new object's properties, even when another panel was showing.

Verification: new tests/e2e/panel-deeplinks.test.cjs (16 checks: open from
closed, expand a collapsed section, no-close on a second link, look-at follow
snapped to cells with y pinned, Off recentres, 0.25 + custom step present and
marked, themed checkbox, Add switches the panel); grid-snapping,
add-menu-cursor, physics-colliders, collider-viz, inspector green. Build green,
svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 second drop, batch Q3.

The transform rows had a drag-to-scrub control; every other number was a plain
<input type="number"> that only committed on Enter or blur - so its arrows looked
like they did nothing, and the two behaved differently. DragRow is now THE numeric
field and the sliders' boxes, the snapping steps, the clip planes, render order
and the particle offsets all use it:

  drag horizontally   scrub (Shift = fine, Ctrl = snap)
  click / type        applies LIVE, no Enter needed
  ArrowUp / Down      one MINOR unit (0.01 at 2 decimals), Ctrl x10, Shift x100;
                      integer fields step by 1 / 10 / 100
  Esc                 back to the value you focused it with

It is a real <input> the whole time instead of swapping a button for a box: the
caret is always there, ids keep working, and touch gets the numeric keypad
(inputmode). type="text" on purpose - a native number spinner would fight our own
arrow steps.

TWO DELEGATION TRAPS, both found by the suites: Svelte's `onkeydown`/`onpointerdown`
attributes are DELEGATED, running only once the event reaches the app root, and
the panels this field lives in swallow both on the way up (the drawer's drag/resize
wiring eats pointerdown, the flowbite dialog eats Escape). Esc-to-revert did
nothing, and a drag whose pointerdown never arrived jumped the value by the
pointer's absolute X (+22 instead of +2). Both are direct element listeners now.
The pointer trio rides the WRAPPER so a scrub can start on the axis label too,
and a click there focuses the input for typing.

Verification: new tests/e2e/number-fields.test.cjs (13 checks: live typing, the
three arrow steps, integer stepping, Esc revert, drag scrub, the same rules in a
slider box); inspector updated for the always-input field (its drag check now
lands on 2.0 instead of 22.9); camera-clipping, grid-snapping, camera-bookmarks,
object-properties, panel-deeplinks, geometry-params, particles(+impact),
environment(+v2) green. Build green, svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 second drop, batch Q4.

Selecting a camera object now shows a small LIVE preview bottom-right. It is not
a second WebGL context (that duplicates every texture and geometry on the GPU):
Outline.svelte draws one extra SCISSORED viewport of the same renderer after the
composer pass, into the rect the window publishes - so the DOM part is pure chrome
and the cost is one viewport draw, only while a camera is selected. gl clears
respect the scissor box, so the inset clears just itself.

- Right-drag moves it (left stays free for the viewport), touch-HOLD then drag;
  it clamps fully on screen and parks bottom-right, LEFT of an open side panel so
  the two never overlap.
- Inside the frame the camera's own marker and the frustum lines are hidden for
  that draw - you would otherwise be looking at the inside of its body.
- A ⤢ button jumps to the full-screen preview; ✕ turns the window off for that
  camera. On by default, with "Preview window while selected" in Camera
  properties.
- CAPTURE moved onto its own row below Preview / Set from view / Align view, with
  a camera icon and a note that it saves a PNG at the framing aspect.

Two familiar traps, both caught by the suite: `pipTarget` reads the selection SET
(the sticky `selectedObject` kept the window open after a deselect) and takes
`objectsGroup` as a dependency (the `pip` flag lives on userData, and THREE trees
are not reactive - the post-write poke is the only signal a derived store gets).

Verification: new tests/e2e/camera-pip.test.cjs (18 checks: targeting, published
rect matches the frame, framing aspect, parking clear of the panel, right-drag +
clamping, hidden during a full preview and after deselect, the per-camera switch,
and the DOM->gl y-flip); camera-objects (banner check hardened against its fly
transition), camera-preview-control, viewport-selection, collider-viz green.
view-mode still fails its pre-existing shadow-catcher check. Build green,
svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… PiP polish

Roadmap #16 second drop, batch Q5.

THE BUG, properly this time. Nothing in the app ever disabled orbiting during a
gizmo drag: threlte's TransformControls disables whatever controls sit in ITS OWN
context slot, and a camera preview unmounts + remounts the editor's OrbitControls,
after which that slot points at an instance which no longer drives the view. So
dragging an object also spun the camera. The `dragging-changed` hook now does the
suppression itself, first thing and for every gizmo target, writing through
`activeOrbit` - instance-proof. The preview's own controls are disposed when
Control ends (three keeps DOM listeners on a merely-dropped instance).
Reproduced end to end first: the SAME real drag on the REAL gizmo arrow rotated
the view 0.21 after a preview cycle and 0.00 before it; now both read 0.00000 and
the object still moves the identical 1.04 units.

MENU placement, rewritten to the rules you gave: open AT the cursor preferring
downward; when the content does not fit below, shift the WHOLE menu up just far
enough that its bottom stays inside (no flipping to the other side of the
pointer); a scrollbar appears only when the content is taller than the window.
SEARCHING no longer moves anything - the menu keeps the top it opened with, the
list gets a sensible height instead of unfolding to the floor, and a corner grip
resizes it while searching.

GRID follow: 'camera' now uses threlte's own followCamera, which keeps the shader
drawing lines at WORLD positions - smooth while you pan. Only 'look-at' snaps the
centre to whole cells, which is what you want when it is locked to an object.

DEEP LINKS scroll the container with the sticky header's height as an offset, so
the section label lands BELOW "Filter properties" instead of under it, and
"Manage saved views…" targets the SAVED VIEWS sub-heading (`data-anchor`) rather
than the top of the Camera section.

SNAP STEPS are clean numbers: the shared numeric field quantizes a drag to its own
precision (a scrub used to produce 0.7999999999999999), the position/scale snap
fields are 2-decimal, and every menu label and hint prints through one formatter.

CAMERA PiP: the title bar drags with the LEFT button too (the body keeps
right-drag so a stray left-click cannot move it), the transform gizmo is hidden
for the inset draw - it sat on the lens as a giant coloured blob, and hiding the
controls was not enough because three keeps the gizmo's visuals in a separate
helper object - and the parked window keeps clear of the round HUD buttons.

Verification: new tests/e2e/gizmo-orbit-leak.test.cjs (9 checks, A/B real-mouse
gizmo drag before and after a preview cycle, so normal left-drag orbiting can
never be mistaken for the bug); context-menu-redesign +5 (cursor-anchored open,
shift-up with no scrollbar, sticky top while searching, bounded height, grip);
panel-deeplinks +4 (three headers clear the sticky header, clean snap hint);
camera-pip +4 (left-drag on the bar, body left-drag inert, gizmo restored after
each draw). camera-objects, camera-preview-control, node-search, grid-snapping,
number-fields, inspector all green. Build green, svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ght, PiP layer

Roadmap #16 second drop, batch Q6 — the six follow-ups.

- LOOK-AT POINT no longer jumps to the world origin when you exit a preview. The
  editor's OrbitControls unmount while the preview owns the view, and the pair that
  mounts on exit starts from the default target (0, 1.5, 0). The target is now
  snapshotted when the preview takes over and copied back onto the FRESH instance
  once it appears (the store still holds the disposed one for a beat). Verified:
  (6, 2, -3) in, (6, 2, -3) out.
- SNAPPING ROWS align: label | chips | field in one grid, so the three numeric
  boxes share a right edge no matter how many preset chips a row has (4 / 4 / 3),
  and the chips are tightened enough to stay on ONE line in the 320px panel.
- NO CARET during a scrub: pointerdown's default is blocked so a drag can never
  place or drag the caret through the digits (you could watch it skate about),
  selection is suppressed while scrubbing, and focus is handed over on RELEASE
  instead — which is also what makes click-to-type work.
- THE SEARCH LIST REMEMBERS the height you drag it to, per menu kind: the viewport
  menu, the node editor and the object menu each keep their own (`sizeKey` prop →
  `ctx:searchHeight:<kind>` locally).
- DEEP LINKS are deterministic: measure → scroll → re-measure → correct, with the
  scroller found by real SCROLLABILITY rather than class names, an instant scroll
  (a `smooth` one was being cancelled by the reflow of the section it had just
  expanded) and retries while the just-expanded content lays out. All three land
  8-20px under the sticky filter header even when the panel starts scrolled to the
  bottom. NOTE: the previous check passed vacuously — it only asked for "somewhere
  below the header", which is true without any scrolling at all.
- CAMERA PiP frame drops to z-index 2: it is a viewport overlay, not chrome, so
  panels and HUD now cover it (verified pip z 2 vs panel z 10).

Verification: camera-preview-control +2 (look-at round trip), panel-deeplinks +3
(tight landing band from a bottom-scrolled panel), context-menu-redesign +3
(height persisted, restored on reopen, per-kind keys), number-fields +4 (scrub
reports itself, no focus, no selection, click still focuses), camera-pip +2
(layer below the panel). camera-objects, gizmo-orbit-leak, grid-snapping,
node-search, inspector, object-properties green — 11 suites in one run. Build
green, svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e2e-verify gains an ASSERTION DISCIPLINE section, because the expensive failures in
this roadmap were not broken code but checks that could not fail:
- position/layout asserts need a tight band AND a start state that forces the
  behaviour (the deep-link check passed while nothing scrolled, because most
  sections were collapsed and the label was trivially "below the header");
- isolate a regression with an A/B of the same gesture, and assert the gesture did
  its job, so a no-op cannot read as a pass;
- match the metric to the gesture (OrbitControls: LEFT rotates, RIGHT pans - a
  quaternion check on a right-drag reads 0.0000 forever);
- when a check says pass and the user says fail, re-read the check first.

Also in e2e-verify: HMR churn makes runs LIE (a page loaded while vite
re-transforms edited modules is half-mounted - three runs "proved" a working
feature dead); component-side debug hooks as the cure (__outlineDebug,
__cameraPreviewDebug, cameraHelpersDebug, pipDebug); the real-mouse GIZMO drag
recipe (find the picker via getHelper().traverse + project, never guess pixels, and
remember three keeps the gizmo visuals in that helper); panels scroll so
scrollIntoViewIfNeeded before boundingBox; section headers are buttons with a -/+
glyph so match with startsWith; the new numeric-field (.dn-wrap/.dn-input) and
context-menu (.ctx-filter-input/.ctx-match/.ctx-grip/[data-ctx-active]) selectors;
the new __stores keys; and node-search is OFF the known-failing list (two of its
assertions were asserting the old no-scroll contract).

peer-feature gains "the cheapest replicated feature: put it on userData" - the
full recipe behind userData.camera/physics/particles (deterministic defaults at
creation, ONE write path with props history + objectParameters + poke, the applier
case that a two-peer suite must cover, scene-root visuals), plus PRESENCE-style
state (campreview: one message on change, per-peer map, cleanup in BOTH teardown
paths, late joiners piggyback an existing handshake reply) and the lock-RELEASE
trap (a `lock` message only replaces the sender's set, so letting go needs explicit
`unlock` per uuid).

CLAUDE.md: the Configure-Scene deep-link seam (openSceneSection/inspectorScrollTo/
data-anchor and why smooth scrolling and class-name scroller lookups fail), the
remembered per-kind menu search height (sizeKey), revealFilter rows, and the PiP
frame's deliberate z-index 2 (a viewport overlay, so chrome covers it).

svelte-check 419/62 unchanged (docs only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AlexZ005
AlexZ005 merged commit d7e6a61 into fix/roadmap15-q-menu Aug 5, 2026
@AlexZ005
AlexZ005 deleted the fix/roadmap16-menus-cameras branch August 5, 2026 20:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant