From 616d6d2e7fa7edf72d2ea772d277747c18bc66a1 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:32:25 +0300 Subject: [PATCH 1/6] =?UTF-8?q?[fix]=2030b=20P0:=20the=20one-eye=20light?= =?UTF-8?q?=20helper=20=E2=80=94=20helpers=20leave=20three's=20XR=20eye=20?= =?UTF-8?q?layers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cause: helpers lived on render layer 1, and three's WebXRManager renders each eye through a sub-camera whose mask it rewrites every frame as (camera.mask | 0b110) & ~0b100 (left) and & ~0b010 (right) — layer 1 IS the left eye. So a light helper was drawn by the left eye whatever the editor camera enabled, and never by the right: the "light source helper with one eye" in Towers, Stars Room and Football on the Quest. - helperLayer: HELPER_LAYER 1 -> 30 (3..30 are only ever inherited by the eye cameras; 31 is overloadGuard's REDUCED_LAYER, postprocessing Selection ids count up from 2). helpersHiddenFor(locked, mode, debug) is the one predicate: Play OR Interact hide helpers (unless the debug toggle); editorHelpersShown is the same answer as a store. The editor camera and the camera-marker hop now follow editorMode as well as isLocked. - helpers-in-play reads the layer from the app instead of assuming 1. - New suite vr-helper-eyes (13): asserts three's installed WebXRManager still reserves 1/2 for the eyes, then computes both eye masks with that formula — Edit: both eyes draw the helper and its proxy; VR Interact: neither; desktop Interact and Play: none. - Counterfactual: HELPER_LAYER back to 1 -> vr-helper-eyes red ("the helper layer is not an XR eye layer", "Edit: BOTH eyes draw the light helper" L true R false). - svelte-check 335/47 (unchanged list); vitest 222 green; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/cameraHelpers.js | 3 +- src/lib/helperLayer.js | 41 +++++++++-- tests/e2e/helpers-in-play.test.cjs | 33 +++++---- tests/e2e/vr-helper-eyes.test.cjs | 112 +++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/vr-helper-eyes.test.cjs diff --git a/src/lib/cameraHelpers.js b/src/lib/cameraHelpers.js index ec90b20a..fe2a2d0e 100644 --- a/src/lib/cameraHelpers.js +++ b/src/lib/cameraHelpers.js @@ -7,7 +7,7 @@ import { wireframeActive } from './viewMode'; // 24-E2: frustums live on the helper layer; MARKERS hop onto it while hidden (Play // without the debug toggle, or a camera preview) — see helperLayer.js for the rule import { markHelper, setMarkersHidden, helpersHidden, helpersInPlay } from './helperLayer'; -import { isLocked } from '../stores/sceneStore'; +import { isLocked, editorMode } from '../stores/sceneStore'; import { safeStorage } from './safeStorage'; // 16-P5: frustum visualization for camera OBJECTS — the colliderHelpers pattern. @@ -175,6 +175,7 @@ export function startCameraHelpers() { // 24-E2: the marker hop follows Play, the debug toggle and the preview if (!started && typeof window !== 'undefined') { isLocked.subscribe(applyMarkerLayers); + editorMode.subscribe(applyMarkerLayers); // 30b: Interact hides markers like Play helpersInPlay.subscribe(applyMarkerLayers); frustumSuppressed.subscribe(applyMarkerLayers); } diff --git a/src/lib/helperLayer.js b/src/lib/helperLayer.js index 1a5c55d2..3123ab54 100644 --- a/src/lib/helperLayer.js +++ b/src/lib/helperLayer.js @@ -1,6 +1,6 @@ // 24-E2: THE HELPER LAYER — "the editor sees it, the game does not". Light helpers and // their pick proxies, camera frustums and (while hidden) camera MARKERS live on render -// layer 1; the editor camera enables it outside Play (and in Play with the debug +// layer HELPER_LAYER (30, see below); the editor camera enables it outside Play (and in Play with the debug // toggle), and every camera that renders FOR someone — the play camera, a camera-object // preview, PiP, thumbnails, captureThroughCamera — is a fresh THREE camera on layer 0 // only, so it never sees them. @@ -20,12 +20,26 @@ // Bits are flipped with enable/disable, never `set`: the outline passes mark a // selected object with a layer bit of their own, and `set` would wipe it. // +// +// 30b P0 — THE LAYER NUMBER IS NOT FREE. three's WebXRManager renders each eye through +// its own sub-camera and RESERVES layers 1 and 2 for them: every frame it writes +// `cameraL.mask = (camera.mask | 0b110) & ~0b100` and `cameraR.mask = (camera.mask | +// 0b110) & ~0b010` (WebXRManager.updateCamera, "1 = left, 2 = right"). So on layer 1 a +// helper was ALWAYS drawn by the left eye — whatever the editor camera enabled — and never +// by the right one: the light-source helper a Quest user saw "with one eye" in Towers, +// Stars Room and Football. Any number the XR eye cameras only ever INHERIT (3..30; 31 is +// overloadGuard's REDUCED_LAYER, and postprocessing's Selection ids count up from 2) is +// correct; 30 keeps well clear of both. +// +// 30b P0/P1 — Interact hides helpers too: Edit is the only mode that draws the editor's +// scaffolding, in both eyes; Interact (desktop or VR) and Play draw none of it. +// // Imports sceneStore only (the lightHelpers/cameraHelpers family), no THREE. -import { get, writable } from 'svelte/store'; -import { isLocked, editorCam, globalCamera, objectsGroup } from '../stores/sceneStore'; +import { derived, get, writable } from 'svelte/store'; +import { isLocked, editorCam, globalCamera, objectsGroup, editorMode } from '../stores/sceneStore'; import { safeStorage } from './safeStorage'; -export const HELPER_LAYER = 1; +export const HELPER_LAYER = 30; /** "Show helpers in Play (debug)" — LOCAL pref, default off. While on, helpers render * in Play and a DEBUG chip sits in the play HUD so a screenshot cannot be mistaken for @@ -54,11 +68,25 @@ export function applyHelperLayer(camera, on) { else camera.layers.disable(HELPER_LAYER); } -/** Should helpers be hidden right now (Play without the debug toggle)? */ +/** 30b P1: the ONE predicate every editor helper answers to — Play (`isLocked === true`) + * or Interact hides them, unless the "Show helpers in Play (debug)" toggle keeps them. + * Pure so a suite can pin the table. @param {any} locked @param {any} mode @param {boolean} debug */ +export function helpersHiddenFor(locked, mode, debug) { + return (locked === true || mode === 'interact') && !debug; +} + +/** Should helpers be hidden right now (Play or Interact, without the debug toggle)? */ export function helpersHidden() { - return !!get(isLocked) && !get(helpersInPlay); + return helpersHiddenFor(get(isLocked), get(editorMode), get(helpersInPlay)); } +/** 30b P1: the same answer as a store, for components (the grid, the VR selection shell) + * and for anything that toggles `visible` per frame. */ +export const editorHelpersShown = derived( + [isLocked, editorMode, helpersInPlay], + ([$locked, $mode, $debug]) => !helpersHiddenFor($locked, $mode, $debug) +); + /** the marker-hop state the last `setMarkersHidden` applied (reads for the test seam) */ let markersHidden = false; export function markersAreHidden() { @@ -119,5 +147,6 @@ export function startHelperLayer() { started = true; editorCam.subscribe(applyToEditorCamera); isLocked.subscribe(applyToEditorCamera); + editorMode.subscribe(applyToEditorCamera); helpersInPlay.subscribe(applyToEditorCamera); } diff --git a/tests/e2e/helpers-in-play.test.cjs b/tests/e2e/helpers-in-play.test.cjs index 18e48769..1bc5e736 100644 --- a/tests/e2e/helpers-in-play.test.cjs +++ b/tests/e2e/helpers-in-play.test.cjs @@ -1,7 +1,7 @@ // 24-E2: helpers on their own render layer, hidden in Play, a debug toggle. Light // helpers + pick proxies and camera frustums (scene root) were gated by editor prefs // only; camera MARKERS are replicated meshes that rendered inside the game, in camera -// previews and in captures (reported). Now: scene-root helpers sit on HELPER_LAYER (1) +// previews and in captures (reported). Now: scene-root helpers sit on HELPER_LAYER (30 since 30b P0 — 1 is the XR LEFT-EYE layer) // for good and only the editor camera enables it (off in Play unless the debug toggle); // markers HOP onto the layer while hidden (Play / a preview / a capture's render) and // come back to layer 0 after, so every raycaster in the app keeps picking them. @@ -10,6 +10,7 @@ const h = require('./helpers.cjs'); const state = (page) => page.evaluate(() => { const s = window.__stores; + const HL = s.helperLayer.HELPER_LAYER; const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; const cam = get(s.editorCam) || get(s.globalCamera); const scene = get(s.globalScene); @@ -19,7 +20,7 @@ const state = (page) => const helperMasks = scene.children.filter((c) => /LightHelper$/.test(c.type || '') || c.name === 'light-proxies' || c.name === 'camera-frustums').map((c) => ({ name: c.name || c.type, mask: c.layers.mask, kids: c.children.map((k) => k.layers.mask) })); return { locked: get(s.isLocked), - camHelper: !!cam?.layers?.isEnabled?.(1), + camHelper: !!cam?.layers?.isEnabled?.(HL), camMask: cam?.layers?.mask ?? null, markers, helperMasks, @@ -29,6 +30,8 @@ const state = (page) => }; }); const bit = (mask, n) => (mask & (1 << n)) !== 0; +// 30b P0: the helper layer is read from the app, never assumed (it was 1 — the XR left eye) +let HL = 30; h.run(async () => { const browser = await h.launch({ args: h.GPU_ARGS }); @@ -36,6 +39,8 @@ h.run(async () => { const B = await h.setupPage(browser, 'B'); await h.connect(B, A); + HL = await A.page.evaluate(() => window.__stores.helperLayer.HELPER_LAYER); + h.check(HL !== 1 && HL !== 2, `the helper layer is not one of three's XR EYE layers 1/2 (${HL})`); const ids = await A.page.evaluate(() => { const s = window.__stores; s.commandsHandler.sceneCommand('/light point'); @@ -49,10 +54,10 @@ h.run(async () => { // ---- 1. outside Play: helpers on layer 1 only, the editor camera renders it ------- let s = await state(A.page); - h.check(!s.outlineLayers.includes(1), `premise: the outline passes do not use layer 1 (${JSON.stringify(s.outlineLayers)})`); - h.check(s.helperMasks.length >= 2 && s.helperMasks.every((e) => e.kids.length === 0 || e.kids.every((m) => m === 2)) && s.helperMasks.filter((e) => /LightHelper$/.test(e.name)).every((e) => e.mask === 2), `light helper + proxies + frustum root children sit on layer 1 only (${JSON.stringify(s.helperMasks)})`); + h.check(!s.outlineLayers.includes(HL), `premise: the outline passes do not use the helper layer (${JSON.stringify(s.outlineLayers)})`); + h.check(s.helperMasks.length >= 2 && s.helperMasks.every((e) => e.kids.length === 0 || e.kids.every((m) => m === 1 << HL)) && s.helperMasks.filter((e) => /LightHelper$/.test(e.name)).every((e) => e.mask === 1 << HL), `light helper + proxies + frustum root children sit on the helper layer only (${JSON.stringify(s.helperMasks)})`); h.check(s.camHelper === true, `the editor camera renders the helper layer outside Play (mask ${s.camMask})`); - h.check(s.markers.length === 1 && bit(s.markers[0].mask, 0) && !bit(s.markers[0].mask, 1), `the marker is ordinary scenery outside Play (mask ${s.markers[0]?.mask})`); + h.check(s.markers.length === 1 && bit(s.markers[0].mask, 0) && !bit(s.markers[0].mask, HL), `the marker is ordinary scenery outside Play (mask ${s.markers[0]?.mask})`); // ---- 2. a real click still picks the marker (it never left layer 0) --------------- const at = await h.projectPoint(A.page, [0, 1, 0]); @@ -67,7 +72,7 @@ h.run(async () => { await A.page.waitForTimeout(400); s = await state(A.page); h.check(s.locked === true && s.camHelper === false, `in Play the camera no longer renders the helper layer (mask ${s.camMask})`); - h.check(!bit(s.markers[0].mask, 0) && bit(s.markers[0].mask, 1), `the marker hopped onto the helper layer (mask ${s.markers[0].mask})`); + h.check(!bit(s.markers[0].mask, 0) && bit(s.markers[0].mask, HL), `the marker hopped onto the helper layer (mask ${s.markers[0].mask})`); h.check(!s.chip, 'no DEBUG chip while helpers are hidden'); // ---- 4. the debug toggle brings them back, with the chip ----------------------------- @@ -85,14 +90,14 @@ h.run(async () => { await A.page.evaluate(() => window.__stores.isLocked.set(false)); await A.page.waitForTimeout(400); s = await state(A.page); - h.check(s.camHelper === true && bit(s.markers[0].mask, 0) && !bit(s.markers[0].mask, 1), `leaving Play: layer back on, marker back on layer 0 (mask ${s.markers[0].mask})`); + h.check(s.camHelper === true && bit(s.markers[0].mask, 0) && !bit(s.markers[0].mask, HL), `leaving Play: layer back on, marker back on layer 0 (mask ${s.markers[0].mask})`); // ---- 6. a camera preview hides markers; stopping it restores them ------------------ await A.page.evaluate((u) => window.__stores.cameraPreview.startCameraPreview(u), ids.marker); await A.page.waitForTimeout(500); s = await state(A.page); h.check(!bit(s.markers[0].mask, 0), `a camera preview hides the markers (mask ${s.markers[0].mask})`); - const previewCam = await A.page.evaluate(() => { let c; window.__stores.globalCamera.subscribe((x) => (c = x))(); return c?.layers?.isEnabled?.(1) ?? null; }); + const previewCam = await A.page.evaluate((HL) => { let c; window.__stores.globalCamera.subscribe((x) => (c = x))(); return c?.layers?.isEnabled?.(HL) ?? null; }, HL); h.check(previewCam === false, `the preview camera never renders the helper layer (${previewCam})`); await A.page.evaluate(() => window.__stores.cameraPreview.stopCameraPreview()); await A.page.waitForTimeout(500); @@ -100,7 +105,7 @@ h.run(async () => { h.check(bit(s.markers[0].mask, 0), `stopping the preview restores the marker (mask ${s.markers[0].mask})`); // ---- 7. a capture hides markers for its one render, then puts them back -------------- - const capture = await A.page.evaluate((u) => { + const capture = await A.page.evaluate(({ u, HL }) => { let r; window.__stores.globalRenderer.subscribe((x) => (r = x))(); let g; @@ -108,22 +113,22 @@ h.run(async () => { const marker = g.getObjectByProperty('uuid', u); const seen = []; const original = r.render.bind(r); - r.render = (scene, camera) => { seen.push({ mask: marker.layers.mask, camHelper: camera.layers.isEnabled(1) }); return original(scene, camera); }; + r.render = (scene, camera) => { seen.push({ mask: marker.layers.mask, camHelper: camera.layers.isEnabled(HL) }); return original(scene, camera); }; try { window.__stores.cameraObjects.captureThroughCamera(u, 120); } catch (e) { seen.push({ error: e.message }); } r.render = original; return { seen, after: marker.layers.mask }; - }, ids.marker); + }, { u: ids.marker, HL }); const during = capture.seen.find((x) => x.mask !== undefined); h.check(!!during && !bit(during.mask, 0) && during.camHelper === false, `captureThroughCamera renders with the marker hidden and no helper layer (${JSON.stringify(capture.seen)})`); - h.check(bit(capture.after, 0) && !bit(capture.after, 1), `...and restores the marker afterwards (mask ${capture.after})`); + h.check(bit(capture.after, 0) && !bit(capture.after, HL), `...and restores the marker afterwards (mask ${capture.after})`); // ---- 8. peer B: ordinary mask outside Play; its own Play hides its own copy ------- let b = await state(B.page); - h.check(b.markers.length === 1 && bit(b.markers[0].mask, 0) && !bit(b.markers[0].mask, 1), `peer B's copy of the marker is on layer 0 (mask ${b.markers[0]?.mask})`); + h.check(b.markers.length === 1 && bit(b.markers[0].mask, 0) && !bit(b.markers[0].mask, HL), `peer B's copy of the marker is on layer 0 (mask ${b.markers[0]?.mask})`); await B.page.evaluate(() => window.__stores.isLocked.set(true)); await B.page.waitForTimeout(400); b = await state(B.page); - h.check(b.camHelper === false && bit(b.markers[0].mask, 1) && !bit(b.markers[0].mask, 0), `B entering Play hides its own marker copy (mask ${b.markers[0].mask})`); + h.check(b.camHelper === false && bit(b.markers[0].mask, HL) && !bit(b.markers[0].mask, 0), `B entering Play hides its own marker copy (mask ${b.markers[0].mask})`); // a snapshot taken by A mid-Play on B carries no hop for B's editor: A's copy is normal s = await state(A.page); h.check(bit(s.markers[0].mask, 0), "A's marker is unaffected by B's Play"); diff --git a/tests/e2e/vr-helper-eyes.test.cjs b/tests/e2e/vr-helper-eyes.test.cjs new file mode 100644 index 00000000..1ed7b38c --- /dev/null +++ b/tests/e2e/vr-helper-eyes.test.cjs @@ -0,0 +1,112 @@ +// 30b P0: THE ONE-EYE LIGHT HELPER. A Quest user saw the light-source helper in the +// middle of Towers / Stars Room / Football "with one eye" while playing. The cause is a +// layer number: helpers lived on render layer 1, and three's WebXRManager renders the two +// eyes through sub-cameras whose masks it rewrites EVERY FRAME from the user camera's: +// cameraL.mask = (camera.mask | 0b110) & ~0b100 -> layer 1 ALWAYS on for the left eye +// cameraR.mask = (camera.mask | 0b110) & ~0b010 -> layer 1 always OFF for the right eye +// so a layer-1 helper was drawn by the left eye whatever the editor camera enabled, and +// never by the right one. Headless Chromium cannot present WebXR, so this suite computes +// the two eye masks with three's own formula (and first asserts the installed three still +// SAYS that formula, so a three upgrade that changes it turns this red instead of lying). +// +// The contract (C1): helpers draw in BOTH eyes in Edit and in NEITHER in Interact/Play. +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const XR_SOURCE = path.join(__dirname, '..', '..', 'node_modules', 'three', 'src', 'renderers', 'webxr', 'WebXRManager.js'); + +/** what each eye camera would see of the scene-root light helper and its pick proxy */ +const eyes = (page) => + page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const THREE = s.THREE; + const cam = get(s.editorCam) || get(s.globalCamera); + const scene = get(s.globalScene); + const helper = scene.children.find((c) => /LightHelper$/.test(c.type || '')); + const proxy = scene.getObjectByName('light-proxy'); + // WebXRManager.updateCamera, verbatim in bit form + const xrMask = cam.layers.mask | 0b110; + const left = new THREE.Layers(); + left.mask = xrMask & ~0b100; + const right = new THREE.Layers(); + right.mask = xrMask & ~0b010; + const sees = (eye, node) => !!node && eye.test(node.layers); + return { + layer: s.helperLayer.HELPER_LAYER, + helper: !!helper, + proxy: !!proxy, + leftHelper: sees(left, helper), + rightHelper: sees(right, helper), + leftProxy: sees(left, proxy), + rightProxy: sees(right, proxy), + desktop: !!helper && cam.layers.test(helper.layers), + mode: get(s.editorMode), + vr: get(s.isVRMode), + locked: get(s.isLocked) + }; + }); + +h.run(async () => { + // ---- 0. the premise: three still reserves layers 1/2 for the eyes ----------------- + const src = fs.readFileSync(XR_SOURCE, 'utf8'); + h.check( + /cameraL\.layers\.mask\s*=\s*cameraXR\.layers\.mask\s*&\s*~\s*0b100/.test(src) && + /cameraR\.layers\.mask\s*=\s*cameraXR\.layers\.mask\s*&\s*~\s*0b010/.test(src) && + /cameraXR\.layers\.mask\s*=\s*camera\.layers\.mask\s*\|\s*0b110/.test(src), + 'premise: three WebXRManager still gives the left eye layer 1 and the right eye layer 2' + ); + + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + await A.page.evaluate(() => { + window.__stores.commandsHandler.sceneCommand('/light point'); + window.__stores.objectActions.deselectObject(); + }); + await h.eventually(() => eyes(A.page), (e) => e.helper && e.proxy, 'a point light has its helper + pick proxy', 10000); + + // ---- 1. Edit on the desktop -------------------------------------------------------- + let e = await eyes(A.page); + h.check(e.layer !== 1 && e.layer !== 2, `the helper layer is not an XR eye layer (${e.layer})`); + h.check(e.mode === 'edit' && e.desktop, `Edit: the desktop editor camera draws the helper (${JSON.stringify(e)})`); + h.check(e.leftHelper && e.rightHelper, `Edit: BOTH eyes draw the light helper (L ${e.leftHelper} R ${e.rightHelper})`); + h.check(e.leftProxy && e.rightProxy, `Edit: BOTH eyes draw its pick proxy (L ${e.leftProxy} R ${e.rightProxy})`); + + // ---- 2. Edit in VR (the headset keeps the editor camera, so the same answer) ------- + await A.page.evaluate(() => window.__stores.isVRMode.set(true)); + await A.page.waitForTimeout(200); + e = await eyes(A.page); + h.check(e.vr && e.leftHelper && e.rightHelper, `VR Edit: both eyes draw the helper (L ${e.leftHelper} R ${e.rightHelper})`); + + // ---- 3. Interact in VR: neither eye --------------------------------------------------- + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.waitForTimeout(200); + e = await eyes(A.page); + h.check(e.mode === 'interact' && !e.leftHelper && !e.rightHelper, `VR Interact: NEITHER eye draws the helper (L ${e.leftHelper} R ${e.rightHelper})`); + h.check(!e.leftProxy && !e.rightProxy, `VR Interact: neither eye draws the pick proxy (L ${e.leftProxy} R ${e.rightProxy})`); + + // ---- 4. back to Edit in VR ------------------------------------------------------------ + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await A.page.waitForTimeout(200); + e = await eyes(A.page); + h.check(e.leftHelper && e.rightHelper, `VR back to Edit: both eyes again (L ${e.leftHelper} R ${e.rightHelper})`); + await A.page.evaluate(() => window.__stores.isVRMode.set(false)); + + // ---- 5. Interact on the desktop and desktop Play hide it too --------------------------- + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.waitForTimeout(200); + e = await eyes(A.page); + h.check(!e.desktop, 'desktop Interact: the editor camera no longer draws the helper'); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + await A.page.waitForTimeout(200); + e = await eyes(A.page); + h.check(!e.leftHelper && !e.rightHelper, `Play: neither eye (L ${e.leftHelper} R ${e.rightHelper})`); + await A.page.evaluate(() => window.__stores.isLocked.set(false)); + await A.page.waitForTimeout(400); + e = await eyes(A.page); + h.check(e.desktop && e.leftHelper && e.rightHelper, 'leaving Play: the helper is back for both eyes'); + + await h.finish(browser); +}); From 4b46f71b63e7174f3c966516126f854b0749bcbf Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:32:50 +0300 Subject: [PATCH 2/6] [feat] 30b P1: Interact and Play draw no editor scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Quest report: "In interact mode I do not need helper boxes or shapes, outlines around objects." Play already hid the helper layer and the grid; Interact hid nothing. - Every editor helper now answers the ONE predicate from P0 (helperLayer.helpersHidden / editorHelpersShown — Play or Interact, unless "Show helpers in Play (debug)"): the grid (Grid.svelte), collider/trigger wireframes (colliderHelpers), the tiny-object dots (tinyMarkers), a peer's lock box (LockHighlights), the selection + lock outlines (Outline: Interact joins Play), the VR selection shell (VRSelectionShell) and the VR hover box + emissive hover tint (vrControls updateHoverBox / setHovered). Light helpers, frustums and the module-content proxy ride the helper layer (P0). The selection itself survives Interact — only its glare goes. - New suite interact-clean-view (16): a scene holding one of each helper, read in Edit, Interact, back in Edit, VR Interact/Edit, Play, and Play with the debug toggle. - Counterfactuals: colliderHelpers without the helpersHidden gate -> "Interact: no collider wireframes" red (and Play); Outline without the Interact clause -> "Interact: no selection or lock outline (1/1)" red. - Held: editor-modes, play-interact, pointer-capture-play green (= base). - svelte-check 335/47 unchanged; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/LockHighlights.svelte | 3 + src/components/Outline.svelte | 6 +- src/components/play/VRSelectionShell.svelte | 4 +- src/extensions/Grid.svelte | 7 +- src/lib/colliderHelpers.js | 4 +- src/lib/tinyMarkers.js | 6 + src/lib/vrControls.js | 5 + tests/e2e/interact-clean-view.test.cjs | 141 ++++++++++++++++++++ 8 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/interact-clean-view.test.cjs diff --git a/src/components/LockHighlights.svelte b/src/components/LockHighlights.svelte index c61921b1..50e2811a 100644 --- a/src/components/LockHighlights.svelte +++ b/src/components/LockHighlights.svelte @@ -4,6 +4,8 @@ import { lockedObjects, objectsGroup } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; import { peerColor } from '$lib/lockControl'; + // 30b P1: who-holds-what boxes are editor information - none in Interact/Play + import { editorHelpersShown } from '$lib/helperLayer'; // Wireframe box around every object locked by ANOTHER peer, tinted with // that peer's color (same hash as pings/cursors). Boxes track the object @@ -16,6 +18,7 @@ const helpers = new Map(); useTask(() => { + group.visible = $editorHelpersShown; /** @type {any} */ const peer = $peers; const scene = $objectsGroup; diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 760800b0..ccbf5950 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -1,5 +1,5 @@ - {#if showGrid && ($isLocked !== true || $helpersInPlay)} + {#if showGrid && $editorHelpersShown} 0 || !!groundProxy) && !wireframeActive(); + // 30b P1: collider/trigger wireframes are editor scaffolding — gone in Interact and Play + proxyRoot.visible = (entries.size > 0 || !!groundProxy) && !wireframeActive() && !helpersHidden(); if (!proxyRoot.visible) return; entries.forEach((entry) => { entry.object.updateMatrixWorld(); diff --git a/src/lib/tinyMarkers.js b/src/lib/tinyMarkers.js index fae5b1ea..8eff110f 100644 --- a/src/lib/tinyMarkers.js +++ b/src/lib/tinyMarkers.js @@ -2,6 +2,7 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; import { objectsGroup, globalScene, globalCamera, globalRenderer, selectedObjects } from '../stores/sceneStore'; +import { helpersHidden } from './helperLayer'; // A dot for an object you can no longer see. // @@ -74,6 +75,11 @@ export function updateTinyMarkers() { const renderer = get(globalRenderer); const height = renderer?.domElement?.clientHeight ?? 0; if (!scene || !group || !camera?.isPerspectiveCamera || !height) return; + // 30b P1: an aiming aid for the EDITOR — Interact and Play draw no scaffolding + if (helpersHidden()) { + if (points) points.visible = false; + return; + } /** @type {number[]} */ const spots = []; diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index eef68025..341d0fa9 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -128,6 +128,7 @@ import { suspendAnimation, resumeAnimation } from './flowRuntime'; import { drawMode, toggleDrawMode, addStrokePoint, endStroke } from './drawMode'; import { setPttHeld, cycleMicMode, vrMicMode, micActive, pttActive } from './voiceChat'; import { safeStorage } from './safeStorage'; +import { helpersHidden } from './helperLayer'; import { HOLD_MS, vrWindowAdjust, @@ -310,6 +311,8 @@ function ensureRayLines() { export function updateHoverBox(object) { const scene = get(globalScene); if (!scene) return; + // 30b P1: the hover shell is editor scaffolding — none in Interact/Play + if (helpersHidden()) object = null; if (!hoverBox) { hoverBox = new THREE.Box3Helper(new THREE.Box3(), new THREE.Color(RAY_HOVER)); hoverBox.name = 'vr-hover-box'; @@ -329,6 +332,8 @@ function setHovered(object) { // the shell is the primary, emissive-independent cue; the emissive tint is a // secondary touch for materials that support it updateHoverBox(object); + // 30b P1: ...and neither is the emissive hover tint (it paints a replicated material) + if (helpersHidden()) object = null; if (hoveredObject === object) return; if (hoveredObject?.material?.emissive) hoveredObject.material.emissive.setHex(hoveredEmissive); hoveredObject = null; diff --git a/tests/e2e/interact-clean-view.test.cjs b/tests/e2e/interact-clean-view.test.cjs new file mode 100644 index 00000000..b7d4a797 --- /dev/null +++ b/tests/e2e/interact-clean-view.test.cjs @@ -0,0 +1,141 @@ +// 30b P1: INTERACT AND PLAY DRAW NO EDITOR SCAFFOLDING. The Quest report: "In interact +// mode I do not need helper boxes or shapes, outlines around objects." Before this, Play +// hid the helper LAYER and the grid, and Interact hid nothing at all — the grid, the +// collider/trigger wireframes, the selection outline, a peer's lock box, the tiny-object +// dots, the VR selection shell and the VR hover box all stayed. Every one of them now +// answers ONE predicate (helperLayer.helpersHidden / editorHelpersShown), so this suite +// builds a scene holding one of each and reads each one in Edit, Interact, back in Edit, +// and in Play. +const h = require('./helpers.cjs'); + +const read = (page) => + page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const scene = get(s.globalScene); + const cam = get(s.editorCam) || get(s.globalCamera); + const HL = s.helperLayer.HELPER_LAYER; + const colliders = scene.getObjectByName('collider-proxies'); + const locks = scene.getObjectByName('lock-highlights'); + const tiny = scene.getObjectByName('tiny-object-markers'); + const shell = scene.getObjectByName('vr-selection-shell'); + const hover = scene.getObjectByName('vr-hover-box'); + const outline = window.__outlineDebug?.() ?? null; + return { + mode: get(s.editorMode), + locked: get(s.isLocked), + grid: !!scene.getObjectByName('editor-grid'), + helperLayer: !!cam?.layers?.isEnabled?.(HL), + colliders: !!colliders?.visible && colliders.children.length > 0, + locks: !!locks?.visible && locks.children.length > 0, + tiny: !!tiny?.visible, + shell: !!shell?.visible, + hover: !!hover?.visible, + outlineSelected: outline ? outline.selected : -1, + outlineLocked: outline ? outline.locked : -1 + }; + }); + +const everything = (r) => r.grid && r.helperLayer && r.colliders && r.locks && r.tiny && r.outlineSelected > 0 && r.outlineLocked > 0; +const nothing = (r) => !r.grid && !r.helperLayer && !r.colliders && !r.locks && !r.tiny && r.outlineSelected === 0 && r.outlineLocked === 0; + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- the fixture: one of each helper -------------------------------------------------- + const ids = await A.page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + s.commandsHandler.sceneCommand('/light point'); + s.commandsHandler.sceneCommand('/create box'); + s.commandsHandler.sceneCommand('/create box'); + s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 400)); + const boxes = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + const [selected, lockedByPeer, tiny] = boxes; + selected.position.set(-1.5, 0.5, 0); + lockedByPeer.position.set(1.5, 0.5, 0); + tiny.position.set(0, 0.5, -1); + tiny.scale.setScalar(0.0005); + s.colliderHelpers.showColliders.set(true); + // a peer holding the second box (LockHighlights + the locked outline read this) + s.lockedObjects.set([['peer-elsewhere', lockedByPeer.uuid]]); + s.objectActions.selectObject(selected.uuid); + s.objectsGroup.update((g) => g); + return { selected: selected.uuid, locked: lockedByPeer.uuid }; + }); + await A.page.waitForTimeout(1200); + + // ---- 1. Edit shows all of it ------------------------------------------------------------- + let r = await read(A.page); + h.check(r.mode === 'edit', 'premise: the editor starts in Edit'); + h.check(everything(r), `Edit: grid, helper layer, colliders, lock box, tiny dot and both outlines are drawn (${JSON.stringify(r)})`); + + // ---- 2. Interact hides every one ------------------------------------------------------------ + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.waitForTimeout(600); + r = await read(A.page); + h.check(r.mode === 'interact', 'Interact is on'); + h.check(!r.grid, `Interact: no editor grid (${r.grid})`); + h.check(!r.helperLayer, 'Interact: the camera stops drawing the helper layer (light helpers, proxies, frustums)'); + h.check(!r.colliders, 'Interact: no collider wireframes'); + h.check(!r.locks, "Interact: no peer lock box"); + h.check(!r.tiny, 'Interact: no tiny-object dot'); + h.check(r.outlineSelected === 0 && r.outlineLocked === 0, `Interact: no selection or lock outline (${r.outlineSelected}/${r.outlineLocked})`); + const kept = await A.page.evaluate(() => { let v; window.__stores.selectedObjects.subscribe((x) => (v = x))(); return v.length; }); + h.check(kept === 1, `Interact keeps the selection itself — only its glare goes (${kept})`); + + // ---- 3. VR: the selection shell and the hover box stand down in Interact --------------------- + await A.page.evaluate(() => window.__stores.isVRMode.set(true)); + const vr = await A.page.evaluate((uuid) => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const box = get(s.objectsGroup).getObjectByProperty('uuid', uuid); + s.vrControls.updateHoverBox(box); + const hover = get(s.globalScene).getObjectByName('vr-hover-box'); + return { hover: !!hover?.visible }; + }, ids.selected); + await A.page.waitForTimeout(400); + r = await read(A.page); + h.check(!r.shell, 'VR Interact: no selection shell'); + h.check(!vr.hover, 'VR Interact: the hover box refuses to show'); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + const vrEdit = await A.page.evaluate((uuid) => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const box = get(s.objectsGroup).getObjectByProperty('uuid', uuid); + s.vrControls.updateHoverBox(box); + return !!get(s.globalScene).getObjectByName('vr-hover-box')?.visible; + }, ids.selected); + await A.page.waitForTimeout(400); + r = await read(A.page); + h.check(r.shell && vrEdit, `VR Edit: the selection shell and hover box are back (shell ${r.shell}, hover ${vrEdit})`); + await A.page.evaluate(() => { + window.__stores.vrControls.updateHoverBox(null); + window.__stores.isVRMode.set(false); + }); + await A.page.waitForTimeout(600); + + // ---- 4. back in Edit on the desktop, everything returns --------------------------------------- + r = await read(A.page); + h.check(everything(r), `back to Edit: all of it returns (${JSON.stringify(r)})`); + + // ---- 5. Play hides them all too (the grid and helper layer already did; now the rest) --------- + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + await A.page.waitForTimeout(600); + r = await read(A.page); + h.check(nothing(r), `Play: nothing of the editor is drawn (${JSON.stringify(r)})`); + // ...unless the debug toggle asks for them + await A.page.evaluate(() => window.__stores.helperLayer.helpersInPlay.set(true)); + await A.page.waitForTimeout(500); + r = await read(A.page); + h.check(r.grid && r.colliders && r.helperLayer, `"Show helpers in Play (debug)" still brings the scaffolding back (${JSON.stringify(r)})`); + await A.page.evaluate(() => { + window.__stores.helperLayer.helpersInPlay.set(false); + window.__stores.isLocked.set(false); + window.__stores.lockedObjects.set([]); + }); + + await h.finish(browser); +}); From 8677f38b335474a29763743042c859196257d0e8 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:33:07 +0300 Subject: [PATCH 3/6] =?UTF-8?q?[fix]=2030b=20P2:=20grips=20by=20mode=20?= =?UTF-8?q?=E2=80=94=20Edit=20moves=20the=20world=20in=20game=20scenes,=20?= =?UTF-8?q?Interact=20never=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Quest report: "When in game and in edit mode I should be able to move around the world and scale it with grips, now it disables this for some reason". Nothing disabled it: the world gestures fire only on a grip that closes on EMPTY AIR, and a game scene has none — Stars Room is a closed room, Football a glass box, Towers a 26 m floor ringed by walls — so every grip grabbed the room. - vrGrip.js (pure, imports nothing): SCENERY (world bounds >= 4 m on any axis, or bounds that contain the head) is never held by a grip; pickGripTarget walks the ray's top-level hits: Edit takes the first non-scenery object, Interact only a grabbable one (a dynamic body under a play block whose interaction is 'grab'; a static object in front blocks); gripMovesWorld: only Edit's empty-air grip moves the world. - vrControls: gripTargetOf (ray hits + the hand-inside test through that rule) feeds onSqueezeStart. Interact: no world grab/pan, no two-hand object scale, a RIGID grab with no snapping and no stick reel/scale, no selection (no editor lock), and no undo entry on release (a player's throw is not an edit). vrGripDebug for suites. - knock.js: in VR only Interact's hands knock (an Edit hand is placing things). - tests/e2e/fakeXR.cjs: a fake XR session whose gamepads a suite presses, so the REAL per-frame path (Scene's useTask -> updateVRControls) runs headless; controller poses are written into the controller matrices. - New: vitest vrGrip (10), e2e vr-grips-by-mode (18): Edit two grips on a wall = world grab that scales x2 and leaves the wall; right grip on the floor = pan; a cube is still held, selected and one undo step. Interact: walls/air hold nothing and never move the world; a static podium is not holdable; the dynamic cube is, unselected, with no undo. - Counterfactuals: scenery not skipped in pickGripTarget -> 8 e2e reds (Edit grabs the wall/floor instead of the world) + 3 vitest reds; gripMovesWorld always true -> "Interact: ... start no world gesture" and "Interact: the world stays put (x2.67)" red. - Held: vr-world-grab 9/2 = base (the 2 are its pre-existing two-peer sync checks, red on pristine base with PEER_CONFIG too); vr-locomotion green. - svelte-check 335/47 unchanged; vitest green; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/knock.js | 7 +- src/lib/vrControls.js | 91 ++++++++++++-- src/lib/vrGrip.js | 74 ++++++++++++ tests/e2e/fakeXR.cjs | 175 +++++++++++++++++++++++++++ tests/e2e/vr-grips-by-mode.test.cjs | 179 ++++++++++++++++++++++++++++ tests/unit/vrGrip.test.js | 65 ++++++++++ 6 files changed, 576 insertions(+), 15 deletions(-) create mode 100644 src/lib/vrGrip.js create mode 100644 tests/e2e/fakeXR.cjs create mode 100644 tests/e2e/vr-grips-by-mode.test.cjs create mode 100644 tests/unit/vrGrip.test.js diff --git a/src/lib/knock.js b/src/lib/knock.js index 0775625a..9544f88b 100644 --- a/src/lib/knock.js +++ b/src/lib/knock.js @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { isLocked, isVRMode, objectsGroup } from '../stores/sceneStore'; +import { isLocked, isVRMode, objectsGroup, editorMode } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; import { sceneKnock } from './scenePhysics'; import { sessionNow } from './sessionClock'; // 25-E: `at` crosses the wire, so it is SESSION time @@ -141,7 +141,10 @@ function armed() { const cfg = get(sceneKnock); if (!cfg?.enabled) return false; if (!get(simulating) && !get(remoteSimulating)) return false; - return get(isLocked) === true || get(isVRMode) === true; + // 30b P2: in VR only a PLAYER's hands knock — an Edit hand is placing things, and a + // knock would fling the object it is reaching for (contract C1: grips grab/knock in + // Interact/Play; Edit moves things as it always has) + return get(isLocked) === true || (get(isVRMode) === true && get(editorMode) === 'interact'); } /** @param {number} now @param {any} group */ diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 341d0fa9..1c82a890 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -39,7 +39,10 @@ import { vrToolMode, vrTargetHz, vrSleeveEnabled, + editorMode, peerHandStyle, pokeScene } from '../stores/sceneStore'; +import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip'; +import { resolvePlaySettings } from './playSettings'; import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu'; import { paletteColorAt, barValueAt } from './vrPalette'; import { recordMaterialChange, setMaterialParam } from './materialsHandler'; @@ -1577,11 +1580,12 @@ function broadcastMove(object, force = false) { }); } -/** @param {any} object @param {any} before */ +/** @param {any} object @param {any} before null = a PLAYER's grab (Interact): moved and + * thrown like any other, but not an edit, so no undo entry */ function endGrab(object, before) { broadcastMove(object, true); const after = transformStateOf(object); - if (JSON.stringify(before) !== JSON.stringify(after)) + if (before && JSON.stringify(before) !== JSON.stringify(after)) recordTransform({ uuid: object.uuid, before: before, after: after }); // PFX-C: mid-sim release = throw (velocity estimate from the hold samples) import('./physics').then((m) => m.releaseBody(object.uuid)); @@ -1591,6 +1595,17 @@ function endGrab(object, before) { /** @type {{index: number, prev: any} | null} right-grip drag-the-world pan */ let worldPan = null; +/** 30b P2: test/debug view of what the grips are doing right now */ +export function vrGripDebug() { + return { + grab: grab?.object?.uuid ?? null, + grabInteract: !!grab?.interact, + worldGrab: !!worldGrab, + worldPan: !!worldPan, + emptyAir: [...emptyAirSqueeze] + }; +} + // ---- 214: Box Select — a 3D drag-box marquee. Trigger-press anchors a corner, // the controller drags the opposite corner, release selects every top-level // object whose world origin falls inside. The visual is a scene-root mesh @@ -2229,13 +2244,13 @@ function onSqueezeStart(index) { } if (!get(objectsGroup)) return; const controller = renderer.xr.getController(index); - const hits = controllerRay(index).intersectObjects(get(objectsGroup).children, true); - let object = hits.length ? topLevelObjectOf(hits[0].object) : null; - if (!object) { - // hand inside an object grabs it without a pointer (100.3) - object = containedTopLevel(controller.getWorldPosition(new THREE.Vector3()), get(objectsGroup)); - } + // 30b P2: the grip takes what vrGrip.pickGripTarget says — scenery (floors, walls, the + // room you stand in) passes through, and in INTERACT only a player-holdable body counts + const mode = get(editorMode) === 'interact' ? 'interact' : 'edit'; + let object = gripTargetOf(controllerRay(index), controller.getWorldPosition(new THREE.Vector3()), mode); if (!object) { + // 30b P2: Interact's grips never move the world (contract C1) + if (!gripMovesWorld(mode)) return; emptyAirSqueeze[index] = true; // 186: in stretch mode both grips drive the stretch, not a world grab if (get(vrStretchObject)) return; @@ -2255,6 +2270,8 @@ function onSqueezeStart(index) { if (get(lockedObjects).find((lock) => lock[1] === object.uuid)) return; if (grab && grab.object === object && grab.index !== index) { + // 30b P2: a player's second hand does not resize the thing it is holding + if (mode === 'interact') return; // second hand on the same object -> two-hand scale const distance = controllerDistance(); scaleGrab = { @@ -2268,6 +2285,7 @@ function onSqueezeStart(index) { return; } + const interact = mode === 'interact'; suspendAnimation(object.uuid); // animated objects park at their base while held // PFX-C: mid-sim, a VR-grabbed dynamic body follows the hand kinematically // and RELEASE throws it with the estimated hand velocity — the exact desktop @@ -2286,7 +2304,10 @@ function onSqueezeStart(index) { grab = { object, index, - style: get(vrGrabStyle), + // 30b P2: a player's hand is RIGID (no gizmo-style move/rotate), and `interact` + // switches off the editor's extras in updateGrab/endGrab (snap, stick scale, undo) + interact, + style: interact ? 'rigid' : get(vrGrabStyle), relPos: object.position.clone().sub(pPos).applyQuaternion(pQuat.clone().invert()), relQuat: pQuat.clone().invert().multiply(object.quaternion), startScale: object.scale.clone(), @@ -2297,7 +2318,49 @@ function onSqueezeStart(index) { }; vrGrabbedHand.set(renderer.xr.getController(index)?.userData?.handedness ?? null); hapticPulse(0.25, 30); - selectObject(object.uuid); // locks it for peers, updates selection state + // 30b P2: a player picking something up is not SELECTING it — no lock broadcast, no + // selection shell, no inspector (Edit keeps all three) + if (!interact) selectObject(object.uuid); // locks it for peers, updates selection state +} + +/** + * 30b P2: the top-level object a grip closes on, or null for empty air. Ray hits first + * (nearest first, each top-level object once), then the hand-inside test (100.3); both go + * through the vrGrip rule, so a floor, a wall or the room you stand in is never held. + * Exported for the headless suite. @param {any} ray a THREE.Raycaster + * @param {any} handPos the controller's world position @param {'edit'|'interact'} mode + */ +export function gripTargetOf(ray, handPos, mode) { + const group = get(objectsGroup); + if (!group) return null; + /** @type {any} */ + const camera = get(globalCamera); + const head = camera ? camera.getWorldPosition(new THREE.Vector3()) : null; + const locked = get(lockedObjects); + const interaction = mode === 'interact' ? resolvePlaySettings(get(globalScene)).interaction : 'grab'; + /** @param {any} object */ + const describe = (object) => { + const box = new THREE.Box3().setFromObject(object); + return { + scenery: isScenery(box.isEmpty() ? null : box, head), + grabbable: + interaction === 'grab' && + object.userData?.physics?.mode === 'dynamic' && + !locked.find((/** @type {any} */ lock) => lock[1] === object.uuid) + }; + }; + /** @type {any[]} */ + const order = []; + for (const hit of ray.intersectObjects(group.children, true)) { + const top = topLevelObjectOf(hit.object); + if (top && !order.includes(top)) order.push(top); + } + const picked = pickGripTarget(order.map(describe), mode); + if (picked >= 0) return order[picked]; + // a hand INSIDE an object needs no pointer (100.3) — the same rule decides + const inside = containedTopLevel(handPos, group); + if (inside && pickGripTarget([describe(inside)], mode) === 0) return inside; + return null; } /** @param {number} index */ @@ -2364,7 +2427,7 @@ function onSqueezeEnd(index) { hapticPulse(0.4, 60); return; } - endGrab(object, grab.before); + endGrab(object, grab.interact ? null : grab.before); grab = null; vrGrabbedHand.set(null); hapticPulse(0.18, 24); @@ -2424,7 +2487,7 @@ function updateGrab() { const pPos = position.clone().applyMatrix4(parentInv); const pQuat = parentQuat.clone().invert().multiply(quaternion); - const axes = axesForSlot(grab.index); + const axes = grab.interact ? [] : axesForSlot(grab.index); // 30b P2: no reel/scale in Interact const adjusted = grabStickAdjust({ length: Math.max(grab.relPos.length(), 0.05), scale: grab.scaleFactor, @@ -2440,7 +2503,9 @@ function updateGrab() { const pose = rigidGrabPose(pPos, pQuat, grab.relPos, grab.relQuat); object.position.copy(pose.position); object.quaternion.copy(pose.quaternion); - if (get(vrSnapMode) === 'surface') { + if (grab.interact) { + // 30b P2: a player's hand does not snap + } else if (get(vrSnapMode) === 'surface') { dropToSurface(object, get(objectsGroup)); // 156: rest on the nearest surface under it } else if (get(snapEnabled)) { const step = get(snapSettings).translate; diff --git a/src/lib/vrGrip.js b/src/lib/vrGrip.js new file mode 100644 index 00000000..a4aaba3a --- /dev/null +++ b/src/lib/vrGrip.js @@ -0,0 +1,74 @@ +// 30b P2: WHAT A VR GRIP TAKES HOLD OF — a pure leaf (imports nothing), so the rule is +// unit-tested with no headset and no scene. +// +// THE FINDING (the Quest report: "when in game and in edit mode I should be able to move +// around the world and scale it with grips, now it disables this for some reason"). No +// code disabled the world grab. It fires only when a grip closes on EMPTY AIR, and a game +// scene has none: Stars Room is a closed 12 m room (floor, four walls, a ceiling), the +// football pitch sits in a glass box, Towers stands on a 26 m floor ringed by walls. Every +// controller ray ends on one of them, and the hand-inside test (100.3) is no better in a +// room-sized mesh, so every grip GRABBED THE ROOM — the wall moved with your hand, the world +// never did. A "normal" editor scene has sky behind everything, which is why it only broke +// in games. +// +// THE RULE: SCENERY is not held by a grip. An object is scenery when its world bounds +// reach SCENERY_EXTENT on any axis (a floor, a wall, a ceiling, a pitch) or the viewer's +// HEAD is inside them (you are standing in it — a room mesh, an arena). A grip ray passes +// through scenery to what is behind it, and a grip that finds nothing else is empty air — +// which in EDIT is the world gesture again, everywhere. Scenery still moves in Edit through +// every other path (the trigger selects it; the gizmo, the props panel and the menus act on +// the selection). +// +// And by MODE (contract C1): EDIT holds anything that is not scenery; INTERACT holds only +// what a player may hold — a dynamic physics body, under a play block whose interaction is +// 'grab' — and NEVER moves the world. The first non-scenery hit decides: in Interact a +// static podium in front of a ball blocks the grab, the way a wall blocks your hand. + +/** metres: a world-bounds extent at or past this on any axis makes an object scenery */ +export const SCENERY_EXTENT = 4; + +/** + * @param {{min: {x: number, y: number, z: number}, max: {x: number, y: number, z: number}} | null} box + * the object's WORLD bounds (null / empty = nothing to hold, never scenery) + * @param {{x: number, y: number, z: number} | null} head the viewer's head in world space + * @returns {boolean} + */ +export function isScenery(box, head) { + if (!box || !box.min || !box.max) return false; + const dx = box.max.x - box.min.x; + const dy = box.max.y - box.min.y; + const dz = box.max.z - box.min.z; + if (!(dx >= 0 && dy >= 0 && dz >= 0)) return false; // empty / NaN bounds + if (Math.max(dx, dy, dz) >= SCENERY_EXTENT) return true; + if (!head) return false; + return ( + head.x >= box.min.x && head.x <= box.max.x && + head.y >= box.min.y && head.y <= box.max.y && + head.z >= box.min.z && head.z <= box.max.z + ); +} + +/** + * Which candidate a grip takes, in ray order. + * @param {{scenery: boolean, grabbable: boolean}[]} candidates top-level objects along the + * ray, nearest first (each once) + * @param {'edit' | 'interact'} mode + * @returns {number} the index taken, or -1 for "nothing" (empty air in Edit = the world) + */ +export function pickGripTarget(candidates, mode) { + for (let i = 0; i < candidates.length; i++) { + const c = candidates[i]; + if (!c || c.scenery) continue; + if (mode === 'interact') return c.grabbable ? i : -1; + return i; + } + return -1; +} + +/** + * Does an empty-air grip move the world in this mode? Only Edit's does. + * @param {'edit' | 'interact'} mode + */ +export function gripMovesWorld(mode) { + return mode !== 'interact'; +} diff --git a/tests/e2e/fakeXR.cjs b/tests/e2e/fakeXR.cjs new file mode 100644 index 00000000..7cdadfd2 --- /dev/null +++ b/tests/e2e/fakeXR.cjs @@ -0,0 +1,175 @@ +// 30b: a FAKE XR SESSION for headless suites. Headless Chromium cannot present WebXR, so +// the VR suites before this drove the pure exports one by one. This drives the REAL +// per-frame path instead: `renderer.xr.getSession` answers a fake session whose two +// inputSources carry gamepads the suite presses, and Scene's own useTask then runs +// `updateVRControls()` (grips, sticks, buttons) every frame exactly as in a headset. +// Controller poses are written straight into the controller groups' MATRICES +// (WebXRManager owns them, `matrixAutoUpdate` is false — the vr-teleport-handedness trick). +// +// Page-side state lives on `window.__fakeXR`; every helper here is a thin page.evaluate. + +/** Install the session (idempotent). Slot 0 = left, slot 1 = right. @param {any} page */ +async function install(page) { + await page.evaluate(() => { + if (window.__fakeXR) return; + const s = window.__stores; + let r; + s.globalRenderer.subscribe((v) => (r = v))(); + const pad = () => ({ + buttons: Array.from({ length: 6 }, () => ({ pressed: false, touched: false, value: 0 })), + axes: [0, 0, 0, 0], + hapticActuators: [{ pulses: [], pulse(i, ms) { this.pulses.push([i, ms]); return Promise.resolve(true); } }] + }); + const sources = [ + { handedness: 'left', gamepad: pad() }, + { handedness: 'right', gamepad: pad() } + ]; + const session = { inputSources: sources, addEventListener() {}, removeEventListener() {} }; + const original = r.xr.getSession.bind(r.xr); + r.xr.getSession = () => (window.__fakeXR?.on ? session : original()); + r.xr.getController(0).userData.handedness = 'left'; + r.xr.getController(1).userData.handedness = 'right'; + window.__fakeXR = { on: true, session, sources, renderer: r, restore: () => (r.xr.getSession = original) }; + }); +} + +/** @param {any} page @param {boolean} on */ +async function setOn(page, on) { + await page.evaluate((on) => { + window.__fakeXR.on = on; + }, on); +} + +/** + * Pose a controller: world position + a yaw/pitch aim (radians; yaw 0 aims -Z). + * @param {any} page @param {'left'|'right'} hand @param {number[]} pos @param {{yaw?: number, pitch?: number}} [aim] + */ +async function pose(page, hand, pos, aim = {}) { + await page.evaluate( + ({ hand, pos, aim }) => { + const { renderer } = window.__fakeXR; + const THREE = window.__stores.THREE; + const c = renderer.xr.getController(hand === 'left' ? 0 : 1); + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(aim.pitch ?? 0, aim.yaw ?? 0, 0, 'YXZ')); + c.matrix.compose(new THREE.Vector3(...pos), q, new THREE.Vector3(1, 1, 1)); + c.updateMatrixWorld(true); + }, + { hand, pos, aim } + ); +} + +/** Press or release a gamepad button (0 trigger, 1 grip, 3 stick, 4 A/X, 5 B/Y). + * @param {any} page @param {'left'|'right'} hand @param {number} index @param {boolean} down */ +async function button(page, hand, index, down) { + await page.evaluate( + ({ hand, index, down }) => { + const src = window.__fakeXR.sources.find((s) => s.handedness === hand); + src.gamepad.buttons[index].pressed = down; + src.gamepad.buttons[index].value = down ? 1 : 0; + }, + { hand, index, down } + ); +} + +/** Stick axes (xr-standard: axes[2] x, axes[3] y; stick UP is NEGATIVE y). + * @param {any} page @param {'left'|'right'} hand @param {number} x @param {number} y */ +async function stick(page, hand, x, y) { + await page.evaluate( + ({ hand, x, y }) => { + const src = window.__fakeXR.sources.find((s) => s.handedness === hand); + src.gamepad.axes[2] = x; + src.gamepad.axes[3] = y; + }, + { hand, x, y } + ); +} + +/** haptic pulses recorded per hand @param {any} page */ +async function pulses(page) { + return page.evaluate(() => + Object.fromEntries(window.__fakeXR.sources.map((s) => [s.handedness, s.gamepad.hapticActuators[0].pulses.length])) + ); +} + +/** + * A SPEC-ACCURATE reference space + frame, for suites that move the player. WebXR: + * `space.getOffsetReferenceSpace(t)` makes a space whose origin sits at `t` in the old one, + * and a pose in it is `inverse(accumulated origin offset) * the pose in the base space`. + * The head stands at `head` (base-space metres) facing `yaw`; the renderer's reference + * space becomes the base, and `vrControls.noteXRBaseSpace()` remembers it the way Scene's + * onsessionstart does. + * @param {any} page @param {{head?: number[], yaw?: number}} [opts] + */ +async function installSpace(page, opts = {}) { + await page.evaluate(({ head, yaw }) => { + const fx = window.__fakeXR; + const THREE = window.__stores.THREE; + const r = fx.renderer; + if (typeof window.XRRigidTransform === 'undefined') { + window.XRRigidTransform = class { + constructor(p = {}, o = {}) { + this.position = { x: p.x ?? 0, y: p.y ?? 0, z: p.z ?? 0, w: 1 }; + this.orientation = { x: o.x ?? 0, y: o.y ?? 0, z: o.z ?? 0, w: o.w ?? 1 }; + } + }; + } + const basePose = new THREE.Matrix4().compose( + new THREE.Vector3(...head), + new THREE.Quaternion().setFromEuler(new THREE.Euler(0, yaw, 0, 'YXZ')), + new THREE.Vector3(1, 1, 1) + ); + const makeSpace = (m) => ({ + __m: m, + getOffsetReferenceSpace(t) { + const p = t.position; + const o = t.orientation; + const offset = new THREE.Matrix4().compose( + new THREE.Vector3(p.x, p.y, p.z), + new THREE.Quaternion(o.x, o.y, o.z, o.w), + new THREE.Vector3(1, 1, 1) + ); + return makeSpace(m.clone().multiply(offset)); + } + }); + const base = makeSpace(new THREE.Matrix4()); + const poseIn = (space) => { + const m = space.__m.clone().invert().multiply(basePose); + const pos = new THREE.Vector3(); + const q = new THREE.Quaternion(); + m.decompose(pos, q, new THREE.Vector3()); + return { transform: { position: { x: pos.x, y: pos.y, z: pos.z }, orientation: { x: q.x, y: q.y, z: q.z, w: q.w } } }; + }; + const frame = { getViewerPose: (space) => (space?.__m ? poseIn(space) : null) }; + fx.originalGetFrame = fx.originalGetFrame ?? r.xr.getFrame.bind(r.xr); + r.xr.getFrame = () => (window.__fakeXR?.on ? frame : fx.originalGetFrame()); + r.xr.setReferenceSpace(base); + fx.base = base; + fx.poseNow = () => { + const pose = poseIn(r.xr.getReferenceSpace()); + const p = pose.transform.position; + const o = pose.transform.orientation; + const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(new THREE.Quaternion(o.x, o.y, o.z, o.w)); + return { x: p.x, y: p.y, z: p.z, yaw: Math.atan2(-fwd.x, -fwd.z) }; + }; + window.__stores.vrControls.noteXRBaseSpace(); + }, { head: opts.head ?? [0, 1.6, 0], yaw: opts.yaw ?? 0 }); +} + +/** the head in the CURRENT reference space (= world, the dolly never moves) @param {any} page */ +async function head(page) { + return page.evaluate(() => window.__fakeXR.poseNow()); +} + +/** uninstall: the renderer's own getSession comes back @param {any} page */ +async function uninstall(page) { + await page.evaluate(() => { + if (!window.__fakeXR) return; + const fx = window.__fakeXR; + if (fx.originalGetFrame) fx.renderer.xr.getFrame = fx.originalGetFrame; + if (fx.base) fx.renderer.xr.setReferenceSpace(null); + fx.restore(); + window.__fakeXR = null; + }); +} + +module.exports = { install, setOn, pose, button, stick, pulses, installSpace, head, uninstall }; diff --git a/tests/e2e/vr-grips-by-mode.test.cjs b/tests/e2e/vr-grips-by-mode.test.cjs new file mode 100644 index 00000000..108026cb --- /dev/null +++ b/tests/e2e/vr-grips-by-mode.test.cjs @@ -0,0 +1,179 @@ +// 30b P2: GRIPS BY MODE. The Quest report: "when in game and in edit mode I should be able +// to move around the world and scale it with grips, now it disables this for some reason". +// Nothing disabled the world grab — it only ever fired on EMPTY AIR, and a game scene has +// none: every controller ray ends on a floor, a wall or a ceiling, so every grip grabbed +// the ROOM. The grip now passes through scenery (vrGrip.js), and the mode decides the rest: +// Edit holds anything that is not scenery and moves the world on air; Interact holds only +// dynamic bodies and never moves the world. +// +// Driven through the REAL per-frame path (Scene's useTask -> updateVRControls) with a fake +// XR session (fakeXR.cjs): the suite presses grips and moves controllers. +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +const state = (page) => + page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const rig = get(s.worldRig); + return { + ...s.vrControls.vrGripDebug(), + rigScale: rig.scale.x, + rigPos: rig.position.toArray(), + selected: [...get(s.selectedObjects)], + undo: s.history.undoStack ? get(s.history.undoStack).length : null, + mode: get(s.editorMode) + }; + }); +const posOf = (page, uuid) => + page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((x) => (g = x))(); + return g.getObjectByProperty('uuid', uuid).position.toArray(); + }, uuid); +const grip = async (page, hand, down) => { + await xr.button(page, hand, 1, down); + await page.waitForTimeout(250); +}; +const resetRig = (page) => page.evaluate(() => window.__stores.vrControls.resetWorldRig()); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- the fixture: a game-shaped room --------------------------------------------------- + // a 20 m floor, a 10 m wall 3 m ahead, a dynamic cube and a static podium in front of it + const ids = await A.page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + for (let i = 0; i < 4; i++) s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 600)); + const boxes = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + const [floor, wall, cube, podium] = boxes; + floor.scale.set(20, 0.5, 20); + floor.position.set(0, -0.25, 0); + floor.userData.physics = { mode: 'static' }; + wall.scale.set(10, 4, 0.5); + wall.position.set(0, 2, -3); + wall.userData.physics = { mode: 'static' }; + cube.scale.setScalar(0.4); + cube.position.set(0, 1.2, -1.5); + cube.userData.physics = { mode: 'dynamic', mass: 1 }; + podium.scale.setScalar(0.4); + podium.position.set(1.2, 1.2, -1.5); + podium.userData.physics = { mode: 'static' }; + for (const b of boxes) b.updateMatrixWorld(true); + s.objectActions.deselectObject(); + s.isVRMode.set(true); + return { floor: floor.uuid, wall: wall.uuid, cube: cube.uuid, podium: podium.uuid }; + }); + await A.page.waitForTimeout(800); + await xr.install(A.page); + // both hands aim straight ahead at the WALL (the only thing along -Z at y 1.6) + const aimWall = async () => { + await xr.pose(A.page, 'left', [-0.3, 1.6, 0]); + await xr.pose(A.page, 'right', [0.3, 1.6, 0]); + }; + await aimWall(); + await A.page.waitForTimeout(200); + + // ---- 1. EDIT: two grips on the wall = the WORLD grab, not the wall ---------------------- + let s = await state(A.page); + h.check(s.mode === 'edit' && Math.abs(s.rigScale - 1) < 1e-6, `premise: Edit, the world at 1:1 (${s.rigScale})`); + const wallBefore = await posOf(A.page, ids.wall); + await grip(A.page, 'left', true); + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === null, `Edit: a grip on a wall does not hold the wall (grab ${s.grab})`); + h.check(s.worldGrab, 'Edit: two grips on scenery start the two-grip WORLD grab'); + // spread the hands: the world scales up + await xr.pose(A.page, 'left', [-0.6, 1.6, 0]); + await xr.pose(A.page, 'right', [0.6, 1.6, 0]); + await A.page.waitForTimeout(300); + s = await state(A.page); + h.check(s.rigScale > 1.8, `Edit: spreading the hands scales the world (x${s.rigScale.toFixed(2)})`); + const wallAfter = await posOf(A.page, ids.wall); + h.check(JSON.stringify(wallAfter) === JSON.stringify(wallBefore), 'Edit: the wall itself never moved'); + await grip(A.page, 'left', false); + await grip(A.page, 'right', false); + await resetRig(A.page); + await aimWall(); + + // ---- 2. EDIT: one right grip on the floor = the one-hand world PAN -------------------------- + await xr.pose(A.page, 'right', [0.3, 1.2, 0], { pitch: -Math.PI / 2 }); // straight down at the floor + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === null && s.worldPan, `Edit: a right grip on the floor pans the world (grab ${s.grab}, pan ${s.worldPan})`); + await grip(A.page, 'right', false); + await aimWall(); + + // ---- 3. EDIT: a grip on the cube still holds and moves it, as always ------------------------- + await xr.pose(A.page, 'right', [0, 1.2, 0]); // aims -Z through the cube at (0,1.2,-1.5) + const undoBefore = (await state(A.page)).undo; + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === ids.cube && !s.grabInteract, `Edit: a grip on the cube holds it (${s.grab})`); + h.check(s.selected.includes(ids.cube), 'Edit: holding selects it (the editor lock)'); + await xr.pose(A.page, 'right', [0.5, 1.2, 0]); + await A.page.waitForTimeout(250); + await grip(A.page, 'right', false); + const cubeMoved = await posOf(A.page, ids.cube); + h.check(Math.abs(cubeMoved[0] - 0.5) < 0.05, `Edit: the cube followed the hand (x ${cubeMoved[0].toFixed(2)})`); + s = await state(A.page); + if (undoBefore !== null) h.check(s.undo === undoBefore + 1, `Edit: the move is one undo entry (${undoBefore} -> ${s.undo})`); + await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); + + // ---- 4. INTERACT: scenery and air do nothing — the world never moves ------------------------ + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await aimWall(); + await A.page.waitForTimeout(200); + await grip(A.page, 'left', true); + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === null && !s.worldGrab && !s.worldPan, `Interact: two grips on the wall hold nothing and start no world gesture (${JSON.stringify(s)})`); + await xr.pose(A.page, 'left', [-0.8, 1.6, 0]); + await xr.pose(A.page, 'right', [0.8, 1.6, 0]); + await A.page.waitForTimeout(300); + s = await state(A.page); + h.check(Math.abs(s.rigScale - 1) < 1e-6 && s.rigPos.every((v) => Math.abs(v) < 1e-6), `Interact: the world stays put (x${s.rigScale})`); + await grip(A.page, 'left', false); + await grip(A.page, 'right', false); + + // ---- 5. INTERACT: the static podium is not holdable ------------------------------------------ + await xr.pose(A.page, 'right', [1.2, 1.2, 0]); + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === null, `Interact: a static podium is not holdable (${s.grab})`); + await grip(A.page, 'right', false); + + // ---- 6. INTERACT: the dynamic cube IS, as a player's hand ----------------------------------- + const cubeNow = await posOf(A.page, ids.cube); + await xr.pose(A.page, 'right', [cubeNow[0], 1.2, 0]); + const undoInteract = (await state(A.page)).undo; + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === ids.cube && s.grabInteract, `Interact: a grip holds the dynamic cube (${s.grab}, interact ${s.grabInteract})`); + h.check(!s.selected.includes(ids.cube), 'Interact: holding is not selecting (no editor lock)'); + await xr.pose(A.page, 'right', [cubeNow[0] - 0.5, 1.2, 0]); + await A.page.waitForTimeout(250); + await grip(A.page, 'right', false); + const cubeThrown = await posOf(A.page, ids.cube); + h.check(Math.abs(cubeThrown[0] - (cubeNow[0] - 0.5)) < 0.05, `Interact: the cube followed the hand (x ${cubeThrown[0].toFixed(2)})`); + s = await state(A.page); + if (undoInteract !== null) h.check(s.undo === undoInteract, `Interact: a player's grab is not an undo step (${undoInteract} -> ${s.undo})`); + + // ---- 7. back to EDIT: the world grab works again --------------------------------------------- + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await aimWall(); + await grip(A.page, 'left', true); + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.worldGrab, 'back in Edit: two grips on the wall grab the world again'); + await grip(A.page, 'left', false); + await grip(A.page, 'right', false); + + await resetRig(A.page); + await xr.uninstall(A.page); + await A.page.evaluate(() => window.__stores.isVRMode.set(false)); + await h.finish(browser); +}); diff --git a/tests/unit/vrGrip.test.js b/tests/unit/vrGrip.test.js new file mode 100644 index 00000000..b09e4a80 --- /dev/null +++ b/tests/unit/vrGrip.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { isScenery, pickGripTarget, gripMovesWorld, SCENERY_EXTENT } from '../../src/lib/vrGrip.js'; + +// 30b P2: the grip rule. The game-scene shapes below are the real ones from the Quest report +// (Towers' 26 m floor, Stars Room's 12.5 m walls, the football pitch's 5.5 m glass). +/** @param {number[]} min @param {number[]} max */ +const box = (min, max) => ({ + min: { x: min[0], y: min[1], z: min[2] }, + max: { x: max[0], y: max[1], z: max[2] } +}); +const head = { x: 0, y: 1.6, z: 0 }; + +describe('isScenery', () => { + it('a floor, a wall and a glass side are scenery by extent', () => { + expect(isScenery(box([-13, -0.45, -13], [13, 0.05, 13]), head)).toBe(true); // Towers floor + expect(isScenery(box([-6.25, 0, -6.25], [6.25, 7, -5.75]), head)).toBe(true); // Stars Room wall + expect(isScenery(box([-1.525, 0, -2.75], [-1.475, 2.4, 2.75]), head)).toBe(true); // football glass + }); + it('a cube, a ball, a podium are not', () => { + expect(isScenery(box([-0.3, 0.5, -0.3], [0.3, 1.1, 0.3]), head)).toBe(false); + expect(isScenery(box([-0.22, 1.08, -0.22], [0.22, 1.52, 0.22]), { x: 3, y: 1.6, z: 3 })).toBe(false); + }); + it('the extent threshold is inclusive at SCENERY_EXTENT', () => { + expect(isScenery(box([0, 0, 0], [SCENERY_EXTENT, 1, 1]), null)).toBe(true); + expect(isScenery(box([0, 0, 0], [SCENERY_EXTENT - 0.01, 1, 1]), null)).toBe(false); + }); + it('a small room you stand INSIDE is scenery (head inside the bounds)', () => { + const booth = box([-1.5, 0, -1.5], [1.5, 2.4, 1.5]); + expect(isScenery(booth, head)).toBe(true); + expect(isScenery(booth, { x: 5, y: 1.6, z: 0 })).toBe(false); + }); + it('empty or missing bounds hold nothing and are never scenery', () => { + expect(isScenery(null, head)).toBe(false); + expect(isScenery(box([1, 1, 1], [-1, -1, -1]), head)).toBe(false); // Box3.makeEmpty() + expect(isScenery(box([NaN, 0, 0], [1, 1, 1]), head)).toBe(false); + }); +}); + +describe('pickGripTarget', () => { + const wall = { scenery: true, grabbable: false }; + const podium = { scenery: false, grabbable: false }; + const ball = { scenery: false, grabbable: true }; + it('Edit: scenery is passed through; the first real object is taken', () => { + expect(pickGripTarget([wall, podium, ball], 'edit')).toBe(1); + expect(pickGripTarget([wall, ball], 'edit')).toBe(1); + }); + it('Edit: only scenery along the ray = empty air (the world gesture)', () => { + expect(pickGripTarget([wall, wall], 'edit')).toBe(-1); + expect(pickGripTarget([], 'edit')).toBe(-1); + }); + it('Interact: only a grabbable body is taken, through scenery', () => { + expect(pickGripTarget([wall, ball], 'interact')).toBe(1); + }); + it('Interact: a non-grabbable object in front blocks the grab', () => { + expect(pickGripTarget([podium, ball], 'interact')).toBe(-1); + expect(pickGripTarget([wall, podium], 'interact')).toBe(-1); + }); +}); + +describe('gripMovesWorld', () => { + it('only Edit moves the world', () => { + expect(gripMovesWorld('edit')).toBe(true); + expect(gripMovesWorld('interact')).toBe(false); + }); +}); From 35079f9d07736ad608b38982a0891408e10ebaed Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:33:24 +0300 Subject: [PATCH 4/6] =?UTF-8?q?[feat]=2030b=20P3:=20walk=20like=20a=20game?= =?UTF-8?q?=20=E2=80=94=20Interact=20collides,=20falls=20and=20climbs;=20E?= =?UTF-8?q?dit=20still=20flies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Quest report (the dungeon): "I can go through walls, teleport, I want to be able to do this only in edit mode and fly only in edit mode. In interactive mode I should be able to move like in a game." - locomotionPolicy.js (pure): Edit = fly + teleport + world gestures, no walls (exactly the old movement); Interact = walk, collide, gravity, snap turn, NO fly or teleport unless `play.locomotion: {teleport?, fly?}` allows (absent = false; a flier drops gravity but keeps walls). normalizeLocomotion/normalizeSpawn + yaw helpers + the VR spawn as two WebXR offsets (used from P4). - scenePhysics: play.locomotion and play.spawn normalised at the boundary and OMITTED when absent/empty, so saved scenes stay byte-identical. playSettings.resolvePlaySettings resolves `locomotion` field by field from the scene and publishers (the dungeon Kit publishes {teleport:false, fly:false}) and `spawn`. - charController: tickWalker's body became resolveWalk(feet, height, dt, desired) so the VR walker resolves through the SAME tiers (rapier capsule with a sim / dungeon raster / ground plane). Asked by the dungeon lane: the raster now also clamps the rapier tier (Kit walls are module meshes, never rapier colliders). Desktop parity: tickWalker unchanged in behaviour (char-controller 55/55, twice). - vrControls: vrWalkStep (stick -> head-yaw step at 2.2 m/s -> resolveWalk) and tickVRInteractLocomotion (feet = head world y minus the head's height in the BASE reference space captured at session start; moves by offsetting the XR reference space; falls out of the world -> back to the spawn/origin). updateTeleport honours the policy. VRControls.svelte: Interact runs the walker (a held object no longer stops your feet); Edit keeps its stick and now FLIES THROUGH walls (the raster clamp moved to Interact). - Desktop Play keeps its built-in flight (the 21-E6 parity contract) but a 1 m head capsule now stops it at colliders while a simulation runs (collideRigStep; no world = byte-for-byte the old step). - fakeXR.installSpace: a reference space + frame that compose offsets exactly as WebXR specifies, so the real per-frame walker runs headless. - New: vitest locomotionPolicy (12, incl. the spawn offsets against WebXR's composition rule), e2e vr-walk-interact (20): a real rapier world — wall stops at -2.5, gravity lands the feet, a 0.25 m step is climbed, a 0.6 m block is a wall, a flier climbs; the real per-frame path walks forward and stops at the wall; teleport off in Interact, on with play.locomotion.teleport and in Edit; Edit flies through the wall; desktop Play stops. - Counterfactuals: tickVRInteractLocomotion disabled -> "Interact: the wall stops you (z -7.55)" red; the teleport gate removed -> "Interact: the right stick does NOT arm a teleport" red; collideRigStep disabled -> "desktop Play: the wall stops you (z -15.10)" red. - svelte-check 334/47 (one base error gone with the Edit raster clamp); build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../play/PointerLockControls.svelte | 11 +- src/components/play/VRControls.svelte | 27 ++- src/lib/charController.js | 117 ++++++++++-- src/lib/locomotionPolicy.js | 108 +++++++++++ src/lib/playSettings.js | 17 +- src/lib/scenePhysics.js | 15 +- src/lib/vrControls.js | 159 +++++++++++++++- tests/e2e/vr-walk-interact.test.cjs | 169 ++++++++++++++++++ tests/unit/locomotionPolicy.test.js | 117 ++++++++++++ 9 files changed, 699 insertions(+), 41 deletions(-) create mode 100644 src/lib/locomotionPolicy.js create mode 100644 tests/e2e/vr-walk-interact.test.cjs create mode 100644 tests/unit/locomotionPolicy.test.js diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte index faa9a53a..f8ddbce4 100644 --- a/src/components/play/PointerLockControls.svelte +++ b/src/components/play/PointerLockControls.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/charController.js b/src/lib/charController.js index 949bb2f0..438297f5 100644 --- a/src/lib/charController.js +++ b/src/lib/charController.js @@ -307,13 +307,48 @@ function floorHeight() { */ export function tickWalker(rig, settings, dt, desired) { if (!rig) return { grounded, vy, source: 'none' }; - const step = Math.max(0, Math.min(dt, 0.1)); // a tab that was backgrounded const eyeHeight = Math.max(0.1, Number(settings?.eyeHeight ?? 1.7) || 1.7); - const useGravity = settings?.gravity !== false; - const g = Math.abs(Number(get(sceneGravity)) || 9.81); - rig.getWorldPosition(_worldPos); - let feet = _worldPos.y - eyeHeight; + const moved = resolveWalk( + { x: _worldPos.x, y: _worldPos.y - eyeHeight, z: _worldPos.z }, + eyeHeight, + dt, + desired, + { gravity: settings?.gravity !== false, jumpHeight: settings?.jumpHeight } + ); + // write back through the rig's PARENT: the camera lives in a group at y = 0.9, so a + // world target has to be converted rather than assigned + _target.set(_worldPos.x + moved.dx, moved.feet + eyeHeight, _worldPos.z + moved.dz); + if (rig.parent) rig.parent.worldToLocal(_target); + rig.position.copy(_target); + return { grounded: moved.grounded, vy: moved.vy, source: moved.source }; +} + +/** + * 30b P3: THE WALKER'S RESOLUTION, without a rig — so the VR walker (which moves the + * XR reference space, not a camera parent) resolves against exactly the same tiers. + * `feetPos` is the feet in WORLD space; the capsule spans feet -> feet + `height`. + * Returns the resolved world step and the new feet height. Owns the module's vertical + * state (vy / grounded / the jump edge): desktop play and a VR session never walk at once. + * + * 30b (asked by the dungeon lane): the dungeon RASTER now also clamps the rapier tier. + * A Kit dungeon's walls are module InstancedMeshes, never rapier colliders, so with a + * simulation running the capsule found nothing to stop it and walked through every wall. + * @param {{x: number, y: number, z: number}} feetPos + * @param {number} height eye/capsule height in metres + * @param {number} dt seconds + * @param {{dx: number, dz: number, dy?: number}} desired the wanted step, world units + * (`dy` only without gravity — a flier's vertical intent) + * @param {{gravity?: boolean, jumpHeight?: number}} [opts] + * @returns {{dx: number, dy: number, dz: number, feet: number, grounded: boolean, vy: number, source: 'rapier'|'dungeon'|'plane'}} + */ +export function resolveWalk(feetPos, height, dt, desired, opts = {}) { + const step = Math.max(0, Math.min(dt, 0.1)); // a tab that was backgrounded + const eyeHeight = Math.max(0.1, Number(height) || 1.7); + const useGravity = opts.gravity !== false; + const g = Math.abs(Number(get(sceneGravity)) || 9.81); + _worldPos.set(feetPos.x, feetPos.y + eyeHeight, feetPos.z); + let feet = feetPos.y; // gravity + the jump edge. The jump is spent only while we are ON something, so a // press in mid-air is DROPPED rather than queued — a queued one fires on landing, @@ -321,7 +356,7 @@ export function tickWalker(rig, settings, dt, desired) { if (useGravity) { if (jumpRequested && grounded) { jumpRequested = false; - vy = Math.sqrt(2 * g * Math.max(0, Number(settings?.jumpHeight ?? 0) || 0)); + vy = Math.sqrt(2 * g * Math.max(0, Number(opts.jumpHeight ?? 0) || 0)); } vy -= g * step; } else { @@ -333,7 +368,7 @@ export function tickWalker(rig, settings, dt, desired) { let source = 'plane'; let dx = desired?.dx ?? 0; let dz = desired?.dz ?? 0; - let dy = useGravity ? vy * step : 0; + let dy = useGravity ? vy * step : Number(desired?.dy ?? 0) || 0; const built = ensureCapsule(physicsRuntime(), eyeHeight); if (built && capsule && controller) { @@ -345,6 +380,13 @@ export function tickWalker(rig, settings, dt, desired) { dx = moved.x; dy = moved.y; dz = moved.z; + // 30b: a dungeon raster clamps the capsule's step too (see resolveWalk) + const raster = dungeonData(get(globalScene)); + if (raster && (dx || dz)) { + const slid = slideMove(raster, _worldPos.x, _worldPos.z, dx, dz, CAPSULE_RADIUS); + dx = slid.x - _worldPos.x; + dz = slid.z - _worldPos.z; + } grounded = !!controller.computedGrounded(); feet += dy; if (grounded && vy < 0) vy = 0; @@ -356,7 +398,7 @@ export function tickWalker(rig, settings, dt, desired) { console.log('character controller step failed', error); dropCapsule(); source = 'plane'; - dy = useGravity ? vy * step : 0; + dy = useGravity ? vy * step : Number(desired?.dy ?? 0) || 0; } } @@ -373,8 +415,15 @@ export function tickWalker(rig, settings, dt, desired) { const floor = raster ? 0 : floorHeight(); feet += dy; if (!useGravity) { - feet = floor; - grounded = true; + // a flier (30b: `desired.dy` given) keeps its height above the floor; the walker + // with gravity off never passes dy, so it still stands ON the floor, unchanged + if (desired?.dy == null) { + feet = floor; + grounded = true; + } else { + feet = Math.max(floor, feet); + grounded = feet <= floor + 1e-4; + } } else if (feet <= floor + 1e-4) { feet = floor; if (vy < 0) vy = 0; @@ -384,12 +433,6 @@ export function tickWalker(rig, settings, dt, desired) { } } - // write back through the rig's PARENT: the camera lives in a group at y = 0.9, so a - // world target has to be converted rather than assigned - _target.set(_worldPos.x + dx, feet + eyeHeight, _worldPos.z + dz); - if (rig.parent) rig.parent.worldToLocal(_target); - rig.position.copy(_target); - const state = { vy, grounded, ground: feet, source }; const previous = get(walkerState); if ( @@ -399,7 +442,47 @@ export function tickWalker(rig, settings, dt, desired) { Math.abs(previous.ground - state.ground) > 1e-4 ) walkerState.set(state); - return { grounded, vy, source }; + return { dx, dy, dz, feet, grounded, vy, source }; +} + +/** the head-sized capsule the built-in flier collides with: eye .. eye - FLY_BODY */ +const FLY_BODY = 1.0; + +/** + * 30b P3: DESKTOP PLAY'S BUILT-IN FLIER COLLIDES. With no Character Controller node, + * PointerLockControls moves the camera rig freely (WASD/QE/the pad) and the only wall it + * ever respected was a dungeon raster, so a player flew through every wall of every game. + * After the built-in step, the rig's world displacement since `before` is resolved through + * the walker's rapier capsule (a 1 m body hanging below the eye — a wall stops you, a floor + * holds you up, a low rim can still be flown over). Only while a simulation RUNS, because + * only then does a world with colliders exist; otherwise nothing happens and the step is + * byte-for-byte the old one (the 21-E6 parity contract: an empty scene flies as before). + * @param {any} rig the camera object PointerLockControls drives + * @param {{x: number, y: number, z: number}} before the rig's WORLD position before the step + * @returns {boolean} whether a collision pass ran + */ +export function collideRigStep(rig, before) { + if (!rig) return false; + const rt = physicsRuntime(); + if (!rt) return false; + if (!ensureCapsule(rt, FLY_BODY) || !capsule || !controller) return false; + rig.getWorldPosition(_worldPos); + const d = { x: _worldPos.x - before.x, y: _worldPos.y - before.y, z: _worldPos.z - before.z }; + if (Math.abs(d.x) + Math.abs(d.y) + Math.abs(d.z) < 1e-7) return false; + try { + capsule.setTranslation({ x: before.x, y: before.y - FLY_BODY / 2, z: before.z }); + controller.computeColliderMovement(capsule, d); + const moved = controller.computedMovement(); + _target.set(before.x + moved.x, before.y + moved.y, before.z + moved.z); + capsule.setTranslation({ x: _target.x, y: _target.y - FLY_BODY / 2, z: _target.z }); + if (rig.parent) rig.parent.worldToLocal(_target); + rig.position.copy(_target); + return true; + } catch (error) { + console.log('fly collision failed', error); + dropCapsule(); + return false; + } } /** Forget the walker's motion (a mode change, the controller removed, a test starting diff --git a/src/lib/locomotionPolicy.js b/src/lib/locomotionPolicy.js new file mode 100644 index 00000000..4643180f --- /dev/null +++ b/src/lib/locomotionPolicy.js @@ -0,0 +1,108 @@ +// 30b P3/P4: HOW A PLAYER MAY MOVE, and WHERE THEY START — a pure leaf (imports nothing), +// so the rule table and the spawn maths are unit-tested with no headset. +// +// Contract C1: EDIT keeps the editor's movement (fly, teleport, the world gestures). +// INTERACT and PLAY walk like a game — the stick walks, a capsule stops at walls, gravity +// pulls you down, a ~0.3 m step is climbed, snap turn stays — and NEITHER fly NOR teleport +// unless the scene's play block allows it (`play.locomotion: {teleport?, fly?}`, absent = +// false). The Quest report this answers: "In [the dungeon] I can go through walls, +// teleport, I want to be able to do this only in edit mode and fly only in edit mode." + +/** + * @param {'edit' | 'interact'} mode + * @param {{teleport?: boolean, fly?: boolean} | null | undefined} locomotion the resolved play block + * @returns {{walk: boolean, fly: boolean, teleport: boolean, collide: boolean, gravity: boolean, worldGestures: boolean}} + */ +export function locomotionPolicy(mode, locomotion) { + if (mode !== 'interact') { + // the editor: movement exactly as it has always been, walls included + return { walk: false, fly: true, teleport: true, collide: false, gravity: false, worldGestures: true }; + } + const fly = locomotion?.fly === true; + return { + walk: true, + fly, + teleport: locomotion?.teleport === true, + collide: true, + gravity: !fly, + worldGestures: false + }; +} + +/** + * `play.locomotion` at a store boundary: only booleans survive, and an empty block is + * ABSENT (so a scene that never used it saves byte-identically). + * @param {any} raw @returns {{teleport?: boolean, fly?: boolean} | null} + */ +export function normalizeLocomotion(raw) { + if (!raw || typeof raw !== 'object') return null; + /** @type {{teleport?: boolean, fly?: boolean}} */ + const out = {}; + if (typeof raw.teleport === 'boolean') out.teleport = raw.teleport; + if (typeof raw.fly === 'boolean') out.fly = raw.fly; + return Object.keys(out).length ? out : null; +} + +const SPAWN_LIMIT = 100000; + +/** + * A spawn point at a store/api boundary: `{position: [x, y, z], yaw}` with finite numbers + * (y is the FEET, yaw in radians, three's rotation.y: 0 faces -Z). Accepts the api's + * `(position, yaw)` pair too. Anything else is null. + * @param {any} raw @param {any} [yawArg] + * @returns {{position: [number, number, number], yaw: number} | null} + */ +export function normalizeSpawn(raw, yawArg) { + const position = Array.isArray(raw) ? raw : raw?.position; + if (!Array.isArray(position) || position.length < 3) return null; + const p = position.slice(0, 3).map(Number); + if (!p.every((v) => Number.isFinite(v) && Math.abs(v) <= SPAWN_LIMIT)) return null; + const yawRaw = Array.isArray(raw) ? yawArg : raw?.yaw; + const yaw = Number(yawRaw ?? 0); + return { + position: /** @type {[number, number, number]} */ (p), + yaw: Number.isFinite(yaw) ? yaw : 0 + }; +} + +/** forward (the direction you face) for a yaw: (-sin yaw, 0, -cos yaw) @param {number} yaw */ +export function yawForward(yaw) { + return { x: -Math.sin(yaw), y: 0, z: -Math.cos(yaw) }; +} + +/** + * The yaw you face from a forward vector (the inverse of yawForward; y is ignored). + * @param {{x: number, z: number}} dir + */ +export function yawOf(dir) { + return Math.atan2(-dir.x, -dir.z); +} + +/** + * THE VR SPAWN as two XR reference-space offsets (WebXR: a pose in the NEW space is + * `inverse(originOffset) * pose in the old one`, so an offset rotating by `a` turns the + * viewer by `-a`, and one translating by `t` moves the viewer by `-t`). + * 1. `turn`: rotate about the viewer's head so they face `yaw` (the snap-turn shape), + * 2. `move`: translate so the FEET land on the spawn point. + * @param {{x: number, y: number, z: number}} head the head in the current space + * @param {number} headYaw the yaw the head currently faces + * @param {number} headHeight the head's height above the physical floor + * @param {{position: number[], yaw: number}} spawn + */ +export function vrSpawnOffsets(head, headYaw, headHeight, spawn) { + const a = headYaw - spawn.yaw; // the viewer turns by -a = spawn.yaw - headYaw + const s = Math.sin(a); + const c = Math.cos(a); + const turn = { + angle: a, + position: { x: head.x - (c * head.x + s * head.z), y: 0, z: head.z - (-s * head.x + c * head.z) }, + orientation: { x: 0, y: Math.sin(a / 2), z: 0, w: Math.cos(a / 2) } + }; + const feet = head.y - headHeight; + const move = { + x: head.x - spawn.position[0], + y: feet - spawn.position[1], + z: head.z - spawn.position[2] + }; + return { turn, move }; +} diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index 2f739586..4b2ded41 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -1,6 +1,7 @@ import { get } from 'svelte/store'; import { scenePlay } from './scenePhysics'; import { showToast } from '../stores/appStore'; +import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; // 21-B B3: what play mode BEHAVES like in this scene. // @@ -45,7 +46,11 @@ export function playPublishers(scene) { * crosshair, today). A module publishing `userData.play.cursor` overrides the scene's, the * way `grounded` does, which is how a board-game module asks for it without an authored * scene field. - * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number, cursor: 'free'|'locked'}} + * 30b P3/P4: `locomotion` ({teleport, fly}, both false unless the scene or a publisher + * allows them — field by field, like `grounded`) and `spawn` (the runtime api.setSpawn, + * else a publisher's `userData.play.spawn`, else the scene's `play.spawn`, else null). + * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number, cursor: 'free'|'locked', + * locomotion: {teleport: boolean, fly: boolean}, spawn: {position: [number, number, number], yaw: number} | null}} */ export function resolvePlaySettings(scene) { const base = get(scenePlay); @@ -54,8 +59,12 @@ export function resolvePlaySettings(scene) { interaction: base.interaction, grounded: base.grounded, eyeHeight: DEFAULT_EYE_HEIGHT, - cursor: base.cursor === 'free' ? 'free' : 'locked' + cursor: base.cursor === 'free' ? 'free' : 'locked', + locomotion: { teleport: false, fly: false }, + spawn: normalizeSpawn(base.spawn) }; + const baseLoco = normalizeLocomotion(base.locomotion); + if (baseLoco) Object.assign(out.locomotion, baseLoco); const publishers = playPublishers(scene); if (publishers.length > 1 && !warnedMultiple) { warnedMultiple = true; @@ -73,6 +82,10 @@ export function resolvePlaySettings(scene) { if (typeof play.grounded === 'boolean') out.grounded = play.grounded; if (typeof play.eyeHeight === 'number') out.eyeHeight = play.eyeHeight; if (play.cursor === 'free' || play.cursor === 'locked') out.cursor = play.cursor; + const loco = normalizeLocomotion(play.locomotion); + if (loco) Object.assign(out.locomotion, loco); + const spawn = normalizeSpawn(play.spawn); + if (spawn) out.spawn = spawn; } return out; } diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js index 0bc8c1d5..29a56f87 100644 --- a/src/lib/scenePhysics.js +++ b/src/lib/scenePhysics.js @@ -1,6 +1,7 @@ import { writable, derived, get } from 'svelte/store'; import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; +import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; // 30b P3/P4 // CL-A A6 / 21-B B1: scene-wide physics settings. ONE shared object for the // whole session, replicated as its OWN latest-wins singleton message (the @@ -65,6 +66,12 @@ function nullableNum(v, lo, hi) { return Math.max(lo, Math.min(hi, n)); } +/** `{[key]: value}` when there is a value, `{}` when not — for fields that are ABSENT at + * their default. @param {string} key @param {any} value */ +function optional(key, value) { + return value == null ? {} : { [key]: value }; +} + /** @param {any} v @param {boolean} fallback */ function bool(v, fallback) { return typeof v === 'boolean' ? v : fallback; @@ -148,9 +155,13 @@ export function normalizeScenePhysics(raw) { grounded: bool(playRaw.grounded, d.play.grounded), simOnPlay: bool(playRaw.simOnPlay, d.play.simOnPlay), // 30 P3: present only when free (see PLAY_CURSORS) - ...(pick(playRaw.cursor, PLAY_CURSORS, 'locked') === 'free' ? { cursor: 'free' } : {}) + ...(pick(playRaw.cursor, PLAY_CURSORS, 'locked') === 'free' ? { cursor: 'free' } : {}), + // 30b P3/P4: present only when authored (locomotionPolicy's normalizers), so a + // scene that never used them stays byte-identical + ...optional('locomotion', normalizeLocomotion(playRaw.locomotion)), + ...optional('spawn', normalizeSpawn(playRaw.spawn)) }, - ['interaction', 'grounded', 'simOnPlay', 'cursor'] + ['interaction', 'grounded', 'simOnPlay', 'cursor', 'locomotion', 'spawn'] ), // A1: the 20 ceiling is throwVelocity's MAX_LINVEL, restated rather than imported — // this module is store-only and the response clamps through clampThrow anyway diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 1c82a890..bad5d938 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -43,6 +43,8 @@ import { peerHandStyle, pokeScene } from '../stores/sceneStore'; import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip'; import { resolvePlaySettings } from './playSettings'; +import { locomotionPolicy, vrSpawnOffsets, yawForward } from './locomotionPolicy'; +import { resolveWalk } from './charController'; import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu'; import { paletteColorAt, barValueAt } from './vrPalette'; import { recordMaterialChange, setMaterialParam } from './materialsHandler'; @@ -808,7 +810,8 @@ export function updateTeleport(session) { const y = source?.gamepad?.axes?.[3] ?? 0; // 157: teleport can be disabled — reset any arm + hide the arc - if (!get(vrTeleportEnabled)) { + // 30b P3: ...and Interact allows it only when the scene's play block says so + if (!get(vrTeleportEnabled) || !vrLocomotionNow().teleport) { teleportEngaged = false; hideArc(); return; @@ -934,6 +937,160 @@ export function initVRControls(r) { renderer = r; } +// ---- 30b P3: WALK LIKE A GAME (Interact) ------------------------------------------------ +// Edit keeps the editor's stick (VRControls.svelte: fly/strafe, left-grip pan/elevate, +// teleport, the world gestures). INTERACT walks: the left stick moves along the head's +// yaw at a walking pace, the step resolves through charController.resolveWalk — the SAME +// three tiers desktop's walker uses (the rapier capsule when a sim runs, a dungeon raster, +// the ground plane) — gravity pulls the feet down, a ~0.3 m step is climbed (the +// capsule's autostep), and nothing flies or teleports unless the play block allows it. +// The rig moves the WebXR way: by offsetting the reference space (offset = -(the viewer's +// displacement), the convention across this file). +// +// FEET. A headset reports the HEAD. The physical head height comes from the viewer pose in +// the BASE reference space captured at session start (local-floor: y 0 is the real floor), +// and the feet are the head's current world y minus it — robust to every offset any gesture +// applied since. Without a base space (a fake session in a suite), a standing 1.6 m head. + +/** metres per second on a full stick */ +export const VR_WALK_SPEED = 2.2; +/** a standing head when no base space can say better */ +const STANDING_HEAD = 1.6; +/** @type {any} */ let xrBaseSpace = null; + +/** Scene's onsessionstart: remember the untouched reference space. */ +export function noteXRBaseSpace() { + xrBaseSpace = renderer?.xr?.getReferenceSpace?.() ?? null; +} + +/** 30b P3: the locomotion rules in force right now (mode + the resolved play block). */ +export function vrLocomotionNow() { + const mode = get(editorMode) === 'interact' ? 'interact' : 'edit'; + return locomotionPolicy(mode, resolvePlaySettings(get(globalScene)).locomotion); +} + +/** the viewer pose in the current space, and the head's physical height @returns {any} */ +function viewerNow() { + const frame = renderer?.xr?.getFrame?.(); + const space = renderer?.xr?.getReferenceSpace?.(); + const pose = frame && space ? frame.getViewerPose?.(space) : null; + if (!pose) return null; + const base = xrBaseSpace ? frame.getViewerPose?.(xrBaseSpace) : null; + const p = pose.transform.position; + const o = pose.transform.orientation; + const q = new THREE.Quaternion(o.x, o.y, o.z, o.w); + const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(q); + return { + head: { x: p.x, y: p.y, z: p.z }, + yaw: Math.atan2(-fwd.x, -fwd.z), + headHeight: base ? base.transform.position.y : STANDING_HEAD + }; +} + +/** @param {{x: number, y: number, z: number}} offset reference-space offset (-(displacement)) + * @param {{x: number, y: number, z: number, w: number}} [orientation] */ +function offsetSpace(offset, orientation) { + const space = renderer?.xr?.getReferenceSpace?.(); + if (!space) return false; + renderer.xr.setReferenceSpace( + space.getOffsetReferenceSpace( + orientation ? new XRRigidTransform(offset, orientation) : new XRRigidTransform(offset) + ) + ); + return true; +} + +/** + * 30b P3: ONE walker step as data — the wanted displacement from the stick, resolved against + * the world. Exported so a suite drives it with a real simulation and no headset. + * @param {{head: {x: number, y: number, z: number}, headHeight: number, yaw: number, + * stick: {x: number, y: number}, dt: number, fly?: boolean, aim?: {x: number, y: number, z: number}}} input + * @returns {{dx: number, dy: number, dz: number, feet: number, grounded: boolean, source: string}} + */ +export function vrWalkStep(input) { + const dead = (/** @type {number} */ v) => (Math.abs(v) > 0.15 ? v : 0); + const sx = dead(input.stick.x); + const sy = dead(input.stick.y); + const dt = Math.max(0, Math.min(input.dt, 0.1)); + const speed = VR_WALK_SPEED * dt; + const fwd = input.fly && input.aim ? input.aim : yawForward(input.yaw); + const flat = yawForward(input.yaw); + // stick UP is negative y in xr-standard; strafe is always horizontal + const right = { x: -flat.z, z: flat.x }; + const desired = { + dx: speed * (-sy * fwd.x + sx * right.x), + dz: speed * (-sy * fwd.z + sx * right.z), + ...(input.fly ? { dy: speed * -sy * (fwd.y ?? 0) } : {}) + }; + // the capsule is quantised to 10 cm so a nodding head does not rebuild it every frame + const height = Math.min(2.1, Math.max(1, Math.round(input.headHeight * 10) / 10)); + const feet = input.head.y - input.headHeight; + const r = resolveWalk({ x: input.head.x, y: feet, z: input.head.z }, height, dt, desired, { + gravity: !input.fly + }); + return { dx: r.dx, dy: r.feet - feet, dz: r.dz, feet: r.feet, grounded: r.grounded, source: r.source }; +} + +/** + * 30b P3: the Interact half of VRControls.svelte's stick task. Returns false in Edit (the + * caller then runs the editor's own stick code unchanged). + * @param {number} dt seconds @param {any} session + */ +export function tickVRInteractLocomotion(dt, session) { + const policy = vrLocomotionNow(); + if (!policy.walk) return false; + const viewer = viewerNow(); + if (!viewer) return true; + const left = [...(session?.inputSources ?? [])].find((s) => s.handedness === 'left'); + const axes = left?.gamepad?.axes ?? []; + /** @type {any} */ + let aim = null; + if (policy.fly) { + const index = controllerIndexFor('left'); + if (index >= 0) { + const v = new THREE.Vector3(0, 0, -1).applyQuaternion( + renderer.xr.getController(index).getWorldQuaternion(new THREE.Quaternion()) + ); + aim = { x: v.x, y: v.y, z: v.z }; + } + } + const step = vrWalkStep({ + head: viewer.head, + headHeight: viewer.headHeight, + yaw: viewer.yaw, + stick: { x: axes[2] ?? 0, y: axes[3] ?? 0 }, + dt, + fly: policy.fly, + aim + }); + // fell out of the world: back to the spawn (or the origin) + if (step.feet < -50) { + // no spawn: stand back up on the origin, feet at 0 + if (!spawnPlayer()) + offsetSpace({ x: viewer.head.x, y: viewer.head.y - viewer.headHeight, z: viewer.head.z }); + return true; + } + if (step.dx || step.dy || step.dz) offsetSpace({ x: -step.dx, y: -step.dy, z: -step.dz }); + return true; +} + +/** + * 30b P4: put the player on the game's spawn — the runtime api.setSpawn, else the scene's + * `play.spawn` (resolvePlaySettings). In VR the FEET land on it facing its yaw; on the + * desktop the play camera does (PointerLockControls reads the same resolution). No spawn, + * no move. @returns {boolean} whether the player was moved + */ +export function spawnPlayer() { + const spawn = resolvePlaySettings(get(globalScene)).spawn; + if (!spawn || !renderer?.xr?.getSession?.()) return false; + const viewer = viewerNow(); + if (!viewer) return false; + const { turn, move } = vrSpawnOffsets(viewer.head, viewer.yaw, viewer.headHeight, spawn); + offsetSpace(turn.position, turn.orientation); + offsetSpace(move); + return true; +} + /** * Buzz the VR controllers if the session's gamepads support it (no-op on * desktop). Used by modules for press feedback. Optional `hand` targets one diff --git a/tests/e2e/vr-walk-interact.test.cjs b/tests/e2e/vr-walk-interact.test.cjs new file mode 100644 index 00000000..572ce28f --- /dev/null +++ b/tests/e2e/vr-walk-interact.test.cjs @@ -0,0 +1,169 @@ +// 30b P3: WALK LIKE A GAME. The Quest report (the dungeon): "I can go through walls, +// teleport, I want to be able to do this only in edit mode and fly only in edit mode. In +// interactive mode I should be able to move like in a game, without going through walls." +// Interact's stick now walks: the step resolves through charController.resolveWalk (the +// rapier capsule while a sim runs), gravity holds the feet on the floor, a ~0.3 m step is +// climbed, and teleport/fly are off unless the play block allows them. Edit is unchanged +// (it flies, through walls, and teleports). +// +// Section 1 drives the walker step with a REAL rapier world; section 2 drives the REAL +// per-frame path (VRControls' task) through a fake XR session whose reference space +// composes offsets exactly as WebXR specifies (fakeXR.installSpace). +// Desktop Play's built-in flier gets the same walls (section 4). +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- the fixture: floor (top 0), a wall ahead, a 0.25 m step, a 0.6 m block -------------- + await A.page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + for (let i = 0; i < 5; i++) s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 600)); + const [floor, wall, step, block, faller] = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + const place = (o, pos, scale) => { o.position.set(...pos); o.scale.set(...scale); o.updateMatrixWorld(true); }; + place(floor, [0, -0.25, 0], [20, 0.5, 20]); + place(wall, [0, 1.5, -3], [10, 3, 0.4]); // front face at z = -2.8 + place(step, [3, 0.125, 0], [2, 0.25, 2]); // x 2..4, top 0.25 + place(block, [-3, 0.3, 0], [2, 0.6, 2]); // x -4..-2, top 0.6 + place(faller, [8, 3, 8], [0.4, 0.4, 0.4]); + for (const o of [floor, wall, step, block]) s.physics.setPhysicsFor(o.uuid, { mode: 'static' }); + s.physics.setPhysicsFor(faller.uuid, { mode: 'dynamic', mass: 1 }); + s.objectActions.deselectObject(); + }); + await A.page.waitForTimeout(800); + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually( + () => A.page.evaluate(() => window.__stores.physics.physicsWorldDebug().running), + (ok) => ok, + 'premise: a simulation is running (the capsule tier needs a world)', + 15000 + ); + + // ---- 1. the walker step against a real world ----------------------------------------------- + // charController primes physics with a LAZY dynamic import (a scene with no walker never + // loads rapier), so the very first steps resolve on the plane tier: wait for the capsule + await h.eventually( + () => A.page.evaluate(() => window.__stores.vrControls.vrWalkStep({ head: { x: 6, y: 1.6, z: 6 }, headHeight: 1.6, yaw: 0, stick: { x: 0, y: 0 }, dt: 0.016 }).source), + (src) => src === 'rapier', + 'premise: the walker has its rapier capsule', + 10000 + ); + const walk = (input, steps) => + A.page.evaluate( + ({ input, steps }) => { + const vr = window.__stores.vrControls; + const head = { ...input.head }; + let last = null; + for (let i = 0; i < steps; i++) { + last = vr.vrWalkStep({ ...input, head: { ...head }, dt: 0.05 }); + head.x += last.dx; + head.y += last.dy; + head.z += last.dz; + } + return { head, feet: head.y - input.headHeight, source: last?.source, grounded: last?.grounded }; + }, + { input, steps } + ); + const fwd = { x: 0, y: -1 }; // stick UP + let r = await walk({ head: { x: 0, y: 1.6, z: 0 }, headHeight: 1.6, yaw: 0, stick: fwd }, 80); + h.check(r.source === 'rapier', `the walker resolved through the rapier capsule (${r.source})`); + h.check(r.head.z < -2.2 && r.head.z > -2.6, `walking at a wall STOPS at it (z ${r.head.z.toFixed(2)}; the face is at -2.8, the capsule radius 0.3)`); + r = await walk({ head: { x: 6, y: 4.6, z: 6 }, headHeight: 1.6, yaw: 0, stick: { x: 0, y: 0 } }, 60); + h.check(Math.abs(r.feet) < 0.08 && r.grounded, `gravity puts the feet on the floor (feet ${r.feet.toFixed(3)}, grounded ${r.grounded})`); + // 14 steps of 0.11 m from x 1.2 end ON the step (x 2..4), not past it + r = await walk({ head: { x: 1.2, y: 1.6, z: 0 }, headHeight: 1.6, yaw: -Math.PI / 2, stick: fwd }, 14); + h.check(r.head.x > 2.4 && r.head.x < 3.8 && Math.abs(r.feet - 0.25) < 0.08, `a 0.25 m step is climbed (x ${r.head.x.toFixed(2)}, feet ${r.feet.toFixed(2)})`); + r = await walk({ head: { x: -1.2, y: 1.6, z: 0 }, headHeight: 1.6, yaw: Math.PI / 2, stick: fwd }, 30); + h.check(r.head.x > -2.05 && r.feet < 0.1, `a 0.6 m block is a wall, not a step (x ${r.head.x.toFixed(2)}, feet ${r.feet.toFixed(2)})`); + r = await walk({ head: { x: 6, y: 1.6, z: 6 }, headHeight: 1.6, yaw: 0, stick: fwd, fly: true, aim: { x: 0, y: 0.8, z: -0.6 } }, 20); + h.check(r.feet > 1, `a flier (only when allowed) climbs along its aim (feet ${r.feet.toFixed(2)})`); + + // ---- 2. the real per-frame path: Interact walks into the wall and stops ---------------------- + await A.page.evaluate(() => window.__stores.isVRMode.set(true)); + await xr.install(A.page); + await xr.installSpace(A.page, { head: [0, 1.6, 0], yaw: 0 }); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.waitForTimeout(300); + let head = await xr.head(A.page); + h.check(Math.abs(head.z) < 0.05 && Math.abs(head.y - 1.6) < 0.05, `premise: the head starts at the origin, 1.6 m up (${JSON.stringify(head)})`); + await xr.stick(A.page, 'left', 0, -1); + await A.page.waitForTimeout(2500); + await xr.stick(A.page, 'left', 0, 0); + head = await xr.head(A.page); + h.check(head.z < -1.5, `Interact: the left stick walks you forward (z ${head.z.toFixed(2)})`); + h.check(head.z > -2.6, `Interact: ...and the wall stops you (z ${head.z.toFixed(2)})`); + h.check(Math.abs(head.y - 1.6) < 0.1, `Interact: the feet stay on the floor (head y ${head.y.toFixed(2)})`); + + // ---- 3. teleport: off in Interact, on in Edit, on in Interact when the play block allows --- + const teleportArms = async () => { + await xr.stick(A.page, 'right', 0, -1); + await A.page.waitForTimeout(300); + const st = await A.page.evaluate(() => window.__stores.vrControls.teleportState()); + await xr.stick(A.page, 'right', 0, 0); + await A.page.waitForTimeout(300); + return st.engaged; + }; + h.check((await teleportArms()) === false, 'Interact: the right stick does NOT arm a teleport'); + await A.page.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: { teleport: true } } })); + await A.page.waitForTimeout(200); + h.check((await teleportArms()) === true, 'Interact + play.locomotion.teleport: the teleport arc arms'); + const saved = await A.page.evaluate(() => { + let v; + window.__stores.scenePhysics.scenePlay.subscribe((x) => (v = x))(); + return v; + }); + h.check(saved.locomotion?.teleport === true && saved.locomotion.fly === undefined, `the play block stores only what was authored (${JSON.stringify(saved.locomotion)})`); + await A.page.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: null } })); + await A.page.waitForTimeout(200); + const cleared = await A.page.evaluate(() => { + let v; + window.__stores.scenePhysics.scenePlay.subscribe((x) => (v = x))(); + return 'locomotion' in v; + }); + h.check(cleared === false, 'clearing the block removes the key (a scene that never used it saves byte-identically)'); + + // ---- 4. Edit flies through the same wall ---------------------------------------------------- + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + h.check((await teleportArms()) === true, 'Edit: the teleport arc arms as it always did'); + await xr.stick(A.page, 'left', 0, -1); + await A.page.waitForTimeout(2500); + await xr.stick(A.page, 'left', 0, 0); + head = await xr.head(A.page); + h.check(head.z < -3.2, `Edit: the same stick flies THROUGH the wall (z ${head.z.toFixed(2)})`); + await xr.uninstall(A.page); + await A.page.evaluate(() => window.__stores.isVRMode.set(false)); + await A.page.waitForTimeout(300); + + // ---- 5. desktop Play's built-in flier stops at the wall too ------------------------------------ + const cam = () => + A.page.evaluate(() => { + let c; + window.__stores.playerCam.subscribe((x) => (c = x))(); + return c.getWorldPosition(new window.__stores.THREE.Vector3()).toArray(); + }); + await A.page.evaluate(() => { + let c; + window.__stores.playerCam.subscribe((x) => (c = x))(); + const THREE = window.__stores.THREE; + const target = new THREE.Vector3(0, 1.7, 0); + c.parent.worldToLocal(target); + c.position.copy(target); + c.quaternion.identity(); + window.__stores.isLocked.set(true); + }); + await A.page.waitForTimeout(400); + await A.page.evaluate(() => document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyW', bubbles: true }))); + await A.page.waitForTimeout(2500); + await A.page.evaluate(() => document.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyW', bubbles: true }))); + const p = await cam(); + h.check(p[2] < -1, `desktop Play: W flies you forward (z ${p[2].toFixed(2)})`); + h.check(p[2] > -2.6, `desktop Play: ...and the wall stops you while a sim runs (z ${p[2].toFixed(2)})`); + await A.page.evaluate(() => window.__stores.isLocked.set(false)); + + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.finish(browser); +}); diff --git a/tests/unit/locomotionPolicy.test.js b/tests/unit/locomotionPolicy.test.js new file mode 100644 index 00000000..081b7316 --- /dev/null +++ b/tests/unit/locomotionPolicy.test.js @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { + locomotionPolicy, + normalizeLocomotion, + normalizeSpawn, + yawForward, + yawOf, + vrSpawnOffsets +} from '../../src/lib/locomotionPolicy.js'; + +// 30b P3/P4: who may fly or teleport, and the VR spawn as WebXR offsets. + +describe('locomotionPolicy', () => { + it('Edit keeps the editor movement: fly, teleport, the world gestures, no walls', () => { + expect(locomotionPolicy('edit', null)).toEqual({ + walk: false, fly: true, teleport: true, collide: false, gravity: false, worldGestures: true + }); + // a play block never narrows the EDITOR + expect(locomotionPolicy('edit', { teleport: false, fly: false }).teleport).toBe(true); + }); + it('Interact walks with walls and gravity; no fly or teleport by default', () => { + expect(locomotionPolicy('interact', null)).toEqual({ + walk: true, fly: false, teleport: false, collide: true, gravity: true, worldGestures: false + }); + expect(locomotionPolicy('interact', {})).toEqual(locomotionPolicy('interact', null)); + }); + it('a play block can allow teleport and fly in Interact (fly drops gravity, keeps walls)', () => { + const p = locomotionPolicy('interact', { teleport: true, fly: true }); + expect(p.teleport).toBe(true); + expect(p.fly).toBe(true); + expect(p.gravity).toBe(false); + expect(p.collide).toBe(true); + expect(p.worldGestures).toBe(false); + }); + it('only a literal true allows (truthy strings do not)', () => { + expect(locomotionPolicy('interact', /** @type {any} */ ({ teleport: 'yes', fly: 1 })).teleport).toBe(false); + }); +}); + +describe('normalizeLocomotion', () => { + it('keeps booleans only; an empty block is absent', () => { + expect(normalizeLocomotion({ teleport: true, fly: 'x', other: 1 })).toEqual({ teleport: true }); + expect(normalizeLocomotion({ fly: false })).toEqual({ fly: false }); + expect(normalizeLocomotion({})).toBe(null); + expect(normalizeLocomotion(null)).toBe(null); + expect(normalizeLocomotion('fly')).toBe(null); + }); +}); + +describe('normalizeSpawn', () => { + it('takes the stored shape and the api pair', () => { + expect(normalizeSpawn({ position: [1, 0, -2], yaw: 1.5 })).toEqual({ position: [1, 0, -2], yaw: 1.5 }); + expect(normalizeSpawn([1, 0.5, -2], 0.25)).toEqual({ position: [1, 0.5, -2], yaw: 0.25 }); + expect(normalizeSpawn([1, 0.5, -2])).toEqual({ position: [1, 0.5, -2], yaw: 0 }); + }); + it('refuses anything that is not three finite numbers', () => { + expect(normalizeSpawn({ position: [1, 2] })).toBe(null); + expect(normalizeSpawn([1, NaN, 2])).toBe(null); + expect(normalizeSpawn([1e9, 0, 0])).toBe(null); + expect(normalizeSpawn(null)).toBe(null); + expect(normalizeSpawn({ position: [0, 0, 0], yaw: 'x' })).toEqual({ position: [0, 0, 0], yaw: 0 }); + }); +}); + +describe('yaw convention (three rotation.y; 0 faces -Z)', () => { + it('forward and back', () => { + expect(yawForward(0)).toEqual({ x: -0, y: 0, z: -1 }); + const f = yawForward(Math.PI / 2); // turned LEFT: facing -X + expect(f.x).toBeCloseTo(-1); + expect(f.z).toBeCloseTo(0); + for (const yaw of [0, 0.7, -2.1, 3]) expect(yawOf(yawForward(yaw))).toBeCloseTo(yaw); + }); +}); + +// ---- the VR spawn, checked against WebXR's own composition rule ------------------------ +// A pose in the new space = inverse(originOffset) * pose in the old one. Rotations here +// are about +Y only, so a pose is {x, y, z, yaw} and an offset is a translation `t` +// followed by a yaw `a` (XRRigidTransform = T(t) * R(a)). +/** @param {number} a @param {{x: number, z: number}} p */ +const rotY = (a, p) => ({ x: Math.cos(a) * p.x + Math.sin(a) * p.z, z: -Math.sin(a) * p.x + Math.cos(a) * p.z }); +/** apply inverse(T(t) R(a)) to a pose @param {{x: number, y: number, z: number, yaw: number}} pose + * @param {{x: number, y: number, z: number}} t @param {number} a */ +function viaOffset(pose, t, a) { + const d = rotY(-a, { x: pose.x - t.x, z: pose.z - t.z }); + return { x: d.x, y: pose.y - t.y, z: d.z, yaw: pose.yaw - a }; +} +/** @param {number} a */ +const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a)); + +describe('vrSpawnOffsets', () => { + const cases = [ + { head: { x: 0, y: 1.6, z: 0 }, yaw: 0, spawn: { position: [3, 0, -4], yaw: Math.PI / 2 } }, + { head: { x: -2, y: 5.1, z: 7 }, yaw: 2.4, spawn: { position: [0.5, 1.2, 0.5], yaw: -1 } }, + { head: { x: 10, y: 1.7, z: -3 }, yaw: -0.3, spawn: { position: [10, 0, -3], yaw: -0.3 } } + ]; + for (const [i, c] of cases.entries()) { + it(`the feet land on the spawn, facing its yaw (case ${i})`, () => { + const headHeight = 1.6; + const { turn, move } = vrSpawnOffsets(c.head, c.yaw, headHeight, c.spawn); + const afterTurn = viaOffset({ ...c.head, yaw: c.yaw }, turn.position, turn.angle); + // the turn is IN PLACE + expect(afterTurn.x).toBeCloseTo(c.head.x, 6); + expect(afterTurn.z).toBeCloseTo(c.head.z, 6); + const after = viaOffset(afterTurn, move, 0); + expect(after.x).toBeCloseTo(c.spawn.position[0], 6); + expect(after.z).toBeCloseTo(c.spawn.position[2], 6); + expect(after.y - headHeight).toBeCloseTo(c.spawn.position[1], 6); // the FEET + expect(wrap(after.yaw - c.spawn.yaw)).toBeCloseTo(0, 6); + }); + } + it('the turn quaternion is the rotation by `angle` about +Y', () => { + const { turn } = vrSpawnOffsets({ x: 0, y: 1.6, z: 0 }, 0.4, 1.6, { position: [0, 0, 0], yaw: -0.6 }); + expect(turn.angle).toBeCloseTo(1.0); + expect(turn.orientation.y).toBeCloseTo(Math.sin(0.5)); + expect(turn.orientation.w).toBeCloseTo(Math.cos(0.5)); + }); +}); From a09a247773f3b2820c73cc9ab40281292a33cf83 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:33:48 +0300 Subject: [PATCH 5/6] [feat] 30b P4: games enter Interact in VR, left Y switches modes, and players spawn - The Quest report: "By default for games there should be interact mode in VR, but I should be able to switch to edit mode", and "if the game should have a specific place where the character should stand, it should move." - vrControls.onVRSessionStart (Scene's XR onsessionstart): remembers the base reference space, and a GAME (a HUD screen bound to a game state, a spawn, or a userData.play publisher) lands in Interact. Entering Interact in a session (editorMode subscription, the LAST statement of the file for the TDZ rule) drops any editor gesture, resets the world to 1:1 and pays the spawn on the first frame with a viewer pose. - The mode button: LEFT Y (B on the right when the radial menu lives on the left, so they never share a button) toggles Edit <-> Interact with a haptic tick and a wrist label on that controller ('vr-mode-label': INTERACT teal / EDIT amber + the button). - Spawn = `play.spawn: {position:[x,y,z], yaw}` (y = FEET, three rotation.y) or a module's `api.setSpawn(position, yaw, {teleport?})` (LOCAL, overrides the scene's, cleared when the module is disabled; without teleport a new spawn is a checkpoint) + `api.respawnPlayer()`. VR: vrControls.spawnPlayer turns the rig in place then moves it so the feet land on the point (locomotionPolicy.vrSpawnOffsets). Desktop Play: the play camera (playSpawn.spawnDesktopPlayer, ahead of the dungeon's per-peer rooms). Desktop Interact: the editor view flies there (objectActions.setEditorMode). - New: e2e vr-mode-spawn (22): a plain scene stays in Edit, a game lands in Interact ON the spawn facing its yaw with the world back at 1:1; left Y -> Edit with one haptic tick and the label; walking off and pressing Y again re-spawns; with the menu on the left the right B switches; api.setSpawn checkpoint vs {teleport:true}, a malformed spawn refused, the module's spawn dropped on disable; desktop Play and desktop Interact spawn. - Counterfactuals: no spawn on entering Interact -> "the player stands ON the spawn" and "facing the spawn's yaw" red; the mode button unwired -> "left Y: Interact -> Edit", "a haptic tick", "the label follows", "the RIGHT B toggles" red. - svelte-check 334/47; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 7 +- src/components/Scene.svelte | 5 +- .../play/PointerLockControls.svelte | 4 + src/lib/moduleSDK.js | 57 ++++++ src/lib/objectActions.js | 10 ++ src/lib/playSettings.js | 25 ++- src/lib/playSpawn.js | 55 ++++++ src/lib/vrControls.js | 164 +++++++++++++++++- tests/e2e/vr-mode-spawn.test.cjs | 142 +++++++++++++++ 9 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 src/lib/playSpawn.js create mode 100644 tests/e2e/vr-mode-spawn.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 40cf9592..239477a5 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -478,9 +478,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/importGate'), import('./lib/decimate'), import('./lib/playCursor'), - import('./lib/gameStorage') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, 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, meshToolParamsLib, 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, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, 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, meshToolParams: meshToolParamsLib, 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, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib } + import('./lib/gameStorage'), + import('./lib/playSpawn') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, 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, meshToolParamsLib, 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, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, playSpawnLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, 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, meshToolParams: meshToolParamsLib, 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, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, playSpawn: playSpawnLib } }) } }) diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index dde54080..5fab55d8 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -54,7 +54,7 @@ // the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; - import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticPulse } from '$lib/vrControls'; + import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticPulse, onVRSessionStart } from '$lib/vrControls'; import { vrKeyboardTarget } from '$lib/vrKeyboard'; import { measureMode, measureClick } from '$lib/measure'; import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler'; @@ -1642,6 +1642,9 @@ position={[0, 2, 3]} // B2.1: request the preferred refresh rate (auto = highest supported) — // without this the Quest stays at its 90Hz default applyVRFrameRate(); + // 30b P4: remember the untouched reference space (the walker's floor), and a + // GAME lands in Interact on its spawn + onVRSessionStart(); }} onsessionend={() => passthroughActive.set(false)} > diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte index f8ddbce4..c8f9b900 100644 --- a/src/components/play/PointerLockControls.svelte +++ b/src/components/play/PointerLockControls.svelte @@ -7,6 +7,7 @@ import { userdata, peers } from '../../stores/appStore' import { dungeonData, slideMove, spawnPointFor } from '$lib/dungeonPlay' import { resolvePlaySettings } from '$lib/playSettings' + import { spawnDesktopPlayer } from '$lib/playSpawn' // 30 P3: free-cursor games never take the pointer — the real cursor aims import { playCursorFree } from '$lib/playCursor' import { inputClaims, getGamepadAxes } from '$lib/inputRuntime' @@ -192,6 +193,9 @@ // in your seed-deterministic room (peers take consecutive rooms). // untracked: the effect must only depend on $isLocked. untrack(() => { + // 30b P4: a game's own spawn (the scene's play.spawn, or a module's api.setSpawn) + // wins; the dungeon's per-peer rooms below are what a scene without one gets + if (spawnDesktopPlayer()) return const data = dungeonData($globalScene) // resolve the rig ONCE and mutate the object: `$cameraParent.position.x = v` // compiles to store_mutate -> cameraParent.set(), and useParent() is a diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index dff5a4d6..ed24c97b 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -51,6 +51,8 @@ export { moduleContentDebug } from './moduleContent'; import { safeStorage } from './safeStorage'; // 30 P4: api.storage — a LEAF (safeStorage only), shared with the Store Value flow node import { makeModuleStorage } from './gameStorage'; +import { runtimeSpawn, setRuntimeSpawn } from './playSettings'; // 30b P4 (a leaf) +import { spawnDesktopPlayer, currentSpawn, spawnEyePose } from './playSpawn'; // 30b P4 (a leaf) /** modules already told they hit the storage cap this session (ONE toast each, never * one per write — a game saving every frame would otherwise bury the screen) */ @@ -343,6 +345,27 @@ function physicsApi() { return physicsRef; } +/** + * 30b P4: move the player to the spawn in force — VR moves the rig (vrControls), desktop + * Play moves the play camera, desktop Interact the editor view. Edit is never moved. + * @returns {boolean} + */ +function respawnPlayerNow() { + const spawn = currentSpawn(); + if (!spawn) return false; + if (get(isVRMode)) { + if (get(editorMode) !== 'interact') return false; + return !!vrControlsRef?.spawnPlayer?.(); + } + if (get(isLocked) === true) return spawnDesktopPlayer(spawn); + if (get(editorMode) === 'interact') { + const { eye, lookAt } = spawnEyePose(spawn); + objectActionsRef?.flyTo?.(eye, lookAt); + return !!objectActionsRef; + } + return false; +} + /** @param {string} moduleId @param {string} [moduleName] the DISPLAY name, needed while * register() runs: loadedModules is not appended until it RETURNS, so anything reading the * name from there during registration gets the raw id (which is how a module HUD kind was @@ -351,6 +374,8 @@ function makeApi(moduleId, moduleName = moduleId) { const disposals = (moduleDisposals[moduleId] ??= []); /** record an undo thunk deactivateModule runs at teardown (A2) @param {() => void} fn */ const onDispose = (fn) => disposals.push(fn); + /** 30b P4: setSpawn journals its clear once per module */ + let spawnDisposeHooked = false; /** A value frozen for the undo stack, so a module mutating its patch object later * cannot rewrite history. @param {any} v */ const frozen = (v) => { @@ -644,6 +669,38 @@ function makeApi(moduleId, moduleName = moduleId) { editorMode() { return get(editorMode) === 'interact' ? 'interact' : 'edit'; }, + /** + * 30b P4: where the player STARTS — entering Interact or Play puts them here (VR: the + * rig so the FEET land on it facing `yaw`; desktop Play: the play camera; desktop + * Interact: the editor view). `position` is [x, y, z] with y the FEET height; `yaw` + * is radians, three's rotation.y (0 faces -Z, forward = (-sin yaw, 0, -cos yaw)). + * Overrides the scene's authored `play.spawn`. LOCAL: every peer's module sets its + * own from the same replicated state; never saved. `{teleport: true}` also moves the + * player there NOW when Interact or Play is on (a new level, a new dungeon floor) — + * without it a changed spawn is a checkpoint, used on the next entry. + * `setSpawn(null)` clears it. Cleared when the module is disabled. + * @param {number[] | null} position @param {number=} yaw + * @param {{teleport?: boolean}=} options @returns {boolean} whether it was accepted + */ + setSpawn(position, yaw = 0, options = {}) { + const ok = setRuntimeSpawn(position, yaw, moduleId); + if (!spawnDisposeHooked) { + spawnDisposeHooked = true; + onDispose(() => { + if (get(runtimeSpawn)?.owner === moduleId) setRuntimeSpawn(null); + }); + } + if (ok && position && options?.teleport) respawnPlayerNow(); + return ok; + }, + /** + * 30b P4: move the player to the spawn in force now (see setSpawn) — only while + * Interact or Play is on; the editor's Edit view is never moved. + * @returns {boolean} whether the player was moved + */ + respawnPlayer() { + return respawnPlayerNow(); + }, /** * Where the user is POINTING, as a THREE.Raycaster in world space — * desktop mouse over the viewport, or the VR pointer hand's ray. A fresh diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index 5420ded6..c757e6d9 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -17,6 +17,7 @@ import { isVRMode, gizmoSuppressed, editorMode, + isLocked, cameraClaim, pokeScene } from '../stores/sceneStore'; import { attachMultiPivot, releaseMultiPivot, hasCustomOrigin, pivotPose, setPivotOrigin } from './multiTransform'; import { focusTargetFace, faceEditObject, hideElementSelection, restoreElementSelection } from './faceEdit'; @@ -38,6 +39,7 @@ import { stripEditOverlays, isEditOverlay } from './editOverlays'; import { markTransient } from './transientObjects'; // 30 P3: a LEAF (THREE + stores), so a static import here closes no cycle import { clearModuleSelection } from './moduleContent'; +import { currentSpawn, spawnEyePose } from './playSpawn'; // 30b P4 // D2: a LEAF (svelte stores + THREE), so a static import here closes no cycle import { shareDuplicatedMaterials, linkMaterials } from './materialSharing'; import { @@ -239,6 +241,14 @@ export function setEditorMode(mode) { if (next === 'interact') { releaseMultiPivot(); if (controls && !get(isVRMode)) controls.detach(); + // 30b P4: a game that names a spawn puts the desktop view there on the way in (VR + // does the same for the rig — vrControls follows this store; Play's camera spawns in + // PointerLockControls). No spawn, no move: the view stays where the author left it. + const spawn = currentSpawn(); + if (spawn && !get(isVRMode) && get(isLocked) !== true) { + const { eye, lookAt } = spawnEyePose(spawn); + flyTo(eye, lookAt); + } } else if (get(selectedObjects).length) { applySelectionSet([...get(selectedObjects)]); } diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index 4b2ded41..39bfe251 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -1,8 +1,29 @@ -import { get } from 'svelte/store'; +import { get, writable } from 'svelte/store'; import { scenePlay } from './scenePhysics'; import { showToast } from '../stores/appStore'; import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; +/** + * 30b P4: a spawn point set at RUNTIME by a module (`api.setSpawn(position, yaw)`) — a + * dungeon's floor, a level's start. LOCAL (every peer's module runs the same code off the + * same replicated state, so each sets its own), never saved, and it wins over the scene's + * authored `play.spawn` (contract C1: a module overrides the scene's). + * @type {import('svelte/store').Writable<{position: [number, number, number], yaw: number, owner?: string} | null>} + */ +export const runtimeSpawn = writable(null); + +/** @param {any} position @param {any} [yaw] @param {string} [owner] @returns {boolean} */ +export function setRuntimeSpawn(position, yaw, owner) { + if (position == null) { + runtimeSpawn.set(null); + return true; + } + const spawn = normalizeSpawn(position, yaw); + if (!spawn) return false; + runtimeSpawn.set(owner ? { ...spawn, owner } : spawn); + return true; +} + // 21-B B3: what play mode BEHAVES like in this scene. // // The shared defaults live in scenePhysics (`play.*`, so they replicate, save @@ -87,6 +108,8 @@ export function resolvePlaySettings(scene) { const spawn = normalizeSpawn(play.spawn); if (spawn) out.spawn = spawn; } + const runtime = get(runtimeSpawn); + if (runtime) out.spawn = { position: runtime.position, yaw: runtime.yaw }; return out; } diff --git a/src/lib/playSpawn.js b/src/lib/playSpawn.js new file mode 100644 index 00000000..bf1a72e6 --- /dev/null +++ b/src/lib/playSpawn.js @@ -0,0 +1,55 @@ +// 30b P4: THE DESKTOP HALF OF A SPAWN — a LEAF (THREE + svelte/store + sceneStore + +// playSettings), so PointerLockControls, objectActions and moduleSDK can all reach it +// without closing a cycle. The VR half lives in vrControls.spawnPlayer (it moves the XR +// reference space, which only vrControls holds). +// +// A spawn is `{position: [x, y, z], yaw}` — y is the FEET, yaw is three's rotation.y (0 faces +// -Z) — resolved by playSettings.resolvePlaySettings: a module's runtime api.setSpawn, else +// a publisher's userData.play.spawn, else the scene's play.spawn. +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { playerCam } from '../stores/sceneStore'; +import { resolvePlaySettings } from './playSettings'; +import { globalScene } from '../stores/sceneStore'; + +/** the play camera's eye above the feet: the walker's 1.7 m person */ +export const SPAWN_EYE = 1.7; + +/** the spawn in force right now, or null @returns {{position: [number, number, number], yaw: number} | null} */ +export function currentSpawn() { + return resolvePlaySettings(get(globalScene)).spawn; +} + +/** + * Where an EYE stands on a spawn, and a point straight ahead of it at eye height (what + * flyTo and lookAt want). Pure. @param {{position: number[], yaw: number}} spawn + */ +export function spawnEyePose(spawn) { + const [x, y, z] = spawn.position; + const eye = [x, y + SPAWN_EYE, z]; + const lookAt = [x - Math.sin(spawn.yaw) * 2, y + SPAWN_EYE, z - Math.cos(spawn.yaw) * 2]; + return { eye, lookAt }; +} + +/** + * Put desktop Play's camera on the spawn: eye at feet + SPAWN_EYE, facing its yaw, level. + * PointerLockControls derives yaw/pitch from the camera's quaternion every look, so writing + * the quaternion is the whole of "face this way". + * @param {{position: number[], yaw: number} | null} [spawn] defaults to the one in force + * @returns {boolean} whether the camera moved + */ +export function spawnDesktopPlayer(spawn = currentSpawn()) { + /** @type {any} */ + const cam = get(playerCam); + if (!spawn || !cam?.isObject3D) return false; + const { eye } = spawnEyePose(spawn); + const target = new THREE.Vector3(eye[0], eye[1], eye[2]); + if (cam.parent) { + cam.parent.updateWorldMatrix(true, false); + cam.parent.worldToLocal(target); + } + cam.position.copy(target); + cam.quaternion.setFromEuler(new THREE.Euler(0, spawn.yaw, 0, 'YXZ')); + cam.updateMatrixWorld(true); + return true; +} diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index bad5d938..a5d8c354 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -42,7 +42,8 @@ import { editorMode, peerHandStyle, pokeScene } from '../stores/sceneStore'; import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip'; -import { resolvePlaySettings } from './playSettings'; +import { resolvePlaySettings, playPublishers } from './playSettings'; +import { hudDocs, isGameHud } from './hudDocs'; import { locomotionPolicy, vrSpawnOffsets, yawForward } from './locomotionPolicy'; import { resolveWalk } from './charController'; import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu'; @@ -122,7 +123,9 @@ import { focusObject, ungroupObject, applySelectionSet, - selectionUuids + selectionUuids, + setEditorMode, + toggleEditorMode } from './objectActions'; import { vrKeyboardTarget, openVRKeyboard, pressVRKey, closeVRKeyboard } from './vrKeyboard'; import { sceneCommand } from './commandsHandler.svelte'; @@ -239,7 +242,7 @@ vrChatPanelOpen.subscribe((open) => { }); /** @type {any} */ let renderer = null; -/** @type {{menu?: boolean, squeeze?: boolean, stick?: boolean, trigger?: boolean, a?: boolean}[]} */ +/** @type {{menu?: boolean, squeeze?: boolean, stick?: boolean, trigger?: boolean, a?: boolean, mode?: boolean}[]} */ const previousButtons = [{}, {}]; const raycaster = new THREE.Raycaster(); const tempMatrix = new THREE.Matrix4(); @@ -3345,11 +3348,13 @@ export function updateVRControls() { console.log('VR frame hook failed', error); } } + updateModeLabel(); // 30b P4: the wrist label follows the mode (hidden outside a session) if (!session) { hideArc(); teleportEngaged = false; return; } + payPendingSpawn(); // 30b P4 // open menu/panel are modal for the sticks: sector nav / scrolling own them; // a RIGHT-hand grab owns the right stick too (reel/scale beats teleport, 100); // D9: manipulation gestures + ANY held grip stand navigation down entirely @@ -3412,6 +3417,13 @@ export function updateVRControls() { } prev.menu = menuPressed; + // 30b P4: the MODE button — Y on the LEFT hand (B on the right when the radial menu + // lives on the left, so the two never share a button): Edit <-> Interact, in VR + if (source.handedness === modeHand()) { + if (menuPressed && !prev.mode) toggleVRMode(); + prev.mode = menuPressed; + } + // right A held = push-to-talk const aPressed = !!buttons[4]?.pressed; @@ -3760,3 +3772,149 @@ export function updateVRControls() { vrHovered.set(null); } } + +// ---- 30b P4: ENTER INTERACT, SWITCH IN VR, SPAWN ------------------------------------------ +// Contract C1: pressing Play in VR on a GAME enters Interact; the left Y button toggles +// Edit <-> Interact in the headset (a haptic tick + a label on that wrist says which); and +// entering Interact puts the world back to 1:1 and the player on the game's spawn. + +/** Is this scene a game? A HUD screen bound to a game state (the GameChip rule), a spawn, + * or a module publishing the play contract (a dungeon). */ +export function sceneIsGame() { + const scene = get(globalScene); + if (isGameHud(get(hudDocs))) return true; + if (resolvePlaySettings(scene).spawn) return true; + return playPublishers(scene).length > 0; +} + +/** which hand owns the mode button: the LEFT (Y), unless the radial menu lives there */ +export function modeHand() { + return get(vrMenuHand) === 'left' ? 'right' : 'left'; +} + +/** Scene's onsessionstart (after the base space is live). */ +export function onVRSessionStart() { + noteXRBaseSpace(); + // a game is played, not edited: Play in VR lands in Interact (the stores follow below) + if (sceneIsGame() && get(editorMode) !== 'interact') setEditorMode('interact'); + else if (get(editorMode) === 'interact') enterInteractVR(); + updateModeLabel(); +} + +/** Interact starts at 1:1, on the spawn, with no editor gesture half-done. */ +function enterInteractVR() { + worldGrab = null; + worldPan = null; + emptyAirSqueeze[0] = emptyAirSqueeze[1] = false; + if (grab && !grab.interact) { + endGrab(grab.object, grab.before); + grab = null; + vrGrabbedHand.set(null); + } + resetWorldRig(); + // at sessionstart no XR frame exists yet (so no viewer pose to move FROM): the spawn + // waits for the first frame that has one (updateVRControls) + spawnPending = true; + if (viewerNow()) { + spawnPending = false; + spawnPlayer(); + } +} + +/** The button: flip the mode, tick the hand, flash the label. @returns {'edit'|'interact'} */ +export function toggleVRMode() { + const next = toggleEditorMode(); + hapticPulse(0.35, 40, /** @type {any} */ (modeHand())); + modeLabelFlashUntil = Date.now() + 500; + updateModeLabel(); + return next; +} + +let spawnPending = false; +/** a spawn owed since enterInteractVR, paid on the first frame with a viewer pose */ +function payPendingSpawn() { + if (!spawnPending || !viewerNow()) return; + spawnPending = false; + spawnPlayer(); +} + +/** @type {any} */ let modeLabel = null; +/** @type {any} */ let modeLabelCanvas = null; +let modeLabelText = ''; +let modeLabelFlashUntil = 0; + +function ensureModeLabel() { + if (modeLabel || typeof document === 'undefined') return modeLabel; + modeLabelCanvas = document.createElement('canvas'); + modeLabelCanvas.width = 256; + modeLabelCanvas.height = 80; + const texture = new THREE.CanvasTexture(modeLabelCanvas); + texture.colorSpace = THREE.SRGBColorSpace; + modeLabel = new THREE.Mesh( + new THREE.PlaneGeometry(0.075, 0.0234), + new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: true }) + ); + modeLabel.name = 'vr-mode-label'; + // on the back of the controller, tilted up toward the eyes (the wrist you glance at) + modeLabel.position.set(0, 0.03, 0.07); + modeLabel.rotation.set(-1.0, 0, 0); + modeLabel.renderOrder = 996; + return modeLabel; +} + +/** redraw the label when the mode changes; re-parent it onto the mode hand's controller */ +function updateModeLabel() { + const label = ensureModeLabel(); + if (!label || !renderer) return; + const presenting = !!renderer.xr.getSession?.(); + const index = presenting ? controllerIndexFor(modeHand()) : -1; + label.visible = index >= 0; + if (index < 0) return; + const controller = renderer.xr.getController(index); + if (label.parent !== controller) controller.add(label); + const interact = get(editorMode) === 'interact'; + const text = interact ? 'INTERACT' : 'EDIT'; + label.scale.setScalar(Date.now() < modeLabelFlashUntil ? 1.35 : 1); + if (text === modeLabelText) return; + modeLabelText = text; + const ctx = modeLabelCanvas.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, 256, 80); + ctx.fillStyle = interact ? 'rgba(20, 110, 90, 0.9)' : 'rgba(150, 95, 10, 0.9)'; + ctx.beginPath(); + ctx.roundRect?.(4, 4, 248, 72, 18); + if (!ctx.roundRect) ctx.rect(4, 4, 248, 72); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 34px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, 112, 42); + ctx.font = 'bold 22px sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + ctx.fillText(modeHand() === 'left' ? 'Y' : 'B', 228, 42); + label.material.map.needsUpdate = true; +} + +/** test/debug view of the label */ +export function vrModeLabelDebug() { + return { + text: modeLabelText, + visible: !!modeLabel?.visible, + hand: modeLabel?.parent?.userData?.handedness ?? null, + flashing: Date.now() < modeLabelFlashUntil + }; +} + +// THE LAST STATEMENT IN THE FILE on purpose: a module-level subscribe runs its callback +// synchronously at evaluation, so every `let` it reaches must already be declared (the +// TDZ rule that took the whole app down twice). Entering Interact in a live session resets +// the world and spawns; either way the label follows. +let lastEditorMode = get(editorMode); +editorMode.subscribe((mode) => { + if (mode === lastEditorMode) return; + lastEditorMode = mode; + if (!renderer?.xr?.getSession?.()) return; + if (mode === 'interact') enterInteractVR(); + updateModeLabel(); +}); diff --git a/tests/e2e/vr-mode-spawn.test.cjs b/tests/e2e/vr-mode-spawn.test.cjs new file mode 100644 index 00000000..7937ffe4 --- /dev/null +++ b/tests/e2e/vr-mode-spawn.test.cjs @@ -0,0 +1,142 @@ +// 30b P4: ENTER INTERACT, SWITCH IN VR, SPAWN. The Quest report: "By default for games +// there should be interact mode in VR, but I should be able to switch to edit mode" and +// "When getting back to interactive mode, if the game should have a specific place where +// the character should stand, it should move." +// · a VR session on a GAME lands in Interact (vrControls.onVRSessionStart), +// · the LEFT Y button toggles Edit <-> Interact, with a haptic tick and a wrist label, +// · entering Interact resets the world to 1:1 and puts the FEET on the spawn, facing it, +// · the spawn is the scene's `play.spawn`, overridden by a module's api.setSpawn, +// · desktop Play spawns the play camera there; desktop Interact flies the editor view. +// Driven through the real per-frame path with a spec-accurate fake XR space (fakeXR.cjs). +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +const mode = (page) => page.evaluate(() => { let v; window.__stores.editorMode.subscribe((x) => (v = x))(); return v; }); +const near = (a, b, eps = 0.05) => Math.abs(a - b) < eps; +const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a)); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await A.page.waitForTimeout(400); + + // ---- 0. no spawn, not a game: a session start keeps Edit -------------------------------- + await A.page.evaluate(() => window.__stores.isVRMode.set(true)); + await xr.install(A.page); + await xr.installSpace(A.page, { head: [0.4, 1.6, 0.2], yaw: 0.3 }); + await A.page.evaluate(() => window.__stores.vrControls.onVRSessionStart()); + h.check((await mode(A.page)) === 'edit', 'a plain scene: entering VR stays in Edit'); + h.check(!(await A.page.evaluate(() => window.__stores.vrControls.sceneIsGame())), 'premise: the scene is not a game'); + + // ---- 1. a game (it names a spawn): entering VR lands in Interact, ON the spawn ------------- + const SPAWN = { position: [3, 0, -4], yaw: Math.PI / 2 }; + await A.page.evaluate((spawn) => window.__stores.scenePhysics.setScenePhysics({ play: { spawn } }), SPAWN); + // the world was left grabbed in Edit: Interact must put it back to 1:1 + await A.page.evaluate(() => { + let rig; + window.__stores.worldRig.subscribe((x) => (rig = x))(); + rig.scale.setScalar(2.5); + rig.position.set(1, 0, 1); + rig.updateMatrixWorld(true); + }); + await A.page.evaluate(() => window.__stores.vrControls.onVRSessionStart()); + await A.page.waitForTimeout(300); + h.check((await mode(A.page)) === 'interact', 'a game: entering VR lands in INTERACT'); + let head = await xr.head(A.page); + h.check(near(head.x, 3) && near(head.z, -4), `the player stands ON the spawn (head ${head.x.toFixed(2)}, ${head.z.toFixed(2)})`); + h.check(near(head.y - 1.6, 0), `the FEET are at the spawn height (feet ${(head.y - 1.6).toFixed(2)})`); + h.check(near(wrap(head.yaw - SPAWN.yaw), 0, 0.02), `facing the spawn's yaw (${head.yaw.toFixed(3)})`); + const rig = await A.page.evaluate(() => { let r; window.__stores.worldRig.subscribe((x) => (r = x))(); return { s: r.scale.x, p: r.position.toArray() }; }); + h.check(rig.s === 1 && rig.p.every((v) => v === 0), `Interact put the world back to 1:1 (${JSON.stringify(rig)})`); + + // ---- 2. the LEFT Y button: to Edit, with a tick and the label ------------------------------- + let label = await A.page.evaluate(() => window.__stores.vrControls.vrModeLabelDebug()); + h.check(label.visible && label.text === 'INTERACT' && label.hand === 'left', `the wrist label says INTERACT on the left hand (${JSON.stringify(label)})`); + const before = await xr.pulses(A.page); + await xr.button(A.page, 'left', 5, true); + await A.page.waitForTimeout(200); + await xr.button(A.page, 'left', 5, false); + await A.page.waitForTimeout(200); + h.check((await mode(A.page)) === 'edit', 'left Y: Interact -> Edit'); + const after = await xr.pulses(A.page); + h.check(after.left === before.left + 1, `a haptic tick on the left hand (${before.left} -> ${after.left})`); + label = await A.page.evaluate(() => window.__stores.vrControls.vrModeLabelDebug()); + h.check(label.text === 'EDIT', `the label follows: ${label.text}`); + h.check(!(await A.page.evaluate(() => { let v; window.__stores.vrMenuOpen.subscribe((x) => (v = x))(); return v; })), 'the radial menu (right B by default) did not open'); + + // walk away in Edit, then Y again: Interact puts you back on the spawn + await A.page.evaluate(() => { + const r = window.__fakeXR.renderer; + r.xr.setReferenceSpace(r.xr.getReferenceSpace().getOffsetReferenceSpace(new XRRigidTransform({ x: -2, y: 0, z: 3 }))); + }); + head = await xr.head(A.page); + h.check(!near(head.x, 3), `premise: moved off the spawn in Edit (x ${head.x.toFixed(2)})`); + await xr.button(A.page, 'left', 5, true); + await A.page.waitForTimeout(200); + await xr.button(A.page, 'left', 5, false); + await A.page.waitForTimeout(200); + head = await xr.head(A.page); + h.check((await mode(A.page)) === 'interact' && near(head.x, 3) && near(head.z, -4), `left Y back to Interact re-spawns (${head.x.toFixed(2)}, ${head.z.toFixed(2)})`); + + // ---- 3. a menu on the LEFT hand moves the mode button to the right B ---------------------- + await A.page.evaluate(() => window.__stores.vrMenuHand.set('left')); + await xr.button(A.page, 'right', 5, true); + await A.page.waitForTimeout(200); + await xr.button(A.page, 'right', 5, false); + await A.page.waitForTimeout(200); + h.check((await mode(A.page)) === 'edit', 'menu on the left: the RIGHT B toggles the mode'); + await A.page.evaluate(() => window.__stores.vrMenuHand.set('right')); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + + // ---- 4. a module's api.setSpawn overrides the scene's; teleport moves you now -------------- + await A.page.evaluate(() => { + window.__stores.moduleSDK.initModules([{ id: 'spawntest', name: 'Spawn test', version: '1', register(api) { window.__spawnApi = api; } }]); + }); + const ok = await A.page.evaluate(() => window.__spawnApi.setSpawn([-5, 0, 2], Math.PI)); + h.check(ok === true, 'api.setSpawn accepts [x, y, z] + yaw'); + head = await xr.head(A.page); + h.check(near(head.x, 3), 'without {teleport} a new spawn is a checkpoint: nobody moves yet'); + await A.page.evaluate(() => window.__spawnApi.setSpawn([-5, 0, 2], Math.PI, { teleport: true })); + await A.page.waitForTimeout(100); + head = await xr.head(A.page); + h.check(near(head.x, -5) && near(head.z, 2) && near(head.y, 1.6), `{teleport: true} moves the player there now (${head.x.toFixed(2)}, ${head.y.toFixed(2)}, ${head.z.toFixed(2)})`); + h.check(near(Math.abs(wrap(head.yaw)), Math.PI, 0.02), `...facing the module's yaw (${head.yaw.toFixed(3)})`); + h.check(!(await A.page.evaluate(() => window.__spawnApi.setSpawn([1, NaN, 2]))), 'a malformed spawn is refused'); + await A.page.evaluate(() => window.__stores.moduleSDK.deactivateModule('spawntest')); + const back = await A.page.evaluate(() => window.__stores.playSettings.resolvePlaySettings((() => { let v; window.__stores.globalScene.subscribe((x) => (v = x))(); return v; })()).spawn); + h.check(back && back.position[0] === 3, `disabling the module drops its spawn: the scene's is back (${JSON.stringify(back)})`); + + await xr.uninstall(A.page); + await A.page.evaluate(() => window.__stores.isVRMode.set(false)); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await A.page.waitForTimeout(300); + + // ---- 5. desktop Play spawns the play camera on the scene's spawn ------------------------------ + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + await A.page.waitForTimeout(500); + const cam = await A.page.evaluate(() => { + let c; + window.__stores.playerCam.subscribe((x) => (c = x))(); + const THREE = window.__stores.THREE; + const p = c.getWorldPosition(new THREE.Vector3()); + const f = new THREE.Vector3(0, 0, -1).applyQuaternion(c.getWorldQuaternion(new THREE.Quaternion())); + return { p: p.toArray(), yaw: Math.atan2(-f.x, -f.z) }; + }); + h.check(near(cam.p[0], 3) && near(cam.p[2], -4) && near(cam.p[1], 1.7), `desktop Play: the camera's eye is on the spawn (${cam.p.map((v) => v.toFixed(2))})`); + h.check(near(wrap(cam.yaw - SPAWN.yaw), 0, 0.02), `desktop Play: facing the spawn's yaw (${cam.yaw.toFixed(3)})`); + await A.page.evaluate(() => window.__stores.isLocked.set(false)); + await A.page.waitForTimeout(400); + + // ---- 6. desktop Interact flies the editor view there -------------------------------------------- + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.waitForTimeout(900); + const ed = await A.page.evaluate(() => { let c; window.__stores.globalCamera.subscribe((x) => (c = x))(); return c.position.toArray(); }); + h.check(near(ed[0], 3, 0.2) && near(ed[2], -4, 0.2), `desktop Interact: the editor view moved to the spawn (${ed.map((v) => v.toFixed(2))})`); + await A.page.evaluate(() => { + window.__stores.objectActions.setEditorMode('edit'); + window.__stores.scenePhysics.setScenePhysics({ play: { spawn: null } }); + }); + + await h.finish(browser); +}); From af38e5550690f2514af1ef341eb16a2a6987b46b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:33:52 +0300 Subject: [PATCH 6/6] [fix] 30b P5: module content follows the VR world (Untangle's dots spin with it) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Quest report: "When I spin the world around, the untangled dots do not spin around." The world gestures transform `world-grab-rig`; module viewport content lives at the SCENE ROOT (golden rule 5 keeps it out of objectsGroup), outside the rig. - moduleWorld.js (a leaf): a `module-world-root` group INSIDE the rig (Scene.svelte, a sibling of objectsGroup — never serialised or sent), and every REGISTERED module group (registerInteractiveGroup / registerSystemGroup / registerListedGroup) is re-homed under it as it reaches the scene root (childadded + registry revision), local transform kept — desktop (rig = identity) moves nothing, VR carries it. For every module at once, no module change: `getObjectByName` still finds it, the scene instance's remove() also takes a re-homed group out, and core's scene-root scans (playSettings.playPublishers, moduleContent's rows) read the module root too. Unregistered content stays put. - New e2e module-world-root (11): an inline module's groups land under the root (also a group added before its name was registered), an unregistered one does not, nothing enters objectsGroup, the dot follows a 90-degree spin + x2 scale of the rig, the Module content rows and the play publishers still find them, api.scene().remove works, disable removes them. - Counterfactual: adoptModuleGroups a no-op -> "a registered group lands under the world rig's module root", "...also when added BEFORE its name was registered" and "the dot followed (1.000,1.000,0.000)" red. - Held: object-list-modules 25/25 (= base, UNTANGLE_ZIP/TPSCENE from the sibling checkouts); svelte-check 334/47; vitest 244; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 5 +- src/components/Scene.svelte | 6 ++ src/lib/moduleContent.js | 4 +- src/lib/moduleWorld.js | 122 +++++++++++++++++++++++++ src/lib/playSettings.js | 6 +- tests/e2e/module-world-root.test.cjs | 127 +++++++++++++++++++++++++++ 6 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 src/lib/moduleWorld.js create mode 100644 tests/e2e/module-world-root.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 239477a5..afdf4daf 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -479,9 +479,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/decimate'), import('./lib/playCursor'), import('./lib/gameStorage'), + import('./lib/moduleWorld'), import('./lib/playSpawn') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, 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, meshToolParamsLib, 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, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, playSpawnLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, 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, meshToolParams: meshToolParamsLib, 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, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, playSpawn: playSpawnLib } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, 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, meshToolParamsLib, 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, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, moduleWorldLib, playSpawnLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, 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, meshToolParams: meshToolParamsLib, 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, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib } }) } }) diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 5fab55d8..1ae43666 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -31,6 +31,7 @@ import { sceneHits } from '$lib/scenePick'; import { pickStack, chooseInStack } from '$lib/selectThrough'; import { tickModuleProxy, selectModuleGroup, moduleGroupOf } from '$lib/moduleContent'; + import { setModuleWorldRoot } from '$lib/moduleWorld'; import { startPlayInteract, tickPlayInteract, stopPlayInteract, carriedUuid, editorInteractActive, cursorGrabStart, cursorGrabMove, cursorGrabEnd, interactClick } from '$lib/playInteract'; import { registerKeySessionProbe } from '$lib/shortcuts'; import { startKnock, tickKnock, stopKnock } from '$lib/knock'; @@ -1600,6 +1601,11 @@ sceneObjects — they'd leak into GLTF sync). oncreate passes the ref DIRECTLY (the { ref } destructure trap, N1). --> setParticleRoot(ref)} /> + + + setModuleWorldRoot(ref)} /> {#if !$isLocked && !$isVRMode} diff --git a/src/lib/moduleContent.js b/src/lib/moduleContent.js index 457e8afc..580d76e9 100644 --- a/src/lib/moduleContent.js +++ b/src/lib/moduleContent.js @@ -107,7 +107,9 @@ export function moduleContentRows(scene) { const rows = []; for (const entry of moduleGroupList()) { const root = scene.getObjectByName(entry.name); - if (!root || root.parent !== scene) continue; + // 30b P5: a registered group is re-homed under the world rig's module root + // (moduleWorld.js — named, not imported, because that leaf imports this one) + if (!root || (root.parent !== scene && root.parent?.name !== 'module-world-root')) continue; /** @type {{name: string, depth: number, uuid: string}[]} */ const children = []; let total = 0; diff --git a/src/lib/moduleWorld.js b/src/lib/moduleWorld.js new file mode 100644 index 00000000..00025850 --- /dev/null +++ b/src/lib/moduleWorld.js @@ -0,0 +1,122 @@ +// 30b P5: MODULE CONTENT FOLLOWS THE WORLD — a leaf (svelte/store + sceneStore + +// moduleContent's registry). +// +// THE FINDING (the Quest report: "When I spin the world around, the untangled dots do not +// spin around"). The VR world gestures (71: two-grip scale/rotate/pan) transform the +// `world-grab-rig` group, and only what lives INSIDE it moves: objectsGroup, the grid, the +// notes, the particles. A module's viewport content — the Untangle board, the dungeon, the +// piano — lives at the SCENE ROOT (golden rule 5: never in objectsGroup, or it would enter +// GLTF sync), so it stayed pinned to the room while the rest of the world spun away. +// +// THE FIX, for every module at once: a `module-world-root` group INSIDE the rig (a sibling +// of objectsGroup, so golden rule 5 still holds — nothing here is serialised or sent), and +// every REGISTERED module group (registerInteractiveGroup / registerSystemGroup / +// registerListedGroup — the names the Module content list already knows) is re-homed +// under it the moment it reaches the scene root. Its LOCAL transform is untouched, so on +// the desktop (rig = identity) nothing moves at all, and in VR it rides the rig. A module +// converting world hits with `group.worldToLocal` (Untangle does) keeps working unchanged, +// because every matrix it reads is the true one. +// +// COMPATIBILITY, the two things a module can still do to "its scene-root group": +// · `api.scene().remove(group)` — Object3D.remove only removes DIRECT children, so the +// scene instance gets a remove that also takes a re-homed group back out; +// · `scene.getObjectByName(name)` — a traversal, so it still finds it. +// Core's own scene-root scans (playSettings.playPublishers, moduleContent's rows) read +// `moduleWorldChildren()` as well as `scene.children`. +// Content a module adds WITHOUT registering a name cannot be told apart from the app's own +// scene-root helpers, so it stays where it was put. +import { get } from 'svelte/store'; +import { globalScene } from '../stores/sceneStore'; +import { moduleGroupList, moduleGroupsRevision, moduleContentRows } from './moduleContent'; + +export const MODULE_WORLD_ROOT = 'module-world-root'; + +/** @type {any} */ let root = null; +/** @type {any} */ let patchedScene = null; +let started = false; + +/** the group inside the world rig that module content is re-homed under (null before mount) */ +export function moduleWorldRoot() { + return root; +} + +/** the re-homed module groups (for scene-root scans that used to read scene.children) */ +export function moduleWorldChildren() { + return root ? root.children : []; +} + +/** is `object` a module group at the top of the module world (or the scene root)? @param {any} object @param {any} scene */ +export function isModuleTopLevel(object, scene) { + return !!object && (object.parent === scene || (!!root && object.parent === root)); +} + +/** @param {any} object */ +function registeredName(object) { + if (!object?.name) return false; + return moduleGroupList().some((entry) => entry.name === object.name); +} + +/** move every registered module group sitting at the scene root under the module root */ +export function adoptModuleGroups() { + const scene = /** @type {any} */ (get(globalScene)); + if (!scene || !root) return 0; + let moved = 0; + for (const child of [...scene.children]) { + if (child === root || !registeredName(child)) continue; + // .add re-parents (three removes it from the scene first) and KEEPS the local + // transform — which is the point: the group's numbers become rig-relative + root.add(child); + moved++; + } + return moved; +} + +/** give the scene a remove() that also finds a re-homed module group @param {any} scene */ +function patchRemove(scene) { + if (!scene || patchedScene === scene) return; + patchedScene = scene; + const nativeRemove = scene.remove; + /** @param {...any} objects */ + scene.remove = function (...objects) { + for (const object of objects) { + if (root && object?.parent === root) root.remove(object); + else nativeRemove.call(this, object); + } + return this; + }; + // a registered group arriving later is re-homed as it lands (after the add returns — + // never re-parent inside three's own add loop) + scene.addEventListener?.('childadded', (/** @type {any} */ event) => { + if (!registeredName(event.child)) return; + queueMicrotask(adoptModuleGroups); + }); +} + +/** Scene mounts the root inside the world rig. @param {any} group */ +export function setModuleWorldRoot(group) { + root = group; + if (root) root.name = MODULE_WORLD_ROOT; + start(); + adoptModuleGroups(); +} + +function start() { + if (started || typeof window === 'undefined') return; + started = true; + globalScene.subscribe((scene) => { + patchRemove(scene); + adoptModuleGroups(); + }); + // a name registered AFTER its group was added (register order varies by module) + moduleGroupsRevision.subscribe(() => adoptModuleGroups()); +} + +/** test/debug view: what is re-homed, and what the object list's Module content lists */ +export function moduleWorldDebug() { + const scene = /** @type {any} */ (get(globalScene)); + return { + root: !!root, + rehomed: moduleWorldChildren().map((/** @type {any} */ c) => c.name), + rows: moduleContentRows(scene).map((/** @type {any} */ row) => row.name) + }; +} diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index 39bfe251..973aed71 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -2,6 +2,7 @@ import { get, writable } from 'svelte/store'; import { scenePlay } from './scenePhysics'; import { showToast } from '../stores/appStore'; import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; +import { moduleWorldChildren } from './moduleWorld'; /** * 30b P4: a spawn point set at RUNTIME by a module (`api.setSpawn(position, yaw)`) — a @@ -50,7 +51,10 @@ let warnedMultiple = false; */ export function playPublishers(scene) { if (!scene?.children) return []; - const found = scene.children.filter((/** @type {any} */ child) => child?.userData?.play); + // 30b P5: registered module groups live under the world rig's module root now + const found = [...scene.children, ...moduleWorldChildren()].filter( + (/** @type {any} */ child) => child?.userData?.play + ); found.sort((/** @type {any} */ a, /** @type {any} */ b) => { if (a.name === 'dungeon-module') return -1; if (b.name === 'dungeon-module') return 1; diff --git a/tests/e2e/module-world-root.test.cjs b/tests/e2e/module-world-root.test.cjs new file mode 100644 index 00000000..fd5d9bc3 --- /dev/null +++ b/tests/e2e/module-world-root.test.cjs @@ -0,0 +1,127 @@ +// 30b P5: MODULE CONTENT FOLLOWS THE WORLD. The Quest report: "When I spin the world +// around, the untangled dots do not spin around." The VR world gestures transform the +// `world-grab-rig` group; module viewport content lived at the SCENE ROOT (golden rule 5 +// keeps it out of objectsGroup), outside the rig, so it stayed pinned to the room. Every +// REGISTERED module group is now re-homed under `module-world-root` inside the rig — for +// every module at once — while staying out of objectsGroup (nothing replicates or saves), +// and the module's own `scene.remove(group)` / `getObjectByName` keep working. +// Driven with an INLINE module through the real SDK (moduleSDK.initModules). +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // a module that builds a board (registered BEFORE it is added) and a late board + // (added first, registered after), plus a stray group it never registers + await A.page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + const mod = { + id: 'worldroottest', + name: 'World root test', + version: '1', + register(api) { + window.__wrApi = api; + api.registerInteractiveGroup('wr-board'); + const scene = api.scene(); + const board = new THREE.Group(); + board.name = 'wr-board'; + board.userData.play = { grounded: true }; + const dot = new THREE.Mesh(new THREE.SphereGeometry(0.1), new THREE.MeshBasicMaterial()); + dot.name = 'wr-dot'; + dot.position.set(1, 1, 0); + board.add(dot); + scene.add(board); + const late = new THREE.Group(); + late.name = 'wr-late'; + scene.add(late); + api.registerListedGroup('wr-late', { label: 'Late board' }); + const stray = new THREE.Group(); + stray.name = 'wr-stray'; + scene.add(stray); + } + }; + s.moduleSDK.initModules([mod]); + }); + await A.page.waitForTimeout(300); + + const read = () => + A.page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const scene = get(s.globalScene); + const THREE = s.THREE; + const board = scene.getObjectByName('wr-board'); + const late = scene.getObjectByName('wr-late'); + const stray = scene.getObjectByName('wr-stray'); + const dot = scene.getObjectByName('wr-dot'); + let inObjects = false; + get(s.objectsGroup).traverse((n) => { if (n.name === 'wr-board') inObjects = true; }); + return { + board: board?.parent?.name ?? null, + late: late?.parent?.name ?? null, + stray: stray?.parent === scene ? 'scene' : stray?.parent?.name ?? null, + dotWorld: dot ? dot.getWorldPosition(new THREE.Vector3()).toArray() : null, + inObjects, + publishers: s.playSettings.playPublishers(scene).map((p) => p.name) + }; + }); + + // ---- 1. registered groups are re-homed inside the rig; the stray one is not ------------------- + let r = await read(); + h.check(r.board === 'module-world-root', `a registered group lands under the world rig's module root (${r.board})`); + h.check(r.late === 'module-world-root', `...also when it was added BEFORE its name was registered (${r.late})`); + h.check(r.stray === 'scene', `an UNREGISTERED scene-root group stays where it was put (${r.stray})`); + h.check(!r.inObjects, 'golden rule 5: module content never enters objectsGroup'); + const rigParent = await A.page.evaluate(() => { + let v; + window.__stores.globalScene.subscribe((x) => (v = x))(); + return v.getObjectByName('module-world-root')?.parent?.name ?? null; + }); + h.check(rigParent === 'world-grab-rig', `the module root is a child of the world rig (${rigParent})`); + h.check(r.dotWorld && Math.abs(r.dotWorld[0] - 1) < 1e-6 && Math.abs(r.dotWorld[1] - 1) < 1e-6, `at 1:1 nothing moved (dot at ${r.dotWorld})`); + h.check(r.publishers.includes('wr-board'), `a re-homed group still publishes the play contract (${r.publishers})`); + + // ---- 2. spin + scale the world: the dot goes with it -------------------------------------------- + await A.page.evaluate(() => { + const s = window.__stores; + let rig; + s.worldRig.subscribe((x) => (rig = x))(); + rig.quaternion.setFromAxisAngle(new s.THREE.Vector3(0, 1, 0), Math.PI / 2); + rig.scale.setScalar(2); + rig.updateMatrixWorld(true); + }); + r = await read(); + // R(90deg about +Y) maps (1,1,0) to (0,1,-1); scale 2 -> (0,2,-2) + h.check( + r.dotWorld && Math.abs(r.dotWorld[0]) < 1e-6 && Math.abs(r.dotWorld[1] - 2) < 1e-6 && Math.abs(r.dotWorld[2] + 2) < 1e-6, + `the world spun and scaled: the module's dot followed (${r.dotWorld.map((v) => v.toFixed(3))})` + ); + await A.page.evaluate(() => window.__stores.vrControls.resetWorldRig()); + + // ---- 3. the Module content list still lists it ------------------------------------------------------ + const rows = await A.page.evaluate(() => window.__stores.moduleWorld.moduleWorldDebug().rows); + h.check(rows.includes('wr-board') && rows.includes('wr-late'), `the object list's Module content rows still find both (${rows})`); + + // ---- 4. the module's own scene.remove(group) still takes it out --------------------------------- + const removed = await A.page.evaluate(() => { + const scene = window.__wrApi.scene(); + const late = scene.getObjectByName('wr-late'); + scene.remove(late); + return { gone: !scene.getObjectByName('wr-late'), parent: late.parent }; + }); + h.check(removed.gone && removed.parent === null, 'api.scene().remove(group) removes a re-homed group'); + + // ---- 5. disabling the module takes its content away --------------------------------------------------- + await A.page.evaluate(() => { + window.__stores.moduleSDK.deactivateModule('worldroottest'); + const scene = window.__wrApi.scene(); + scene.remove(scene.getObjectByName('wr-stray')); + }); + await A.page.waitForTimeout(200); + r = await read(); + h.check(r.board === null, `deactivating the module removes its re-homed group (${r.board})`); + + await h.finish(browser); +});