@displayxr/inline3d 1.46.0 → 1.47.1

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,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;
@@ -38,7 +38,8 @@
38
38
  // └── eye camera, N RenderViews, TONEMAP_NEUTRAL, no sky layer
39
39
 
40
40
  import { PlayCanvasSplatViewer, pageRigTRS, pageViewAxis, pageViewRig } from './inline3d-splat-playcanvas.js';
41
- import { resolveControls, normalizeCameraPose, now, MAX_DT_S, declareViewRig } from './inline3d-splat-shared.js';
41
+ import { resolveControls, normalizeCameraPose, now, MAX_DT_S, declareViewRig, attachModelStereo, attachComfortDepth, resolveDepthOption, framedSubjectBounds } from './inline3d-splat-shared.js';
42
+ import { displayRig } from './inline3d-three.js';
42
43
  import {
43
44
  NEUTRAL_STUDIO,
44
45
  neutralStudioRadiance,
@@ -440,6 +441,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
440
441
  } = opts;
441
442
  const ctl = validatePlayCanvasModelOptions(opts);
442
443
  const pageMode = ctl.page;
444
+ const depthOption = resolveDepthOption(opts.depth, '@displayxr/inline3d/model');
443
445
  if (pageMode && ctl.ignored.length) {
444
446
  console.info(`${TAG} controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ${ctl.ignored.length === 1 ? 'is' : 'are'} ignored.`);
445
447
  }
@@ -468,6 +470,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
468
470
  sky: false,
469
471
  viewerEase: opts.viewerEase,
470
472
  cursor: opts.cursor,
473
+ // Opt-in; undefined = the viewer's own default (a transparent clear).
474
+ clearColor: opts.clearColor,
471
475
  // Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
472
476
  toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
473
477
  });
@@ -510,6 +514,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
510
514
  },
511
515
  remove() {
512
516
  removed = true;
517
+ depth.dispose();
513
518
  handle?.remove();
514
519
  viewer.dispose(); // app.destroy(): the model, its textures and any page entities go with it
515
520
  },
@@ -530,6 +535,21 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
530
535
  Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
531
536
  Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
532
537
 
538
+ // handle.setStereo / handle.rig (a depth slider; nothing is declared until it is called).
539
+ const stereo = attachModelStereo(out, { getTile: () => handle, vH: virtualDisplayHeight, makeDisplayRig: displayRig, page: pageMode });
540
+ // `depth: 'auto'` (opt-in): the comfort factor under setStereo's, on the same display rig.
541
+ // Without it, inert: `depthFactor` reads 1 and nothing is measured or declared.
542
+ const depth = attachComfortDepth(out, {
543
+ option: depthOption,
544
+ canvas,
545
+ getTile: () => handle,
546
+ getBounds: () => (out.frame ? framedSubjectBounds(viewer) : null),
547
+ getVH: () => virtualDisplayHeight,
548
+ apply: (k) => stereo.setBase(k),
549
+ tag: TAG,
550
+ });
551
+ if (pageMode) depth.ignore("controls:'page' (the page's camera rig)");
552
+
533
553
  if (wall && wall.supported) {
534
554
  handle = wall.addScene(canvas, viewer.onFrame, {
535
555
  ...(pageMode
@@ -574,7 +594,11 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
574
594
  const dm = (c[0] - ax.origin[0]) * ax.forward[0] + (c[1] - ax.origin[1]) * ax.forward[1] + (c[2] - ax.origin[2]) * ax.forward[2];
575
595
  if (dm > 0) d = dm / (ax.scale || 1);
576
596
  }
577
- out.viewRig = pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: d ?? 2, comfortDepth: ctl.comfortDepth }, out.viewRig || {});
597
+ // setStereo's values, ABSOLUTE on this camera rig (1 and 1 until it is called: as before).
598
+ out.viewRig = pageViewRig(
599
+ { verticalFovDeg: viewer.page.fov, convergence: d ?? 2, comfortDepth: ctl.comfortDepth, ipdFactor: out.rig.ipdFactor, parallaxFactor: out.rig.parallaxFactor },
600
+ out.viewRig || {},
601
+ );
578
602
  declareViewRig(handle, out.viewRig);
579
603
  };
580
604
  if (typeof opts.onBeforeFrame === 'function') {
@@ -666,6 +690,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
666
690
  if (bounds) {
667
691
  out.frame = bounds;
668
692
  viewer.fitTo(bounds.center, bounds.extent);
693
+ depth.refresh();
669
694
  }
670
695
  return out;
671
696
  })();
@@ -38,9 +38,9 @@
38
38
  // your own origin (`/draco/`, `/basis/`) and `opts.decoderPath` moves that. See
39
39
  // docs/authoring-inline-3d.md#compressed-gltf.
40
40
 
41
- import { guardedAttach } from './inline3d-splat-shared.js';
41
+ import { guardedAttach, attachModelStereo, attachComfortDepth, resolveDepthOption, framedSubjectBounds } from './inline3d-splat-shared.js';
42
42
  import * as THREE from 'three';
43
- import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest } from './inline3d-three.js';
43
+ import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest, displayRig } from './inline3d-three.js';
44
44
  import { resolveCursorOption } from './inline3d-cursor-depth.js';
45
45
  import { CURSOR_MODEL_RAYS_PER_FRAME } from './inline3d-cursor-perf.js';
46
46
  import { SceneViewer } from './inline3d-viewer.js';
@@ -403,6 +403,8 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
403
403
 
404
404
  const paths = normalizeDecoderPath(decoderPath);
405
405
  const injected = { draco: injectedDraco, ktx2: injectedKtx2, meshopt: injectedMeshopt };
406
+ // Opt-in comfort depth; a bad value throws here, at the call.
407
+ const depthOption = resolveDepthOption(opts.depth, '@displayxr/inline3d/model');
406
408
 
407
409
  const viewer = new SceneViewer(THREE, canvas, {
408
410
  virtualDisplayHeight,
@@ -416,6 +418,8 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
416
418
  feather,
417
419
  logTag: '[inline3d/model]',
418
420
  viewerEase: opts.viewerEase,
421
+ // Opt-in; undefined = the viewer's own default (a transparent clear).
422
+ clearColor: opts.clearColor,
419
423
  }).useEyeCamera(EyeCamera, EdgeFeather);
420
424
  const cursorOpts = resolveCursorOption(opts.cursor, '@displayxr/inline3d/model');
421
425
  // Amortised pick (a few rays a frame): the sprite follows the newest pointer position every
@@ -436,6 +440,7 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
436
440
  setPose: (p) => viewer.setPose(p),
437
441
  resetPose: () => viewer.resetPose(),
438
442
  remove() {
443
+ depth.dispose();
439
444
  handle?.remove();
440
445
  viewer.dispose();
441
446
  if (out.model) disposeTree(out.model);
@@ -450,6 +455,20 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
450
455
  Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
451
456
  Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
452
457
 
458
+ // handle.setStereo / handle.rig (a depth slider; nothing is declared until it is called).
459
+ const stereo = attachModelStereo(out, { getTile: () => handle, vH: virtualDisplayHeight, makeDisplayRig: displayRig });
460
+ // `depth: 'auto'` (opt-in): the comfort factor under setStereo's, on the same display rig.
461
+ // Without it, inert: `depthFactor` reads 1 and nothing is measured or declared.
462
+ const depth = attachComfortDepth(out, {
463
+ option: depthOption,
464
+ canvas,
465
+ getTile: () => handle,
466
+ getBounds: () => (out.frame ? framedSubjectBounds(viewer) : null),
467
+ getVH: () => virtualDisplayHeight,
468
+ apply: (k) => stereo.setBase(k),
469
+ tag: '[inline3d/model]',
470
+ });
471
+
453
472
  // Window first, content when it lands — same reasoning as ./splat: a grid should not appear
454
473
  // one tile at a time in download order.
455
474
  let handle = null;
@@ -528,6 +547,7 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
528
547
  if (bounds) {
529
548
  out.frame = bounds;
530
549
  viewer.fitTo(bounds.center, bounds.extent);
550
+ depth.refresh();
531
551
  }
532
552
  return out;
533
553
  })().catch((err) => {
@@ -53,6 +53,8 @@ export function addSplatDeferred(wall, canvas, src, opts) {
53
53
  frame: null,
54
54
  camera: null,
55
55
  rig: null,
56
+ // The `depth` option's comfort factor in force (1 until measured, and always without it).
57
+ depthFactor: 1,
56
58
  perf: null,
57
59
  setPose: page ? pageOnly('setPose') : queue('setPose'),
58
60
  resetPose: page ? pageOnly('resetPose') : queue('resetPose'),
@@ -16,7 +16,7 @@
16
16
  // Not here: measureSplatBounds (it measures a Spark mesh — import it from ./splat).
17
17
 
18
18
  import { resolveRevealOption } from './inline3d-splat-effects.js';
19
- import { CAPTURE_FITS, guardedAttach, playcanvasCannotRead, resolveControls } from './inline3d-splat-shared.js';
19
+ import { CAPTURE_FITS, guardedAttach, playcanvasCannotRead, resolveControls, resolveDepthOption } from './inline3d-splat-shared.js';
20
20
  import { addSplatDeferred } from './inline3d-splat-deferred.js';
21
21
 
22
22
  export { applySplatPerf, SPLAT_PERF_PRESETS } from './inline3d-splat-perf.js';
@@ -44,6 +44,7 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
44
44
  );
45
45
  }
46
46
  resolveControls(opts);
47
+ resolveDepthOption(opts.depth);
47
48
  resolveRevealOption(opts.reveal);
48
49
  const why = playcanvasCannotRead(src, opts);
49
50
  if (why) throw new Error(`@displayxr/inline3d/splat: ${why}`);