@displayxr/inline3d 1.35.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,466 @@
1
+ // inline3d-cursor-depth.js — the depth-aware cursor's placement maths, dependency-free.
2
+ //
3
+ // The OS cursor is flat: it is drawn at zero disparity, on the glass. Hover it over content that
4
+ // pops OUT of the glass and it is drawn on top of pixels whose disparity says they are in front
5
+ // of it — a depth violation, and it reads as a broken image. The fix is a cursor SPRITE drawn at
6
+ // the depth of the nearest content under it.
7
+ //
8
+ // This is a line-for-line port of the runtime's `u_cursor_depth` (displayxr-runtime ADR-046,
9
+ // extension XR_DXR_cursor_depth), so a web page and a native app place their cursors
10
+ // identically. The test file pins the same numbers as the runtime's C tests.
11
+ //
12
+ // Division of labour, as in the runtime: the PAGE knows its content, so it supplies the nearest
13
+ // content point under the cursor (a raycast, a depth readback, a depth map); this module decides
14
+ // where the sprite goes, how big it is, and how it moves over time. ./three's `DepthCursor` wires
15
+ // both for a three.js scene. Nothing here runs unless a page calls it — opt-in, zero cost
16
+ // otherwise.
17
+ //
18
+ // The geometry is solved from the VIEWS the page is about to render (projection + transform
19
+ // matrices, column-major, as an XRView carries them), never from rig internals, so it is
20
+ // correct for display rigs, camera rigs, any scale, and any view count >= 2:
21
+ //
22
+ // - S, the canvas point under the cursor, is where the two outermost view rays through (u, v)
23
+ // meet (every off-axis frustum frames the same canvas);
24
+ // - E, the cyclopean eye, is the midpoint of those two views;
25
+ // - a point's depth is t = its distance in front of E along the display normal over S's
26
+ // (t = 1 on the canvas, t < 1 in front of it);
27
+ // - the policy works in DISPARITY d = 1 - 1/t, in eye-baseline units (on-screen disparity =
28
+ // baseline × d): 0 on the canvas, < 0 in front. The eye compares disparities, so the margin,
29
+ // the clamp and the slew rates live there;
30
+ // - WHERE along that depth the sprite goes is the anchor mode (CURSOR_ANCHOR_MODES):
31
+ // 'screen' — C = E + t (S - E), on the cyclopean ray: exactly over the pointer from where the
32
+ // viewer is, but head-motion invariant — disparity WITHOUT motion parallax, which
33
+ // on a head-tracked display reads as "at the glass";
34
+ // 'world' — C = S - f (1 - t) eyeToCanvas, straight in front of the pointer's canvas point:
35
+ // parallaxes like content, drifts ~mm off the pointer from an off-axis viewer;
36
+ // 'hybrid' (default) — 'screen' while the pointer moves (exact aim), and world-FIXED while it
37
+ // is still (correct parallax): the foot F of the last moving placement on the glass
38
+ // is kept, and C = F - f (1 - t) eyeToCanvas. Continuous at the stop frame.
39
+ // The height is scaled by t either way, so the apparent size is constant.
40
+
41
+ import { CURSOR_ANCHOR_MODES } from './inline3d-cursor-option.js';
42
+
43
+ export { CURSOR_ANCHOR_MODES, CURSOR_OPTION_KEYS, resolveCursorOption } from './inline3d-cursor-option.js';
44
+
45
+ /** Default sprite height, as a fraction of the canvas height. */
46
+ export const CURSOR_DEFAULT_HEIGHT = 0.03;
47
+
48
+ /**
49
+ * The runtime's defaults (u_cursor_depth_tuning_defaults), in the same units.
50
+ * Disparities are in eye-baseline units; times in seconds.
51
+ */
52
+ export const CURSOR_DEFAULT_TUNING = Object.freeze({
53
+ /**
54
+ * How far in front of the content the cursor floats. 0.005 baseline is ~1.5 mm at a 60 cm
55
+ * viewing distance: it RESTS on what it hovers. (1.36.0 shipped 0.03, ~1–2 cm, which a tester
56
+ * on a panel found floated visibly off shallow content.)
57
+ */
58
+ margin: 0.005,
59
+ /** Comfort clamp: t >= 0.625, never more than ~3/8 of the way to the eye. */
60
+ minDisparity: -0.6,
61
+ maxDisparity: 0.6,
62
+ /** Rising toward the viewer is fast — never lag behind content that comes forward. */
63
+ riseTau: 0.03,
64
+ /** Sinking away is slow — no flicker as the footprint crosses an edge. */
65
+ sinkTau: 0.25,
66
+ /** A gap longer than this re-primes (snaps) the filter. */
67
+ stale: 0.5,
68
+ });
69
+
70
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
71
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
72
+ const addScaled = (a, b, s) => [a[0] + b[0] * s, a[1] + b[1] * s, a[2] + b[2] * s];
73
+ const finite3 = (a) => Number.isFinite(a[0]) && Number.isFinite(a[1]) && Number.isFinite(a[2]);
74
+ const rotate = (m, v) => [
75
+ m[0] * v[0] + m[4] * v[1] + m[8] * v[2],
76
+ m[1] * v[0] + m[5] * v[1] + m[9] * v[2],
77
+ m[2] * v[0] + m[6] * v[1] + m[10] * v[2],
78
+ ];
79
+
80
+ /**
81
+ * The ray from a view through canvas point (u, v) — origin and (unnormalised) direction in the
82
+ * space of the view's transform. (u, v) is canvas-normalised: [0,1]², origin top-left, v down.
83
+ *
84
+ * Use this for the page's own hit test, so the rays it casts are exactly the rays the placement
85
+ * is solved from.
86
+ *
87
+ * @param {{projectionMatrix: ArrayLike<number>, transformMatrix: ArrayLike<number>}} view
88
+ * @param {number} u
89
+ * @param {number} v
90
+ * @returns {{origin: number[], direction: number[]}}
91
+ */
92
+ export function cursorViewRay(view, u, v) {
93
+ const p = view.projectionMatrix;
94
+ const m = view.transformMatrix;
95
+ // Any perspective projection maps view-space (X, Y, Z) to NDC x = (a X + c Z) / -Z, so the
96
+ // view-space direction through NDC (x, y) is ((x + c) / a, (y + d) / b, -1).
97
+ const x = 2 * u - 1;
98
+ const y = 1 - 2 * v;
99
+ const d = [(x + p[8]) / p[0], (y + p[9]) / p[5], -1];
100
+ return { origin: [m[12], m[13], m[14]], direction: rotate(m, d) };
101
+ }
102
+
103
+ /**
104
+ * Solve the cursor's line of sight from two views (normally the first and the last).
105
+ * @returns {object|null} the geometry, or null when degenerate — coincident views (2D), parallel
106
+ * rays, (u, v) off the canvas, non-finite input.
107
+ */
108
+ export function solveCursorGeometry(viewA, viewB, u, v) {
109
+ if (!(u >= 0 && u <= 1 && v >= 0 && v <= 1)) return null;
110
+ const ra = cursorViewRay(viewA, u, v);
111
+ const rb = cursorViewRay(viewB, u, v);
112
+ const p1 = ra.origin, d1 = ra.direction, p2 = rb.origin, d2 = rb.direction;
113
+ if (!finite3(p1) || !finite3(p2) || !finite3(d1) || !finite3(d2)) return null;
114
+
115
+ // Closest points of p1 + s d1 and p2 + r d2; the midpoint absorbs float noise.
116
+ const w0 = sub(p1, p2);
117
+ const aa = dot(d1, d1), bb = dot(d1, d2), cc = dot(d2, d2);
118
+ const dd = dot(d1, w0), ee = dot(d2, w0);
119
+ const den = aa * cc - bb * bb;
120
+ if (dot(w0, w0) <= 0 || !(den > 1e-12 * aa * cc)) return null;
121
+ const s = (bb * ee - cc * dd) / den;
122
+ const r = (aa * ee - bb * dd) / den;
123
+ if (!(s > 0 && r > 0)) return null;
124
+ const q1 = addScaled(p1, d1, s), q2 = addScaled(p2, d2, r);
125
+ const canvasPoint = [(q1[0] + q2[0]) / 2, (q1[1] + q2[1]) / 2, (q1[2] + q2[2]) / 2];
126
+ const eye = [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2];
127
+
128
+ const ma = viewA.transformMatrix;
129
+ const fwd = rotate(ma, [0, 0, -1]);
130
+ const fl = Math.hypot(fwd[0], fwd[1], fwd[2]);
131
+ const forward = [fwd[0] / fl, fwd[1] / fl, fwd[2] / fl];
132
+ const eyeToCanvas = dot(sub(canvasPoint, eye), forward);
133
+ // Canvas height from view A's frustum: tan(up) - tan(down) = 2 / b.
134
+ const canvasHeight = dot(sub(canvasPoint, p1), forward) * (2 / viewA.projectionMatrix[5]);
135
+ if (!(eyeToCanvas > 0) || !(canvasHeight > 0) || !Number.isFinite(canvasHeight)) return null;
136
+
137
+ return { eye, canvasPoint, forward, eyeToCanvas, canvasHeight, basis: orthonormalBasis(ma) };
138
+ }
139
+
140
+ /** The view's rotation as unit X/Y/Z columns (a transform with parent scale still works). */
141
+ function orthonormalBasis(m) {
142
+ const col = (i) => {
143
+ const c = [m[i], m[i + 1], m[i + 2]];
144
+ const l = Math.hypot(c[0], c[1], c[2]) || 1;
145
+ return [c[0] / l, c[1] / l, c[2] / l];
146
+ };
147
+ return { x: col(0), y: col(4), z: col(8) };
148
+ }
149
+
150
+ /**
151
+ * Disparity of a point (eye-baseline units; 0 on the canvas, < 0 in front).
152
+ * @returns {number|null} null when the point is not in front of the eye.
153
+ */
154
+ export function cursorPointDisparity(g, point) {
155
+ if (!finite3(point)) return null;
156
+ const t = dot(sub(point, g.eye), g.forward) / g.eyeToCanvas;
157
+ if (!(t > 0) || !Number.isFinite(t)) return null;
158
+ return 1 - 1 / t;
159
+ }
160
+
161
+ /** Target disparity: content minus the margin, clamped; 0 (the canvas) with nothing under it. */
162
+ export function cursorTarget(tuning, hasContent, contentDisparity) {
163
+ if (!hasContent || !Number.isFinite(contentDisparity)) return 0;
164
+ return Math.min(tuning.maxDisparity, Math.max(tuning.minDisparity, contentDisparity - tuning.margin));
165
+ }
166
+
167
+ /**
168
+ * Advance the filter toward `target`. `filter` is a plain object, `{}` to start (the first step
169
+ * snaps). A call at a time not after the previous one does not advance it.
170
+ * @param {number} nowSec seconds (e.g. rAF timestamp / 1000).
171
+ */
172
+ export function cursorFilterStep(filter, tuning, target, nowSec) {
173
+ if (!filter.primed || nowSec < filter.last || nowSec - filter.last > tuning.stale) {
174
+ filter.primed = true;
175
+ filter.disparity = target;
176
+ filter.last = nowSec;
177
+ return target;
178
+ }
179
+ if (nowSec === filter.last) return filter.disparity;
180
+ const dt = nowSec - filter.last;
181
+ const tau = target < filter.disparity ? tuning.riseTau : tuning.sinkTau;
182
+ const alpha = tau > 0 ? 1 - Math.exp(-dt / tau) : 1;
183
+ filter.disparity += alpha * (target - filter.disparity);
184
+ filter.last = nowSec;
185
+ return filter.disparity;
186
+ }
187
+
188
+ /** Where to draw a sprite at `disparity`, and how tall, for `heightFraction` of the canvas. */
189
+ export function placeCursor(g, disparity, heightFraction) {
190
+ const t = 1 / (1 - disparity);
191
+ const hf = heightFraction > 0 && Number.isFinite(heightFraction) ? heightFraction : CURSOR_DEFAULT_HEIGHT;
192
+ return { position: addScaled(g.eye, sub(g.canvasPoint, g.eye), t), height: hf * g.canvasHeight * t };
193
+ }
194
+
195
+
196
+ /** A pointer moving less than this (canvas-normalised) between frames counts as still. */
197
+ const POINTER_STILL_EPS = 1e-4;
198
+
199
+ /**
200
+ * The whole pipeline with its state: one per cursor.
201
+ *
202
+ * const placer = new CursorDepthPlacer();
203
+ * // per frame, with the views you are about to render:
204
+ * const p = placer.update(views, { u, v, nearestPoint, cursorHeight: 0.03 }, now / 1000);
205
+ * if (p.active) drawSprite(p.position, p.basis, p.height);
206
+ *
207
+ * `views` are `{projectionMatrix, transformMatrix}` (or XRViews — `transform.matrix` is read
208
+ * when `transformMatrix` is absent). `nearestPoint` is the content point under the cursor
209
+ * FOOTPRINT nearest the viewer, in the views' space, or null for "nothing under it".
210
+ */
211
+ export class CursorDepthPlacer {
212
+ /**
213
+ * @param {object} [tuning=CURSOR_DEFAULT_TUNING] placement policy (see CursorTuning).
214
+ * @param {{anchor?: 'hybrid'|'screen'|'world'}} [opts]
215
+ */
216
+ constructor(tuning = CURSOR_DEFAULT_TUNING, { anchor = 'hybrid' } = {}) {
217
+ if (!CURSOR_ANCHOR_MODES.includes(anchor)) {
218
+ throw new Error(`@displayxr/inline3d: cursor anchor "${anchor}" — expected ${CURSOR_ANCHOR_MODES.join(', ')}.`);
219
+ }
220
+ this.tuning = tuning;
221
+ this.anchor = anchor;
222
+ this.filter = {};
223
+ this._foot = null; // hybrid: the world-fixed foot on the glass while the pointer is still
224
+ this._last = null; // [u, v] of the previous active frame
225
+ }
226
+
227
+ update(views, hint, nowSec) {
228
+ const inactive = { active: false, position: null, basis: null, height: 0, disparity: 0, targetDisparity: 0 };
229
+ if (!views || views.length < 2 || !hint) {
230
+ this._foot = null;
231
+ this._last = null;
232
+ return inactive;
233
+ }
234
+ const a = asView(views[0]);
235
+ const b = asView(views[views.length - 1]);
236
+ const g = solveCursorGeometry(a, b, hint.u, hint.v);
237
+ if (!g) {
238
+ this._foot = null;
239
+ this._last = null;
240
+ return inactive;
241
+ }
242
+ const content = hint.nearestPoint ? cursorPointDisparity(g, hint.nearestPoint) : null;
243
+ const target = cursorTarget(this.tuning, content !== null, content);
244
+ const f = this.filter;
245
+ const reprimed = !f.primed || nowSec < f.last || nowSec - f.last > this.tuning.stale;
246
+ const disparity = cursorFilterStep(f, this.tuning, target, nowSec);
247
+ const { position: onRay, height } = placeCursor(g, disparity, hint.cursorHeight);
248
+ const t = 1 / (1 - disparity);
249
+ const out = (-(1 - t) * g.eyeToCanvas); // signed offset along the normal (toward the viewer)
250
+ const fw = g.forward;
251
+ let position = onRay;
252
+ let anchored = false;
253
+ if (this.anchor === 'world') {
254
+ position = addScaled(g.canvasPoint, fw, out);
255
+ } else if (this.anchor === 'hybrid') {
256
+ const last = this._last;
257
+ const moved =
258
+ reprimed || !this._foot || !last || Math.abs(hint.u - last[0]) > POINTER_STILL_EPS || Math.abs(hint.v - last[1]) > POINTER_STILL_EPS;
259
+ if (moved) {
260
+ // The foot of the on-ray placement on the glass, along the normal: C = F - f·(1-t)·eyeToCanvas.
261
+ this._foot = addScaled(onRay, fw, -out);
262
+ } else {
263
+ position = addScaled(this._foot, fw, out);
264
+ anchored = true;
265
+ }
266
+ }
267
+ this._last = [hint.u, hint.v];
268
+ return { active: true, position, basis: g.basis, height, disparity, targetDisparity: target, anchored, geometry: g };
269
+ }
270
+ }
271
+
272
+ function asView(v) {
273
+ return v.transformMatrix ? v : { projectionMatrix: v.projectionMatrix, transformMatrix: v.transform.matrix };
274
+ }
275
+
276
+ /**
277
+ * The pointer half of a depth cursor, shared by every backend: tracks the pointer over the
278
+ * canvas (canvas-normalised, v down) and hides the CSS cursor exactly while a sprite replaces it.
279
+ * Constructing one adds two listeners; nothing else runs until a backend asks for `uv`.
280
+ */
281
+ export class CursorPointer {
282
+ /**
283
+ * @param {HTMLElement} canvas
284
+ * @param {{scope?: 'canvas'|'window'}} [opts] 'canvas' (default): the pointer counts only while
285
+ * it is over the canvas element itself. 'window': while it is inside the canvas's BOX,
286
+ * even over DOM layered on top of it (overlay buttons, labels) — and the CSS cursor is then
287
+ * hidden page-wide while the sprite shows, since the overlay has its own cursor.
288
+ */
289
+ constructor(canvas, { scope = 'canvas' } = {}) {
290
+ if (scope !== 'canvas' && scope !== 'window') {
291
+ throw new Error(`@displayxr/inline3d: cursor pointerScope "${scope}" — expected 'canvas' or 'window'.`);
292
+ }
293
+ this.canvas = canvas;
294
+ this.scope = scope;
295
+ /** `[u, v]` while the pointer is over the canvas, else null. */
296
+ this.uv = null;
297
+ this._hidden = false;
298
+ this._prev = '';
299
+ this._onMove = (e) => {
300
+ const r = canvas.getBoundingClientRect();
301
+ if (!(r.width > 0 && r.height > 0)) return void (this.uv = null);
302
+ const u = (e.clientX - r.left) / r.width;
303
+ const v = (e.clientY - r.top) / r.height;
304
+ this.uv = u >= 0 && u <= 1 && v >= 0 && v <= 1 ? [u, v] : null;
305
+ };
306
+ this._onLeave = () => {
307
+ this.uv = null;
308
+ };
309
+ this._target = scope === 'window' ? globalThis : canvas;
310
+ this._target.addEventListener('pointermove', this._onMove);
311
+ if (scope === 'window') {
312
+ // Leaving the window (relatedTarget null) or losing focus mid-hover: nothing to hover.
313
+ this._onOut = (e) => {
314
+ if (!e.relatedTarget) this.uv = null;
315
+ };
316
+ globalThis.addEventListener('pointerout', this._onOut);
317
+ globalThis.addEventListener('blur', this._onLeave);
318
+ } else {
319
+ canvas.addEventListener('pointerleave', this._onLeave);
320
+ }
321
+ }
322
+
323
+ /** Set the pointer directly (canvas-normalised, v down), or null — for scripted input. */
324
+ set(u, v) {
325
+ this.uv = u === null || u === undefined ? null : [u, v];
326
+ }
327
+
328
+ /** The canvas aspect (width / height), for a round footprint. */
329
+ aspect() {
330
+ const r = this.canvas.getBoundingClientRect();
331
+ return r.height > 0 ? r.width / r.height : 1;
332
+ }
333
+
334
+ /** Hide (true) or restore (false) the CSS cursor; idempotent. */
335
+ hideCss(hide) {
336
+ if (hide === this._hidden) return;
337
+ if (this.scope === 'window') {
338
+ pageCursorHidden(hide);
339
+ } else if (hide) {
340
+ this._prev = this.canvas.style.cursor || '';
341
+ this.canvas.style.cursor = 'none';
342
+ } else {
343
+ this.canvas.style.cursor = this._prev;
344
+ }
345
+ this._hidden = hide;
346
+ }
347
+
348
+ dispose() {
349
+ this._target.removeEventListener('pointermove', this._onMove);
350
+ if (this.scope === 'window') {
351
+ globalThis.removeEventListener('pointerout', this._onOut);
352
+ globalThis.removeEventListener('blur', this._onLeave);
353
+ } else {
354
+ this.canvas.removeEventListener('pointerleave', this._onLeave);
355
+ }
356
+ this.hideCss(false);
357
+ }
358
+ }
359
+
360
+ // pointerScope 'window': the overlay DOM has its own cursor, so hiding it on the canvas alone is
361
+ // not enough. A class on <html> plus one injected rule hides it everywhere while any depth cursor
362
+ // shows; a count keeps two tiles from un-hiding each other.
363
+ let _pageHides = 0;
364
+ function pageCursorHidden(hide) {
365
+ const doc = globalThis.document;
366
+ if (!doc || !doc.documentElement) return;
367
+ if (hide && !doc.getElementById('inline3d-cursor-style')) {
368
+ const st = doc.createElement('style');
369
+ st.id = 'inline3d-cursor-style';
370
+ st.textContent = 'html.inline3d-cursor-hidden, html.inline3d-cursor-hidden * { cursor: none !important; }';
371
+ (doc.head || doc.documentElement).appendChild(st);
372
+ }
373
+ _pageHides = Math.max(0, _pageHides + (hide ? 1 : -1));
374
+ doc.documentElement.classList.toggle('inline3d-cursor-hidden', _pageHides > 0);
375
+ }
376
+
377
+ /**
378
+ * The footprint ring every backend samples: the hotspot plus 8 points on a circle of radius
379
+ * 0.75 × the sprite height (canvas heights), so the sprite and a margin are covered.
380
+ * @returns {Array<[number, number]>} canvas-normalised points, the hotspot first.
381
+ */
382
+ export function cursorFootprint(u, v, height, aspect) {
383
+ const r = 0.75 * height;
384
+ const out = [[u, v]];
385
+ for (let k = 0; k < 8; k++) {
386
+ const a = (k * Math.PI) / 4;
387
+ out.push([u + (r * Math.cos(a)) / aspect, v + r * Math.sin(a)]);
388
+ }
389
+ return out;
390
+ }
391
+
392
+ /**
393
+ * The crosshair every backend draws: 8 segments in the sprite's own XY plane, unit = sprite
394
+ * height (a "+" with a gap and a small square at the hotspot), as [x0, y0, x1, y1] tuples.
395
+ */
396
+ export const CURSOR_CROSSHAIR = Object.freeze([
397
+ [-0.5, 0, -0.15, 0], [0.15, 0, 0.5, 0], [0, -0.5, 0, -0.15], [0, 0.15, 0, 0.5],
398
+ [-0.15, -0.15, 0.15, -0.15], [0.15, -0.15, 0.15, 0.15], [0.15, 0.15, -0.15, 0.15], [-0.15, 0.15, -0.15, -0.15],
399
+ ]);
400
+
401
+ /** Fill and outline stroke widths, in sprite heights; and their colours (RGBA, 0..1). */
402
+ const STROKE_FILL = 0.07;
403
+ const STROKE_OUTLINE = 0.15;
404
+ export const CURSOR_FILL_COLOR = Object.freeze([1, 0.84, 0.1, 1]);
405
+ export const CURSOR_OUTLINE_COLOR = Object.freeze([0.04, 0.04, 0.06, 0.85]);
406
+
407
+ /**
408
+ * The crosshair as FILLED strokes in its own XY plane (unit = sprite height): a dark outline
409
+ * pass first, the coloured fill over it — the order a depth-test-off draw needs. A 1 px line
410
+ * reads too thin through a lenticular panel. Triangles, non-indexed.
411
+ * @returns {{positions: Float32Array, colors: Float32Array, count: number}}
412
+ */
413
+ export function cursorCrosshairMesh(fill = CURSOR_FILL_COLOR, outline = CURSOR_OUTLINE_COLOR) {
414
+ const pos = [];
415
+ const col = [];
416
+ const quad = ([x0, y0, x1, y1], w, ext, c) => {
417
+ const dx = x1 - x0, dy = y1 - y0;
418
+ const l = Math.hypot(dx, dy) || 1;
419
+ const ux = dx / l, uy = dy / l; // along
420
+ const nx = -uy * (w / 2), ny = ux * (w / 2); // across
421
+ const ax = x0 - ux * ext, ay = y0 - uy * ext, bx = x1 + ux * ext, by = y1 + uy * ext;
422
+ const v = [[ax - nx, ay - ny], [bx - nx, by - ny], [bx + nx, by + ny], [ax - nx, ay - ny], [bx + nx, by + ny], [ax + nx, ay + ny]];
423
+ for (const [x, y] of v) {
424
+ pos.push(x, y, 0);
425
+ col.push(c[0], c[1], c[2], c[3]);
426
+ }
427
+ };
428
+ for (const seg of CURSOR_CROSSHAIR) quad(seg, STROKE_OUTLINE, (STROKE_OUTLINE - STROKE_FILL) / 2, outline);
429
+ for (const seg of CURSOR_CROSSHAIR) quad(seg, STROKE_FILL, 0, fill);
430
+ return { positions: new Float32Array(pos), colors: new Float32Array(col), count: pos.length / 3 };
431
+ }
432
+
433
+ /**
434
+ * A column-major model matrix placing the unit crosshair for a placement: the sprite's basis
435
+ * scaled by its height, at its position.
436
+ */
437
+ export function cursorModelMatrix(placement, out = new Array(16)) {
438
+ const { x, y, z } = placement.basis;
439
+ const h = placement.height;
440
+ const e = placement.position;
441
+ out[0] = x[0] * h; out[1] = x[1] * h; out[2] = x[2] * h; out[3] = 0;
442
+ out[4] = y[0] * h; out[5] = y[1] * h; out[6] = y[2] * h; out[7] = 0;
443
+ out[8] = z[0] * h; out[9] = z[1] * h; out[10] = z[2] * h; out[11] = 0;
444
+ out[12] = e[0]; out[13] = e[1]; out[14] = e[2]; out[15] = 1;
445
+ return out;
446
+ }
447
+
448
+ /**
449
+ * The crosshair's segment endpoints in the views' space for a placement, as a flat
450
+ * [x, y, z, x, y, z, …] array (16 points) — what a line renderer takes.
451
+ */
452
+ export function cursorCrosshairPoints(placement, out = []) {
453
+ out.length = 0;
454
+ const { position: p, basis: b, height: h } = placement;
455
+ for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) {
456
+ for (const [x, y] of [[x0, y0], [x1, y1]]) {
457
+ out.push(
458
+ p[0] + (b.x[0] * x + b.y[0] * y) * h,
459
+ p[1] + (b.x[1] * x + b.y[1] * y) * h,
460
+ p[2] + (b.x[2] * x + b.y[2] * y) * h,
461
+ );
462
+ }
463
+ }
464
+ return out;
465
+ }
466
+
@@ -0,0 +1,38 @@
1
+ // inline3d-cursor-option.js — `cursor: 'depth' | { … }` as ./model and ./splat accept it.
2
+ //
3
+ // Its own tiny, dependency-free module on purpose: the PlayCanvas adapter validates the option at
4
+ // the call (statically), but must not pull the cursor itself into a page that never asks for it
5
+ // (the cursor modules load only on opt-in). ./cursor-depth re-exports everything here.
6
+
7
+ /** Anchor modes — see ./inline3d-cursor-depth.js. */
8
+ export const CURSOR_ANCHOR_MODES = Object.freeze(['hybrid', 'screen', 'world']);
9
+
10
+ /** The keys `cursor: { … }` accepts on ./model and ./splat. */
11
+ export const CURSOR_OPTION_KEYS = Object.freeze(['height', 'margin', 'anchor', 'pointerScope']);
12
+
13
+ /**
14
+ * `cursor` as ./model and ./splat accept it: `'depth'`, or an options object (which also means
15
+ * depth). Anything falsy is OFF — the zero-cost default. Validated here, at the call.
16
+ * @returns {null | {height?: number, margin?: number, anchor?: string, pointerScope?: string}}
17
+ */
18
+ export function resolveCursorOption(opt, who = '@displayxr/inline3d') {
19
+ if (opt === undefined || opt === null || opt === false) return null;
20
+ if (opt === 'depth' || opt === true) return {};
21
+ if (typeof opt !== 'object') {
22
+ throw new Error(`${who}: cursor ${JSON.stringify(opt)} — expected 'depth' or { height, margin, anchor, pointerScope }.`);
23
+ }
24
+ const unknown = Object.keys(opt).filter((k) => !CURSOR_OPTION_KEYS.includes(k));
25
+ if (unknown.length) throw new Error(`${who}: cursor — unknown option(s) ${unknown.join(', ')}; expected ${CURSOR_OPTION_KEYS.join(', ')}.`);
26
+ if (opt.anchor !== undefined && !CURSOR_ANCHOR_MODES.includes(opt.anchor)) {
27
+ throw new Error(`${who}: cursor.anchor "${opt.anchor}" — expected ${CURSOR_ANCHOR_MODES.join(', ')}.`);
28
+ }
29
+ if (opt.pointerScope !== undefined && opt.pointerScope !== 'canvas' && opt.pointerScope !== 'window') {
30
+ throw new Error(`${who}: cursor.pointerScope "${opt.pointerScope}" — expected 'canvas' or 'window'.`);
31
+ }
32
+ for (const k of ['height', 'margin']) {
33
+ if (opt[k] !== undefined && !(typeof opt[k] === 'number' && opt[k] > 0 && Number.isFinite(opt[k]))) {
34
+ throw new Error(`${who}: cursor.${k} must be a positive number.`);
35
+ }
36
+ }
37
+ return { ...opt };
38
+ }
@@ -467,6 +467,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
467
467
  farClip: opts.farClip,
468
468
  sky: false,
469
469
  viewerEase: opts.viewerEase,
470
+ cursor: opts.cursor,
470
471
  // Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
471
472
  toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
472
473
  });
@@ -39,7 +39,8 @@
39
39
  // docs/authoring-inline-3d.md#compressed-gltf.
40
40
 
41
41
  import * as THREE from 'three';
42
- import { EyeCamera, EdgeFeather } from './inline3d-three.js';
42
+ import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest } from './inline3d-three.js';
43
+ import { resolveCursorOption } from './inline3d-cursor-depth.js';
43
44
  import { SceneViewer } from './inline3d-viewer.js';
44
45
  // Procedural — built in memory, no asset to serve. Imported eagerly rather than lazily because it
45
46
  // is the default lighting path, so deferring it would only add a frame of unlit content.
@@ -382,6 +383,8 @@ export function addModel(wall, canvas, src, opts = {}) {
382
383
  logTag: '[inline3d/model]',
383
384
  viewerEase: opts.viewerEase,
384
385
  }).useEyeCamera(EyeCamera, EdgeFeather);
386
+ const cursorOpts = resolveCursorOption(opts.cursor, '@displayxr/inline3d/model');
387
+ if (cursorOpts) viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]), cursorOpts);
385
388
 
386
389
  if (envMap) viewer.scene.environment = envMap;
387
390
  else if (environment === 'room') addRoomEnvironment(viewer);
@@ -38,6 +38,9 @@ export {
38
38
  Quat,
39
39
  LAYERID_SKYBOX,
40
40
  LAYERID_UI,
41
+ LAYERID_IMMEDIATE,
42
+ // cursor: 'depth' (./inline3d-cursor-depth-playcanvas.js, loaded only on opt-in)
43
+ Picker,
41
44
  // materials / meshes (the edge feather)
42
45
  Mesh,
43
46
  MeshInstance,
@@ -51,6 +54,7 @@ export {
51
54
  CULLFACE_NONE,
52
55
  SEMANTIC_POSITION,
53
56
  SEMANTIC_TEXCOORD0,
57
+ SEMANTIC_COLOR,
54
58
  ShaderChunks,
55
59
  SHADERLANGUAGE_GLSL,
56
60
  CHUNKAPI_2_8,
@@ -120,6 +120,7 @@ import {
120
120
  coverageExponent,
121
121
  declareViewRig,
122
122
  } from './inline3d-splat-shared.js';
123
+ import { resolveCursorOption } from './inline3d-cursor-option.js';
123
124
 
124
125
  /**
125
126
  * The component systems the tile's `AppBase` registers. Camera + GSplat draw the splat; Render,
@@ -823,8 +824,14 @@ export class PlayCanvasSplatViewer {
823
824
  pageCamera = false,
824
825
  toneMapping = 'none',
825
826
  viewerEase,
827
+ cursor = null,
826
828
  } = opts;
827
829
  this.canvas = canvas;
830
+ // `cursor: 'depth'` (ADR-046): the depth-aware cursor. OPT-IN AND FREE OTHERWISE — its module
831
+ // is imported only when asked for (attachEngine), and every per-frame hook is an optional call
832
+ // on `cursorDepth`, which stays null without it.
833
+ this._cursorOpt = resolveCursorOption(cursor, opts.logTag || '[inline3d/splat]'); // null = off
834
+ this.cursorDepth = null;
828
835
  // The tracking-acquisition ease (./inline3d-viewer-ease.js): undefined = the session's
829
836
  // createInline3D({ viewerEase }) default. Built on the first 3D frame, which knows the session.
830
837
  this._viewerEaseOpt = viewerEase;
@@ -1253,6 +1260,7 @@ export class PlayCanvasSplatViewer {
1253
1260
  this._monoRaf = requestAnimationFrame(loop);
1254
1261
  this._beforeFrame?.(null);
1255
1262
  if (this._disposed || this._mode !== 'mono') return; // the callback removed or re-wove us
1263
+ this.cursorDepth?.inactive(); // 2D: the normal cursor
1256
1264
  this._tick();
1257
1265
  this._drawMono();
1258
1266
  };
@@ -1283,6 +1291,8 @@ export class PlayCanvasSplatViewer {
1283
1291
  this._live = null;
1284
1292
  this._videoPlane?.destroy();
1285
1293
  this._videoPlane = null;
1294
+ this.cursorDepth?.dispose();
1295
+ this.cursorDepth = null;
1286
1296
  this.layerRigs = null; // its cameras go with the app
1287
1297
  try {
1288
1298
  this.app?.destroy();
@@ -1405,6 +1415,14 @@ export class PlayCanvasSplatViewer {
1405
1415
  if (this.featherPx > 0) this._makeFeather();
1406
1416
  this._applyTransform();
1407
1417
  app.start();
1418
+ if (this._cursorOpt) {
1419
+ const cursorOpts = this._cursorOpt;
1420
+ import('./inline3d-cursor-depth-playcanvas.js')
1421
+ .then(({ PlayCanvasDepthCursor }) => {
1422
+ if (!this._disposed) this.cursorDepth = new PlayCanvasDepthCursor(pc, this, { ...cursorOpts, canvas: this.canvas });
1423
+ })
1424
+ .catch((err) => console.warn(`${this.logTag || '[inline3d/splat]'} cursor: 'depth' could not load; the normal cursor stays.`, err));
1425
+ }
1408
1426
  return app;
1409
1427
  }
1410
1428
 
@@ -2103,6 +2121,8 @@ export class PlayCanvasSplatViewer {
2103
2121
  }
2104
2122
  // handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
2105
2123
  this._videoPlane?.beforeDraw(entries, rect);
2124
+ // cursor: 'depth' — place the sprite and queue its lines for the tick below (no-op unless opted in).
2125
+ this.cursorDepth?.frame(entries);
2106
2126
  app.tick(now());
2107
2127
  this._afterTick();
2108
2128
  return true;
@@ -3216,6 +3236,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3216
3236
  farClip: opts.farClip,
3217
3237
  sky: opts.sky,
3218
3238
  viewerEase: opts.viewerEase,
3239
+ cursor: opts.cursor,
3219
3240
  });
3220
3241
 
3221
3242
  let handle = null;
@@ -20,7 +20,8 @@
20
20
 
21
21
  import * as THREE from 'three';
22
22
  import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
23
- import { EyeCamera, EdgeFeather, cameraRigFromCamera } from './inline3d-three.js';
23
+ import { EyeCamera, EdgeFeather, cameraRigFromCamera, DepthCursor, raycastHitTest } from './inline3d-three.js';
24
+ import { resolveCursorOption } from './inline3d-cursor-depth.js';
24
25
  import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
25
26
  import { readSogCamera } from './inline3d-sog.js';
26
27
  import { resolveRevealOption, effectsNotOnSpark } from './inline3d-splat-effects.js';
@@ -248,6 +249,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
248
249
  logTag: '[inline3d/splat]',
249
250
  viewerEase: opts.viewerEase,
250
251
  }).useEyeCamera(EyeCamera, EdgeFeather);
252
+ // A gaussian-splat raycast is ~8 ms a ray on Spark: one ray a frame, cycling the footprint.
253
+ // (engine:'playcanvas' hit-tests with the engine's depth picker instead, which is far cheaper.)
254
+ const cursorOpts = resolveCursorOption(opts.cursor, '@displayxr/inline3d/splat');
255
+ if (cursorOpts) viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]), { ...cursorOpts, raysPerFrame: 1 });
251
256
 
252
257
  // Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
253
258
  // sort against each other — which is what lets a product page mix a captured hero with a