@displayxr/inline3d 1.45.0 → 1.46.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.
package/index.d.ts CHANGED
@@ -449,6 +449,14 @@ export interface TileHandle {
449
449
  * The canvas size is read at the call: call again after a resize.
450
450
  */
451
451
  displayMetrics(): Promise<DisplayMetrics>;
452
+ /**
453
+ * Runtime ADR-046 Amendment 4: hand this window's depth under the pointer to its live layer
454
+ * (`XRDisplayLayer.setCursorDepth`); nothing / null withdraws. Returns whether a live layer took
455
+ * it. `cursor: 'depth'` calls this for you; a page drawing its own pair cursor can too.
456
+ */
457
+ setCursorDepth(depth?: XRCursorDepthInit | null): boolean;
458
+ /** "runtime" when the browser and the runtime draw this window's cursor; "none" otherwise (incl. no live layer yet). Never rejects. */
459
+ getCursorDepthSupport(): Promise<'runtime' | 'none'>;
452
460
  /**
453
461
  * Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}.
454
462
  *
@@ -992,5 +1000,28 @@ export interface XRDisplayLayer {
992
1000
  /** Undock this layer's asset into the floating native viewer. Optional; same probe rule. */
993
1001
  undock?(init: UndockOptions): Promise<unknown>;
994
1002
  getUndockCapabilities?(): Promise<UndockCapabilities>;
1003
+ /**
1004
+ * Runtime ADR-046 Amendment 4: the depth under the pointer, per animation frame while it is over
1005
+ * this layer's element; no argument / null / `{}` withdraws. Optional; same probe rule.
1006
+ */
1007
+ setCursorDepth?(depth?: XRCursorDepthInit | null): void;
1008
+ /** "runtime" when the browser and the runtime draw this page's cursor; else "none". Optional; same probe rule. */
1009
+ getCursorDepthSupport?(): Promise<'runtime' | 'none'>;
995
1010
  close(): void;
996
1011
  }
1012
+
1013
+ /**
1014
+ * What a page passes to `setCursorDepth` (runtime ADR-046 Amendment 4). The runtime does the rest:
1015
+ * the pointer (read at draw time), the policy, the anchor, the look (the browser's own cursor).
1016
+ */
1017
+ export interface XRCursorDepthInit {
1018
+ /**
1019
+ * On-screen disparity in DEVICE px between the two outermost rendered views, of the nearest
1020
+ * content under the cursor footprint; < 0 = crossed = in front of the glass. RAW content
1021
+ * disparity, not a target. Absent / null = over the element, nothing 3D under it.
1022
+ */
1023
+ disparityPx?: number | null;
1024
+ /** Where it was sampled: element-normalised, v down. */
1025
+ u: number;
1026
+ v: number;
1027
+ }
@@ -26,9 +26,15 @@
26
26
  // buffer px per metre = outW / tileCssWidth / metersPerCssPx (the tile handle's displayMetrics()),
27
27
  // times the eye separation (displayMetrics' default, 63 mm). Loaded only when a page asks for
28
28
  // `cursor: 'depth'` — zero cost otherwise.
29
+ //
30
+ // Where the browser and the runtime draw the web cursor (runtime ADR-046 Amendment 4: the tile's
31
+ // layer answers getCursorDepthSupport() "runtime"), nothing is drawn into the buffer: the matched
32
+ // content disparity goes to the layer RAW, in device px (−contentPx × device px per output px), and
33
+ // the runtime draws the browser's own cursor with the one policy. Elsewhere: the arrow, as above.
29
34
 
30
35
  import {
31
36
  CursorPointer,
37
+ CursorRuntimeLink,
32
38
  CursorShapeSelector,
33
39
  cursorArrowFootprint,
34
40
  cursorShapePolys,
@@ -357,6 +363,8 @@ export class PairCursor {
357
363
  this.eyeSeparationM = DISPLAY_METRICS_DEFAULTS.eyeSeparationM;
358
364
  /** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx, filteredPx, drawn, pxPerBaseline, soften }` (output px, + = in front) or null. */
359
365
  this.last = null;
366
+ /** The runtime-drawn cursor (ADR-046 Amendment 4), bound to the tile's handle each frame. */
367
+ this.runtime = new CursorRuntimeLink();
360
368
  }
361
369
 
362
370
  get hovering() {
@@ -373,6 +381,7 @@ export class PairCursor {
373
381
  * @param {number} nowSec
374
382
  */
375
383
  draw(g, luma, geo, nowSec) {
384
+ const runtime = this.runtime.bind(geo && geo.handle);
376
385
  const uv = this.pointer.uv;
377
386
  if (!uv) return this.idle();
378
387
  const [u, v] = uv;
@@ -383,8 +392,9 @@ export class PairCursor {
383
392
  this._luma = luma;
384
393
  this._uv = uv;
385
394
  }
386
- this._askMetrics(geo.handle);
387
395
  const r = this.pointer.canvas.getBoundingClientRect?.();
396
+ if (runtime) return this._drawRuntime(u, v, r, geo);
397
+ this._askMetrics(geo.handle);
388
398
  const pxPerBaseline = pairPxPerBaseline(geo.outW, r && r.width, this.metersPerCssPx, this.eyeSeparationM);
389
399
  // The core's policy and filter, in its units (eye baselines, < 0 = in front).
390
400
  const content = this._content === null ? null : -this._content / pxPerBaseline;
@@ -416,6 +426,20 @@ export class PairCursor {
416
426
  return this.last;
417
427
  }
418
428
 
429
+ // The runtime draws (ADR-046 Amendment 4): the matched content disparity, RAW, in device px
430
+ // between the two views (the two halves), < 0 crossed: one output px of an eye spans
431
+ // (tile CSS width × dpr) / outW device px. Nothing drawn into the buffer, the CSS cursor alone.
432
+ _drawRuntime(u, v, r, geo) {
433
+ this.pointer.hideCss(false);
434
+ this.filter = {};
435
+ const dpr = globalThis.devicePixelRatio > 0 ? globalThis.devicePixelRatio : 1;
436
+ const k = r && r.width > 0 && geo.outW > 0 ? (r.width * dpr) / geo.outW : null;
437
+ const px = this._content === null || k === null ? null : -this._content * k;
438
+ this.runtime.report(u, v, px);
439
+ this.last = { contentPx: this._content, targetPx: 0, disparityPx: 0, filteredPx: 0, drawn: false, runtime: true, runtimeDisparityPx: px, pxPerBaseline: null, soften: null, shape: null };
440
+ return this.last;
441
+ }
442
+
419
443
  /** Ask the tile's handle once for the panel's pixel pitch; until it answers, the CSS reference px. */
420
444
  _askMetrics(handle) {
421
445
  if (!handle || handle === this._metricsFor || typeof handle.displayMetrics !== 'function') return;
@@ -438,6 +462,7 @@ export class PairCursor {
438
462
 
439
463
  /** Not drawing this frame (pointer away, tile not woven): the CSS cursor comes back. */
440
464
  idle() {
465
+ this.runtime.withdraw();
441
466
  this.pointer.hideCss(false);
442
467
  this.filter = {};
443
468
  this._uv = null;
@@ -446,6 +471,7 @@ export class PairCursor {
446
471
  }
447
472
 
448
473
  dispose() {
474
+ this.runtime.dispose();
449
475
  this.pointer.dispose();
450
476
  this.shapes.dispose();
451
477
  }
@@ -26,6 +26,7 @@
26
26
  import {
27
27
  CursorDepthPlacer,
28
28
  CursorPointer,
29
+ CursorRuntimeLink,
29
30
  CURSOR_DEFAULT_TUNING,
30
31
  cursorArrowFootprint,
31
32
  cursorDefaultHeight,
@@ -85,6 +86,8 @@ export class PlayCanvasDepthCursor {
85
86
  this._sprite = null; // { mi, node, mat } — built on first use
86
87
  /** Which sprite (CSS mirror / fixed / shapeFor); `.current` = the one drawn last. */
87
88
  this.shapes = new CursorShapeSelector(this.pointer, { shape, shapeFor });
89
+ /** The runtime-drawn cursor (runtime ADR-046 Amendment 4): the depth goes to the frame's layer when it says "runtime". */
90
+ this.runtime = new CursorRuntimeLink();
88
91
  }
89
92
 
90
93
  /** The sprite drawn last: a CURSOR_SHAPES name, 'none' or 'image'. */
@@ -96,11 +99,14 @@ export class PlayCanvasDepthCursor {
96
99
  * One 3D frame, before app.tick: schedule a pick, place the sprite, queue its lines.
97
100
  * @param {Array<{proj: ArrayLike<number>, pose: ArrayLike<number>, width: number, height: number}>} entries
98
101
  * this frame's views, poses in RIG space (the eye cameras' parent).
102
+ * @param {object|null} [layer] the frame's XRDisplayLayer: where it says "runtime" (ADR-046
103
+ * Amendment 4) the raw depth goes to it and no sprite is drawn.
99
104
  */
100
- frame(entries) {
105
+ frame(entries, layer = null) {
101
106
  const app = this.viewer.app;
102
107
  const rig = this.viewer.rigNode;
103
108
  const uv = this.pointer.uv;
109
+ const runtime = this.runtime.bind(layer);
104
110
  if (this._disposed || !app || !rig || !uv || !entries || entries.length < 2) {
105
111
  this.inactive();
106
112
  return;
@@ -122,6 +128,16 @@ export class PlayCanvasDepthCursor {
122
128
  if (this._frame++ % PICK_EVERY === 0 && !this._inFlight) this._pick(app, rig, entries[0], uv, h);
123
129
  const now = (globalThis.performance ? globalThis.performance.now() : Date.now()) / 1000;
124
130
  const p = this.placer.update(views, { u: uv[0], v: uv[1], nearestPoint: this.hit, cursorHeight: h, canvasPx: this.pointer.panelPx() }, now);
131
+ if (runtime) {
132
+ // The runtime draws the browser's own cursor at this depth: the raw content disparity in
133
+ // device px; no sprite, no CSS hide (the browser hides the OS cursor over what it drew).
134
+ this.runtime.report(uv[0], uv[1], p.solved ? p.contentDisparityPx : null);
135
+ this.placement = { ...p, active: false, runtime: true };
136
+ this.softening = null;
137
+ this.pointer.hideCss(false);
138
+ if (this._sprite) this._sprite.mi.visible = false;
139
+ return;
140
+ }
125
141
  this.placement = p;
126
142
  this.pointer.hideCss(!!p.active);
127
143
  const sprite = this._sprite || (p.active ? this._makeSprite(app) : null);
@@ -145,7 +161,8 @@ export class PlayCanvasDepthCursor {
145
161
 
146
162
  /** Mono / no pointer / no views: no sprite, the CSS cursor back. */
147
163
  inactive() {
148
- if (this.placement.active) this.placement = { active: false };
164
+ this.runtime.withdraw();
165
+ if (this.placement.active || this.placement.runtime) this.placement = { active: false };
149
166
  this.softening = null;
150
167
  this.placer.update(null); // forget the hybrid anchor
151
168
  if (this._sprite) this._sprite.mi.visible = false;
@@ -356,6 +373,7 @@ export class PlayCanvasDepthCursor {
356
373
 
357
374
  dispose() {
358
375
  this._disposed = true;
376
+ this.runtime.dispose();
359
377
  this.pointer.dispose();
360
378
  this.shapes.dispose();
361
379
  try {
@@ -415,7 +415,12 @@ export class CursorDepthPlacer {
415
415
  const baseline = Math.hypot(ra[12] - rb[12], ra[13] - rb[13], ra[14] - rb[14]);
416
416
  const pxPerBaseline = hint.canvasPx > 0 ? (baseline / g.canvasHeight) * hint.canvasPx : null;
417
417
  const active = cursorShouldDraw(disparity, pxPerBaseline);
418
- return { active, solved: true, position, basis: g.basis, height, disparity, targetDisparity: target, anchored, geometry: g, pxPerBaseline };
418
+ // The RAW content disparity, before the policy (ADR-046 Amendment 4 sends this, in device px).
419
+ const contentDisparityPx = cursorDisparityPx(content, pxPerBaseline);
420
+ return {
421
+ active, solved: true, position, basis: g.basis, height, disparity, targetDisparity: target, anchored, geometry: g, pxPerBaseline,
422
+ contentDisparity: content, contentDisparityPx,
423
+ };
419
424
  }
420
425
  }
421
426
 
@@ -458,6 +463,11 @@ export class CursorPointer {
458
463
  this._onLeave = () => {
459
464
  this.uv = null;
460
465
  };
466
+ // A touch (or a pen in contact) shows no cursor in the browser, so it must never drive the
467
+ // sprite: clear it on contact too, so a tap without a move cannot leave the last mouse hover up.
468
+ this._onDown = (e) => {
469
+ if (!cursorPointerHovers(e)) this.uv = null;
470
+ };
461
471
  this._target = scope === 'window' ? globalThis : canvas;
462
472
  // Freshest pointer: `pointerrawupdate` (Chromium, secure contexts) fires as each input arrives,
463
473
  // not once per frame, so the frame reads the newest position instead of one up to a frame old.
@@ -467,6 +477,7 @@ export class CursorPointer {
467
477
  this.source = supportsRawUpdate(this._target) ? 'pointerrawupdate' : 'pointermove';
468
478
  this._target.addEventListener('pointermove', this._onMove);
469
479
  if (this.source === 'pointerrawupdate') this._target.addEventListener('pointerrawupdate', this._onMove);
480
+ this._target.addEventListener('pointerdown', this._onDown);
470
481
  if (scope === 'window') {
471
482
  // Leaving the window (relatedTarget null) or losing focus mid-hover: nothing to hover.
472
483
  this._onOut = (e) => {
@@ -503,6 +514,12 @@ export class CursorPointer {
503
514
  // One pointer event: keep its NEWEST coalesced sample (getCoalescedEvents(), last entry), unless
504
515
  // a newer one is already held. No layout read here — uv resolves on the next read, once a frame.
505
516
  _sample(e) {
517
+ // Only a pointer the browser draws a cursor for: a mouse, or a pen hovering. A touch drag (or a
518
+ // pen stroke) clears the sprite — the browser shows no cursor there, so neither do we.
519
+ if (!cursorPointerHovers(e)) {
520
+ this.uv = null;
521
+ return;
522
+ }
506
523
  const list = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : null;
507
524
  const s = list && list.length ? list[list.length - 1] : e;
508
525
  const t = s.timeStamp > 0 ? s.timeStamp : e.timeStamp > 0 ? e.timeStamp : globalThis.performance ? globalThis.performance.now() : Date.now();
@@ -570,6 +587,7 @@ export class CursorPointer {
570
587
  dispose() {
571
588
  this._target.removeEventListener('pointermove', this._onMove);
572
589
  if (this.source === 'pointerrawupdate') this._target.removeEventListener('pointerrawupdate', this._onMove);
590
+ this._target.removeEventListener('pointerdown', this._onDown);
573
591
  if (this.scope === 'window') {
574
592
  globalThis.removeEventListener('pointerout', this._onOut);
575
593
  globalThis.removeEventListener('blur', this._onLeave);
@@ -580,6 +598,20 @@ export class CursorPointer {
580
598
  }
581
599
  }
582
600
 
601
+ /**
602
+ * Does this pointer event come from a pointer the browser shows a cursor for? A mouse, or a pen
603
+ * hovering in range (no buttons pressed). A touch never; a pen in contact neither. An event with
604
+ * no `pointerType` (a synthetic `new PointerEvent('pointermove')`, an old engine) counts as a mouse.
605
+ * Coalesced samples belong to their parent event, so the parent is what is tested.
606
+ * @param {{pointerType?: string, buttons?: number}} e
607
+ */
608
+ export function cursorPointerHovers(e) {
609
+ const t = e && e.pointerType;
610
+ if (!t || t === 'mouse') return true;
611
+ if (t === 'pen') return !(e.buttons > 0);
612
+ return false;
613
+ }
614
+
583
615
  /** Does `target` dispatch `pointerrawupdate`? (Chromium; only in a secure context.) */
584
616
  export function supportsRawUpdate(target) {
585
617
  return !!target && 'onpointerrawupdate' in target && globalThis.isSecureContext !== false;
@@ -599,6 +631,160 @@ function pageCursorHidden(hide) {
599
631
  else el.removeAttribute?.(CURSOR_HIDDEN_ATTR);
600
632
  }
601
633
 
634
+ // ── The runtime draws the cursor (runtime ADR-046 Amendment 4) ──────────────────────────────
635
+ //
636
+ // In a DisplayXR Browser whose runtime can draw the web cursor (Windows, XR_DXR_cursor_depth
637
+ // spec >= 8), the page supplies ONLY the depth under the pointer: `layer.setCursorDepth({
638
+ // disparityPx, u, v })` per animation frame while the pointer is over the layer's element, and
639
+ // `layer.setCursorDepth()` to withdraw. The runtime reads the pointer itself at weave time,
640
+ // applies the one policy (margin, clamp, threshold, filter, hybrid anchor), draws the BROWSER's
641
+ // own cursor (so `grab`, `pointer`, `url(…)` look exactly as they would) with the B.7 softening,
642
+ // and the browser hides the OS cursor over what it drew. The SDK then draws no sprite and never
643
+ // touches the CSS cursor. Anywhere else (`getCursorDepthSupport()` says "none", or the methods do
644
+ // not exist) every backend keeps drawing its own sprite exactly as before — the fallback ladder.
645
+
646
+ /** Re-ask a target that answered "none" at most this often (ms) while the cursor runs… */
647
+ export const CURSOR_RUNTIME_RETRY_MS = 2000;
648
+ /** …this many times per target. The browser answers "none" until its weave session is live. */
649
+ export const CURSOR_RUNTIME_PROBES = 6;
650
+ /**
651
+ * After those, a "none" is still re-asked this often, without a cap: the weave session can take tens
652
+ * of seconds to report (a cold GPU, a slow first frame), longer than the fast probes cover. Only a
653
+ * browser that HAS the API and says "none" is ever asked; one without it is never probed at all.
654
+ */
655
+ export const CURSOR_RUNTIME_SLOW_RETRY_MS = 10000;
656
+
657
+ /**
658
+ * Does `target` (an XRDisplayLayer, or a tile handle that forwards to one) take a cursor depth?
659
+ * Method presence only — never reads an attribute off a prototype (the `Illegal invocation` trap).
660
+ */
661
+ export function cursorRuntimeCapable(target) {
662
+ return !!target && typeof target.setCursorDepth === 'function' && typeof target.getCursorDepthSupport === 'function';
663
+ }
664
+
665
+ /**
666
+ * Content disparity in eye-baseline units (< 0 in front) → on-screen device px between the two
667
+ * outermost rendered views (< 0 crossed), the unit `setCursorDepth` takes: `d × pxPerBaseline`.
668
+ * @returns {number|null} null when either is unknown (nothing under the pointer, canvas size unknown).
669
+ */
670
+ export function cursorDisparityPx(contentDisparity, pxPerBaseline) {
671
+ if (contentDisparity === null || contentDisparity === undefined || !Number.isFinite(contentDisparity)) return null;
672
+ if (!(pxPerBaseline > 0) || !Number.isFinite(pxPerBaseline)) return null;
673
+ const px = contentDisparity * pxPerBaseline;
674
+ return Number.isFinite(px) ? px : null;
675
+ }
676
+
677
+ // What each target answered, shared by every link (one support probe per layer, not per cursor).
678
+ const _runtimeSupport = new WeakMap();
679
+
680
+ /**
681
+ * The page half of a runtime-drawn cursor, shared by every backend. Per frame: `bind(target)` with
682
+ * the layer the cursor's element weaves through (true = the runtime draws, so draw NO sprite and
683
+ * leave the CSS cursor alone), then either `report(u, v, disparityPx)` while the pointer is over
684
+ * the element or `withdraw()` when it is not. Constructing one costs nothing; a cursor builds it
685
+ * only under `cursor: 'depth'` (ADR-046 §0).
686
+ */
687
+ export class CursorRuntimeLink {
688
+ constructor() {
689
+ /** The target bound last (an XRDisplayLayer or a tile handle), or null. */
690
+ this.target = null;
691
+ this._sent = false;
692
+ /** The last depth sent (diagnostics): `{ u, v, disparityPx }`, or null after a withdrawal. */
693
+ this.last = null;
694
+ }
695
+
696
+ /** "runtime" | "none" | "pending" for the bound target. */
697
+ get support() {
698
+ const t = this.target;
699
+ if (!cursorRuntimeCapable(t)) return 'none';
700
+ const s = _runtimeSupport.get(t);
701
+ return s ? s.state : 'pending';
702
+ }
703
+
704
+ /** True while the bound target's runtime draws the cursor. */
705
+ get drawing() {
706
+ return this.support === 'runtime';
707
+ }
708
+
709
+ /**
710
+ * This frame's target. A new target withdraws the depth from the old one. Starts (or re-tries,
711
+ * rate-limited) the support probe. Returns `drawing`.
712
+ */
713
+ bind(target) {
714
+ const t = target || null;
715
+ if (t !== this.target) {
716
+ this.withdraw();
717
+ this.target = t;
718
+ }
719
+ if (!cursorRuntimeCapable(t)) return false;
720
+ let s = _runtimeSupport.get(t);
721
+ if (!s) _runtimeSupport.set(t, (s = { state: 'pending', probes: 0, inFlight: false, at: 0 }));
722
+ if (s.state === 'runtime' || s.inFlight) return s.state === 'runtime';
723
+ const now = globalThis.performance ? globalThis.performance.now() : Date.now();
724
+ const wait = s.probes < CURSOR_RUNTIME_PROBES ? CURSOR_RUNTIME_RETRY_MS : CURSOR_RUNTIME_SLOW_RETRY_MS;
725
+ if (s.probes === 0 || now - s.at >= wait) {
726
+ s.probes++;
727
+ s.at = now;
728
+ s.inFlight = true;
729
+ let p;
730
+ try {
731
+ p = Promise.resolve(t.getCursorDepthSupport());
732
+ } catch (e) {
733
+ p = Promise.reject(e);
734
+ }
735
+ p.then(
736
+ (r) => {
737
+ s.inFlight = false;
738
+ s.state = r === 'runtime' ? 'runtime' : 'none';
739
+ },
740
+ () => {
741
+ s.inFlight = false;
742
+ s.state = 'none';
743
+ },
744
+ );
745
+ }
746
+ return s.state === 'runtime';
747
+ }
748
+
749
+ /**
750
+ * The depth under the pointer at element-normalised (u, v), v down: `disparityPx` in device px
751
+ * between the outermost views (< 0 = in front), RAW — the runtime applies the policy — or null
752
+ * for "over the element, nothing 3D under it". A no-op unless the runtime draws.
753
+ */
754
+ report(u, v, disparityPx) {
755
+ if (!this.drawing) return false;
756
+ const d = { u: clamp01(u), v: clamp01(v) };
757
+ if (disparityPx !== null && disparityPx !== undefined && Number.isFinite(disparityPx)) d.disparityPx = disparityPx;
758
+ try {
759
+ this.target.setCursorDepth(d);
760
+ } catch {
761
+ return false;
762
+ }
763
+ this._sent = true;
764
+ this.last = { u: d.u, v: d.v, disparityPx: d.disparityPx ?? null };
765
+ return true;
766
+ }
767
+
768
+ /** Withdraw the depth (pointer off the element, 2D, teardown). Idempotent: one call per withdrawal. */
769
+ withdraw() {
770
+ this.last = null;
771
+ if (!this._sent) return;
772
+ this._sent = false;
773
+ try {
774
+ this.target?.setCursorDepth();
775
+ } catch {
776
+ /* a closed layer: its depth went with it */
777
+ }
778
+ }
779
+
780
+ dispose() {
781
+ this.withdraw();
782
+ this.target = null;
783
+ }
784
+ }
785
+
786
+ const clamp01 = (x) => (x > 0 ? (x < 1 ? x : 1) : 0);
787
+
602
788
  /**
603
789
  * A footprint ring: the point (u, v) plus 8 points on a circle of radius 0.75 × the sprite
604
790
  * height (canvas heights) around it.
@@ -1306,6 +1306,8 @@ export class PlayCanvasSplatViewer {
1306
1306
  onFrame(views, layer, frame) {
1307
1307
  if (this._disposed) return;
1308
1308
  if (this._mode !== '3d') this.stopMono();
1309
+ // cursor: 'depth' hands its depth to this layer where the runtime draws the web cursor (ADR-046 Amendment 4).
1310
+ this._frameLayer = layer || null;
1309
1311
  // The rig these views were located with: Blink chained the rig declared BEFORE this callback,
1310
1312
  // and the tick below may declare a new one (a focus ease) for the NEXT locate.
1311
1313
  if (this.layerRigs?.active) {
@@ -1348,6 +1350,7 @@ export class PlayCanvasSplatViewer {
1348
1350
  this._monoRaf = requestAnimationFrame(loop);
1349
1351
  this._beforeFrame?.(null);
1350
1352
  if (this._disposed || this._mode !== 'mono') return; // the callback removed or re-wove us
1353
+ this._frameLayer = null;
1351
1354
  this.cursorDepth?.inactive(); // 2D: the normal cursor
1352
1355
  this._tick();
1353
1356
  this._drawMono();
@@ -2221,7 +2224,7 @@ export class PlayCanvasSplatViewer {
2221
2224
  // handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
2222
2225
  this._videoPlane?.beforeDraw(entries, rect);
2223
2226
  // cursor: 'depth' — place the sprite and queue its lines for the tick below (no-op unless opted in).
2224
- this.cursorDepth?.frame(entries);
2227
+ this.cursorDepth?.frame(entries, this._frameLayer);
2225
2228
  app.tick(now());
2226
2229
  this._afterTick();
2227
2230
  return true;
@@ -55,6 +55,7 @@
55
55
  import {
56
56
  CursorDepthPlacer,
57
57
  CursorPointer,
58
+ CursorRuntimeLink,
58
59
  cursorArrowFootprint,
59
60
  cursorDefaultHeight,
60
61
  cursorModelMatrix,
@@ -569,6 +570,13 @@ export class DepthCursor {
569
570
  this.shapes = new CursorShapeSelector(this.pointer, { shape, shapeFor });
570
571
  this._tex = null; // { geometry, material } — the textured quad, built on the first non-arrow sprite
571
572
  this._textures = new Map(); // sprite key → THREE texture
573
+ /**
574
+ * The runtime-drawn cursor (runtime ADR-046 Amendment 4): when the frame's layer says
575
+ * "runtime", the depth goes to the browser and no sprite is drawn. Inert without a layer.
576
+ */
577
+ this.runtime = new CursorRuntimeLink();
578
+ /** The XRDisplayLayer this canvas weaves through; `update(views, { layer })` sets it per frame. */
579
+ this.layer = null;
572
580
 
573
581
  // The arrow: a dark outline polygon, the fill inset over it; tip at the origin (the hotspot).
574
582
  const fill = [((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255, 1];
@@ -606,10 +614,22 @@ export class DepthCursor {
606
614
 
607
615
  /**
608
616
  * Place the sprite for this frame. Call with the views you are about to render.
617
+ *
618
+ * Pass the frame's `layer` (the second argument of wall.addScene's callback) as
619
+ * `update(views, { layer })`: where the browser and the runtime draw the web cursor
620
+ * (`layer.getCursorDepthSupport()` → "runtime", runtime ADR-046 Amendment 4) the depth under the
621
+ * pointer is handed to the layer and no sprite is drawn — the browser's own cursor, lifted by
622
+ * the runtime. Without a layer, or where it says "none", the sprite is drawn as always.
609
623
  * @param {ArrayLike<XRView|{projectionMatrix:ArrayLike<number>,transformMatrix:ArrayLike<number>}>} views
610
- * @param {number} [nowMs=performance.now()]
624
+ * @param {number|{nowMs?: number, layer?: object|null}} [opts] the time (ms), or `{ nowMs, layer }`.
611
625
  */
612
- update(views, nowMs = globalThis.performance ? globalThis.performance.now() : Date.now()) {
626
+ update(views, opts) {
627
+ let nowMs = opts;
628
+ if (opts && typeof opts === 'object') {
629
+ if ('layer' in opts) this.layer = opts.layer || null;
630
+ nowMs = opts.nowMs;
631
+ }
632
+ if (!(typeof nowMs === 'number' && Number.isFinite(nowMs))) nowMs = globalThis.performance ? globalThis.performance.now() : Date.now();
613
633
  if (this.debugStats) return this._updateMeasured(views, nowMs);
614
634
  return this._update(views, nowMs);
615
635
  }
@@ -631,6 +651,7 @@ export class DepthCursor {
631
651
  }
632
652
 
633
653
  _update(views, nowMs) {
654
+ if (this.runtime.bind(this.layer)) return this._updateRuntime(views, nowMs);
634
655
  const vs = this._worldViews(views);
635
656
  const uv = this.pointer.uv;
636
657
  let p = { active: false };
@@ -661,6 +682,27 @@ export class DepthCursor {
661
682
  return p;
662
683
  }
663
684
 
685
+ // The runtime draws (ADR-046 Amendment 4): the same pick and the same placement maths, but the
686
+ // RAW content disparity goes to the layer in device px, and nothing is drawn or hidden here.
687
+ _updateRuntime(views, nowMs) {
688
+ this.object.visible = false;
689
+ this.softening = null;
690
+ this.pointer.hideCss(false);
691
+ const uv = this.pointer.uv;
692
+ const vs = uv && views && views.length >= 2 ? this._worldViews(views) : null;
693
+ if (!vs) {
694
+ this.placer.update(null); // forget the hybrid anchor
695
+ this.runtime.withdraw();
696
+ return (this.placement = { active: false, runtime: true });
697
+ }
698
+ const [u, v] = uv;
699
+ const h = this.height ?? cursorDefaultHeight(this.pointer.cssHeight());
700
+ const nearestPoint = this._footprintHit(vs, u, v, h);
701
+ const p = this.placer.update(vs, { u, v, nearestPoint, cursorHeight: h, canvasPx: this.pointer.panelPx() }, nowMs / 1000);
702
+ this.runtime.report(u, v, p.solved ? p.contentDisparityPx : null);
703
+ return (this.placement = { ...p, active: false, runtime: true });
704
+ }
705
+
664
706
  // Crisp frames draw the arrow mesh exactly as without softening; softened ones swap in a quad
665
707
  // whose shader evaluates the arrow at the runtime's 13 blur taps, times the opacity.
666
708
  _useSoft(s) {
@@ -804,6 +846,7 @@ export class DepthCursor {
804
846
 
805
847
  /** Remove listeners, give the CSS cursor back, free the sprite's GPU resources. */
806
848
  dispose() {
849
+ this.runtime.dispose();
807
850
  this.pointer.dispose();
808
851
  this.shapes.dispose();
809
852
  this.object.visible = false;
@@ -740,7 +740,8 @@ export class SceneViewer {
740
740
  // cursor: 'depth' (ADR-046) — only when a page opted in; null otherwise.
741
741
  if (this.cursorDepth) {
742
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 })));
743
+ // The layer too: where the runtime draws the web cursor (ADR-046 Amendment 4) the depth goes to it.
744
+ this.cursorDepth.update(g.entries.map((e) => ({ projectionMatrix: e.proj, transformMatrix: e.pose })), { layer });
744
745
  }
745
746
  const r = this.renderer;
746
747
  // getViewport() splits canvas.width; a drawing buffer the browser clamped behind our back
package/js/inline3d.js CHANGED
@@ -1274,6 +1274,42 @@ class Inline3D {
1274
1274
  win.viewRig = rig || null;
1275
1275
  return this._pushViewRig(win);
1276
1276
  },
1277
+ /**
1278
+ * Runtime ADR-046 Amendment 4: the depth under the pointer for THIS window, forwarded to its
1279
+ * live layer's `setCursorDepth` — `{ disparityPx, u, v }` (device px between the outermost
1280
+ * views, < 0 in front, RAW content disparity; u/v element-normalised, v down; no
1281
+ * `disparityPx` = nothing 3D there), or nothing / null to withdraw. Returns whether a live
1282
+ * layer took it. The depth cursors call this for you under `cursor: 'depth'`.
1283
+ */
1284
+ setCursorDepth: (depth) => {
1285
+ const layer = win.layer;
1286
+ if (!layer || typeof layer.setCursorDepth !== 'function') return false;
1287
+ try {
1288
+ if (depth) layer.setCursorDepth(depth);
1289
+ else layer.setCursorDepth();
1290
+ return true;
1291
+ } catch {
1292
+ return false;
1293
+ }
1294
+ },
1295
+ /**
1296
+ * "runtime" when the browser AND the runtime draw this window's cursor (the page then supplies
1297
+ * only the depth, setCursorDepth); "none" otherwise — including no live layer yet, so ask
1298
+ * again later. Never rejects.
1299
+ * @returns {Promise<'runtime'|'none'>}
1300
+ */
1301
+ getCursorDepthSupport: () => {
1302
+ const layer = win.layer;
1303
+ if (!layer || typeof layer.getCursorDepthSupport !== 'function') return Promise.resolve('none');
1304
+ try {
1305
+ return Promise.resolve(layer.getCursorDepthSupport()).then(
1306
+ (s) => (s === 'runtime' ? 'runtime' : 'none'),
1307
+ () => 'none',
1308
+ );
1309
+ } catch {
1310
+ return Promise.resolve('none');
1311
+ }
1312
+ },
1277
1313
  /**
1278
1314
  * The panel this window is weaving on: physical size in metres, pixel size, and the view
1279
1315
  * scale the runtime RECOMMENDS. Resolves null on a machine with no glasses-free display.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.45.0",
3
+ "version": "1.46.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",
package/three.d.ts CHANGED
@@ -143,6 +143,7 @@ export class EdgeFeather {
143
143
  ): void;
144
144
  }
145
145
 
146
+ import type { CursorRuntimeLink } from './cursor-depth.js';
146
147
  import type { CursorAnchorMode, CursorPlacement, CursorShape, CursorShapeHit, CursorShapeOption, CursorSoftenOption, CursorSoftenParams, CursorSoftening, CursorView } from './cursor-depth.js';
147
148
 
148
149
  /** One second of `debug` stats: each figure as `{ mean, p95, max, n }`. */
@@ -234,8 +235,17 @@ export class DepthCursor {
234
235
  readonly softening: CursorSoftening | null;
235
236
  /** The sprite drawn last: a name, 'none' or 'image'. */
236
237
  readonly shape: CursorShape | 'none' | 'image';
237
- /** Place the sprite for this frame — call with the views you are about to render, before rendering. */
238
- update(views: ArrayLike<XRView | CursorView>, nowMs?: number): CursorPlacement | { active: false };
238
+ /** The runtime-drawn cursor link (runtime ADR-046 Amendment 4). */
239
+ readonly runtime: CursorRuntimeLink;
240
+ /** The layer this canvas weaves through, as last passed to `update(views, { layer })`. */
241
+ layer: unknown;
242
+ /**
243
+ * Place the sprite for this frame — call with the views you are about to render, before rendering.
244
+ * Pass `{ layer }` (addScene's second callback argument): where it says "runtime" (ADR-046
245
+ * Amendment 4) the depth goes to the layer and the browser's own cursor is lifted by the runtime;
246
+ * no sprite is drawn. A number is the time in ms, as before.
247
+ */
248
+ update(views: ArrayLike<XRView | CursorView> | null, opts?: number | { nowMs?: number; layer?: unknown }): CursorPlacement | { active: false; runtime?: boolean };
239
249
  /** Set the pointer directly (canvas-normalised, v down), or null — for scripted input. */
240
250
  setPointer(u: number | null, v?: number): void;
241
251
  /** Remove listeners, restore the CSS cursor, free the sprite's GPU resources. */