From 7669ad73a5c660bb6e092728346b8e14dce0c148 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:52:03 +0300 Subject: [PATCH 1/3] =?UTF-8?q?[feat]=2060=20Q1:=20Play=20here=20=E2=80=94?= =?UTF-8?q?=20start=20Play=20from=20the=20editor=20camera,=20Stop=20brings?= =?UTF-8?q?=20the=20view=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The player starts where the editor camera is: X/Z under it, the feet on the first solid surface straight below (scene meshes on the shared BVH trees, then the physics ground plane; hidden objects, sensors, water and reference images do not count), facing the view's heading. Nothing below → the scene's spawn; no spawn either → the camera's eye. A session-only override that currentSpawn() returns first, so desktop Play, the VR walker, api.respawnPlayer and the game shell all honour it for that round; leaving Play clears it and restores the editor camera, its orbit target and the selection. Entry points: Ctrl+Shift+Enter, a row in Play's right-click / phone long-press menu, an optional toolbar roster button (Customize), phone More ▸ Tools & View, the viewport menu's Tools, and the VR radial Tools ▸ Play here / Back to edit (Interact from where you stand; Edit restores the exact reference space and world grab). Height follows the scene's locomotion: desktop Play is grounded by default (37 R24 pins the eye), and the VR walker stands on real surfaces only while a simulation runs. Tests: unit playHere (the spawn raycast through a real three scene), e2e play-here (desktop + phone N6 folded) and play-here-iwer (IWER, applied-offset spy). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/Controls.svelte | 20 ++- src/components/phone/PhoneShell.svelte | 4 + src/components/ui/Icon.svelte | 7 +- src/lib/debugHooks.js | 2 + src/lib/playHere.js | 189 ++++++++++++++++++++ src/lib/playHereCore.js | 68 +++++++ src/lib/playSpawn.js | 22 ++- src/lib/shortcuts.js | 13 ++ src/lib/ui/phoneBar.js | 2 +- src/lib/vr/frame.js | 3 +- src/lib/vr/locomotion.js | 4 +- src/lib/vr/modes.js | 83 ++++++++- src/lib/vrControls.js | 4 +- src/lib/vrRadialMenu.js | 25 ++- tests/e2e/play-here-iwer.test.cjs | 147 ++++++++++++++++ tests/e2e/play-here.test.cjs | 235 +++++++++++++++++++++++++ tests/unit/phoneBar.test.js | 2 +- tests/unit/playHere.test.js | 130 ++++++++++++++ 18 files changed, 949 insertions(+), 11 deletions(-) create mode 100644 src/lib/playHere.js create mode 100644 src/lib/playHereCore.js create mode 100644 tests/e2e/play-here-iwer.test.cjs create mode 100644 tests/e2e/play-here.test.cjs create mode 100644 tests/unit/playHere.test.js diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index b330b8cbf..ac75c7478 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -65,6 +65,8 @@ import { dockShownKeys, dockOccupants, bottomInset, FLOW_FAMILY, armDockMode, DOCK_TITLES } from '$lib/bottomDock'; import { togglePanel } from '$lib/panelToggles'; import { requestPlay, willEnterXR, willEnterAR, xrSessionFailed, playAsItems } from '$lib/playMode'; + // 60 Q1: Play here — a roster button (Customize), a row in Play's own menu, Ctrl+Shift+Enter + import { playHere, playHereItems, playingHere } from '$lib/playHere'; // 30 P2: Test play — the play button's right-click row beside the game chip's button import { testPlay } from '$lib/gamePresence'; import { hudIsGame } from '$lib/hudDocs'; @@ -1035,6 +1037,8 @@ // 41b H7: the "Play as" rows live in $lib/playMode (`playAsItems`) — the phone bar's Play // opens the same ones, so the two buttons write the same choice the same way ...playAsItems(), + // 60 Q1: Play here (the phone's Play menu carries the same row) + ...playHereItems(), // 30 P2: TEST PLAY — back to the menu, into Play, the Start screen. Offered only // where it means something: a scene with no game shell has no menu to start from. ...($hudIsGame @@ -1344,6 +1348,18 @@ tint: () => ICON_OFF, run: () => redo() }, + // 60 Q1: PLAY HERE — an OPTIONAL roster button (Customize toolbar / the "+" list place it): + // Play with the player where the editor camera is, and the view comes back on Stop. Not on + // the default bar, so a profile that never asks for it is byte-identical; the same act is + // Play's right-click row and Ctrl+Shift+Enter. + playhere: { + title: 'Play here — start from this view (Ctrl+Shift+Enter)', + label: 'Play here', + slot: 'play-here-button', + icon: 'map-pin', + tint: () => ($playingHere ? ICON_ON : ICON_OFF), + run: () => playHere() + }, multiselect: { get title() { return $multiSelectMode @@ -1366,7 +1382,9 @@ /** 42 I9: the bell and the peers trigger open their panels from the top-right corner, so they * may only live in the top-right group (moved, removed, re-added there — never elsewhere) */ const HOME_ONLY: Record = { notifications: 'top', peers: 'top' }; - const ROSTER_IDS = [...DEFAULT_ORDER, ...OPTIONAL_VIEWS, ...CORNER_LEFT, ...CORNER_RIGHT, ...TOP_DEFAULT, ...TOUCH_DEFAULT]; + /** 60 Q1: optional tool buttons (listed in Customize unchecked, like the optional views) */ + const OPTIONAL_TOOLS = ['playhere']; + const ROSTER_IDS = [...DEFAULT_ORDER, ...OPTIONAL_VIEWS, ...OPTIONAL_TOOLS, ...CORNER_LEFT, ...CORNER_RIGHT, ...TOP_DEFAULT, ...TOUCH_DEFAULT]; /** 42 I7: the name a MENU prints for a button (its short label, else its title) */ function labelOf(id: string) { return id === SPACER ? 'Play' : (BUTTONS[id]?.label ?? BUTTONS[id]?.title ?? id); diff --git a/src/components/phone/PhoneShell.svelte b/src/components/phone/PhoneShell.svelte index 8d945ce5f..5fc5bbc08 100644 --- a/src/components/phone/PhoneShell.svelte +++ b/src/components/phone/PhoneShell.svelte @@ -86,6 +86,7 @@ import { phoneBarTap, openPhoneTab, closePhoneTab, phoneDockShown, phoneTabsOpen } from '$lib/phoneDock'; import { toggleViewportFocus } from '$lib/focusMode'; import { requestPlay, willEnterXR, willEnterAR, playAsItems } from '$lib/playMode'; + import { playHere, playHereItems } from '$lib/playHere'; // 60 Q1 import { visibleDockKey, DOCK_TITLES, dockMinimized, dockHeight } from '$lib/bottomDock'; import { micActive, pttActive, toggleMic } from '$lib/voiceChat'; import { aiReady } from '$lib/ai/providers'; @@ -441,6 +442,7 @@ const at = list.indexOf('play'); return [ ...playAsItems(), + ...playHereItems(), // 60 Q1 ...($hudIsGame ? [ { section: 'Game' }, @@ -496,6 +498,8 @@ * @type {Record void, pressed?: () => boolean, disabled?: () => boolean, badge?: () => number, dot?: () => string}>} */ const ACTIONS = { play: { label: 'Play', icon: 'play', run: fromMore(() => requestPlay()) }, + // 60 Q1: Play from where the camera is (More ▸ Tools & View; placeable on the bar) + playhere: { label: 'Play here', short: 'Here', hint: 'Play here — start from this view', icon: 'map-pin', run: fromMore(() => playHere()) }, add: { label: 'Add', icon: 'plus', run: () => add() }, // 41b H1/H5: Objects, Configure Scene and Notes are dock tabs — the bar shows / minimizes them objects: { label: 'Objects', icon: 'list', run: fromMore(() => phoneBarTap('objects')), pressed: () => $visibleDockKey === 'objects' && !$dockMinimized }, diff --git a/src/components/ui/Icon.svelte b/src/components/ui/Icon.svelte index 9f039dc0b..adafb5fcf 100644 --- a/src/components/ui/Icon.svelte +++ b/src/components/ui/Icon.svelte @@ -270,7 +270,10 @@ X, Zap, ZoomIn, - ZoomOut + ZoomOut, + // 60 Q1 + Q5: Play here, reference image + MapPin, + ImageUp } from '@lucide/svelte'; import ToolIcon from './ToolIcon.svelte'; import { iconSize, ICON_STROKE } from '$lib/ui/icons.js'; @@ -393,6 +396,8 @@ 'lock-open': LockOpen, magnet: Magnet, map: MapIcon, + 'map-pin': MapPin, + 'image-up': ImageUp, 'maximize-2': Maximize2, 'minimize-2': Minimize2, menu: Menu, diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 46aa9ba1b..bd9e1094b 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -371,6 +371,8 @@ export const DEBUG_HOOKS = [ ['touchActions', () => import('./touchActions')], ['touchSpec', () => import('./touchSpec')], ['playMode', () => import('./playMode')], + // 60 Q1: Play here + ['playHere', () => import('./playHere')], // 36-export: the export builder, its stores, the export boot + runtime state ['exportBuilder', () => import('./export/exportBuilder.js')], ['exportStores', () => import('./export/exportStores.js')], diff --git a/src/lib/playHere.js b/src/lib/playHere.js new file mode 100644 index 000000000..f39e65eba --- /dev/null +++ b/src/lib/playHere.js @@ -0,0 +1,189 @@ +// 60 Q1: PLAY HERE — Play, with the player standing where the editor camera is looking from. +// Roblox Studio's "Play Here" / Godot's PlayAtCamPos: test the corner you are building without +// walking there from the spawn, and come back to exactly the view you left. +// +// The whole act is four things around the ordinary play press: +// 1. remember the editor camera (pose + orbit target) and the selection; +// 2. resolve a spawn from the camera (playSpawn.playHereSpawnFrom — pure, unit-tested): the +// feet on the first solid surface straight under the eye, facing the view's heading; nothing +// under it → the scene's spawn; no spawn either → the eye stays where the camera is; +// 3. hand that spawn to playSpawn as a session-only OVERRIDE — `currentSpawn()` is the one read +// every spawn path shares (PointerLockControls, the VR walker, api.respawnPlayer, the game +// shell), so a character controller, a game's restart and a fall out of the world all +// respawn HERE for this round, with no second code path; +// 4. `requestPlay()` — the same press as the FAB, so VR/AR vs desktop is still decided once. +// Leaving Play (any exit: Esc, the touch ✕, Back, a HUD quit) clears the override and puts the +// camera and the selection back. +// +// A near-LEAF on purpose (three, stores, playSpawn, playMode, bvhPicking, scenePhysics): it is +// imported from shortcuts.js, which sits in history.js's import subtree. The selection restore +// reaches objectActions by DYNAMIC import for that reason. +import * as THREE from 'three'; +import { get, writable } from 'svelte/store'; +import { editorCam, orbitControls, isLocked, isVRMode, objectsGroup, selectedObjects, selectedObject } from '../stores/sceneStore'; +import { requestPlay } from './playMode'; +import { desktopSpawn, setPlayHereSpawn, playHereSpawn, SPAWN_EYE } from './playSpawn'; +import { playHereSpawnFrom, headingOf, surfaceBelowIn } from './playHereCore'; +import { ensureBoundsTrees } from './bvhPicking'; +import { scenePhysicsGround } from './scenePhysics'; + +/** true from a Play here press until that Play ends (the toolbar cell's tint, the suites' view) */ +export const playingHere = writable(false); + +/** @type {{position: number[], quaternion: number[], target: number[] | null, selection: string[]} | null} */ +let saved = null; +/** the last resolution, for the suites and the debug hook */ +/** @type {{position: number[], yaw: number, source: string} | null} */ +let lastSpawn = null; + +/** + * The y of the first SOLID surface straight under `eye` — the scene's meshes (on the shared pick's + * BVH bounds trees) and the physics ground plane when it is on. Hidden objects, sensors, water and + * reference images do not count (playHereCore.notGround). null = nothing below. + * @param {number[]} eye + * @returns {number | null} + */ +export function surfaceBelow(eye) { + /** @type {any} */ + const group = get(objectsGroup); + if (group?.children?.length) ensureBoundsTrees(group); + return surfaceBelowIn(group, eye, get(scenePhysicsGround)); +} + +/** the editor camera as a Play here view: eye + heading @returns {{eye: number[], yaw: number} | null} */ +export function editorView() { + /** @type {any} */ + const cam = get(editorCam); + if (!cam?.isObject3D) return null; + cam.updateMatrixWorld(true); + const eye = cam.getWorldPosition(new THREE.Vector3()); + const q = cam.getWorldQuaternion(new THREE.Quaternion()); + return { eye: eye.toArray(), yaw: headingOf(q) }; +} + +/** resolve (without playing) where Play here would put the player — the suites' and the button's view */ +export function resolvePlayHere() { + const view = editorView(); + if (!view) return null; + return playHereSpawnFrom(view, surfaceBelow, desktopSpawn(), SPAWN_EYE); +} + +function snapshotEditor() { + /** @type {any} */ + const cam = get(editorCam); + /** @type {any} */ + const controls = get(orbitControls); + const set = get(selectedObjects); + /** @type {any} */ + const primary = get(selectedObject); + return { + position: cam.position.toArray(), + quaternion: cam.quaternion.toArray(), + target: controls?.target ? controls.target.toArray() : null, + selection: set.length ? set.map((/** @type {any} */ o) => o?.uuid ?? o).filter(Boolean) : primary?.uuid ? [primary.uuid] : [] + }; +} + +/** + * THE PRESS. Synchronous all the way to `requestPlay` (an XR session is only granted inside the + * gesture that asked for it). @returns {boolean} whether Play was asked for + */ +export function playHere() { + if (get(isLocked) === true || get(isVRMode)) return false; + /** @type {any} */ + const cam = get(editorCam); + if (!cam?.isObject3D) return false; + const spawn = resolvePlayHere(); + if (!spawn) return false; + saved = snapshotEditor(); + lastSpawn = spawn; + setPlayHereSpawn(spawn); + playingHere.set(true); + requestPlay(); + // a press that could not start Play (already in a transient exit, a refused session) must not + // leave the override standing for the NEXT ordinary Play: the queued replay sets isLocked + // within a macrotask, the XR path within its watchdog — both clear through the exit below + return true; +} + +/** put the editor camera and the selection back the way Play here found them */ +function restoreEditor() { + const was = saved; + saved = null; + if (!was) return; + /** @type {any} */ + const cam = get(editorCam); + if (cam?.isObject3D) { + cam.position.fromArray(was.position); + cam.quaternion.fromArray(was.quaternion); + cam.updateMatrixWorld(true); + } + /** @type {any} */ + const controls = get(orbitControls); + if (controls?.target && was.target) { + controls.target.fromArray(was.target); + // damping would otherwise ease the view from wherever it drifted + controls.update?.(); + if (cam?.isObject3D) { + cam.position.fromArray(was.position); + cam.quaternion.fromArray(was.quaternion); + } + } + if (was.selection.length) { + void import('./objectActions') + .then((m) => { + /** @type {any} */ + const group = get(objectsGroup); + const live = was.selection.filter((uuid) => !!group?.getObjectByProperty?.('uuid', uuid)); + const now = get(selectedObjects).map((/** @type {any} */ o) => o?.uuid ?? o); + if (!live.length || (live.length === now.length && live.every((uuid) => now.includes(uuid)))) return; + m.applySelectionSet(live); + }) + .catch(() => { + /* a selection that cannot come back is not worth an error */ + }); + } +} + +/** the end of a Play here round: whatever ended it */ +export function endPlayHere() { + if (!get(playingHere) && !playHereSpawn()) return; + setPlayHereSpawn(null); + playingHere.set(false); + restoreEditor(); +} + +/** the row Play's own menus carry (the desktop FAB's right-click, the phone bar's long press) */ +export function playHereItems() { + return [ + { section: 'Start' }, + { + label: 'Play here', + tooltip: 'Start Play where the camera is, facing the view — leaving Play brings this view back (Ctrl+Shift+Enter)', + action: () => void playHere() + } + ]; +} + +/** test/debug view */ +export function playHereDebug() { + return { playing: get(playingHere), override: playHereSpawn(), last: lastSpawn, saved }; +} + +// The exit, on the state and not on any one button (the playMode W4 rule): `isLocked` leaving +// `true` after a Play here press ends the round. A press that never reached Play (refused XR, no +// lock) is cleared when the watchdog / transient settles back to the editor. +if (typeof window !== 'undefined') { + let wasPlaying = get(isLocked) === true; + isLocked.subscribe((v) => { + const playing = v === true; + if (wasPlaying && !playing) endPlayHere(); + wasPlaying = playing; + }); + // a Play here that turned into an XR session ends with the session (isVRMode back to false) + let wasVR = get(isVRMode); + isVRMode.subscribe((v) => { + if (wasVR && !v && get(isLocked) !== true) endPlayHere(); + wasVR = !!v; + }); +} diff --git a/src/lib/playHereCore.js b/src/lib/playHereCore.js new file mode 100644 index 000000000..8739ae542 --- /dev/null +++ b/src/lib/playHereCore.js @@ -0,0 +1,68 @@ +// 60 Q1: THE PLAY HERE RULE — three only, no stores, so the unit layer runs it (playHere.js is the +// store-reading half; playSpawn re-exports the two pure functions). +import * as THREE from 'three'; + +/** + * Where Play here puts the player: the feet on the first solid surface straight under the eye, + * facing the view's heading; nothing under it → the scene's own spawn (with its own facing); no + * spawn either → the eye stays exactly where the camera is (feet = eye − eyeHeight), which is where + * a flying player would start anyway. + * @param {{eye: number[], yaw: number}} view the editor camera: eye + heading (rotation.y, 0 faces −Z) + * @param {(eye: number[]) => number | null} surfaceBelow y of the first solid surface under `eye`, or null + * @param {{position: number[], yaw: number} | null} fallback the scene's spawn + * @param {number} eyeHeight how far above the feet the eye stands (SPAWN_EYE on a screen, the head height in VR) + * @returns {{position: [number, number, number], yaw: number, source: 'surface' | 'spawn' | 'camera'}} + */ +export function playHereSpawnFrom(view, surfaceBelow, fallback, eyeHeight) { + const [x, y, z] = view.eye; + const yaw = Number.isFinite(view.yaw) ? view.yaw : 0; + const floor = surfaceBelow(view.eye); + if (floor != null && Number.isFinite(floor) && floor <= y) return { position: [x, floor, z], yaw, source: 'surface' }; + if (fallback) { + const [fx, fy, fz] = fallback.position; + return { position: [fx, fy, fz], yaw: Number.isFinite(fallback.yaw) ? fallback.yaw : 0, source: 'spawn' }; + } + return { position: [x, y - eyeHeight, z], yaw, source: 'camera' }; +} + +/** the heading of an orientation — rotation.y in YXZ order, which stays right when the view looks + * straight down (a forward vector has no horizontal part there) @param {any} quaternion */ +export function headingOf(quaternion) { + return new THREE.Euler().setFromQuaternion(quaternion, 'YXZ').y; +} + +/** something a player cannot stand on: hidden (itself or an ancestor), a sensor, water, a reference image + * @param {any} object @param {any} [root] where the walk stops */ +export function notGround(object, root = null) { + for (let node = object; node && node !== root; node = node.parent) { + if (node.visible === false) return true; + const data = node.userData ?? {}; + if (data.water || data.physics?.sensor || data.underlay) return true; + } + return false; +} + +/** + * The y of the first SOLID surface straight under `eye`: the meshes under `root` and the physics + * ground plane when it is on and below the eye. null = nothing below. + * @param {any} root the scene objects group (or null) + * @param {number[]} eye + * @param {{enabled?: boolean, height?: number} | null} [ground] + * @returns {number | null} + */ +export function surfaceBelowIn(root, eye, ground = null) { + let best = /** @type {number | null} */ (null); + if (root?.children?.length) { + root.updateMatrixWorld(true); + const ray = new THREE.Raycaster(new THREE.Vector3(eye[0], eye[1], eye[2]), new THREE.Vector3(0, -1, 0)); + for (const hit of ray.intersectObjects(root.children, true)) { + const object = /** @type {any} */ (hit.object); + if (!object?.isMesh || notGround(object, root)) continue; + best = hit.point.y; + break; + } + } + const h = Number(ground?.height); + if (ground?.enabled && Number.isFinite(h) && h <= eye[1] && (best == null || h > best)) best = h; + return best; +} diff --git a/src/lib/playSpawn.js b/src/lib/playSpawn.js index f62636359..40a8f2f16 100644 --- a/src/lib/playSpawn.js +++ b/src/lib/playSpawn.js @@ -11,13 +11,33 @@ import { get } from 'svelte/store'; import { playerCam } from '../stores/sceneStore'; import { resolvePlaySettings } from './playSettings'; import { globalScene } from '../stores/sceneStore'; +// 60 Q1: the Play here rule (pure, three only) lives in its own leaf so the unit layer can run it +export { playHereSpawnFrom, headingOf } from './playHereCore'; /** the play camera's eye above the feet: the walker's 1.7 m person */ export const SPAWN_EYE = 1.7; +// 60 Q1: PLAY HERE — a session-only spawn that beats every authored one while it is set. It is +// written by playHere (desktop/phone) and the VR radial's Play here, and cleared when that Play +// ends, so `currentSpawn()` stays the ONE read every spawn path shares (PointerLockControls, the +// VR spawnPlayer, a module's api.respawnPlayer, the game shell's restart). LOCAL: it never +// replicates, saves or undoes — "where I pressed Play here" is a fact about this peer. +/** @type {{position: [number, number, number], yaw: number} | null} */ +let playHereOverride = null; + +/** @param {{position: [number, number, number], yaw: number} | null} spawn */ +export function setPlayHereSpawn(spawn) { + playHereOverride = spawn ? { position: [spawn.position[0], spawn.position[1], spawn.position[2]], yaw: spawn.yaw } : null; +} + +/** the Play here spawn while one is in force (test/debug view too) */ +export function playHereSpawn() { + return playHereOverride; +} + /** the spawn in force right now, or null @returns {{position: [number, number, number], yaw: number} | null} */ export function currentSpawn() { - return resolvePlaySettings(get(globalScene)).spawn; + return playHereOverride ?? resolvePlaySettings(get(globalScene)).spawn; } /** the spawn a DESKTOP view honours — the one in force unless it is VR-only (30b core-games) diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js index b399cf7d6..d4df4e1d2 100644 --- a/src/lib/shortcuts.js +++ b/src/lib/shortcuts.js @@ -47,6 +47,7 @@ import { toggleSidebar } from './sidebars'; // 44 M7 (a leaf: app stores + safeS // shortcuts sits inside history's import family and a cycle there TDZ-crashes the // SSR prerender. import { requestPlay } from './playMode'; +import { playHere } from './playHere'; // 60 Q1 import { selectedObject, selectedObjects } from '../stores/sceneStore'; import { safeStorage } from './safeStorage'; // 53 A3: does a key press belong to the 3D view (a zero-import leaf) @@ -695,6 +696,18 @@ export const shortcuts = [ label: 'Play / Enter VR·AR (right-click the play button for modes)', action: () => requestPlay() }, + { + // 60 Q1: PLAY HERE — the play key with Shift: the player starts where the editor camera + // is (feet on the surface under it, facing the view), and leaving Play brings the view + // and the selection back. playHere is a near-leaf (playSpawn/playMode/bvhPicking), so + // the static edge is cycle-safe from here. + id: 'scene.playHere', + keys: 'Ctrl+Shift+Enter', + group: 'Scene', + scope: 'global', + label: 'Play here — start from the editor camera', + action: () => playHere() + }, { // 19-B P4: the element-snap master switch. Deliberately NOT the grid switch // — the grid keeps `snapEnabled` (VR's applySnapMode owns that one). diff --git a/src/lib/ui/phoneBar.js b/src/lib/ui/phoneBar.js index 43ba1a7a1..b5b3e262d 100644 --- a/src/lib/ui/phoneBar.js +++ b/src/lib/ui/phoneBar.js @@ -26,7 +26,7 @@ export const WINDOW_ITEMS = ['play', 'add', 'more', 'objects', 'stats', 'chat', export const EDIT_ITEMS = ['inspect', 'move', 'rotate', 'scale', 'undo', 'redo', 'multiselect', 'interact']; /** More ▸ Tools & View (46 B1: + the scene budget — the toolbar chip's phone form; a folded bar has no * room for it by default, so it waits here and can be dragged onto the bar like any item) */ -export const VIEW_ITEMS = ['tools', 'focus', 'budget']; +export const VIEW_ITEMS = ['tools', 'focus', 'budget', 'playhere']; // 60 Q1: + Play here /** every key the bar may hold */ export const BAR_CATALOG = [...WINDOW_ITEMS, ...EDIT_ITEMS, ...VIEW_ITEMS]; diff --git a/src/lib/vr/frame.js b/src/lib/vr/frame.js index 599b9fa3d..206789af8 100644 --- a/src/lib/vr/frame.js +++ b/src/lib/vr/frame.js @@ -75,7 +75,7 @@ import { vrJumpHeight, updateHeightLift } from './locomotion.js'; -import { modeHand, toggleVRMode, payPendingSpawn, updateModeLabel } from './modes.js'; +import { modeHand, toggleVRMode, payPendingSpawn, payPendingPlayHere, updateModeLabel } from './modes.js'; import { vrHovered, vrPanelCursor, @@ -216,6 +216,7 @@ export function updateVRControls() { return; } payPendingSpawn(); // 30b P4 + payPendingPlayHere(); // 60 Q1 updateHeightLift(); // 36: Settings ▸ VR ▸ Body (seated / height), applied once per change // 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); diff --git a/src/lib/vr/locomotion.js b/src/lib/vr/locomotion.js index a56f7d6df..1fd4f9c35 100644 --- a/src/lib/vr/locomotion.js +++ b/src/lib/vr/locomotion.js @@ -15,6 +15,8 @@ import { moduleWorldChildren } from '../moduleWorld'; import { teleportVerdictPure, shrinkBox, BOUNDS_INSET, PROBE_HEIGHT } from '../teleportRules'; import { walkable as dungeonWalkable } from '../dungeonPlay'; import { resolvePlaySettings, playPublishers } from '../playSettings'; +// 60 Q1: the spawn read every spawn path shares (a Play here override first, else the scene's) +import { currentSpawn } from '../playSpawn'; import { locomotionPolicy, vrSpawnOffsets, yawForward } from '../locomotionPolicy'; import { resolveWalk, charControl } from '../charController'; import { kitPlayRules } from '../kit/runtime.js'; @@ -790,7 +792,7 @@ export function vrJumpHeight() { * no move. @returns {boolean} whether the player was moved */ export function spawnPlayer() { - const spawn = resolvePlaySettings(get(globalScene)).spawn; + const spawn = currentSpawn(); if (!spawn || !renderer?.xr?.getSession?.()) return false; const viewer = viewerNow(); if (!viewer) return false; diff --git a/src/lib/vr/modes.js b/src/lib/vr/modes.js index 77ce9b95a..d2652b9b9 100644 --- a/src/lib/vr/modes.js +++ b/src/lib/vr/modes.js @@ -2,7 +2,7 @@ // 34 R4 (A5): one concern of src/lib/vrControls.js, which re-exports the public names unchanged. import * as THREE from 'three'; import { get } from 'svelte/store'; -import { globalScene, editorMode } from '../../stores/sceneStore'; +import { globalScene, editorMode, worldRig } from '../../stores/sceneStore'; import { resolvePlaySettings, playPublishers } from '../playSettings'; import { hudDocs, isGameHud } from '../hudDocs'; import { setEditorMode, toggleEditorMode } from '../objectActions'; @@ -14,6 +14,9 @@ import { hapticPulse } from './haptics.js'; import { controllerIndexFor } from './input.js'; import { noteXRBaseSpace, viewerNow, spawnPlayer } from './locomotion.js'; import { handOf, bindingOf, bindingLabel, controlName } from './bindings.js'; +// 60 Q1: Play here in a headset — the same spawn rule and override the desktop press uses +import { currentSpawn, playHereSpawnFrom, headingOf, setPlayHereSpawn } from '../playSpawn'; +import { surfaceBelow, playingHere } from '../playHere'; // ---- 30b P4: ENTER INTERACT, SWITCH IN VR, SPAWN ------------------------------------------ // Contract C1: pressing Play in VR on a GAME enters Interact; the left Y button toggles @@ -84,6 +87,81 @@ export function payPendingSpawn() { spawnPlayer(); } +// ---- 60 Q1: PLAY HERE IN A HEADSET -------------------------------------------------------------- +// The radial's Tools ▸ Play here: Interact (the headset's Play) starting where you stand, not on the +// game's spawn — the feet on the surface under your head, facing where you look — and the next +// switch back to Edit puts you (and the world grab you had) back exactly where you were. A viewer +// pose exists only inside the session's frame callback, so the press is PAID on the next frame +// (frame.js, beside payPendingSpawn) — the recenter rule. +let playHerePending = false; +/** @type {{space: any, rig: {p: number[], q: number[], s: number[]} | null} | null} */ +let playHereSaved = null; + +/** the radial's press @returns {boolean} whether a Play here was queued */ +export function requestVRPlayHere() { + if (!renderer?.xr?.getSession?.() || get(editorMode) === 'interact') return false; + playHerePending = true; + return true; +} + +/** a Play here owed since the press, paid on the first frame with a viewer pose */ +export function payPendingPlayHere() { + if (!playHerePending) return; + const viewer = viewerNow(); + if (!viewer) return; + playHerePending = false; + if (get(editorMode) === 'interact') return; + /** @type {any} */ + const rig = get(worldRig); + playHereSaved = { + space: renderer.xr.getReferenceSpace(), + rig: rig ? { p: rig.position.toArray(), q: rig.quaternion.toArray(), s: rig.scale.toArray() } : null + }; + // where the head is in the CONTENT frame: Interact resets the world grab to 1:1, so the + // content point under you is where you will stand + const head = new THREE.Vector3(viewer.head.x, viewer.head.y, viewer.head.z); + let yaw = viewer.yaw; + if (rig) { + rig.updateMatrixWorld(true); + rig.worldToLocal(head); + yaw -= headingOf(rig.quaternion); + } + resetWorldRig(); + setPlayHereSpawn(playHereSpawnFrom({ eye: head.toArray(), yaw }, surfaceBelow, currentSpawn(), viewer.headHeight)); + playingHere.set(true); + // → the editorMode subscription → enterInteractVR → spawnPlayer, which reads the override + setEditorMode('interact'); +} + +/** back to Edit (or the session ended): the override goes, and the view you left comes back */ +function endVRPlayHere(/** @type {boolean} */ restore) { + playHerePending = false; + const was = playHereSaved; + playHereSaved = null; + if (!was) return; + setPlayHereSpawn(null); + playingHere.set(false); + if (!restore) return; + try { + if (was.space) renderer.xr.setReferenceSpace(was.space); + } catch { + /* a space from an ended session — nothing to go back to */ + } + /** @type {any} */ + const rig = get(worldRig); + if (rig && was.rig) { + rig.position.fromArray(was.rig.p); + rig.quaternion.fromArray(was.rig.q); + rig.scale.fromArray(was.rig.s); + rig.updateMatrixWorld(true); + } +} + +/** test/debug view */ +export function vrPlayHereDebug() { + return { pending: playHerePending, saved: !!playHereSaved }; +} + /** @type {any} */ let modeLabel = null; /** @type {any} */ let modeLabelCanvas = null; let modeLabelText = ''; @@ -165,7 +243,8 @@ let lastEditorMode = get(editorMode); editorMode.subscribe((mode) => { if (mode === lastEditorMode) return; lastEditorMode = mode; - if (!renderer?.xr?.getSession?.()) return; + if (!renderer?.xr?.getSession?.()) return endVRPlayHere(false); if (mode === 'interact') enterInteractVR(); + else endVRPlayHere(true); // 60 Q1 updateModeLabel(); }); diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 58120f8ef..03becf97d 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -224,5 +224,7 @@ export { modeHand, onVRSessionStart, toggleVRMode, - vrModeLabelDebug + vrModeLabelDebug, + requestVRPlayHere, + vrPlayHereDebug } from './vr/modes.js'; diff --git a/src/lib/vrRadialMenu.js b/src/lib/vrRadialMenu.js index 95972ebaa..9e86e0de1 100644 --- a/src/lib/vrRadialMenu.js +++ b/src/lib/vrRadialMenu.js @@ -6,8 +6,10 @@ import { vrWireframeSelection, vrEditMenuOpen, vrSnapMenuOpen, - vrToolMode + vrToolMode, + editorMode } from '../stores/sceneStore'; +import { playingHere } from './playHere'; // 60 Q1 (a near-leaf: playSpawn / playMode / stores) import { environment, setEnvironment, ENVIRONMENT_PRESETS } from './environment'; import { simulating, remoteSimulating, toggleSimulation } from './physics'; import { setMicMode, vrMicMode } from './voiceChat'; @@ -307,6 +309,27 @@ function registerBuiltins() { action: () => toggleSimulation() }); + // 60 Q1: PLAY HERE — Interact (the headset's Play) from where you stand, facing where you look; + // the same entry (or the mode button) brings you back to Edit where you were. The press is paid + // on the next XR frame (vr/modes.js), so the dynamic import's microtask costs nothing. + registerVRMenuEntry({ + id: 'playhere', + group: 'tools', + label: () => (get(playingHere) && get(editorMode) === 'interact' ? 'Back to edit' : 'Play here'), + icon: () => (get(playingHere) && get(editorMode) === 'interact' ? 'square' : 'play'), + order: 6, + closes: true, + active: () => get(playingHere), + visible: () => get(editorMode) !== 'interact' || get(playingHere), + action: () => { + if (get(playingHere) && get(editorMode) === 'interact') { + void import('./objectActions').then((m) => m.setEditorMode('edit')); + return; + } + void import('./vr/modes.js').then((m) => m.requestVRPlayHere()); + } + }); + // Add ▸ — the desktop Add menu's names; ids resolve in executeVRMenuAction's switch, which spawns // the primitive 2 m ahead of the camera (spawnPrimitive) [ diff --git a/tests/e2e/play-here-iwer.test.cjs b/tests/e2e/play-here-iwer.test.cjs new file mode 100644 index 000000000..de9c4a9e2 --- /dev/null +++ b/tests/e2e/play-here-iwer.test.cjs @@ -0,0 +1,147 @@ +// 60 Q1: PLAY HERE IN A HEADSET (IWER — the app's own WebXR path end to end). The radial's +// Tools ▸ Play here switches to Interact (the headset's Play) with the FEET on the surface under the +// head, facing where the head looks — not on the scene's spawn — and Tools ▸ Back to edit (or the mode +// button) puts the viewer, and any world grab they had, back exactly where they were. +// +// IWER's offset reference spaces never reach the XR camera it reports (vr-comfort-iwer's note), so +// the rows read WHAT THE APP APPLIED: every XRRigidTransform the rig moves by is recorded — the +// horizontal move, the lift and the turn of a spawn — and the restore is read as the reference +// space OBJECT itself (Back to edit hands back the very space Edit had). +const h = require('./helpers.cjs'); +const iwer = require('./iwer.cjs'); + +const near = (a, b, eps = 0.06) => Math.abs(a - b) < eps; +const mode = (page) => page.evaluate(() => { let v; window.__stores.editorMode.subscribe((x) => (v = x))(); return v; }); +const SPY = () => { + if (window.__rigSpy) return; + const Orig = window.XRRigidTransform; + window.__rigSpy = []; + window.XRRigidTransform = class extends Orig { + constructor(p, o) { + super(p, o); + window.__rigSpy.push({ p: p ? [p.x || 0, p.y || 0, p.z || 0] : [0, 0, 0], yaw: o ? 2 * Math.atan2(o.y || 0, o.w ?? 1) : 0 }); + } + }; +}; +/** the offsets applied since the last call: {move (m, horizontal), lift (m), turns: [yaw…]} */ +const applied = (page) => + page.evaluate(() => { + const list = window.__rigSpy.splice(0); + let x = 0, y = 0, z = 0; + const turns = []; + for (const r of list) { + if (Math.abs(r.yaw) > 1e-4) turns.push(+r.yaw.toFixed(3)); + else { + x += r.p[0]; + y += r.p[1]; + z += r.p[2]; + } + } + return { move: +Math.hypot(x, z).toFixed(3), lift: +(-y).toFixed(3), turns, n: list.length }; + }); +/** remember the live reference space as `name`, or ask whether it is still that one */ +const space = (page, name, op) => + page.evaluate(({ name, op }) => { + let r; + window.__stores.globalRenderer.subscribe((v) => (r = v))(); + const now = r.xr.getReferenceSpace(); + if (op === 'keep') return !!(window['__space_' + name] = now); + return now === window['__space_' + name]; + }, { name, op }); +const rigPose = (page) => + page.evaluate(() => { let r; window.__stores.worldRig.subscribe((x) => (r = x))(); return { p: r.position.toArray(), s: r.scale.x }; }); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { init: iwer.initScript() }); + const P = A.page; + h.check(await iwer.installed(P), 'premise: IWER owns navigator.xr'); + + // a box at (8, _, −6) whose top is 1 m up (below the 1.6 m head) + await P.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await P.waitForTimeout(500); + const top = await P.evaluate(() => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const b = g.children.at(-1); + b.scale.set(4, 1, 4); + b.position.set(8, 0.5, -6); + b.updateMatrixWorld(true); + return new s.THREE.Box3().setFromObject(b).max.y; + }); + // a plain scene: the session starts in Edit; the spawn (which makes it a game) comes after + await iwer.enter(P); + await iwer.frames(P, 4); + h.check((await mode(P)) === 'edit', 'premise: the headset is in Edit'); + await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { spawn: { position: [-10, 0, 10], yaw: 0 } } })); + await P.evaluate(SPY); + + // ---- 1. stand over the box looking half-left, Tools ▸ Play here --------------------------------- + await iwer.pose(P, 'head', [8, 1.6, -6], { yaw: 0.5 }); + await iwer.frames(P, 4); + await space(P, 'edit', 'keep'); + await applied(P); + const entry = await P.evaluate(() => window.__stores.vrRadialMenu.ringEntries('tools').map((e) => e.id)); + h.check(entry.includes('playhere'), `the radial's Tools ring offers Play here (${JSON.stringify(entry)})`); + await P.evaluate(() => window.__stores.vrControls.executeVRMenuAction('playhere')); + await iwer.frames(P, 8); + h.check((await mode(P)) === 'interact', 'Play here switches to Interact'); + const dbg = await P.evaluate(() => window.__stores.playHere.playHereDebug()); + h.check( + dbg.playing === true && dbg.override && near(dbg.override.position[0], 8) && near(dbg.override.position[2], -6) && near(dbg.override.position[1], top) && near(dbg.override.yaw, 0.5, 0.02), + `the round's spawn: under the head, on the box top, facing the head (${JSON.stringify(dbg.override)})` + ); + let a = await applied(P); + h.check(a.move < 0.05, `...so the player is NOT moved across the world to the spawn (moved ${a.move} m)`); + h.check(near(a.lift, top, 0.05), `...the feet are lifted onto the box top (lift ${a.lift} m vs ${top.toFixed(2)})`); + h.check(a.turns.every((t) => Math.abs(t) < 0.02), `...and the view keeps its facing (turns ${JSON.stringify(a.turns)})`); + h.check(!(await space(P, 'edit', 'same')), 'Interact plays in a moved reference space'); + + // ---- 2. Back to edit: the very reference space Edit had -------------------------------------- + h.check(!!(await P.evaluate(() => window.__stores.vrRadialMenu.ringEntries('tools').find((e) => e.id === 'playhere'))), 'the entry stays while the round runs'); + await P.evaluate(() => window.__stores.vrControls.executeVRMenuAction('playhere')); + await iwer.frames(P, 6); + h.check((await mode(P)) === 'edit', 'Back to edit: Edit'); + h.check(await space(P, 'edit', 'same'), '...in the reference space Edit had (the view is where it was)'); + const after = await P.evaluate(() => window.__stores.playHere.playHereDebug()); + h.check(after.override === null && after.playing === false, 'the override is gone'); + + // ---- 3. a world grab (miniature) is put back too; Interact played it at 1:1 ------------------- + await P.evaluate(() => { + let r; + window.__stores.worldRig.subscribe((x) => (r = x))(); + r.scale.setScalar(0.5); + r.position.set(4, 0, -3); + r.updateMatrixWorld(true); + }); + await iwer.frames(P, 3); + await applied(P); + await P.evaluate(() => window.__stores.vrControls.executeVRMenuAction('playhere')); + await iwer.frames(P, 8); + let rig = await rigPose(P); + h.check((await mode(P)) === 'interact' && rig.s === 1 && rig.p.every((x) => x === 0), `Play here plays at 1:1 (${JSON.stringify(rig)})`); + // the content point under the head: (8 − 4) / 0.5 = 8, (−6 + 3) / 0.5 = −6 → over the box again + const o3 = (await P.evaluate(() => window.__stores.playHere.playHereDebug())).override; + h.check(o3 && near(o3.position[0], 8, 0.1) && near(o3.position[2], -6, 0.1) && near(o3.position[1], top), `...from the content point under the head (${JSON.stringify(o3)})`); + // the mode button (left Y) is the other way back + await iwer.tap(P, 'left', 5); + await iwer.frames(P, 6); + rig = await rigPose(P); + h.check((await mode(P)) === 'edit', 'left Y: back to Edit'); + h.check(rig.s === 0.5 && near(rig.p[0], 4) && near(rig.p[2], -3), `...and the world grab is back (${JSON.stringify(rig)})`); + h.check(await space(P, 'edit', 'same'), '...in the reference space Edit had'); + + // ---- 4. an ordinary Interact afterwards uses the scene's spawn (the override did not leak) ----- + await applied(P); + await iwer.tap(P, 'left', 5); + await iwer.frames(P, 8); + h.check((await mode(P)) === 'interact', 'left Y: an ordinary Interact'); + a = await applied(P); + const toSpawn = Math.hypot(-10 - 8, 10 + 6); + h.check(near(a.move, toSpawn, 0.15), `...moves the player to the scene's spawn (${a.move} m vs ${toSpawn.toFixed(2)})`); + h.check(a.turns.some((t) => Math.abs(Math.abs(t) - 0.5) < 0.03), `...and turns them to its facing (turns ${JSON.stringify(a.turns)})`); + + await iwer.exit(P); + await h.finish(browser); +}); diff --git a/tests/e2e/play-here.test.cjs b/tests/e2e/play-here.test.cjs new file mode 100644 index 000000000..bccffe173 --- /dev/null +++ b/tests/e2e/play-here.test.cjs @@ -0,0 +1,235 @@ +// 60 Q1: PLAY HERE (desktop + phone). The player starts where the editor camera is — the feet on the +// surface straight under it, facing the view's heading — and leaving Play puts the editor view and the +// selection back. Nothing under the camera → the scene's spawn; no spawn either → the camera's own eye. +// The override is a ONE-ROUND thing: the next ordinary Play lands on the scene's spawn again. +// HEIGHT: desktop Play is GROUNDED by default (37 R24 — PointerLockControls pins the eye at the +// scene's eye height), so the surface height counts where the scene lets the player fly (or a walk +// controller owns Y); a grounded scene keeps X/Z + facing and its own eye height. Both are covered. +// Entry points covered: Ctrl+Shift+Enter, the Play button's right-click row, the optional toolbar +// button, and on a phone the Play long-press row + More ▸ Play here. +// IWER (the headset) lives in play-here-iwer.test.cjs. +const h = require('./helpers.cjs'); + +const store = (name) => `(() => { let v; window.__stores.${name}.subscribe((x) => (v = x))(); return v; })()`; +const near = (a, b, eps = 0.06) => Math.abs(a - b) < eps; +const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a)); +const SPAWN = { position: [3, 0, -4], yaw: Math.PI / 2 }; +/** the toolbar with the optional Play here button placed at the end of the bar */ +const BAR = JSON.stringify({ + order: ['move', 'rotate', 'scale', 'pivot', 'mode', 'objects', 'flow', 'explorer', 'animation', 'playhere'], + hidden: [], + spacerIndex: 5, + seen: ['move', 'rotate', 'scale', 'pivot', 'mode', 'objects', 'flow', 'explorer', 'animation', 'playhere', 'ai', 'add', 'chat', 'mic', 'notes', 'notifications', 'peers', 'undo', 'redo', 'multiselect'] +}); + +/** the play camera's world eye + heading, the editor camera's pose + orbit target */ +const cams = (page) => + page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; }; + const pc = get(s.playerCam); + const ec = get(s.editorCam); + const oc = get(s.orbitControls); + const f = new THREE.Vector3(0, 0, -1).applyQuaternion(pc.getWorldQuaternion(new THREE.Quaternion())); + return { + player: pc.getWorldPosition(new THREE.Vector3()).toArray(), + playerYaw: Math.atan2(-f.x, -f.z), + editor: ec.position.toArray(), + editorQ: ec.quaternion.toArray(), + target: oc?.target?.toArray() ?? null, + locked: get(s.isLocked) + }; + }); + +/** park the editor camera at `pos` looking at `target` (the OrbitControls own the view, so through them) */ +const aim = (page, pos, target) => + page.evaluate( + ({ pos, target }) => { + const s = window.__stores; + const get = (st) => { let v; st.subscribe((x) => (v = x))(); return v; }; + const cam = get(s.editorCam); + const oc = get(s.orbitControls); + oc.target.set(...target); + cam.position.set(...pos); + cam.lookAt(...target); + oc.update(); + cam.updateMatrixWorld(true); + }, + { pos, target } + ); + +const waitLocked = async (page, want) => { + for (let i = 0; i < 40; i++) { + if ((await page.evaluate(store('isLocked'))) === want) return true; + await page.waitForTimeout(100); + } + return false; +}; + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1440, height: 900 } }, storage: { controlsLayout: BAR } }); + const P = A.page; + + // ---- the world: a wide box far from the origin (top at y = 5), the scene's spawn elsewhere ---- + await P.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await P.waitForTimeout(500); + const box = await P.evaluate(() => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const b = g.children.at(-1); + b.scale.set(10, 5, 10); + b.position.set(40, 0, -35); + b.updateMatrixWorld(true); + const bb = new s.THREE.Box3().setFromObject(b); + b.position.y -= bb.min.y; // sit it on the ground: its top is then its height + b.updateMatrixWorld(true); + const top = new s.THREE.Box3().setFromObject(b).max.y; + s.objectActions.applySelectionSet([b.uuid]); + return { uuid: b.uuid, top }; + }); + h.check(box.top > 4, `premise: a box with its top at ${box.top.toFixed(2)} m, far from the origin`); + // flying allowed: the spawn's height is honoured (the grounded case is row 1b) + await P.evaluate((spawn) => window.__stores.scenePhysics.setScenePhysics({ play: { spawn, locomotion: { fly: true } } }), SPAWN); + + // ---- 1. Ctrl+Shift+Enter from a far corner: on the box roof, facing the view ------------------ + const EYE_AT = [40, 14, -32]; + await aim(P, EYE_AT, [30, 0, -42]); + const before = await cams(P); + const resolved = await P.evaluate(() => window.__stores.playHere.resolvePlayHere()); + h.check(resolved?.source === 'surface' && near(resolved.position[1], box.top), `the resolver lands on the roof (${JSON.stringify(resolved)})`); + await P.mouse.move(700, 450); + await P.keyboard.press('Control+Shift+Enter'); + h.check(await waitLocked(P, true), 'Ctrl+Shift+Enter enters Play'); + await P.waitForTimeout(400); + let c = await cams(P); + h.check(near(c.player[0], 40) && near(c.player[2], -32), `the player stands under the camera, not on the spawn (${c.player.map((v) => v.toFixed(2))})`); + h.check(near(c.player[1], box.top + 1.7), `the eye is 1.7 m above the roof (${c.player[1].toFixed(2)} vs ${(box.top + 1.7).toFixed(2)})`); + h.check(near(wrap(c.playerYaw - Math.PI / 4), 0, 0.03), `facing the view's heading (${c.playerYaw.toFixed(3)} vs ${(Math.PI / 4).toFixed(3)})`); + h.check((await P.evaluate(store('playHere.playingHere'))) === true, 'playingHere is on while the round runs'); + + // Stop: the view and the selection come back + h.check(await h.leavePlay(A), 'Escape leaves Play'); + await P.waitForTimeout(300); + c = await cams(P); + h.check(c.editor.every((v, i) => near(v, before.editor[i], 0.01)), `the editor camera is back where it was (${c.editor.map((v) => v.toFixed(2))})`); + h.check(c.target && c.target.every((v, i) => near(v, before.target[i], 0.01)), `...looking at the same orbit target (${JSON.stringify(c.target)})`); + h.check(c.editorQ.every((v, i) => near(v, before.editorQ[i], 0.01)), '...with the same orientation'); + const sel = await P.evaluate(store('selectedObjects')); + h.check(sel.map((o) => o?.uuid ?? o).includes(box.uuid), `the selection is back (${JSON.stringify(sel.map((o) => o?.uuid ?? o))})`); + const dbg = await P.evaluate(() => window.__stores.playHere.playHereDebug()); + h.check(dbg.override === null && dbg.playing === false, `the override is gone with the round (${JSON.stringify({ o: dbg.override, p: dbg.playing })})`); + + // ---- 1b. a GROUNDED scene (the default): X/Z and facing from the camera, the scene's eye height -- + await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: { fly: false } } })); + await P.keyboard.press('Control+Shift+Enter'); + h.check(await waitLocked(P, true), 'grounded: Play here'); + await P.waitForTimeout(400); + c = await cams(P); + h.check(near(c.player[0], 40) && near(c.player[2], -32) && near(c.player[1], 1.7), `grounded: under the camera at the scene's eye height (${c.player.map((v) => v.toFixed(2))})`); + h.check(near(wrap(c.playerYaw - Math.PI / 4), 0, 0.03), `grounded: facing the view (${c.playerYaw.toFixed(3)})`); + await h.leavePlay(A); + await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: { fly: true } } })); + + // ---- 2. the next ORDINARY Play lands on the scene's spawn (the override does not leak) -------- + await P.keyboard.press('Control+Enter'); + h.check(await waitLocked(P, true), 'Ctrl+Enter: an ordinary Play'); + await P.waitForTimeout(400); + c = await cams(P); + h.check(near(c.player[0], 3) && near(c.player[2], -4) && near(c.player[1], 1.7), `an ordinary Play spawns on the scene's spawn (${c.player.map((v) => v.toFixed(2))})`); + await h.leavePlay(A); + + // ---- 3. the Play button's right-click row; nothing under the camera → the scene's spawn --------- + await aim(P, [0, -6, 20], [0, -6, 0]); // under the ground plane: nothing below + await P.click('#play-button', { button: 'right' }); + await P.waitForTimeout(300); + const rows = await P.evaluate(() => [...document.querySelectorAll('[role="menuitem"]')].map((el) => el.textContent.trim())); + h.check(rows.includes('Play here'), `Play's own menu carries "Play here" (${JSON.stringify(rows)})`); + await P.getByRole('menuitem', { name: 'Play here' }).click(); + h.check(await waitLocked(P, true), 'the menu row enters Play'); + await P.waitForTimeout(400); + c = await cams(P); + const last = (await P.evaluate(() => window.__stores.playHere.playHereDebug())).last; + h.check(last?.source === 'spawn', `nothing under the camera: the scene's spawn (${JSON.stringify(last)})`); + h.check(near(c.player[0], 3) && near(c.player[2], -4), `...the player is on it (${c.player.map((v) => v.toFixed(2))})`); + h.check(near(wrap(c.playerYaw - SPAWN.yaw), 0, 0.03), `...facing the spawn's own yaw (${c.playerYaw.toFixed(3)})`); + await h.leavePlay(A); + + // ---- 4. the toolbar button; no ground, no spawn → the camera's own eye ------------------------ + await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ ground: { enabled: false }, play: { spawn: null } })); + await aim(P, [70, 9, 70], [60, 9, 60]); + h.check(await P.locator('#play-here-button').isVisible(), 'the optional toolbar button is on the bar when placed'); + await P.click('#play-here-button'); + h.check(await waitLocked(P, true), 'the toolbar button enters Play'); + await P.waitForTimeout(400); + c = await cams(P); + h.check(near(c.player[0], 70) && near(c.player[1], 9) && near(c.player[2], 70), `no surface, no spawn: the eye stays at the camera (${c.player.map((v) => v.toFixed(2))})`); + h.check(near(wrap(c.playerYaw - Math.PI / 4), 0, 0.03), `...facing the view (${c.playerYaw.toFixed(3)})`); + await h.leavePlay(A); + await P.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ ground: { enabled: true } })); + await A.page.context().close(); + + // ---- 5. a phone (N6 folded): Play's long-press row and More ▸ Play here ------------------------ + const FOLDED = { width: 390, height: 896 }; + const B = await h.setupPage(browser, 'phone', { + context: { viewport: FOLDED, hasTouch: true, isMobile: true, deviceScaleFactor: 2.9 }, + storage: { toursSeen: '{"editor-touch":true,"editor":true}' } + }); + const Q = B.page; + await Q.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await Q.waitForTimeout(500); + await Q.evaluate(() => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + const b = g.children.at(-1); + b.scale.set(6, 2, 6); + b.position.set(-20, 1, 15); + b.updateMatrixWorld(true); + }); + const top = await Q.evaluate(() => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((x) => (g = x))(); + return new s.THREE.Box3().setFromObject(g.children.at(-1)).max.y; + }); + await aim(Q, [-20, 10, 15], [-20, 0, 5]); + const phoneBefore = await cams(Q); + // long press on Play = its contextmenu + await Q.locator('#ps-bar [data-bar-item="play"]').dispatchEvent('contextmenu', { clientX: 195, clientY: 860 }); + await Q.waitForTimeout(300); + const phoneRows = await Q.evaluate(() => [...document.querySelectorAll('[role="menuitem"]')].map((el) => el.textContent.trim())); + h.check(phoneRows.includes('Play here'), `the phone Play menu carries "Play here" (${JSON.stringify(phoneRows)})`); + await Q.getByRole('menuitem', { name: 'Play here' }).click(); + h.check(await waitLocked(Q, true), 'phone: the row enters Play'); + await Q.waitForTimeout(400); + c = await cams(Q); + // a phone scene is grounded by default: X/Z from the camera, the eye at the scene's height + h.check(near(c.player[0], -20) && near(c.player[2], 15) && near(c.player[1], 1.7), `phone: under the camera, grounded (${c.player.map((v) => v.toFixed(2))}, box top ${top.toFixed(2)})`); + h.check(near(wrap(c.playerYaw), 0, 0.03), `phone: facing the view (${c.playerYaw.toFixed(3)})`); + // the touch ✕ is the phone's way out + const exitBtn = Q.locator('#touch-play-exit, [data-touch-exit], button[aria-label="Exit play"]').first(); + if (await exitBtn.isVisible().catch(() => false)) await exitBtn.click(); + else await Q.evaluate(() => window.__stores.playMode.exitPlay()); + h.check(await waitLocked(Q, null), 'phone: Play ends'); + await Q.waitForTimeout(300); + c = await cams(Q); + h.check(c.editor.every((v, i) => near(v, phoneBefore.editor[i], 0.01)), `phone: the editor view is back (${c.editor.map((v) => v.toFixed(2))})`); + + // More ▸ Tools & View ▸ Play here + await Q.locator('#ps-bar [data-bar-item="more"]').click(); + await Q.waitForTimeout(400); + const tile = Q.locator('#ps-tile-playhere'); + h.check(await tile.isVisible().catch(() => false), 'phone: More lists Play here'); + await tile.click(); + h.check(await waitLocked(Q, true), 'phone: the More tile enters Play'); + await Q.waitForTimeout(300); + c = await cams(Q); + h.check(near(c.player[0], -20) && near(c.player[2], 15), `phone (More): under the camera again (${c.player.map((v) => v.toFixed(2))})`); + await Q.evaluate(() => window.__stores.playMode.exitPlay()); + await waitLocked(Q, null); + + await h.finish(browser); +}); diff --git a/tests/unit/phoneBar.test.js b/tests/unit/phoneBar.test.js index cf14a21b6..4f1a7050f 100644 --- a/tests/unit/phoneBar.test.js +++ b/tests/unit/phoneBar.test.js @@ -28,7 +28,7 @@ describe('phoneBar', () => { it('the catalog: More ▸ Windows / Edit / Tools & View, every one placeable', () => { expect(WINDOW_ITEMS.slice(0, 5)).toEqual(['play', 'add', 'more', 'objects', 'stats']); expect(EDIT_ITEMS).toEqual(['inspect', 'move', 'rotate', 'scale', 'undo', 'redo', 'multiselect', 'interact']); - expect(VIEW_ITEMS).toEqual(['tools', 'focus', 'budget']); + expect(VIEW_ITEMS).toEqual(['tools', 'focus', 'budget', 'playhere']); // 60 Q1: + Play here expect(new Set(BAR_CATALOG).size).toBe(BAR_CATALOG.length); }); diff --git a/tests/unit/playHere.test.js b/tests/unit/playHere.test.js new file mode 100644 index 000000000..f15cead1b --- /dev/null +++ b/tests/unit/playHere.test.js @@ -0,0 +1,130 @@ +// @ts-nocheck — plain fixture scenes +// 60 Q1: Play here — where the player lands. The ray straight down from the editor camera through +// a real three scene (boxes, a hidden box, a sensor, water, a reference image, the physics ground +// plane), the heading of the view, and the fallbacks: the scene's spawn, then the camera's own eye. +import { describe, it, expect } from 'vitest'; +import * as THREE from 'three'; +import { playHereSpawnFrom, headingOf, surfaceBelowIn, notGround } from '../../src/lib/playHereCore.js'; + +const EYE = 1.7; +const GROUND_ON = { enabled: true, height: 0 }; +const GROUND_OFF = { enabled: false, height: 0 }; + +/** a box whose top face sits at `top`, centred on (x, z), `size` wide */ +function box(x, z, top, size = 2, userData = {}) { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(size, 1, size), new THREE.MeshBasicMaterial()); + mesh.position.set(x, top - 0.5, z); + Object.assign(mesh.userData, userData); + return mesh; +} +function scene(...children) { + const group = new THREE.Group(); + group.name = 'sceneObjects'; + for (const c of children) group.add(c); + return group; +} +const below = (root, ground) => (eye) => surfaceBelowIn(root, eye, ground); + +describe('surfaceBelowIn (the spawn raycast)', () => { + it('lands on the first surface straight under the eye — the roof over the floor', () => { + const root = scene(box(0, 0, 1), box(0, 0, 4, 1)); + expect(surfaceBelowIn(root, [0, 10, 0], GROUND_ON)).toBeCloseTo(4); + // beside the tall box, the low one + expect(surfaceBelowIn(root, [0.8, 10, 0.8], GROUND_ON)).toBeCloseTo(1); + }); + it('falls through to the physics ground plane, and not when it is off', () => { + const root = scene(box(0, 0, 1)); + expect(surfaceBelowIn(root, [20, 10, 20], GROUND_ON)).toBe(0); + expect(surfaceBelowIn(root, [20, 10, 20], GROUND_OFF)).toBeNull(); + expect(surfaceBelowIn(null, [0, 3, 0], { enabled: true, height: 2 })).toBe(2); + }); + it('a ground plane ABOVE the eye is not under it', () => { + expect(surfaceBelowIn(scene(), [0, 1, 0], { enabled: true, height: 2 })).toBeNull(); + }); + it('a surface above the eye is not under it (inside a building you stand on its floor)', () => { + const root = scene(box(0, 0, 0.5, 6), box(0, 0, 5, 6)); + expect(surfaceBelowIn(root, [0, 3, 0], GROUND_ON)).toBeCloseTo(0.5); + }); + it('skips what a player cannot stand on: hidden, sensors, water, reference images', () => { + const hidden = box(0, 0, 3); + hidden.visible = false; + const hiddenParent = new THREE.Group(); + hiddenParent.visible = false; + hiddenParent.add(box(0, 0, 3.5)); + const root = scene( + box(0, 0, 1), + hidden, + hiddenParent, + box(0, 0, 4, 2, { physics: { sensor: true } }), + box(0, 0, 5, 2, { water: { kind: 'surface' } }), + box(0, 0, 6, 2, { underlay: true }) + ); + expect(surfaceBelowIn(root, [0, 10, 0], GROUND_ON)).toBeCloseTo(1); + expect(notGround(hiddenParent.children[0], root)).toBe(true); + expect(notGround(root.children[0], root)).toBe(false); + }); + it('only meshes count (a line or points under the camera is not a floor)', () => { + const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(-1, 3, 0), new THREE.Vector3(1, 3, 0)])); + const root = scene(line); + expect(surfaceBelowIn(root, [0, 10, 0], GROUND_ON)).toBe(0); + }); + it('reads world positions through parents (a group moved up carries its floor)', () => { + const parent = new THREE.Group(); + parent.position.set(0, 3, 0); + parent.add(box(0, 0, 1)); + expect(surfaceBelowIn(scene(parent), [0, 10, 0], GROUND_ON)).toBeCloseTo(4); + }); +}); + +describe('playHereSpawnFrom', () => { + const spawn = { position: [5, 0, -5], yaw: 1.2 }; + it('stands the feet on the surface under the eye, facing the view', () => { + const root = scene(box(30, -40, 2)); + const s = playHereSpawnFrom({ eye: [30, 12, -40], yaw: 0.5 }, below(root, GROUND_ON), spawn, EYE); + expect(s.source).toBe('surface'); + expect(s.position[0]).toBe(30); + expect(s.position[1]).toBeCloseTo(2); + expect(s.position[2]).toBe(-40); + expect(s.yaw).toBe(0.5); + }); + it('nothing under the camera → the scene spawn, with its own facing', () => { + const s = playHereSpawnFrom({ eye: [30, 12, -40], yaw: 0.5 }, below(scene(), GROUND_OFF), spawn, EYE); + expect(s).toEqual({ position: [5, 0, -5], yaw: 1.2, source: 'spawn' }); + }); + it('below the ground plane → the scene spawn too', () => { + const s = playHereSpawnFrom({ eye: [0, -3, 0], yaw: 0 }, below(scene(), GROUND_ON), spawn, EYE); + expect(s.source).toBe('spawn'); + }); + it('no surface and no spawn → the eye stays where the camera is', () => { + const s = playHereSpawnFrom({ eye: [1, 4, 2], yaw: -0.3 }, below(scene(), GROUND_OFF), null, EYE); + expect(s.source).toBe('camera'); + expect(s.position[1] + EYE).toBeCloseTo(4); + expect(s.yaw).toBe(-0.3); + }); + it('a VR head height is the eye height there', () => { + const s = playHereSpawnFrom({ eye: [0, 5, 0], yaw: 0 }, () => null, null, 1.4); + expect(s.position[1]).toBeCloseTo(3.6); + }); + it('a non-finite heading faces −Z rather than poisoning the camera', () => { + const s = playHereSpawnFrom({ eye: [0, 5, 0], yaw: NaN }, () => 0, null, EYE); + expect(s.yaw).toBe(0); + }); +}); + +describe('headingOf', () => { + it('is rotation.y for a level view', () => { + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0.8, 0, 'YXZ')); + expect(headingOf(q)).toBeCloseTo(0.8); + }); + it('keeps the heading of an editor camera tilted down (the usual three-quarter view)', () => { + const cam = new THREE.PerspectiveCamera(); + cam.position.set(-10, 10, 10); + cam.lookAt(0, 0, 0); + // looking toward +X/−Z from (-10, _, 10): the heading is −45° + expect(headingOf(cam.quaternion)).toBeCloseTo(-Math.PI / 4); + }); + it('stays defined looking straight down', () => { + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0.6, 0, 'YXZ')); + expect(headingOf(q)).toBeCloseTo(0.6, 5); + }); +}); From 7585d8369589becdfc63520b851d584e0b8f6728 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:52:08 +0300 Subject: [PATCH 2/3] =?UTF-8?q?[wip]=2060=20Q5:=20reference=20image=20unde?= =?UTF-8?q?rlays=20=E2=80=94=20store/wire,=20layer,=20window,=20drop=20men?= =?UTF-8?q?u=20(untested)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit underlays.js (LOCAL per scene in IndexedDB; Share with session = underlay / underlaydelete / getunderlays, owner-only rows, ROOM_SCOPED, request on the floor, validated), UnderlayLayer (under the world rig, outside sceneObjects, on the editor helper layer, hidden in Play/Interact, no-op raycast), UnderlayWindow (opacity, size, height, ground / facing the view, share, remove) + the image-drop 'Use as reference' menu, viewport Tools rows. Viewport/room thumbnails now render without the helper layer. NOT yet run: svelte-check ratchet after the type fix, the underlay e2e suite. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 11 + src/components/Scene.svelte | 5 + src/components/UnderlayLayer.svelte | 101 +++++++ src/components/menu/UnderlayWindow.svelte | 211 ++++++++++++++ src/components/menu/ViewportMenu.svelte | 17 ++ src/lib/cloudHooks.js | 4 +- src/lib/cloudPlugin.js | 6 + src/lib/peerHandler.svelte.js | 12 + src/lib/peerScenes.js | 3 + src/lib/sessions.js | 7 + src/lib/underlayPose.js | 45 +++ src/lib/underlays.js | 318 ++++++++++++++++++++++ src/lib/wireValidate.js | 6 + 13 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 src/components/UnderlayLayer.svelte create mode 100644 src/components/menu/UnderlayWindow.svelte create mode 100644 src/lib/underlayPose.js create mode 100644 src/lib/underlays.js diff --git a/src/App.svelte b/src/App.svelte index db386f8ce..2190c072c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -67,6 +67,8 @@ import SculptToolbar from './components/menu/SculptToolbar.svelte' import SplineToolbar from './components/menu/SplineToolbar.svelte' import MeasureToolbar from './components/menu/MeasureToolbar.svelte' // 47 M1 + import UnderlayWindow from './components/menu/UnderlayWindow.svelte' // 60 Q5 + import { underlayDropMenu } from '$lib/underlays' import ModuleToolboxLayer from './components/ui/ModuleToolboxLayer.svelte' // 48 D1: the node graph debugger's panel (loaded the first time it opens) import { debugOpen } from '$lib/flowDebug.js' @@ -388,6 +390,13 @@ import { startMusicToolbox } from './lib/musicToolbox' // an unsupported file — importFile pairs them up. const companionExt = /\.(mtl|png|jpe?g|webp|bmp|gif)$/i const companions = files.filter((file) => companionExt.test(file.name)) + // 60 Q5: a drop of nothing but images is not a model's companions — they used to be + // dropped silently. Ask what they are for: a reference image to build over, or the Explorer. + const imageExt = /\.(png|jpe?g|webp|bmp|gif)$/i + if (files.every((file) => imageExt.test(file.name))) { + underlayDropMenu.set({ x: event.clientX, y: event.clientY, files }) + return + } files.forEach((file) => { const name = file.name.toLowerCase() if (modelExt.test(name)) importFile(file, file.name.replace(modelExt, ''), undefined, undefined, companions) @@ -451,6 +460,8 @@ import { startMusicToolbox } from './lib/musicToolbox' + + diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 9065cea28..b507495ab 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -105,6 +105,7 @@ // 36 A2: the headset browser's own "Enter VR" offer — decided by us, not threlte's default import { xrOfferMode } from '$lib/xrOffer'; import MeasureOverlay from './MeasureOverlay.svelte'; + import UnderlayLayer from './UnderlayLayer.svelte'; // 60 Q5 import AnnotationPins from './AnnotationPins.svelte'; import PingMarkers from './PingMarkers.svelte'; import PingHighlights from './PingHighlights.svelte'; @@ -1811,6 +1812,10 @@ + + +