@displayxr/inline3d 1.45.1 → 1.47.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.
@@ -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
 
@@ -626,6 +631,160 @@ function pageCursorHidden(hide) {
626
631
  else el.removeAttribute?.(CURSOR_HIDDEN_ATTR);
627
632
  }
628
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
+
629
788
  /**
630
789
  * A footprint ring: the point (u, v) plus 8 points on a circle of radius 0.75 × the sprite
631
790
  * height (canvas heights) around it.
@@ -0,0 +1,342 @@
1
+ // inline3d-depth-budget.js — how deep is this scene, in the units a 3D display cares about.
2
+ //
3
+ // Pure functions, dependency-free, re-exported by ./inline3d.js (the root), ./three and the
4
+ // `./depth` subpath. NOTHING HERE CHANGES A PIXEL: these are measurements, plus one-time warnings.
5
+ // The SDK never clamps, refuses or overrides a depth a page asked for. The one consumer that acts
6
+ // on a number from here is the OPT-IN `depth` option of ./model and ./splat, which feeds
7
+ // comfortDepthFactor's result into the display rig it declares (only ever lowering depth).
8
+ //
9
+ // The units are relative, which is what makes them portable across panels: disparity as a
10
+ // percentage of the TILE's width, or depth in TILE HEIGHTS on a display rig (where the canvas is a
11
+ // portal and world depth / virtualDisplayHeight equals physical depth / the tile's physical
12
+ // height). Depth ranges measured on a panel are in docs/authoring-inline-3d.md; they are reference
13
+ // points, not limits this module applies.
14
+ //
15
+ // The conversion on a display rig: physical depth z = depthH · h (h = the tile's physical height),
16
+ // on-screen disparity p = e · z / (V ∓ z) (e = 0.063 m, V = the nominal viewer distance, − in
17
+ // front, + behind), as a percentage of the tile width w = aspect · h. When h is unknown the
18
+ // small-depth limit p ≈ e · z / V is used, in which h cancels: pct ≈ 100 · e · depthH / (aspect · V).
19
+ //
20
+ // Comfort zones (vergence–accommodation conflict) are in DIOPTERS of vergence away from the glass:
21
+ // ΔD = 1/V − 1/z (> 0 behind, < 0 in front), and the on-screen disparity is p = e · V · ΔD. A zone
22
+ // is a max |ΔD| on each side; comfortZone() states it, comfortDepthFactor() says how much a display
23
+ // rig's depth would have to shrink for a subject to sit inside it (never more than 1: no zone here
24
+ // ever ADDS depth).
25
+
26
+ /** The eye separation every estimate here assumes (no browser surface reports a real one). */
27
+ export const DEPTH_EYE_SEPARATION_M = 0.063;
28
+
29
+ /** The viewer distance used when the display does not report its nominal viewer (desktop panel). */
30
+ export const DEPTH_DEFAULT_VIEWER_M = 0.6;
31
+
32
+ const num = (v) => typeof v === 'number' && Number.isFinite(v);
33
+
34
+ /**
35
+ * Disparity, % of the tile width, of a point `depthH` tile heights out of the glass on a display
36
+ * rig. SIGNED like display space: `depthH > 0` is IN FRONT (toward the viewer) and gives a NEGATIVE
37
+ * (crossed) disparity; `depthH < 0` is behind and gives a positive one — the same sign as
38
+ * {@link cameraRigDisparityPct}.
39
+ *
40
+ * @param {object} o
41
+ * @param {number} o.depthH tile heights out of the glass (+ in front, − behind).
42
+ * @param {number} o.aspect the tile's width / height.
43
+ * @param {number} [o.viewerM=0.6] nominal viewer distance, metres.
44
+ * @param {number} [o.heightM] the tile's physical height, metres. Unset: the small-depth limit.
45
+ * @param {number} [o.ipdFactor=1] a display rig's ipdFactor (disparity scales with it).
46
+ * @param {number} [o.eyeSeparationM=0.063]
47
+ * @returns {number} %, −Infinity at or past the viewer, NaN on bad input.
48
+ */
49
+ export function disparityPct({ depthH, aspect, viewerM = DEPTH_DEFAULT_VIEWER_M, heightM, ipdFactor = 1, eyeSeparationM = DEPTH_EYE_SEPARATION_M } = {}) {
50
+ if (!num(depthH) || !num(aspect) || !(aspect > 0) || !num(viewerM) || !(viewerM > 0) || !num(ipdFactor)) return NaN;
51
+ const h = num(heightM) && heightM > 0 ? heightM : 0;
52
+ const denom = viewerM - depthH * h;
53
+ if (!(denom > 0)) return -Infinity;
54
+ if (depthH === 0) return 0;
55
+ return (-100 * eyeSeparationM * ipdFactor * depthH) / (aspect * denom);
56
+ }
57
+
58
+ /**
59
+ * Disparity, % of the tile width, of a point at distance `z` on a CAMERA rig:
60
+ * `100 · b · (1/D − 1/z) / (2 · tan(verticalFov/2) · aspect)`, `b = 0.063 · ipdFactor · metersToVirtual`,
61
+ * D the convergence distance. `+` = behind the glass, `−` = in front. Convergence 0 / unset is
62
+ * infinity (1/D = 0): everything is in front.
63
+ *
64
+ * @param {object} o
65
+ * @param {number} o.z distance of the point along the camera's view axis, world units.
66
+ * @param {number} [o.convergence] zero-disparity distance, world units (0 = infinity).
67
+ * @param {number} [o.convergenceDiopters] or 1/distance, as the descriptor carries it.
68
+ * @param {number} [o.ipdFactor=1] [o.metersToVirtual=1]
69
+ * @param {number} o.verticalFov FULL vertical angle, RADIANS (the descriptor's unit).
70
+ * @param {number} o.aspect the tile's width / height.
71
+ * @returns {number} %, NaN on bad input.
72
+ */
73
+ export function cameraRigDisparityPct({ z, convergence, convergenceDiopters, ipdFactor = 1, metersToVirtual = 1, verticalFov, aspect } = {}) {
74
+ if (!num(z) || !(z > 0) || !num(verticalFov) || !(verticalFov > 0) || !num(aspect) || !(aspect > 0)) return NaN;
75
+ if (!num(ipdFactor) || !num(metersToVirtual)) return NaN;
76
+ const invD = num(convergenceDiopters) ? Math.max(0, convergenceDiopters) : num(convergence) && convergence > 0 ? 1 / convergence : 0;
77
+ const b = DEPTH_EYE_SEPARATION_M * ipdFactor * metersToVirtual;
78
+ return (100 * b * (invD - 1 / z)) / (2 * Math.tan(verticalFov / 2) * aspect);
79
+ }
80
+
81
+ /**
82
+ * A depth report from a display-rig subject box (SceneViewer.getSubjectBounds' `front` / `back`,
83
+ * display space: +z toward the viewer, glass at 0, units of the virtual display).
84
+ *
85
+ * `frontH` / `backH` are MAGNITUDES in tile heights: how far the nearest point sits in front of the
86
+ * glass and the farthest behind it (0 when nothing is on that side). `frontPct` / `backPct` are the
87
+ * matching disparity magnitudes, % of the tile width.
88
+ *
89
+ * @param {object} o
90
+ * @param {number} o.front z of the nearest surface.
91
+ * @param {number} o.back z of the farthest surface.
92
+ * @param {number} o.virtualDisplayHeight what one tile height is in those units.
93
+ * @param {number} o.aspect the tile's width / height.
94
+ * @param {number} [o.viewerM=0.6] [o.heightM] [o.ipdFactor=1] as {@link disparityPct}.
95
+ */
96
+ export function depthBudgetFromBounds({ front, back, virtualDisplayHeight, aspect, viewerM = DEPTH_DEFAULT_VIEWER_M, heightM, ipdFactor = 1 } = {}) {
97
+ const vH = virtualDisplayHeight;
98
+ const ok = num(front) && num(back) && num(vH) && vH > 0;
99
+ const frontH = ok ? Math.max(0, front / vH) : NaN;
100
+ const backH = ok ? Math.max(0, -back / vH) : NaN;
101
+ const common = { aspect, viewerM, heightM, ipdFactor };
102
+ const frontPct = Math.abs(disparityPct({ depthH: frontH, ...common }));
103
+ const backPct = Math.abs(disparityPct({ depthH: -backH, ...common }));
104
+ return {
105
+ frontH,
106
+ backH,
107
+ frontPct,
108
+ backPct,
109
+ basis: {
110
+ rig: 'display',
111
+ aspect,
112
+ viewerM,
113
+ heightM: num(heightM) && heightM > 0 ? heightM : null,
114
+ ipdFactor,
115
+ eyeSeparationM: DEPTH_EYE_SEPARATION_M,
116
+ },
117
+ };
118
+ }
119
+
120
+ // ── comfort zones ─────────────────────────────────────────────────────────────────────────────
121
+
122
+ /** The comfort zones comfortZone() knows by name; a positive number is a symmetric |ΔD| bound. */
123
+ const COMFORT_ZONES = Object.freeze(['shibata', 'percival']);
124
+
125
+ /** A `zone` comfortZone() accepts: a known name, or a finite number of diopters > 0. */
126
+ export function isComfortZone(zone) {
127
+ return COMFORT_ZONES.includes(zone) || (num(zone) && zone > 0);
128
+ }
129
+
130
+ /**
131
+ * The comfort zone for a viewer at `viewerM`: the largest vergence change, in diopters, that is
132
+ * comfortable in front of the glass (`frontD`) and behind it (`behindD`), and the matching
133
+ * on-screen disparity magnitudes in mm (`e · V · ΔD`).
134
+ *
135
+ * - `'shibata'` (default): Shibata et al. 2011, "The zone of comfort" (J. Vision 11(8):11). The
136
+ * near vergence limit is `Dv/1.035 + 0.626`, the far `Dv/1.129 − 0.442` (Dv = 1/V), so
137
+ * `frontD = (Dv/1.035 + 0.626) − Dv` and `behindD = Dv − (Dv/1.129 − 0.442)`. A far limit at or
138
+ * below 0 D (a distant viewer) leaves behind unbounded: `Infinity`. At 0.6 m: 0.57 D / 0.63 D.
139
+ * - `'percival'`: Percival's zone, ±1/3 D on both sides.
140
+ * - a number: a symmetric |ΔD| bound, diopters.
141
+ *
142
+ * @param {object} [o]
143
+ * @param {number} [o.viewerM=0.6] viewing distance, metres.
144
+ * @param {'shibata'|'percival'|number} [o.zone='shibata']
145
+ * @returns {{zone:'shibata'|'percival'|number, viewerM:number, frontD:number, behindD:number,
146
+ * frontMm:number, behindMm:number}} NaN fields on bad input.
147
+ */
148
+ export function comfortZone({ viewerM = DEPTH_DEFAULT_VIEWER_M, zone = 'shibata' } = {}) {
149
+ let frontD = NaN;
150
+ let behindD = NaN;
151
+ if (num(viewerM) && viewerM > 0 && isComfortZone(zone)) {
152
+ const Dv = 1 / viewerM;
153
+ if (zone === 'shibata') {
154
+ frontD = Dv / 1.035 + 0.626 - Dv;
155
+ const far = Dv / 1.129 - 0.442;
156
+ behindD = far > 0 ? Dv - far : Infinity;
157
+ } else if (zone === 'percival') {
158
+ frontD = behindD = 1 / 3;
159
+ } else {
160
+ frontD = behindD = zone;
161
+ }
162
+ }
163
+ const mm = (d) => DEPTH_EYE_SEPARATION_M * viewerM * d * 1000;
164
+ return { zone, viewerM, frontD, behindD, frontMm: mm(frontD), behindMm: mm(behindD) };
165
+ }
166
+
167
+ /**
168
+ * How much a DISPLAY rig's depth would have to shrink for a subject to sit inside a comfort zone:
169
+ * the largest `k ≤ 1` such that the subject's disparities scaled by `k` (a display rig's
170
+ * ipdFactor, with parallaxFactor moved with it) stay within {@link comfortZone}. 1 = it already
171
+ * fits; never above 1 — this only ever says "less".
172
+ *
173
+ * Exact geometry, at real proportions: the nearest point is `frontH` tile heights in front of the
174
+ * glass, physically `frontH · tileHeightM`, so at `z = V − frontH · tileHeightM` from the viewer;
175
+ * the farthest at `z = V + backH · tileHeightM`. Each side's `|ΔD| = |1/V − 1/z|` against the
176
+ * zone's bound on that side gives `k = min(1, frontD/|ΔD_front|, behindD/ΔD_behind)` (a side with
177
+ * no depth does not count). The SAME subject in tile heights asks more of a small, close screen —
178
+ * a tablet at 0.4 m carries more vergence per tile height than a laptop at 0.6 m — which is the
179
+ * whole reason this depends on the panel and not just on the scene.
180
+ *
181
+ * Returns 1 (no change) for any missing or invalid input — a non-finite depth, a height or
182
+ * viewer distance ≤ 0, an unknown zone — and when the front reaches the viewer (no finite
183
+ * disparity to scale): a measurement that cannot be made must never be read as "flatten it".
184
+ *
185
+ * @param {object} o
186
+ * @param {number} o.frontH MAGNITUDE, tile heights in front of the glass (0 = nothing in front).
187
+ * @param {number} o.backH MAGNITUDE, tile heights behind it.
188
+ * @param {number} o.tileHeightM the tile's (canvas's) physical height, metres.
189
+ * @param {number} [o.viewerM=0.6] NOMINAL viewer distance, metres.
190
+ * @param {'shibata'|'percival'|number} [o.zone='shibata']
191
+ * @returns {number} in (0, 1].
192
+ */
193
+ export function comfortDepthFactor({ frontH, backH, tileHeightM, viewerM = DEPTH_DEFAULT_VIEWER_M, zone = 'shibata' } = {}) {
194
+ if (!num(frontH) || !num(backH) || !num(tileHeightM) || !(tileHeightM > 0)) return 1;
195
+ const z = comfortZone({ viewerM, zone });
196
+ if (!num(z.frontD) || Number.isNaN(z.behindD)) return 1;
197
+ const V = viewerM;
198
+ const zf = V - Math.max(0, frontH) * tileHeightM;
199
+ const zb = V + Math.max(0, backH) * tileHeightM;
200
+ if (!(zf > 0)) return 1;
201
+ const dFront = 1 / zf - 1 / V;
202
+ const dBehind = 1 / V - 1 / zb;
203
+ let k = 1;
204
+ if (dFront > 0) k = Math.min(k, z.frontD / dFront);
205
+ if (dBehind > 0) k = Math.min(k, z.behindD / dBehind);
206
+ return k > 0 ? k : 1;
207
+ }
208
+
209
+ /**
210
+ * The CAMERA-rig counterpart of {@link comfortDepthFactor}: the multiplier `m` on the eye
211
+ * separation (the rig's ipdFactor) that puts the scene's nearest point (`zNear`) and farthest
212
+ * (`zFar`, may be Infinity) at the edge of the comfort zone, at the given viewing distance.
213
+ *
214
+ * Measurement only; the page decides whether to apply it. Unlike the display-rig factor it can
215
+ * exceed 1, because a camera rig's 1 is only physical in metre-scale worlds: `ipdFactor` 1 is
216
+ * 63 mm × metersToVirtual in scene units, which is right for a real-sized room and far too little
217
+ * for a scene authored in large units (it renders nearly flat).
218
+ *
219
+ * Method: the on-screen disparity as a fraction of the tile width is
220
+ * `f = b · (1/D − 1/z) / (2 · tan(verticalFov/2) · aspect)` (b = 0.063 · ipdFactor ·
221
+ * metersToVirtual; {@link cameraRigDisparityPct} / 100), physically `p = f · aspect · tileHeightM`
222
+ * on the glass, which is `ΔD = p / (0.063 · V)` diopters of vergence. `m` = the smallest of
223
+ * zone side / |ΔD side| over the sides with content (in front: z < D, behind: z > D).
224
+ * Convergence 0 / unset is infinity: everything is in front.
225
+ *
226
+ * @param {object} o
227
+ * @param {number} o.zNear nearest scene point along the view axis, world units (> 0).
228
+ * @param {number} o.zFar farthest, world units (≥ zNear; Infinity allowed).
229
+ * @param {number} [o.convergence] zero-disparity distance, world units (0 = infinity).
230
+ * @param {number} [o.convergenceDiopters] or 1/distance; wins over `convergence`.
231
+ * @param {number} o.verticalFov FULL vertical angle, RADIANS.
232
+ * @param {number} o.aspect the tile's width / height.
233
+ * @param {number} o.tileHeightM the tile's physical height, metres.
234
+ * @param {number} [o.viewerM=0.6] [o.zone='shibata'] [o.ipdFactor=1] [o.metersToVirtual=1]
235
+ * @returns {number} > 0, no upper bound; Infinity when nothing is off the plane; NaN on bad input.
236
+ */
237
+ export function cameraRigComfortFactor({
238
+ zNear,
239
+ zFar,
240
+ convergence,
241
+ convergenceDiopters,
242
+ verticalFov,
243
+ aspect,
244
+ tileHeightM,
245
+ viewerM = DEPTH_DEFAULT_VIEWER_M,
246
+ zone = 'shibata',
247
+ ipdFactor = 1,
248
+ metersToVirtual = 1,
249
+ } = {}) {
250
+ const farOk = num(zFar) || zFar === Infinity;
251
+ if (!num(zNear) || !(zNear > 0) || !farOk || !(zFar >= zNear)) return NaN;
252
+ if (!num(verticalFov) || !(verticalFov > 0) || !num(aspect) || !(aspect > 0) || !num(tileHeightM) || !(tileHeightM > 0)) return NaN;
253
+ if (!num(ipdFactor) || !(ipdFactor > 0) || !num(metersToVirtual) || !(metersToVirtual > 0)) return NaN;
254
+ const cz = comfortZone({ viewerM, zone });
255
+ if (!num(cz.frontD)) return NaN;
256
+ const invD = num(convergenceDiopters) ? Math.max(0, convergenceDiopters) : num(convergence) && convergence > 0 ? 1 / convergence : 0;
257
+ const b = DEPTH_EYE_SEPARATION_M * ipdFactor * metersToVirtual;
258
+ // ΔD of a point at z (+ behind, − in front): f · aspect · H / (e · V), the aspect cancelling.
259
+ const dD = (z) => (b * (invD - 1 / z) * tileHeightM) / (2 * Math.tan(verticalFov / 2) * DEPTH_EYE_SEPARATION_M * viewerM);
260
+ let m = Infinity;
261
+ const front = -dD(zNear);
262
+ const behind = dD(zFar);
263
+ if (front > 0) m = Math.min(m, cz.frontD / front);
264
+ if (behind > 0) m = Math.min(m, cz.behindD / behind);
265
+ return m;
266
+ }
267
+
268
+ // ── one-time warnings (never a behaviour change) ──────────────────────────────────────────────
269
+
270
+ const CONVERGENCE_MSG =
271
+ '[inline3d] camera rig with convergenceDiopters 0: convergence at infinity puts the whole scene in front of the glass; ' +
272
+ 'set convergence to the subject\'s distance.';
273
+
274
+ /** Descriptors a rig builder already warned about, so setViewRig does not say it twice. */
275
+ const builderWarned = new WeakSet();
276
+ let builderWarnedOnce = false;
277
+
278
+ /** cameraRigFromCamera / cameraRigFromPose: warn once per page when convergence is 0 / unset. */
279
+ export function noteBuiltCameraRig(rig) {
280
+ if (rig.convergenceDiopters > 0) return;
281
+ if (!builderWarnedOnce) {
282
+ builderWarnedOnce = true;
283
+ console.warn(CONVERGENCE_MSG);
284
+ }
285
+ builderWarned.add(rig);
286
+ }
287
+
288
+ /**
289
+ * What looks wrong about a view-rig descriptor: `[key, message]` pairs (key = 'convergence' |
290
+ * 'factor' | 'fov'). Read only; the descriptor goes to the runtime exactly as given.
291
+ */
292
+ export function viewRigWarnings(rig) {
293
+ const out = [];
294
+ if (!rig || typeof rig !== 'object') return out;
295
+ if (rig.type === 'camera') {
296
+ if (!(rig.convergenceDiopters > 0)) out.push(['convergence', CONVERGENCE_MSG]);
297
+ if (rig.verticalFov > Math.PI) {
298
+ out.push([
299
+ 'fov',
300
+ `[inline3d] camera rig verticalFov ${rig.verticalFov} is more than π: it looks like DEGREES — the descriptor takes RADIANS ` +
301
+ '(cameraRigFromCamera / cameraRigFromPose convert three\'s degrees for you).',
302
+ ]);
303
+ }
304
+ } else if (rig.ipdFactor > 1 || rig.parallaxFactor > 1) {
305
+ out.push([
306
+ 'factor',
307
+ `[inline3d] display rig ipdFactor ${rig.ipdFactor ?? 1} / parallaxFactor ${rig.parallaxFactor ?? 1}: above 1 exaggerates ` +
308
+ 'depth past what the display shows by construction (1 = correct; below 1 = a comfort dial). Sent as given.',
309
+ ]);
310
+ }
311
+ return out;
312
+ }
313
+
314
+ /** Warn each of viewRigWarnings(rig) once per `seen` set (one per handle). */
315
+ export function warnViewRigOnce(seen, rig) {
316
+ if (!rig || typeof rig !== 'object') return;
317
+ // The cheap common case first: called per frame by pages that drive a rig.
318
+ if (rig.type === 'camera' ? rig.convergenceDiopters > 0 && !(rig.verticalFov > Math.PI) : !(rig.ipdFactor > 1 || rig.parallaxFactor > 1)) return;
319
+ for (const [key, msg] of viewRigWarnings(rig)) {
320
+ if (seen.has(key)) continue;
321
+ seen.add(key);
322
+ if (key === 'convergence' && builderWarned.has(rig)) continue; // the builder said it already
323
+ console.warn(msg);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * The hint for a `wovenState === 'withheld'` reason token (docs/woven-canvas-rules.md §3).
329
+ */
330
+ export function withheldReasonHint(reason) {
331
+ const r = String(reason || '');
332
+ if (r.startsWith('cross-pass')) {
333
+ return 'the canvas is inside its own render surface: a CSS effect on it or an ancestor (filter, opacity < 1, mask, clip-path, mix-blend-mode, backdrop-filter). Remove it (woven-canvas rule 7)';
334
+ }
335
+ if (r === 'no-quad') {
336
+ return 'the canvas drew nothing the compositor can see: it is not drawing, is occluded, or a CSS effect on it or an ancestor (filter, opacity < 1, mask, clip-path, mix-blend-mode, backdrop-filter) hides it (woven-canvas rule 7)';
337
+ }
338
+ if (r === 'no-identity') return 'the canvas reached the browser without its identity: register it once, keep it mounted, cover it until firstWoven (woven-canvas rules 3-5)';
339
+ if (r === 'no-join') return 'nothing joined this rect; harmless off screen, otherwise check the canvas is drawing and on screen';
340
+ if (r === 'resolve-dropped') return "the browser could not resolve the canvas's GPU resource (a browser-side issue)";
341
+ return 'see docs/woven-canvas-rules.md §3';
342
+ }
@@ -21,7 +21,7 @@
21
21
  // engine import fails, one warning names what to install, and the tile renders on three. With
22
22
  // neither, `ready` rejects naming both. An explicit `engine` never falls back.
23
23
 
24
- import { guardedAttach } from './inline3d-splat-shared.js';
24
+ import { guardedAttach, validateSetStereo, resolveDepthOption } from './inline3d-splat-shared.js';
25
25
  /** The engines `addModel` renders with; the first is the default. */
26
26
  export const MODEL_ENGINES = Object.freeze(['playcanvas', 'three']);
27
27
 
@@ -71,6 +71,7 @@ export function validateModelCall(canvas, src, opts, route) {
71
71
  if (opts.orbitStyle !== undefined && opts.orbitStyle !== 'turntable' && opts.orbitStyle !== 'tilt') {
72
72
  throw new Error(`@displayxr/inline3d/model: orbitStyle must be 'turntable' or 'tilt' (got ${String(opts.orbitStyle)}).`);
73
73
  }
74
+ resolveDepthOption(opts.depth, '@displayxr/inline3d/model');
74
75
  if (route.engine === 'three' && route.explicit) {
75
76
  const bad = PLAYCANVAS_ONLY.filter((k) => opts[k] !== undefined && !(k === 'controls' && opts[k] === 'viewer'));
76
77
  if (bad.length) {
@@ -141,6 +142,15 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
141
142
  remove: queue('remove'),
142
143
  exclude: queue('exclude'),
143
144
  unexclude: queue('unexclude'),
145
+ // Validated now (a bad argument throws at the call, as once the backend is in), replayed then.
146
+ setStereo(o) {
147
+ validateSetStereo(o, null, '@displayxr/inline3d/model');
148
+ pending.push(['setStereo', [o]]);
149
+ return out;
150
+ },
151
+ rig: null,
152
+ // The `depth` option's comfort factor in force (1 until measured, and always without it).
153
+ depthFactor: 1,
144
154
  // Before the backend lands: once it has, the backend's own (PlayCanvas); three has none (0).
145
155
  warmEntity: (entity, o) => out.ready.then(() => (out.warmEntity !== stubWarm && typeof out.warmEntity === 'function' ? out.warmEntity(entity, o) : 0)),
146
156
  };
@@ -233,6 +243,10 @@ async function attachThree(out, wall, canvas, src, opts, pending, mod) {
233
243
  }
234
244
  out.backend = 'three';
235
245
  out.engine = null;
246
+ for (const k of ['rig', 'viewRig', 'depthFactor']) {
247
+ Object.defineProperty(out, k, { get: () => inner[k], enumerable: true, configurable: true });
248
+ }
249
+ out.setStereo = (o) => (inner.setStereo(o), out);
236
250
  out.setPose = inner.setPose;
237
251
  out.resetPose = inner.resetPose;
238
252
  out.remove = inner.remove;