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..1e27eab2c 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -170,6 +170,11 @@ 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')], + ['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/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/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 new file mode 100644 index 000000000..fe75e6961 --- /dev/null +++ b/src/lib/vr/buildRings.js @@ -0,0 +1,171 @@ +// 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, 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'); +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 + 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: 'circle-check', + 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(() => {})); + +/** @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 d4fac95be..493ff5fb9 100644 --- a/src/lib/vr/iconData.js +++ b/src/lib/vr/iconData.js @@ -87,5 +87,11 @@ 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"}]], + '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 + '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 }; 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..c1831d1f2 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, registerArrangeRings } from './vr/buildRings.js'; // 52 V1/V2 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,10 @@ 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/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 new file mode 100644 index 000000000..5bf080ac3 --- /dev/null +++ b/tests/e2e/vr-measure-iwer.test.cjs @@ -0,0 +1,535 @@ +// 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; +// 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); +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 --------------------------------------------------------------------------------- + /** 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'); + 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'); + 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 ----------------------------- + 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, 2.5], { pitch: -0.25 }); + await shot('02-measure-labels-far'); + 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); + 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) -------------------- + 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})`); + 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'); + 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 })); + } + + // ---- 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 ▸'); + 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); + 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 ==='); + 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/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 + }); +}); 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); + }); +});