From e10a65d0e013d679c90c4df51acb45074ec6aa3d Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 1 Aug 2026 15:26:27 +0300 Subject: [PATCH 01/28] [fix] UI batch: dropdown width, duplicate tint, modal key gate, window resize Roadmap #15 batch B (plan: cloud repo plans-core/roadmap-15, specs inline): - B1 ThemedSelect: the portaled popup copied the TRIGGER's width, and the trigger is sized to the SELECTED label - picking a short option ("Box") ellipsised every longer name on reopen. The trigger width is now a MINIMUM; the popup sizes to its content (capped at 28rem / the viewport) and is clamped back on screen when that overflows the right edge. Fixes all ~22 call sites at once. - B2 duplicate: a multi-select member wears an emissive highlight, and the clone's fresh material baked it in - the FIRST clone stayed selection-blue forever (its tinted value got recorded as its "original"). Clones now restore the source's recorded emissive (stripSelectionTint), and duplicateSelection no longer selects each clone mid-loop (which collapsed the set and restored sources one at a time); it selects the whole clone set once at the end. - B3 packs: double-click / Enter / "Place in scene" now show the same dismissible loading toast the viewport DROP path shows (shared holdLoadingToast helper) - a slow CDN fetch used to look like nothing happened. - B4 Connect: the chevron badges the toast count the CLOSED drawer is holding when toasts are routed drawer-only (mirrors the drawer tab badge; amber when approvals are pending). - B5 Sessions: naming a session (save + rename) is an inline textbox instead of window.prompt - Enter commits, Esc cancels, focus is taken automatically. - B6 modals: app modals are non-modal s, so the page behind them is NOT inert and window key handlers still fired (WASD flew the camera behind Settings). New derived `anyModalOpen` gates shortcuts.js, editorNavigation (keydown AND the per-frame fly, which also clears held keys) and inputRuntime; only Settings was guarded before. - B7 windows: dragWindow gains an opt-in `resizable` corner grabber that persists {w,h} in the same win: record and re-clamps on viewport resize; What's New opts in and goes full-screen below the Connect bar at <=640px (the .tp-modal-frame treatment). - B8 context menu: Edit mesh / Sculpt hide for a multi-selection - they are single-object modes and silently acted on the last-picked object only. Verification: new tests/e2e/ui-fixes-15b.test.cjs (22 checks covering B2/B4/ B5/B6/B7/B8), themed-select extended for B1; sessions + whats-new + the new suite green; build green; svelte-check baseline 435/62 held. Co-Authored-By: Claude Fable 5 --- src/components/editors/Explorer.svelte | 11 +- src/components/menu/Connect.svelte | 39 +++- src/components/menu/SessionsManager.svelte | 102 +++++++++-- src/components/menu/WhatsNew.svelte | 18 +- src/components/ui/ThemedSelect.svelte | 14 +- src/lib/dragWindow.js | 90 ++++++++- src/lib/editorNavigation.js | 8 +- src/lib/explorerDrop.js | 22 ++- src/lib/inputRuntime.js | 3 + src/lib/objectActions.js | 32 +++- src/lib/objectMenu.js | 23 ++- src/lib/shortcuts.js | 9 +- src/stores/appStore.js | 17 +- tests/e2e/themed-select.test.cjs | 33 ++++ tests/e2e/ui-fixes-15b.test.cjs | 203 +++++++++++++++++++++ 15 files changed, 576 insertions(+), 48 deletions(-) create mode 100644 tests/e2e/ui-fixes-15b.test.cjs diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 4df05374..f72a6573 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -832,12 +832,21 @@ } // N6: place a default-pack item into the scene (double-click / Enter) at origin + // 15-B3: the CDN fetch takes seconds — hold the SAME loading toast the drop + // path shows (it used to look like nothing happened until the model popped in) async function placePackItem(item: any) { + const { holdLoadingToast } = await import('$lib/explorerDrop'); + const dismiss = holdLoadingToast(String(item.name || 'model')); try { const res = await fetch(item.glbUrl); - if (!res.ok) return showToast('Could not fetch the pack item'); + if (!res.ok) { + dismiss(); + return showToast('Could not fetch the pack item'); + } await importFile(new File([await res.blob()], item.name + '.glb'), item.name, 'glb'); + dismiss(); } catch { + dismiss(); showToast('Could not load the pack item (check the network / CORS)'); } } diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index c5ff8262..488ee421 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -1,6 +1,6 @@ @@ -892,7 +905,8 @@
{ - $backgroundColor = event.detail.hex; - $globalScene.background = new THREE.Color($backgroundColor); - sendBackgroundColor(); - }} + hex={$backgroundColor} + onInput={(/** @type {any} */ c) => setBackground(c.hex)} /> { - if (hexColor.test(e.currentTarget.value)) { - $backgroundColor = e.currentTarget.value; - $globalScene.background = new THREE.Color($backgroundColor); - sendBackgroundColor(); - } + if (hexColor.test(e.currentTarget.value)) setBackground(e.currentTarget.value); }} />
@@ -927,7 +933,8 @@
{ - fogColor = event.detail.hex; + hex={fogColor} + onInput={(/** @type {any} */ c) => { + fogColor = c.hex; applyFog(); }} /> @@ -963,10 +970,9 @@ size="xs" color="alternative" onclick={() => { - $globalScene.fog = null; fogNear = null; fogFar = null; - sendFogColor(); + editEnvSky({ fog: null }); }}>Remove Fog
@@ -1169,7 +1175,8 @@
{ - $selectedObject.color.set(event.detail.hex); - color = event.detail.hex; + hex={color} + onInput={(/** @type {any} */ c) => { + $selectedObject.color.set(c.hex); + color = c.hex; sendLightUpdate(); }} /> @@ -1203,7 +1210,8 @@ { - $selectedObject.groundColor.set(event.detail.hex); - groundColor = event.detail.hex; + hex={groundColor} + onInput={(/** @type {any} */ c) => { + $selectedObject.groundColor.set(c.hex); + groundColor = c.hex; sendLightUpdate(); }} /> @@ -1333,7 +1341,8 @@ {#if material.color && material.type !== 'MeshNormalMaterial'} { - trackColorGesture($selectedObject.uuid, event.detail.hex); - $selectedObject.material.color.set(event.detail.hex); - $peers.send({ type: 'color', uuid: $selectedObject.uuid, color: event.detail.hex }); + hex={color} + onInput={(/** @type {any} */ c) => { + // live drag: ONE debounced undo entry per gesture (setObjectColor + // would record on every frame), then apply + replicate + trackColorGesture($selectedObject.uuid, c.hex); + $selectedObject.material.color.set(c.hex); + $selectedObject.material.needsUpdate = true; + objectsGroup.update((v) => v); + $peers.send({ type: 'color', uuid: $selectedObject.uuid, color: c.hex }); }} /> { if (hexColor.test(e.currentTarget.value)) { color = e.currentTarget.value; - $selectedObject.material.color.set(color); - $peers.send({ type: 'color', uuid: $selectedObject.uuid, color }); + // a typed value is ONE discrete change — the shared write path + // applies, replicates and records a single undo entry + setObjectColor($selectedObject.uuid, color); } }} /> diff --git a/src/lib/environment.js b/src/lib/environment.js index 0aaf627c..0c0664a2 100644 --- a/src/lib/environment.js +++ b/src/lib/environment.js @@ -289,6 +289,24 @@ export function applyCustomPreset(payload) { }); } +/** + * 15-C: the scene inspector's Background / Fog controls wrote the scene (and + * the backgroundColor store) DIRECTLY, and the next applyEnvironment() restored + * the preset's values — so the edit looked like it did nothing. (Invisible + * until the color picker's dead `on:input` was fixed, since the handler never + * ran at all.) Editing the sky now detaches into a live custom payload, exactly + * like editRigComponent: it sticks, persists and replicates. + * @param {{background?: string, fog?: {color?: string, near?: number, far?: number} | null}} patch + */ +export function editEnvSky(patch) { + const payload = JSON.parse(JSON.stringify(presetPayload())); + payload.label = 'Custom'; + if (patch.background !== undefined) payload.background = patch.background; + if (patch.fog !== undefined) + payload.fog = patch.fog === null ? null : { ...(payload.fog ?? {}), ...patch.fog }; + commit({ preset: 'custom', customPreset: payload }); +} + /** Editing a rig component detaches into a live custom payload * @param {'hemi'|'sun'} part @param {any} patch */ export function editRigComponent(part, patch) { diff --git a/tests/e2e/color-picker-15c.test.cjs b/tests/e2e/color-picker-15c.test.cjs new file mode 100644 index 00000000..84d04ede --- /dev/null +++ b/tests/e2e/color-picker-15c.test.cjs @@ -0,0 +1,119 @@ +// Roadmap #15 batch C — the color picker went dead in the deps migration: +// svelte-awesome-color-picker 3.x -> 4.1.3 is a runes rewrite with NO component +// events, so every `on:input` handler silently never fired (bind:hex still +// tracked, which is why the swatch moved while nothing applied). The fix is the +// `onInput` PROP + `c.hex`. C2 enables the picker's hex/rgb/hsv text inputs. +// +// Test hook: the picker's OWN hex field (rendered by C2) calls the very same +// `onInput` prop the drag surface does — far more stable than canvas-drag math, +// and it fails loudly against the pre-fix `on:input` wiring. The app's separate +// hex box below the picker is a different control (it always worked). +const h = require('./helpers.cjs'); + +/** type a hex into the Nth picker's own text field (fires the lib's onInput) */ +const typeIntoPicker = (page, index, hex) => + page.evaluate( + ([i, value]) => { + const wrapper = document.querySelectorAll('.wrapper')[i]; + const input = wrapper?.querySelector('input'); + if (!input) return false; + input.value = value; + // delegated attribute-form handlers need a BUBBLING event + input.dispatchEvent(new Event('input', { bubbles: true })); + return true; + }, + [index, hex] + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // a white box, selected with the Properties inspector open + await A.page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + window.__box = box; + box.material.color.set('#ffffff'); + w.objectActions.selectObject(box.uuid, true); + }); + await A.page.waitForTimeout(700); + + // ---- C2: the hex/rgb/hsv text inputs render (were disabled everywhere) ---- + const ui = await A.page.evaluate(() => { + const wrapper = document.querySelector('.wrapper'); + if (!wrapper) return null; + return { + inputs: wrapper.querySelectorAll('input').length, + modeToggle: (wrapper.querySelector('button')?.textContent ?? '').trim() + }; + }); + h.check(!!ui, 'the material color picker renders'); + h.check(ui.inputs > 0, `the picker exposes a text input (${ui?.inputs})`); + h.check(/rgb|hsv|hex/i.test(ui.modeToggle), `a mode cycle button is offered ("${ui?.modeToggle}")`); + + // ---- C1: the picker's onInput applies + replicates (was a silent no-op) ---- + await A.page.evaluate(async () => { + const peer = await new Promise((r) => window.__stores.peers.subscribe(r)()); + window.__sentColors = []; + const orig = peer.send.bind(peer); + peer.send = (m) => { + if (m && m.type === 'color') window.__sentColors.push(m.color); + return orig(m); + }; + }); + h.check(await typeIntoPicker(A.page, 0, '#3366ff'), 'the picker hex field is reachable'); + await A.page.waitForTimeout(200); + const applied = await A.page.evaluate(() => ({ + hex: window.__box.material.color.getHexString(), + sends: window.__sentColors.slice() + })); + h.check(applied.hex === '3366ff', `the picker applies to the material (${applied.hex})`); + h.check( + applied.sends.includes('#3366ff'), + `the change replicates as {type:'color'} (${JSON.stringify(applied.sends)})` + ); + + // one DEBOUNCED undo entry per gesture, and undo restores the old color. + // (the first change's 600ms timer must FIRE before the second starts, or + // both collapse into one gesture — which is the intended live-drag behavior) + await A.page.waitForTimeout(900); + const depthBefore = await A.page.evaluate( + () => new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))()) + ); + await typeIntoPicker(A.page, 0, '#22cc55'); + await A.page.waitForTimeout(900); // past the 600ms gesture debounce + const afterGesture = await A.page.evaluate( + () => new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))()) + ); + h.check( + afterGesture === depthBefore + 1, + `a color gesture records exactly one undo entry (${depthBefore} -> ${afterGesture})` + ); + const undone = await A.page.evaluate(async () => { + window.__stores.history.undo(); + await new Promise((r) => setTimeout(r, 200)); + return window.__box.material.color.getHexString(); + }); + h.check(undone === '3366ff', `undo steps back one color change (${undone})`); + + // ---- C1: the SCENE pickers (Configure scene ▸ background / fog) ---- + await A.page.evaluate(() => window.__stores.showSidebar('scene')); + await A.page.waitForTimeout(600); + const wrappers = await A.page.evaluate(() => document.querySelectorAll('.wrapper').length); + h.check(wrappers >= 2, `the scene inspector renders background + fog pickers (${wrappers})`); + + h.check(await typeIntoPicker(A.page, 0, '#123456'), 'the background picker hex field is reachable'); + await A.page.waitForTimeout(250); + const bg = await A.page.evaluate(async () => { + const store = await new Promise((r) => window.__stores.backgroundColor.subscribe((v) => r(v))()); + const scene = await new Promise((r) => window.__stores.globalScene.subscribe((s) => r(s))()); + return { store, applied: '#' + (scene.background?.getHexString?.() ?? '') }; + }); + h.check(String(bg.store).toLowerCase() === '#123456', `the background store follows (${bg.store})`); + h.check(bg.applied.toLowerCase() === '#123456', `the three.js scene background applies (${bg.applied})`); + + await h.finish(browser); +}); From ec486dbf8764744e38dbbc7c3c4b21e92a9f12e5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 2 Aug 2026 10:47:12 +0300 Subject: [PATCH 03/28] [fix] properties UX, info toasts, GitHub stars, PWA install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap #15 second drop, Opus batches L / M / N / O. O1 material readouts: Roughness/Metalness/Opacity showed the PRE-drag value. `material` was $derived from $selectedObject, but setMaterialParam mutates the material in place and pokes objectsGroup - and $derived compares with ===, so returning the same THREE material never propagated. It now returns a fresh SNAPSHOT per poke, which fixes every material row at once. O2 pin: the properties panel gains a pin toggle (persisted). Pinned it stays open and follows you - object selected shows its properties, nothing selected falls back to the Scene's (deselect no longer closes it). O3 opening it: a plain viewport click now only SELECTS (it used to force the panel open on every click - raycastSelect passed openProperties=true). The panel opens on DOUBLE-click, from the new context-menu "Properties" entry, or from the object list - and always when pinned. O4 menu indicator: the "● " prefix on Configure Scene shifted the label as it appeared and duplicated what the open panel already shows; the row carries an `active` highlight instead. L1/L2 info toasts: "Restore previous session?" and the first-run open-source notice were hand-rolled flowbite blocks - which is exactly why they looked nothing like the other cards AND never appeared in the Connect drawer's Toasts tab. They are now sticky INFO entries in the normal toast store (teal accent vs the blue default and amber approvals), mirrored from their source stores so they still appear/disappear with them and keep their dismiss side effects. autoDismiss skips sticky entries. L3 the standalone hex textboxes under the colour pickers are gone - 15-C2 gave every picker its own hex/rgb/hsv field, so they were duplicate UI. L4 the "+N more" overflow line is a button that opens the drawer on Toasts. M GitHub stars: the Welcome overlay's GitHub button shows the star count, and the cloud profile dropdown gains "★ Star on GitHub" ABOVE Support (cloud repo, separate commit). Cached 12h in localStorage (unauthenticated GitHub allows 60 req/h/IP) and HIDDEN on any failure - never a 0. N PWA: manifest + icon set (rasterized from logo.svg) + iOS meta, and a service worker that is a pure network passthrough. That satisfies Chrome's installability criteria without ever serving a stale build - version.json polling stays the update path. Verification: new tests/e2e/ui-fixes-15lmno.test.cjs (26 checks); inspector, whats-new, themed-select, color-picker-15c, ui-fixes-15b, connect-states all green; build green; svelte-check 421/62 held. panels + roadmap-13-notifications failures verified PRE-EXISTING on clean HEAD (git stash). Co-Authored-By: Claude Opus 5 (1M context) --- src/App.svelte | 5 + src/app.html | 12 +- src/components/Scene.svelte | 14 +- src/components/menu/ConnectInfoDrawer.svelte | 7 +- src/components/menu/Inspector.svelte | 104 ++++---- src/components/menu/Sidebar.svelte | 14 +- src/components/menu/Toasts.svelte | 129 +++++----- src/components/menu/Welcome.svelte | 24 +- src/components/ui/PanelHeader.svelte | 43 +++- src/lib/githubStars.js | 52 ++++ src/lib/objectActions.js | 8 +- src/lib/objectMenu.js | 7 + src/stores/appStore.js | 49 +++- static/icons/apple-touch-icon.png | Bin 0 -> 4021 bytes static/icons/icon-192.png | Bin 0 -> 4639 bytes static/icons/icon-512.png | Bin 0 -> 13245 bytes static/icons/icon-maskable-512.png | Bin 0 -> 10312 bytes static/manifest.webmanifest | 17 ++ static/sw.js | 18 ++ tests/e2e/ui-fixes-15lmno.test.cjs | 256 +++++++++++++++++++ 20 files changed, 636 insertions(+), 123 deletions(-) create mode 100644 src/lib/githubStars.js create mode 100644 static/icons/apple-touch-icon.png create mode 100644 static/icons/icon-192.png create mode 100644 static/icons/icon-512.png create mode 100644 static/icons/icon-maskable-512.png create mode 100644 static/manifest.webmanifest create mode 100644 static/sw.js create mode 100644 tests/e2e/ui-fixes-15lmno.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 24f65a64..5f2521b2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -51,6 +51,11 @@ // node graph animations keep running even when the flow drawer is closed onMount(() => { + // 15-N: register the PWA service worker (a no-cache passthrough — see + // static/sw.js) so mobile browsers offer "Install app". Dev is skipped: a + // SW in front of vite's HMR only causes confusion. + if ('serviceWorker' in navigator && import.meta.env.PROD) + navigator.serviceWorker.register('/sw.js').catch(() => {}) startFlowRuntime() startNodeSync() startLockSweep() diff --git a/src/app.html b/src/app.html index 0e84b3c0..5bc3ed46 100644 --- a/src/app.html +++ b/src/app.html @@ -5,7 +5,17 @@ - + + + + + + + + + -{#if $appNotice && typeof localStorage !== 'undefined' && !localStorage.getItem('hasSeenDisclaimer')} -
- - { localStorage.setItem('hasSeenDisclaimer', 'true'); } - }> -
- -
-
- -

- {$appNotice.text}
-

- {#if $appNotice.ctaUrl} - - {/if} -
- -
-
-{/if} + {#if $specatorMode}
@@ -271,13 +259,23 @@ style="left: 50%; max-width: 500px; transform: translate(-50%, 0%); z-index: var {/if} {#if $toastStore.length > MAX_TOASTS} -
+{$toastStore.length - MAX_TOASTS} more…
+ +
+ +
{/if} {#each $toastStore.slice(-MAX_TOASTS) as toast (toast)} -
+
{/if} + + {#if isCameraObject($selectedObject)} + {@const cam = cameraSpec($selectedObject)} +
+
+ Kind + {#each [['perspective', 'Perspective'], ['orthographic', 'Orthographic']] as [kind, label]} + + {/each} +
+ {#if cam.kind === 'perspective'} + setCameraFor($selectedObject.uuid, { fov: v })} + /> + {:else} + setCameraFor($selectedObject.uuid, { orthoSize: v })} + /> + {/if} + setCameraFor($selectedObject.uuid, { near: v })} + /> +
+ Far + + setCameraFor($selectedObject.uuid, { far: parseFloat(e.currentTarget.value) || cam.far })} + /> +
+
+ Framing + {#each ASPECTS as aspect} + + {/each} +
+ setCameraFor($selectedObject.uuid, { guide: e.currentTarget.checked })} + >Letterbox guide while previewing +
+ + + + +
+ showCameraFrustums.set(e.currentTarget.checked)} + >Show camera frustums — this device +

+ The camera itself is shared; previewing and the frustum lines are yours alone. +

+
+ {/if}
Position diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index a46cac4f..40df9d07 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -1,5 +1,6 @@ {#if $cameraPreview && spec} @@ -68,7 +86,7 @@ {#if $cameraPreview.controlling} - + {/if} {:else} @@ -81,7 +99,7 @@ bind:ref={cameraRef} > {#if $cameraPreview.controlling} - + {/if} {/if} diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 62938511..05fb07b0 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -32,7 +32,7 @@ import { startLightHelpers, updateLightHelpers, lightProxiesGroup } from '$lib/lightHelpers'; import { startColliderHelpers, updateColliderHelpers } from '$lib/colliderHelpers'; import { startCameraHelpers, updateCameraHelpers } from '$lib/cameraHelpers'; - import { cameraPreview } from '$lib/cameraPreview'; + import { cameraPreview, activeOrbit } from '$lib/cameraPreview'; import CameraPreview from './CameraPreview.svelte'; import { startEditorNavigation, updateEditorNavigation } from '$lib/editorNavigation'; import { vrMenuOpen } from '../stores/sceneStore'; @@ -263,7 +263,7 @@ updateLightHelpers(); updateColliderHelpers(); // CL-A A7: collider proxies follow their objects updateCameraHelpers(); // 16-P5: camera-object frustums follow their markers - if (!renderer.xr.isPresenting) updateEditorNavigation(delta, camera.current, $orbitControls); + if (!renderer.xr.isPresenting) updateEditorNavigation(delta, camera.current, $activeOrbit); }); // --- undo/redo: record one history entry per gizmo drag --- @@ -420,7 +420,7 @@ // draw mode: dragging paints a stroke instead of orbiting if ($drawMode && !$isLocked && !$isVRMode) { strokeActive = true; - if ($orbitControls) $orbitControls.enabled = false; + if ($activeOrbit) $activeOrbit.enabled = false; setRayFromEvent(event); strokePointFromRay(selectionRaycaster); return; @@ -433,7 +433,7 @@ if (hit) { sculptActive = true; lastSculptAt = performance.now(); - if ($orbitControls) $orbitControls.enabled = false; + if ($activeOrbit) $activeOrbit.enabled = false; beginStroke($sculptObject); const local = terrain.worldToLocal(hit.point.clone()); strokeMove($sculptObject, local.x, local.z, 0.016, local.y); // y feeds the mesh brush @@ -443,7 +443,7 @@ // Shift+drag = marquee select (13) — orbit pauses for the gesture if (event.shiftKey && !$isLocked && !$isVRMode && !$specatorMode && !$editingObject && !$faceEditObject) { marqueeStart = [event.clientX, event.clientY]; - if ($orbitControls) $orbitControls.enabled = false; + if ($activeOrbit) $activeOrbit.enabled = false; } downPosition = [event.clientX, event.clientY]; downTime = Date.now(); @@ -507,7 +507,7 @@ if (marqueeStart && event.button === 0) { const start = marqueeStart; marqueeStart = null; - if ($orbitControls) $orbitControls.enabled = true; + if ($activeOrbit) $activeOrbit.enabled = true; const moved = Math.hypot(event.clientX - start[0], event.clientY - start[1]); if ($marqueeRect && moved > 8) { // marquee ADDS to the selection (it already needs Shift to start) @@ -524,13 +524,13 @@ } if (sculptActive && event.button === 0) { sculptActive = false; - if ($orbitControls) $orbitControls.enabled = true; + if ($activeOrbit) $activeOrbit.enabled = true; sculptEndStroke(); // flush the pending preview + ONE undoable snapshot return; } if (strokeActive && event.button === 0) { strokeActive = false; - if ($orbitControls) $orbitControls.enabled = true; + if ($activeOrbit) $activeOrbit.enabled = true; endStroke(); return; } diff --git a/src/lib/cameraPreview.js b/src/lib/cameraPreview.js index 445bd5e2..8f3278d0 100644 --- a/src/lib/cameraPreview.js +++ b/src/lib/cameraPreview.js @@ -1,6 +1,6 @@ -import { writable, get } from 'svelte/store'; +import { writable, derived, get } from 'svelte/store'; import * as THREE from 'three'; -import { objectsGroup } from '../stores/sceneStore'; +import { objectsGroup, orbitControls } from '../stores/sceneStore'; import { peers, showToast, specatorMode } from '../stores/appStore'; import { recordTransformSet } from './history'; import { findCameraObject, cameraSpec } from './cameraObjects'; @@ -27,6 +27,21 @@ export const cameraPreview = writable(null); * @type {import('svelte/store').Writable>} */ export const cameraPreviews = writable({}); +/** + * The OrbitControls that belong to the PREVIEW camera while Control is on. + * Deliberately its OWN store instead of binding the shared `orbitControls`: + * threlte clears a bound ref when the component unmounts, and with both sets of + * controls bound to one store the unmount could land AFTER the editor controls + * remounted — leaving the store empty. Everything that suppresses orbiting + * (notably the transform-gizmo drag) writes through that store, so an empty one + * meant dragging the gizmo ALSO orbited the camera, for the rest of the session. + * @type {import('svelte/store').Writable} + */ +export const previewOrbit = writable(null); + +/** Whichever controls are actually steering the view right now. */ +export const activeOrbit = derived([previewOrbit, orbitControls], ([preview, editor]) => preview ?? editor); + /** pose the marker had when Control began (for the single undo entry) */ /** @type {any} */ let controlBefore = null; @@ -106,6 +121,21 @@ export function toggleCameraControl() { showToast('Flying the camera — WASD to move, drag to look, Exit when done'); } +/** + * Seat OrbitControls behind a camera WITHOUT moving it. OrbitControls.update() + * ends with `camera.lookAt(target)`, and a fresh instance targets the world + * origin — so mounting it on a camera that was looking elsewhere snapped the view + * to (0,0,0) the moment Control was pressed (the "preview jumps" bug). Putting the + * target on the camera's own forward axis first makes that lookAt a no-op. + * @param {any} controls @param {any} camera @param {number} [distance] + */ +export function seatOrbitBehind(controls, camera, distance = 6) { + if (!controls?.target || !camera) return; + const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); + controls.target.copy(camera.position).add(forward.multiplyScalar(distance)); + controls.update?.(); +} + /** Seal the ride: ONE undo entry + a final authoritative pose. */ function endControl() { const current = get(cameraPreview); diff --git a/tests/e2e/camera-preview-control.test.cjs b/tests/e2e/camera-preview-control.test.cjs new file mode 100644 index 00000000..06497cd0 --- /dev/null +++ b/tests/e2e/camera-preview-control.test.cjs @@ -0,0 +1,154 @@ +// 16-P5 follow-up: the two Control bugs. +// 1. Pressing Control SNAPPED the view to the world origin — a fresh +// OrbitControls targets (0,0,0) and its update() ends with +// camera.lookAt(target), so mounting it rotated the camera. The controls are +// now seated behind the camera first, so the pose survives. +// 2. After exiting the preview, dragging the transform gizmo also ORBITED the +// camera: both sets of OrbitControls were bound to the same store, and the +// preview's unmount could clear it after the editor's remounted — so every +// `$orbitControls.enabled = false` suppression silently no-oped. The preview +// now publishes its own `previewOrbit`, and Scene suppresses/steers through +// the derived `activeOrbit`. +const h = require('./helpers.cjs'); + +const camQuat = (page) => + page.evaluate( + () => + new Promise((r) => + window.__stores.globalCamera.subscribe((c) => r(c.quaternion.toArray()))() + ) + ); + +const orbitState = (page) => + page.evaluate( + () => + new Promise((r) => { + let editor = null; + let preview = null; + let active = null; + window.__stores.orbitControls.subscribe((v) => (editor = v))(); + window.__stores.cameraPreview.previewOrbit.subscribe((v) => (preview = v))(); + window.__stores.cameraPreview.activeOrbit.subscribe((v) => (active = v))(); + r({ + hasEditor: !!editor, + editorEnabled: editor?.enabled ?? null, + hasPreview: !!preview, + activeIsEditor: !!active && active === editor, + activeIsPreview: !!active && active === preview + }); + }) + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // a camera aimed at the origin from an angle (aimed the CAMERA way: -Z forward) + const uuid = await A.page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 2 2 2'); + w.commandsHandler.sceneCommand('/create Camera'); + await new Promise((r) => setTimeout(r, 300)); + let g = null; + w.objectsGroup.subscribe((v) => (g = v))(); + const cam = g.children[g.children.length - 1]; + cam.position.set(-5, 4, 7); + const m = new w.THREE.Matrix4().lookAt( + cam.position, + new w.THREE.Vector3(0, 1, 0), + new w.THREE.Vector3(0, 1, 0) + ); + cam.quaternion.setFromRotationMatrix(m); + cam.updateMatrix(); + w.objectsGroup.update((v) => v); + return cam.uuid; + }); + await A.page.waitForTimeout(300); + + // ---------- 1. Control must not move the view ---------- + await A.page.evaluate((u) => window.__stores.cameraPreview.startCameraPreview(u), uuid); + await A.page.waitForTimeout(800); + const beforeControl = await camQuat(A.page); + await A.page.evaluate(() => window.__stores.cameraPreview.toggleCameraControl()); + await A.page.waitForTimeout(700); + const afterControl = await camQuat(A.page); + const drift = Math.max(...afterControl.map((v, i) => Math.abs(v - beforeControl[i]))); + h.check(drift < 0.02, `pressing Control keeps the view where it was (max component drift ${drift.toFixed(4)})`); + + // the preview owns the controls while controlling + let state = await orbitState(A.page); + h.check(state.hasPreview && state.activeIsPreview, `Control publishes its own controls (${JSON.stringify(state)})`); + + // the seated target sits in FRONT of the camera, not at the origin + const targetInFront = await A.page.evaluate( + () => + new Promise((r) => + window.__stores.cameraPreview.previewOrbit.subscribe((c) => { + if (!c) return r(null); + let cam = null; + window.__stores.globalCamera.subscribe((v) => (cam = v))(); + const forward = new window.__stores.THREE.Vector3(0, 0, -1).applyQuaternion(cam.quaternion); + const toTarget = c.target.clone().sub(cam.position).normalize(); + r(forward.dot(toTarget)); + })() + ) + ); + h.check(targetInFront !== null && targetInFront > 0.95, `the orbit target is seated straight ahead (dot ${targetInFront})`); + + // ---------- 2. exiting restores a LIVE editor controls object ---------- + await A.page.evaluate(() => window.__stores.cameraPreview.stopCameraPreview()); + await A.page.waitForTimeout(900); + state = await orbitState(A.page); + h.check(state.hasEditor, 'the editor controls are back'); + h.check(!state.hasPreview, 'the preview controls are released'); + h.check(state.activeIsEditor, 'suppression + navigation route back to the editor controls'); + + // they are actually WIRED: attached to the CURRENT editor camera and listening on + // the canvas (a stale instance left over from the preview would fail both) + const wiring = await A.page.evaluate( + () => + new Promise((r) => { + let controls = null; + let cam = null; + window.__stores.orbitControls.subscribe((v) => (controls = v))(); + window.__stores.globalCamera.subscribe((v) => (cam = v))(); + r({ + attachedToActiveCamera: !!controls && controls.object === cam, + listening: !!controls?.domElement, + enabled: controls?.enabled === true + }); + }) + ); + h.check( + wiring.attachedToActiveCamera && wiring.listening && wiring.enabled, + `the restored controls drive the live camera and listen for input (${JSON.stringify(wiring)})` + ); + + // ...and the gizmo suppression path can still switch them off (this is the + // store write that used to hit nothing) + const suppressed = await A.page.evaluate(async () => { + let active = null; + window.__stores.cameraPreview.activeOrbit.subscribe((v) => (active = v))(); + if (!active) return null; + active.enabled = false; + const off = active.enabled === false; + active.enabled = true; + return off; + }); + h.check(suppressed === true, 'the gizmo-drag suppression reaches live controls'); + + // a second preview cycle stays healthy (no accumulated stale refs) + await A.page.evaluate((u) => window.__stores.cameraPreview.startCameraPreview(u), uuid); + await A.page.waitForTimeout(700); + await A.page.evaluate(() => window.__stores.cameraPreview.toggleCameraControl()); + await A.page.waitForTimeout(500); + await A.page.evaluate(() => window.__stores.cameraPreview.stopCameraPreview()); + await A.page.waitForTimeout(900); + state = await orbitState(A.page); + h.check( + state.hasEditor && !state.hasPreview && state.activeIsEditor, + `a second preview+control cycle leaves the controls clean (${JSON.stringify(state)})` + ); + + await h.finish(browser); +}); From a02ededeedc3e96f9c6fa5655a0875a9aa944625 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 4 Aug 2026 15:12:52 +0300 Subject: [PATCH 19/28] [feat] context menu: sticky search, remembered cursor, fixed anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/ContextMenu.svelte | 82 +++++++++++++++----- src/lib/menuFilter.js | 4 +- tests/e2e/context-menu-redesign.test.cjs | 95 ++++++++++++++++++++++-- tests/e2e/node-search.test.cjs | 38 +++++++++- 4 files changed, 186 insertions(+), 33 deletions(-) diff --git a/src/components/ContextMenu.svelte b/src/components/ContextMenu.svelte index 92e2c190..7f71fb43 100644 --- a/src/components/ContextMenu.svelte +++ b/src/components/ContextMenu.svelte @@ -30,7 +30,8 @@ // 16-P2: `revealFilter` rows (the node editor's "Search nodes…") just show the // filter row — the menu STAYS open and the input keeps the keyboard if (item.revealFilter) { - forceFilter = true; + searchMode = true; + highlight = -1; inputEl?.focus({ preventScroll: true }); return; } @@ -39,6 +40,13 @@ } let query = ''; + /** 16-P7: search MODE is sticky — clearing the query keeps the flat list (now + * showing everything) instead of snapping back to the grouped menu; only Esc + * leaves search. Entered by typing or by a `revealFilter` row. */ + let searchMode = false; + /** the cursor position we left behind on each level, keyed by its path — so + * stepping OUT of a submenu lands back on the row you came from */ + let levelHighlight: Record = {}; /** labels of the submenu chain currently RENDERED open */ let openPath: string[] = []; /** level the keyboard cursor sits on — usually === openPath, but a pointer @@ -48,15 +56,20 @@ let navPath: string[] = []; /** highlighted row at navPath's level; -1 = nothing yet */ let highlight = -1; - /** a `revealFilter` row asked for the (empty) filter to be shown */ - let forceFilter = false; + /** how many rows the empty-query browse list shows (it scrolls) */ + const BROWSE_CAP = 200; let inputEl: HTMLInputElement | null = null; // the header strip (what this menu acts on) leads the menu, ABOVE the filter $: headerItem = items[0]?.header ? items[0] : null; $: bodyItems = headerItem ? items.slice(1) : items; $: leaves = collectLeaves(items); - $: matches = query ? rankMatches(leaves, query) : []; + /** With a query: ranked matches. In search mode WITHOUT one: every action, so + * the box doubles as a full browse list (the node editor's old search box did + * this, and clearing the query should not throw you out of it). */ + $: matches = query ? rankMatches(leaves, query) : searchMode ? leaves.slice(0, BROWSE_CAP) : []; + /** flat list instead of the grouped tree? */ + $: listMode = searchMode || !!query; /** the item list at a submenu path @param {any[]} list @param {string[]} path */ function levelItems(list: any[], path: string[]) { @@ -69,8 +82,8 @@ return current ?? []; } const selectable = (list: any[]) => (list ?? []).filter((item) => item && !item.section && !item.header); - /** rows the keyboard walks: the matches while filtering, else the cursor's level */ - $: navRows = query ? matches.map((entry) => entry.item) : selectable(levelItems(bodyItems, navPath)); + /** rows the keyboard walks: the flat list while searching, else the cursor's level */ + $: navRows = listMode ? matches.map((entry) => entry.item) : selectable(levelItems(bodyItems, navPath)); $: if (highlight >= navRows.length) highlight = navRows.length - 1; function scrollNavIntoView() { @@ -96,13 +109,17 @@ scrollNavIntoView(); } + const levelKey = (path: string[]) => path.join('|'); + /** @param {any} item */ function openChildrenOf(item: any) { if (!item?.children) return false; + levelHighlight[levelKey(navPath)] = highlight; // remember where we were navPath = [...navPath, item.label]; openPath = navPath; - highlight = -1; - move(1); + const remembered = levelHighlight[levelKey(navPath)]; + highlight = remembered ?? -1; + if (highlight < 0) move(1); return true; } @@ -116,9 +133,12 @@ function back() { if (!navPath.length) return false; + // leaving a submenu forgets ITS cursor but restores the parent's (Q1: it used + // to reset to the top row every time) + delete levelHighlight[levelKey(navPath)]; navPath = navPath.slice(0, -1); openPath = navPath; - highlight = -1; + highlight = levelHighlight[levelKey(navPath)] ?? -1; return true; } @@ -133,13 +153,15 @@ activate(); event.preventDefault(); } else if (event.key === 'Escape') { - // Esc unwinds one step at a time: query → revealed filter → open submenu → - // the menu itself + // Esc unwinds one step at a time: query → search mode → open submenu → + // the menu itself. (Clearing the query by BACKSPACE deliberately stays in + // search mode showing everything — only Esc goes back to the menu.) if (query) { query = ''; + highlight = 0; + } else if (searchMode) { + searchMode = false; highlight = -1; - } else if (forceFilter) { - forceFilter = false; } else if (!back()) { dispatch('close'); } @@ -156,10 +178,13 @@ function onFilterInput(event: Event) { query = (event.currentTarget as HTMLInputElement).value; + // typing enters search mode; DELETING the text keeps you there (the list simply + // widens to every action) — only Esc returns to the grouped menu + if (query) searchMode = true; // filtering resets the walk to the top hit so Enter is predictable openPath = []; navPath = []; - highlight = query ? 0 : -1; + highlight = 0; } function focusInput(node: HTMLInputElement) { @@ -194,9 +219,13 @@ function place(node: HTMLElement) { let lastW = -1; let lastH = -1; + /** which side of the click we opened toward — decided ONCE (Q1: a growing + * list used to re-decide and teleport the menu to the top of the screen) */ + let flipUp: boolean | null = null; const reposition = () => { const vw = window.innerWidth; const vh = window.innerHeight; + // measure unconstrained first so the side decision sees the natural height node.style.maxHeight = vh - 8 + 'px'; const w = node.offsetWidth; const h = node.offsetHeight; @@ -204,8 +233,18 @@ lastH = h; let left = x > vw - w - 4 ? x - w : x; // near the right edge -> open leftward left = Math.max(4, Math.min(left, vw - w - 4)); - let top = y > vh - h - 4 ? y - h : y; - top = Math.max(4, Math.min(top, vh - h - 4)); + + const below = vh - y - 8; // room under the click + const above = y - 8; // room over it + // first placement picks the roomier side; later ones KEEP it, so filtering + // (which changes the height a lot) never re-anchors the menu + if (flipUp === null) flipUp = h > below && above > below; + // the anchor edge stays ON the click: top edge downward, bottom edge upward. + // Anything that doesn't fit gets capped and scrolls (.ctx-scroll). + const room = Math.max(120, flipUp ? above : below); + const height = Math.min(h, room); + node.style.maxHeight = room + 'px'; + const top = flipUp ? Math.max(4, y - height) : Math.min(y, Math.max(4, vh - height - 4)); node.style.left = left + 'px'; node.style.top = top + 'px'; node.style.right = 'auto'; @@ -257,7 +296,7 @@ {/if} - {/each} {#if !matches.length} - + {/if} {:else} { h.check(ran.toggled, 'Enter runs the top filtered action'); h.check(ran.menuGone, 'running an action closes the menu'); + // ---------- 16-Q1: search mode is STICKY, and the menu keeps its anchor ------- + await A.page.evaluate(() => window.__stores.viewportMenu.set(null)); + await A.page.evaluate(() => window.__stores.viewportMenu.set({ x: 220, y: 140, point: [0, 0, 0] })); + await A.page.waitForTimeout(400); + const sticky = await A.page.evaluate(async () => { + const input = document.querySelector('.ctx-filter-input'); + const menu = () => document.querySelector('[role="menu"]'); + const type = async (value) => { + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 200)); + }; + const key = async (k) => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true })); + await new Promise((r) => setTimeout(r, 200)); + }; + const topBefore = menu().getBoundingClientRect().top; + await type('grid'); + const filtered = document.querySelectorAll('.ctx-match').length; + // DELETE the query: the box must stay, now listing everything + await type(''); + const browsing = document.querySelectorAll('.ctx-match').length; + const rowStillThere = (document.querySelector('.ctx-filter')?.getBoundingClientRect().height ?? 0) > 0; + const topWhileBrowsing = menu().getBoundingClientRect().top; + const scrolls = menu().scrollHeight > menu().clientHeight; + // Esc leaves search, back to the grouped menu + await key('Escape'); + const grouped = !!menu()?.textContent?.includes('Snapping') && document.querySelectorAll('.ctx-match').length === 0; + const rowHidden = (document.querySelector('.ctx-filter')?.getBoundingClientRect().height ?? 0) === 0; + return { topBefore, filtered, browsing, rowStillThere, topWhileBrowsing, scrolls, grouped, rowHidden }; + }); + h.check(sticky.filtered > 0, `typing filters (${sticky.filtered} matches)`); + h.check( + sticky.rowStillThere && sticky.browsing > sticky.filtered, + `clearing the query KEEPS the search box and lists everything (${sticky.browsing} rows)` + ); + h.check( + Math.abs(sticky.topWhileBrowsing - sticky.topBefore) < 2, + `the menu keeps the anchor it opened at (top ${sticky.topBefore} -> ${sticky.topWhileBrowsing})` + ); + h.check(sticky.scrolls, 'a long list scrolls inside the menu instead of moving it'); + h.check(sticky.grouped && sticky.rowHidden, 'Esc returns to the grouped menu and hides the box'); + await A.page.evaluate(() => window.__stores.viewportMenu.set(null)); + // ---------- 16-P1: arrow navigation ---------- await A.page.evaluate(() => window.__stores.viewportMenu.set({ x: 220, y: 140, point: [0, 0, 0] })); await A.page.waitForTimeout(400); @@ -174,26 +218,61 @@ h.run(async () => { `the highlight moves INTO the submenu, not the parent row (${nav.insideSubmenu})` ); h.check(nav.stillOpen && nav.submenuClosed, 'Escape inside a submenu closes only the submenu'); + + // ---------- 16-Q1: stepping out of a submenu keeps the parent's cursor ------- + const cursorMemory = await A.page.evaluate(async () => { + const input = document.querySelector('.ctx-filter-input'); + const key = async (k) => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true })); + await new Promise((r) => setTimeout(r, 130)); + }; + const activeLabel = () => document.querySelector('[data-ctx-active="true"]')?.textContent?.trim() ?? null; + // walk down a few rows to a submenu row that is NOT the first row + let guard = 0; + while (guard++ < 12 && !(activeLabel() ?? '').startsWith('Tools')) await key('ArrowDown'); + const parentRow = activeLabel(); + await key('Enter'); // into Tools + const inside = activeLabel(); + await key('Escape'); // back out + const backOn = activeLabel(); + return { parentRow, inside, backOn }; + }); + h.check( + (cursorMemory.parentRow ?? '').startsWith('Tools') && !!cursorMemory.inside, + `descended into a submenu from a mid-list row (${cursorMemory.parentRow} -> ${cursorMemory.inside})` + ); + h.check( + (cursorMemory.backOn ?? '').startsWith('Tools'), + `stepping back keeps the cursor on the row we came from (${cursorMemory.backOn})` + ); await A.page.evaluate(() => window.__stores.viewportMenu.set(null)); - // ---------- Esc semantics: clear the query first, then close ---------- + // ---------- Esc semantics: query → search mode → menu (16-Q1: search is sticky, + // so leaving it is its own step) ---------- await A.page.evaluate(() => window.__stores.viewportMenu.set({ x: 220, y: 140, point: [0, 0, 0] })); await A.page.waitForTimeout(400); const esc = await A.page.evaluate(async () => { const input = document.querySelector('.ctx-filter-input'); + const esc = async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await new Promise((r) => setTimeout(r, 160)); + }; input.value = 'grid'; input.dispatchEvent(new Event('input', { bubbles: true })); await new Promise((r) => setTimeout(r, 150)); - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - await new Promise((r) => setTimeout(r, 150)); + await esc(); const clearedNotClosed = !!document.querySelector('[role="menu"]') && input.value === ''; - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - await new Promise((r) => setTimeout(r, 150)); + const stillSearching = document.querySelectorAll('.ctx-match').length > 0; + await esc(); + const groupedAgain = + !!document.querySelector('[role="menu"]') && document.querySelectorAll('.ctx-match').length === 0; + await esc(); const closed = !document.querySelector('[role="menu"]'); - return { clearedNotClosed, closed }; + return { clearedNotClosed, stillSearching, groupedAgain, closed }; }); - h.check(esc.clearedNotClosed, 'Esc clears the query first'); - h.check(esc.closed, 'a second Esc closes the menu'); + h.check(esc.clearedNotClosed && esc.stillSearching, 'Esc clears the query but stays in search'); + h.check(esc.groupedAgain, 'a second Esc leaves search for the grouped menu'); + h.check(esc.closed, 'a third Esc closes the menu'); await h.finish(browser); }); diff --git a/tests/e2e/node-search.test.cjs b/tests/e2e/node-search.test.cjs index a9fd4ec8..193496bc 100644 --- a/tests/e2e/node-search.test.cjs +++ b/tests/e2e/node-search.test.cjs @@ -63,14 +63,44 @@ h.run(async () => { await A.page.waitForTimeout(200); await A.page.keyboard.press('Escape'); await A.page.waitForTimeout(200); + // 16-Q1: Esc clears the query but STAYS in search (the flat list widens to every + // node); a second Esc returns to the grouped menu, a third closes it const afterEsc = await A.page.evaluate(() => ({ query: document.querySelector('.ctx-filter-input')?.value ?? null, - grouped: !!document.querySelector('[role="menu"]')?.textContent?.includes('Search nodes') + browsing: document.querySelectorAll('.ctx-match').length })); - h.check(afterEsc.query === '' && afterEsc.grouped, 'Esc clears the query back to the grouped menu'); - await A.page.keyboard.press('Escape'); // a second Esc closes it + h.check( + afterEsc.query === '' && afterEsc.browsing > 0, + `Esc clears the query but keeps browsing every node (${afterEsc.browsing} rows)` + ); + await A.page.keyboard.press('Escape'); // leaves search for the grouped menu + await A.page.waitForTimeout(250); + const grouped = await A.page.evaluate(() => ({ + open: !!document.querySelector('[role="menu"]'), + flat: document.querySelectorAll('.ctx-match').length, + hasSearchRow: !!document.querySelector('[role="menu"]')?.textContent?.includes('Search nodes') + })); + h.check(grouped.open && grouped.flat === 0 && grouped.hasSearchRow, 'a second Esc returns to the grouped menu'); + await A.page.keyboard.press('Escape'); // and a third closes it + await A.page.waitForTimeout(250); + h.check(!(await A.page.locator('[role="menu"]').isVisible()), 'a third Esc closes the menu'); + + // 16-Q1: the row that OPENS the search must not appear among its own results + await pane.click({ button: 'right', position: { x: 500, y: 200 } }); + await A.page.waitForTimeout(250); + await A.page.locator('.ctx-filter-input').fill('search'); + await A.page.waitForTimeout(250); + const selfMatch = await A.page.evaluate(() => + [...document.querySelectorAll('.ctx-match')].map((m) => m.textContent?.trim()) + ); + h.check( + !selfMatch.some((m) => /Search nodes/.test(m ?? '')), + `"Search nodes…" is excluded from search results (${selfMatch.length} matches)` + ); + await A.page.keyboard.press('Escape'); + await A.page.keyboard.press('Escape'); + await A.page.mouse.click(900, 60); await A.page.waitForTimeout(200); - h.check(!(await A.page.locator('[role="menu"]').isVisible()), 'a second Esc closes the menu'); // module + custom entries searchable: wave from the hello module await pane.click({ button: 'right', position: { x: 240, y: 220 } }); From 6d1726dd792ed49698a66c56aecc252e759a70f0 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 4 Aug 2026 15:25:47 +0300 Subject: [PATCH 20/28] [feat] panel deep links, grid look-at follow, snap values, themed checkboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- src/App.svelte | 7 +- src/components/menu/Inspector.svelte | 74 +++++----- src/components/menu/ViewportMenu.svelte | 35 +++-- src/components/ui/Section.svelte | 16 ++- src/extensions/Grid.svelte | 29 +++- src/lib/addObjects.js | 6 +- src/lib/gridSettings.js | 19 ++- src/stores/appStore.js | 21 +++ tests/e2e/panel-deeplinks.test.cjs | 171 ++++++++++++++++++++++++ 9 files changed, 320 insertions(+), 58 deletions(-) create mode 100644 tests/e2e/panel-deeplinks.test.cjs diff --git a/src/App.svelte b/src/App.svelte index aedba1ff..f0fd01be 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -179,9 +179,10 @@ import('./lib/cameraBookmarks'), import('./lib/cameraObjects'), import('./lib/cameraHelpers'), - import('./lib/cameraPreview') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, cameraPreviewLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, cameraPreview: cameraPreviewLib } + import('./lib/cameraPreview'), + import('./lib/addObjects') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, cameraPreviewLib, addObjectsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib } }) } }) diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index a28273ab..be9766ea 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -1153,12 +1153,26 @@ onchange={(v) => setGrid({ size: v })} /> {/if} - setGrid({ followCamera: e.currentTarget.checked })} - >Follow the camera + +
+ Follow + {#each [['off', 'Off'], ['lookat', 'Look-at'], ['camera', 'Camera']] as [mode, label]} + + {/each} +
{/if} - + setPhysics({ sensor: e.currentTarget.checked || null })} + >Sensor — no collision, fires On Enter / On Exit {#if ($selectedObject.userData.physics?.mode ?? 'auto') === 'dynamic'}
Lock rotation {#each [['rx', 'X'], ['ry', 'Y'], ['rz', 'Z']] as [key, label] (key)} - + setFreeze(key, e.currentTarget.checked)} + >{label} {/each}
Lock position {#each [['px', 'X'], ['py', 'Y'], ['pz', 'Z']] as [key, label] (key)} - + setFreeze(key, e.currentTarget.checked)} + >{label} {/each}
{/if} - + setColliderViz($selectedObject.uuid, e.currentTarget.checked)} + >Show collider — this device

Dynamic bodies fall and collide when a simulation runs; flow Mass/Bounciness/Friction nodes override these.

diff --git a/src/components/menu/ViewportMenu.svelte b/src/components/menu/ViewportMenu.svelte index 52b34c3e..701b1cfa 100644 --- a/src/components/menu/ViewportMenu.svelte +++ b/src/components/menu/ViewportMenu.svelte @@ -9,7 +9,7 @@ import { measureMode, toggleMeasure } from '$lib/measure'; import { bookmarks, saveBookmark, recallBookmark, clearBookmarks, SHORTCUT_SLOTS } from '$lib/cameraBookmarks'; import { showGrid, globalScene, globalCamera, globalRenderer, selectedObject, selectedObjects, lockedObjects } from '../../stores/sceneStore'; - import { viewportMenu, objectSearch, objectSearchEnabled, showSidebar } from '../../stores/appStore'; + import { viewportMenu, objectSearch, objectSearchEnabled, openSceneSection } from '../../stores/appStore'; import { buildAddChildren } from '$lib/addObjects'; import { buildObjectMenuItems } from '$lib/objectMenu'; import { sendPing } from '$lib/ping'; @@ -39,9 +39,9 @@ // 16-P3: the active choice is `checked` (bold + accent) instead of a '● ' label // prefix, which shifted the label sideways as it appeared and read as a glitch. - function snapSizeItem(key: 'translate' | 'scale', value: number, label: string) { + function snapSizeItem(key: 'translate' | 'scale', value: number, label?: string) { return { - label, + label: label ?? String(value), checked: $snapSettings[key] === value, action: () => snapSettings.update((s) => ({ ...s, [key]: value })) }; @@ -53,6 +53,18 @@ action: () => snapSettings.update((s) => ({ ...s, rotateDeg: value })) }; } + // 16-Q2: a step typed in Configure Scene has to be reachable — and visible as the + // ACTIVE one — here too, so it joins the presets whenever it isn't one of them. + function snapRow(key: 'translate' | 'scale', presets: number[]) { + const current = $snapSettings[key]; + const values = presets.includes(current) ? presets : [...presets, current].sort((a, b) => a - b); + return values.map((value) => snapSizeItem(key, value)); + } + function snapRotRow(presets: number[]) { + const current = $snapSettings.rotateDeg; + const values = presets.includes(current) ? presets : [...presets, current].sort((a, b) => a - b); + return values.map((value) => snapRotItem(value)); + } $: hasSelection = $selectedObjects.length > 0 || @@ -158,16 +170,11 @@ action: () => snapEnabled.update((v) => !v) }, { section: 'Position' }, - snapSizeItem('translate', 0.1, '0.1'), - snapSizeItem('translate', 0.5, '0.5'), - snapSizeItem('translate', 1, '1'), + ...snapRow('translate', [0.1, 0.25, 0.5, 1]), { section: 'Rotation' }, - snapRotItem(5), - snapRotItem(15), - snapRotItem(45), + ...snapRotRow([5, 15, 45, 90]), { section: 'Scale' }, - snapSizeItem('scale', 0.05, '0.05'), - snapSizeItem('scale', 0.1, '0.1'), + ...snapRow('scale', [0.05, 0.1, 0.25]), { section: 'Surface' }, { label: 'Snap to surface', @@ -180,7 +187,7 @@ label: 'More snapping settings…', icon: 'sliders-horizontal', tooltip: 'Custom steps live in Configure Scene ▸ Snapping', - action: () => showSidebar('scene') + action: () => openSceneSection('Snapping') } ] }, @@ -201,7 +208,7 @@ label: 'Grid & axes settings…', icon: 'sliders-horizontal', tooltip: 'Cell size, colours, fade and the origin axes (Configure Scene ▸ Grid)', - action: () => showSidebar('scene') + action: () => openSceneSection('Grid') }, { label: 'Screenshot', icon: 'camera', action: screenshot } ] @@ -228,7 +235,7 @@ label: 'Manage saved views…', icon: 'sliders-horizontal', tooltip: 'Rename, re-shoot, reorder or delete (Configure Scene ▸ Camera)', - action: () => showSidebar('scene') + action: () => openSceneSection('Camera') }, { label: 'Clear bookmarks', diff --git a/src/components/ui/Section.svelte b/src/components/ui/Section.svelte index 7e68e08d..67c3a5de 100644 --- a/src/components/ui/Section.svelte +++ b/src/components/ui/Section.svelte @@ -4,7 +4,7 @@ // while $inspectorFilter is non-empty every section force-renders its // content (so hidden rows are searchable), matches the query against its // rendered TEXT, and hides itself when nothing matches. - import { inspectorFilter } from '../../stores/appStore'; + import { inspectorFilter, inspectorScrollTo } from '../../stores/appStore'; /** @type {{label?: string, collapsible?: boolean, open?: boolean, children?: any}} */ let { label = '', collapsible = true, open = $bindable(true), children = null } = $props(); @@ -35,6 +35,20 @@ const filtering = $derived($inspectorFilter.trim().length > 0); const showContent = $derived(filtering ? true : !collapsible || (open && !collapsed)); + + // 16-Q2: a menu deep link ("More snapping settings…") names a section — expand it + // even if the user had collapsed it, scroll it into view, then clear the request + // so it fires exactly once. + $effect(() => { + if ($inspectorScrollTo !== label) return; + collapsed = false; + try { + LS?.setItem('inspector:sec:' + label, 'open'); + } catch {} + const node = root; + requestAnimationFrame(() => node?.scrollIntoView({ block: 'start', behavior: 'smooth' })); + inspectorScrollTo.set(null); + });
diff --git a/src/extensions/Grid.svelte b/src/extensions/Grid.svelte index b3c9d777..5cc12977 100644 --- a/src/extensions/Grid.svelte +++ b/src/extensions/Grid.svelte @@ -22,15 +22,35 @@ // // 16-P3: all of the appearance now comes from the LOCAL `gridSettings` prefs // (Configure Scene ▸ Grid); 'fixed' fade mode skips the auto math entirely. + // + // 16-Q2: FOLLOW is ours, not threlte's `followCamera` — that one tracked your + // POSITION, which is not what "follow the camera" wants to mean when you are + // looking somewhere else. 'lookat' centres the grid under the orbit target, + // 'camera' keeps the old behaviour, and both stay HORIZONTAL (y = 0: it is the + // ground plane, not a flying sheet). The centre snaps to whole cells so the + // lines keep lining up with world coordinates instead of sliding. const { camera } = useThrelte() let fade = $state(100) + let centerX = $state(0) + let centerZ = $state(0) useTask(() => { - if ($gridSettings.fadeMode !== 'auto') return const cam = camera.current if (!cam) return const oc = $orbitControls - const dist = oc?.target ? cam.position.distanceTo(oc.target) : cam.position.length() - fade = Math.min(Math.max(100, dist * 1.6), 5000) + if ($gridSettings.fadeMode === 'auto') { + const dist = oc?.target ? cam.position.distanceTo(oc.target) : cam.position.length() + fade = Math.min(Math.max(100, dist * 1.6), 5000) + } + const follow = $gridSettings.follow + if (follow === 'off') { + centerX = 0 + centerZ = 0 + return + } + const anchor = follow === 'lookat' && oc?.target ? oc.target : cam.position + const step = Math.max(0.001, cell) + centerX = Math.round(anchor.x / step) * step + centerZ = Math.round(anchor.z / step) * step }) const cell = $derived(effectiveCell($gridSettings, $snapSettings.translate)) @@ -42,9 +62,8 @@ inspectorScrollTo.set(label), wasOpen ? 0 : 140); +} + /** * Close the inspector only when it shows the selection — deselect, lock and * delete paths must not close an open scene view. @@ -322,6 +339,10 @@ export const notificationsUnread = writable(0); /** Inspector property search (PFX-C follow-up): non-empty = Sections filter * themselves by their rendered text (Section.svelte reads this). LOCAL. */ export const inspectorFilter = writable(''); +/** 16-Q2: a Section LABEL to expand + scroll into view (set by `openSceneSection`, + * consumed and cleared by the matching Section). + * @type {import('svelte/store').Writable} */ +export const inspectorScrollTo = writable(null); /** the notification center panel open state */ export const notificationCenterOpen = writable(false); /** E2: the scene-notes drawer (lists every annotation) open state */ diff --git a/tests/e2e/panel-deeplinks.test.cjs b/tests/e2e/panel-deeplinks.test.cjs new file mode 100644 index 00000000..b199bed9 --- /dev/null +++ b/tests/e2e/panel-deeplinks.test.cjs @@ -0,0 +1,171 @@ +// 16-Q2: menu → panel deep links and the fixes that ride along. +// - "More snapping settings…" / "Grid & axes settings…" / "Manage saved views…" +// OPEN Configure Scene, EXPAND the named section (even if collapsed) and scroll +// to it; clicking one while the panel is already open must NOT close it (the old +// `showSidebar('scene')` toggled). +// - the grid follows the LOOK-AT point (orbit target), horizontally, snapped to +// whole cells; 'camera' and 'off' still available. +// - the snapping submenu offers 0.25 for Scale and surfaces a CUSTOM value. +// - the Physics section's checkboxes are themed (no raw ). +// - adding from the Add menu opens the new object's properties. +const h = require('./helpers.cjs'); + +const panel = (page) => + page.evaluate( + () => + new Promise((r) => { + let closed = true; + let kind = null; + window.__stores.inspectorClose.subscribe((v) => (closed = v))(); + window.__stores.inspectorKind.subscribe((v) => (kind = v))(); + r({ open: !closed, kind }); + }) + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + const openMenu = async () => { + await A.page.evaluate(() => window.__stores.viewportMenu.set({ x: 240, y: 150, point: [0, 0, 0] })); + await A.page.waitForTimeout(350); + }; + /** hover a parent row then click a child by text */ + const pick = async (parent, child) => { + await openMenu(); + await A.page.locator('[role="menuitem"]').filter({ hasText: parent }).first().hover(); + await A.page.waitForTimeout(300); + await A.page.getByText(child, { exact: false }).first().click(); + await A.page.waitForTimeout(600); + }; + + // ---------- deep link from a CLOSED panel ---------- + await A.page.evaluate(() => { + window.__stores.inspectorClose.set(true); + // collapse the target section first, so expanding is observable + localStorage.setItem('inspector:sec:Snapping', 'closed'); + }); + await pick('Snapping', 'More snapping settings'); + let state = await panel(A.page); + h.check(state.open && state.kind === 'scene', `the deep link opens Configure Scene (${JSON.stringify(state)})`); + const expanded = await A.page.evaluate(() => localStorage.getItem('inspector:sec:Snapping')); + h.check(expanded === 'open', `the collapsed section was expanded (${expanded})`); + const snapVisible = await A.page.evaluate(() => !!document.querySelector('#snap-rotate')); + h.check(snapVisible, 'its contents are rendered'); + + // ---------- deep link while the panel is ALREADY open: must not close it ---------- + await pick('View', 'Grid & axes settings'); + state = await panel(A.page); + h.check(state.open && state.kind === 'scene', 'a second deep link keeps the panel open instead of toggling it'); + const gridVisible = await A.page.evaluate(() => !!document.querySelector('#grid-follow')); + h.check(gridVisible, 'the Grid section is showing'); + + await pick('Camera bookmarks', 'Manage saved views'); + state = await panel(A.page); + h.check(state.open && state.kind === 'scene', 'and so does the bookmarks one'); + + // ---------- grid follow: look-at ---------- + const follow = await A.page.evaluate(async () => { + const w = window.__stores; + w.gridSettings.setGrid({ follow: 'lookat', cellSize: 1, matchSnapStep: false }); + let controls = null; + w.orbitControls.subscribe((v) => (controls = v))(); + controls.target.set(7.4, 3, -4.6); // a target that is NOT on a cell boundary + await new Promise((r) => setTimeout(r, 400)); + let scene = null; + w.globalScene.subscribe((v) => (scene = v))(); + let grid = null; + scene.traverse((n) => { + if (n.type === 'Mesh' && n.material?.type === 'ShaderMaterial' && n.geometry?.type === 'PlaneGeometry') + grid = grid ?? n; + }); + return grid ? { x: grid.position.x, y: grid.position.y, z: grid.position.z } : null; + }); + h.check(follow !== null, 'found the grid mesh'); + h.check( + follow && Math.abs(follow.x - 7) < 0.001, + `the grid centres under the look-at point, snapped to whole cells (x ${follow?.x})` + ); + h.check(follow && Math.abs(follow.y) < 0.001, `it never lifts vertically (y ${follow?.y})`); + h.check(follow && Math.abs(follow.z - (-5 + 0.03)) < 0.001, `z follows too (${follow?.z})`); + + // 'off' returns it to the origin + await A.page.evaluate(() => window.__stores.gridSettings.setGrid({ follow: 'off' })); + await A.page.waitForTimeout(400); + const offCentre = await A.page.evaluate(() => { + let scene = null; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + let grid = null; + scene.traverse((n) => { + if (n.type === 'Mesh' && n.material?.type === 'ShaderMaterial' && n.geometry?.type === 'PlaneGeometry') + grid = grid ?? n; + }); + return grid ? [grid.position.x, grid.position.z] : null; + }); + h.check(offCentre && Math.abs(offCentre[0]) < 0.001, `Off puts it back at the origin (${offCentre})`); + + // ---------- snapping submenu: 0.25 for Scale + a custom value ---------- + await A.page.evaluate(() => window.__stores.viewportMenu.set(null)); + await A.page.evaluate(() => + window.__stores.snapping.snapSettings.set({ translate: 0.5, rotateDeg: 15, scale: 0.075 }) + ); + await openMenu(); + await A.page.locator('[role="menuitem"]').filter({ hasText: 'Snapping' }).first().hover(); + await A.page.waitForTimeout(350); + const rows = await A.page.evaluate(() => { + const sub = [...document.querySelectorAll('div')].find( + (d) => + getComputedStyle(d).position === 'fixed' && + !d.getAttribute('role') && + d.textContent?.includes('Snap to surface') + ); + return { + labels: [...(sub?.querySelectorAll('[role="menuitem"]') ?? [])].map((r) => r.textContent?.trim()), + checked: [...(sub?.querySelectorAll('.ctx-checked') ?? [])].map((r) => r.textContent?.trim()) + }; + }); + h.check(rows.labels.includes('0.25'), `Scale offers 0.25 (${rows.labels.join(' ')})`); + h.check(rows.labels.includes('0.075'), 'a custom step from the panel appears in the menu'); + h.check(rows.checked.includes('0.075'), 'and it is marked as the active one'); + await A.page.evaluate(() => window.__stores.viewportMenu.set(null)); + + // ---------- physics checkboxes are themed ---------- + const physics = await A.page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + await new Promise((r) => setTimeout(r, 250)); + let g = null; + w.objectsGroup.subscribe((v) => (g = v))(); + const box = g.children[g.children.length - 1]; + w.physics.setPhysicsFor(box.uuid, { mode: 'dynamic' }); + w.objectActions.selectObject(box.uuid, true); + localStorage.setItem('inspector:sec:Physics', 'open'); + await new Promise((r) => setTimeout(r, 700)); + const section = [...document.querySelectorAll('div')].find((d) => + d.querySelector('#physics-sensor') + ); + const raw = section ? section.querySelectorAll('input[type="checkbox"]:not([class])').length : -1; + const sensor = document.querySelector('#physics-sensor'); + return { + found: !!sensor, + classed: (sensor?.getAttribute('class') ?? '').length > 0, + freezeRows: !!document.querySelector('#physics-freeze-rot') + }; + }); + h.check(physics.found && physics.classed, `the Sensor checkbox is the themed component (${JSON.stringify(physics)})`); + + // ---------- Add opens the new object's properties ---------- + await A.page.evaluate(() => { + window.__stores.showSidebar('scene'); // start on a DIFFERENT panel + }); + await A.page.waitForTimeout(400); + await A.page.evaluate(() => window.__stores.addObjects?.spawnAtPoint?.('/create Sphere 0.5', [1, 0, 1])); + await A.page.waitForTimeout(500); + const afterAdd = await panel(A.page); + h.check( + afterAdd.open && afterAdd.kind === 'selection', + `adding switches the panel to the new object's properties (${JSON.stringify(afterAdd)})` + ); + + await h.finish(browser); +}); From 2b4e19fe64338150f4a47c83ebaf56ee7f620ceb Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 4 Aug 2026 15:50:26 +0300 Subject: [PATCH 21/28] [feat] one numeric field everywhere: live typing, arrow-key steps Roadmap #16 second drop, batch Q3. The transform rows had a drag-to-scrub control; every other number was a plain 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 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) --- src/components/menu/Inspector.svelte | 154 ++++++++------- src/components/ui/DragRow.svelte | 283 +++++++++++++++++++++------ src/components/ui/SliderRow.svelte | 31 ++- tests/e2e/inspector.test.cjs | 8 +- tests/e2e/number-fields.test.cjs | 139 +++++++++++++ 5 files changed, 469 insertions(+), 146 deletions(-) create mode 100644 tests/e2e/number-fields.test.cjs diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index be9766ea..06dfdddb 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -936,15 +936,18 @@ />
Far clip - setCameraFar(parseFloat(e.currentTarget.value))} - /> +
+ setCameraFar(v)} + /> +
grows to fit the scene

Clip planes are per-device (not shared).

@@ -1205,16 +1208,18 @@ onclick={() => snapSettings.update((s) => ({ ...s, translate: step }))}>{step} {/each} - - snapSettings.update((s) => ({ ...s, translate: parseFloat(e.currentTarget.value) || s.translate }))} - /> +
+ snapSettings.update((s) => ({ ...s, translate: v || s.translate }))} + /> +
Rotation @@ -1227,16 +1232,18 @@ onclick={() => snapSettings.update((s) => ({ ...s, rotateDeg: step }))}>{step}° {/each} - - snapSettings.update((s) => ({ ...s, rotateDeg: parseFloat(e.currentTarget.value) || s.rotateDeg }))} - /> +
+ snapSettings.update((s) => ({ ...s, rotateDeg: v || s.rotateDeg }))} + /> +
Scale @@ -1249,16 +1256,18 @@ onclick={() => snapSettings.update((s) => ({ ...s, scale: step }))}>{step} {/each} - - snapSettings.update((s) => ({ ...s, scale: parseFloat(e.currentTarget.value) || s.scale }))} - /> +
+ snapSettings.update((s) => ({ ...s, scale: v || s.scale }))} + /> +
Far - - setCameraFor($selectedObject.uuid, { far: parseFloat(e.currentTarget.value) || cam.far })} - /> +
+ setCameraFor($selectedObject.uuid, { far: v || cam.far })} + /> +
Framing @@ -1633,14 +1644,17 @@
Render order - setObjectParam('renderOrder', +e.currentTarget.value || 0)} - /> +
+ setObjectParam('renderOrder', Math.round(v) || 0)} + /> +
Emit from {#each ['x', 'y', 'z'] as axis, i} - { - const off = [...(p.offset ?? [0, 0, 0])]; - off[i] = +e.currentTarget.value; - setParticles({ offset: off }); - }} - /> +
+ { + const off = [...(p.offset ?? [0, 0, 0])]; + off[i] = v; + setParticles({ offset: off }); + }} + /> +
{/each}
diff --git a/src/components/ui/DragRow.svelte b/src/components/ui/DragRow.svelte index c6c1e41c..7c63d3d7 100644 --- a/src/components/ui/DragRow.svelte +++ b/src/components/ui/DragRow.svelte @@ -1,96 +1,249 @@ -{#if typing} +
+ {#if label} + {label} + {/if} commit(e.currentTarget.value)} - onkeydown={(e) => { - if (e.key === 'Enter') commit(e.currentTarget.value); - else if (e.key === 'Escape') typing = false; - }} + {id} + type="text" + inputmode="decimal" + autocomplete="off" + spellcheck="false" + class="dn-input tabular-nums" + aria-label={ariaLabel || label || 'value'} + title={title || 'Drag to scrub · type to set · ↑↓ steps (Ctrl ×10, Shift ×100) · Esc reverts'} + value={display} + use:keys + oninput={onInput} + onchange={onInput} + onfocus={onFocus} + onblur={onBlur} /> -{:else} - -{/if} +
+ + diff --git a/src/components/ui/SliderRow.svelte b/src/components/ui/SliderRow.svelte index 804527ba..a966cdc4 100644 --- a/src/components/ui/SliderRow.svelte +++ b/src/components/ui/SliderRow.svelte @@ -2,7 +2,14 @@ // The one true slider row: label · range · number. One-way flow: render // from `value`, report through `onchange(next)` (replication stays at the // call site). Phase 64 layers the infinite-drag input on the label. - /** @type {{label?: string, value?: number, min?: number, max?: number, step?: number, decimals?: number, onchange?: (next: number) => void}} */ + // + // 16-Q3: the trailing box is the shared DragRow field now — drag to scrub, type + // with live updates, arrow keys stepping by one minor unit (Ctrl ×10, Shift + // ×100). It used to be a plain that only committed on + // Enter/blur, which made its arrows look broken. + import DragRow from './DragRow.svelte'; + + /** @type {{label?: string, value?: number, min?: number, max?: number, step?: number, decimals?: number, id?: string, onchange?: (next: number) => void}} */ let { label = '', value = 0, @@ -10,6 +17,7 @@ max = 1, step = 0.01, decimals = 2, + id = undefined, onchange = () => {} } = $props(); @@ -27,17 +35,24 @@ commit(e.currentTarget.value)} /> - commit(e.currentTarget.value)} - /> +
+ onchange(next)} + /> +
diff --git a/tests/e2e/inspector.test.cjs b/tests/e2e/inspector.test.cjs index 70a665f8..c07c12fe 100644 --- a/tests/e2e/inspector.test.cjs +++ b/tests/e2e/inspector.test.cjs @@ -45,7 +45,7 @@ h.run(async () => { } // ---- drag-to-scrub: +100px at 0.02/px ≈ +2, replicated live, no snap-back ---- - const scrubber = A.page.locator('#inspector-position .drag-number').first(); + const scrubber = A.page.locator('#inspector-position .dn-wrap').first(); const box = await scrubber.boundingBox(); await A.page.mouse.move(box.x + 8, box.y + box.height / 2); await A.page.mouse.down(); @@ -61,8 +61,8 @@ h.run(async () => { // ---- click without moving = type the exact value ---- await scrubber.click(); - const typeInput = A.page.locator('#inspector-position input'); - h.check(await typeInput.isVisible(), 'click swaps the scrubber into typing mode'); + const typeInput = A.page.locator('#inspector-position .dn-input').first(); + h.check(await typeInput.isVisible(), 'the scrubber is a typable field (16-Q3: always an input)'); await typeInput.fill('5'); await A.page.keyboard.press('Enter'); h.check((await selectedX(A.page)) === 5, 'typed value commits'); @@ -89,7 +89,7 @@ h.run(async () => { 'light badge shown' ); h.check( - await A.page.locator('#inspector-intensity .drag-number').isVisible(), + await A.page.locator('#inspector-intensity .dn-wrap').isVisible(), 'intensity scrubber shown for lights' ); h.check( diff --git a/tests/e2e/number-fields.test.cjs b/tests/e2e/number-fields.test.cjs new file mode 100644 index 00000000..17f97f41 --- /dev/null +++ b/tests/e2e/number-fields.test.cjs @@ -0,0 +1,139 @@ +// 16-Q3: ONE numeric field everywhere (DragRow) — transform rows, the boxes beside +// sliders, and the loose inputs that used to be plain . +// • typing applies LIVE (no Enter) +// • ArrowUp/Down step one MINOR unit (0.01 at 2 decimals), Ctrl ×10, Shift ×100 +// • integer fields (decimals 0) step by 1 / 10 / 100 +// • Esc reverts to the value you started with +// • horizontal drag still scrubs +const h = require('./helpers.cjs'); + +const posX = (page) => + page.evaluate( + () => + new Promise((r) => + window.__stores.selectedObject.subscribe((o) => r(Math.round((o?.position.x ?? 0) * 1000) / 1000))() + ) + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // a box selected with its properties open + await A.page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + await new Promise((r) => setTimeout(r, 250)); + let g = null; + w.objectsGroup.subscribe((v) => (g = v))(); + const box = g.children[g.children.length - 1]; + box.position.set(0, 0, 0); + w.objectActions.selectObject(box.uuid, true); + localStorage.setItem('inspector:sec:Transform', 'open'); + }); + await A.page.waitForTimeout(700); + + // the X position field of the transform row + const field = A.page.locator('.dn-wrap', { has: A.page.locator('.dn-label', { hasText: 'X' }) }).first(); + const input = field.locator('.dn-input'); + h.check(await input.count() > 0, 'the transform row renders the shared numeric field'); + + // ---------- typing is LIVE ---------- + await input.click(); + await A.page.waitForTimeout(150); + await input.fill('2.5'); + await A.page.waitForTimeout(250); + h.check((await posX(A.page)) === 2.5, `typing applies without Enter (${await posX(A.page)})`); + + // ---------- arrow keys: minor unit, then Ctrl and Shift ---------- + await input.press('ArrowUp'); + await A.page.waitForTimeout(150); + let value = await posX(A.page); + h.check(Math.abs(value - 2.51) < 0.0005, `ArrowUp adds one minor unit 0.01 (${value})`); + + await input.press('ArrowDown'); + await A.page.waitForTimeout(150); + value = await posX(A.page); + h.check(Math.abs(value - 2.5) < 0.0005, `ArrowDown subtracts 0.01 (${value})`); + + await input.press('Control+ArrowUp'); + await A.page.waitForTimeout(150); + value = await posX(A.page); + h.check(Math.abs(value - 2.6) < 0.0005, `Ctrl+ArrowUp adds 0.10 (${value})`); + + await input.press('Shift+ArrowUp'); + await A.page.waitForTimeout(150); + value = await posX(A.page); + h.check(Math.abs(value - 3.6) < 0.0005, `Shift+ArrowUp adds 1 (${value})`); + + // ---------- Esc reverts to the value the field was focused with ---------- + await A.page.locator('#drawer-label').click({ position: { x: 5, y: 5 } }).catch(() => {}); + await A.page.waitForTimeout(200); + await input.click(); + await A.page.waitForTimeout(200); + const beforeEsc = await posX(A.page); + await input.press('ArrowUp'); + await input.press('ArrowUp'); + await A.page.waitForTimeout(150); + await input.press('Escape'); + await A.page.waitForTimeout(250); + value = await posX(A.page); + h.check(Math.abs(value - beforeEsc) < 0.0005, `Esc reverts the edit (${beforeEsc} -> ${value})`); + + // ---------- drag still scrubs ---------- + const box = await field.boundingBox(); + const start = await posX(A.page); + await A.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await A.page.mouse.down(); + await A.page.mouse.move(box.x + box.width / 2 + 40, box.y + box.height / 2, { steps: 6 }); + await A.page.mouse.up(); + await A.page.waitForTimeout(250); + value = await posX(A.page); + h.check(value > start + 0.2, `dragging right scrubs the value up (${start} -> ${value})`); + + // ---------- an INTEGER field steps by whole numbers ---------- + const intStep = await A.page.evaluate(async () => { + const w = window.__stores; + localStorage.setItem('inspector:sec:Object', 'open'); + await new Promise((r) => setTimeout(r, 500)); + const el = document.querySelector('#inspector-render-order'); + if (!el) return null; + el.focus(); + const before = Number(el.value); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await new Promise((r) => setTimeout(r, 200)); + const afterPlain = Number(document.querySelector('#inspector-render-order').value); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', ctrlKey: true, bubbles: true })); + await new Promise((r) => setTimeout(r, 200)); + const afterCtrl = Number(document.querySelector('#inspector-render-order').value); + return { before, afterPlain, afterCtrl }; + }); + h.check(intStep !== null, 'found the integer field (render order)'); + h.check( + intStep && intStep.afterPlain === intStep.before + 1, + `an integer field steps by 1 (${JSON.stringify(intStep)})` + ); + h.check(intStep && intStep.afterCtrl === intStep.afterPlain + 10, 'Ctrl steps it by 10'); + + // ---------- the boxes beside sliders are the same field ---------- + const sliderBox = await A.page.evaluate(async () => { + window.__stores.showSidebar('scene'); + await new Promise((r) => setTimeout(r, 600)); + window.__stores.inspectorScrollTo.set('Camera'); + await new Promise((r) => setTimeout(r, 400)); + const far = document.querySelector('#camera-far'); + if (!far) return null; + far.focus(); + const before = Number(far.value); + far.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', ctrlKey: true, bubbles: true })); + await new Promise((r) => setTimeout(r, 250)); + return { before, after: Number(document.querySelector('#camera-far').value), tag: far.tagName }; + }); + h.check(sliderBox !== null && sliderBox.tag === 'INPUT', 'the far-clip box is the shared field'); + h.check( + sliderBox && sliderBox.after === sliderBox.before + 10, + `it steps with the same rules (${JSON.stringify(sliderBox)})` + ); + + await h.finish(browser); +}); From 3d1be254d77055a0b790d7011bdcef6abe858ef2 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 4 Aug 2026 16:05:11 +0300 Subject: [PATCH 22/28] [feat] camera preview window (PiP) and a Capture row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/App.svelte | 7 +- src/components/Menu.svelte | 4 + src/components/Outline.svelte | 35 ++++ src/components/menu/CameraPipWindow.svelte | 178 +++++++++++++++++++++ src/components/menu/Inspector.svelte | 24 ++- src/lib/cameraObjects.js | 4 +- src/lib/cameraPip.js | 98 ++++++++++++ src/lib/geometries.svelte.js | 3 +- tests/e2e/camera-objects.test.cjs | 8 +- tests/e2e/camera-pip.test.cjs | 133 +++++++++++++++ 10 files changed, 481 insertions(+), 13 deletions(-) create mode 100644 src/components/menu/CameraPipWindow.svelte create mode 100644 src/lib/cameraPip.js create mode 100644 tests/e2e/camera-pip.test.cjs diff --git a/src/App.svelte b/src/App.svelte index f0fd01be..21654e75 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -180,9 +180,10 @@ import('./lib/cameraObjects'), import('./lib/cameraHelpers'), import('./lib/cameraPreview'), - import('./lib/addObjects') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, cameraPreviewLib, addObjectsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib } + import('./lib/addObjects'), + import('./lib/cameraPip') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, cameraPreviewLib, addObjectsLib, cameraPipLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib } }) } }) diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte index 02f3b1d9..4da6d5db 100644 --- a/src/components/Menu.svelte +++ b/src/components/Menu.svelte @@ -5,6 +5,7 @@ import MeshJobsCard from './menu/MeshJobsCard.svelte'; import Toasts from './menu/Toasts.svelte'; import FramingGuide from './menu/FramingGuide.svelte'; + import CameraPipWindow from './menu/CameraPipWindow.svelte'; import Connect from './menu/Connect.svelte'; import Controls from './menu/Controls.svelte'; import Inspector from './menu/Inspector.svelte'; @@ -60,6 +61,9 @@ + +
diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index cae4a005..73cf809a 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -13,6 +13,9 @@ // @ts-ignore - n8ao ships no bundled type declarations import { N8AOPostPass } from 'n8ao'; import { onMount } from 'svelte'; + // 16-Q4: the camera preview window renders as an inset viewport of THIS renderer + import { pipRect, pipTarget, glRect } from '$lib/cameraPip'; + import { buildCamera } from '$lib/cameraObjects'; let outlineEffectSelected: OutlineEffect | null = null; let outlineEffectLocked: OutlineEffect | null = null; @@ -122,10 +125,42 @@ else { composer.render(delta); if (!aoWarm && ++warmupFrames > 10) aoWarm = true; + renderPip(); } }, { stage: renderStage, autoInvalidate: false } ); + + // 16-Q4: the camera PREVIEW WINDOW. One extra SCISSORED viewport of the same + // renderer, drawn over the composer's output into the rect CameraPipWindow + // publishes — no second WebGL context, so no duplicated GPU memory, and it only + // runs while a camera object is selected. gl clears respect the scissor box, so + // the inset clears just itself. + let pipCamera: any = null; + function renderPip() { + const rect = $pipRect; + const uuid = $pipTarget; + if (!rect || !uuid) return; + const object = $objectsGroup?.getObjectByProperty('uuid', uuid); + if (!object) return; + pipCamera = buildCamera(object, rect.w / rect.h, pipCamera); + // looking through a camera means standing inside its own body — and its + // frustum lines would wrap the lens + const markerWasVisible = object.visible; + const frustums = scene.getObjectByName('camera-frustums'); + const frustumsWereVisible = frustums?.visible ?? false; + object.visible = false; + if (frustums) frustums.visible = false; + const box = glRect(rect, renderer.domElement.clientHeight || $size.height); + renderer.setScissorTest(true); + renderer.setScissor(box.x, box.y, box.w, box.h); + renderer.setViewport(box.x, box.y, box.w, box.h); + renderer.render(scene, pipCamera); + renderer.setScissorTest(false); + renderer.setViewport(0, 0, $size.width, $size.height); + object.visible = markerWasVisible; + if (frustums) frustums.visible = frustumsWereVisible; + } // 15-K: collect every mesh under a uuid — OutlineEffect only renders MESHES // in its selection, so adding a Group outlined nothing useful, and adding a // parent mesh skipped its children (imported models). Traversal makes the diff --git a/src/components/menu/CameraPipWindow.svelte b/src/components/menu/CameraPipWindow.svelte new file mode 100644 index 00000000..f25a634a --- /dev/null +++ b/src/components/menu/CameraPipWindow.svelte @@ -0,0 +1,178 @@ + + + + +{#if object} + +
e.preventDefault()} + > +
+ {object.name || 'Camera'} + + +
+

right-drag to move

+
+{/if} + + diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index 06dfdddb..69b9cad8 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -1575,12 +1575,6 @@ : startCameraPreview($selectedObject.uuid)} >{$cameraPreview?.uuid === $selectedObject.uuid ? 'Previewing' : 'Preview'} - + +
+ + saves a PNG at the framing aspect +
+ setCameraFor($selectedObject.uuid, { pip: e.currentTarget.checked })} + >Preview window while selected } */ +export const pipPosition = writable(null); + +/** the rect the renderer draws into, in CSS px from the top-left of the viewport + * @type {import('svelte/store').Writable<{x: number, y: number, w: number, h: number} | null>} */ +export const pipRect = writable(null); + +/** + * Which camera the window is showing: the selected camera object, unless it is + * already filling the viewport as a full preview, VR is on, or that camera has its + * `pip` flag off. + * + * Reads the selection SET, not `selectedObject` — the latter is STICKY (it keeps + * the last object after a deselect so the open inspector still has something to + * bind to), which left the window hanging around after you clicked empty space. + * `objectsGroup` is in the dependency list because the `pip` flag lives on + * userData: THREE trees are not reactive, so the poke that follows a settings + * write is the only signal this derived store gets. + */ +export const pipTarget = derived( + [selectedObjects, objectsGroup, cameraPreview, isVRMode], + ([set, group, preview, vr]) => { + if (vr || !set?.length) return null; + const uuid = set[set.length - 1]; // the primary of the set + const object = group?.getObjectByProperty('uuid', uuid); + if (!isCameraObject(object)) return null; + if (cameraSpec(object).pip === false) return null; + if (preview?.uuid === uuid) return null; // you're already inside it + return uuid; + } +); + +/** Window size for a camera's framing (16:9 unless it declares otherwise). + * @param {any} object */ +export function pipSize(object) { + const ratio = aspectRatio(cameraSpec(object).aspect) || 16 / 9; + const h = PIP_HEIGHT; + return { w: Math.round(h * ratio), h }; +} + +/** + * Where to park the window when the user hasn't dragged it: bottom-right, but + * LEFT of an open side panel so the two never overlap. + * @param {{w: number, h: number}} size + * @param {{width: number, height: number}} viewport + * @param {number} [panelWidth] width of an open right-side panel (0 = none) + */ +export function autoPosition(size, viewport, panelWidth = 0) { + return { + x: Math.max(MARGIN, viewport.width - size.w - MARGIN - panelWidth), + y: Math.max(MARGIN, viewport.height - size.h - MARGIN) + }; +} + +/** Keep a dragged window fully on screen. + * @param {{x: number, y: number}} pos @param {{w: number, h: number}} size + * @param {{width: number, height: number}} viewport */ +export function clampPosition(pos, size, viewport) { + return { + x: Math.min(Math.max(0, pos.x), Math.max(0, viewport.width - size.w)), + y: Math.min(Math.max(0, pos.y), Math.max(0, viewport.height - size.h)) + }; +} + +/** Convert the DOM rect into the gl viewport three wants (y from the BOTTOM). + * @param {{x: number, y: number, w: number, h: number}} rect + * @param {number} canvasHeight */ +export function glRect(rect, canvasHeight) { + return { x: rect.x, y: canvasHeight - (rect.y + rect.h), w: rect.w, h: rect.h }; +} + +export function resetPipPosition() { + pipPosition.set(null); +} + +/** test/debug view */ +export function pipDebug() { + return { target: get(pipTarget), rect: get(pipRect), position: get(pipPosition) }; +} diff --git a/src/lib/geometries.svelte.js b/src/lib/geometries.svelte.js index 1baed21e..4a61c549 100644 --- a/src/lib/geometries.svelte.js +++ b/src/lib/geometries.svelte.js @@ -110,7 +110,8 @@ export function createGeometry(command, uuid) { near: 0.1, far: 1000, aspect: '16:9', - guide: true + guide: true, + pip: true }; // a viewpoint marker is not scenery you light: no shadows, no physics object.userData.shadow = false; diff --git a/tests/e2e/camera-objects.test.cjs b/tests/e2e/camera-objects.test.cjs index 4142c3ac..2f0ccc05 100644 --- a/tests/e2e/camera-objects.test.cjs +++ b/tests/e2e/camera-objects.test.cjs @@ -241,8 +241,12 @@ h.run(async () => { await A.page.waitForTimeout(900); const back = await activeCamera(A.page); h.check(back.ortho === false, 'exiting returns to your own perspective camera'); - const bannerGone = await A.page.evaluate(() => !document.querySelector('.preview-banner')); - h.check(bannerGone, 'the banner goes with it'); + // the banner has an intro/outro fly — poll rather than assume one frame is enough + await h.eventually( + () => A.page.evaluate(() => !document.querySelector('.preview-banner')), + (gone) => gone === true, + 'the banner goes with it' + ); await h.eventually( () => B.page.evaluate( diff --git a/tests/e2e/camera-pip.test.cjs b/tests/e2e/camera-pip.test.cjs new file mode 100644 index 00000000..fa08ae7b --- /dev/null +++ b/tests/e2e/camera-pip.test.cjs @@ -0,0 +1,133 @@ +// 16-Q4: the camera preview WINDOW (picture-in-picture). +// Selecting a camera object shows a small live view bottom-right; it parks clear +// of an open side panel, right-drags to anywhere on screen (clamped), hides while +// that camera fills the viewport as a full preview, and can be switched off per +// camera in Camera properties. +// +// The IMAGE is an inset scissored viewport drawn by the render loop, so the DOM +// part is only chrome — these checks cover the geometry contract (the rect the +// renderer is handed) plus the visibility rules; the picture itself was verified +// visually (a live view through the camera inside the frame). +const h = require('./helpers.cjs'); + +const pip = (page) => page.evaluate(() => window.__stores.cameraPip.pipDebug()); + +const frame = (page) => + page.evaluate(() => { + const el = document.querySelector('.pip'); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }; + }); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + const uuid = await A.page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 2 2 2'); + w.commandsHandler.sceneCommand('/create Camera'); + await new Promise((r) => setTimeout(r, 350)); + let g = null; + w.objectsGroup.subscribe((v) => (g = v))(); + const cam = g.children[g.children.length - 1]; + cam.position.set(-5, 3, 6); + w.objectsGroup.update((v) => v); + return cam.uuid; + }); + + // ---------- selecting a camera shows it ---------- + await A.page.evaluate((u) => window.__stores.objectActions.selectObject(u), uuid); + await A.page.waitForTimeout(600); + let state = await pip(A.page); + let box = await frame(A.page); + h.check(state.target === uuid, `selecting a camera targets the window at it (${state.target === uuid})`); + h.check(!!box, 'the frame is on screen'); + h.check( + state.rect && Math.abs(state.rect.x - box.x) < 2 && Math.abs(state.rect.w - box.w) < 2, + `the published rect matches the frame (${JSON.stringify(state.rect)} vs ${JSON.stringify(box)})` + ); + const aspect = box.w / box.h; + h.check(Math.abs(aspect - 16 / 9) < 0.25, `it uses the camera's framing aspect (${aspect.toFixed(2)})`); + + // bottom-right, and INSIDE the viewport + const viewport = A.page.viewportSize(); + h.check( + box.x + box.w <= viewport.width && box.y + box.h <= viewport.height, + `parked fully on screen (${JSON.stringify(box)} in ${viewport.width}x${viewport.height})` + ); + h.check(box.y > viewport.height / 2, 'parked towards the bottom'); + + // ---------- it steps aside for an open panel ---------- + const withPanel = await A.page.evaluate(async (u) => { + window.__stores.objectActions.selectObject(u, true); // opens Properties + await new Promise((r) => setTimeout(r, 700)); + const el = document.querySelector('.pip'); + const panel = document.querySelector('#drawer-label')?.getBoundingClientRect(); + const r = el?.getBoundingClientRect(); + return r && panel ? { pipRight: Math.round(r.right), panelLeft: Math.round(panel.left) } : null; + }, uuid); + h.check( + withPanel && withPanel.pipRight <= withPanel.panelLeft + 2, + `it stays clear of the open panel (${JSON.stringify(withPanel)})` + ); + + // ---------- right-drag moves it ---------- + box = await frame(A.page); + await A.page.mouse.move(box.x + box.w / 2, box.y + 8); + await A.page.mouse.down({ button: 'right' }); + await A.page.mouse.move(box.x - 200, box.y - 120, { steps: 8 }); + await A.page.mouse.up({ button: 'right' }); + await A.page.waitForTimeout(400); + const moved = await frame(A.page); + h.check(moved && moved.x < box.x - 100, `a right-drag moves the window (${box.x} -> ${moved?.x})`); + state = await pip(A.page); + h.check( + state.rect && Math.abs(state.rect.x - moved.x) < 2, + 'the renderer rect follows the drag' + ); + + // a drag towards the corner clamps instead of leaving the screen + await A.page.mouse.move(moved.x + moved.w / 2, moved.y + 8); + await A.page.mouse.down({ button: 'right' }); + await A.page.mouse.move(-400, -400, { steps: 6 }); + await A.page.mouse.up({ button: 'right' }); + await A.page.waitForTimeout(400); + const clamped = await frame(A.page); + h.check(clamped && clamped.x >= 0 && clamped.y >= 0, `it clamps on screen (${JSON.stringify(clamped)})`); + + // ---------- hidden while the camera fills the viewport ---------- + await A.page.evaluate((u) => window.__stores.cameraPreview.startCameraPreview(u), uuid); + await A.page.waitForTimeout(600); + h.check((await pip(A.page)).target === null, 'no window while you are already inside that camera'); + h.check((await frame(A.page)) === null, 'the frame is gone with it'); + await A.page.evaluate(() => window.__stores.cameraPreview.stopCameraPreview()); + await A.page.waitForTimeout(700); + h.check((await pip(A.page)).target === uuid, 'it comes back when the preview ends'); + + // ---------- per-camera off switch ---------- + await A.page.evaluate((u) => window.__stores.cameraObjects.setCameraFor(u, { pip: false }), uuid); + await A.page.waitForTimeout(500); + h.check((await frame(A.page)) === null, 'turning the camera preview off hides the window'); + h.check((await pip(A.page)).rect === null, 'and stops the renderer drawing it'); + await A.page.evaluate((u) => window.__stores.cameraObjects.setCameraFor(u, { pip: true }), uuid); + await A.page.waitForTimeout(500); + h.check(!!(await frame(A.page)), 'the setting brings it back'); + + // ---------- deselecting hides it ---------- + await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); + await A.page.waitForTimeout(500); + h.check((await frame(A.page)) === null, 'deselecting the camera hides the window'); + + // ---------- the DOM→gl rect flip is right (y measured from the bottom) -------- + const flip = await A.page.evaluate(() => + window.__stores.cameraPip.glRect({ x: 10, y: 20, w: 100, h: 50 }, 500) + ); + h.check( + flip.x === 10 && flip.y === 430 && flip.w === 100 && flip.h === 50, + `glRect flips the origin for WebGL (${JSON.stringify(flip)})` + ); + + await h.finish(browser); +}); From 58a7e97b2f640f9212a12af26c0fe6ec99535ec7 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 4 Aug 2026 16:07:33 +0300 Subject: [PATCH 23/28] [docs] CLAUDE.md: roadmap 16 second drop (delegation trap, PiP, numeric field) --- CLAUDE.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0b3a3ca3..74c9d96e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,7 +250,10 @@ loadable play content. Everything a user does must be visible to connected peers letterbox FramingGuide, Capture (offscreen render at the framing aspect), and replicated `campreview` presence + Join in Users), `gridSettings` (#16-P3 LOCAL grid look: cell size / match-snap-step / major lines / colours / fade / extent / - follow / origin axes, read by extensions/Grid.svelte), `menuFilter` (#16-P1/P2 + follow / origin axes + #16-Q2 follow modes off/lookat/camera, cell-snapped, read + by extensions/Grid.svelte), `cameraPip` (#16-Q4 the camera preview WINDOW: rect + + target stores and the DOM→gl y-flip; CameraPipWindow.svelte is chrome only, the + inset is drawn by Outline), `menuFilter` (#16-P1/P2 the ONE context-menu flatten + ranking, shared by every menu incl. node search), `sceneAssets` (derived Scene manifest: audio/config/textures in use), `avatarModel` (avatar defaults, photo-card rule, per-shape hat anchors), `themes` (data-theme @@ -281,6 +284,10 @@ loadable play content. Everything a user does must be visible to connected peers panels, keys `sculptToolbar`/`meshEditToolbar`), shared `ContextMenu.svelte` (caps to viewport + scrolls vertically when tall, never horizontally; per-submenu flip via left/right/top/bottom — no transform), + `components/ui/DragRow.svelte` (#16-Q3: THE numeric field — drag to scrub, type + with LIVE updates, ↑↓ step one minor unit with Ctrl ×10 / Shift ×100, Esc + reverts; SliderRow's box and every Inspector number use it, and its key/pointer + handlers are DIRECT listeners because panels swallow delegated ones), `components/shared/WindowShell.svelte` (197: reusable window CHROME — collapsible/ resizable/side-switchable primary sidebar + a multi-mode secondary panel that reflows opposite it; snippet slots topbar/primary/main/secondary; chrome-only, @@ -565,6 +572,23 @@ loadable play content. Everything a user does must be visible to connected peers (autoRender off) — its passes target canvas-sized buffers, not the XR framebuffer, so in WebXR it must `renderer.render(scene, camera.current)` directly (composer resumes on desktop). +- **Svelte 5 DELEGATES `onkeydown`/`onpointerdown`/`onclick` attributes** — the + handler only runs once the event reaches the app root, so any ancestor that + stops propagation on the way up silently kills it. Panel widgets are exactly + where this bites: the drawer chrome swallows pointerdown and the flowbite + dialog swallows Escape, so DragRow's 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, 16-Q3). For keys and pointer gestures inside panels, attach + DIRECT listeners via `use:action` + addEventListener. +- A `derived` store that reads anything off `userData` must list `objectsGroup` in + its dependencies — THREE trees aren't reactive, so the post-write poke is the + only signal it gets; and any "is something selected" check reads the SET, never + the sticky `selectedObject` (the camera PiP hit both at once, 16-Q4). +- An INSET viewport (camera PiP) is `setScissorTest(true)` + `setScissor` + + `setViewport` on the SAME renderer after the composer pass — no second WebGL + context (which would duplicate every texture/geometry on the GPU). gl clears + respect the scissor box, so the inset clears only itself; remember gl measures + y from the BOTTOM (`glRect`) and restore the full viewport afterwards. - **Threlte's `camera.current` is a PLAIN PROPERTY** on a CurrentWritable, so reading it inside `$effect` registers NO dependency — the effect runs exactly once. Track `$camera` (the store) when you must react to a camera SWAP. This @@ -778,6 +802,18 @@ override for e2e — never share 5173 (the user's main-checkout server). v2 still pending there). Lane: ../theprototype-lane-ui @ port 5186 (5176 is shadowed by a stale [::1] server — the port-shadow trap; ALWAYS curl a source file and grep your new symbol before trusting a lane server). +- Status (2026-08-04, drop 2): **#16 follow-ups on the SAME PR #86** — [fix] camera + Control (no view jump: OrbitControls is seated behind the camera and the pose + re-synced from the marker, because its constructor already ran one update(); no + more orbit-controls leak: the preview owns `previewOrbit`, a derived `activeOrbit` + drives Scene's suppression + navigation) · [feat] menu (per-level cursor memory, + STICKY search mode, one-time side decision so a growing list keeps the anchor, + revealFilter rows excluded from their own results) · [feat] panel DEEP LINKS + (`openSceneSection` opens+expands+scrolls instead of toggling shut) + grid follow + Off/Look-at/Camera + Scale 0.25 and custom snap steps in the menu + themed physics + checkboxes + Add opens properties · [feat] ONE numeric field (DragRow everywhere) + · [feat] camera PiP window + Capture row. New suites camera-preview-control(9)/ + panel-deeplinks(16)/number-fields(13)/camera-pip(18); 419/62 held. - Status (2026-08-04): **Roadmap #16 (menus, grid & scene cameras) EXECUTED → core PR #86** (branch fix/roadmap16-menus-cameras, six commits, STACKED on #85 → #84 → #82; retarget to release/next as they land; plan + as-built notes in the From d0db189f67c102922a23f982ec4f8d7bd2121668 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 4 Aug 2026 20:35:15 +0300 Subject: [PATCH 24/28] [fix] gizmo drags no longer orbit the view; menu, grid, deep-link and PiP polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/CameraPreview.svelte | 17 ++- src/components/ContextMenu.svelte | 102 +++++++++++---- src/components/Outline.svelte | 13 +- src/components/Scene.svelte | 14 +- src/components/menu/CameraPipWindow.svelte | 7 +- src/components/menu/Inspector.svelte | 10 +- src/components/menu/ViewportMenu.svelte | 12 +- src/components/ui/DragRow.svelte | 5 +- src/components/ui/Section.svelte | 21 ++- src/extensions/Grid.svelte | 14 +- src/lib/cameraPip.js | 7 +- src/lib/cameraPreview.js | 31 +++++ tests/e2e/camera-pip.test.cjs | 47 +++++++ tests/e2e/context-menu-redesign.test.cjs | 56 ++++++++ tests/e2e/gizmo-orbit-leak.test.cjs | 145 +++++++++++++++++++++ tests/e2e/panel-deeplinks.test.cjs | 49 +++++++ 16 files changed, 505 insertions(+), 45 deletions(-) create mode 100644 tests/e2e/gizmo-orbit-leak.test.cjs diff --git a/src/components/CameraPreview.svelte b/src/components/CameraPreview.svelte index b0192b7a..7a6ffaf9 100644 --- a/src/components/CameraPreview.svelte +++ b/src/components/CameraPreview.svelte @@ -14,7 +14,7 @@ // OrbitControls, so Scene's existing per-frame nav call just works) and each // frame writes the pose back onto the marker. - const { size } = useThrelte(); + const { size, camera: activeCamera } = useThrelte(); const object = $derived( $cameraPreview ? ($objectsGroup?.getObjectByProperty('uuid', $cameraPreview.uuid) ?? null) : null @@ -31,6 +31,21 @@ /** @type {any} */ let controlsRef: any = $state(null); + // debug probe for the suites (opt-in, like __outlineDebug) + $effect(() => { + if (typeof window === 'undefined' || !localStorage.getItem('debugStores')) return; + (window as any).__cameraPreviewDebug = () => ({ + preview: $cameraPreview, + hasObject: !!object, + hasSpec: !!spec, + cameraMounted: !!cameraRef, + controlsMounted: !!controlsRef, + cameraParent: cameraRef?.parent?.name || cameraRef?.parent?.type || null, + defaultCamera: (activeCamera as any)?.current?.type ?? null, + defaultIsMine: (activeCamera as any)?.current === cameraRef + }); + }); + // pose sync, both directions const pos = new THREE.Vector3(); const quat = new THREE.Quaternion(); diff --git a/src/components/ContextMenu.svelte b/src/components/ContextMenu.svelte index 7f71fb43..8dcd57a2 100644 --- a/src/components/ContextMenu.svelte +++ b/src/components/ContextMenu.svelte @@ -58,6 +58,17 @@ let highlight = -1; /** how many rows the empty-query browse list shows (it scrolls) */ const BROWSE_CAP = 200; + /** 16-Q5: default height of the SEARCH list. A menu that unfolds down the whole + * screen is unusable, so the list gets a sensible box you can resize from the + * corner grip. */ + const SEARCH_HEIGHT = 360; + const MIN_LIST_HEIGHT = 140; + /** the top edge chosen when the menu OPENED — searching keeps it */ + let placedTop: number | null = null; + /** user height for the search list, dragged from the corner grip */ + let searchHeight: number | null = null; + /** lets the grip re-run the placement after changing `searchHeight` */ + let repositionMenu: () => void = () => {}; let inputEl: HTMLInputElement | null = null; // the header strip (what this menu acts on) leads the menu, ABOVE the filter @@ -219,37 +230,45 @@ function place(node: HTMLElement) { let lastW = -1; let lastH = -1; - /** which side of the click we opened toward — decided ONCE (Q1: a growing - * list used to re-decide and teleport the menu to the top of the screen) */ - let flipUp: boolean | null = null; const reposition = () => { const vw = window.innerWidth; const vh = window.innerHeight; - // measure unconstrained first so the side decision sees the natural height - node.style.maxHeight = vh - 8 + 'px'; + // measure the NATURAL height first (uncapped) — every decision needs it + node.style.maxHeight = 'none'; const w = node.offsetWidth; - const h = node.offsetHeight; + const natural = node.offsetHeight; lastW = w; - lastH = h; + node.style.right = 'auto'; + node.style.bottom = 'auto'; let left = x > vw - w - 4 ? x - w : x; // near the right edge -> open leftward - left = Math.max(4, Math.min(left, vw - w - 4)); + node.style.left = Math.max(4, Math.min(left, vw - w - 4)) + 'px'; - const below = vh - y - 8; // room under the click - const above = y - 8; // room over it - // first placement picks the roomier side; later ones KEEP it, so filtering - // (which changes the height a lot) never re-anchors the menu - if (flipUp === null) flipUp = h > below && above > below; - // the anchor edge stays ON the click: top edge downward, bottom edge upward. - // Anything that doesn't fit gets capped and scrolls (.ctx-scroll). - const room = Math.max(120, flipUp ? above : below); - const height = Math.min(h, room); - node.style.maxHeight = room + 'px'; - const top = flipUp ? Math.max(4, y - height) : Math.min(y, Math.max(4, vh - height - 4)); - node.style.left = left + 'px'; + if (listMode) { + // 16-Q5: SEARCHING must not move the menu. Keep the top it opened with + // and give the list a sensible height (resizable from the corner grip) + // instead of letting it unfold down the whole screen. + const top = placedTop ?? Math.max(4, Math.min(y, vh - Math.min(natural, SEARCH_HEIGHT) - 4)); + const room = Math.max(MIN_LIST_HEIGHT, vh - top - 8); + node.style.top = top + 'px'; + node.style.maxHeight = Math.min(searchHeight ?? SEARCH_HEIGHT, room) + 'px'; + lastH = node.offsetHeight; + return; + } + + // Opening: sit AT the cursor and prefer downward. Not enough room below? + // shift the whole menu UP just far enough that its bottom stays inside, + // keeping the top as close to the cursor as possible — no flipping, so the + // menu never jumps to the other side of the pointer. A scrollbar appears + // only when the content is taller than the window itself. + const maxH = vh - 8; + let top = y; + if (natural > vh - y - 4) top = Math.max(4, vh - natural - 4); node.style.top = top + 'px'; - node.style.right = 'auto'; - node.style.bottom = 'auto'; + node.style.maxHeight = maxH + 'px'; + placedTop = top; + lastH = node.offsetHeight; }; + repositionMenu = reposition; reposition(); requestAnimationFrame(reposition); // 16-P1: the menu RESIZES while it is open now (the filter row reveals, matches @@ -339,6 +358,28 @@ {query ? 'No matching action' : 'Nothing to search here'} {/if} + + +
{ + const startY = event.clientY; + const startH = (event.currentTarget as HTMLElement).closest('[role="menu"]')?.clientHeight ?? SEARCH_HEIGHT; + const move = (moveEvent: PointerEvent) => { + searchHeight = Math.max(MIN_LIST_HEIGHT, startH + (moveEvent.clientY - startY)); + repositionMenu(); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + event.preventDefault(); + event.stopPropagation(); + }} + >
{:else} - import { objectsGroup, selectedObjects, lockedObjects, viewMode } from '../stores/sceneStore.js'; + import { objectsGroup, selectedObjects, lockedObjects, viewMode, TControls } from '../stores/sceneStore.js'; import { showToast } from '../stores/appStore.js'; import { shadowQuality } from '$lib/lightParams'; import { useTask, useThrelte } from '@threlte/core'; @@ -145,12 +145,20 @@ if (!object) return; pipCamera = buildCamera(object, rect.w / rect.h, pipCamera); // looking through a camera means standing inside its own body — and its - // frustum lines would wrap the lens + // frustum lines would wrap the lens. The transform GIZMO goes too (16-Q5): + // attached to this very camera it sat right on the lens and rendered as a + // giant coloured blob across the preview. const markerWasVisible = object.visible; const frustums = scene.getObjectByName('camera-frustums'); const frustumsWereVisible = frustums?.visible ?? false; + // three r16x+ keeps the gizmo's VISUALS in a separate helper object (the controls + // themselves render nothing), so hiding the controls left an arrow poking into + // the frame — hide whatever `getHelper()` returns + const gizmo = (($TControls as any)?.getHelper?.() ?? $TControls) as any; + const gizmoWasVisible = gizmo?.visible ?? false; object.visible = false; if (frustums) frustums.visible = false; + if (gizmo) gizmo.visible = false; const box = glRect(rect, renderer.domElement.clientHeight || $size.height); renderer.setScissorTest(true); renderer.setScissor(box.x, box.y, box.w, box.h); @@ -160,6 +168,7 @@ renderer.setViewport(0, 0, $size.width, $size.height); object.visible = markerWasVisible; if (frustums) frustums.visible = frustumsWereVisible; + if (gizmo) gizmo.visible = gizmoWasVisible; } // 15-K: collect every mesh under a uuid — OutlineEffect only renders MESHES // in its selection, so adding a Group outlined nothing useful, and adding a diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 05fb07b0..2f96b268 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -272,6 +272,13 @@ $: if ($TControls && $TControls !== hookedControls) { hookedControls = $TControls; $TControls.addEventListener('dragging-changed', (event) => { + // 16-Q5: suppress orbiting for the whole drag OURSELVES — first thing, for + // every kind of gizmo target. threlte's TransformControls disables whatever + // controls sit in ITS OWN context slot, and after a camera preview that slot + // can point at an instance that no longer drives the view, so dragging an + // object also orbited the camera ("as if I would move around with mouse"). + // Writing through `activeOrbit` is instance-proof. + if ($activeOrbit) $activeOrbit.enabled = !event.value; const object = hookedControls.object; if (!object) return; // vertex handles record their own history entries @@ -967,7 +974,12 @@ - + {#if !$specatorMode && !$cameraPreview} {/if} diff --git a/src/components/menu/CameraPipWindow.svelte b/src/components/menu/CameraPipWindow.svelte index f25a634a..f61f2990 100644 --- a/src/components/menu/CameraPipWindow.svelte +++ b/src/components/menu/CameraPipWindow.svelte @@ -57,7 +57,12 @@ /** @param {PointerEvent} event */ function onPointerDown(event) { const touch = event.pointerType !== 'mouse'; - if (!touch && event.button !== 2) return; // mouse: RIGHT button drags + // 16-Q5: the title BAR drags with the left button too (that's where a hand + // goes); the body keeps right-drag so a left-click there can't move the window + // by accident. Touch holds anywhere. + const onBar = !!(/** @type {any} */ (event.target)?.closest?.('.pip-bar')); + const leftOnBar = event.button === 0 && onBar && !/** @type {any} */ (event.target)?.closest?.('.pip-btn'); + if (!touch && event.button !== 2 && !leftOnBar) return; startX = event.clientX; startY = event.clientY; origin = { ...position }; diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index 69b9cad8..2bdbcfe3 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -1002,7 +1002,7 @@ Reset feel - +
{#if menu} - (menu = null)} /> + (menu = null)} /> {/if} diff --git a/src/components/menu/ViewportMenu.svelte b/src/components/menu/ViewportMenu.svelte index 8fedbd41..33b07b5d 100644 --- a/src/components/menu/ViewportMenu.svelte +++ b/src/components/menu/ViewportMenu.svelte @@ -254,5 +254,5 @@ {#if menu} - + {/if} diff --git a/src/components/ui/DragRow.svelte b/src/components/ui/DragRow.svelte index df39e282..b49a19d2 100644 --- a/src/components/ui/DragRow.svelte +++ b/src/components/ui/DragRow.svelte @@ -140,6 +140,10 @@ startValue = Number(value) || 0; startX = event.clientX; scrubbing = false; + // 16-Q6: block the default so a scrub never places or drags the CARET (you could + // watch it skate left and right through the digits). Focus is granted on release + // instead — see onPointerUp — which is also what makes click-to-type work. + if (!focused) event.preventDefault(); } /** @param {any} event */ @@ -171,8 +175,8 @@ return; } scrubbing = false; - // a click that did not scrub = "let me type it" (the label counts too) - if (event.target !== inputEl) inputEl?.focus(); + // a click that did not scrub = "let me type it" (anywhere on the field) + inputEl?.focus(); } @@ -219,6 +223,12 @@ } .dn-wrap.dn-scrub { border-color: var(--color-primary-400, #60a5fa); + /* 16-Q6: a scrub must not smear a selection or show a caret */ + user-select: none; + } + .dn-wrap.dn-scrub .dn-input { + user-select: none; + caret-color: transparent; } .dn-label { flex: 0 0 auto; diff --git a/src/components/ui/Section.svelte b/src/components/ui/Section.svelte index 81160066..c7479710 100644 --- a/src/components/ui/Section.svelte +++ b/src/components/ui/Section.svelte @@ -49,21 +49,45 @@ LS?.setItem('inspector:sec:' + label, 'open'); } catch {} const node = root; - requestAnimationFrame(() => { - // 16-Q5: scroll the CONTAINER, offset by the sticky header (title + property - // filter) — plain scrollIntoView tucked the section label underneath it, so - // you landed on the section's first row with no idea where you were. - const target = (anchor ? node?.querySelector(`[data-anchor="${anchor}"]`) : null) ?? node; - const scroller = node?.closest('.overflow-y-auto, .overflow-auto') ?? null; - const sticky = scroller?.querySelector('#drawer-label'); - const pad = (sticky?.getBoundingClientRect().height ?? 0) + 10; - if (scroller && target) { - const delta = target.getBoundingClientRect().top - scroller.getBoundingClientRect().top; - scroller.scrollTo({ top: Math.max(0, scroller.scrollTop + delta - pad), behavior: 'smooth' }); - } else { - target?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + // 16-Q6: measure → scroll → re-measure → correct. The old single-shot version + // could fire before the just-expanded content had laid out, and a `smooth` + // scroll could be cancelled by that very reflow — so the label sometimes ended + // up under the sticky header (or nowhere). The scroller is found by real + // SCROLLABILITY, not class names. + const findScroller = () => { + let el = node?.parentElement; + while (el) { + const overflow = getComputedStyle(el).overflowY; + if ((overflow === 'auto' || overflow === 'scroll') && el.scrollHeight > el.clientHeight + 1) return el; + el = el.parentElement; } - }); + return null; + }; + /** the label to land on: a named sub-heading, else the section itself */ + const findTarget = () => (anchor ? node?.querySelector(`[data-anchor="${anchor}"]`) : null) ?? node; + + let attempts = 0; + const settle = () => { + const scroller = findScroller(); + const target = findTarget(); + if (!scroller || !target) { + // the section (or its anchor) may still be rendering after expanding + if (attempts++ < 12) requestAnimationFrame(settle); + else target?.scrollIntoView({ block: 'start' }); + return; + } + // the sticky title + property filter sit ON TOP of the scroll area + const sticky = scroller.querySelector('#drawer-label'); + const pad = (sticky?.getBoundingClientRect().height ?? 0) + 8; + const delta = target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - pad; + if (Math.abs(delta) > 2) { + // instant, not smooth: a reflow mid-animation used to cancel it + scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); + } + // verify once more next frame — expanding a section changes heights under us + if (attempts++ < 6) requestAnimationFrame(settle); + }; + requestAnimationFrame(settle); inspectorScrollTo.set(null); }); diff --git a/src/lib/cameraPreview.js b/src/lib/cameraPreview.js index eb19586c..625aa29f 100644 --- a/src/lib/cameraPreview.js +++ b/src/lib/cameraPreview.js @@ -51,6 +51,33 @@ export function releasePreviewOrbit() { previewOrbit.set(null); } +/** the editor's orbit TARGET when the preview took over, and the instance we + * disposed doing so — the controls REMOUNT on exit with a default target + * (0, 1.5, 0), which threw your look-at point back to the world origin (16-Q6) */ +/** @type {any} */ let savedTarget = null; +/** @type {any} */ let staleControls = null; + +/** Put the look-at point back once the editor's controls have REMOUNTED. */ +function restoreEditorTarget() { + const target = savedTarget; + savedTarget = null; + if (!target) return; + let tries = 0; + const tick = () => { + const controls = /** @type {any} */ (get(orbitControls)); + // wait for the FRESH instance: the store still holds the disposed one until + // Scene remounts + if (controls?.target && controls !== staleControls) { + controls.target.copy(target); + controls.update?.(); + staleControls = null; + return; + } + if (tries++ < 60) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} + /** three's dispose() only detaches listeners, so a double call is harmless * @param {any} controls */ function disposeControls(controls) { @@ -106,6 +133,12 @@ export function startCameraPreview(uuid) { // switching straight from another preview: restore that marker first const previous = get(cameraPreview); if (previous && previous.uuid !== uuid) setMarkerHidden(findCameraObject(previous.uuid), false); + // 16-Q6: remember WHERE YOU WERE LOOKING. These controls are about to unmount and + // the pair that mounts on exit starts with the default target, which recentred the + // view on the world origin. + const editor = /** @type {any} */ (get(orbitControls)); + savedTarget = editor?.target?.clone?.() ?? null; + staleControls = editor ?? null; // THE fix for "moving an object with the gizmo also rotates my view" (16-Q5). // Scene gates the editor's OrbitControls on this store, so they are about to // UNMOUNT — and threlte does not dispose them, so they keep their DOM listeners @@ -129,6 +162,7 @@ export function stopCameraPreview() { cameraPreview.set(null); frustumSuppressed.set(null); broadcast(null); + restoreEditorTarget(); // 16-Q6: your look-at point survives the round trip } /** Take the controls (or give them back). */ diff --git a/tests/e2e/camera-pip.test.cjs b/tests/e2e/camera-pip.test.cjs index c21da089..5e2e5d71 100644 --- a/tests/e2e/camera-pip.test.cjs +++ b/tests/e2e/camera-pip.test.cjs @@ -176,5 +176,24 @@ h.run(async () => { `the gizmo is restored after each inset draw (visible ${gizmoHidden.visibleAfterFrames})` ); + + // ---------- 16-Q6: the frame sits BELOW the UI chrome ------------------------ + await A.page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), uuid); + await A.page.waitForTimeout(600); + const layering = await A.page.evaluate(() => { + const pip = document.querySelector('.pip'); + if (!pip) return null; + const z = Number(getComputedStyle(pip).zIndex); + // the panel that must cover it + const panel = document.querySelector('#drawer-label')?.closest('div[class*="z-"]'); + const panelZ = panel ? Number(getComputedStyle(panel).zIndex) : null; + return { z, panelZ }; + }); + h.check(layering !== null, 'the frame is up'); + h.check( + layering.z < 30 && (layering.panelZ === null || layering.z < layering.panelZ), + `it sits below the panel/drawer tier (pip z ${layering.z} vs panel ${layering.panelZ})` + ); + await h.finish(browser); }); diff --git a/tests/e2e/camera-preview-control.test.cjs b/tests/e2e/camera-preview-control.test.cjs index 06497cd0..4234eba6 100644 --- a/tests/e2e/camera-preview-control.test.cjs +++ b/tests/e2e/camera-preview-control.test.cjs @@ -150,5 +150,41 @@ h.run(async () => { `a second preview+control cycle leaves the controls clean (${JSON.stringify(state)})` ); + + // ---------- 16-Q6: exiting must not recentre your look-at on the origin -------- + const target = () => + A.page.evaluate( + () => + new Promise((r) => + window.__stores.orbitControls.subscribe((c) => + r(c?.target ? c.target.toArray().map((n) => Math.round(n * 100) / 100) : null) + )() + ) + ); + // put the look-at somewhere distinctive + await A.page.evaluate(() => { + let c = null; + window.__stores.orbitControls.subscribe((v) => (c = v))(); + c.target.set(6, 2, -3); + c.update(); + }); + await A.page.waitForTimeout(300); + const before = await target(); + await A.page.evaluate((u) => window.__stores.cameraPreview.startCameraPreview(u), uuid); + await A.page.waitForTimeout(700); + await A.page.evaluate(() => window.__stores.cameraPreview.toggleCameraControl()); + await A.page.waitForTimeout(700); + await A.page.evaluate(() => window.__stores.cameraPreview.stopCameraPreview()); + await A.page.waitForTimeout(1200); + const restored = await target(); + h.check( + restored && before && Math.hypot(restored[0] - before[0], restored[1] - before[1], restored[2] - before[2]) < 0.05, + `the look-at point survives preview + Control + exit (${JSON.stringify(before)} -> ${JSON.stringify(restored)})` + ); + h.check( + restored && Math.hypot(restored[0], restored[2]) > 1, + 'it did not snap back to the world origin' + ); + await h.finish(browser); }); diff --git a/tests/e2e/context-menu-redesign.test.cjs b/tests/e2e/context-menu-redesign.test.cjs index 424bbbe3..3092d462 100644 --- a/tests/e2e/context-menu-redesign.test.cjs +++ b/tests/e2e/context-menu-redesign.test.cjs @@ -330,5 +330,53 @@ h.run(async () => { ); h.check(placement.grip, 'a resize grip appears while searching'); + + // ---------- 16-Q6: a resized search list is REMEMBERED, per menu kind --------- + const resize = async () => { + await A.page.evaluate(() => window.__stores.viewportMenu.set(null)); + await A.page.waitForTimeout(150); + await A.page.evaluate(() => window.__stores.viewportMenu.set({ x: 240, y: 120, point: [0, 0, 0] })); + await A.page.waitForTimeout(350); + await A.page.evaluate(async () => { + const input = document.querySelector('.ctx-filter-input'); + input.value = 'e'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 300)); + }); + const grip = await A.page.locator('.ctx-grip').boundingBox(); + await A.page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); + await A.page.mouse.down(); + await A.page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2 - 120, { steps: 8 }); + await A.page.mouse.up(); + await A.page.waitForTimeout(300); + return A.page.evaluate(() => Math.round(document.querySelector('[role="menu"]').getBoundingClientRect().height)); + }; + const shrunk = await resize(); + const stored = await A.page.evaluate(() => localStorage.getItem('ctx:searchHeight:viewport')); + h.check(stored !== null, `the dragged height is persisted (${stored})`); + await A.page.evaluate(() => window.__stores.viewportMenu.set(null)); + await A.page.waitForTimeout(200); + + // reopen + search again: the list comes back at the size we left it + await A.page.evaluate(() => window.__stores.viewportMenu.set({ x: 240, y: 120, point: [0, 0, 0] })); + await A.page.waitForTimeout(350); + await A.page.evaluate(async () => { + const input = document.querySelector('.ctx-filter-input'); + input.value = 'e'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 300)); + }); + const reopened = await A.page.evaluate(() => + Math.round(document.querySelector('[role="menu"]').getBoundingClientRect().height) + ); + h.check( + Math.abs(reopened - shrunk) <= 6, + `reopening keeps that height (${shrunk}px -> ${reopened}px)` + ); + // the node editor keeps its OWN size + const perKind = await A.page.evaluate(() => localStorage.getItem('ctx:searchHeight:nodes')); + h.check(perKind === null, 'the node editor has its own key, untouched by the viewport menu'); + await A.page.evaluate(() => window.__stores.viewportMenu.set(null)); + await h.finish(browser); }); diff --git a/tests/e2e/number-fields.test.cjs b/tests/e2e/number-fields.test.cjs index 17f97f41..247b834d 100644 --- a/tests/e2e/number-fields.test.cjs +++ b/tests/e2e/number-fields.test.cjs @@ -135,5 +135,47 @@ h.run(async () => { `it steps with the same rules (${JSON.stringify(sliderBox)})` ); + + // ---------- 16-Q6: a scrub must not put a caret in the field ----------------- + await A.page.evaluate(async () => { + const w = window.__stores; + let g = null; + w.objectsGroup.subscribe((v) => (g = v))(); + w.objectActions.selectObject(g.children[0].uuid, true); + localStorage.setItem("inspector:sec:Transform", "open"); + await new Promise((r) => setTimeout(r, 600)); + }); + await A.page.waitForTimeout(700); + const caretField = A.page.locator(".dn-wrap").first(); + // the panel is still scrolled from the deep-link section above + await caretField.scrollIntoViewIfNeeded(); + await A.page.waitForTimeout(300); + const caret = await A.page.evaluate(() => document.activeElement?.className ?? ''); + await A.page.locator('#drawer-label').click({ position: { x: 5, y: 5 } }).catch(() => {}); + await A.page.waitForTimeout(200); + const fieldBox = await caretField.boundingBox(); + await A.page.mouse.move(fieldBox.x + fieldBox.width / 2, fieldBox.y + fieldBox.height / 2); + await A.page.mouse.down(); + await A.page.mouse.move(fieldBox.x + fieldBox.width / 2 + 30, fieldBox.y + fieldBox.height / 2, { steps: 5 }); + const duringDrag = await A.page.evaluate(() => ({ + focused: document.activeElement === document.querySelector('.dn-wrap .dn-input'), + selection: (document.getSelection?.()?.toString() ?? '').length, + scrubbing: !!document.querySelector('.dn-wrap.dn-scrub') + })); + await A.page.mouse.up(); + await A.page.waitForTimeout(200); + h.check(duringDrag.scrubbing, 'the field reports a scrub in progress'); + h.check(!duringDrag.focused, 'no caret: dragging never focuses the input'); + h.check(duringDrag.selection === 0, 'and never smears a selection'); + + // a plain CLICK still hands over the caret for typing + await A.page.mouse.click(fieldBox.x + fieldBox.width / 2, fieldBox.y + fieldBox.height / 2); + await A.page.waitForTimeout(250); + const afterClick = await A.page.evaluate( + () => document.activeElement === document.querySelector('.dn-wrap .dn-input') + ); + h.check(afterClick, 'a click focuses it for typing'); + void caret; + await h.finish(browser); }); diff --git a/tests/e2e/panel-deeplinks.test.cjs b/tests/e2e/panel-deeplinks.test.cjs index f5241f1c..554484c9 100644 --- a/tests/e2e/panel-deeplinks.test.cjs +++ b/tests/e2e/panel-deeplinks.test.cjs @@ -216,5 +216,52 @@ h.run(async () => { }); h.check(!clean.ugly && clean.hint.startsWith('0.8'), `the menu hint shows 0.8, not float noise (${clean.hint})`); + + // ---------- 16-Q6: land JUST below the sticky header, even when the panel was + // already open and scrolled somewhere else (the earlier check only asked for + // "somewhere below", which passed without any scrolling at all) -------------- + const landing = async (label, parent, child) => { + await A.page.evaluate(() => { + // every section expanded => a long panel that really has to scroll + for (const k of ['Environment', 'Music', 'View', 'Camera', 'Grid', 'Snapping', 'Physics', 'Background', 'Fog']) + localStorage.setItem('inspector:sec:' + k, 'open'); + window.__stores.showSidebar('scene'); + }); + await A.page.waitForTimeout(700); + // scroll to the very bottom first + await A.page.evaluate(() => { + const panels = [...document.querySelectorAll('div')].filter((d) => { + const o = getComputedStyle(d).overflowY; + return (o === 'auto' || o === 'scroll') && d.scrollHeight > d.clientHeight + 1 && d.querySelector('#drawer-label'); + }); + const panel = panels[0]; + if (panel) panel.scrollTop = panel.scrollHeight; + }); + await A.page.waitForTimeout(300); + await pick(parent, child); + await A.page.waitForTimeout(900); + return A.page.evaluate((wanted) => { + const el = [...document.querySelectorAll('.ui-section-label')].find((n) => + n.textContent?.trim().toLowerCase().startsWith(wanted.toLowerCase()) + ); + const sticky = document.querySelector('#drawer-label')?.getBoundingClientRect(); + if (!el || !sticky) return { found: false }; + const r = el.getBoundingClientRect(); + return { found: true, gap: Math.round(r.top - sticky.bottom) }; + }, label); + }; + + for (const [label, parent, child] of [ + ['Snapping', 'Snapping', 'More snapping settings'], + ['Grid', 'View', 'Grid & axes settings'], + ['Saved views', 'Camera bookmarks', 'Manage saved views'] + ]) { + const spot = await landing(label, parent, child); + h.check( + spot.found && spot.gap >= -2 && spot.gap <= 40, + `"${child}…" parks ${label} just under the filter header (gap ${spot.gap}px)` + ); + } + await h.finish(browser); }); From 7db1606f80de2624ee8c591dd4b1ce7dbedd4392 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 5 Aug 2026 05:26:52 +0300 Subject: [PATCH 27/28] [docs] CLAUDE.md: vacuous-assertion + remount-defaults gotchas (16-Q6) --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3b61d28b..d815a27b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -587,6 +587,16 @@ loadable play content. Everything a user does must be visible to connected peers OrbitControls — dispose() it, or it goes on steering whatever camera threlte points it at. And its gizmo VISUALS live in a separate object (`getHelper()`), so `controls.visible = false` hides nothing. +- **A check that cannot fail is not a check** (16-Q6): the first deep-link + assertion asked "is the section label somewhere below the sticky header" — true + whenever no scrolling happens at all, so it passed while the feature was broken + for the user. Assertions about POSITION need a tight band and a starting state + that forces the behaviour (expand every section, scroll to the bottom first). +- **A threlte component that REMOUNTS comes back with its prop defaults** — the + editor `` unmounts while a camera preview owns the + view, so exiting threw the look-at point back to the origin. Snapshot such state + at handover and copy it onto the FRESH instance (the store still holds the old + one for a beat, so wait for a different object). - **Mid-session HMR churn makes e2e runs LIE** (bit hard in 16-Q5): a suite that loads the page while vite is still re-transforming just-edited modules sees half-mounted components — three runs "proved" a working feature broken. Let the From 7b340e0c1ad37d36e03bb6d78343b15a61be0153 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 5 Aug 2026 12:13:08 +0300 Subject: [PATCH 28/28] [docs] skills + CLAUDE.md: roadmap 16 learnings 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) --- .claude/skills/e2e-verify/SKILL.md | 79 ++++++++++++++++++++++++++-- .claude/skills/peer-feature/SKILL.md | 51 +++++++++++++++++- CLAUDE.md | 21 +++++++- 3 files changed, 143 insertions(+), 8 deletions(-) diff --git a/.claude/skills/e2e-verify/SKILL.md b/.claude/skills/e2e-verify/SKILL.md index 6242e91f..2c786461 100644 --- a/.claude/skills/e2e-verify/SKILL.md +++ b/.claude/skills/e2e-verify/SKILL.md @@ -24,7 +24,30 @@ peer id), `connect(from, to, settleMs=9000)`, `check(ok, label)`, screen pixel for real clicks), `finish(browser)` (exit code), `run(body)`. Rules: never run suites in parallel AGAINST THE SAME dev server, never edit sources -while one runs (HMR reloads the pages mid-test). +while one runs (HMR reloads the pages mid-test — see "HMR churn makes runs LIE"). + +## Assertion discipline (a check that cannot fail is not a check) + +The expensive failures in #16 were not broken code — they were assertions that +passed while the user watched the feature misbehave: + +- **Position/layout asserts need a TIGHT BAND and a forcing start state.** "the + section label is somewhere below the sticky header" is true when NO scrolling + happened at all (short panel = most sections collapsed), so a deep-link check + green-lit a link that never scrolled. The fix: expand every section, scroll the + panel to the BOTTOM first, then demand `0 <= gap <= 40px` (panel-deeplinks). +- **Isolate a REGRESSION with an A/B, not an absolute.** "dragging the gizmo must + not rotate the view" can pass vacuously (the drag missed the gizmo) or fail + innocently (left-drag on empty space orbits BY DESIGN). Measure the same gesture + before and after the suspect sequence and compare — plus assert the gesture did + its job (the object moved), so a no-op can never look like a pass + (gizmo-orbit-leak). +- **Match the metric to the gesture**: in OrbitControls LEFT-drag rotates and + RIGHT-drag PANS — a right-drag "orbit works" check that compares quaternions + reads 0.0000 forever. Compare `camera.position` for pans, `quaternion` for + rotation. +- When a check reports success but the user reports failure, re-read the check + before re-reading the code: ask what state would make it fail. **Parallel lanes (multi-session work, 2026-07-21):** each session works in its own `git worktree` (e.g. `../theprototype-lane-flow`) with its OWN dev server on its own @@ -67,7 +90,9 @@ palette, viewModeCtl, inputRuntime, shortcutsRegistry, themes, vrRadialMenu, vrPalette, vrWindowPoses, vrKeyboard, faceEdit, avatarModel, explorer, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets, THREE, GLTFExporterModule, snapping, flowSockets, networkQuality, packs, customNodes, -nodesHandler, nodeCatalog, objectMenu, flowGraphsCtl, objectFlow, vrSleeve` (+ from the +nodesHandler, nodeCatalog, objectMenu, flowGraphsCtl, objectFlow, vrSleeve, +gridSettings, cameraBookmarks, cameraObjects, cameraHelpers, cameraPreview, +cameraPip, addObjects` (+ from the flowStore spread: `flowGraphs, activeGraphId, setActiveGraph, allNodes, allEdges, findNodeAnyGraph, SCENE_GRAPH`; `moduleSDK.pointerRayNow()` = the api.pointerRay internals, `moduleSDK.applyModuleMessage(msg)` = simulate a PEER's module message @@ -100,6 +125,29 @@ const value = await page.evaluate(() => modules manager via `#open-modules-manager` (drawer: `closeMenu.set(false)` first) or `modulesOpen.set(true)`; module cards `#module-card-`; draw `#draw-toolbar`; dungeon `#dungeon-panel`; script editor close `#script-panel-close`. +- **Real-mouse GIZMO drags**: never guess a pixel offset from the object — find the + actual picker and project it. `const helper = controls.getHelper?.() ?? controls;` + then `helper.traverse(n => { if (n.isMesh && n.name === 'X') pick = n })`, + `pick.getWorldPosition(v).project(cam)` → screen px (gizmo-orbit-leak). three keeps + the gizmo VISUALS in that helper object, so `controls.visible = false` hides + nothing — hide the helper. +- **Panels scroll**: a field can be off-screen (`y: -664`) after an earlier + deep-link/scroll in the same suite — `await locator.scrollIntoViewIfNeeded()` + before `boundingBox()`/mouse work, or the events land nowhere and the failure + looks like broken behaviour. +- Inspector section HEADERS are `