@displayxr/inline3d 1.35.0 → 1.36.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,311 @@
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
+ // - the sprite goes at C = E + t (S - E) — on the cyclopean ray, so it never slides sideways
31
+ // as it rises — with its height scaled by t so its apparent size is constant.
32
+
33
+ /** Default sprite height, as a fraction of the canvas height. */
34
+ export const CURSOR_DEFAULT_HEIGHT = 0.03;
35
+
36
+ /**
37
+ * The runtime's defaults (u_cursor_depth_tuning_defaults), in the same units.
38
+ * Disparities are in eye-baseline units; times in seconds.
39
+ */
40
+ export const CURSOR_DEFAULT_TUNING = Object.freeze({
41
+ /** How far in front of the content the cursor floats (~2 mm on screen at 65 mm IPD). */
42
+ margin: 0.03,
43
+ /** Comfort clamp: t >= 0.625, never more than ~3/8 of the way to the eye. */
44
+ minDisparity: -0.6,
45
+ maxDisparity: 0.6,
46
+ /** Rising toward the viewer is fast — never lag behind content that comes forward. */
47
+ riseTau: 0.03,
48
+ /** Sinking away is slow — no flicker as the footprint crosses an edge. */
49
+ sinkTau: 0.25,
50
+ /** A gap longer than this re-primes (snaps) the filter. */
51
+ stale: 0.5,
52
+ });
53
+
54
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
55
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
56
+ const addScaled = (a, b, s) => [a[0] + b[0] * s, a[1] + b[1] * s, a[2] + b[2] * s];
57
+ const finite3 = (a) => Number.isFinite(a[0]) && Number.isFinite(a[1]) && Number.isFinite(a[2]);
58
+ const rotate = (m, v) => [
59
+ m[0] * v[0] + m[4] * v[1] + m[8] * v[2],
60
+ m[1] * v[0] + m[5] * v[1] + m[9] * v[2],
61
+ m[2] * v[0] + m[6] * v[1] + m[10] * v[2],
62
+ ];
63
+
64
+ /**
65
+ * The ray from a view through canvas point (u, v) — origin and (unnormalised) direction in the
66
+ * space of the view's transform. (u, v) is canvas-normalised: [0,1]², origin top-left, v down.
67
+ *
68
+ * Use this for the page's own hit test, so the rays it casts are exactly the rays the placement
69
+ * is solved from.
70
+ *
71
+ * @param {{projectionMatrix: ArrayLike<number>, transformMatrix: ArrayLike<number>}} view
72
+ * @param {number} u
73
+ * @param {number} v
74
+ * @returns {{origin: number[], direction: number[]}}
75
+ */
76
+ export function cursorViewRay(view, u, v) {
77
+ const p = view.projectionMatrix;
78
+ const m = view.transformMatrix;
79
+ // Any perspective projection maps view-space (X, Y, Z) to NDC x = (a X + c Z) / -Z, so the
80
+ // view-space direction through NDC (x, y) is ((x + c) / a, (y + d) / b, -1).
81
+ const x = 2 * u - 1;
82
+ const y = 1 - 2 * v;
83
+ const d = [(x + p[8]) / p[0], (y + p[9]) / p[5], -1];
84
+ return { origin: [m[12], m[13], m[14]], direction: rotate(m, d) };
85
+ }
86
+
87
+ /**
88
+ * Solve the cursor's line of sight from two views (normally the first and the last).
89
+ * @returns {object|null} the geometry, or null when degenerate — coincident views (2D), parallel
90
+ * rays, (u, v) off the canvas, non-finite input.
91
+ */
92
+ export function solveCursorGeometry(viewA, viewB, u, v) {
93
+ if (!(u >= 0 && u <= 1 && v >= 0 && v <= 1)) return null;
94
+ const ra = cursorViewRay(viewA, u, v);
95
+ const rb = cursorViewRay(viewB, u, v);
96
+ const p1 = ra.origin, d1 = ra.direction, p2 = rb.origin, d2 = rb.direction;
97
+ if (!finite3(p1) || !finite3(p2) || !finite3(d1) || !finite3(d2)) return null;
98
+
99
+ // Closest points of p1 + s d1 and p2 + r d2; the midpoint absorbs float noise.
100
+ const w0 = sub(p1, p2);
101
+ const aa = dot(d1, d1), bb = dot(d1, d2), cc = dot(d2, d2);
102
+ const dd = dot(d1, w0), ee = dot(d2, w0);
103
+ const den = aa * cc - bb * bb;
104
+ if (dot(w0, w0) <= 0 || !(den > 1e-12 * aa * cc)) return null;
105
+ const s = (bb * ee - cc * dd) / den;
106
+ const r = (aa * ee - bb * dd) / den;
107
+ if (!(s > 0 && r > 0)) return null;
108
+ const q1 = addScaled(p1, d1, s), q2 = addScaled(p2, d2, r);
109
+ const canvasPoint = [(q1[0] + q2[0]) / 2, (q1[1] + q2[1]) / 2, (q1[2] + q2[2]) / 2];
110
+ const eye = [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2];
111
+
112
+ const ma = viewA.transformMatrix;
113
+ const fwd = rotate(ma, [0, 0, -1]);
114
+ const fl = Math.hypot(fwd[0], fwd[1], fwd[2]);
115
+ const forward = [fwd[0] / fl, fwd[1] / fl, fwd[2] / fl];
116
+ const eyeToCanvas = dot(sub(canvasPoint, eye), forward);
117
+ // Canvas height from view A's frustum: tan(up) - tan(down) = 2 / b.
118
+ const canvasHeight = dot(sub(canvasPoint, p1), forward) * (2 / viewA.projectionMatrix[5]);
119
+ if (!(eyeToCanvas > 0) || !(canvasHeight > 0) || !Number.isFinite(canvasHeight)) return null;
120
+
121
+ return { eye, canvasPoint, forward, eyeToCanvas, canvasHeight, basis: orthonormalBasis(ma) };
122
+ }
123
+
124
+ /** The view's rotation as unit X/Y/Z columns (a transform with parent scale still works). */
125
+ function orthonormalBasis(m) {
126
+ const col = (i) => {
127
+ const c = [m[i], m[i + 1], m[i + 2]];
128
+ const l = Math.hypot(c[0], c[1], c[2]) || 1;
129
+ return [c[0] / l, c[1] / l, c[2] / l];
130
+ };
131
+ return { x: col(0), y: col(4), z: col(8) };
132
+ }
133
+
134
+ /**
135
+ * Disparity of a point (eye-baseline units; 0 on the canvas, < 0 in front).
136
+ * @returns {number|null} null when the point is not in front of the eye.
137
+ */
138
+ export function cursorPointDisparity(g, point) {
139
+ if (!finite3(point)) return null;
140
+ const t = dot(sub(point, g.eye), g.forward) / g.eyeToCanvas;
141
+ if (!(t > 0) || !Number.isFinite(t)) return null;
142
+ return 1 - 1 / t;
143
+ }
144
+
145
+ /** Target disparity: content minus the margin, clamped; 0 (the canvas) with nothing under it. */
146
+ export function cursorTarget(tuning, hasContent, contentDisparity) {
147
+ if (!hasContent || !Number.isFinite(contentDisparity)) return 0;
148
+ return Math.min(tuning.maxDisparity, Math.max(tuning.minDisparity, contentDisparity - tuning.margin));
149
+ }
150
+
151
+ /**
152
+ * Advance the filter toward `target`. `filter` is a plain object, `{}` to start (the first step
153
+ * snaps). A call at a time not after the previous one does not advance it.
154
+ * @param {number} nowSec seconds (e.g. rAF timestamp / 1000).
155
+ */
156
+ export function cursorFilterStep(filter, tuning, target, nowSec) {
157
+ if (!filter.primed || nowSec < filter.last || nowSec - filter.last > tuning.stale) {
158
+ filter.primed = true;
159
+ filter.disparity = target;
160
+ filter.last = nowSec;
161
+ return target;
162
+ }
163
+ if (nowSec === filter.last) return filter.disparity;
164
+ const dt = nowSec - filter.last;
165
+ const tau = target < filter.disparity ? tuning.riseTau : tuning.sinkTau;
166
+ const alpha = tau > 0 ? 1 - Math.exp(-dt / tau) : 1;
167
+ filter.disparity += alpha * (target - filter.disparity);
168
+ filter.last = nowSec;
169
+ return filter.disparity;
170
+ }
171
+
172
+ /** Where to draw a sprite at `disparity`, and how tall, for `heightFraction` of the canvas. */
173
+ export function placeCursor(g, disparity, heightFraction) {
174
+ const t = 1 / (1 - disparity);
175
+ const hf = heightFraction > 0 && Number.isFinite(heightFraction) ? heightFraction : CURSOR_DEFAULT_HEIGHT;
176
+ return { position: addScaled(g.eye, sub(g.canvasPoint, g.eye), t), height: hf * g.canvasHeight * t };
177
+ }
178
+
179
+ /**
180
+ * The whole pipeline with its state: one per cursor.
181
+ *
182
+ * const placer = new CursorDepthPlacer();
183
+ * // per frame, with the views you are about to render:
184
+ * const p = placer.update(views, { u, v, nearestPoint, cursorHeight: 0.03 }, now / 1000);
185
+ * if (p.active) drawSprite(p.position, p.basis, p.height);
186
+ *
187
+ * `views` are `{projectionMatrix, transformMatrix}` (or XRViews — `transform.matrix` is read
188
+ * when `transformMatrix` is absent). `nearestPoint` is the content point under the cursor
189
+ * FOOTPRINT nearest the viewer, in the views' space, or null for "nothing under it".
190
+ */
191
+ export class CursorDepthPlacer {
192
+ constructor(tuning = CURSOR_DEFAULT_TUNING) {
193
+ this.tuning = tuning;
194
+ this.filter = {};
195
+ }
196
+
197
+ update(views, hint, nowSec) {
198
+ const inactive = { active: false, position: null, basis: null, height: 0, disparity: 0, targetDisparity: 0 };
199
+ if (!views || views.length < 2 || !hint) return inactive;
200
+ const a = asView(views[0]);
201
+ const b = asView(views[views.length - 1]);
202
+ const g = solveCursorGeometry(a, b, hint.u, hint.v);
203
+ if (!g) return inactive;
204
+ const content = hint.nearestPoint ? cursorPointDisparity(g, hint.nearestPoint) : null;
205
+ const target = cursorTarget(this.tuning, content !== null, content);
206
+ const disparity = cursorFilterStep(this.filter, this.tuning, target, nowSec);
207
+ const { position, height } = placeCursor(g, disparity, hint.cursorHeight);
208
+ return { active: true, position, basis: g.basis, height, disparity, targetDisparity: target, geometry: g };
209
+ }
210
+ }
211
+
212
+ function asView(v) {
213
+ return v.transformMatrix ? v : { projectionMatrix: v.projectionMatrix, transformMatrix: v.transform.matrix };
214
+ }
215
+
216
+ /**
217
+ * The pointer half of a depth cursor, shared by every backend: tracks the pointer over the
218
+ * canvas (canvas-normalised, v down) and hides the CSS cursor exactly while a sprite replaces it.
219
+ * Constructing one adds two listeners; nothing else runs until a backend asks for `uv`.
220
+ */
221
+ export class CursorPointer {
222
+ /** @param {HTMLElement} canvas */
223
+ constructor(canvas) {
224
+ this.canvas = canvas;
225
+ /** `[u, v]` while the pointer is over the canvas, else null. */
226
+ this.uv = null;
227
+ this._hidden = false;
228
+ this._prev = '';
229
+ this._onMove = (e) => {
230
+ const r = canvas.getBoundingClientRect();
231
+ this.uv = r.width > 0 && r.height > 0 ? [(e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height] : null;
232
+ };
233
+ this._onLeave = () => {
234
+ this.uv = null;
235
+ };
236
+ canvas.addEventListener('pointermove', this._onMove);
237
+ canvas.addEventListener('pointerleave', this._onLeave);
238
+ }
239
+
240
+ /** Set the pointer directly (canvas-normalised, v down), or null — for scripted input. */
241
+ set(u, v) {
242
+ this.uv = u === null || u === undefined ? null : [u, v];
243
+ }
244
+
245
+ /** The canvas aspect (width / height), for a round footprint. */
246
+ aspect() {
247
+ const r = this.canvas.getBoundingClientRect();
248
+ return r.height > 0 ? r.width / r.height : 1;
249
+ }
250
+
251
+ /** Hide (true) or restore (false) the CSS cursor; idempotent. */
252
+ hideCss(hide) {
253
+ if (hide === this._hidden) return;
254
+ if (hide) {
255
+ this._prev = this.canvas.style.cursor || '';
256
+ this.canvas.style.cursor = 'none';
257
+ } else {
258
+ this.canvas.style.cursor = this._prev;
259
+ }
260
+ this._hidden = hide;
261
+ }
262
+
263
+ dispose() {
264
+ this.canvas.removeEventListener('pointermove', this._onMove);
265
+ this.canvas.removeEventListener('pointerleave', this._onLeave);
266
+ this.hideCss(false);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * The footprint ring every backend samples: the hotspot plus 8 points on a circle of radius
272
+ * 0.75 × the sprite height (canvas heights), so the sprite and a margin are covered.
273
+ * @returns {Array<[number, number]>} canvas-normalised points, the hotspot first.
274
+ */
275
+ export function cursorFootprint(u, v, height, aspect) {
276
+ const r = 0.75 * height;
277
+ const out = [[u, v]];
278
+ for (let k = 0; k < 8; k++) {
279
+ const a = (k * Math.PI) / 4;
280
+ out.push([u + (r * Math.cos(a)) / aspect, v + r * Math.sin(a)]);
281
+ }
282
+ return out;
283
+ }
284
+
285
+ /**
286
+ * The crosshair every backend draws: 8 segments in the sprite's own XY plane, unit = sprite
287
+ * height (a "+" with a gap and a small square at the hotspot), as [x0, y0, x1, y1] tuples.
288
+ */
289
+ export const CURSOR_CROSSHAIR = Object.freeze([
290
+ [-0.5, 0, -0.15, 0], [0.15, 0, 0.5, 0], [0, -0.5, 0, -0.15], [0, 0.15, 0, 0.5],
291
+ [-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],
292
+ ]);
293
+
294
+ /**
295
+ * The crosshair's segment endpoints in the views' space for a placement, as a flat
296
+ * [x, y, z, x, y, z, …] array (16 points) — what a line renderer takes.
297
+ */
298
+ export function cursorCrosshairPoints(placement, out = []) {
299
+ out.length = 0;
300
+ const { position: p, basis: b, height: h } = placement;
301
+ for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) {
302
+ for (const [x, y] of [[x0, y0], [x1, y1]]) {
303
+ out.push(
304
+ p[0] + (b.x[0] * x + b.y[0] * y) * h,
305
+ p[1] + (b.x[1] * x + b.y[1] * y) * h,
306
+ p[2] + (b.x[2] * x + b.y[2] * y) * h,
307
+ );
308
+ }
309
+ }
310
+ return out;
311
+ }
@@ -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,7 @@
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
43
  import { SceneViewer } from './inline3d-viewer.js';
44
44
  // Procedural — built in memory, no asset to serve. Imported eagerly rather than lazily because it
45
45
  // is the default lighting path, so deferring it would only add a frame of unlit content.
@@ -382,6 +382,7 @@ export function addModel(wall, canvas, src, opts = {}) {
382
382
  logTag: '[inline3d/model]',
383
383
  viewerEase: opts.viewerEase,
384
384
  }).useEyeCamera(EyeCamera, EdgeFeather);
385
+ if (opts.cursor === 'depth') viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]));
385
386
 
386
387
  if (envMap) viewer.scene.environment = envMap;
387
388
  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,
@@ -823,8 +823,14 @@ export class PlayCanvasSplatViewer {
823
823
  pageCamera = false,
824
824
  toneMapping = 'none',
825
825
  viewerEase,
826
+ cursor = null,
826
827
  } = opts;
827
828
  this.canvas = canvas;
829
+ // `cursor: 'depth'` (ADR-046): the depth-aware cursor. OPT-IN AND FREE OTHERWISE — its module
830
+ // is imported only when asked for (attachEngine), and every per-frame hook is an optional call
831
+ // on `cursorDepth`, which stays null without it.
832
+ this._cursorOpt = cursor;
833
+ this.cursorDepth = null;
828
834
  // The tracking-acquisition ease (./inline3d-viewer-ease.js): undefined = the session's
829
835
  // createInline3D({ viewerEase }) default. Built on the first 3D frame, which knows the session.
830
836
  this._viewerEaseOpt = viewerEase;
@@ -1253,6 +1259,7 @@ export class PlayCanvasSplatViewer {
1253
1259
  this._monoRaf = requestAnimationFrame(loop);
1254
1260
  this._beforeFrame?.(null);
1255
1261
  if (this._disposed || this._mode !== 'mono') return; // the callback removed or re-wove us
1262
+ this.cursorDepth?.inactive(); // 2D: the normal cursor
1256
1263
  this._tick();
1257
1264
  this._drawMono();
1258
1265
  };
@@ -1283,6 +1290,8 @@ export class PlayCanvasSplatViewer {
1283
1290
  this._live = null;
1284
1291
  this._videoPlane?.destroy();
1285
1292
  this._videoPlane = null;
1293
+ this.cursorDepth?.dispose();
1294
+ this.cursorDepth = null;
1286
1295
  this.layerRigs = null; // its cameras go with the app
1287
1296
  try {
1288
1297
  this.app?.destroy();
@@ -1405,6 +1414,13 @@ export class PlayCanvasSplatViewer {
1405
1414
  if (this.featherPx > 0) this._makeFeather();
1406
1415
  this._applyTransform();
1407
1416
  app.start();
1417
+ if (this._cursorOpt === 'depth') {
1418
+ import('./inline3d-cursor-depth-playcanvas.js')
1419
+ .then(({ PlayCanvasDepthCursor }) => {
1420
+ if (!this._disposed) this.cursorDepth = new PlayCanvasDepthCursor(pc, this, { canvas: this.canvas });
1421
+ })
1422
+ .catch((err) => console.warn(`${this.logTag || '[inline3d/splat]'} cursor: 'depth' could not load; the normal cursor stays.`, err));
1423
+ }
1408
1424
  return app;
1409
1425
  }
1410
1426
 
@@ -2103,6 +2119,8 @@ export class PlayCanvasSplatViewer {
2103
2119
  }
2104
2120
  // handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
2105
2121
  this._videoPlane?.beforeDraw(entries, rect);
2122
+ // cursor: 'depth' — place the sprite and queue its lines for the tick below (no-op unless opted in).
2123
+ this.cursorDepth?.frame(entries);
2106
2124
  app.tick(now());
2107
2125
  this._afterTick();
2108
2126
  return true;
@@ -3216,6 +3234,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3216
3234
  farClip: opts.farClip,
3217
3235
  sky: opts.sky,
3218
3236
  viewerEase: opts.viewerEase,
3237
+ cursor: opts.cursor,
3219
3238
  });
3220
3239
 
3221
3240
  let handle = null;
@@ -20,7 +20,7 @@
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
24
  import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
25
25
  import { readSogCamera } from './inline3d-sog.js';
26
26
  import { resolveRevealOption, effectsNotOnSpark } from './inline3d-splat-effects.js';
@@ -248,6 +248,9 @@ export function addSplat(wall, canvas, src, opts = {}) {
248
248
  logTag: '[inline3d/splat]',
249
249
  viewerEase: opts.viewerEase,
250
250
  }).useEyeCamera(EyeCamera, EdgeFeather);
251
+ // A gaussian-splat raycast is ~8 ms a ray on Spark: one ray a frame, cycling the footprint.
252
+ // (engine:'playcanvas' hit-tests with the engine's depth picker instead, which is far cheaper.)
253
+ if (opts.cursor === 'depth') viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]), { raysPerFrame: 1 });
251
254
 
252
255
  // Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
253
256
  // sort against each other — which is what lets a product page mix a captured hero with a
@@ -52,6 +52,8 @@
52
52
  // descriptor and nothing else: no Kooima, no off-axis math, no scale, here or anywhere in this
53
53
  // SDK. That stays in the runtime, which is the point of the extension.
54
54
 
55
+ import { CursorDepthPlacer, CursorPointer, cursorFootprint, cursorViewRay, CURSOR_CROSSHAIR, CURSOR_DEFAULT_HEIGHT } from './inline3d-cursor-depth.js';
56
+
55
57
  /**
56
58
  * A reusable three.js camera driven directly by an XRView's matrices. Construct once with
57
59
  * your THREE namespace and reuse across frames/windows.
@@ -413,3 +415,202 @@ export class EdgeFeather {
413
415
  renderer.autoClear = prevAutoClear;
414
416
  }
415
417
  }
418
+
419
+ // ── Depth-aware cursor ─────────────────────────────────────────────────────────────────────
420
+
421
+ /**
422
+ * A hit test for {@link DepthCursor} that raycasts three.js objects: the first hit along the
423
+ * ray, as a world point, or null.
424
+ *
425
+ * @param {object} THREE your imported three.js module namespace.
426
+ * @param {object[]} targets the Object3Ds that count as content (searched recursively).
427
+ */
428
+ export function raycastHitTest(THREE, targets) {
429
+ const raycaster = new THREE.Raycaster();
430
+ const o = new THREE.Vector3();
431
+ const d = new THREE.Vector3();
432
+ return (origin, direction) => {
433
+ o.set(origin[0], origin[1], origin[2]);
434
+ d.set(direction[0], direction[1], direction[2]).normalize();
435
+ raycaster.set(o, d);
436
+ const hit = raycaster.intersectObjects(targets, true)[0];
437
+ return hit ? [hit.point.x, hit.point.y, hit.point.z] : null;
438
+ };
439
+ }
440
+
441
+ /**
442
+ * A cursor that rises to the depth of the content under it, so it is never drawn behind
443
+ * something that pops out of the glass (a depth violation). The page supplies the content —
444
+ * a hit test — and this does the rest: tracks the pointer over the canvas, hit-tests the
445
+ * cursor FOOTPRINT (not just the hotspot), places a crosshair sprite with the same maths and
446
+ * defaults as the runtime's XR_DXR_cursor_depth (ADR-046), and hides the CSS cursor exactly
447
+ * while the sprite replaces it.
448
+ *
449
+ * Opt-in: nothing runs unless you construct one and call update(). In 2D (one view) or with the
450
+ * pointer off the canvas it is inactive, the sprite is hidden and the CSS cursor is back.
451
+ *
452
+ * const cursor = new DepthCursor(THREE, { canvas, hitTest: raycastHitTest(THREE, [model]) });
453
+ * scene.add(cursor.object);
454
+ * wall.addScene(canvas, (views, layer) => {
455
+ * cursor.update(views); // first, with THIS frame's views
456
+ * // ... render every view as usual; the sprite draws last, depth test off ...
457
+ * });
458
+ *
459
+ * Head motion: the sprite sits on the line from the viewer through the pointer's point on the
460
+ * canvas, so its image on the glass does not move when the head does — it stays under the
461
+ * mouse like the OS cursor, and only its depth follows the content under that line of sight.
462
+ *
463
+ * The attach pattern (eye cameras parented under an app camera): pass `viewSpace` = that
464
+ * parent, so the views' local transforms are carried into world space; keep `cursor.object` at
465
+ * the scene root.
466
+ */
467
+ export class DepthCursor {
468
+ /**
469
+ * @param {object} THREE your imported three.js module namespace.
470
+ * @param {object} opts
471
+ * @param {HTMLElement} opts.canvas the woven canvas (pointer is tracked over it).
472
+ * @param {(origin:number[], direction:number[]) => number[]|null} opts.hitTest nearest
473
+ * content point along a ray in WORLD space, or null. {@link raycastHitTest} for meshes;
474
+ * for splats return the renderer's expected depth along the ray (never raycast splats).
475
+ * @param {number} [opts.height=0.03] sprite height as a fraction of the canvas height.
476
+ * @param {number} [opts.color=0xffd419]
477
+ * @param {object} [opts.viewSpace] Object3D whose world matrix maps view transforms to world.
478
+ * @param {number} [opts.raysPerFrame=0] 0 = the whole footprint every frame (two eyes × 9
479
+ * points). N > 0 = an EXPENSIVE hit test (e.g. a gaussian-splat raycast, ~8 ms a ray):
480
+ * cast N rays a frame from the first eye, cycling through the footprint, and take the
481
+ * nearest of the most recent full cycle.
482
+ */
483
+ constructor(THREE, { canvas, hitTest, height = CURSOR_DEFAULT_HEIGHT, color = 0xffd419, viewSpace = null, raysPerFrame = 0 }) {
484
+ this._THREE = THREE;
485
+ this.canvas = canvas;
486
+ this.hitTest = hitTest;
487
+ this.height = height;
488
+ this.viewSpace = viewSpace;
489
+ this.raysPerFrame = raysPerFrame;
490
+ this._ring = []; // amortised mode: the last hit per footprint point (null = a miss)
491
+ this._next = 0;
492
+ this.placer = new CursorDepthPlacer();
493
+ this.pointer = new CursorPointer(canvas);
494
+ /** The last placement (diagnostics): `{active, position, height, disparity, targetDisparity}`. */
495
+ this.placement = { active: false };
496
+
497
+ const pos = [];
498
+ for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) pos.push(x0, y0, 0, x1, y1, 0);
499
+ const geom = new THREE.BufferGeometry();
500
+ geom.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
501
+ const mat = new THREE.LineBasicMaterial({ color, depthTest: false, depthWrite: false, transparent: true });
502
+ this.object = new THREE.LineSegments(geom, mat);
503
+ this.object.matrixAutoUpdate = false;
504
+ this.object.frustumCulled = false;
505
+ this.object.renderOrder = 1e9; // last: never occluded, it is in front by construction
506
+ this.object.visible = false;
507
+ }
508
+
509
+ /** Set the pointer position directly (canvas-normalised, v down), or null — for scripted input. */
510
+ setPointer(u, v) {
511
+ this.pointer.set(u, v);
512
+ }
513
+
514
+ /**
515
+ * Place the sprite for this frame. Call with the views you are about to render.
516
+ * @param {ArrayLike<XRView|{projectionMatrix:ArrayLike<number>,transformMatrix:ArrayLike<number>}>} views
517
+ * @param {number} [nowMs=performance.now()]
518
+ */
519
+ update(views, nowMs = globalThis.performance ? globalThis.performance.now() : Date.now()) {
520
+ const vs = this._worldViews(views);
521
+ const uv = this.pointer.uv;
522
+ let p = { active: false };
523
+ if (uv && vs.length >= 2) {
524
+ const [u, v] = uv;
525
+ const nearestPoint = this._footprintHit(vs, u, v);
526
+ p = this.placer.update(vs, { u, v, nearestPoint, cursorHeight: this.height }, nowMs / 1000);
527
+ }
528
+ this.placement = p;
529
+ this.object.visible = !!p.active;
530
+ if (p.active) {
531
+ const { x, y, z } = p.basis;
532
+ const h = p.height;
533
+ const e = p.position;
534
+ this.object.matrix.fromArray([
535
+ x[0] * h, x[1] * h, x[2] * h, 0,
536
+ y[0] * h, y[1] * h, y[2] * h, 0,
537
+ z[0] * h, z[1] * h, z[2] * h, 0,
538
+ e[0], e[1], e[2], 1,
539
+ ]);
540
+ this.object.matrixWorldNeedsUpdate = true;
541
+ }
542
+ this.pointer.hideCss(!!p.active);
543
+ return p;
544
+ }
545
+
546
+ /** Remove listeners, give the CSS cursor back, free the sprite's GPU resources. */
547
+ dispose() {
548
+ this.pointer.dispose();
549
+ this.object.visible = false;
550
+ this.object.geometry.dispose();
551
+ this.object.material.dispose();
552
+ }
553
+
554
+ // Nearest content point under the footprint, from the two outer views (or, amortised, from
555
+ // the first eye over the last cycle of footprint points).
556
+ _footprintHit(views, u, v) {
557
+ const a = views[0], b = views[views.length - 1];
558
+ const m = a.transformMatrix;
559
+ const fl = Math.hypot(m[8], m[9], m[10]) || 1;
560
+ const f = [-m[8] / fl, -m[9] / fl, -m[10] / fl];
561
+ const fp = cursorFootprint(u, v, this.height, this.pointer.aspect());
562
+ let candidates;
563
+ if (this.raysPerFrame > 0) {
564
+ const ring = this._ring;
565
+ ring.length = fp.length;
566
+ for (let i = 0; i < this.raysPerFrame; i++) {
567
+ const k = this._next++ % fp.length;
568
+ const ray = cursorViewRay(a, fp[k][0], fp[k][1]);
569
+ ring[k] = this.hitTest(ray.origin, ray.direction);
570
+ }
571
+ candidates = ring;
572
+ } else {
573
+ candidates = [];
574
+ for (const [su, sv] of fp) {
575
+ for (const view of [a, b]) {
576
+ const ray = cursorViewRay(view, su, sv);
577
+ candidates.push(this.hitTest(ray.origin, ray.direction));
578
+ }
579
+ }
580
+ }
581
+ let best = Infinity, nearest = null;
582
+ for (const p of candidates) {
583
+ if (!p) continue;
584
+ const depth = p[0] * f[0] + p[1] * f[1] + p[2] * f[2]; // smaller = nearer the viewer
585
+ if (depth < best) {
586
+ best = depth;
587
+ nearest = p;
588
+ }
589
+ }
590
+ return nearest;
591
+ }
592
+
593
+ _worldViews(views) {
594
+ const out = [];
595
+ if (!views) return out;
596
+ const parent = this.viewSpace ? this.viewSpace.matrixWorld.elements : null;
597
+ for (const v of views) {
598
+ const t = v.transformMatrix || v.transform.matrix;
599
+ out.push({ projectionMatrix: v.projectionMatrix, transformMatrix: parent ? mul4(parent, t) : t });
600
+ }
601
+ return out;
602
+ }
603
+ }
604
+
605
+ /** Column-major 4x4 multiply, a × b. */
606
+ function mul4(a, b) {
607
+ const o = new Array(16);
608
+ for (let c = 0; c < 4; c++) {
609
+ for (let r = 0; r < 4; r++) {
610
+ let s = 0;
611
+ for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
612
+ o[c * 4 + r] = s;
613
+ }
614
+ }
615
+ return o;
616
+ }