@displayxr/inline3d 1.10.1 → 1.12.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.
@@ -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,8 +139,23 @@ 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
 
148
+ /** Tone-mapping names the viewer takes → the engine's constant names. */
149
+ export const TONE_MAPPINGS = Object.freeze({
150
+ none: 'TONEMAP_NONE',
151
+ linear: 'TONEMAP_LINEAR',
152
+ neutral: 'TONEMAP_NEUTRAL',
153
+ aces: 'TONEMAP_ACES',
154
+ aces2: 'TONEMAP_ACES2',
155
+ filmic: 'TONEMAP_FILMIC',
156
+ hejl: 'TONEMAP_HEJL',
157
+ });
158
+
142
159
  // ── pure matrix arithmetic (column-major, the layout XRView, three and the engine all use) ──
143
160
 
144
161
  /** three's `Matrix4.makePerspective(left, right, top, bottom, near, far)`, element for element. */
@@ -362,6 +379,69 @@ export function pivotInverseTRS({ yaw = 0, pitch = 0, scale = 1, focus = [0, 0,
362
379
  };
363
380
  }
364
381
 
382
+ /**
383
+ * controls:'page' — the rig node's TRS from the page's camera matrix.
384
+ *
385
+ * `matrixWorld` is the page camera's world matrix in the SPLAT's model space (the space of the
386
+ * `.sog` camera block's `rest`), a three.js-convention camera (looks down −Z, +Y up). The splat
387
+ * entity carries the OpenCV → GL half-turn about X under `flipY`, so the camera rides the same
388
+ * turn: rig = F · M, F = Rx(180°) (or the identity without the flip). A uniform scale in M is
389
+ * kept — it is how a page whose world scales the splat (F1000: S(2.5)) expresses "page units",
390
+ * and it puts the runtime's eye offsets (metres) and the page's near/far into page units for free.
391
+ *
392
+ * @returns {{position:number[], rotation:number[], scale:number, uniform:boolean}}
393
+ */
394
+ export function pageRigTRS(M, flipY = true) {
395
+ const m = Float64Array.from(M);
396
+ if (flipY) {
397
+ for (const c of [0, 4, 8, 12]) {
398
+ m[c + 1] = -m[c + 1];
399
+ m[c + 2] = -m[c + 2];
400
+ }
401
+ }
402
+ const len = (c) => Math.hypot(m[c], m[c + 1], m[c + 2]);
403
+ const lx = len(0), ly = len(4), lz = len(8);
404
+ const s = Math.cbrt(lx * ly * lz);
405
+ const uniform = Math.abs(lx - s) < 1e-4 * s && Math.abs(ly - s) < 1e-4 * s && Math.abs(lz - s) < 1e-4 * s;
406
+ const R = new Float64Array(16);
407
+ R[0] = m[0] / lx; R[1] = m[1] / lx; R[2] = m[2] / lx;
408
+ R[4] = m[4] / ly; R[5] = m[5] / ly; R[6] = m[6] / ly;
409
+ R[8] = m[8] / lz; R[9] = m[9] / lz; R[10] = m[10] / lz;
410
+ R[15] = 1;
411
+ return { position: [m[12], m[13], m[14]], rotation: quatFromMatrix(R), scale: s, uniform };
412
+ }
413
+
414
+ /** pageRigTRS as a column-major matrix (the pick ray and the tests read it this way). */
415
+ export function pageRigMatrix(M, flipY = true) {
416
+ const t = pageRigTRS(M, flipY);
417
+ const out = poseMatrix(t.position, t.rotation);
418
+ for (let i = 0; i < 12; i++) if (i % 4 !== 3) out[i] *= t.scale;
419
+ return out;
420
+ }
421
+
422
+ /**
423
+ * The page camera's view axis in MODEL space: its position and unit forward (−Z of the matrix),
424
+ * plus the matrix's uniform scale (model units per page unit).
425
+ */
426
+ export function pageViewAxis(M) {
427
+ 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]));
428
+ const fl = Math.hypot(M[8], M[9], M[10]) || 1;
429
+ return { origin: [M[12], M[13], M[14]], forward: [-M[8] / fl, -M[9] / fl, -M[10] / fl], scale: s };
430
+ }
431
+
432
+ /**
433
+ * The attach-pattern camera rig for controls:'page' — the auto-3D shim's buildRig, field for
434
+ * field: identity pose, the page's vertical FOV, convergence d, metersToVirtual = depth · d / 0.5
435
+ * (so comfort = ipd × m2v × (1/d) × 0.5 = depth by construction).
436
+ */
437
+ export function pageViewRig({ verticalFovDeg, convergence, comfortDepth, ipdFactor = 1, parallaxFactor = 1 }, out = {}) {
438
+ const d = Math.max(1e-6, convergence);
439
+ return cameraRigFromPose(
440
+ { fov: verticalFovDeg },
441
+ { attach: true, convergence: d, ipdFactor, parallaxFactor, metersToVirtual: (comfortDepth * d) / 0.5, out },
442
+ );
443
+ }
444
+
365
445
  /**
366
446
  * SceneViewer.fitTo's scale, as a function. Same arithmetic, same order, same clamps — see the
367
447
  * reasoning there (swept width, 'height' guard, depth backstop).
@@ -536,14 +616,26 @@ export class PlayCanvasSplatViewer {
536
616
  nearClip,
537
617
  farClip,
538
618
  sky = false,
619
+ pageCamera = false,
620
+ toneMapping = 'none',
539
621
  } = opts;
540
622
  this.canvas = canvas;
623
+ // controls:'page': the PAGE owns the camera (setPageCamera). No orbit, no idle, no fit; the
624
+ // rig node carries the page's matrix instead of the inverse pivot.
625
+ this.pageCamera = pageCamera === true;
626
+ this.page = this.pageCamera
627
+ ? { matrix: poseMatrix([0, 0, 0], [0, 0, 0, 1]), fov: MONO_FOV, near: MONO_NEAR, far: CAPTURE_FAR, set: false }
628
+ : null;
541
629
  // The engine draws a sky box whenever the scene has something to draw it from — and
542
630
  // `scene.envAtlas` counts: a page that sets one to light its own meshes under
543
631
  // handle.engine.root got a grey gradient box behind the splat the instant the splat was
544
632
  // hidden (mid-setSource, or a page showing only its meshes). The SDK's contract is a
545
633
  // transparent canvas the page shows through, so the sky layer is off unless asked for.
546
634
  this.sky = sky === true;
635
+ // The eye camera's tone mapping, by name (the engine constant is looked up once the engine has
636
+ // loaded). 'none' for splats — their colours are already display-referred; ./model's mesh
637
+ // tiles pass 'neutral' (Khronos PBR Neutral), the glTF Sample Viewer's default.
638
+ this.toneMapping = toneMapping;
547
639
  // Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
548
640
  // other; splats only test against them). The projections' own near/far stay the adapter's —
549
641
  // these only raise the near (floor) and lower the far (cap). Unset: untouched.
@@ -568,7 +660,7 @@ export class PlayCanvasSplatViewer {
568
660
  this.fitSweep = fitSweep;
569
661
  this.renderScale = renderScale;
570
662
  this.pitchLimit = pitchLimit;
571
- this.idleSpin = idleSpin;
663
+ this.idleSpin = this.pageCamera ? 0 : idleSpin;
572
664
  this.flipY = opts.flipY !== false;
573
665
 
574
666
  this._fitScale = 1;
@@ -587,6 +679,8 @@ export class PlayCanvasSplatViewer {
587
679
  this._focusSettled = true;
588
680
  this.onFocusChange = null;
589
681
  this.onTick = null;
682
+ /** controls:'page' — `(views|null) => void`, run once per frame before anything else. */
683
+ this._beforeFrame = null;
590
684
  this._lastInput = now();
591
685
  this._lastTick = 0;
592
686
  this._monoRaf = 0;
@@ -608,6 +702,13 @@ export class PlayCanvasSplatViewer {
608
702
  proj: new Float64Array(16),
609
703
  capture: null, // intrinsics when on a camera rig
610
704
  };
705
+ if (this.pageCamera) {
706
+ // The mono camera IS the page camera: identity under the rig node, the page's lens.
707
+ this.mono.fov = this.page.fov;
708
+ this.mono.near = this.page.near;
709
+ this.mono.far = this.page.far;
710
+ this.mono.pose = poseMatrix([0, 0, 0], [0, 0, 0, 1]);
711
+ }
611
712
  this._placeMonoForFit();
612
713
 
613
714
  // Engine objects — null until attachEngine().
@@ -635,7 +736,7 @@ export class PlayCanvasSplatViewer {
635
736
  if (this._ro) this._ro.observe(canvas);
636
737
  else if (typeof addEventListener === 'function') addEventListener('resize', this._onResize);
637
738
 
638
- if (orbit) this._bindOrbit();
739
+ if (orbit && !this.pageCamera) this._bindOrbit();
639
740
  this._resize();
640
741
 
641
742
  this.onFrame = this.onFrame.bind(this);
@@ -658,6 +759,7 @@ export class PlayCanvasSplatViewer {
658
759
  }
659
760
 
660
761
  fitTo(center, extent) {
762
+ if (this.pageCamera) return; // the page frames its own camera
661
763
  const c = Array.isArray(center) ? center : [center.x, center.y, center.z];
662
764
  const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
663
765
  this.setFocus(c, { snap: true });
@@ -677,6 +779,7 @@ export class PlayCanvasSplatViewer {
677
779
  }
678
780
 
679
781
  setPose({ yaw, pitch, zoom, depthOffset } = {}) {
782
+ if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
680
783
  // A snap, as on SceneViewer; it also ends an orbit relax (the page is driving now).
681
784
  if (yaw !== undefined || pitch !== undefined) this._orbitMode = null;
682
785
  if (yaw !== undefined) this._targetYaw = this._yaw = yaw;
@@ -698,6 +801,7 @@ export class PlayCanvasSplatViewer {
698
801
  }
699
802
 
700
803
  resetPose() {
804
+ if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
701
805
  this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
702
806
  this._lastInput = now();
703
807
  }
@@ -744,8 +848,40 @@ export class PlayCanvasSplatViewer {
744
848
  return { x: v.x, y: v.y, z: v.z };
745
849
  }
746
850
 
851
+ /**
852
+ * controls:'page' — take the page's camera for the next frame drawn (last call wins; no call
853
+ * keeps the last pose). `pose` is normalizeCameraPose()'s output. The rig node is written here
854
+ * and by the tick, through _applyTransform (still its only writer).
855
+ */
856
+ setPageCamera(pose) {
857
+ if (!this.pageCamera) throw new Error("setPageCamera needs controls:'page'");
858
+ const p = this.page;
859
+ p.matrix.set(pose.matrixWorld);
860
+ p.set = true;
861
+ if (pose.verticalFovDeg !== p.fov || pose.near !== p.near || pose.far !== p.far) {
862
+ p.fov = pose.verticalFovDeg;
863
+ p.near = pose.near;
864
+ p.far = pose.far;
865
+ this.mono.fov = p.fov;
866
+ this.mono.near = p.near;
867
+ this.mono.far = p.far;
868
+ this._updateMonoProjection();
869
+ }
870
+ this._applyTransform();
871
+ }
872
+
873
+ /**
874
+ * The rig node's matrix (eye space → content space): the inverse pivot on the viewer's own
875
+ * controls, F · matrixWorld on controls:'page'.
876
+ */
877
+ rigMatrix() {
878
+ if (this.pageCamera) return pageRigMatrix(this.page.matrix, this.flipY);
879
+ return mat4Invert(pivotMatrix(this.pivotState()));
880
+ }
881
+
747
882
  /** Pose + lens the mono camera as the recording camera (applyCaptureCamera's counterpart). */
748
883
  useCaptureCamera(rig) {
884
+ if (this.pageCamera) return; // the page's lens, not the recording's
749
885
  const pose = capturePose(rig.rest, this.flipY);
750
886
  this.mono.pose = pose.matrix;
751
887
  this.mono.capture = rig.intrinsics;
@@ -763,6 +899,7 @@ export class PlayCanvasSplatViewer {
763
899
 
764
900
  /** Back to the display rig's mono camera (setSource from a photo lift to an object). */
765
901
  useDisplayCamera() {
902
+ if (this.pageCamera) return;
766
903
  this.mono.capture = null;
767
904
  this.mono.fov = MONO_FOV;
768
905
  this.mono.far = MONO_FAR;
@@ -779,6 +916,8 @@ export class PlayCanvasSplatViewer {
779
916
  onFrame(views, layer) {
780
917
  if (this._disposed) return;
781
918
  if (this._mode !== '3d') this.stopMono();
919
+ // BEFORE the tick: a pose the page sets in here is the one this very frame renders.
920
+ this._beforeFrame?.(views || null);
782
921
  this._tick();
783
922
  if (!views || views.length < 2) {
784
923
  this._replayLastGood();
@@ -805,6 +944,8 @@ export class PlayCanvasSplatViewer {
805
944
  const loop = () => {
806
945
  if (this._disposed) return;
807
946
  this._monoRaf = requestAnimationFrame(loop);
947
+ this._beforeFrame?.(null);
948
+ if (this._disposed || this._mode !== 'mono') return; // the callback removed or re-wove us
808
949
  this._tick();
809
950
  this._drawMono();
810
951
  };
@@ -847,13 +988,15 @@ export class PlayCanvasSplatViewer {
847
988
  * @param {object} o.perf the resolved playcanvasPerfSettings().
848
989
  * @param {string} [o.viewPath] force 'cameras' | 'renderview' (diagnostics).
849
990
  */
850
- async attachEngine(pc, { preserveDrawingBuffer = false, perf, viewPath } = {}) {
991
+ async attachEngine(pc, { preserveDrawingBuffer = false, perf, viewPath, antialias = false, patchSplats = true } = {}) {
851
992
  this.pc = pc;
852
993
  const device = await pc.createGraphicsDevice(this.canvas, {
853
994
  deviceTypes: [pc.DEVICETYPE_WEBGL2],
854
995
  alpha: true,
855
996
  premultipliedAlpha: true,
856
- antialias: false,
997
+ // Off for splats (alpha-blended quads gain nothing from MSAA); ./model turns it on, as
998
+ // three's SceneViewer has it, because mesh silhouettes alias visibly without it.
999
+ antialias,
857
1000
  xrCompatible: false,
858
1001
  preserveDrawingBuffer,
859
1002
  });
@@ -897,10 +1040,12 @@ export class PlayCanvasSplatViewer {
897
1040
  // Footprint fix (§ patchGsplatFootprint) and the perf quad-extent cap, both as chunk
898
1041
  // overrides. The gsplat chunks are registered by GSplatComponentSystem during init, so
899
1042
  // after it; before any gsplat material compiles.
900
- const chunks = pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL);
901
- const corner = patchGsplatFootprint(chunks.get('gsplatCornerVS'));
1043
+ // ./model passes patchSplats:false: its subject is a mesh, and a page adding a splat under
1044
+ // handle.engine.root on a model tile gets the engine's stock footprint (documented).
1045
+ const chunks = patchSplats ? pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL) : null;
1046
+ const corner = chunks ? patchGsplatFootprint(chunks.get('gsplatCornerVS')) : { ok: false, skipped: true };
902
1047
  if (corner.ok) chunks.set('gsplatCornerVS', corner.src);
903
- else if (!warnedFootprint) {
1048
+ else if (!corner.skipped && !warnedFootprint) {
904
1049
  warnedFootprint = true;
905
1050
  console.warn(
906
1051
  '[inline3d/splat] engine:playcanvas — this engine build does not have the gsplatCornerVS ' +
@@ -910,7 +1055,7 @@ export class PlayCanvasSplatViewer {
910
1055
  );
911
1056
  }
912
1057
  this.footprintPatched = corner.ok;
913
- if (perf?.quadExtent) {
1058
+ if (chunks && perf?.quadExtent) {
914
1059
  const q = patchPlayCanvasQuadExtent(chunks.get('gsplatCommonVS'), perf.quadExtent);
915
1060
  if (q.ok) chunks.set('gsplatCommonVS', q.src);
916
1061
  else if (!warnedQuadExtent) {
@@ -1059,7 +1204,7 @@ export class PlayCanvasSplatViewer {
1059
1204
  // The engine's default camera tonemap is LINEAR, which routes every splat colour through
1060
1205
  // decodeGamma → toneMap → gammaCorrectOutput. Spark writes the stored colour straight out;
1061
1206
  // NONE is the same thing here (GAMMA_SRGB alone leaves a gamma-space colour untouched).
1062
- e.camera.toneMapping = pc.TONEMAP_NONE;
1207
+ e.camera.toneMapping = TONE_MAPPINGS[this.toneMapping] ? pc[TONE_MAPPINGS[this.toneMapping]] ?? pc.TONEMAP_NONE : pc.TONEMAP_NONE;
1063
1208
  this.rigNode.addChild(e);
1064
1209
  return e;
1065
1210
  }
@@ -1092,7 +1237,7 @@ export class PlayCanvasSplatViewer {
1092
1237
 
1093
1238
  _placeMonoForFit() {
1094
1239
  // SceneViewer.fitTo: distance to make the frustum exactly vH tall at z = 0, looking down −z.
1095
- if (this.mono.capture) return;
1240
+ if (this.mono.capture || this.pageCamera) return;
1096
1241
  const d = (0.5 * this.vH) / Math.tan((this.mono.fov * DEG) / 2);
1097
1242
  this.mono.pose = poseMatrix([0, 0, d], [0, 0, 0, 1]);
1098
1243
  }
@@ -1134,7 +1279,14 @@ export class PlayCanvasSplatViewer {
1134
1279
  const el = this.canvas;
1135
1280
  const sx = cache && cache.bufW > 0 && el.width ? el.width / cache.bufW : 1;
1136
1281
  const sy = cache && cache.bufH > 0 && el.height ? el.height / cache.bufH : 1;
1137
- if (this.nearClip !== null || this.farClip !== null) {
1282
+ if (this.pageCamera) {
1283
+ // The page's near/far join the caller's nearClip/farClip as a floor and a cap: only the
1284
+ // depth mapping moves, the runtime's frustum (fov, skew, principal point) stays untouched.
1285
+ const p = this.page;
1286
+ const nf = this.nearClip !== null ? Math.max(this.nearClip, p.near) : p.near;
1287
+ const fc = this.farClip !== null ? Math.min(this.farClip, p.far) : p.far;
1288
+ for (const e of entries) clampProjectionDepth(e.proj, nf, fc);
1289
+ } else if (this.nearClip !== null || this.farClip !== null) {
1138
1290
  for (const e of entries) clampProjectionDepth(e.proj, this.nearClip, this.farClip);
1139
1291
  }
1140
1292
  const rect = (e) =>
@@ -1344,7 +1496,7 @@ export class PlayCanvasSplatViewer {
1344
1496
  */
1345
1497
  _applyTransform() {
1346
1498
  if (!this.rigNode) return;
1347
- const trs = pivotInverseTRS(this.pivotState());
1499
+ const trs = this.pageCamera ? pageRigTRS(this.page.matrix, this.flipY) : pivotInverseTRS(this.pivotState());
1348
1500
  this.rigNode.setLocalPosition(trs.position[0], trs.position[1], trs.position[2]);
1349
1501
  this.rigNode.setLocalRotation(trs.rotation[0], trs.rotation[1], trs.rotation[2], trs.rotation[3]);
1350
1502
  this.rigNode.setLocalScale(trs.scale, trs.scale, trs.scale);
@@ -1736,8 +1888,22 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1736
1888
  // `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
1737
1889
  // ROTATES (one directional sort serves every view), not on a timer — docs/playcanvas-adapter.md.
1738
1890
 
1891
+ // controls:'page' — validated again here (./splat already did, at call time) because a test or
1892
+ // an advanced caller may reach this function directly.
1893
+ const ctl = resolveControls(opts);
1894
+ const pageMode = ctl.page;
1895
+ if (pageMode && ctl.ignored.length) {
1896
+ console.info(
1897
+ `[inline3d/splat] controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ` +
1898
+ `${ctl.ignored.length === 1 ? 'is' : 'are'} ignored.`,
1899
+ );
1900
+ }
1901
+ // The rig question is answered by the page: its camera IS the rig.
1902
+ const rigOpts = pageMode ? { ...opts, rig: 'camera' } : opts;
1903
+
1739
1904
  const perfResolved = playcanvasPerfSettings(perf);
1740
1905
  const viewer = new PlayCanvasSplatViewer(canvas, {
1906
+ pageCamera: pageMode,
1741
1907
  virtualDisplayHeight,
1742
1908
  fit,
1743
1909
  margin,
@@ -1773,13 +1939,49 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1773
1939
  perf: perfResolved.applied,
1774
1940
  setPose: (p) => viewer.setPose(p),
1775
1941
  resetPose: () => viewer.resetPose(),
1942
+ /**
1943
+ * controls:'page' — the page's camera for the next frame drawn. See normalizeCameraPose for
1944
+ * the arguments; last call wins; a page that stops calling keeps its last pose.
1945
+ */
1946
+ setCameraPose(matrixWorld, o) {
1947
+ if (!pageMode) {
1948
+ throw new Error(
1949
+ "@displayxr/inline3d/splat: setCameraPose() needs addSplat(…, { controls:'page' }) — " +
1950
+ 'with the default controls the SDK owns the camera (use setPose).',
1951
+ );
1952
+ }
1953
+ const pose = normalizeCameraPose(matrixWorld, o);
1954
+ if (!warnedNonUniform && !pageRigTRS(pose.matrixWorld, flipY).uniform) {
1955
+ warnedNonUniform = true;
1956
+ console.warn(
1957
+ "[inline3d/splat] controls:'page' — matrixWorld has a NON-uniform scale; the adapter uses " +
1958
+ 'its geometric mean. A camera pose is rotation + translation + at most a uniform scale.',
1959
+ );
1960
+ }
1961
+ lastPagePose = pose;
1962
+ viewer.setPageCamera(pose);
1963
+ return out;
1964
+ },
1965
+ /** The last pose the page set (a copy), or null before the first setCameraPose. */
1966
+ getCameraPose() {
1967
+ if (!lastPagePose) return null;
1968
+ return {
1969
+ matrixWorld: Float32Array.from(lastPagePose.matrixWorld),
1970
+ verticalFovDeg: lastPagePose.verticalFovDeg,
1971
+ near: lastPagePose.near,
1972
+ far: lastPagePose.far,
1973
+ convergence: lastPagePose.convergence,
1974
+ };
1975
+ },
1776
1976
  getFocus: (o) => {
1977
+ if (pageMode) return pageFocusPoint();
1777
1978
  // In MODEL space, like setFocus takes it.
1778
1979
  const f = viewer.getFocus(o);
1779
1980
  return contentToModel([f.x, f.y, f.z]);
1780
1981
  },
1781
1982
  setFocus(point, o = {}) {
1782
1983
  if (!out.mesh || !out.rig) return out;
1984
+ if (pageMode) return pageSetFocus(point, o);
1783
1985
  const model = point == null ? out.rig.focusDefault : toArray3(point);
1784
1986
  out.rig.focus = model;
1785
1987
  out.rig.focusSource = point == null ? out.rig.focusDefaultSource : 'set';
@@ -1815,9 +2017,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1815
2017
  return flipY ? [c[0], -c[1], -c[2]] : [c[0], c[1], c[2]];
1816
2018
  }
1817
2019
 
2020
+ // ── controls:'page' state (null/unused on the viewer's own controls) ──
2021
+ let lastPagePose = null; // what the page last handed setCameraPose, validated
2022
+ let warnedNonUniform = false;
2023
+ // The convergence the adapter owns when the page gives none, in MODEL units (so it is stable
2024
+ // while the camera moves): the waterfall's on load / setSource, a setFocus after that. Eased
2025
+ // toward `target` at the viewer's FOCUS_EASE unless snapped.
2026
+ const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
2027
+
1818
2028
  if (wall && wall.supported) {
1819
2029
  handle = wall.addScene(canvas, viewer.onFrame, {
1820
- virtualDisplayHeight,
2030
+ // controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
2031
+ // and the waterfall say otherwise); the display rig's height means nothing there.
2032
+ ...(pageMode
2033
+ ? { viewRig: pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth }) }
2034
+ : { virtualDisplayHeight }),
1821
2035
  onLayerLost: viewer.onLayerLost,
1822
2036
  ...(observe ? { observe } : {}),
1823
2037
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
@@ -1841,9 +2055,100 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1841
2055
  out[name]?.(...args);
1842
2056
  }
1843
2057
 
2058
+ // ── controls:'page': focus = the convergence point on the page camera's view axis ──
2059
+ /** The convergence distance for THIS frame, in page units, and where it came from. */
2060
+ function pageConvergence() {
2061
+ const ax = pageViewAxis(viewer.page.matrix);
2062
+ if (lastPagePose && lastPagePose.convergence !== null) {
2063
+ return { d: lastPagePose.convergence, ax, source: 'page' };
2064
+ }
2065
+ const dm = Number.isFinite(pageFocus.d) ? pageFocus.d : 2;
2066
+ return { d: dm / (ax.scale || 1), ax, source: out.rig?.focusSource ?? 'default' };
2067
+ }
2068
+ function pageFocusPoint() {
2069
+ if (!out.rig) return null;
2070
+ const { d, ax } = pageConvergence();
2071
+ const dm = d * ax.scale;
2072
+ return [ax.origin[0] + ax.forward[0] * dm, ax.origin[1] + ax.forward[1] * dm, ax.origin[2] + ax.forward[2] * dm];
2073
+ }
2074
+ function pageSetFocus(point, { snap = false } = {}) {
2075
+ let dm;
2076
+ if (point == null) {
2077
+ dm = out.rig.convergenceDefault;
2078
+ out.rig.focus = out.rig.focusDefault;
2079
+ out.rig.focusSource = out.rig.focusDefaultSource;
2080
+ } else {
2081
+ const m = toArray3(point);
2082
+ const ax = pageViewAxis(viewer.page.matrix);
2083
+ 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];
2084
+ if (!(dm > 0)) {
2085
+ console.warn('[inline3d/splat] setFocus: that point is behind the page camera — focus unchanged');
2086
+ return out;
2087
+ }
2088
+ out.rig.focus = m;
2089
+ out.rig.focusSource = 'set';
2090
+ }
2091
+ pageFocus.target = dm;
2092
+ if (snap || !Number.isFinite(pageFocus.d)) pageFocus.d = dm;
2093
+ return out;
2094
+ }
2095
+ /** Per tick (before the draw, so the rig drives the next locate): ease, declare, notify. */
2096
+ function pageTick() {
2097
+ if (Number.isFinite(pageFocus.target) && pageFocus.d !== pageFocus.target) {
2098
+ const dd = pageFocus.target - pageFocus.d;
2099
+ pageFocus.d = Math.abs(dd) < 1e-6 ? pageFocus.target : pageFocus.d + dd * FOCUS_EASE;
2100
+ }
2101
+ const { d, source } = pageConvergence();
2102
+ out.viewRig = pageViewRig(
2103
+ {
2104
+ verticalFovDeg: viewer.page.fov,
2105
+ convergence: d,
2106
+ comfortDepth: ctl.comfortDepth,
2107
+ ipdFactor: out.rig ? out.rig.ipdFactor : Number.isFinite(opts.ipdFactor) ? opts.ipdFactor : 1,
2108
+ parallaxFactor: out.rig ? out.rig.parallaxFactor : Number.isFinite(opts.parallaxFactor) ? opts.parallaxFactor : 1,
2109
+ },
2110
+ out.viewRig || {},
2111
+ );
2112
+ handle?.setViewRig?.(out.viewRig);
2113
+ if (!out.rig) return;
2114
+ out.rig.convergence = d;
2115
+ if (Math.abs(d - pageFocus.fired) > 1e-3 || source !== pageFocus.source) {
2116
+ pageFocus.fired = d;
2117
+ pageFocus.source = source;
2118
+ const cb = out.onFocusChange;
2119
+ if (typeof cb === 'function') cb(pageFocusPoint(), { focusSource: source });
2120
+ }
2121
+ }
2122
+ if (pageMode) viewer.onTick = pageTick;
2123
+
2124
+ // onBeforeFrame: the page's game step INSIDE this adapter's frame, before the tick and the
2125
+ // draw — so a setCameraPose made in it is what this frame renders (zero lag, the attach
2126
+ // pattern). The session rAF (3D) and the window rAF have no guaranteed order, so a pose set
2127
+ // from the page's own rAF may be one frame late. A throw is the page's bug: warned once, and
2128
+ // the frame still renders (with whatever pose it had).
2129
+ if (pageMode && typeof opts.onBeforeFrame === 'function') {
2130
+ const cb = opts.onBeforeFrame;
2131
+ let lastT = 0;
2132
+ let warned = false;
2133
+ viewer._beforeFrame = (views) => {
2134
+ const t = now();
2135
+ const dt = lastT ? Math.min((t - lastT) / 1000, MAX_DT_S) : 0;
2136
+ lastT = t;
2137
+ try {
2138
+ cb({ time: t, views, dt });
2139
+ } catch (err) {
2140
+ if (!warned) {
2141
+ warned = true;
2142
+ console.warn('[inline3d/splat] onBeforeFrame threw (warned once; frames keep rendering)', err);
2143
+ }
2144
+ }
2145
+ };
2146
+ }
2147
+
1844
2148
  // ── focus → view rig ──
1845
2149
  let lastConvergence = Number.NaN;
1846
2150
  function pushViewRig(force) {
2151
+ if (pageMode) return; // pageTick declares the rig every frame
1847
2152
  if (!out.rig || out.rig.type !== 'camera') return;
1848
2153
  const pose = viewer.mono.pose;
1849
2154
  const f = viewer.getFocus();
@@ -1905,8 +2210,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1905
2210
  const v = viewer.currentView();
1906
2211
  const invP = mat4Invert(v.proj);
1907
2212
  if (!invP) return null;
1908
- const Minv = mat4Invert(pivotMatrix(viewer.pivotState()));
1909
- const toContent = mat4Mul(Minv, v.pose);
2213
+ const toContent = mat4Mul(viewer.rigMatrix(), v.pose);
1910
2214
  const a = transformPoint(invP, ndc.x, ndc.y, -1);
1911
2215
  const b = transformPoint(invP, ndc.x, ndc.y, 1);
1912
2216
  const o = contentToModel(transformPoint(toContent, a[0], a[1], a[2]));
@@ -1953,7 +2257,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1953
2257
  }
1954
2258
 
1955
2259
  function bindFocusInput() {
1956
- if (focusInput === false || unbindFocusInput) return;
2260
+ // controls:'page' binds no input at all: gestures are the page's (pick + setFocus remain).
2261
+ if (pageMode || focusInput === false || unbindFocusInput) return;
1957
2262
  unbindFocusInput = bindFocusGestures(canvas, {
1958
2263
  onDoubleClick: (e) => {
1959
2264
  const m = pickModel(e.clientX, e.clientY);
@@ -1993,7 +2298,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1993
2298
  // The camera block off the SAME bytes, before the engine takes them (as the Spark path).
1994
2299
  let camera = null;
1995
2300
  let t0 = performance.now();
1996
- if (bytes && rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
2301
+ if (bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
1997
2302
  perfSpan('readSogMeta(async)', t0);
1998
2303
 
1999
2304
  const url = bytes
@@ -2017,7 +2322,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2017
2322
  const desc = describeResource(res);
2018
2323
  // A URL `.sog` carries its meta in the resource (the engine keeps unknown keys); a Streamed
2019
2324
  // 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);
2325
+ if (!bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(desc.meta);
2021
2326
  const cloud = desc.kind === 'flat' ? await readCloud(res) : null;
2022
2327
  // The cloud passes, each in its OWN task: framing, the rest-space sample and the pick set
2023
2328
  // were one ~60 ms main-thread block on a 1.18M-gaussian swap — pointer input waited on it.
@@ -2071,7 +2376,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2071
2376
  const box = canvas.getBoundingClientRect();
2072
2377
  const resolved = resolveRig({
2073
2378
  camera: loaded.camera,
2074
- opts,
2379
+ opts: rigOpts,
2075
2380
  cloud: sample,
2076
2381
  canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
2077
2382
  });
@@ -2092,7 +2397,23 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2092
2397
  // The strided pick fallback (used only if the engine releases its full centre set).
2093
2398
  const pickCentres = loaded.pre?.pickCentres || null;
2094
2399
 
2095
- if (resolved.type === 'camera') {
2400
+ if (pageMode) {
2401
+ // The page owns the pose; the waterfall only supplies the convergence (re-estimated here,
2402
+ // on load and on setSource, never per frame). Until the page's first setCameraPose the
2403
+ // camera sits at the asset's rest pose (the block's, else the origin looking down +z) with
2404
+ // the resolved lens, so the first frame is not an empty tile.
2405
+ resolved.convergenceDefault = resolved.convergence;
2406
+ pageFocus.target = pageFocus.d = resolved.convergence;
2407
+ pageFocus.fired = Number.NaN;
2408
+ if (!lastPagePose) {
2409
+ const r = resolved.rest;
2410
+ viewer.setPageCamera(
2411
+ normalizeCameraPose(poseMatrix(r.position, qmul(r.rotation, FLIP_Q)), {
2412
+ verticalFovDeg: captureVerticalFovDeg(resolved.intrinsics, NaN, MONO_NEAR, 'height'),
2413
+ }),
2414
+ );
2415
+ }
2416
+ } else if (resolved.type === 'camera') {
2096
2417
  if (!('idleSpin' in opts)) viewer.idleSpin = 0;
2097
2418
  // The camera rig never auto-fits (the capture IS the framing): a scale left by a previous
2098
2419
  // display-rig asset (setSource) must not shrink this one.
@@ -2114,7 +2435,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2114
2435
 
2115
2436
  let pcModule = null;
2116
2437
  const booted = (async () => {
2117
- const pc = opts.playcanvas || (await import('playcanvas'));
2438
+ // Named re-exports, not the package namespace: lets a bundler drop the ~40% of the engine the
2439
+ // SDK never touches (./inline3d-playcanvas-engine.js).
2440
+ const pc = opts.playcanvas || (await import('./inline3d-playcanvas-engine.js'));
2118
2441
  if (removed) return null;
2119
2442
  const app = await viewer.attachEngine(pc, {
2120
2443
  preserveDrawingBuffer,
@@ -2184,7 +2507,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2184
2507
  };
2185
2508
  const kept = applyLoaded(loaded);
2186
2509
  current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
2187
- if (resetPose) viewer.resetPose();
2510
+ if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
2188
2511
 
2189
2512
  const release = () => {
2190
2513
  if (!prev) return;