@displayxr/inline3d 1.5.1 → 1.6.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,93 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.6.1 — 2026-09-09
9
+
10
+ Touches the **core tier** (`.`) with a behaviour fix only — no API changes — and the **preview tier**
11
+ (`./viewer`, `./splat`, `./model`) with one additive option. A page that ignores everything below
12
+ renders identically while it is weaving; the changes only decide what a canvas shows once nothing
13
+ weaves it any more.
14
+
15
+ ### Fixed
16
+
17
+ - **A canvas nothing is weaving can no longer be left holding a raw side-by-side pair**
18
+ ([#28](https://github.com/DisplayXR/displayxr-web/issues/28), field report
19
+ [browser-pvt#99](https://github.com/DisplayXR/displayxr-browser-pvt/issues/99)). Every fallback in
20
+ this SDK was decided once at boot, so four paths ended with a tile showing a flat squeezed
21
+ left|right pair, permanently — the symptom users report as "3D element shows SBS", usually on a
22
+ slow connection:
23
+ - session `end` tore down without the mono repaint `_deactivate` does — every image and video
24
+ canvas kept its last SBS frame; teardown, deactivate and a failed activate now share one
25
+ `_paintMono`;
26
+ - an image whose download landed **after** teardown painted a fresh SBS pair into a canvas with no
27
+ layer — `_paint` now forces the mono branch (and a 1:1 buffer) whenever the manager is stopped or
28
+ the window has no live layer, whatever `win.sbs` says;
29
+ - a throwing `new XRDisplayLayer()` was swallowed silently and left the canvas as it was — it now
30
+ warns once per window (with the error) and repaints mono; still no retry;
31
+ - a buffering video (`readyState < 2`) skipped its paint entirely, so its canvas layer went idle and
32
+ could drop out of the browser's aggregated frame — it now re-commits its last decoded frame with
33
+ an identity blit.
34
+ - **One throwing scene no longer stops the windows after it from repainting.** `onFrame` is contained
35
+ per window and warned about once; an un-redrawn canvas is exactly what the browser's weave join
36
+ loses.
37
+
38
+ ### Added
39
+
40
+ - **`addScene({ onLayerLost })`** (preview tier): called once when a scene window's layer goes away
41
+ for good (session end, or the layer could not be created) — not when a lazy tile scrolls off. The
42
+ SDK does not own a scene canvas's pixels, so this is how it tells the owner to go flat.
43
+ `SceneViewer.onLayerLost` is the ready-made handler (`startMono()`), and `./splat` / `./model` wire
44
+ it for you.
45
+
46
+ ## 1.6.0 — 2026-09-08
47
+
48
+ Touches the **preview tier** (`./viewer`) and fixes a **documentation error in the core tier**.
49
+ Purely additive to the viewer's API: no framing behaviour changes and no existing option changes
50
+ meaning, so a page that ignores everything below renders identically to 1.5.1.
51
+
52
+ ### Added
53
+
54
+ - **`SceneViewer` has an output surface** ([#26](https://github.com/DisplayXR/displayxr-web/issues/26)).
55
+ It could frame, scale and orbit a subject but never say where the subject ended up, so pages
56
+ that needed that — a pop-out readout, a depth-budget check, a HUD that must clear the model —
57
+ had to read `_pivot`, `_fitScale` and `_zoom`. Three additions replace all of it:
58
+ - **`getSubjectBounds()`** → `{center, extent, front, back, scale}` in **display metres**, for
59
+ the pose being drawn. `front` is the surface nearest the viewer (`> 0` = out of the glass),
60
+ `back` the far side, `scale` the model-unit → metre factor in force (fit × zoom).
61
+ **Call it per frame.** The orbit rotates the *subject*, so yaw swings its depth into the
62
+ display's `z`: a page-shaped subject 1 m × 0.02 m is 0.01 m deep face-on and 0.5 m deep
63
+ turned side-on. Anything measured once at load is correct at yaw 0 and wrong everywhere
64
+ else — and with `idleSpin` on, yaw 0 is a passing instant. The call allocates one object and
65
+ does no matrix work.
66
+ - **`depthOffset`** (get/set) — slides the subject along the depth axis in display metres,
67
+ `+` toward the viewer. It **translates and never rescales**, so it moves the depth budget
68
+ without resizing it.
69
+ - **`getPose({target})`** — the counterpart to `setPose`. Yaw/pitch/zoom are eased, so
70
+ mid-gesture "what is drawn" and "what it is settling toward" genuinely differ; the default
71
+ reports the drawn value, `target: true` the destination.
72
+ - `setPose()` now also accepts `depthOffset`.
73
+
74
+ ### Changed
75
+
76
+ - **`fitTo()` no longer discards `depthOffset`.** It used to hardcode the pivot's z to 0, so
77
+ reframing a subject silently threw away where the author had placed it. `resetPose()` clears
78
+ it, which is where "back to default" belongs. **No effect on any 1.5.x page**: the offset is 0
79
+ unless something sets it, and 0 is what `fitTo` used to write.
80
+ - `samples/model` reads `getSubjectBounds()` instead of `_fitScale`, and prints its footprint and
81
+ pop-out **live** — the visible disagreement between that and the static fit numbers is the
82
+ point, and is why the call is per-frame.
83
+
84
+ ### Fixed
85
+
86
+ - **The depth axis was documented BACKWARDS.** `docs/authoring-inline-3d.md` and the header of
87
+ `inline3d-three.js` both said "`+z` behind the glass, `−z` in front". It is the other way
88
+ round: the runtime places the nominal viewer at `z = +0.6 m` with the glass at `z = 0`
89
+ (`dxr_view_math`'s `nomv`), so **`+z` is toward the viewer, out of the glass**. Every mono
90
+ fallback camera in this repo already sat at `+z` for that reason, so only the prose was wrong —
91
+ but the prose is what authors code against, and the symptom is a depth control whose labels are
92
+ inverted. Symmetric subjects hide it completely. New section:
93
+ [Which way is out](docs/authoring-inline-3d.md#which-way-is-out).
94
+
8
95
  ## 1.5.1 — 2026-09-06
9
96
 
10
97
  ### Fixed
package/README.md CHANGED
@@ -65,8 +65,14 @@ if (!wall.supported) {
65
65
  The browser weaves each element's stereo pair at its on-screen rect; the surrounding DOM stays flat.
66
66
  The runtime batches every visible window into one weave per frame, so it scales to a wall of elements.
67
67
 
68
- A scene with its own camera can hand that camera to the runtime instead of being told where the eyes
69
- are a **camera rig** and let eye tracking perturb its frustum:
68
+ Most pages want the default a **display rig**, where the canvas is a portal onto a virtual
69
+ display and the runtime places the eyes. That includes every scene that frames a *subject* (a model
70
+ or splat viewer, an avatar, a product hero) **even when the user orbits it**: rotate the subject,
71
+ not the camera, and the stereo comes out the same for a figurine and an airliner.
72
+
73
+ A scene whose viewpoint the user *moves through a world* — first person, a walkthrough, a game, a
74
+ map, an editor, a ported VR app — can instead hand its own camera to the runtime and let eye
75
+ tracking perturb that frustum. That is a **camera rig**:
70
76
 
71
77
  ```js
72
78
  import { cameraRigFromCamera } from '@displayxr/inline3d/three';
@@ -104,7 +110,8 @@ js/
104
110
  inline3d.js the SDK: createInline3D() → { addImage, addVideo, addScene }, feature-detect,
105
111
  SBS buffer management, and a lazy create/close lifecycle for many windows
106
112
  inline3d-three.js optional three.js helper (EyeCamera: off-axis projection from the session's eyes)
107
- inline3d-viewer.js experimental: SceneViewer — framing, orbit, idle turntable, mono fallback
113
+ inline3d-viewer.js experimental: SceneViewer — framing, orbit, idle turntable, mono fallback,
114
+ and the placement readback (getSubjectBounds / getPose / depthOffset)
108
115
  inline3d-splat.js experimental: addSplat() — a Gaussian splat window via Spark
109
116
  inline3d-model.js experimental: addModel() — a glTF/GLB window; wires Draco / meshopt / KTX2
110
117
  from what the asset declares (you serve the decoder files — see the guide)
package/index.d.ts CHANGED
@@ -179,6 +179,15 @@ export interface SceneOptions extends TileOptions {
179
179
  viewRig?: XRViewRigInit;
180
180
  /** Element whose visibility drives the lazy create/close lifecycle (defaults to the canvas). */
181
181
  observe?: Element;
182
+ /**
183
+ * Called once when this window's weave layer goes away for good — the session ended, or the
184
+ * layer could not be created. You own a scene canvas's pixels, so this is the SDK's only way
185
+ * to tell you that the side-by-side pair in it is no longer being woven and is now just
186
+ * squeezed 2D on the page: take the canvas flat here (`SceneViewer.startMono`, or your own
187
+ * mono path). NOT called when a lazy tile merely scrolls off screen — that layer is coming
188
+ * back. A throw is caught and warned about.
189
+ */
190
+ onLayerLost?: () => void;
182
191
  }
183
192
 
184
193
  /** The per-frame render callback passed to {@link Inline3D.addScene}. */
@@ -393,6 +393,9 @@ export function addModel(wall, canvas, src, opts = {}) {
393
393
  if (wall && wall.supported) {
394
394
  handle = wall.addScene(canvas, viewer.onFrame, {
395
395
  virtualDisplayHeight,
396
+ // The layer can go away for good (the session ends, the constructor refuses): take the
397
+ // canvas flat rather than leave its last side-by-side frame on the page (web#28).
398
+ onLayerLost: viewer.onLayerLost,
396
399
  ...(observe ? { observe } : {}),
397
400
  });
398
401
  } else {
@@ -228,6 +228,9 @@ export function addSplat(wall, canvas, src, opts = {}) {
228
228
  if (wall && wall.supported) {
229
229
  handle = wall.addScene(canvas, viewer.onFrame, {
230
230
  virtualDisplayHeight,
231
+ // The layer can go away for good (the session ends, the constructor refuses): take the
232
+ // canvas flat rather than leave its last side-by-side frame on the page (web#28).
233
+ onLayerLost: viewer.onLayerLost,
231
234
  ...(observe ? { observe } : {}),
232
235
  });
233
236
  } else {
@@ -41,8 +41,8 @@
41
41
  // are already scaled to your scene by the layer's `virtualDisplayHeight` (see addScene) — the
42
42
  // runtime places each eye at eye_physical × (virtualDisplayHeight / element_physical_height),
43
43
  // so the z=0 plane spans that virtual display. Author your scene in metres for a display that
44
- // tall (0.24 m by default), put focused content at z=0 (positive z behind the glass, negative
45
- // in front), and render `eye.camera` directly. No per-frame world scaling — that is the whole
44
+ // tall (0.24 m by default), put focused content at z=0 (POSITIVE z is toward the viewer, out
45
+ // of the glass; negative z is behind it), and render `eye.camera` directly. No per-frame world scaling — that is the whole
46
46
  // point of using the rig instead of re-deriving it in the app, and it mirrors the native
47
47
  // reference apps (cube_handle), which supply one scale number and consume render-ready views.
48
48
  //
@@ -167,10 +167,18 @@ function scratch(THREE) {
167
167
  * anything here: this function only fills in a descriptor, and every off-axis projection stays
168
168
  * in the runtime, where it is the same code the native apps use.
169
169
  *
170
+ * WHICH RIG. Decide by what the USER moves, not by whether you hold a camera. If the user turns
171
+ * a SUBJECT — a model, a splat, an avatar, a product hero, and yes, an orbit around one — use a
172
+ * display rig and rotate the subject under a fixed portal: a display rig is scale-invariant, so a
173
+ * figurine and an airframe get the same stereo. A camera rig is literal (two eyes 63 mm apart at
174
+ * your camera), so its disparity falls off as baseline / framing distance and a big subject framed
175
+ * from far away renders FLAT. Use a camera rig for a viewpoint the user moves through a world:
176
+ * first person, a walkthrough, a game, a map, an editor, a ported VR app.
177
+ *
170
178
  * CONVERGENCE IS THE ONE KNOB TO GET RIGHT. It is the distance at which content sits ON the
171
179
  * glass; everything nearer pops out, everything further recedes. Point it at whatever the viewer
172
- * is meant to be looking at (an orbit target, a hit-tested surface) — for an orbiting camera
173
- * that is usually just the orbit radius. Left at 0 it means infinity, which puts the entire
180
+ * is meant to be looking at (the subject's centre, a hit-tested surface) — with a moving camera
181
+ * that is usually just the distance to it. Left at 0 it means infinity, which puts the entire
174
182
  * scene in front of the display and is comfortable for almost nothing.
175
183
  *
176
184
  * COMFORT. The runtime's rule is `ipdFactor × metersToVirtual × convergenceDiopters × N <= 1`
@@ -180,6 +188,16 @@ function scratch(THREE) {
180
188
  * runtime clamps out-of-range values itself, once, with a warning — but a scene authored in
181
189
  * centimetres with a 0.1-unit convergence is the shape of the mistake.
182
190
  *
191
+ * COMFORT IS NOT A DEPTH METER. It bounds where the depth budget SITS (it guards the background
192
+ * against divergence), not how big the budget is. The budget is
193
+ * `(baseline / tan(vFov/2)) * (1/z_near - 1/z_far)`, which convergence cancels out of exactly —
194
+ * convergence slides the scene relative to the glass and never resizes its depth, which is why
195
+ * ipdFactor/parallaxFactor here are ABSOLUTE rather than scaled by the convergence distance. A
196
+ * window that weaves but looks FLAT is a budget problem (a 63 mm baseline framing a large subject
197
+ * from far away) and comfort will report a healthy number while it happens. Do not rescale the
198
+ * baseline to paper over it: a camera rig needing a scale correction is a scene that wanted a
199
+ * display rig.
200
+ *
183
201
  * @param {object} THREE your imported three.js module namespace.
184
202
  * @param {object} camera a THREE.PerspectiveCamera (`.fov` in degrees, `.matrixWorld` current).
185
203
  * @param {object} [opts]
@@ -28,7 +28,11 @@
28
28
  // viewer.content.add(myMesh);
29
29
  // viewer.fitTo(center, extent); // model-space bounds of the subject
30
30
  // const wall = await createInline3D();
31
- // if (wall.supported) wall.addScene(canvas, viewer.onFrame, { virtualDisplayHeight: 0.18 });
31
+ // if (wall.supported)
32
+ // wall.addScene(canvas, viewer.onFrame, {
33
+ // virtualDisplayHeight: 0.18,
34
+ // onLayerLost: viewer.onLayerLost, // the session ended: go flat rather than show raw SBS
35
+ // });
32
36
  // else viewer.startMono();
33
37
  //
34
38
  // WHY FRAMING IS SCENE-GRAPH WORK AND NOT A RIG FIELD. The native display rig
@@ -49,6 +53,9 @@ const DEFAULT_DEPTH_LIMIT = 4.0;
49
53
  const IDLE_DELAY_MS = 2500;
50
54
 
51
55
  const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
56
+ // NaN/Infinity into a transform silently blanks the tile — three propagates it into the
57
+ // matrix and every vertex lands undefined. Reject at the setter instead.
58
+ const finite = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
52
59
 
53
60
  /**
54
61
  * Robust model-space bounds from a flat array of splat/vertex centres.
@@ -220,6 +227,14 @@ export class SceneViewer {
220
227
  this._fitScale = 1;
221
228
  this._zoom = 1;
222
229
  this._targetZoom = 1;
230
+ // Author-driven slide along the depth axis, display metres, +z toward the viewer. Applied
231
+ // by _applyTransform, PRESERVED by fitTo, cleared by resetPose. Default 0 means every page
232
+ // that never touches it is bit-identical to 1.5.x.
233
+ this._depthOffset = 0;
234
+ // Subject half-extents in MODEL units, from the last fitTo. getSubjectBounds turns these
235
+ // into a display-space box under the live pose; without them it would have to re-measure
236
+ // the content every call.
237
+ this._subjectHalf = [0, 0, 0];
223
238
  this._yaw = 0;
224
239
  this._pitch = 0;
225
240
  this._targetYaw = 0;
@@ -258,8 +273,23 @@ export class SceneViewer {
258
273
  if (orbit) this._bindOrbit();
259
274
  this._resize();
260
275
 
261
- // Bound so it can be passed straight to addScene without a wrapper closure.
276
+ // Bound so they can be passed straight to addScene without a wrapper closure.
262
277
  this.onFrame = this.onFrame.bind(this);
278
+ this.onLayerLost = this.onLayerLost.bind(this);
279
+ }
280
+
281
+ /**
282
+ * The weave layer went away for good — pass this to `wall.addScene(canvas, viewer.onFrame,
283
+ * { onLayerLost: viewer.onLayerLost })` (`./splat` and `./model` do it for you).
284
+ *
285
+ * Without it the canvas keeps its last woven side-by-side frame on screen as ordinary squeezed
286
+ * 2D, because `_mode` stays `'3d'` and every mono fallback in this SDK is a one-shot decision
287
+ * made at boot (web#28). Going mono here is safe even if a tile is later re-woven: `onFrame`
288
+ * calls `stopMono()` on the first 3D frame it gets.
289
+ */
290
+ onLayerLost() {
291
+ if (this._disposed) return;
292
+ this.startMono();
263
293
  }
264
294
 
265
295
  /**
@@ -278,10 +308,12 @@ export class SceneViewer {
278
308
  const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
279
309
 
280
310
  this._centering.position.set(-c[0], -c[1], -c[2]);
311
+ // Recorded for getSubjectBounds(). Model units; the fit scale is applied at read time so a
312
+ // later zoom or orbit needs no re-measure.
313
+ this._subjectHalf = [Math.abs(e[0]) / 2, Math.abs(e[1]) / 2, Math.abs(e[2]) / 2];
281
314
 
282
315
  if (this.fit === 'none') {
283
316
  this._fitScale = 1;
284
- this._pivot.position.z = 0;
285
317
  } else {
286
318
  const box = this.canvas.getBoundingClientRect();
287
319
  const aspect = box.height > 0 ? box.width / box.height : 1;
@@ -325,8 +357,9 @@ export class SceneViewer {
325
357
  //
326
358
  // A biased variant that slid the subject behind the glass was tried and dropped: on
327
359
  // hardware it read WORSE, and it moved content the wrong way besides. Do not re-add it
328
- // without a hardware comparison.
329
- this._pivot.position.z = 0;
360
+ // without a hardware comparison. `depthOffset` is NOT that variant: it is an author
361
+ // asking for a specific placement, and it stays 0 unless someone sets it — so the
362
+ // default framing this comment defends is unchanged.
330
363
 
331
364
  // Backstop only: something pathologically deep still gets scaled down.
332
365
  const sz = (this.depthLimit * vH) / ez;
@@ -341,19 +374,115 @@ export class SceneViewer {
341
374
  this.monoCamera.lookAt(0, 0, 0);
342
375
  }
343
376
 
344
- /** Set the orbit pose directly. Angles in degrees; zoom is a multiplier on the fit scale. */
345
- setPose({ yaw, pitch, zoom } = {}) {
377
+ /**
378
+ * Set the pose directly. Angles in degrees; zoom is a multiplier on the fit scale;
379
+ * depthOffset is display metres along the depth axis (+ toward the viewer).
380
+ *
381
+ * This SNAPS — it writes the eased value and its target together. The easing in _tick exists
382
+ * for input, not for programmatic placement.
383
+ */
384
+ setPose({ yaw, pitch, zoom, depthOffset } = {}) {
346
385
  if (yaw !== undefined) this._targetYaw = this._yaw = yaw;
347
386
  if (pitch !== undefined) {
348
387
  this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
349
388
  }
350
389
  if (zoom !== undefined) this._targetZoom = this._zoom = clamp(zoom, ZOOM_MIN, ZOOM_MAX);
390
+ if (depthOffset !== undefined) this._depthOffset = finite(depthOffset, this._depthOffset);
391
+ this._applyTransform();
392
+ }
393
+
394
+ /**
395
+ * What the pose IS right now — the counterpart to setPose, and the reason an app no longer
396
+ * has to read `_zoom` to know where its subject sits.
397
+ *
398
+ * `_tick` eases yaw/pitch/zoom toward their targets, so during an orbit or a wheel-zoom the
399
+ * two answers genuinely differ and consumers want different ones: a readout that describes
400
+ * what is ON SCREEN wants the eased value (the default), while "remember this view" wants
401
+ * the target it is settling on. depthOffset never eases, so both agree.
402
+ *
403
+ * @param {object} [opts]
404
+ * @param {boolean} [opts.target=false] report the values being eased TOWARD.
405
+ * @returns {{yaw:number, pitch:number, zoom:number, depthOffset:number}} degrees / multiplier
406
+ * / metres.
407
+ */
408
+ getPose({ target = false } = {}) {
409
+ return {
410
+ yaw: target ? this._targetYaw : this._yaw,
411
+ pitch: target ? this._targetPitch : this._pitch,
412
+ zoom: target ? this._targetZoom : this._zoom,
413
+ depthOffset: this._depthOffset,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Where the subject actually IS, in display metres, under the pose being drawn.
419
+ *
420
+ * This is the viewer's output surface. Everything a page needs in order to reason about
421
+ * depth — a pop-out readout, a depth-budget check, a HUD that must clear the subject — is a
422
+ * function of this box, and none of it is derivable from the outside: the fit scale, the
423
+ * live zoom and the orbit are all viewer state.
424
+ *
425
+ * THE ORBIT IS WHY THIS CANNOT BE CACHED. The pivot rotates about Y (and X), so yaw swings
426
+ * the subject's DEPTH into the display's z and its width out of it. A page that measures its
427
+ * model once at load and scales by zoom is correct at yaw 0 and wrong everywhere else — and
428
+ * with `idleSpin` on, yaw 0 is a passing instant. Call this per frame; it allocates one
429
+ * object and does no matrix work.
430
+ *
431
+ * SIGNS. Display space puts the viewer at +z and the glass at z = 0, so `front` (the surface
432
+ * nearest the viewer) is the LARGER z and a positive `front` means the subject pops out of
433
+ * the glass. `back` is the far side; a negative `back` is depth behind the glass. See
434
+ * docs/authoring-inline-3d.md § "Which way is out".
435
+ *
436
+ * The box is axis-aligned in display space and encloses the oriented subject — the standard
437
+ * conservative bound, so it never under-reports pop-out.
438
+ *
439
+ * @returns {{center:{x:number,y:number,z:number}, extent:{x:number,y:number,z:number},
440
+ * front:number, back:number, scale:number}} metres, except `scale` which is the
441
+ * model-unit → metre factor currently in force (fit x zoom).
442
+ */
443
+ getSubjectBounds() {
444
+ const s = this._fitScale * this._zoom;
445
+ const [hx, hy, hz] = this._subjectHalf;
446
+ const p = (this._pitch * Math.PI) / 180;
447
+ const y = (this._yaw * Math.PI) / 180;
448
+ // Rows of R = Rx(pitch) . Ry(yaw) — the same product _applyTransform builds, and the same
449
+ // order, which is the part that matters (see its comment on why 'XYZ' and not 'YXZ').
450
+ // |row| . half gives the AABB half-extent along that world axis.
451
+ const cp = Math.cos(p);
452
+ const sp = Math.sin(p);
453
+ const cy = Math.cos(y);
454
+ const sy = Math.sin(y);
455
+ const ex = s * (Math.abs(cy) * hx + Math.abs(sy) * hz);
456
+ const ey = s * (Math.abs(sp * sy) * hx + Math.abs(cp) * hy + Math.abs(sp * cy) * hz);
457
+ const ez = s * (Math.abs(cp * sy) * hx + Math.abs(sp) * hy + Math.abs(cp * cy) * hz);
458
+ const cz = this._depthOffset;
459
+ return {
460
+ center: { x: 0, y: 0, z: cz },
461
+ extent: { x: 2 * ex, y: 2 * ey, z: 2 * ez },
462
+ front: cz + ez,
463
+ back: cz - ez,
464
+ scale: s,
465
+ };
466
+ }
467
+
468
+ /**
469
+ * Slide the whole subject along the depth axis, display metres, + toward the viewer.
470
+ *
471
+ * Survives `fitTo` — a refit reframes the subject without discarding where the author put
472
+ * it — and is cleared by `resetPose`, which is where "back to default" belongs.
473
+ */
474
+ get depthOffset() {
475
+ return this._depthOffset;
476
+ }
477
+
478
+ set depthOffset(m) {
479
+ this._depthOffset = finite(m, this._depthOffset);
351
480
  this._applyTransform();
352
481
  }
353
482
 
354
- /** Return to the framed default pose. */
483
+ /** Return to the framed default pose, depth slide included. */
355
484
  resetPose() {
356
- this.setPose({ yaw: 0, pitch: 0, zoom: 1 });
485
+ this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
357
486
  this._lastInput = now();
358
487
  }
359
488
 
@@ -625,6 +754,10 @@ export class SceneViewer {
625
754
  _applyTransform() {
626
755
  const s = this._fitScale * this._zoom;
627
756
  this._pivot.scale.setScalar(s);
757
+ // The depth slide lives here, not in fitTo, so it survives a refit and cannot be left
758
+ // stale by a code path that forgets it. x/y are never written: the fit centres the subject
759
+ // on the tile and sliding it sideways is a scene concern, not a viewer one.
760
+ this._pivot.position.z = this._depthOffset;
628
761
  // Order 'XYZ' == R = Rx(pitch) · Ry(yaw), and the order is the whole point.
629
762
  //
630
763
  // Yaw must act in the subject's OWN frame (spin it on its axis); pitch must act in the
package/js/inline3d.js CHANGED
@@ -860,6 +860,12 @@ class Inline3D {
860
860
  * falls back to `virtualDisplayHeight` if one was given (that pair is the one reason
861
861
  * to pass both) — either way the window still weaves.
862
862
  * @param {Element} [opts.observe=canvas] element whose visibility gates lazy create/close.
863
+ * @param {() => void} [opts.onLayerLost] called once when this window's weave layer goes away
864
+ * for good — the session ended, or the layer could not be created. YOU own a scene
865
+ * canvas's pixels, so this is the SDK's only way to tell you that the side-by-side pair
866
+ * in it is no longer being woven and is now just squeezed 2D on the page; take the
867
+ * canvas flat here (`SceneViewer.startMono`, or your own mono path). NOT called when a
868
+ * lazy tile merely scrolls off screen — that layer is coming back. Errors are caught.
863
869
  * @returns {{remove():void}}
864
870
  */
865
871
  addScene(canvas, onFrame, opts = {}) {
@@ -1746,6 +1752,12 @@ class Inline3D {
1746
1752
  img: null,
1747
1753
  video: null,
1748
1754
  onFrame: null,
1755
+ // Scene windows only (addScene's `onLayerLost`): the layer went away for good. See
1756
+ // _notifyLayerLost — `layerLostSent` keeps it one-shot per loss. Read from the options
1757
+ // HERE rather than after `_register` returns, because a non-lazy window activates (and can
1758
+ // therefore already fail to build its layer) inside this call.
1759
+ onLayerLost: typeof opts.onLayerLost === 'function' ? opts.onLayerLost : null,
1760
+ layerLostSent: false,
1749
1761
  ready: null,
1750
1762
  ownsBuffer: kind !== 'scene',
1751
1763
  cornerRadius: opts.cornerRadius || 0,
@@ -1844,10 +1856,27 @@ class Inline3D {
1844
1856
  ? { virtualDisplayHeight: win.virtualDisplayHeight }
1845
1857
  : {};
1846
1858
  win.layer = new XRDisplayLayer(this.session, win.canvas, init);
1847
- } catch {
1859
+ } catch (err) {
1848
1860
  win.layer = null;
1861
+ // Say so, once per window, and take the canvas flat. Swallowed silently this was
1862
+ // undiagnosable in the field AND left a re-activated tile holding the SBS pair it wove
1863
+ // with last time — nothing repaints it, and the IntersectionObserver does not re-fire
1864
+ // while the tile stays intersecting. No retry: a constructor that refused this canvas will
1865
+ // refuse it again, and a retry loop would run per frame with nothing to report (web#28).
1866
+ if (!win.layerFailWarned) {
1867
+ win.layerFailWarned = true;
1868
+ console.warn(
1869
+ '[inline3d] new XRDisplayLayer() failed for this window — it will show FLAT 2D ' +
1870
+ 'instead of woven 3D, and the SDK will not retry. The canvas has been repainted ' +
1871
+ 'mono so it cannot be left holding a raw side-by-side pair.',
1872
+ err
1873
+ );
1874
+ }
1875
+ this._paintMono(win);
1876
+ this._notifyLayerLost(win);
1849
1877
  return;
1850
1878
  }
1879
+ win.layerLostSent = false; // a live layer again: a future loss is worth reporting again
1851
1880
  // Nothing about the hardware state is re-asserted here, and that is the point: the panel's
1852
1881
  // mode is the DISPLAY's, it survives a tile scrolling away, and this SDK never requests it
1853
1882
  // behind the page's back. The rig went into the init above already flattened if a 1-view
@@ -1898,9 +1927,47 @@ class Inline3D {
1898
1927
  win.layer = null;
1899
1928
  }
1900
1929
  // Leave a flat (left-eye-only) frame so an off-screen image/video still shows 2D.
1901
- if (win.ownsBuffer && win.kind !== 'scene') {
1902
- this._sizeBuffer(win, /*sbs*/ false);
1903
- this._paint(win, null);
1930
+ //
1931
+ // A SCENE is deliberately NOT notified here (see _notifyLayerLost): its layer is coming
1932
+ // back the moment the tile scrolls into view again, and `SceneViewer.onFrame` takes the
1933
+ // backing store back to SBS by itself — collapsing on every scroll would make the lazy
1934
+ // lifecycle visible as a mode change.
1935
+ this._paintMono(win);
1936
+ }
1937
+
1938
+ /**
1939
+ * Take a window whose layer is gone back to the ONE state a canvas nothing weaves may be left
1940
+ * in: a flat, left-eye-only frame in a 1:1 buffer (web#28).
1941
+ *
1942
+ * Shared by _deactivate, _teardown and the _activate failure path precisely so the three
1943
+ * cannot drift — _teardown used to skip it entirely, which left every image and video tile on
1944
+ * the page holding its last side-by-side frame, forever, the moment the session ended.
1945
+ * Scene canvases are the page's pixels and are handled by _notifyLayerLost instead.
1946
+ */
1947
+ _paintMono(win) {
1948
+ if (!win.ownsBuffer || win.kind === 'scene') return;
1949
+ this._sizeBuffer(win, /*sbs*/ false);
1950
+ this._paint(win, null);
1951
+ }
1952
+
1953
+ /**
1954
+ * The scene half of the same problem. The SDK does not own a scene canvas's backing store, so
1955
+ * the most it can do is SAY the layer went away and let the owner take itself flat —
1956
+ * `SceneViewer` wires its `startMono()` here (`addScene({ onLayerLost })`), and `./splat` and
1957
+ * `./model` do that for you. Without it a scene tile keeps its last woven side-by-side frame
1958
+ * on screen after the session ends, because every mono fallback in this SDK and its samples is
1959
+ * a one-shot `!supported` branch decided at boot.
1960
+ *
1961
+ * One-shot per loss and never allowed to throw: this runs inside teardown, where a page
1962
+ * callback that raises must not strand the windows behind it.
1963
+ */
1964
+ _notifyLayerLost(win) {
1965
+ if (win.kind !== 'scene' || typeof win.onLayerLost !== 'function' || win.layerLostSent) return;
1966
+ win.layerLostSent = true;
1967
+ try {
1968
+ win.onLayerLost();
1969
+ } catch (err) {
1970
+ console.warn('[inline3d] a scene window\'s onLayerLost callback threw', err);
1904
1971
  }
1905
1972
  }
1906
1973
 
@@ -2208,9 +2275,28 @@ class Inline3D {
2208
2275
 
2209
2276
  _paint(win, _views) {
2210
2277
  if (win.kind === 'scene' || !win.ctx) return;
2278
+ // NOTHING IS WEAVING THIS CANVAS (web#28, browser-pvt#99). A dead manager or a window with
2279
+ // no layer means the browser is not consuming this canvas as a stereo pair any more — so an
2280
+ // SBS paint here puts the raw squeezed left|right pair on screen as ordinary 2D page
2281
+ // content, permanently, because nothing ever repaints it. The path that makes this a FIELD
2282
+ // bug rather than a theoretical one is a slow download: `addImage`'s load resolves after the
2283
+ // session ended and calls `win.repaint()` straight into a canvas whose layer is gone.
2284
+ // Forced here rather than at each call site because the call sites are the async ones.
2285
+ // The buffer comes with it — the mono branch below stretches ONE eye across the whole
2286
+ // backing store, so leaving a 2:1 store would show a double-width half-image.
2287
+ const live = this._running && !!win.layer;
2288
+ if (!live && win.sbs) this._sizeBuffer(win, /*sbs*/ false);
2211
2289
  const src = win.kind === 'video' ? win.video : win.img;
2212
2290
  if (!src) return;
2213
- if (win.kind === 'video' && (src.readyState || 0) < 2) return; // no frame yet
2291
+ if (win.kind === 'video' && (src.readyState || 0) < 2) {
2292
+ // Buffering: no new frame to draw, and drawing an unready <video> is a no-op per spec (it
2293
+ // would leave the clearRect below as the only thing that happened, i.e. blank the tile).
2294
+ // Skipping the paint entirely is what the old code did, and that is its own bug — see
2295
+ // _frame: a canvas that is not redrawn can have its layer dropped from the aggregated
2296
+ // frame. So re-commit what the canvas already holds instead.
2297
+ if (live) this._recommitLastFrame(win);
2298
+ return;
2299
+ }
2214
2300
  const c = win.canvas;
2215
2301
  const ctx = win.ctx;
2216
2302
  const srcW = src.videoWidth || src.naturalWidth || src.width;
@@ -2234,6 +2320,30 @@ class Inline3D {
2234
2320
  }
2235
2321
  }
2236
2322
 
2323
+ /**
2324
+ * Re-commit the pixels the canvas already holds, unchanged — the cheapest "last decoded frame"
2325
+ * there is, because the last decoded frame is already in the backing store.
2326
+ *
2327
+ * Drawing the canvas onto itself is one same-size blit that dirties the canvas (which is the
2328
+ * whole point: see the every-frame-repaint note in _frame), and `globalCompositeOperation =
2329
+ * 'copy'` is what makes it a true identity — source-over would composite a feathered buffer's
2330
+ * transparent edges onto themselves and darken the ramp a little more every stalled frame.
2331
+ * Only ever reached while a source has nothing new, so a healthy video never pays for it.
2332
+ */
2333
+ _recommitLastFrame(win) {
2334
+ const c = win.canvas;
2335
+ if (!c.width || !c.height) return;
2336
+ const ctx = win.ctx;
2337
+ try {
2338
+ ctx.save();
2339
+ ctx.globalCompositeOperation = 'copy';
2340
+ ctx.drawImage(c, 0, 0);
2341
+ ctx.restore();
2342
+ } catch {
2343
+ /* a context that refuses a self-blit: leave the stale pixels rather than blank the tile */
2344
+ }
2345
+ }
2346
+
2237
2347
  /**
2238
2348
  * Arm the next session frame. `force` starts a NEW loop even though one is nominally
2239
2349
  * pending: each loop carries an id and only the current id re-arms, so a stalled
@@ -2293,7 +2403,22 @@ class Inline3D {
2293
2403
  );
2294
2404
  }
2295
2405
  }
2296
- win.onFrame(views, win.layer, f);
2406
+ // Contained, and warned about once. A scene that throws (a texture that 404s, a
2407
+ // decoder that gives up) used to abort this loop body for every window AFTER it in the
2408
+ // map — and an un-redrawn canvas can have its layer dropped from the aggregated frame
2409
+ // (see the note below), so one broken tile took its neighbours' weave with it (web#28).
2410
+ try {
2411
+ win.onFrame(views, win.layer, f);
2412
+ } catch (err) {
2413
+ if (!win.frameThrewWarned) {
2414
+ win.frameThrewWarned = true;
2415
+ console.warn(
2416
+ "[inline3d] a scene window's onFrame threw; this window will keep whatever it " +
2417
+ 'last drew, and the other windows carry on. Further throws from it are silent.',
2418
+ err
2419
+ );
2420
+ }
2421
+ }
2297
2422
  }
2298
2423
  } else {
2299
2424
  // Repaint image AND video every frame. The weave reads each window's
@@ -2430,6 +2555,13 @@ class Inline3D {
2430
2555
  }
2431
2556
  win.layer = null;
2432
2557
  }
2558
+ // The repaint _deactivate has always done, which this path used to skip (web#28). Closing
2559
+ // the layer also clears the browser's tracked rect, so from here nothing suppresses these
2560
+ // canvases and nothing will ever repaint them either — whatever is in the backing store
2561
+ // when the session ends is what the page shows from now on. A side-by-side pair is the one
2562
+ // thing that must not be. AFTER the close, so the flat frame is the last thing committed.
2563
+ this._paintMono(win);
2564
+ this._notifyLayerLost(win);
2433
2565
  }
2434
2566
  this._windows.clear();
2435
2567
  // Page listeners go with the session that fed them: a manager whose session has ended will
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
package/viewer.d.ts CHANGED
@@ -38,9 +38,35 @@ export interface SceneViewerOptions {
38
38
  }
39
39
 
40
40
  export interface OrbitPose {
41
+ /** Degrees about Y. */
41
42
  yaw?: number;
43
+ /** Degrees about X, clamped to `pitchLimit`. */
42
44
  pitch?: number;
45
+ /** Multiplier on the fit scale. */
43
46
  zoom?: number;
47
+ /** Display metres along the depth axis, `+` toward the viewer (out of the glass). */
48
+ depthOffset?: number;
49
+ }
50
+
51
+ /**
52
+ * Where the subject sits in DISPLAY METRES under the pose currently being drawn — an
53
+ * axis-aligned box enclosing the oriented subject.
54
+ *
55
+ * Display space puts the viewer at `+z` and the glass at `z = 0`, so `front` is the larger z:
56
+ * a positive `front` means the subject pops OUT of the glass, a negative `back` means depth
57
+ * behind it.
58
+ */
59
+ export interface SubjectPlacement {
60
+ /** Box centre. x and y are always 0 — the fit centres the subject on the tile. */
61
+ center: { x: number; y: number; z: number };
62
+ /** Full box size (not half-extents). */
63
+ extent: { x: number; y: number; z: number };
64
+ /** z of the surface nearest the viewer. `> 0` = in front of the glass. */
65
+ front: number;
66
+ /** z of the surface furthest from the viewer. `< 0` = behind the glass. */
67
+ back: number;
68
+ /** Model units → display metres currently in force (fit scale × zoom). */
69
+ scale: number;
44
70
  }
45
71
 
46
72
  /**
@@ -77,7 +103,31 @@ export declare class SceneViewer {
77
103
  extent: number[] | { x: number; y: number; z: number },
78
104
  ): void;
79
105
 
106
+ /** Snap the pose. Writes the eased value and its target together. */
80
107
  setPose(pose?: OrbitPose): void;
108
+
109
+ /**
110
+ * The pose as it is right now. `target: true` reports what it is easing TOWARD, which differs
111
+ * mid-orbit — a readout wants the default (eased), "save this view" wants the target.
112
+ */
113
+ getPose(opts?: { target?: boolean }): Required<OrbitPose>;
114
+
115
+ /**
116
+ * Where the subject is, in display metres, under the live pose. Safe and cheap to call every
117
+ * frame; allocates one object and does no matrix work.
118
+ *
119
+ * Recompute per frame rather than caching: orbit swings the subject's depth into the
120
+ * display's z, so a value measured at load is only correct at yaw 0.
121
+ */
122
+ getSubjectBounds(): SubjectPlacement;
123
+
124
+ /**
125
+ * Slide the subject along the depth axis, display metres, `+` toward the viewer. Survives
126
+ * `fitTo`; cleared by `resetPose`.
127
+ */
128
+ depthOffset: number;
129
+
130
+ /** Return to the framed default pose, depth slide included. */
81
131
  resetPose(): void;
82
132
 
83
133
  /**
@@ -89,6 +139,12 @@ export declare class SceneViewer {
89
139
  * frame either way, so the tile never goes dark and never smears (web#12).
90
140
  */
91
141
  onFrame(views: readonly XRView[], layer: object): void;
142
+ /**
143
+ * The weave layer went away for good — pass to `addScene(canvas, viewer.onFrame,
144
+ * { onLayerLost: viewer.onLayerLost })` so the tile goes flat instead of showing its last
145
+ * side-by-side frame as squeezed 2D. Pre-bound; `./splat` and `./model` wire it for you.
146
+ */
147
+ onLayerLost(): void;
92
148
 
93
149
  /** Supply the ./three glue so the 3D path can build its eye camera. Returns `this`. */
94
150
  useEyeCamera(EyeCameraClass: unknown, EdgeFeatherClass?: unknown): this;