@displayxr/inline3d 1.38.0 → 1.40.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.
@@ -36,3 +36,21 @@ export function resolveCursorOption(opt, who = '@displayxr/inline3d') {
36
36
  }
37
37
  return { ...opt };
38
38
  }
39
+
40
+ /** The keys `cursor: { … }` accepts on a stereo PAIR (./camera's addCameraView, ./call's tiles). */
41
+ export const PAIR_CURSOR_OPTION_KEYS = Object.freeze(['height', 'pointerScope']);
42
+
43
+ /**
44
+ * `cursor` on a stereo pair — a camera view or a call tile: `'depth'`, or `{ height, pointerScope }`.
45
+ * A pair has no scene: its depth is MEASURED off the two halves, and the sprite is drawn into them,
46
+ * so there is no anchor mode (it is always on the line of sight) and the margin is the SDK's. Falsy
47
+ * is OFF, the zero-cost default.
48
+ * @returns {null | {height?: number, pointerScope?: string}}
49
+ */
50
+ export function resolvePairCursorOption(opt, who = '@displayxr/inline3d') {
51
+ const o = resolveCursorOption(opt, who);
52
+ if (!o) return null;
53
+ const unknown = Object.keys(o).filter((k) => !PAIR_CURSOR_OPTION_KEYS.includes(k));
54
+ if (unknown.length) throw new Error(`${who}: cursor — ${unknown.join(', ')} not supported on a stereo pair; expected ${PAIR_CURSOR_OPTION_KEYS.join(', ')}.`);
55
+ return o;
56
+ }
@@ -21,6 +21,7 @@
21
21
  // engine import fails, one warning names what to install, and the tile renders on three. With
22
22
  // neither, `ready` rejects naming both. An explicit `engine` never falls back.
23
23
 
24
+ import { guardedAttach } from './inline3d-splat-shared.js';
24
25
  /** The engines `addModel` renders with; the first is the default. */
25
26
  export const MODEL_ENGINES = Object.freeze(['playcanvas', 'three']);
26
27
 
@@ -98,6 +99,12 @@ export function validateModelCall(canvas, src, opts, route) {
98
99
  * fill in as the backend loads.
99
100
  */
100
101
  export function addModel(wall, canvas, src, opts = {}) {
102
+ // A second call on a canvas still registered on this wall warns once, then proceeds as before
103
+ // (see guardedAttach).
104
+ return guardedAttach(wall, canvas, 'addModel', opts, (o) => addModelUnguarded(wall, canvas, src, o));
105
+ }
106
+
107
+ function addModelUnguarded(wall, canvas, src, opts = {}) {
101
108
  const route = resolveModelEngine(opts);
102
109
  validateModelCall(canvas, src, opts, route);
103
110
  if (route.engine === 'playcanvas') {
@@ -531,6 +531,11 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
531
531
  bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context
532
532
  ...(observe ? { observe } : {}),
533
533
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
534
+ // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the
535
+ // preview tier; 'off' for a page that runs its own cover.
536
+ rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover,
537
+ // The entry's double-attach claim (guardedAttach): lets THIS registration through.
538
+ _dxrClaim: opts._dxrClaim,
534
539
  });
535
540
  } else {
536
541
  viewer.startMono();
@@ -38,6 +38,7 @@
38
38
  // your own origin (`/draco/`, `/basis/`) and `opts.decoderPath` moves that. See
39
39
  // docs/authoring-inline-3d.md#compressed-gltf.
40
40
 
41
+ import { guardedAttach } from './inline3d-splat-shared.js';
41
42
  import * as THREE from 'three';
42
43
  import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest } from './inline3d-three.js';
43
44
  import { resolveCursorOption } from './inline3d-cursor-depth.js';
@@ -345,6 +346,12 @@ function kindFromMessage(msg) {
345
346
  * `setPose`, `resetPose`, `frame`, and `ready`.
346
347
  */
347
348
  export function addModel(wall, canvas, src, opts = {}) {
349
+ // A second call on a canvas still registered on this wall warns once, then proceeds as before
350
+ // (see guardedAttach).
351
+ return guardedAttach(wall, canvas, 'addModel', opts, (o) => addModelUnguarded(wall, canvas, src, o));
352
+ }
353
+
354
+ function addModelUnguarded(wall, canvas, src, opts = {}) {
348
355
  const {
349
356
  virtualDisplayHeight = 0.24,
350
357
  frame = null,
@@ -426,6 +433,11 @@ export function addModel(wall, canvas, src, opts = {}) {
426
433
  bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context
427
434
  ...(observe ? { observe } : {}),
428
435
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
436
+ // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the
437
+ // preview tier; 'off' for a page that runs its own cover.
438
+ rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover,
439
+ // The entry's double-attach claim (guardedAttach): lets THIS registration through.
440
+ _dxrClaim: opts._dxrClaim,
429
441
  });
430
442
  } else {
431
443
  viewer.startMono();
@@ -16,7 +16,7 @@
16
16
  // Not here: measureSplatBounds (it measures a Spark mesh — import it from ./splat).
17
17
 
18
18
  import { resolveRevealOption } from './inline3d-splat-effects.js';
19
- import { CAPTURE_FITS, playcanvasCannotRead, resolveControls } from './inline3d-splat-shared.js';
19
+ import { CAPTURE_FITS, guardedAttach, playcanvasCannotRead, resolveControls } from './inline3d-splat-shared.js';
20
20
  import { addSplatDeferred } from './inline3d-splat-deferred.js';
21
21
 
22
22
  export { applySplatPerf, SPLAT_PERF_PRESETS } from './inline3d-splat-perf.js';
@@ -25,6 +25,12 @@ export { resolveRig } from './inline3d-splat-rig.js';
25
25
 
26
26
  /** addSplat with `engine: 'playcanvas'`; the same validation, at the call, as ./splat's. */
27
27
  export function addSplat(wall, canvas, src, opts = {}) {
28
+ // A second call on a canvas still registered on this wall warns once, then proceeds as before
29
+ // (see guardedAttach).
30
+ return guardedAttach(wall, canvas, 'addSplat', opts, (o) => addSplatUnguarded(wall, canvas, src, o));
31
+ }
32
+
33
+ function addSplatUnguarded(wall, canvas, src, opts = {}) {
28
34
  if (opts.engine !== undefined && opts.engine !== 'playcanvas') {
29
35
  throw new Error(
30
36
  `@displayxr/inline3d/splat/playcanvas: engine "${opts.engine}" — this entry is PlayCanvas ` +
@@ -1002,7 +1002,11 @@ export class PlayCanvasSplatViewer {
1002
1002
  this._tileBudget = undefined; // undefined = the engine's own default
1003
1003
  this._budgetViews = 1;
1004
1004
 
1005
- this._onResize = () => this._scheduleResize();
1005
+ // Resize AND draw inside the observer callback (it runs after the frame's animation callbacks
1006
+ // and before the paint). Deferring to the next animation frame, as this used to, let one
1007
+ // paint show the old backing store stretched onto the new box: a visible jolt on every
1008
+ // CSS resize of a woven canvas. See _resizeNow for the two cases that still defer.
1009
+ this._onResize = () => this._resizeNow();
1006
1010
  this._ro = typeof ResizeObserver === 'function' ? new ResizeObserver(this._onResize) : null;
1007
1011
  if (this._ro) this._ro.observe(canvas);
1008
1012
  else if (typeof addEventListener === 'function') addEventListener('resize', this._onResize);
@@ -1231,7 +1235,7 @@ export class PlayCanvasSplatViewer {
1231
1235
  this._beforeFrame?.(views || null);
1232
1236
  this._tick();
1233
1237
  if (!views || views.length < 2) {
1234
- this._replayLastGood();
1238
+ this._replayOrFlat();
1235
1239
  return;
1236
1240
  }
1237
1241
  const vps = this._vps;
@@ -1239,7 +1243,7 @@ export class PlayCanvasSplatViewer {
1239
1243
  for (const view of views) {
1240
1244
  const vp = layer && typeof layer.getViewport === 'function' ? layer.getViewport(view) : null;
1241
1245
  if (!vp || !(vp.width > 0) || !(vp.height > 0)) {
1242
- this._replayLastGood();
1246
+ this._replayOrFlat();
1243
1247
  return;
1244
1248
  }
1245
1249
  vps.push(vp);
@@ -2010,6 +2014,17 @@ export class PlayCanvasSplatViewer {
2010
2014
  * engine tick. Every entry is {proj, pose, x, y, width, height} in BUFFER pixels.
2011
2015
  */
2012
2016
  _drawEntries(entries, cache) {
2017
+ // `_drawing` is what _resizeNow checks: never reallocate the buffer under a running render.
2018
+ if (this._drawing) return this._drawEntriesInner(entries, cache);
2019
+ this._drawing = true;
2020
+ try {
2021
+ return this._drawEntriesInner(entries, cache);
2022
+ } finally {
2023
+ this._drawing = false;
2024
+ }
2025
+ }
2026
+
2027
+ _drawEntriesInner(entries, cache) {
2013
2028
  const app = this.app;
2014
2029
  if (!app || !this.pc) return false;
2015
2030
  const pc = this.pc;
@@ -2139,6 +2154,7 @@ export class PlayCanvasSplatViewer {
2139
2154
  }
2140
2155
  g.bufW = el.width || 0;
2141
2156
  g.bufH = el.height || 0;
2157
+ g.boxAspect = this.boxAspect || 0; // the CSS shape these views were located for (see _replayLastGood)
2142
2158
  g.rig = this._rigSnap ? { ...this._rigSnap } : null; // a replay re-uses the rig of its views
2143
2159
  g.rigAt = this._rigAtPull ?? null;
2144
2160
  for (let i = 0; i < views.length; i++) {
@@ -2153,10 +2169,90 @@ export class PlayCanvasSplatViewer {
2153
2169
  }
2154
2170
  }
2155
2171
 
2172
+ /**
2173
+ * A session frame that cannot draw its own views still PRESENTS: the last good frame, or —
2174
+ * before there is one — the mono camera into both halves (flat, the subject if loaded, else
2175
+ * the clear colour). A woven canvas that is not redrawn can drop out of the frame the
2176
+ * compositor aggregates (woven-canvas-rules, "redraw every frame"), and a load-fallback frame
2177
+ * before the first stereo one is exactly when no cache exists. The only frame this cannot
2178
+ * cover is one before the engine has attached: there is no GL context yet, and creating one
2179
+ * here would create it with the wrong attributes.
2180
+ */
2181
+ _replayOrFlat() {
2182
+ return this._replayLastGood() || this._drawFlatPair();
2183
+ }
2184
+
2185
+ _drawFlatPair() {
2186
+ if (this._disposed || !this.app || !this.pc) return false;
2187
+ const b = this._bufScale();
2188
+ const W = b.w || this.canvas.width;
2189
+ const H = b.h || this.canvas.height;
2190
+ if (!(W > 1) || !(H > 0)) return false;
2191
+ const half = Math.floor(W / 2);
2192
+ const v = this._monoView();
2193
+ const pair = (this._flatPair ||= [0, 1].map(() => ({ proj: null, pose: null, x: 0, y: 0, width: 0, height: 0 })));
2194
+ for (let i = 0; i < 2; i++) {
2195
+ const e = pair[i];
2196
+ e.proj = v.proj;
2197
+ e.pose = v.pose;
2198
+ e.x = i * half;
2199
+ e.y = 0;
2200
+ e.width = half;
2201
+ e.height = H;
2202
+ }
2203
+ return this._drawEntries(pair, null);
2204
+ }
2205
+
2156
2206
  _replayLastGood() {
2157
2207
  const g = this._lastGood;
2158
2208
  if (!g || this._disposed) return false;
2159
- return this._drawEntries(g.entries, g);
2209
+ // A box whose aspect changed since the cache (a CSS resize replayed before the runtime has
2210
+ // located views for the new rect): the cached frustum was built for the OLD shape, and the
2211
+ // viewports map onto the new buffer proportionally, so the picture would be stretched by
2212
+ // exactly the aspect ratio change. Widen (or narrow) the frustum by the same factor —
2213
+ // dividing the projection's x row by it — so the replayed frame keeps its proportions. One
2214
+ // frame's approximation: the next located views carry the true frustum for the new window.
2215
+ const f = g.boxAspect > 0 && this.boxAspect > 0 ? this.boxAspect / g.boxAspect : 1;
2216
+ if (Math.abs(f - 1) < 1e-6) return this._drawEntries(g.entries, g);
2217
+ const fixed = (this._aspectFixed ||= []);
2218
+ fixed.length = g.entries.length;
2219
+ for (let i = 0; i < g.entries.length; i++) {
2220
+ const e = g.entries[i];
2221
+ const o = (fixed[i] ||= { proj: new Float32Array(16), pose: null, x: 0, y: 0, width: 0, height: 0 });
2222
+ o.proj.set(e.proj);
2223
+ o.proj[0] /= f;
2224
+ o.proj[4] /= f;
2225
+ o.proj[8] /= f;
2226
+ o.proj[12] /= f;
2227
+ o.pose = e.pose;
2228
+ o.x = e.x;
2229
+ o.y = e.y;
2230
+ o.width = e.width;
2231
+ o.height = e.height;
2232
+ }
2233
+ return this._drawEntries(fixed, g);
2234
+ }
2235
+
2236
+ /**
2237
+ * The observer's resize, synchronous. Two cases still take the debounced path instead: a call
2238
+ * that arrives while this viewer is already resizing (re-entrancy: setting canvas.width can
2239
+ * queue more observer work), and one that arrives while it is drawing (a nested dispatch from
2240
+ * inside the engine's tick). Reallocating the drawing buffer under a running render would
2241
+ * leave that render half on a cleared store.
2242
+ */
2243
+ _resizeNow() {
2244
+ if (this._disposed) return;
2245
+ if (this._inResize || this._drawing) {
2246
+ this._scheduleResize();
2247
+ return;
2248
+ }
2249
+ this._resizePending = false; // this run supersedes a debounced one already queued
2250
+ this._inResize = true;
2251
+ try {
2252
+ this._resize();
2253
+ } finally {
2254
+ this._inResize = false;
2255
+ }
2160
2256
  }
2161
2257
 
2162
2258
  _scheduleResize() {
@@ -3599,6 +3695,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3599
3695
  bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context
3600
3696
  ...(observe ? { observe } : {}),
3601
3697
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
3698
+ // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the
3699
+ // preview tier; 'off' for a page that runs its own cover.
3700
+ rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover,
3701
+ // The entry's double-attach claim (guardedAttach): lets THIS registration through.
3702
+ _dxrClaim: opts._dxrClaim,
3602
3703
  });
3603
3704
  } else {
3604
3705
  viewer.startMono();
@@ -12,6 +12,23 @@
12
12
  // apart on how a drag, a wheel notch, an idle turntable or a focus change feels. Pinned by
13
13
  // test/splat-playcanvas.test.mjs (values, and a behavioural trace of both viewers side by side).
14
14
 
15
+ /**
16
+ * Double-attach detection around a scene subpath's public entry (`addSplat`, `addModel`).
17
+ *
18
+ * The check has to run HERE, at the entry, and not only in the core's addScene: the subpaths build
19
+ * a renderer on the canvas BEFORE the core window exists (asynchronously, once the engine module
20
+ * has loaded), so a second `addSplat` during the first one's load would never reach the core's
21
+ * check in time. `wall._attachGuard` (the core manager) warns once per canvas and claims it; the
22
+ * call then proceeds exactly as before. A wall without it (no session, an older core) attaches as is.
23
+ */
24
+ export function guardedAttach(wall, canvas, method, opts, attach) {
25
+ const g = wall && typeof wall._attachGuard === 'function' ? wall._attachGuard(canvas, method, opts) : null;
26
+ if (!g) return attach(opts);
27
+ const out = attach(g.opts);
28
+ g.own(out);
29
+ return out;
30
+ }
31
+
15
32
  /**
16
33
  * Backstop on total subject depth, as a multiple of the display height. Generous on purpose:
17
34
  * depth placement is a z decision (see fitTo), not a scale one, so this only catches the
@@ -39,6 +39,7 @@ import {
39
39
  STREAMED_NEEDS_PLAYCANVAS,
40
40
  resolveControls,
41
41
  declareViewRig,
42
+ guardedAttach,
42
43
  } from './inline3d-splat-shared.js';
43
44
  import {
44
45
  resolveRig,
@@ -162,6 +163,12 @@ function sniffFileType(bytes) {
162
163
  * good enough for a clean, isolated capture, weaker on a scene with a background wall.
163
164
  */
164
165
  export function addSplat(wall, canvas, src, opts = {}) {
166
+ // A second call on a canvas still registered on this wall warns once, then proceeds as before
167
+ // (see guardedAttach).
168
+ return guardedAttach(wall, canvas, 'addSplat', opts, (o) => addSplatUnguarded(wall, canvas, src, o));
169
+ }
170
+
171
+ function addSplatUnguarded(wall, canvas, src, opts = {}) {
165
172
  if (opts.captureFit !== undefined && !CAPTURE_FITS.includes(opts.captureFit)) {
166
173
  throw new Error(
167
174
  `@displayxr/inline3d/splat: captureFit "${opts.captureFit}" — expected ` +
@@ -487,6 +494,11 @@ export function addSplat(wall, canvas, src, opts = {}) {
487
494
  bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context
488
495
  ...(observe ? { observe } : {}),
489
496
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
497
+ // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the
498
+ // preview tier; 'off' for a page that runs its own cover.
499
+ rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover,
500
+ // The entry's double-attach claim (guardedAttach): lets THIS registration through.
501
+ _dxrClaim: opts._dxrClaim,
490
502
  });
491
503
  } else {
492
504
  viewer.startMono();
@@ -699,7 +699,7 @@ export class SceneViewer {
699
699
 
700
700
  // 1. A short view list is the load-induced mono fallback. Stereo needs two.
701
701
  if (!views || views.length < 2) {
702
- this._replayLastGood();
702
+ this._replayOrFlat();
703
703
  return;
704
704
  }
705
705
 
@@ -725,7 +725,7 @@ export class SceneViewer {
725
725
  for (const view of views) {
726
726
  const vp = layer && typeof layer.getViewport === 'function' ? layer.getViewport(view) : null;
727
727
  if (!vp || !(vp.width > 0) || !(vp.height > 0)) {
728
- this._replayLastGood();
728
+ this._replayOrFlat();
729
729
  return;
730
730
  }
731
731
  vps.push(vp);
@@ -891,11 +891,41 @@ export class SceneViewer {
891
891
  }
892
892
  }
893
893
 
894
+ /**
895
+ * A session frame that cannot draw its own views still PRESENTS (1.38): the last good frame,
896
+ * or, before there is one, the mono camera into both halves — flat, but drawn. An undrawn
897
+ * woven canvas can drop out of the frame the compositor aggregates (woven-canvas-rules,
898
+ * "redraw every frame"), and this viewer used to leave exactly the frames before the first
899
+ * stereo one undrawn. Every clear here is followed by a draw, so the dark-blink rule (web#12)
900
+ * still holds.
901
+ */
902
+ _replayOrFlat() {
903
+ return this._replayLastGood() || this._drawFlatPair();
904
+ }
905
+
906
+ _drawFlatPair() {
907
+ if (this._disposed) return false;
908
+ const r = this.renderer;
909
+ const b = this._bufScale();
910
+ if (!(b.w > 1) || !(b.h > 0)) return false;
911
+ const half = Math.floor(b.w / 2);
912
+ r.clear();
913
+ r.setScissorTest(true);
914
+ for (const x of [0, half]) {
915
+ const vp = { x, y: 0, width: half, height: b.h };
916
+ r.setViewport(vp.x, vp.y, vp.width, vp.height);
917
+ r.setScissor(vp.x, vp.y, vp.width, vp.height);
918
+ r.render(this.scene, this.monoCamera);
919
+ if (this._feather) this._feather.render(r, vp);
920
+ }
921
+ r.setScissorTest(false);
922
+ return true;
923
+ }
924
+
894
925
  /**
895
926
  * Re-render the last good frame from the cached matrices. Returns false when there is no
896
- * cache yet — and the caller must then do NOTHING, not clear: before the first good frame
897
- * the canvas holds either the page's own initial state or the mono fallback's output, both
898
- * of which are better than black.
927
+ * cache yet (onFrame then draws the flat pair, see _replayOrFlat; a resize leaves the
928
+ * freshly cleared store to the next session frame).
899
929
  */
900
930
  _replayLastGood() {
901
931
  const g = this._lastGood;