@displayxr/inline3d 1.10.1 → 1.11.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.11.0 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), additively. Nothing changes
11
+ for a page that does not pass `controls: 'page'`.
12
+
13
+ ### Added
14
+
15
+ - **`controls: 'page'`: the page owns the camera.** This is for a game, or any page with its own
16
+ camera (F1000's phase 1). With `addSplat(…, { engine: 'playcanvas', controls: 'page' })` the
17
+ adapter runs no orbit, idle spin, auto-fit or focus gestures. It keeps the eye math: the
18
+ `RenderView` list, the footprint fix, near/far and the mono fallback.
19
+ - **`handle.setCameraPose(matrixWorld, { verticalFovDeg, near?, far?, convergence? })`**, called
20
+ every frame. `matrixWorld` is the page camera in the splat's own space (three.js convention).
21
+ A uniform scale is allowed: F1000 passes `inv(splatWorld) · camera.matrixWorld`, which carries
22
+ 0.4. The adapter applies the OpenCV → engine flip itself. The last call wins, and a page that
23
+ stops calling keeps its last pose. **`handle.getCameraPose()`** returns a copy of it.
24
+ - **`onBeforeFrame(frame)`** (`{ time, views, dt }`) runs once per adapter frame, before
25
+ anything renders. A pose set inside it is what that frame draws, with zero lag. A throw is
26
+ warned once and frames keep rendering.
27
+ - **Mono** renders exactly that camera: fov × canvas aspect, principal point centred.
28
+ - **3D** is the attach pattern. Each eye = `matrixWorld × view.transform`, with the runtime's
29
+ projection untouched: the page's near/far rewrite only the depth mapping. The rig is declared
30
+ every frame and is the auto-3D shim's, field for field: `type: 'camera'`, identity pose,
31
+ `verticalFov`, `convergenceDiopters = 1/d`, `metersToVirtual = comfortDepth · d / 0.5`.
32
+ **`comfortDepth`** is a new option, default 0.3, in (0, 1].
33
+ - `d` is the page's `convergence`, else the focus waterfall's. It stays fixed while the camera
34
+ moves and is re-estimated only on `setSource`. `setFocus(point)` sets `d` to the point's
35
+ distance along the view axis. `getFocus` / `onFocusChange` report the convergence point.
36
+ - `setPose` / `resetPose` throw, and `rig: 'display'` throws. `fit`, `virtualDisplayHeight`,
37
+ `orbit`, `idleSpin`, `focusInput` and the other framing/orbit knobs are ignored, named once in
38
+ a `console.info`.
39
+ - **Spark:** `controls: 'page'` throws, naming `engine: 'playcanvas'`. Spark would need
40
+ SceneViewer (shared with `./viewer` and `./model`) to take an external camera on both its
41
+ paths.
42
+
8
43
  ## 1.10.1 — 2026-09-23
9
44
 
10
45
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), and `boundsFromPositions` in
package/README.md CHANGED
@@ -60,6 +60,15 @@ one dense sheet that is fully on screen, so it gains nothing from streaming. How
60
60
  and budget one, with measurements:
61
61
  [`docs/playcanvas-adapter.md` §Streamed SOG](docs/playcanvas-adapter.md#streamed-sog-p2).
62
62
 
63
+ **A game owns its camera (preview).** `addSplat(wall, canvas, src, { engine: 'playcanvas',
64
+ controls: 'page', onBeforeFrame })` hands the camera to the page. Call
65
+ `handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far, convergence? })` inside
66
+ `onBeforeFrame` and that frame renders it. The SDK runs no orbit, idle spin, fit or focus
67
+ gestures. It keeps the eye math: the attach-pattern camera rig (the auto-3D shim's, with
68
+ `comfortDepth` 0.3 by default), the runtime's projections, and the mono fallback. Spark refuses
69
+ this mode. Contract and numbers:
70
+ [`docs/playcanvas-adapter.md` §controls:'page'](docs/playcanvas-adapter.md#controlspage--the-page-owns-the-camera).
71
+
63
72
  Stability & what's covered by semver (and the deferred N-view / web-components / CSS-native roadmap
64
73
  that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stability.md).
65
74
 
@@ -82,6 +82,8 @@ import {
82
82
  engineFormatFor,
83
83
  pathOf,
84
84
  streamedBytesError,
85
+ resolveControls,
86
+ normalizeCameraPose,
85
87
  } from './inline3d-splat-shared.js';
86
88
 
87
89
  /**
@@ -137,6 +139,10 @@ export const PLAYCANVAS_TESTED = '2.22.3';
137
139
 
138
140
  const PICK_CONE_RAD = 0.02;
139
141
 
142
+ const PAGE_POSE_ERROR =
143
+ "@displayxr/inline3d/splat: setPose()/resetPose() are not available with controls:'page' — the " +
144
+ 'page owns the camera. Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
145
+
140
146
  const DEG = Math.PI / 180;
141
147
 
142
148
  // ── pure matrix arithmetic (column-major, the layout XRView, three and the engine all use) ──
@@ -362,6 +368,69 @@ export function pivotInverseTRS({ yaw = 0, pitch = 0, scale = 1, focus = [0, 0,
362
368
  };
363
369
  }
364
370
 
371
+ /**
372
+ * controls:'page' — the rig node's TRS from the page's camera matrix.
373
+ *
374
+ * `matrixWorld` is the page camera's world matrix in the SPLAT's model space (the space of the
375
+ * `.sog` camera block's `rest`), a three.js-convention camera (looks down −Z, +Y up). The splat
376
+ * entity carries the OpenCV → GL half-turn about X under `flipY`, so the camera rides the same
377
+ * turn: rig = F · M, F = Rx(180°) (or the identity without the flip). A uniform scale in M is
378
+ * kept — it is how a page whose world scales the splat (F1000: S(2.5)) expresses "page units",
379
+ * and it puts the runtime's eye offsets (metres) and the page's near/far into page units for free.
380
+ *
381
+ * @returns {{position:number[], rotation:number[], scale:number, uniform:boolean}}
382
+ */
383
+ export function pageRigTRS(M, flipY = true) {
384
+ const m = Float64Array.from(M);
385
+ if (flipY) {
386
+ for (const c of [0, 4, 8, 12]) {
387
+ m[c + 1] = -m[c + 1];
388
+ m[c + 2] = -m[c + 2];
389
+ }
390
+ }
391
+ const len = (c) => Math.hypot(m[c], m[c + 1], m[c + 2]);
392
+ const lx = len(0), ly = len(4), lz = len(8);
393
+ const s = Math.cbrt(lx * ly * lz);
394
+ const uniform = Math.abs(lx - s) < 1e-4 * s && Math.abs(ly - s) < 1e-4 * s && Math.abs(lz - s) < 1e-4 * s;
395
+ const R = new Float64Array(16);
396
+ R[0] = m[0] / lx; R[1] = m[1] / lx; R[2] = m[2] / lx;
397
+ R[4] = m[4] / ly; R[5] = m[5] / ly; R[6] = m[6] / ly;
398
+ R[8] = m[8] / lz; R[9] = m[9] / lz; R[10] = m[10] / lz;
399
+ R[15] = 1;
400
+ return { position: [m[12], m[13], m[14]], rotation: quatFromMatrix(R), scale: s, uniform };
401
+ }
402
+
403
+ /** pageRigTRS as a column-major matrix (the pick ray and the tests read it this way). */
404
+ export function pageRigMatrix(M, flipY = true) {
405
+ const t = pageRigTRS(M, flipY);
406
+ const out = poseMatrix(t.position, t.rotation);
407
+ for (let i = 0; i < 12; i++) if (i % 4 !== 3) out[i] *= t.scale;
408
+ return out;
409
+ }
410
+
411
+ /**
412
+ * The page camera's view axis in MODEL space: its position and unit forward (−Z of the matrix),
413
+ * plus the matrix's uniform scale (model units per page unit).
414
+ */
415
+ export function pageViewAxis(M) {
416
+ const s = Math.cbrt(Math.hypot(M[0], M[1], M[2]) * Math.hypot(M[4], M[5], M[6]) * Math.hypot(M[8], M[9], M[10]));
417
+ const fl = Math.hypot(M[8], M[9], M[10]) || 1;
418
+ return { origin: [M[12], M[13], M[14]], forward: [-M[8] / fl, -M[9] / fl, -M[10] / fl], scale: s };
419
+ }
420
+
421
+ /**
422
+ * The attach-pattern camera rig for controls:'page' — the auto-3D shim's buildRig, field for
423
+ * field: identity pose, the page's vertical FOV, convergence d, metersToVirtual = depth · d / 0.5
424
+ * (so comfort = ipd × m2v × (1/d) × 0.5 = depth by construction).
425
+ */
426
+ export function pageViewRig({ verticalFovDeg, convergence, comfortDepth, ipdFactor = 1, parallaxFactor = 1 }, out = {}) {
427
+ const d = Math.max(1e-6, convergence);
428
+ return cameraRigFromPose(
429
+ { fov: verticalFovDeg },
430
+ { attach: true, convergence: d, ipdFactor, parallaxFactor, metersToVirtual: (comfortDepth * d) / 0.5, out },
431
+ );
432
+ }
433
+
365
434
  /**
366
435
  * SceneViewer.fitTo's scale, as a function. Same arithmetic, same order, same clamps — see the
367
436
  * reasoning there (swept width, 'height' guard, depth backstop).
@@ -536,8 +605,15 @@ export class PlayCanvasSplatViewer {
536
605
  nearClip,
537
606
  farClip,
538
607
  sky = false,
608
+ pageCamera = false,
539
609
  } = opts;
540
610
  this.canvas = canvas;
611
+ // controls:'page': the PAGE owns the camera (setPageCamera). No orbit, no idle, no fit; the
612
+ // rig node carries the page's matrix instead of the inverse pivot.
613
+ this.pageCamera = pageCamera === true;
614
+ this.page = this.pageCamera
615
+ ? { matrix: poseMatrix([0, 0, 0], [0, 0, 0, 1]), fov: MONO_FOV, near: MONO_NEAR, far: CAPTURE_FAR, set: false }
616
+ : null;
541
617
  // The engine draws a sky box whenever the scene has something to draw it from — and
542
618
  // `scene.envAtlas` counts: a page that sets one to light its own meshes under
543
619
  // handle.engine.root got a grey gradient box behind the splat the instant the splat was
@@ -568,7 +644,7 @@ export class PlayCanvasSplatViewer {
568
644
  this.fitSweep = fitSweep;
569
645
  this.renderScale = renderScale;
570
646
  this.pitchLimit = pitchLimit;
571
- this.idleSpin = idleSpin;
647
+ this.idleSpin = this.pageCamera ? 0 : idleSpin;
572
648
  this.flipY = opts.flipY !== false;
573
649
 
574
650
  this._fitScale = 1;
@@ -587,6 +663,8 @@ export class PlayCanvasSplatViewer {
587
663
  this._focusSettled = true;
588
664
  this.onFocusChange = null;
589
665
  this.onTick = null;
666
+ /** controls:'page' — `(views|null) => void`, run once per frame before anything else. */
667
+ this._beforeFrame = null;
590
668
  this._lastInput = now();
591
669
  this._lastTick = 0;
592
670
  this._monoRaf = 0;
@@ -608,6 +686,13 @@ export class PlayCanvasSplatViewer {
608
686
  proj: new Float64Array(16),
609
687
  capture: null, // intrinsics when on a camera rig
610
688
  };
689
+ if (this.pageCamera) {
690
+ // The mono camera IS the page camera: identity under the rig node, the page's lens.
691
+ this.mono.fov = this.page.fov;
692
+ this.mono.near = this.page.near;
693
+ this.mono.far = this.page.far;
694
+ this.mono.pose = poseMatrix([0, 0, 0], [0, 0, 0, 1]);
695
+ }
611
696
  this._placeMonoForFit();
612
697
 
613
698
  // Engine objects — null until attachEngine().
@@ -635,7 +720,7 @@ export class PlayCanvasSplatViewer {
635
720
  if (this._ro) this._ro.observe(canvas);
636
721
  else if (typeof addEventListener === 'function') addEventListener('resize', this._onResize);
637
722
 
638
- if (orbit) this._bindOrbit();
723
+ if (orbit && !this.pageCamera) this._bindOrbit();
639
724
  this._resize();
640
725
 
641
726
  this.onFrame = this.onFrame.bind(this);
@@ -658,6 +743,7 @@ export class PlayCanvasSplatViewer {
658
743
  }
659
744
 
660
745
  fitTo(center, extent) {
746
+ if (this.pageCamera) return; // the page frames its own camera
661
747
  const c = Array.isArray(center) ? center : [center.x, center.y, center.z];
662
748
  const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
663
749
  this.setFocus(c, { snap: true });
@@ -677,6 +763,7 @@ export class PlayCanvasSplatViewer {
677
763
  }
678
764
 
679
765
  setPose({ yaw, pitch, zoom, depthOffset } = {}) {
766
+ if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
680
767
  // A snap, as on SceneViewer; it also ends an orbit relax (the page is driving now).
681
768
  if (yaw !== undefined || pitch !== undefined) this._orbitMode = null;
682
769
  if (yaw !== undefined) this._targetYaw = this._yaw = yaw;
@@ -698,6 +785,7 @@ export class PlayCanvasSplatViewer {
698
785
  }
699
786
 
700
787
  resetPose() {
788
+ if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
701
789
  this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
702
790
  this._lastInput = now();
703
791
  }
@@ -744,8 +832,40 @@ export class PlayCanvasSplatViewer {
744
832
  return { x: v.x, y: v.y, z: v.z };
745
833
  }
746
834
 
835
+ /**
836
+ * controls:'page' — take the page's camera for the next frame drawn (last call wins; no call
837
+ * keeps the last pose). `pose` is normalizeCameraPose()'s output. The rig node is written here
838
+ * and by the tick, through _applyTransform (still its only writer).
839
+ */
840
+ setPageCamera(pose) {
841
+ if (!this.pageCamera) throw new Error("setPageCamera needs controls:'page'");
842
+ const p = this.page;
843
+ p.matrix.set(pose.matrixWorld);
844
+ p.set = true;
845
+ if (pose.verticalFovDeg !== p.fov || pose.near !== p.near || pose.far !== p.far) {
846
+ p.fov = pose.verticalFovDeg;
847
+ p.near = pose.near;
848
+ p.far = pose.far;
849
+ this.mono.fov = p.fov;
850
+ this.mono.near = p.near;
851
+ this.mono.far = p.far;
852
+ this._updateMonoProjection();
853
+ }
854
+ this._applyTransform();
855
+ }
856
+
857
+ /**
858
+ * The rig node's matrix (eye space → content space): the inverse pivot on the viewer's own
859
+ * controls, F · matrixWorld on controls:'page'.
860
+ */
861
+ rigMatrix() {
862
+ if (this.pageCamera) return pageRigMatrix(this.page.matrix, this.flipY);
863
+ return mat4Invert(pivotMatrix(this.pivotState()));
864
+ }
865
+
747
866
  /** Pose + lens the mono camera as the recording camera (applyCaptureCamera's counterpart). */
748
867
  useCaptureCamera(rig) {
868
+ if (this.pageCamera) return; // the page's lens, not the recording's
749
869
  const pose = capturePose(rig.rest, this.flipY);
750
870
  this.mono.pose = pose.matrix;
751
871
  this.mono.capture = rig.intrinsics;
@@ -763,6 +883,7 @@ export class PlayCanvasSplatViewer {
763
883
 
764
884
  /** Back to the display rig's mono camera (setSource from a photo lift to an object). */
765
885
  useDisplayCamera() {
886
+ if (this.pageCamera) return;
766
887
  this.mono.capture = null;
767
888
  this.mono.fov = MONO_FOV;
768
889
  this.mono.far = MONO_FAR;
@@ -779,6 +900,8 @@ export class PlayCanvasSplatViewer {
779
900
  onFrame(views, layer) {
780
901
  if (this._disposed) return;
781
902
  if (this._mode !== '3d') this.stopMono();
903
+ // BEFORE the tick: a pose the page sets in here is the one this very frame renders.
904
+ this._beforeFrame?.(views || null);
782
905
  this._tick();
783
906
  if (!views || views.length < 2) {
784
907
  this._replayLastGood();
@@ -805,6 +928,8 @@ export class PlayCanvasSplatViewer {
805
928
  const loop = () => {
806
929
  if (this._disposed) return;
807
930
  this._monoRaf = requestAnimationFrame(loop);
931
+ this._beforeFrame?.(null);
932
+ if (this._disposed || this._mode !== 'mono') return; // the callback removed or re-wove us
808
933
  this._tick();
809
934
  this._drawMono();
810
935
  };
@@ -1092,7 +1217,7 @@ export class PlayCanvasSplatViewer {
1092
1217
 
1093
1218
  _placeMonoForFit() {
1094
1219
  // SceneViewer.fitTo: distance to make the frustum exactly vH tall at z = 0, looking down −z.
1095
- if (this.mono.capture) return;
1220
+ if (this.mono.capture || this.pageCamera) return;
1096
1221
  const d = (0.5 * this.vH) / Math.tan((this.mono.fov * DEG) / 2);
1097
1222
  this.mono.pose = poseMatrix([0, 0, d], [0, 0, 0, 1]);
1098
1223
  }
@@ -1134,7 +1259,14 @@ export class PlayCanvasSplatViewer {
1134
1259
  const el = this.canvas;
1135
1260
  const sx = cache && cache.bufW > 0 && el.width ? el.width / cache.bufW : 1;
1136
1261
  const sy = cache && cache.bufH > 0 && el.height ? el.height / cache.bufH : 1;
1137
- if (this.nearClip !== null || this.farClip !== null) {
1262
+ if (this.pageCamera) {
1263
+ // The page's near/far join the caller's nearClip/farClip as a floor and a cap: only the
1264
+ // depth mapping moves, the runtime's frustum (fov, skew, principal point) stays untouched.
1265
+ const p = this.page;
1266
+ const nf = this.nearClip !== null ? Math.max(this.nearClip, p.near) : p.near;
1267
+ const fc = this.farClip !== null ? Math.min(this.farClip, p.far) : p.far;
1268
+ for (const e of entries) clampProjectionDepth(e.proj, nf, fc);
1269
+ } else if (this.nearClip !== null || this.farClip !== null) {
1138
1270
  for (const e of entries) clampProjectionDepth(e.proj, this.nearClip, this.farClip);
1139
1271
  }
1140
1272
  const rect = (e) =>
@@ -1344,7 +1476,7 @@ export class PlayCanvasSplatViewer {
1344
1476
  */
1345
1477
  _applyTransform() {
1346
1478
  if (!this.rigNode) return;
1347
- const trs = pivotInverseTRS(this.pivotState());
1479
+ const trs = this.pageCamera ? pageRigTRS(this.page.matrix, this.flipY) : pivotInverseTRS(this.pivotState());
1348
1480
  this.rigNode.setLocalPosition(trs.position[0], trs.position[1], trs.position[2]);
1349
1481
  this.rigNode.setLocalRotation(trs.rotation[0], trs.rotation[1], trs.rotation[2], trs.rotation[3]);
1350
1482
  this.rigNode.setLocalScale(trs.scale, trs.scale, trs.scale);
@@ -1736,8 +1868,22 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1736
1868
  // `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
1737
1869
  // ROTATES (one directional sort serves every view), not on a timer — docs/playcanvas-adapter.md.
1738
1870
 
1871
+ // controls:'page' — validated again here (./splat already did, at call time) because a test or
1872
+ // an advanced caller may reach this function directly.
1873
+ const ctl = resolveControls(opts);
1874
+ const pageMode = ctl.page;
1875
+ if (pageMode && ctl.ignored.length) {
1876
+ console.info(
1877
+ `[inline3d/splat] controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ` +
1878
+ `${ctl.ignored.length === 1 ? 'is' : 'are'} ignored.`,
1879
+ );
1880
+ }
1881
+ // The rig question is answered by the page: its camera IS the rig.
1882
+ const rigOpts = pageMode ? { ...opts, rig: 'camera' } : opts;
1883
+
1739
1884
  const perfResolved = playcanvasPerfSettings(perf);
1740
1885
  const viewer = new PlayCanvasSplatViewer(canvas, {
1886
+ pageCamera: pageMode,
1741
1887
  virtualDisplayHeight,
1742
1888
  fit,
1743
1889
  margin,
@@ -1773,13 +1919,49 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1773
1919
  perf: perfResolved.applied,
1774
1920
  setPose: (p) => viewer.setPose(p),
1775
1921
  resetPose: () => viewer.resetPose(),
1922
+ /**
1923
+ * controls:'page' — the page's camera for the next frame drawn. See normalizeCameraPose for
1924
+ * the arguments; last call wins; a page that stops calling keeps its last pose.
1925
+ */
1926
+ setCameraPose(matrixWorld, o) {
1927
+ if (!pageMode) {
1928
+ throw new Error(
1929
+ "@displayxr/inline3d/splat: setCameraPose() needs addSplat(…, { controls:'page' }) — " +
1930
+ 'with the default controls the SDK owns the camera (use setPose).',
1931
+ );
1932
+ }
1933
+ const pose = normalizeCameraPose(matrixWorld, o);
1934
+ if (!warnedNonUniform && !pageRigTRS(pose.matrixWorld, flipY).uniform) {
1935
+ warnedNonUniform = true;
1936
+ console.warn(
1937
+ "[inline3d/splat] controls:'page' — matrixWorld has a NON-uniform scale; the adapter uses " +
1938
+ 'its geometric mean. A camera pose is rotation + translation + at most a uniform scale.',
1939
+ );
1940
+ }
1941
+ lastPagePose = pose;
1942
+ viewer.setPageCamera(pose);
1943
+ return out;
1944
+ },
1945
+ /** The last pose the page set (a copy), or null before the first setCameraPose. */
1946
+ getCameraPose() {
1947
+ if (!lastPagePose) return null;
1948
+ return {
1949
+ matrixWorld: Float32Array.from(lastPagePose.matrixWorld),
1950
+ verticalFovDeg: lastPagePose.verticalFovDeg,
1951
+ near: lastPagePose.near,
1952
+ far: lastPagePose.far,
1953
+ convergence: lastPagePose.convergence,
1954
+ };
1955
+ },
1776
1956
  getFocus: (o) => {
1957
+ if (pageMode) return pageFocusPoint();
1777
1958
  // In MODEL space, like setFocus takes it.
1778
1959
  const f = viewer.getFocus(o);
1779
1960
  return contentToModel([f.x, f.y, f.z]);
1780
1961
  },
1781
1962
  setFocus(point, o = {}) {
1782
1963
  if (!out.mesh || !out.rig) return out;
1964
+ if (pageMode) return pageSetFocus(point, o);
1783
1965
  const model = point == null ? out.rig.focusDefault : toArray3(point);
1784
1966
  out.rig.focus = model;
1785
1967
  out.rig.focusSource = point == null ? out.rig.focusDefaultSource : 'set';
@@ -1815,9 +1997,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1815
1997
  return flipY ? [c[0], -c[1], -c[2]] : [c[0], c[1], c[2]];
1816
1998
  }
1817
1999
 
2000
+ // ── controls:'page' state (null/unused on the viewer's own controls) ──
2001
+ let lastPagePose = null; // what the page last handed setCameraPose, validated
2002
+ let warnedNonUniform = false;
2003
+ // The convergence the adapter owns when the page gives none, in MODEL units (so it is stable
2004
+ // while the camera moves): the waterfall's on load / setSource, a setFocus after that. Eased
2005
+ // toward `target` at the viewer's FOCUS_EASE unless snapped.
2006
+ const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
2007
+
1818
2008
  if (wall && wall.supported) {
1819
2009
  handle = wall.addScene(canvas, viewer.onFrame, {
1820
- virtualDisplayHeight,
2010
+ // controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
2011
+ // and the waterfall say otherwise); the display rig's height means nothing there.
2012
+ ...(pageMode
2013
+ ? { viewRig: pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth }) }
2014
+ : { virtualDisplayHeight }),
1821
2015
  onLayerLost: viewer.onLayerLost,
1822
2016
  ...(observe ? { observe } : {}),
1823
2017
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
@@ -1841,9 +2035,100 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1841
2035
  out[name]?.(...args);
1842
2036
  }
1843
2037
 
2038
+ // ── controls:'page': focus = the convergence point on the page camera's view axis ──
2039
+ /** The convergence distance for THIS frame, in page units, and where it came from. */
2040
+ function pageConvergence() {
2041
+ const ax = pageViewAxis(viewer.page.matrix);
2042
+ if (lastPagePose && lastPagePose.convergence !== null) {
2043
+ return { d: lastPagePose.convergence, ax, source: 'page' };
2044
+ }
2045
+ const dm = Number.isFinite(pageFocus.d) ? pageFocus.d : 2;
2046
+ return { d: dm / (ax.scale || 1), ax, source: out.rig?.focusSource ?? 'default' };
2047
+ }
2048
+ function pageFocusPoint() {
2049
+ if (!out.rig) return null;
2050
+ const { d, ax } = pageConvergence();
2051
+ const dm = d * ax.scale;
2052
+ return [ax.origin[0] + ax.forward[0] * dm, ax.origin[1] + ax.forward[1] * dm, ax.origin[2] + ax.forward[2] * dm];
2053
+ }
2054
+ function pageSetFocus(point, { snap = false } = {}) {
2055
+ let dm;
2056
+ if (point == null) {
2057
+ dm = out.rig.convergenceDefault;
2058
+ out.rig.focus = out.rig.focusDefault;
2059
+ out.rig.focusSource = out.rig.focusDefaultSource;
2060
+ } else {
2061
+ const m = toArray3(point);
2062
+ const ax = pageViewAxis(viewer.page.matrix);
2063
+ dm = (m[0] - ax.origin[0]) * ax.forward[0] + (m[1] - ax.origin[1]) * ax.forward[1] + (m[2] - ax.origin[2]) * ax.forward[2];
2064
+ if (!(dm > 0)) {
2065
+ console.warn('[inline3d/splat] setFocus: that point is behind the page camera — focus unchanged');
2066
+ return out;
2067
+ }
2068
+ out.rig.focus = m;
2069
+ out.rig.focusSource = 'set';
2070
+ }
2071
+ pageFocus.target = dm;
2072
+ if (snap || !Number.isFinite(pageFocus.d)) pageFocus.d = dm;
2073
+ return out;
2074
+ }
2075
+ /** Per tick (before the draw, so the rig drives the next locate): ease, declare, notify. */
2076
+ function pageTick() {
2077
+ if (Number.isFinite(pageFocus.target) && pageFocus.d !== pageFocus.target) {
2078
+ const dd = pageFocus.target - pageFocus.d;
2079
+ pageFocus.d = Math.abs(dd) < 1e-6 ? pageFocus.target : pageFocus.d + dd * FOCUS_EASE;
2080
+ }
2081
+ const { d, source } = pageConvergence();
2082
+ out.viewRig = pageViewRig(
2083
+ {
2084
+ verticalFovDeg: viewer.page.fov,
2085
+ convergence: d,
2086
+ comfortDepth: ctl.comfortDepth,
2087
+ ipdFactor: out.rig ? out.rig.ipdFactor : Number.isFinite(opts.ipdFactor) ? opts.ipdFactor : 1,
2088
+ parallaxFactor: out.rig ? out.rig.parallaxFactor : Number.isFinite(opts.parallaxFactor) ? opts.parallaxFactor : 1,
2089
+ },
2090
+ out.viewRig || {},
2091
+ );
2092
+ handle?.setViewRig?.(out.viewRig);
2093
+ if (!out.rig) return;
2094
+ out.rig.convergence = d;
2095
+ if (Math.abs(d - pageFocus.fired) > 1e-3 || source !== pageFocus.source) {
2096
+ pageFocus.fired = d;
2097
+ pageFocus.source = source;
2098
+ const cb = out.onFocusChange;
2099
+ if (typeof cb === 'function') cb(pageFocusPoint(), { focusSource: source });
2100
+ }
2101
+ }
2102
+ if (pageMode) viewer.onTick = pageTick;
2103
+
2104
+ // onBeforeFrame: the page's game step INSIDE this adapter's frame, before the tick and the
2105
+ // draw — so a setCameraPose made in it is what this frame renders (zero lag, the attach
2106
+ // pattern). The session rAF (3D) and the window rAF have no guaranteed order, so a pose set
2107
+ // from the page's own rAF may be one frame late. A throw is the page's bug: warned once, and
2108
+ // the frame still renders (with whatever pose it had).
2109
+ if (pageMode && typeof opts.onBeforeFrame === 'function') {
2110
+ const cb = opts.onBeforeFrame;
2111
+ let lastT = 0;
2112
+ let warned = false;
2113
+ viewer._beforeFrame = (views) => {
2114
+ const t = now();
2115
+ const dt = lastT ? Math.min((t - lastT) / 1000, MAX_DT_S) : 0;
2116
+ lastT = t;
2117
+ try {
2118
+ cb({ time: t, views, dt });
2119
+ } catch (err) {
2120
+ if (!warned) {
2121
+ warned = true;
2122
+ console.warn('[inline3d/splat] onBeforeFrame threw (warned once; frames keep rendering)', err);
2123
+ }
2124
+ }
2125
+ };
2126
+ }
2127
+
1844
2128
  // ── focus → view rig ──
1845
2129
  let lastConvergence = Number.NaN;
1846
2130
  function pushViewRig(force) {
2131
+ if (pageMode) return; // pageTick declares the rig every frame
1847
2132
  if (!out.rig || out.rig.type !== 'camera') return;
1848
2133
  const pose = viewer.mono.pose;
1849
2134
  const f = viewer.getFocus();
@@ -1905,8 +2190,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1905
2190
  const v = viewer.currentView();
1906
2191
  const invP = mat4Invert(v.proj);
1907
2192
  if (!invP) return null;
1908
- const Minv = mat4Invert(pivotMatrix(viewer.pivotState()));
1909
- const toContent = mat4Mul(Minv, v.pose);
2193
+ const toContent = mat4Mul(viewer.rigMatrix(), v.pose);
1910
2194
  const a = transformPoint(invP, ndc.x, ndc.y, -1);
1911
2195
  const b = transformPoint(invP, ndc.x, ndc.y, 1);
1912
2196
  const o = contentToModel(transformPoint(toContent, a[0], a[1], a[2]));
@@ -1953,7 +2237,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1953
2237
  }
1954
2238
 
1955
2239
  function bindFocusInput() {
1956
- if (focusInput === false || unbindFocusInput) return;
2240
+ // controls:'page' binds no input at all: gestures are the page's (pick + setFocus remain).
2241
+ if (pageMode || focusInput === false || unbindFocusInput) return;
1957
2242
  unbindFocusInput = bindFocusGestures(canvas, {
1958
2243
  onDoubleClick: (e) => {
1959
2244
  const m = pickModel(e.clientX, e.clientY);
@@ -1993,7 +2278,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1993
2278
  // The camera block off the SAME bytes, before the engine takes them (as the Spark path).
1994
2279
  let camera = null;
1995
2280
  let t0 = performance.now();
1996
- if (bytes && rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
2281
+ if (bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
1997
2282
  perfSpan('readSogMeta(async)', t0);
1998
2283
 
1999
2284
  const url = bytes
@@ -2017,7 +2302,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2017
2302
  const desc = describeResource(res);
2018
2303
  // A URL `.sog` carries its meta in the resource (the engine keeps unknown keys); a Streamed
2019
2304
  // SOG carries it at the top level of lod-meta.json. Both validated by the same reader.
2020
- if (!bytes && rig !== 'display') camera = sogCameraFromMeta(desc.meta);
2305
+ if (!bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(desc.meta);
2021
2306
  const cloud = desc.kind === 'flat' ? await readCloud(res) : null;
2022
2307
  // The cloud passes, each in its OWN task: framing, the rest-space sample and the pick set
2023
2308
  // were one ~60 ms main-thread block on a 1.18M-gaussian swap — pointer input waited on it.
@@ -2071,7 +2356,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2071
2356
  const box = canvas.getBoundingClientRect();
2072
2357
  const resolved = resolveRig({
2073
2358
  camera: loaded.camera,
2074
- opts,
2359
+ opts: rigOpts,
2075
2360
  cloud: sample,
2076
2361
  canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
2077
2362
  });
@@ -2092,7 +2377,23 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2092
2377
  // The strided pick fallback (used only if the engine releases its full centre set).
2093
2378
  const pickCentres = loaded.pre?.pickCentres || null;
2094
2379
 
2095
- if (resolved.type === 'camera') {
2380
+ if (pageMode) {
2381
+ // The page owns the pose; the waterfall only supplies the convergence (re-estimated here,
2382
+ // on load and on setSource, never per frame). Until the page's first setCameraPose the
2383
+ // camera sits at the asset's rest pose (the block's, else the origin looking down +z) with
2384
+ // the resolved lens, so the first frame is not an empty tile.
2385
+ resolved.convergenceDefault = resolved.convergence;
2386
+ pageFocus.target = pageFocus.d = resolved.convergence;
2387
+ pageFocus.fired = Number.NaN;
2388
+ if (!lastPagePose) {
2389
+ const r = resolved.rest;
2390
+ viewer.setPageCamera(
2391
+ normalizeCameraPose(poseMatrix(r.position, qmul(r.rotation, FLIP_Q)), {
2392
+ verticalFovDeg: captureVerticalFovDeg(resolved.intrinsics, NaN, MONO_NEAR, 'height'),
2393
+ }),
2394
+ );
2395
+ }
2396
+ } else if (resolved.type === 'camera') {
2096
2397
  if (!('idleSpin' in opts)) viewer.idleSpin = 0;
2097
2398
  // The camera rig never auto-fits (the capture IS the framing): a scale left by a previous
2098
2399
  // display-rig asset (setSource) must not shrink this one.
@@ -2184,7 +2485,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2184
2485
  };
2185
2486
  const kept = applyLoaded(loaded);
2186
2487
  current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
2187
- if (resetPose) viewer.resetPose();
2488
+ if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
2188
2489
 
2189
2490
  const release = () => {
2190
2491
  if (!prev) return;
@@ -316,3 +316,124 @@ export function playcanvasCannotRead(src, { fileType, fileName } = {}) {
316
316
  `${what}. Pass engine:'spark' for .spz / .splat / .ksplat / .rad.`
317
317
  );
318
318
  }
319
+
320
+ // ── controls:'page' — the page drives the camera (docs/playcanvas-adapter.md §controls:'page') ──
321
+
322
+ /** Who owns the camera: the SDK's viewer (orbit, idle, fit, focus gestures) or the page. */
323
+ export const SPLAT_CONTROLS = Object.freeze(['viewer', 'page']);
324
+
325
+ /**
326
+ * `comfortDepth` default — the auto-3D shim's `depth`, so a game on `controls:'page'` gets the
327
+ * same stereo as the same game under the shim. It is the runtime's comfort number
328
+ * `ipd × metersToVirtual × convergenceDiopters × 0.5`, which the rig below makes equal to it by
329
+ * construction (`metersToVirtual = comfortDepth · d / 0.5`, `convergenceDiopters = 1 / d`).
330
+ */
331
+ export const PAGE_COMFORT_DEPTH = 0.3;
332
+
333
+ /**
334
+ * Options that mean nothing when the page owns the camera. Passing one is not an error (a page
335
+ * switching an existing call site over should not have to prune it first); it is named ONCE in a
336
+ * console.info line and ignored.
337
+ */
338
+ export const PAGE_IGNORED_OPTIONS = Object.freeze([
339
+ 'fit',
340
+ 'virtualDisplayHeight',
341
+ 'orbit',
342
+ 'idleSpin',
343
+ 'focusInput',
344
+ 'margin',
345
+ 'fitSweep',
346
+ 'depthLimit',
347
+ 'orbitMaxDeg',
348
+ 'orbitEase',
349
+ 'captureFit',
350
+ ]);
351
+
352
+ /**
353
+ * Validate the `controls` family at CALL time (both engines call this before anything loads).
354
+ *
355
+ * @returns {{page:boolean, comfortDepth:number, ignored:string[]}}
356
+ * @throws on an unknown `controls`, a `comfortDepth` outside (0, 1], or `rig:'display'` with
357
+ * `controls:'page'` (the page's camera IS the rig; a display rig has no camera).
358
+ */
359
+ export function resolveControls(opts = {}) {
360
+ const controls = opts.controls ?? 'viewer';
361
+ if (!SPLAT_CONTROLS.includes(controls)) {
362
+ throw new Error(
363
+ `@displayxr/inline3d/splat: controls "${controls}" — expected ` +
364
+ `${SPLAT_CONTROLS.map((c) => `'${c}'`).join(' or ')} (default 'viewer').`,
365
+ );
366
+ }
367
+ const page = controls === 'page';
368
+ let comfortDepth = PAGE_COMFORT_DEPTH;
369
+ if (opts.comfortDepth !== undefined) {
370
+ const c = opts.comfortDepth;
371
+ if (typeof c !== 'number' || !Number.isFinite(c) || c <= 0 || c > 1) {
372
+ throw new Error(
373
+ `@displayxr/inline3d/splat: comfortDepth ${c} — expected a number in (0, 1] ` +
374
+ `(the runtime's comfort rule caps it at 1; the default is ${PAGE_COMFORT_DEPTH}).`,
375
+ );
376
+ }
377
+ comfortDepth = c;
378
+ }
379
+ if (page && opts.rig === 'display') {
380
+ throw new Error(
381
+ "@displayxr/inline3d/splat: controls:'page' with rig:'display' — the page's camera IS the " +
382
+ "rig, so it is always a camera rig. Drop `rig`, or use the default controls for a display rig.",
383
+ );
384
+ }
385
+ if (opts.onBeforeFrame !== undefined) {
386
+ if (typeof opts.onBeforeFrame !== 'function') {
387
+ throw new Error('@displayxr/inline3d/splat: onBeforeFrame must be a function (frame) => void.');
388
+ }
389
+ if (!page) {
390
+ throw new Error(
391
+ "@displayxr/inline3d/splat: onBeforeFrame needs controls:'page' — with the default controls " +
392
+ 'the SDK owns the camera and there is no page pose to set before the draw.',
393
+ );
394
+ }
395
+ }
396
+ const ignored = page ? PAGE_IGNORED_OPTIONS.filter((k) => opts[k] !== undefined) : [];
397
+ return { page, comfortDepth, ignored };
398
+ }
399
+
400
+ /**
401
+ * Validate one `setCameraPose(matrixWorld, o)` call and return a private copy (the page may
402
+ * reuse its arrays). Throws synchronously on a malformed call: a NaN in a camera matrix is a page
403
+ * bug that would otherwise render nothing, silently.
404
+ *
405
+ * @returns {{matrixWorld:Float64Array, verticalFovDeg:number, near:number, far:number,
406
+ * convergence:number|null}}
407
+ */
408
+ export function normalizeCameraPose(matrixWorld, o = {}) {
409
+ const bad = (why) => new Error(`@displayxr/inline3d/splat: setCameraPose — ${why}`);
410
+ if (!matrixWorld || typeof matrixWorld.length !== 'number' || matrixWorld.length !== 16) {
411
+ throw bad('matrixWorld must be 16 numbers, column-major (a Float32Array(16) or an array).');
412
+ }
413
+ const m = new Float64Array(16);
414
+ for (let i = 0; i < 16; i++) {
415
+ const v = matrixWorld[i];
416
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw bad(`matrixWorld[${i}] is ${v}, not a finite number.`);
417
+ m[i] = v;
418
+ }
419
+ const det =
420
+ m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2]) + m[8] * (m[1] * m[6] - m[5] * m[2]);
421
+ if (!(det > 1e-12)) {
422
+ throw bad(`matrixWorld is singular or mirrored (det ${det.toPrecision(3)}); a camera pose is a rotation, a translation and at most a uniform scale.`);
423
+ }
424
+ const fov = o.verticalFovDeg;
425
+ if (typeof fov !== 'number' || !(fov > 0 && fov < 180)) {
426
+ throw bad(`verticalFovDeg ${fov} — the FULL vertical angle in degrees, in (0, 180).`);
427
+ }
428
+ const near = o.near === undefined ? MONO_NEAR : o.near;
429
+ const far = o.far === undefined ? CAPTURE_FAR : o.far;
430
+ if (typeof near !== 'number' || !(near > 0) || !Number.isFinite(near)) throw bad(`near ${near} — expected a finite number > 0.`);
431
+ if (typeof far !== 'number' || !(far > near) || !Number.isFinite(far)) throw bad(`far ${far} — expected a finite number > near.`);
432
+ let convergence = null;
433
+ if (o.convergence !== undefined && o.convergence !== null) {
434
+ const c = o.convergence;
435
+ if (typeof c !== 'number' || !(c > 0) || !Number.isFinite(c)) throw bad(`convergence ${c} — metres along the view axis, > 0 (omit it for the adapter's own).`);
436
+ convergence = c;
437
+ }
438
+ return { matrixWorld: m, verticalFovDeg: fov, near, far, convergence };
439
+ }
@@ -34,6 +34,8 @@ import {
34
34
  playcanvasCannotRead,
35
35
  isStreamedUrl,
36
36
  STREAMED_NEEDS_PLAYCANVAS,
37
+ resolveControls,
38
+ normalizeCameraPose,
37
39
  } from './inline3d-splat-shared.js';
38
40
  import {
39
41
  resolveRig,
@@ -163,6 +165,9 @@ export function addSplat(wall, canvas, src, opts = {}) {
163
165
  `${CAPTURE_FITS.map((f) => `'${f}'`).join(' or ')}.`,
164
166
  );
165
167
  }
168
+ // WHO OWNS THE CAMERA. Validated here, synchronously, for both engines (an unknown `controls`
169
+ // or a comfortDepth out of range is a page bug true of every call).
170
+ const { page: pageControls } = resolveControls(opts);
166
171
  // WHICH ENGINE. Unset (or 'spark') is everything below, untouched. 'playcanvas' goes to
167
172
  // ./inline3d-splat-playcanvas.js, imported DYNAMICALLY so a page that never asks for it never
168
173
  // resolves `playcanvas` — see addSplatDeferred.
@@ -174,6 +179,16 @@ export function addSplat(wall, canvas, src, opts = {}) {
174
179
  return addSplatDeferred(wall, canvas, src, opts);
175
180
  }
176
181
 
182
+ // controls:'page' is a PlayCanvas-backend feature (docs/playcanvas-adapter.md §controls:'page'):
183
+ // on Spark it would need SceneViewer — which ./viewer and ./model share — to take an external
184
+ // camera in both its mono and eye paths. Refused by name rather than half-supported.
185
+ if (pageControls) {
186
+ throw new Error(
187
+ "@displayxr/inline3d/splat: controls:'page' is not supported on Spark (the default engine) — " +
188
+ "pass engine:'playcanvas'. Spark's viewer owns its camera; see docs/playcanvas-adapter.md.",
189
+ );
190
+ }
191
+
177
192
  // A Streamed SOG on Spark is a page bug (Spark has no lod-meta.json reader): say so now, by
178
193
  // name, rather than let Spark fail on an "unknown file type" that reads like a corrupt asset.
179
194
  if (isStreamedUrl(src)) throw new Error(`@displayxr/inline3d/splat: ${STREAMED_NEEDS_PLAYCANVAS}`);
@@ -336,6 +351,13 @@ export function addSplat(wall, canvas, src, opts = {}) {
336
351
  },
337
352
  /** Called with the live focus, in the splat's own space, whenever it moves. */
338
353
  onFocusChange: null,
354
+ /** Not on this backend: controls:'page' is a PlayCanvas-backend feature. */
355
+ setCameraPose() {
356
+ throw new Error(
357
+ "@displayxr/inline3d/splat: setCameraPose() needs addSplat(…, { engine:'playcanvas', controls:'page' }).",
358
+ );
359
+ },
360
+ getCameraPose: () => null,
339
361
  /** Not on this backend: the crossfading asset swap is a PlayCanvas-backend feature. */
340
362
  setSource() {
341
363
  throw new Error(
@@ -622,6 +644,16 @@ function addSplatDeferred(wall, canvas, src, opts) {
622
644
  pending.push([name, args]);
623
645
  return name === 'setFocus' ? out : undefined;
624
646
  };
647
+ const page = opts.controls === 'page';
648
+ const pageOnly = (name) => () => {
649
+ throw new Error(
650
+ `@displayxr/inline3d/splat: ${name}() is not available with controls:'page' — the page owns the ` +
651
+ 'camera. Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).',
652
+ );
653
+ };
654
+ // Before the adapter lands, a page calling setCameraPose every frame keeps ONE pending pose
655
+ // (validated now, so a bad call throws at its own line): last call wins, as it will after.
656
+ let pendingPose = null;
625
657
  const out = {
626
658
  backend: 'playcanvas',
627
659
  engine: null,
@@ -631,8 +663,30 @@ function addSplatDeferred(wall, canvas, src, opts) {
631
663
  camera: null,
632
664
  rig: null,
633
665
  perf: null,
634
- setPose: queue('setPose'),
635
- resetPose: queue('resetPose'),
666
+ setPose: page ? pageOnly('setPose') : queue('setPose'),
667
+ resetPose: page ? pageOnly('resetPose') : queue('resetPose'),
668
+ setCameraPose(matrixWorld, o) {
669
+ if (!page) {
670
+ throw new Error(
671
+ "@displayxr/inline3d/splat: setCameraPose() needs addSplat(…, { controls:'page' }) — " +
672
+ 'with the default controls the SDK owns the camera (use setPose).',
673
+ );
674
+ }
675
+ const pose = normalizeCameraPose(matrixWorld, o);
676
+ if (pendingPose) pendingPose[1] = [pose.matrixWorld, pose];
677
+ else pending.push((pendingPose = ['setCameraPose', [pose.matrixWorld, pose]]));
678
+ return out;
679
+ },
680
+ getCameraPose: () =>
681
+ pendingPose
682
+ ? {
683
+ matrixWorld: Float32Array.from(pendingPose[1][1].matrixWorld),
684
+ verticalFovDeg: pendingPose[1][1].verticalFovDeg,
685
+ near: pendingPose[1][1].near,
686
+ far: pendingPose[1][1].far,
687
+ convergence: pendingPose[1][1].convergence,
688
+ }
689
+ : null,
636
690
  setFocus: queue('setFocus'),
637
691
  // A swap requested before the first asset has landed runs once it has (the adapter's own
638
692
  // setSource replaces this stub on the same object by then).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.10.1",
3
+ "version": "1.11.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",
package/splat.d.ts CHANGED
@@ -304,6 +304,61 @@ export interface SplatOptions {
304
304
  observe?: Element;
305
305
  /** Forwarded to the core window: see `TileOptions.firstWovenHoldMs`. */
306
306
  firstWovenHoldMs?: number;
307
+ /**
308
+ * Who owns the camera. `'viewer'` (the default): the SDK's orbit, idle spin, auto-fit and focus
309
+ * gestures. `'page'` (`engine: 'playcanvas'` only; throws on Spark): the page drives the camera
310
+ * every frame through {@link SplatHandle.setCameraPose} and the adapter keeps only the eye math —
311
+ * the attach-pattern camera rig, the runtime's projections, the mono fallback. `fit`,
312
+ * `virtualDisplayHeight`, `orbit`, `idleSpin`, `focusInput` (and the other framing/orbit knobs)
313
+ * are ignored, named once in a `console.info`; `rig: 'display'` throws.
314
+ */
315
+ controls?: 'viewer' | 'page';
316
+ /**
317
+ * `controls: 'page'`: the comfort number `ipd × metersToVirtual × convergenceDiopters × 0.5` the
318
+ * rig is built to, in (0, 1]. Default 0.3, the auto-3D shim's. `metersToVirtual = comfortDepth ·
319
+ * d / 0.5`, so the depth budget is the same for a 10 cm subject and a 150 m castle.
320
+ */
321
+ comfortDepth?: number;
322
+ /**
323
+ * `controls: 'page'` only: called once per adapter frame — the wall's session frame in 3D, the
324
+ * mono rAF in 2D — BEFORE anything renders. Call `handle.setCameraPose(…)` synchronously in
325
+ * here and THIS frame renders that pose (zero lag, the attach pattern); a pose set from the
326
+ * page's own rAF may be one frame late, since the two rAFs have no guaranteed order. A throw is
327
+ * caught and warned once; frames keep rendering.
328
+ */
329
+ onBeforeFrame?: (frame: SplatFrameInfo) => void;
330
+ }
331
+
332
+ /** What `onBeforeFrame` receives. */
333
+ export interface SplatFrameInfo {
334
+ /** `performance.now()` at the call. */
335
+ time: number;
336
+ /** The runtime's view list in 3D (valid only inside the call — copy what you keep); null in mono. */
337
+ views: readonly XRView[] | null;
338
+ /** Seconds since the previous call (0 on the first; capped at 0.1). */
339
+ dt: number;
340
+ }
341
+
342
+ /** A page camera, as `setCameraPose` takes it and `getCameraPose` returns it. */
343
+ export interface SplatCameraPose {
344
+ /**
345
+ * Column-major world matrix of the page camera in the SPLAT's own space (the space of the camera
346
+ * block's `rest`), three.js convention: looks down −Z, +Y up. Rotation + translation + at most a
347
+ * UNIFORM scale (a page whose world scales the splat passes `inv(splatWorld) · camera.matrixWorld`;
348
+ * the scale is how page units reach the adapter). The adapter applies its own OpenCV → engine flip.
349
+ */
350
+ matrixWorld: Float32Array;
351
+ /** Full vertical field of view, degrees. */
352
+ verticalFovDeg: number;
353
+ /** Page units. Default 0.001. A floor on every projection's near (depth mapping only). */
354
+ near: number;
355
+ /** Page units. Default 5000. A cap on every projection's far. */
356
+ far: number;
357
+ /**
358
+ * Zero-disparity distance along the view axis, page units; null = the adapter's own (the focus
359
+ * waterfall's, fixed until `setFocus` / `setSource`).
360
+ */
361
+ convergence: number | null;
307
362
  }
308
363
 
309
364
  /** `handle.stats()` on `engine: 'playcanvas'`. */
@@ -384,8 +439,21 @@ export interface SplatHandle {
384
439
  /** Resolves once the asset has loaded and been framed; rejects if the load failed. */
385
440
  readonly ready: Promise<SplatHandle>;
386
441
 
442
+ /** Throws on `controls: 'page'` (the page owns the camera). */
387
443
  setPose(pose?: OrbitPose): void;
444
+ /** Throws on `controls: 'page'`. */
388
445
  resetPose(): void;
446
+ /**
447
+ * `controls: 'page'` only (throws otherwise): the camera for the next frame drawn — call it every
448
+ * frame you render, ideally inside `onBeforeFrame`. Last call wins; a page that stops calling
449
+ * keeps its last pose. Throws synchronously on a malformed matrix or lens.
450
+ */
451
+ setCameraPose(
452
+ matrixWorld: ArrayLike<number>,
453
+ opts: { verticalFovDeg: number; near?: number; far?: number; convergence?: number },
454
+ ): SplatHandle;
455
+ /** The last pose the page set (a copy), or null before the first `setCameraPose`. */
456
+ getCameraPose(): SplatCameraPose | null;
389
457
  /**
390
458
  * Point the window at something, in the SPLAT's own space (the space the `camera` block's
391
459
  * `focus.point` is in). Null returns to whatever the waterfall resolved. Eased unless `snap`.