Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/components/MeasureOverlay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
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:
// they are objects in objectsGroup now, so this draws them from their records and follows
// 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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -159,6 +161,9 @@
/>
</T.Points>
{/each}
{#if $isVRMode}
<VRMeasureLabels {chains} />
{/if}
{#if preview}
<T.Line geometry={preview} renderOrder={997}>
<T.LineDashedMaterial color="#ff6a1a" dashSize={0.08} gapSize={0.06} depthTest={false} transparent opacity={0.85} />
Expand Down
131 changes: 131 additions & 0 deletions src/components/VRMeasureLabels.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script lang="ts">
import * as THREE from 'three'
import { T, useTask, useThrelte } from '@threlte/core'
// @ts-ignore - the Text typing re-exports a const enum that clashes with verbatimModuleSyntax
import { Text } from '@threlte/extras'
import { chainLabels, layoutLabels } from '$lib/measureCore'
import { labelWorldHeight, labelPxMetrics } from '$lib/vrMeasureCore'

// 52 V1: the measure labels in a HEADSET — the desktop's labels are DOM pills (MeasureLabels), which a
// headset cannot see, so here every length / angle / total is a troika text on a dark plate in the scene.
// Each label FACES the viewer and is the same ANGLE tall at any distance (vrMeasureCore.labelWorldHeight),
// so a 3 m wall reads as well from across the room as from beside it; and because every label is then the
// same number of pixels tall, the desktop's own screen-space layout (measureCore.layoutLabels) keeps them
// apart — a crowded chain hides what it cannot place rather than stacking text. Presentation only: the
// chains come from MeasureOverlay (the drawn one + every measurement item), nothing here is replicated.

type Chain = { id: string; points: number[][]; item: boolean }
let { chains }: { chains: Chain[] } = $props()

const { renderer } = useThrelte()

/** the labels of every chain, keyed stably per chain + index (the troika meshes survive a hover move) */
const labels = $derived(chains.flatMap((c) => chainLabels(c.points, c.id).map((l) => ({ ...l, item: c.item }))))

/** one group per label, posed every frame (plain: nothing renders from it) */
const refs: Record<string, any> = {}
/** ONE base material for the numbers: troika derives a per-label material from it (AnnotationPins) */
const textBase = new THREE.MeshBasicMaterial({ depthTest: false, transparent: true })
const H = 1000 // the virtual view the layout runs in (px)
const eye = new THREE.Vector3()
const right = new THREE.Vector3()
const up = new THREE.Vector3()
const quat = new THREE.Quaternion()
const projected = new THREE.Vector3()
const anchor = new THREE.Vector3()
/** plate width in em for a label text (tabular digits ~0.56 em each, plus the pill's padding) */
const plateW = (text: string) => text.length * 0.56 + 0.7

/** the suites' view: what each label is doing this frame */
const debug: { key: string; text: string; hidden: boolean; height: number; distance: number }[] = []
/** and what posing them costs (the 52 frame-time row reads it) */
const cost = { frames: 0, ms: 0 }
if (typeof window !== 'undefined') {
;(window as any).__vrMeasureLabels = debug
;(window as any).__vrMeasureLabelsCost = cost
}

useTask(() => {
const xr = (renderer as any)?.xr
if (!xr?.isPresenting || !labels.length) {
debug.length = 0
return
}
const t0 = performance.now()
const cam: any = xr.getCamera()
const view: any = cam.cameras?.[0] ?? cam
cam.getWorldPosition(eye)
cam.getWorldQuaternion(quat)
right.set(1, 0, 0).applyQuaternion(quat)
up.set(0, 1, 0).applyQuaternion(quat)
const fovDeg = (2 * Math.atan(1 / (view.projectionMatrix.elements[5] || 1)) * 180) / Math.PI
const tanHalf = Math.tan((fovDeg * Math.PI) / 360)
// project every anchor into one virtual view of the left eye, lay them out like the desktop pills
const anchors: any[] = []
const byKey = new Map<string, any>()
for (const l of labels) {
anchor.set(l.point[0], l.point[1], l.point[2])
projected.copy(anchor).project(view)
if (projected.z >= 1 || projected.z <= -1) continue
const a = { key: l.key, kind: l.kind, text: l.text, point: l.point, x: ((projected.x + 1) / 2) * H, y: ((1 - projected.y) / 2) * H }
anchors.push(a)
byKey.set(l.key, a)
}
const placed = layoutLabels(anchors, labelPxMetrics(H, fovDeg))
const seen = new Set<string>()
debug.length = 0
for (const p of placed) {
const g = refs[p.key]
if (!g) continue
seen.add(p.key)
anchor.set(p.point[0], p.point[1], p.point[2])
const distance = Math.max(anchor.distanceTo(eye), 0.05)
// the layout's px move from the anchor, back into metres at that distance
const metresPerPx = (2 * tanHalf * distance) / H
const a = byKey.get(p.key)
const dx = (p.x - a.x) * metresPerPx
const dy = -(p.y - a.y) * metresPerPx
g.position.copy(anchor).addScaledVector(right, dx).addScaledVector(up, dy)
g.quaternion.copy(quat)
const height = labelWorldHeight(distance)
g.scale.setScalar(height)
g.visible = !p.hidden
debug.push({ key: p.key, text: p.text, hidden: !!p.hidden, height, distance })
}
for (const key of Object.keys(refs)) if (!seen.has(key) && refs[key]) refs[key].visible = false
cost.frames++
cost.ms += performance.now() - t0
})
</script>

<!-- tokens-ok-begin: troika text + plates of the headset's measure labels (meshes cannot read CSS var()) -->
{#each labels as l (l.key)}
<T.Group
name="vr-measure-label"
visible={false}
oncreate={(ref: any) => {
refs[l.key] = ref
return () => {
if (refs[l.key] === ref) delete refs[l.key]
}
}}
>
<T.Mesh scale={[plateW(l.text), 1.45, 1]} renderOrder={998}>
<T.PlaneGeometry />
<T.MeshBasicMaterial color="#0b0f17" transparent opacity={0.84} depthTest={false} depthWrite={false} />
</T.Mesh>
<Text
text={l.text}
color={l.kind === 'angle' ? '#cbd5e1' : '#ffffff'}
outlineColor={l.item ? '#0369a1' : '#c2410c'}
outlineWidth={0.06}
fontSize={1}
anchorX="center"
anchorY="middle"
position={[0, 0, 0.02]}
renderOrder={999}
material={textBase}
/>
</T.Group>
{/each}
<!-- tokens-ok-end -->
5 changes: 5 additions & 0 deletions src/lib/debugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 29 additions & 13 deletions src/lib/measure.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. */
Expand Down
68 changes: 68 additions & 0 deletions src/lib/vr/arrangeCore.js
Original file line number Diff line number Diff line change
@@ -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:<axis>:<edge>` · `distribute:<axis>:<mode>` · `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;
}
Loading
Loading