@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.
@@ -52,6 +52,17 @@
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 {
56
+ CursorDepthPlacer,
57
+ CursorPointer,
58
+ cursorCrosshairMesh,
59
+ cursorFootprint,
60
+ cursorModelMatrix,
61
+ cursorViewRay,
62
+ CURSOR_DEFAULT_HEIGHT,
63
+ CURSOR_DEFAULT_TUNING,
64
+ } from './inline3d-cursor-depth.js';
65
+
55
66
  /**
56
67
  * A reusable three.js camera driven directly by an XRView's matrices. Construct once with
57
68
  * your THREE namespace and reuse across frames/windows.
@@ -413,3 +424,215 @@ export class EdgeFeather {
413
424
  renderer.autoClear = prevAutoClear;
414
425
  }
415
426
  }
427
+
428
+ // ── Depth-aware cursor ─────────────────────────────────────────────────────────────────────
429
+
430
+ /**
431
+ * A hit test for {@link DepthCursor} that raycasts three.js objects: the first hit along the
432
+ * ray, as a world point, or null.
433
+ *
434
+ * @param {object} THREE your imported three.js module namespace.
435
+ * @param {object[]} targets the Object3Ds that count as content (searched recursively).
436
+ */
437
+ export function raycastHitTest(THREE, targets) {
438
+ const raycaster = new THREE.Raycaster();
439
+ const o = new THREE.Vector3();
440
+ const d = new THREE.Vector3();
441
+ return (origin, direction) => {
442
+ o.set(origin[0], origin[1], origin[2]);
443
+ d.set(direction[0], direction[1], direction[2]).normalize();
444
+ raycaster.set(o, d);
445
+ const hit = raycaster.intersectObjects(targets, true)[0];
446
+ return hit ? [hit.point.x, hit.point.y, hit.point.z] : null;
447
+ };
448
+ }
449
+
450
+ /**
451
+ * A cursor that rises to the depth of the content under it, so it is never drawn behind
452
+ * something that pops out of the glass (a depth violation). The page supplies the content —
453
+ * a hit test — and this does the rest: tracks the pointer over the canvas, hit-tests the
454
+ * cursor FOOTPRINT (not just the hotspot), places a crosshair sprite with the same maths and
455
+ * defaults as the runtime's XR_DXR_cursor_depth (ADR-046), and hides the CSS cursor exactly
456
+ * while the sprite replaces it.
457
+ *
458
+ * Opt-in: nothing runs unless you construct one and call update(). In 2D (one view) or with the
459
+ * pointer off the canvas it is inactive, the sprite is hidden and the CSS cursor is back.
460
+ *
461
+ * const cursor = new DepthCursor(THREE, { canvas, hitTest: raycastHitTest(THREE, [model]) });
462
+ * scene.add(cursor.object);
463
+ * wall.addScene(canvas, (views, layer) => {
464
+ * cursor.update(views); // first, with THIS frame's views
465
+ * // ... render every view as usual; the sprite draws last, depth test off ...
466
+ * });
467
+ *
468
+ * Head motion: the sprite sits on the line from the viewer through the pointer's point on the
469
+ * canvas, so its image on the glass does not move when the head does — it stays under the
470
+ * mouse like the OS cursor, and only its depth follows the content under that line of sight.
471
+ *
472
+ * The attach pattern (eye cameras parented under an app camera): pass `viewSpace` = that
473
+ * parent, so the views' local transforms are carried into world space; keep `cursor.object` at
474
+ * the scene root.
475
+ */
476
+ export class DepthCursor {
477
+ /**
478
+ * @param {object} THREE your imported three.js module namespace.
479
+ * @param {object} opts
480
+ * @param {HTMLElement} opts.canvas the woven canvas (pointer is tracked over it).
481
+ * @param {(origin:number[], direction:number[]) => number[]|null} opts.hitTest nearest
482
+ * content point along a ray in WORLD space, or null. {@link raycastHitTest} for meshes;
483
+ * for splats return the renderer's expected depth along the ray (never raycast splats).
484
+ * @param {number} [opts.height=0.03] sprite height as a fraction of the canvas height.
485
+ * @param {number} [opts.color=0xffd61a] fill colour (the outline is always dark).
486
+ * @param {number} [opts.margin=0.005] how far in front of the content it floats, in eye-baseline
487
+ * units (0.005 ≈ 1.5 mm at 60 cm: it rests on the content).
488
+ * @param {'hybrid'|'screen'|'world'} [opts.anchor='hybrid'] where along that depth: 'hybrid'
489
+ * follows the pointer exactly while it moves and stays world-fixed (parallaxes with the
490
+ * content) while it is still; 'screen' never parallaxes; 'world' always does.
491
+ * @param {'canvas'|'window'} [opts.pointerScope='canvas'] 'window' keeps the cursor over DOM
492
+ * layered on the canvas (overlay buttons) and hides the CSS cursor page-wide meanwhile.
493
+ * @param {object} [opts.viewSpace] Object3D whose world matrix maps view transforms to world.
494
+ * @param {number} [opts.raysPerFrame=0] 0 = the whole footprint every frame (two eyes × 9
495
+ * points). N > 0 = an EXPENSIVE hit test (e.g. a gaussian-splat raycast, ~8 ms a ray):
496
+ * cast N rays a frame from the first eye, cycling through the footprint, and take the
497
+ * nearest of the most recent full cycle.
498
+ */
499
+ constructor(
500
+ THREE,
501
+ { canvas, hitTest, height = CURSOR_DEFAULT_HEIGHT, color = 0xffd61a, viewSpace = null, raysPerFrame = 0, margin, anchor = 'hybrid', pointerScope = 'canvas' },
502
+ ) {
503
+ this._THREE = THREE;
504
+ this.canvas = canvas;
505
+ this.hitTest = hitTest;
506
+ this.height = height;
507
+ this.viewSpace = viewSpace;
508
+ this.raysPerFrame = raysPerFrame;
509
+ this._ring = []; // amortised mode: the last hit per footprint point (null = a miss)
510
+ this._next = 0;
511
+ const tuning = margin > 0 && Number.isFinite(margin) ? { ...CURSOR_DEFAULT_TUNING, margin } : CURSOR_DEFAULT_TUNING;
512
+ this.placer = new CursorDepthPlacer(tuning, { anchor });
513
+ this.pointer = new CursorPointer(canvas, { scope: pointerScope });
514
+ /** The last placement (diagnostics): `{active, position, height, disparity, targetDisparity, anchored}`. */
515
+ this.placement = { active: false };
516
+
517
+ // Filled strokes with a dark outline (a 1 px line reads too thin through the lens).
518
+ const fill = [((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255, 1];
519
+ const m = cursorCrosshairMesh(fill);
520
+ const geom = new THREE.BufferGeometry();
521
+ geom.setAttribute('position', new THREE.Float32BufferAttribute(m.positions, 3));
522
+ geom.setAttribute('color', new THREE.Float32BufferAttribute(m.colors, 4));
523
+ const mat = new THREE.MeshBasicMaterial({
524
+ vertexColors: true,
525
+ transparent: true,
526
+ depthTest: false,
527
+ depthWrite: false,
528
+ side: THREE.DoubleSide,
529
+ toneMapped: false,
530
+ });
531
+ this.object = new THREE.Mesh(geom, mat);
532
+ this.object.name = 'inline3d-cursor';
533
+ this.object.matrixAutoUpdate = false;
534
+ this.object.frustumCulled = false;
535
+ this.object.renderOrder = 1e9; // last: never occluded, it is in front by construction
536
+ this.object.visible = false;
537
+ }
538
+
539
+ /** Set the pointer position directly (canvas-normalised, v down), or null — for scripted input. */
540
+ setPointer(u, v) {
541
+ this.pointer.set(u, v);
542
+ }
543
+
544
+ /**
545
+ * Place the sprite for this frame. Call with the views you are about to render.
546
+ * @param {ArrayLike<XRView|{projectionMatrix:ArrayLike<number>,transformMatrix:ArrayLike<number>}>} views
547
+ * @param {number} [nowMs=performance.now()]
548
+ */
549
+ update(views, nowMs = globalThis.performance ? globalThis.performance.now() : Date.now()) {
550
+ const vs = this._worldViews(views);
551
+ const uv = this.pointer.uv;
552
+ let p = { active: false };
553
+ if (uv && vs.length >= 2) {
554
+ const [u, v] = uv;
555
+ const nearestPoint = this._footprintHit(vs, u, v);
556
+ p = this.placer.update(vs, { u, v, nearestPoint, cursorHeight: this.height }, nowMs / 1000);
557
+ }
558
+ this.placement = p;
559
+ this.object.visible = !!p.active;
560
+ if (p.active) {
561
+ this.object.matrix.fromArray(cursorModelMatrix(p));
562
+ this.object.matrixWorldNeedsUpdate = true;
563
+ }
564
+ this.pointer.hideCss(!!p.active);
565
+ return p;
566
+ }
567
+
568
+ /** Remove listeners, give the CSS cursor back, free the sprite's GPU resources. */
569
+ dispose() {
570
+ this.pointer.dispose();
571
+ this.object.visible = false;
572
+ this.object.geometry.dispose();
573
+ this.object.material.dispose();
574
+ }
575
+
576
+ // Nearest content point under the footprint, from the two outer views (or, amortised, from
577
+ // the first eye over the last cycle of footprint points).
578
+ _footprintHit(views, u, v) {
579
+ const a = views[0], b = views[views.length - 1];
580
+ const m = a.transformMatrix;
581
+ const fl = Math.hypot(m[8], m[9], m[10]) || 1;
582
+ const f = [-m[8] / fl, -m[9] / fl, -m[10] / fl];
583
+ const fp = cursorFootprint(u, v, this.height, this.pointer.aspect());
584
+ let candidates;
585
+ if (this.raysPerFrame > 0) {
586
+ const ring = this._ring;
587
+ ring.length = fp.length;
588
+ for (let i = 0; i < this.raysPerFrame; i++) {
589
+ const k = this._next++ % fp.length;
590
+ const ray = cursorViewRay(a, fp[k][0], fp[k][1]);
591
+ ring[k] = this.hitTest(ray.origin, ray.direction);
592
+ }
593
+ candidates = ring;
594
+ } else {
595
+ candidates = [];
596
+ for (const [su, sv] of fp) {
597
+ for (const view of [a, b]) {
598
+ const ray = cursorViewRay(view, su, sv);
599
+ candidates.push(this.hitTest(ray.origin, ray.direction));
600
+ }
601
+ }
602
+ }
603
+ let best = Infinity, nearest = null;
604
+ for (const p of candidates) {
605
+ if (!p) continue;
606
+ const depth = p[0] * f[0] + p[1] * f[1] + p[2] * f[2]; // smaller = nearer the viewer
607
+ if (depth < best) {
608
+ best = depth;
609
+ nearest = p;
610
+ }
611
+ }
612
+ return nearest;
613
+ }
614
+
615
+ _worldViews(views) {
616
+ const out = [];
617
+ if (!views) return out;
618
+ const parent = this.viewSpace ? this.viewSpace.matrixWorld.elements : null;
619
+ for (const v of views) {
620
+ const t = v.transformMatrix || v.transform.matrix;
621
+ out.push({ projectionMatrix: v.projectionMatrix, transformMatrix: parent ? mul4(parent, t) : t });
622
+ }
623
+ return out;
624
+ }
625
+ }
626
+
627
+ /** Column-major 4x4 multiply, a × b. */
628
+ function mul4(a, b) {
629
+ const o = new Array(16);
630
+ for (let c = 0; c < 4; c++) {
631
+ for (let r = 0; r < 4; r++) {
632
+ let s = 0;
633
+ for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
634
+ o[c * 4 + r] = s;
635
+ }
636
+ }
637
+ return o;
638
+ }
@@ -737,6 +737,11 @@ export class SceneViewer {
737
737
  this._cacheGood(views, vps, !eye);
738
738
  const g = this._lastGood;
739
739
  if (eye) (this.viewerEase ||= viewerEaseFor(frame, this._viewerEaseOpt)).apply(g.entries, frameTrackingState(frame));
740
+ // cursor: 'depth' (ADR-046) — only when a page opted in; null otherwise.
741
+ if (this.cursorDepth) {
742
+ this.scene.updateMatrixWorld(); // the hit test reads THIS frame's subject pose
743
+ this.cursorDepth.update(g.entries.map((e) => ({ projectionMatrix: e.proj, transformMatrix: e.pose })));
744
+ }
740
745
  const r = this.renderer;
741
746
  // getViewport() splits canvas.width; a drawing buffer the browser clamped behind our back
742
747
  // (bufferScale().mismatch) gets the same split mapped onto its real size, never canvas.width/2.
@@ -771,6 +776,22 @@ export class SceneViewer {
771
776
  return this;
772
777
  }
773
778
 
779
+ /**
780
+ * `cursor: 'depth'` (ADR-046): a cursor that rises to the subject under it instead of being
781
+ * drawn on the glass behind content that pops out. Called by ./model and ./splat on opt-in
782
+ * only — without it nothing is built and every hook below is skipped. Injected, like
783
+ * useEyeCamera, so this module never imports three.js itself.
784
+ * @param {Function} DepthCursorClass ./three's DepthCursor.
785
+ * @param {(content: object) => Function} hitTestFor builds the hit test over the subject group.
786
+ * @param {object} [opts] extra DepthCursor options (./splat passes `raysPerFrame`).
787
+ */
788
+ useDepthCursor(DepthCursorClass, hitTestFor, opts = {}) {
789
+ if (this.cursorDepth) return this;
790
+ this.cursorDepth = new DepthCursorClass(this._THREE, { ...opts, canvas: this.canvas, hitTest: hitTestFor(this.content) });
791
+ this.scene.add(this.cursorDepth.object);
792
+ return this;
793
+ }
794
+
774
795
  /** Drive a flat, single-camera render loop for browsers without inline-3D. */
775
796
  startMono() {
776
797
  if (this._monoRaf || this._disposed) return;
@@ -780,6 +801,7 @@ export class SceneViewer {
780
801
  if (this._disposed) return;
781
802
  this._monoRaf = requestAnimationFrame(loop);
782
803
  this._tick();
804
+ this.cursorDepth?.update(null); // 2D: no sprite, the normal cursor
783
805
  const r = this.renderer;
784
806
  r.clear();
785
807
  const b = this._bufScale();
@@ -809,6 +831,8 @@ export class SceneViewer {
809
831
  if (this._ro) this._ro.disconnect();
810
832
  else removeEventListener('resize', this._onResize);
811
833
  this._unbindOrbit();
834
+ this.cursorDepth?.dispose();
835
+ this.cursorDepth = null;
812
836
  this.renderer.dispose();
813
837
  }
814
838
 
package/model.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Type definitions for @displayxr/inline3d/model (and ./model/three).
2
2
  // EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
3
 
4
+ import type { CursorOptions } from './cursor-depth.js';
4
5
  import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
5
6
  import type { FirstWovenResult, ViewerEaseOption } from './index.js';
6
7
  import type { PlayCanvasSplatViewer, SplatCameraPose, SplatFrameInfo } from './splat.js';
@@ -48,6 +49,17 @@ export interface ModelOptions {
48
49
  * `createInline3D({ viewerEase })`, else on. `false` restores the snap.
49
50
  */
50
51
  viewerEase?: ViewerEaseOption;
52
+ /**
53
+ * `'depth'`: a cursor that rises to the subject under it instead of being drawn on the glass,
54
+ * behind content that pops out (ADR-046, same maths as native `XR_DXR_cursor_depth`). A
55
+ * crosshair just in front of the nearest content under the pointer's footprint; the CSS cursor
56
+ * is hidden while it shows and comes back in 2D and off the canvas. OPT-IN: without it nothing
57
+ * is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
58
+ * one low-res pass every other frame while the pointer is over the tile). three: a raycast
59
+ * against the subject. Pass an object to tune it: `{ margin, anchor, height, pointerScope }`
60
+ * (any object also means depth).
61
+ */
62
+ cursor?: 'depth' | CursorOptions;
51
63
  /**
52
64
  * How the mesh is lit.
53
65
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -15,6 +15,10 @@
15
15
  "types": "./three.d.ts",
16
16
  "import": "./js/inline3d-three.js"
17
17
  },
18
+ "./cursor-depth": {
19
+ "types": "./cursor-depth.d.ts",
20
+ "import": "./js/inline3d-cursor-depth.js"
21
+ },
18
22
  "./viewer": {
19
23
  "types": "./viewer.d.ts",
20
24
  "import": "./js/inline3d-viewer.js"
@@ -70,6 +74,9 @@
70
74
  "js/inline3d-buffer-limit.js",
71
75
  "js/inline3d-viewer-ease.js",
72
76
  "js/inline3d-three.js",
77
+ "js/inline3d-cursor-option.js",
78
+ "js/inline3d-cursor-depth.js",
79
+ "js/inline3d-cursor-depth-playcanvas.js",
73
80
  "js/inline3d-viewer.js",
74
81
  "js/inline3d-splat.js",
75
82
  "js/inline3d-splat-pc-entry.js",
@@ -100,6 +107,7 @@
100
107
  "dist/",
101
108
  "index.d.ts",
102
109
  "three.d.ts",
110
+ "cursor-depth.d.ts",
103
111
  "viewer.d.ts",
104
112
  "splat.d.ts",
105
113
  "splat-playcanvas.d.ts",
@@ -167,6 +175,7 @@
167
175
  "prepack": "node tools/dist/build.mjs",
168
176
  "test:e2e": "node test/e2e/call-element.e2e.mjs",
169
177
  "test:e2e:camera": "node test/e2e/camera.e2e.mjs",
178
+ "test:e2e:cursor": "node test/e2e/cursor-depth.e2e.mjs",
170
179
  "api:check": "node tools/api-surface.mjs --check",
171
180
  "api:update": "node tools/api-surface.mjs --update",
172
181
  "check:snippets": "node tools/check-snippets.mjs",
package/splat.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Type definitions for @displayxr/inline3d/splat.
2
2
  // EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
3
 
4
+ import type { CursorOptions } from './cursor-depth.js';
4
5
  import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
5
6
  import type { FirstWovenResult, ViewerEaseOption, DisplayMetrics } from './index.js';
6
7
 
@@ -472,6 +473,17 @@ export interface SplatOptions {
472
473
  * `createInline3D({ viewerEase })`, else on. `false` restores the snap.
473
474
  */
474
475
  viewerEase?: ViewerEaseOption;
476
+ /**
477
+ * `'depth'`: a cursor that rises to the subject under it instead of being drawn on the glass,
478
+ * behind content that pops out (ADR-046, same maths as native `XR_DXR_cursor_depth`). A
479
+ * crosshair just in front of the nearest content under the pointer's footprint; the CSS cursor
480
+ * is hidden while it shows and comes back in 2D and off the canvas. OPT-IN: without it nothing
481
+ * is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
482
+ * one low-res pass every other frame while the pointer is over the tile). three: a raycast
483
+ * against the subject. Pass an object to tune it: `{ margin, anchor, height, pointerScope }`
484
+ * (any object also means depth).
485
+ */
486
+ cursor?: 'depth' | CursorOptions;
475
487
  /** Camera rig only: the distance in world metres that sits ON the glass. */
476
488
  convergence?: number;
477
489
  /**
package/three.d.ts CHANGED
@@ -142,3 +142,60 @@ export class EdgeFeather {
142
142
  vp: { x: number; y: number; width: number; height: number },
143
143
  ): void;
144
144
  }
145
+
146
+ import type { CursorAnchorMode, CursorPlacement, CursorView } from './cursor-depth.js';
147
+
148
+ /**
149
+ * A hit test for {@link DepthCursor} that raycasts three.js objects (recursively): the first hit
150
+ * along the ray as a world point, or null.
151
+ */
152
+ export function raycastHitTest(
153
+ THREE: unknown,
154
+ targets: unknown[],
155
+ ): (origin: number[], direction: number[]) => number[] | null;
156
+
157
+ /**
158
+ * A cursor that rises to the depth of the content under it, so it is never drawn behind something
159
+ * that pops out of the glass. Same maths and defaults as the runtime's XR_DXR_cursor_depth
160
+ * (ADR-046). Tracks the pointer over `canvas`, hit-tests the cursor FOOTPRINT, places a crosshair
161
+ * (`object`, add it to your scene) and hides the CSS cursor exactly while the crosshair replaces it.
162
+ * Opt-in: nothing runs unless you construct one and call `update(views)` each frame.
163
+ *
164
+ * Head motion leaves the crosshair's image on the glass where it is (it stays under the mouse like
165
+ * the OS cursor); only its depth follows the content under that line of sight.
166
+ */
167
+ export class DepthCursor {
168
+ constructor(
169
+ THREE: unknown,
170
+ opts: {
171
+ /** The woven canvas; the pointer is tracked over it. */
172
+ canvas: HTMLElement;
173
+ /** Nearest content point along a WORLD ray, or null. For splats return the expected depth along the ray. */
174
+ hitTest: (origin: number[], direction: number[]) => number[] | null;
175
+ /** Sprite height as a fraction of the canvas height (default 0.03). */
176
+ height?: number;
177
+ /** Fill colour of the crosshair (default 0xffd61a; the outline is always dark). */
178
+ color?: number;
179
+ /** How far in front of the content it floats, in eye-baseline units (default 0.005 ≈ 1.5 mm at 60 cm). */
180
+ margin?: number;
181
+ /** Default `'hybrid'`: exact while the pointer moves, world-fixed (parallaxing) while it is still. */
182
+ anchor?: CursorAnchorMode;
183
+ /** `'window'`: keep the cursor over DOM layered on the canvas; hides the CSS cursor page-wide meanwhile. */
184
+ pointerScope?: 'canvas' | 'window';
185
+ /** An EXPENSIVE hit test: N rays a frame from the first eye, cycling the footprint (0 = all). */
186
+ raysPerFrame?: number;
187
+ /** Attach pattern: the Object3D the eye cameras hang off; its world matrix carries the views to world. */
188
+ viewSpace?: unknown;
189
+ },
190
+ );
191
+ /** The crosshair (a `THREE.Mesh` of filled, outlined strokes; depth test off, drawn last). Add it to the scene root. */
192
+ readonly object: unknown;
193
+ /** The last placement, for diagnostics. */
194
+ readonly placement: CursorPlacement | { active: false };
195
+ /** Place the sprite for this frame — call with the views you are about to render, before rendering. */
196
+ update(views: ArrayLike<XRView | CursorView>, nowMs?: number): CursorPlacement | { active: false };
197
+ /** Set the pointer directly (canvas-normalised, v down), or null — for scripted input. */
198
+ setPointer(u: number | null, v?: number): void;
199
+ /** Remove listeners, restore the CSS cursor, free the sprite's GPU resources. */
200
+ dispose(): void;
201
+ }