@displayxr/inline3d 1.6.0 → 1.7.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.
@@ -28,7 +28,11 @@
28
28
  // viewer.content.add(myMesh);
29
29
  // viewer.fitTo(center, extent); // model-space bounds of the subject
30
30
  // const wall = await createInline3D();
31
- // if (wall.supported) wall.addScene(canvas, viewer.onFrame, { virtualDisplayHeight: 0.18 });
31
+ // if (wall.supported)
32
+ // wall.addScene(canvas, viewer.onFrame, {
33
+ // virtualDisplayHeight: 0.18,
34
+ // onLayerLost: viewer.onLayerLost, // the session ended: go flat rather than show raw SBS
35
+ // });
32
36
  // else viewer.startMono();
33
37
  //
34
38
  // WHY FRAMING IS SCENE-GRAPH WORK AND NOT A RIG FIELD. The native display rig
@@ -48,6 +52,15 @@ const DEFAULT_DEPTH_LIMIT = 4.0;
48
52
  /** Milliseconds of no interaction before the idle turntable starts. */
49
53
  const IDLE_DELAY_MS = 2500;
50
54
 
55
+ /**
56
+ * Per-frame easing factor for a focus change, matching the gallery's `EASE`.
57
+ *
58
+ * Deliberately per FRAME and not per second, because that is what the reference implementation
59
+ * does and a focus change is a one-off gesture response rather than a continuous motion — the
60
+ * difference between 60 and 120 Hz here is a settle that takes half as long, not a bug.
61
+ */
62
+ const FOCUS_EASE = 0.18;
63
+
51
64
  const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
52
65
  // NaN/Infinity into a transform silently blanks the tile — three propagates it into the
53
66
  // matrix and every vertex lands undefined. Reject at the setter instead.
@@ -222,6 +235,23 @@ export class SceneViewer {
222
235
 
223
236
  this._fitScale = 1;
224
237
  this._zoom = 1;
238
+ // FOCUS — the point everything turns about, eased. `_focus` is where it is now, `_target`
239
+ // where it is going; `_orbitCentre` is where the pivot sits afterwards, and it is what
240
+ // separates the two rigs: a DISPLAY rig brings the focused point to the middle of the tile
241
+ // (centre 0), a CAMERA rig leaves the capture exactly where it was placed and only moves
242
+ // what the rotation turns about (centre = the focus point). See setFocus().
243
+ // Plain triples, not THREE.Vector3: this module takes its THREE by injection and is tested
244
+ // against a stub, so every three.js type it reaches for is one more thing a consumer has to
245
+ // supply. Three numbers need no library.
246
+ this._focus = { x: 0, y: 0, z: 0 };
247
+ this._targetFocus = { x: 0, y: 0, z: 0 };
248
+ this._orbitCentre = { x: 0, y: 0, z: 0 };
249
+ this._focusRecentres = true;
250
+ this._focusSettled = true;
251
+ /** Called after every focus ease step, with the live focus. Set by ./splat. */
252
+ this.onFocusChange = null;
253
+ /** Called at the end of every _tick, after the transform is applied. */
254
+ this.onTick = null;
225
255
  this._targetZoom = 1;
226
256
  // Author-driven slide along the depth axis, display metres, +z toward the viewer. Applied
227
257
  // by _applyTransform, PRESERVED by fitTo, cleared by resetPose. Default 0 means every page
@@ -269,8 +299,23 @@ export class SceneViewer {
269
299
  if (orbit) this._bindOrbit();
270
300
  this._resize();
271
301
 
272
- // Bound so it can be passed straight to addScene without a wrapper closure.
302
+ // Bound so they can be passed straight to addScene without a wrapper closure.
273
303
  this.onFrame = this.onFrame.bind(this);
304
+ this.onLayerLost = this.onLayerLost.bind(this);
305
+ }
306
+
307
+ /**
308
+ * The weave layer went away for good — pass this to `wall.addScene(canvas, viewer.onFrame,
309
+ * { onLayerLost: viewer.onLayerLost })` (`./splat` and `./model` do it for you).
310
+ *
311
+ * Without it the canvas keeps its last woven side-by-side frame on screen as ordinary squeezed
312
+ * 2D, because `_mode` stays `'3d'` and every mono fallback in this SDK is a one-shot decision
313
+ * made at boot (web#28). Going mono here is safe even if a tile is later re-woven: `onFrame`
314
+ * calls `stopMono()` on the first 3D frame it gets.
315
+ */
316
+ onLayerLost() {
317
+ if (this._disposed) return;
318
+ this.startMono();
274
319
  }
275
320
 
276
321
  /**
@@ -288,7 +333,10 @@ export class SceneViewer {
288
333
  const c = Array.isArray(center) ? center : [center.x, center.y, center.z];
289
334
  const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
290
335
 
291
- this._centering.position.set(-c[0], -c[1], -c[2]);
336
+ // Through the focus, not around it: framing a subject IS pointing the viewer at its centre,
337
+ // and keeping the two in one place is what stops an orbit turning about somewhere the fit
338
+ // has since moved away from. Snapped — a refit is not a gesture.
339
+ this.setFocus(c, { snap: true });
292
340
  // Recorded for getSubjectBounds(). Model units; the fit scale is applied at read time so a
293
341
  // later zoom or orbit needs no re-measure.
294
342
  this._subjectHalf = [Math.abs(e[0]) / 2, Math.abs(e[1]) / 2, Math.abs(e[2]) / 2];
@@ -461,6 +509,57 @@ export class SceneViewer {
461
509
  this._applyTransform();
462
510
  }
463
511
 
512
+ /**
513
+ * Point the viewer at something — the one point that is simultaneously the orbit centre, the
514
+ * pivot plane and (on a camera rig) the convergence distance.
515
+ *
516
+ * Those three are the same thing and saying so is the point of this method. A viewer that lets
517
+ * them drift apart orbits about one place, converges at another and rotates the picture around
518
+ * a third, which is how "the scene swings away when I turn it" happens.
519
+ *
520
+ * The two rigs differ in what MOVES, and only in that:
521
+ *
522
+ * - **`recentre: true`** (a display rig, the default) — the focused point is brought to the
523
+ * middle of the tile and onto the zero-disparity plane. That is what a portal does: you
524
+ * chose a subject, so the subject is what the window shows.
525
+ * - **`recentre: false`** (a camera rig) — the capture stays exactly where it was placed and
526
+ * only the rotation centre moves. Translating a camera-rig scene would move the viewpoint,
527
+ * and the neutral view IS the photograph; nothing may move it.
528
+ *
529
+ * Eased at {@link FOCUS_EASE} per frame unless `snap`.
530
+ *
531
+ * @param {{x:number,y:number,z:number}|number[]|null} point in CONTENT space (the space your
532
+ * object sits in, i.e. `viewer.content`'s local space). Null resets to the origin.
533
+ * @param {object} [opts]
534
+ * @param {boolean} [opts.snap=false] arrive immediately.
535
+ * @param {boolean} [opts.recentre] see above. Sticky: set once when the rig is chosen.
536
+ */
537
+ setFocus(point, { snap = false, recentre } = {}) {
538
+ if (recentre !== undefined) this._focusRecentres = !!recentre;
539
+ const p = point == null ? [0, 0, 0] : Array.isArray(point) ? point : [point.x, point.y, point.z];
540
+ this._targetFocus = { x: finite(p[0], 0), y: finite(p[1], 0), z: finite(p[2], 0) };
541
+ this._focusSettled = false;
542
+ if (snap) {
543
+ this._focus = { ...this._targetFocus };
544
+ this._focusSettled = true;
545
+ this._applyFocus();
546
+ this._applyTransform();
547
+ this.onFocusChange?.(this._focus);
548
+ }
549
+ return this;
550
+ }
551
+
552
+ /**
553
+ * Where the viewer is pointed, in content space.
554
+ *
555
+ * @param {object} [opts]
556
+ * @param {boolean} [opts.target=false] the value being eased TOWARD, as with getPose().
557
+ */
558
+ getFocus({ target = false } = {}) {
559
+ const v = target ? this._targetFocus : this._focus;
560
+ return { x: v.x, y: v.y, z: v.z };
561
+ }
562
+
464
563
  /** Return to the framed default pose, depth slide included. */
465
564
  resetPose() {
466
565
  this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
@@ -738,7 +837,13 @@ export class SceneViewer {
738
837
  // The depth slide lives here, not in fitTo, so it survives a refit and cannot be left
739
838
  // stale by a code path that forgets it. x/y are never written: the fit centres the subject
740
839
  // on the tile and sliding it sideways is a scene concern, not a viewer one.
741
- this._pivot.position.z = this._depthOffset;
840
+ // The orbit centre is where the pivot SITS; the depth slide rides on top of it. Both are
841
+ // zero for the ordinary framed subject, so this is identity for every existing page.
842
+ this._pivot.position.set(
843
+ this._orbitCentre.x,
844
+ this._orbitCentre.y,
845
+ this._orbitCentre.z + this._depthOffset,
846
+ );
742
847
  // Order 'XYZ' == R = Rx(pitch) · Ry(yaw), and the order is the whole point.
743
848
  //
744
849
  // Yaw must act in the subject's OWN frame (spin it on its axis); pitch must act in the
@@ -805,7 +910,44 @@ export class SceneViewer {
805
910
  } else {
806
911
  this._zoom = this._targetZoom;
807
912
  }
913
+ this._easeFocus();
808
914
  this._applyTransform();
915
+ this.onTick?.();
916
+ }
917
+
918
+ /**
919
+ * Walk the live focus toward its target. A no-op — not even a vector compare — for every
920
+ * viewer that never sets one.
921
+ */
922
+ _easeFocus() {
923
+ if (this._focusSettled) return;
924
+ const f = this._focus;
925
+ const t = this._targetFocus;
926
+ const dx = t.x - f.x;
927
+ const dy = t.y - f.y;
928
+ const dz = t.z - f.z;
929
+ if (dx * dx + dy * dy + dz * dz < 1e-10) {
930
+ f.x = t.x;
931
+ f.y = t.y;
932
+ f.z = t.z;
933
+ this._focusSettled = true;
934
+ } else {
935
+ f.x += dx * FOCUS_EASE;
936
+ f.y += dy * FOCUS_EASE;
937
+ f.z += dz * FOCUS_EASE;
938
+ }
939
+ this._applyFocus();
940
+ this.onFocusChange?.(f);
941
+ }
942
+
943
+ /** Write the current focus into the scene graph. */
944
+ _applyFocus() {
945
+ const f = this._focus;
946
+ this._centering.position.set(-f.x, -f.y, -f.z);
947
+ const c = this._orbitCentre;
948
+ c.x = this._focusRecentres ? 0 : f.x;
949
+ c.y = this._focusRecentres ? 0 : f.y;
950
+ c.z = this._focusRecentres ? 0 : f.z;
809
951
  }
810
952
 
811
953
  _bindOrbit() {
package/js/inline3d.js CHANGED
@@ -860,6 +860,12 @@ class Inline3D {
860
860
  * falls back to `virtualDisplayHeight` if one was given (that pair is the one reason
861
861
  * to pass both) — either way the window still weaves.
862
862
  * @param {Element} [opts.observe=canvas] element whose visibility gates lazy create/close.
863
+ * @param {() => void} [opts.onLayerLost] called once when this window's weave layer goes away
864
+ * for good — the session ended, or the layer could not be created. YOU own a scene
865
+ * canvas's pixels, so this is the SDK's only way to tell you that the side-by-side pair
866
+ * in it is no longer being woven and is now just squeezed 2D on the page; take the
867
+ * canvas flat here (`SceneViewer.startMono`, or your own mono path). NOT called when a
868
+ * lazy tile merely scrolls off screen — that layer is coming back. Errors are caught.
863
869
  * @returns {{remove():void}}
864
870
  */
865
871
  addScene(canvas, onFrame, opts = {}) {
@@ -1746,6 +1752,12 @@ class Inline3D {
1746
1752
  img: null,
1747
1753
  video: null,
1748
1754
  onFrame: null,
1755
+ // Scene windows only (addScene's `onLayerLost`): the layer went away for good. See
1756
+ // _notifyLayerLost — `layerLostSent` keeps it one-shot per loss. Read from the options
1757
+ // HERE rather than after `_register` returns, because a non-lazy window activates (and can
1758
+ // therefore already fail to build its layer) inside this call.
1759
+ onLayerLost: typeof opts.onLayerLost === 'function' ? opts.onLayerLost : null,
1760
+ layerLostSent: false,
1749
1761
  ready: null,
1750
1762
  ownsBuffer: kind !== 'scene',
1751
1763
  cornerRadius: opts.cornerRadius || 0,
@@ -1844,10 +1856,27 @@ class Inline3D {
1844
1856
  ? { virtualDisplayHeight: win.virtualDisplayHeight }
1845
1857
  : {};
1846
1858
  win.layer = new XRDisplayLayer(this.session, win.canvas, init);
1847
- } catch {
1859
+ } catch (err) {
1848
1860
  win.layer = null;
1861
+ // Say so, once per window, and take the canvas flat. Swallowed silently this was
1862
+ // undiagnosable in the field AND left a re-activated tile holding the SBS pair it wove
1863
+ // with last time — nothing repaints it, and the IntersectionObserver does not re-fire
1864
+ // while the tile stays intersecting. No retry: a constructor that refused this canvas will
1865
+ // refuse it again, and a retry loop would run per frame with nothing to report (web#28).
1866
+ if (!win.layerFailWarned) {
1867
+ win.layerFailWarned = true;
1868
+ console.warn(
1869
+ '[inline3d] new XRDisplayLayer() failed for this window — it will show FLAT 2D ' +
1870
+ 'instead of woven 3D, and the SDK will not retry. The canvas has been repainted ' +
1871
+ 'mono so it cannot be left holding a raw side-by-side pair.',
1872
+ err
1873
+ );
1874
+ }
1875
+ this._paintMono(win);
1876
+ this._notifyLayerLost(win);
1849
1877
  return;
1850
1878
  }
1879
+ win.layerLostSent = false; // a live layer again: a future loss is worth reporting again
1851
1880
  // Nothing about the hardware state is re-asserted here, and that is the point: the panel's
1852
1881
  // mode is the DISPLAY's, it survives a tile scrolling away, and this SDK never requests it
1853
1882
  // behind the page's back. The rig went into the init above already flattened if a 1-view
@@ -1898,9 +1927,47 @@ class Inline3D {
1898
1927
  win.layer = null;
1899
1928
  }
1900
1929
  // Leave a flat (left-eye-only) frame so an off-screen image/video still shows 2D.
1901
- if (win.ownsBuffer && win.kind !== 'scene') {
1902
- this._sizeBuffer(win, /*sbs*/ false);
1903
- this._paint(win, null);
1930
+ //
1931
+ // A SCENE is deliberately NOT notified here (see _notifyLayerLost): its layer is coming
1932
+ // back the moment the tile scrolls into view again, and `SceneViewer.onFrame` takes the
1933
+ // backing store back to SBS by itself — collapsing on every scroll would make the lazy
1934
+ // lifecycle visible as a mode change.
1935
+ this._paintMono(win);
1936
+ }
1937
+
1938
+ /**
1939
+ * Take a window whose layer is gone back to the ONE state a canvas nothing weaves may be left
1940
+ * in: a flat, left-eye-only frame in a 1:1 buffer (web#28).
1941
+ *
1942
+ * Shared by _deactivate, _teardown and the _activate failure path precisely so the three
1943
+ * cannot drift — _teardown used to skip it entirely, which left every image and video tile on
1944
+ * the page holding its last side-by-side frame, forever, the moment the session ended.
1945
+ * Scene canvases are the page's pixels and are handled by _notifyLayerLost instead.
1946
+ */
1947
+ _paintMono(win) {
1948
+ if (!win.ownsBuffer || win.kind === 'scene') return;
1949
+ this._sizeBuffer(win, /*sbs*/ false);
1950
+ this._paint(win, null);
1951
+ }
1952
+
1953
+ /**
1954
+ * The scene half of the same problem. The SDK does not own a scene canvas's backing store, so
1955
+ * the most it can do is SAY the layer went away and let the owner take itself flat —
1956
+ * `SceneViewer` wires its `startMono()` here (`addScene({ onLayerLost })`), and `./splat` and
1957
+ * `./model` do that for you. Without it a scene tile keeps its last woven side-by-side frame
1958
+ * on screen after the session ends, because every mono fallback in this SDK and its samples is
1959
+ * a one-shot `!supported` branch decided at boot.
1960
+ *
1961
+ * One-shot per loss and never allowed to throw: this runs inside teardown, where a page
1962
+ * callback that raises must not strand the windows behind it.
1963
+ */
1964
+ _notifyLayerLost(win) {
1965
+ if (win.kind !== 'scene' || typeof win.onLayerLost !== 'function' || win.layerLostSent) return;
1966
+ win.layerLostSent = true;
1967
+ try {
1968
+ win.onLayerLost();
1969
+ } catch (err) {
1970
+ console.warn('[inline3d] a scene window\'s onLayerLost callback threw', err);
1904
1971
  }
1905
1972
  }
1906
1973
 
@@ -2208,9 +2275,28 @@ class Inline3D {
2208
2275
 
2209
2276
  _paint(win, _views) {
2210
2277
  if (win.kind === 'scene' || !win.ctx) return;
2278
+ // NOTHING IS WEAVING THIS CANVAS (web#28, browser-pvt#99). A dead manager or a window with
2279
+ // no layer means the browser is not consuming this canvas as a stereo pair any more — so an
2280
+ // SBS paint here puts the raw squeezed left|right pair on screen as ordinary 2D page
2281
+ // content, permanently, because nothing ever repaints it. The path that makes this a FIELD
2282
+ // bug rather than a theoretical one is a slow download: `addImage`'s load resolves after the
2283
+ // session ended and calls `win.repaint()` straight into a canvas whose layer is gone.
2284
+ // Forced here rather than at each call site because the call sites are the async ones.
2285
+ // The buffer comes with it — the mono branch below stretches ONE eye across the whole
2286
+ // backing store, so leaving a 2:1 store would show a double-width half-image.
2287
+ const live = this._running && !!win.layer;
2288
+ if (!live && win.sbs) this._sizeBuffer(win, /*sbs*/ false);
2211
2289
  const src = win.kind === 'video' ? win.video : win.img;
2212
2290
  if (!src) return;
2213
- if (win.kind === 'video' && (src.readyState || 0) < 2) return; // no frame yet
2291
+ if (win.kind === 'video' && (src.readyState || 0) < 2) {
2292
+ // Buffering: no new frame to draw, and drawing an unready <video> is a no-op per spec (it
2293
+ // would leave the clearRect below as the only thing that happened, i.e. blank the tile).
2294
+ // Skipping the paint entirely is what the old code did, and that is its own bug — see
2295
+ // _frame: a canvas that is not redrawn can have its layer dropped from the aggregated
2296
+ // frame. So re-commit what the canvas already holds instead.
2297
+ if (live) this._recommitLastFrame(win);
2298
+ return;
2299
+ }
2214
2300
  const c = win.canvas;
2215
2301
  const ctx = win.ctx;
2216
2302
  const srcW = src.videoWidth || src.naturalWidth || src.width;
@@ -2234,6 +2320,30 @@ class Inline3D {
2234
2320
  }
2235
2321
  }
2236
2322
 
2323
+ /**
2324
+ * Re-commit the pixels the canvas already holds, unchanged — the cheapest "last decoded frame"
2325
+ * there is, because the last decoded frame is already in the backing store.
2326
+ *
2327
+ * Drawing the canvas onto itself is one same-size blit that dirties the canvas (which is the
2328
+ * whole point: see the every-frame-repaint note in _frame), and `globalCompositeOperation =
2329
+ * 'copy'` is what makes it a true identity — source-over would composite a feathered buffer's
2330
+ * transparent edges onto themselves and darken the ramp a little more every stalled frame.
2331
+ * Only ever reached while a source has nothing new, so a healthy video never pays for it.
2332
+ */
2333
+ _recommitLastFrame(win) {
2334
+ const c = win.canvas;
2335
+ if (!c.width || !c.height) return;
2336
+ const ctx = win.ctx;
2337
+ try {
2338
+ ctx.save();
2339
+ ctx.globalCompositeOperation = 'copy';
2340
+ ctx.drawImage(c, 0, 0);
2341
+ ctx.restore();
2342
+ } catch {
2343
+ /* a context that refuses a self-blit: leave the stale pixels rather than blank the tile */
2344
+ }
2345
+ }
2346
+
2237
2347
  /**
2238
2348
  * Arm the next session frame. `force` starts a NEW loop even though one is nominally
2239
2349
  * pending: each loop carries an id and only the current id re-arms, so a stalled
@@ -2293,7 +2403,22 @@ class Inline3D {
2293
2403
  );
2294
2404
  }
2295
2405
  }
2296
- win.onFrame(views, win.layer, f);
2406
+ // Contained, and warned about once. A scene that throws (a texture that 404s, a
2407
+ // decoder that gives up) used to abort this loop body for every window AFTER it in the
2408
+ // map — and an un-redrawn canvas can have its layer dropped from the aggregated frame
2409
+ // (see the note below), so one broken tile took its neighbours' weave with it (web#28).
2410
+ try {
2411
+ win.onFrame(views, win.layer, f);
2412
+ } catch (err) {
2413
+ if (!win.frameThrewWarned) {
2414
+ win.frameThrewWarned = true;
2415
+ console.warn(
2416
+ "[inline3d] a scene window's onFrame threw; this window will keep whatever it " +
2417
+ 'last drew, and the other windows carry on. Further throws from it are silent.',
2418
+ err
2419
+ );
2420
+ }
2421
+ }
2297
2422
  }
2298
2423
  } else {
2299
2424
  // Repaint image AND video every frame. The weave reads each window's
@@ -2430,6 +2555,13 @@ class Inline3D {
2430
2555
  }
2431
2556
  win.layer = null;
2432
2557
  }
2558
+ // The repaint _deactivate has always done, which this path used to skip (web#28). Closing
2559
+ // the layer also clears the browser's tracked rect, so from here nothing suppresses these
2560
+ // canvases and nothing will ever repaint them either — whatever is in the backing store
2561
+ // when the session ends is what the page shows from now on. A side-by-side pair is the one
2562
+ // thing that must not be. AFTER the close, so the flat frame is the last thing committed.
2563
+ this._paintMono(win);
2564
+ this._notifyLayerLost(win);
2433
2565
  }
2434
2566
  this._windows.clear();
2435
2567
  // Page listeners go with the session that fed them: a manager whose session has ended will
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -40,6 +40,9 @@
40
40
  "js/inline3d-three.js",
41
41
  "js/inline3d-viewer.js",
42
42
  "js/inline3d-splat.js",
43
+ "js/inline3d-sog.js",
44
+ "js/inline3d-splat-perf.js",
45
+ "js/inline3d-splat-rig.js",
43
46
  "js/inline3d-model.js",
44
47
  "index.d.ts",
45
48
  "three.d.ts",
package/splat.d.ts CHANGED
@@ -3,6 +3,114 @@
3
3
 
4
4
  import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
5
5
 
6
+ /**
7
+ * The knobs behind `SplatOptions.perf`. Every one is a Spark 2.1.0 setting except `alphaRadius`,
8
+ * which is a patch to Spark's own vertex shader (there is no option for it). Bit-exact vs lossy,
9
+ * and the defaults each one overrides, are tabled in `js/inline3d-splat-perf.js`.
10
+ */
11
+ export interface SplatPerfOptions {
12
+ /** Shrink each quad to the radius where its alpha reaches `alphaFloor`. Bit-exact by default. */
13
+ alphaRadius?: boolean;
14
+ /**
15
+ * The alpha each splat's tail may be cut at, PER SPLAT. Defaults to `minAlpha`, which is the
16
+ * bit-exact cut; above it this is a lossy crop that scales with each splat's own opacity.
17
+ */
18
+ alphaFloor?: number;
19
+ /** Drop splats and fragments under this alpha. Spark's default is `0.5/255`. */
20
+ minAlpha?: number;
21
+ /** Quad extent in σ, globally. Spark's default is `Math.sqrt(8)`. */
22
+ maxStdDev?: number;
23
+ /** Drop splats smaller than this, in pixels. Spark's default is 0. */
24
+ minPixelRadius?: number;
25
+ /** Clamp on quad size in pixels — note it SQUASHES rather than crops. Default 512. */
26
+ maxPixelRadius?: number;
27
+ /** 1 = Gaussian falloff, 0 = flat. Not a perf knob; 0 costs MORE. */
28
+ falloff?: number;
29
+ /** Build LOD data at load, so Spark can substitute merged splats against a budget. */
30
+ lod?: boolean | 'quality';
31
+ /** LOD budget multiplier (needs `lod`). */
32
+ lodSplatScale?: number;
33
+ /** Absolute LOD budget in splats (needs `lod`). */
34
+ lodSplatCount?: number;
35
+ /** Minimum on-screen splat size multiplier (needs `lod`); up to ~5 is often invisible. */
36
+ lodRenderScale?: number;
37
+ }
38
+
39
+ /** Camera intrinsics for ONE eye, in pixels, OpenCV convention. */
40
+ export interface SogIntrinsics {
41
+ fx: number;
42
+ fy: number;
43
+ cx: number;
44
+ cy: number;
45
+ width: number;
46
+ height: number;
47
+ }
48
+
49
+ /**
50
+ * The `camera` block of a `.sog`'s `meta.json` (v2), plus the fields this SDK derives from it.
51
+ * v2 is a superset of v1: everything but `convention` is optional, `intrinsics` included.
52
+ */
53
+ export interface SogCamera {
54
+ convention: 'opencv';
55
+ /** Which rig the asset asks for. Null when the block does not say. */
56
+ rig: 'camera' | 'display' | null;
57
+ rest: { position: number[]; rotation: number[] };
58
+ /** Null when the block carried none, or none that were usable — estimate one instead. */
59
+ intrinsics: SogIntrinsics | null;
60
+ stereo: { baseline_m: number } | null;
61
+ /**
62
+ * The point that is simultaneously the orbit centre, the pivot plane and the convergence
63
+ * distance. In the splat's own space. The three distances are advisory.
64
+ */
65
+ focus: {
66
+ point: number[];
67
+ subject_m: number | null;
68
+ near_m: number | null;
69
+ far_m: number | null;
70
+ source: string | null;
71
+ } | null;
72
+ /** The camera rig's ABSOLUTE scalars. Null when unstated. */
73
+ dxr: { ipdFactor: number | null; parallaxFactor: number | null };
74
+ /** Full vertical angle of the capture in RADIANS; null without intrinsics. */
75
+ verticalFov: number | null;
76
+ /** Principal point off the frame centre, fraction of the frame, y UP; null without intrinsics. */
77
+ principalOffset: { x: number; y: number } | null;
78
+ }
79
+
80
+ /**
81
+ * What the waterfall resolved, with the step that produced each value beside it — which is the
82
+ * point of it. `intrinsicsSource: 'fallback-28mm'` on an asset that looks zoomed out says more
83
+ * than any amount of staring at the picture.
84
+ */
85
+ export interface ResolvedRig {
86
+ type: 'camera' | 'display';
87
+ typeSource: 'caller' | 'block' | 'block-present' | 'default';
88
+ rest: { position: number[]; rotation: number[] };
89
+ intrinsics: SogIntrinsics;
90
+ intrinsicsSource: 'block' | 'caller' | 'estimated' | 'fallback-28mm';
91
+ /** 35 mm-equivalent focal of whatever lens was resolved. */
92
+ focalEqMm: number;
93
+ /** The live focus, in the splat's own space. */
94
+ focus: number[];
95
+ focusSource:
96
+ | 'caller'
97
+ | 'caller-convergence'
98
+ | 'block'
99
+ | 'median-disparity'
100
+ | 'default'
101
+ | 'picked'
102
+ | 'set';
103
+ /** What Space returns to. */
104
+ focusDefault: number[];
105
+ focusDefaultSource: string;
106
+ /** The block's advisory distances, when it carried any. */
107
+ focusDistances: { subject_m: number | null; near_m: number | null; far_m: number | null } | null;
108
+ /** Focus distance along the rest camera's view axis — the zero-disparity PLANE. */
109
+ convergence: number;
110
+ ipdFactor: number;
111
+ parallaxFactor: number;
112
+ }
113
+
6
114
  export interface SplatOptions {
7
115
  /** Metres of world the tile's height spans (default 0.24). */
8
116
  virtualDisplayHeight?: number;
@@ -32,6 +140,41 @@ export interface SplatOptions {
32
140
  feather?: number;
33
141
  /** Minimum ms between splat sorts. Defaults to 16 so both eyes share one sort per frame. */
34
142
  sortIntervalMs?: number;
143
+ /**
144
+ * Cut overdraw. UNSET changes nothing — every Spark default stays where Spark put it, so an
145
+ * existing page's pixels do not move.
146
+ *
147
+ * `'exact'` is the bit-exact pair — each quad shrunk to where its own alpha reaches 1/255
148
+ * (those fragments were already being discarded), plus the 1/255 peak-opacity cull. It buys
149
+ * little on a mostly-opaque capture, which is what a lifted photograph is. `'balanced'` (or
150
+ * `true`, −5…−20 % measured) and `'aggressive'` (−22 %) tighten the quad extent instead, which
151
+ * is the axis that actually pays on the web; both move pixels.
152
+ */
153
+ perf?: true | 'exact' | 'balanced' | 'aggressive' | SplatPerfOptions;
154
+ /**
155
+ * Which view rig. `'auto'` (the default) reads it off the ASSET — a `.sog` carrying a `camera`
156
+ * block was lifted from a photograph and gets a camera rig that conserves the recording
157
+ * camera; anything else is an object and gets the display rig with the auto-frame. Only
158
+ * detectable when `src` is BYTES.
159
+ */
160
+ rig?: 'auto' | 'display' | 'camera';
161
+ /** Camera rig only: the distance in world metres that sits ON the glass. */
162
+ convergence?: number;
163
+ /**
164
+ * The point to converge on and orbit about, in the splat's own space — the highest step of the
165
+ * focus waterfall. Wins over `convergence`, which is the straight-ahead shorthand for it.
166
+ */
167
+ focus?: number[];
168
+ /** Override the lens, when the asset carries none and the estimate is wrong. */
169
+ intrinsics?: SogIntrinsics;
170
+ /** Camera rig scalars. ABSOLUTE, never normalised against the convergence. */
171
+ ipdFactor?: number;
172
+ parallaxFactor?: number;
173
+ /**
174
+ * Bind double-click (focus what was clicked) and Space (back to the resolved focus). Default
175
+ * true; pass false when the page owns those gestures itself.
176
+ */
177
+ focusInput?: boolean;
35
178
  /**
36
179
  * Disambiguates .splat from .ksplat when passing BYTES — content-sniffing cannot separate
37
180
  * those two. Unnecessary for .sog/.ply/.spz, which are identifiable by magic number.
@@ -55,11 +198,32 @@ export interface SplatHandle {
55
198
  readonly spark: object;
56
199
  /** Bounds actually used for framing; null until `ready` resolves. */
57
200
  frame: SubjectBounds | null;
201
+ /**
202
+ * The `.sog`'s `camera` block — the recording camera, when the asset carries one. Null for a
203
+ * URL source, a non-`.sog`, or an object splat (which is most of them).
204
+ */
205
+ camera: SogCamera | null;
206
+ /** What the waterfall resolved, sources included. Null until `ready` resolves. */
207
+ rig: ResolvedRig | null;
208
+ /** The view-rig descriptor sent to the runtime, on the camera path. */
209
+ viewRig?: object;
210
+ /** What `perf` actually applied, or null. */
211
+ perf: object | null;
58
212
  /** Resolves once the asset has loaded and been framed; rejects if the load failed. */
59
213
  readonly ready: Promise<SplatHandle>;
60
214
 
61
215
  setPose(pose?: OrbitPose): void;
62
216
  resetPose(): void;
217
+ /**
218
+ * Point the window at something, in the SPLAT's own space (the space the `camera` block's
219
+ * `focus.point` is in). Null returns to whatever the waterfall resolved. Eased unless `snap`.
220
+ */
221
+ setFocus(
222
+ point: number[] | { x: number; y: number; z: number } | null,
223
+ opts?: { snap?: boolean },
224
+ ): SplatHandle;
225
+ /** What is under a point on the canvas, in the splat's own space — the double-click's raycast. */
226
+ pick(clientX: number, clientY: number): number[] | null;
63
227
 
64
228
  /** Close this window and release its GPU resources. */
65
229
  remove(): void;
package/viewer.d.ts CHANGED
@@ -105,6 +105,22 @@ export declare class SceneViewer {
105
105
 
106
106
  /** Snap the pose. Writes the eased value and its target together. */
107
107
  setPose(pose?: OrbitPose): void;
108
+ /**
109
+ * Point the viewer at something: the orbit centre, the pivot plane and (on a camera rig) the
110
+ * convergence distance are ONE point, and this is it. `recentre: true` (a display rig) brings
111
+ * that point to the middle of the tile; `recentre: false` (a camera rig) leaves the capture
112
+ * where it was placed and moves only the rotation centre. Eased 0.18/frame unless `snap`.
113
+ */
114
+ setFocus(
115
+ point: { x: number; y: number; z: number } | number[] | null,
116
+ opts?: { snap?: boolean; recentre?: boolean },
117
+ ): SceneViewer;
118
+ /** Where the viewer is pointed, in content space. */
119
+ getFocus(opts?: { target?: boolean }): { x: number; y: number; z: number };
120
+ /** Called after every focus ease step, with the live focus. */
121
+ onFocusChange: ((focus: { x: number; y: number; z: number }) => void) | null;
122
+ /** Called at the end of every tick, after the transform is applied. */
123
+ onTick: (() => void) | null;
108
124
 
109
125
  /**
110
126
  * The pose as it is right now. `target: true` reports what it is easing TOWARD, which differs
@@ -139,6 +155,12 @@ export declare class SceneViewer {
139
155
  * frame either way, so the tile never goes dark and never smears (web#12).
140
156
  */
141
157
  onFrame(views: readonly XRView[], layer: object): void;
158
+ /**
159
+ * The weave layer went away for good — pass to `addScene(canvas, viewer.onFrame,
160
+ * { onLayerLost: viewer.onLayerLost })` so the tile goes flat instead of showing its last
161
+ * side-by-side frame as squeezed 2D. Pre-bound; `./splat` and `./model` wire it for you.
162
+ */
163
+ onLayerLost(): void;
142
164
 
143
165
  /** Supply the ./three glue so the 3D path can build its eye camera. Returns `this`. */
144
166
  useEyeCamera(EyeCameraClass: unknown, EdgeFeatherClass?: unknown): this;