@displayxr/inline3d 1.24.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.
@@ -39,9 +39,41 @@ function rotate(q, v) {
39
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)];
40
40
  }
41
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
+
42
74
  /**
43
75
  * setLayerRig's arguments, validated (throws at the call).
44
- * @returns {{ rig: 'display'|'camera', viewerDistance: number|null, gain: number|null }}
76
+ * @returns {{ rig: 'display'|'camera', opts: object }}
45
77
  */
46
78
  export function validateLayerRig(layer, rig, o = {}) {
47
79
  if (!(typeof layer === 'string' && layer.length) && !(Number.isInteger(layer) && layer >= 0) && !(layer && typeof layer === 'object' && Number.isInteger(layer.id))) {
@@ -50,13 +82,7 @@ export function validateLayerRig(layer, rig, o = {}) {
50
82
  if (!LAYER_RIGS.includes(rig)) {
51
83
  throw new Error(`@displayxr/inline3d/splat: setLayerRig — rig "${rig}", expected ${LAYER_RIGS.map((r) => `'${r}'`).join(' or ')}.`);
52
84
  }
53
- if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setLayerRig options must be an object.');
54
- const num = (k) => {
55
- if (o[k] === undefined) return null;
56
- if (!Number.isFinite(o[k]) || !(o[k] > 0)) throw new Error(`@displayxr/inline3d/splat: setLayerRig — bad ${k}: ${o[k]}.`);
57
- return o[k];
58
- };
59
- return { rig, viewerDistance: num('viewerDistance'), gain: num('gain') };
85
+ return { rig, opts: validateLayerRigOptions(o) };
60
86
  }
61
87
 
62
88
  /**
@@ -77,11 +103,24 @@ export function cameraRigFrame(rig) {
77
103
 
78
104
  /** k = D / (m·n), or the caller's explicit gain. */
79
105
  export function layerRigGain(frame, { viewerDistance = null, gain = null } = {}) {
80
- if (gain !== null) return gain;
81
- 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;
82
108
  return frame.D / (frame.m * n);
83
109
  }
84
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
+
85
124
  /**
86
125
  * The window-fixing shear for one eye, as a column-major 4×4 (and its inverse), in the space the
87
126
  * declared rig and the views share.
@@ -175,27 +214,47 @@ export function invertRigid(m, out = new Float64Array(16)) {
175
214
  }
176
215
 
177
216
  /**
178
- * The round views for one frame: for each runtime view pose P_i (camera → rig space, rigid),
179
- * `viewInv = M_i⁻¹ · P_i` and `view = P_i⁻¹ · M_i` — what RenderView.setView takes. Null (the
180
- * caller keeps the photo views) when there is nothing to round: no camera rig, fewer than two
181
- * 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.
182
228
  */
183
- export function roundViews(entries, frame, k, into = []) {
184
- 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;
185
232
  into.length = entries.length;
233
+ const S = scaleAbout(frame.N0, sigma);
234
+ const Sinv = scaleAbout(frame.N0, 1 / sigma);
186
235
  for (let i = 0; i < entries.length; i++) {
187
236
  const P = entries[i].pose;
188
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] });
189
238
  const { Eround } = windowShear([P[12], P[13], P[14]], frame, k, o.M, o.Minv);
190
- o.eye[0] = Eround[0];
191
- o.eye[1] = Eround[1];
192
- o.eye[2] = Eround[2];
193
- mul4(o.Minv, P, o.viewInv);
194
- mul4(invertAffine(P), o.M, o.view); // P is rigid, or affine once remapped to the declared rig (./inline3d-splat-rig-map.js)
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)
195
243
  }
196
244
  return into;
197
245
  }
198
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
+
199
258
  /**
200
259
  * Split a layer composition into contiguous RUNS for per-rig cameras. `entries` is the
201
260
  * composition's sublayer list as layer ids, in draw order (opaque and transparent sublayers both
@@ -248,178 +307,335 @@ export function layerRuns(entries, display, eyeLayers) {
248
307
 
249
308
  // ── the engine side ──────────────────────────────────────────────────────────────────────────
250
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
+
251
316
  /**
252
- * The per-rig cameras of one tile. Owned by the viewer; `sync` + `frame` are called by its draw.
253
- * 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.
254
323
  */
255
324
  export class LayerRigCameras {
256
325
  constructor(viewer) {
257
326
  this.viewer = viewer;
258
- /** What the page asked for: name or id → { viewerDistance, gain }. */
327
+ /** What the page asked for: name or id → true. */
259
328
  this.requests = new Map();
260
- /** 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). */
261
330
  this.layers = new Map();
262
- /** The gain options in force (the last setLayerRig call's — one gain per tile). */
263
- this.opts = {};
331
+ /** The tile's options: merged across setLayerRig / setLayerRigOptions calls. */
332
+ this.opts = { viewerDistance: null, gain: null, planeOffset: null, planeDistance: null };
264
333
  this.disabled = false; // the kill switch (diag 'nolayerrig')
265
- this.cams = { display: null, post: null };
334
+ this.cams = { display: [], post: [] };
266
335
  this.moved = { display: [], post: [] };
267
336
  this._key = '';
268
337
  this._rvs = { display: [], post: [] };
269
338
  this._round = [];
270
- /** Diagnostics: what the last frame did. */
271
- 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 };
272
344
  }
273
345
 
274
346
  get active() {
275
347
  return !this.disabled && this.requests.size > 0;
276
348
  }
277
349
 
278
- /** Resolve a name / id / pc.Layer to an id in this tile's composition (null if unknown). */
279
- resolve(layer) {
350
+ /** Resolve a name / id / pc.Layer to a layer of this tile's composition (null if unknown). */
351
+ resolve(key) {
280
352
  const comp = this.viewer.app?.scene?.layers;
281
353
  if (!comp) return null;
282
- if (layer && typeof layer === 'object') return layer.id;
283
- if (Number.isInteger(layer)) return layer;
284
- const l = comp.getLayerByName?.(layer);
285
- return l ? l.id : null;
354
+ if (Number.isInteger(key)) return comp.getLayerById?.(key) || { id: key };
355
+ return comp.getLayerByName?.(key) || null;
286
356
  }
287
357
 
288
- /**
289
- * Record a request by the caller's key (name, id): resolved against the composition on every
290
- * sync, so a layer the page adds AFTER calling setLayerRig is picked up when it appears.
291
- */
292
- set(key, rig, o) {
358
+ /** Record a request by the caller's key (name, id); resolved on every sync. */
359
+ set(key, rig, o = {}) {
293
360
  const k = key && typeof key === 'object' ? key.id : key;
294
- if (rig === 'display') this.requests.set(k, o);
361
+ if (rig === 'display') this.requests.set(k, true);
295
362
  else this.requests.delete(k);
296
- this.opts = o;
363
+ this.setOptions(o);
297
364
  this._key = ''; // re-split on the next draw
298
365
  }
299
366
 
300
- _resolveAll() {
301
- this.layers.clear();
302
- for (const [k, o] of this.requests) {
303
- const id = this.resolve(k);
304
- if (id !== null && id !== undefined) this.layers.set(id, o);
305
- }
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] : [];
306
377
  }
307
378
 
308
379
  _makeCam(name, priority) {
309
380
  const v = this.viewer;
310
- 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);
311
382
  cam.camera.priority = priority;
312
383
  cam.camera.clearColorBuffer = false;
313
384
  cam.camera.clearDepthBuffer = false;
314
385
  cam.camera.clearStencilBuffer = false;
315
386
  cam.camera.frustumCulling = false;
316
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
+ }
317
393
  return cam;
318
394
  }
319
395
 
320
- /** 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
+ */
321
407
  sync() {
322
408
  const v = this.viewer;
323
- const eyeCam = v.eye?.camera;
409
+ const eyes = this._eyeCams();
324
410
  const comp = v.app?.scene?.layers;
325
- if (!eyeCam || !comp) return false;
326
411
  if (!this.active) {
327
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';
328
414
  return false;
329
415
  }
330
- this._resolveAll();
331
- const entries = (comp.layerList || []).map((l) => l.id);
332
- const eyeLayers = [...(eyeCam.layers || []), ...this.moved.display, ...this.moved.post];
333
- const key = `${[...this.layers.keys()].join(',')}|${entries.join(',')}|${(eyeCam.layers || []).join(',')}`;
334
- if (key === this._key) return this.moved.display.length > 0;
335
- const runs = layerRuns(entries, new Set(this.layers.keys()), eyeLayers);
336
- if (runs.interleaved.length && !this._warnedInterleaved) {
337
- this._warnedInterleaved = true;
338
- console.warn(
339
- `[inline3d/splat] setLayerRig: layer(s) ${runs.interleaved.join(', ')} sit BETWEEN display-rig ` +
340
- 'sublayers; they are drawn before them (with the eye camera). Move them before or after the display layers.',
341
- );
416
+ if (!eyes.length || !comp) {
417
+ this._syncReason = 'engine not ready';
418
+ return false;
342
419
  }
343
- this.moved.display = runs.display;
344
- this.moved.post = runs.post;
345
- eyeCam.layers = runs.pre;
346
- if (runs.display.length) {
347
- this.cams.display ||= this._makeCam('inline3d-eye-display', 1);
348
- 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);
349
446
  }
350
- if (runs.post.length) {
351
- this.cams.post ||= this._makeCam('inline3d-eye-post', 2);
352
- 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}`;
353
463
  }
354
- if (this.cams.display) this.cams.display.enabled = runs.display.length > 0;
355
- if (this.cams.post) this.cams.post.enabled = runs.post.length > 0;
356
- this._key = `${[...this.layers.keys()].join(',')}|${entries.join(',')}|${eyeCam.layers.join(',')}`;
357
- 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;
358
467
  }
359
468
 
360
- /** 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). */
361
470
  restore() {
362
- const eyeCam = this.viewer.eye?.camera;
363
- if (eyeCam) {
364
- const back = [...this.moved.display, ...this.moved.post];
365
- 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))];
366
475
  }
367
476
  this.moved = { display: [], post: [] };
368
- if (this.cams.display) this.cams.display.enabled = false;
369
- 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;
370
478
  this._key = '';
371
479
  }
372
480
 
373
481
  /**
374
- * Per drawn frame, after the eye camera's RenderViews are set. `rig` is the descriptor the
375
- * 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.
376
486
  */
377
- frame(entries, rect, f, rig) {
378
- const pc = this.viewer.pc;
379
- this.last.rounded = false;
380
- 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();
381
496
  const frame = cameraRigFrame(rig);
382
- const k = frame ? layerRigGain(frame, this.opts || {}) : null;
383
- const round = frame ? roundViews(entries, frame, k, this._round) : null;
384
- this.last.gain = k;
385
- for (const run of ['display', 'post']) {
386
- const cam = this.cams[run];
387
- if (!cam || !cam.enabled) continue;
388
- const rvs = this._rvs[run];
389
- if (rvs.length !== entries.length) {
390
- rvs.length = 0;
391
- for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
392
- cam.camera.camera.xrViews = rvs.slice();
393
- }
394
- const r = run === 'display' ? round : null;
395
- for (let i = 0; i < entries.length; i++) {
396
- const e = entries[i];
397
- if (r) rvs[i].setView(e.proj, r[i].viewInv, r[i].view);
398
- else rvs[i].setView(e.proj, e.pose);
399
- const [x, y, w, h] = rect(e);
400
- 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
401
527
  }
402
- if (cam._frustumKey !== this.viewer._frustumKey) {
403
- cam._frustumKey = this.viewer._frustumKey;
404
- 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;
405
584
  }
406
- // The node drives transparent sorting (the views ignore it): on the (round) first eye.
407
- const p = entries[0].node || entries[0].pose; // `node`: a rigid pose when the views are remapped
408
- 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);
409
588
  cam.setLocalRotation(q[0], q[1], q[2], q[3]);
410
- if (r) cam.setLocalPosition(r[0].eye[0], r[0].eye[1], r[0].eye[2]);
411
- else cam.setLocalPosition(p[12], p[13], p[14]);
412
- if (run === 'display') {
413
- // Two camera spaces never share a depth test; in mono (and at gain 1) they are one space.
414
- cam.camera.clearDepthBuffer = !!r;
415
- this.last.rounded = !!r;
416
- }
589
+ cam.setLocalPosition(node[12], node[13], node[14]);
417
590
  }
418
591
  }
419
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
+
420
625
  destroy() {
421
626
  this.restore();
422
- for (const c of Object.values(this.cams)) c?.destroy?.();
423
- 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: [] };
424
629
  }
425
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
+ }