diff --git a/src/App.svelte b/src/App.svelte
index 40cf9592..afdf4daf 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -478,9 +478,11 @@ 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/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, 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/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 @@
diff --git a/src/components/play/VRSelectionShell.svelte b/src/components/play/VRSelectionShell.svelte
index 24258eed..faa46c19 100644
--- a/src/components/play/VRSelectionShell.svelte
+++ b/src/components/play/VRSelectionShell.svelte
@@ -5,6 +5,8 @@
import { selectedObject, isVRMode, objectsGroup, vrWireframeSelection } from '../../stores/sceneStore'
import { editingObject } from '$lib/meshEdit'
import { faceEditObject } from '$lib/faceEdit'
+ // 30b P1: a selection indicator is editor scaffolding - none in Interact/Play
+ import { editorHelpersShown } from '$lib/helperLayer'
// VR selection indicator (101/110): the desktop outline is a postprocessing
// composer and does NOT render in WebXR. Default is a two-tone wireframe —
@@ -106,7 +108,7 @@
// the live structure — a static indicator would just lag behind edits
const editing = !!$editingObject || !!$faceEditObject
const active =
- $isVRMode && !editing && !!target?.uuid && !!$objectsGroup?.getObjectByProperty('uuid', target.uuid)
+ $isVRMode && $editorHelpersShown && !editing && !!target?.uuid && !!$objectsGroup?.getObjectByProperty('uuid', target.uuid)
group.visible = active
if (!active) return
const style = $vrWireframeSelection ? 'wire' : 'shell'
diff --git a/src/extensions/Grid.svelte b/src/extensions/Grid.svelte
index c07a0725..1abce886 100644
--- a/src/extensions/Grid.svelte
+++ b/src/extensions/Grid.svelte
@@ -1,11 +1,12 @@
- {#if showGrid && ($isLocked !== true || $helpersInPlay)}
+ {#if showGrid && $editorHelpersShown}
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/colliderHelpers.js b/src/lib/colliderHelpers.js
index 5c7aaeb4..5205f0f9 100644
--- a/src/lib/colliderHelpers.js
+++ b/src/lib/colliderHelpers.js
@@ -7,6 +7,7 @@ import { colliderSpecOf } from './colliderSpec';
import { wireframeActive } from './viewMode';
import { scenePhysicsGround } from './scenePhysics';
import { safeStorage } from './safeStorage';
+import { helpersHidden } from './helperLayer';
// CL-A A7: collider visualization (the lightHelpers pattern). Per tracked
// object a wireframe built FROM colliderSpecOf — the SAME spec physics
@@ -233,7 +234,8 @@ const followQuat = new THREE.Quaternion();
* entirely in wireframe view mode (they'd render as junk). */
export function updateColliderHelpers() {
if (!proxyRoot) return;
- proxyRoot.visible = (entries.size > 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/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/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/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/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/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/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/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 2f739586..973aed71 100644
--- a/src/lib/playSettings.js
+++ b/src/lib/playSettings.js
@@ -1,6 +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';
+import { moduleWorldChildren } from './moduleWorld';
+
+/**
+ * 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.
//
@@ -28,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;
@@ -45,7 +71,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 +84,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,7 +107,13 @@ 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;
}
+ 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/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/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..a5d8c354 100644
--- a/src/lib/vrControls.js
+++ b/src/lib/vrControls.js
@@ -39,7 +39,13 @@ import {
vrToolMode,
vrTargetHz,
vrSleeveEnabled,
+ editorMode,
peerHandStyle, pokeScene } from '../stores/sceneStore';
+import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip';
+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';
import { paletteColorAt, barValueAt } from './vrPalette';
import { recordMaterialChange, setMaterialParam } from './materialsHandler';
@@ -117,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';
@@ -128,6 +136,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,
@@ -233,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();
@@ -310,6 +319,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 +340,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;
@@ -800,7 +813,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;
@@ -926,6 +940,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
@@ -1572,11 +1740,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));
@@ -1586,6 +1755,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
@@ -2224,13 +2404,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;
@@ -2250,6 +2430,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 = {
@@ -2263,6 +2445,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
@@ -2281,7 +2464,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(),
@@ -2292,7 +2478,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 */
@@ -2359,7 +2587,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);
@@ -2419,7 +2647,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,
@@ -2435,7 +2663,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;
@@ -3118,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
@@ -3185,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;
@@ -3533,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/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/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/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);
+});
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);
+});
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/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);
+});
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);
+});
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));
+ });
+});
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);
+ });
+});