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);
+ });
+});