@displayxr/inline3d 1.23.0 → 1.25.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.
@@ -25,14 +25,29 @@
25
25
  //
26
26
  // THE RIG. The incoming asset's rig (camera rig: its capture pose, lens and convergence; display
27
27
  // rig: its fit and focus) is adopted at once, as in the frozen path, so the views the runtime
28
- // hands us after that are the INCOMING photo's. The outgoing camera is parented under a node
29
- // chain that maps them back to the OUTGOING photo's framing:
28
+ // hands us — once the declaration has reached them, a frame or more later — are the INCOMING
29
+ // photo's. The outgoing photo must keep being drawn through ITS OWN rig for the whole window.
30
+ //
31
+ // Two camera rigs (a photo slideshow: every photo a SOG with a camera block): the views are
32
+ // remapped, per frame, from the rig they were LOCATED for to the outgoing photo's rig, by the
33
+ // exact portal map of ./inline3d-splat-rig-map.js — window onto window, eye onto eye, the
34
+ // runtime's projection verbatim — under the outgoing photo's rig node R_o. Before the
35
+ // declaration arrives the located rig IS the outgoing one and the views pass through untouched.
36
+ //
37
+ // Otherwise (a display rig on either side, views no declared rig explains, the kill switch
38
+ // `?dxrdiag=oldrig`): the pre-1.24 node chain, which maps the incoming views back to the outgoing
39
+ // photo's framing:
30
40
  // N = R_o · K_o · D_o · D_n⁻¹ · K_n⁻¹
31
41
  // with R the rig node (inverse pivot), K the mono/capture pose in rig space and
32
42
  // D = diag(c·t, c·t, c) (c = convergence distance, t = tan(vfov/2)). The eye poses (head
33
43
  // motion) pass through untouched; the outgoing photo keeps its own window and zero-disparity
34
44
  // plane. Two display-rig assets cancel to N = R_o (the old fit and focus); in controls:'page' the
35
- // page owns the camera and N is the rig node itself.
45
+ // page owns the camera and N is the rig node itself. For two CAMERA rigs this chain is not exact:
46
+ // it scales the eyes with the window, by (c_o·t_o)/(c_n·t_n) — the outgoing photo's disparity and
47
+ // head parallax jumped by that factor at the swap (the "camera change right before the
48
+ // transition" report) — and it assumed the views were already the incoming rig's.
49
+
50
+ import { remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
36
51
 
37
52
  /** setSource's `outgoing` option. */
38
53
  export const OUTGOING_MODES = Object.freeze(['live', 'frozen']);
@@ -151,6 +166,12 @@ export class LiveOutgoing {
151
166
  /** PRE-SORT (warm): the current asset is ALSO on the live layer, before the swap. */
152
167
  this.warming = false;
153
168
  this._warmLayers = null;
169
+ /** The outgoing photo's own declared rig (a RigTracker entry), taken at start(). */
170
+ this.ownRig = null;
171
+ this._map = [];
172
+ this._node = new Float64Array(16);
173
+ /** What the last sync drew through: 'eye' (the eye's views), 'own' (the photo's rig, as located), 'remapped', 'chain'. */
174
+ this.path = null;
154
175
  }
155
176
 
156
177
  /** Engine support for this path: RenderViews on one camera, render targets, layers. */
@@ -299,6 +320,8 @@ export class LiveOutgoing {
299
320
  entity.gsplat.layers = [this.layer.id];
300
321
  this.entity = entity;
301
322
  this.oldFrame = oldFrame;
323
+ // Read BEFORE the incoming asset's rig is declared: the last declared rig is the outgoing's.
324
+ this.ownRig = v.rigTrack?.latest ?? null;
302
325
  if (!warmed) this.frames = 0;
303
326
  this.warming = false;
304
327
  this._warmLayers = null;
@@ -347,10 +370,11 @@ export class LiveOutgoing {
347
370
  }
348
371
 
349
372
  /**
350
- * Per frame, before the engine tick: the eye's views on the outgoing camera (same proj, pose,
351
- * viewport), the eye's node pose, and the rig chain.
373
+ * Per frame, before the engine tick: the views on the outgoing camera — through the outgoing
374
+ * photo's own rig (see the header) — the viewports, and the node chain. `rf` is the viewer's rig
375
+ * mapping for this frame (viewer._mapViews), or undefined.
352
376
  */
353
- sync(entries, rect, frustum) {
377
+ sync(entries, rect, frustum, rf) {
354
378
  if (!this.active && !this.warming) return;
355
379
  const v = this.viewer;
356
380
  const pc = v.pc;
@@ -360,11 +384,50 @@ export class LiveOutgoing {
360
384
  for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
361
385
  this.cam.camera.camera.xrViews = rvs.slice();
362
386
  }
387
+ // Which views: the pre-sort (and controls:'page', where both photos are the page camera's)
388
+ // draws what the eye draws; an active window draws the outgoing photo's own rig.
389
+ let mapped = null; // remapped views, or null = the entries as they are
390
+ let exact = false; // the rig map applies: no node chain
391
+ let nodeRig = null; // the portal the remapped views belong to (for the camera node)
392
+ const own = this.ownRig;
393
+ if (rf && v.rigTrack && (!this.active || v.pageCamera)) {
394
+ exact = true;
395
+ mapped = rf.remapped ? rf.entries : null;
396
+ nodeRig = rf.eye?.portal ?? null;
397
+ this.path = 'eye';
398
+ } else if (rf?.located && own?.portal && this.active && !v.pageCamera) {
399
+ if (rf.located === own || sameRig(rf.located.rig, own.rig)) {
400
+ exact = true;
401
+ this.path = 'own';
402
+ } else {
403
+ mapped = remapViews(entries, rf.located.portal, own.portal, this._map);
404
+ exact = !!mapped;
405
+ nodeRig = own.portal;
406
+ if (mapped) this.path = 'remapped';
407
+ }
408
+ }
409
+ if (!exact) this.path = 'chain';
363
410
  for (let i = 0; i < entries.length; i++) {
364
- rvs[i].setView(entries[i].proj, entries[i].pose);
411
+ if (mapped) rvs[i].setView(mapped[i].proj, mapped[i].viewInv, mapped[i].view);
412
+ else rvs[i].setView(entries[i].proj, entries[i].pose);
365
413
  const [x, y, w, h] = rect(entries[i]);
366
414
  rvs[i].setViewport(x, y, w, h);
367
415
  }
416
+ // The wavefront's cull reads the views this camera really draws (viewer.cullViews).
417
+ if (mapped) {
418
+ const cull = (this._cull ||= []);
419
+ cull.length = mapped.length;
420
+ for (let i = 0; i < mapped.length; i++) {
421
+ const c = (cull[i] ||= {});
422
+ c.proj = mapped[i].proj;
423
+ c.pose = mapped[i].viewInv;
424
+ c.x = mapped[i].x;
425
+ c.y = mapped[i].y;
426
+ c.width = mapped[i].width;
427
+ c.height = mapped[i].height;
428
+ }
429
+ this.cam._dxrViews = cull;
430
+ } else this.cam._dxrViews = null;
368
431
  if (frustum) {
369
432
  const key = `${frustum.fov.toFixed(4)}|${frustum.aspectRatio.toFixed(4)}|${frustum.nearClip}|${frustum.farClip}`;
370
433
  if (key !== this._frustumKey) {
@@ -374,6 +437,20 @@ export class LiveOutgoing {
374
437
  }
375
438
  // The node chain (see the header). controls:'page': the page's camera, for both assets.
376
439
  const { n1, n2, n3 } = this.nodes;
440
+ if (exact) {
441
+ // The views are in rig space already: only the photo's rig node above them — the one the
442
+ // outgoing photo had at the swap (the pre-sort and controls:'page': the current one).
443
+ setTRS(n1, similarityTRS(this.active && !v.pageCamera && this.oldFrame ? this.oldFrame.rig : v.rigMatrix()));
444
+ n2.setLocalScale(1, 1, 1);
445
+ setTRS(n3, { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: 1 });
446
+ // The camera NODE (sort direction, LOD distance) on the first eye this camera draws from.
447
+ const pose = mapped && nodeRig ? nodePose(nodeRig, mapped[0].eye, this._node) : entries[0].pose;
448
+ const q = similarityTRS(pose).rotation;
449
+ this.cam.setLocalPosition(pose[12], pose[13], pose[14]);
450
+ this.cam.setLocalRotation(q[0], q[1], q[2], q[3]);
451
+ this.frames++;
452
+ return;
453
+ }
377
454
  if (v.pageCamera || !this.oldFrame) {
378
455
  const r = similarityTRS(v.rigMatrix());
379
456
  setTRS(n1, r);
@@ -401,6 +478,9 @@ export class LiveOutgoing {
401
478
  if (this.warming) return this.cancelWarm();
402
479
  this.entity = null;
403
480
  this.oldFrame = null;
481
+ this.ownRig = null;
482
+ this.path = null;
483
+ if (this.cam) this.cam._dxrViews = null;
404
484
  this.active = false;
405
485
  this.warmed = false;
406
486
  if (this.cam) this.cam.enabled = false;
@@ -68,7 +68,8 @@ import {
68
68
  } from './inline3d-splat-effects.js';
69
69
  import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
70
70
  import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR, eyeSplit, EYE_SPLIT_UNIFORM, makeSbsMaterial } from './inline3d-splat-video.js';
71
- import { LayerRigCameras, validateLayerRig } from './inline3d-splat-layer-rig.js';
71
+ import { LayerRigCameras, validateLayerRig, validateLayerRigOptions, DEFAULT_VIEWER_DISTANCE_M } from './inline3d-splat-layer-rig.js';
72
+ import { RigTracker, remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
72
73
  import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, DIAG_SWITCHES } from './inline3d-splat-diag.js';
73
74
  import {
74
75
  clamp,
@@ -823,6 +824,16 @@ export class PlayCanvasSplatViewer {
823
824
  this.layerRigSource = null;
824
825
  /** That descriptor as it stood when THIS frame's views were pulled (snapshotted in onFrame). */
825
826
  this._rigSnap = null;
827
+ /**
828
+ * The rigs this tile declared and which one each frame's views were located for
829
+ * (./inline3d-splat-rig-map.js). Null = the kill switch (diag 'oldrig'): views drawn as
830
+ * located, the live outgoing on its pre-1.24 node chain.
831
+ */
832
+ this.rigTrack = null;
833
+ /** This frame's rig mapping: { located, eye, remapped, entries } (see _mapViews). */
834
+ this.rigFrame = null;
835
+ this._eyeMap = [];
836
+ this._rigAtPull = null;
826
837
  this._eyeSplit = NaN;
827
838
  this.boxAspect = 1;
828
839
  this.featherPx = feather > 0 ? feather : 0;
@@ -1136,6 +1147,8 @@ export class PlayCanvasSplatViewer {
1136
1147
  // The rig these views were located with: Blink chained the rig declared BEFORE this callback,
1137
1148
  // and the tick below may declare a new one (a focus ease) for the NEXT locate.
1138
1149
  if (this.layerRigs?.active) this._rigSnap = snapshotRig(this.layerRigSource?.(), this._rigSnap);
1150
+ // Same moment, for the rig map: the rig these views were most likely located for (a tie-break).
1151
+ this._rigAtPull = this.rigTrack ? this.rigTrack.latest : null;
1139
1152
  // BEFORE the tick: a pose the page sets in here is the one this very frame renders.
1140
1153
  this._beforeFrame?.(views || null);
1141
1154
  this._tick();
@@ -1588,6 +1601,44 @@ export class PlayCanvasSplatViewer {
1588
1601
  }
1589
1602
  }
1590
1603
 
1604
+ /**
1605
+ * This frame's rig mapping (./inline3d-splat-rig-map.js): which declared rig the views were
1606
+ * located for (`located`), which the current photo wants (`eye`, the last declared), and — only
1607
+ * when those differ and both are camera rigs — the views remapped to `eye` (`entries`: proj,
1608
+ * viewInv, view per view; `cull`: the same as { proj, pose } entries; `node`: the first eye's
1609
+ * rigid pose). Otherwise `remapped` is false and the views are drawn exactly as located.
1610
+ */
1611
+ _mapViews(entries, prefer = null) {
1612
+ const rt = this.rigTrack;
1613
+ const rf = (this.rigFrame ||= { located: null, eye: null, remapped: false, entries: null, cull: [], node: new Float64Array(16) });
1614
+ rf.remapped = false;
1615
+ rf.located = rf.eye = null;
1616
+ if (!rt || this._mode !== '3d' || entries.length < 2) return rf;
1617
+ rf.located = rt.locate(entries, prefer);
1618
+ rf.eye = rt.latest;
1619
+ const L = rf.located;
1620
+ const T = rf.eye;
1621
+ if (!L || !T?.portal || L === T || sameRig(L.rig, T.rig)) return rf;
1622
+ const r = remapViews(entries, L.portal, T.portal, this._eyeMap);
1623
+ if (!r) return rf;
1624
+ rf.remapped = true;
1625
+ rf.entries = r;
1626
+ rf.cull.length = r.length;
1627
+ for (let i = 0; i < r.length; i++) {
1628
+ const c = (rf.cull[i] ||= {});
1629
+ c.proj = r[i].proj;
1630
+ c.pose = r[i].viewInv;
1631
+ c.x = r[i].x;
1632
+ c.y = r[i].y;
1633
+ c.width = r[i].width;
1634
+ c.height = r[i].height;
1635
+ c.node = null;
1636
+ }
1637
+ nodePose(T.portal, r[0].eye, rf.node);
1638
+ rf.cull[0].node = rf.node; // a rigid pose for a camera NODE (the views are affine)
1639
+ return rf;
1640
+ }
1641
+
1591
1642
  /**
1592
1643
  * The mono/capture camera as a lens frame, for the live outgoing's rig chain
1593
1644
  * (./inline3d-splat-live.js): the rig node's matrix, the camera pose in rig space, the
@@ -1612,6 +1663,8 @@ export class PlayCanvasSplatViewer {
1612
1663
  */
1613
1664
  cullViews(cam, entries, rect, into = []) {
1614
1665
  const parent = cam?.parent?.getWorldTransform?.()?.data ?? null;
1666
+ // A camera drawing remapped views (./inline3d-splat-rig-map.js) publishes them as _dxrViews.
1667
+ if (cam?._dxrViews?.length === entries.length) entries = cam._dxrViews;
1615
1668
  into.length = entries.length;
1616
1669
  for (let i = 0; i < entries.length; i++) {
1617
1670
  const e = entries[i];
@@ -1625,7 +1678,8 @@ export class PlayCanvasSplatViewer {
1625
1678
  o.W[0] = C[3]; o.W[1] = C[7]; o.W[2] = C[11]; o.W[3] = C[15];
1626
1679
  o.K[0] = w * e.proj[0];
1627
1680
  o.K[1] = 1 / w;
1628
- o.K[2] = V[0] * V[0] + V[1] * V[1] + V[2] * V[2];
1681
+ // The view's largest column scale² (1 for a rigid view; a remapped view is affine).
1682
+ o.K[2] = Math.max(V[0] * V[0] + V[1] * V[1] + V[2] * V[2], V[4] * V[4] + V[5] * V[5] + V[6] * V[6], V[8] * V[8] + V[9] * V[9] + V[10] * V[10]);
1629
1683
  o.K[3] = 0;
1630
1684
  }
1631
1685
  return into;
@@ -1857,12 +1911,18 @@ export class PlayCanvasSplatViewer {
1857
1911
  for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
1858
1912
  this.eye.camera.camera.xrViews = rvs.slice();
1859
1913
  }
1914
+ // The current photo through ITS rig (the last declared): the views are remapped only while
1915
+ // they were located for another one (a declaration still in flight, ./inline3d-splat-rig-map.js).
1916
+ const rf = this._mapViews(entries, cache ? cache.rigAt : null);
1917
+ const eyeViews = rf.remapped ? rf.entries : null;
1860
1918
  for (let i = 0; i < entries.length; i++) {
1861
1919
  const e = entries[i];
1862
- rvs[i].setView(e.proj, e.pose);
1920
+ if (eyeViews) rvs[i].setView(eyeViews[i].proj, eyeViews[i].viewInv, eyeViews[i].view);
1921
+ else rvs[i].setView(e.proj, e.pose);
1863
1922
  const [x, y, w, h] = rect(e);
1864
1923
  rvs[i].setViewport(x, y, w, h);
1865
1924
  }
1925
+ this.eye._dxrViews = eyeViews ? rf.cull : null;
1866
1926
  // LOD and FOV-compensation read camera.fov/near/far, which under xrViews come from the
1867
1927
  // XR properties — the frustum the views actually have, as XrManager does it.
1868
1928
  const f = frustumFromProjection(entries[0].proj);
@@ -1873,13 +1933,19 @@ export class PlayCanvasSplatViewer {
1873
1933
  }
1874
1934
  // The camera NODE drives the sort direction and LOD distance; the views ignore it (they
1875
1935
  // compose the node's PARENT with their own pose). Park it on the first eye.
1876
- placeNode(this.eye, entries[0].pose);
1877
- // setSource's live outgoing: the same views on its own camera, into its own target.
1878
- if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f);
1936
+ placeNode(this.eye, eyeViews ? rf.node : entries[0].pose);
1937
+ // setSource's live outgoing: the same views on its own camera (through ITS photo's rig),
1938
+ // into its own target.
1939
+ if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f, rf);
1879
1940
  // setSource's wavefront: this frame's eye views, as the engine is about to compose them.
1880
1941
  this.onBeforeRender?.(entries, rect);
1881
1942
  // handle.setLayerRig: the display / post run cameras, on the same views (display: rounded).
1882
- if (this.layerRigs && this.layerRigs.sync()) this.layerRigs.frame(entries, rect, f, cache ? cache.rig : null);
1943
+ if (this.layerRigs) {
1944
+ this.layerRigs.frame(eyeViews ? rf.cull : entries, rect, f, eyeViews ? rf.eye.rig : rf.located ? rf.located.rig : cache ? cache.rig : null, {
1945
+ located: this.rigTrack && entries.length > 1 ? !!rf.located : null,
1946
+ residual: this.rigTrack?.lastResidual ?? null,
1947
+ });
1948
+ }
1883
1949
  } else {
1884
1950
  // Fallback: one camera per view, `rect` + `calculateProjection`.
1885
1951
  const cams = this._views;
@@ -1911,6 +1977,15 @@ export class PlayCanvasSplatViewer {
1911
1977
  cam.camera.rect = new pc.Vec4(x / W, y / H, w / W, h / H);
1912
1978
  placeNode(cam, e.pose);
1913
1979
  }
1980
+ // handle.setLayerRig on the N-camera path: N display / post cameras mirroring these, the
1981
+ // views as located (this path draws the photo through them too — no remap here).
1982
+ if (this.layerRigs) {
1983
+ const rf = this._mapViews(entries, cache ? cache.rigAt : null);
1984
+ this.layerRigs.frame(entries, rect, null, rf.located ? rf.located.rig : cache ? cache.rig : null, {
1985
+ located: this.rigTrack && entries.length > 1 ? !!rf.located : null,
1986
+ residual: this.rigTrack?.lastResidual ?? null,
1987
+ });
1988
+ }
1914
1989
  }
1915
1990
  this._updateFeather(entries[0].width * sx, entries[0].height * sy);
1916
1991
  // handle.makeSbsMaterial: the eye split, scene-wide (a material only declares the uniform).
@@ -1938,6 +2013,7 @@ export class PlayCanvasSplatViewer {
1938
2013
  g.bufW = el.width || 0;
1939
2014
  g.bufH = el.height || 0;
1940
2015
  g.rig = this._rigSnap ? { ...this._rigSnap } : null; // a replay re-uses the rig of its views
2016
+ g.rigAt = this._rigAtPull ?? null;
1941
2017
  for (let i = 0; i < views.length; i++) {
1942
2018
  const e = g.entries[i];
1943
2019
  const vp = vps[i];
@@ -2869,6 +2945,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2869
2945
  let rigLockArmed = false;
2870
2946
  if (diag) {
2871
2947
  diag.imageState = () => viewer.diagImageState();
2948
+ diag.rigState = () => {
2949
+ const rf = viewer.rigFrame;
2950
+ if (!viewer.rigTrack || !rf) return null;
2951
+ return {
2952
+ at: rf.located ? rf.located.id : null,
2953
+ in: rf.eye ? `${rf.eye.id}${rf.remapped ? '+' : ''}` : null,
2954
+ out: viewer._live?.active ? viewer._live.path : null,
2955
+ };
2956
+ };
2872
2957
  diag.observeLongTasks();
2873
2958
  diag.observeLongFrames();
2874
2959
  diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
@@ -2876,7 +2961,6 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2876
2961
  let removed = false;
2877
2962
  // handle.setLayerRig: the per-rig cameras and where they read the declared rig from.
2878
2963
  // Kill switch `nolayerrig`: requests are recorded but every layer stays on the eye camera.
2879
- let warnedLayerRigPath = false;
2880
2964
  function layerRigs() {
2881
2965
  if (!viewer.layerRigs) {
2882
2966
  viewer.layerRigs = new LayerRigCameras(viewer);
@@ -2971,26 +3055,31 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2971
3055
  * on the camera rig ('camera'). No second full pass: one extra camera over the named layers,
2972
3056
  * into the same target, in composition order. docs/playcanvas-adapter.md §setLayerRig and
2973
3057
  * docs/proposals/layer-display-rig.md (the exact mapping, and why it is not Kooima).
2974
- * `viewerDistance` (m) / `gain` set the rounding for the whole tile (last call wins).
3058
+ * Options (`viewerDistance`, `gain`, `planeOffset`, `planeDistance`) are tile-wide and MERGE
3059
+ * (a key given replaces; `null` clears) — see setLayerRigOptions.
3060
+ * Works on both view paths (RenderViews and the N-camera fallback); layerRigState() says
3061
+ * whether it engaged and, if not, why.
2975
3062
  */
2976
3063
  setLayerRig(layer, rig, o = {}) {
2977
3064
  const r = validateLayerRig(layer, rig, o);
2978
- if (viewer._viewPath === 'cameras' && !warnedLayerRigPath) {
2979
- warnedLayerRigPath = true;
2980
- console.warn('[inline3d/splat] setLayerRig needs the engine RenderView path; on the N-camera fallback the layer stays on the photo rig.');
2981
- }
2982
- layerRigs().set(layer, r.rig, { viewerDistance: r.viewerDistance, gain: r.gain });
3065
+ layerRigs().set(layer, r.rig, r.opts);
2983
3066
  return out;
2984
3067
  },
2985
- /** The layers on the display rig (as the page named them), and what the last frame did. */
3068
+ /** Change the layer rig's tile-wide options live (no layer change): merge, `null` clears. */
3069
+ setLayerRigOptions(o = {}) {
3070
+ layerRigs().setOptions(validateLayerRigOptions(o, 'setLayerRigOptions'));
3071
+ return out;
3072
+ },
3073
+ /**
3074
+ * What setLayerRig is doing: `path` ('renderviews' | 'ncamera' | 'mono') and `engaged` (the
3075
+ * display-rig views were applied on the last drawn frame), `reason` when not, and the numbers
3076
+ * (`viewerDistance`, `gain`, `planeM` — the plane that lands on the glass, as a distance from
3077
+ * the photo's camera — `photoConvergenceM`, `planeOffset`, `located`).
3078
+ */
2986
3079
  layerRigState() {
2987
3080
  const lr = viewer.layerRigs;
2988
- return {
2989
- display: lr ? [...lr.requests.keys()] : [],
2990
- disabled: !!lr?.disabled,
2991
- rounded: !!lr?.last.rounded,
2992
- gain: lr?.last.gain ?? null,
2993
- };
3081
+ if (lr) return lr.state();
3082
+ return { display: [], disabled: false, path: null, engaged: false, rounded: false, reason: 'no layer on the display rig', viewerDistance: DEFAULT_VIEWER_DISTANCE_M, gain: null, planeM: null, photoConvergenceM: null, planeOffset: 0, located: null };
2994
3083
  },
2995
3084
  /**
2996
3085
  * An unlit material showing the left half of `texture` to left-eye views and the right half to
@@ -3109,6 +3198,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3109
3198
  // toward `target` at the viewer's FOCUS_EASE unless snapped.
3110
3199
  const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
3111
3200
 
3201
+ let initialViewRig = null; // the rig addScene is created with (controls:'page'), else the shorthand
3112
3202
  if (wall && wall.supported) {
3113
3203
  const onFrame = diag
3114
3204
  ? (views, layer) => {
@@ -3121,7 +3211,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3121
3211
  // controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
3122
3212
  // and the waterfall say otherwise); the display rig's height means nothing there.
3123
3213
  ...(pageMode
3124
- ? { viewRig: pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth }) }
3214
+ ? { viewRig: (initialViewRig = pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth })) }
3125
3215
  : { virtualDisplayHeight }),
3126
3216
  onLayerLost: viewer.onLayerLost,
3127
3217
  ...(observe ? { observe } : {}),
@@ -3130,6 +3220,20 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3130
3220
  } else {
3131
3221
  viewer.startMono();
3132
3222
  }
3223
+ // Every rig this tile declares, by VALUE (./inline3d-splat-rig-map.js): which one a frame's
3224
+ // views were located for is read off the views, so each photo can be drawn through its own rig
3225
+ // while a declaration is in flight or while a live outgoing photo is still on screen.
3226
+ // `?dxrdiag=oldrig` turns it off (views drawn as located; the live outgoing's node chain).
3227
+ if (handle && typeof handle.setViewRig === 'function' && !diag?.has('oldrig')) {
3228
+ const rt = (viewer.rigTrack = new RigTracker());
3229
+ rt.note(initialViewRig || { type: 'display', virtualDisplayHeight });
3230
+ const push = handle.setViewRig;
3231
+ handle.setViewRig = (rig) => {
3232
+ const r = push.call(handle, rig);
3233
+ rt.note(rig);
3234
+ return r;
3235
+ };
3236
+ }
3133
3237
  let diagLoop = null;
3134
3238
  if (diag) {
3135
3239
  // Every rig push, logged with its values; under `norig`, every push after the first
@@ -3160,8 +3264,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3160
3264
  if (opts.displayRigLayers !== undefined) {
3161
3265
  const d = opts.displayRigLayers;
3162
3266
  const list = Array.isArray(d) ? d : d && Array.isArray(d.layers) ? d.layers : null;
3163
- if (!list) throw new TypeError('@displayxr/inline3d/splat: displayRigLayers — expected an array of layers, or { layers, viewerDistance?, gain? }.');
3164
- const o = Array.isArray(d) ? {} : { ...(d.viewerDistance !== undefined ? { viewerDistance: d.viewerDistance } : {}), ...(d.gain !== undefined ? { gain: d.gain } : {}) };
3267
+ if (!list) throw new TypeError('@displayxr/inline3d/splat: displayRigLayers — expected an array of layers, or { layers, viewerDistance?, gain?, planeOffset?, planeDistance? }.');
3268
+ const o = Array.isArray(d) ? {} : Object.fromEntries(Object.entries(d).filter(([k]) => k !== 'layers'));
3165
3269
  for (const l of list) out.setLayerRig(l, 'display', o);
3166
3270
  }
3167
3271
  // Replay what the page did before this module arrived — exclude() above all, which a product