From 963ee43507fdae34a803c63c4edd0cd335d1357e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 00:05:53 +0300 Subject: [PATCH 01/29] [feat] 52 V1: measure in VR - laser points with the 47 snapping, chains, readable troika labels, kept as 48 B2 items - Tools > Measure (radial) arms a trigger tool: every press puts a point down, snapped through the SAME snap-engine search as the desktop (vertex / edge / face, the measure flags) inside a 3 deg cone around the laser - a virtual camera down the ray turns the engine's px radius into an angle; the desktop bias is scaled to the cone (unscaled, an edge seen edge-on beat a corner 1.2 deg off - measured on IWER). A raw point lands on the grid when grid snapping is on. - chains with lengths / angles / total; a double tap (or the Measure ring's Keep) keeps the chain as a 48 B2 measurement ITEM (replicated, undoable, saved); the radial opens on the Measure ring while the tool is up: Keep / Undo point / Discard / Snap / Done (the sculpt ring's rule) - Selected > Edit measurement: a press near a point (by angle) picks it up, the next puts it down snapped, a press elsewhere adds one - measure.js's own edit step (measureEditStep, shared with the desktop click): one undo entry and one message per placed point. Edit mesh is no longer offered for a measurement item. - labels: VRMeasureLabels = troika text on a plate, facing the viewer, a constant ANGLE tall (0.03 rad) so they read the same near and far; measureCore.layoutLabels keeps them apart (every label is the same px tall). MeasureOverlay now draws items in a headset too. - hands: a pinch while measuring says "pick up a controller" and adds nothing (52 input rule) - Interact / leaving the headset / another tool ends it (a chain of 2+ points is kept, the desktop check rule) - tests: vitest vrMeasureCore (14); IWER suite vr-measure-iwer (T M L K N G E H F rows, two peers); vr-radial-menu's Tools list gains Measure. Frame cost on IWER: 0.16 ms/frame (hover search 0.24 ms at 30 Hz, labels 0.06 ms), 20 draw calls for a 4-point chain. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/MeasureOverlay.svelte | 9 +- src/components/VRMeasureLabels.svelte | 131 ++++++++ src/lib/debugHooks.js | 4 + src/lib/measure.js | 42 ++- src/lib/vr/buildRings.js | 92 ++++++ src/lib/vr/iconData.js | 2 +- src/lib/vr/measure.js | 316 ++++++++++++++++++ src/lib/vrMeasureCore.js | 110 +++++++ src/lib/vrRadialMenu.js | 8 +- tests/e2e/vr-measure-iwer.test.cjs | 440 ++++++++++++++++++++++++++ tests/e2e/vr-radial-menu.test.cjs | 4 +- tests/unit/vrMeasureCore.test.js | 103 ++++++ 12 files changed, 1242 insertions(+), 19 deletions(-) create mode 100644 src/components/VRMeasureLabels.svelte create mode 100644 src/lib/vr/buildRings.js create mode 100644 src/lib/vr/measure.js create mode 100644 src/lib/vrMeasureCore.js create mode 100644 tests/e2e/vr-measure-iwer.test.cjs create mode 100644 tests/unit/vrMeasureCore.test.js diff --git a/src/components/MeasureOverlay.svelte b/src/components/MeasureOverlay.svelte index f43726c12..2563a48fa 100644 --- a/src/components/MeasureOverlay.svelte +++ b/src/components/MeasureOverlay.svelte @@ -5,6 +5,7 @@ import { chainLabels, layoutLabels } from '$lib/measureCore' import { isMeasurement, worldPointsOf } from '$lib/measureItems' import { isVRMode, isLocked, objectsGroup, selectedObjects } from '../stores/sceneStore' + import VRMeasureLabels from './VRMeasureLabels.svelte' // 47 M1 + 48 B2: the measure chains in the 3D view — the one being drawn (with a dashed rubber // band to where the next click would land) and every measurement ITEM in the scene (48 B2: @@ -12,7 +13,8 @@ // their transforms). Lines and dots only: the labels are DOM (MeasureLabels.svelte), placed // from the screen positions this component publishes in the RENDER stage, after OrbitControls // has moved the camera for the frame. Scene-root, never in objectsGroup. Hidden in Play (a - // measurement is an editing aid) and in a headset (the labels are DOM). + // measurement is an editing aid). 52 V1: in a headset the lines and dots draw as well and the labels + // are troika text in the scene instead (VRMeasureLabels) — DOM is invisible there. const { camera, renderer } = useThrelte() const { renderStage } = useScheduler() @@ -105,7 +107,7 @@ useTask( () => { - const next = $isVRMode ? [] : collectItems() + const next = collectItems() const key = next.map((c) => c.id + (c.selected ? 's' : '') + (c.editing ? 'e' : '') + c.points.map((p) => p.map((n) => n.toFixed(4)).join(',')).join(';')).join('|') if (key !== itemsKey) { itemsKey = key @@ -159,6 +161,9 @@ /> {/each} +{#if $isVRMode} + +{/if} {#if preview} diff --git a/src/components/VRMeasureLabels.svelte b/src/components/VRMeasureLabels.svelte new file mode 100644 index 000000000..da6602227 --- /dev/null +++ b/src/components/VRMeasureLabels.svelte @@ -0,0 +1,131 @@ + + + +{#each labels as l (l.key)} + { + refs[l.key] = ref + return () => { + if (refs[l.key] === ref) delete refs[l.key] + } + }} + > + + + + + + +{/each} + diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 46aa9ba1b..7af050aa4 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -170,6 +170,10 @@ export const DEBUG_HOOKS = [ ['vrDollhouse', () => import('./vr/dollhouse.js')], ['vrWorldSnap', () => import('./vr/worldSnap.js')], ['vrSculpt', () => import('./vr/sculpt.js')], + // 52 V1/V2: VR measure (the laser tool + its leaf) and the build-tool radial entries (Arrange's runArrange) + ['vrMeasure', () => import('./vr/measure.js')], + ['vrMeasureCore', () => import('./vrMeasureCore')], + ['vrBuildRings', () => import('./vr/buildRings.js')], ['vrTourTargets', () => import('./vr/tourTargets.js')], ['vrPrefs', () => import('./vr/prefs.js')], // 53 (A7): recenter + the lift / recenter counts diff --git a/src/lib/measure.js b/src/lib/measure.js index dcc82c190..68b610d4f 100644 --- a/src/lib/measure.js +++ b/src/lib/measure.js @@ -57,11 +57,11 @@ measureSnap.subscribe((on) => { /** the measure tool's own snap flags (the drag snapping's prefs are left alone). No `surface` * candidate: the raw hit under the ray IS the fallback, and as a candidate at 0 px it would beat * a corner a few pixels away. */ -const MEASURE_TARGETS = { vertex: true, edge: true, face: true, surface: false, object: false }; +export const MEASURE_TARGETS = { vertex: true, edge: true, face: true, surface: false, object: false }; /** a corner wins from anywhere inside the radius; an edge over a face centre */ -const MEASURE_BIAS = { vertex: -16, edge: -6, face: -2 }; +export const MEASURE_BIAS = { vertex: -16, edge: -6, face: -2 }; /** screen radius a candidate may sit from the pointer — a fingertip covers more than a cursor */ -const RADIUS_MOUSE = 18; +export const RADIUS_MOUSE = 18; const RADIUS_TOUCH = 36; /** 48 B2: how close (css px) an edit click must land to a point to pick it up */ const HANDLE_MOUSE = 14; @@ -335,22 +335,38 @@ function editClick(raycaster, clientPx, opts) { const editing = get(measureEditing); const object = editing ? measurementOf(editing.uuid) : null; if (!editing || !object) return; - const world = worldPointsOf(object); - if (editing.active < 0 && clientPx) { - const near = nearestScreenPoint(screenOf(world), clientPx, opts.touch ? HANDLE_TOUCH : HANDLE_MOUSE); - if (near >= 0) { - measureEditing.set({ ...editing, active: near }); - return; - } + const near = + editing.active < 0 && clientPx + ? nearestScreenPoint(screenOf(worldPointsOf(object)), clientPx, opts.touch ? HANDLE_TOUCH : HANDLE_MOUSE) + : -1; + measureEditStep(near, () => resolveMeasurePoint(raycaster, clientPx, { touch: opts.touch, publish: false })); +} + +/** + * One edit step on the measurement being edited — the half the desktop click and the VR trigger + * share (52 V1). With no point picked up, `near` (a point the press landed on, or -1) picks one up; + * otherwise the resolved point puts the picked one down, or is added at the end of the chain. One + * undo entry and one message per placed point. + * @param {number} near @param {() => ({point: number[]} | null)} resolve + * @returns {'picked'|'placed'|'added'|null} + */ +export function measureEditStep(near, resolve) { + const editing = get(measureEditing); + const object = editing ? measurementOf(editing.uuid) : null; + if (!editing || !object) return null; + if (editing.active < 0 && near >= 0) { + measureEditing.set({ ...editing, active: near }); + return 'picked'; } - const hit = resolveMeasurePoint(raycaster, clientPx, { touch: opts.touch, publish: false }); - if (!hit) return; - const next = world.map((p) => [...p]); + const hit = resolve(); + if (!hit) return null; + const next = worldPointsOf(object).map((p) => [...p]); if (editing.active >= 0) next[editing.active] = hit.point; else next.push(hit.point); const before = object.userData.measure; commitMeasureEdit(editing.uuid, before, { points: localPointsOf(object, next) }); measureEditing.set({ ...editing, active: -1 }); + return editing.active >= 0 ? 'placed' : 'added'; } /** Backspace / Delete while editing: remove the picked point (else the last), never below two. */ diff --git a/src/lib/vr/buildRings.js b/src/lib/vr/buildRings.js new file mode 100644 index 000000000..ee3817b80 --- /dev/null +++ b/src/lib/vr/buildRings.js @@ -0,0 +1,92 @@ +// 52 — the VR build-tool radial entries: Tools ▸ Measure + the Measure ring (V1), Selected ▸ Arrange with its +// Align / Distribute / Drop rings (V2), Selected ▸ Edit measurement. vrRadialMenu calls these from its +// registerBuiltins with its own `registerVRMenuEntry` (handed in, not imported: this file is reached from +// vrRadialMenu, and an import back would close a cycle). The tools themselves load on first use (dynamic +// imports): the measure runtime and align.js sit in history's family, which the radial must not pull in eagerly. +import { get } from 'svelte/store'; +import { selectedObjects, selectedObject, vrToolMode } from '../../stores/sceneStore'; + +const selCount = () => /** @type {string[]} */ (get(selectedObjects))?.length ?? 0; +const vrMeasure = () => import('./measure.js'); +const measureOn = () => get(vrToolMode) === 'measure'; +/** is the (single) selection a kept measurement? (measureItems.isMeasurement, read inline — no import) */ +const measurementSelected = () => selCount() === 1 && Array.isArray(/** @type {any} */ (get(selectedObject))?.userData?.measure?.points); + +/** @param {(entry: any) => void} register vrRadialMenu.registerVRMenuEntry */ +export function registerMeasureRings(register) { + // Tools ▸ Measure — a trigger tool beside Select / Box select / Draw + register({ + id: 'measure:tool', + group: 'tools', + label: 'Measure', + icon: 'ruler', + order: 2.5, + closes: true, + active: measureOn, + action: () => void vrMeasure().then((m) => (measureOn() ? m.stopVRMeasure() : m.startVRMeasure())) + }); + // the Measure ring — the radial opens on it while the tool is up (vr/measure.js) + register({ + id: 'measure:keep', + group: 'measure', + label: 'Keep', + icon: 'check', + order: 0, + closes: true, + action: () => void vrMeasure().then((m) => m.vrKeepMeasurement()) + }); + register({ + id: 'measure:undo', + group: 'measure', + label: 'Undo point', + icon: 'undo-2', + order: 1, + action: () => void vrMeasure().then((m) => m.vrUndoMeasurePoint()) + }); + register({ + id: 'measure:discard', + group: 'measure', + label: 'Discard', + icon: 'trash-2', + order: 2, + closes: true, + action: () => void vrMeasure().then((m) => m.vrDiscardMeasurement()) + }); + register({ + id: 'measure:snap', + group: 'measure', + label: 'Snap', + icon: 'magnet', + order: 3, + active: () => measureSnapOn, + action: () => void import('../measure').then((m) => m.measureSnap.update((on) => !on)) + }); + register({ + id: 'measure:done', + group: 'measure', + label: 'Done', + icon: 'log-out', + order: 4, + closes: true, + action: () => void vrMeasure().then((m) => m.stopVRMeasure()) + }); + // Selected ▸ Edit measurement — a kept measurement's points from the headset (the desktop's Tab) + register({ + id: 'obj:editmeasure', + group: 'object', + label: 'Edit measurement', + icon: 'ruler', + order: 1, + closes: true, + visible: measurementSelected, + action: () => { + const uuid = /** @type {any} */ (get(selectedObject))?.uuid; + if (uuid) void vrMeasure().then((m) => m.editVRMeasurement(uuid)); + } + }); +} + +/** the Snap sector's dot reads this (the measure module loads lazily, so its store is mirrored once loaded) */ +let measureSnapOn = true; +void (typeof window !== 'undefined' && + import('../measure').then((m) => m.measureSnap.subscribe((on) => (measureSnapOn = !!on))).catch(() => {})); diff --git a/src/lib/vr/iconData.js b/src/lib/vr/iconData.js index d4fac95be..e2dbbd3ac 100644 --- a/src/lib/vr/iconData.js +++ b/src/lib/vr/iconData.js @@ -87,5 +87,5 @@ export const LUCIDE_NODES = { 'waves-horizontal': [["path",{"d":"M2 12q2.5 2 5 0t5 0 5 0 5 0"}],["path",{"d":"M2 19q2.5 2 5 0t5 0 5 0 5 0"}],["path",{"d":"M2 5q2.5 2 5 0t5 0 5 0 5 0"}]], 'minus': [["path",{"d":"M5 12h14"}]], 'check': [["path",{"d":"M20 6 9 17l-5-5"}]], - 'brush': [["path",{"d":"m11 10 3 3"}],["path",{"d":"M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z"}],["path",{"d":"M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"}]] + 'brush': [["path",{"d":"m11 10 3 3"}],["path",{"d":"M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z"}],["path",{"d":"M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"}]], }; diff --git a/src/lib/vr/measure.js b/src/lib/vr/measure.js new file mode 100644 index 000000000..b259632d9 --- /dev/null +++ b/src/lib/vr/measure.js @@ -0,0 +1,316 @@ +// VR controls — MEASURE IN THE HEADSET (52 V1). The desktop Measure tool (measure.js: chains of points with a +// length per segment, the angle at every joint and a running total; a kept chain is a 48 B2 measurement ITEM in +// the object list, replicated and undoable) driven by the laser instead of the mouse: +// - Tools ▸ Measure arms it (the trigger tool, beside Select / Box select / Draw); the pointer hand's laser +// shows where the next point lands — the dashed preview segment and the snap marker follow it +// - every trigger press puts a point down, SNAPPED through the snap engine's own search (vertex / edge / face, +// the desktop tool's flags and bias) inside a 3° cone around the laser; a raw hit lands on the grid when +// grid snapping is on (vrMeasureCore) +// - a second press on the same spot (a double tap), or the ring's Keep, keeps the chain as a scene item; the +// radial opens on the Measure ring while the tool is up: Keep · Undo point · Discard · Snap · Done +// - Selected ▸ Edit measurement edits a kept item: a press near one of its points (by angle) picks it up, the +// next puts it down (snapped), a press anywhere else adds a point at the end — measure.js's own edit step, +// so one undo entry and one message per placed point, exactly as on the desktop +// - the labels are troika text in the scene (VRMeasureLabels), facing you and the same size at any distance +// Hands: precise tools want a controller — a pinch while measuring says "pick up a controller" and does nothing. +// Edit only: Interact, leaving the headset or another trigger tool ends it (a chain of 2+ points is kept, the +// desktop ✓ rule). Rides the K1 hook registries, registered on first use (the vr/sculpt.js pattern). +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { editorMode, vrMenuOpen, vrMenuHand, vrToolMode, isVRMode } from '../../stores/sceneStore'; +import { showToast } from '../../stores/appStore'; +import { + measureMode, + measurement, + measureHover, + measureSnap, + measureEditing, + startMeasure, + stopMeasure, + addMeasurePoint, + undoMeasurePoint, + keepMeasurement, + discardMeasurement, + editMeasurement, + removeEditPoint, + measureEditStep, + MEASURE_TARGETS, + MEASURE_BIAS, + RADIUS_MOUSE +} from '../measure'; +import { measurementOf, worldPointsOf } from '../measureItems'; +import { findSnapCandidate, activeSnapCandidate } from '../snapEngine'; +import { sceneHits } from '../scenePick'; +import { snapEnabled, snapSettings } from '../snapping'; +import { drawMode, toggleDrawMode } from '../drawMode'; +import { + VR_SNAP_RADIUS_PX, + rayCameraFov, + snapToGrid, + angularPick, + isDoubleTap, + coneBias +} from '../vrMeasureCore'; +import { renderer } from './core.js'; +import { hapticPulse } from './haptics.js'; +import { controllerIndexFor } from './input.js'; +import { controllerRay, beamTarget } from './pointer.js'; +import { vrHovered } from './panels.js'; +import { pushRing, activeRing } from '../vrRadialMenu'; +import { registerVRFrameHook, registerVRTriggerHooks } from './hooks.js'; +import { boxSelectEnd } from './tools.js'; + +const HOVER_INTERVAL_MS = 33; +const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); +const _plane = new THREE.Vector3(); +const rayCam = new THREE.PerspectiveCamera(50, 1, 0.01, 2000); +const _fwd = new THREE.Vector3(0, 0, -1); +/** the desktop measure bias, scaled to the cone (a corner wins from as far out, relatively, as under the mouse) */ +const VR_BIAS = coneBias(MEASURE_BIAS, RADIUS_MOUSE); + +let hooked = false; +let swallowAt = 0; +let lastHoverAt = 0; +let lastHandToast = 0; +/** the last point a press put down — the double-tap rule reads it @type {{at: number, point: number[] | null}} */ +let lastTap = { at: 0, point: null }; +/** the suites' view */ +const stats = { presses: 0, placed: 0, kept: 0, edits: 0, handRefusals: 0, hoverMs: 0, hovers: 0 }; + +/** the pointer hand (the one that does not hold the menu) */ +function pointerHand() { + return get(vrMenuHand) === 'right' ? 'left' : 'right'; +} +function pointerIndex() { + return controllerIndexFor(pointerHand()); +} + +/** is the VR measure tool up? (the trigger tool is Measure, in a headset) */ +export function vrMeasureActive() { + return get(vrToolMode) === 'measure' && !!renderer?.xr?.isPresenting; +} + +/** a FRESH raycaster down a controller's laser (the shared one is reused every frame) @param {number} index */ +function rayOf(index) { + const shared = controllerRay(index); + const ray = new THREE.Raycaster(shared.ray.origin.clone(), shared.ray.direction.clone()); + /** @type {any} */ (ray).camera = /** @type {any} */ (shared).camera; + return ray; +} + +/** is this slot's input a tracked HAND (no controller in it)? @param {number} index */ +function isHandInput(index) { + const session = renderer?.xr?.getSession?.(); + const hand = renderer?.xr?.getController(index)?.userData?.handedness; + const source = session && hand ? [...session.inputSources].find((s) => s.handedness === hand) : null; + return !!source?.hand; +} + +/** + * Where a laser measures to: the best snap candidate inside the cone, else the object surface under the + * ray, else the ground plane — a raw point lands on the grid when grid snapping is on. + * @param {any} ray a THREE.Raycaster @param {{publish?: boolean}} [opts] publish = move the snap marker + * @returns {{point: number[], snap: string} | null} + */ +export function resolveVRMeasurePoint(ray, opts = {}) { + if (get(measureSnap)) { + const rect = renderer?.domElement?.getBoundingClientRect?.(); + if (rect && rect.width && rect.height) { + // the virtual camera: at the controller, looking down the laser (roll does not matter — the + // cone is round), its fov set so the engine's px radius is the angular cone + rayCam.fov = rayCameraFov(rect.height); + rayCam.aspect = rect.width / rect.height; + rayCam.updateProjectionMatrix(); + rayCam.position.copy(ray.ray.origin); + rayCam.quaternion.setFromUnitVectors(_fwd, ray.ray.direction); + rayCam.updateMatrixWorld(true); + const before = get(activeSnapCandidate); + /** @type {any} */ + const candidate = findSnapCandidate(rayCam, [0, 0], [], { + targets: { ...MEASURE_TARGETS, radiusPx: VR_SNAP_RADIUS_PX }, + bias: VR_BIAS + }); + if (opts.publish === false && get(activeSnapCandidate) !== before) activeSnapCandidate.set(before); + if (candidate?.point) return { point: candidate.point.toArray(), snap: candidate.type }; + } else if (opts.publish !== false) activeSnapCandidate.set(null); + } + const step = get(snapEnabled) ? Number(get(snapSettings)?.translate) || 0 : 0; + const hit = sceneHits(ray)[0]; + if (hit) return snapToGrid(hit.point.toArray(), step, ''); + if (ray.ray.intersectPlane(groundPlane, _plane)) return snapToGrid(_plane.toArray(), step, 'ground'); + return null; +} + +function ensureHooks() { + if (hooked) return; + hooked = true; + registerVRTriggerHooks({ + start: (/** @type {number} */ i) => measureTriggerStart(i), + swallow: () => { + const pending = swallowAt && Date.now() - swallowAt < 5000; + swallowAt = 0; + return !!pending; + } + }); + registerVRFrameHook(() => tickVRMeasure()); +} + +/** Tools ▸ Measure: arm the laser tool (and the desktop tool's state under it). */ +export function startVRMeasure() { + ensureHooks(); + if (get(drawMode)) toggleDrawMode(); + boxSelectEnd(true); + vrToolMode.set('measure'); + if (!get(measureMode)) startMeasure(); + vrMenuOpen.set(false); + lastTap = { at: 0, point: null }; + return true; +} + +/** Done / another tool / Interact / leaving the headset: a chain of 2+ points is KEPT (the desktop ✓); + * `keep: false` drops it (Discard then Done). An edit session simply ends (its points are saved). */ +export function stopVRMeasure(opts = {}) { + if (get(measureMode)) stopMeasure(opts); + measureHover.set(null); + activeSnapCandidate.set(null); + if (get(vrToolMode) === 'measure') vrToolMode.set('select'); + lastTap = { at: 0, point: null }; +} + +/** Selected ▸ Edit measurement: edit a kept item's points from the headset @param {string} uuid */ +export function editVRMeasurement(uuid) { + ensureHooks(); + if (get(drawMode)) toggleDrawMode(); + boxSelectEnd(true); + if (!editMeasurement(uuid)) return false; + vrToolMode.set('measure'); + vrMenuOpen.set(false); + return true; +} + +/** the ring's Keep: the chain becomes a scene item and a fresh one starts */ +export function vrKeepMeasurement() { + if (get(measureEditing)) { + stopVRMeasure(); + return true; + } + const kept = keepMeasurement(); + if (kept) { + stats.kept++; + hapticPulse(0.5, 40, pointerHand()); + } + lastTap = { at: 0, point: null }; + return kept; +} + +/** the ring's Undo point: the last point of the chain being drawn (or, editing, the picked/last point) */ +export function vrUndoMeasurePoint() { + if (get(measureEditing)) return removeEditPoint(); + undoMeasurePoint(); + lastTap = { at: 0, point: null }; + return true; +} + +/** the ring's Discard */ +export function vrDiscardMeasurement() { + discardMeasurement(); + lastTap = { at: 0, point: null }; +} + +/** @param {number} index */ +export function measureTriggerStart(index) { + if (!vrMeasureActive() || index < 0) return false; + // the radial or a panel under the laser owns this press + if (get(vrMenuOpen) || get(vrHovered)) return false; + if (beamTarget(controllerRay(index)).panel) return false; + stats.presses++; + swallowAt = Date.now(); + if (isHandInput(index)) { + stats.handRefusals++; + if (Date.now() - lastHandToast > 4000) { + lastHandToast = Date.now(); + showToast('Measure: pick up a controller — hand tracking browses and places, measuring needs the laser'); + } + return true; + } + const ray = rayOf(index); + if (get(measureEditing)) { + const object = measurementOf(/** @type {any} */ (get(measureEditing)).uuid); + const editing = get(measureEditing); + const near = + object && editing && editing.active < 0 + ? angularPick(ray.ray.origin.toArray(), ray.ray.direction.toArray(), worldPointsOf(object)) + : -1; + const step = measureEditStep(near, () => resolveVRMeasurePoint(ray, { publish: false })); + if (step) { + stats.edits++; + hapticPulse(step === 'picked' ? 0.25 : 0.4, 25, index === pointerIndex() ? pointerHand() : undefined); + } + return true; + } + const hit = resolveVRMeasurePoint(ray, { publish: false }); + if (!hit) return true; + const now = performance.now(); + const handDistance = ray.ray.origin.distanceTo(new THREE.Vector3(hit.point[0], hit.point[1], hit.point[2])); + if ((get(measurement)?.points.length ?? 0) >= 2 && isDoubleTap(lastTap, now, hit.point, handDistance)) { + vrKeepMeasurement(); + return true; + } + addMeasurePoint(hit.point, hit.snap); + lastTap = { at: now, point: hit.point }; + stats.placed++; + // a corner / edge / face snap clicks harder than a raw point: you feel what you got + hapticPulse(hit.snap && hit.snap !== 'ground' ? 0.45 : 0.25, 22); + return true; +} + +/** per frame: the preview follows the pointer laser (throttled), the tool ends with its context */ +export function tickVRMeasure() { + if (get(vrToolMode) !== 'measure') return; + if (!renderer?.xr?.getSession?.() || get(editorMode) === 'interact' || !get(measureMode)) { + stopVRMeasure(); + return; + } + const now = performance.now(); + if (now - lastHoverAt < HOVER_INTERVAL_MS) return; + lastHoverAt = now; + if (get(vrMenuOpen) || get(vrHovered)) { + if (get(measureHover)) measureHover.set(null); + if (get(activeSnapCandidate)) activeSnapCandidate.set(null); + return; + } + const index = pointerIndex(); + if (index < 0 || isHandInput(index)) return; + const t0 = performance.now(); + const ray = rayOf(index); + measureHover.set(beamTarget(ray).panel ? null : resolveVRMeasurePoint(ray)); + stats.hoverMs += performance.now() - t0; + stats.hovers++; +} + +/** the suites' view */ +export function vrMeasureDebug() { + const editing = get(measureEditing); + return { + active: vrMeasureActive(), + tool: get(vrToolMode), + mode: get(measureMode), + points: get(measurement)?.points.map((p) => [...p]) ?? [], + snaps: get(measurement)?.snaps ?? [], + hover: get(measureHover), + editing: editing ? { ...editing } : null, + ring: get(activeRing), + ...stats, + hoverAvgMs: stats.hovers ? stats.hoverMs / stats.hovers : 0 + }; +} + +// the tool follows the trigger-tool choice: picking Select / Box / Draw (or leaving the headset) ends it. +// Declared LAST (the TDZ rule: a subscriber runs synchronously at subscribe time). +vrToolMode.subscribe((tool) => { + if (tool !== 'measure' && get(measureMode) && (get(isVRMode) || renderer?.xr?.isPresenting)) stopVRMeasure(); +}); +// the radial opens on the Measure ring while the tool is up (Back reaches the usual menu) — the sculpt ring's rule +vrMenuOpen.subscribe((open) => { + if (open && get(vrToolMode) === 'measure' && get(measureMode) && get(activeRing) === 'root') pushRing('measure'); +}); diff --git a/src/lib/vrMeasureCore.js b/src/lib/vrMeasureCore.js new file mode 100644 index 000000000..529d4e0e3 --- /dev/null +++ b/src/lib/vrMeasureCore.js @@ -0,0 +1,110 @@ +// 52 V1: the VR measure tool's arithmetic. A LEAF — imports nothing — so the laser's snap cone, +// the grid rounding, the point pick-up, the double-tap keep and the label sizing are unit-tested +// without a headset, and vrMeasure.js / VRMeasureLabels.svelte read the same numbers. +// +// The laser snaps the way the desktop cursor does, through the SAME snap-engine search: a virtual +// camera stands at the controller looking down the ray, so the ray is the centre of its view and +// the engine's screen-pixel radius becomes an ANGULAR cone around the laser. A cone (not a +// distance) is what a pointing hand can hold steady: a corner 1° off reads as close at 1 m and at +// 10 m alike. + +/** half-angle (degrees) of the cone around the laser a corner / edge / face snaps from */ +export const VR_SNAP_CONE_DEG = 3; +/** the engine's radius (screen px of the virtual camera) the cone is expressed in */ +export const VR_SNAP_RADIUS_PX = 40; +/** how far (degrees) from the laser an edit trigger may land to pick a kept point up */ +export const VR_PICK_CONE_DEG = 3; +/** a second trigger this soon, on (nearly) the same spot, keeps the chain — the desktop double click */ +export const VR_DOUBLE_TAP_MS = 450; +/** the label's height as an ANGLE (radians): a label scales with its distance, so it reads the same + * size near and far (~1.7°, comfortably above a Quest 3's ~0.04°/px) */ +export const VR_LABEL_ANGLE = 0.03; +export const VR_LABEL_MIN_M = 0.008; +export const VR_LABEL_MAX_M = 1.5; + +/** + * The desktop measure bias (px, for an 18 px mouse radius) laid onto the laser's cone: a bias is a pull + * RELATIVE to the reach, so it scales with the radius. Unscaled, a corner 1.2° off (16 px of a 40 px cone) + * spent the whole -16 px vertex bias on its distance and an edge 1 cm off the ray won — measured on IWER. + * @param {Record} bias @param {number} mouseRadiusPx @param {number} [radiusPx] + * @returns {Record} + */ +export function coneBias(bias, mouseRadiusPx, radiusPx = VR_SNAP_RADIUS_PX) { + const k = radiusPx / mouseRadiusPx; + return Object.fromEntries(Object.entries(bias).map(([type, b]) => [type, b * k])); +} + +/** + * The vertical field of view (degrees) of the virtual ray camera, chosen so `radiusPx` from the + * centre of a `rectHeight`-px view spans `coneDeg`. + * @param {number} rectHeight @param {number} [radiusPx] @param {number} [coneDeg] + */ +export function rayCameraFov(rectHeight, radiusPx = VR_SNAP_RADIUS_PX, coneDeg = VR_SNAP_CONE_DEG) { + const h = rectHeight > 0 ? rectHeight : 720; + const tanHalf = (Math.tan((coneDeg * Math.PI) / 180) * (h / 2)) / radiusPx; + return (2 * Math.atan(tanHalf) * 180) / Math.PI; +} + +/** + * Grid snapping for a RAW point (no corner / edge / face won): x and z land on the grid; y too on + * the ground plane (a raw surface hit keeps its height — rounding it would lift the point off the + * surface it sits on). Step <= 0 = no grid. + * @param {number[]} point @param {number} step @param {string} snap '' (surface) | 'ground' + * @returns {{point: number[], snap: string}} + */ +export function snapToGrid(point, step, snap) { + if (!(step > 0)) return { point: [point[0], point[1], point[2]], snap }; + const r = (/** @type {number} */ v) => Math.round(v / step) * step; + return { point: [r(point[0]), snap === 'ground' ? r(point[1]) : point[1], r(point[2])], snap: 'grid' }; +} + +/** + * The index of the point nearest the ray by ANGLE, within `maxDeg`, or -1 (points behind the + * controller are never picked). + * @param {number[]} origin @param {number[]} dir unit @param {number[][]} points @param {number} [maxDeg] + */ +export function angularPick(origin, dir, points, maxDeg = VR_PICK_CONE_DEG) { + let best = -1; + let bestAngle = (maxDeg * Math.PI) / 180; + points.forEach((p, i) => { + const v = [p[0] - origin[0], p[1] - origin[1], p[2] - origin[2]]; + const len = Math.hypot(v[0], v[1], v[2]); + if (len < 1e-6) return; + const cos = (v[0] * dir[0] + v[1] * dir[1] + v[2] * dir[2]) / len; + if (cos <= 0) return; + const angle = Math.acos(Math.min(1, cos)); + if (angle <= bestAngle) (best = i), (bestAngle = angle); + }); + return best; +} + +/** + * Is this trigger the second half of a double tap: soon after the last point, on (nearly) the same + * spot — within 3 cm or 2 % of the distance from the hand, whichever is larger. + * @param {{at: number, point: number[] | null}} last @param {number} now @param {number[]} point + * @param {number} handDistance metres from the controller to the point + */ +export function isDoubleTap(last, now, point, handDistance) { + if (!last.point || now - last.at > VR_DOUBLE_TAP_MS) return false; + const d = Math.hypot(point[0] - last.point[0], point[1] - last.point[1], point[2] - last.point[2]); + return d <= Math.max(0.03, handDistance * 0.02); +} + +/** a label's world height at `distance` metres from the eye: constant ANGULAR size, clamped + * @param {number} distance */ +export function labelWorldHeight(distance) { + return Math.min(VR_LABEL_MAX_M, Math.max(VR_LABEL_MIN_M, distance * VR_LABEL_ANGLE)); +} + +/** + * The px metrics `measureCore.layoutLabels` needs for VR labels seen through a camera whose view is + * `viewHeightPx` tall over `fovDeg`: every label is the same ANGLE tall, so it is the same number of + * pixels tall wherever it stands — which is what lets the desktop's screen-space layout keep VR + * labels apart too. + * @param {number} viewHeightPx @param {number} fovDeg + * @returns {{h: number, charW: number, padX: number, gap: number}} + */ +export function labelPxMetrics(viewHeightPx, fovDeg) { + const h = (VR_LABEL_ANGLE / ((fovDeg * Math.PI) / 180)) * viewHeightPx * 1.25; // a line + its outline + return { h, charW: h * 0.5, padX: h * 0.5, gap: Math.max(1, h * 0.1) }; +} diff --git a/src/lib/vrRadialMenu.js b/src/lib/vrRadialMenu.js index 95972ebaa..dded712b2 100644 --- a/src/lib/vrRadialMenu.js +++ b/src/lib/vrRadialMenu.js @@ -16,6 +16,7 @@ import { savePrefab, savePrefabSelection } from './prefabs'; import { perfContext } from './perf/perfMarks.js'; // 34 PF: an import-free leaf import { vrDollhouseOpen, vrSculptActive } from './vr/worldStores.js'; // 37: a leaf import { sculptOp } from './terrainSculpt'; +import { registerMeasureRings } from './vr/buildRings.js'; // 52 V1 import { primitivesCatalog } from './primitivesCatalog'; // D4 (roadmap 13): selection-set helpers for the Edit ring — counted labels @@ -200,6 +201,7 @@ export function ringTitle(ring) { if (ring === 'root') return ''; if (ring === 'object') return 'Selected'; if (ring === 'sculpt') return 'Sculpt terrain'; // 37 R9: opened by a session, not a ▸ sector + if (ring === 'measure') return 'Measure'; // 52 V1: opened by the tool, not a ▸ sector for (const list of registry.values()) { const opener = list.find((e) => e.ring === ring); if (opener) return typeof opener.label === 'function' ? opener.label() : opener.label; @@ -461,7 +463,8 @@ function registerBuiltins() { selCount() <= 1 && /** @type {any} */ (get(selectedObject))?.type !== 'Group' && !isSplineSelection() && - !isTerrainSelection() + !isTerrainSelection() && + !Array.isArray(/** @type {any} */ (get(selectedObject))?.userData?.measure?.points) // 52: Edit measurement }); // 37 R9: a TERRAIN gets the sculpt brush in the same slot (the desktop object menu's Sculpt terrain) registerVRMenuEntry({ @@ -604,6 +607,9 @@ function registerBuiltins() { registerVRMenuEntry({ id: 'face:inset', group: 'faces', label: 'Inset', order: 1 }); registerVRMenuEntry({ id: 'face:move', group: 'faces', label: 'Move', order: 2 }); registerVRMenuEntry({ id: 'face:delete', group: 'faces', label: 'Delete', order: 3 }); + + // 52 V1: Tools ▸ Measure + the Measure ring, Selected ▸ Edit measurement + registerMeasureRings(registerVRMenuEntry); } registerBuiltins(); diff --git a/tests/e2e/vr-measure-iwer.test.cjs b/tests/e2e/vr-measure-iwer.test.cjs new file mode 100644 index 000000000..2bbfbb85b --- /dev/null +++ b/tests/e2e/vr-measure-iwer.test.cjs @@ -0,0 +1,440 @@ +// 52 V1 + V2 — MEASURE AND ARRANGE IN A HEADSET, on IWER (48 G2: the app's own XR path, a Quest 3 emulated). +// A wears the headset; B is a desktop peer that must see every result. +// +// T Tools ▸ Measure is picked with the STICK and the stick PRESS (the radial, no test call) +// M laser points snap through the snap engine inside a 3° cone: a laser 3 cm inside a box corner puts the +// point ON the corner (vertex); the chain reads 2.000 m / 90.0° / Total 3.000 m +// L the labels are troika text in the scene: they face the viewer and are the same ANGLE tall near and far +// K a double tap keeps the chain: a measurement ITEM on A and on B (the 48 B2 item) +// N Snap off (the Measure ring) = the same laser does NOT land on the corner (the counterfactual for M) +// G grid snapping on: a raw floor point lands on the grid +// E Selected ▸ Edit measurement: a press near a point (by angle) picks it up, the next puts it down +// snapped; ONE undo entry; B sees it; Undo puts it back on both peers +// H hand tracking: a pinch while measuring says "pick up a controller" and adds nothing +// A Selected ▸ Arrange ▸ Align X min (stick-driven) / X even gaps / Drop to ground: the alignCore +// answer, on A and B, ONE undo restores the whole set on both +// F frame cost on the Quest profile: the hover search + the label posing per frame, against 13.9 ms +// A real-Quest row (feel, label legibility at the true eye resolution, haptics) goes on 52-int's checklist. +// Run: APP_URL=https://localhost:5481/ node tests/e2e/vr-measure-iwer.test.cjs (ONLY=M,A to pick rows) +const h = require('./helpers.cjs'); +const iwer = require('./iwer.cjs'); + +const ONLY = (process.env.ONLY || '').split(',').filter(Boolean); +const want = (row) => !ONLY.length || ONLY.includes(row); +const near = (a, b, tol = 1e-5) => a.length === b.length && a.every((v, i) => Math.abs(v - b[i]) <= tol); +const fmt = (p) => (p ? '[' + p.map((v) => (+v).toFixed(3)).join(', ') + ']' : 'null'); +const dist = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { init: iwer.initScript(), context: { viewport: { width: 1280, height: 720 } } }); + const B = await h.setupPage(browser, 'B', { context: { viewport: { width: 1280, height: 720 } } }); + await h.connect(B, A); + const page = A.page; + const g = (fn, arg) => page.evaluate(fn, arg); + const read = (name, p = page) => p.evaluate((n) => { let v; window.__stores[n].subscribe((x) => (v = x))(); return v; }, name); + const settle = (n = 6) => iwer.frames(page, n); + const dbg = () => g(() => window.__stores.vrMeasure.vrMeasureDebug()); + const depth = (p = page) => p.evaluate(() => { let d; window.__stores.history.undoStack.subscribe((v) => (d = v.length))(); return d; }); + const items = (p) => p.evaluate(() => window.__stores.measureItems.measurementItems().map((o) => ({ uuid: o.uuid, points: window.__stores.measureItems.worldPointsOf(o) }))); + + /** create boxes on A (`/create Box w h d`), return their uuids */ + const createBoxes = (dims) => + g(async (list) => { + const w = window.__stores; + const out = []; + for (const d of list) { + w.commandsHandler.sceneCommand('/create Box ' + d); + await new Promise((r) => setTimeout(r, 400)); + let gr; w.objectsGroup.subscribe((v) => (gr = v))(); + out.push(gr.children[gr.children.length - 1].uuid); + } + return out; + }, dims); + /** pose boxes on A and replicate them (the move path), the align-arrange suite's helper */ + const place = (list) => + g((items) => { + const w = window.__stores; + let gr; w.objectsGroup.subscribe((v) => (gr = v))(); + let p; w.peers.subscribe((v) => (p = v))(); + for (const it of items) { + const o = gr.getObjectByProperty('uuid', it.uuid); + o.position.set(...it.pos); + o.rotation.set(0, 0, 0); + o.scale.set(1, 1, 1); + o.updateMatrixWorld(true); + p.send({ type: 'move', uuid: it.uuid, pos: it.pos, rot: [0, 0, 0], scale: [1, 1, 1] }); + } + }, list); + /** world boxes of uuids on a page */ + const boxes = (p, uuids) => + p.evaluate((ids) => { + let gr; window.__stores.objectsGroup.subscribe((v) => (gr = v))(); + return ids.map((id) => { + const o = gr.getObjectByProperty('uuid', id); + if (!o) return null; + o.updateMatrixWorld(true); + o.geometry.computeBoundingBox(); + const bb = o.geometry.boundingBox.clone().applyMatrix4(o.matrixWorld); + return { pos: o.position.toArray(), min: bb.min.toArray(), max: bb.max.toArray() }; + }); + }, uuids); + + // ---- the headset --------------------------------------------------------------------------------- + const st = await iwer.enter(page); + h.check(st.presenting && st.same, 'premise: Play presents the IWER session'); + const menuHand = await read('vrMenuHand'); + const pointer = menuHand === 'right' ? 'left' : 'right'; + const menuButton = menuHand === 'left' ? 'y-button' : 'b-button'; + const P0 = pointer === 'left' ? [-0.2, 1.3, -0.35] : [0.2, 1.3, -0.35]; + await iwer.pose(page, 'head', [0, 1.6, 0]); + await iwer.pose(page, menuHand, menuHand === 'left' ? [-0.25, 1.1, -0.3] : [0.25, 1.1, -0.3]); + await iwer.pose(page, pointer, P0); + await settle(8); + const w0 = await iwer.controllerWorld(page); + const rig = w0[pointer].pos.map((v, i) => v - P0[i]); + const head = w0.head.pos; + console.log(`menu hand ${menuHand}, pointer ${pointer}, rig offset ${fmt(rig)}, head ${fmt(head)}`); + + /** the pointer controller's world forward */ + const forward = () => + g(() => { + let r; window.__stores.globalRenderer.subscribe((v) => (r = v))(); + for (const i of [0, 1]) { + const c = r.xr.getController(i); + if (c.userData.handedness !== window.__pointerHand) continue; + c.updateMatrixWorld(true); + const e = c.matrixWorld.elements; + return [-e[8], -e[9], -e[10]]; + } + return null; + }); + await g((p) => (window.__pointerHand = p), pointer); + /** aim the pointer laser (from P0) at a WORLD point */ + const aim = async (target, frames = 6) => { + const o = P0.map((v, i) => v + rig[i]); + const d = target.map((v, i) => v - o[i]); + const yaw = Math.atan2(-d[0], -d[2]); + const pitch = Math.atan2(d[1], Math.hypot(d[0], d[2])); + await iwer.pose(page, pointer, P0, { yaw, pitch }); + await settle(frames); + return { o, d }; + }; + const press = async (hand = pointer) => { + await iwer.button(page, hand, 'trigger', 1); + await settle(3); + await iwer.button(page, hand, 'trigger', 0); + await settle(5); + }; + /** pick a sector of the OPEN ring with the stick + stick press (null id = the hub) */ + const stickRun = async (id) => { + if (id === null) { + await iwer.stick(page, menuHand, 0, 0); + await settle(3); + await iwer.tap(page, menuHand, 'thumbstick'); + await settle(3); + return true; + } + const pick = await g((want) => { + const m = window.__stores.vrRadialMenu; + let ring; m.activeRing.subscribe((v) => (ring = v))(); + const ents = m.ringEntries(ring); + const k = ents.findIndex((e) => e.id === want); + if (k < 0) return { ring, ids: ents.map((e) => e.id) }; + const a = (k * 2 * Math.PI) / ents.length; + return { ring, k, x: Math.sin(a), y: -Math.cos(a) }; + }, id); + if (pick.k == null) { + h.check(false, `the "${pick.ring}" ring offers ${id} (${pick.ids})`); + return false; + } + await iwer.stick(page, menuHand, pick.x, pick.y); + await settle(8); + const hovered = await g(() => { let v; window.__stores.vrControls.vrHovered.subscribe((x) => (v = x))(); return v; }); + await iwer.tap(page, menuHand, 'thumbstick'); + await iwer.stick(page, menuHand, 0, 0); + await settle(4); + return hovered === id; + }; + const openMenu = async () => { + if (!(await read('vrMenuOpen'))) await iwer.tap(page, menuHand, menuButton); + await settle(4); + return read('vrMenuOpen'); + }; + const closeMenu = async () => { + if (await read('vrMenuOpen')) await iwer.tap(page, menuHand, menuButton); + await settle(3); + }; + + // the fixture: a 2 x 1 x 1 box standing 2 m ahead of the head, so its top corners are (cx ± 1, 1, cz ± 0.5) + const [boxId] = await createBoxes(['2 1 1']); + await page.waitForTimeout(1500); // /create re-seats after it returns + const C = [head[0], 0.5, head[2] - 2]; + await place([{ uuid: boxId, pos: C }]); + await h.eventually(() => boxes(B.page, [boxId]), (b) => b[0] && near(b[0].pos, C, 1e-6), 'premise: the box stands 2 m ahead of the head, on A and on B', 20000); + await g(() => window.__stores.objectActions.deselectObject?.()); + const FL = [C[0] - 1, 1, C[2] + 0.5]; + const FR = [C[0] + 1, 1, C[2] + 0.5]; + const BR = [C[0] + 1, 1, C[2] - 0.5]; + const BL = [C[0] - 1, 1, C[2] - 0.5]; + const TOP = [C[0], 1, C[2]]; + /** 3 cm inside a corner toward the top centre — on the box, off the corner */ + const inside = (corner, by = 0.03) => { + const d = TOP.map((v, i) => v - corner[i]); + const l = Math.hypot(...d); + return corner.map((v, i) => v + (d[i] / l) * by); + }; + + // ---- T. Tools ▸ Measure from the stick --------------------------------------------------------------- + if (want('T') || want('M') || want('L') || want('K') || want('N') || want('G')) { + console.log('\n=== T. Tools ▸ Measure ==='); + h.check(await openMenu(), 'the menu button opens the radial'); + h.check(await stickRun('nav:tools'), 'the stick lights Tools ▸ and the press opens it'); + h.check(await stickRun('measure:tool'), 'the stick lights Measure in the Tools ring'); + const d0 = await dbg(); + h.check(d0.active && d0.mode && d0.tool === 'measure', `Measure is the trigger tool (${JSON.stringify({ active: d0.active, mode: d0.mode, tool: d0.tool })})`); + h.check((await read('vrMenuOpen')) === false, 'picking it closes the radial'); + } + + // ---- M. snapped laser points ----------------------------------------------------------------------- + if (want('M') || want('L') || want('K') || want('N')) { + console.log('\n=== M. the laser snaps to corners ==='); + const { o, d } = await aim(inside(FL)); + const f = await forward(); + const l = Math.hypot(...d); + const angle = (Math.acos(Math.min(1, (f[0] * d[0] + f[1] * d[1] + f[2] * d[2]) / l)) * 180) / Math.PI; + h.check(angle < 0.3, `premise: the laser points where the suite aims it (${angle.toFixed(3)}° off)`); + const off = (Math.atan2(0.03, dist(o, FL)) * 180) / Math.PI; + console.log(`the laser runs ${off.toFixed(2)}° from the corner`); + await h.eventually(dbg, (x) => x.hover && near(x.hover.point, FL), `the HOVER sits on the corner while the laser is 3 cm inside it (${off.toFixed(2)}° off)`, 5000); + const hv = (await dbg()).hover; + h.check(hv?.snap === 'vertex', `…snapped as a vertex (${hv?.snap})`); + const marker = await g(() => { let s; window.__stores.globalScene.subscribe((v) => (s = v))(); let vis = false; s.traverse((o) => { if (/snap/i.test(o.name) && o.visible && o.isGroup) vis = true; }); let c; window.__stores.snapEngine.activeSnapCandidate.subscribe((v) => (c = v))(); return { vis, type: c?.type ?? null }; }); + h.check(marker.type === 'vertex', `the snap marker shows the corner under the laser (${JSON.stringify(marker)})`); + await press(); + let s = await dbg(); + h.check(s.points.length === 1 && near(s.points[0], FL) && s.snaps[0] === 'vertex', `the TRIGGER puts the first point ON the corner ${fmt(s.points[0])} (${s.snaps[0]})`); + await aim(inside(FR)); + await press(); + await aim(inside(BR)); + await press(); + s = await dbg(); + h.check(s.points.length === 3 && near(s.points[1], FR) && near(s.points[2], BR), `three points: ${s.points.map(fmt).join(' → ')}`); + const labels = await g(() => window.__vrMeasureLabels.filter((l) => !l.hidden && l.key.startsWith('cur:')).map((l) => l.text)); + h.check(labels.includes('2.000 m') && labels.includes('1.000 m'), `the segments read 2.000 m and 1.000 m (${labels.join(' | ')})`); + h.check(labels.includes('90.0°'), 'the turn at the corner reads 90.0°'); + h.check(labels.some((t) => t.startsWith('Total 3.000 m')), 'the running total reads Total 3.000 m'); + } + + // ---- L. the labels: troika text facing you, the same angle tall near and far ----------------------------- + if (want('L')) { + console.log('\n=== L. labels in the headset ==='); + const look = () => + g(() => { + let r; window.__stores.globalRenderer.subscribe((v) => (r = v))(); + let s; window.__stores.globalScene.subscribe((v) => (s = v))(); + const cam = r.xr.getCamera(); + const q = cam.getWorldQuaternion(cam.quaternion.clone()); + const out = []; + s.traverse((o) => { + if (o.name !== 'vr-measure-label' || !o.visible) return; + const lq = o.getWorldQuaternion(o.quaternion.clone()); + const text = o.children.find((c) => c.text != null)?.text; + out.push({ text, dot: Math.abs(q.dot(lq)), troika: !!o.children.find((c) => c.text != null && c.isMesh) }); + }); + return { out, list: window.__vrMeasureLabels.map((l) => ({ ...l })) }; + }); + await settle(10); + const a = await look(); + const seg = a.list.find((l) => l.text === '2.000 m'); + h.check(a.out.length >= 4 && a.out.every((x) => x.troika), `the labels are troika text meshes in the scene (${a.out.length}: ${a.out.map((x) => x.text).join(' | ')})`); + h.check(a.out.every((x) => x.dot > 0.999), `every label faces the viewer (min |q·q| ${Math.min(...a.out.map((x) => x.dot)).toFixed(5)})`); + h.check(!!seg && Math.abs(seg.height / seg.distance - 0.03) < 1e-6, `a label is 0.03 rad tall: ${seg?.height.toFixed(4)} m at ${seg?.distance.toFixed(3)} m`); + h.check(!!seg && seg.height >= 0.025, `…which at arm's length is a readable ${((seg?.height ?? 0) * 100).toFixed(1)} cm`); + await iwer.pose(page, 'head', [0, 1.6, 2.5]); // step back 2.5 m + await settle(10); + const b = await look(); + const seg2 = b.list.find((l) => l.text === '2.000 m'); + h.check(!!seg2 && seg2.distance > seg.distance + 2 && Math.abs(seg2.height / seg2.distance - 0.03) < 1e-6, `stepping back, the label GROWS with its distance (${seg?.height.toFixed(3)} m → ${seg2?.height.toFixed(3)} m at ${seg2?.distance.toFixed(2)} m): the same size to the eye`); + h.check(b.out.every((x) => x.dot > 0.999), '…and still faces the viewer'); + await iwer.pose(page, 'head', [0, 1.6, 0]); + await settle(6); + } + + // ---- K. a double tap keeps the chain — a measurement ITEM on both peers -------------------------------- + let itemId = null; + if (want('K') || want('E')) { + console.log('\n=== K. keep ==='); + if (!(await dbg()).points.length) { + for (const c of [FL, FR, BR]) { + await aim(inside(c)); + await press(); + } + } + // the chain ends ON BR: take that point back (the ring's Undo point), then put it down with a DOUBLE TAP — + // the first tap places it, the second, on the same spot, keeps the chain (the desktop double click) + await g(() => window.__stores.vrControls.executeVRMenuAction('measure:undo')); + await h.eventually(dbg, (x) => x.points.length === 2, 'the ring\'s Undo point takes the last point back', 5000); + const before = (await items(page)).length; + await aim(inside(BR)); + for (let i = 0; i < 2; i++) { + await iwer.button(page, pointer, 'trigger', 1); + await settle(2); + await iwer.button(page, pointer, 'trigger', 0); + await settle(2); + } + await settle(4); + const after = await items(page); + const s = await dbg(); + h.check(after.length === before + 1 && s.points.length === 0, `a second press on the same spot KEEPS the chain (${before} → ${after.length} items, drawing ${s.points.length})`); + const item = after[after.length - 1]; + itemId = item?.uuid ?? null; + h.check(!!item && item.points.length === 3 && near(item.points[0], FL) && near(item.points[2], BR), `the item holds the three snapped corners (${item?.points.map(fmt).join(' → ')})`); + await h.eventually(() => items(B.page), (b) => b.some((x) => x.uuid === itemId && x.points.length === 3 && near(x.points[1], FR, 1e-4)), 'B holds the same measurement item (synced)', 15000); + } + + // ---- N. snap off: the same laser does NOT land on the corner (the counterfactual for M) -------------------- + if (want('N')) { + console.log('\n=== N. the Measure ring and snap off ==='); + h.check(await openMenu(), 'the menu button opens the radial while measuring…'); + const ring = await g(() => { let v; window.__stores.vrRadialMenu.activeRing.subscribe((x) => (v = x))(); return v; }); + h.check(ring === 'measure', `…on the Measure ring (${ring})`); + h.check(await stickRun('measure:snap'), 'the stick picks Snap'); + const snapOn = await g(() => { let v; window.__stores.measure.measureSnap.subscribe((x) => (v = x))(); return v; }); + h.check(snapOn === false, 'Snap is off'); + await closeMenu(); + await aim(inside(FL)); + await press(); + const s = await dbg(); + const p = s.points[s.points.length - 1]; + h.check(!!p && dist(p, FL) > 0.02 && Math.abs(p[1] - 1) < 1e-4, `with Snap off the same laser lands ON THE TOP FACE, ${p ? (dist(p, FL) * 100).toFixed(1) : '?'} cm from the corner — the snap is what put it there`); + await g(() => window.__stores.vrControls.executeVRMenuAction('measure:snap')); + await g(() => window.__stores.vrControls.executeVRMenuAction('measure:discard')); + await h.eventually(dbg, (x) => x.points.length === 0, 'Discard drops the chain being drawn', 5000); + } + + // ---- G. grid: a raw floor point lands on the grid ------------------------------------------------------------- + if (want('G')) { + console.log('\n=== G. grid snapping ==='); + await g(() => { const s = window.__stores.snapping; s.snapSettings.set({ translate: 0.5, rotateDeg: 15, scale: 0.1 }); s.snapEnabled.set(true); }); + const floor = [head[0] + 1.27, 0, head[2] - 1.12]; + await aim(floor); + await press(); + const s = await dbg(); + const p = s.points[s.points.length - 1]; + const expect = [Math.round(floor[0] / 0.5) * 0.5, 0, Math.round(floor[2] / 0.5) * 0.5]; + h.check(!!p && near(p, expect, 1e-4) && s.snaps[s.snaps.length - 1] === 'grid', `a floor point lands on the 0.5 m grid: ${fmt(p)} = ${fmt(expect)} (${s.snaps[s.snaps.length - 1]})`); + await g(() => window.__stores.snapping.snapEnabled.set(false)); + await aim(floor); + await press(); + const s2 = await dbg(); + const q = s2.points[s2.points.length - 1]; + h.check(!!q && dist(q, floor) < 0.01 && !near(q, expect, 0.01), `grid off: the same laser lands where it points ${fmt(q)}`); + await g(() => window.__stores.vrControls.executeVRMenuAction('measure:discard')); + } + + // ---- E. Selected ▸ Edit measurement ------------------------------------------------------------------------------- + if (want('E') && itemId) { + console.log('\n=== E. edit a kept measurement ==='); + await g(() => window.__stores.vrControls.executeVRMenuAction('measure:done')); + await settle(3); + h.check(!(await dbg()).active, 'Done ends the tool (the trigger selects again)'); + await g((u) => window.__stores.objectActions.applySelectionSet([u]), itemId); + await settle(3); + h.check(await openMenu(), 'the radial opens'); + h.check(await stickRun(null), 'the centred stick press takes the hub (Selected)'); + const offered = await g(() => window.__stores.vrRadialMenu.ringEntries('object').map((e) => e.id)); + h.check(offered.includes('obj:editmeasure') && !offered.includes('obj:editmesh'), `a measurement offers Edit measurement, not Edit mesh (${offered.join(', ')})`); + h.check(await stickRun('obj:editmeasure'), 'the stick picks Edit measurement'); + let s = await dbg(); + h.check(!!s.editing && s.editing.uuid === itemId && s.active, `editing the item from the headset (${JSON.stringify(s.editing)})`); + const d0 = await depth(); + await aim(FL); // the laser ON the first point: picks it up (by angle, no snap search) + await press(); + s = await dbg(); + h.check(s.editing?.active === 0, `a press on a point picks it up (active ${s.editing?.active})`); + await aim(inside(BL)); // put it down 3 cm inside another corner: it snaps there + await press(); + s = await dbg(); + const a1 = (await items(page)).find((x) => x.uuid === itemId); + h.check(s.editing?.active === -1 && near(a1.points[0], BL), `the next press puts it down ON the corner it snapped to ${fmt(a1.points[0])}`); + h.check((await depth()) === d0 + 1, `ONE undo entry for the edit (${d0} → ${await depth()})`); + await h.eventually(() => items(B.page), (b) => b.some((x) => x.uuid === itemId && near(x.points[0], BL, 1e-4)), 'B sees the edited point', 15000); + await g(() => window.__stores.vrControls.executeVRMenuAction('undo')); + await settle(3); + const a2 = (await items(page)).find((x) => x.uuid === itemId); + h.check(near(a2.points[0], FL), `Undo puts the point back on A ${fmt(a2.points[0])}`); + await h.eventually(() => items(B.page), (b) => b.some((x) => x.uuid === itemId && near(x.points[0], FL, 1e-4)), '…and on B', 15000); + h.check(await openMenu(), 'the radial opens on the Measure ring while editing'); + h.check(await stickRun('measure:done'), 'Done'); + s = await dbg(); + h.check(!s.editing && !s.active && s.tool === 'select', `Done ends the edit and the tool (${JSON.stringify({ editing: s.editing, tool: s.tool })})`); + } + + // ---- H. hand tracking says "pick up a controller" -------------------------------------------------------------- + if (want('H')) { + console.log('\n=== H. hands ==='); + await g(() => window.__stores.vrBuildRings && window.__stores.vrMeasure.startVRMeasure()); + const switched = await g(async () => { + const d = window.__iwer.device; + try { + d.primaryInputMode = 'hand'; + } catch { + return false; + } + await new Promise((r) => setTimeout(r, 500)); + let r; window.__stores.globalRenderer.subscribe((v) => (r = v))(); + return [...r.xr.getSession().inputSources].some((s) => !!s.hand); + }); + if (!switched) console.log('SKIP H: this IWER build cannot switch to tracked hands'); + else { + await settle(10); + const before = await dbg(); + await g((hand) => { const d = window.__iwer.device; d.hands[hand].updatePinchValue(1); }, pointer); + await settle(6); + await g((hand) => { const d = window.__iwer.device; d.hands[hand].updatePinchValue(0); }, pointer); + await settle(6); + const after = await dbg(); + const toast = await page.evaluate(() => [...document.querySelectorAll('.tp-toast, [role=status]')].map((e) => e.textContent).join(' | ')); + h.check(after.handRefusals > before.handRefusals && after.points.length === before.points.length, `a pinch while measuring adds no point (${before.points.length} → ${after.points.length}, refusals ${after.handRefusals})`); + h.check(/pick up a controller/i.test(toast), `…and says "pick up a controller" (${toast.slice(0, 120)})`); + await g(async () => { window.__iwer.device.primaryInputMode = 'controller'; await new Promise((r) => setTimeout(r, 500)); }); + await settle(10); + } + await g(() => window.__stores.vrMeasure.stopVRMeasure({ keep: false })); + } + + // ---- F. frame cost on the Quest profile ---------------------------------------------------------------------------- + if (want('F')) { + console.log('\n=== F. frame cost ==='); + await g(() => window.__stores.vrMeasure.startVRMeasure()); + for (const c of [FL, FR, BR]) { + await aim(inside(c)); + await press(); + } + await aim(inside(BL)); + await g(() => { const c = window.__vrMeasureLabelsCost; c.frames = 0; c.ms = 0; }); + const s0 = await dbg(); + // sweep the laser across the box for ~120 frames (the hover search runs at ~30 Hz, the labels every frame) + for (let i = 0; i < 12; i++) { + await aim([C[0] - 1 + (i / 11) * 2, 1, C[2] + 0.4], 10); + } + const s1 = await dbg(); + const lc = await g(() => ({ ...window.__vrMeasureLabelsCost })); + const hoverPerSearch = (s1.hoverMs - s0.hoverMs) / Math.max(1, s1.hovers - s0.hovers); + const labelsPerFrame = lc.ms / Math.max(1, lc.frames); + // at 72 Hz the hover search runs on ~42 % of frames (33 ms throttle); budget = the Quest 3's 13.9 ms frame + const perFrame = labelsPerFrame + hoverPerSearch * Math.min(1, (1000 / 72) / 33); + console.log(`hover search ${hoverPerSearch.toFixed(3)} ms (${s1.hovers - s0.hovers} searches), labels ${labelsPerFrame.toFixed(3)} ms/frame (${lc.frames} frames) → ${perFrame.toFixed(3)} ms/frame`); + h.check(s1.hovers - s0.hovers >= 10 && lc.frames >= 60, 'premise: the sweep ran the hover search and the labels'); + h.check(perFrame < 1.4, `the tool costs ${perFrame.toFixed(2)} ms per frame on this box — under 10 % of the 72 Hz budget (13.9 ms)`); + const calls = await g(() => { let r; window.__stores.globalRenderer.subscribe((v) => (r = v))(); let n = 0; let s; window.__stores.globalScene.subscribe((v) => (s = v))(); s.traverse((o) => { if (o.name === 'vr-measure-label' && o.visible) n += 2; }); return n; }); + console.log(`labels add ${calls} draw calls (plate + text each)`); + h.check(calls <= 24, `the labels add ${calls} draw calls (≤ 24 for a 4-point chain)`); + await g(() => window.__stores.vrMeasure.stopVRMeasure({ keep: false })); + } + + await iwer.exit(page); + h.check(!(await dbg()).active, 'leaving the headset ends the tool'); + await h.finish(browser); +}); diff --git a/tests/e2e/vr-radial-menu.test.cjs b/tests/e2e/vr-radial-menu.test.cjs index 4c0c348fa..d7ec3e08d 100644 --- a/tests/e2e/vr-radial-menu.test.cjs +++ b/tests/e2e/vr-radial-menu.test.cjs @@ -100,8 +100,8 @@ h.run(async () => { `base ring: Settings ▸ replaces System ▸ (${registry.root.join(',')})` ); h.check( - registry.tools.join(',') === 'tool:select,tool:box,tool:draw,ping,physics,nav:profile,ai', - `Tools: Select / Box select / Draw mode / Ping / Simulate physics / Profile ▸ / AI (1.24 plan F) (${registry.tools.join(',')})` + registry.tools.join(',') === 'tool:select,tool:box,tool:draw,measure:tool,ping,physics,nav:profile,ai', + `Tools: Select / Box select / Draw mode / Measure (52 V1) / Ping / Simulate physics / Profile ▸ / AI (1.24 plan F) (${registry.tools.join(',')})` ); h.check( registry.settings.slice(0, 5).join(',') === 'nav:settings:comfort,nav:settings:body,nav:settings:controls,nav:settings:display,nav:settings:editing' && diff --git a/tests/unit/vrMeasureCore.test.js b/tests/unit/vrMeasureCore.test.js new file mode 100644 index 000000000..3c9aa32a0 --- /dev/null +++ b/tests/unit/vrMeasureCore.test.js @@ -0,0 +1,103 @@ +// @ts-nocheck — plain fixtures; the module under test is typed +// 52 V1: the VR measure tool's arithmetic — the laser's snap cone, grid rounding, the angular point pick-up, +// the double-tap keep and the distance-scaled label. +import { describe, it, expect } from 'vitest'; +import { + VR_SNAP_CONE_DEG, + VR_SNAP_RADIUS_PX, + VR_DOUBLE_TAP_MS, + VR_LABEL_ANGLE, + rayCameraFov, + snapToGrid, + angularPick, + isDoubleTap, + labelWorldHeight, + labelPxMetrics, + coneBias +} from '../../src/lib/vrMeasureCore.js'; + +describe('rayCameraFov', () => { + it('puts the engine radius exactly on the cone, at any view height', () => { + for (const h of [480, 720, 1080]) { + const fov = rayCameraFov(h); + // the px at which a direction `cone` off-centre projects, in that camera + const px = (Math.tan((VR_SNAP_CONE_DEG * Math.PI) / 180) / Math.tan((fov * Math.PI) / 360)) * (h / 2); + expect(px).toBeCloseTo(VR_SNAP_RADIUS_PX, 6); + } + }); + it('a missing view height falls back instead of dividing by zero', () => { + expect(Number.isFinite(rayCameraFov(0))).toBe(true); + }); +}); + +describe('snapToGrid', () => { + it('a raw surface point lands on the grid in x and z and keeps its height', () => { + expect(snapToGrid([1.26, 0.83, -0.74], 0.5, '')).toEqual({ point: [1.5, 0.83, -0.5], snap: 'grid' }); + }); + it('a ground point rounds y too', () => { + expect(snapToGrid([0.2, 0.01, 0.9], 0.25, 'ground')).toEqual({ point: [0.25, 0, 1], snap: 'grid' }); + }); + it('no grid = the point and its snap kind unchanged', () => { + expect(snapToGrid([1.26, 0.83, -0.74], 0, 'ground')).toEqual({ point: [1.26, 0.83, -0.74], snap: 'ground' }); + }); +}); + +describe('angularPick', () => { + const o = [0, 1.5, 0]; + const d = [0, 0, -1]; + it('picks the point nearest the laser by angle, near or far', () => { + // 1° off at 10 m beats 2° off at 1 m + const far = [Math.tan((1 * Math.PI) / 180) * 10, 1.5, -10]; + const near = [Math.tan((2 * Math.PI) / 180) * 1, 1.5, -1]; + expect(angularPick(o, d, [near, far])).toBe(1); + }); + it('nothing inside the cone = -1, and a point behind the hand never counts', () => { + expect(angularPick(o, d, [[1, 1.5, -1]])).toBe(-1); + expect(angularPick(o, d, [[0, 1.5, 2]])).toBe(-1); + }); +}); + +describe('isDoubleTap', () => { + it('a second press soon after, on the same spot, keeps the chain', () => { + expect(isDoubleTap({ at: 1000, point: [1, 0, 0] }, 1000 + VR_DOUBLE_TAP_MS - 1, [1.01, 0, 0], 2)).toBe(true); + }); + it('too late, too far, or no previous point is a new point', () => { + expect(isDoubleTap({ at: 1000, point: [1, 0, 0] }, 1000 + VR_DOUBLE_TAP_MS + 1, [1, 0, 0], 2)).toBe(false); + expect(isDoubleTap({ at: 1000, point: [1, 0, 0] }, 1100, [1.2, 0, 0], 2)).toBe(false); + expect(isDoubleTap({ at: 0, point: null }, 10, [1, 0, 0], 2)).toBe(false); + }); + it('the tolerance grows with distance (2 %), never below 3 cm', () => { + expect(isDoubleTap({ at: 0, point: [0, 0, 0] }, 10, [0.15, 0, 0], 10)).toBe(true); + expect(isDoubleTap({ at: 0, point: [0, 0, 0] }, 10, [0.029, 0, 0], 0.5)).toBe(true); + }); +}); + +describe('labels', () => { + it('a label is the same ANGLE tall near and far (between the clamps)', () => { + expect(labelWorldHeight(1) / 1).toBeCloseTo(VR_LABEL_ANGLE, 9); + expect(labelWorldHeight(8) / 8).toBeCloseTo(VR_LABEL_ANGLE, 9); + expect(labelWorldHeight(0.01)).toBeGreaterThan(0); + expect(labelWorldHeight(1000)).toBeLessThanOrEqual(1.5); + }); + it('the layout metrics are a whole line tall in the virtual view, wider than tall per two chars', () => { + const m = labelPxMetrics(1000, 90); + expect(m.h).toBeGreaterThan((VR_LABEL_ANGLE / (Math.PI / 2)) * 1000); + expect(m.charW * 2).toBeCloseTo(m.h, 6); + }); +}); + +describe('coneBias', () => { + it('scales the desktop pull with the reach: the same RELATIVE pull inside the cone', () => { + const b = coneBias({ vertex: -16, edge: -6, face: -2 }, 18); + expect(b.vertex / VR_SNAP_RADIUS_PX).toBeCloseTo(-16 / 18, 9); + expect(b.edge / VR_SNAP_RADIUS_PX).toBeCloseTo(-6 / 18, 9); + }); + it('a corner 0.4 of the cone off beats an edge seen almost edge-on (0.04 of it off — the IWER case: a laser grazing a box top foreshortens the edge), as under the mouse', () => { + const b = coneBias({ vertex: -16, edge: -6 }, 18); + const vertex = 0.4 * VR_SNAP_RADIUS_PX + b.vertex; + const edge = 0.04 * VR_SNAP_RADIUS_PX + b.edge; + expect(vertex).toBeLessThan(edge); + // unscaled, the edge won — the bug this exists for + expect(0.4 * VR_SNAP_RADIUS_PX - 16).toBeGreaterThan(0.04 * VR_SNAP_RADIUS_PX - 6); + }); +}); From 483e403d864dc6e92387a22953d66f404c66c44b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 00:06:09 +0300 Subject: [PATCH 02/29] [feat] 52 V2: align / distribute / drop to ground from the VR radial - the desktop's results, one undo, peers see it - Selected > Arrange (2+ objects): ONE ring, the radial's two-level rule - Axis (cycles X / Y / Z, value line), Align min / centre / max, Even centres / Even gaps (3+, greyed below), Drop to ground, Align to (Selection / Active, the Inspector's switch). Labels carry the axis ("Align X min"). - every sector is DATA (vr/arrangeCore.js, a leaf): sectorOp + arrangeCall name the SAME align.js call, with the same arguments, that the multi-select Inspector's Arrange buttons and the object menu's Transform rows make - so the move, the ONE transformSet undo entry and the replication (the move path) are the desktop's own. - unit-level parity (tests/unit/vrArrangeParity, 23): the table per desktop control, and the REAL align.js run on two identical scenes, one through the desktop call and one through the ring sector, lands every object in the same place with one undo entry each (counterfactual: mapping gaps -> centres goes red on 4 checks). - IWER row A: Selected > Arrange > Align X min picked with the stick (two peers), ONE Undo restores the set on both, X even gaps, Drop to ground on A and B. - icons: align-start/center/end-vertical, align-horizontal-space-around, arrow-down-to-line in the VR icon table. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/debugHooks.js | 1 + src/lib/vr/arrangeCore.js | 68 +++++++++++++++ src/lib/vr/buildRings.js | 85 +++++++++++++++++- src/lib/vr/iconData.js | 5 ++ src/lib/vrRadialMenu.js | 5 +- tests/e2e/vr-measure-iwer.test.cjs | 61 +++++++++++++ tests/unit/vrArrangeParity.test.js | 136 +++++++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 src/lib/vr/arrangeCore.js create mode 100644 tests/unit/vrArrangeParity.test.js diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 7af050aa4..1e27eab2c 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -174,6 +174,7 @@ export const DEBUG_HOOKS = [ ['vrMeasure', () => import('./vr/measure.js')], ['vrMeasureCore', () => import('./vrMeasureCore')], ['vrBuildRings', () => import('./vr/buildRings.js')], + ['align', () => import('./align')], ['vrTourTargets', () => import('./vr/tourTargets.js')], ['vrPrefs', () => import('./vr/prefs.js')], // 53 (A7): recenter + the lift / recenter counts diff --git a/src/lib/vr/arrangeCore.js b/src/lib/vr/arrangeCore.js new file mode 100644 index 000000000..5c6941b46 --- /dev/null +++ b/src/lib/vr/arrangeCore.js @@ -0,0 +1,68 @@ +// 52 V2: the VR Arrange ring as DATA. A LEAF — imports nothing — so the parity with the desktop is a +// unit test: every sector names the SAME align.js call, with the same arguments, that the multi-select +// Inspector's Arrange buttons and the object menu's Transform ▸ rows make. The ring adds no arithmetic of +// its own; alignCore does the maths and align.js moves, records ONE undo entry and replicates. + +export const ARRANGE_AXES = /** @type {const} */ (['x', 'y', 'z']); +export const ALIGN_EDGES = /** @type {const} */ ([ + { edge: 'min', label: 'Min', icon: 'align-start-vertical' }, + { edge: 'center', label: 'Centre', icon: 'align-center-vertical' }, + { edge: 'max', label: 'Max', icon: 'align-end-vertical' } +]); +export const DISTRIBUTE_MODES = /** @type {const} */ ([ + { mode: 'centers', label: 'centres' }, + { mode: 'gaps', label: 'gaps' } +]); + +/** + * The align.js call a sector makes: `{fn, args}` with `fn` the exported name, or null for an id that is + * not an arrange op. `toActive` = the ring's "Align to: Active" switch (the Inspector's Align to). + * Ids: `align::` · `distribute::` · `arrange:drop`. + * @param {string} id @param {boolean} [toActive] + * @returns {{fn: 'alignSelection'|'distributeSelection'|'dropSelection', args: any[]} | null} + */ +export function arrangeCall(id, toActive = false) { + if (id === 'arrange:drop') return { fn: 'dropSelection', args: [] }; + const [kind, axis, which] = id.split(':'); + if (!(/** @type {readonly string[]} */ (ARRANGE_AXES)).includes(axis)) return null; + if (kind === 'align' && ALIGN_EDGES.some((e) => e.edge === which)) + return { fn: 'alignSelection', args: [axis, which, { to: toActive ? 'active' : 'selection' }] }; + if (kind === 'distribute' && DISTRIBUTE_MODES.some((m) => m.mode === which)) + return { fn: 'distributeSelection', args: [axis, which] }; + return null; +} + +/** every arrange sector id, in ring order (for the registry and the suites) */ +export function arrangeIds() { + return [ + ...ARRANGE_AXES.flatMap((axis) => ALIGN_EDGES.map((e) => `align:${axis}:${e.edge}`)), + ...ARRANGE_AXES.flatMap((axis) => DISTRIBUTE_MODES.map((m) => `distribute:${axis}:${m.mode}`)), + 'arrange:drop' + ]; +} + +/** + * The Arrange RING is one level deep (the radial's two-level rule: Selected ▸ Arrange ▸ acts): an Axis + * sector cycles X → Y → Z and the op sectors act on that axis. This turns a sector id + the ring's axis + * into the canonical op id `arrangeCall` reads (null for a sector that is not an op). + * @param {string} sector `arrange:min|center|max|centers|gaps|drop` @param {string} axis + */ +export function sectorOp(sector, axis) { + const which = sector.startsWith('arrange:') ? sector.slice('arrange:'.length) : ''; + if (which === 'drop') return 'arrange:drop'; + if (ALIGN_EDGES.some((e) => e.edge === which)) return `align:${axis}:${which}`; + if (DISTRIBUTE_MODES.some((m) => m.mode === which)) return `distribute:${axis}:${which}`; + return null; +} + +/** the next axis of the ring's Axis sector @param {string} axis */ +export function nextAxis(axis) { + return axis === 'x' ? 'y' : axis === 'y' ? 'z' : 'x'; +} + +/** how many objects an op needs (align 2, distribute 3, drop 1) — the sector greys out below it @param {string} id */ +export function arrangeMinimum(id) { + if (id.startsWith('align:') || ['arrange:min', 'arrange:center', 'arrange:max'].includes(id)) return 2; + if (id.startsWith('distribute:') || ['arrange:centers', 'arrange:gaps'].includes(id)) return 3; + return 1; +} diff --git a/src/lib/vr/buildRings.js b/src/lib/vr/buildRings.js index ee3817b80..6351601b8 100644 --- a/src/lib/vr/buildRings.js +++ b/src/lib/vr/buildRings.js @@ -1,10 +1,16 @@ -// 52 — the VR build-tool radial entries: Tools ▸ Measure + the Measure ring (V1), Selected ▸ Arrange with its -// Align / Distribute / Drop rings (V2), Selected ▸ Edit measurement. vrRadialMenu calls these from its +// 52 — the VR build-tool radial entries: Tools ▸ Measure + the Measure ring (V1), Selected ▸ Edit measurement, +// Selected ▸ Arrange (V2: one ring — Axis · Align min / centre / max · Even centres / gaps · Drop · Align to). vrRadialMenu calls these from its // registerBuiltins with its own `registerVRMenuEntry` (handed in, not imported: this file is reached from // vrRadialMenu, and an import back would close a cycle). The tools themselves load on first use (dynamic // imports): the measure runtime and align.js sit in history's family, which the radial must not pull in eagerly. -import { get } from 'svelte/store'; +import { get, writable } from 'svelte/store'; import { selectedObjects, selectedObject, vrToolMode } from '../../stores/sceneStore'; +import { ALIGN_EDGES, DISTRIBUTE_MODES, arrangeCall, arrangeMinimum, sectorOp, nextAxis } from './arrangeCore.js'; + +/** the Arrange ring's "Align to" switch — LOCAL, the Inspector Arrange section's Selection / Active */ +export const vrAlignToActive = writable(false); +/** the Arrange ring's axis (its Axis sector cycles it) — LOCAL */ +export const vrArrangeAxis = writable('x'); const selCount = () => /** @type {string[]} */ (get(selectedObjects))?.length ?? 0; const vrMeasure = () => import('./measure.js'); @@ -12,6 +18,19 @@ const measureOn = () => get(vrToolMode) === 'measure'; /** is the (single) selection a kept measurement? (measureItems.isMeasurement, read inline — no import) */ const measurementSelected = () => selCount() === 1 && Array.isArray(/** @type {any} */ (get(selectedObject))?.userData?.measure?.points); +/** + * Run an arrange op: the SAME align.js call the desktop makes (arrangeCore.arrangeCall). Takes a ring + * sector (`arrange:min` … on the ring's axis) or a canonical op id (`align:x:min`). + * @param {string} id @returns {Promise} objects moved + */ +export async function runArrange(id) { + const op = id.startsWith('arrange:') ? sectorOp(id, get(vrArrangeAxis)) : id; + const call = op ? arrangeCall(op, get(vrAlignToActive)) : null; + if (!call) return 0; + const align = /** @type {any} */ (await import('../align')); + return align[call.fn](...call.args); +} + /** @param {(entry: any) => void} register vrRadialMenu.registerVRMenuEntry */ export function registerMeasureRings(register) { // Tools ▸ Measure — a trigger tool beside Select / Box select / Draw @@ -90,3 +109,63 @@ export function registerMeasureRings(register) { let measureSnapOn = true; void (typeof window !== 'undefined' && import('../measure').then((m) => m.measureSnap.subscribe((on) => (measureSnapOn = !!on))).catch(() => {})); + +/** @param {(entry: any) => void} register vrRadialMenu.registerVRMenuEntry */ +export function registerArrangeRings(register) { + const tooFew = (/** @type {string} */ id) => () => selCount() < arrangeMinimum(id); + const AX = () => get(vrArrangeAxis).toUpperCase(); + // Selected ▸ Arrange — a multi-selection's (the desktop's Arrange section is the multi-select Inspector's) + register({ id: 'nav:arrange', group: 'object', label: 'Arrange', icon: 'align-start-vertical', order: 4.5, ring: 'arrange', visible: () => selCount() > 1 }); + // one ring, the radial's two-level rule: the Axis sector picks X / Y / Z, every other sector acts on it + register({ + id: 'arrange:axis', + group: 'arrange', + label: 'Axis', + icon: 'move', + order: 0, + value: AX, + action: () => vrArrangeAxis.update(nextAxis) + }); + ALIGN_EDGES.forEach((e, i) => + register({ + id: `arrange:${e.edge}`, + group: 'arrange', + label: () => `Align ${AX()} ${e.label.toLowerCase()}`, + icon: e.icon, + order: 1 + i, + closes: true, + disabled: tooFew(`arrange:${e.edge}`), + action: () => void runArrange(`arrange:${e.edge}`) + }) + ); + DISTRIBUTE_MODES.forEach((m, i) => + register({ + id: `arrange:${m.mode}`, + group: 'arrange', + label: () => `${AX()} even ${m.label}`, + icon: 'align-horizontal-space-around', + order: 4 + i, + closes: true, + disabled: tooFew(`arrange:${m.mode}`), + action: () => void runArrange(`arrange:${m.mode}`) + }) + ); + register({ + id: 'arrange:drop', + group: 'arrange', + label: 'Drop to ground', + icon: 'arrow-down-to-line', + order: 6, + closes: true, + action: () => void runArrange('arrange:drop') + }); + register({ + id: 'arrange:to', + group: 'arrange', + label: 'Align to', + icon: 'crosshair', + order: 7, + value: () => (get(vrAlignToActive) ? 'Active' : 'Selection'), + action: () => vrAlignToActive.update((v) => !v) + }); +} diff --git a/src/lib/vr/iconData.js b/src/lib/vr/iconData.js index e2dbbd3ac..4b2c37f34 100644 --- a/src/lib/vr/iconData.js +++ b/src/lib/vr/iconData.js @@ -88,4 +88,9 @@ export const LUCIDE_NODES = { 'minus': [["path",{"d":"M5 12h14"}]], 'check': [["path",{"d":"M20 6 9 17l-5-5"}]], 'brush': [["path",{"d":"m11 10 3 3"}],["path",{"d":"M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z"}],["path",{"d":"M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"}]], + 'align-start-vertical': [["rect",{"width":"9","height":"6","x":"6","y":"14","rx":"2"}],["rect",{"width":"16","height":"6","x":"6","y":"4","rx":"2"}],["path",{"d":"M2 2v20"}]], // 52 V2: the VR Arrange ring + 'align-center-vertical': [["path",{"d":"M12 2v20"}],["path",{"d":"M8 10H4a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h4"}],["path",{"d":"M16 10h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4"}],["path",{"d":"M8 20H7a2 2 0 0 1-2-2v-2c0-1.1.9-2 2-2h1"}],["path",{"d":"M16 14h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1"}]], // 52 V2: the VR Arrange ring + 'align-end-vertical': [["rect",{"width":"16","height":"6","x":"2","y":"4","rx":"2"}],["rect",{"width":"9","height":"6","x":"9","y":"14","rx":"2"}],["path",{"d":"M22 22V2"}]], // 52 V2: the VR Arrange ring + 'align-horizontal-space-around': [["rect",{"width":"6","height":"10","x":"9","y":"7","rx":"2"}],["path",{"d":"M4 22V2"}],["path",{"d":"M20 22V2"}]], // 52 V2: the VR Arrange ring + 'arrow-down-to-line': [["path",{"d":"M12 17V3"}],["path",{"d":"m6 11 6 6 6-6"}],["path",{"d":"M19 21H5"}]], // 52 V2: the VR Arrange ring }; diff --git a/src/lib/vrRadialMenu.js b/src/lib/vrRadialMenu.js index dded712b2..c1831d1f2 100644 --- a/src/lib/vrRadialMenu.js +++ b/src/lib/vrRadialMenu.js @@ -16,7 +16,7 @@ import { savePrefab, savePrefabSelection } from './prefabs'; import { perfContext } from './perf/perfMarks.js'; // 34 PF: an import-free leaf import { vrDollhouseOpen, vrSculptActive } from './vr/worldStores.js'; // 37: a leaf import { sculptOp } from './terrainSculpt'; -import { registerMeasureRings } from './vr/buildRings.js'; // 52 V1 +import { registerMeasureRings, registerArrangeRings } from './vr/buildRings.js'; // 52 V1/V2 import { primitivesCatalog } from './primitivesCatalog'; // D4 (roadmap 13): selection-set helpers for the Edit ring — counted labels @@ -608,8 +608,9 @@ function registerBuiltins() { registerVRMenuEntry({ id: 'face:move', group: 'faces', label: 'Move', order: 2 }); registerVRMenuEntry({ id: 'face:delete', group: 'faces', label: 'Delete', order: 3 }); - // 52 V1: Tools ▸ Measure + the Measure ring, Selected ▸ Edit measurement + // 52 V1/V2: Tools ▸ Measure + the Measure ring, Selected ▸ Edit measurement, Selected ▸ Arrange registerMeasureRings(registerVRMenuEntry); + registerArrangeRings(registerVRMenuEntry); } registerBuiltins(); diff --git a/tests/e2e/vr-measure-iwer.test.cjs b/tests/e2e/vr-measure-iwer.test.cjs index 2bbfbb85b..0d6df805f 100644 --- a/tests/e2e/vr-measure-iwer.test.cjs +++ b/tests/e2e/vr-measure-iwer.test.cjs @@ -404,6 +404,67 @@ h.run(async () => { await g(() => window.__stores.vrMeasure.stopVRMeasure({ keep: false })); } + // ---- A. Selected ▸ Arrange ------------------------------------------------------------------------------------- + if (want('A')) { + console.log('\n=== A. arrange from the radial ==='); + const ids = await createBoxes(['1 1 1', '2 1 1', '0.5 1 1']); + await page.waitForTimeout(1500); + const START = [ + { uuid: ids[0], pos: [head[0] - 3, 0.5, head[2] - 4] }, // x [-3.5, -2.5] rel + { uuid: ids[1], pos: [head[0] + 1, 2, head[2] - 3] }, // x [0, 2] + { uuid: ids[2], pos: [head[0] + 4, 0.5, head[2] - 5] } // x [3.75, 4.25] + ]; + await place(START); + await h.eventually(() => boxes(B.page, ids), (b) => b.every((q, i) => q && near(q.pos, START[i].pos, 1e-6)), 'premise: three boxes posed on A and B', 15000); + await g((u) => window.__stores.objectActions.applySelectionSet(u), ids.slice(0, 2)); + await settle(3); + const dis = await g(() => window.__stores.vrRadialMenu.findMenuEntry('arrange:gaps').disabled()); + h.check(dis === true, 'with two selected, Even gaps is greyed (it needs three)'); + await g((u) => window.__stores.objectActions.applySelectionSet(u), ids); + await settle(3); + await g(() => window.__stores.vrBuildRings.vrArrangeAxis.set('x')); + const d0 = await depth(); + const d0B = await depth(B.page); + h.check(await openMenu(), 'the radial opens'); + h.check(await stickRun(null), 'the hub opens Selected'); + h.check(await stickRun('nav:arrange'), 'the stick picks Arrange ▸'); + h.check(await stickRun('arrange:min'), '…Align X min'); + const minX = START[0].pos[0] - 0.5; + const test = (b) => b.every((q) => q && Math.abs(q.min[0] - minX) < 1e-6); + const a = await boxes(page, ids); + h.check(test(a), `Align X min: every box's left edge on the selection's (${a.map((q) => q.min[0].toFixed(3)).join(', ')} = ${minX.toFixed(3)})`); + h.check((await depth()) === d0 + 1, `ONE undo entry for the set (${d0} → ${await depth()})`); + await h.eventually(() => boxes(B.page, ids), test, 'B sees the aligned set', 15000); + await g(() => window.__stores.vrControls.executeVRMenuAction('undo')); + await settle(3); + const u = await boxes(page, ids); + h.check(u.every((q, i) => near(q.pos, START[i].pos, 1e-6)), 'ONE Undo puts all three back on A'); + await h.eventually(() => boxes(B.page, ids), (b) => b.every((q, i) => q && near(q.pos, START[i].pos, 1e-6)), '…and on B', 15000); + h.check((await depth(B.page)) === d0B, 'B records no undo entry of its own (the move path)'); + // distribute gaps on X through the radial entries (the stick path is proven above) + await g(() => { + const r = window.__stores.vrControls; + window.__stores.vrMenuOpen.set(true); + ['nav:object', 'nav:arrange', 'arrange:gaps'].forEach((id) => r.executeVRMenuAction(id)); + }); + await h.eventually(() => boxes(page, ids), (b) => { + const s = [...b].sort((p, q) => p.min[0] - q.min[0]); + return Math.abs((s[1].min[0] - s[0].max[0]) - (s[2].min[0] - s[1].max[0])) < 1e-6; + }, 'Distribute X gaps: equal space between neighbours', 5000); + await h.eventually(() => boxes(B.page, ids), (b) => { + const s = [...b].sort((p, q) => p.min[0] - q.min[0]); + return Math.abs((s[1].min[0] - s[0].max[0]) - (s[2].min[0] - s[1].max[0])) < 1e-6; + }, '…on B', 15000); + await g(() => { + const r = window.__stores.vrControls; + window.__stores.vrMenuOpen.set(true); + ['nav:object', 'nav:arrange', 'arrange:drop'].forEach((id) => r.executeVRMenuAction(id)); + }); + const groundTest = (b) => b.every((q) => q && Math.abs(q.min[1]) < 1e-4); + await h.eventually(() => boxes(page, ids), groundTest, 'Drop to ground: every box rests on y = 0', 5000); + await h.eventually(() => boxes(B.page, ids), groundTest, '…on B', 15000); + } + // ---- F. frame cost on the Quest profile ---------------------------------------------------------------------------- if (want('F')) { console.log('\n=== F. frame cost ==='); diff --git a/tests/unit/vrArrangeParity.test.js b/tests/unit/vrArrangeParity.test.js new file mode 100644 index 000000000..3e877f022 --- /dev/null +++ b/tests/unit/vrArrangeParity.test.js @@ -0,0 +1,136 @@ +// @ts-nocheck — plain fixtures; the code under test is typed +// 52 V2: the VR radial's Arrange ring gives the SAME results as the desktop. Two layers: +// 1. the table — every desktop control (the multi-select Inspector's Arrange buttons, the object menu's +// Transform ▸ rows) has a VR sector naming the identical align.js call with identical arguments; +// 2. the result — the REAL align.js run on two identical scenes, one through the desktop call and one +// through the VR sector (buildRings.runArrange), leaves every object in the same place, and records +// ONE undo entry each. +import { describe, it, expect, beforeEach } from 'vitest'; +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { objectsGroup, selectedObjects } from '../../src/stores/sceneStore'; +import { arrangeCall, arrangeIds, arrangeMinimum, sectorOp, nextAxis, ARRANGE_AXES, ALIGN_EDGES, DISTRIBUTE_MODES } from '../../src/lib/vr/arrangeCore.js'; +import { runArrange, vrAlignToActive, vrArrangeAxis, registerArrangeRings } from '../../src/lib/vr/buildRings.js'; +import * as align from '../../src/lib/align.js'; +import { undoStack } from '../../src/lib/history.js'; + +describe('the table: one sector per desktop control, the same call', () => { + it('align: 3 axes × min / centre / max, to the selection or the active object', () => { + for (const axis of ['x', 'y', 'z']) + for (const edge of ['min', 'center', 'max']) { + // ArrangeSection: alignSelection(axis, e.edge, { to: alignTo === 'active' ? 'active' : 'selection' }) + expect(arrangeCall(`align:${axis}:${edge}`)).toEqual({ fn: 'alignSelection', args: [axis, edge, { to: 'selection' }] }); + expect(arrangeCall(`align:${axis}:${edge}`, true)).toEqual({ fn: 'alignSelection', args: [axis, edge, { to: 'active' }] }); + } + }); + it('distribute: 3 axes × even centres / even gaps', () => { + for (const axis of ['x', 'y', 'z']) + for (const mode of ['centers', 'gaps']) + expect(arrangeCall(`distribute:${axis}:${mode}`)).toEqual({ fn: 'distributeSelection', args: [axis, mode] }); + }); + it('drop to ground', () => { + expect(arrangeCall('arrange:drop')).toEqual({ fn: 'dropSelection', args: [] }); + }); + it('every sector id resolves, unknown ids do not, and the minimums match the desktop toasts', () => { + const ids = arrangeIds(); + expect(ids.length).toBe(ARRANGE_AXES.length * (ALIGN_EDGES.length + DISTRIBUTE_MODES.length) + 1); + for (const id of ids) expect(arrangeCall(id)).not.toBeNull(); + expect(arrangeCall('align:w:min')).toBeNull(); + expect(arrangeCall('align:x:middle')).toBeNull(); + expect(arrangeCall('distribute:x:spread')).toBeNull(); + expect([arrangeMinimum('align:x:min'), arrangeMinimum('distribute:y:gaps'), arrangeMinimum('arrange:drop')]).toEqual([2, 3, 1]); + expect([arrangeMinimum('arrange:center'), arrangeMinimum('arrange:gaps')]).toEqual([2, 3]); + }); + it('the ring: an Axis sector cycles X → Y → Z, every op sector acts on that axis', () => { + expect([nextAxis('x'), nextAxis('y'), nextAxis('z')]).toEqual(['y', 'z', 'x']); + for (const axis of ['x', 'y', 'z']) { + for (const edge of ['min', 'center', 'max']) expect(sectorOp('arrange:' + edge, axis)).toBe(`align:${axis}:${edge}`); + for (const mode of ['centers', 'gaps']) expect(sectorOp('arrange:' + mode, axis)).toBe(`distribute:${axis}:${mode}`); + } + expect(sectorOp('arrange:drop', 'y')).toBe('arrange:drop'); + expect(sectorOp('arrange:axis', 'x')).toBeNull(); + expect(sectorOp('arrange:to', 'x')).toBeNull(); + }); + it('the ring is ONE level (Selected ▸ Arrange ▸ acts) and offers every desktop op', () => { + const entries = []; + registerArrangeRings((e) => entries.push(e)); + const ring = entries.filter((e) => e.group === 'arrange'); + expect(ring.some((e) => e.ring)).toBe(false); // no deeper ring: the radial's two-level rule + expect(ring.map((e) => e.id)).toEqual(['arrange:axis', 'arrange:min', 'arrange:center', 'arrange:max', 'arrange:centers', 'arrange:gaps', 'arrange:drop', 'arrange:to']); + const opener = entries.find((e) => e.id === 'nav:arrange'); + expect(opener.group).toBe('object'); + expect(opener.ring).toBe('arrange'); + // every desktop op is reachable: 3 axes (the Axis sector) × the 5 op sectors + drop + const reach = new Set(); + for (const axis of ['x', 'y', 'z']) for (const e of ring) { const op = sectorOp(e.id, axis); if (op) reach.add(op); } + expect([...reach].sort()).toEqual(arrangeIds().sort()); + }); +}); + +/** a fresh scene of boxes at deterministic, irregular places; returns their uuids (fixed per index) */ +function buildScene() { + const group = new THREE.Group(); + group.name = 'objects'; + const scene = new THREE.Scene(); + scene.add(group); + const spec = [ + { p: [0, 2, 0], s: [1, 1, 1] }, + { p: [3.2, 4.1, -1], s: [2, 0.5, 1] }, + { p: [-1.7, 1.3, 2.4], s: [0.5, 2, 0.5] }, + { p: [6.1, 0.9, 0.3], s: [1, 1, 3] } + ]; + const uuids = spec.map((o, i) => { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(...o.s), new THREE.MeshBasicMaterial()); + mesh.position.set(...o.p); + mesh.uuid = 'box-' + i; + group.add(mesh); + return mesh.uuid; + }); + group.updateMatrixWorld(true); + objectsGroup.set(group); + selectedObjects.set(uuids); + return { group, uuids }; +} +const poses = (group) => group.children.map((c) => c.position.toArray().map((n) => +n.toFixed(9))); + +describe('the result: the VR sector moves objects exactly where the desktop call does', () => { + beforeEach(() => { + vrAlignToActive.set(false); + vrArrangeAxis.set('x'); + }); + const cases = [ + ...['x', 'y', 'z'].flatMap((axis) => ['min', 'center', 'max'].map((edge) => [`align:${axis}:${edge}`, () => align.alignSelection(axis, edge, { to: 'selection' })])), + ...['x', 'y', 'z'].flatMap((axis) => ['centers', 'gaps'].map((mode) => [`distribute:${axis}:${mode}`, () => align.distributeSelection(axis, mode)])), + ['arrange:drop', () => align.dropSelection()] + ]; + for (const [id, desktop] of cases) + it(id, async () => { + const a = buildScene(); + const before = poses(a.group); + const depth0 = get(undoStack).length; + const movedDesktop = desktop(); + const desktopPoses = poses(a.group); + const depthDesktop = get(undoStack).length; + const b = buildScene(); + // through the RING: set the ring's axis, press the sector (arrange:min …), exactly what the headset does + const [, axis, which] = id.split(':'); + if (id !== 'arrange:drop') vrArrangeAxis.set(axis); + const movedVR = await runArrange(id === 'arrange:drop' ? id : 'arrange:' + which); + expect(poses(b.group)).toEqual(desktopPoses); + expect(movedVR).toBe(movedDesktop); + expect(movedVR).toBeGreaterThan(0); // the fixture is irregular on purpose: every op moves something + expect(depthDesktop - depth0).toBe(1); // ONE undo entry for the whole set + expect(get(undoStack).length - depthDesktop).toBe(1); + expect(poses(b.group)).not.toEqual(before); + }); + it('Align to: Active lines everything up with the active (last picked) object, which stays', async () => { + const a = buildScene(); + align.alignSelection('x', 'min', { to: 'active' }); + const desktopPoses = poses(a.group); + const b = buildScene(); + vrAlignToActive.set(true); + await runArrange('arrange:min'); + expect(poses(b.group)).toEqual(desktopPoses); + expect(b.group.children[3].position.x).toBeCloseTo(6.1, 9); // the active one stayed + }); +}); From d10042deb3ebe3beb00a0cba93ec1513d16fffa6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 00:06:33 +0300 Subject: [PATCH 03/29] [feat] 52-browser P1: the drag-to-place ghost on the VR laser (absorbs f-vr-place) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - placeDrag RAY mode: beginRayPlace / updateRayPlace / commitRayPlace lay the ghost out in the objectsGroup's frame, so a moved or shrunk world rig (miniature, dollhouse) places at the scene-unit spot under the laser; desktop pointer path unchanged (frame = identity) - placeGhost.setGhostFrame + a frame for the pending drop boxes; explorerDrop.rayHit + placeFromGhost(items, placements, {frame}) - prefabs get measured dims (their ghost was the 1 m unknown box and they landed half sunk) - vr/placeRay.js: the VR glue — pointer laser per frame, stick flick turns 15 deg, the trigger places (stays armed, keeps the turn), a trigger-drag drops on release, a grip cancels, no ghost while the laser is on a panel - the VR prefabs window's private ghost moved onto the shared one - hooks: registerGripHooks (a feature may take a squeeze before anything grabs); the select swallow learns which controller (IWER fires select BEFORE selectstart on press) - suites: vr-asset-browser-iwer row G (new, IWER), vr-prefabs taught the shared ghost Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 2 +- src/lib/debugHooks.js | 1 + src/lib/explorerDrop.js | 27 ++- src/lib/placeDrag.js | 258 ++++++++++++++++++++++- src/lib/placeGhost.js | 40 +++- src/lib/vr/grip.js | 6 +- src/lib/vr/hooks.js | 52 ++++- src/lib/vr/panels.js | 93 +++----- src/lib/vr/placeRay.js | 255 ++++++++++++++++++++++ src/lib/vrControls.js | 3 +- tests/e2e/vr-asset-browser-iwer.test.cjs | 167 +++++++++++++++ tests/e2e/vr-prefabs.test.cjs | 62 +++--- 12 files changed, 840 insertions(+), 126 deletions(-) create mode 100644 src/lib/vr/placeRay.js create mode 100644 tests/e2e/vr-asset-browser-iwer.test.cjs diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 9065cea28..aa4f3702d 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -1473,7 +1473,7 @@ if (Date.now() - pinchMenuToggledAt < 250) return; // K1: a sleeve ghost pick/place just handled this trigger — the release // must not fall through to raycastSelect - if (vrModuleSelectSwallowed()) return; + if (vrModuleSelectSwallowed(xrControllers.indexOf(controller))) return; // 52-browser: per controller // the keyboard is modal on top of any panel (116) if ($vrKeyboardTarget) { const key = raycastKeyboard(xrControllers.indexOf(controller)); diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 46aa9ba1b..0e4bd8b05 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -44,6 +44,7 @@ export const DEBUG_HOOKS = [ ['placementDims', () => import('./placementDims.js')], ['packCache', () => import('./packCache.js')], ['placeGhost', () => import('./placeGhost.js')], + ['vrPlace', () => import('./vr/placeRay.js')], ['tourVR', () => import('./tours/vrPanel.js')], // 36-share (B13): the recorder + its stores ['recording', () => import('./recording/recorder.js')], diff --git a/src/lib/explorerDrop.js b/src/lib/explorerDrop.js index 956197d7d..560c3a582 100644 --- a/src/lib/explorerDrop.js +++ b/src/lib/explorerDrop.js @@ -67,6 +67,18 @@ export function dropTarget(clientX, clientY) { return { point: planePoint.toArray(), object: null, normal: [0, 1, 0], hit: null }; } +/** + * 52-browser: the first scene surface along a WORLD ray (the VR laser) — `{point, normal, object}` in + * world space, or null when the ray meets nothing. No ground-plane fallback here: the caller knows + * the frame its ground lives in (the world rig may have moved or shrunk the objectsGroup). + * @param {any} ray a THREE.Raycaster @returns {{point: number[], normal: number[] | null, object: any} | null} + */ +export function rayHit(ray) { + const hit = sceneHits(ray)[0]; + if (!hit) return null; + return { point: hit.point.toArray(), normal: hitWorldNormal(hit)?.toArray() ?? null, object: topLevelObjectOf(hit.object) }; +} + const _down = new THREE.Raycaster(); /** * 39 P6: the surface straight BELOW a world point (the camera's focus point is usually in the @@ -302,9 +314,12 @@ function stubbable(payload, dims) { /** * The one spawn a drag-to-place release makes. * @param {{payload: any, dims: any}[]} items @param {{position: number[], quaternion: number[]}[]} placements + * @param {{frame?: any}} [opts] `frame` (52-browser): the world matrix of the placements' frame — the + * objectsGroup's, which the VR world rig may have moved or shrunk — so the pending boxes draw there * @returns {Promise} the uuids placed (stubs at once; imports once they land) */ -export async function placeFromGhost(items, placements) { +export async function placeFromGhost(items, placements, opts = {}) { + const frame = opts.frame ?? null; const { placementGroupFor } = await import('./lodGroup'); /** @type {string[]} */ const placed = []; @@ -315,7 +330,7 @@ export async function placeFromGhost(items, placements) { for (let i = 0; i < items.length; i++) { const { payload, dims } = items[i]; if (payload?.url && !payload.behavior && !dims?.known && !peekPackTemplate(payload.url)) { - const release = holdPendingGhost(dims?.box ?? [-0.5, 0, -0.5, 0.5, 1, 0.5], placements[i].position, placements[i].quaternion); + const release = holdPendingGhost(dims?.box ?? [-0.5, 0, -0.5, 0.5, 1, 0.5], placements[i].position, placements[i].quaternion, frame); try { await loadPackTemplate(payload.url); } catch (e) { @@ -366,7 +381,7 @@ export async function placeFromGhost(items, placements) { } if (placed.length > 1) selectedObjects.set([...placed]); for (const { item, at } of later) { - const uuid = await importAtGhost(item.payload, item.dims, at); + const uuid = await importAtGhost(item.payload, item.dims, at, frame); if (uuid) placed.push(uuid); } return placed; @@ -374,11 +389,11 @@ export async function placeFromGhost(items, placements) { /** * The import path at a ghost's pose (a pending box holds the spot meanwhile). - * @param {any} payload @param {any} dims @param {{position: number[], quaternion: number[]}} at + * @param {any} payload @param {any} dims @param {{position: number[], quaternion: number[]}} at @param {any} [frame] * @returns {Promise} */ -async function importAtGhost(payload, dims, at) { - const release = holdPendingGhost(dims?.box ?? [-0.5, 0, -0.5, 0.5, 1, 0.5], at.position, at.quaternion); +async function importAtGhost(payload, dims, at, frame = null) { + const release = holdPendingGhost(dims?.box ?? [-0.5, 0, -0.5, 0.5, 1, 0.5], at.position, at.quaternion, frame); try { if (payload.url) { const name = String(payload.name || 'model').replace(/\.\w+$/, ''); diff --git a/src/lib/placeDrag.js b/src/lib/placeDrag.js index 53451e319..e9b9614de 100644 --- a/src/lib/placeDrag.js +++ b/src/lib/placeDrag.js @@ -5,7 +5,7 @@ import { globalRenderer, globalCamera, orbitControls, objectsGroup } from '../st import { snapEnabled, snapSettings, snapTargets } from './snapping'; import { dimsOf, dimsRevision, loadMeasuredDims, measureDims, dimsLabel, costLabel } from './placementDims'; import { placementPreview, placementTriBudget, placementShowDims, currentGhostTier } from './placementPrefs'; -import { showGhost, poseGhost, hideGhost, setGhostModel, ghostInfo } from './placeGhost'; +import { showGhost, poseGhost, hideGhost, setGhostModel, ghostInfo, setGhostFrame } from './placeGhost'; import { registerDebugHook } from './debugHooks'; // 39 P1 — DRAG TO PLACE. An Explorer card dragged onto the viewport shows a ghost where the @@ -30,6 +30,16 @@ import { registerDebugHook } from './debugHooks'; // long-press drag works the same with the Explorer sheet collapsed out of the way. // // LOCAL: the ghost and this state never leave the machine. +// +// 52-browser — THE RAY MODE (the VR laser; absorbs follow-up f-vr-place). The same gesture with a +// world RAY instead of a screen point: `beginRayPlace` / `updateRayPlace` / `commitRayPlace`. It adds +// no window listeners (the VR glue drives it per frame and owns the trigger/grip/stick) and publishes +// to `rayPlace`, not the DOM chip's store. Everything it lays out lives in the OBJECTSGROUP's frame — +// the space a placed object's position is written in — so the world rig moving, turning or SHRINKING +// the world (miniature mode, the dollhouse) changes nothing: the ray is carried into that frame, the +// ground is that frame's y = 0, grid snap is in scene units, and the ghost draws through the frame +// (placeGhost.setGhostFrame), so a 2 m crate over a table-sized world is table-sized. On a desktop the +// frame is the identity and the pointer path is byte-unchanged. const STEP_DEG = 15; /** gap between items dropped as a row (metres) */ @@ -52,6 +62,13 @@ const OVERLAP_WARN = 0.5; */ export const placeDrag = writable(null); +/** + * 52-browser: what the RAY mode shows (the VR panel reads it). null when no ray placement runs. + * @type {import('svelte/store').Writable} + */ +export const rayPlace = writable(null); + /** the current gesture (module state; the store is its view) @type {any} */ let drag = null; /** @type {any} */ @@ -92,14 +109,23 @@ export function isPlaceable(item) { export function placeDragActive() { return !!drag; } +/** 52-browser: the running gesture's turn in degrees (0 when none) */ +export function placeRotation() { + return drag?.rotation ?? 0; +} +/** 52-browser: is the running gesture the VR laser's? */ +export function rayPlaceActive() { + return drag?.mode === 'ray'; +} /** @param {any} p @returns {DragItem} */ function toItem(p) { const library = !p.url && !p.prefabId && p.id ? get(explorerRef?.explorerItems ?? writable([])).find((/** @type {any} */ r) => r.id === p.id) : null; const src = p.dims ? p : library ?? p; + const dims = dimsOf({ ...src, glbUrl: p.url ?? null }); return { payload: p, - dims: dimsOf({ ...src, glbUrl: p.url ?? null }), + dims: dims.known || !p.prefabId ? dims : (prefabDims(p.prefabId) ?? dims), url: p.url ?? null, libId: !p.url && !p.prefabId ? p.id ?? null : null, prefabId: p.prefabId ?? null, @@ -117,7 +143,9 @@ export function beginPlaceDrag(opts) { void loadMeasuredDims(); const list = Array.isArray(opts.payload?.items) && opts.payload.items.length > 1 ? opts.payload.items : [opts.payload]; const items = list.map(toItem); + setGhostFrame(null); drag = { + mode: 'pointer', payload: opts.payload, items, x: opts.x, @@ -156,15 +184,156 @@ export function beginPlaceDrag(opts) { return true; } +/** + * 52-browser: begin a RAY placement (the VR laser). No window listeners — the caller drives it: + * `updateRayPlace(ray)` per frame, `rotateBy` on the stick, `commitRayPlace()` on the trigger, + * `cancelPlaceDrag()` on the grip. `payload` is an Explorer-shaped card ({url} a pack item, {id} a + * Library item, {prefabId} a prefab), or one carrying `items`. + * `rotation` (degrees) starts the ghost turned — a re-armed placement keeps the turn it was placed with. + * @param {{payload: any, hand?: string | null, rotation?: number}} opts @returns {boolean} + */ +export function beginRayPlace(opts) { + if (drag) cancelPlaceDrag('restart'); + void loadMeasuredDims(); + const list = Array.isArray(opts.payload?.items) && opts.payload.items.length > 1 ? opts.payload.items : [opts.payload]; + const items = list.map(toItem); + drag = { + mode: 'ray', + hand: opts.hand ?? null, + payload: opts.payload, + items, + x: 0, + y: 0, + pointerType: 'xr', + pointerId: -1, + source: null, + collapse: false, + rotation: Number.isFinite(opts.rotation) ? (((/** @type {number} */ (opts.rotation) % 360) + 360) % 360) : 0, + shift: false, + alt: false, + over: /** @type {'viewport'|'explorer'|'other'} */ ('other'), + state: /** @type {'ok'|'warn'|'bad'} */ ('ok'), + placements: /** @type {any[]} */ ([]), + bridgeEl: null, + prefetched: false, + ghostUp: false, + tiers: items.map(() => 'box'), + obstacles: /** @type {any[] | null} */ (null), + unsubDims: /** @type {(() => void) | null} */ (null), + frame: new THREE.Matrix4(), + frameInv: new THREE.Matrix4(), + ray: /** @type {any} */ (null) + }; + drag.unsubDims = dimsRevision.subscribe(() => drag && refreshDims()); + publish(); + return true; +} + +/** + * 52-browser: one frame of a ray placement — `ray` is a WORLD THREE.Raycaster (the pointer hand's + * laser), or null to hide the ghost (the laser is on a panel). Returns the state ('ok' | 'warn' | + * 'bad'), or null when no ray placement runs. The ray is HELD (a rotate or a dims refresh re-lays the + * ghost on it), so pass a raycaster this module may keep — not one reused for other picks. + * @param {any} ray @returns {'ok' | 'warn' | 'bad' | null} + */ +export function updateRayPlace(ray) { + if (!drag || drag.mode !== 'ray') return null; + if (!ray) { + // the laser is on a panel (or nowhere): no ghost, and a trigger now places nothing + drag.ray = null; + drag.over = 'other'; + drag.placements = []; + drag.target = null; + poseGhost([], drag.state, false); + publish(); + return drag.state; + } + drag.ray = ray; + updateFrame(); + drag.over = 'viewport'; + if (!drag.ghostUp) { + setGhostFrame(drag.frame); + drag.ghostUp = showGhost(drag.items.map((/** @type {DragItem} */ it) => ({ box: it.dims.box }))); + applyTiers(); + } else setGhostFrame(drag.frame); + if (!drag.prefetched) prefetch(); + placeRay(ray); + poseGhost(drag.placements, drag.state, drag.placements.length > 0); + publish(); + return drag.state; +} + +/** 52-browser: the trigger — spawn at the ghost (ONE undo step, replicated). False when nothing was + * placed (no spot, or the spot is off the world). */ +export function commitRayPlace() { + if (!drag || drag.mode !== 'ray') return false; + if (drag.ray) placeRay(drag.ray); + const ok = drag.state !== 'bad' && drag.placements.length > 0; + commit(); + return ok; +} + +/** the objectsGroup's world matrix (and its inverse) — the frame a ray placement lays out in */ +function updateFrame() { + /** @type {any} */ + const group = get(objectsGroup); + if (!group) { + drag.frame.identity(); + drag.frameInv.identity(); + return; + } + group.updateWorldMatrix(true, false); + if (!drag.frame.equals(group.matrixWorld)) drag.obstacles = null; // the rig moved: re-read the boxes + drag.frame.copy(group.matrixWorld); + drag.frameInv.copy(group.matrixWorld).invert(); +} + +/** + * 52-browser: a PREFAB's size, measured off its decoded snapshot (a prefab carries no dims row, so its + * ghost was the 1 m "size unknown" box and it landed with its pivot on the surface — half sunk for a + * centred box). The root's own scale is kept: the placement writes only position + rotation. + * @param {string} id @returns {import('./placementDims').Dims | null} + */ +function prefabDims(id) { + if (!decoded.has('prefab:' + id)) decodePrefab(id); + const root = decoded.get('prefab:' + id); + if (!root) return null; + // measured under a bare parent, the root posed at the origin with its own SCALE (the box a placed + // copy actually occupies, before the placement's rotation) + const pos = root.position.clone(); + const quat = root.quaternion.clone(); + const parent = root.parent; + const holder = new THREE.Group(); + root.position.set(0, 0, 0); + root.quaternion.identity(); + holder.add(root); + const d = measureDims(holder); + holder.remove(root); + if (parent) parent.add(root); + root.position.copy(pos); + root.quaternion.copy(quat); + root.updateMatrixWorld(true); + return d; +} + function refreshDims() { if (!drag) return; - drag.items = drag.items.map((/** @type {DragItem} */ it) => ({ ...it, dims: it.dims.known ? it.dims : dimsOf({ ...it.payload, glbUrl: it.url }) })); + drag.items = drag.items.map((/** @type {DragItem} */ it) => { + if (it.dims.known) return it; + const d = dimsOf({ ...it.payload, glbUrl: it.url }); + return { ...it, dims: d.known || !it.prefabId ? d : (prefabDims(it.prefabId) ?? d) }; + }); if (drag.ghostUp) { // sizes changed: rebuild the boxes (the model tiers re-apply below) showGhost(drag.items.map((/** @type {DragItem} */ it) => ({ box: it.dims.box }))); drag.tiers = drag.items.map(() => 'box'); applyTiers(); } + if (drag.mode === 'ray') { + if (drag.ray) updateRayPlace(drag.ray); + else publish(); + return; + } update(drag.x, drag.y); } @@ -250,6 +419,11 @@ function onWheel(e) { export function rotateBy(steps) { if (!drag) return; drag.rotation = (((drag.rotation + steps * STEP_DEG) % 360) + 360) % 360; + if (drag.mode === 'ray') { + if (drag.ray) updateRayPlace(drag.ray); + else publish(); + return; + } update(drag.x, drag.y); } @@ -289,9 +463,25 @@ function update(x, y) { } function publish() { - if (!drag) return placeDrag.set(null); + if (!drag) { + rayPlace.set(null); + return placeDrag.set(null); + } const first = drag.items[0]; const many = drag.items.length; + if (drag.mode === 'ray') { + rayPlace.set({ + label: many > 1 ? `${first.label} + ${many - 1} more` : first.label, + count: many, + state: drag.state, + dims: first.dims.known ? dimsLabel(first.dims.size) : 'size unknown', + unknown: !first.dims.known, + rotation: drag.rotation, + onSurface: !!drag.target, + hand: drag.hand ?? null + }); + return; + } placeDrag.set({ x: drag.x, y: drag.y, @@ -331,6 +521,8 @@ function obstacles() { if (!child.visible) continue; const b = new THREE.Box3().setFromObject(child); if (b.isEmpty() || !Number.isFinite(b.min.x)) continue; + // 52-browser: the ray mode lays out in the objectsGroup's frame + if (drag.mode === 'ray') b.applyMatrix4(drag.frameInv); out.push(b); } drag.obstacles = out; @@ -371,6 +563,45 @@ function place(x, y) { if (t.normal) normal = new THREE.Vector3().fromArray(t.normal).normalize(); } } + let right = null; + /** @type {any} */ + const camera = get(globalCamera); + if (camera) right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0); + layout(point, normal, right); +} + +const _rayLocal = new THREE.Ray(); +const _groundLocal = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); +/** + * 52-browser: the ray mode's target — the first scene surface along the WORLD ray, else the ground + * (y = 0 of the objectsGroup's frame) — laid out in that frame. The row direction is the viewer's + * right (the XR camera's in a session) carried into the frame. @param {any} ray + */ +function placeRay(ray) { + if (!dropRef) return (drag.placements = []); + let point = null; + let normal = UP.clone(); + const hit = dropRef.rayHit(ray); + if (hit) { + point = new THREE.Vector3().fromArray(hit.point).applyMatrix4(drag.frameInv); + if (hit.normal) normal = new THREE.Vector3().fromArray(hit.normal).transformDirection(drag.frameInv); + } else { + _rayLocal.copy(ray.ray).applyMatrix4(drag.frameInv); + point = _rayLocal.intersectPlane(_groundLocal, new THREE.Vector3()); + } + // the viewer's right: the camera the ray carries (withRayCamera), else the editor's + /** @type {any} */ + const camera = ray.camera ?? get(globalCamera); + const right = camera ? new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0).transformDirection(drag.frameInv) : null; + layout(point, normal, right); +} + +/** + * Lay the dragged items out at `point` (in the placement frame) on a surface of normal `normal`, + * a row along `right`. Sets drag.placements / target / state. + * @param {any} point @param {any} normal @param {any} right + */ +function layout(point, normal, right) { if (!point || point.length() > WORLD_LIMIT) { drag.state = 'bad'; drag.placements = point ? drag.items.map(() => ({ position: point.clone(), quaternion: new THREE.Quaternion() })) : []; @@ -393,10 +624,8 @@ function place(x, y) { else _align.identity(); const q = _align.clone().multiply(_yaw); // a row of several items, laid out along the camera's right (on the surface) - /** @type {any} */ - const camera = get(globalCamera); _right.set(1, 0, 0); - if (camera) _right.setFromMatrixColumn(camera.matrixWorld, 0); + if (right) _right.copy(right); _right.addScaledVector(normal, -_right.dot(normal)); if (_right.lengthSq() < 1e-8) _right.set(1, 0, 0).addScaledVector(normal, -normal.x); _right.normalize(); @@ -583,9 +812,10 @@ function commit() { if (host.state === 'bad' || !host.placements.length) return end('invalid'); const placements = host.placements.map((/** @type {any} */ p) => ({ position: p.position.toArray(), quaternion: p.quaternion.toArray() })); const items = host.items.map((/** @type {DragItem} */ it) => ({ payload: it.payload, dims: it.dims })); + const frame = host.mode === 'ray' && host.frame ? host.frame.clone() : null; end('committed'); - lastCommit = { at: Date.now(), count: items.length, placements }; - primed.then(() => dropRef?.placeFromGhost(items, placements)); + lastCommit = { at: Date.now(), count: items.length, placements, mode: host.mode }; + primed.then(() => dropRef?.placeFromGhost(items, placements, frame ? { frame } : {})); } /** Cancel the running drag: nothing spawns, nothing enters the undo stack. @param {string} [why] */ @@ -615,6 +845,7 @@ function end(why) { drag = null; lastEnd = why; placeDrag.set(null); + rayPlace.set(null); } /** for the suites @type {string | null} */ @@ -626,6 +857,11 @@ registerDebugHook('placeDrag', { placeDrag, placeDragActive, beginPlaceDrag, + beginRayPlace, + updateRayPlace, + commitRayPlace, + rayPlaceActive, + rayPlace, cancelPlaceDrag, rotateBy, ghostInfo, @@ -639,7 +875,9 @@ registerDebugHook('placeDrag', { tiers: [...drag.tiers], items: drag.items.map((/** @type {DragItem} */ it) => ({ label: it.label, dims: it.dims })), placements: drag.placements.map((/** @type {any} */ p) => ({ position: p.position.toArray(), quaternion: p.quaternion.toArray() })), - target: drag.target ?? null + target: drag.target ?? null, + mode: drag.mode, + frame: drag.frame ? drag.frame.toArray() : null } : null, last: () => ({ end: lastEnd, commit: lastCommit }), diff --git a/src/lib/placeGhost.js b/src/lib/placeGhost.js index c7a382dd1..9d641689a 100644 --- a/src/lib/placeGhost.js +++ b/src/lib/placeGhost.js @@ -47,6 +47,32 @@ let items = []; let shownState = 'ok'; /** pending model swaps (cancelled when the ghost goes) @type {number[]} */ let swapTimers = []; +/** + * 52-browser: the FRAME placements are expressed in. Null = the scene root (the desktop drag: + * objectsGroup sits at the identity there). The VR laser places in the objectsGroup's frame, which + * the world rig may have moved, turned or SHRUNK (miniature / dollhouse) — the ghost then rides + * that frame so a 2 m crate on a table-sized world draws table-sized. Kept across hideGhost: the + * pending boxes a drop holds come AFTER the ghost is gone. @type {any} + */ +let frame = null; + +/** + * Set (or clear with null) the frame the next placements live in — a world matrix (objectsGroup's + * matrixWorld). @param {any} matrix + */ +export function setGhostFrame(matrix) { + frame = matrix ? matrix.clone() : null; + if (root) applyFrame(root); +} +/** @param {any} obj */ +function applyFrame(obj) { + if (frame) frame.decompose(obj.position, obj.quaternion, obj.scale); + else { + obj.position.set(0, 0, 0); + obj.quaternion.identity(); + obj.scale.set(1, 1, 1); + } +} function materials() { if (mats) return mats; @@ -160,6 +186,7 @@ export function showGhost(specs) { items.push({ group, box, model: null, modelKey: null }); } quiet(root); + if (frame) applyFrame(root); root.visible = false; scene.add(root); return true; @@ -240,6 +267,7 @@ export function ghostInfo() { return { visible: !!root?.visible, state: shownState, + frame: frame ? frame.toArray() : null, items: items.map((it) => ({ tier: it.model ? 'model' : 'box', key: it.modelKey, @@ -258,9 +286,12 @@ const pending = new Set(); * Hold a box ghost at a drop's spot until the object lands — the import paths (an animated pack * item, a library model, a prefab) parse before they add, and the spot must not look empty while * they do. LOCAL like the ghost. Returns the release. A pending box gives up after 30 s. - * @param {number[]} box @param {number[]} position @param {number[]} quaternion @returns {() => void} + * `frameMatrix` (52-browser): the world matrix the pose is expressed in (the VR laser places in the + * objectsGroup's frame, which the world rig may have moved or shrunk); null = the scene root. + * @param {number[]} box @param {number[]} position @param {number[]} quaternion @param {any} [frameMatrix] + * @returns {() => void} */ -export function holdPendingGhost(box, position, quaternion) { +export function holdPendingGhost(box, position, quaternion, frameMatrix = null) { /** @type {any} */ const scene = get(globalScene); if (!scene) return () => {}; @@ -271,6 +302,11 @@ export function holdPendingGhost(box, position, quaternion) { g.name = 'place-ghost-pending'; g.position.fromArray(position); g.quaternion.fromArray(quaternion); + // 52-browser: a drop placed in a moved / scaled world frame holds its box in that frame too + if (frameMatrix) { + g.updateMatrix(); + g.matrix.premultiply(frameMatrix).decompose(g.position, g.quaternion, g.scale); + } scene.add(g); pending.add(g); const release = () => { diff --git a/src/lib/vr/grip.js b/src/lib/vr/grip.js index a9b7fc95a..1e286a483 100644 --- a/src/lib/vr/grip.js +++ b/src/lib/vr/grip.js @@ -60,7 +60,7 @@ import { safeStorage } from '../safeStorage'; import { S } from './state.js'; import { renderer, tempVector } from './core.js'; import { hapticPulse, hapticPattern } from './haptics.js'; -import { gripDropHooks, worldGrabDiverts, worldGestureDiverted } from './hooks.js'; +import { gripDropHooks, worldGrabDiverts, worldGestureDiverted, vrModuleGripStart, vrModuleGripEnd } from './hooks.js'; import { controllerIndexFor, axesForSlot } from './input.js'; import { STANDING_HEAD, vrLocomotionNow, viewerNow } from './locomotion.js'; import { @@ -428,6 +428,8 @@ export function vertexTriggerActive() { /** @param {number} index */ export function onSqueezeStart(index) { + // 52-browser: a feature took this squeeze (a VR placement ghost cancels, a wrist panel lifts off) + if (vrModuleGripStart(index)) return; // an armed prefab ghost cancels on grip (115) — nothing else grabs if (get(vrPrefabGhost)) { cancelPrefabGhost(); @@ -687,6 +689,8 @@ export function lastGripRefusalDebug() { /** @param {number} index */ export function onSqueezeEnd(index) { + // 52-browser: the release of a squeeze a feature took + if (vrModuleGripEnd(index)) return; // face grab (122): release commits the reshape as one meshgeo + undo entry if (faceGrabHand?.index === index) { faceGrabHand = null; diff --git a/src/lib/vr/hooks.js b/src/lib/vr/hooks.js index 598623516..2905bb055 100644 --- a/src/lib/vr/hooks.js +++ b/src/lib/vr/hooks.js @@ -47,7 +47,7 @@ export function registerPanelGroupProvider(fn) { if (i >= 0) panelGroupProviders.splice(i, 1); }; } -/** @type {{start?: (i: number) => boolean, end?: (i: number) => boolean, swallow?: () => boolean}[]} */ +/** @type {{start?: (i: number) => boolean, end?: (i: number) => boolean, swallow?: (i: number) => boolean}[]} */ const triggerHooks = []; /** Trigger (select) hooks: `start` may consume a selectstart, `end` a * selectend, `swallow` eats the trailing 'select' click (Scene.svelte calls @@ -91,16 +91,60 @@ export function vrModuleTriggerEnd(index) { } }); } -/** @returns {boolean} */ -export function vrModuleSelectSwallowed() { +/** 52-browser: `index` = the slot whose 'select' this is (-1 when the caller does not know), so a hook + * can claim the select of ONE controller. NOTE the order differs by runtime: a headset fires selectstart on + * press and select + selectend on release, while IWER (the e2e headset) fires select + selectstart on press. + * @param {number} [index] @returns {boolean} */ +export function vrModuleSelectSwallowed(index = -1) { return triggerHooks.some((h) => { try { - return !!h.swallow?.(); + return !!h.swallow?.(index); } catch { return false; } }); } +/** @type {{start?: (i: number) => boolean, end?: (i: number) => boolean}[]} */ +const gripHooks = []; +/** 52-browser: grip (squeeze) hooks — `start` may CONSUME a squeeze before anything grabs (a ghost + * the grip cancels, a wrist panel the grip lifts off), `end` its release. The sleeve's trigger hooks, + * one button over. @param {{start?: any, end?: any}} hooks @returns {() => void} */ +export function registerGripHooks(hooks) { + gripHooks.push(hooks); + return () => { + const i = gripHooks.indexOf(hooks); + if (i >= 0) gripHooks.splice(i, 1); + }; +} +/** which slots a grip hook took (its release goes to the hooks too) */ +const gripClaims = [false, false]; +/** @param {number} index @returns {boolean} */ +export function vrModuleGripStart(index) { + const claimed = gripHooks.some((h) => { + try { + return !!h.start?.(index); + } catch (error) { + console.log('VR grip hook failed', error); + return false; + } + }); + gripClaims[index] = claimed; + return claimed; +} +/** @param {number} index @returns {boolean} */ +export function vrModuleGripEnd(index) { + const claimed = !!gripClaims[index]; + gripClaims[index] = false; + const taken = gripHooks.some((h) => { + try { + return !!h.end?.(index); + } catch (error) { + console.log('VR grip hook failed', error); + return false; + } + }); + return claimed || taken; +} /** @type {((object: any, before: any) => boolean)[]} K2: grip-release interceptors */ export const gripDropHooks = []; /** A hook may CONSUME a grip-grab release (e.g. dropping an object onto the diff --git a/src/lib/vr/panels.js b/src/lib/vr/panels.js index 3dc5768d3..82836d437 100644 --- a/src/lib/vr/panels.js +++ b/src/lib/vr/panels.js @@ -21,7 +21,7 @@ import { } from '../../stores/sceneStore'; import { paletteColorAt, barValueAt } from '../vrPalette'; import { recordMaterialChange, setMaterialParam } from '../materialsHandler'; -import { prefabs, instantiatePrefab } from '../prefabs'; +import { prefabs } from '../prefabs'; import { peers, showToast, messages } from '../../stores/appStore'; import { recordTransform, beginHistoryBatch, endHistoryBatch } from '../history'; import { snapEnabled, snapSettings, surfaceSnap } from '../snapping'; @@ -41,6 +41,7 @@ import { hapticPulse } from './haptics.js'; import { axesForSlot } from './input.js'; import { controllerRay } from './pointer.js'; import { executeVRMenuAction } from './radial.js'; +import { beginVRPlace, cancelVRPlace, placeVRNow, vrPlaceActive } from './placeRay.js'; // VR interactions (all gated on an active XR session): // - A/X button on the menu hand toggles the quick-menu @@ -356,10 +357,6 @@ export const vrPrefabsCursor = writable(0); /** armed prefab {id, name} while a placement ghost rides the ray, else null * @type {import('svelte/store').Writable} */ export const vrPrefabGhost = writable(null); -/** @type {any} the translucent THREE clone at the scene root */ -let ghostObject = null; -/** @type {any} */ -let ghostPrefab = null; // ---- VR chat panel (117) ---- /** the chat panel THREE group @type {import('svelte/store').Writable} */ @@ -409,42 +406,29 @@ export function raycastPrefabs(index) { return control ? 'prefabs:' + control.object.name.slice('vrprefabs-'.length) : null; } -/** Arm the placement ghost for a prefab id (trigger on a cell) @param {string} id */ +/** Arm the placement ghost for a prefab id (trigger on a cell) @param {string} id + * 52-browser: the window's ghost IS the shared laser ghost now (vr/placeRay.js — absorbs f-vr-place): + * the real model translucent when within the preview budget, surface rest + grid snap, the stick + * turns it, the overlap colours, and ONE replicated undo step per placement. Still: trigger places + * and stays armed, grip cancels, closing the window drops it. */ export function armPrefabGhost(id) { const prefab = get(prefabs).find((p) => p.id === id); - const scene = get(globalScene); - if (!prefab || !scene) return; - cancelPrefabGhost(); - let clone; - try { - clone = new THREE.ObjectLoader().parse(prefab.element); - } catch { - return; - } - // fresh parse = own materials, safe to fade in place - clone.traverse((/** @type {any} */ node) => { - if (node.material) { - node.material.transparent = true; - node.material.opacity = 0.35; - node.material.depthWrite = false; + if (!prefab || !get(globalScene)) return; + const armed = beginVRPlace( + { prefabId: prefab.id, kind: 'prefab', name: prefab.name }, + { + repeat: true, + onEnd: () => { + if (get(vrPrefabGhost)?.id === prefab.id) vrPrefabGhost.set(null); + } } - }); - clone.name = 'vr-prefab-ghost'; - scene.add(clone); - ghostObject = clone; - ghostPrefab = prefab; - vrPrefabGhost.set({ id: prefab.id, name: prefab.name }); - hapticPulse(0.2, 25); + ); + if (armed) vrPrefabGhost.set({ id: prefab.id, name: prefab.name }); } /** Drop the ghost without placing (grip / panel close / menu open) */ export function cancelPrefabGhost() { - if (ghostObject) { - ghostObject.parent?.remove(ghostObject); - ghostObject.traverse((/** @type {any} */ node) => node.geometry?.dispose?.()); - } - ghostObject = null; - ghostPrefab = null; + if (get(vrPrefabGhost)) cancelVRPlace('cancel'); vrPrefabGhost.set(null); } @@ -453,38 +437,19 @@ vrPrefabsPanelOpen.subscribe((open) => { if (!open) cancelPrefabGhost(); }); -/** Ghost follows the pointer ray: objects first, floor plane as fallback @param {number} index */ -export function updatePrefabGhost(index) { - if (!ghostObject || index < 0) return; - const ray = controllerRay(index); - const group = get(objectsGroup); - let point = null; - if (group) { - const hits = ray - .intersectObjects(group.children, true) - .filter((/** @type {any} */ h) => h.object !== ghostObject); - if (hits.length) point = hits[0].point; - } - if (!point) { - const floor = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); - point = ray.ray.intersectPlane(floor, new THREE.Vector3()); - } - if (point) ghostObject.position.copy(point); -} +/** 52-browser: the shared laser ghost follows the pointer itself (placeRay's frame hook); kept for + * the façade. @param {number} _index */ +export function updatePrefabGhost(_index) {} -/** Trigger while the ghost is armed: instantiate at the ghost spot, stay armed. - * Returns true when a placement happened (Scene.svelte consumes the select). */ +/** Trigger while the ghost is armed: place at the ghost spot, stay armed. Returns true when a + * placement happened (Scene.svelte consumes the select). 52-browser: in a session the shared ghost's + * trigger hook places; this is the programmatic path (outside a session). */ export function placePrefabGhost() { - if (!ghostObject || !ghostPrefab) return false; - // picking a cell re-arms instead of placing — the panel raycast runs first - const group = get(objectsGroup); - if (!group) return false; - group.updateMatrixWorld(true); - const local = group.worldToLocal(ghostObject.position.clone()); - const object = instantiatePrefab(ghostPrefab, local); - if (!object) return false; - hapticPulse(0.35, 40); - return true; + if (!get(vrPrefabGhost) || !vrPlaceActive()) return false; + // in a session the placing hand's trigger HOOK places (and claims its select); another hand's select + // passes through to what it would do anyway + if (renderer?.xr?.isPresenting) return false; + return placeVRNow(); } let paintGesture = /** @type {any} */ (null); let lastColorSent = 0; diff --git a/src/lib/vr/placeRay.js b/src/lib/vr/placeRay.js new file mode 100644 index 000000000..ebe8ddec9 --- /dev/null +++ b/src/lib/vr/placeRay.js @@ -0,0 +1,255 @@ +// VR controls — 52-browser: THE DRAG-TO-PLACE GHOST ON THE LASER (absorbs follow-up f-vr-place). +// The desktop's drag-to-place (placeDrag.js, 39) with the pointer hand's ray instead of the mouse: the +// same ghost tiers (box first, the real model when it is decoded and under the budget), the same +// surface rest + grid snap + overlap colours, the same ONE-undo replicated spawn (explorerDrop +// .placeFromGhost). This file is only the VR glue; the layout lives in placeDrag's RAY mode, in the +// objectsGroup's frame — so it works unchanged while the world rig is moved or shrunk (miniature). +// +// THE GESTURE, one model for a controller and a tracked hand (pinch = trigger): +// · pick an item (a wrist Asset Browser card, a prefab tile) -> the ghost rides the laser +// · trigger-DRAG out of the panel and let go over the world -> placed there (once) +// · let go still on the panel (a click) -> armed: the next trigger places, +// and stays armed for the next one +// · the pointer hand's stick sideways -> turn 15° per flick +// · either grip -> cancel (nothing spawns, no undo) +// · the laser on a panel -> no ghost; a trigger there is the +// panel's, never a placement +// +// Rides the K1 hook registries (frame / trigger / grip / stick owner), registered on first use (the +// dollhouse pattern) and left in place — every hook is a no-op while nothing is being placed. +import * as THREE from 'three'; +import { get, writable } from 'svelte/store'; +import { renderer } from './core.js'; +import { controllerIndexFor, axesForSlot } from './input.js'; +import { pointerHand } from './bindings.js'; +import { hapticPulse } from './haptics.js'; +import { beamTarget, withRayCamera } from './pointer.js'; +import { registerVRFrameHook, registerVRTriggerHooks, registerGripHooks, registerStickOwner } from './hooks.js'; +import { beginRayPlace, updateRayPlace, commitRayPlace, cancelPlaceDrag, rotateBy, rayPlaceActive, rayPlace, placeRotation } from '../placeDrag'; + +export { rayPlace }; + +/** a stick deflection past this is a flick; it re-arms back under STICK_REARM */ +const STICK_FLICK = 0.6; +const STICK_REARM = 0.3; + +/** + * The running VR placement, or null: what was picked (so a placement can re-arm), which hand's laser + * carries it, and whether its trigger is still held from the pick (a drag). + * @type {null | {payload: any, hand: 'left' | 'right', dragging: boolean, repeat: boolean, onEnd: ((why: string) => void) | null, + * placed: number, stickArmed: boolean}} + */ +let current = null; +/** published for panels / suites: {label, hand, armed} or null @type {import('svelte/store').Writable} */ +export const vrPlacing = writable(null); +/** the laser this module hands placeDrag (it HOLDS the ray, so it is ours alone) */ +const ray = new THREE.Raycaster(); +let hooked = false; +/** a press this module took: its 'select' is ours (a FLAG for the press, cleared on its selectend — a + * time window lost to a slow frame and placed twice). The stamp only retires a flag no end came for. */ +let swallowNext = false; +let swallowSetAt = 0; +function claimSelect() { + swallowNext = true; + swallowSetAt = Date.now(); +} +/** + * Is controller slot `index`'s 'select' the placement's? Its press's flag, or — whatever the runtime's event + * order (IWER fires select BEFORE selectstart) — the placing hand's select while its laser is off the panels: + * the trigger hook is what places, so that select must not also select or place. + * @param {number} index + */ +function swallowSelect(index) { + if (swallowNext && Date.now() - swallowSetAt < 5000) return true; + if (!current || index < 0 || index !== placingSlot()) return false; + const laser = laserOf(current.hand); + return !!laser && !beamTarget(laser).panel; +} +/** the suites' view */ +const debug = { placed: 0, cancelled: 0, rotations: 0, onPanelFrames: 0, lastEnd: /** @type {string | null} */ (null) }; + +/** the laser of `hand` as a world raycaster, or null (not tracked) @param {'left' | 'right'} hand */ +function laserOf(hand) { + const index = controllerIndexFor(hand); + if (index < 0) return null; + const controller = renderer?.xr?.getController(index); + if (!controller) return null; + controller.updateWorldMatrix(true, false); + const m = new THREE.Matrix4().extractRotation(controller.matrixWorld); + ray.ray.origin.setFromMatrixPosition(controller.matrixWorld); + ray.ray.direction.set(0, 0, -1).applyMatrix4(m).normalize(); + ray.far = Infinity; + return withRayCamera(ray); +} + +let publishedSig = ''; +function publish() { + const next = current ? { label: get(rayPlace)?.label ?? '', hand: current.hand, armed: !current.dragging } : null; + const sig = next ? next.label + '|' + next.hand + '|' + next.armed : ''; + if (sig === publishedSig) return; + publishedSig = sig; + vrPlacing.set(next); +} + +/** + * Pick something up onto the laser. `payload` is Explorer-shaped ({url, name} a pack item, {id, name} + * a Library item, {prefabId, name} a prefab). `dragging`: the trigger that picked it is still down — + * its release over the world places (a drag); released on the panel it stays armed (a click). + * @param {any} payload + * @param {{hand?: 'left' | 'right', dragging?: boolean, repeat?: boolean, onEnd?: (why: string) => void}} [opts] + * @returns {boolean} + */ +export function beginVRPlace(payload, opts = {}) { + if (!payload) return false; + ensureHooks(); + const prevEnd = current?.onEnd; + current = null; + if (rayPlaceActive()) cancelPlaceDrag('restart'); + prevEnd?.('restart'); + const hand = opts.hand ?? pointerHand(); + if (!beginRayPlace({ payload, hand })) return false; + current = { payload, hand, dragging: !!opts.dragging, repeat: opts.repeat !== false, onEnd: opts.onEnd ?? null, placed: 0, stickArmed: true }; + hapticPulse(0.2, 25, hand); + frame(); + publish(); + return true; +} + +/** Drop the ghost: nothing spawns, nothing enters the undo stack. @param {string} [why] */ +export function cancelVRPlace(why = 'cancel') { + if (!current) return; + const end = current.onEnd; + current = null; + cancelPlaceDrag(why); + debug.cancelled++; + debug.lastEnd = why; + publish(); + end?.(why); +} + +/** Is a VR placement running? */ +export function vrPlaceActive() { + return !!current && rayPlaceActive(); +} + +/** + * Place at the ghost (the trigger / the drag release). A click placement re-arms the same item; a + * drag places once. Returns true when something spawned. + */ +export function placeVRNow() { + if (!current || !rayPlaceActive()) return false; + const host = current; + const rotation = placeRotation(); + const ok = commitRayPlace(); + if (!ok) { + // no spot (the laser on the sky, off the world): the gesture stays as it was + if (!rayPlaceActive()) beginRayPlace({ payload: host.payload, hand: host.hand, rotation }); + hapticPulse(0.15, 20, host.hand); + return false; + } + debug.placed++; + host.placed++; + hapticPulse(0.35, 40, host.hand); + if (host.repeat && !host.dragging) { + // stay armed, turned as before: the next trigger places another + beginRayPlace({ payload: host.payload, hand: host.hand, rotation }); + current = host; + frame(); + publish(); + return true; + } + current = null; + debug.lastEnd = 'placed'; + publish(); + host.onEnd?.('placed'); + return true; +} + +/** the slot index of the placing hand, or -1 */ +function placingSlot() { + return current ? controllerIndexFor(current.hand) : -1; +} + +/** One frame: the ghost follows the placing hand's laser; on a panel there is no ghost. */ +function frame() { + if (!current) return; + if (!rayPlaceActive()) { + // placeDrag ended underneath us (a scene clear, another drag): let go too + const end = current.onEnd; + current = null; + publish(); + end?.('ended'); + return; + } + if (!renderer?.xr?.isPresenting) return; + const laser = laserOf(current.hand); + if (!laser) return; + if (beamTarget(laser).panel) { + debug.onPanelFrames++; + updateRayPlace(null); + } else updateRayPlace(laser); + // the stick turns the ghost, a flick per 15° + const axes = axesForSlot(placingSlot()); + const x = axes[2] ?? 0; + if (current.stickArmed && Math.abs(x) > STICK_FLICK) { + current.stickArmed = false; + rotateBy(x > 0 ? -1 : 1); // right turns clockwise seen from above + debug.rotations++; + hapticPulse(0.12, 15, current.hand); + } else if (!current.stickArmed && Math.abs(x) < STICK_REARM) current.stickArmed = true; + publish(); +} + +/** @param {number} index */ +function onTrigger(index) { + if (!current || index !== placingSlot()) return false; + const laser = laserOf(current.hand); + // a trigger on a panel belongs to the panel (re-pick a card, press a button) + if (laser && beamTarget(laser).panel) return false; + claimSelect(); + placeVRNow(); + return true; +} + +/** @param {number} index */ +function onTriggerEnd(index) { + // the press is over: its flag is spent (a headset's select came just before this) + swallowNext = false; + if (!current?.dragging || index !== placingSlot()) return false; + current.dragging = false; + const laser = laserOf(current.hand); + // let go over the world = the drag's drop; still on the panel = a click, stay armed + if (laser && !beamTarget(laser).panel) { + claimSelect(); + placeVRNow(); + return true; + } + publish(); + return false; +} + +function ensureHooks() { + if (hooked) return; + hooked = true; + registerVRFrameHook(frame); + registerVRTriggerHooks({ + start: onTrigger, + end: onTriggerEnd, + swallow: swallowSelect + }); + // either grip cancels — before anything grabs + registerGripHooks({ + start: () => { + if (!current) return false; + cancelVRPlace('grip'); + return true; + } + }); + // the placing hand's stick turns the ghost: its turn / teleport stand down meanwhile + registerStickOwner(() => (current ? current.hand : null)); +} + +/** the suites' view */ +export function vrPlaceDebug() { + return { active: vrPlaceActive(), current: current ? { hand: current.hand, dragging: current.dragging, repeat: current.repeat, placed: current.placed } : null, ...debug }; +} diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 58120f8ef..ca771f018 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -123,7 +123,8 @@ export { registerVRFrameHook, registerWorldGrabDivert, registerStickOwner, - stickOwned + stickOwned, + registerGripHooks } from './vr/hooks.js'; export { computeMoveOffset, diff --git a/tests/e2e/vr-asset-browser-iwer.test.cjs b/tests/e2e/vr-asset-browser-iwer.test.cjs new file mode 100644 index 000000000..3af2b2007 --- /dev/null +++ b/tests/e2e/vr-asset-browser-iwer.test.cjs @@ -0,0 +1,167 @@ +// 52-browser — V4 on an EMULATED QUEST (IWER, iwer.cjs): the Asset Browser on the wrist, the drag-to-place +// ghost on the laser, and light hand tracking. Every row starts from the person's own gesture (Play, a real +// trigger / grip / stick on IWER's Touch Plus controllers, a real pinch on IWER's hands). +// G. the laser ghost (absorbs f-vr-place): a prefab tile arms placeDrag's RAY mode — the ghost rides the +// pointer hand's laser to the floor, the stick turns it 15° a flick, the TRIGGER places it as ONE +// replicated undo step and it stays armed, a GRIP cancels with nothing spawned; in MINIATURE (the +// world rig shrunk to 1:10 and moved) the ghost draws in the shrunk frame and the object lands at the +// scene-unit spot under the laser +// Run: APP_URL=https://theprototype.app:5484/ node tests/e2e/vr-asset-browser-iwer.test.cjs (ONLY=G,… to pick rows) +const h = require('./helpers.cjs'); +const iwer = require('./iwer.cjs'); + +const ONLY = (process.env.ONLY || '').split(',').filter(Boolean); +const want = (row) => !ONLY.length || ONLY.includes(row); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { init: iwer.initScript(), context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + const g = (fn, arg) => page.evaluate(fn, arg); + const read = (name) => g((n) => { let v; window.__stores[n].subscribe((x) => (v = x))(); return v; }, name); + const settle = (n = 6) => iwer.frames(page, n); + const near = (a, b, tol = 0.02) => a.length === b.length && a.every((v, i) => Math.abs(v - b[i]) <= tol); + const count = () => g(() => new Promise((r) => window.__stores.objectsGroup.subscribe((grp) => r(grp.children.length))())); + + console.log('\n=== 0. enter VR on IWER ==='); + h.check(await iwer.installed(page), 'IWER owns navigator.xr before the app boots'); + const st0 = await iwer.enter(page); + h.check(st0.presenting && st0.same, `Play presents the IWER session (${JSON.stringify(st0)})`); + const menuHand = await read('vrMenuHand'); + const pointer = menuHand === 'right' ? 'left' : 'right'; + console.log(`menu hand ${menuHand}, pointer ${pointer}`); + await iwer.pose(page, 'head', [0, 1.6, 0]); + await iwer.pose(page, menuHand, [menuHand === 'left' ? -0.25 : 0.25, 1.1, -0.2]); + + /** where the pointer hand's laser meets the objectsGroup frame's ground (y = 0), in that frame */ + const laserGround = () => + g((hand) => { + const T = window.__stores.THREE; + let r, grp; + window.__stores.globalRenderer.subscribe((v) => (r = v))(); + window.__stores.objectsGroup.subscribe((v) => (grp = v))(); + const c = [0, 1].map((i) => r.xr.getController(i)).find((x) => x.userData.handedness === hand); + c.updateMatrixWorld(true); + grp.updateWorldMatrix(true, false); + const inv = grp.matrixWorld.clone().invert(); + const o = new T.Vector3().setFromMatrixPosition(c.matrixWorld); + const d = new T.Vector3(0, 0, -1).applyMatrix4(new T.Matrix4().extractRotation(c.matrixWorld)).normalize(); + const local = new T.Ray(o, d).applyMatrix4(inv); + const p = local.intersectPlane(new T.Plane(new T.Vector3(0, 1, 0), 0), new T.Vector3()); + return p ? p.toArray() : null; + }, pointer); + + // ---- G. the laser ghost ------------------------------------------------------------------------ + if (want('G')) { + console.log('\n=== G. the drag-to-place ghost on the laser ==='); + // a prefab to place (Edit ▸ Save prefab), the scene emptied after so the laser meets the floor + await g(async () => { + window.__stores.commandsHandler.sceneCommand('/create box'); + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const box = group.children[group.children.length - 1]; + box.name = 'GhostCrate'; + window.__stores.objectActions.selectObject(box); + window.__stores.vrControls.executeVRMenuAction('obj:prefab'); + }); + await h.eventually(() => g(() => { let l; window.__stores.prefabs.prefabs.subscribe((x) => (l = x))(); return l.length; }), (n) => n >= 1, 'premise: a prefab in the library', 8000); + await g(async () => { + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const box = group.children.find((c) => c.name === 'GhostCrate'); + window.__stores.objectActions.selectObject(box); + window.__stores.objectActions.deleteSelection?.(); + }); + await settle(); + const n0 = await count(); + // open Add ▸ Prefabs and pick the tile (the panel's own action) + await g(() => { + window.__stores.vrMenuOpen.set(true); + window.__stores.vrControls.executeVRMenuAction('prefabs'); + let l; + window.__stores.prefabs.prefabs.subscribe((x) => (l = x))(); + window.__stores.vrControls.executeVRMenuAction('prefabs:select:' + l[0].id); + }); + // the pointer hand aims down at the floor ~1.4 m ahead + await iwer.pose(page, pointer, [pointer === 'left' ? -0.15 : 0.15, 1.2, -0.3], { pitch: -0.7 }); + await settle(8); + const aim = await laserGround(); + const gh = await g(() => window.__stores.placeGhost.ghostInfo()); + const st = await g(() => window.__stores.placeDrag.state()); + h.check(st?.mode === 'ray' && gh.visible, `a prefab tile arms the laser ghost (mode ${st?.mode}, visible ${gh.visible})`); + h.check(!!aim && near([gh.items[0].position[0], gh.items[0].position[2]], [aim[0], aim[2]], 0.03), `the ghost rides the laser to the floor (${gh.items[0]?.position.map((v) => v.toFixed(2))} vs ${aim?.map((v) => v.toFixed(2))})`); + const lift = -(st?.items?.[0]?.dims?.box?.[1] ?? NaN); + h.check(!!st?.items?.[0]?.dims?.known && Math.abs(gh.items[0].position[1] - lift) < 0.01, `it rests ON the floor: the measured prefab's bottom on y 0 (centre ${gh.items[0].position[1].toFixed(3)}, lift ${lift})`); + + // the stick turns it: a flick right = -15° + await iwer.stick(page, pointer, 0.9, 0); + await settle(4); + await iwer.stick(page, pointer, 0, 0); + await settle(4); + const rot = (await g(() => window.__stores.placeDrag.state()))?.rotation; + h.check(rot === 345, `a stick flick right turns the ghost 15° clockwise (rotation ${rot})`); + const turnsDuring = await g(() => window.__stores.vrPlace.vrPlaceDebug().rotations); + h.check(turnsDuring === 1, `one flick is one step (${turnsDuring})`); + + // the TRIGGER places it (the session's own selectstart), ONE undo step, and it stays armed + const undo0 = await g(() => window.__stores.history.undoStack?.length ?? window.__stores.history.historyDepth?.() ?? null); + await iwer.tap(page, pointer, 'trigger'); + await h.eventually(count, (n) => n === n0 + 1, 'the trigger places the prefab through the replicated path', 6000); + const placed = await g(async () => { + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const o = group.children[group.children.length - 1]; + return { name: o.name, pos: o.position.toArray(), yaw: o.rotation.y }; + }); + h.check(placed.name === 'GhostCrate', `the placed object is the prefab (${placed.name})`); + h.check(near([placed.pos[0], placed.pos[2]], [gh.items[0].position[0], gh.items[0].position[2]], 0.05), `it lands at the ghost spot (${placed.pos.map((v) => v.toFixed(2))})`); + h.check(Math.abs(Math.abs(placed.yaw) - Math.PI / 12) < 0.01, `it lands turned as the ghost was (${((placed.yaw * 180) / Math.PI).toFixed(1)}°)`); + const still = await g(() => window.__stores.vrPlace.vrPlaceDebug()); + h.check(still.active && still.placed === 1, `still armed for the next one (${JSON.stringify(still)})`); + await g(() => window.__stores.history.undo()); + h.check((await count()) === n0, 'ONE undo removes the placement'); + if (undo0 != null) console.log('undo depth before', undo0); + + // MINIATURE: shrink the world rig to 1:10 and move it — the ghost draws in the shrunk frame and the + // object lands at the scene-unit spot under the laser + await g(() => { + let rig; + window.__stores.worldRig.subscribe((v) => (rig = v))(); + rig.scale.setScalar(0.1); + rig.position.set(0.1, 0.8, -0.6); + rig.updateMatrixWorld(true); + }); + await iwer.pose(page, pointer, [pointer === 'left' ? -0.15 : 0.15, 1.2, -0.3], { pitch: -0.9 }); + await settle(8); + const aimMini = await laserGround(); + const ghMini = await g(() => window.__stores.placeGhost.ghostInfo()); + const frameScale = ghMini.frame ? Math.hypot(ghMini.frame[0], ghMini.frame[1], ghMini.frame[2]) : 1; + h.check(Math.abs(frameScale - 0.1) < 1e-3, `in miniature the ghost draws in the 1:10 frame (scale ${frameScale.toFixed(3)})`); + h.check(!!aimMini && near([ghMini.items[0].position[0], ghMini.items[0].position[2]], [aimMini[0], aimMini[2]], 0.3), `the ghost sits under the laser in scene units (${ghMini.items[0]?.position.map((v) => v.toFixed(2))} vs ${aimMini?.map((v) => v.toFixed(2))})`); + await iwer.tap(page, pointer, 'trigger'); + await h.eventually(count, (n) => n === n0 + 1, 'the trigger places in miniature too', 6000); + const mini = await g(async () => { + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const o = group.children[group.children.length - 1]; + return { pos: o.position.toArray(), scale: o.scale.toArray() }; + }); + h.check(near([mini.pos[0], mini.pos[2]], [ghMini.items[0].position[0], ghMini.items[0].position[2]], 0.05), `the miniature placement lands at the ghost's scene-unit spot (${mini.pos.map((v) => v.toFixed(2))})`); + h.check(near(mini.scale, [1, 1, 1], 1e-6), 'and at full scene size (the rig, not the object, is small)'); + await g(() => { + let rig; + window.__stores.worldRig.subscribe((v) => (rig = v))(); + rig.scale.setScalar(1); + rig.position.set(0, 0, 0); + rig.updateMatrixWorld(true); + }); + + // a GRIP cancels: nothing spawns, the ghost goes + const before = await count(); + await iwer.tap(page, menuHand, 'squeeze'); + await settle(4); + const after = await g(() => ({ active: window.__stores.vrPlace.vrPlaceDebug().active, ghost: !!window.__stores.placeGhost.ghostRoot(), last: window.__stores.vrPlace.vrPlaceDebug().lastEnd })); + h.check(!after.active && !after.ghost && after.last === 'grip', `a grip cancels the ghost (${JSON.stringify(after)})`); + h.check((await count()) === before, 'and nothing spawned'); + h.check((await g(() => { let v; window.__stores.vrControls.vrPrefabGhost.subscribe((x) => (v = x))(); return v; })) === null, 'the prefab window is no longer armed'); + } + + await iwer.exit(page); + await h.finish(browser); +}); diff --git a/tests/e2e/vr-prefabs.test.cjs b/tests/e2e/vr-prefabs.test.cjs index c8c939cfa..9dc574758 100644 --- a/tests/e2e/vr-prefabs.test.cjs +++ b/tests/e2e/vr-prefabs.test.cjs @@ -87,7 +87,8 @@ h.run(async () => { }); h.check(pinned.on === true && pinned.off === false, 'pin toggles the world-fixed flag'); - // --- ghost: arm from a cell, translucent clone rides the scene root --- + // --- ghost: arm from a cell — 52-browser: the window's ghost is the SHARED laser ghost + // (placeDrag's ray mode, absorbs f-vr-place): a box / translucent model at the scene root --- const ghost = await A.page.evaluate(() => { const readStore = (store) => { let v; @@ -98,21 +99,27 @@ h.run(async () => { window.__stores.prefabs.prefabs.subscribe((x) => (list = x))(); window.__stores.vrControls.executeVRMenuAction('prefabs:select:' + list[0].id); const armed = readStore(window.__stores.vrControls.vrPrefabGhost); - let scene; - window.__stores.globalScene.subscribe((x) => (scene = x))(); - const clone = scene?.getObjectByName('vr-prefab-ghost'); + // aim the laser straight down at (2, 0, -3), as the pointer hand would + const T = window.__stores.THREE; + window.__vrPrefabRay = new T.Raycaster(new T.Vector3(2, 5, -3), new T.Vector3(0, -1, 0)); + window.__stores.placeDrag.updateRayPlace(window.__vrPrefabRay); + const root = window.__stores.placeGhost.ghostRoot(); let translucent = false; - clone?.traverse((o) => { - if (o.material?.transparent && o.material.opacity < 0.5) translucent = true; + root?.traverse((o) => { + if (o.material?.transparent && o.material.opacity < 0.6) translucent = true; }); - // aim the ghost somewhere specific, as the pointer ray would - clone?.position.set(2, 0, -3); - return { armed: armed?.name, hasClone: !!clone, translucent }; + const info = window.__stores.placeGhost.ghostInfo(); + return { armed: armed?.name, hasGhost: !!root && info.visible, translucent, mode: window.__stores.placeDrag.state()?.mode, at: info.items[0]?.position }; }); - h.check(ghost.armed === 'TestCrate' && ghost.hasClone, 'selecting a cell arms the placement ghost'); + h.check(ghost.armed === 'TestCrate' && ghost.hasGhost && ghost.mode === 'ray', `selecting a cell arms the shared laser ghost (${JSON.stringify(ghost)})`); h.check(ghost.translucent, 'the ghost renders translucent'); + h.check(Math.abs(ghost.at[0] - 2) < 1e-6 && Math.abs(ghost.at[2] + 3) < 1e-6, `the ghost rides the laser to the floor spot (${ghost.at})`); // --- placement: instantiates at the ghost spot, stays armed, undoable --- + const count = () => A.page.evaluate(() => new Promise((r) => window.__stores.objectsGroup.subscribe((g) => r(g.children.length))())); + const before = await count(); + const ok = await A.page.evaluate(() => window.__stores.vrControls.placePrefabGhost()); + await h.eventually(count, (n) => n === before + 1, 'trigger instantiates the prefab through the replicated path', 5000); const placed = await A.page.evaluate(async () => { const readStore = (store) => { let v; @@ -120,40 +127,21 @@ h.run(async () => { return v; }; const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); - const before = group.children.length; - const ok = window.__stores.vrControls.placePrefabGhost(); - const after = group.children.length; const child = group.children[group.children.length - 1]; const stillArmed = !!readStore(window.__stores.vrControls.vrPrefabGhost); + window.__stores.placeDrag.updateRayPlace(window.__vrPrefabRay); const again = window.__stores.vrControls.placePrefabGhost(); - const afterSecond = group.children.length; - window.__stores.history.undo(); - const afterUndo = group.children.length; - return { - ok, - before, - after, - afterSecond, - afterUndo, - again, - pos: [child.position.x, child.position.y, child.position.z], - name: child.name, - stillArmed - }; + return { pos: [child.position.x, child.position.y, child.position.z], name: child.name, stillArmed, again }; }); - h.check( - placed.ok && placed.after === placed.before + 1 && placed.name === 'TestCrate', - 'trigger instantiates the prefab through the replicated path' - ); + h.check(ok && placed.name === 'TestCrate', `the placed object is the prefab (${placed.name})`); h.check( Math.abs(placed.pos[0] - 2) < 1e-6 && Math.abs(placed.pos[2] + 3) < 1e-6, `the instance lands at the ghost spot (${placed.pos.map((v) => v.toFixed(1))})` ); - h.check( - placed.stillArmed && placed.again && placed.afterSecond === placed.after + 1, - 'placement stays armed for repeats' - ); - h.check(placed.afterUndo === placed.afterSecond - 1, 'placements are undoable'); + await h.eventually(count, (n) => n === before + 2, 'placement stays armed for repeats', 5000); + h.check(placed.stillArmed && placed.again, 'the ghost is still armed after a placement'); + await A.page.evaluate(() => window.__stores.history.undo()); + h.check((await count()) === before + 1, 'placements are undoable (one undo = one placement)'); // --- grip cancels; closing the window drops the ghost too --- const cancelled = await A.page.evaluate(() => { @@ -174,7 +162,7 @@ h.run(async () => { return { afterCancel, afterClose, - cloneGone: !scene?.getObjectByName('vr-prefab-ghost'), + cloneGone: !window.__stores.placeGhost.ghostRoot() && !window.__stores.placeDrag.placeDragActive(), panelClosed: readStore(window.__stores.vrPrefabsPanelOpen) === false }; }); From 87b1b80b5d9e17edd2367ed5090679f5582fcb46 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 00:06:54 +0300 Subject: [PATCH 04/29] [fix] 52 V1: the Measure ring's Done uses circle-check (log-out is not in the desktop icon map - iconLint) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/vr/buildRings.js | 2 +- src/lib/vr/iconData.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/vr/buildRings.js b/src/lib/vr/buildRings.js index 6351601b8..fe75e6961 100644 --- a/src/lib/vr/buildRings.js +++ b/src/lib/vr/buildRings.js @@ -84,7 +84,7 @@ export function registerMeasureRings(register) { id: 'measure:done', group: 'measure', label: 'Done', - icon: 'log-out', + icon: 'circle-check', order: 4, closes: true, action: () => void vrMeasure().then((m) => m.stopVRMeasure()) diff --git a/src/lib/vr/iconData.js b/src/lib/vr/iconData.js index 4b2c37f34..493ff5fb9 100644 --- a/src/lib/vr/iconData.js +++ b/src/lib/vr/iconData.js @@ -93,4 +93,5 @@ export const LUCIDE_NODES = { 'align-end-vertical': [["rect",{"width":"16","height":"6","x":"2","y":"4","rx":"2"}],["rect",{"width":"9","height":"6","x":"9","y":"14","rx":"2"}],["path",{"d":"M22 22V2"}]], // 52 V2: the VR Arrange ring 'align-horizontal-space-around': [["rect",{"width":"6","height":"10","x":"9","y":"7","rx":"2"}],["path",{"d":"M4 22V2"}],["path",{"d":"M20 22V2"}]], // 52 V2: the VR Arrange ring 'arrow-down-to-line': [["path",{"d":"M12 17V3"}],["path",{"d":"m6 11 6 6 6-6"}],["path",{"d":"M19 21H5"}]], // 52 V2: the VR Arrange ring + 'circle-check': [["circle",{"cx":"12","cy":"12","r":"10"}],["path",{"d":"m16 9-5.5 5.5L8 12"}]], // 52 V1: the Measure ring's Done }; From 22d9cca8793b7aa617732fe81823fb6d7c902672 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 00:12:33 +0300 Subject: [PATCH 05/29] [test] 52: vr-measure-iwer writes headset eye shots of the labels and the rings with SHOTS= (vrEyes) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/vr-measure-iwer.test.cjs | 36 +++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/e2e/vr-measure-iwer.test.cjs b/tests/e2e/vr-measure-iwer.test.cjs index 0d6df805f..5bf080ac3 100644 --- a/tests/e2e/vr-measure-iwer.test.cjs +++ b/tests/e2e/vr-measure-iwer.test.cjs @@ -15,9 +15,12 @@ // answer, on A and B, ONE undo restores the whole set on both // F frame cost on the Quest profile: the hover search + the label posing per frame, against 13.9 ms // A real-Quest row (feel, label legibility at the true eye resolution, haptics) goes on 52-int's checklist. -// Run: APP_URL=https://localhost:5481/ node tests/e2e/vr-measure-iwer.test.cjs (ONLY=M,A to pick rows) +// Run: APP_URL=https://localhost:5481/ node tests/e2e/vr-measure-iwer.test.cjs (ONLY=M,A to pick rows; +// SHOTS= also writes headset eye shots of the labels and the rings there — vrEyes.cjs) const h = require('./helpers.cjs'); const iwer = require('./iwer.cjs'); +const SHOTS = process.env.SHOTS || ''; +const eyes = SHOTS ? require('./vrEyes.cjs') : null; const ONLY = (process.env.ONLY || '').split(',').filter(Boolean); const want = (row) => !ONLY.length || ONLY.includes(row); @@ -81,6 +84,21 @@ h.run(async () => { }, uuids); // ---- the headset --------------------------------------------------------------------------------- + /** eye shots from where the XR camera stands (the labels face IT) — only with SHOTS set */ + const shot = async (name) => { + if (!eyes) return; + await settle(4); + const head = await g(() => { + let r; window.__stores.globalRenderer.subscribe((v) => (r = v))(); + const cam = r.xr.getCamera(); + cam.updateMatrixWorld(true); + const e = cam.matrixWorld.elements; + const f = [-e[8], -e[9], -e[10]]; + return { position: [e[12], e[13], e[14]], yaw: Math.atan2(-f[0], -f[2]), pitch: Math.asin(Math.max(-1, Math.min(1, f[1]))) }; + }); + const out = await eyes.save(page, SHOTS + '/' + name, { size: 768, head }); + console.log('shot', out.pair); + }; const st = await iwer.enter(page); h.check(st.presenting && st.same, 'premise: Play presents the IWER session'); const menuHand = await read('vrMenuHand'); @@ -224,6 +242,9 @@ h.run(async () => { h.check(labels.includes('2.000 m') && labels.includes('1.000 m'), `the segments read 2.000 m and 1.000 m (${labels.join(' | ')})`); h.check(labels.includes('90.0°'), 'the turn at the corner reads 90.0°'); h.check(labels.some((t) => t.startsWith('Total 3.000 m')), 'the running total reads Total 3.000 m'); + await iwer.pose(page, 'head', [0, 1.6, 0], { pitch: -0.45 }); + await shot('01-measure-chain-near'); + await iwer.pose(page, 'head', [0, 1.6, 0]); } // ---- L. the labels: troika text facing you, the same angle tall near and far ----------------------------- @@ -257,6 +278,8 @@ h.run(async () => { const seg2 = b.list.find((l) => l.text === '2.000 m'); h.check(!!seg2 && seg2.distance > seg.distance + 2 && Math.abs(seg2.height / seg2.distance - 0.03) < 1e-6, `stepping back, the label GROWS with its distance (${seg?.height.toFixed(3)} m → ${seg2?.height.toFixed(3)} m at ${seg2?.distance.toFixed(2)} m): the same size to the eye`); h.check(b.out.every((x) => x.dot > 0.999), '…and still faces the viewer'); + await iwer.pose(page, 'head', [0, 1.6, 2.5], { pitch: -0.25 }); + await shot('02-measure-labels-far'); await iwer.pose(page, 'head', [0, 1.6, 0]); await settle(6); } @@ -291,6 +314,11 @@ h.run(async () => { itemId = item?.uuid ?? null; h.check(!!item && item.points.length === 3 && near(item.points[0], FL) && near(item.points[2], BR), `the item holds the three snapped corners (${item?.points.map(fmt).join(' → ')})`); await h.eventually(() => items(B.page), (b) => b.some((x) => x.uuid === itemId && x.points.length === 3 && near(x.points[1], FR, 1e-4)), 'B holds the same measurement item (synced)', 15000); + if (SHOTS) { + await B.page.evaluate((c) => window.__stores.objectActions.flyTo([c[0] + 2.5, 3, c[2] + 3.5], c, 0), C); + await B.page.waitForTimeout(1200); + await B.page.screenshot({ path: SHOTS + '/03-peer-sees-the-item.png' }); + } } // ---- N. snap off: the same laser does NOT land on the corner (the counterfactual for M) -------------------- @@ -299,6 +327,9 @@ h.run(async () => { h.check(await openMenu(), 'the menu button opens the radial while measuring…'); const ring = await g(() => { let v; window.__stores.vrRadialMenu.activeRing.subscribe((x) => (v = x))(); return v; }); h.check(ring === 'measure', `…on the Measure ring (${ring})`); + if (SHOTS) await iwer.pose(page, 'head', [0, 1.6, 0], { pitch: -0.75, yaw: menuHand === 'right' ? -0.4 : 0.4 }); + await shot('04-measure-ring'); + if (SHOTS) await iwer.pose(page, 'head', [0, 1.6, 0]); h.check(await stickRun('measure:snap'), 'the stick picks Snap'); const snapOn = await g(() => { let v; window.__stores.measure.measureSnap.subscribe((x) => (v = x))(); return v; }); h.check(snapOn === false, 'Snap is off'); @@ -428,6 +459,9 @@ h.run(async () => { h.check(await openMenu(), 'the radial opens'); h.check(await stickRun(null), 'the hub opens Selected'); h.check(await stickRun('nav:arrange'), 'the stick picks Arrange ▸'); + if (SHOTS) await iwer.pose(page, 'head', [0, 1.6, 0], { pitch: -0.75, yaw: menuHand === 'right' ? -0.4 : 0.4 }); + await shot('05-arrange-ring'); + if (SHOTS) await iwer.pose(page, 'head', [0, 1.6, 0]); h.check(await stickRun('arrange:min'), '…Align X min'); const minX = START[0].pos[0] - 0.5; const test = (b) => b.every((q) => q && Math.abs(q.min[0] - minX) < 1e-6); From 282b46763e69364669c9797b835539a5d6d24b1e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 00:13:36 +0300 Subject: [PATCH 06/29] [feat] 52-browser P2: the Asset Browser on the VR wrist (packs, free CC0, your library) - vr/assetBrowser.js + assetBrowserDraw.js: one canvas on one plane (one draw call) on the OFF hand's wrist, facing the head; Add > Assets toggles it; steps aside while the radial is open on that hand; one-handed it opens pinned in front - tabs Packs (drill into a pack) / Free CC0 (Poly Haven models: a card downloads into the Library through the Explorer import, the card shows the progress, then it rides the laser) / My library (models + prefabs); search via the VR keyboard, filtering as you type; pages by the buttons or the stick while the laser is on the panel - a trigger-DRAG from a card drops the item where you let go (once); a CLICK arms it (the next trigger places, stays armed); the status line says what the trigger / grip do now - a GRIP lifts the panel off the wrist: let go in the air = pinned 1.6x, near the wrist = back on it; the Pin / To wrist button does both - a trigger on the panel is the panel's: never a select of what is behind it, never a placement (also under IWER's select-before-selectstart order) - suite vr-asset-browser-iwer row B (55 checks with G), evidence panel shots Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 2 + src/components/play/VRAssetBrowser.svelte | 9 + src/lib/debugHooks.js | 1 + src/lib/vr/assetBrowser.js | 825 ++++++++++++++++++++++ src/lib/vr/assetBrowserDraw.js | 265 +++++++ src/lib/vr/placeRay.js | 9 +- tests/e2e/vr-asset-browser-iwer.test.cjs | 220 ++++++ 7 files changed, 1327 insertions(+), 4 deletions(-) create mode 100644 src/components/play/VRAssetBrowser.svelte create mode 100644 src/lib/vr/assetBrowser.js create mode 100644 src/lib/vr/assetBrowserDraw.js diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index aa4f3702d..49eb317d1 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -102,6 +102,7 @@ import VRSelectionShell from './play/VRSelectionShell.svelte'; // 36 U3b: the VR welcome tour's world-space panel import TourVRPanel from './tours/TourVRPanel.svelte'; + import VRAssetBrowser from './play/VRAssetBrowser.svelte'; // 52-browser: the wrist Asset Browser // 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'; @@ -1874,6 +1875,7 @@ position={[0, 2, 3]} + + // 52-browser (V4): the wrist Asset Browser's lifetime ($lib/vr/assetBrowser.js). No markup: the panel is + // one canvas-textured plane the module adds to the scene while it is open in a session; this component + // only mounts its hooks and the radial entry (Add ▸ Assets), and undoes them on unmount. + import { onMount } from 'svelte' + import { mountVRAssetBrowser } from '$lib/vr/assetBrowser.js' + + onMount(() => mountVRAssetBrowser()) + diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 0e4bd8b05..9933b0356 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -45,6 +45,7 @@ export const DEBUG_HOOKS = [ ['packCache', () => import('./packCache.js')], ['placeGhost', () => import('./placeGhost.js')], ['vrPlace', () => import('./vr/placeRay.js')], + ['vrAssetBrowser', () => import('./vr/assetBrowser.js')], ['tourVR', () => import('./tours/vrPanel.js')], // 36-share (B13): the recorder + its stores ['recording', () => import('./recording/recorder.js')], diff --git a/src/lib/vr/assetBrowser.js b/src/lib/vr/assetBrowser.js new file mode 100644 index 000000000..287908d0f --- /dev/null +++ b/src/lib/vr/assetBrowser.js @@ -0,0 +1,825 @@ +// VR controls — 52-browser (V4): THE ASSET BROWSER ON THE WRIST. The desktop's ways to get things into a +// scene — the packs, the free CC0 models (47-cc0), your own library + prefabs — as one panel on the +// OFF-HAND wrist (the menu hand; the other hand points). A grip lifts it off the wrist and lets go of it in +// the world, PINNED and bigger (1.6x); a grip on the pinned panel carries it again, and letting go near +// the wrist puts it back there (the Pin / To wrist button does both in one press). Search goes through the +// VR keyboard (filtering as you type). Picking a card puts the drag-to-place ghost on the laser +// (vr/placeRay.js): a trigger-DRAG out of the panel drops it where you let go, a click arms it for the next +// trigger, the stick turns it, a grip cancels. A free CC0 model downloads first (the card shows the +// progress), lands in your Library through the Explorer's own import, and then rides the laser. +// +// Hands (light hand tracking): a pinch IS the trigger — pick, pinch-drag out, release to drop — because the +// panel reads the session's selectstart/selectend, which a pinch fires. +// +// The panel is ONE canvas on ONE plane (assetBrowserDraw.js): one draw call, redrawn only when what it shows +// changes. It is an overlay panel (drawn over the scene, the laser ends on it), and a trigger that lands on +// it is the panel's — never a placement, never a select of whatever stands behind it. +// +// Data modules are PRIMED dynamic imports (the aiPanel rule): packs / explorer / the CC0 importer reach the +// history family, and a static edge from vr/ closes a loop a green build can still TDZ-crash in dev. +// +// LOCAL: which tab, the search, wrist-or-pinned — a fact about this headset, never replicated or saved +// (the pinned pose is not remembered across sessions either: a room you stood in yesterday is not this one). +import * as THREE from 'three'; +import { get, writable } from 'svelte/store'; +import { globalScene, globalRenderer, isVRMode, vrMenuOpen, vrMenuHand } from '../../stores/sceneStore'; +import { prefabs, loadPrefabs } from '../prefabs'; +import { openVRKeyboard, vrKeyboardTarget } from '../vrKeyboard'; +import { registerVRMenuEntry, unregisterVRMenuEntry } from '../vrRadialMenu'; +import { assetBrowserRes } from '../cc0/assetBrowserState.js'; +import { registerVRFrameHook, registerVRTriggerHooks, registerGripHooks, registerStickOwner } from './hooks.js'; +import { registerOverlayPanel } from './pointer.js'; +import { controllerIndexFor, axesForSlot } from './input.js'; +import { pointerHand, oneHandedHand } from './bindings.js'; +import { hapticPulse } from './haptics.js'; +import { beginVRPlace, cancelVRPlace, vrPlacing, rayPlace } from './placeRay.js'; +import { drawAssetPanel, hitAtUv, PANEL_PX, PAGE_SIZE } from './assetBrowserDraw.js'; + +/** the radial entry: Add ▸ Assets */ +export const RADIAL_ENTRY = { id: 'assets', group: 'add', order: 6.5 }; +/** the panel in metres on the wrist; pinned it is PIN_SCALE times that */ +export const PANEL_M = { w: 0.2, h: (0.2 * PANEL_PX.h) / PANEL_PX.w }; +export const PIN_SCALE = 1.6; +/** where the panel rides, in the wrist hand's GRIP space: above the back of the hand, toward the elbow */ +const WRIST_OFFSET = new THREE.Vector3(0, 0.085, 0.05); +/** a grip letting go of the panel within this of the wrist spot puts it back on the wrist */ +const RETURN_RANGE = 0.2; +/** a grip whose hand is this close to the panel takes it, even without the laser on it */ +const TOUCH_RANGE = 0.12; +const STICK_FLICK = 0.6; +const STICK_REARM = 0.3; +const MAX_FREE_ROWS = 400; + +/** the browser is up (the radial entry toggles it) */ +export const vrAssetBrowserOpen = writable(false); + +/** @type {any} */ let packsRef = null; +/** @type {any} */ let explorerRef = null; +/** @type {any} */ let cc0FetchRef = null; +/** @type {any} */ let cc0CoreRef = null; +/** @type {any} */ let cc0ImportRef = null; +const primed = Promise.all([ + import('../packs').then((m) => (packsRef = m)), + import('../explorer').then((m) => (explorerRef = m)), + import('../cc0/cc0Fetch.js').then((m) => (cc0FetchRef = m)), + import('../cc0/cc0Core.js').then((m) => (cc0CoreRef = m)), + import('../cc0/cc0Import.js').then((m) => (cc0ImportRef = m)) +]).catch(() => {}); + +/** @type {{tab: 'packs' | 'free' | 'library', pack: any, query: string, page: number}} */ +const view = { tab: 'packs', pack: null, query: '', page: 0 }; +/** @type {'wrist' | 'pinned' | 'held'} */ +let mode = 'wrist'; +/** what loaded: the pack items per pack name, the CC0 model rows, and a revision the redraw keys on */ +const data = { + /** @type {Record} */ packItems: {}, + /** @type {any[] | null} */ free: null, + freeError: '', + loading: false, + rev: 0 +}; +/** a one-line status (download progress, a refusal, a hint) */ +let status = { text: '', tone: /** @type {'info' | 'warn' | 'bad'} */ ('info'), until: 0 }; +/** downloads in flight: row key -> {done, total} (mirrors cc0Jobs) @type {Record} */ +let jobs = {}; +/** the card that is on the laser now @type {string | null} */ +let placingKey = null; + +/** @type {any} */ let mesh = null; +/** @type {HTMLCanvasElement | null} */ let canvas = null; +/** @type {any} */ let texture = null; +/** @type {import('./assetBrowserDraw.js').PanelHit[]} */ let hits = []; +/** the button each controller slot's laser is on @type {(string | null)[]} */ +const hover = [null, null]; +/** per slot: the stick is armed for the next paging flick */ +const stickArmed = [true, true]; +let drawnSig = ''; +/** the pinned / held pose (world) */ +const pinned = { position: new THREE.Vector3(), quaternion: new THREE.Quaternion() }; +/** a grip carrying the panel: slot + the panel's pose relative to that controller @type {any} */ +let held = null; +/** the trailing 'select' of a press the panel took (a flag consumed once; see placeRay) */ +let swallowNext = false; +let swallowSetAt = 0; +let mounted = false; +/** the suites' view */ +const debug = { draws: 0, presses: /** @type {string[]} */ ([]), lastPress: /** @type {string | null} */ (null) }; + +// ---- images (thumbnails): one loaded per url, a repaint when it arrives ------------------------ +/** @type {Map} */ +const images = new Map(); +let imagesLoaded = 0; +/** @param {string} url @returns {HTMLImageElement | null} the image once loaded */ +function imageFor(url) { + if (!url || typeof Image === 'undefined') return null; + let img = images.get(url); + if (!img) { + img = new Image(); + // a cross-origin image without CORS would TAINT the canvas, and a tainted canvas cannot be + // uploaded as a texture at all — ask for CORS (jsDelivr and Poly Haven's CDN answer it) + if (!url.startsWith('data:') && !url.startsWith('blob:')) img.crossOrigin = 'anonymous'; + img.onload = () => { + imagesLoaded++; + }; + img.onerror = () => { + /** @type {any} */ (img).failed = true; + imagesLoaded++; + }; + img.src = url; + images.set(url, img); + if (images.size > 160) images.delete(/** @type {string} */ (images.keys().next().value)); + } + return img.complete && img.naturalWidth > 0 ? img : null; +} + +// ---- content --------------------------------------------------------------------------------------------- +/** @param {string} label */ +const matches = (label) => { + const words = view.query.toLowerCase().split(/\s+/).filter(Boolean); + if (!words.length) return true; + const hay = String(label ?? '').toLowerCase(); + return words.every((w) => hay.includes(w)); +}; +/** the library payload of an Explorer record (the Explorer's own drag shape) @param {any} item */ +function libraryPayload(item) { + return { id: item.id, kind: item.kind, name: item.name, prefabId: null, url: null, ...(item.dims ? { dims: item.dims } : {}) }; +} + +/** + * Every entry the current tab lists (before paging): {key, label, sub, thumb, badge, progress, folder, act}. + * @returns {any[]} + */ +function entries() { + if (view.tab === 'packs') { + if (!view.pack) { + const list = packsRef ? get(packsRef.packs) : []; + return list + .filter((/** @type {any} */ p) => matches(p.title || p.name)) + .map((/** @type {any} */ p) => ({ + key: 'pack:' + p.name, + label: p.title || p.name, + sub: p.source === 'imported' ? 'Your pack' : p.listUrl ? 'Pack' : 'Install on desktop', + folder: true, + act: { type: 'pack', pack: p } + })); + } + const items = data.packItems[view.pack.name] ?? []; + return items + .filter((/** @type {any} */ it) => matches(it.label || it.name)) + .map((/** @type {any} */ it) => ({ + key: 'item:' + view.pack.name + '/' + it.name, + label: it.label || it.name, + sub: it.dims?.known && it.dims.size ? it.dims.size.map((/** @type {number} */ v) => v.toFixed(1)).join('×') + ' m' : '', + thumb: it.resolvedThumb || it.thumbs?.[0] || null, + badge: it.behavior ? '▶' : '', + act: { + type: 'place', + payload: it.imported + ? libraryPayload(it) + : { + id: null, + kind: 'object', + name: it.name, + prefabId: null, + url: it.glbUrl, + ...(it.lods ? { lods: it.lods } : {}), + ...(it.behavior ? { behavior: it.behavior } : {}), + ...(it.dims ? { dims: it.dims } : {}), + packName: it.packName + } + } + })); + } + if (view.tab === 'free') { + const rows = data.free ?? []; + const filtered = cc0CoreRef ? cc0CoreRef.filterRows(rows, { query: view.query }) : rows; + return filtered.slice(0, MAX_FREE_ROWS).map((/** @type {any} */ row) => { + const key = 'cc0:' + row.id; + const held = cc0ImportRef?.heldItemFor(row); + const job = jobs[cc0ImportRef?.rowKey(row) ?? '']; + return { + key, + label: row.name, + sub: 'Poly Haven · CC0', + thumb: row.thumb, + badge: held ? '✓' : '↓', + progress: job ? (job.total ? job.done / job.total : 0) : null, + act: { type: 'cc0', row } + }; + }); + } + // library: your models, newest first, then your prefabs + const items = explorerRef ? get(explorerRef.explorerItems) : []; + const models = items + .filter((/** @type {any} */ it) => it.kind === 'object' && matches(it.name)) + .sort((/** @type {any} */ a, /** @type {any} */ b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)) + .map((/** @type {any} */ it) => ({ + key: 'lib:' + it.id, + label: String(it.name).replace(/\.\w+$/, ''), + sub: it.source?.site === 'polyhaven' ? 'Poly Haven' : 'Model', + thumb: it.thumbnail || null, + act: { type: 'place', payload: libraryPayload(it) } + })); + const prefabCards = get(prefabs) + .filter((/** @type {any} */ p) => matches(p.name)) + .map((/** @type {any} */ p) => ({ + key: 'prefab:' + p.id, + label: p.name, + sub: 'Prefab', + thumb: p.thumbnail || null, + act: { type: 'place', payload: { prefabId: p.id, kind: 'prefab', name: p.name } } + })); + return [...models, ...prefabCards]; +} + +function emptyText() { + if (view.tab === 'free') return data.freeError || (data.free ? 'Nothing matches' : 'Loading…'); + if (view.tab === 'library') return view.query ? 'Nothing matches' : 'Your library has no models yet'; + return view.query ? 'Nothing matches' : view.pack ? 'This pack lists no models' : 'No packs'; +} + +/** @returns {import('./assetBrowserDraw.js').PanelView} */ +function currentView() { + const all = entries(); + const pages = Math.max(1, Math.ceil(all.length / PAGE_SIZE)); + if (view.page >= pages) view.page = pages - 1; + if (view.page < 0) view.page = 0; + const slice = all.slice(view.page * PAGE_SIZE, view.page * PAGE_SIZE + PAGE_SIZE); + const placing = get(vrPlacing); + let text = status.text && Date.now() < status.until ? status.text : ''; + let tone = status.tone; + if (!text && placing) { + const handSource = sourceIsHand(placing.hand); + text = handSource + ? placing.armed + ? `${placing.label}: pinch to place · grip to stop` + : `${placing.label}: let go over the world to drop it` + : placing.armed + ? `${placing.label}: trigger places · stick turns · grip stops` + : `${placing.label}: let go of the trigger over the world`; + tone = 'info'; + } + return { + tab: view.tab, + crumb: view.tab === 'packs' && view.pack ? view.pack.title || view.pack.name : null, + query: view.query, + cards: slice.map((e) => ({ key: e.key, label: e.label, sub: e.sub, thumb: e.thumb, badge: e.badge, progress: e.progress ?? null, folder: e.folder, placing: !!placing && e.key === placingKey })), + page: view.page, + pages, + total: all.length, + status: text, + statusTone: tone, + loading: data.loading, + pinned: mode !== 'wrist', + empty: emptyText() + }; +} + +/** @param {string} text @param {'info' | 'warn' | 'bad'} [tone] @param {number} [ms] */ +function say(text, tone = 'info', ms = 4000) { + status = { text, tone, until: Date.now() + ms }; +} + +// ---- loading ----------------------------------------------------------------------------------------------- +async function loadTab() { + await primed; + if (view.tab === 'packs') { + if (!get(packsRef.packs).length) { + data.loading = true; + try { + await packsRef.loadPacks(); + } finally { + data.loading = false; + } + } + if (view.pack && !data.packItems[view.pack.name]) { + data.loading = true; + const pack = view.pack; + try { + data.packItems[pack.name] = await packsRef.listPackItems(pack); + } finally { + data.loading = false; + } + } + } else if (view.tab === 'free') { + if (!data.free) { + data.loading = true; + try { + const list = await cc0FetchRef.phList('models'); + data.free = cc0CoreRef.normalizePolyHaven(list.data, 'models'); + data.freeError = ''; + } catch (err) { + data.free = []; + data.freeError = cc0ImportRef?.errorText?.(err) ?? 'The free models could not load'; + } finally { + data.loading = false; + } + } + } else { + await explorerRef.loadExplorer?.(); + void loadPrefabs?.(); + } + data.rev++; +} + +// ---- actions ------------------------------------------------------------------------------------------- +/** what a hand is: a tracked hand (no controller) @param {string | null | undefined} hand */ +function sourceIsHand(hand) { + const r = /** @type {any} */ (get(globalRenderer)); + const session = r?.xr?.getSession?.(); + if (!session || !hand) return false; + for (const s of session.inputSources) if (s.handedness === hand) return !!s.hand; + return false; +} + +/** the handedness of controller slot `index` @param {number} index @returns {'left' | 'right'} */ +function handOfSlot(index) { + const r = /** @type {any} */ (get(globalRenderer)); + const h = r?.xr?.getController(index)?.userData?.handedness; + return h === 'left' || h === 'right' ? h : pointerHand(); +} + +/** + * Press a panel control. `index` is the controller slot whose trigger (or pinch) pressed — a card it picks + * rides THAT hand's laser, with the trigger still down (a drag). + * @param {string} id @param {number} [index] + */ +export function pressAssetButton(id, index = -1) { + debug.presses.push(id); + debug.lastPress = id; + const hand = index >= 0 ? handOfSlot(index) : pointerHand(); + if (id === 'close') return closeVRAssetBrowser(); + if (id === 'pin') return mode === 'wrist' ? pinInFront() : returnToWrist(); + if (id.startsWith('tab:')) { + const tab = /** @type {any} */ (id.slice(4)); + if (tab !== view.tab) { + view.tab = tab; + view.page = 0; + view.query = ''; + view.pack = null; + void loadTab(); + } + return; + } + if (id === 'back') { + view.pack = null; + view.page = 0; + view.query = ''; + data.rev++; + return; + } + if (id === 'clear') { + view.query = ''; + view.page = 0; + data.rev++; + return; + } + if (id === 'search') return openSearch(); + if (id.startsWith('page:')) { + view.page += Number(id.slice(5)) || 0; + data.rev++; + return; + } + if (id.startsWith('card:')) { + const key = id.slice(5); + const entry = entries().find((e) => e.key === key); + if (!entry) return; + return pickEntry(entry, hand, index >= 0); + } +} + +/** @param {any} entry @param {'left' | 'right'} hand @param {boolean} dragging */ +function pickEntry(entry, hand, dragging) { + const act = entry.act; + if (act.type === 'pack') { + if (!act.pack.listUrl && act.pack.source !== 'imported') { + say('This pack installs from the Explorer on the desktop', 'warn'); + return; + } + view.pack = act.pack; + view.page = 0; + view.query = ''; + void loadTab(); + return; + } + if (act.type === 'place') return placeEntry(entry.key, act.payload, hand, dragging); + if (act.type === 'cc0') { + const heldItem = cc0ImportRef?.heldItemFor(act.row); + if (heldItem) return placeEntry(entry.key, libraryPayload(heldItem), hand, dragging); + return downloadThenPlace(entry.key, act.row, hand); + } +} + +/** @param {string} key @param {any} payload @param {'left' | 'right'} hand @param {boolean} dragging */ +function placeEntry(key, payload, hand, dragging) { + placingKey = key; + const ok = beginVRPlace(payload, { + hand, + dragging, + repeat: true, + onEnd: () => { + if (placingKey === key) placingKey = null; + } + }); + if (!ok) placingKey = null; +} + +/** a free model: download into the Library (the card shows it), then onto the laser @param {string} key @param {any} row @param {'left' | 'right'} hand */ +async function downloadThenPlace(key, row, hand) { + await primed; + if (jobs[cc0ImportRef.rowKey(row)]) return; // already on its way + say(`Downloading ${row.name}…`, 'info', 60000); + try { + const { item } = await cc0ImportRef.importCc0(row, get(assetBrowserRes)); + say(`${row.name} is in your Library`, 'info', 3000); + // only if nothing else was picked meanwhile and the browser is still up + if (item && get(vrAssetBrowserOpen) && !get(vrPlacing)) placeEntry(key, libraryPayload(item), hand, false); + } catch (err) { + say(cc0ImportRef.errorText(err), 'bad', 6000); + } + data.rev++; +} + +function openSearch() { + openVRKeyboard({ + title: view.tab === 'free' ? 'Search free models' : view.tab === 'library' ? 'Search your library' : view.pack ? `Search ${view.pack.title || view.pack.name}` : 'Search packs', + initial: view.query, + onInput: (/** @type {string} */ text) => { + view.query = text; + view.page = 0; + data.rev++; + }, + onCommit: (/** @type {string} */ text) => { + view.query = text.trim(); + view.page = 0; + data.rev++; + }, + onCancel: () => {} + }); +} + +// ---- open / close / pose --------------------------------------------------------------------------- +/** Open the browser (on the wrist, or pinned in front when one-handed). */ +export function openVRAssetBrowser() { + ensureMesh(); + if (oneHandedHand()) { + mode = 'wrist'; + pinInFront(); + } + vrAssetBrowserOpen.set(true); + void loadTab(); +} +export function closeVRAssetBrowser() { + vrAssetBrowserOpen.set(false); + if (placingKey) cancelVRPlace('closed'); + held = null; +} +export function toggleVRAssetBrowser() { + if (get(vrAssetBrowserOpen)) closeVRAssetBrowser(); + else openVRAssetBrowser(); +} + +function ensureMesh() { + if (mesh || typeof document === 'undefined') return mesh; + canvas = document.createElement('canvas'); + canvas.width = PANEL_PX.w; + canvas.height = PANEL_PX.h; + texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + mesh = new THREE.Mesh( + new THREE.PlaneGeometry(PANEL_M.w, PANEL_M.h), + new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide, depthWrite: false }) + ); + mesh.name = 'vr-asset-browser'; + mesh.visible = false; + return mesh; +} + +/** the off hand: the menu hand (the other one points); null when one-handed @returns {'left' | 'right' | null} */ +export function wristHand() { + if (oneHandedHand()) return null; + return pointerHand() === 'left' ? 'right' : 'left'; +} + +/** the headset camera's world position, or null */ +function headPosition() { + const r = /** @type {any} */ (get(globalRenderer)); + if (!r?.xr?.isPresenting) return null; + const cam = r.xr.getCamera(); + cam.updateMatrixWorld?.(true); + return cam.getWorldPosition(new THREE.Vector3()); +} + +/** the wrist spot (world) of the off hand, or null when it is not tracked */ +function wristSpot() { + const hand = wristHand(); + const r = /** @type {any} */ (get(globalRenderer)); + if (!hand || !r?.xr) return null; + const index = controllerIndexFor(hand); + if (index < 0) return null; + const grip = r.xr.getControllerGrip(index); + if (!grip || grip.visible === false) return null; + grip.updateMatrixWorld(true); + return grip.localToWorld(WRIST_OFFSET.clone()); +} + +/** face `object` toward the head, upright @param {any} object @param {any} head */ +function faceHead(object, head) { + if (!head) return; + const target = new THREE.Vector3(head.x, object.position.y + (head.y - object.position.y) * 0.6, head.z); + object.lookAt(target); +} + +/** Pin the panel in the world in front of the head, bigger. */ +function pinInFront() { + const head = headPosition(); + const r = /** @type {any} */ (get(globalRenderer)); + if (!head || !mesh) { + mode = 'pinned'; + return; + } + const cam = r.xr.getCamera(); + const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(cam.getWorldQuaternion(new THREE.Quaternion())); + fwd.y = 0; + if (fwd.lengthSq() < 1e-6) fwd.set(0, 0, -1); + fwd.normalize(); + mesh.position.copy(head).addScaledVector(fwd, 0.65); + mesh.position.y -= 0.15; + faceHead(mesh, head); + pinned.position.copy(mesh.position); + pinned.quaternion.copy(mesh.quaternion); + mode = 'pinned'; + hapticPulse(0.25, 30); +} + +/** Back to the wrist (one-handed: nowhere to go — it stays pinned). */ +function returnToWrist() { + if (!wristHand()) { + say('One-handed: the panel stays in the world', 'warn'); + return; + } + mode = 'wrist'; + hapticPulse(0.25, 30); +} + +/** world pose of controller slot `index` @param {number} index */ +function controllerPose(index) { + const r = /** @type {any} */ (get(globalRenderer)); + const c = r?.xr?.getController(index); + if (!c) return null; + c.updateMatrixWorld(true); + return { position: c.getWorldPosition(new THREE.Vector3()), quaternion: c.getWorldQuaternion(new THREE.Quaternion()) }; +} + +/** pose the mesh for this frame (wrist / pinned / held) */ +function pose() { + const head = headPosition(); + if (mode === 'held' && held) { + const p = controllerPose(held.index); + if (p) { + mesh.position.copy(held.relPos).applyQuaternion(p.quaternion).add(p.position); + mesh.quaternion.copy(p.quaternion).multiply(held.relQuat); + const s = mesh.scale.x + (PIN_SCALE - mesh.scale.x) * 0.25; + mesh.scale.setScalar(s); + } + return true; + } + if (mode === 'pinned') { + mesh.position.copy(pinned.position); + mesh.quaternion.copy(pinned.quaternion); + mesh.scale.setScalar(PIN_SCALE); + return true; + } + const spot = wristSpot(); + if (!spot) return false; + mesh.position.copy(spot); + mesh.scale.setScalar(1); + faceHead(mesh, head); + return true; +} + +// ---- the laser --------------------------------------------------------------------------------------- +const _ray = new THREE.Raycaster(); +/** the panel hit of controller slot `index`'s laser, or null @param {number} index */ +function laserHit(index) { + if (!mesh?.visible || index < 0) return null; + const r = /** @type {any} */ (get(globalRenderer)); + const c = r?.xr?.getController(index); + if (!c) return null; + c.updateMatrixWorld(true); + const m = new THREE.Matrix4().extractRotation(c.matrixWorld); + _ray.ray.origin.setFromMatrixPosition(c.matrixWorld); + _ray.ray.direction.set(0, 0, -1).applyMatrix4(m); + _ray.far = 4; + return _ray.intersectObject(mesh, false)[0] ?? null; +} +/** the control under slot `index`'s laser @param {number} index */ +function buttonAt(index) { + const hit = laserHit(index); + return hit?.uv ? hitAtUv(hits, hit.uv) : null; +} +/** is slot `index` the wrist hand while the panel rides it? (it cannot point at its own wrist) @param {number} index */ +function isWristSlot(index) { + return mode === 'wrist' && index === controllerIndexFor(wristHand() ?? ''); +} + +// ---- per frame ----------------------------------------------------------------------------------------- +function redraw() { + const v = currentView(); + const sig = JSON.stringify([v, hover[0], hover[1], imagesLoaded, data.rev, Object.keys(jobs).length]); + if (sig === drawnSig) return; + drawnSig = sig; + const g = /** @type {CanvasRenderingContext2D} */ (canvas?.getContext('2d')); + if (!g) return; + hits = drawAssetPanel(g, v, { images: (url) => imageFor(url), hover: hover[0] ?? hover[1] }); + texture.needsUpdate = true; + debug.draws++; +} + +function frame() { + if (!mesh) return; + const r = /** @type {any} */ (get(globalRenderer)); + const live = get(vrAssetBrowserOpen) && !!r?.xr?.isPresenting && get(isVRMode); + if (!live) { + mesh.visible = false; + held = null; + if (mode === 'held') mode = 'pinned'; + return; + } + const scene = /** @type {any} */ (get(globalScene)); + if (scene && mesh.parent !== scene) scene.add(mesh); + // the radial owns the wrist hand while it is open there: the panel steps aside + const radialOnWrist = mode === 'wrist' && get(vrMenuOpen) && get(vrMenuHand) === wristHand(); + mesh.visible = pose() && !radialOnWrist; + mesh.updateMatrixWorld(true); + jobs = cc0ImportRef ? get(cc0ImportRef.cc0Jobs) : {}; + for (const i of [0, 1]) { + hover[i] = isWristSlot(i) ? null : (buttonAt(i)?.id ?? null); + // the laser on the panel: that hand's stick pages (up = back, down = forward) + const onPanel = !isWristSlot(i) && !!laserHit(i); + const y = onPanel ? (axesForSlot(i)[3] ?? 0) : 0; + if (stickArmed[i] && Math.abs(y) > STICK_FLICK) { + stickArmed[i] = false; + view.page += y > 0 ? 1 : -1; + data.rev++; + hapticPulse(0.1, 12, handOfSlot(i)); + } else if (!stickArmed[i] && Math.abs(y) < STICK_REARM) stickArmed[i] = true; + } + redraw(); +} + +/** which slots' lasers are on the panel (their sticks page instead of turning) */ +function stickHand() { + for (const i of [0, 1]) if (mesh?.visible && !isWristSlot(i) && laserHit(i)) return handOfSlot(i); + return null; +} + +// ---- trigger + grip ---------------------------------------------------------------------------------- +/** the keyboard or the radial is up over everything: the panel stands aside for its presses */ +function panelBusy() { + return !!get(vrKeyboardTarget) || get(vrMenuOpen); +} + +/** @param {number} index */ +function onTriggerStart(index) { + if (!mesh?.visible || panelBusy() || isWristSlot(index)) return false; + const hit = laserHit(index); + if (!hit) return false; + // the panel's press: never a select of what stands behind it, never a placement + swallowNext = true; + swallowSetAt = Date.now(); + const button = hit.uv ? hitAtUv(hits, hit.uv) : null; + if (button) { + pressAssetButton(button.id, index); + hapticPulse(0.2, 20, handOfSlot(index)); + } + return true; +} + +/** @param {number} index */ +function onGripStart(index) { + if (!mesh?.visible || held || isWristSlot(index)) return false; + const p = controllerPose(index); + if (!p) return false; + const near = p.position.distanceTo(mesh.getWorldPosition(new THREE.Vector3())) < TOUCH_RANGE * Math.max(1, mesh.scale.x); + if (!near && !laserHit(index)) return false; + const inv = p.quaternion.clone().invert(); + held = { + index, + relPos: mesh.position.clone().sub(p.position).applyQuaternion(inv), + relQuat: inv.multiply(mesh.quaternion.clone()) + }; + mode = 'held'; + hapticPulse(0.3, 30, handOfSlot(index)); + return true; +} + +/** @param {number} index */ +function onGripEnd(index) { + if (!held || held.index !== index) return false; + held = null; + const spot = wristSpot(); + if (spot && mesh.position.distanceTo(spot) < RETURN_RANGE) { + mode = 'wrist'; + } else { + pinned.position.copy(mesh.position); + pinned.quaternion.copy(mesh.quaternion); + mode = 'pinned'; + } + hapticPulse(0.25, 30, handOfSlot(index)); + return true; +} + +// ---- mount ------------------------------------------------------------------------------------------- +/** Mount the panel + its hooks + the radial entry; returns the teardown (VRAssetBrowser.svelte). */ +export function mountVRAssetBrowser() { + if (mounted) return () => {}; + mounted = true; + const panel = ensureMesh(); + if (!panel) return () => {}; + const undo = [ + registerOverlayPanel(panel), + registerVRFrameHook(frame), + registerVRTriggerHooks({ + start: onTriggerStart, + end: () => { + swallowNext = false; // the press is over (a headset's select came just before this) + return false; + }, + // the panel's press — and, whatever the runtime's event order (IWER fires select BEFORE + // selectstart), any select whose laser is on the panel: never a select of what is behind it + swallow: (/** @type {number} */ index) => + (swallowNext && Date.now() - swallowSetAt < 5000) || (!panelBusy() && index >= 0 && !isWristSlot(index) && !!laserHit(index)) + }), + registerGripHooks({ start: onGripStart, end: onGripEnd }), + registerStickOwner(stickHand), + // a refreshed library / prefab list / pack list repaints the cards + prefabs.subscribe(() => (data.rev++)), + vrPlacing.subscribe(() => (data.rev++)), + rayPlace.subscribe(() => {}) + ]; + primed.then(() => { + if (!mounted) return; + undo.push(packsRef.packs.subscribe(() => data.rev++)); + undo.push(explorerRef.explorerItems.subscribe(() => data.rev++)); + }); + registerVRMenuEntry({ + ...RADIAL_ENTRY, + label: 'Assets', + icon: 'package', + closes: true, + active: () => get(vrAssetBrowserOpen), + action: () => toggleVRAssetBrowser() + }); + undo.push(() => unregisterVRMenuEntry(RADIAL_ENTRY.id, RADIAL_ENTRY.group)); + return () => { + mounted = false; + for (const fn of undo) fn(); + closeVRAssetBrowser(); + panel.parent?.remove(panel); + panel.visible = false; + }; +} + +// ---- the suites ----------------------------------------------------------------------------------------- +/** the panel as a PNG data URL (the suites' evidence shots) */ +export function assetPanelImage() { + return canvas ? canvas.toDataURL('image/png') : null; +} +/** world point of a control's centre (for aiming a controller at it), or null @param {string} id */ +export function assetControlPoint(id) { + const r = hits.find((h) => h.id === id); + if (!r || !mesh) return null; + mesh.updateMatrixWorld(true); + const u = (r.x + r.w / 2) / PANEL_PX.w; + const v = (r.y + r.h / 2) / PANEL_PX.h; + return mesh.localToWorld(new THREE.Vector3((u - 0.5) * PANEL_M.w, (0.5 - v) * PANEL_M.h, 0)).toArray(); +} +export function vrAssetBrowserDebug() { + const v = currentView(); + return { + open: get(vrAssetBrowserOpen), + visible: !!mesh?.visible, + mode, + wristHand: wristHand(), + tab: view.tab, + pack: view.pack?.name ?? null, + query: view.query, + page: view.page, + pages: v.pages, + total: v.total, + cards: v.cards.map((c) => c.key), + status: v.status, + placingKey, + hover: hover.slice(), + buttons: hits.map((h) => h.id), + position: mesh ? mesh.position.toArray() : null, + scale: mesh ? mesh.scale.x : null, + draws: debug.draws, + presses: debug.presses.slice(), + lastPress: debug.lastPress, + loading: data.loading, + freeCount: data.free?.length ?? null + }; +} diff --git a/src/lib/vr/assetBrowserDraw.js b/src/lib/vr/assetBrowserDraw.js new file mode 100644 index 000000000..e31e471c1 --- /dev/null +++ b/src/lib/vr/assetBrowserDraw.js @@ -0,0 +1,265 @@ +// VR controls — 52-browser: PAINT the wrist Asset Browser. DOM is invisible in a headset, so the panel is +// one canvas on one plane (ONE draw call — a grid of meshes + troika labels would be ~40 on a Quest whose +// budget is 150 for the whole scene). Pure over its arguments (imports nothing): it paints one view and +// returns the pressable rects, the shape the laser hit test reads (the vrPanelDraw / shellPanelDraw +// convention). +// +// Canvas colours are literals (a canvas cannot read a CSS var()), the headset-panel exception the other VR +// panels take; the high-contrast pairs keep body text >= 7:1 on the panel ground. + +/** canvas size in px. On the wrist the plane is 0.2 m wide (~3.8 px/mm, ~1 texel per Quest 3 pixel at the + * 0.35 m a wrist is read from); pinned it grows 1.6x and is read from ~0.7 m. */ +export const PANEL_PX = { w: 768, h: 896 }; +/** cards per page */ +export const COLS = 3; +export const ROWS = 3; +export const PAGE_SIZE = COLS * ROWS; + +/** @typedef {{id: string, x: number, y: number, w: number, h: number}} PanelHit */ +/** @typedef {{key: string, label: string, sub?: string, thumb?: string | null, badge?: string, progress?: number | null, + * placing?: boolean, folder?: boolean}} Card */ +/** + * @typedef {{tab: 'packs' | 'free' | 'library', crumb: string | null, query: string, cards: Card[], page: number, pages: number, + * total: number, status: string, statusTone?: 'info' | 'warn' | 'bad', loading: boolean, pinned: boolean, empty: string}} PanelView + */ + +const C = { + bg: 'rgba(17, 21, 28, 0.96)', + border: '#2f3846', + title: '#f1f4f8', + body: '#d5dbe4', + dim: '#97a1b0', + card: '#232934', + cardHover: '#33405a', + cardPlacing: '#1d4f91', + tab: '#232934', + tabOn: '#2563eb', + button: '#2a313d', + buttonHover: '#3b4658', + accent: '#5aa2ff', + warn: '#fbbf24', + bad: '#f87171', + progress: '#3fb950', + thumbBg: '#11151c' +}; + +/** @param {CanvasRenderingContext2D} g @param {number} x @param {number} y @param {number} w @param {number} h @param {number} r */ +function roundRect(g, x, y, w, h, r) { + g.beginPath(); + g.moveTo(x + r, y); + g.arcTo(x + w, y, x + w, y + h, r); + g.arcTo(x + w, y + h, x, y + h, r); + g.arcTo(x, y + h, x, y, r); + g.arcTo(x, y, x + w, y, r); + g.closePath(); +} + +/** one line, shortened with an ellipsis to fit `maxW` @param {CanvasRenderingContext2D} g @param {string} text @param {number} maxW */ +export function fitText(g, text, maxW) { + const s = String(text ?? ''); + if (g.measureText(s).width <= maxW) return s; + let lo = 0; + let hi = s.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (g.measureText(s.slice(0, mid) + '…').width <= maxW) lo = mid; + else hi = mid - 1; + } + return s.slice(0, lo) + '…'; +} + +const TABS = /** @type {const} */ ([ + ['packs', 'Packs'], + ['free', 'Free CC0'], + ['library', 'My library'] +]); + +/** the grid's geometry (canvas px), shared by the painter and the suites */ +export const GRID = (() => { + const pad = 16; + const top = 236; + const bottom = 764; + const w = (PANEL_PX.w - pad * (COLS + 1)) / COLS; + const h = (bottom - top - pad * (ROWS - 1)) / ROWS; + return { pad, top, bottom, w, h }; +})(); + +/** + * Paint one view. `images` resolves a thumbnail url to a LOADED image (or null while it loads — the card + * shows its initial meanwhile). `hover` is the id under a laser. Returns the pressable rects. + * @param {CanvasRenderingContext2D} g @param {PanelView} view + * @param {{images: (url: string) => (CanvasImageSource | null), hover: string | null}} opts + * @returns {PanelHit[]} + */ +export function drawAssetPanel(g, view, opts) { + const { w: W, h: H } = PANEL_PX; + /** @type {PanelHit[]} */ + const hits = []; + const hot = (/** @type {string} */ id) => opts.hover === id; + g.clearRect(0, 0, W, H); + roundRect(g, 2, 2, W - 4, H - 4, 28); + g.fillStyle = C.bg; + g.fill(); + g.lineWidth = 3; + g.strokeStyle = C.border; + g.stroke(); + g.textBaseline = 'middle'; + + // ---- header: title · pin / return · close ---------------------------------------------------- + g.fillStyle = C.title; + g.font = '600 40px system-ui, sans-serif'; + g.textAlign = 'left'; + g.fillText('Assets', 28, 46); + /** @param {string} id @param {string} label @param {number} x @param {number} w @param {number} y @param {number} h */ + const button = (id, label, x, y, w, h, on = false) => { + roundRect(g, x, y, w, h, 14); + g.fillStyle = on ? C.tabOn : hot(id) ? C.buttonHover : C.button; + g.fill(); + g.fillStyle = C.title; + g.textAlign = 'center'; + g.fillText(label, x + w / 2, y + h / 2 + 1); + hits.push({ id, x, y, w, h }); + }; + g.font = '500 28px system-ui, sans-serif'; + button('pin', view.pinned ? 'To wrist' : 'Pin', W - 232, 18, 128, 56); + button('close', '✕', W - 92, 18, 64, 56); + + // ---- tabs -------------------------------------------------------------------------------------- + g.font = '500 30px system-ui, sans-serif'; + const tabW = (W - 16 * 4) / 3; + TABS.forEach(([id, label], i) => button('tab:' + id, label, 16 + i * (tabW + 16), 92, tabW, 60, view.tab === id)); + + // ---- search row (a press opens the VR keyboard); inside a pack, a back crumb first ----------------- + let sx = 16; + if (view.crumb) { + g.font = '500 28px system-ui, sans-serif'; + button('back', '‹ Packs', 16, 166, 150, 56); + sx = 182; + } + const sw = W - 16 - sx; + roundRect(g, sx, 166, sw, 56, 14); + g.fillStyle = hot('search') ? C.buttonHover : C.thumbBg; + g.fill(); + g.lineWidth = 2; + g.strokeStyle = C.border; + g.stroke(); + hits.push({ id: 'search', x: sx, y: 166, w: sw, h: 56 }); + g.textAlign = 'left'; + g.font = '400 28px system-ui, sans-serif'; + const q = view.query ? view.query : view.crumb ? `Search ${view.crumb}…` : 'Search…'; + g.fillStyle = view.query ? C.title : C.dim; + g.fillText(fitText(g, '⌕ ' + q, sw - 120), sx + 18, 195); + if (view.query) { + g.font = '500 26px system-ui, sans-serif'; + button('clear', 'Clear', sx + sw - 104, 172, 96, 44); + } + + // ---- the grid ------------------------------------------------------------------------------------ + const { pad, top, w: cw, h: ch } = GRID; + if (!view.cards.length) { + g.fillStyle = C.dim; + g.font = '400 30px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText(view.loading ? 'Loading…' : view.empty, W / 2, (top + GRID.bottom) / 2); + } + view.cards.forEach((card, i) => { + const col = i % COLS; + const row = Math.floor(i / COLS); + const x = pad + col * (cw + pad); + const y = top + row * (ch + pad); + const id = 'card:' + card.key; + roundRect(g, x, y, cw, ch, 16); + g.fillStyle = card.placing ? C.cardPlacing : hot(id) ? C.cardHover : C.card; + g.fill(); + if (hot(id)) { + g.lineWidth = 4; + g.strokeStyle = C.accent; + g.stroke(); + } + hits.push({ id, x, y, w: cw, h: ch }); + // the thumbnail box + const tx = x + 10; + const ty = y + 10; + const tw = cw - 20; + const th = ch - 62; + roundRect(g, tx, ty, tw, th, 10); + g.fillStyle = C.thumbBg; + g.fill(); + const img = card.thumb ? opts.images(card.thumb) : null; + if (img) { + // contain, centred + const iw = /** @type {any} */ (img).naturalWidth || /** @type {any} */ (img).width || 1; + const ih = /** @type {any} */ (img).naturalHeight || /** @type {any} */ (img).height || 1; + const s = Math.min(tw / iw, th / ih); + g.save(); + roundRect(g, tx, ty, tw, th, 10); + g.clip(); + g.drawImage(img, tx + (tw - iw * s) / 2, ty + (th - ih * s) / 2, iw * s, ih * s); + g.restore(); + } else { + g.fillStyle = C.dim; + g.font = '600 52px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText(card.folder ? '▤' : String(card.label || '?').slice(0, 1).toUpperCase(), tx + tw / 2, ty + th / 2); + } + if (card.badge) { + g.font = '600 22px system-ui, sans-serif'; + const bw = g.measureText(card.badge).width + 18; + roundRect(g, x + cw - bw - 14, y + 14, bw, 32, 10); + g.fillStyle = 'rgba(17, 21, 28, 0.85)'; + g.fill(); + g.fillStyle = C.body; + g.textAlign = 'center'; + g.fillText(card.badge, x + cw - bw / 2 - 14, y + 31); + } + if (card.progress != null) { + const p = Math.max(0, Math.min(1, card.progress)); + roundRect(g, tx, ty + th - 12, tw, 12, 6); + g.fillStyle = 'rgba(17, 21, 28, 0.85)'; + g.fill(); + if (p > 0) { + roundRect(g, tx, ty + th - 12, Math.max(12, tw * p), 12, 6); + g.fillStyle = C.progress; + g.fill(); + } + } + g.fillStyle = C.title; + g.font = '500 26px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText(fitText(g, card.label, cw - 20), x + cw / 2, y + ch - 36); + if (card.sub) { + g.fillStyle = C.dim; + g.font = '400 20px system-ui, sans-serif'; + g.fillText(fitText(g, card.sub, cw - 20), x + cw / 2, y + ch - 13); + } + }); + + // ---- footer: paging + status ------------------------------------------------------------------------ + g.font = '500 30px system-ui, sans-serif'; + if (view.pages > 1) { + button('page:-1', '‹', 16, 776, 88, 60); + button('page:1', '›', W - 104, 776, 88, 60); + } + g.textAlign = 'center'; + g.fillStyle = C.dim; + g.font = '400 24px system-ui, sans-serif'; + g.fillText(view.total ? `${view.page + 1} / ${Math.max(1, view.pages)} · ${view.total} ${view.total === 1 ? 'item' : 'items'}` : '', W / 2, 806); + if (view.status) { + g.fillStyle = view.statusTone === 'bad' ? C.bad : view.statusTone === 'warn' ? C.warn : C.body; + g.font = '500 26px system-ui, sans-serif'; + g.fillText(fitText(g, view.status, W - 48), W / 2, 860); + } + return hits; +} + +/** the rect under a hit's uv (three's plane uv: v up) @param {PanelHit[]} hits @param {{x: number, y: number}} uv */ +export function hitAtUv(hits, uv) { + const px = uv.x * PANEL_PX.w; + const py = (1 - uv.y) * PANEL_PX.h; + // last painted wins (the Clear button sits on the search box) + for (let i = hits.length - 1; i >= 0; i--) { + const r = hits[i]; + if (px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) return r; + } + return null; +} diff --git a/src/lib/vr/placeRay.js b/src/lib/vr/placeRay.js index ebe8ddec9..f1011abcb 100644 --- a/src/lib/vr/placeRay.js +++ b/src/lib/vr/placeRay.js @@ -134,9 +134,10 @@ export function vrPlaceActive() { /** * Place at the ghost (the trigger / the drag release). A click placement re-arms the same item; a - * drag places once. Returns true when something spawned. + * drag (`opts.drop`) places once. Returns true when something spawned. + * @param {{drop?: boolean}} [opts] */ -export function placeVRNow() { +export function placeVRNow(opts = {}) { if (!current || !rayPlaceActive()) return false; const host = current; const rotation = placeRotation(); @@ -150,7 +151,7 @@ export function placeVRNow() { debug.placed++; host.placed++; hapticPulse(0.35, 40, host.hand); - if (host.repeat && !host.dragging) { + if (host.repeat && !opts.drop) { // stay armed, turned as before: the next trigger places another beginRayPlace({ payload: host.payload, hand: host.hand, rotation }); current = host; @@ -221,7 +222,7 @@ function onTriggerEnd(index) { // let go over the world = the drag's drop; still on the panel = a click, stay armed if (laser && !beamTarget(laser).panel) { claimSelect(); - placeVRNow(); + placeVRNow({ drop: true }); return true; } publish(); diff --git a/tests/e2e/vr-asset-browser-iwer.test.cjs b/tests/e2e/vr-asset-browser-iwer.test.cjs index 3af2b2007..3f0e69568 100644 --- a/tests/e2e/vr-asset-browser-iwer.test.cjs +++ b/tests/e2e/vr-asset-browser-iwer.test.cjs @@ -6,9 +6,40 @@ // replicated undo step and it stays armed, a GRIP cancels with nothing spawned; in MINIATURE (the // world rig shrunk to 1:10 and moved) the ghost draws in the shrunk frame and the object lands at the // scene-unit spot under the laser +// B. the wrist Asset Browser: Add ▸ Assets puts the panel on the OFF hand's wrist facing you; the laser +// hovers its controls and the TRIGGER presses them (tabs, a pack, the search box -> the VR keyboard +// filters as you type); a trigger-DRAG from a card drops the item where you let go (one object, one +// undo); a trigger CLICK on a card arms it (the next trigger places, the grip stops); a GRIP lifts the +// panel off the wrist and lets go of it PINNED and 1.6x bigger, a grip near the wrist puts it back +// (the Pin / To wrist button too); Free CC0: a card downloads (progress on the card), lands in the +// Library and rides the laser; My library lists it; one draw call for the whole panel // Run: APP_URL=https://theprototype.app:5484/ node tests/e2e/vr-asset-browser-iwer.test.cjs (ONLY=G,… to pick rows) const h = require('./helpers.cjs'); const iwer = require('./iwer.cjs'); +const fs = require('fs'); +const path = require('path'); +const { makeCc0Network } = require('./cc0Fixtures.cjs'); +const STATIC = path.join(__dirname, '../../static/library'); +const SHOTS = process.env.SHOTS_DIR || ''; +/** save the panel canvas as evidence (SHOTS_DIR=…) @param {any} page @param {string} name */ +async function shot(page, name) { + if (!SHOTS) return; + const url = await page.evaluate(() => window.__stores.vrAssetBrowser.assetPanelImage()); + if (url) fs.writeFileSync(path.join(SHOTS, name + '.png'), Buffer.from(url.split(',')[1], 'base64')); +} + +/** the packs CDN answered from static/library (the place-drag recipe): the starter pack, Duck with dims */ +async function routeStarterPack(page) { + await page.route(/cdn\.jsdelivr\.net\/gh\/theprototype-app\/packs@[^/]+\/(.*)$/, (route) => { + const rel = decodeURIComponent(new URL(route.request().url()).pathname.replace(/^.*packs@[^/]+\//, '')); + if (rel === 'index.json') + return route.fulfill({ contentType: 'application/json', headers: { 'access-control-allow-origin': '*' }, body: JSON.stringify([{ name: 'default', title: 'The Prototype', value: 'default/default.json', attribution: 'default/attribution.html', copyright: '', license: '', source: 'https://github.com/theprototype-app/packs' }]) }); + const file = path.join(STATIC, rel); + if (!file.startsWith(STATIC) || !fs.existsSync(file)) return route.fulfill({ status: 404, body: '' }); + const type = /\.json$/.test(file) ? 'application/json' : /\.glb$/.test(file) ? 'model/gltf-binary' : /\.png$/.test(file) ? 'image/png' : 'text/html'; + return route.fulfill({ contentType: type, headers: { 'access-control-allow-origin': '*' }, body: fs.readFileSync(file) }); + }); +} const ONLY = (process.env.ONLY || '').split(',').filter(Boolean); const want = (row) => !ONLY.length || ONLY.includes(row); @@ -17,6 +48,9 @@ h.run(async () => { const browser = await h.launch({ args: h.GPU_ARGS }); const A = await h.setupPage(browser, 'A', { init: iwer.initScript(), context: { viewport: { width: 1280, height: 720 } } }); const page = A.page; + await routeStarterPack(page); + const cc0 = makeCc0Network(); + await cc0.routes(page); const g = (fn, arg) => page.evaluate(fn, arg); const read = (name) => g((n) => { let v; window.__stores[n].subscribe((x) => (v = x))(); return v; }, name); const settle = (n = 6) => iwer.frames(page, n); @@ -162,6 +196,192 @@ h.run(async () => { h.check((await g(() => { let v; window.__stores.vrControls.vrPrefabGhost.subscribe((x) => (v = x))(); return v; })) === null, 'the prefab window is no longer armed'); } + + // ---- B. the wrist Asset Browser ---------------------------------------------------------------- + if (want('B')) { + console.log('\n=== B. the Asset Browser on the wrist ==='); + const dbg = () => g(() => window.__stores.vrAssetBrowser.vrAssetBrowserDebug()); + /** aim the pointer controller (from where it is) at a panel control, then let a few frames pass */ + const aimAt = async (id, from = [pointer === 'left' ? -0.12 : 0.12, 1.3, -0.15]) => { + const pt = await g((cid) => window.__stores.vrAssetBrowser.assetControlPoint(cid), id); + if (!pt) throw new Error('no control ' + id + ' on the panel: ' + JSON.stringify(await dbg())); + const d = [pt[0] - from[0], pt[1] - from[1], pt[2] - from[2]]; + const yaw = Math.atan2(-d[0], -d[2]); + const pitch = Math.atan2(d[1], Math.hypot(d[0], d[2])); + await iwer.pose(page, pointer, from, { yaw, pitch }); + await settle(4); + return { yaw, pitch }; + }; + const press = async (id) => { + await aimAt(id); + await iwer.tap(page, pointer, 'trigger'); + await settle(3); + }; + await iwer.pose(page, 'head', [0, 1.6, 0]); + await iwer.pose(page, menuHand, [menuHand === 'left' ? -0.18 : 0.18, 1.25, -0.3], { pitch: 0.3 }); + await iwer.pose(page, pointer, [pointer === 'left' ? -0.12 : 0.12, 1.3, -0.15]); + // Add ▸ Assets (the sector's own action) + await g(() => window.__stores.vrControls.executeVRMenuAction('assets')); + await settle(8); + let d = await dbg(); + h.check(d.open && d.visible && d.mode === 'wrist' && d.wristHand === menuHand, `Add ▸ Assets opens the panel on the ${menuHand} wrist (${JSON.stringify({ open: d.open, visible: d.visible, mode: d.mode, wrist: d.wristHand })})`); + const geo = await g((hand) => { + let r; + window.__stores.globalRenderer.subscribe((v) => (r = v))(); + const T = window.__stores.THREE; + const i = [0, 1].find((k) => r.xr.getController(k).userData.handedness === hand); + const grip = r.xr.getControllerGrip(i).getWorldPosition(new T.Vector3()); + const head = r.xr.getCamera().getWorldPosition(new T.Vector3()); + let mesh; + window.__stores.globalScene.subscribe((sc) => (mesh = sc.getObjectByName('vr-asset-browser')))(); + const p = mesh.getWorldPosition(new T.Vector3()); + const n = new T.Vector3(0, 0, 1).applyQuaternion(mesh.getWorldQuaternion(new T.Quaternion())); + const toHead = head.clone().sub(p).normalize(); + let meshes = 0; + mesh.traverse((o) => (o.isMesh ? meshes++ : 0)); + return { dist: p.distanceTo(grip), facing: n.dot(toHead), meshes }; + }, menuHand); + h.check(geo.dist < 0.15, `it rides the wrist (${geo.dist.toFixed(3)} m from the grip)`); + h.check(geo.facing > 0.8, `and faces the head (${geo.facing.toFixed(2)})`); + h.check(geo.meshes === 1, `one mesh, one draw call for the whole panel (${geo.meshes})`); + await h.eventually(async () => (await dbg()).cards.length, (n) => n >= 1, 'the Packs tab lists the packs', 10000); + h.check((await dbg()).cards.includes('pack:default'), 'the starter pack is a card'); + await shot(page, '01-panel-packs'); + + // hover + trigger: open the pack + await aimAt('card:pack:default'); + await g(() => window.__stores.selectedObjects.set([])); + d = await dbg(); + h.check(d.hover.includes('card:pack:default'), `the laser hovers the pack card (${d.hover})`); + await iwer.tap(page, pointer, 'trigger'); + await h.eventually(async () => (await dbg()).cards, (c) => c.includes('item:default/Duck'), 'the trigger opens the pack: its items are cards', 10000); + await settle(10); + await shot(page, '02-panel-pack-items'); + const nSel = await g(() => new Promise((r) => window.__stores.selectedObjects.subscribe((v) => r(v.length))())); + h.check(nSel === 0, `a press on the panel selects nothing behind it (${nSel})`); + + // search through the VR keyboard, filtering as you type + await press('search'); + const kb = await g(() => { let v; window.__stores.vrKeyboard?.vrKeyboardTarget?.subscribe((x) => (v = x))(); return v ? v.title : null; }); + h.check(!!kb, `the search box opens the VR keyboard (${kb})`); + await g(() => { for (const k of ['l', 'o', 'g']) window.__stores.vrControls.executeVRMenuAction('kbd:' + k); }); + await settle(3); + d = await dbg(); + h.check(d.query === 'log' && d.cards.length === 1 && d.cards[0] === 'item:default/ThePrototypeLogo', `typing filters the cards as you type (${d.query}: ${d.cards})`); + await g(() => window.__stores.vrControls.executeVRMenuAction('kbd:clear')); + await g(() => window.__stores.vrControls.executeVRMenuAction('kbd:enter')); + await settle(3); + d = await dbg(); + h.check(d.query === '' && d.cards.includes('item:default/Duck'), `Enter commits (cleared: ${d.cards.length} cards)`); + + // a trigger-DRAG from the Duck card drops it where the trigger lets go + const nB = await count(); + await aimAt('card:item:default/Duck'); + await iwer.button(page, pointer, 'trigger', 1); + await settle(4); + const drag = await g(() => window.__stores.vrPlace.vrPlaceDebug()); + h.check(drag.active && drag.current?.dragging, `pressing a card puts it on the laser, the trigger still down (${JSON.stringify(drag.current)})`); + await iwer.pose(page, pointer, [pointer === 'left' ? -0.15 : 0.15, 1.2, -0.3], { pitch: -0.75 }); + await settle(6); + const ghostAt = (await g(() => window.__stores.placeGhost.ghostInfo())).items[0]?.position; + await iwer.button(page, pointer, 'trigger', 0); + await settle(4); + await h.eventually(count, (n) => n === nB + 1, 'letting go over the floor drops ONE Duck there', 8000); + const duck = await g(async () => { + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const o = group.children[group.children.length - 1]; + return { pos: o.position.toArray(), ref: o.userData?.packRef?.item ?? null }; + }); + h.check(duck.ref === 'Duck' && near([duck.pos[0], duck.pos[2]], [ghostAt[0], ghostAt[2]], 0.05), `the Duck is the pack item, at the ghost (${duck.ref} ${duck.pos.map((v) => v.toFixed(2))})`); + h.check(!(await g(() => window.__stores.vrPlace.vrPlaceDebug().active)), 'a drag places once (nothing still on the laser)'); + await g(() => window.__stores.history.undo()); + h.check((await count()) === nB, 'ONE undo takes the drop back'); + + // a trigger CLICK on a card arms it: the next trigger places, it stays armed, the grip stops + await press('card:item:default/Duck'); + const armed = await g(() => window.__stores.vrPlace.vrPlaceDebug()); + h.check(armed.active && !armed.current?.dragging, 'a click on a card arms it on the laser'); + d = await dbg(); + h.check(/trigger places/.test(d.status), `the panel says what the trigger does now (${d.status})`); + await shot(page, '03-panel-placing'); + await iwer.pose(page, pointer, [pointer === 'left' ? -0.15 : 0.15, 1.2, -0.3], { pitch: -0.75 }); + await settle(6); + await iwer.tap(page, pointer, 'trigger'); + await h.eventually(count, (n) => n === nB + 1, 'the next trigger places it', 8000); + h.check((await g(() => window.__stores.vrPlace.vrPlaceDebug().active)), 'and it stays armed for another'); + await iwer.tap(page, menuHand, 'squeeze'); + await settle(3); + h.check(!(await g(() => window.__stores.vrPlace.vrPlaceDebug().active)), 'a grip stops placing'); + + // the GRIP lifts the panel off the wrist; let go away from it = pinned, bigger + await aimAt('tab:packs'); + await iwer.button(page, pointer, 'squeeze', 1); + await settle(3); + h.check((await dbg()).mode === 'held', 'a grip with the laser on the panel takes it off the wrist'); + await iwer.pose(page, pointer, [pointer === 'left' ? -0.3 : 0.3, 1.45, -0.55]); + await settle(10); + await iwer.button(page, pointer, 'squeeze', 0); + await settle(4); + d = await dbg(); + h.check(d.mode === 'pinned' && Math.abs(d.scale - 1.6) < 0.05, `let go in the air: pinned, ${d.scale?.toFixed(2)}x`); + const pinnedAt = d.position; + await iwer.pose(page, menuHand, [menuHand === 'left' ? -0.18 : 0.18, 1.0, -0.1], { pitch: 0.3 }); + await settle(6); + h.check(near((await dbg()).position, pinnedAt, 1e-6), 'a pinned panel stays put when the wrist moves'); + // carry it back to the wrist + const carryAim = await aimAt('tab:packs', [pointer === 'left' ? -0.3 : 0.3, 1.2, 0.05]); + await iwer.button(page, pointer, 'squeeze', 1); + await settle(3); + const wristNow = await g((hand) => { + let r; + window.__stores.globalRenderer.subscribe((v) => (r = v))(); + const T = window.__stores.THREE; + const i = [0, 1].find((k) => r.xr.getController(k).userData.handedness === hand); + return r.xr.getControllerGrip(i).localToWorld(new T.Vector3(0, 0.085, 0.05)).toArray(); + }, menuHand); + const heldNow = await dbg(); + // the carrying controller in IWER's own space (a world displacement is the same there: no turn yet) + const ctrl = await g((hand) => window.__iwer.device.controllers[hand].position.toArray?.() ?? [window.__iwer.device.controllers[hand].position.x, window.__iwer.device.controllers[hand].position.y, window.__iwer.device.controllers[hand].position.z], pointer); + // move the carrying controller so the panel lands on the wrist spot + const delta = wristNow.map((v, i) => v - heldNow.position[i]); + await iwer.pose(page, pointer, ctrl.map((v, i) => v + delta[i]), carryAim); + await settle(10); + await iwer.button(page, pointer, 'squeeze', 0); + await settle(4); + d = await dbg(); + h.check(d.mode === 'wrist' && Math.abs(d.scale - 1) < 1e-6, `let go near the wrist: back on it (${d.mode}, ${d.scale})`); + // the Pin / To wrist button + await iwer.pose(page, menuHand, [menuHand === 'left' ? -0.18 : 0.18, 1.25, -0.3], { pitch: 0.3 }); + await settle(4); + await press('pin'); + h.check((await dbg()).mode === 'pinned', 'Pin pins it in front of you'); + await press('pin'); + h.check((await dbg()).mode === 'wrist', 'To wrist puts it back'); + + // Free CC0: download -> Library -> the laser + await press('tab:free'); + await h.eventually(async () => (await dbg()).cards, (c) => c.some((k) => k.startsWith('cc0:')), 'the Free CC0 tab lists Poly Haven models', 10000); + d = await dbg(); + const freeKey = d.cards.find((k) => k.startsWith('cc0:')); + const nF = await count(); + await press('card:' + freeKey); + await settle(10); + await shot(page, '04-panel-free'); + await h.eventually(() => g(() => window.__stores.vrPlace.vrPlaceDebug().active), (v) => v === true, `a free model downloads into the Library and rides the laser (${freeKey})`, 20000); + await iwer.pose(page, pointer, [pointer === 'left' ? -0.15 : 0.15, 1.2, -0.3], { pitch: -0.75 }); + await settle(6); + await iwer.tap(page, pointer, 'trigger'); + await h.eventually(count, (n) => n === nF + 1, 'the trigger places the downloaded model', 15000); + await iwer.tap(page, menuHand, 'squeeze'); + await settle(3); + await press('tab:library'); + await h.eventually(async () => (await dbg()).cards, (c) => c.some((k) => k.startsWith('lib:')) && c.some((k) => k.startsWith('prefab:')), 'My library lists the downloaded model and the prefab', 8000); + await settle(10); + await shot(page, '05-panel-library'); + await press('close'); + h.check(!(await dbg()).open, '✕ closes the panel'); + } + await iwer.exit(page); await h.finish(browser); }); From 8c75f6a0eb996b4668dd14fb37e1a30f29856b02 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 00:26:09 +0300 Subject: [PATCH 07/29] [feat] 52-browser P3: light hand tracking - pinch browses and places, precise tools ask for a controller - a pinch IS the trigger: the panel and the laser ghost read the session's select events, so a pinch presses a card, a pinch held while the hand moves drops the item where it lets go, a pinch-click arms it and the next pinch places; the panel speaks hands ("pinch to place", "let go over the world") - vr/handInput.js: isTrackedHand + controllerRequired(tool, slot) - a tracked hand gets "Pick up a controller to " on a small chip at that hand (one draw call) and the tool stands down; wired into the VR sculpt brush, face edit and vertex edit; exported from the vrControls facade for 52-measure / 52-paint - the placing hint outranks a passing info line on the panel - suite vr-asset-browser-iwer row H (IWER tracked hands; 70 checks with G + B) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/debugHooks.js | 1 + src/lib/vr/assetBrowser.js | 3 +- src/lib/vr/grip.js | 6 + src/lib/vr/handInput.js | 168 +++++++++++++++++++++++ src/lib/vr/sculpt.js | 3 + src/lib/vrControls.js | 2 + tests/e2e/vr-asset-browser-iwer.test.cjs | 146 ++++++++++++++++++++ 7 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 src/lib/vr/handInput.js diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 9933b0356..61a3f135c 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -46,6 +46,7 @@ export const DEBUG_HOOKS = [ ['placeGhost', () => import('./placeGhost.js')], ['vrPlace', () => import('./vr/placeRay.js')], ['vrAssetBrowser', () => import('./vr/assetBrowser.js')], + ['vrHands', () => import('./vr/handInput.js')], ['tourVR', () => import('./tours/vrPanel.js')], // 36-share (B13): the recorder + its stores ['recording', () => import('./recording/recorder.js')], diff --git a/src/lib/vr/assetBrowser.js b/src/lib/vr/assetBrowser.js index 287908d0f..7207be00d 100644 --- a/src/lib/vr/assetBrowser.js +++ b/src/lib/vr/assetBrowser.js @@ -248,7 +248,8 @@ function currentView() { const placing = get(vrPlacing); let text = status.text && Date.now() < status.until ? status.text : ''; let tone = status.tone; - if (!text && placing) { + // what the trigger does NOW outranks a passing note; a warning or a refusal still shows + if (placing && (!text || tone === 'info')) { const handSource = sourceIsHand(placing.hand); text = handSource ? placing.armed diff --git a/src/lib/vr/grip.js b/src/lib/vr/grip.js index 1e286a483..62b631c65 100644 --- a/src/lib/vr/grip.js +++ b/src/lib/vr/grip.js @@ -61,6 +61,7 @@ import { S } from './state.js'; import { renderer, tempVector } from './core.js'; import { hapticPulse, hapticPattern } from './haptics.js'; import { gripDropHooks, worldGrabDiverts, worldGestureDiverted, vrModuleGripStart, vrModuleGripEnd } from './hooks.js'; +import { controllerRequired } from './handInput.js'; import { controllerIndexFor, axesForSlot } from './input.js'; import { STANDING_HEAD, vrLocomotionNow, viewerNow } from './locomotion.js'; import { @@ -339,6 +340,8 @@ export let faceGrabHand = null; export let faceAdjustHand = null; export function vrFaceTrigger() { + // 52 (H): editing faces is a precise tool — a tracked hand is asked to pick up a controller + if (controllerRequired('edit faces', controllerIndexFor(pointerHand()))) return; // 212: Multi mode — the trigger ACCUMULATES the pointed unit; the op button // applies to the whole set (no live adjust while multi-selecting) if (get(faceEditMulti)) { @@ -397,6 +400,8 @@ function endVertexCarry() { /** @param {number} index */ export function vrVertexTrigger(index) { if (!get(editingObject)) return; + // 52 (H): moving / picking a vertex is a precise tool — a tracked hand is asked to pick up a controller + if (controllerRequired('edit vertices', index ?? -1)) return; // 183: in create-face mode a trigger click toggles the picked vertex into the // selection (once per click — the 'select' event) instead of grabbing it if (get(vrFaceCreateMode)) { @@ -415,6 +420,7 @@ export function vrVertexTrigger(index) { export function vrVertexGrabStart(index) { // create-face selection is driven by the 'select' click, not the press if (!get(editingObject) || get(vrFaceCreateMode) || !get(vrVertexHold) || S.vertexTriggerGrab) return; + if (controllerRequired('edit vertices', index)) return; // 52 (H) beginVertexCarry(index); } export function vrVertexGrabEnd() { diff --git a/src/lib/vr/handInput.js b/src/lib/vr/handInput.js new file mode 100644 index 000000000..2ff59e6d8 --- /dev/null +++ b/src/lib/vr/handInput.js @@ -0,0 +1,168 @@ +// VR controls — 52-browser (H): LIGHT HAND TRACKING. The user's rule (roadmap 52, 2026-10-21): every tool +// is full on controllers; a TRACKED HAND browses and places — a pinch IS the trigger (the session's +// select / selectstart / selectend, which the headset fires for a pinch), a pinch held while the hand moves +// is the drag (the Asset Browser's pinch-drag puts the ghost down where the pinch lets go) — and a PRECISE +// tool (a sculpt / paint brush, measuring with snapping, dragging a vertex or a face) answers a pinch with +// "Pick up a controller" instead of doing something a fingertip cannot aim. +// +// `controllerRequired(tool, slot)` is the one gate: true (and the note shown at that hand) when the slot's +// input is a tracked hand, false for a controller. Siblings call it at the top of their trigger path +// (52-measure's snap measure, 52-paint's brushes) — the note, its wording and its look stay one thing. +// +// The note is one canvas chip (one draw call) that rides the hand that pinched for a few seconds, drawn over +// the scene like the panels. LOCAL; nothing here replicates. +import * as THREE from 'three'; +import { get, writable } from 'svelte/store'; +import { globalScene, globalRenderer } from '../../stores/sceneStore'; +import { overlayPanel } from '../vrPanelOverlay'; +import { registerVRFrameHook } from './hooks.js'; + +const NOTE_MS = 2600; +const CHIP_PX = { w: 640, h: 128 }; +const CHIP_M = { w: 0.16, h: 0.032 }; + +/** the note on show: {text, hand, until} or null (the suites read it) @type {import('svelte/store').Writable} */ +export const vrHandNote = writable(null); + +/** the input source of a hand (by handedness), or null @param {string | null | undefined} hand */ +function sourceOf(hand) { + const r = /** @type {any} */ (get(globalRenderer)); + const session = r?.xr?.getSession?.(); + if (!session || !hand) return null; + for (const s of session.inputSources) if (s.handedness === hand) return s; + return null; +} + +/** the handedness of controller slot `index` @param {number} index @returns {string | null} */ +function handOfSlot(index) { + const r = /** @type {any} */ (get(globalRenderer)); + return r?.xr?.getController(index)?.userData?.handedness ?? null; +} + +/** Is that hand a TRACKED HAND (no controller in it)? @param {string | number | null | undefined} handOrSlot */ +export function isTrackedHand(handOrSlot) { + const hand = typeof handOrSlot === 'number' ? handOfSlot(handOrSlot) : handOrSlot; + return !!sourceOf(hand)?.hand; +} + +/** + * The precise-tool gate: a tracked hand gets "Pick up a controller to " at that hand and TRUE (the + * caller stands down and should swallow the press); a controller gets false. `slot` = the controller slot + * (or a handedness) that pressed. + * @param {string} tool e.g. 'sculpt' · 'measure with snapping' · 'move a vertex' + * @param {number | string} slot + * @returns {boolean} + */ +export function controllerRequired(tool, slot) { + if (!isTrackedHand(slot)) return false; + const hand = typeof slot === 'number' ? handOfSlot(slot) : slot; + showHandNote(`Pick up a controller to ${tool}`, hand); + return true; +} + +/** @type {any} */ let chip = null; +/** @type {HTMLCanvasElement | null} */ let canvas = null; +/** @type {any} */ let texture = null; +let hooked = false; +let drawn = ''; + +function ensureChip() { + if (chip || typeof document === 'undefined') return chip; + canvas = document.createElement('canvas'); + canvas.width = CHIP_PX.w; + canvas.height = CHIP_PX.h; + texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + chip = new THREE.Mesh( + new THREE.PlaneGeometry(CHIP_M.w, CHIP_M.h), + new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide, depthWrite: false }) + ); + chip.name = 'vr-hand-note'; + chip.visible = false; + overlayPanel(chip); + return chip; +} + +/** @param {string} text */ +function paint(text) { + if (text === drawn || !canvas) return; + drawn = text; + const g = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')); + if (!g) return; + const { w, h } = CHIP_PX; + g.clearRect(0, 0, w, h); + g.beginPath(); + g.roundRect?.(4, 4, w - 8, h - 8, 40) ?? g.rect(4, 4, w - 8, h - 8); + // tokens-ok: a canvas cannot read var() — the headset-panel literal exception + g.fillStyle = 'rgba(17, 21, 28, 0.94)'; + g.fill(); + g.lineWidth = 4; + g.strokeStyle = '#fbbf24'; // tokens-ok: the warn ink as a literal (canvas) + g.stroke(); + g.fillStyle = '#f1f4f8'; // tokens-ok + g.font = '600 40px system-ui, sans-serif'; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillText(text, w / 2, h / 2 + 2, w - 48); + texture.needsUpdate = true; +} + +/** Show a short note at a hand (any feature may). @param {string} text @param {string | null} hand */ +export function showHandNote(text, hand) { + ensureChip(); + if (!hooked) { + hooked = true; + registerVRFrameHook(frame); + } + vrHandNote.set({ text, hand, until: Date.now() + NOTE_MS }); +} + +/** where a hand is (its controller group follows the tracked hand's target ray) @param {string} hand */ +function handPosition(hand) { + const r = /** @type {any} */ (get(globalRenderer)); + if (!r?.xr) return null; + for (const i of [0, 1]) { + const c = r.xr.getController(i); + if (c?.userData?.handedness === hand) { + c.updateMatrixWorld(true); + return c.getWorldPosition(new THREE.Vector3()); + } + } + return null; +} + +function frame() { + if (!chip) return; + const note = get(vrHandNote); + const r = /** @type {any} */ (get(globalRenderer)); + if (!note || Date.now() > note.until || !r?.xr?.isPresenting) { + chip.visible = false; + if (note && Date.now() > note.until) vrHandNote.set(null); + return; + } + const scene = /** @type {any} */ (get(globalScene)); + if (scene && chip.parent !== scene) scene.add(chip); + const at = handPosition(note.hand); + const cam = r.xr.getCamera(); + const head = cam.getWorldPosition(new THREE.Vector3()); + if (!at) { + chip.visible = false; + return; + } + paint(note.text); + // just above the hand, a little toward the eyes, facing them + chip.position.copy(at).add(new THREE.Vector3(0, 0.07, 0)).lerp(head, 0.15); + chip.lookAt(head); + chip.visible = true; +} + +/** the suites' view */ +export function handInputDebug() { + const r = /** @type {any} */ (get(globalRenderer)); + const session = r?.xr?.getSession?.(); + return { + note: get(vrHandNote), + chipVisible: !!chip?.visible, + sources: session ? Array.from(session.inputSources).map((/** @type {any} */ s) => s.handedness + ':' + (s.hand ? 'hand' : 'controller')) : [] + }; +} diff --git a/src/lib/vr/sculpt.js b/src/lib/vr/sculpt.js index 05bb87038..c5085fca6 100644 --- a/src/lib/vr/sculpt.js +++ b/src/lib/vr/sculpt.js @@ -35,6 +35,7 @@ import { controllerIndexFor, axesForSlot } from './input.js'; import { controllerRay } from './pointer.js'; import { vrHovered } from './panels.js'; import { registerVRFrameHook, registerVRTriggerHooks, registerStickOwner } from './hooks.js'; +import { controllerRequired } from './handInput.js'; import { vrSculptActive } from './worldStores.js'; export const SCULPT_OPS = /** @type {const} */ (['raise', 'lower', 'smooth', 'flatten']); @@ -126,6 +127,8 @@ export function sculptTriggerStart(index) { const uuid = get(sculptObject); if (!hit || !uuid) return false; // off the terrain: an ordinary press (a panel, a ping) swallowAt = Date.now(); + // 52 (H): a brush is a precise tool — a tracked hand is asked to pick up a controller + if (controllerRequired('sculpt', index)) return true; beginStroke(uuid); stroke = { index, last: performance.now() }; stats.strokes++; diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index ca771f018..8c3beb559 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -126,6 +126,8 @@ export { stickOwned, registerGripHooks } from './vr/hooks.js'; +// 52 (H): light hand tracking — the precise-tool gate siblings call ("Pick up a controller") +export { controllerRequired, isTrackedHand, showHandNote } from './vr/handInput.js'; export { computeMoveOffset, computeTeleportArc, diff --git a/tests/e2e/vr-asset-browser-iwer.test.cjs b/tests/e2e/vr-asset-browser-iwer.test.cjs index 3f0e69568..fecf6cbc7 100644 --- a/tests/e2e/vr-asset-browser-iwer.test.cjs +++ b/tests/e2e/vr-asset-browser-iwer.test.cjs @@ -13,6 +13,10 @@ // panel off the wrist and lets go of it PINNED and 1.6x bigger, a grip near the wrist puts it back // (the Pin / To wrist button too); Free CC0: a card downloads (progress on the card), lands in the // Library and rides the laser; My library lists it; one draw call for the whole panel +// H. light hand tracking (IWER's tracked hands): a PINCH is the trigger — it presses a panel card, a +// pinch held while the hand moves drops the item where the pinch lets go, a pinch-click arms it and the +// next pinch places; a PRECISE tool (the sculpt brush) answers a pinch with "Pick up a controller" at +// that hand and does nothing // Run: APP_URL=https://theprototype.app:5484/ node tests/e2e/vr-asset-browser-iwer.test.cjs (ONLY=G,… to pick rows) const h = require('./helpers.cjs'); const iwer = require('./iwer.cjs'); @@ -382,6 +386,148 @@ h.run(async () => { h.check(!(await dbg()).open, '✕ closes the panel'); } + + // ---- H. light hand tracking ---------------------------------------------------------------------- + if (want('H')) { + console.log('\n=== H. hands: pinch = trigger, pinch-drag = the ghost, precise tools ask for a controller ==='); + const hdbg = () => g(() => window.__stores.vrHands.handInputDebug()); + await g(() => (window.__iwer.device.primaryInputMode = 'hand')); + await settle(10); + await h.eventually(async () => (await hdbg()).sources.join(), (v) => /left:hand/.test(v) && /right:hand/.test(v), 'IWER switches to tracked hands (no controllers)', 8000); + const handPose = async (hand, pos, aim = {}) => + g(({ hand, pos, aim }) => { + const t = window.__iwer.device.hands[hand]; + t.position.set(pos[0], pos[1], pos[2]); + const cy = Math.cos((aim.yaw ?? 0) / 2), sy = Math.sin((aim.yaw ?? 0) / 2); + const cp = Math.cos((aim.pitch ?? 0) / 2), sp = Math.sin((aim.pitch ?? 0) / 2); + t.quaternion.set(cy * sp, sy * cp, -sy * sp, cy * cp); + }, { hand, pos, aim }); + const pinch = (hand, v) => g(({ hand, v }) => window.__iwer.device.hands[hand].updatePinchValue(v), { hand, v }); + /** the app's ray of a hand (where three put the target-ray group) */ + const appRay = (hand) => + g((hand) => { + let r; + window.__stores.globalRenderer.subscribe((v) => (r = v))(); + const T = window.__stores.THREE; + const c = [0, 1].map((i) => r.xr.getController(i)).find((x) => x.userData.handedness === hand); + c.updateMatrixWorld(true); + const o = new T.Vector3().setFromMatrixPosition(c.matrixWorld); + const d = new T.Vector3(0, 0, -1).applyMatrix4(new T.Matrix4().extractRotation(c.matrixWorld)); + return { o: o.toArray(), d: d.toArray() }; + }, hand); + /** aim the pointing hand's RAY at a world point: pose, read the ray three derived, correct the aim */ + const aimHandAt = async (target, from) => { + let yaw = Math.atan2(-(target[0] - from[0]), -(target[2] - from[2])); + let pitch = Math.atan2(target[1] - from[1], Math.hypot(target[0] - from[0], target[2] - from[2])); + for (let k = 0; k < 4; k++) { + await handPose(pointer, from, { yaw, pitch }); + await settle(3); + const ray = await appRay(pointer); + const want = target.map((v, i) => v - ray.o[i]); + const wy = Math.atan2(-want[0], -want[2]); + const wp = Math.atan2(want[1], Math.hypot(want[0], want[2])); + const hy = Math.atan2(-ray.d[0], -ray.d[2]); + const hp = Math.atan2(ray.d[1], Math.hypot(ray.d[0], ray.d[2])); + yaw += wy - hy; + pitch += wp - hp; + } + await handPose(pointer, from, { yaw, pitch }); + await settle(3); + }; + const dbg = () => g(() => window.__stores.vrAssetBrowser.vrAssetBrowserDebug()); + await handPose(menuHand, [menuHand === 'left' ? -0.18 : 0.18, 1.25, -0.3], { pitch: 0.3 }); + const fromH = [pointer === 'left' ? -0.12 : 0.12, 1.3, -0.12]; + await handPose(pointer, fromH); + await g(() => window.__stores.vrControls.executeVRMenuAction('assets')); + await settle(8); + const aimCtl = async (id) => { + const pt = await g((cid) => window.__stores.vrAssetBrowser.assetControlPoint(cid), id); + if (!pt) throw new Error('no control ' + id + ': ' + JSON.stringify(await dbg())); + await aimHandAt(pt, fromH); + }; + const pinchTap = async () => { + await pinch(pointer, 1); + await settle(4); + await pinch(pointer, 0); + await settle(4); + }; + let d = await dbg(); + h.check(d.open && d.visible, `the panel opens with hands too (${d.mode}, wrist ${d.wristHand})`); + await aimCtl('tab:packs'); + await pinchTap(); + await h.eventually(async () => (await dbg()).cards, (c) => c.includes('pack:default') || c.includes('item:default/Duck'), 'a PINCH presses a tab (pinch = trigger)', 8000); + d = await dbg(); + if (!d.cards.includes('item:default/Duck')) { + await aimCtl('card:pack:default'); + await pinchTap(); + await h.eventually(async () => (await dbg()).cards, (c) => c.includes('item:default/Duck'), 'a pinch opens the pack', 8000); + } + // pinch-DRAG: pinch the Duck, move the hand to the floor, let go + const n0 = await count(); + await aimCtl('card:item:default/Duck'); + await pinch(pointer, 1); + await settle(4); + const held = await g(() => window.__stores.vrPlace.vrPlaceDebug()); + h.check(held.active && held.current?.dragging, `a pinch on a card puts it on the hand's ray, the pinch still held (${JSON.stringify(held.current)})`); + d = await dbg(); + h.check(/let go over the world/.test(d.status), `the panel speaks hands (${d.status})`); + // a clear stretch of floor on the pointing hand's own side (the wrist panel is on the other; the + // earlier rows left their placements straight ahead) + const side = pointer === 'left' ? -1 : 1; + await aimHandAt([0.9 * side, 0, -0.9], fromH); + await settle(4); + const ghostH = (await g(() => window.__stores.placeGhost.ghostInfo())).items[0]?.position; + await pinch(pointer, 0); + await settle(4); + await h.eventually(count, (n) => n === n0 + 1, 'releasing the pinch over the floor drops ONE Duck', 8000); + const where = await g(async () => { + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + return group.children[group.children.length - 1].position.toArray(); + }); + h.check(!!ghostH && near(where, ghostH, 0.02), `it lands at the ghost the hand's ray showed (${where.map((v) => v.toFixed(2))} vs ${ghostH?.map((v) => v.toFixed(2))})`); + // pinch-click arms; the next pinch places + await aimCtl('card:item:default/Duck'); + await pinchTap(); + h.check((await g(() => window.__stores.vrPlace.vrPlaceDebug())).active, 'a pinch-click arms the card'); + d = await dbg(); + h.check(/pinch to place/.test(d.status), `and says so for a hand (${d.status})`); + await aimHandAt([0.6 * side, 0, -1.4], fromH); + await pinchTap(); + await h.eventually(count, (n) => n === n0 + 2, 'the next pinch places it', 8000); + await g(() => window.__stores.vrPlace.cancelVRPlace('test')); + await g(() => window.__stores.vrAssetBrowser.closeVRAssetBrowser()); + + // a precise tool: the sculpt brush asks for a controller + const sc = () => g(() => window.__stores.vrSculpt.vrSculptDebug()); + await g(() => { + const s = window.__stores; + s.vrMenuOpen.set(true); + s.vrControls.executeVRMenuAction('nav:add'); + s.vrControls.executeVRMenuAction('terrain'); + }); + await h.eventually(sc, (x) => x.active && !!x.uuid, 'premise: Add ▸ Terrain starts a VR sculpt session', 8000); + await g(() => window.__stores.vrMenuOpen.set(false)); + const terr = await g(() => { + let grp; + window.__stores.objectsGroup.subscribe((x) => (grp = x))(); + const t = grp.children.find((c) => c.userData?.terrain); + t.updateMatrixWorld(true); + const e = t.matrixWorld.elements; + return [e[12], e[13], e[14]]; + }); + await aimHandAt(terr, fromH); + const strokes0 = (await sc()).strokes ?? null; + await pinchTap(); + const note = await hdbg(); + const strokes1 = (await sc()).strokes ?? null; + h.check(note.note?.text === 'Pick up a controller to sculpt' && note.note?.hand === pointer, `a pinch on the brush says "Pick up a controller" at that hand (${JSON.stringify(note.note)})`); + h.check(note.chipVisible, 'the note is drawn by the hand'); + h.check(strokes0 != null && strokes0 === strokes1, `and no stroke ran (${strokes0} -> ${strokes1})`); + await g(() => window.__stores.vrControls.executeVRMenuAction('sculpt:done')); + await g(() => (window.__iwer.device.primaryInputMode = 'controller')); + await settle(10); + } + await iwer.exit(page); await h.finish(browser); }); From f3ce2f15a28a4ff23c5a0f092fd258b24fbe1fa3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 00:39:03 +0300 Subject: [PATCH 08/29] [perf] 52-browser P4: the wrist panel repaints only on change; frame cost on the Quest 3 profile - the panel compares a few inputs per frame and builds its card list only when one moved (the Free tab is 400 rows; it used to be walked every frame); one laser raycast per slot - frame-hook and repaint counters on the panel and the laser ghost - suite row P (IWER metaQuest3, CPU x4): panel 0.59 ms/frame, a repaint 1.5 ms (only when the view changes), the ghost 1.17 ms/frame, +1 draw call for the panel, +2 for a box ghost Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/vr/assetBrowser.js | 57 ++++++++++++++--- src/lib/vr/placeRay.js | 10 ++- tests/e2e/vr-asset-browser-iwer.test.cjs | 80 ++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 10 deletions(-) diff --git a/src/lib/vr/assetBrowser.js b/src/lib/vr/assetBrowser.js index 7207be00d..67ba14471 100644 --- a/src/lib/vr/assetBrowser.js +++ b/src/lib/vr/assetBrowser.js @@ -626,20 +626,56 @@ function isWristSlot(index) { } // ---- per frame ----------------------------------------------------------------------------------------- +/** + * Repaint when what the panel shows could have changed. The INPUTS are compared first (a few numbers and + * strings, every frame) — building the view walks whole lists (400 free models, the library), which is not + * a per-frame cost at 72-90 Hz. + */ function redraw() { - const v = currentView(); - const sig = JSON.stringify([v, hover[0], hover[1], imagesLoaded, data.rev, Object.keys(jobs).length]); - if (sig === drawnSig) return; - drawnSig = sig; + const placing = get(vrPlacing); + const inputs = [ + data.rev, + data.loading, + view.tab, + view.pack?.name ?? '', + view.query, + view.page, + hover[0], + hover[1], + imagesLoaded, + JSON.stringify(jobs), + status.text && Date.now() < status.until ? status.text : '', + placing ? placing.label + '|' + placing.hand + '|' + placing.armed : '', + placingKey, + mode === 'wrist' + ].join('\u0001'); + if (inputs === drawnSig) return; + drawnSig = inputs; const g = /** @type {CanvasRenderingContext2D} */ (canvas?.getContext('2d')); if (!g) return; - hits = drawAssetPanel(g, v, { images: (url) => imageFor(url), hover: hover[0] ?? hover[1] }); + const t0 = performance.now(); + hits = drawAssetPanel(g, currentView(), { images: (url) => imageFor(url), hover: hover[0] ?? hover[1] }); texture.needsUpdate = true; + perf.paintMs += performance.now() - t0; debug.draws++; } +/** the suites' cost reading: frame-hook time (ms, summed), frames, repaints and their paint time */ +const perf = { frames: 0, frameMs: 0, paintMs: 0 }; +/** this frame's laser-on-panel per slot (the stick owner asks every frame; one raycast per slot) */ +const onPanelNow = [false, false]; + function frame() { if (!mesh) return; + const t0 = performance.now(); + frameBody(); + if (mesh.visible) { + perf.frames++; + perf.frameMs += performance.now() - t0; + } +} + +function frameBody() { const r = /** @type {any} */ (get(globalRenderer)); const live = get(vrAssetBrowserOpen) && !!r?.xr?.isPresenting && get(isVRMode); if (!live) { @@ -656,9 +692,11 @@ function frame() { mesh.updateMatrixWorld(true); jobs = cc0ImportRef ? get(cc0ImportRef.cc0Jobs) : {}; for (const i of [0, 1]) { - hover[i] = isWristSlot(i) ? null : (buttonAt(i)?.id ?? null); + const hit = isWristSlot(i) || !mesh.visible ? null : laserHit(i); + hover[i] = hit?.uv ? (hitAtUv(hits, hit.uv)?.id ?? null) : null; // the laser on the panel: that hand's stick pages (up = back, down = forward) - const onPanel = !isWristSlot(i) && !!laserHit(i); + const onPanel = !!hit; + onPanelNow[i] = onPanel; const y = onPanel ? (axesForSlot(i)[3] ?? 0) : 0; if (stickArmed[i] && Math.abs(y) > STICK_FLICK) { stickArmed[i] = false; @@ -672,7 +710,7 @@ function frame() { /** which slots' lasers are on the panel (their sticks page instead of turning) */ function stickHand() { - for (const i of [0, 1]) if (mesh?.visible && !isWristSlot(i) && laserHit(i)) return handOfSlot(i); + for (const i of [0, 1]) if (mesh?.visible && onPanelNow[i]) return handOfSlot(i); return null; } @@ -821,6 +859,7 @@ export function vrAssetBrowserDebug() { presses: debug.presses.slice(), lastPress: debug.lastPress, loading: data.loading, - freeCount: data.free?.length ?? null + freeCount: data.free?.length ?? null, + perf: { ...perf } }; } diff --git a/src/lib/vr/placeRay.js b/src/lib/vr/placeRay.js index f1011abcb..1037f0e9a 100644 --- a/src/lib/vr/placeRay.js +++ b/src/lib/vr/placeRay.js @@ -66,7 +66,7 @@ function swallowSelect(index) { return !!laser && !beamTarget(laser).panel; } /** the suites' view */ -const debug = { placed: 0, cancelled: 0, rotations: 0, onPanelFrames: 0, lastEnd: /** @type {string | null} */ (null) }; +const debug = { placed: 0, cancelled: 0, rotations: 0, onPanelFrames: 0, lastEnd: /** @type {string | null} */ (null), frames: 0, frameMs: 0 }; /** the laser of `hand` as a world raycaster, or null (not tracked) @param {'left' | 'right'} hand */ function laserOf(hand) { @@ -173,6 +173,14 @@ function placingSlot() { /** One frame: the ghost follows the placing hand's laser; on a panel there is no ghost. */ function frame() { + if (!current) return; + const t0 = performance.now(); + frameBody(); + debug.frames++; + debug.frameMs += performance.now() - t0; +} + +function frameBody() { if (!current) return; if (!rayPlaceActive()) { // placeDrag ended underneath us (a scene clear, another drag): let go too diff --git a/tests/e2e/vr-asset-browser-iwer.test.cjs b/tests/e2e/vr-asset-browser-iwer.test.cjs index fecf6cbc7..cf0edd7d2 100644 --- a/tests/e2e/vr-asset-browser-iwer.test.cjs +++ b/tests/e2e/vr-asset-browser-iwer.test.cjs @@ -17,6 +17,10 @@ // pinch held while the hand moves drops the item where the pinch lets go, a pinch-click arms it and the // next pinch places; a PRECISE tool (the sculpt brush) answers a pinch with "Pick up a controller" at // that hand and does nothing +// P. the cost on the Quest 3 profile (IWER metaQuest3, CPU x4 like perf-games): the panel's frame hook, +// its repaints while the laser sweeps the cards, and the laser ghost's frame hook, per XR frame — and +// the meshes they add (the panel 1, a box ghost 3, the hand note 1). Headless says nothing about the +// Quest GPU; the ms are the CPU side, which is what these hooks add. // Run: APP_URL=https://theprototype.app:5484/ node tests/e2e/vr-asset-browser-iwer.test.cjs (ONLY=G,… to pick rows) const h = require('./helpers.cjs'); const iwer = require('./iwer.cjs'); @@ -528,6 +532,82 @@ h.run(async () => { await settle(10); } + + // ---- P. the cost on the Quest 3 profile ------------------------------------------------------------- + if (want('P')) { + console.log('\n=== P. frame cost (IWER Quest 3 profile, CPU x4) ==='); + await iwer.pose(page, 'head', [0, 1.6, 0]); + await iwer.pose(page, menuHand, [menuHand === 'left' ? -0.18 : 0.18, 1.25, -0.3], { pitch: 0.3 }); + const from = [pointer === 'left' ? -0.12 : 0.12, 1.3, -0.15]; + await iwer.pose(page, pointer, from); + await g(() => { + const ab = window.__stores.vrAssetBrowser; + if (!ab.vrAssetBrowserDebug().open) ab.openVRAssetBrowser(); + }); + await settle(6); + await g(() => window.__stores.vrAssetBrowser.pressAssetButton('tab:free')); + await h.eventually(() => g(() => window.__stores.vrAssetBrowser.vrAssetBrowserDebug().cards.length), (n) => n > 0, 'premise: the Free tab lists models', 10000); + const aimAtCtl = async (id) => { + const pt = await g((cid) => window.__stores.vrAssetBrowser.assetControlPoint(cid), id); + const dd = [pt[0] - from[0], pt[1] - from[1], pt[2] - from[2]]; + await iwer.pose(page, pointer, from, { yaw: Math.atan2(-dd[0], -dd[2]), pitch: Math.atan2(dd[1], Math.hypot(dd[0], dd[2])) }); + }; + const cards = (await g(() => window.__stores.vrAssetBrowser.vrAssetBrowserDebug())).cards; + const cdp = await page.context().newCDPSession(page); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + const p0 = await g(() => window.__stores.vrAssetBrowser.vrAssetBrowserDebug().perf); + const t0 = Date.now(); + const draws0 = (await g(() => window.__stores.vrAssetBrowser.vrAssetBrowserDebug())).draws; + // the laser sweeps the cards: every hop is a hover change = a repaint + a texture upload + for (let k = 0; k < 12; k++) { + await aimAtCtl('card:' + cards[k % cards.length]); + await settle(5); + } + const p1 = await g(() => window.__stores.vrAssetBrowser.vrAssetBrowserDebug().perf); + const draws1 = (await g(() => window.__stores.vrAssetBrowser.vrAssetBrowserDebug())).draws; + // the ghost on the laser (a library model armed), the laser on the floor + await g(() => window.__stores.vrAssetBrowser.pressAssetButton('tab:library')); + await settle(4); + const lib = (await g(() => window.__stores.vrAssetBrowser.vrAssetBrowserDebug())).cards.find((k) => k.startsWith('lib:') || k.startsWith('prefab:')); + await g((k) => window.__stores.vrAssetBrowser.pressAssetButton('card:' + k), lib); + await iwer.pose(page, pointer, [from[0], 1.2, -0.3], { pitch: -0.75 }); + const r0 = await g(() => window.__stores.vrPlace.vrPlaceDebug()); + await settle(60); + const r1 = await g(() => window.__stores.vrPlace.vrPlaceDebug()); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }); + const meshes = await g(() => { + let sc; + window.__stores.globalScene.subscribe((v) => (sc = v))(); + const count = (o) => { + let n = 0; + o?.traverse((x) => ((x.isMesh || x.isLineSegments) && x.visible ? n++ : 0)); + return n; + }; + return { panel: count(sc.getObjectByName('vr-asset-browser')), ghost: count(window.__stores.placeGhost.ghostRoot()) }; + }); + await g(() => window.__stores.vrPlace.cancelVRPlace('test')); + const panelMs = (p1.frameMs - p0.frameMs) / Math.max(1, p1.frames - p0.frames); + const paintMs = (p1.paintMs - p0.paintMs) / Math.max(1, draws1 - draws0); + const ghostMs = (r1.frameMs - r0.frameMs) / Math.max(1, r1.frames - r0.frames); + const result = { + profile: 'IWER metaQuest3, CPU x4 (Emulation.setCPUThrottlingRate)', + panelFrameMs: +panelMs.toFixed(3), + panelFrames: p1.frames - p0.frames, + repaints: draws1 - draws0, + paintMsPerRepaint: +paintMs.toFixed(3), + ghostFrameMs: +ghostMs.toFixed(3), + ghostFrames: r1.frames - r0.frames, + meshes, + wallSeconds: (Date.now() - t0) / 1000 + }; + console.log('PERF ' + JSON.stringify(result)); + if (SHOTS) fs.writeFileSync(path.join(SHOTS, 'perf-quest3-cpu4.json'), JSON.stringify(result, null, 2)); + h.check(result.panelFrames > 20 && panelMs < 1.5, `the panel's frame hook costs ${panelMs.toFixed(3)} ms/frame at CPU x4 (budget 13.9 / 11.1 ms per frame at 72 / 90 Hz)`); + h.check(result.repaints >= 6 && paintMs < 8, `a repaint (the laser moving card to card) costs ${paintMs.toFixed(2)} ms at CPU x4, only when the view changes (${result.repaints} repaints)`); + h.check(result.ghostFrames > 20 && ghostMs < 3, `the laser ghost costs ${ghostMs.toFixed(3)} ms/frame at CPU x4`); + h.check(meshes.panel === 1 && meshes.ghost <= 4, `draw calls added: the panel ${meshes.panel}, the ghost ${meshes.ghost}`); + } + await iwer.exit(page); await h.finish(browser); }); From 74d2073bd3e7aae4778e20db6396c0d6c9eccd01 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 12:48:33 +0300 Subject: [PATCH 09/29] [feat] 52 V5: universal two-grip world grab (miniature), detents to 1:50, Back to 1:1 - both grips = the world in Edit whatever the rays hit or the hands are inside (Open Brush / Gravity Sketch / ShapesXR); a first-hand grab is TENTATIVE (no selection, nothing sent) until it moves > 2 cm or 250 ms pass; an unmoved first grab joined by the second grip goes back to its start pose with no undo entry and the world grab starts - two-hand object scale (+ rotate with the hands' axis) only when the first hand already MOVED the object; a second hand INSIDE another small object takes that one instead (33 G4 by touch) - grip-by-contact skips objects that enclose the headset, are larger than 2 m, are terrain or are locked (vrGrip.contactGrabAllowed, smallest containing object wins); terrain never moves by grip - Settings > VR "Both grips scale the world: Always / Only in empty air" (vrBothGripsWorld) - detents 1:50 1:10 1:5 1:2 1:1 2x 5x 10x (WORLD_SCALE_MIN 0.05 -> 0.02), readout "1:10" below life size, the 53 comfort vignette hears the world gesture's scale/yaw rate, radial root "Back to 1:1" while the world is off life size - suites: vr-universal-world-grab (new, IWER; red on the 51 union in 1/3/4/5), vr-grips-by-mode, vr-two-hand-grab, vr-world-grab adapted; unit vrGrip / vrWorldSnap / gameShell Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/play/VRMenu.svelte | 3 +- src/lib/comfortVignette.js | 25 +- src/lib/vr/grip.js | 222 ++++++++++++- src/lib/vr/prefs.js | 5 + src/lib/vr/radial.js | 2 +- src/lib/vr/settingsSchema.js | 22 +- src/lib/vr/state.js | 3 +- src/lib/vr/worldSnap.js | 22 +- src/lib/vrGrip.js | 63 +++- src/lib/vrRadialMenu.js | 13 +- tests/e2e/vr-grips-by-mode.test.cjs | 4 +- tests/e2e/vr-two-hand-grab.test.cjs | 13 +- tests/e2e/vr-universal-world-grab.test.cjs | 364 +++++++++++++++++++++ tests/e2e/vr-world-grab.test.cjs | 4 +- tests/unit/gameShell.test.js | 6 + tests/unit/vrGrip.test.js | 64 ++++ tests/unit/vrWorldSnap.test.js | 15 +- 17 files changed, 805 insertions(+), 45 deletions(-) create mode 100644 tests/e2e/vr-universal-world-grab.test.cjs diff --git a/src/components/play/VRMenu.svelte b/src/components/play/VRMenu.svelte index 2bad96d30..23e1ffd7b 100644 --- a/src/components/play/VRMenu.svelte +++ b/src/components/play/VRMenu.svelte @@ -48,7 +48,8 @@ $remoteSimulating, // PFX-C: the Physics sector label/dot tracks the sim $selectedObject, $selectedObjects, // D4: counted labels + Make Group re-derive on the SET - $vrSettingsVersion // 36: a setting's value line follows any VR setting change + $vrSettingsVersion, // 36: a setting's value line follows any VR setting change + $vrMenuOpen // 52 (V5): a sector whose visibility follows the world (Back to 1:1) re-reads on every open ) // 36: label / icon / value are resolved HERE (a function label read inside the template would be // untracked — the legacy-mode rule the 31 R1 note below describes) diff --git a/src/lib/comfortVignette.js b/src/lib/comfortVignette.js index a26b14b36..6f69aaf3e 100644 --- a/src/lib/comfortVignette.js +++ b/src/lib/comfortVignette.js @@ -21,6 +21,9 @@ import { vrComfortVignette, vignetteLevel } from './vr/prefs.js'; let stick = 0; let turn = 0; +/** 52 (V5): the two-grip world gesture's rate (log-scale + yaw, per second) and when it was heard */ +let gesture = 0; +let gestureAt = -Infinity; let strength = 0; /** @type {THREE.Mesh | null} */ let mesh = null; @@ -34,16 +37,28 @@ export function noteArtificialMotion(stickMag, turnRad) { turn = Math.max(0, Number(turnRad) || 0); } +/** + * 52 (V5): the two-grip world grab, every frame it runs — how fast the world scales and turns around you + * (|d ln scale| + |d yaw|, per second). Scaling the world is vection like walking: the ring closes in. + * @param {number} rate + */ +export function noteWorldGesture(rate) { + gesture = Math.max(0, Number(rate) || 0); + gestureAt = performance.now(); +} + /** * The strength the ring should head toward, 0..1. Pure; exported for the suites. A stick * inside its deadzone and no turn = 0. * @param {number} stickMag @param {number} turnRad radians applied this frame @param {number} dt seconds + * @param {number} [worldRate] 52 (V5): the world gesture's rate (noteWorldGesture); ~1/s = full ring */ -export function vignetteTarget(stickMag, turnRad, dt) { +export function vignetteTarget(stickMag, turnRad, dt, worldRate = 0) { const move = stickMag > 0.2 ? Math.min(1, (stickMag - 0.2) / 0.6) : 0; const rate = dt > 0 ? turnRad / dt : 0; // radians per second const spin = Math.min(1, rate / 1.2); - return Math.max(move, spin); + const world = worldRate > 0.08 ? Math.min(1, (worldRate - 0.08) / 0.9) : 0; // a trembling hand rings nothing + return Math.max(move, spin, world); } /** Ease `current` toward `target`: fast in, slower out. Pure. @param {number} current @param {number} target @param {number} dt */ @@ -87,7 +102,9 @@ export function vignetteFrame(head, dt = 1 / 72) { level = vignetteLevelNow(); const cap = LEVEL_CAP[level] ?? 0; const on = !!head && cap > 0; - const target = on ? vignetteTarget(stick, turn, dt) * cap : 0; + // the gesture rate counts only while it is fresh (the world grab stopped = no more notes) + const worldRate = performance.now() - gestureAt < 120 ? gesture : 0; + const target = on ? vignetteTarget(stick, turn, dt, worldRate) * cap : 0; strength = on ? easeVignette(strength, target, dt) : 0; if (!on && !mesh) return 0; if (!mesh) mesh = build(); @@ -127,5 +144,5 @@ function vignetteLevelNow() { /** the suites' view */ export function vignetteDebug() { - return { strength, visible: !!mesh?.visible, stick, turn, level }; + return { strength, visible: !!mesh?.visible, stick, turn, level, world: performance.now() - gestureAt < 120 ? gesture : 0 }; } diff --git a/src/lib/vr/grip.js b/src/lib/vr/grip.js index a9b7fc95a..fbf709c55 100644 --- a/src/lib/vr/grip.js +++ b/src/lib/vr/grip.js @@ -17,7 +17,7 @@ import { editorMode, pokeScene } from '../../stores/sceneStore'; -import { isScenery, pickGripTarget, gripMovesWorld } from '../vrGrip'; +import { isScenery, pickGripTarget, gripMovesWorld, contactGrabAllowed, secondGripVerdict, grabMoved, GRAB_JOIN_MS } from '../vrGrip'; import { pointGrabAllowed } from '../pointGrab'; import { resolvePlaySettings } from '../playSettings'; import { withinReach } from '../playReach'; @@ -75,6 +75,8 @@ import { stretch } from './tools.js'; import { perfMark } from '../perf/perfMarks.js'; import { handOf, pointerHand } from './bindings.js'; import { applyWorldSnap, endWorldSnap, yawOf } from './worldSnap.js'; +import { vrBothGripsWorld } from './prefs.js'; +import { noteWorldGesture } from '../comfortVignette'; /** @type {any[]} per-hand grabs, indexed by controller SLOT: each is { object, index, prevPos, * prevQuat, before, ... }. 33 G4: one per hand, so two hands hold two things at once — a single @@ -182,15 +184,22 @@ export function vrGripDebug() { worldGrab: !!S.worldGrab, worldPan: !!S.worldPan, panReach: S.worldPan ? S.worldPan.reach : null, // 33: the stick reel's distance - emptyAir: [...emptyAirSqueeze] + emptyAir: [...emptyAirSqueeze], + // 52 (V5): the universal world grab + tentative: grabs.map((g) => !!g?.tentative), + moved: grabs.map((g) => !!g?.moved), + scaleRotates: !!S.scaleGrab?.axis0, + ...universalStats }; } +/** 52 (V5): what the second grips decided, for the suites */ +const universalStats = { secondGrip: /** @type {string | null} */ (null), dropped: 0, worldByUniversal: 0, twoHandScales: 0 }; // ---- world grab (71): BOTH grips in empty air scale/rotate/pan the world --- // The gesture transforms the world-grab-rig LOCALLY (peers see nothing — // broadcasts stay in objectsGroup-local coords, which never change here). -const WORLD_SCALE_MIN = 0.05; +const WORLD_SCALE_MIN = 0.02; // 52 (V5): 1:50, the miniature table (was 0.05) const WORLD_SCALE_MAX = 20; export const emptyAirSqueeze = [false, false]; @@ -297,6 +306,17 @@ export function updateWorldGrab() { } } const next = computeWorldGrabTransform(live.start, live.now, S.worldGrab.rig0, applyWorldSnap); + // 52 (V5): the comfort vignette (53) closes in while the world scales or turns around you + const now = performance.now(); + const last = S.worldGrab.last; + if (last) { + const dt = Math.max((now - last.at) / 1000, 1e-3); + const yaw = yawOf(next.quat); + let dYaw = yaw - last.yaw; + dYaw = Math.atan2(Math.sin(dYaw), Math.cos(dYaw)); + noteWorldGesture(Math.abs(Math.log(next.scale / last.scale)) / dt + Math.abs(dYaw) / dt); + } + S.worldGrab.last = { at: now, scale: next.scale, yaw: yawOf(next.quat) }; rig.position.fromArray(next.pos); rig.quaternion.fromArray(next.quat); rig.scale.setScalar(next.scale); @@ -325,6 +345,30 @@ export function containedTopLevel(point, group) { return null; } +/** + * 52 (V5): the object a hand INSIDE it may take by touch — the SMALLEST containing top-level object that + * vrGrip.contactGrabAllowed passes (a cup on a table, not the table; never the room you stand in). + * @param {any} point @param {any} group @param {any} head @param {any[]} locked + */ +export function containedGrabbable(point, group, head, locked = []) { + if (!group) return null; + const box = new THREE.Box3(); + let best = null; + let bestVolume = Infinity; + for (const child of group.children) { + box.setFromObject(child); + if (!isFinite(box.min.x) || !box.containsPoint(point)) continue; + const flags = { terrain: !!child.userData?.terrain, locked: !!locked?.find((/** @type {any} */ lock) => lock[1] === child.uuid) }; + if (!contactGrabAllowed(box, head, flags)) continue; + const volume = (box.max.x - box.min.x) * (box.max.y - box.min.y) * (box.max.z - box.min.z); + if (volume < bestVolume) { + best = child; + bestVolume = volume; + } + } + return best; +} + /** @type {any} active VR vertex-handle drag: {index, offset} */ export let vertexGrab = null; /** @type {any} active VR face grab (122): {index, pos0, quat0, push, scale} */ @@ -471,6 +515,8 @@ export function onSqueezeStart(index) { return; } } + // 52 (V5): a SECOND grip while the other hand's is held — both grips are the world, whatever this ray hits + if (gripHeld[1 - index] && universalSecondGrip(index)) return; // grip on a follower window (111): hold to detach it into adjust mode const windowId = windowHitAt(index); if (windowId) { @@ -572,18 +618,140 @@ export function onSqueezeStart(index) { scaleFactor: 1, prevPos: cPos, prevQuat: cQuat, - before: transformStateOf(object) + before: transformStateOf(object), + // 52 (V5): where it was taken (world) and when — a second grip before it MOVES makes this the world + // grab; until it moves or GRAB_JOIN_MS pass it is TENTATIVE: not selected, nothing sent + startWorld: object.getWorldPosition(new THREE.Vector3()).toArray(), + startedAt: performance.now(), + moved: false, + tentative: !interact && get(vrBothGripsWorld) === 'always' }; syncGrabbedHand(); // 30b (C4): a grab lands with a `hit` (a gated no-op in Edit, like every pulse) hapticPattern('hit', renderer.xr.getController(index)?.userData?.handedness ?? undefined); // 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 (core-games): a PLAYER's grab reaches On Grab nodes (Edit moves things, it does not play) - else fireObjectGrab(object.uuid); + // selection shell, no inspector (Edit keeps all three). 52 (V5): a tentative grab selects when it commits. + if (interact) fireObjectGrab(object.uuid); // 30b (core-games): a PLAYER's grab reaches On Grab nodes + else if (!grabs[index].tentative) selectObject(object.uuid); // locks it for peers, updates selection state +} + +/** + * 52 (V5): what a SECOND grip does while the other hand's grip is held (vrGrip.secondGripVerdict). Returns + * true when it took the press: 'world' drops an unmoved first grab and starts the two-grip world gesture, + * 'scale' turns an already-moved object in the other hand into the two-hand scale/rotate. + * @param {number} index the hand whose grip just closed + */ +function universalSecondGrip(index) { + const other = 1 - index; + const held = grabs[other]; + const verdict = secondGripVerdict({ + setting: get(vrBothGripsWorld), + mode: get(editorMode) === 'interact' ? 'interact' : 'edit', + stretch: !!get(vrStretchObject), + otherBusy: + S.windowGrabPending?.index === other || + S.windowGrab?.index === other || + faceGrabHand?.index === other || + vertexGrab?.index === other || + S.vertexTriggerGrab?.index === other || + !!S.scaleGrab || + !!S.worldGrab, + otherGrab: held ? { moved: !!held.moved, set: !!held.set } : null, + // only asked when it matters (a moved object in the other hand): is THIS hand inside another object? + takesOther: !!held?.moved && touchesOtherObject(index, held) + }); + universalStats.secondGrip = verdict; + if (verdict === 'legacy') return false; + if (verdict === 'scale') { + beginTwoHandScale(held); + universalStats.twoHandScales++; + return true; + } + if (held) dropUnmovedGrab(other); + // both hands are WORLD hands now: releasing either lets a still-held pan hand pan (onSqueezeEnd) + emptyAirSqueeze[index] = true; + emptyAirSqueeze[other] = true; + beginWorldGrab(); + universalStats.worldByUniversal++; + return true; +} + +/** + * 52 (V5): a first-hand grab that never moved, overtaken by the second grip: the object goes back to the pose + * it was taken in, NO undo entry; nothing is sent while it was tentative (else one move puts peers back too). + * @param {number} i + */ +function dropUnmovedGrab(i) { + const grab = grabs[i]; + if (!grab) return; + grabs[i] = null; + const members = grab.set ? grab.set : [{ object: grab.object, before: grab.before }]; + for (const m of members) { + m.object.position.fromArray(m.before.pos); + m.object.rotation.fromArray(m.before.rot); + m.object.scale.fromArray(m.before.scale); + m.object.updateMatrixWorld(true); + const uuid = m.object.uuid; + import('../physics').then((mod) => mod.releaseBody(uuid)); + resumeAnimation(uuid); + if (!grab.tentative && !grab.set) broadcastMove(m.object, true); + } + if (!grab.tentative && grab.set) sendSetMoves(grab); + universalStats.dropped++; + syncGrabbedHand(); + pokeScene(); } +/** + * 52 (V5): is this hand INSIDE (touching) an object other than the one the other hand holds — one it may take by + * touch? Then it holds that one (two things at once, 33 G4). A RAY on something else does not count: with an + * object already moving in the other hand, the second grip scales it wherever this ray points (Open Brush). + * @param {number} index @param {any} held + */ +function touchesOtherObject(index, held) { + const camera = /** @type {any} */ (get(globalCamera)); + const head = camera ? camera.getWorldPosition(new THREE.Vector3()) : null; + const hand = renderer.xr.getController(index).getWorldPosition(new THREE.Vector3()); + const inside = containedGrabbable(hand, get(objectsGroup), head, get(lockedObjects)); + return !!inside && inside !== held.object && !held.set?.some((/** @type {any} */ m) => m.object === inside); +} + +/** 52 (V5): the moved object in the other hand becomes a two-hand scale + rotate @param {any} held */ +function beginTwoHandScale(held) { + const a = renderer.xr.getController(0).getWorldPosition(new THREE.Vector3()); + const b = renderer.xr.getController(1).getWorldPosition(new THREE.Vector3()); + const axis = b.sub(a); + S.scaleGrab = { + object: held.object, + startDistance: Math.max(axis.length(), 0.05), + startScale: held.object.scale.clone(), + before: held.before, + axis0: axis.lengthSq() > 1e-8 ? axis.clone().normalize() : null, + startQuat: held.object.quaternion.clone() + }; + grabs[held.index] = null; + syncGrabbedHand(); +} + +/** 52 (V5): a tentative grab becomes a real one — selected (the lock) and sent @param {any} grab */ +function commitTentative(grab) { + if (!grab?.tentative) return; + grab.tentative = false; + if (grab.set) sendSetMoves(grab); + else { + selectObject(grab.object.uuid); + broadcastMove(grab.object, true); + } +} + +/** 52 (V5): per frame — has the held object moved yet, and is the tentative window over? @param {any} grab */ +function trackGrabMotion(grab) { + if (!grab.startWorld) return; + if (!grab.moved) grab.moved = grabMoved(grab.startWorld, grab.object.getWorldPosition(_motionProbe).toArray()); + if (grab.tentative && (grab.moved || performance.now() - grab.startedAt >= GRAB_JOIN_MS)) commitTentative(grab); +} +const _motionProbe = new THREE.Vector3(); + /** * 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 @@ -628,7 +796,8 @@ export function gripTargetOf(ray, handPos, mode, gripHand = 'right') { scenery: isScenery(box.isEmpty() ? null : box, head), grabbable: holdable && near, // 33 E4: in Edit a SELECTED wall/floor/arena is held like any object - selected: selectedNow.includes(object.uuid) + selected: selectedNow.includes(object.uuid), + terrain: !!object.userData?.terrain // 52 (V5): never by grip }; }; /** @type {any[]} */ @@ -654,8 +823,10 @@ export function gripTargetOf(ray, handPos, mode, gripHand = 'right') { lastGripRefusal = null; return order[picked]; } - // a hand INSIDE an object needs no pointer (100.3) — the same rule decides - const inside = containedTopLevel(handPos, group); + // a hand INSIDE an object needs no pointer (100.3) — the same rule decides. 52 (V5): and only what a hand + // can sensibly hold by touch: never what encloses the headset, is larger than ~2 m, is terrain or is locked + // (vrGrip.contactGrabAllowed — those move deliberately: select with the ray, then grip) + const inside = containedGrabbable(handPos, group, head, locked); if (inside && pickGripTarget([describe(inside, handPos)], mode) === 0) { lastGripRefusal = null; return inside; @@ -752,6 +923,7 @@ export function onSqueezeEnd(index) { return; } grabs[index] = null; + commitTentative(grab); // 52 (V5): a grip tap on an object still selects it endGrab(object, grab.interact ? null : grab.before); syncGrabbedHand(); hapticPulse(0.18, 24); @@ -835,7 +1007,12 @@ function beginSetGrab(index, object) { handPos0: controller.getWorldPosition(new THREE.Vector3()), handQuat0: controller.getWorldQuaternion(new THREE.Quaternion()), scaleFactor: 1, - /** @type {any} */ before: null + /** @type {any} */ before: null, + // 52 (V5): the same tentative window as a single grab (vrGrip.GRAB_JOIN_MS) + startWorld: object.getWorldPosition(new THREE.Vector3()).toArray(), + startedAt: performance.now(), + moved: false, + tentative: get(vrBothGripsWorld) === 'always' }; grab.before = { setGrab: grab }; grabs[index] = grab; @@ -857,6 +1034,8 @@ function updateSetGrab(grab) { grab.scaleFactor = grabStickAdjust({ length: 1, scale: grab.scaleFactor, x: axes[2] ?? 0, y: 0 }).scale; poseSetGrab(grab.set, grab.pivot, move, turn, grab.scaleFactor); pokeScene(); + trackGrabMotion(grab); + if (grab.tentative) return; // 52 (V5): nothing sent before it commits const now = Date.now(); if (now - lastSetSent < 50) return; lastSetSent = now; @@ -936,7 +1115,8 @@ export function updateGrab(grab) { grab.prevPos.copy(position); grab.prevQuat.copy(quaternion); pokeScene(); - broadcastMove(object); + trackGrabMotion(grab); + if (!grab.tentative) broadcastMove(object); // 52 (V5): nothing sent before it commits return; } @@ -965,7 +1145,8 @@ export function updateGrab(grab) { grab.prevPos.copy(position); grab.prevQuat.copy(quaternion); pokeScene(); - broadcastMove(object); + trackGrabMotion(grab); + if (!grab.tentative) broadcastMove(object); } export function updateScaleGrab() { @@ -976,6 +1157,19 @@ export function updateScaleGrab() { factor = Math.max(Math.round(factorRaw / step) * step, step); } S.scaleGrab.object.scale.copy(S.scaleGrab.startScale).multiplyScalar(factor); + // 52 (V5): the universal two-hand grab also TURNS the object with the hands' axis (about its own origin) + if (S.scaleGrab.axis0) { + const object = S.scaleGrab.object; + const a = renderer.xr.getController(0).getWorldPosition(new THREE.Vector3()); + const axis = renderer.xr.getController(1).getWorldPosition(new THREE.Vector3()).sub(a); + if (axis.lengthSq() > 1e-8) { + const turn = new THREE.Quaternion().setFromUnitVectors(S.scaleGrab.axis0, axis.normalize()); + const parentQuat = object.parent ? object.parent.getWorldQuaternion(new THREE.Quaternion()) : new THREE.Quaternion(); + // the world-space turn, conjugated into the object's parent frame + const local = parentQuat.clone().invert().multiply(turn).multiply(parentQuat); + object.quaternion.copy(local.multiply(S.scaleGrab.startQuat)); + } + } pokeScene(); broadcastMove(S.scaleGrab.object); } diff --git a/src/lib/vr/prefs.js b/src/lib/vr/prefs.js index 791669671..f6dab24f9 100644 --- a/src/lib/vr/prefs.js +++ b/src/lib/vr/prefs.js @@ -12,6 +12,9 @@ // vrHeightOffset metres added on top (−0.5 … +0.5), e.g. a tall player asking for a lower view // vrSnapAngleLast the snap angle to come back to when turning goes Off → Snap // vrWorldSnap 37 R10: the two-grip world gesture snaps (15° yaw steps, 1/2/5/10× scale detents) +// vrBothGripsWorld 52 (V5): 'always' (default) — both grips are the world gesture whatever the rays hit or the +// hands are inside (a first-hand grab that has not moved yet is dropped); 'emptyAir' — the +// pre-52 rule, both grips must close on empty air // vrOneHanded 53 (A7): 'off' | 'left' | 'right' — play with ONE controller: that stick moves // (forward/back) and turns (sideways), holding its press aims a teleport, B/Y opens // the radial menu (which then offers Edit / Interact and the game menu), A/X talks or @@ -77,6 +80,8 @@ export const vrHeightOffset = persisted('vrHeightOffset', 0, (r) => clampHeight( export const vrSnapAngleLast = persisted('vrSnapAngleLast', 45, oneOf(SNAP_ANGLES, 45)); /** @type {import('svelte/store').Writable} */ export const vrWorldSnap = persisted('vrWorldSnap', true, bool); +/** 52 (V5) @type {import('svelte/store').Writable<'always' | 'emptyAir'>} */ +export const vrBothGripsWorld = persisted('vrBothGripsWorld', /** @type {'always' | 'emptyAir'} */ ('always'), (r) => (r === 'emptyAir' ? 'emptyAir' : 'always')); /** @type {import('svelte/store').Writable<'off' | 'left' | 'right'>} */ export const vrOneHanded = persisted('vrOneHanded', /** @type {'off' | 'left' | 'right'} */ ('off'), (r) => (r === 'left' || r === 'right' ? r : 'off')); diff --git a/src/lib/vr/radial.js b/src/lib/vr/radial.js index 970f79c6b..093f2b839 100644 --- a/src/lib/vr/radial.js +++ b/src/lib/vr/radial.js @@ -712,7 +712,7 @@ export function executeVRMenuAction(name) { }); } else if (name === 'mic') { cycleMicMode(); - } else if (name === 'world') { + } else if (name === 'world' || name === 'world:back') { closeDollhouse(false); // 37: 1:1 also ends a dollhouse (without putting its model back) resetWorldRig(); // back to 1:1 mid-session } else if (name === 'settings') { diff --git a/src/lib/vr/settingsSchema.js b/src/lib/vr/settingsSchema.js index ae5a921ed..d37427677 100644 --- a/src/lib/vr/settingsSchema.js +++ b/src/lib/vr/settingsSchema.js @@ -36,7 +36,7 @@ import { pivotMode } from '../multiTransform'; // 37 R1: the selection's pivot p import { openVRKeyboard } from '../vrKeyboard'; import { renderer } from './core.js'; import { applyVRFrameRate } from './input.js'; -import { vrSmoothTurn, vrSmoothTurnSpeed, vrComfortVignette, vrStance, vrHeightOffset, vrSnapAngleLast, vrWorldSnap, vrOneHanded, clampHeight, SMOOTH_SPEEDS, SNAP_ANGLES, HEIGHT_LIMIT } from './prefs.js'; +import { vrSmoothTurn, vrSmoothTurnSpeed, vrComfortVignette, vrStance, vrHeightOffset, vrSnapAngleLast, vrWorldSnap, vrBothGripsWorld, vrOneHanded, clampHeight, SMOOTH_SPEEDS, SNAP_ANGLES, HEIGHT_LIMIT } from './prefs.js'; import { recenterView } from './locomotion.js'; // 53 (A7) import { vrHudPlacement, vrHudSize, vrHudHints, setVrHudPlacement, setVrHudSize, setVrHudHints } from '../vrHudPrefs'; import { vrBindings, resetBindings, mirrorBindings, isLeftHanded, VR_ACTIONS, CONTROLS_FOR, bindingOf, setBinding, actionInfo, controlName } from './bindings.js'; @@ -254,7 +254,23 @@ export const VR_SETTINGS = [ note: 'Rigid: the controller is the handle (the stick reels and scales)' }, // 37 R10: the two-grip world gesture's steps (worldSnap.js) - { id: 'worldSnap', page: 'controls', label: 'World grab snapping', icon: 'magnet', kind: 'toggle', get: () => get(vrWorldSnap), set: (v) => vrWorldSnap.set(!!v), keywords: ['world grab', 'scale', 'rotate', 'detent', 'steps', 'two grips'], note: 'Both grips: the world turns in 15° steps and sticks at 1/2/5/10× either way' }, + { id: 'worldSnap', page: 'controls', label: 'World grab snapping', icon: 'magnet', kind: 'toggle', get: () => get(vrWorldSnap), set: (v) => vrWorldSnap.set(!!v), keywords: ['world grab', 'scale', 'rotate', 'detent', 'steps', 'two grips'], note: 'Both grips: the world turns in 15° steps and sticks at 1:50 · 1:10 · 1:5 · 1:2 · 1:1 · 2× · 5× · 10×' }, + // 52 (V5): the universal world grab — both grips are the world even over objects (Open Brush / Gravity Sketch) + { + id: 'bothGripsWorld', + page: 'controls', + label: 'Both grips scale the world', + icon: 'maximize', + kind: 'choice', + options: [ + { value: 'always', label: 'Always' }, + { value: 'emptyAir', label: 'Only in empty air' } + ], + get: () => get(vrBothGripsWorld), + set: (v) => vrBothGripsWorld.set(v === 'emptyAir' ? 'emptyAir' : 'always'), + keywords: ['world grab', 'miniature', 'scale', 'two grips', 'zoom'], + note: 'Always: squeeze both grips anywhere to scale, turn and move the world; a held object scales only once it has moved' + }, { id: 'remap', page: 'controls', label: 'Remap buttons', icon: 'keyboard', kind: 'action', run: () => openVRSettingsPage('buttons'), desktop: false }, { id: 'bindingsReset', page: 'controls', label: 'Reset buttons', icon: 'refresh-cw', kind: 'action', run: () => (resetBindings(), showToast('VR buttons reset to the defaults')), desktop: false }, // ---- Display @@ -471,7 +487,7 @@ export function activateVRSetting(id, dir = 1) { export const vrSettingsTick = writable(0); /** 36-vr-ai (B9): what the in-headset Search page filters by (typed on the VR keyboard) */ export const vrSettingsQuery = writable(''); -const watched = [vrSnapAngle, vrMirrorSnapTurn, vrTeleportEnabled, vrFlying, vrMenuHand, vrMenuHold, vrGrabStyle, vrTargetHz, vrStatsOpen, peerHandStyle, vrPassthrough, vrWireframeSelection, vrVertexHold, vrSleeveEnabled, perfStatsShown, vrMicMode, vrFaceCap, vrVertexCap, vrSmoothTurn, vrSmoothTurnSpeed, vrComfortVignette, vrStance, vrHeightOffset, vrWorldSnap, vrOneHanded, vrBindings, vrSettingsQuery]; +const watched = [vrSnapAngle, vrMirrorSnapTurn, vrTeleportEnabled, vrFlying, vrMenuHand, vrMenuHold, vrGrabStyle, vrTargetHz, vrStatsOpen, peerHandStyle, vrPassthrough, vrWireframeSelection, vrVertexHold, vrSleeveEnabled, perfStatsShown, vrMicMode, vrFaceCap, vrVertexCap, vrSmoothTurn, vrSmoothTurnSpeed, vrComfortVignette, vrStance, vrHeightOffset, vrWorldSnap, vrBothGripsWorld, vrOneHanded, vrBindings, vrSettingsQuery]; /** one derived over every watched store: any change re-renders whatever shows a setting */ let version = 0; export const vrSettingsVersion = derived([vrSettingsTick, ...watched], () => ++version); diff --git a/src/lib/vr/state.js b/src/lib/vr/state.js index 347a410f1..35081c836 100644 --- a/src/lib/vr/state.js +++ b/src/lib/vr/state.js @@ -17,7 +17,8 @@ export const S = { /** @type {{index: number, prev: any, reach: number} | null} right-grip drag-the-world pan * (33: `reach` = how far along the hand's ray the gripped spot is — the stick reels it) */ worldPan: null, - /** @type {{a0: any, b0: any, rig0: {pos: any, quat: any, scale: number}} | null} */ + /** @type {{a0: any, b0: any, rig0: {pos: any, quat: any, scale: number}, last?: {at: number, scale: number, yaw: number}} | null} + * 52 (V5): `last` = the previous frame's scale + yaw, for the comfort vignette's rate */ worldGrab: null, /** @type {{id: string, index: number, startedAt: number}|null} */ windowGrabPending: null, diff --git a/src/lib/vr/worldSnap.js b/src/lib/vr/worldSnap.js index 9a1c00d25..af22c6865 100644 --- a/src/lib/vr/worldSnap.js +++ b/src/lib/vr/worldSnap.js @@ -2,10 +2,10 @@ // local world rig freely; with snapping on (Settings ▸ VR ▸ Controls ▸ World grab snapping, default on): // - the world's YAW moves in 15° steps of its ABSOLUTE heading, so 0° (the scene as authored) is always // a step — a twist lands squarely back on the room's axes; -// - the SCALE sticks at the detents 1/10 · 1/5 · 1/2 · 1 · 2 · 5 · 10× (a band of ±7 % around each, in -// log space), and slides freely between them; +// - the SCALE sticks at the detents 1:50 · 1:10 · 1:5 · 1:2 · 1:1 · 2× · 5× · 10× (a band of ±7 % around +// each, in log space), and slides freely between them (52 V5 added 1:50, the miniature table); // - every step change and every detent catch ticks the hands, and a readout between them says where -// you are ("2× · 45°"). +// you are ("2× · 45°", "1:10 · 0°"). // Hysteresis on both (a step or a detent is LEFT only a little past where it was taken), so a hand // trembling on a boundary never chatters. The maths is pure (snapWorldGrab); grip.js calls it with // the previous frame's result. The world rig is local, so none of this replicates. @@ -22,7 +22,7 @@ import { vrWorldSnap } from './prefs.js'; export const YAW_STEP = Math.PI / 12; // 15° /** the scale detents (both ways from 1:1) */ -export const SCALE_DETENTS = [0.1, 0.2, 0.5, 1, 2, 5, 10]; +export const SCALE_DETENTS = [0.02, 0.1, 0.2, 0.5, 1, 2, 5, 10]; // 52 (V5): + 1:50 /** a detent catches within ±7 % (log space) and lets go past ±11 % */ const CATCH = Math.log(1.07); const RELEASE = Math.log(1.11); @@ -58,13 +58,17 @@ export function snapWorldGrab(raw, prev) { return { total: detent ?? raw.total, yaw, step, detent }; } -/** "2×", "½×", "1.37×" @param {number} s */ +/** "2×", "1.4×" above life size; a MAP SCALE below it — "1:1", "1:10", "1:50", "1:2.7" (52 V5: a miniature + * reads as the model it is) @param {number} s */ export function scaleLabel(s) { - const frac = { 0.5: '½', 0.2: '⅕', 0.1: '⅒' }; - const key = /** @type {keyof typeof frac} */ (Math.round(s * 1000) / 1000); - if (frac[key]) return frac[key] + '×'; + if (!(s > 0)) return '1:1'; + if (s < 1 - 1e-6) { + const n = 1 / s; + return '1:' + (Math.abs(n - Math.round(n)) < 0.05 ? String(Math.round(n)) : n.toFixed(1)); + } + if (Math.abs(s - 1) < 1e-6) return '1:1'; if (Math.abs(s - Math.round(s)) < 1e-6) return Math.round(s) + '×'; - return (s >= 1 ? s.toFixed(1) : s.toFixed(2)) + '×'; + return s.toFixed(1) + '×'; } /** "45°" in 0..359 @param {number} step */ export function yawLabel(step) { diff --git a/src/lib/vrGrip.js b/src/lib/vrGrip.js index c6bddc80b..ed9d8d9d4 100644 --- a/src/lib/vrGrip.js +++ b/src/lib/vrGrip.js @@ -57,7 +57,7 @@ export function isScenery(box, head) { * EDIT, scenery you have SELECTED (the trigger selects it) is held like any object — the * select-then-manipulate rule every editor follows. Unselected scenery still passes through * to the world grab, so grabbing a room you are standing in still moves the world. - * @param {{scenery: boolean, grabbable: boolean, selected?: boolean}[]} candidates + * @param {{scenery: boolean, grabbable: boolean, selected?: boolean, terrain?: 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) @@ -66,6 +66,7 @@ export function pickGripTarget(candidates, mode) { for (let i = 0; i < candidates.length; i++) { const c = candidates[i]; if (!c) continue; + if (c.terrain) continue; // 52 (V5): terrain never moves by grip, selected or not if (c.scenery && !(mode !== 'interact' && c.selected)) continue; if (mode === 'interact') return c.grabbable ? i : -1; return i; @@ -81,3 +82,63 @@ export function pickGripTarget(candidates, mode) { export function gripMovesWorld(mode, worldGrab = false) { return mode !== 'interact' || worldGrab === true; } + +// ---- 52 (V5): THE UNIVERSAL WORLD GRAB ------------------------------------------------------------- +// The user (2026-10-21): the two-grip world gesture worked "only if controllers do not point at any object" +// — surrounded by things, every grip closed on one of them. VR editors (Open Brush, Gravity Sketch, ShapesXR) +// read BOTH GRIPS as the world, and a two-hand object scale as a follow-up of an object already in hand. +// So: both grips are the world whatever the rays hit; the second hand scales an object only when the first +// has ALREADY MOVED it; a first grab that has not moved is dropped (back to its start pose, nothing recorded, +// nothing sent) and the world takes over. And a hand merely TOUCHING or INSIDE something big never takes it. + +/** metres an object must have moved in the first hand before a second grip scales it instead of the world */ +export const GRAB_MOVED_M = 0.02; +/** ms after a first-hand grab during which it stays TENTATIVE (no selection, nothing sent) unless it moves */ +export const GRAB_JOIN_MS = 250; +/** metres: a hand touching / inside an object larger than this on any axis does not take it */ +export const CONTACT_MAX_EXTENT = 2; + +/** + * May a hand that is merely INSIDE an object (100.3 — no ray) take it? Not when the object encloses the + * headset, is larger than CONTACT_MAX_EXTENT on any axis, is terrain, or is locked — those move only + * deliberately (select with the ray, then grip; terrain never by grip). Pure. + * @param {{min: {x: number, y: number, z: number}, max: {x: number, y: number, z: number}} | null} box world bounds + * @param {{x: number, y: number, z: number} | null} head + * @param {{terrain?: boolean, locked?: boolean}} [flags] + */ +export function contactGrabAllowed(box, head, flags = {}) { + if (!box || !box.min || !box.max) return false; + if (flags.terrain || flags.locked) 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; + if (Math.max(dx, dy, dz) > CONTACT_MAX_EXTENT) return false; + if (head && 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) + return false; + return true; +} + +/** + * What a SECOND grip does while the other hand's grip is held. Pure. + * - 'legacy' the pre-52 rule decides (setting "Only in empty air", Interact, stretch mode, or the other hand + * is busy with a window / vertex / face — not an object or the air) + * - 'scale' the other hand holds an object it has already moved: two-hand scale/rotate of THAT object + * wherever this hand's ray points (unless this HAND is inside a different object it may take by + * touch: then it holds that one — 33 G4's two things at once; 'legacy' decides it) + * - 'world' everything else: drop an unmoved first grab, both grips are the world + * @param {{setting: 'always' | 'emptyAir', mode: 'edit' | 'interact', stretch?: boolean, otherBusy?: boolean, + * otherGrab?: {moved: boolean, set?: boolean} | null, takesOther?: boolean}} s + * @returns {'legacy' | 'scale' | 'world'} + */ +export function secondGripVerdict(s) { + if (s.setting !== 'always' || s.mode !== 'edit' || s.stretch || s.otherBusy) return 'legacy'; + if (s.otherGrab?.moved) return s.otherGrab.set || s.takesOther ? 'legacy' : 'scale'; + return 'world'; +} + +/** has a held object moved past GRAB_MOVED_M from where it was taken? (world metres) Pure. + * @param {number[]} start @param {number[]} now */ +export function grabMoved(start, now) { + return Math.hypot(now[0] - start[0], now[1] - start[1], now[2] - start[2]) > GRAB_MOVED_M; +} diff --git a/src/lib/vrRadialMenu.js b/src/lib/vrRadialMenu.js index 95972ebaa..6df49b35f 100644 --- a/src/lib/vrRadialMenu.js +++ b/src/lib/vrRadialMenu.js @@ -6,7 +6,8 @@ import { vrWireframeSelection, vrEditMenuOpen, vrSnapMenuOpen, - vrToolMode + vrToolMode, + worldRig } from '../stores/sceneStore'; import { environment, setEnvironment, ENVIRONMENT_PRESETS } from './environment'; import { simulating, remoteSimulating, toggleSimulation } from './physics'; @@ -38,6 +39,14 @@ function archIcon(label) { return label.startsWith('Wall') ? 'brick-wall' : label.includes('oor') ? 'door-open' : label.includes('indow') ? 'grid-2x2' : 'door-stairwell'; } +/** 52 (V5): is the local world rig off life size (scaled, turned or moved by the two-grip gesture)? */ +export function worldOffLifeSize() { + const rig = /** @type {any} */ (get(worldRig)); + if (!rig) return false; + const q = rig.quaternion; + return Math.abs(rig.scale.x - 1) > 1e-3 || rig.position.lengthSq() > 1e-6 || Math.abs(Math.abs(q.w) - 1) > 1e-6; +} + /** 37 R9: is the lone selection a Terrain (its Selected ring offers Sculpt terrain instead of Edit mesh)? */ function isTerrainSelection() { return !!(/** @type {any} */ (get(selectedObject))?.userData?.terrain); @@ -287,6 +296,8 @@ function registerBuiltins() { registerVRMenuEntry({ id: 'undo', label: 'Undo', icon: 'undo-2', order: 5 }); registerVRMenuEntry({ id: 'chat', label: 'Chat', icon: 'message-square', order: 6 }); registerVRMenuEntry({ id: 'nav:settings', label: 'Settings', icon: 'settings', order: 7, ring: 'settings' }); + // 52 (V5): the way back from a miniature, one press from the root — only while the world is off 1:1 + registerVRMenuEntry({ id: 'world:back', label: 'Back to 1:1', icon: 'maximize', order: 8, closes: true, visible: () => worldOffLifeSize() }); // Tools ▸ (214 + 36) — the trigger tool mode, Ping, the simulation (desktop: Tools ▸ Simulate physics) // and the profiler diff --git a/tests/e2e/vr-grips-by-mode.test.cjs b/tests/e2e/vr-grips-by-mode.test.cjs index c974e8386..7c6b14925 100644 --- a/tests/e2e/vr-grips-by-mode.test.cjs +++ b/tests/e2e/vr-grips-by-mode.test.cjs @@ -120,7 +120,9 @@ h.run(async () => { 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)'); + // 52 (V5): a grab selects when it COMMITS (it moves, or 250 ms pass — a second grip inside that window + // makes it the world grab instead), so the lock lands a moment after the press + await h.eventually(() => state(A.page), (x) => x.selected.includes(ids.cube), 'Edit: holding selects it (the editor lock)', 2000); await xr.pose(A.page, 'right', [0.5, 1.2, 0]); await A.page.waitForTimeout(250); await grip(A.page, 'right', false); diff --git a/tests/e2e/vr-two-hand-grab.test.cjs b/tests/e2e/vr-two-hand-grab.test.cjs index 2c181e8be..13c9617a4 100644 --- a/tests/e2e/vr-two-hand-grab.test.cjs +++ b/tests/e2e/vr-two-hand-grab.test.cjs @@ -71,7 +71,12 @@ h.run(async () => { const b0 = await posOf(A.page, ids.b); await xr.pose(A.page, 'right', [a0[0], a0[1], 0]); // aims -Z through crate A await grip(A.page, 'right', true); - await xr.pose(A.page, 'left', [b0[0], b0[1], 0]); // aims -Z through crate B + // 52 (V5): both grips are the WORLD unless the first hand has already MOVED what it holds — carry A a + // little first; then a second hand INSIDE crate B takes B (two things at once, 33 G4; a second hand + // that only POINTS elsewhere would scale A) + await xr.pose(A.page, 'right', [a0[0], a0[1] + 0.05, 0]); + await A.page.waitForTimeout(150); + await xr.pose(A.page, 'left', [b0[0], b0[1], b0[2]]); // the hand inside crate B await grip(A.page, 'left', true); const g = await grips(A.page); h.check(g.grabs[1] === ids.a && g.grabs[0] === ids.b, `1.1 the right hand holds A, the left holds B (${JSON.stringify(g.grabs)})`); @@ -81,7 +86,7 @@ h.run(async () => { await A.page.waitForTimeout(300); const a1 = await posOf(A.page, ids.a); const b1 = await posOf(A.page, ids.b); - h.check(Math.abs(a1[1] - (a0[1] + 0.5)) < 0.05 && Math.abs(a1[0] - (a0[0] + 0.3)) < 0.05, `1.2 crate A follows the right hand (y ${a0[1].toFixed(2)} -> ${a1[1].toFixed(2)})`); + h.check(Math.abs(a1[1] - (a0[1] + 0.5)) < 0.06 && Math.abs(a1[0] - (a0[0] + 0.3)) < 0.05, `1.2 crate A follows the right hand (y ${a0[1].toFixed(2)} -> ${a1[1].toFixed(2)})`); h.check(Math.abs(b1[1] - (b0[1] + 0.5)) < 0.05 && Math.abs(b1[0] - (b0[0] - 0.3)) < 0.05, `1.3 crate B follows the left hand (y ${b0[1].toFixed(2)} -> ${b1[1].toFixed(2)})`); await grip(A.page, 'right', false); await grip(A.page, 'left', false); @@ -91,7 +96,9 @@ h.run(async () => { const a2 = await posOf(A.page, ids.a); await xr.pose(A.page, 'right', [a2[0], a2[1], 0]); await grip(A.page, 'right', true); - await xr.pose(A.page, 'left', [a2[0] - 0.05, a2[1], 0]); + await xr.pose(A.page, 'right', [a2[0], a2[1] + 0.05, 0]); // 52 (V5): moved first, then the second hand scales + await A.page.waitForTimeout(150); + await xr.pose(A.page, 'left', [a2[0] - 0.05, a2[1] + 0.05, 0]); await grip(A.page, 'left', true); const g3 = await grips(A.page); h.check(g3.scaleGrab === ids.a && g3.grabs.every((u) => u === null), `1.5 two hands on ONE object still scale it in Edit (${g3.scaleGrab === ids.a})`); diff --git a/tests/e2e/vr-universal-world-grab.test.cjs b/tests/e2e/vr-universal-world-grab.test.cjs new file mode 100644 index 000000000..8e9649964 --- /dev/null +++ b/tests/e2e/vr-universal-world-grab.test.cjs @@ -0,0 +1,364 @@ +// 52 (V5) — THE UNIVERSAL WORLD GRAB, on IWER (48 G2: the app's own XR path, a Quest 3 emulated). +// The user (2026-10-21): "I can grab both grips and scale world, but it works only if controllers do not +// point at any object, it might be worth of making it universal, so it works even if I am surrounded by +// objects" — refined the same day: object two-hand scale only after one hand already moves the object; +// big / enclosing / terrain / locked things never by touch. VR editor practice (Open Brush, Gravity +// Sketch, ShapesXR): both grips = the world. +// +// 1 standing INSIDE a hollow box (six 3 m panels, none of them scenery on its own) with both rays on its +// floor: both grips scale the WORLD; no panel moves, no undo entry, nothing sent, nothing selected +// 2 the same with the second grip LATE (> 250 ms, the first grab unmoved): still the world, the floor +// back where it was, no undo entry +// 3 one hand MOVES a cube, the other grips (its ray on the floor): the CUBE scales (and turns), the +// world stays 1:1; one undo entry for the whole hold +// 4 touching a 10 m wall (selected) / a 3 m crate with one grip, the ray at the sky: no grab; the +// selected wall still moves deliberately (ray on it + grip) +// 5 terrain never moves by grip (ray on a small terrain, selected or not) +// 6 Settings ▸ VR "Both grips scale the world: Only in empty air" = the pre-52 rule (both rays on the +// floor take the floor) +// 7 the miniature: 1:10 and 1:50 are detents (a tick, the readout between the hands says "1:10"); the +// radial root offers Back to 1:1 only while the world is off life size; the comfort vignette closes in +// while the world scales +// Red on the base (origin/feat/51, the 1.43 union): 1, 3, 4, 5 (2 passes there too: a late grip on the +// base takes the floor too and never moves it). Green here. +const h = require('./helpers.cjs'); +const iwer = require('./iwer.cjs'); + +const g = (page, fn, arg) => page.evaluate(fn, arg); + +const state = (page) => + g(page, () => { + 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: get(s.history.undoStack).length, + sent: window.__sent ? window.__sent.slice() : [] + }; + }); +/** every fixture object's LOCAL pose, keyed by uuid */ +const poses = (page, ids) => + g(page, (ids) => { + let grp; + window.__stores.objectsGroup.subscribe((x) => (grp = x))(); + const out = {}; + for (const id of ids) { + const o = grp.getObjectByProperty('uuid', id); + out[id] = [...o.position.toArray(), ...o.quaternion.toArray(), ...o.scale.toArray()].map((v) => +v.toFixed(4)); + } + return out; + }, ids); +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const clearSent = (page) => g(page, () => (window.__sent = [])); +const grip = (page, hand, down) => iwer.button(page, hand, 'squeeze', down); +const release = async (page) => { + await grip(page, 'left', false); + await grip(page, 'right', false); + await iwer.frames(page, 4); +}; +const reset = async (page) => { + await release(page); + await g(page, () => { + window.__stores.vrControls.resetWorldRig(); + window.__stores.objectActions.deselectObject(); + }); + await iwer.frames(page, 3); +}; + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { init: iwer.initScript(), context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + + // ---- the fixture ------------------------------------------------------------------------------------- + // a hollow 3 m box around the viewer (floor, ceiling, four walls: each panel is < 4 m and does not contain + // the head, so no panel is "scenery" by itself), a 30 m ground under it, a cube, a 10 m wall and a 3 m crate + // out to the right, a 3 m terrain behind + const ids = await g(page, async () => { + const s = window.__stores; + const get = (store) => { + let v; + store.subscribe((x) => (v = x))(); + return v; + }; + const make = async (cmd) => { + s.commandsHandler.sceneCommand(cmd); + await new Promise((r) => setTimeout(r, 120)); + return get(s.selectedObject); + }; + const box = async (name, scale, pos) => { + const o = await make('/create Box 1 1 1'); + o.name = name; + o.scale.set(...scale); + o.position.set(...pos); + o.updateMatrixWorld(true); + return o.uuid; + }; + const out = { + ground: await box('ground', [30, 0.2, 30], [0, -0.2, 0]), + floor: await box('floor', [3, 0.04, 3], [0, 0.02, 0]), + ceiling: await box('ceiling', [3, 0.04, 3], [0, 2.6, 0]), + north: await box('north', [3, 2.6, 0.1], [0, 1.3, -1.5]), + south: await box('south', [3, 2.6, 0.1], [0, 1.3, 1.5]), + east: await box('east', [0.1, 2.6, 3], [1.5, 1.3, 0]), + westWall: await box('west', [0.1, 2.6, 3], [-1.5, 1.3, 0]), + cube: await box('cube', [0.3, 0.3, 0.3], [0.35, 1.2, -0.8]), + bigWall: await box('bigWall', [10, 4, 0.5], [0, 2, -12]), + crate: await box('crate', [3, 1, 1], [0, 0.5, 12]) + }; + const terrain = await make('/create Terrain 3 8'); + terrain.position.set(6, 0.5, 6); + terrain.updateMatrixWorld(true); + out.terrain = terrain.uuid; + out.terrainIsTerrain = !!terrain.userData?.terrain; + s.objectActions.deselectObject(); + // the wire, seen from here: every message this peer sends + const peer = get(s.peers); + if (peer && !peer.__spy) { + const send = peer.send.bind(peer); + peer.send = (m) => { + (window.__sent ??= []).push(m?.type); + return send(m); + }; + peer.__spy = true; + } + window.__sent = []; + return out; + }); + const { terrainIsTerrain, ...objects } = ids; + const all = Object.values(objects); + h.check(terrainIsTerrain, 'premise: /create Terrain made a terrain'); + + await iwer.enter(page); + const s0 = await state(page); + h.check(Math.abs(s0.rigScale - 1) < 1e-6 && s0.grab === null, 'premise: in the headset, Edit, the world 1:1, nothing held'); + + // both hands low, rays pointing steeply down at the hollow box's floor + const aimFloor = async (spread = 0.25) => { + await iwer.pose(page, 'left', [-spread, 1.1, -0.3], { pitch: -1.3 }); + await iwer.pose(page, 'right', [spread, 1.1, -0.3], { pitch: -1.3 }); + await iwer.frames(page, 3); + }; + + console.log('\n=== 1. inside a hollow box, both rays on its floor: the WORLD scales, nothing moves ==='); + await aimFloor(); + let before = await poses(page, all); + let undo0 = (await state(page)).undo; + await clearSent(page); + await grip(page, 'left', true); + await grip(page, 'right', true); // the second grip lands inside the 250 ms window + await iwer.frames(page, 3); + let s = await state(page); + h.check(s.worldGrab && s.grab === null, `1.1 both grips start the world grab (world ${s.worldGrab}, holding ${s.grab})`); + await aimFloor(0.5); // spread the hands 0.5 m -> 1 m + await iwer.frames(page, 6); + s = await state(page); + h.check(s.rigScale > 1.6, `1.2 spreading the hands scales the world (x${s.rigScale.toFixed(2)})`); + let after = await poses(page, all); + h.check(same(before, after), '1.3 no panel, no object moved (local poses identical)'); + h.check(s.undo === undo0, `1.4 no undo entry (${undo0} -> ${s.undo})`); + h.check(!s.sent.includes('move') && s.selected.length === 0, `1.5 nothing sent, nothing selected (sent: ${s.sent.join(',') || 'none'})`); + await reset(page); + + console.log('\n=== 2. the second grip LATE, the first grab unmoved: still the world ==='); + await aimFloor(); + before = await poses(page, all); + undo0 = (await state(page)).undo; + await grip(page, 'left', true); + await page.waitForTimeout(450); + await iwer.frames(page, 3); + await grip(page, 'right', true); + await iwer.frames(page, 3); + s = await state(page); + h.check(s.worldGrab && s.grab === null, `2.1 a late second grip still grabs the world (world ${s.worldGrab}, holding ${s.grab}, dropped ${s.dropped})`); + await aimFloor(0.5); + await iwer.frames(page, 6); + s = await state(page); + after = await poses(page, all); + h.check(s.rigScale > 1.6 && same(before, after), `2.2 the world scaled (x${s.rigScale.toFixed(2)}), the floor back where it was`); + h.check(s.undo === undo0, `2.3 no undo entry (${undo0} -> ${s.undo})`); + await reset(page); + + console.log('\n=== 3. one hand MOVES a cube, the other grips: the CUBE scales ==='); + // right hand aims at the cube (0.35, 1.2, -0.8) from 0.6 m behind it, level + await iwer.pose(page, 'right', [0.35, 1.2, -0.2]); + await iwer.pose(page, 'left', [-0.25, 1.1, -0.3], { pitch: -1.3 }); // left ray on the floor + await iwer.frames(page, 3); + const cube0 = (await poses(page, [objects.cube]))[objects.cube]; + undo0 = (await state(page)).undo; + await grip(page, 'right', true); + await iwer.frames(page, 3); + s = await state(page); + h.check(s.grab === objects.cube, `3.1 the right grip holds the cube (${s.grab})`); + await iwer.pose(page, 'right', [0.45, 1.25, -0.2]); // carry it ~11 cm + await iwer.frames(page, 4); + s = await state(page); + h.check((s.moved ?? []).some(Boolean) && s.selected.includes(objects.cube), `3.2 moved past 2 cm: committed (selected ${s.selected.includes(objects.cube)})`); + await grip(page, 'left', true); + await iwer.frames(page, 3); + s = await state(page); + h.check(s.scaleGrab === objects.cube && !s.worldGrab, `3.3 the second grip scales the cube, not the world (scale ${s.scaleGrab}, world ${s.worldGrab})`); + // hands 0.73 m apart -> 1.4 m apart, and a turn of the hands' axis + await iwer.pose(page, 'left', [-0.6, 1.1, -0.6], { pitch: -1.3 }); + await iwer.pose(page, 'right', [0.8, 1.25, -0.2]); + await iwer.frames(page, 6); + s = await state(page); + const cube1 = (await poses(page, [objects.cube]))[objects.cube]; + h.check(cube1[7] > cube0[7] * 1.5, `3.4 the cube grew with the hands (${cube0[7]} -> ${cube1[7]})`); + h.check(!same(cube0.slice(3, 7), cube1.slice(3, 7)) && s.scaleRotates, '3.5 ...and turned with the hands’ axis'); + h.check(Math.abs(s.rigScale - 1) < 1e-6, `3.6 the world stayed 1:1 (x${s.rigScale})`); + await release(page); + s = await state(page); + h.check(s.undo === undo0 + 1, `3.7 one undo entry for the whole hold (${undo0} -> ${s.undo})`); + await reset(page); + + console.log('\n=== 4. touching big things never grabs them ==='); + // the right hand INSIDE the 10 m wall (selected with the trigger earlier), its ray up at the sky + await g(page, (id) => window.__stores.objectActions.selectObject(id), objects.bigWall); + await iwer.pose(page, 'right', [0, 1.6, -12], { pitch: 1.4 }); + await iwer.frames(page, 3); + const wall0 = (await poses(page, [objects.bigWall]))[objects.bigWall]; + await grip(page, 'right', true); + await iwer.frames(page, 3); + s = await state(page); + h.check(s.grab === null, `4.1 a hand inside a selected 10 m wall does not take it (holding ${s.grab})`); + await iwer.pose(page, 'right', [0.6, 1.6, -12], { pitch: 1.4 }); + await iwer.frames(page, 4); + h.check(same(wall0, (await poses(page, [objects.bigWall]))[objects.bigWall]), '4.2 ...and it did not move'); + await grip(page, 'right', false); + await iwer.frames(page, 3); + // a hand inside a 3 m crate (not selected), ray at the sky + await iwer.pose(page, 'right', [0, 0.5, 12], { pitch: 1.4 }); + await iwer.frames(page, 3); + await grip(page, 'right', true); + await iwer.frames(page, 3); + s = await state(page); + h.check(s.grab === null, `4.3 a hand inside a 3 m crate does not take it (holding ${s.grab})`); + await grip(page, 'right', false); + await iwer.frames(page, 3); + // deliberately: the selected wall, the RAY on it, then grip — it moves + await g(page, (id) => window.__stores.objectActions.selectObject(id), objects.bigWall); + await iwer.pose(page, 'right', [0, 1.6, -9]); // 2.75 m in front of the wall, aiming -Z at it + await iwer.frames(page, 3); + await grip(page, 'right', true); + await iwer.frames(page, 3); + s = await state(page); + h.check(s.grab === objects.bigWall, `4.4 select + ray + grip still holds the wall (holding ${s.grab})`); + await release(page); + await g(page, ({ id, pose }) => { + let grp; + window.__stores.objectsGroup.subscribe((x) => (grp = x))(); + const o = grp.getObjectByProperty('uuid', id); + o.position.set(pose[0], pose[1], pose[2]); + o.updateMatrixWorld(true); + }, { id: objects.bigWall, pose: wall0 }); + await reset(page); + + console.log('\n=== 5. terrain never moves by grip ==='); + // the terrain's centre is (6, 0.5, 6): stand the hand 1.5 m short of it, ray down at it + await iwer.pose(page, 'right', [5, 1.6, 5], { yaw: -Math.PI * 0.75, pitch: -0.6 }); + await iwer.frames(page, 3); + const ray = await g(page, () => { + let r, grp; + window.__stores.globalRenderer.subscribe((v) => (r = v))(); + window.__stores.objectsGroup.subscribe((v) => (grp = v))(); + const c = r.xr.getController(r.xr.getController(0).userData.handedness === 'right' ? 0 : 1); + const T = window.__stores.THREE; + const o = c.getWorldPosition(new T.Vector3()); + const d = new T.Vector3(0, 0, -1).applyQuaternion(c.getWorldQuaternion(new T.Quaternion())); + const hit = new T.Raycaster(o, d).intersectObjects(grp.children, true)[0]; + return hit ? window.__stores.objectActions.topLevelObjectOf(hit.object)?.uuid : null; + }); + h.check(ray === objects.terrain, `premise: the right ray points at the terrain (${ray})`); + for (const selected of [false, true]) { + if (selected) await g(page, (id) => window.__stores.objectActions.selectObject(id), objects.terrain); + await grip(page, 'right', true); + await iwer.frames(page, 3); + s = await state(page); + h.check(s.grab === null, `5.${selected ? 2 : 1} a grip on the terrain${selected ? ' (selected)' : ''} does not take it (holding ${s.grab})`); + await grip(page, 'right', false); + await iwer.frames(page, 3); + } + await reset(page); + + console.log('\n=== 6. Settings ▸ VR: "Only in empty air" = the pre-52 rule ==='); + const row = await g(page, () => { + const r = window.__stores.vrSettingsSchema.vrSettingRow('bothGripsWorld'); + return { kind: r.kind, label: r.label, options: r.options.map((o) => o.label), value: r.get() }; + }); + h.check(row.kind === 'choice' && row.label === 'Both grips scale the world' && row.options.join('|') === 'Always|Only in empty air' && row.value === 'always', `6.1 the row: ${row.label}: ${row.options.join(' / ')} (default ${row.value})`); + await g(page, () => window.__stores.vrSettingsSchema.vrSettingRow('bothGripsWorld').set('emptyAir')); + await aimFloor(); + await grip(page, 'left', true); + await iwer.frames(page, 2); + await grip(page, 'right', true); + await iwer.frames(page, 3); + s = await state(page); + h.check(!s.worldGrab && (s.grab === objects.floor || s.scaleGrab === objects.floor), `6.2 both rays on the floor take the floor again (world ${s.worldGrab}, holding ${s.grab ?? s.scaleGrab})`); + await release(page); + await g(page, () => window.__stores.vrSettingsSchema.vrSettingRow('bothGripsWorld').set('always')); + + console.log('\n=== 7. the miniature: detents 1:10 / 1:50, the readout, the vignette, Back to 1:1 ==='); + const snap = () => g(page, () => window.__stores.vrWorldSnap.worldSnapDebug()); + // hands 1 m apart, both grips (on the floor: the universal rule), then together to 10 cm = 1:10 + await aimFloor(0.5); + await grip(page, 'left', true); + await grip(page, 'right', true); + await iwer.frames(page, 3); + const ticks0 = (await snap()).ticks; + await aimFloor(0.05); + await iwer.frames(page, 6); + let d = await snap(); + s = await state(page); + h.check(d.detent === 0.1 && Math.abs(s.rigScale - 0.1) < 1e-6, `7.1 1:10 is a detent (scale ${s.rigScale}, detent ${d.detent})`); + h.check(/^1:10 · /.test(d.readout) && d.readoutVisible, `7.2 the readout between the hands says 1:10 (${d.readout})`); + h.check(d.ticks > ticks0, `7.3 catching it ticked the hands (${ticks0} -> ${d.ticks})`); + await aimFloor(0.01); // 2 cm apart = 1:50 + await iwer.frames(page, 6); + d = await snap(); + s = await state(page); + h.check(d.detent === 0.02 && Math.abs(s.rigScale - 0.02) < 1e-6 && /^1:50 · /.test(d.readout), `7.4 1:50, the table-top miniature (scale ${s.rigScale}, ${d.readout})`); + await release(page); + // Back to 1:1 sits on the ROOT ring only while the world is off life size + const root = () => g(page, () => window.__stores.vrRadialMenu.ringEntries('root').map((e) => e.id)); + h.check((await root()).includes('world:back'), '7.5 the radial root offers Back to 1:1 while the world is a miniature'); + await g(page, () => window.__stores.vrControls.executeVRMenuAction('world:back')); + await iwer.frames(page, 3); + s = await state(page); + h.check(Math.abs(s.rigScale - 1) < 1e-9 && s.rigPos.every((v) => v === 0) && !(await root()).includes('world:back'), `7.6 Back to 1:1 restores life size and leaves the root ring (x${s.rigScale})`); + // the comfort vignette (53, High) closes in while the world scales, and opens again when it stops + await g(page, () => { + window.__stores.vrPrefs.vrComfortVignette.set('high'); + window.__stores.vrPrefs.vrWorldSnap.set(false); // a steady, unsnapped pull + }); + await aimFloor(0.5); + await grip(page, 'left', true); + await grip(page, 'right', true); + await iwer.frames(page, 2); + let peak = 0; + for (let i = 1; i <= 30; i++) { + await aimFloor(0.5 - i * 0.014); + await iwer.frames(page, 1); + peak = Math.max(peak, (await g(page, () => window.__stores.gameKit.comfortVignette.vignetteDebug())).strength); + } + h.check(peak > 0.4, `7.7 the vignette closes in while the world scales (peak ${peak.toFixed(2)})`); + await iwer.frames(page, 90); // hands still + const still = await g(page, () => window.__stores.gameKit.comfortVignette.vignetteDebug()); + h.check(still.strength < 0.1, `7.8 hands still: it opens again (${still.strength.toFixed(3)})`); + await reset(page); + await g(page, () => { + window.__stores.vrPrefs.vrComfortVignette.set('off'); + window.__stores.vrPrefs.vrWorldSnap.set(true); + }); + + await iwer.exit(page); + await h.finish(browser); +}); diff --git a/tests/e2e/vr-world-grab.test.cjs b/tests/e2e/vr-world-grab.test.cjs index 9e0002d17..94582374d 100644 --- a/tests/e2e/vr-world-grab.test.cjs +++ b/tests/e2e/vr-world-grab.test.cjs @@ -51,13 +51,13 @@ h.run(async () => { `pan follows the hands (${t.pos.map((v) => v.toFixed(2))})` ); - // total scale clamps at 0.05x–20x + // total scale clamps at 0.02x–20x (52 V5: the floor went 1:20 -> 1:50, the miniature table) t = await compute( { a: [-1, 1, 0], b: [1, 1, 0] }, { a: [-0.001, 1, 0], b: [0.001, 1, 0] }, { ...identity, scale: 0.1 } ); - h.check(t.scale >= 0.05, `scale clamps low (${t.scale})`); + h.check(Math.abs(t.scale - 0.02) < 1e-9, `scale clamps low at 1:50 (${t.scale})`); // ---- rig application + replication invariance (71.4) ---- const uuid = await A.page.evaluate(async () => { diff --git a/tests/unit/gameShell.test.js b/tests/unit/gameShell.test.js index 252b30640..55a294579 100644 --- a/tests/unit/gameShell.test.js +++ b/tests/unit/gameShell.test.js @@ -115,6 +115,12 @@ describe('fps + vignette', () => { expect(easeVignette(0, 1, 1 / 72)).toBeGreaterThan(0); expect(easeVignette(1, 0, 10)).toBe(0); }); + it('52 (V5): the world gesture rings it too — a steady scale/turn, not a trembling hand', () => { + expect(vignetteTarget(0, 0, 1 / 72, 0.05)).toBe(0); + expect(vignetteTarget(0, 0, 1 / 72, 0.53)).toBeCloseTo(0.5); + expect(vignetteTarget(0, 0, 1 / 72, 3)).toBe(1); + expect(vignetteTarget(0.8, 0, 1 / 72, 0)).toBe(1); + }); }); describe('33 (G3): level tabs + the headset grid pages', () => { diff --git a/tests/unit/vrGrip.test.js b/tests/unit/vrGrip.test.js index d54eea168..7bef435c7 100644 --- a/tests/unit/vrGrip.test.js +++ b/tests/unit/vrGrip.test.js @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { isScenery, pickGripTarget, gripMovesWorld, SCENERY_EXTENT } from '../../src/lib/vrGrip.js'; +import { contactGrabAllowed, secondGripVerdict, grabMoved, CONTACT_MAX_EXTENT, GRAB_MOVED_M, GRAB_JOIN_MS } 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). @@ -85,3 +86,66 @@ describe('31 K1 gripMovesWorld', () => { expect(gripMovesWorld('interact', true)).toBe(true); }); }); + +// 52 (V5): the universal world grab + +describe('contactGrabAllowed (52 V5)', () => { + it('a cup in the hand: yes', () => { + expect(contactGrabAllowed(box([-0.1, 0.9, -0.5], [0.1, 1.1, -0.3]), head)).toBe(true); + }); + it('anything over ~2 m on any axis: no (a 3 m crate, a 10 m wall)', () => { + expect(contactGrabAllowed(box([0, 0, 0], [3, 1, 1]), head)).toBe(false); + expect(contactGrabAllowed(box([-5, 0, -3.25], [5, 4, -2.75]), head)).toBe(false); + expect(contactGrabAllowed(box([0, 0, 0], [CONTACT_MAX_EXTENT, 1, 1]), null)).toBe(true); + }); + it('a hollow box around the headset: no, however small', () => { + expect(contactGrabAllowed(box([-0.9, 0, -0.9], [0.9, 1.9, 0.9]), head)).toBe(false); + expect(contactGrabAllowed(box([-0.9, 0, -0.9], [0.9, 1.9, 0.9]), { x: 3, y: 1.6, z: 0 })).toBe(true); + }); + it('terrain and locked objects: no', () => { + const b = box([0, 0, 0], [1, 1, 1]); + expect(contactGrabAllowed(b, null, { terrain: true })).toBe(false); + expect(contactGrabAllowed(b, null, { locked: true })).toBe(false); + expect(contactGrabAllowed(null, null)).toBe(false); + }); +}); + +describe('pickGripTarget never takes terrain (52 V5)', () => { + it('even selected, the ray passes through terrain', () => { + expect(pickGripTarget([{ scenery: true, grabbable: false, selected: true, terrain: true }, { scenery: false, grabbable: false }], 'edit')).toBe(1); + expect(pickGripTarget([{ scenery: false, grabbable: false, selected: true, terrain: true }], 'edit')).toBe(-1); + }); +}); + +describe('secondGripVerdict (52 V5)', () => { + const base = { setting: /** @type {'always'} */ ('always'), mode: /** @type {'edit'} */ ('edit') }; + it('nothing or air in the other hand: the world', () => { + expect(secondGripVerdict({ ...base, otherGrab: null })).toBe('world'); + }); + it('an UNMOVED object in the other hand: dropped, the world', () => { + expect(secondGripVerdict({ ...base, otherGrab: { moved: false } })).toBe('world'); + expect(secondGripVerdict({ ...base, otherGrab: { moved: false, set: true } })).toBe('world'); + }); + it('an object the other hand already MOVED: two-hand scale (a set keeps its own rule)', () => { + expect(secondGripVerdict({ ...base, otherGrab: { moved: true } })).toBe('scale'); + expect(secondGripVerdict({ ...base, otherGrab: { moved: true, set: true } })).toBe('legacy'); + }); + it('a moved object in one hand, the other grip on a DIFFERENT object: that hand holds it (33 G4)', () => { + expect(secondGripVerdict({ ...base, otherGrab: { moved: true }, takesOther: true })).toBe('legacy'); + expect(secondGripVerdict({ ...base, otherGrab: { moved: false }, takesOther: true })).toBe('world'); + }); + it('the pre-52 rule: "Only in empty air", Interact, stretch mode, a busy other hand', () => { + expect(secondGripVerdict({ ...base, setting: 'emptyAir', otherGrab: null })).toBe('legacy'); + expect(secondGripVerdict({ ...base, mode: 'interact', otherGrab: null })).toBe('legacy'); + expect(secondGripVerdict({ ...base, stretch: true, otherGrab: null })).toBe('legacy'); + expect(secondGripVerdict({ ...base, otherBusy: true, otherGrab: null })).toBe('legacy'); + }); +}); + +describe('grabMoved (52 V5)', () => { + it('past ~2 cm counts, a tremble does not', () => { + expect(grabMoved([0, 1, 0], [0.015, 1, 0])).toBe(false); + expect(grabMoved([0, 1, 0], [0, 1, GRAB_MOVED_M + 0.001])).toBe(true); + expect(GRAB_JOIN_MS).toBe(250); + }); +}); diff --git a/tests/unit/vrWorldSnap.test.js b/tests/unit/vrWorldSnap.test.js index 8050935c2..f2b3e32c6 100644 --- a/tests/unit/vrWorldSnap.test.js +++ b/tests/unit/vrWorldSnap.test.js @@ -32,8 +32,11 @@ describe('yaw steps', () => { }); describe('scale detents', () => { - it('lists 1/2/5/10 both ways', () => { - expect(SCALE_DETENTS).toEqual([0.1, 0.2, 0.5, 1, 2, 5, 10]); + it('lists 1/2/5/10 both ways, plus the 1:50 miniature (52 V5)', () => { + expect(SCALE_DETENTS).toEqual([0.02, 0.1, 0.2, 0.5, 1, 2, 5, 10]); + }); + it('catches the 1:50 table (52 V5)', () => { + expect(snapWorldGrab({ total: 0.021, yaw: 0, yaw0: 0 }, null)).toMatchObject({ total: 0.02, detent: 0.02 }); }); it('sticks inside ±7 % and slides freely between', () => { expect(snapWorldGrab({ total: 2.1, yaw: 0, yaw0: 0 }, null)).toMatchObject({ total: 2, detent: 2 }); @@ -54,9 +57,13 @@ describe('scale detents', () => { describe('labels', () => { it('reads like the readout', () => { expect(scaleLabel(2)).toBe('2×'); - expect(scaleLabel(0.5)).toBe('½×'); - expect(scaleLabel(0.1)).toBe('⅒×'); expect(scaleLabel(3.3)).toBe('3.3×'); + // 52 (V5): below life size it is a map scale + expect(scaleLabel(1)).toBe('1:1'); + expect(scaleLabel(0.5)).toBe('1:2'); + expect(scaleLabel(0.1)).toBe('1:10'); + expect(scaleLabel(0.02)).toBe('1:50'); + expect(scaleLabel(0.37)).toBe('1:2.7'); expect(yawLabel(3)).toBe('45°'); expect(yawLabel(-1)).toBe('345°'); }); From 22e92b1c28abc822626f0a89d05a16b3046222f6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 14:39:53 +0300 Subject: [PATCH 10/29] [feat] 52 V3: terrain paint + scatter brushes on the controller, in miniature too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sculpt ring gains Paint ▸ (layers 1-4, terrain only) and Scatter ▸ (paint/erase/rotate/scale/relax + Source ▸) - Source ▸ wheel: current + in-use sources + primitives; stick Y = density while scatter-painting - haptic pulse on stroke start; tracked hands are asked to pick up a controller - brush label reads layer/tool, radius and strength or density; ring scales with the world (miniature) - wheel picks apply synchronously (primed sculpt module), no late dynamic import - e2e: vr-brushes-iwer (wheels, strokes, undo, miniature 1:10 placement), vr-brushes-perf (Quest profile, 2k instances) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/terrainSculpt.js | 11 +- src/lib/vr/sculpt.js | 188 ++++++++++++++++++++- src/lib/vrRadialMenu.js | 86 +++++++++- tests/e2e/vr-brushes-iwer.test.cjs | 255 +++++++++++++++++++++++++++++ tests/e2e/vr-brushes-perf.test.cjs | 134 +++++++++++++++ tests/e2e/vr-world.test.cjs | 3 +- 6 files changed, 669 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/vr-brushes-iwer.test.cjs create mode 100644 tests/e2e/vr-brushes-perf.test.cjs diff --git a/src/lib/terrainSculpt.js b/src/lib/terrainSculpt.js index 73e183370..3cfd566c7 100644 --- a/src/lib/terrainSculpt.js +++ b/src/lib/terrainSculpt.js @@ -557,9 +557,18 @@ export function showCursorAt(/** @type {any} */ worldPoint, /** @type {any} */ w cursor.rotation.x = -Math.PI / 2; cursor.position.set(worldPoint.x, worldPoint.y + 0.02, worldPoint.z); } - const r = get(sculptRadius); + // 52 (V3): the radius is in the host's own metres — a scaled host (a miniature world, 52 V5) draws it at + // its world size; an unscaled one is 1 and unchanged + const host = objectOf(get(sculptObject) ?? ''); + const k = host ? host.getWorldScale(_cursorScale).x : 1; + const r = get(sculptRadius) * (k > 0 ? k : 1); cursor.scale.set(r, r, r); } +const _cursorScale = new THREE.Vector3(); +/** 52 (V3): the brush ring's world radius + where it is, for the suites */ +export function cursorDebug() { + return cursor ? { visible: !!cursor.visible, radius: cursor.scale.x, pos: cursor.position.toArray() } : null; +} export function hideCursor() { if (cursor) cursor.visible = false; diff --git a/src/lib/vr/sculpt.js b/src/lib/vr/sculpt.js index 05bb87038..c1f974e46 100644 --- a/src/lib/vr/sculpt.js +++ b/src/lib/vr/sculpt.js @@ -12,6 +12,18 @@ // The tool and the two numbers are the desktop toolbar's own stores, so a desktop peer-of-yourself and the // headset agree. Edit only: switching to Interact or leaving the session ends the session. // +// 52 (V3) — THE PAINT AND SCATTER BRUSHES ON THE CONTROLLER. The same session grows the desktop toolbar's two +// other brushes (47 T1/T2 + 51): Paint lays one of the terrain's FOUR layers, Scatter paints instances of a +// source and its 51 edit tools (Erase · Rotate · Scale · Relax) work on what is there. The Sculpt ring's +// Paint ▸ and Scatter ▸ are the WHEELS: Paint ▸ = the four layers, Scatter ▸ = the tools + Source ▸ (the +// sources this terrain already scatters, the one in use, then the primitives). Trigger strokes, the stick +// sizes (x) and sets the strength (y — the DENSITY while scatter-painting, the toolbar's own row), the ring +// on the surface follows the pointer, and a stroke starts with a tick in the hand. Everything goes through +// terrainSculpt's beginStroke/strokeMove/endStroke, so a stroke is the desktop's: one commit, one undo +// entry. It works in a miniature (52 V5): the hit is taken into the terrain's own frame and the ring is +// drawn at the terrain's world scale. With hand tracking the brushes ask for a controller (52 rules: precise +// tools are controller tools). +// // Rides the K1 hook registries (trigger / frame / stick owner), registered on first use (the splineEdit pattern). import * as THREE from 'three'; import { get } from 'svelte/store'; @@ -19,6 +31,7 @@ import { objectsGroup, editorMode, vrMenuHand, vrMenuOpen } from '../../stores/s import { sculptObject, sculptOp, + sculptMode, sculptRadius, sculptStrength, enterSculpt, @@ -36,12 +49,25 @@ import { controllerRay } from './pointer.js'; import { vrHovered } from './panels.js'; import { registerVRFrameHook, registerVRTriggerHooks, registerStickOwner } from './hooks.js'; import { vrSculptActive } from './worldStores.js'; +import { paintLayer, scatterSource, scatterDensity, scatterErase, scatterBlockNow } from '../terrainPaint'; +import { scatterTool, setScatterTool } from '../scatterEdit'; +import { sourceChoices, resolveSourceChoice, sourceLabel, packChoiceList } from '../scatterSources'; +import { sourceKey, sourceOf } from '../terrainScatterCore'; export const SCULPT_OPS = /** @type {const} */ (['raise', 'lower', 'smooth', 'flatten']); export const RADIUS_MIN = 0.25; export const RADIUS_MAX = 20; export const STRENGTH_MIN = 0.05; export const STRENGTH_MAX = 1; +/** 52 (V3): the brushes beside the four sculpt ops (paint = terrain only, scatter = any sculpted host) */ +export const BRUSH_OPS = /** @type {const} */ (['paint', 'scatter']); +/** 52 (V3): the scatter tools a controller drives (the toolbar's Select is a screen marquee — not in VR) */ +export const SCATTER_TOOLS = /** @type {const} */ (['paint', 'erase', 'rotate', 'scale', 'relax']); +/** 52 (V3): the scatter density range (the toolbar's own slider) */ +export const DENSITY_MIN = 0.05; +export const DENSITY_MAX = 2; +/** 52 (V3): at most this many sources on the Source ▸ wheel */ +export const SOURCE_SLOTS = 7; const DEAD = 0.25; let hooked = false; @@ -50,7 +76,7 @@ let stroke = null; let lastFrame = 0; let swallowAt = 0; /** the suites' view */ -const stats = { strokes: 0, moves: 0, ticks: 0 }; +const stats = { strokes: 0, moves: 0, ticks: 0, strokeTicks: 0, handRefusals: 0 }; /** the pointer hand (the one that does not hold the menu) */ function pointerHand() { @@ -107,6 +133,118 @@ export function stopVRSculpt() { /** a sculpt-ring sector: pick the brush @param {string} op */ export function setVRSculptOp(op) { if (/** @type {readonly string[]} */ (SCULPT_OPS).includes(op)) sculptOp.set(/** @type {any} */ (op)); + // 52 (V3): Paint needs a splat map (a terrain); Scatter paints on any sculpted host (51-surface) + else if (op === 'paint' && onTerrain()) sculptOp.set('paint'); + else if (op === 'scatter') sculptOp.set('scatter'); +} + +/** 52 (V3): is the session on a terrain (the paint layers and the edit tools need one)? */ +export function onTerrain() { + return get(sculptMode) === 'terrain'; +} + +/** 52 (V3): the Paint ▸ wheel — pick a layer (0..3) and arm Paint @param {number} i */ +export function setVRPaintLayer(i) { + if (!onTerrain() || !(i >= 0 && i <= 3)) return; + paintLayer.set(i); + sculptOp.set('paint'); +} + +/** 52 (V3): the tool in force while scatter is armed — the toolbar's reading (the 47 erase flag reads Erase) */ +export function vrScatterTool() { + if (!onTerrain()) return get(scatterErase) ? 'erase' : 'paint'; + return get(scatterErase) && get(scatterTool) === 'paint' ? 'erase' : get(scatterTool); +} + +/** 52 (V3): the Scatter ▸ wheel — arm a tool (the toolbar's pickTool; a mesh host keeps Paint + Erase) @param {string} t */ +export function setVRScatterTool(t) { + if (!(/** @type {readonly string[]} */ (SCATTER_TOOLS).includes(t))) return; + if (!onTerrain() && t !== 'paint' && t !== 'erase') return; + sculptOp.set('scatter'); + if (!onTerrain()) { + scatterErase.set(t === 'erase'); + setScatterTool('paint'); + return; + } + scatterErase.set(false); + setScatterTool(/** @type {any} */ (t)); +} + +/** @type {{at: number, slots: {value: string, label: string, source?: any}[]}} */ +let slotCache = { at: 0, slots: [] }; +/** + * 52 (V3): the Source ▸ wheel's choices: the source in use, the ones this host already scatters, then the + * primitives — the toolbar's catalog (scatterSources.sourceChoices), at most SOURCE_SLOTS. Cached for a + * second (the radial reads it once per sector). + * @returns {{value: string, label: string, source?: any}[]} + */ +export function vrSourceSlots() { + const now = performance.now(); + if (now - slotCache.at < 1000) return slotCache.slots; + const current = get(scatterSource); + const uuid = get(sculptObject); + /** @type {any[]} */ + const inUse = []; + try { + for (const set of (uuid ? scatterBlockNow(uuid)?.sets : null) ?? []) for (const src of set?.sources ?? (set?.source ? [set.source] : [])) inUse.push(src?.source ?? src); + } catch {} + /** @type {{value: string, label: string, source?: any}[]} */ + const slots = []; + const push = (/** @type {string} */ value, /** @type {string} */ label, /** @type {any} */ source) => { + if (value && !slots.some((x) => x.value === value) && slots.length < SOURCE_SLOTS) slots.push({ value, label, source }); + }; + // what is in use first (kept as the source itself: a Library model keeps its name), then the primitives — + // the toolbar's catalog order (scatterSources.sourceChoices) + for (const src of [current, ...inUse]) if (src) push(sourceKey(src), sourceLabel(src), src); + for (const c of sourceChoices({ packs: get(packChoiceList) })) if (c.value.startsWith('p:')) push(c.value, c.name, null); + slotCache = { at: now, slots }; + return slots; +} + +/** 52 (V3): pick a Source ▸ sector (resolves like the toolbar: a prefab exports its look first) @param {number} i */ +export async function setVRScatterSource(i) { + const slot = vrSourceSlots()[i]; + if (!slot) return false; + const source = slot.source ? sourceOf(slot.source) : await resolveSourceChoice(slot.value); + if (!source) return false; + scatterSource.set(source); + slotCache.at = 0; + if (vrScatterTool() !== 'paint') setVRScatterTool('paint'); // a source is something to PAINT with + else sculptOp.set('scatter'); + return true; +} + +/** 52 (V3): the source in use, as the wheel names it */ +export function vrSourceLabel() { + try { + return sourceLabel(get(scatterSource)); + } catch { + return ''; + } +} + +/** 52 (V3): is the stick's Y the scatter DENSITY right now (scatter-painting) rather than the strength? */ +function stickIsDensity() { + return get(sculptOp) === 'scatter' && vrScatterTool() === 'paint'; +} + +/** 52 (V3): is this controller slot a TRACKED HAND (no gamepad) rather than a controller? @param {number} index */ +function isHandSlot(index) { + const handedness = renderer?.xr?.getController(index)?.userData?.handedness; + const session = renderer?.xr?.getSession?.(); + if (!session || !handedness) return false; + const source = [...session.inputSources].find((s) => s.handedness === handedness); + return !!source?.hand && !source?.gamepad; +} +let handHintAt = 0; +/** 52 rules: a precise tool asks for a controller when a hand pinches it */ +function askForController() { + stats.handRefusals++; + if (Date.now() - handHintAt < 5000) return; + handHintAt = Date.now(); + import('../gameAnnounce') + .then((m) => m.announce('Pick up a controller', { sub: 'The brushes paint with the trigger; hands browse and place', ms: 3500 })) + .catch(() => {}); } /** the pointed spot on the sculpted object @param {number} index @returns {any} */ @@ -126,9 +264,17 @@ export function sculptTriggerStart(index) { const uuid = get(sculptObject); if (!hit || !uuid) return false; // off the terrain: an ordinary press (a panel, a ping) swallowAt = Date.now(); + // 52 rules: the brushes are controller tools — a pinching hand gets the ask, no stroke + if (isHandSlot(index)) { + askForController(); + return true; + } beginStroke(uuid); stroke = { index, last: performance.now() }; stats.strokes++; + // 52 (V3): a stroke starts with a tick in the painting hand (felt in Edit too — the brush is the tool) + stats.strokeTicks++; + hapticPulse(0.35, 18, pointerHand(), true); brushAt(hit, 0.016); return true; } @@ -160,7 +306,13 @@ export function adjustBrush(x, y, dt) { sculptRadius.set(Math.round(r * 100) / 100); if (Math.floor(r0 * 2) !== Math.floor(r * 2)) ticked = true; } - if (Math.abs(y) > DEAD) { + if (Math.abs(y) > DEAD && stickIsDensity()) { + // 52 (V3): scatter-painting — up = denser (the toolbar's density row), a tick every 0.25 / m² + const d0 = get(scatterDensity); + const d = THREE.MathUtils.clamp(d0 - y * dt * 1.2, DENSITY_MIN, DENSITY_MAX); + scatterDensity.set(Math.round(d * 1000) / 1000); + if (Math.floor(d0 * 4 + 1e-6) !== Math.floor(d * 4 + 1e-6)) ticked = true; + } else if (Math.abs(y) > DEAD) { const s0 = get(sculptStrength); const s = THREE.MathUtils.clamp(s0 - y * dt * 0.7, STRENGTH_MIN, STRENGTH_MAX); sculptStrength.set(Math.round(s * 1000) / 1000); @@ -208,6 +360,12 @@ export function vrSculptDebug() { strength: get(sculptStrength), stroking: !!stroke, label: labelText, + // 52 (V3) + layer: get(paintLayer), + tool: vrScatterTool(), + density: get(scatterDensity), + source: vrSourceLabel(), + mode: get(sculptMode), labelHand: label?.parent?.userData?.handedness ?? null, ...stats }; @@ -219,6 +377,24 @@ export function vrSculptDebug() { let labelText = ''; const OP_LABEL = { raise: 'Raise', lower: 'Lower', smooth: 'Smooth', flatten: 'Flatten' }; +const TOOL_LABEL = /** @type {Record} */ ({ erase: 'Erase', rotate: 'Rotate', scale: 'Scale', relax: 'Relax' }); + +/** 52 (V3): what the hand label says — the brush, its size and its strength (density while scatter-painting) */ +export function brushLabelText() { + const op = get(sculptOp); + const size = get(sculptRadius).toFixed(1) + ' m'; + const pct = Math.round(get(sculptStrength) * 100) + '%'; + if (op === 'paint') return 'Paint ' + (get(paintLayer) + 1) + ' · ' + size + ' · ' + pct; + if (op === 'scatter') { + const tool = vrScatterTool(); + if (tool === 'paint') { + const name = vrSourceLabel(); + return (name ? (name.length > 10 ? name.slice(0, 9) + '…' : name) : 'Scatter') + ' · ' + size + ' · ' + get(scatterDensity).toFixed(2) + '/m²'; + } + return (TOOL_LABEL[tool] ?? tool) + ' · ' + size + (tool === 'erase' ? '' : ' · ' + pct); + } + return (OP_LABEL[/** @type {keyof typeof OP_LABEL} */ (op)] ?? op) + ' · ' + size + ' · ' + pct; +} function updateLabel() { if (typeof document === 'undefined' || !renderer?.xr) return; @@ -243,8 +419,7 @@ function updateLabel() { if (index < 0) return; const controller = renderer.xr.getController(index); if (label.parent !== controller) controller.add(label); - const op = /** @type {keyof typeof OP_LABEL} */ (get(sculptOp)); - const text = (OP_LABEL[op] ?? op) + ' · ' + get(sculptRadius).toFixed(1) + ' m · ' + Math.round(get(sculptStrength) * 100) + '%'; + const text = brushLabelText(); if (text === labelText) return; labelText = text; const ctx = labelCanvas.getContext('2d'); @@ -256,7 +431,10 @@ function updateLabel() { if (!ctx.roundRect) ctx.rect(4, 4, 312, 72); ctx.fill(); ctx.fillStyle = '#5fd0ff'; // the brush ring's colour (terrainSculpt's cursor) - ctx.font = 'bold 30px sans-serif'; + // 52 (V3): a longer brush line (a source name) shrinks to fit the plate + let px = 30; + ctx.font = 'bold ' + px + 'px sans-serif'; + while (px > 18 && ctx.measureText(text).width > 296) ctx.font = 'bold ' + --px + 'px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, 160, 42); diff --git a/src/lib/vrRadialMenu.js b/src/lib/vrRadialMenu.js index 6df49b35f..4b21b764b 100644 --- a/src/lib/vrRadialMenu.js +++ b/src/lib/vrRadialMenu.js @@ -16,7 +16,8 @@ import { duplicateSelection, deleteSelection, groupSelection, selectionUuids } f import { savePrefab, savePrefabSelection } from './prefabs'; import { perfContext } from './perf/perfMarks.js'; // 34 PF: an import-free leaf import { vrDollhouseOpen, vrSculptActive } from './vr/worldStores.js'; // 37: a leaf -import { sculptOp } from './terrainSculpt'; +import { sculptOp, sculptMode } from './terrainSculpt'; +import { paintLayer } from './terrainPaint'; import { primitivesCatalog } from './primitivesCatalog'; // D4 (roadmap 13): selection-set helpers for the Edit ring — counted labels @@ -608,6 +609,67 @@ function registerBuiltins() { closes: true, action: () => void import('./vr/sculpt.js').then((m) => m.stopVRSculpt()) }); + // 52 (V3): the brush WHEELS — Paint ▸ (the terrain's four layers), Scatter ▸ (the tools + Source ▸) + // the primed module when it has landed (a sector press acts at once), else the import + const vrSculpt = () => (sculptRef ? Promise.resolve(sculptRef) : import('./vr/sculpt.js')); + /** @param {(m: any) => any} fn */ + const withSculpt = (fn) => (sculptRef ? void fn(sculptRef) : void vrSculpt().then(fn)); + registerVRMenuEntry({ + id: 'nav:sculpt-paint', + group: 'sculpt', + label: 'Paint', + icon: 'palette', + order: 3.5, + ring: 'sculpt-paint', + active: () => get(sculptOp) === 'paint', + visible: () => get(sculptMode) === 'terrain' + }); + registerVRMenuEntry({ id: 'nav:sculpt-scatter', group: 'sculpt', label: 'Scatter', icon: 'sparkles', order: 3.7, ring: 'sculpt-scatter', active: () => get(sculptOp) === 'scatter' }); + [0, 1, 2, 3].forEach((i) => + registerVRMenuEntry({ + id: 'sculpt:layer:' + i, + group: 'sculpt-paint', + label: 'Layer ' + (i + 1), + icon: 'layers', + order: i, + closes: true, + active: () => get(sculptOp) === 'paint' && get(paintLayer) === i, + action: () => withSculpt((m) => m.setVRPaintLayer(i)) + }) + ); + /** @type {[string, string, string][]} */ ([ + ['paint', 'Paint', 'pencil'], + ['erase', 'Erase', 'x'], + ['rotate', 'Rotate', 'rotate-cw'], + ['scale', 'Scale', 'maximize'], + ['relax', 'Relax', 'waves-horizontal'] + ]).forEach(([tool, label, icon], order) => + registerVRMenuEntry({ + id: 'sculpt:tool:' + tool, + group: 'sculpt-scatter', + label, + icon, + order, + closes: true, + // a mesh host keeps Paint + Erase (51-surface); the edit tools read a terrain's instances + visible: () => tool === 'paint' || tool === 'erase' || get(sculptMode) === 'terrain', + active: () => get(sculptOp) === 'scatter' && vrScatterToolNow === tool, + action: () => withSculpt((m) => m.setVRScatterTool(tool)) + }) + ); + registerVRMenuEntry({ id: 'nav:sculpt-source', group: 'sculpt-scatter', label: 'Source', icon: 'package', order: 5, ring: 'sculpt-source', value: () => vrSourceNow }); + for (let i = 0; i < 7; i++) + registerVRMenuEntry({ + id: 'sculpt:src:' + i, + group: 'sculpt-source', + label: () => vrSourceSlotsNow()[i]?.label ?? '', + icon: 'package', + order: i, + closes: true, + visible: () => !!vrSourceSlotsNow()[i], + active: () => vrSourceSlotsNow()[i]?.label === vrSourceNow, + action: () => withSculpt((m) => m.setVRScatterSource(i)) + }); // Face ops (118/137): ids the side-menu arms via setFaceOp; the 'faces' group stays registered so // those ids resolve (no longer a ring) @@ -617,10 +679,32 @@ function registerBuiltins() { registerVRMenuEntry({ id: 'face:delete', group: 'faces', label: 'Delete', order: 3 }); } +// 52 (V3): the brush wheels read vr/sculpt.js, which is a STATIC import away from this leaf's importers only +// through the radial; it is primed here (the colliderEdit pattern) so the sectors' label / visible / active +// stay synchronous — and every read is null-safe until it lands +/** @type {any} */ let sculptRef = null; +let vrScatterToolNow = 'paint'; +let vrSourceNow = ''; +if (typeof window !== 'undefined') + import('./vr/sculpt.js').then((m) => { + sculptRef = m; + refreshBrushReadouts(); + }); +/** @returns {{value: string, label: string}[]} */ +function vrSourceSlotsNow() { + return sculptRef ? sculptRef.vrSourceSlots() : []; +} +function refreshBrushReadouts() { + if (!sculptRef) return; + vrScatterToolNow = sculptRef.vrScatterTool(); + vrSourceNow = sculptRef.vrSourceLabel(); +} + registerBuiltins(); // closing the menu (any path) resets navigation to the base ring vrMenuOpen.subscribe((open) => { + refreshBrushReadouts(); // 52 (V3): the Scatter ▸ / Source ▸ sectors read the brush as it is now if (!open) resetRings(); // 37 R9: a sculpt session opens the menu on its own ring (Back pops to the base ring) else if (get(vrSculptActive) && get(activeRing) === 'root') pushRing('sculpt'); diff --git a/tests/e2e/vr-brushes-iwer.test.cjs b/tests/e2e/vr-brushes-iwer.test.cjs new file mode 100644 index 000000000..79184d156 --- /dev/null +++ b/tests/e2e/vr-brushes-iwer.test.cjs @@ -0,0 +1,255 @@ +// 52 (V3) — TERRAIN PAINT + SCATTER BRUSHES ON THE CONTROLLER, on IWER (48 G2: the app's own XR path, a +// Quest 3 emulated). The desktop toolbar's Paint (four layers) and Scatter (+ 51's Erase / Rotate / Scale / +// Relax) in the headset: trigger strokes, the stick sizes (x) and sets strength / density (y), the Sculpt +// ring's Paint ▸ and Scatter ▸ wheels (+ Source ▸), the brush ring on the surface, a tick on stroke start. +// And all of it in a MINIATURE (52 V5: the world scaled down to a table) — plus placement there. +// +// 1 the Sculpt ring grows Paint ▸ and Scatter ▸; Paint ▸ lists Layer 1-4; Scatter ▸ the five tools + Source ▸ +// 2 Paint: Layer 3 from the wheel, a trigger stroke paints the splat map (ONE undo entry), a tick in the +// painting hand, the ring on the terrain at the brush radius; the label says "Paint 3 · …" +// 3 the stick: x sizes the brush, y the strength — and the DENSITY while scatter-painting +// 4 Scatter: Source ▸ lists the primitives (+ what is in use); Sphere from it; a stroke adds instances; +// Erase removes them; Rotate turns them (each ONE undo entry) +// 5 the miniature (the world at 1:10): the ring is drawn at a tenth; a paint and a scatter stroke land at +// the terrain-local spot under the ray; Add ▸ Cube lands 2 m ahead in REAL space, a tenth in the model +// Red on the base (origin/feat/51): no Paint ▸ / Scatter ▸ in the headset at all. +const h = require('./helpers.cjs'); +const iwer = require('./iwer.cjs'); + +const g = (page, fn, arg) => page.evaluate(fn, arg); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { init: iwer.initScript(), context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + const sc = () => g(page, () => window.__stores.vrSculpt.vrSculptDebug()); + const menu = (id) => g(page, (id) => window.__stores.vrControls.executeVRMenuAction(id), id); + const ring = (r) => g(page, (r) => window.__stores.vrRadialMenu.ringEntries(r).map((e) => e.id), r); + const undoLen = () => + g(page, () => { + let v; + window.__stores.history.undoStack.subscribe((x) => (v = x))(); + return v.length; + }); + const terrain = () => + g(page, () => { + let sel; + window.__stores.selectedObject.subscribe((x) => (sel = x))(); + return sel?.uuid ?? null; + }); + const digest = (uuid) => g(page, (uuid) => window.__stores.terrainPaint.splatDigest(uuid), uuid); + const instances = (uuid) => + g(page, (uuid) => { + let grp; + window.__stores.objectsGroup.subscribe((x) => (grp = x))(); + const o = grp.getObjectByProperty('uuid', uuid); + const counts = window.__stores.terrainSurface.scatterCounts(o); + return Object.values(counts).reduce((a, b) => a + b, 0); + }, uuid); + const scatterYaws = (uuid) => + g(page, (uuid) => { + const block = window.__stores.terrainPaint.scatterBlockNow(uuid); + const out = []; + for (const set of block?.sets ?? []) for (const i of window.__stores.terrainPaint.scatterInstancesOf(uuid, set.id)) out.push(+(+i.yaw).toFixed(4)); + return out; + }, uuid); + + // ---- a 12 m terrain 5 m ahead, and the session on it ------------------------------------------------ + const uuid = await g(page, async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create Terrain 12 48'); + await new Promise((r) => setTimeout(r, 300)); + let sel; + s.selectedObject.subscribe((x) => (sel = x))(); + sel.position.set(0, 0, -5); + sel.updateMatrixWorld(true); + return sel.uuid; + }); + h.check(!!uuid, 'premise: a terrain'); + await iwer.enter(page); + const menuHand = await g(page, () => { + let v; + window.__stores.vrMenuHand.subscribe((x) => (v = x))(); + return v; + }); + const pointer = menuHand === 'right' ? 'left' : 'right'; + console.log(`menu hand ${menuHand}, pointer ${pointer}`); + h.check(await g(page, (u) => window.__stores.vrSculpt.startVRSculpt(u), uuid), 'premise: a VR sculpt session on it'); + // the pointer hand aims down at the terrain ~2.5 m ahead (local (0, -2.5) on the terrain) + const aim = (z = -2.5, x = 0) => iwer.pose(page, pointer, [x, 1.3, z + 0.75], { pitch: -1.05 }); + await aim(); + await iwer.frames(page, 4); + /** a held trigger stroke over `frames`, the hand sweeping 40 cm sideways */ + const stroke = async (n = 24, z = -2.5) => { + await aim(z, -0.2); + await iwer.frames(page, 2); + await iwer.button(page, pointer, 'trigger', true); + for (let i = 0; i < n; i++) { + await aim(z, -0.2 + (0.4 * i) / n); + await iwer.frames(page, 1); + } + await iwer.button(page, pointer, 'trigger', false); + await iwer.frames(page, 4); + }; + + console.log('\n=== 1. the wheels ==='); + const sculptRing = await ring('sculpt'); + h.check(sculptRing.join() === 'sculpt:raise,sculpt:lower,sculpt:smooth,sculpt:flatten,nav:sculpt-paint,nav:sculpt-scatter,sculpt:done', `1.1 the Sculpt ring: Raise · Lower · Smooth · Flatten · Paint ▸ · Scatter ▸ · Done (${sculptRing.join(' ')})`); + const paintRing = await ring('sculpt-paint'); + h.check(paintRing.join() === 'sculpt:layer:0,sculpt:layer:1,sculpt:layer:2,sculpt:layer:3', `1.2 Paint ▸ = the four layers (${paintRing.join(' ')})`); + const scatterRing = await ring('sculpt-scatter'); + h.check(scatterRing.join() === 'sculpt:tool:paint,sculpt:tool:erase,sculpt:tool:rotate,sculpt:tool:scale,sculpt:tool:relax,nav:sculpt-source', `1.3 Scatter ▸ = Paint · Erase · Rotate · Scale · Relax · Source ▸ (${scatterRing.join(' ')})`); + + console.log('\n=== 2. Paint ==='); + await menu('sculpt:layer:2'); + await iwer.frames(page, 3); + let s = await sc(); + h.check(s.op === 'paint' && s.layer === 2, `2.1 Layer 3 from the wheel arms Paint on layer 3 (op ${s.op}, layer ${s.layer})`); + const cur = await g(page, () => window.__stores.terrainSculpt.cursorDebug()); + h.check(cur?.visible && Math.abs(cur.radius - s.radius) < 1e-6 && Math.abs(cur.pos[2] - -2.5) < 0.3, `2.2 the ring sits on the terrain under the ray at the brush radius (r ${cur?.radius}, z ${cur?.pos?.[2]?.toFixed(2)})`); + h.check(/^Paint 3 · \d/.test(s.label) && s.labelHand === pointer, `2.3 the hand label reads "${s.label}"`); + let d0 = await digest(uuid); + let u0 = await undoLen(); + const ticks0 = s.strokeTicks; + await stroke(); + s = await sc(); + let d1 = await digest(uuid); + h.check(d1 !== d0, '2.4 a trigger stroke paints the splat map'); + h.check((await undoLen()) === u0 + 1, `2.5 ONE undo entry for the stroke (${u0} -> ${await undoLen()})`); + h.check(s.strokeTicks === ticks0 + 1, `2.6 the stroke started with a tick in the hand (${ticks0} -> ${s.strokeTicks})`); + + console.log('\n=== 3. the stick ==='); + await aim(); // off the stroke + const r0 = s.radius; + const st0 = s.strength; + await iwer.stick(page, pointer, 1, 0); + await iwer.frames(page, 40); + await iwer.stick(page, pointer, 0, -1); + await iwer.frames(page, 40); + await iwer.stick(page, pointer, 0, 0); + await iwer.frames(page, 3); + s = await sc(); + h.check(s.radius > r0 + 0.3 && s.strength > st0 + 0.1, `3.1 stick right grows the brush, up raises the strength (r ${r0} -> ${s.radius}, ${st0} -> ${s.strength})`); + + console.log('\n=== 4. Scatter ==='); + await menu('sculpt:tool:paint'); + await iwer.frames(page, 3); + const sources = await g(page, () => window.__stores.vrSculpt.vrSourceSlots().map((x) => x.label)); + h.check(['Sphere', 'Cone', 'Box'].every((p) => sources.includes(p)), `4.1 Source ▸ lists the primitives (${sources.join(', ')})`); + await menu('sculpt:src:' + sources.indexOf('Sphere')); + await iwer.frames(page, 4); + s = await sc(); + h.check(s.op === 'scatter' && s.tool === 'paint' && s.source === 'Sphere', `4.2 Sphere from the wheel: scatter-painting spheres (${s.op}/${s.tool}/${s.source})`); + h.check(/^Sphere · .*\/m²$/.test(s.label), `4.3 the label shows the density (${s.label})`); + const den0 = s.density; + await iwer.stick(page, pointer, 0, -1); + await iwer.frames(page, 30); + await iwer.stick(page, pointer, 0, 0); + await iwer.frames(page, 3); + s = await sc(); + h.check(s.density > den0 + 0.2, `4.4 while scatter-painting the stick's y is the density (${den0} -> ${s.density})`); + let n0 = await instances(uuid); + u0 = await undoLen(); + await stroke(); + let n1 = await instances(uuid); + h.check(n1 > n0 + 3, `4.5 a stroke scatters spheres (${n0} -> ${n1})`); + h.check((await undoLen()) === u0 + 1, '4.6 ONE undo entry'); + const y0 = await scatterYaws(uuid); + await menu('sculpt:tool:rotate'); + await iwer.frames(page, 3); + u0 = await undoLen(); + await stroke(); + const y1 = await scatterYaws(uuid); + h.check(y1.length === y0.length && y1.some((v, i) => Math.abs(v - y0[i]) > 1e-3), '4.7 Rotate turns the instances under the brush'); + h.check((await undoLen()) === u0 + 1, '4.8 ONE undo entry'); + await menu('sculpt:tool:erase'); + await iwer.frames(page, 3); + await stroke(40); + const n2 = await instances(uuid); + h.check(n2 < n1, `4.9 Erase removes them (${n1} -> ${n2})`); + + console.log('\n=== 5. in a miniature (the world at 1:10) ==='); + // the rig at 1:10 with the terrain's centre on a table 1 m ahead, 0.9 m up: local (0,0,-5) -> world (0, 0.9, -1) + await g(page, () => { + let rig; + window.__stores.worldRig.subscribe((x) => (rig = x))(); + rig.scale.setScalar(0.1); + rig.position.set(0, 0.9, -0.5); + rig.updateMatrixWorld(true); + }); + await menu('sculpt:layer:1'); + // the pointer over the table: world (0.1, 0.9, -1.1) -> terrain-local (1, 0, -1) + await iwer.pose(page, pointer, [0.1, 1.25, -0.95], { pitch: -1.2 }); + await iwer.frames(page, 4); + s = await sc(); + const mini = await g(page, () => window.__stores.terrainSculpt.cursorDebug()); + h.check(mini?.visible && Math.abs(mini.radius - s.radius * 0.1) < 1e-4, `5.1 the ring is drawn at a tenth (world r ${mini?.radius?.toFixed(3)} for a ${s.radius} m brush)`); + // where the pointer's ray meets the terrain (world), and the ring's place in the terrain's own frame + const probe = await g(page, (u) => { + const st = window.__stores; + const T = st.THREE; + let grp, sc, r; + st.objectsGroup.subscribe((x) => (grp = x))(); + st.globalScene.subscribe((x) => (sc = x))(); + st.globalRenderer.subscribe((x) => (r = x))(); + const o = grp.getObjectByProperty('uuid', u); + let menuHand; + st.vrMenuHand.subscribe((x) => (menuHand = x))(); + const want = menuHand === 'right' ? 'left' : 'right'; + const c = [r.xr.getController(0), r.xr.getController(1)].find((x) => x.userData.handedness === want); + const origin = c.getWorldPosition(new T.Vector3()); + const dir = new T.Vector3(0, 0, -1).applyQuaternion(c.getWorldQuaternion(new T.Quaternion())); + const hit = new T.Raycaster(origin, dir).intersectObject(o, false)[0]; + const ring = sc.getObjectByName('sculpt-cursor'); + return { hit: hit ? hit.point.toArray() : null, ring: ring.position.toArray(), local: o.worldToLocal(ring.position.clone()).toArray() }; + }, uuid); + const local = probe.local; + const off = probe.hit ? Math.hypot(probe.hit[0] - probe.ring[0], probe.hit[2] - probe.ring[2]) : 9; + h.check(off < 0.01 && Math.abs(local[0] - 1) < 0.3, `5.2 the ring is where the ray meets the MODEL (${off.toFixed(4)} m off; terrain-local ${local.map((v) => v.toFixed(2))})`); + d0 = await digest(uuid); + u0 = await undoLen(); + await iwer.button(page, pointer, 'trigger', true); + await iwer.frames(page, 12); + await iwer.button(page, pointer, 'trigger', false); + await iwer.frames(page, 4); + d1 = await digest(uuid); + h.check(d1 !== d0 && (await undoLen()) === u0 + 1, '5.3 a paint stroke in the miniature paints (one undo entry)'); + await menu('sculpt:tool:paint'); + n0 = await instances(uuid); + await iwer.button(page, pointer, 'trigger', true); + await iwer.frames(page, 16); + await iwer.button(page, pointer, 'trigger', false); + await iwer.frames(page, 4); + n1 = await instances(uuid); + const near = await g(page, (u) => { + const out = []; + const block = window.__stores.terrainPaint.scatterBlockNow(u); + for (const set of block?.sets ?? []) for (const i of window.__stores.terrainPaint.scatterInstancesOf(u, set.id)) out.push([i.x, i.z]); + return out; + }, uuid); + const fresh = near.filter(([x, z]) => Math.hypot(x - local[0], z - local[2]) <= s.radius + 0.5).length; + h.check(n1 > n0 && fresh >= n1 - n0, `5.4 a scatter stroke in the miniature lands under the ray (${n1 - n0} new, ${fresh} within the brush of the ring)`); + await menu('sculpt:done'); + await iwer.frames(page, 3); + // placement: Add ▸ Cube 2 m ahead in REAL space + const placed = await g(page, async () => { + const s = window.__stores; + s.vrControls.executeVRMenuAction('box'); + await new Promise((r) => setTimeout(r, 300)); + let sel, cam; + s.selectedObject.subscribe((x) => (sel = x))(); + s.globalCamera.subscribe((x) => (cam = x))(); + const T = s.THREE; + const w = sel.getWorldPosition(new T.Vector3()); + const c = cam.getWorldPosition(new T.Vector3()); + return { world: w.toArray(), cam: c.toArray(), local: sel.position.toArray(), worldScale: sel.getWorldScale(new T.Vector3()).x }; + }); + const dist = Math.hypot(placed.world[0] - placed.cam[0], placed.world[2] - placed.cam[2]); + h.check(Math.abs(dist - 2) < 0.1 && Math.abs(placed.worldScale - 0.1) < 1e-6, `5.5 Add ▸ Cube in the miniature lands 2 m ahead, model-sized (${dist.toFixed(2)} m, world scale ${placed.worldScale})`); + // objectsGroup rides the rig: local = (world - rig position) / 0.1 + const want = [placed.world[0] * 10, (placed.world[2] + 0.5) * 10]; + h.check(Math.hypot(placed.local[0] - want[0], placed.local[2] - want[1]) < 0.2, `5.6 ...at the model's own coordinates (local ${placed.local.map((v) => v.toFixed(1))})`); + + await iwer.exit(page); + await h.finish(browser); +}); diff --git a/tests/e2e/vr-brushes-perf.test.cjs b/tests/e2e/vr-brushes-perf.test.cjs new file mode 100644 index 000000000..1283b8405 --- /dev/null +++ b/tests/e2e/vr-brushes-perf.test.cjs @@ -0,0 +1,134 @@ +// 52 (V3) PERF — a paint stroke and a scatter stroke on a terrain carrying 2 000 scattered instances, in the +// headset (IWER, a Quest 3), with the budget meter on the Quest target ("the Quest profile"). Run it with +// `e2e-slot --local` (or --exclusive): a remote slot renders on a desktop GPU. +// +// What is GATED is the work the brush adds on the main thread per XR frame — what a Quest has to fit +// inside 13.9 ms at 72 Hz beside everything else: the stroke frames' XR-frame callback time minus the +// same scene's idle frames (median and p95), plus the instancing (2 000 instances must stay a handful of +// draw calls). The whole frame on this box is PRINTED, never gated: it is not a Quest number (headless +// ANGLE on the Deck's GPU). A real-Quest row (stroke at 72 Hz, no dropped frames) goes on the device checklist. +const h = require('./helpers.cjs'); +const iwer = require('./iwer.cjs'); + +const g = (page, fn, arg) => page.evaluate(fn, arg); +const BUDGET_MS = 1000 / 72; // 13.9 +const BRUSH_SHARE_MS = 4; // the brush's own share of a 72 Hz frame (median); p95 may reach 7 + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { init: iwer.initScript(), context: { viewport: { width: 1280, height: 720 } }, storage: { budgetTarget: 'quest' } }); + const page = A.page; + // time every XR frame callback (the whole frame: VR input, the brush, the scene update, the render submit) + await g(page, () => { + const proto = window.XRSession?.prototype; + if (!proto || proto.__timed) return; + const raf = proto.requestAnimationFrame; + window.__xrFrames = []; + proto.requestAnimationFrame = function (cb) { + return raf.call(this, (t, f) => { + const t0 = performance.now(); + try { + cb(t, f); + } finally { + window.__xrFrames.push(performance.now() - t0); + if (window.__xrFrames.length > 4000) window.__xrFrames.shift(); + } + }); + }; + proto.__timed = true; + }); + const uuid = await g(page, async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create Terrain 24 64'); + await new Promise((r) => setTimeout(r, 300)); + let sel; + s.selectedObject.subscribe((x) => (sel = x))(); + sel.position.set(0, 0, -8); + sel.updateMatrixWorld(true); + s.terrainPaint.addScatterSet(sel.uuid, { source: { kind: 'primitive', type: 'Cone' }, seed: 7, gen: { count: 2000, scaleMin: 0.2, scaleMax: 0.4 } }); + await new Promise((r) => setTimeout(r, 500)); + return sel.uuid; + }); + const count = await g(page, (u) => { + let grp; + window.__stores.objectsGroup.subscribe((x) => (grp = x))(); + return Object.values(window.__stores.terrainSurface.scatterCounts(grp.getObjectByProperty('uuid', u))).reduce((a, b) => a + b, 0); + }, uuid); + h.check(count >= 2000, `premise: the terrain draws 2 000 scattered instances (${count})`); + const target = await g(page, () => { + let v; + window.__stores.budgetMeter?.budgetTarget?.subscribe((x) => (v = x))(); + return v ?? localStorage.getItem('budgetTarget'); + }); + h.check(target === 'quest', `premise: the budget meter judges for the Quest (${target})`); + + await iwer.enter(page); + const menuHand = await g(page, () => { + let v; + window.__stores.vrMenuHand.subscribe((x) => (v = x))(); + return v; + }); + const pointer = menuHand === 'right' ? 'left' : 'right'; + await g(page, (u) => window.__stores.vrSculpt.startVRSculpt(u), uuid); + const aim = (x = 0) => iwer.pose(page, pointer, [x, 1.3, -2], { pitch: -0.9 }); + await aim(); + await iwer.frames(page, 30); + + const drawCalls = await g(page, () => { + const s = window.__stores; + let r, scene, cam; + s.globalRenderer.subscribe((x) => (r = x))(); + s.globalScene.subscribe((x) => (scene = x))(); + s.globalCamera.subscribe((x) => (cam = x))(); + const auto = r.info.autoReset; + r.info.autoReset = false; + r.info.reset(); + const xr = r.xr.enabled; + r.xr.enabled = false; // one mono render, the instancing count + r.render(scene, cam); + r.xr.enabled = xr; + const out = { calls: r.info.render.calls, tris: r.info.render.triangles }; + r.info.autoReset = auto; + return out; + }); + console.log(` draw calls (mono, whole scene): ${drawCalls.calls}, triangles ${drawCalls.tris}`); + h.check(drawCalls.calls < 40, `2 000 instances stay instanced (${drawCalls.calls} draw calls for the whole scene)`); + + /** XR frame times over n frames, optionally with a trigger stroke sweeping the hand */ + const sample = async (n, stroking) => { + await g(page, () => (window.__xrFrames = [])); + if (stroking) await iwer.button(page, pointer, 'trigger', true); + for (let i = 0; i < n; i++) { + if (stroking) await aim(-1 + (2 * i) / n); + await iwer.frames(page, 1); + } + if (stroking) await iwer.button(page, pointer, 'trigger', false); + await iwer.frames(page, 4); + const list = await g(page, () => window.__xrFrames.slice()); + const sorted = [...list].sort((a, b) => a - b); + const q = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))] ?? 0; + return { n: list.length, med: q(0.5), p95: q(0.95) }; + }; + const fmt = (x) => `median ${x.med.toFixed(2)} ms, p95 ${x.p95.toFixed(2)} ms over ${x.n} frames`; + + const idle = await sample(90, false); + console.log(' idle (brush up, 2 000 instances):', fmt(idle)); + await g(page, () => window.__stores.vrControls.executeVRMenuAction('sculpt:layer:1')); + const paint = await sample(120, true); + console.log(' paint stroke:', fmt(paint)); + await g(page, () => window.__stores.vrControls.executeVRMenuAction('sculpt:tool:paint')); + const scatter = await sample(120, true); + console.log(' scatter stroke:', fmt(scatter)); + await g(page, () => window.__stores.vrControls.executeVRMenuAction('sculpt:tool:rotate')); + const rotate = await sample(120, true); + console.log(' rotate stroke (51 edit brush over the 2 000):', fmt(rotate)); + for (const [name, x] of [['paint', paint], ['scatter', scatter], ['rotate', rotate]]) { + const added = x.med - idle.med; + h.check(added < BRUSH_SHARE_MS, `the ${name} stroke adds ${added.toFixed(2)} ms per XR frame (median; budget share ${BRUSH_SHARE_MS} of ${BUDGET_MS.toFixed(1)} ms)`); + h.check(x.p95 - idle.med < 7, `the ${name} stroke's p95 frame stays within 7 ms of idle (${(x.p95 - idle.med).toFixed(2)} ms)`); + } + console.log(` (this box's whole XR frame is printed only — not a Quest number; the 72 Hz budget is ${BUDGET_MS.toFixed(1)} ms)`); + + await iwer.exit(page); + await h.finish(browser); +}); diff --git a/tests/e2e/vr-world.test.cjs b/tests/e2e/vr-world.test.cjs index 701e5b186..8a3a31504 100644 --- a/tests/e2e/vr-world.test.cjs +++ b/tests/e2e/vr-world.test.cjs @@ -247,7 +247,8 @@ h.run(async () => { }); const sculptRing = await g(() => window.__stores.vrRadialMenu.ringEntries('sculpt').map((e) => e.id)); h.check(activeRing === 'sculpt', `while sculpting the menu opens on the Sculpt ring (${activeRing})`); - h.check(sculptRing.join() === 'sculpt:raise,sculpt:lower,sculpt:smooth,sculpt:flatten,sculpt:done', `Raise / Lower / Smooth / Flatten / Done (${sculptRing.join(' ')})`); + // 52 (V3): + the Paint ▸ and Scatter ▸ brush wheels (vr-brushes-iwer) + h.check(sculptRing.join() === 'sculpt:raise,sculpt:lower,sculpt:smooth,sculpt:flatten,nav:sculpt-paint,nav:sculpt-scatter,sculpt:done', `Raise / Lower / Smooth / Flatten / Paint ▸ / Scatter ▸ / Done (${sculptRing.join(' ')})`); await g(() => window.__stores.vrControls.executeVRMenuAction('sculpt:raise')); await wait(300); h.check(!(await g(() => { let v; window.__stores.vrMenuOpen.subscribe((x) => (v = x))(); return v; })) && (await sc()).op === 'raise', 'Raise picks the brush and closes the ring'); From 118a4cacf54abeb44bea912ad07e418c85e28ab8 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 18:17:35 +0300 Subject: [PATCH 11/29] [test] 52 V3 perf: time XR callbacks summed per frame, not one by one - several callbacks share one XR frame and most are empty, so per-callback medians read 0 ms - lane run (--local, Quest profile, 2k instances): paint +0.2 ms, scatter +0.3 ms, rotate +3.0 ms median over idle Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/vr-brushes-perf.test.cjs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/e2e/vr-brushes-perf.test.cjs b/tests/e2e/vr-brushes-perf.test.cjs index 1283b8405..73f785727 100644 --- a/tests/e2e/vr-brushes-perf.test.cjs +++ b/tests/e2e/vr-brushes-perf.test.cjs @@ -18,20 +18,21 @@ h.run(async () => { const browser = await h.launch({ args: h.GPU_ARGS }); const A = await h.setupPage(browser, 'A', { init: iwer.initScript(), context: { viewport: { width: 1280, height: 720 } }, storage: { budgetTarget: 'quest' } }); const page = A.page; - // time every XR frame callback (the whole frame: VR input, the brush, the scene update, the render submit) + // time every XR frame callback, summed PER FRAME (the frame's timestamp keys it: several callbacks — three's + // loop, IWER's, the app's — share one XR frame; most are empty, so timing them one by one reads 0 ms) await g(page, () => { const proto = window.XRSession?.prototype; if (!proto || proto.__timed) return; const raf = proto.requestAnimationFrame; - window.__xrFrames = []; + window.__xrFrames = new Map(); proto.requestAnimationFrame = function (cb) { return raf.call(this, (t, f) => { const t0 = performance.now(); try { cb(t, f); } finally { - window.__xrFrames.push(performance.now() - t0); - if (window.__xrFrames.length > 4000) window.__xrFrames.shift(); + const m = window.__xrFrames; + m.set(t, (m.get(t) ?? 0) + performance.now() - t0); } }); }; @@ -96,7 +97,7 @@ h.run(async () => { /** XR frame times over n frames, optionally with a trigger stroke sweeping the hand */ const sample = async (n, stroking) => { - await g(page, () => (window.__xrFrames = [])); + await g(page, () => window.__xrFrames.clear()); if (stroking) await iwer.button(page, pointer, 'trigger', true); for (let i = 0; i < n; i++) { if (stroking) await aim(-1 + (2 * i) / n); @@ -104,7 +105,7 @@ h.run(async () => { } if (stroking) await iwer.button(page, pointer, 'trigger', false); await iwer.frames(page, 4); - const list = await g(page, () => window.__xrFrames.slice()); + const list = await g(page, () => [...window.__xrFrames.values()]); const sorted = [...list].sort((a, b) => a - b); const q = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))] ?? 0; return { n: list.length, med: q(0.5), p95: q(0.95) }; From 4cd3dafc21984b5d68f2a202694376689bcec3a6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 00:26:26 +0300 Subject: [PATCH 12/29] =?UTF-8?q?[test]=2052=20union:=20vr-radial-menu=20c?= =?UTF-8?q?ounts=20Add=20=E2=96=B8=20Assets=20(52=20V4)=20in=20the=20Add?= =?UTF-8?q?=20ring=20(9=20=E2=86=92=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 52-browser registers Add ▸ Assets from its mount; the suite was not in that lane's battery. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/vr-radial-menu.test.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/vr-radial-menu.test.cjs b/tests/e2e/vr-radial-menu.test.cjs index d7ec3e08d..0d025a0cb 100644 --- a/tests/e2e/vr-radial-menu.test.cjs +++ b/tests/e2e/vr-radial-menu.test.cjs @@ -112,7 +112,7 @@ h.run(async () => { ); h.check(registry.system.length === 0, 'no System ring is left (a module filing under "system" lands in Settings)'); h.check(registry.objectOps.includes('snap'), 'Selected ring keeps Snapping'); - h.check(registry.addCount === 9 && registry.sceneHasEnv, `Add ring: 6 primitives + Terrain (37 R9) + Architecture ▸ (37 R3) + Prefabs (115) (${registry.addCount})`); + h.check(registry.addCount === 10 && registry.sceneHasEnv, `Add ring: 6 primitives + Terrain (37 R9) + Architecture ▸ (37 R3) + Prefabs (115) + Assets (52 V4) (${registry.addCount})`); h.check( registry.archRing.length === 12 && registry.archRing[0] === 'Wall' && registry.archRing.includes('Stairs (spiral)'), `Architecture ▸ ring = the desktop Add menu's 12 pieces (${registry.archRing.join(', ')})` From b727168aeea7ef306d2bf1d77d0f7de90920fdcc Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 17:27:48 +0300 Subject: [PATCH 13/29] [physics] #14 On Impact fires on a hit from any side, not only on a landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The impact rule was "pre-step downward velocity >= 1.2 m/s", so a ball thrown sideways at a wall or a target never fired On Impact. A contact now also counts when the two bodies close along the contact normal (rapier's manifold) at >= 1.2 m/s. The downward test stays as its own channel with its own cooldown: On Impact gains `direction: any | down` — new nodes are seeded 'any', a node saved without the key reads 'down' (param `legacy`, honoured by the Flow panel), so old scenes fire exactly as before; emitters' "On impact" keep the landing rule. impactSpeed.js is the leaf (closingSpeed / impactStrengthFor), unit-tested; e2e flow-impact-any (red on 4cd3dafc: the 'any' nodes never fire on the wall/target hit). Co-Authored-By: Claude Opus 5.5 (1M context) --- NODES.md | 2 +- .../editors/nodes/ContactNode.svelte | 13 ++ src/lib/flowRuntime.js | 15 +- src/lib/graphContract.js | 4 +- src/lib/impactSpeed.js | 44 ++++ src/lib/nodeCatalog.js | 19 +- src/lib/nodeDocs.js | 2 +- src/lib/nodeProps.js | 2 +- src/lib/physics.js | 99 ++++++-- tests/e2e/flow-impact-any.test.cjs | 214 ++++++++++++++++++ tests/unit/impactSpeed.test.js | 76 +++++++ 11 files changed, 455 insertions(+), 35 deletions(-) create mode 100644 src/lib/impactSpeed.js create mode 100644 tests/e2e/flow-impact-any.test.cjs create mode 100644 tests/unit/impactSpeed.test.js diff --git a/NODES.md b/NODES.md index 52ef9b869..118e648be 100644 --- a/NODES.md +++ b/NODES.md @@ -31,7 +31,7 @@ Verdicts: OK · FIX(ed this batch) · DOC(umented quirk). | visibility | Logic | effect | on | OK | | onclick | Triggers | event | — | FIX: event→effect is now a legal drag (the typed sockets rejected wiring On Click into the Object Selector its own feature requires). Pulse window OK. | | counter | Triggers | number | pulse reset | FIX (21-E4): a wired `reset` EVENT zeroes it, which is what a round-2 score needs — the counting lives inside `applyNodeTrigger`, so the reset rides that same path and is handle-aware there rather than being a second code path. The `op` param is unchanged and still governs a counter with nothing wired into reset. | -| onimpact | Triggers | event | minStrength | NEW (PFX-C): pulses when the physics INITIATOR lands the object on the ground/another object (rapier contact-start + pre-step downward velocity ≥ 1.2 m/s + 300ms per-body cooldown). Targeting walks THROUGH intermediate nodes to the Object Selector (the fireObjectClick BFS) and honors the implicit-owner rule. Replicated via nodetrigger stamps; `minStrength` gates on impact speed (wirable). | +| onimpact | Triggers | event | minStrength, direction | NEW (PFX-C): pulses when the physics INITIATOR registers a contact-start for the object. fix-batch-1 #14: `direction: 'any'` (new nodes) = the pre-step CLOSING speed along the contact normal ≥ 1.2 m/s, from any side (walls, targets, a kinematic paddle); `'down'` = pre-step downward velocity ≥ 1.2 m/s, the only rule up to 1.43 and what a node saved WITHOUT `direction` keeps (`legacy: 'down'` on the param). Each channel has its own 300ms per-body cooldown; emitters' "On impact" stay on the landing channel. Targeting walks THROUGH intermediate nodes to the Object Selector (the fireObjectClick BFS) and honors the implicit-owner rule. Replicated via nodetrigger stamps; `minStrength` gates on impact speed (wirable). | | shake/spin/bounce/orbit | Animation | effect | per-param | OK (generic AnimationNode; params get ⓘ editors where sensible) | | rotor (Rotate / Motor) | Animation | effect | on (boolean) + per-param | NEW (36-fb F25): turns about a LOCAL axis and the object's origin, rpm + spin-up as a pure function of the shared clock (deterministic, kinematic for physics — pushes bodies, carries Fluid-emitter water through the measured collider motion); on a DYNAMIC body during a run it is a torque-limited motor on the initiator instead (POSE_FREE). | | flowfloat (Float Along Flow) | Animation | effect | path (object) + per-param | NEW (36-fb F24): rides a Flow path from the base pose at the path speed × speed, bob, faces the current, wraps; nearest path within 4 m when unwired. Pure function of (base, path, time) — nothing sent. | diff --git a/src/components/editors/nodes/ContactNode.svelte b/src/components/editors/nodes/ContactNode.svelte index 06bdbe929..573ebf04b 100644 --- a/src/components/editors/nodes/ContactNode.svelte +++ b/src/components/editors/nodes/ContactNode.svelte @@ -48,6 +48,19 @@ /> {(+(data.minStrength ?? 1)).toFixed(1)} + + {/if}
other diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 619962fa9..c1b892ee1 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -17,6 +17,7 @@ import { scriptInputs, scriptOutputs, isScriptValue, coerceInput } from './scrip import { findNodeDef } from './customNodes'; import { updateSounds } from './soundRuntime'; import { colliderSpecOf } from './colliderSpec'; // B6: pure THREE leaf +import { impactStrengthFor } from './impactSpeed'; // fix-batch-1 #14: a leaf import { updateParticles } from './particleRuntime'; // 36-sim: jiggle's park (a leaf over three + flowStore; closes no cycle) import { parkJiggle } from './sim/jiggleRuntime.js'; @@ -3568,15 +3569,21 @@ export function objectHasOnClick(uuid) { * stamp replicates (nodetrigger), so every peer computes the identical pulse. * 36 X6: `otherUuid` = the body it hit ('' = the ground) — gated by the node's * `filter` input and carried to every peer for its `other` output. - * @param {string} uuid @param {number} strength downward speed at contact (m/s) - * @param {string=} otherUuid + * fix-batch-1 #14: physics registers TWO channels per contact and each node reads the one + * its `direction` names — 'any' the closing speed along the contact normal (a wall, a + * target), 'down' the landing speed (the only rule up to 1.43, and what a node saved + * before the option keeps). null = that channel did not count this contact. + * @param {string} uuid @param {number | null} down downward speed at contact (m/s) + * @param {string=} otherUuid @param {number | null=} speed closing speed along the normal + * (m/s; omitted = `down`, a caller that only knows one number) */ -export function fireObjectImpact(uuid, strength, otherUuid = '') { +export function fireObjectImpact(uuid, down, otherUuid = '', speed = down) { const ctx = runtimeCtx(); nodes.forEach((node) => { if (node.type !== 'onimpact') return; const data = resolveInputs(node, nodes, edges, syncedNow(), ctx); - if (strength < num(data.minStrength ?? 0)) return; + const strength = impactStrengthFor(data.direction, down, speed ?? null); + if (strength === null || strength < num(data.minStrength ?? 0)) return; if (!contactPasses(data, otherUuid)) return; if (reachesObjectSelector(node.id, uuid) || implicitOwnerOf(node) === uuid) fireContact(node, otherUuid); }); diff --git a/src/lib/graphContract.js b/src/lib/graphContract.js index 008b556d7..103432de7 100644 --- a/src/lib/graphContract.js +++ b/src/lib/graphContract.js @@ -59,13 +59,15 @@ export const SOCKET_TYPES = Object.freeze([ * min/max/step (range, number) · options (select) · placeholder/maxLength (text) * socket? false = never offer an input socket for it (a range gets one by default when the * spec has `inputs`; any key named in `inputs` is wire-overridable via resolveInputs) + * legacy? what a node saved BEFORE this key existed reads (`defaults` seeds new nodes; the + * runtime's own fallback must say the same — fix-batch-1 #14, On Impact `direction`) * Module nodes declare the same list in `api.registerNodeGroup` items (`params`). * @typedef {{ * key: string, * kind: 'range' | 'number' | 'select' | 'toggle' | 'text' | 'color' | 'vector3' | 'code', * label?: string, doc?: string, group?: string, * min?: number, max?: number, step?: number, options?: string[], - * placeholder?: string, maxLength?: number, socket?: boolean + * placeholder?: string, maxLength?: number, socket?: boolean, legacy?: any * }} PropSpec */ diff --git a/src/lib/impactSpeed.js b/src/lib/impactSpeed.js new file mode 100644 index 000000000..8ecf99c42 --- /dev/null +++ b/src/lib/impactSpeed.js @@ -0,0 +1,44 @@ +// fix-batch-1 #14: what counts as an IMPACT, as pure functions (physics.js measures, flowRuntime +// gates). Up to 1.43 only a landing counted — the pre-step DOWNWARD velocity had to reach 1.2 m/s, +// so a ball thrown sideways at a wall or a target never fired On Impact. Now a contact counts +// when the bodies CLOSE along the contact normal fast enough, from any side; the downward test +// is kept as its own channel so On Impact nodes saved before it (no `direction`) and the +// emitters' "On impact" burst behave exactly as they did. +// +// A LEAF: no imports, plain {x, y, z} objects, so the whole rule is unit-testable. + +export const IMPACT_MIN_DOWN_VY = 1.2; // m/s downward (pre-step) for a landing to count +export const IMPACT_MIN_SPEED = 1.2; // m/s closing speed along the contact normal (pre-step) + +/** the On Impact `direction` a node saved before the key existed has */ +export const LEGACY_IMPACT_DIRECTION = 'down'; + +/** @typedef {{x: number, y: number, z: number}} V3 */ + +/** + * How fast two bodies approach each other along the contact normal (m/s, ≥ 0). The normal's + * sign does not matter (rapier's points from collider 1 to 2, either side may be ours), and a + * graze along a wall reads ~0 however fast it is. No normal (no manifold) → the vertical, the + * old landing assumption. + * @param {V3} va pre-step velocity of this body @param {V3 | null} vb the other's (null = static) + * @param {V3 | null} n contact normal (any length) + */ +export function closingSpeed(va, vb, n) { + const rx = va.x - (vb?.x ?? 0); + const ry = va.y - (vb?.y ?? 0); + const rz = va.z - (vb?.z ?? 0); + const len = n ? Math.hypot(n.x, n.y, n.z) : 0; + if (!n || !(len > 1e-9)) return Math.abs(ry); + return Math.abs((rx * n.x + ry * n.y + rz * n.z) / len); +} + +/** + * The strength an On Impact node sees for one registered impact, or null when it does not fire. + * `down` / `speed` are the two channels physics registered (null = that channel did not count + * this contact, e.g. a sideways hit has no downward channel). + * @param {any} direction the node's `direction` ('any' | 'down'; missing = the legacy 'down') + * @param {number | null} down @param {number | null} speed + */ +export function impactStrengthFor(direction, down, speed) { + return (direction ?? LEGACY_IMPACT_DIRECTION) === 'down' ? down : speed; +} diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index 19f94bf2d..006d7cce6 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -43,7 +43,7 @@ const V_MARK = '{v}'; * not see it", "my copies vanished when I stopped the sim". HudNode had hand-rolled * exactly this for `hudscreen`; a spec field means the next such node needs no card of * its own to say its one important thing. - * @typedef {{ key: string, kind: 'range' | 'select' | 'toggle' | 'text' | 'number' | 'color' | 'vector3' | 'code', label?: string, doc?: string, group?: string, socket?: boolean, min?: number, max?: number, step?: number, options?: string[], placeholder?: string, maxLength?: number }} NodeParam + * @typedef {{ key: string, kind: 'range' | 'select' | 'toggle' | 'text' | 'number' | 'color' | 'vector3' | 'code', label?: string, doc?: string, group?: string, socket?: boolean, min?: number, max?: number, step?: number, options?: string[], placeholder?: string, maxLength?: number, legacy?: any }} NodeParam * @typedef {{ type: string, label: string, defaults: Record, params?: NodeParam[], inputs?: string[], inputLabels?: Record, note?: string }} NodeSpec */ @@ -1147,12 +1147,23 @@ export const nodeCatalog = [ // input (only fire for that other object) and an `other` output, and Triggers // holds only input-less sources (the palette rule). Palette-only: no wire change. // PFX-C: fires when the physics sim lands this object on the ground / - // another object (initiator-detected, replicated trigger stamp) + // another object (initiator-detected, replicated trigger stamp). + // fix-batch-1 #14: from ANY side by default; a node saved before `direction` + // existed reads 'down' (`legacy`), so old scenes fire exactly as they did { type: 'onimpact', label: 'On Impact', - defaults: { pulse: 0.3, minStrength: 1 }, - params: [{ key: 'minStrength', kind: 'range', min: 0, max: 10, step: 0.1 }] + defaults: { pulse: 0.3, minStrength: 1, direction: 'any' }, + params: [ + { key: 'minStrength', kind: 'range', min: 0, max: 10, step: 0.1 }, + { + key: 'direction', + kind: 'select', + options: ['any', 'down'], + legacy: 'down', + doc: 'any = a hit from any side (speed along the contact); down = landings only' + } + ] }, // CL-C C2: sensor overlap edges (initiator-detected, replicated stamps) { type: 'onenter', label: 'On Enter', defaults: { pulse: 0.3 } }, diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js index e2686bb78..91f3f156a 100644 --- a/src/lib/nodeDocs.js +++ b/src/lib/nodeDocs.js @@ -90,7 +90,7 @@ export const NODE_DOCS = { setuniform: "Writes one number inside an object's shader graph, so a trigger, a", onclick: "Fires a short pulse when its object is clicked - the bridge from user input into the graph.", keypress: "Fires a pulse while a keyboard key is pressed - the bridge from your keyboard into the graph.", - onimpact: "Fires a pulse when a physics simulation lands the connected object on the ground or another object.", + onimpact: "Fires a pulse when the connected object hits something in a physics simulation - from any side (a wall, a target), or only when it lands with From: landing only.", onhit: "Fires a pulse when a hand or a walking player knocks the object - with how hard it was hit (speed) and whether you did it (byMe).", onenter: "Fires a pulse when something enters a trigger volume - the checkpoint, doorway and pressure-plate node.", onexit: "Fires a pulse when something leaves a trigger volume - the other half of On Enter.", diff --git a/src/lib/nodeProps.js b/src/lib/nodeProps.js index 7a4820261..fc11962c6 100644 --- a/src/lib/nodeProps.js +++ b/src/lib/nodeProps.js @@ -84,7 +84,7 @@ export function propertyRows(node, spec, edges, extra = {}) { key: p.key, kind: p.kind, label: p.label ?? p.key, - value: data[p.key] ?? spec?.defaults?.[p.key], + value: data[p.key] ?? ('legacy' in p ? p.legacy : spec?.defaults?.[p.key]), group: p.group ?? '', min: p.min, max: p.max, diff --git a/src/lib/physics.js b/src/lib/physics.js index b03b944bf..930b2a14f 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -35,6 +35,9 @@ import { velocityFromSamples, clampThrow, MAX_LINVEL, MAX_ANGVEL } from './throw // 29-F: the lower-id-keeps-the-world rule, as a leaf that imports nothing — the whole // decision is a pure function of four facts, so its truth table is a vitest unit. import { simulateVerdict } from './simAuthority'; +// fix-batch-1 #14: the impact rule (closing speed along the contact normal, the legacy +// downward channel beside it) as a leaf of pure functions +import { closingSpeed, IMPACT_MIN_DOWN_VY, IMPACT_MIN_SPEED } from './impactSpeed'; // B7: spawned objects are swept when the run ends. transientObjects is a LEAF (the two // stores only), so this edge closes nothing — unlike objectActions, which the // out-of-bounds delete has to reach dynamically. @@ -90,7 +93,7 @@ export const remoteSimulating = writable(null); * lastSent?: {pos: THREE.Vector3, quat: THREE.Quaternion}, * colliders: any[], shapeKey: string, * oob?: boolean, restSince?: number | null, - * preVy?: number, ccd?: boolean, + * preV?: {x: number, y: number, z: number}, ccd?: boolean, * buoy?: {points: Float32Array, weights: Float32Array, volume: number, cellH: number}}} BodyEntry */ /** @type {BodyEntry[]} */ let bodies = []; @@ -130,9 +133,16 @@ let defaultsSubPrimed = false; let oobCount = 0; /** @type {any} */ let oobTimer = null; /** @type {string} */ let oobAction = 'respawn'; -/** @type {Map} uuid -> last impact stamp (step-now ms) */ +/** @type {Map} uuid -> last DOWNWARD impact stamp (step-now ms) */ let lastImpactAt = new Map(); -/** @type {{uuid: string, strength: number, other: string}[]} contacts collected inside the substep loop (36 X6: + the other body) */ +/** fix-batch-1 #14: uuid -> last any-direction impact stamp. Its OWN cooldown: a sideways + * bump must not swallow the landing a downward-only node (an old scene) is waiting for. + * @type {Map} */ +let lastAnyImpactAt = new Map(); +/** contacts collected inside the substep loop (36 X6: + the other body; fix-batch-1 #14: the + * two channels — `down` = downward speed, `speed` = closing speed along the contact normal, + * null where that channel did not count the contact) + * @type {{uuid: string, down: number | null, speed: number | null, other: string}[]} */ let pendingImpacts = []; /** CL-A: colliders attached to FIXED scenery bodies (rebuild bookkeeping — * BodyEntry only exists for dynamic/kinematic). @type {Map} */ @@ -174,7 +184,6 @@ export const PHYSICS_MATERIALS = { // two copies of a clamp is how they drift apart const EXTERNAL_HOLD_MS = 250; // peer-move silence before a held body drops const IMPACT_COOLDOWN_MS = 300; // per-body: a roll/settle can't machine-gun impacts -const IMPACT_MIN_DOWN_VY = 1.2; // m/s downward (pre-step) for a contact to count /** Pre-load the wasm module (also lets tests warm the vite dep cache) */ export async function warmup() { @@ -1063,6 +1072,7 @@ async function startSimulation() { eventQueue = new RAPIER.EventQueue(true); colliderOwner = new Map(); lastImpactAt = new Map(); + lastAnyImpactAt = new Map(); pendingImpacts = []; // 36 X2: static/kinematic scenery may pick the EXACT mesh (ColliderDesc.trimesh, // colliderSpec kind 'trimesh'); terrain from a heightfield is still deferred. @@ -1294,6 +1304,7 @@ export function physicsRemoveBody(uuid) { // cooldown would grow without bound. Purging is also strictly correct — a uuid is // never reused, so a surviving row could only ever suppress nothing. lastImpactAt.delete(uuid); + lastAnyImpactAt.delete(uuid); return true; } @@ -1859,9 +1870,15 @@ function stepInner(now) { feed.entry.body.setNextKinematicRotation({ x: stepQuat.x, y: stepQuat.y, z: stepQuat.z, w: stepQuat.w }); } // PFX-C: impact strength must be sampled BEFORE the step — contact - // resolution rewrites linvel during it (a bounce reads as upward after) + // resolution rewrites linvel during it (a bounce reads as upward after). + // fix-batch-1 #14: the whole vector (a wall hit is horizontal), every body + // (a kinematic mover or a held one is the OTHER side of somebody's impact) bodies.forEach((entry) => { - if (entry.mode === 'dynamic' && !entry.hold) entry.preVy = entry.body.linvel().y; + const v = entry.body.linvel(); + const pre = (entry.preV ??= { x: 0, y: 0, z: 0 }); + pre.x = v.x; + pre.y = v.y; + pre.z = v.z; }); for (const f of floaters) if (!f.entry.hold && f.entry.buoy) @@ -1950,37 +1967,69 @@ function stepInner(now) { } /** - * A contact-start seen inside the substep loop. Either side that is a falling - * DYNAMIC body (pre-step downward velocity above the threshold, not held, off - * cooldown) registers an impact — dynamic-vs-ground, dynamic-vs-scenery and - * dynamic-vs-dynamic all count. + * A contact-start seen inside the substep loop. Either side that is a DYNAMIC body (not held) + * registers an impact when it passes either channel, each with its own cooldown: + * down — the pre-step DOWNWARD velocity reaches 1.2 m/s (a landing; the only rule up to + * 1.43, kept for On Impact nodes set to "down" and every node saved before the + * option existed, and for the emitters' "On impact" burst) + * speed — fix-batch-1 #14: the two bodies CLOSE along the contact normal at ≥ 1.2 m/s, + * from any side (a ball thrown at a wall, a crate shoved into a target, a + * kinematic paddle hitting a ball at rest) + * Dynamic-vs-ground, dynamic-vs-scenery and dynamic-vs-dynamic all count. * * 49 P2: the body it landed ON hears it too when that body cannot register for itself — a * fixed or kinematic object (a pressure plate, a target, a moving platform) gets the same * impact with `other` = the mover. A DYNAMIC partner is left to its own test above, so two * dynamic bodies never fire twice for one contact; a sensor partner has On Enter instead. - * The passive event rides the mover's cooldown, so it cannot machine-gun either. + * The passive event rides the mover's cooldowns, so it cannot machine-gun either. * @param {number} h1 @param {number} h2 @param {number} now step clock (ms) */ function queueContact(h1, h2, now) { for (const handle of [h1, h2]) { if (groundHandles.has(handle)) continue; const entry = colliderOwner.get(handle)?.entry; - if (!entry || entry.mode !== 'dynamic' || entry.hold) continue; - const down = -(entry.preVy ?? 0); - if (down < IMPACT_MIN_DOWN_VY) continue; - const uuid = entry.object.uuid; - if (now - (lastImpactAt.get(uuid) ?? -Infinity) < IMPACT_COOLDOWN_MS) continue; - lastImpactAt.set(uuid, now); - // 36 X6: and what it hit — the other collider's owner, '' for the ground + if (!entry || entry.mode !== 'dynamic' || entry.hold || !entry.preV) continue; const other = handle === h1 ? h2 : h1; const hit = groundHandles.has(other) ? null : colliderOwner.get(other); - pendingImpacts.push({ uuid, strength: down, other: hit?.uuid ?? '' }); + const uuid = entry.object.uuid; + const fall = -entry.preV.y; + const down = fall >= IMPACT_MIN_DOWN_VY && now - (lastImpactAt.get(uuid) ?? -Infinity) >= IMPACT_COOLDOWN_MS ? fall : null; + let speed = null; + if (now - (lastAnyImpactAt.get(uuid) ?? -Infinity) >= IMPACT_COOLDOWN_MS) { + // the ground and fixed scenery stand still; anything else brings its own velocity + const closing = closingSpeed(entry.preV, hit?.entry?.preV ?? null, contactNormal(handle, other)); + if (closing >= IMPACT_MIN_SPEED) speed = closing; + } + if (down === null && speed === null) continue; + if (down !== null) lastImpactAt.set(uuid, now); + if (speed !== null) lastAnyImpactAt.set(uuid, now); + // 36 X6: and what it hit — the other collider's owner, '' for the ground + pendingImpacts.push({ uuid, down, speed, other: hit?.uuid ?? '' }); if (hit && !hit.sensor && hit.uuid !== uuid && hit.entry?.mode !== 'dynamic') - pendingImpacts.push({ uuid: hit.uuid, strength: down, other: uuid }); + pendingImpacts.push({ uuid: hit.uuid, down, speed, other: uuid }); } } +/** + * fix-batch-1 #14: the world-space normal of a contact that just started (its sign is + * irrelevant to the caller), or null when rapier holds no manifold for the pair — the + * caller then falls back to the vertical, the old landing assumption. + * @param {number} h1 @param {number} h2 @returns {{x: number, y: number, z: number} | null} + */ +function contactNormal(h1, h2) { + const c1 = world?.getCollider(h1); + const c2 = world?.getCollider(h2); + if (!c1 || !c2) return null; + /** @type {{x: number, y: number, z: number} | null} */ + let n = null; + world.contactPair(c1, c2, (/** @type {any} */ manifold) => { + if (n || !manifold.numContacts()) return; + const v = manifold.normal(); + n = { x: v.x, y: v.y, z: v.z }; + }); + return n; +} + /** * CL-A A3: a sensor pair crossed (started) or separated (stopped) — collect * the edge for BOTH tracked sides. Sensors bypass the downward-velocity @@ -2020,10 +2069,14 @@ function dispatchImpacts() { const impacts = pendingImpacts; pendingImpacts = []; const group = get(objectsGroup); - impacts.forEach(({ uuid, strength, other }) => { + impacts.forEach(({ uuid, down, speed, other }) => { // flow path: pulse On Impact nodes targeting this object — the trigger - // stamp replicates (nodetrigger), so every peer computes the same pulse - fireObjectImpact(uuid, strength, other); + // stamp replicates (nodetrigger), so every peer computes the same pulse. + // fix-batch-1 #14: each node picks its channel (direction any | down) + fireObjectImpact(uuid, down, other, speed); + // the emitters keep the landing rule: their "On impact" has no direction option, so + // a sideways contact bursting them would change every scene that uses one + if (down === null) return; // zero-flow path: an emitter set to "On impact" bursts for everyone // (replicated particleburst timestamp) — userData emitters checked on // the object, NODE emitters through the runtime diff --git a/tests/e2e/flow-impact-any.test.cjs b/tests/e2e/flow-impact-any.test.cjs new file mode 100644 index 000000000..28c4fbc44 --- /dev/null +++ b/tests/e2e/flow-impact-any.test.cjs @@ -0,0 +1,214 @@ +// fix-batch-1 #14: On Impact fires on a hit from ANY side, not only on a landing. A ball rolled +// sideways into a STATIC wall and a KINEMATIC (flow-animated) target fires its own On Impact +// and theirs (other = the ball) — up to 1.43 the rule was "pre-step downward velocity ≥ 1.2 +// m/s", so neither fired. Old scenes keep behaving: a node saved WITHOUT `direction` and one +// set to 'down' stay quiet on the sideways hits and still fire on a landing. Every fire is +// counted from the trigger log. Red on origin/feat/52 (4cd3dafc): the 'any' nodes never fire +// on the wall/target hit. EVIDENCE_DIR for the log line. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + await A.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + + const spec = await A.page.evaluate(async () => { + const cat = await window.__stores.nodeCatalog; + const s = (cat.findNodeSpec ?? cat.default?.findNodeSpec)?.('onimpact'); + return { + defaults: s?.defaults ?? null, + direction: s?.params?.find((p) => p.key === 'direction') ?? null + }; + }); + h.check( + spec.defaults?.direction === 'any', + `a NEW On Impact node is seeded direction 'any' (${JSON.stringify(spec.defaults)})` + ); + h.check( + spec.direction?.legacy === 'down', + `the Flow panel reads a node saved without it as 'down' (${JSON.stringify(spec.direction)})` + ); + + const ids = await A.page.evaluate(() => { + const s = window.__stores; + const cmd = s.commandsHandler.sceneCommand; + cmd('/create Box 0.4 2 3'); // wall (static) + cmd('/create Box 0.4 1 1.2'); // target (kinematic: a slow Spin animates it) + cmd('/create Sphere 0.2'); // ballW -> rolled into the wall + cmd('/create Sphere 0.2'); // ballT -> rolled into the target + cmd('/create Sphere 0.2'); // ballD -> dropped onto the ground (the landing control) + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const [wall, target, ballW, ballT, ballD] = g.children.slice(-5); + const place = (o, name, x, y, z, physics) => { + o.name = name; + o.position.set(x, y, z); + o.updateMatrixWorld(true); + if (physics) s.physics.setPhysicsFor(o.uuid, physics); + }; + // restitution 0: a bounce would hit a second time, and this suite counts hits + const ball = { mode: 'dynamic', mass: 1, collider: 'sphere', restitution: 0 }; + place(wall, 'Wall', 3, 1, 0, { mode: 'static', restitution: 0 }); + place(target, 'Target', 3, 0.5, 6, { mode: 'auto', mass: undefined, restitution: 0 }); // /create stamps dynamic + place(ballW, 'Ball to wall', 0, 0.2, 0, ball); + place(ballT, 'Ball to target', 0, 0.2, 6, ball); + place(ballD, 'Ball dropped', -6, 3, 0, ball); + window.__fx = { wall, target, ballW, ballT, ballD }; + return Object.fromEntries(Object.entries(window.__fx).map(([k, o]) => [k, o.uuid])); + }); + + await A.page.evaluate((u) => { + const sel = (id, uuid, y) => ({ + id, + type: 'objectselector', + position: { x: 300, y }, + data: { label: 'Object', selected: uuid } + }); + // direction: 'any' | 'down' | undefined (= a node saved before the option existed) + const imp = (id, y, direction) => ({ + id, + type: 'onimpact', + position: { x: 0, y }, + data: { + label: 'On Impact', + pulse: 0.3, + minStrength: 0.5, + ...(direction ? { direction } : {}) + } + }); + const rows = [ + ['impWallAny', u.wall, 'any'], + ['impWallOld', u.wall, undefined], + ['impTargetAny', u.target, 'any'], + ['impTargetDown', u.target, 'down'], + ['impBallWAny', u.ballW, 'any'], + ['impBallWOld', u.ballW, undefined], + ['impBallWDown', u.ballW, 'down'], + ['impBallTAny', u.ballT, 'any'], + ['impBallDAny', u.ballD, 'any'], + ['impBallDOld', u.ballD, undefined] + ]; + const nodes = []; + const edges = []; + rows.forEach(([id, uuid, dir], i) => { + nodes.push(imp(id, i * 140, dir), sel('s' + id, uuid, i * 140)); + edges.push({ id: `e-${id}-s${id}`, source: id, target: 's' + id }); + }); + // a slow Spin makes the target flow-animated -> a KINEMATIC body for the run + nodes.push({ + id: 'spin1', + type: 'spin', + position: { x: 0, y: 1600 }, + data: { label: 'Spin', speed: 0.02, axis: 'y' } + }); + nodes.push(sel('sSpin', u.target, 1600)); + edges.push({ id: 'e-spin1-sSpin', source: 'spin1', target: 'sSpin' }); + window.__stores.flowNodes.set(nodes); + window.__stores.flowEdges.set(edges); + // count every fire WITH what it hit: the trigger log keeps only the latest stamp per + // node, and `other` is set just before the stamp (fireContact). A rolling ball that + // hits a wall can climb it on its spin and LAND again — a real landing (other = the + // ground), which a 'down' node rightly fires on; the claims below are about the hit + window.__fires = {}; + const fr = window.__stores.flowRuntime; + window.__stores.flowTriggers.subscribe((log) => { + for (const [id, e] of Object.entries(log ?? {})) { + if (!id.startsWith('imp')) continue; + const seen = (window.__fires[id] ??= []); + if (typeof e?.lastT === 'number' && seen[seen.length - 1]?.t !== e.lastT) + seen.push({ t: e.lastT, other: fr.contactOtherOf(id) }); + } + }); + }, ids); + await A.page.waitForTimeout(600); + + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + // the dropped ball lands; the rolling balls settle on the ground first + await h.eventually( + () => A.page.evaluate(() => window.__fx.ballD.position.y), + (y) => y < 0.25, + 'premise: the dropped ball lands on the ground', + 15000 + ); + await A.page.waitForTimeout(400); + const beforeRoll = await A.page.evaluate(() => JSON.parse(JSON.stringify(window.__fires))); + const n0 = (id) => beforeRoll[id]?.length ?? 0; + h.check( + n0('impBallWAny') + n0('impBallTAny') + n0('impWallAny') + n0('impTargetAny') === 0, + `premise: nothing fired on the resting balls or the wall before the roll (${JSON.stringify(beforeRoll)})` + ); + + const pushed = await A.page.evaluate(() => { + const p = window.__stores.physics; + return [ + p.setBodyVelocity(window.__fx.ballW.uuid, [6, 0, 0], null), + p.setBodyVelocity(window.__fx.ballT.uuid, [6, 0, 0], null) + ]; + }); + h.check(pushed[0] && pushed[1], `premise: both balls were rolled sideways at 6 m/s (${pushed})`); + await h.eventually( + () => A.page.evaluate(() => [window.__fx.ballW.position.x, window.__fx.ballT.position.x]), + (xs) => xs[0] > 2.4 && xs[1] > 2.4, + 'premise: each ball reached its wall / target', + 10000 + ); + await A.page.waitForTimeout(800); + + const r = await A.page.evaluate(() => { + const kinds = Object.fromEntries( + window.__stores.physics.physicsDebug().map((e) => [e.uuid, e.mode]) + ); + return { + fires: window.__fires, + targetMode: kinds[window.__fx.target.uuid] ?? null, + ballWx: window.__fx.ballW.position.x + }; + }); + console.log('fires', JSON.stringify(r)); + const f = (id) => r.fires[id] ?? []; + /** fires of node `id` whose other is `uuid` */ + const hits = (id, uuid) => f(id).filter((e) => e.other === uuid).length; + h.check( + r.targetMode === 'kinematic', + `premise: the target is a kinematic body (${r.targetMode})` + ); + h.check( + r.ballWx < 2.85, + `premise: the ball stopped at the wall, not through it (x ${r.ballWx.toFixed(2)})` + ); + h.check( + hits('impBallWAny', ids.wall) === 1, + `a ball rolled into a wall fires its 'any' On Impact once with other = Wall (${hits('impBallWAny', ids.wall)})` + ); + h.check( + hits('impWallAny', ids.ballW) === 1 && f('impWallAny').length === 1, + `the STATIC wall hit sideways fires its 'any' On Impact once, other = the ball (${f('impWallAny').length})` + ); + h.check( + hits('impBallTAny', ids.target) === 1, + `a ball rolled into a target fires once with other = Target (${hits('impBallTAny', ids.target)})` + ); + h.check( + hits('impTargetAny', ids.ballT) === 1 && f('impTargetAny').length === 1, + `the KINEMATIC target hit sideways fires once, other = the ball (${f('impTargetAny').length})` + ); + h.check( + hits('impBallWOld', ids.wall) === 0 && f('impWallOld').length === 0, + `nodes saved before the option (no direction) stay quiet on the sideways hit (${hits('impBallWOld', ids.wall)}, ${f('impWallOld').length})` + ); + h.check( + hits('impBallWDown', ids.wall) === 0 && f('impTargetDown').length === 0, + `'down' nodes stay quiet on the sideways hit (${hits('impBallWDown', ids.wall)}, ${f('impTargetDown').length})` + ); + h.check( + f('impBallDOld').length === 1 && hits('impBallDOld', '') === 1, + `a legacy node still fires once on a landing, other = the ground (${f('impBallDOld').length})` + ); + h.check( + f('impBallDAny').length === 1, + `an 'any' node fires once on a landing too (${f('impBallDAny').length})` + ); + + await A.page.evaluate(() => window.__stores.physics.stopSimulation({ reset: true })); + await h.finish(browser); +}); diff --git a/tests/unit/impactSpeed.test.js b/tests/unit/impactSpeed.test.js new file mode 100644 index 000000000..5c7287e90 --- /dev/null +++ b/tests/unit/impactSpeed.test.js @@ -0,0 +1,76 @@ +// fix-batch-1 #14: what counts as an impact (src/lib/impactSpeed.js, a leaf). Up to 1.43 only +// the downward channel existed, so a sideways hit on a wall or a target never fired On Impact. +import { describe, test, expect } from 'vitest'; +import { + closingSpeed, + impactStrengthFor, + IMPACT_MIN_DOWN_VY, + IMPACT_MIN_SPEED, + LEGACY_IMPACT_DIRECTION +} from '../../src/lib/impactSpeed.js'; +import { propertyRows } from '../../src/lib/nodeProps.js'; + +/** @param {number} x @param {number} y @param {number} z */ +const v = (x, y, z) => ({ x, y, z }); +const UP = v(0, 1, 0); +const WALL = v(1, 0, 0); // a wall facing -x/+x + +describe('closingSpeed', () => { + test('a landing on still ground reads the downward speed (the old number)', () => { + expect(closingSpeed(v(0.3, -4, 0), null, UP)).toBeCloseTo(4); + }); + test('a sideways hit on a wall reads its speed along the normal; downward is ~0', () => { + const ball = v(5, 0, 0); + expect(closingSpeed(ball, null, WALL)).toBeCloseTo(5); + expect(-ball.y).toBeLessThan(IMPACT_MIN_DOWN_VY); // the old rule never fired here + expect(closingSpeed(ball, null, WALL)).toBeGreaterThanOrEqual(IMPACT_MIN_SPEED); + }); + test('an angled hit counts only the normal component, and a graze reads ~0', () => { + expect(closingSpeed(v(3, 0, 4), null, WALL)).toBeCloseTo(3); + expect(closingSpeed(v(0, 0, 9), null, WALL)).toBeCloseTo(0); + }); + test('the normal sign and length do not matter', () => { + expect(closingSpeed(v(2, 0, 0), null, v(-3, 0, 0))).toBeCloseTo(2); + }); + test('relative velocity: a mover hitting a body at rest, and two bodies moving together', () => { + expect(closingSpeed(v(0, 0, 0), v(-3, 0, 0), WALL)).toBeCloseTo(3); + expect(closingSpeed(v(2, 0, 0), v(2, 0, 0), WALL)).toBeCloseTo(0); + }); + test('no normal (no manifold) falls back to the vertical', () => { + expect(closingSpeed(v(5, -2, 0), null, null)).toBeCloseTo(2); + expect(closingSpeed(v(5, -2, 0), null, v(0, 0, 0))).toBeCloseTo(2); + }); +}); + +describe('impactStrengthFor', () => { + test("'any' reads the normal channel, 'down' the landing channel", () => { + expect(impactStrengthFor('any', null, 5)).toBe(5); + expect(impactStrengthFor('down', null, 5)).toBe(null); // sideways: a down node stays quiet + expect(impactStrengthFor('down', 4, 4)).toBe(4); + expect(impactStrengthFor('any', 4, null)).toBe(null); + }); + test('a node saved before the option (no direction) keeps the landing rule', () => { + expect(LEGACY_IMPACT_DIRECTION).toBe('down'); + expect(impactStrengthFor(undefined, null, 5)).toBe(null); + expect(impactStrengthFor(undefined, 4, 6)).toBe(4); + }); +}); + +describe('On Impact direction in the Flow ⚙ panel', () => { + const spec = { + type: 'onimpact', + defaults: { pulse: 0.3, minStrength: 1, direction: 'any' }, + params: [ + { key: 'minStrength', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'direction', kind: 'select', options: ['any', 'down'], legacy: 'down' } + ] + }; + test('an old node (no key) shows its legacy value, not the new default', () => { + const rows = propertyRows({ id: 'n', type: 'onimpact', data: { minStrength: 1 } }, spec, []); + expect(rows.find((r) => r.key === 'direction')?.value).toBe('down'); + }); + test('a new node shows what it was seeded with', () => { + const rows = propertyRows({ id: 'n', type: 'onimpact', data: { ...spec.defaults } }, spec, []); + expect(rows.find((r) => r.key === 'direction')?.value).toBe('any'); + }); +}); From 257e8f6a1f4d85bd608ff19f679903c41ba54324 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 17:50:02 +0300 Subject: [PATCH 14/29] [a11y] #23 profile menu opens on Enter/Space only; compact headers at 150 % on the folded N6 Profile circle (53-int Q5): it opened on focus, so Tab walked the whole panel. Now the ARIA menu-button pattern: Tab passes it, Enter / Space / Down open it with focus on the first row, Escape closes and returns focus to the circle. Compact header (53-int Q4): at a large UI scale a WindowChrome header wrapped before it ellipsized (flex lines break on hypothetical sizes). The title enters line-breaking at its minimum, header buttons stop growing at 120 %, the panel icon and the back label's text step aside (still the accessible name). The object list header's shed thresholds follow the scale and its search narrows. Settings / Inspector / object list headers are one row at 150 % folded. Rows: a11y-axe (profile menu keys + axe on the open panel), a11y-scale (three one-row header checks on the folded N6 at 150 %). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/Controls.svelte | 16 ++++++++--- src/components/menu/Users.svelte | 32 +++++++++++++++++++--- src/components/ui/WindowChrome.svelte | 36 +++++++++++++++++++++++++ tests/e2e/a11y-axe.test.cjs | 38 ++++++++++++++++++++++++++- tests/e2e/a11y-scale.test.cjs | 38 +++++++++++++++++++++++++++ tests/e2e/keyboard-walk.test.cjs | 2 +- 6 files changed, 154 insertions(+), 8 deletions(-) diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index b330b8cbf..15b391599 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -101,7 +101,7 @@ import { geometry as sidebarGeometry } from '$lib/sidebars'; import type { ToolbarLayout, LayoutConfig, Placements, Region } from '$lib/toolbarLayout'; import { pivotMode, pivotParentAvailable } from '$lib/multiTransform'; // 37 R1: the toolbar Pivot cell - import { cssPx, scaledPx } from '$lib/uiScale'; // 53 A1: hud.css px tokens read back as calc(...) + import { cssPx, scaledPx, uiScale } from '$lib/uiScale'; // 53 A1: hud.css px tokens read back as calc(...) import { VRButton, XRButton } from '@threlte/xr' // A panel is "shown" when it is open AND either the visible dock tab OR floating @@ -776,8 +776,10 @@ read(); return { destroy: () => ro.disconnect() }; } - const objHideSearch = $derived(objHeaderW < 260); - const objHideLabel = $derived(objHeaderW < 190); + // fix-batch-1 #23: the thresholds are 100 % widths — at 150 % the search and the label are 1.5x as + // wide, and an unscaled 260 kept the search on the folded N6 (a ~390 px header) and wrapped the ✕ + const objHideSearch = $derived(objHeaderW < (260 * $uiScale) / 100); + const objHideLabel = $derived(objHeaderW < (190 * $uiScale) / 100); // 38 R6: what the header element carries now that WindowChrome draws it — the width // probe above (as an attachment) and the drop-to-root target const objHeaderAttrs = { @@ -4397,6 +4399,14 @@ .obj-search :global(.sf) { width: 100%; } + /* fix-batch-1 #23: at a large UI scale the search enters the header's line-breaking at a minimum + and grows into what is left (WindowChrome's compact-header rule for the title), so it narrows + before the ✕ wraps to a second row */ + :global(:root[data-ui-large]) .obj-search { + flex: 3 1 0; /* most of what is left: the spacer after it only pushes the ✕ right */ + width: auto; + min-width: min(6em, 100%); + } .obj-filters { padding: 8px 10px; border-bottom: 1px solid var(--border); diff --git a/src/components/menu/Users.svelte b/src/components/menu/Users.svelte index 79434f5c3..ef0af5468 100644 --- a/src/components/menu/Users.svelte +++ b/src/components/menu/Users.svelte @@ -520,6 +520,16 @@ // viewport trap below), so the notch's circle sits exactly over the trigger wherever it is. dropdownEl.style.setProperty('--pm-right', `${Math.max(0, Math.round(window.innerWidth - r.right))}px`); } + /** fix-batch-1 #23: the trigger's keys — Enter / Space toggle, ↓ opens; opening moves focus in */ + function triggerKey(e: KeyboardEvent) { + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'ArrowDown') return; + e.preventDefault(); + const opening = e.key === 'ArrowDown' || !openDropdown; + openDropdown = opening; + if (opening) focusFirstRow = true; + } + /** set by a keyboard open: the panel's mount focuses its first row */ + let focusFirstRow = false; /** focus moving OUT of the menu or its trigger (to a real element) closes it */ function dropdownFocusOut(e: FocusEvent) { const to = e.relatedTarget as Node | null; @@ -558,8 +568,21 @@ openDropdown = false; }; const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') openDropdown = false; + if (e.key !== 'Escape') return; + // fix-batch-1 #23: focus inside the panel goes back to the button that opened it + const inPanel = !!document.activeElement && node.contains(document.activeElement); + openDropdown = false; + if (inPanel) triggerEl?.focus(); }; + if (focusFirstRow) { + focusFirstRow = false; + queueMicrotask(() => { + // the first ROW — not the notch's avatar circle, which only closes the panel again + const rows = 'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])'; + const first = node.querySelector(`.pm-card :is(${rows})`) ?? node.querySelector(rows); + first?.focus(); + }); + } const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(placeDropdown) : null; if (triggerEl) ro?.observe(triggerEl); window.addEventListener('resize', placeDropdown); @@ -1015,7 +1038,10 @@ top-right corner. -->
- +
(openDropdown = !openDropdown)} - onfocusin={() => (openDropdown = true)} + onkeydown={triggerKey} onfocusout={dropdownFocusOut} > {@render avatarImg(effAvatar || undefined, 'bg-surface-2 h-12 w-12 border-2 border-border-strong')} diff --git a/src/components/ui/WindowChrome.svelte b/src/components/ui/WindowChrome.svelte index ebb982077..131ed64c0 100644 --- a/src/components/ui/WindowChrome.svelte +++ b/src/components/ui/WindowChrome.svelte @@ -208,6 +208,42 @@ :global(:root[data-ui-large]) .wc-title { min-width: min(6.5em, 100%); } + /* fix-batch-1 #23 (53-int Q4): …but the wrap is the LAST resort now, not the first. A flex line + breaks on its items' HYPOTHETICAL sizes, so a title as long as its text pushed the buttons down + while there was room to ellipsize it — at 150 % on the folded N6 (a ~260 px layout) the Settings + sheet's close button sat on a second row and the Inspector header took three. Compact header at + a large scale: the title enters line-breaking at its minimum (basis 0, grows into what is left), + the decorative icon and the back button's text step aside (the text stays its accessible name), + and the header buttons stop growing at 120 % — still a 44 px target and more — so the scale goes + to the words */ + :global(:root[data-ui-large]) .wc-head { + column-gap: calc(4px * var(--ui-scale, 1)); + padding-left: calc(8px * var(--ui-scale, 1)); + } + :global(:root[data-ui-large]) .wc-head .wc-title { + flex: 1 1 0; + min-width: min(4em, 100%); + } + :global(:root[data-ui-large]) .wc-icon { + display: none; + } + :global(:root[data-ui-large]) .wc-actions { + gap: calc(4px * var(--ui-scale, 1)); + } + :global(:root[data-ui-large]) .wc-nav .wc-back > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + :global(:root[data-ui-large]) .wc-modal .wc-btn, + :global(:root[data-ui-large]) .wc-panel .wc-btn, + :global(:root[data-ui-large]) .wc-panel .wc-head :global(.wc-act) { + width: calc(44px * min(var(--ui-scale, 1), 1.2)); + height: calc(44px * min(var(--ui-scale, 1), 1.2)); + } .wc-modal .wc-title { font-size: var(--fs-modal-title); letter-spacing: -0.01em; diff --git a/tests/e2e/a11y-axe.test.cjs b/tests/e2e/a11y-axe.test.cjs index 0b0902f6a..f482da4cb 100644 --- a/tests/e2e/a11y-axe.test.cjs +++ b/tests/e2e/a11y-axe.test.cjs @@ -1,5 +1,6 @@ // 53-reader (A4) — THE AXE GATE. axe-core (via @axe-core/playwright) over the main surfaces: the -// editor shell, the object list, the Inspector, Settings, the Asset Browser and the phone shell. +// editor shell, the object list, the Inspector, Settings, the Asset Browser, the profile menu and the phone shell. +// fix-batch-1 #23: + the profile menu's menu-button keys (Tab passes it, Enter/Space open, Escape returns). // // It FAILS only on `serious` and `critical` violations. `moderate` and `minor` are LISTED in the // report (stdout + `axe-.md` / `.json` in AXE_REPORT_DIR) and not fixed — the user took the @@ -152,6 +153,41 @@ h.run(async () => { await page.waitForTimeout(500); await gate(page, 'Asset Browser', '#asset-browser-window', '#asset-browser-window'); await page.locator('#asset-browser-close').click().catch(() => {}); + await page.waitForTimeout(300); + + // 5b. fix-batch-1 #23 (53-int Q5): the profile menu is a MENU BUTTON — reaching the circle with Tab + // does not open it (it opened on focus, so Tab walked the whole panel: sign-in + links on a cloud + // build); Enter opens it with focus on its first row; Escape closes it and focus goes back to the + // circle. COUNTERFACTUAL (4cd3dafc): the panel is open the moment the circle takes focus. + const profile = () => + page.evaluate(() => { + const dd = document.querySelector('#avatar-dropdown'); + const a = document.activeElement; + return { open: !!dd && dd.getBoundingClientRect().width > 0, onTrigger: a?.id === 'avatar-trigger', inRow: !!a?.closest('#avatar-dropdown .pm-card'), expanded: document.querySelector('#avatar-trigger')?.getAttribute('aria-expanded') }; + }); + await page.evaluate(() => /** @type {HTMLElement} */ (document.querySelector('#avatar-trigger'))?.focus()); + await page.waitForTimeout(300); + const p0 = await profile(); + h.check(p0.onTrigger && !p0.open && p0.expanded === 'false', `profile menu: focus on the circle does NOT open it (${JSON.stringify(p0)})`); + await page.keyboard.press('Tab'); + await page.waitForTimeout(200); + const p1 = await profile(); + h.check(!p1.open && !p1.inRow, `profile menu: Tab moves past the circle, not into the panel (${JSON.stringify(p1)})`); + await page.evaluate(() => /** @type {HTMLElement} */ (document.querySelector('#avatar-trigger'))?.focus()); + await page.keyboard.press('Enter'); + await page.waitForTimeout(400); + const p2 = await profile(); + h.check(p2.open && p2.inRow && p2.expanded === 'true', `profile menu: Enter opens it, focus on its first row (${JSON.stringify(p2)})`); + if (p2.open) await gate(page, 'profile menu', '#avatar-dropdown', '#avatar-dropdown'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + const p3 = await profile(); + h.check(!p3.open && p3.onTrigger, `profile menu: Escape closes it and focus returns to the circle (${JSON.stringify(p3)})`); + await page.keyboard.press(' '); + await page.waitForTimeout(400); + const p4 = await profile(); + h.check(p4.open && p4.inRow, `profile menu: Space opens it too (${JSON.stringify(p4)})`); + await page.keyboard.press('Escape'); await h.closePeer(A); // ---- 6. the phone shell (Find N6 folded) --------------------------------------------------- diff --git a/tests/e2e/a11y-scale.test.cjs b/tests/e2e/a11y-scale.test.cjs index 129ea0094..8f9315b3a 100644 --- a/tests/e2e/a11y-scale.test.cjs +++ b/tests/e2e/a11y-scale.test.cjs @@ -8,6 +8,7 @@ // `@media (prefers-reduced-motion)` rule stands down — the build transform re-keyed it) and "Reduce" works on // an OS that is not; CSS motion goes instant and the camera fly-to lands in one frame. // Counterfactual (v1.39.0): no #ui-scale / #motion rows, no data-motion, a media-query-only reduced motion — red. +// fix-batch-1 #23: at 150 % on the folded N6 the Settings / Inspector / object list headers are one row. const h = require('./helpers.cjs'); /** @param {any} page */ @@ -232,6 +233,43 @@ h.run(async () => { const fdlg = await box(fp, 'dialog.settings-dialog'); const docW = await fp.evaluate(() => document.documentElement.scrollWidth); h.check(inside(fdlg, FOLD) && docW <= FOLD.width, `folded 150 %: Settings fits the screen, no sideways overflow (${JSON.stringify(fdlg)}, doc ${docW})`); + + // fix-batch-1 #23 (53-int Q4): the compact header — at 150 % on the folded N6 (a ~260 px layout) the + // header stays ONE row with the ✕ on it: Settings' close used to sit on a second row, the Inspector's + // header took three, the object list's two. One row = the header is its 48 px minimum (× 1.5) and the + // ✕'s vertical centre is inside the title's/label's box. COUNTERFACTUAL (4cd3dafc): 136 px each. + /** @param {string} root @param {string} titleSel */ + const head = (root, titleSel) => + fp.evaluate( + ([r, t]) => { + const hd = [...document.querySelectorAll(`${r} .wc-head`)].find((e) => e.getBoundingClientRect().width > 0); + if (!hd) return null; + const b = hd.getBoundingClientRect(); + const title = hd.querySelector(t)?.getBoundingClientRect(); + const x = hd.querySelector('.wc-close')?.getBoundingClientRect(); + const mid = x ? x.top + x.height / 2 : -1; + return { h: Math.round(b.height), w: Math.round(b.width), titleW: Math.round(title?.width ?? 0), sameRow: !!title && !!x && mid >= title.top && mid <= title.bottom, closeIn: !!x && x.right <= innerWidth + 1 }; + }, + [root, titleSel] + ); + /** @param {string} what @param {any} r */ + const oneRow = (what, r) => + h.check(!!r && r.h <= 48 * 1.5 + 1 && r.sameRow && r.closeIn && r.titleW >= 40, `folded 150 %: the ${what} header is one row, ✕ beside the title, on screen (${JSON.stringify(r)})`); + oneRow('Settings', await head('dialog.settings-dialog', '.wc-title')); + await closeAll(fp); + await fp.evaluate(() => { + const s = /** @type {any} */ (window).__stores; + s.inspectorKind?.set?.('scene'); + s.inspectorClose.set(false); + }); + await fp.waitForSelector('#inspector', { state: 'visible', timeout: 15000 }).catch(() => {}); + await fp.waitForTimeout(500); + oneRow('Inspector', await head('#inspector', '.wc-title')); + await closeAll(fp); + await fp.evaluate(() => /** @type {any} */ (window).__stores.objectListClose.set(false)); + await fp.waitForTimeout(900); + oneRow('object list', await head('#object-list', '.wc-label')); + await fp.evaluate(() => /** @type {any} */ (window).__stores.objectListClose.set(true)); h.check(h.pageErrors(F).length === 0, `folded: no page errors (${JSON.stringify(h.pageErrors(F)).slice(0, 200)})`); await h.finish(browser); }); diff --git a/tests/e2e/keyboard-walk.test.cjs b/tests/e2e/keyboard-walk.test.cjs index 59aec2c20..a396a2b32 100644 --- a/tests/e2e/keyboard-walk.test.cjs +++ b/tests/e2e/keyboard-walk.test.cjs @@ -69,7 +69,7 @@ h.run(async () => { const order = []; let ringless = []; // walk until the toolbar, then a few stops past it (a cloud build has more top-bar stops: rooms, - // sign-in and the links inside the profile panel, which opens on focus) — no fixed window + // sign-in; since fix-batch-1 #23 the profile panel no longer opens on focus) — no fixed window let afterToolbar = -1; for (let i = 0; i < 40 && afterToolbar < 6; i++) { await press(page, 'Tab'); From 677d36934c5744115e21427bf801598c62170f70 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 18:47:19 +0300 Subject: [PATCH 15/29] [flow] draw the sockets the runtime reads: Set Game State, Set Variable, HUD Rows, HUD Set Input; HUD Timer honours start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrator add (docs-1 Q1). flowSockets typed these inputs and the runtime read them, but the cards drew no socket, so they could not be wired by hand (hudActions' own 'trigger' wires had nowhere to land): Set Game State (trigger) and Set Variable (trigger, value) gain catalog `inputs`; HudNode draws HUD Rows (trigger, text) and HUD Set Input (trigger, value, options). HUD Timer drew `start` but triggerStampFor only counted an unnamed/`trigger` wire — it now takes `start` as an alias. The audit (scripts/graph-audit.cjs) gains an undrawn-input guard for the two generic cards; it found Device Param (value) and Note Trigger (trigger, note, velocity) with the same gap, fixed here too. Unit guard red on 257e8f6a with exactly those six; e2e flow-sockets-wire wires each by mouse and checks the effect (11 red on 257e8f6a). Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/graph-audit.cjs | 41 ++++- src/components/editors/nodes/HudNode.svelte | 6 +- src/lib/flowRuntime.js | 11 +- src/lib/nodeCatalog.js | 14 +- tests/e2e/flow-sockets-wire.test.cjs | 185 ++++++++++++++++++++ tests/unit/nodeCatalogAudit.test.js | 12 ++ 6 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/flow-sockets-wire.test.cjs diff --git a/scripts/graph-audit.cjs b/scripts/graph-audit.cjs index ee7ba8318..e81acc140 100644 --- a/scripts/graph-audit.cjs +++ b/scripts/graph-audit.cjs @@ -24,7 +24,7 @@ const path = require('node:path'); const ROOT = path.join(__dirname, '..'); const read = (/** @type {string} */ p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); -/** @returns {{type: string, group: string, label: string, inputs: string[], params: string[], note: boolean}[]} */ +/** @returns {{type: string, group: string, label: string, inputs: string[], hasInputs: boolean, params: string[], ranges: string[], note: boolean}[]} */ function catalogTypes() { const src = read('src/lib/nodeCatalog.js'); const out = []; @@ -53,7 +53,8 @@ function catalogTypes() { .map((s) => s.trim().replace(/'/g, '')) .filter(Boolean); const params = [...body.matchAll(/key: '([^']+)'/g)].map((m) => m[1]); - out.push({ type: t[1], group, label, inputs, params, note: /\bnote:/.test(body) }); + const ranges = [...body.matchAll(/key: '([^']+)', kind: 'range'/g)].map((m) => m[1]); + out.push({ type: t[1], group, label, inputs, hasInputs: /\binputs: \[/.test(body), params, ranges, note: /\bnote:/.test(body) }); } // a type spelled twice (inside an options literal etc.) keeps its first row const seen = new Set(); @@ -82,6 +83,34 @@ function outputTable() { return Object.fromEntries([...block.matchAll(/([a-z0-9_-]+): '([a-z0-9]+)'/g)].map((m) => [m[1], m[2]])); } +/** flowSockets' INPUT table: type -> the handles the runtime types (and so reads) */ +function inputTable() { + const src = read('src/lib/flowSockets.js'); + const a = src.indexOf('const INPUT = {'); + const block = src.slice(a, src.indexOf('\n};', a)); + return Object.fromEntries([...block.matchAll(/^\t'?([a-z0-9_-]+)'?: \{([^}]*)\}/gm)].map((m) => [m[1], [...m[2].matchAll(/'?([A-Za-z0-9_]+)'?:/g)].map((k) => k[1])])); +} + +/** HudNode's own INPUTS map: type -> the sockets that card draws */ +function hudCardInputs() { + const src = read('src/components/editors/nodes/HudNode.svelte'); + const a = src.indexOf('const INPUTS'); + const block = src.slice(a, src.indexOf('};', a)); + return Object.fromEntries([...block.matchAll(/^\t\t([a-z0-9_-]+): \[([^\]]*)\]/gm)].map((m) => [m[1], [...m[2].matchAll(/'([^']+)'/g)].map((k) => k[1])])); +} + +/** + * fix-batch-1 (docs-1 Q1): the sockets a GENERIC card draws — AnimationNode draws the spec's + * `inputs` (+ a socket per range param when it has them) or, with no `inputs`, one per range + * param; HudNode draws its INPUTS map. null = a card of its own (it draws what it draws). + * @param {any} t @param {string} card @param {Record} hud + */ +function drawnInputs(t, card, hud) { + if (card === 'AnimationNode') return t.hasInputs ? [...t.inputs, ...t.ranges] : t.ranges; + if (card === 'HudNode') return hud[t.type] ?? []; + return null; +} + function docKeys() { const src = read('src/lib/nodeDocs.js'); return new Set([...src.matchAll(/^\t'?([a-z0-9_-]+)'?:/gm)].map((m) => m[1])); @@ -93,8 +122,10 @@ function catalogReport() { const { cases, valueTypes } = evaluatorInfo(); const outs = outputTable(); const docs = docKeys(); + const ins = inputTable(); + const hud = hudCardInputs(); const rows = []; - const flags = { noCard: [], valueNoEval: [], evalNotListed: [], noDoc: [], noOutType: [] }; + const flags = { noCard: [], valueNoEval: [], evalNotListed: [], noDoc: [], noOutType: [], undrawnInput: [] }; for (const t of types) { const f = []; if (!cards[t.type]) (f.push('NO CARD'), flags.noCard.push(t.type)); @@ -103,6 +134,10 @@ function catalogReport() { (f.push('evaluated but not a listed value source'), flags.evalNotListed.push(t.type)); if (!docs.has(t.type)) (f.push('no doc'), flags.noDoc.push(t.type)); if (!outs[t.type]) flags.noOutType.push(t.type); + // a handle the runtime types (and reads) that the card never draws: unwirable by hand + const drawn = drawnInputs(t, cards[t.type], hud); + const missing = drawn ? (ins[t.type] ?? []).filter((h) => !drawn.includes(h)) : []; + if (missing.length) (f.push('UNDRAWN INPUT ' + missing.join(',')), flags.undrawnInput.push(t.type + ':' + missing.join(','))); rows.push( `| ${t.type} | ${t.group} | ${cards[t.type] ?? '—'} | ${outs[t.type] ?? '—'} | ${t.inputs.join(' ') || '—'} | ${t.params.join(' ') || '—'} | ${cases.has(t.type) ? 'yes' : '—'} | ${f.join('; ') || 'OK'} |` ); diff --git a/src/components/editors/nodes/HudNode.svelte b/src/components/editors/nodes/HudNode.svelte index fa6244e2e..acd79d154 100644 --- a/src/components/editors/nodes/HudNode.svelte +++ b/src/components/editors/nodes/HudNode.svelte @@ -47,7 +47,11 @@ hudtimer: ['start', 'duration'], hudscreen: ['trigger'], hudlist: ['trigger'], - hudbutton: [] + hudbutton: [], + // fix-batch-1 (docs-1 Q1): the runtime read these (flowSockets types them) but the card + // drew no socket, so neither node could be wired by hand + hudrows: ['trigger', 'text'], + hudset: ['trigger', 'value', 'options'] }; $: inputs = INPUTS[data.type] ?? []; $: wiredSource = (key: string) => diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index c1b892ee1..53251109f 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1218,7 +1218,9 @@ function updateHudRuntime(time, ctx, now) { * @param {any} node @param {any} data @param {number} time @param {any} ctx */ function hudTimerRemaining(node, data, time, ctx) { const duration = Math.max(0, num(data.duration)); - const stamp = triggerStampFor(node.id, ctx); + // fix-batch-1 (docs-1 Q1): the card's socket is `start` (flowSockets types it), and only an + // unnamed / `trigger` wire counted — a Start wired by hand was ignored + const stamp = triggerStampFor(node.id, ctx, 'start'); if (stamp === null) return data.autostart === false ? duration : Math.max(0, duration - time); // a synced stamp can sit AHEAD of `time` by a frame; clamp both ends return Math.max(0, Math.min(duration, duration - (time - stamp))); @@ -2081,12 +2083,13 @@ function updateDerivedPulses(ctx, fired = scheduledFired) { } /** The shared timestamp of whatever event is wired into this node's `trigger` - * (the newest, with several sources fanned in). @param {string} nodeId @param {any} ctx */ -function triggerStampFor(nodeId, ctx) { + * (the newest, with several sources fanned in). `alias` = a second handle name that counts as + * the trigger (HUD Timer's `start`). @param {string} nodeId @param {any} ctx @param {string} [alias] */ +function triggerStampFor(nodeId, ctx, alias = '') { if (!ctx?.triggers) return null; let newest = null; for (const edge of edgesInto(edges, nodeId)) { - if (edge.targetHandle && edge.targetHandle !== 'trigger') continue; + if (edge.targetHandle && edge.targetHandle !== 'trigger' && edge.targetHandle !== alias) continue; // 21-E4: a Delay/Sequence source has NO entry in the trigger log - its stamp is // derived - so reading the log directly here left those nodes unable to drive // anything that acts on a trigger edge (hudscreen, setgamestate, playanim). diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index 006d7cce6..b7786d536 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -154,6 +154,10 @@ export const nodeCatalog = [ type: 'setgamestate', label: 'Set Game State', defaults: { state: 'playing', outcome: '' }, + // fix-batch-1 (docs-1 Q1): the runtime always read a `trigger` wire (flowSockets + // types it, hudActions wires it) but the card drew no socket, so it could not be + // wired by hand and a built binding's wire had nowhere to land + inputs: ['trigger'], params: [ { key: 'state', kind: 'select', options: [...GAME_STATES] }, { key: 'outcome', kind: 'text', placeholder: 'won / lost', maxLength: 40 }, @@ -243,6 +247,9 @@ export const nodeCatalog = [ type: 'setvariable', label: 'Set Variable', defaults: { name: 'score', value: 0, op: 'set', scope: 'shared' }, + // fix-batch-1 (docs-1 Q1): sockets for what the runtime reads (the setgamestate note) + inputs: ['trigger', 'value'], + inputLabels: { value: 'value — wire a number (else the value below)' }, params: [ { key: 'name', kind: 'text', placeholder: 'score', maxLength: 40 }, { key: 'op', kind: 'select', options: ['set', 'add', 'subtract'] }, @@ -1353,14 +1360,17 @@ export const nodeCatalog = [ // writes ONE param of the device this graph belongs to (or the object a Selector // names) — LOCAL per peer, the setcolor rule: the value already arrives through the // replicated graph, so it is never broadcast again - { type: 'deviceparam', label: 'Device Param', defaults: { key: 'level', value: 0 }, params: [{ key: 'key', kind: 'text', placeholder: 'param key (level, freq, ...)', maxLength: 40 }] }, + // fix-batch-1: `value` is what the runtime writes (flowSockets types it) and had no + // socket — found by the audit's undrawn-input guard, the docs-1 Q1 class + { type: 'deviceparam', label: 'Device Param', defaults: { key: 'level', value: 0 }, inputs: ['value'], inputLabels: { value: 'value — wire an LFO, a slider' }, params: [{ key: 'key', kind: 'text', placeholder: 'param key (level, freq, ...)', maxLength: 40 }] }, // the device's live output level, 0..1 — feeds core nodes and the HUD { type: 'devicelevel', label: 'Device Level', defaults: {} }, // the shared transport as a number: beat, bar, phase (0..1 of the loop), bpm, playing { type: 'transportbeat', label: 'Transport', defaults: { read: 'beat' }, params: [{ key: 'read', kind: 'select', options: ['beat', 'bar', 'phase', 'bpm', 'playing', 'loopBeats'] }] }, // a note-on per trigger pulse: the pulse is already the replicated event, so every // peer synthesizes the note itself (once per pulse per peer) - { type: 'notetrigger', label: 'Note Trigger', defaults: { note: 60, velocity: 0.9 } } + // fix-batch-1: it drew NO socket, so a note could not be triggered by hand (the audit guard) + { type: 'notetrigger', label: 'Note Trigger', defaults: { note: 60, velocity: 0.9 }, inputs: ['trigger', 'note', 'velocity'] } ] }, // 34 R2 (T3): the game kit — one group per piece, GENERATED from the piece specs diff --git a/tests/e2e/flow-sockets-wire.test.cjs b/tests/e2e/flow-sockets-wire.test.cjs new file mode 100644 index 000000000..ef2db5f9a --- /dev/null +++ b/tests/e2e/flow-sockets-wire.test.cjs @@ -0,0 +1,185 @@ +// fix-batch-1 (docs-1 Q1): inputs the runtime reads but the card drew no socket for — Set Game State +// (trigger), Set Variable (trigger, value), HUD Rows (trigger, text), HUD Set Input (trigger, value, +// options), and HUD Timer, whose `start` socket was drawn but its pulse ignored. Each is WIRED BY HAND +// here (a real mouse drag from the source dot to the target dot in the node editor), then the source +// fires and the input must take effect. Device Param (value) and Note Trigger (trigger, note, +// velocity) are the same class found by the audit guard: their dots must be drawn. +// COUNTERFACTUAL (257e8f6a): no target dot to drop on, so no edge; the timer never starts. +// Run: APP_URL=https://theprototype.app:5591/ node tests/e2e/flow-sockets-wire.test.cjs +const h = require('./helpers.cjs'); + +const source = (node) => + `.svelteFlow .svelte-flow__handle.source[data-nodeid="${node}"]:not([data-handleid])`; +const N = (id, type, x, y, data = {}) => ({ + id, + type, + position: { x, y }, + data: { type, label: id, ...data }, + class: 'w-[150px]' +}); + +/** drag from one handle to another with the mouse — the gesture a person makes */ +async function wire(page, fromSel, toSel) { + const a = await page.locator(fromSel).first().boundingBox(); + const b = await page.locator(toSel).first().boundingBox(); + if (!a || !b) return false; + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move((a.x + b.x) / 2, (a.y + b.y) / 2, { steps: 6 }); + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 6 }); + await page.mouse.up(); + await page.waitForTimeout(400); + return true; +} + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { + context: { viewport: { width: 1440, height: 900 } } + }); + const p = A.page; + await p.locator('p[title="Node editor (N)"]').click(); + await p.waitForTimeout(1500); + + /** a fresh scene graph: an event source, an optional Number (7), and the node under test. The + * source gets a NEW id per round: the trigger log keeps an old source's stamp, and a timer wired + * to a source that already fired starts from that stamp (right, but not what round 5 measures) */ + let round = 0; + let SRC = 'src0'; + const load = async (target, data, withValue) => { + SRC = 'src' + ++round; + const nodes = [N(SRC, 'onclick', 0, 0), N('dut', target, 360, 0, data)]; + if (withValue) nodes.push(N('num', 'number', 0, 160, { value: 7, step: 1 })); + await p.evaluate((doc) => window.__stores.restoreGraphs(doc), { scene: { nodes, edges: [] } }); + await p.waitForTimeout(900); + }; + const edgeInto = (h2) => + p.evaluate( + (th) => + window.__stores.allEdges().some((e) => e.target === 'dut' && (e.targetHandle ?? '') === th), + h2 + ); + const drawn = (id) => + p.locator(`.svelteFlow .svelte-flow__handle[data-nodeid="dut"][data-handleid="${id}"]`).count(); + const fire = () => + p.evaluate( + (id) => + window.__stores.flowRuntime.applyNodeTrigger(id, (Date.now() % 86400000) / 1000, true), + SRC + ); + /** wire `from` into the node's `id` socket by hand and say whether the edge landed there */ + const byHand = async (from, id) => { + const n = await drawn(id); + if (n) + await wire( + p, + from, + `.svelteFlow .svelte-flow__handle[data-nodeid="dut"][data-handleid="${id}"]` + ); + return { drawn: n > 0, edge: await edgeInto(id) }; + }; + + // ---- 1. Set Game State: trigger + await p.evaluate(() => window.__stores.gameState.setGameState('menu')); + await load('setgamestate', { state: 'over', outcome: '' }, false); + const g1 = await byHand(source(SRC), 'trigger'); + h.check( + g1.drawn && g1.edge, + `Set Game State: the trigger socket is drawn and takes a wire by hand (${JSON.stringify(g1)})` + ); + await fire(); + await p.waitForTimeout(600); + const st = await p.evaluate( + () => new Promise((r) => window.__stores.gameState.gameState.subscribe((v) => r(v.state))()) + ); + h.check(st === 'over', `…and the pulse sets the game state (${st})`); + await p.evaluate(() => window.__stores.gameState.setGameState('menu')); + + // ---- 2. Set Variable: trigger + value + await load('setvariable', { name: 'fb1', op: 'set', value: 0, scope: 'shared' }, true); + const v1 = await byHand(source(SRC), 'trigger'); + const v2 = await byHand(source('num'), 'value'); + h.check( + v1.drawn && v1.edge && v2.drawn && v2.edge, + `Set Variable: trigger and value sockets take wires by hand (${JSON.stringify([v1, v2])})` + ); + await fire(); + await p.waitForTimeout(600); + const fb1 = await p.evaluate( + () => new Promise((r) => window.__stores.gameState.gameState.subscribe((v) => r(v.vars?.fb1))()) + ); + h.check(fb1 === 7, `…and the pulse writes the WIRED value, 7 (${fb1})`); + + // ---- 3. HUD Rows: trigger + text + await load('hudrows', { element: 'fb1list', op: 'append', text: '' }, true); + const r1 = await byHand(source(SRC), 'trigger'); + const r2 = await byHand(source('num'), 'text'); + h.check( + r1.drawn && r1.edge && r2.drawn && r2.edge, + `HUD Rows: trigger and text sockets take wires by hand (${JSON.stringify([r1, r2])})` + ); + await fire(); + await p.waitForTimeout(600); + const rows = await p.evaluate(() => window.__stores.flowRuntime.hudRowsOf('fb1list')); + h.check( + Array.isArray(rows) && rows.map(String).includes('7'), + `…and the pulse appends the wired row (${JSON.stringify(rows)})` + ); + + // ---- 4. HUD Set Input: trigger + value (+ options drawn) + await load('hudset', { element: 'fb1slider', value: 0 }, true); + const s1 = await byHand(source(SRC), 'trigger'); + const s2 = await byHand(source('num'), 'value'); + const sOpt = (await drawn('options')) > 0; + h.check( + s1.drawn && s1.edge && s2.drawn && s2.edge && sOpt, + `HUD Set Input: trigger, value (and options) sockets are drawn, wired by hand (${JSON.stringify([s1, s2, sOpt])})` + ); + await fire(); + await p.waitForTimeout(600); + const sv = await p.evaluate(() => window.__stores.hudDocs.hudValueOf('fb1slider')); + h.check(Number(sv) === 7, `…and the pulse moves the control to the wired value (${sv})`); + + // ---- 5. HUD Timer: the drawn `start` socket now starts it + await load( + 'hudtimer', + { element: 'fb1timer', duration: 60, format: '{v}', decimals: 0, autostart: false }, + false + ); + const t1 = await byHand(source(SRC), 'start'); + const before = await p.evaluate( + () => + new Promise((r) => window.__stores.hudDocs.hudRuntime.subscribe((v) => r(v.fb1timer?.text))()) + ); + await fire(); + await p.waitForTimeout(2600); + const after = await p.evaluate( + () => + new Promise((r) => window.__stores.hudDocs.hudRuntime.subscribe((v) => r(v.fb1timer?.text))()) + ); + h.check( + t1.drawn && t1.edge && before === '60', + `HUD Timer: start wired by hand, the timer waits at 60 (${JSON.stringify(t1)}, ${before})` + ); + h.check( + Number(after) < 60 && Number(after) >= 55, + `…and a pulse into start counts it down (${before} -> ${after})` + ); + + // ---- 6. the audit's two: their sockets are drawn + await load('notetrigger', { note: 60, velocity: 0.9 }, false); + const nt = await Promise.all(['trigger', 'note', 'velocity'].map(drawn)); + h.check( + nt.every((n) => n > 0), + `Note Trigger draws trigger, note and velocity sockets (${nt})` + ); + await load('deviceparam', { key: 'level', value: 0 }, false); + const dp = await drawn('value'); + h.check(dp > 0, `Device Param draws its value socket (${dp})`); + + h.check( + h.pageErrors(A).length === 0, + `no page errors (${JSON.stringify(h.pageErrors(A)).slice(0, 200)})` + ); + await h.finish(browser); +}); diff --git a/tests/unit/nodeCatalogAudit.test.js b/tests/unit/nodeCatalogAudit.test.js index 3b19da541..e29b02f85 100644 --- a/tests/unit/nodeCatalogAudit.test.js +++ b/tests/unit/nodeCatalogAudit.test.js @@ -23,4 +23,16 @@ describe('node catalog audit', () => { test('every palette type has a doc line', () => { expect(flags.noDoc).toEqual([]); }); + // fix-batch-1 (docs-1 Q1): a handle the runtime types in flowSockets' INPUT table (so reads) + // that a generic card never draws cannot be wired by hand — Set Game State, Set Variable, + // HUD Rows, HUD Set Input, Device Param and Note Trigger all shipped like that + test('every input the runtime reads is drawn on a generic card', () => { + expect(flags.undrawnInput).toEqual([]); + }); + test('the audit sees the sockets the generic cards draw', () => { + const t = (/** @type {string} */ type) => types.find((r) => r.type === type); + expect(t('setgamestate')?.inputs).toEqual(['trigger']); + expect(t('setvariable')?.inputs).toEqual(['trigger', 'value']); + expect(t('hudtimer')?.ranges).toContain('duration'); + }); }); From 10111ec37244b8bb511a0a5dbbb1e30fa394e2d1 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 25 Oct 2026 08:48:30 +0200 Subject: [PATCH 16/29] [flow] Rotate / Motor spin-up and HUD Timer autostart count from their start, not the clock's origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs-1 CORE BUGS (verified): the rotor's ramp and an autostart HUD Timer both measured from t = 0 of the clock they are handed, and the synced clock is seconds since UTC midnight — every wheel was past its spin-up from the first frame (full speed at once) and an autostart timer read 0 the moment it appeared. The rotor now counts from the moment this peer saw it switch on (off -> rests and spins up again from rest); an autostart timer from when this peer first saw it. Both re-base when the clock goes back (midnight, a session clock adopted on join) instead of freezing. e2e flow-clock-origin: 4 red on 4cd3dafc (rotor 4.70 of 5.22 rad/s in its first half-second; timer 0), branch: 0.55 vs 4.67 rad/s, timer 59 -> 55. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/flowRuntime.js | 17 +++- src/lib/sim/motionNodes.js | 23 +++++- tests/e2e/flow-clock-origin.test.cjs | 118 +++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/flow-clock-origin.test.cjs diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 53251109f..e97d80273 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1211,17 +1211,28 @@ function updateHudRuntime(time, ctx, now) { hudRuntime.set(next); } +/** fix-batch-1: node id -> the clock time an autostart HUD Timer was first seen @type {Map} */ +const hudTimerSeen = new Map(); + /** Seconds left on a HUD Timer. DERIVED from the shared trigger stamp, so every peer * reads the same number with no clock and no message of its own — the same reasoning - * as a looping sound's phase. An `autostart` timer with nothing wired counts from the - * runtime's own clock origin. + * as a looping sound's phase. An `autostart` timer with nothing wired counts from when THIS + * peer first saw the node (fix-batch-1, docs-1: it counted from the clock's origin, and the + * synced clock is seconds since midnight, so an autostart timer always read 0). That one is + * per peer — a timer everyone must agree on is wired to a shared pulse (`start`). * @param {any} node @param {any} data @param {number} time @param {any} ctx */ function hudTimerRemaining(node, data, time, ctx) { const duration = Math.max(0, num(data.duration)); // fix-batch-1 (docs-1 Q1): the card's socket is `start` (flowSockets types it), and only an // unnamed / `trigger` wire counted — a Start wired by hand was ignored const stamp = triggerStampFor(node.id, ctx, 'start'); - if (stamp === null) return data.autostart === false ? duration : Math.max(0, duration - time); + if (stamp === null) { + if (data.autostart === false) return duration; + const seen = hudTimerSeen.get(node.id); + // a clock that went BACK (midnight, a session clock adopted on join) re-bases + if (seen === undefined || time < seen) hudTimerSeen.set(node.id, time); + return Math.max(0, duration - (time - /** @type {number} */ (hudTimerSeen.get(node.id)))); + } // a synced stamp can sit AHEAD of `time` by a frame; clamp both ends return Math.max(0, Math.min(duration, duration - (time - stamp))); } diff --git a/src/lib/sim/motionNodes.js b/src/lib/sim/motionNodes.js index 63c41430b..a4f9d1bdd 100644 --- a/src/lib/sim/motionNodes.js +++ b/src/lib/sim/motionNodes.js @@ -34,8 +34,8 @@ const vPivot = new THREE.Vector3(); const radPerSec = (rpm) => (rpm * Math.PI * 2) / 60; /** - * The rotor's angle at `time` (s on the shared, pause-folded clock): a constant speed after a - * linear spin-up of `spinUp` seconds from t = 0 (continuous in angle and speed). + * The rotor's angle `time` seconds after it switched on: a constant speed after a linear + * spin-up of `spinUp` seconds from t = 0 (continuous in angle and speed). * @param {number} rpm @param {number} spinUp @param {number} time */ export function rotorAngle(rpm, spinUp, time) { @@ -58,8 +58,19 @@ export function applyRotor(object, base, data, time, ctx) { if (on) driveBody(object, axis, rpm, +data.torque || 0, ctx); return; } - if (!on) return; // rests at its base pose - const angle = rotorAngle(rpm, Math.max(0, +data.spinUp || 0), time); + if (!on) { + rotorOnSince.delete(ctx.key); // rests at its base pose, and spins up again from rest + return; + } + // fix-batch-1 (docs-1): the ramp counted from the CLOCK's origin, and the synced clock is + // seconds since midnight, so every wheel was already past its spin-up and turned at full speed + // from the first frame. It counts from the moment this peer saw it switch on (or appear); a peer + // joining later sees its own spin-up from rest, the price of a phase no message carries. + // a clock that went BACK (the synced clock wraps at midnight; joining a session swaps the + // local clock for the session's) re-bases rather than freezing the wheel until it catches up + const since = rotorOnSince.get(ctx.key); + if (since === undefined || time < since) rotorOnSince.set(ctx.key, time); + const angle = rotorAngle(rpm, Math.max(0, +data.spinUp || 0), time - /** @type {number} */ (rotorOnSince.get(ctx.key))); qBase.setFromEuler(eul.set(base.rot[0], base.rot[1], base.rot[2])); qSpin.setFromAxisAngle(axis, angle); object.quaternion.copy(qBase).multiply(qSpin); @@ -72,6 +83,9 @@ export function applyRotor(object, base, data, time, ctx) { } } +/** @type {Map} per rotor (node|object): the clock time it switched on */ +const rotorOnSince = new Map(); + /** @type {Map} the last frame time per drive (dt for the torque cap) */ const lastDrive = new Map(); @@ -109,6 +123,7 @@ function driveBody(object, axis, rpm, torque, ctx) { /** TEST: forget the drive clocks */ export function resetMotionNodes() { lastDrive.clear(); + rotorOnSince.clear(); floatCache.clear(); } diff --git a/tests/e2e/flow-clock-origin.test.cjs b/tests/e2e/flow-clock-origin.test.cjs new file mode 100644 index 000000000..8cd62a7b4 --- /dev/null +++ b/tests/e2e/flow-clock-origin.test.cjs @@ -0,0 +1,118 @@ +// fix-batch-1 (docs-1 CORE BUGS): two nodes counted from the CLOCK's origin instead of from when they +// started — and the synced clock is seconds since midnight, so the start was always long past: +// · Rotate / Motor's spin-up ramp: every wheel turned at full speed from the first frame; +// · HUD Timer with Autostart: `duration - time` read 0 the moment it appeared. +// Measured: a 60 rpm rotor with a 3 s spin-up turns well under its post-ramp speed in its first +// half-second; an autostart 60 s timer starts near 60 and counts down. +// COUNTERFACTUAL (4cd3dafc): the rotor is at full speed at once; the timer reads 0. +// Run: APP_URL=https://theprototype.app:5591/ node tests/e2e/flow-clock-origin.test.cjs +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const p = A.page; + + const uuid = await p.evaluate(() => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create Box 1 1 1'); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const box = g.children[g.children.length - 1]; + box.name = 'Wheel'; + window.__wheel = box; + return box.uuid; + }); + await p.waitForTimeout(600); + + /** the wheel's angle about +y (rad, unwrapped by the caller) */ + const angle = () => + p.evaluate(() => { + const q = window.__wheel.quaternion; + return 2 * Math.atan2(q.y, q.w); + }); + /** angular speed over `ms`, unwrapped for one turn at most */ + const speed = async (ms) => { + const a = await angle(); + const t0 = Date.now(); + await p.waitForTimeout(ms); + const b = await angle(); + const dt = (Date.now() - t0) / 1000; + let d = b - a; + while (d < -Math.PI) d += 2 * Math.PI; + while (d > Math.PI) d -= 2 * Math.PI; + return Math.abs(d) / dt; + }; + const FULL = 2 * Math.PI; // 60 rpm in rad/s + + await p.evaluate((u) => { + const s = window.__stores; + s.flowNodes.set([ + { + id: 'rot', + type: 'rotor', + position: { x: 0, y: 0 }, + data: { label: 'Rotate / Motor', axis: 'y', rpm: 60, torque: 50, spinUp: 3, on: true } + }, + { + id: 'sel', + type: 'objectselector', + position: { x: 300, y: 0 }, + data: { label: 'Object', selected: u } + }, + { + id: 'tmr', + type: 'hudtimer', + position: { x: 0, y: 200 }, + data: { + label: 'HUD Timer', + element: 'fb1auto', + duration: 60, + format: '{v}', + decimals: 0, + autostart: true + } + } + ]); + s.flowEdges.set([{ id: 'e-rot-sel', source: 'rot', target: 'sel' }]); + }, uuid); + await p.waitForTimeout(150); + const early = await speed(350); + const timerEarly = await p.evaluate( + () => + new Promise((r) => window.__stores.hudDocs.hudRuntime.subscribe((v) => r(v.fb1auto?.text))()) + ); + await p.waitForTimeout(3200); + const late = await speed(400); + const timerLate = await p.evaluate( + () => + new Promise((r) => window.__stores.hudDocs.hudRuntime.subscribe((v) => r(v.fb1auto?.text))()) + ); + console.log('rotor', JSON.stringify({ early, late, full: FULL }), 'timer', timerEarly, timerLate); + + // the page samples the pose per FRAME while the window is wall-clock (round-trips included), so an + // absolute rad/s reads ~20 % low on a headless page — the claims are a RATIO against the same + // wheel after its ramp, plus a floor that a stopped or crawling wheel cannot pass + h.check( + late > 0.6 * FULL, + `premise: after the 3 s ramp the wheel turns near 60 rpm (${late.toFixed(2)} of ${FULL.toFixed(2)} rad/s)` + ); + h.check( + early < 0.4 * late, + `Rotate / Motor spins UP: its first half-second is well under its speed after the ramp (${early.toFixed(2)} vs ${late.toFixed(2)} rad/s)` + ); + h.check( + Number(timerEarly) >= 58 && Number(timerEarly) <= 60, + `HUD Timer with Autostart starts at its duration, not 0 (${timerEarly})` + ); + h.check( + Number(timerLate) < Number(timerEarly) && Number(timerLate) >= 54, + `…and counts down (${timerEarly} -> ${timerLate})` + ); + + h.check( + h.pageErrors(A).length === 0, + `no page errors (${JSON.stringify(h.pageErrors(A)).slice(0, 200)})` + ); + await h.finish(browser); +}); From f42422de5e643bc2f86907f33b70b14efa4d7ac5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 25 Oct 2026 08:50:25 +0200 Subject: [PATCH 17/29] [docs] correct thirteen one-line node descriptions (docs-1) Each checked against the node's catalog entry and runtime before rewriting: Sequence fires all four steps from one pulse (not one per pulse, no wrap); Move Input is WASD x/y only (no stick, no jump); All Players is a group gate, not an iterator; Character Controller sets the player's own movement model; Move Speed writes on `set`; Camera Follow has no distance/height; Gamepad Axis is the two sticks (L2/R2 are buttons); HUD Screen acts per pulse (show / hide / toggle); HUD List names the element, title and row cap; HUD Rows and Leaderboard write a HUD List element; HUD Timer a HUD Timer element; HUD Input also reads sliders, toggles, dropdowns and tabs. These lines feed the palette tooltip, the info pane and the docs-site node pages. Unit guard tests/unit/nodeDocsClaims.test.js: 6/6 red on 4cd3dafc. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/nodeDocs.js | 26 ++++++++++----------- tests/unit/nodeDocsClaims.test.js | 39 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 tests/unit/nodeDocsClaims.test.js diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js index 91f3f156a..8dc0d289f 100644 --- a/src/lib/nodeDocs.js +++ b/src/lib/nodeDocs.js @@ -37,7 +37,7 @@ export const NODE_DOCS = { setlook: "Switches a camera's look (its post-processing) on or off when a pulse arrives, and by default looks through that camera too.", gamestart: "Fires once when the game starts (Play, or a round reset) - the place to spawn, reset counters and arm timers.", travel: "Loads another saved scene by name when a pulse arrives - a door to the next level, replicated to everyone.", - allplayers: "Iterates the connected players so a per-player value can be read, or a HUD row shown, for each of them.", + allplayers: "A group gate: every player in Play answers its condition for themselves, and it fires once when all of them say yes - travel together, start when everyone is ready.", setvariable: "Stores a named value in the replicated scene variables when a pulse arrives - shared state without an object.", getvariable: "Reads a named scene variable as a value - the read side of Set Variable, live on every peer.", storevalue: "Saves a value on THIS device when a pulse arrives (set, keep the max or min, or add) - a best score or a level reached that survives a reload; never sent to other players.", @@ -53,20 +53,20 @@ export const NODE_DOCS = { onclap: "Fires a pulse when a VR player brings both hands together and holds them - with where they met (point) and whether it was you (byMe); wire 'enabled' to a setting to switch it.", gametime: "Seconds since the game (or the round) started, the same on every peer - a clock that pauses with the game.", peervariable: "A value kept PER PLAYER (score, lives, team) - each peer reads its own row, or a named player's.", - charcontroller: "Turns the connected object into a walking, jumping character driven by the player's movement input.", - moveinput: "The player's movement input as numbers - stick or WASD axes and the jump button - for driving things by hand.", + charcontroller: "Sets how the player moves in Play - fly or walk, speed, jump and eye height, gravity - driving the player's own view rather than an object (the newest one in the scene wins).", + moveinput: "This player's WASD keys as two numbers, x (A/D) and y (W/S), each -1 to 1 - read on every device for its own player.", possessnode: "Puts the player in control of the connected object when a pulse arrives - a vehicle, a turret, a ball.", - camerafollow: "Makes the camera follow the connected object at a set distance and height - a third-person or chase view.", - movespeed: "Overrides the character's walking speed while a value is wired - sprint, mud, slow motion.", - hudscreen: "Shows or hides a whole HUD screen (a named layer of elements) while its condition holds.", + camerafollow: "On a pulse, the camera flies behind the target object and follows it until stop is pulsed - for an object something else moves (the sim, a clip).", + movespeed: "On a pulse into set, writes the player's movement speed - sprint, mud, slow motion.", + hudscreen: "Shows, hides or toggles a whole HUD screen (a named layer of elements) on each pulse.", hudtext: "Writes a number into a HUD text element through a format such as 'Gems: {v}' - a score, a count, a readout.", - hudtimer: "Runs a countdown in a HUD text element from its duration, formatted like '{v}s' - starts by itself or on a pulse.", + hudtimer: "Counts down in a HUD Timer element from its duration, formatted like '{v}s' - by itself (Autostart) or from a pulse into start.", hudbar: "Drives a HUD bar element's fill from a number - health, fuel, progress.", hudbutton: "Fires a pulse when a HUD button is pressed - the on-screen counterpart of On Click.", - hudlist: "Fills a HUD list element with the items a value provides - an inventory, objectives, players.", - leaderboard: "Ranks the players by their own per-player variable (laps, score) into a HUD element, highest or lowest first, top N rows.", - hudrows: "Appends or replaces rows in a HUD rows element from a pulse - a log, a chat, a scoreboard feed.", - hudinput: "Reads what the player typed into a HUD input element as a value.", + hudlist: "Names a HUD List element and sets its title and how many rows it shows; HUD Rows and Leaderboard fill it.", + leaderboard: "Ranks the players by their own per-player variable (laps, score) into a HUD List element, highest or lowest first, top N rows.", + hudrows: "Sets, appends or clears the rows of a HUD List element on each pulse - a log, a chat, a scoreboard feed.", + hudinput: "Reads a HUD control as a value - a slider's number, a toggle's on/off, a dropdown's or tabs' choice, a text field's text.", hudset: "Writes a value into a HUD input element when a pulse arrives - to prefill, clear or correct it.", script: "Your own JavaScript - an effect that moves its object every frame, or (with declared typed inputs and outputs) a small pure function whose return feeds other nodes. v2 scripts also get `api`: object(uuid or name), raycast(from, dir), and in effects keys() (this device only) and spawn('/create ...') (the host spawns once for everyone).", math: "Combines numbers (+ - * / min max mod pow) or shapes one: sin/cos (b scales them, 0 = 1), abs, round, floor, clamp to 0..b, negate. + input adds sockets (up to 8) for + - * / min max.", @@ -76,7 +76,7 @@ export const NODE_DOCS = { select: "Picks one of up to four values (a, b, c, d) by a rounded index.", latch: "Holds a boolean until told otherwise - set, reset and toggle pulses make it a memory bit.", delay: "Passes a pulse on after a set number of seconds - the timing of a fuse, or a door that closes later.", - sequence: "Fires its outputs one after another, one per pulse, then wraps around - a step-by-step trigger.", + sequence: "One pulse in, four pulses out in order - step1 to step4, each after its own wait (the waits add up from the trigger).", once: "Lets exactly one pulse through until it is re-armed - a checkpoint, a pickup, a first-time event.", counter: "Counts trigger pulses and outputs the running total.", loop: "Sweeps a number from one value to another over time, repeating - a ready-made animation ramp.", @@ -96,7 +96,7 @@ export const NODE_DOCS = { onexit: "Fires a pulse when something leaves a trigger volume - the other half of On Enter.", onrest: "Fires a pulse when a physics body has finished moving - the counterpart to On Impact, which fires when it starts.", gamepadbutton: "Fires a pulse (or holds a level) while a gamepad button is pressed - the pad's counterpart of Key Press.", - gamepadaxis: "A gamepad stick or trigger axis as a number between -1 and 1, with a dead zone.", + gamepadaxis: "A gamepad stick axis (left or right stick, x or y) as a number between -1 and 1, with a dead zone (the L2/R2 triggers are Gamepad Button inputs).", shake: "Jitters the connected object around its resting position - rumble, nervousness, impact feedback.", spin: "Rotates the connected object continuously around one axis.", rotor: "Turns the connected object about its own local axis (and its origin, so a wheel turns on its hub) at a set rpm with a spin-up. Paddles and scoops it turns carry water from Fluid emitters. On a dynamic physics body during a simulation it becomes a real motor: torque-limited, so a load slows it.", diff --git a/tests/unit/nodeDocsClaims.test.js b/tests/unit/nodeDocsClaims.test.js new file mode 100644 index 000000000..1c05da759 --- /dev/null +++ b/tests/unit/nodeDocsClaims.test.js @@ -0,0 +1,39 @@ +// fix-batch-1 (docs-1): one-line node descriptions that said something the node does not do. They feed +// the palette tooltip, the editor's info pane and the docs-site node pages, so a wrong line is a wrong +// manual. Each was checked against the node's catalog entry and runtime (see the commit). +import { describe, test, expect } from 'vitest'; +import { NODE_DOCS } from '../../src/lib/nodeDocs.js'; + +describe('node docs say what the node does', () => { + test('Sequence fires all four steps from ONE pulse (it does not step once per pulse or wrap)', () => { + expect(NODE_DOCS.sequence).not.toMatch(/one per pulse|wraps/i); + expect(NODE_DOCS.sequence).toMatch(/four/i); + }); + test('Move Input is WASD only (no stick, no jump)', () => { + expect(NODE_DOCS.moveinput).not.toMatch(/stick|jump/i); + expect(NODE_DOCS.moveinput).toMatch(/WASD/); + }); + test('All Players is a group gate, not an iterator', () => { + expect(NODE_DOCS.allplayers).not.toMatch(/iterat/i); + expect(NODE_DOCS.allplayers).toMatch(/gate/i); + }); + test("Character Controller drives the player's view, not an object", () => { + expect(NODE_DOCS.charcontroller).not.toMatch(/turns the connected object/i); + expect(NODE_DOCS.charcontroller).toMatch(/player/i); + }); + test('Move Speed writes on `set`, Camera Follow has no distance/height, Gamepad Axis is sticks only', () => { + expect(NODE_DOCS.movespeed).not.toMatch(/while a value is wired/i); + expect(NODE_DOCS.movespeed).toMatch(/\bset\b/); + expect(NODE_DOCS.camerafollow).not.toMatch(/distance|height/i); + expect(NODE_DOCS.gamepadaxis).not.toMatch(/stick or trigger axis/i); + }); + test('the HUD nodes name the element kind they write, and act per pulse', () => { + expect(NODE_DOCS.hudscreen).not.toMatch(/while/i); + expect(NODE_DOCS.hudrows).toMatch(/HUD List element/); + expect(NODE_DOCS.leaderboard).toMatch(/HUD List element/); + expect(NODE_DOCS.hudtimer).toMatch(/HUD Timer element/); + expect(NODE_DOCS.hudlist).toMatch(/title/i); + expect(NODE_DOCS.hudlist).not.toMatch(/items a value provides/i); + expect(NODE_DOCS.hudinput).toMatch(/slider/i); + }); +}); From d90b9607be0dafe1c455130e31a2363f57a3f414 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 25 Oct 2026 08:52:39 +0200 Subject: [PATCH 18/29] [flow] Sequence card: the wait fields show their values at the standard card width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs-1: at the 150 px card a "wait" word on every row plus a 48 px step label left each field's input 12 px wide — the values read as empty boxes. "wait (s)" is now said once as the column heading (each field keeps an accessible name, "wait before step N (seconds)"), the step label is a fixed 36 px so the field does not jump when the ● lights, and the field takes the rest: 68 px, "10.50" fits. e2e sequence-card: base 4cd3dafc 12 px fields, all four values clipped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../editors/nodes/SequenceNode.svelte | 12 +- tests/e2e/sequence-card.test.cjs | 107 ++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/sequence-card.test.cjs diff --git a/src/components/editors/nodes/SequenceNode.svelte b/src/components/editors/nodes/SequenceNode.svelte index 51a3323f3..d534240d7 100644 --- a/src/components/editors/nodes/SequenceNode.svelte +++ b/src/components/editors/nodes/SequenceNode.svelte @@ -31,11 +31,19 @@ trigger
+ + {#each STEPS as step (step)}
- wait setNodeData(id, { ['delay' + step]: v })} /> diff --git a/tests/e2e/sequence-card.test.cjs b/tests/e2e/sequence-card.test.cjs new file mode 100644 index 000000000..07f80e6f3 --- /dev/null +++ b/tests/e2e/sequence-card.test.cjs @@ -0,0 +1,107 @@ +// fix-batch-1 (docs-1): the Sequence card's four `wait` fields. At the standard 150 px card the two +// fixed labels left each field ~30 px and its values were clipped (they read as empty boxes). Every +// field must show its whole value, and the step label must fit while it is lit ("● step4"). +// COUNTERFACTUAL (4cd3dafc): the inputs are narrower than their values. +// Run: APP_URL=https://theprototype.app:5591/ node tests/e2e/sequence-card.test.cjs +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { + context: { viewport: { width: 1440, height: 900 } } + }); + const p = A.page; + await p.locator('p[title="Node editor (N)"]').click(); + await p.waitForTimeout(1500); + await p.evaluate(() => + window.__stores.restoreGraphs({ + scene: { + nodes: [ + { + id: 'src', + type: 'onclick', + position: { x: 0, y: 0 }, + data: { type: 'onclick', label: 'On Click' } + }, + { + id: 'seq', + type: 'sequence', + position: { x: 260, y: 0 }, + data: { + type: 'sequence', + label: 'Sequence', + delay1: 0.25, + delay2: 10.5, + delay3: 2, + delay4: 0.05 + }, + class: 'w-[150px]' + } + ], + edges: [{ id: 'e-src-seq.trigger', source: 'src', target: 'seq', targetHandle: 'trigger' }] + } + }) + ); + await p.waitForTimeout(900); + /** every wait field of the card: its width, whether its value fits, what it shows */ + const fields = () => + p.evaluate(() => + [...document.querySelectorAll('.svelteFlow .svelte-flow__node[data-id="seq"] input')].map( + (i) => { + const el = /** @type {HTMLInputElement} */ (i); + return { w: el.clientWidth, fits: el.scrollWidth <= el.clientWidth + 1, value: el.value }; + } + ) + ); + // offsetWidth, not the rect: the rect includes the editor's ZOOM, and the claim is about the card's own width + const card = await p.evaluate( + () => + /** @type {HTMLElement | null} */ ( + document.querySelector('.svelteFlow .svelte-flow__node[data-id="seq"]') + )?.offsetWidth ?? 0 + ); + const f = await fields(); + console.log('card', card, JSON.stringify(f)); + h.check( + card >= 140 && card <= 170 && f.length === 4, + `premise: one standard-width Sequence card with four wait fields (${card} px, ${f.length})` + ); + h.check( + f.every((x) => x.fits), + `every wait field shows its whole value (${JSON.stringify(f.map((x) => x.value + (x.fits ? '' : ' CLIPPED')))})` + ); + h.check( + f.every((x) => x.w >= 40), + `…in a field at least 40 px wide (${f.map((x) => x.w).join(', ')})` + ); + + // light a step: step1 fires 0.25 s after the pulse (the delays add up) + await p.evaluate(() => + window.__stores.flowRuntime.applyNodeTrigger('src', (Date.now() % 86400000) / 1000, true) + ); + await p.waitForTimeout(450); + const lit = await p.evaluate(() => + [...document.querySelectorAll('.svelteFlow .svelte-flow__node[data-id="seq"] span')] + .filter((s) => /step\d/.test(s.textContent ?? '')) + .map((s) => ({ t: (s.textContent ?? '').trim(), fits: s.scrollWidth <= s.clientWidth + 1 })) + ); + console.log('labels', JSON.stringify(lit)); + h.check( + lit.length === 4 && lit.every((x) => x.fits), + `the step labels fit, lit or not (${JSON.stringify(lit)})` + ); + h.check( + lit.some((x) => x.t.startsWith('●')), + `premise: a step is lit after the pulse (${lit.map((x) => x.t).join(' | ')})` + ); + await p + .locator('.svelteFlow .svelte-flow__node[data-id="seq"]') + .screenshot({ path: (process.env.EVIDENCE_DIR || '/tmp') + '/sequence-card.png' }) + .catch(() => {}); + + h.check( + h.pageErrors(A).length === 0, + `no page errors (${JSON.stringify(h.pageErrors(A)).slice(0, 200)})` + ); + await h.finish(browser); +}); From 0b5a1b152f6de82305a2d714ae6ea4fa9475cc67 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 25 Oct 2026 08:55:00 +0200 Subject: [PATCH 19/29] [test] Rotate / Motor unit tests follow switch-on time (10111ec3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two pose tests called applyRotor once at t = 1 and expected the angle from the clock's origin; they now switch the wheel on at t = 0 first. New: a wheel switched on at t = 51 300 (mid-day on the synced clock) spins up from rest, off -> on spins up again, and a clock that goes back re-bases — red on 4cd3dafc's motionNodes.js. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/unit/motionNodes.test.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/unit/motionNodes.test.js b/tests/unit/motionNodes.test.js index ee13e64b0..2aeb1e5aa 100644 --- a/tests/unit/motionNodes.test.js +++ b/tests/unit/motionNodes.test.js @@ -19,6 +19,9 @@ describe('Rotate / Motor', () => { const o = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); o.rotation.set(0, 0, Math.PI / 2); const b = base(o); + // fix-batch-1: the angle counts from SWITCH-ON (the first call), so switch it on at t = 0 + resetMotionNodes(); + applyRotor(o, b, { axis: 'x', rpm: 15, spinUp: 0 }, 0, { suspended: false, physics: null, pivot: null, key: 'k', now: 0 }); applyRotor(o, b, { axis: 'x', rpm: 15, spinUp: 0 }, 1, { suspended: false, physics: null, pivot: null, key: 'k', now: 0 }); // the local x axis is unchanged by a spin about it const ax = new THREE.Vector3(1, 0, 0).applyQuaternion(o.quaternion); @@ -33,10 +36,31 @@ describe('Rotate / Motor', () => { const o = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); o.position.set(2, 1, 0); const pivot = new THREE.Vector3(2, 0, 0); - applyRotor(o, base(o), { axis: 'z', rpm: 15, spinUp: 0 }, 1, { suspended: false, physics: null, pivot, key: 'k', now: 0 }); + const b = base(o); + resetMotionNodes(); + applyRotor(o, b, { axis: 'z', rpm: 15, spinUp: 0 }, 0, { suspended: false, physics: null, pivot, key: 'k', now: 0 }); + applyRotor(o, b, { axis: 'z', rpm: 15, spinUp: 0 }, 1, { suspended: false, physics: null, pivot, key: 'k', now: 0 }); expect(o.position.distanceTo(pivot)).toBeCloseTo(1, 6); expect(o.position.x).toBeCloseTo(1, 5); // a quarter turn about +z moves +y to -x }); + // fix-batch-1 (docs-1): the ramp counted from the clock's ORIGIN, and the synced clock is seconds + // since midnight — so a wheel switched on at 14:15 (t = 51 300) was "51 298 s into a 2 s spin-up" + it('spins up from SWITCH-ON, not from the clock origin; off -> on spins up again; a clock that goes back re-bases', () => { + const o = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); + const b = base(o); + const spin = (/** @type {any} */ data, /** @type {number} */ t) => { + applyRotor(o, b, { axis: 'y', rpm: 60, spinUp: 2, ...data }, t, { suspended: false, physics: null, pivot: null, key: 'r', now: 0 }); + return 2 * Math.atan2(o.quaternion.y, o.quaternion.w); + }; + resetMotionNodes(); + expect(spin({}, 51300)).toBeCloseTo(0, 9); // switched on: at rest + expect(spin({}, 51301)).toBeCloseTo(Math.PI / 2, 6); // 1 s into a 2 s ramp: ω t² / 2T, not a full turn + spin({ on: false }, 51302); // off (the runtime's base restore puts the pose back; the ramp is forgotten) + expect(spin({}, 51310)).toBeCloseTo(0, 9); // on again: from rest + expect(spin({}, 51311)).toBeCloseTo(Math.PI / 2, 6); + expect(spin({}, 3)).toBeCloseTo(0, 9); // the clock went BACK (midnight): re-based, not frozen + expect(spin({}, 4)).toBeCloseTo(Math.PI / 2, 6); + }); it('off = rests at its base pose; a dynamic body is never posed, it is driven (initiator only, torque-capped)', () => { const o = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 0.2)); o.userData.physics = { mode: 'dynamic', mass: 4 }; From 0522e70391bb041382711e52479fc64a2e1ba8b0 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 25 Oct 2026 09:26:59 +0200 Subject: [PATCH 20/29] [flow] rotor / autostart timer: re-base only on a real clock jump (> 1 s back) Follow-up to 10111ec3: a frame of jitter before the switch-on time no longer moves the start; only a jump back of more than a second (midnight, a session clock adopted on join) re-bases. Unit test covers the jitter case. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/flowRuntime.js | 5 +++-- src/lib/sim/motionNodes.js | 8 ++++++-- tests/unit/motionNodes.test.js | 2 ++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index e97d80273..cf90feefb 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1229,8 +1229,9 @@ function hudTimerRemaining(node, data, time, ctx) { if (stamp === null) { if (data.autostart === false) return duration; const seen = hudTimerSeen.get(node.id); - // a clock that went BACK (midnight, a session clock adopted on join) re-bases - if (seen === undefined || time < seen) hudTimerSeen.set(node.id, time); + // a clock that went BACK by more than a second (midnight, a session clock adopted on join) + // re-bases; frame jitter does not (it would hold the timer at its full duration) + if (seen === undefined || time < seen - 1) hudTimerSeen.set(node.id, time); return Math.max(0, duration - (time - /** @type {number} */ (hudTimerSeen.get(node.id)))); } // a synced stamp can sit AHEAD of `time` by a frame; clamp both ends diff --git a/src/lib/sim/motionNodes.js b/src/lib/sim/motionNodes.js index a4f9d1bdd..c7c931dc3 100644 --- a/src/lib/sim/motionNodes.js +++ b/src/lib/sim/motionNodes.js @@ -67,9 +67,11 @@ export function applyRotor(object, base, data, time, ctx) { // from the first frame. It counts from the moment this peer saw it switch on (or appear); a peer // joining later sees its own spin-up from rest, the price of a phase no message carries. // a clock that went BACK (the synced clock wraps at midnight; joining a session swaps the - // local clock for the session's) re-bases rather than freezing the wheel until it catches up + // local clock for the session's) re-bases rather than freezing the wheel until it catches up. + // Only a real jump (> 1 s): a frame's worth of jitter between two callers must not re-base the + // wheel every frame, which holds it at rest (rotorAngle clamps a small negative to 0 anyway) const since = rotorOnSince.get(ctx.key); - if (since === undefined || time < since) rotorOnSince.set(ctx.key, time); + if (since === undefined || time < since - REBASE_S) rotorOnSince.set(ctx.key, time); const angle = rotorAngle(rpm, Math.max(0, +data.spinUp || 0), time - /** @type {number} */ (rotorOnSince.get(ctx.key))); qBase.setFromEuler(eul.set(base.rot[0], base.rot[1], base.rot[2])); qSpin.setFromAxisAngle(axis, angle); @@ -85,6 +87,8 @@ export function applyRotor(object, base, data, time, ctx) { /** @type {Map} per rotor (node|object): the clock time it switched on */ const rotorOnSince = new Map(); +/** a clock that goes back by more than this has jumped (midnight, a session clock adopted) */ +const REBASE_S = 1; /** @type {Map} the last frame time per drive (dt for the torque cap) */ const lastDrive = new Map(); diff --git a/tests/unit/motionNodes.test.js b/tests/unit/motionNodes.test.js index 2aeb1e5aa..bc6ab11f7 100644 --- a/tests/unit/motionNodes.test.js +++ b/tests/unit/motionNodes.test.js @@ -58,6 +58,8 @@ describe('Rotate / Motor', () => { spin({ on: false }, 51302); // off (the runtime's base restore puts the pose back; the ramp is forgotten) expect(spin({}, 51310)).toBeCloseTo(0, 9); // on again: from rest expect(spin({}, 51311)).toBeCloseTo(Math.PI / 2, 6); + spin({}, 51309.99); // a frame of jitter BEFORE the switch-on time is not a re-base: the ramp still counts from 51 310 + expect(spin({}, 51311.5)).toBeCloseTo((2 * Math.PI * 1.5 * 1.5) / 4, 6); expect(spin({}, 3)).toBeCloseTo(0, 9); // the clock went BACK (midnight): re-based, not frozen expect(spin({}, 4)).toBeCloseTo(Math.PI / 2, 6); }); From 729ee67eeddf380e13076b22ebb75264fc946e0a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 04:11:39 +0200 Subject: [PATCH 21/29] [docs] CHANGELOG 1.45.0 + battery: the 52 and fix-batch-1 suites - CHANGELOG 1.45.0 "Build in the headset" (52 V1-V5 + hands, fix-batch-1 #445 fixes). 1.44.0 went to roadmap 60, so roadmap 52 releases as the next minor. - tests/e2e/battery.json (written with battery.cjs's own writer, round-trip checked): suites vr-measure-iwer, vr-asset-browser-iwer, vr-universal-world-grab, vr-brushes-iwer (2x the 52-union desk times), flow-impact-any, flow-sockets-wire, flow-clock-origin, sequence-card (120 s, unmeasured); vr-brushes-perf excluded as a frame-rate measurement (--exclusive by hand). The batteryDesk coverage test was red on the union for exactly these nine. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++++++++++++ tests/e2e/battery.json | 10 ++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1868bf6e..53e6c46e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## 1.45.0 — Build in the headset: measure, arrange, paint, scatter, browse assets and shrink the world 🥽 + +Everything you need to build a scene now works with the headset on: measure and line things up, paint terrain and +scatter, pull models off your wrist and drop them in, and scale the whole world down to a miniature to work from above. + +### 📏 Measure and arrange + +- **Tools ▸ Measure**: tap points with the laser — they snap to corners, edges and faces (and to the grid when grid + snapping is on). Chains of points, labels that turn to face you and stay readable, a double tap (or **Keep**) makes it + a measurement item like on the desktop; **Selected ▸ Edit measurement** moves its points. +- **Selected ▸ Arrange** for two or more objects: pick the axis, then Align min / centre / max, Even centres / gaps, + Drop to ground or Align to — the same as the desktop, one Undo, everyone sees it. + +### 🌍 Shrink the world + +- In Edit, **both grips always grab the world**, wherever you point: pull your hands together to shrink it down to a + **1:50 miniature**, apart to grow it, twist to turn it. It clicks (with a buzz) at 1:50 · 1:10 · 1:5 · 1:2 · 1:1 · 2× · 5× + · 10×, a readout shows the scale, and **Back to 1:1** sits on the quick menu while the world is off life size. The + comfort vignette closes in while it scales or turns. Only you see it. +- To scale or turn an **object** with two hands, grab it and move it a little first, then add the second grip. Grips + never pick up the terrain, locked objects, things bigger than about 2 m or a room you are standing in. +- Settings ▸ VR ▸ **Both grips scale the world**: Always (new) or Only in empty air (the old way). + +### 🖌️ Brushes on the controller + +- Quick menu ▸ Sculpt ▸ **Paint** (terrain layers 1-4) and **Scatter** (paint, erase, rotate, scale, relax, choose a + source). The trigger paints; the stick sets radius and strength (or density); a ring shows the brush on the ground + and a buzz starts each stroke. Works in a miniature too. + +### 📦 Asset Browser on your wrist + +- **Add ▸ Assets** opens a browser on your wrist: packs, free CC0 models and your library, searchable with the VR + keyboard. Trigger-drag a card into the scene, or click it and place copies with the laser (it snaps, the stick turns + it, grip stops). Grip the panel off your wrist to pin it in the air; grip it back to return it. +- **Hand tracking**: no controllers needed to browse and place — a pinch is the trigger, pinch-drag drops a model. Tools + that need precision ask you to pick up a controller. + +### 🔧 Fixes + +- **On Impact** now fires when an object hits something from any side — a ball thrown at a wall or a target — not only + when it lands (new **From** option: any side / landing only; On Impact nodes in existing scenes keep landing only). +- Rotate and Motor nodes spin up from rest when they switch on, and an auto-starting HUD Timer counts down from its full + time (both used to start as if they had been running since midnight). +- Set Game State, Set Variable, HUD Rows, HUD Set Input, Device Param and Note Trigger show the sockets they read, so + you can wire them by hand; a HUD Timer's **start** socket starts it. +- The Sequence card shows its wait times at the normal card width; thirteen node descriptions say what the node really + does. +- The profile button opens with Enter or Space instead of on focus, so Tab moves past it, and Esc returns to it. At + large UI scales on a narrow phone, window headers stay on one row with the close button beside the title. + ## 1.44.0 — Quick wins: play from where you stand, trace over a picture, share files in, embed anywhere ⚡ Small things you feel in every session: try the corner you are building without walking there, trace a plan, send a diff --git a/tests/e2e/battery.json b/tests/e2e/battery.json index a94c07622..ad7aea355 100644 --- a/tests/e2e/battery.json +++ b/tests/e2e/battery.json @@ -368,10 +368,12 @@ "files-format-row": 181, "fix-live": 600, "floating-window-focus": 77, + "flow-clock-origin": 120, "flow-code": 120, "flow-code-text": 360, "flow-debugger": 74, "flow-dock-toggle": 24, + "flow-impact-any": 120, "flow-impact-static": 25, "flow-nodes-core": 70, "flow-object-graphs": 203, @@ -381,6 +383,7 @@ "flow-physics-nodes": 120, "flow-runtime": 146, "flow-socket-colors": 25, + "flow-sockets-wire": 120, "flow-typed-sockets": 8, "flow-unknown-node": 41, "fluid-diorama": 140, @@ -583,6 +586,7 @@ "search-menus": 120, "select-41": 114, "select-inside-36": 38, + "sequence-card": 120, "session-scenes": 560, "sessions": 120, "settings-autorestore-colors": 96, @@ -684,6 +688,8 @@ "uv-paint": 162, "viewport-scale": 102, "voice-ptt": 69, + "vr-asset-browser-iwer": 240, + "vr-brushes-iwer": 110, "vr-comfort-iwer": 88, "vr-game-panel": 41, "vr-grab": 12, @@ -692,6 +698,7 @@ "vr-iwer": 44, "vr-locomotion": 22, "vr-locomotion-flags": 30, + "vr-measure-iwer": 160, "vr-mode-spawn": 120, "vr-panel-sharpness": 11, "vr-panels-v3": 10, @@ -702,6 +709,7 @@ "vr-settings-panel": 12, "vr-snapturn-mirror": 17, "vr-teleport": 13, + "vr-universal-world-grab": 140, "vr-walk-interact": 120, "vr-world": 36, "water": 68, @@ -740,7 +748,7 @@ "exclude": { "a VR (IWER) editing suite not in the 1.43 integrator battery: triage owed (test-health-1)": ["music-vr-patch","novr-inset","play-here-iwer","vr-ai-panel","vr-ai-voice","vr-bounded-teleport","vr-box-select","vr-chat","vr-collider-edit","vr-controller-handedness","vr-create-face","vr-create-face-facing","vr-edit-caps","vr-edit-menu","vr-editmenu-reposition","vr-editmenu-tabs","vr-editmode-persist","vr-face-correctness","vr-face-edit","vr-face-live-adjust","vr-face-manip","vr-face-move-fix","vr-face-polygon","vr-face-weld","vr-grips-by-mode","vr-helper-eyes","vr-hz-pinch","vr-keyboard","vr-keyboard-search","vr-laser-panels","vr-mesh-undo","vr-multi-prefab","vr-multiselect-ring","vr-nav-suppression","vr-objects-groups","vr-objects-panel","vr-palette","vr-panel-sharpness-all","vr-panels-on-top","vr-peer-approve","vr-peer-hands","vr-peer-hands-net","vr-ping-accuracy","vr-prefabs","vr-props-panel","vr-ptt","vr-radial-stick","vr-ray-hover","vr-reticle-v2","vr-sleeve","vr-snap-menu","vr-source-rebind","vr-stats","vr-stretch","vr-stretch-fix","vr-stretch-sliders","vr-sweep","vr-teleport-handedness","vr-teleport-toggle","vr-two-hand-grab","vr-ungroup","vr-vertex-edit","vr-vertex-handle-bounds","vr-vertex-hold","vr-vertex-polish","vr-vertex-ray","vr-vertex-trigger","vr-window-grab","vr-world-grab","vr-world-reel","vr-worldgrab-presence"], "a docs / screenshot generator, not a gate": ["flow-node-docs","shader-node-docs","vr-eye-shots"], - "a frame-rate / timing measurement: run by hand on an --exclusive slot, never in a battery half": ["avatar-knocked-perf","avatars-budget","import-budget","mesh-budget","perf-detailed","perf-live","perf-moment","perf-stale-modules","perf-stutter","place-drag-perf"], + "a frame-rate / timing measurement: run by hand on an --exclusive slot, never in a battery half": ["avatar-knocked-perf","avatars-budget","import-budget","mesh-budget","perf-detailed","perf-live","perf-moment","perf-stale-modules","perf-stutter","place-drag-perf","vr-brushes-perf"], "not in the 1.43 integrator battery (listA5/listB5): kept for focused runs, triage owed (test-health-1)": ["add-menu","add-menu-cursor","advanced-mode","ai-assistant","ai-local-model","ai-presets","ai-reference","anim-pack-refs","animated-base","animation-autokey","animation-autokey-material","animation-bake","animation-look-channels","animation-loop-pause","animation-markers","animation-marquee","animation-node","animation-onion","animation-persist","animation-relative-motion","annotation-anchor","approval-timeout","asset-cache-opfs","audio-devices","audio-engine","audio-harness","audio-pack","audio-patch","audio-sdk","author-kit","avatar-fly","avatar-v2","button","camera-bookmarks","camera-clipping","camera-looks","camera-objects","camera-preview-control","checkpoints","clear-scene","code-workspace","collectibles-v2","collision-groups","colocation-anchor","colocation-ar-mode","colocation-calibrate","colocation-core","colocation-nudge","colocation-presence","connect-pin-slide-43","content-index-cache","contest-starters","controls-state","controls-transform-tint","convert-to-mesh","deleted-folders","deselect-unlock","desktop-face-gizmo","dial-metadata","drawing","dungeon","dungeon-play","duplicate-after-create","duplicate-parity","edit-overlay-gaps","editmesh-focus","editmesh-mode-memory","env-fog-sticks","environment-v2","explorer-columns","explorer-duplicate","explorer-duplicate-scene-folder","explorer-expandable","explorer-inline-input","explorer-menu-icons","explorer-mount-open-peers","explorer-mounts","explorer-mounts-edit","explorer-rename","explorer-tree-floor","faces-nested-toolbar","faces-toolbar","far-clipping","fast-channel-fallback","fast-channel-loss","fb-input","fbx-mtl-import","file-windows-esc","flow-adjustable-params","flow-audit","flow-background","flow-contact-filter","flow-curvy-edges","flow-customnode-io","flow-explorer-floating","flow-input-moddefs","flow-mouse-bindings","flow-new-nodes","flow-node-duplicate","flow-node-undocked","flow-nodes-extended","flow-object-embed","flow-palette","flow-palette-reflow","flow-particle","flow-properties","flow-spawner","flow-window-mobile","gallery-submit","game-editor-flow","game-effects","game-feel-nodes","game-haptics","game-loop","game-loop-v2","game-loop-v3","game-presence","geometry-lock","geometry-params","gizmo-orbit-leak","gizmo-recall","grab-key","graph-games-lint","graph-tidy","graph-tree","grid-snapping","hand-models","hud-actions","hud-content","hud-editor-tools","hud-inputs","hud-nodes","hud-visibility","import-duplicates","import-reduce-gate","kit-entities","launch-inbox","level-templates","level-templates-33","light-aim","light-catalog","llms-txt","load-primitives-first-36","load-supersede-36","material-sharing","material-types","mesh-adjust","mesh-advanced-ops","mesh-bevel","mesh-bridge-normals","mesh-cleanup","mesh-edge-bevel","mesh-edge-gizmo","mesh-edge-mode","mesh-edge-vertex-ops","mesh-edit-display","mesh-edit-materials","mesh-edit-popup","mesh-falloff-rotate-scale","mesh-falloff-sync","mesh-fixes-round2","mesh-fixes-round3","mesh-generation","mesh-gizmo-modes","mesh-grab-uv","mesh-inset-move","mesh-loop-cut","mesh-loop-hardening","mesh-loop-select","mesh-multishell-ops","mesh-ops","mesh-ops-p5b","mesh-pivot-gizmo","mesh-proportional","mesh-quad-select","mesh-selection-undo","mesh-session-undo","mesh-symmetrize","mesh-symmetry-live","mesh-uv-preserve","mesh-vertex-bevel","mesh-vertex-slide","model-preview-controls","module-lifecycle-repo","module-node-io","module-pointer-seam","module-sdk","module-sdk-world","module-seed-graph","module-storage","module-update-on-load","module-world-root","modules-discoverability","multi-select","multiselect-menu","music-clock","music-flow-nodes","music-prefabs","music-sampling","music-template","music-toolbox","net-backoff","net-locks","network-quality","node-editor-open-view","node-search","node-ux-library","note-replies","object-list-drag","object-list-raise","object-list-size","object-origin","object-properties","object-search","object-sync","objectlist-hidden-icon","objectlist-search","open-core-m1","origin-rows","outline-scene-swap","pack-behavior","pack-undo","packs-explorer","palette","palette-groups","particles","path-node","peer-ice-config","peer-variables","pick-tiny-objects","ping","ping-highlights","ping-v2","placeholders-peers","play-free-cursor","play-here","play-reach-jump","pong","possess","prefab-explorer","prefab-library","prefab-propagation","preview-animation","private-scene","problem-report","project-file","project-manifest","race-touch-vr","recording","resync-converge","roadmap-13-notifications-notes","roadmap-13-viewport-camera","save-as-formats","scene-adopt","scene-assets","scene-default-material","scene-duplicate","scene-folders","scene-identity","scene-isolation","scene-levels","scene-load","scene-music","scene-poke","scene-presence","scene-rename","scene-rooms","scene-rooms-matrix","scene-rooms-truth","scene-stress","scene-switch","scene-switch-clear","scene-switch-games","script-guard","sdk-game-seams","sdk-input","sdk-pointer-ray","select-through","selection-extras","session-clock","session-download-formats","session-scene-data","session-zip","sessions-open","sessions-open-peers","sessions-packs","shader-editor","shader-inspector","shader-module-flow","shader-persist","shader-scene-default","shadows","share-gate-defer","share-stash","shared-library","shared-library-copies","sidebar-toggle","small-fixes-37","snap-advanced","spatial-voice","spline-tool","stars-clap","start-view","start-view-hold","stroke-streaming","switcher-mux","template-modules","terrain","topo-channel","towers-levels","trackpad-nav","trackpad-pinch","ui-fixes-15b","underlay","uv-dense","uv-live-faces","uv-materials","uv-pen-pressure","uv-slots","uv-slots-persist","uv-smart-unwrap","uv-target","uv-texture-params","uv-texture-tools","uv-transform","uv-unwrap","uv-unwrap-module","value-graph","variadic-nodes","version","vertices-create-face","view-mode","viewport-selection","viewport-tools-menu","walker-sensors","watch-look","waypoint-viz","wheel-classify"], "the 38 R1 behaviour lock: npm run e2e -- @lock (tests/e2e/lock.json), not the release battery": ["dock-tabs-coexist","flow-dock-mixed","flow-dock-tabs","flow-explorer-dock","inspector-multi-edit","node-drag-fields","units"] } From 4fb3dfc5e94a8420f57f48b28af8f3c2246bd21f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 09:35:35 +0200 Subject: [PATCH 22/29] net-drop: wire + app trace for deployed builds, B->A premise before any drop The suite's shim now records, per page: every RTCPeerConnection's real ICE / connection state, every data channel's open/close with sent / delivered / held counts (a peerjs channel's label is its conn id), the selected ICE pair's candidate types, each DataConnection's sends and 'data' events by message type, and what the send / receive / role gates answer about the other peer. Printed with NETDROP_TRACE=1, or when B's edit never reaches A. A new premise checks that B's edit reaches A before any drop (the lock alone is a protocol-floor message). Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/net-drop.test.cjs | 199 +++++++++++++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 5 deletions(-) diff --git a/tests/e2e/net-drop.test.cjs b/tests/e2e/net-drop.test.cjs index 79958318b..98274e1fe 100644 --- a/tests/e2e/net-drop.test.cjs +++ b/tests/e2e/net-drop.test.cjs @@ -23,21 +23,65 @@ const h = require('./helpers.cjs'); const DROP_MS = Number(process.env.NETDROP_MS || 5000); +/** net-drop-1: print the wire trace for every phase, not only a failing one */ +const TRACE = !!process.env.NETDROP_TRACE; /** src/lib/peerDrop.js ICE_DROP_GRACE_MS */ const ICE_DROP_GRACE_MS = 4000; -/** runs in the page before any app code: the hold switch + fake ICE state */ +/** runs in the page before any app code: the hold switch + fake ICE state + the WIRE TRACE (net-drop-1: + * every peer connection's real ICE/connection state, every data channel's open/close and its sent / + * delivered / held message counts by label — a peerjs channel's label is its conn's connectionId, so + * "which conn does each send use, and does anything read it" is answerable on a deployed build) */ function dropShim() { - const D = (window.__drop = { holding: false, queue: /** @type {Function[]} */ ([]), pcs: /** @type {RTCPeerConnection[]} */ ([]), held: 0 }); + const D = (window.__drop = { + holding: false, + queue: /** @type {Function[]} */ ([]), + pcs: /** @type {RTCPeerConnection[]} */ ([]), + held: 0, + log: /** @type {string[]} */ ([]), + /** label -> {pc, sent, recv, held, state} */ + dcs: /** @type {Record} */ ({}) + }); + const realIce = Object.getOwnPropertyDescriptor(RTCPeerConnection.prototype, 'iceConnectionState')?.get; + // @ts-ignore + D.note = (/** @type {string} */ what) => { + D.log.push(new Date().toISOString().slice(14, 23) + ' ' + what); + if (D.log.length > 600) D.log.splice(0, 100); + }; + const dcRow = (/** @type {any} */ dc) => (D.dcs[dc.label] ??= { pc: dc.__pc ?? '?', sent: 0, recv: 0, held: 0, state: 'new' }); + const send = RTCDataChannel.prototype.send; + RTCDataChannel.prototype.send = function (/** @type {any} */ data) { + const row = dcRow(this); + row.sent++; + if (this.readyState !== 'open') D.note(`SEND on ${this.label} (pc${row.pc}) while ${this.readyState}`); + return send.call(this, data); + }; + const watchDc = (/** @type {any} */ dc, /** @type {number} */ n, /** @type {string} */ how) => { + dc.__pc = n; + const row = dcRow(dc); + row.pc = n; + D.note(`dc ${dc.label} ${how} on pc${n} (${dc.readyState})`); + for (const ev of ['open', 'close', 'closing', 'error']) + dc.addEventListener(ev, () => { + row.state = ev; + D.note(`dc ${dc.label} pc${n} ${ev} sent=${row.sent} recv=${row.recv} held=${row.held}`); + }); + }; const wrap = (/** @type {Function} */ fn) => function (/** @type {any} */ event) { // @ts-ignore const self = this; + const row = dcRow(self); if (D.holding) { D.held++; - D.queue.push(() => fn.call(self, event)); + row.held++; + D.queue.push(() => { + row.recv++; + return fn.call(self, event); + }); return; } + row.recv++; return fn.call(self, event); }; const proto = RTCDataChannel.prototype; @@ -70,7 +114,20 @@ function dropShim() { // @ts-ignore window.RTCPeerConnection = function (/** @type {any[]} */ ...args) { const pc = new Real(...args); - D.pcs.push(pc); + const n = D.pcs.push(pc) - 1; + const policy = args[0]?.iceTransportPolicy ?? 'all'; + const servers = (args[0]?.iceServers ?? []).map((/** @type {any} */ s) => [].concat(s.urls).map((u) => String(u).split(':')[0]).join('+')).join(','); + D.note(`pc${n} new policy=${policy} servers=${servers || '-'}`); + const cdc = pc.createDataChannel.bind(pc); + pc.createDataChannel = (/** @type {any[]} */ ...a) => { + // @ts-ignore + const dc = cdc(...a); + watchDc(dc, n, 'created'); + return dc; + }; + pc.addEventListener('datachannel', (e) => watchDc(e.channel, n, 'inbound')); + pc.addEventListener('iceconnectionstatechange', () => D.note(`pc${n} ice=${realIce?.call(pc)}${D.holding ? ' (held)' : ''}`)); + pc.addEventListener('connectionstatechange', () => D.note(`pc${n} conn=${pc.connectionState}`)); return pc; }; window.RTCPeerConnection.prototype = Real.prototype; @@ -147,6 +204,125 @@ h.run(async () => { const bLocks = async () => (await store(A, 'lockedObjects')).filter((/** @type {any} */ x) => x[0] === B.id).length; const lockBox = await create(B, 'box'); await h.eventually(() => store(A, 'lockedObjects'), (l) => l.some((/** @type {any} */ x) => x[0] === B.id && x[1] === lockBox), "premise: A sees B's lock"); + // net-drop-1: B -> A worked BEFORE any drop (on a deployed build the lock alone, a protocol-floor + // message, could not tell a direction lost to the heal from one that never carried content) + await h.eventually(() => has(A, lockBox), (v) => v, "premise: B's edit reaches A before any drop"); + + /** net-drop-1: both pages' wire logs, each conn's channel counts and the selected ICE pair's + * candidate types (host / srflx / relay), printed — the deployed-build diagnosis */ + async function wireTrace(/** @type {string} */ tag, /** @type {string} */ why) { + for (const [p, other] of [[A, B.id], [B, A.id]]) { + const t = await p.page + .evaluate(async (/** @type {string} */ pid) => { + // @ts-ignore + const D = window.__drop; + /** @type {any} */ let pc; + window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))(); + const c = pc.connections[pid]; + const pairs = []; + for (const [n, rpc] of D.pcs.entries()) { + if (rpc.signalingState === 'closed') continue; + try { + const rows = []; + (await rpc.getStats()).forEach((/** @type {any} */ r) => rows.push(r)); + const tr = rows.find((r) => r.type === 'transport' && r.selectedCandidatePairId); + const pair = tr && rows.find((r) => r.id === tr.selectedCandidatePairId); + const kind = (/** @type {string} */ id) => rows.find((r) => r.id === id)?.candidateType ?? '?'; + pairs.push(`pc${n}: ${pair ? kind(pair.localCandidateId) + '->' + kind(pair.remoteCandidateId) + ' ' + pair.state : 'no pair'}`); + } catch (e) { + pairs.push(`pc${n}: stats ${String(e).slice(0, 40)}`); + } + } + return { + app: c ? `connections[peer] = ${c.connectionId} open=${c.open} dc=${c.dataChannel?.label}/${c.dataChannel?.readyState}` : 'connections[peer] = none', + dcs: Object.entries(D.dcs).map(([l, r]) => `${l} pc${r.pc} ${r.state} sent=${r.sent} recv=${r.recv} held=${r.held}`), + pairs, + log: D.log.slice(-220).concat(Object.entries(D.quiet ?? {}).map(([k, n]) => `counted ${k} x${n}`)) + }; + }, other) + .catch((/** @type {any} */ e) => ({ app: 'err ' + e, dcs: [], pairs: [], log: [] })); + console.log(` ${tag} GATES ${p === A ? 'A' : 'B'} about the other:\n ` + (await gates(p, other))); + console.log(` ${tag} WIRE ${p === A ? 'A' : 'B'} (${p.id}) — ${why}\n ${t.app}\n ${t.pairs.join(' | ')}\n ` + t.dcs.join('\n ') + '\n --\n ' + t.log.join('\n ')); + } + } + + /** net-drop-1: the APP half of the trace — every DataConnection's sends and 'data' events by message + * type (pose/clock streams only counted), patched on peerjs's shared prototype so a conn made by a + * heal is covered too */ + async function appTrace(/** @type {any} */ p) { + await p.page.evaluate(() => { + // @ts-ignore + const D = window.__drop; + /** @type {any} */ let pc; + window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))(); + const any = Object.values(pc.connections)[0]; + let proto = Object.getPrototypeOf(any); + while (proto && !Object.prototype.hasOwnProperty.call(proto, 'send')) proto = Object.getPrototypeOf(proto); + if (!proto || proto.__ndTraced) return; + proto.__ndTraced = true; + const noisy = new Set(['camera', 'vrhands', 'clockping', 'clockpong', 'cursor', 'cursors', 'pointer', 'ping', 'pong', 'pose', 'move', 'batch']); + D.quiet = {}; + const note = (/** @type {string} */ dir, /** @type {any} */ c, /** @type {any} */ d) => { + const t = String(d?.type ?? (d && d.__peerData ? 'peerjs' : typeof d)); + if (noisy.has(t)) { + const k = dir + ' ' + t + ' ' + c.peer + ' ' + c.connectionId; + D.quiet[k] = (D.quiet[k] ?? 0) + 1; + return; + } + D.note(`APP ${dir} ${t} ${dir === 'out' ? 'to' : 'from'} ${c.peer} on ${c.connectionId}${c.open ? '' : ' (NOT OPEN)'}`); + }; + const send = proto.send; + proto.send = function (/** @type {any} */ d, /** @type {any[]} */ ...rest) { + note('out', this, d); + return send.call(this, d, ...rest); + }; + const emit = proto.emit; + proto.emit = function (/** @type {string} */ ev, /** @type {any[]} */ ...args) { + if (ev === 'data') note('in', this, args[0]); + return emit.call(this, ev, ...args); + }; + }); + } + await appTrace(A); + await appTrace(B); + + /** net-drop-1: what each side's gates answer about the other right now */ + async function gates(/** @type {any} */ p, /** @type {string} */ other) { + return p.page + .evaluate(async (/** @type {string} */ pid) => { + const S = window.__stores; + /** @type {any} */ let pc; + S.peers.subscribe((/** @type {any} */ v) => (pc = v))(); + const get = (/** @type {any} */ st) => { + let v; + st?.subscribe?.((/** @type {any} */ x) => (v = x))(); + return v; + }; + const out = []; + const safe = (/** @type {string} */ k, /** @type {() => any} */ f) => { + try { + out.push(k + '=' + JSON.stringify(f())); + } catch (e) { + out.push(k + '=ERR ' + String(e).slice(0, 50)); + } + }; + safe('withholdFrom(create)', () => pc.withholdFrom(pid, { type: 'create' })); + safe('canApply(create)', () => S.cloudHooks?.canApply?.(pid, 'create')); + safe('canApplyByRoom(create)', () => S.peerScenes?.canApplyByRoom?.(pid, 'create')); + safe('gateHolds', () => S.sessions?.gateHolds?.(pid)); + safe('amPrivate', () => S.peerScenes?.amPrivate?.()); + safe('myScene', () => S.peerScenes?.myScene?.()); + safe('peerScenes', () => Object.fromEntries(Object.entries(get(S.peerScenes?.peerScenes) ?? {}))); + safe('sessionHost', () => get(S.connectionState?.sessionHost)); + safe('rolesInfo', () => { + const r = get(S.cloudHooks?.rolesInfo); + return r ? { myRole: r.myRole, amAdmin: r.amAdmin, theirs: r.roleOf?.(pid) } : null; + }); + safe('viewer', () => S.objectPermissions?.isViewer?.()); + return out.join('\n '); + }, other) + .catch((/** @type {any} */ e) => 'err ' + e); + } /** @param {'quiet' | 'hard'} shape */ async function drop(shape) { @@ -181,6 +357,17 @@ h.run(async () => { let cut = false; for (const t = Date.now(); !cut && Date.now() - t < 3000; await A.page.waitForTimeout(150)) cut = !(await signalingUp(A)) && !(await signalingUp(B)); // peerjs marks itself down on the socket's close event + if (!cut || TRACE) + for (const p of [A, B]) + console.log( + ` ${tag} signaling at the cut, ${p === A ? 'A' : 'B'}: ` + + (await p.page.evaluate(() => { + /** @type {any} */ let pc; + window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))(); + const q = pc.peer; + return `open=${q?.open} disconnected=${q?.disconnected} destroyed=${q?.destroyed} ws=${q?._socket?._socket?.readyState ?? '-'} id=${q?.id}`; + })) + ); if (shape === 'hard') { await A.page.evaluate((/** @type {string} */ id) => { /** @type {any} */ let pc; @@ -200,7 +387,7 @@ h.run(async () => { window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))(); const c = pc.connections[pid]; const age = (/** @type {any} */ x) => (x?.__dialedAt ? Math.round((Date.now() - x.__dialedAt) / 100) / 10 + 's' : '-'); - return `sig=${pc.peer?.open ? 1 : 0} rec=${pc.reconnecting.get(pid) ?? '-'} conn=${c ? (c.open ? 'open' : 'neg ' + age(c)) : 'none'} in=${pc.inboundDials?.has(pid) ? 'neg ' + age(pc.inboundDials.get(pid)) : '-'} opened=${pc.openedPeers.has(pid) ? 1 : 0}`; + return `sig=${pc.peer?.open ? 1 : 0} rec=${pc.reconnecting.get(pid) ?? '-'} conn=${c ? (c.open ? 'open' : 'neg ' + age(c)) + ' ' + c.connectionId : 'none'} in=${pc.inboundDials?.has(pid) ? 'neg ' + age(pc.inboundDials.get(pid)) : '-'} opened=${pc.openedPeers.has(pid) ? 1 : 0}`; }, id) .catch((/** @type {any} */ e) => 'err ' + String(e?.message ?? e).slice(0, 60)); const watch = async () => { @@ -241,11 +428,13 @@ h.run(async () => { } h.check(lost.length === 0, `${tag} each peer kept the other in its roster, and B's lock stood, throughout (${lost.slice(0, 2).join(', ') || 'never lost'})`); const healedBoth = (await openTo(A, B.id)) && (await openTo(B, A.id)); + if (TRACE) await wireTrace(tag, 'after the heal'); if (!healedBoth) console.log(` ${tag} timeline (A=${A.id} B=${B.id}; the lower id dials):\n ` + timeline.join('\n ')); h.check(healedBoth, `${tag} the conn is open both ways again`); h.check((await disconnectToasts(A, toastsA)) === 0 && (await disconnectToasts(B, toastsB)) === 0, `${tag} no "disconnected" toast on either side`); await h.eventually(() => has(B, duringA), (v) => v, `${tag} A's edit made during the drop reaches B`, 15000); await h.eventually(() => has(A, duringB), (v) => v, `${tag} B's edit made during the drop reaches A`, 15000); + if (!TRACE && !(await has(A, duringB))) await wireTrace(tag, "B's edit never reached A"); const afterB = await create(B, 'cone'); await h.eventually(() => has(A, afterB), (v) => v, `${tag} an edit after the drop replicates`, 10000); await h.eventually(async () => (await signalingUp(A)) && (await signalingUp(B)), (v) => v, `${tag} both pages are back on the signaling server`, 20000); From 8a2148b9a16e383c74ab97b6d0c48af9a62a0d30 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 09:42:27 +0200 Subject: [PATCH 23/29] net: our peer id outlives a signaling drop (deployed net-drop B->A loss) peerjs Peer.disconnect() - every drop of the signaling socket - sets peer.id to null until the socket re-registers. The cloud roles plugin reads its own id from getPeers().peer.id, so for the length of a drop it counted itself alone, wiped its role map and the host it joined; after the heal a joiner read itself as a VIEWER (the host still had it as an editor and, its open set unchanged, never re-sent the map), and core's creation gate kept every edit local. Only deployed builds load the plugin, which is why net-drop was green on a dev server and red on production 1.43.2 / 1.44.0 and every preview. peerDrop.keepPeerId gives the Peer an own id getter: the live id, else the id it was created with. peerjs bookkeeping (_id, _lastServerId, open, disconnected) is untouched. reconnectDial keeps its old no-dial-while-disconnected behaviour. net-drop: own id readable at the cut (red on v1.44.0), roles unchanged by the heal (with a plugin). Unit: keepPeerId against the real peerjs Peer. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/peerDrop.js | 27 ++++++++++++++++++++ src/lib/peerHandler.svelte.js | 8 +++--- tests/e2e/net-drop.test.cjs | 40 +++++++++++++++++++++++++++++ tests/unit/peerDrop.test.js | 48 ++++++++++++++++++++++++++++++++++- 4 files changed, 119 insertions(+), 4 deletions(-) diff --git a/src/lib/peerDrop.js b/src/lib/peerDrop.js index 59f627f1f..3ba467310 100644 --- a/src/lib/peerDrop.js +++ b/src/lib/peerDrop.js @@ -102,3 +102,30 @@ export function quietPeerError(type, { lastLeaveAt, reconnecting, now }) { export function unavailablePeerId(message) { return String(message ?? '').match(/peer\s+([0-9a-z][0-9a-z_-]{2,})\s*$/i)?.[1] ?? ''; } + +/** + * net-drop-1: OUR ID OUTLIVES A SIGNALING DROP. peerjs's `Peer.disconnect()` — run on every + * drop of the signaling socket — sets `peer.id` to null until the socket re-registers, while every + * data conn, lock, roster row and plugin keys on that id, and it comes back unchanged (`reconnect()` + * re-registers the id it remembered; a rebuild reuses `PeerConnection.myId`). Everything asking + * "who am I" during the gap read null: the cloud roles plugin counted itself ALONE, wiped its role + * map, and a joiner came back from a healed drop as a VIEWER whose every edit stayed local (the + * net-drop B -> A loss on deployed builds); in core an own avatar, own locks and own shared-library + * rows read as somebody else's. An own getter on THIS Peer answers the live id while there is one, + * else the id it was created with; peerjs's own bookkeeping (`_id`, `_lastServerId`, `open`, + * `disconnected`) is untouched, so "is the signaling link up" is still `open`, never `id`. + * @template T @param {T} peer @param {string} id @returns {T} + */ +export function keepPeerId(peer, id) { + if (!peer || !id) return peer; + /** @type {(() => any) | undefined} */ + let live; + for (let o = Object.getPrototypeOf(peer); o && !live; o = Object.getPrototypeOf(o)) live = Object.getOwnPropertyDescriptor(o, 'id')?.get; + Object.defineProperty(peer, 'id', { + configurable: true, + get() { + return (live ? live.call(this) : null) ?? id; + } + }); + return peer; +} diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index e8b9957ed..8066f1bfb 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -37,7 +37,7 @@ import { registerDebugHook } from '$lib/debugHooks'; import { routePerfLive, perfLivePeerGone } from '$lib/perf/liveWire.js'; // 34 PF: the live perf stream (an import-free leaf) import { noteWireError } from '$lib/wireErrors'; import { composeAdmitNotice, shortPeer } from '$lib/admitNotice'; -import { ICE_DROP_GRACE_MS, LINK_POLL_MS, strikesNeeded, quietPeerError, unavailablePeerId, consentCounters, linkSilence } from '$lib/peerDrop'; +import { ICE_DROP_GRACE_MS, LINK_POLL_MS, strikesNeeded, quietPeerError, unavailablePeerId, consentCounters, linkSilence, keepPeerId } from '$lib/peerDrop'; import { createDialBackQueue } from '$lib/dialBackQueue'; // scatter-sync: room edits held for an opening approval dial-back import { noteWire } from '$lib/sceneBudget'; import { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler'; @@ -380,7 +380,8 @@ export class PeerConnection { // 36-export: an exported game is single-player and opens NO signaling socket — a stub // Peer that never opens (`connect()` answers undefined, which every dial site already // guards, the signaling-down rule), so `$peers.peer.id` and every send stay valid - this.peer = exportMode ? /** @type {Peer} */ (offlinePeer(this.myId)) : new Peer(this.myId, options); + // net-drop-1: the id stays readable through a signaling drop (peerDrop.keepPeerId) + this.peer = exportMode ? /** @type {Peer} */ (offlinePeer(this.myId)) : keepPeerId(new Peer(this.myId, options), this.myId); }; // 27-F (audit H2): THE recreate ritual, in one place. Four callers had their own @@ -2156,7 +2157,8 @@ export class PeerConnection { * Called from the window's timers and when the signaling link comes back. * @param {string} peerId */ reconnectDial(peerId) { - if (this.connections[peerId] || !(this.peer.id < peerId)) return; + // a Peer off the signaling server cannot dial (its id still reads, keepPeerId): the `open` handler redials + if (this.connections[peerId] || this.peer.disconnected || !(this.peer.id < peerId)) return; const conn = this.peer.connect(peerId, dialOptions()); if (!conn) return; // the signaling link is down: the next tick (or its return) redials /** @type {any} */ (conn).__dialedAt = Date.now(); diff --git a/tests/e2e/net-drop.test.cjs b/tests/e2e/net-drop.test.cjs index 98274e1fe..c7ec96622 100644 --- a/tests/e2e/net-drop.test.cjs +++ b/tests/e2e/net-drop.test.cjs @@ -17,6 +17,12 @@ // 2. HARD (a network CHANGE): the same, plus the peer connection is closed outright at the start, so // the B5 reconnect window has to redial — through a signaling server it cannot reach until the // drop ends. The window is ~15.5 s (checks at 0.5/1/2/4/8 s), so a 5 s drop must fit inside it. +// net-drop-1 (deployed builds): with a cloud ROLES plugin loaded — every Pages deploy, or a dev server run +// with E2E_CLOUD_PLUGIN=https://theprototype.pages.dev/cloud-plugin.js — the joiner came back from the heal +// a VIEWER and its edits never left it: peerjs nulls `peer.id` while the signaling socket is down, the +// plugin read "no id, one peer" as alone and wiped its role map. Two checks hold the fix +// (peerDrop.keepPeerId): the own id at the cut (no plugin needed; red on v1.44.0) and roles unchanged by +// the heal (with a plugin; B read viewer on v1.44.0 + the production plugin, 4 B -> A checks red). // Counterfactual: NETDROP_MS=20000 outlasts the window — phase 2 goes red (teardown + toast; measured // e2e/v10cf). Phase 1 stays green at 20 s, by design: a QUIET drop never breaks the data channel, so it // proves the conn carries on, not the window's length. @@ -324,6 +330,17 @@ h.run(async () => { .catch((/** @type {any} */ e) => 'err ' + e); } + /** each side's own role and its role for the other, from the rolesInfo bridge (null without a plugin) */ + async function roles() { + const one = (/** @type {any} */ p, /** @type {string} */ other) => + p.page.evaluate((/** @type {string} */ pid) => { + /** @type {any} */ let r; + window.__stores.cloudHooks?.rolesInfo?.subscribe((/** @type {any} */ v) => (r = v))(); + return r ? `${r.myRole}/${r.roleOf?.(pid)}` : null; + }, other); + return { a: await one(A, B.id), b: await one(B, A.id) }; + } + /** @param {'quiet' | 'hard'} shape */ async function drop(shape) { const tag = shape.toUpperCase() + ` ${DROP_MS / 1000}s drop:`; @@ -337,6 +354,7 @@ h.run(async () => { pc.connections[id].__th1 = 'before'; }, other); } + const rolesBefore = await roles(); // ---- the network goes away for (const p of [A, B]) await p.page.context().setOffline(true); for (const p of [A, B]) { @@ -357,6 +375,20 @@ h.run(async () => { let cut = false; for (const t = Date.now(); !cut && Date.now() - t < 3000; await A.page.waitForTimeout(150)) cut = !(await signalingUp(A)) && !(await signalingUp(B)); // peerjs marks itself down on the socket's close event + // net-drop-1: who we are does not change while the signaling link is down. peerjs nulls `peer.id` + // on every drop (Peer.disconnect); the cloud roles plugin then counted itself alone, wiped its role + // map, and B came back from the heal a VIEWER whose edits never left (deployed builds only — a dev + // server has no plugin). Counterfactual: red on v1.44.0 (both read null), green with keepPeerId. + const ids = []; + for (const p of [A, B]) + ids.push( + await p.page.evaluate(() => { + /** @type {any} */ let pc; + window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))(); + return pc.peer?.id ?? null; + }) + ); + h.check(!cut || (ids[0] === A.id && ids[1] === B.id), `${tag} each page still reads its own peer id while the signaling link is down (${ids.join(', ')})`); if (!cut || TRACE) for (const p of [A, B]) console.log( @@ -428,6 +460,14 @@ h.run(async () => { } h.check(lost.length === 0, `${tag} each peer kept the other in its roster, and B's lock stood, throughout (${lost.slice(0, 2).join(', ') || 'never lost'})`); const healedBoth = (await openTo(A, B.id)) && (await openTo(B, A.id)); + // with a roles plugin loaded (every deployed build; E2E_CLOUD_PLUGIN on a dev server), the heal + // changes nobody's role — on either side + const rolesNow = await roles(); + if (rolesBefore.a || rolesBefore.b) + h.check( + JSON.stringify(rolesNow) === JSON.stringify(rolesBefore), + `${tag} roles unchanged by the heal (before ${JSON.stringify(rolesBefore)}, after ${JSON.stringify(rolesNow)})` + ); if (TRACE) await wireTrace(tag, 'after the heal'); if (!healedBoth) console.log(` ${tag} timeline (A=${A.id} B=${B.id}; the lower id dials):\n ` + timeline.join('\n ')); h.check(healedBoth, `${tag} the conn is open both ways again`); diff --git a/tests/unit/peerDrop.test.js b/tests/unit/peerDrop.test.js index 781e6b478..8d9fa8132 100644 --- a/tests/unit/peerDrop.test.js +++ b/tests/unit/peerDrop.test.js @@ -8,8 +8,10 @@ import { strikesNeeded, consentCounters, linkSilence, - LINK_SILENT_MS + LINK_SILENT_MS, + keepPeerId } from '../../src/lib/peerDrop.js'; +import { Peer } from 'peerjs'; // F-3: a dropped peer leaves Users within seconds and a leave never toasts "Connection error". describe('F-3 peer drop policy', () => { @@ -72,3 +74,47 @@ describe('F-3 peer drop policy', () => { expect(linkSilence({ responses: 5, requests: 5, at: 0 }, null, 60000).silent).toBe(false); }); }); + +// net-drop-1: peerjs's Peer.disconnect() (every signaling drop) sets `id` to null until the socket +// re-registers, and everything that asks "who am I" read null for the length of the drop — the +// cloud roles plugin then counted itself ALONE, wiped its role map, and a joiner came back from a +// healed drop as a viewer whose every edit stayed local (net-drop on deployed builds, B -> A red). +describe('keepPeerId: our id survives a signaling drop', () => { + /** the real peerjs Peer, disconnected the way a dropped socket disconnects it */ + function droppedPeer(/** @type {boolean} */ kept) { + const peer = Object.create(Peer.prototype); + Object.assign(peer, { _id: 'abc12', _lastServerId: null, _disconnected: false, _open: true, _destroyed: false }); + peer._socket = { close() {} }; + peer.emit = () => true; + if (kept) keepPeerId(peer, 'abc12'); + expect(peer.id).toBe('abc12'); + peer.disconnect(); + expect(peer.disconnected).toBe(true); + return peer; + } + it('counterfactual: an unwrapped peerjs Peer reads null while disconnected', () => { + expect(droppedPeer(false).id).toBe(null); + }); + it('a wrapped Peer keeps reading its id through the drop, and peerjs still reconnects on it', () => { + const peer = droppedPeer(true); + expect(peer.id).toBe('abc12'); + // peerjs keeps its own bookkeeping: the reconnect re-registers the id it remembered + expect(peer._lastServerId).toBe('abc12'); + const started = []; + peer._socket = { start: (/** @type {string} */ id) => started.push(id), close() {} }; + peer._options = {}; + peer.reconnect(); + expect(started).toEqual(['abc12']); + expect(peer.id).toBe('abc12'); + }); + it('the live id wins over the remembered one, and a missing peer or id is left alone', () => { + const peer = Object.create(Peer.prototype); + peer._id = 'zzz99'; + keepPeerId(peer, 'abc12'); + expect(peer.id).toBe('zzz99'); + expect(keepPeerId(null, 'abc12')).toBe(null); + const bare = { id: 'x' }; + expect(keepPeerId(bare, '')).toBe(bare); + expect(bare.id).toBe('x'); + }); +}); From 2974199df2f54d49470619effe925244ca4dd1da Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 09:53:54 +0200 Subject: [PATCH 24/29] =?UTF-8?q?[test]=20lock-search=20follows=2052=20V5:?= =?UTF-8?q?=20the=20Settings=20=E2=96=B8=20VR=20'Both=20grips=20scale=20th?= =?UTF-8?q?e=20world'=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-recorded on the union (729ee67e snapshot) and checked to differ by exactly that row in 5 queries (Scale, xr, zoom, vr, ai) plus the VR section on 'zoom'; verify 11/0. Base v1.44.0 is green with the old lock, the union was red 6 (A/B ab1). Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/fixtures/lock/search.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/e2e/fixtures/lock/search.json b/tests/e2e/fixtures/lock/search.json index e3de8d81a..1e93ce865 100644 --- a/tests/e2e/fixtures/lock/search.json +++ b/tests/e2e/fixtures/lock/search.json @@ -1,5 +1,5 @@ { - "recordedAt": "2026-10-24T15:10:13.838Z", + "recordedAt": "2026-10-26T07:45:28.020Z", "settings": { "Theme": { "rows": [ @@ -485,6 +485,7 @@ }, "Scale": { "rows": [ + "Both grips scale the world", "Grab style", "Phone selection toolbar", "Pivot point", @@ -3555,6 +3556,7 @@ }, "xr": { "rows": [ + "Both grips scale the world", "Button hints", "Colocation", "Colocation probe", @@ -4278,6 +4280,7 @@ "zoom": { "rows": [ "Allow browser pinch zoom", + "Both grips scale the world", "High contrast", "Motion", "Node editor opens", @@ -4290,7 +4293,8 @@ "sections": [ "Accessibility", "Controls", - "Input" + "Input", + "VR" ] }, "pan": { @@ -4386,6 +4390,7 @@ }, "vr": { "rows": [ + "Both grips scale the world", "Button hints", "Colocation", "Colocation probe", @@ -4443,6 +4448,7 @@ }, "ai": { "rows": [ + "Both grips scale the world", "Cached downloads", "Colocation probe", "Compress textures (KTX2)", From 51630e43a364ba86ea8f1302514522c67d5bec6a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 09:56:32 +0200 Subject: [PATCH 25/29] peerDrop test: type the started list (svelte-check ratchet back to 287/39) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/unit/peerDrop.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/peerDrop.test.js b/tests/unit/peerDrop.test.js index 8d9fa8132..053698b46 100644 --- a/tests/unit/peerDrop.test.js +++ b/tests/unit/peerDrop.test.js @@ -100,6 +100,7 @@ describe('keepPeerId: our id survives a signaling drop', () => { expect(peer.id).toBe('abc12'); // peerjs keeps its own bookkeeping: the reconnect re-registers the id it remembered expect(peer._lastServerId).toBe('abc12'); + /** @type {string[]} */ const started = []; peer._socket = { start: (/** @type {string} */ id) => started.push(id), close() {} }; peer._options = {}; From 64292d40ec4c87af3ca925e63b7761128b58ea12 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 09:56:57 +0200 Subject: [PATCH 26/29] CLAUDE.md: peer.id survives a signaling drop; run two-peer suites with E2E_CLOUD_PLUGIN for the deployed condition Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE-index.md | 539 ++++++++++++++++++++++++------------------------ CLAUDE.md | 14 ++ 2 files changed, 284 insertions(+), 269 deletions(-) diff --git a/CLAUDE-index.md b/CLAUDE-index.md index 830b840ba..327550803 100644 --- a/CLAUDE-index.md +++ b/CLAUDE-index.md @@ -2,7 +2,7 @@ Generated by `node scripts/claude-index.cjs` (never edit; on a merge conflict, run it again). Grep this file for a word, then read CLAUDE.md from that line (`Read CLAUDE.md offset= limit=60`). -7068 lines indexed. +7082 lines indexed. 1 # CLAUDE.md — theprototype.app @@ -363,274 +363,275 @@ Grep this file for a word, then read CLAUDE.md from that line (`Read CLAUDE.md o 4233 - **flowbite `Button disabled` styling can go stale until the component 4240 - #14 file-shape traps 4261 - Connection "connected" state = `$peers.openedPeers`, NOT `userdata.length` - 4266 - A fixed overlay anchored under the Connect pill can't use a `position:fixed` - 4272 - Headless e2e can't reach a signaling server (peer.open stays false, `peer.connect` - 4275 - Svelte 5.5x runes-mode: a MULTI-CODE `svelte-ignore` comment only honors the FIRST - 4281 - Svelte 5 forbids mixing `on:click` and `onclick` **per component** — match the file's - 4288 - a11y warnings count against the baseline too. A `
` you make keyboard-focusable - 4296 - Editing svelte files by exact-match is whitespace-sensitive; the files use TABS. - 4300 - Module-level `store.subscribe(cb)` runs cb SYNCHRONOUSLY at module eval — any - 4310 - svelte-awesome-color-picker 4.x (runes rewrite, #15-C) - 4318 - Toasts (#15-L/P) - 4328 - PWA (#15-N) - 4336 - **A component's scoped `