From 963ee43507fdae34a803c63c4edd0cd335d1357e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 00:05:53 +0300 Subject: [PATCH 1/4] [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 2/4] [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 87b1b80b5d9e17edd2367ed5090679f5582fcb46 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 00:06:54 +0300 Subject: [PATCH 3/4] [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 4/4] [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);