@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 +35 -0
- package/README.md +9 -0
- package/js/inline3d-splat-playcanvas.js +315 -14
- package/js/inline3d-splat-shared.js +121 -0
- package/js/inline3d-splat.js +56 -2
- package/package.json +1 -1
- package/splat.d.ts +68 -0
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|
package/js/inline3d-splat.js
CHANGED
|
@@ -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.
|
|
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`.
|