@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.
@@ -30,6 +30,15 @@
30
30
  // pick index. Tests "the page's picks are what blocks the main thread".
31
31
  // nolayerrig — handle.setLayerRig is recorded but never applied: every layer stays on the eye
32
32
  // camera and the photo's camera rig (the pre-1.23 path, byte for byte).
33
+ // oldrig — no rig tracking (./inline3d-splat-rig-map.js): the views are drawn as the runtime
34
+ // located them, and a live outgoing photo goes back on the pre-1.24 node chain (which
35
+ // scales its disparity by the two rigs' window ratio at the swap). A/B for "the
36
+ // outgoing photo jumps when the incoming rig is declared".
37
+ //
38
+ // Per frame, `rigAt` / `rigIn` / `rigOut` say which declared rig the views were located for
39
+ // (the tracker's id; null = none matched), which rig the current photo was drawn through
40
+ // (`+` = remapped to it) and how the live outgoing photo was drawn ('own' = its rig as located,
41
+ // 'remapped', 'eye', 'chain'; null = no live outgoing).
33
42
  //
34
43
  // WHAT RUNS ON THE MAIN THREAD, per transition phase (the fifth cause: a stall at the swap's END
35
44
  // that is not the SDK's transition at all):
@@ -44,7 +53,7 @@
44
53
  // after it (the page's continuation of `await setSource`).
45
54
 
46
55
  /** The switches `diag` / `?dxrdiag` understand, besides the plain on values. */
47
- export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick', 'nolayerrig']);
56
+ export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick', 'nolayerrig', 'oldrig']);
48
57
  const ON_TOKENS = new Set(['1', 'on', 'true', 'yes']);
49
58
 
50
59
  /**
@@ -191,6 +200,7 @@ export class DiagRecorder {
191
200
  this._frameNo = 0;
192
201
  this._open = null; // the transition being recorded
193
202
  this.imageState = null; // () => { overlay: 'none' | 'frozen' | 'live', w }
203
+ this.rigState = null; // () => { at, in, out } (see the header)
194
204
  this.longTasks = [];
195
205
  this._lto = null;
196
206
  this.rigLocked = null; // norig: the summary of the rig kept
@@ -373,6 +383,7 @@ export class DiagRecorder {
373
383
  this._lastFrameAt = t;
374
384
  this._frameNo++;
375
385
  const img = this.imageState ? safe(this.imageState) : null;
386
+ const rs = this.rigState ? safe(this.rigState) : null;
376
387
  const f = {
377
388
  n: this._frameNo,
378
389
  t: round1(t),
@@ -388,6 +399,9 @@ export class DiagRecorder {
388
399
  afterRig: this._frameNo - this._rigFrame <= 3,
389
400
  img: img ? img.overlay : 'none',
390
401
  imgW: img ? round4(img.w) : 0,
402
+ rigAt: rs ? rs.at : null,
403
+ rigIn: rs ? rs.in : null,
404
+ rigOut: rs ? rs.out : null,
391
405
  };
392
406
  this.frames.push(f);
393
407
  if (this.frames.length > FRAME_CAP) this.frames.splice(0, this.frames.length - FRAME_CAP);
@@ -19,6 +19,8 @@
19
19
  // nominal viewer distance in metres — the one quantity the browser does not expose (see the
20
20
  // design note §5). Everything here is PLAIN ARITHMETIC on arrays, unit-testable without a GPU.
21
21
 
22
+ import { invertAffine } from './inline3d-splat-rig-map.js';
23
+
22
24
  /** handle.setLayerRig's rigs. 'camera' is the default every layer starts on. */
23
25
  export const LAYER_RIGS = Object.freeze(['display', 'camera']);
24
26
 
@@ -37,9 +39,41 @@ function rotate(q, v) {
37
39
  return [v[0] + w * tx + (y * tz - z * ty), v[1] + w * ty + (z * tx - x * tz), v[2] + w * tz + (x * ty - y * tx)];
38
40
  }
39
41
 
42
+ /** The option keys setLayerRig / setLayerRigOptions take (the tile-wide rounding + plane). */
43
+ export const LAYER_RIG_OPTION_KEYS = Object.freeze(['viewerDistance', 'gain', 'planeOffset', 'planeDistance']);
44
+
45
+ /**
46
+ * setLayerRig / setLayerRigOptions options, validated (throws at the call). Only the keys given
47
+ * are returned: they MERGE into the tile's options. `null` clears a key (back to its default).
48
+ * viewerDistance m, > 0 the nominal viewer distance n (default 0.6)
49
+ * gain > 0 an explicit rounding gain k instead of D/(m·n)
50
+ * planeOffset m, finite moves the stage TOWARD the viewer by this much on the panel (display
51
+ * space): the plane that lands on the glass moves back by offset·D/n
52
+ * planeDistance > 0 the plane that lands on the glass, as a distance from the photo's
53
+ * camera in world units (wins over planeOffset)
54
+ */
55
+ export function validateLayerRigOptions(o = {}, who = 'setLayerRig') {
56
+ if (o === null || typeof o !== 'object') throw new TypeError(`@displayxr/inline3d/splat: ${who} options must be an object.`);
57
+ const unknown = Object.keys(o).filter((k) => !LAYER_RIG_OPTION_KEYS.includes(k));
58
+ if (unknown.length) throw new Error(`@displayxr/inline3d/splat: ${who} — unknown option(s) ${unknown.join(', ')}; expected ${LAYER_RIG_OPTION_KEYS.join(', ')}.`);
59
+ const out = {};
60
+ for (const k of LAYER_RIG_OPTION_KEYS) {
61
+ if (o[k] === undefined) continue;
62
+ if (o[k] === null) {
63
+ out[k] = null;
64
+ continue;
65
+ }
66
+ const v = o[k];
67
+ const ok = k === 'planeOffset' ? Number.isFinite(v) : Number.isFinite(v) && v > 0;
68
+ if (!ok) throw new Error(`@displayxr/inline3d/splat: ${who} — bad ${k}: ${v}.`);
69
+ out[k] = v;
70
+ }
71
+ return out;
72
+ }
73
+
40
74
  /**
41
75
  * setLayerRig's arguments, validated (throws at the call).
42
- * @returns {{ rig: 'display'|'camera', viewerDistance: number|null, gain: number|null }}
76
+ * @returns {{ rig: 'display'|'camera', opts: object }}
43
77
  */
44
78
  export function validateLayerRig(layer, rig, o = {}) {
45
79
  if (!(typeof layer === 'string' && layer.length) && !(Number.isInteger(layer) && layer >= 0) && !(layer && typeof layer === 'object' && Number.isInteger(layer.id))) {
@@ -48,13 +82,7 @@ export function validateLayerRig(layer, rig, o = {}) {
48
82
  if (!LAYER_RIGS.includes(rig)) {
49
83
  throw new Error(`@displayxr/inline3d/splat: setLayerRig — rig "${rig}", expected ${LAYER_RIGS.map((r) => `'${r}'`).join(' or ')}.`);
50
84
  }
51
- if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setLayerRig options must be an object.');
52
- const num = (k) => {
53
- if (o[k] === undefined) return null;
54
- if (!Number.isFinite(o[k]) || !(o[k] > 0)) throw new Error(`@displayxr/inline3d/splat: setLayerRig — bad ${k}: ${o[k]}.`);
55
- return o[k];
56
- };
57
- return { rig, viewerDistance: num('viewerDistance'), gain: num('gain') };
85
+ return { rig, opts: validateLayerRigOptions(o) };
58
86
  }
59
87
 
60
88
  /**
@@ -75,11 +103,24 @@ export function cameraRigFrame(rig) {
75
103
 
76
104
  /** k = D / (m·n), or the caller's explicit gain. */
77
105
  export function layerRigGain(frame, { viewerDistance = null, gain = null } = {}) {
78
- if (gain !== null) return gain;
79
- const n = viewerDistance !== null ? viewerDistance : DEFAULT_VIEWER_DISTANCE_M;
106
+ if (gain !== null && gain !== undefined) return gain;
107
+ const n = viewerDistance !== null && viewerDistance !== undefined ? viewerDistance : DEFAULT_VIEWER_DISTANCE_M;
80
108
  return frame.D / (frame.m * n);
81
109
  }
82
110
 
111
+ /**
112
+ * The plane that lands on the GLASS, as a distance from the photo's camera (world units): the
113
+ * convergence distance D by default; `planeDistance` if given; else D·(1 + planeOffset/n) — a
114
+ * stage moved toward the viewer by `planeOffset` metres on the panel (near the plane the round rig
115
+ * maps world depth to panel depth at n/D). Always > 0.
116
+ */
117
+ export function layerRigPlane(frame, { viewerDistance = null, planeOffset = null, planeDistance = null } = {}) {
118
+ if (planeDistance !== null && planeDistance !== undefined) return planeDistance;
119
+ if (planeOffset === null || planeOffset === undefined || planeOffset === 0) return frame.D;
120
+ const n = viewerDistance !== null && viewerDistance !== undefined ? viewerDistance : DEFAULT_VIEWER_DISTANCE_M;
121
+ return Math.max(frame.D * 1e-3, frame.D * (1 + planeOffset / n));
122
+ }
123
+
83
124
  /**
84
125
  * The window-fixing shear for one eye, as a column-major 4×4 (and its inverse), in the space the
85
126
  * declared rig and the views share.
@@ -173,27 +214,47 @@ export function invertRigid(m, out = new Float64Array(16)) {
173
214
  }
174
215
 
175
216
  /**
176
- * The round views for one frame: for each runtime view pose P_i (camera → rig space, rigid),
177
- * `viewInv = M_i⁻¹ · P_i` and `view = P_i⁻¹ · M_i` — what RenderView.setView takes. Null (the
178
- * caller keeps the photo views) when there is nothing to round: no camera rig, fewer than two
179
- * views, or a gain of exactly 1.
217
+ * The round views for one frame: for each runtime view pose P_i (camera → rig space),
218
+ * `viewInv = S⁻¹ · M_i⁻¹ · P_i` and `view = P_i⁻¹ · M_i · S` — what RenderView.setView takes.
219
+ *
220
+ * S is the uniform scale by σ = D / plane about the photo camera N0: it sends the plane that
221
+ * should land on the glass (`plane`, a distance from N0) onto the photo's window plane, and every
222
+ * ray from N0 onto itself — so at the nominal viewpoint the picture is unchanged and only the
223
+ * depth that lands on the glass moves. Exact (a portal through the window at `plane`, seen from
224
+ * N0 + k·D/plane·(E − N0) … mapped by S onto the photo's own portal). σ = 1 without an offset.
225
+ *
226
+ * Null (the caller keeps the photo views) when there is nothing to do: no camera rig, fewer than
227
+ * two views, or a gain of exactly 1 with no plane offset.
180
228
  */
181
- export function roundViews(entries, frame, k, into = []) {
182
- if (!frame || !entries || entries.length < 2 || !(k > 0) || k === 1) return null;
229
+ export function roundViews(entries, frame, k, into = [], plane = frame ? frame.D : 1) {
230
+ const sigma = frame && plane > 0 ? frame.D / plane : 1;
231
+ if (!frame || !entries || entries.length < 2 || !(k > 0) || (k === 1 && sigma === 1)) return null;
183
232
  into.length = entries.length;
233
+ const S = scaleAbout(frame.N0, sigma);
234
+ const Sinv = scaleAbout(frame.N0, 1 / sigma);
184
235
  for (let i = 0; i < entries.length; i++) {
185
236
  const P = entries[i].pose;
186
237
  const o = (into[i] ||= { viewInv: new Float64Array(16), view: new Float64Array(16), M: new Float64Array(16), Minv: new Float64Array(16), eye: [0, 0, 0] });
187
238
  const { Eround } = windowShear([P[12], P[13], P[14]], frame, k, o.M, o.Minv);
188
- o.eye[0] = Eround[0];
189
- o.eye[1] = Eround[1];
190
- o.eye[2] = Eround[2];
191
- mul4(o.Minv, P, o.viewInv);
192
- mul4(invertRigid(P), o.M, o.view);
239
+ // The virtual eye in rig space: S⁻¹(E′).
240
+ for (let a = 0; a < 3; a++) o.eye[a] = frame.N0[a] + (Eround[a] - frame.N0[a]) / sigma;
241
+ mul4(Sinv, mul4(o.Minv, P), o.viewInv);
242
+ mul4(mul4(invertAffine(P), o.M), S, o.view); // P is rigid, or affine once remapped to the declared rig (./inline3d-splat-rig-map.js)
193
243
  }
194
244
  return into;
195
245
  }
196
246
 
247
+ /** X ↦ C + s·(X − C), column-major. */
248
+ function scaleAbout(C, s, out = new Float64Array(16)) {
249
+ out.fill(0);
250
+ out[0] = out[5] = out[10] = s;
251
+ out[12] = C[0] * (1 - s);
252
+ out[13] = C[1] * (1 - s);
253
+ out[14] = C[2] * (1 - s);
254
+ out[15] = 1;
255
+ return out;
256
+ }
257
+
197
258
  /**
198
259
  * Split a layer composition into contiguous RUNS for per-rig cameras. `entries` is the
199
260
  * composition's sublayer list as layer ids, in draw order (opaque and transparent sublayers both
@@ -246,178 +307,335 @@ export function layerRuns(entries, display, eyeLayers) {
246
307
 
247
308
  // ── the engine side ──────────────────────────────────────────────────────────────────────────
248
309
 
310
+ /** How many state lines the layer rig may WARN per tile (first 3D frame + every change). */
311
+ const MAX_STATE_WARNS = 12;
312
+
313
+ /** Name of a camera component's entity, for a reason string. */
314
+ const camName = (c) => c?.entity?.name || c?.name || '(unnamed camera)';
315
+
249
316
  /**
250
- * The per-rig cameras of one tile. Owned by the viewer; `sync` + `frame` are called by its draw.
251
- * The eye camera keeps its `layers` minus what moved; `restore()` puts them back.
317
+ * The per-rig cameras of one tile. Owned by the viewer; `sync` + `frame` are called by its draw,
318
+ * on EVERY view path:
319
+ * renderviews — the eye camera draws N RenderViews; each run is ONE camera with N RenderViews.
320
+ * ncamera — the fallback: one eye camera per view (rect + projection override); each run is
321
+ * N cameras the same way, the display run's projections carrying the shear.
322
+ * The eye camera(s) keep their `layers` minus what moved; `restore()` puts them back.
252
323
  */
253
324
  export class LayerRigCameras {
254
325
  constructor(viewer) {
255
326
  this.viewer = viewer;
256
- /** What the page asked for: name or id → { viewerDistance, gain }. */
327
+ /** What the page asked for: name or id → true. */
257
328
  this.requests = new Map();
258
- /** Those, resolved: layer id → options (the ones present in the composition right now). */
329
+ /** Those, resolved: layer id → the caller's key (the ones the eye camera draws right now). */
259
330
  this.layers = new Map();
260
- /** The gain options in force (the last setLayerRig call's — one gain per tile). */
261
- this.opts = {};
331
+ /** The tile's options: merged across setLayerRig / setLayerRigOptions calls. */
332
+ this.opts = { viewerDistance: null, gain: null, planeOffset: null, planeDistance: null };
262
333
  this.disabled = false; // the kill switch (diag 'nolayerrig')
263
- this.cams = { display: null, post: null };
334
+ this.cams = { display: [], post: [] };
264
335
  this.moved = { display: [], post: [] };
265
336
  this._key = '';
266
337
  this._rvs = { display: [], post: [] };
267
338
  this._round = [];
268
- /** Diagnostics: what the last frame did. */
269
- this.last = { rounded: false, gain: null, views: 0 };
339
+ this._syncReason = null;
340
+ this._warns = 0;
341
+ this._warnKey = '';
342
+ /** What the last drawn frame did (layerRigState reads it). */
343
+ this.last = { path: null, engaged: false, reason: 'no frame drawn yet', gain: null, planeM: null, views: 0, located: null, rounded: false };
270
344
  }
271
345
 
272
346
  get active() {
273
347
  return !this.disabled && this.requests.size > 0;
274
348
  }
275
349
 
276
- /** Resolve a name / id / pc.Layer to an id in this tile's composition (null if unknown). */
277
- resolve(layer) {
350
+ /** Resolve a name / id / pc.Layer to a layer of this tile's composition (null if unknown). */
351
+ resolve(key) {
278
352
  const comp = this.viewer.app?.scene?.layers;
279
353
  if (!comp) return null;
280
- if (layer && typeof layer === 'object') return layer.id;
281
- if (Number.isInteger(layer)) return layer;
282
- const l = comp.getLayerByName?.(layer);
283
- return l ? l.id : null;
354
+ if (Number.isInteger(key)) return comp.getLayerById?.(key) || { id: key };
355
+ return comp.getLayerByName?.(key) || null;
284
356
  }
285
357
 
286
- /**
287
- * Record a request by the caller's key (name, id): resolved against the composition on every
288
- * sync, so a layer the page adds AFTER calling setLayerRig is picked up when it appears.
289
- */
290
- set(key, rig, o) {
358
+ /** Record a request by the caller's key (name, id); resolved on every sync. */
359
+ set(key, rig, o = {}) {
291
360
  const k = key && typeof key === 'object' ? key.id : key;
292
- if (rig === 'display') this.requests.set(k, o);
361
+ if (rig === 'display') this.requests.set(k, true);
293
362
  else this.requests.delete(k);
294
- this.opts = o;
363
+ this.setOptions(o);
295
364
  this._key = ''; // re-split on the next draw
296
365
  }
297
366
 
298
- _resolveAll() {
299
- this.layers.clear();
300
- for (const [k, o] of this.requests) {
301
- const id = this.resolve(k);
302
- if (id !== null && id !== undefined) this.layers.set(id, o);
303
- }
367
+ /** Merge options (validated by the caller): a key given replaces, `null` clears. */
368
+ setOptions(o = {}) {
369
+ for (const [k, v] of Object.entries(o)) this.opts[k] = v;
370
+ }
371
+
372
+ /** The eye camera entities of the current view path. */
373
+ _eyeCams() {
374
+ const v = this.viewer;
375
+ if (v._viewPath === 'cameras') return (v._views || []).filter((c) => c?.camera);
376
+ return v.eye?.camera ? [v.eye] : [];
304
377
  }
305
378
 
306
379
  _makeCam(name, priority) {
307
380
  const v = this.viewer;
308
- const cam = v._makeCamera(name, null);
381
+ const cam = v._makeCamera(name, v._viewPath === 'cameras' && v.pc?.Vec4 ? new v.pc.Vec4(0, 0, 1, 1) : null);
309
382
  cam.camera.priority = priority;
310
383
  cam.camera.clearColorBuffer = false;
311
384
  cam.camera.clearDepthBuffer = false;
312
385
  cam.camera.clearStencilBuffer = false;
313
386
  cam.camera.frustumCulling = false;
314
387
  cam.camera.layers = [];
388
+ cam._dxrLayerRig = true;
389
+ if (v._viewPath === 'cameras') {
390
+ cam._dxrProj = new Float64Array(16);
391
+ cam.camera.calculateProjection = (out) => out.set(cam._dxrProj);
392
+ }
315
393
  return cam;
316
394
  }
317
395
 
318
- /** Re-split the composition when the display set, the eye's layers or the composition moved. */
396
+ /** Ensure `n` cameras for a run (1 on renderviews, one per view on ncamera). */
397
+ _runCams(run, n, priority) {
398
+ const list = this.cams[run];
399
+ while (list.length < n) list.push(this._makeCam(`inline3d-eye-${run}${list.length ? `-${list.length}` : ''}`, priority));
400
+ return list;
401
+ }
402
+
403
+ /**
404
+ * Re-split the composition when the display set, the eye's layers or the composition moved.
405
+ * Returns whether a display run exists; `_syncReason` says why not.
406
+ */
319
407
  sync() {
320
408
  const v = this.viewer;
321
- const eyeCam = v.eye?.camera;
409
+ const eyes = this._eyeCams();
322
410
  const comp = v.app?.scene?.layers;
323
- if (!eyeCam || !comp) return false;
324
411
  if (!this.active) {
325
412
  if (this.moved.display.length || this.moved.post.length) this.restore();
413
+ this._syncReason = this.disabled ? 'kill switch (?dxrdiag=nolayerrig)' : 'no layer on the display rig';
326
414
  return false;
327
415
  }
328
- this._resolveAll();
329
- const entries = (comp.layerList || []).map((l) => l.id);
330
- const eyeLayers = [...(eyeCam.layers || []), ...this.moved.display, ...this.moved.post];
331
- const key = `${[...this.layers.keys()].join(',')}|${entries.join(',')}|${(eyeCam.layers || []).join(',')}`;
332
- if (key === this._key) return this.moved.display.length > 0;
333
- const runs = layerRuns(entries, new Set(this.layers.keys()), eyeLayers);
334
- if (runs.interleaved.length && !this._warnedInterleaved) {
335
- this._warnedInterleaved = true;
336
- console.warn(
337
- `[inline3d/splat] setLayerRig: layer(s) ${runs.interleaved.join(', ')} sit BETWEEN display-rig ` +
338
- 'sublayers; they are drawn before them (with the eye camera). Move them before or after the display layers.',
339
- );
416
+ if (!eyes.length || !comp) {
417
+ this._syncReason = 'engine not ready';
418
+ return false;
340
419
  }
341
- this.moved.display = runs.display;
342
- this.moved.post = runs.post;
343
- eyeCam.layers = runs.pre;
344
- if (runs.display.length) {
345
- this.cams.display ||= this._makeCam('inline3d-eye-display', 1);
346
- this.cams.display.camera.layers = runs.display;
420
+ const ref = eyes[0].camera;
421
+ const eyeLayers = [...(ref.layers || []), ...this.moved.display, ...this.moved.post];
422
+ const orphans = []; // display layers NO camera draws: ours to draw (nobody else can lose them)
423
+ const entries = (comp.layerList || []).map((l) => l.id);
424
+ // Resolve every request; a layer the eye camera does not draw cannot be moved (another camera
425
+ // — the page's own, a reflection pass — draws it, or none does): say so, never silently.
426
+ this.layers.clear();
427
+ const missing = [];
428
+ const foreign = [];
429
+ for (const key of this.requests.keys()) {
430
+ const layer = this.resolve(key);
431
+ if (!layer) {
432
+ missing.push(`"${key}" is not in the tile's layer composition`);
433
+ continue;
434
+ }
435
+ if (!eyeLayers.includes(layer.id)) {
436
+ const by = (layer.cameras || []).filter((c) => !c?.entity?._dxrLayerRig).map(camName);
437
+ if (by.length) {
438
+ // Another camera (the page's own, a reflection pass) draws it: taking it away could break
439
+ // that camera's output, so it is left alone — and said.
440
+ foreign.push(`"${key}" is drawn by ${by.join(', ')}, not by the tile's eye camera — draw it with handle.engine.camera (or pass it to setLayerRig without adding it to any camera)`);
441
+ continue;
442
+ }
443
+ orphans.push(layer.id);
444
+ }
445
+ this.layers.set(layer.id, key);
347
446
  }
348
- if (runs.post.length) {
349
- this.cams.post ||= this._makeCam('inline3d-eye-post', 2);
350
- this.cams.post.camera.layers = runs.post;
447
+ const problems = [...missing, ...foreign];
448
+ const key = `${eyes.length}|${[...this.layers.keys()].join(',')}|${entries.join(',')}|${(ref.layers || []).join(',')}|${problems.length}`;
449
+ if (key !== this._key) {
450
+ const runs = layerRuns(entries, new Set(this.layers.keys()), [...eyeLayers, ...orphans]);
451
+ if (runs.interleaved.length && !this._warnedInterleaved) {
452
+ this._warnedInterleaved = true;
453
+ console.warn(
454
+ `[inline3d/splat] setLayerRig: layer(s) ${runs.interleaved.join(', ')} sit BETWEEN display-rig ` +
455
+ 'sublayers; they are drawn before them (with the eye camera). Move them before or after the display layers.',
456
+ );
457
+ }
458
+ this.moved.display = runs.display;
459
+ this.moved.post = runs.post;
460
+ for (const e of eyes) e.camera.layers = runs.pre.slice();
461
+ this._runLayers = { display: runs.display, post: runs.post };
462
+ this._key = `${eyes.length}|${[...this.layers.keys()].join(',')}|${entries.join(',')}|${runs.pre.join(',')}|${problems.length}`;
351
463
  }
352
- if (this.cams.display) this.cams.display.enabled = runs.display.length > 0;
353
- if (this.cams.post) this.cams.post.enabled = runs.post.length > 0;
354
- this._key = `${[...this.layers.keys()].join(',')}|${entries.join(',')}|${eyeCam.layers.join(',')}`;
355
- return runs.display.length > 0;
464
+ this._problems = problems;
465
+ this._syncReason = this.moved.display.length ? null : problems.join('; ') || 'no display layer resolved';
466
+ return this.moved.display.length > 0;
356
467
  }
357
468
 
358
- /** Every moved layer back on the eye camera, run cameras off (the kill switch / last layer off). */
469
+ /** Every moved layer back on the eye camera(s), run cameras off (the kill switch / last layer off). */
359
470
  restore() {
360
- const eyeCam = this.viewer.eye?.camera;
361
- if (eyeCam) {
362
- const back = [...this.moved.display, ...this.moved.post];
363
- eyeCam.layers = [...(eyeCam.layers || []), ...back.filter((id) => !(eyeCam.layers || []).includes(id))];
471
+ const back = [...this.moved.display, ...this.moved.post];
472
+ for (const e of this._eyeCams()) {
473
+ const cur = e.camera.layers || [];
474
+ e.camera.layers = [...cur, ...back.filter((id) => !cur.includes(id))];
364
475
  }
365
476
  this.moved = { display: [], post: [] };
366
- if (this.cams.display) this.cams.display.enabled = false;
367
- if (this.cams.post) this.cams.post.enabled = false;
477
+ for (const list of Object.values(this.cams)) for (const c of list) c.enabled = false;
368
478
  this._key = '';
369
479
  }
370
480
 
371
481
  /**
372
- * Per drawn frame, after the eye camera's RenderViews are set. `rig` is the descriptor the
373
- * runtime located these views with (the viewer snapshots it before the tick can declare anew).
482
+ * Per drawn frame, after the eye camera(s) are set up. `rig` is the descriptor the runtime
483
+ * located these views with; `located` whether the views were VERIFIED against it (the rig map
484
+ * read it off the views) — false means it is the SDK's own record, unverified. Records what
485
+ * happened in `last` and WARNs one line on the first 3D frame and on every change.
374
486
  */
375
- frame(entries, rect, f, rig) {
376
- const pc = this.viewer.pc;
377
- this.last.rounded = false;
378
- this.last.views = entries.length;
487
+ frame(entries, rect, f, rig, { located = null, residual = null } = {}) {
488
+ const v = this.viewer;
489
+ const ncam = v._viewPath === 'cameras';
490
+ const L = this.last;
491
+ L.views = entries.length;
492
+ L.path = entries.length < 2 ? 'mono' : ncam ? 'ncamera' : 'renderviews';
493
+ L.located = located;
494
+ L.residual = residual;
495
+ const hasRun = this.sync();
379
496
  const frame = cameraRigFrame(rig);
380
- const k = frame ? layerRigGain(frame, this.opts || {}) : null;
381
- const round = frame ? roundViews(entries, frame, k, this._round) : null;
382
- this.last.gain = k;
383
- for (const run of ['display', 'post']) {
384
- const cam = this.cams[run];
385
- if (!cam || !cam.enabled) continue;
386
- const rvs = this._rvs[run];
387
- if (rvs.length !== entries.length) {
388
- rvs.length = 0;
389
- for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
390
- cam.camera.camera.xrViews = rvs.slice();
391
- }
392
- const r = run === 'display' ? round : null;
393
- for (let i = 0; i < entries.length; i++) {
394
- const e = entries[i];
395
- if (r) rvs[i].setView(e.proj, r[i].viewInv, r[i].view);
396
- else rvs[i].setView(e.proj, e.pose);
397
- const [x, y, w, h] = rect(e);
398
- rvs[i].setViewport(x, y, w, h);
497
+ const k = frame ? layerRigGain(frame, this.opts) : null;
498
+ const plane = frame ? layerRigPlane(frame, this.opts) : null;
499
+ L.gain = k;
500
+ L.planeM = plane;
501
+ L.photoConvergenceM = frame ? frame.D : null;
502
+ const round = hasRun && frame ? roundViews(entries, frame, k, this._round, plane) : null;
503
+ let reason = null;
504
+ if (!hasRun) reason = this._syncReason;
505
+ else if (entries.length < 2) reason = 'mono (one view): nothing to round — identical to the photo rig';
506
+ else if (!rig) reason = 'no declared view rig for these views';
507
+ else if (!frame) reason = rig.type === 'camera' ? 'camera rig with no finite convergence' : `the declared rig is a ${rig.type || 'non-camera'} rig (setRig('display') / setVideo): already the display rig`;
508
+ else if (!round) reason = 'gain 1 and no plane offset: identical to the photo rig';
509
+ L.engaged = !!round;
510
+ L.rounded = L.engaged;
511
+ L.reason = reason || (this._problems?.length ? `engaged; ${this._problems.join('; ')}` : null);
512
+ if (hasRun) {
513
+ const nViews = entries.length;
514
+ for (const run of ['display', 'post']) {
515
+ const layers = this._runLayers?.[run] || [];
516
+ const need = layers.length ? (ncam ? nViews : 1) : 0;
517
+ const list = need ? this._runCams(run, need, run === 'display' ? 1 : 2) : this.cams[run];
518
+ for (let c = 0; c < list.length; c++) {
519
+ list[c].enabled = c < need;
520
+ if (c < need) list[c].camera.layers = layers;
521
+ }
522
+ if (!need) continue;
523
+ const r = run === 'display' ? round : null;
524
+ if (ncam) this._frameNcam(list, entries, rect, r, run);
525
+ else this._frameRenderViews(list[0], entries, rect, f, r, run);
526
+ if (run === 'display') for (let c = 0; c < need; c++) list[c].camera.clearDepthBuffer = !!r; // two spaces never share a depth test
399
527
  }
400
- if (cam._frustumKey !== this.viewer._frustumKey) {
401
- cam._frustumKey = this.viewer._frustumKey;
402
- cam.camera.camera.setXrProperties({ ...f, horizontalFov: false });
528
+ }
529
+ this._warnState();
530
+ }
531
+
532
+ _frameRenderViews(cam, entries, rect, f, r, run) {
533
+ const pc = this.viewer.pc;
534
+ const rvs = this._rvs[run];
535
+ if (rvs.length !== entries.length) {
536
+ rvs.length = 0;
537
+ for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
538
+ cam.camera.camera.xrViews = rvs.slice();
539
+ }
540
+ for (let i = 0; i < entries.length; i++) {
541
+ const e = entries[i];
542
+ if (r) rvs[i].setView(e.proj, r[i].viewInv, r[i].view);
543
+ else rvs[i].setView(e.proj, e.pose);
544
+ const [x, y, w, h] = rect(e);
545
+ rvs[i].setViewport(x, y, w, h);
546
+ }
547
+ if (cam._frustumKey !== this.viewer._frustumKey) {
548
+ cam._frustumKey = this.viewer._frustumKey;
549
+ cam.camera.camera.setXrProperties({ ...f, horizontalFov: false });
550
+ }
551
+ // The node drives transparent sorting (the views ignore it): on the (round) first eye.
552
+ const p = entries[0].node || entries[0].pose; // `node`: a rigid pose when the views are remapped
553
+ const q = quatOf(p);
554
+ cam.setLocalRotation(q[0], q[1], q[2], q[3]);
555
+ if (r) cam.setLocalPosition(r[0].eye[0], r[0].eye[1], r[0].eye[2]);
556
+ else cam.setLocalPosition(p[12], p[13], p[14]);
557
+ }
558
+
559
+ /**
560
+ * The N-camera path, mirroring the viewer's own fallback: camera i at view i's rect, its node on
561
+ * the view's rigid pose, and a projection override. The engine then draws with
562
+ * proj' · node⁻¹ (node under the rig node), so the display run's shear rides in the projection:
563
+ * proj' = proj · view_round · node ⇒ proj' · node⁻¹ = proj · view_round. Culling is off on these
564
+ * cameras (the sheared frustum is not the node's).
565
+ */
566
+ _frameNcam(list, entries, rect, r, run) {
567
+ const v = this.viewer;
568
+ const pc = v.pc;
569
+ const el = v.canvas;
570
+ const W = el?.width || 1;
571
+ const H = el?.height || 1;
572
+ for (let i = 0; i < entries.length; i++) {
573
+ const e = entries[i];
574
+ const cam = list[i];
575
+ const node = e.node || e.pose;
576
+ if (r) mul4(e.proj, mul4(r[i].view, node), cam._dxrProj);
577
+ else if (e.node) mul4(e.proj, mul4(invertAffine(e.pose), node), cam._dxrProj);
578
+ else cam._dxrProj.set(e.proj);
579
+ const fr = v._frustumOf ? v._frustumOf(e.proj) : null;
580
+ if (fr) {
581
+ cam.camera.fov = fr.fov;
582
+ cam.camera.nearClip = fr.nearClip;
583
+ cam.camera.farClip = fr.farClip;
403
584
  }
404
- // The node drives transparent sorting (the views ignore it): on the (round) first eye.
405
- const p = entries[0].pose;
406
- const q = quatOf(p);
585
+ const [x, y, w, h] = rect(e);
586
+ cam.camera.rect = pc?.Vec4 ? new pc.Vec4(x / W, y / H, w / W, h / H) : [x / W, y / H, w / W, h / H];
587
+ const q = quatOf(node);
407
588
  cam.setLocalRotation(q[0], q[1], q[2], q[3]);
408
- if (r) cam.setLocalPosition(r[0].eye[0], r[0].eye[1], r[0].eye[2]);
409
- else cam.setLocalPosition(p[12], p[13], p[14]);
410
- if (run === 'display') {
411
- // Two camera spaces never share a depth test; in mono (and at gain 1) they are one space.
412
- cam.camera.clearDepthBuffer = !!r;
413
- this.last.rounded = !!r;
414
- }
589
+ cam.setLocalPosition(node[12], node[13], node[14]);
415
590
  }
416
591
  }
417
592
 
593
+ /** One WARN line on the first 3D frame and on every change of engaged / reason / path. */
594
+ _warnState() {
595
+ const L = this.last;
596
+ if (L.path === 'mono' && !this._warnedOnce3d) return; // wait for the first 3D frame
597
+ if (!this.requests.size) return; // nothing asked for: nothing to report
598
+ const key = `${L.path}|${L.engaged}|${L.reason}`;
599
+ if (key === this._warnKey || this._warns >= MAX_STATE_WARNS) return;
600
+ this._warnKey = key;
601
+ this._warns++;
602
+ if (L.path !== 'mono') this._warnedOnce3d = true;
603
+ console.warn(`[inline3d/splat] setLayerRig: ${layerRigLine(this.state())}`);
604
+ }
605
+
606
+ /** The public state (handle.layerRigState). */
607
+ state() {
608
+ const L = this.last;
609
+ return {
610
+ display: [...this.requests.keys()],
611
+ disabled: this.disabled,
612
+ path: L.path,
613
+ engaged: !!L.engaged,
614
+ rounded: !!L.engaged,
615
+ reason: this.disabled ? 'kill switch (?dxrdiag=nolayerrig)' : L.reason,
616
+ viewerDistance: this.opts.viewerDistance ?? DEFAULT_VIEWER_DISTANCE_M,
617
+ gain: L.gain,
618
+ planeM: L.planeM,
619
+ photoConvergenceM: L.photoConvergenceM ?? null,
620
+ planeOffset: this.opts.planeOffset ?? 0,
621
+ located: L.located,
622
+ };
623
+ }
624
+
418
625
  destroy() {
419
626
  this.restore();
420
- for (const c of Object.values(this.cams)) c?.destroy?.();
421
- this.cams = { display: null, post: null };
627
+ for (const list of Object.values(this.cams)) for (const c of list) c?.destroy?.();
628
+ this.cams = { display: [], post: [] };
422
629
  }
423
630
  }
631
+
632
+ /** layerRigState() as one console line (the WARN, and what a page's HUD can print). */
633
+ export function layerRigLine(st) {
634
+ const n = (x, d = 3) => (x === null || x === undefined ? '-' : Number(x).toFixed(d));
635
+ return (
636
+ `path=${st.path ?? '-'} engaged=${st.engaged} layers=[${st.display.join(', ')}] ` +
637
+ `viewerDistance=${n(st.viewerDistance, 2)}m gain=${n(st.gain)} planeM=${n(st.planeM)} ` +
638
+ `photoConvergenceM=${n(st.photoConvergenceM)} planeOffset=${n(st.planeOffset)}m ` +
639
+ `located=${st.located === null || st.located === undefined ? '-' : st.located} reason=${st.reason ?? '-'}`
640
+ );
641
+ }