@displayxr/inline3d 1.7.1 → 1.8.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.
@@ -24,7 +24,25 @@ import { EyeCamera, EdgeFeather, cameraRigFromCamera } from './inline3d-three.js
24
24
  import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
25
25
  import { readSogCamera } from './inline3d-sog.js';
26
26
  import { applySplatPerf, splatPerfMeshOptions } from './inline3d-splat-perf.js';
27
- import { resolveRig, toRestSpace, planeDistance } from './inline3d-splat-rig.js';
27
+ import {
28
+ toArray3,
29
+ canvasNdc,
30
+ bindFocusGestures,
31
+ captureWindow,
32
+ captureVerticalFovDeg,
33
+ CAPTURE_FITS,
34
+ playcanvasCannotRead,
35
+ } from './inline3d-splat-shared.js';
36
+ import {
37
+ resolveRig,
38
+ planeDistance,
39
+ sampleCloudRestSpace,
40
+ rigNeedsCloud,
41
+ sampleCloudCentres,
42
+ RIG_SAMPLE_CAP,
43
+ RIG_MIN_OPACITY,
44
+ resolveSplatEngine,
45
+ } from './inline3d-splat-rig.js';
28
46
 
29
47
  export { applySplatPerf, SPLAT_PERF_PRESETS } from './inline3d-splat-perf.js';
30
48
  export { readSogCamera, readSogMeta } from './inline3d-sog.js';
@@ -40,9 +58,6 @@ export { resolveRig } from './inline3d-splat-rig.js';
40
58
  */
41
59
  const DEFAULT_SORT_INTERVAL_MS = 16;
42
60
 
43
- /** Cap on how many splat centres the fallback framing pass inspects. */
44
- const FRAME_SAMPLE_CAP = 200000;
45
-
46
61
  /**
47
62
  * three.js floor for THIS subpath — Spark's own floor, above the package-wide >=0.150 that the
48
63
  * core and ./three ask for.
@@ -140,6 +155,23 @@ function sniffFileType(bytes) {
140
155
  * good enough for a clean, isolated capture, weaker on a scene with a background wall.
141
156
  */
142
157
  export function addSplat(wall, canvas, src, opts = {}) {
158
+ if (opts.captureFit !== undefined && !CAPTURE_FITS.includes(opts.captureFit)) {
159
+ throw new Error(
160
+ `@displayxr/inline3d/splat: captureFit "${opts.captureFit}" — expected ` +
161
+ `${CAPTURE_FITS.map((f) => `'${f}'`).join(' or ')}.`,
162
+ );
163
+ }
164
+ // WHICH ENGINE. Unset (or 'spark') is everything below, untouched. 'playcanvas' goes to
165
+ // ./inline3d-splat-playcanvas.js, imported DYNAMICALLY so a page that never asks for it never
166
+ // resolves `playcanvas` — see addSplatDeferred.
167
+ if (resolveSplatEngine(opts) === 'playcanvas') {
168
+ // A format that engine provably cannot read (a .spz URL, gzip bytes, a Spark-only fileType)
169
+ // is a page bug: say so NOW rather than fail inside a loader later.
170
+ const why = playcanvasCannotRead(src, opts);
171
+ if (why) throw new Error(`@displayxr/inline3d/splat: ${why}`);
172
+ return addSplatDeferred(wall, canvas, src, opts);
173
+ }
174
+
143
175
  // Fail here, synchronously, and not through `ready`: a peer too old is an install-time mistake
144
176
  // in the page's dependencies, not a condition of this asset, and it will be true of every call.
145
177
  // Surfacing it as a load rejection would let a caller render an "asset unavailable" placeholder
@@ -168,6 +200,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
168
200
  sortIntervalMs = DEFAULT_SORT_INTERVAL_MS,
169
201
  perf = null,
170
202
  rig = 'auto',
203
+ captureFit = 'height',
171
204
  focusInput = true,
172
205
  convergence,
173
206
  fileName,
@@ -211,6 +244,19 @@ export function addSplat(wall, canvas, src, opts = {}) {
211
244
  // the console and a fit pipeline that provably never executed.
212
245
  let handle = null;
213
246
  const out = {
247
+ backend: 'spark',
248
+ /**
249
+ * ADVANCED, not covered by the semver promise: the three.js objects behind this window, for
250
+ * a page that wants to add its own content. `camera` is whichever camera draws the current
251
+ * frame (an eye in 3D, the mono camera flat).
252
+ */
253
+ engine: Object.freeze({
254
+ renderer: viewer.renderer,
255
+ scene: viewer.scene,
256
+ get camera() {
257
+ return (viewer.is3D && viewer._eye?.camera) || viewer.monoCamera;
258
+ },
259
+ }),
214
260
  viewer,
215
261
  // null until the bytes are read and the mesh is constructed; use `ready` to await it.
216
262
  mesh: null,
@@ -275,6 +321,21 @@ export function addSplat(wall, canvas, src, opts = {}) {
275
321
  },
276
322
  exclude: (el) => handle?.exclude(el),
277
323
  unexclude: (el) => handle?.unexclude(el),
324
+ /** Where the window is pointed, in the SPLAT's own space; null before load. */
325
+ getFocus(o) {
326
+ if (!out.mesh) return null;
327
+ const f = viewer.getFocus(o);
328
+ return toModelSpace(out.mesh, viewer.content.localToWorld(new THREE.Vector3(f.x, f.y, f.z)), THREE);
329
+ },
330
+ /** Called with the live focus, in the splat's own space, whenever it moves. */
331
+ onFocusChange: null,
332
+ /** Not on this backend: the crossfading asset swap is a PlayCanvas-backend feature. */
333
+ setSource() {
334
+ throw new Error(
335
+ "@displayxr/inline3d/splat: setSource() is implemented on the PlayCanvas backend " +
336
+ "only; with the Spark backend, remove() this handle and addSplat() the new asset.",
337
+ );
338
+ },
278
339
  };
279
340
 
280
341
  // `src` may be a URL or the bytes themselves.
@@ -362,7 +423,11 @@ export function addSplat(wall, canvas, src, opts = {}) {
362
423
  });
363
424
  handle?.setViewRig(out.viewRig);
364
425
  }
365
- viewer.onFocusChange = () => pushViewRig(false);
426
+ viewer.onFocusChange = () => {
427
+ pushViewRig(false);
428
+ const cb = out.onFocusChange;
429
+ if (typeof cb === 'function' && out.mesh) cb(out.getFocus(), { focusSource: out.rig?.focusSource ?? null });
430
+ };
366
431
 
367
432
  /**
368
433
  * What is under a point on the canvas.
@@ -382,15 +447,9 @@ export function addSplat(wall, canvas, src, opts = {}) {
382
447
  function pickPoint(clientX, clientY) {
383
448
  const mesh = out.mesh;
384
449
  if (!mesh) return null;
385
- const box = canvas.getBoundingClientRect();
386
- if (!(box.width > 0) || !(box.height > 0)) return null;
387
- // NDC from the CSS box. On a woven canvas the backing store is double-width and each eye
388
- // owns half of it, but what the VIEWER sees is one image filling the box, so the box is the
389
- // right frame to pick in; the eye camera supplies the parallax-correct ray.
390
- const ndc = {
391
- x: ((clientX - box.left) / box.width) * 2 - 1,
392
- y: -(((clientY - box.top) / box.height) * 2 - 1),
393
- };
450
+ // NDC from the CSS box (./inline3d-splat-shared.js says why the box and not the store).
451
+ const ndc = canvasNdc(canvas, clientX, clientY);
452
+ if (!ndc) return null;
394
453
  const cam = (viewer.is3D && viewer._eye?.camera) || viewer.monoCamera;
395
454
  if (!raycaster) raycaster = new THREE.Raycaster();
396
455
  raycaster.setFromCamera(ndc, cam);
@@ -412,46 +471,27 @@ export function addSplat(wall, canvas, src, opts = {}) {
412
471
  // ── input ─────────────────────────────────────────────────────────────────────────────
413
472
  let unbindFocusInput = null;
414
473
  function bindFocusInput() {
415
- if (focusInput === false || unbindFocusInput || typeof canvas.addEventListener !== 'function') {
416
- return;
417
- }
418
- let hovering = false;
419
- const onEnter = () => {
420
- hovering = true;
421
- };
422
- const onLeave = () => {
423
- hovering = false;
424
- };
425
- const onDblClick = (e) => {
426
- const world = pickPoint(e.clientX, e.clientY);
427
- if (!world) return;
428
- e.preventDefault();
429
- out.mesh.updateWorldMatrix(true, false);
430
- out.rig.focus = toArray3(out.mesh.worldToLocal(world.clone()));
431
- out.rig.focusSource = 'picked';
432
- out.rig.convergence = planeDistance(out.rig.rest, out.rig.focus);
433
- viewer.content.updateWorldMatrix(true, false);
434
- viewer.setFocus(toArray3(viewer.content.worldToLocal(world.clone())));
435
- };
436
- const onKeyDown = (e) => {
437
- // Scoped to this window on purpose: a page with four splat tiles must not have one key
438
- // reset all four. Hover OR focus, so it works with a pointer and with a keyboard.
439
- if (e.code !== 'Space' && e.key !== ' ') return;
440
- if (!hovering && document.activeElement !== canvas) return;
441
- e.preventDefault();
442
- out.setFocus(null);
443
- };
444
- canvas.addEventListener('pointerenter', onEnter);
445
- canvas.addEventListener('pointerleave', onLeave);
446
- canvas.addEventListener('dblclick', onDblClick);
447
- addEventListener('keydown', onKeyDown);
448
- unbindFocusInput = () => {
449
- canvas.removeEventListener('pointerenter', onEnter);
450
- canvas.removeEventListener('pointerleave', onLeave);
451
- canvas.removeEventListener('dblclick', onDblClick);
452
- removeEventListener('keydown', onKeyDown);
453
- unbindFocusInput = null;
454
- };
474
+ if (focusInput === false || unbindFocusInput) return;
475
+ const off = bindFocusGestures(canvas, {
476
+ onDoubleClick: (e) => {
477
+ const world = pickPoint(e.clientX, e.clientY);
478
+ if (!world) return false;
479
+ out.mesh.updateWorldMatrix(true, false);
480
+ out.rig.focus = toArray3(out.mesh.worldToLocal(world.clone()));
481
+ out.rig.focusSource = 'picked';
482
+ out.rig.convergence = planeDistance(out.rig.rest, out.rig.focus);
483
+ viewer.content.updateWorldMatrix(true, false);
484
+ viewer.setFocus(toArray3(viewer.content.worldToLocal(world.clone())));
485
+ return true;
486
+ },
487
+ onReset: () => out.setFocus(null),
488
+ });
489
+ unbindFocusInput =
490
+ off &&
491
+ (() => {
492
+ off();
493
+ unbindFocusInput = null;
494
+ });
455
495
  }
456
496
 
457
497
  // Await the MESH first, then its load. Reading `mesh.initialized` here directly would
@@ -489,7 +529,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
489
529
  // is the lens, and the median of 1/z is the focus. Skipped entirely when the block already
490
530
  // answers both, so an asset that carries a full camera pays nothing for it.
491
531
  const cloud =
492
- out.camera?.intrinsics && out.camera?.focus
532
+ !rigNeedsCloud(out.camera)
493
533
  ? null
494
534
  : sampleRestSpace(out.mesh, out.camera?.rest);
495
535
  const box = canvas.getBoundingClientRect();
@@ -510,7 +550,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
510
550
  // A turntable on a photograph is nonsense, so the default spin stops here — but only the
511
551
  // DEFAULT: a page that asked for one still gets it.
512
552
  if (!('idleSpin' in opts)) viewer.idleSpin = 0;
513
- applyCaptureCamera(viewer, resolved, flipY);
553
+ applyCaptureCamera(viewer, resolved, flipY, THREE, captureFit, () => pushViewRig(true));
514
554
  // The capture does not move; only what the rotation turns about does. Snapped, because
515
555
  // this is the asset arriving, not a gesture.
516
556
  viewer.setFocus(toContentSpace(out.mesh, resolved.focus, THREE), {
@@ -555,6 +595,58 @@ export function addSplat(wall, canvas, src, opts = {}) {
555
595
  return out;
556
596
  }
557
597
 
598
+ /**
599
+ * The `engine: 'playcanvas'` handle, returned SYNCHRONOUSLY like the Spark one.
600
+ *
601
+ * The adapter module is loaded on demand, so for a moment the handle exists before its
602
+ * implementation does. Rather than make every caller await something new, the handle starts as
603
+ * stubs that QUEUE: `exclude()` (which a product page calls on the very next line), `setPose`,
604
+ * `setFocus`, `remove` — and the adapter replays the queue into the real implementation, on the
605
+ * SAME object, the moment it arrives. `ready` resolves to this object, as it does on Spark.
606
+ * Fields (`viewer`, `mesh`, `rig`, …) are null until then; `viewer` in particular appears one
607
+ * module-load later than on Spark.
608
+ */
609
+ function addSplatDeferred(wall, canvas, src, opts) {
610
+ const pending = [];
611
+ const queue = (name) => (...args) => {
612
+ pending.push([name, args]);
613
+ return name === 'setFocus' ? out : undefined;
614
+ };
615
+ const out = {
616
+ backend: 'playcanvas',
617
+ engine: null,
618
+ viewer: null,
619
+ mesh: null,
620
+ frame: null,
621
+ camera: null,
622
+ rig: null,
623
+ perf: null,
624
+ setPose: queue('setPose'),
625
+ resetPose: queue('resetPose'),
626
+ setFocus: queue('setFocus'),
627
+ // A swap requested before the first asset has landed runs once it has (the adapter's own
628
+ // setSource replaces this stub on the same object by then).
629
+ setSource: (...args) => out.ready.then(() => out.setSource(...args)),
630
+ getFocus: () => null,
631
+ // A plain data slot the adapter reads at call time, so a callback assigned on the very next
632
+ // line after addSplat — before the module has loaded — is the one that fires.
633
+ onFocusChange: null,
634
+ pick: () => null,
635
+ remove: queue('remove'),
636
+ exclude: queue('exclude'),
637
+ unexclude: queue('unexclude'),
638
+ };
639
+ // The ONE owner of `ready`: the adapter returns its load promise and never touches this field.
640
+ out.ready = import('./inline3d-splat-playcanvas.js')
641
+ .then((m) => m.attachPlayCanvasSplat(out, wall, canvas, src, opts, pending))
642
+ .catch((err) => {
643
+ // The adapter warns about its own load failures; this is for the module not arriving.
644
+ if (!out.viewer) console.warn('[inline3d/splat] engine:playcanvas failed to start', err);
645
+ throw err;
646
+ });
647
+ return out;
648
+ }
649
+
558
650
  /**
559
651
  * Percentile bounds from the loaded splats — the fallback when no sidecar was supplied.
560
652
  *
@@ -594,10 +686,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
594
686
  * resize, and three's symmetric version would silently throw the off-axis window away on the
595
687
  * first layout nudge.
596
688
  */
597
- function applyCaptureCamera(viewer, rig, flipY, THREE_) {
689
+ function applyCaptureCamera(viewer, rig, flipY, THREE_, captureFit = 'height', onFov = null) {
598
690
  const three = THREE_ || THREE;
599
691
  const camera = viewer.monoCamera;
600
- const { fx, fy, cx, cy, width, height } = rig.intrinsics;
692
+ const { width, height } = rig.intrinsics;
601
693
 
602
694
  const q = new three.Quaternion(
603
695
  rig.rest.rotation[0],
@@ -626,7 +718,7 @@ function applyCaptureCamera(viewer, rig, flipY, THREE_) {
626
718
  q.multiply(flip);
627
719
  camera.position.copy(p);
628
720
  camera.quaternion.copy(q);
629
- camera.fov = (2 * Math.atan(height / (2 * fy)) * 180) / Math.PI;
721
+ camera.fov = captureVerticalFovDeg(rig.intrinsics, NaN, camera.near, 'height');
630
722
  // FAR, and why it is not the viewer's default. A deconverged capture parks its sky at the
631
723
  // lifter's depth cap and the refinement scatters some gaussians beyond it (239 m measured on a
632
724
  // street scene); anything past the far plane is CLIPPED in Spark's vertex shader and pops out
@@ -637,70 +729,45 @@ function applyCaptureCamera(viewer, rig, flipY, THREE_) {
637
729
 
638
730
  camera.updateProjectionMatrix = () => {
639
731
  const near = camera.near;
640
- // OpenCV's y grows DOWN the image, so the TOP edge is the `cy` side.
641
- const top = (near * cy) / fy;
642
- const bottom = -(near * (height - cy)) / fy;
643
732
  const box = viewer.canvas.getBoundingClientRect();
644
733
  const aspect = box.height > 0 ? box.width / box.height : width / height;
645
- const mid = (near * (width / 2 - cx)) / fx; // horizontal centre of the capture's frustum
646
- const half = ((top - bottom) * aspect) / 2;
647
- camera.projectionMatrix.makePerspective(mid - half, mid + half, top, bottom, near, camera.far);
734
+ // The window is shared with the PlayCanvas backend (./inline3d-splat-shared.js), `captureFit`
735
+ // included; on 'height' it is the same arithmetic this function always did.
736
+ const w = captureWindow(rig.intrinsics, aspect, near, captureFit);
737
+ camera.projectionMatrix.makePerspective(w.left, w.right, w.top, w.bottom, near, camera.far);
648
738
  camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
739
+ if (captureFit !== 'height') {
740
+ const fov = captureVerticalFovDeg(rig.intrinsics, aspect, near, captureFit);
741
+ if (fov !== camera.fov) {
742
+ camera.fov = fov;
743
+ onFov?.();
744
+ }
745
+ }
649
746
  };
650
747
  camera.updateProjectionMatrix();
651
748
  }
652
749
 
653
750
 
654
- /** Cap on how many splats the rig pass inspects. Percentiles of a uniform subsample converge. */
655
- const RIG_SAMPLE_CAP = 40000;
656
-
657
- /** Below this, a splat is haze — it is not where the camera was pointed and not what it saw. */
658
- const RIG_MIN_OPACITY = 0.05;
659
-
660
- /** Nearer than this, a splat is behind or on the lens and its x/z, y/z, 1/z are meaningless. */
661
- const RIG_MIN_Z = 0.05;
662
-
663
751
  /**
664
- * ONE walk over the cloud, in the REST CAMERA's frame, producing everything the waterfall needs
665
- * that is not in the file: the angular extent that is the lens (x/z, y/z) and the disparities
666
- * whose median is the focus (1/z).
667
- *
668
- * Model space, deliberately — `forEachSplat` reports centres before the mesh's own transform, so
669
- * this is the file's own OpenCV frame, which is the frame `rest` and `intrinsics` are expressed
670
- * in. Doing it after the Y-flip would mean undoing the flip to compare with the block.
752
+ * ONE walk over the cloud, in the REST CAMERA's frame see `sampleCloudRestSpace` in
753
+ * ./inline3d-splat-rig.js, which holds the sampling rules for every backend. This is only the
754
+ * Spark adapter onto it: `forEachSplat` reports centres before the mesh's own transform, so
755
+ * this is the file's own OpenCV frame, which is the frame `rest` and `intrinsics` are in.
671
756
  *
672
757
  * @returns {{tx:Float64Array,ty:Float64Array,invz:Float64Array,n:number}|null}
673
758
  */
674
759
  function sampleRestSpace(mesh, rest) {
675
760
  const total = mesh?.numSplats || 0;
676
761
  if (!total || typeof mesh.forEachSplat !== 'function') return null;
677
- const r = rest || { position: [0, 0, 0], rotation: [0, 0, 0, 1] };
678
- const stride = Math.max(1, Math.ceil(total / RIG_SAMPLE_CAP));
679
- const cap = Math.ceil(total / stride) + 1;
680
- const tx = new Float64Array(cap);
681
- const ty = new Float64Array(cap);
682
- const invz = new Float64Array(cap);
683
- let n = 0;
684
- const p = [0, 0, 0];
685
- mesh.forEachSplat((index, center, scales, quaternion, opacity) => {
686
- if (index % stride !== 0 || n >= cap) return;
687
- if (opacity !== undefined && opacity < RIG_MIN_OPACITY) return;
688
- p[0] = center.x;
689
- p[1] = center.y;
690
- p[2] = center.z;
691
- const c = toRestSpace(r, p);
692
- if (!(c[2] > RIG_MIN_Z)) return;
693
- tx[n] = c[0] / c[2];
694
- ty[n] = c[1] / c[2];
695
- invz[n] = 1 / c[2];
696
- n++;
697
- });
698
- return n ? { tx, ty, invz, n } : null;
762
+ return sampleCloudRestSpace(total, sparkCentres(mesh), rest);
699
763
  }
700
764
 
701
- /** [x,y,z] out of anything vector-shaped. */
702
- function toArray3(v) {
703
- return Array.isArray(v) ? [v[0], v[1], v[2]] : [v.x, v.y, v.z];
765
+ /** Spark's `forEachSplat` as the backend-neutral centre visitor ./inline3d-splat-rig.js walks. */
766
+ function sparkCentres(mesh) {
767
+ return (visit) =>
768
+ mesh.forEachSplat((index, center, scales, quaternion, opacity) =>
769
+ visit(index, center.x, center.y, center.z, opacity),
770
+ );
704
771
  }
705
772
 
706
773
  /** A point in the splat's own (model) space, in the viewer's CONTENT space. */
@@ -784,18 +851,7 @@ export function measureSplatBounds(mesh, three = THREE) {
784
851
  function measureBounds(mesh, THREE) {
785
852
  const total = mesh.numSplats || 0;
786
853
  if (!total) return null;
787
- const stride = Math.max(1, Math.ceil(total / FRAME_SAMPLE_CAP));
788
- const xyz = new Float32Array(Math.ceil(total / stride) * 3);
789
- let k = 0;
790
- mesh.forEachSplat((index, center, scales, quaternion, opacity) => {
791
- if (index % stride !== 0) return;
792
- if (opacity !== undefined && opacity < 0.05) return;
793
- if (k + 3 > xyz.length) return;
794
- xyz[k++] = center.x;
795
- xyz[k++] = center.y;
796
- xyz[k++] = center.z;
797
- });
798
- const local = boundsFromPositions(xyz.subarray(0, k));
854
+ const local = boundsFromPositions(sampleCloudCentres(total, sparkCentres(mesh)));
799
855
  if (!local) return null;
800
856
 
801
857
  mesh.updateMatrix();
@@ -212,14 +212,7 @@ function scratch(THREE) {
212
212
  * @returns {object} an XRViewRigInit-shaped plain object.
213
213
  */
214
214
  export function cameraRigFromCamera(THREE, camera, opts = {}) {
215
- const {
216
- convergence = 0,
217
- attach = false,
218
- ipdFactor = 1,
219
- parallaxFactor = 1,
220
- metersToVirtual = 1,
221
- out = {},
222
- } = opts;
215
+ const { attach = false, out = {} } = opts;
223
216
  out.type = 'camera';
224
217
  if (attach) {
225
218
  // Identity pose: the rig IS the camera, so the runtime reports eyes in camera space and the
@@ -236,12 +229,53 @@ export function cameraRigFromCamera(THREE, camera, opts = {}) {
236
229
  out.position = { x: p.x, y: p.y, z: p.z };
237
230
  out.orientation = { x: q.x, y: q.y, z: q.z, w: q.w };
238
231
  }
232
+ // three's fov is the FULL angle, in degrees
233
+ return fillCameraRig(out, THREE.MathUtils.degToRad(camera.fov), opts);
234
+ }
235
+
236
+ /**
237
+ * The same CAMERA-rig descriptor as {@link cameraRigFromCamera}, from a plain pose instead of a
238
+ * three.js camera — for a renderer that is not three (./splat's PlayCanvas backend), or a page
239
+ * that keeps its camera as numbers.
240
+ *
241
+ * Field for field the descriptor cameraRigFromCamera builds, in the same key order, and the
242
+ * degrees → radians step is the same multiplication three's `MathUtils.degToRad` does, so the
243
+ * two agree to the bit for the same pose (pinned in test/view-rig.test.mjs).
244
+ *
245
+ * @param {object} pose
246
+ * @param {{x:number,y:number,z:number}} pose.position WORLD position.
247
+ * @param {{x:number,y:number,z:number,w:number}} pose.orientation WORLD orientation.
248
+ * @param {number} pose.fov FULL vertical angle, in DEGREES (three's `camera.fov` convention).
249
+ * @param {object} [opts] as cameraRigFromCamera.
250
+ * @returns {object} an XRViewRigInit-shaped plain object.
251
+ */
252
+ export function cameraRigFromPose(pose, opts = {}) {
253
+ const { attach = false, out = {} } = opts;
254
+ out.type = 'camera';
255
+ if (attach) {
256
+ out.position = { x: 0, y: 0, z: 0 };
257
+ out.orientation = { x: 0, y: 0, z: 0, w: 1 };
258
+ } else {
259
+ const p = pose.position;
260
+ const q = pose.orientation;
261
+ out.position = { x: p.x, y: p.y, z: p.z };
262
+ out.orientation = { x: q.x, y: q.y, z: q.z, w: q.w };
263
+ }
264
+ return fillCameraRig(out, pose.fov * DEG2RAD, opts);
265
+ }
266
+
267
+ /** three's `MathUtils.DEG2RAD`, so a pose-built rig matches a camera-built one to the bit. */
268
+ const DEG2RAD = Math.PI / 180;
269
+
270
+ /** The fields every camera rig carries after its pose. One writer, so the two builders agree. */
271
+ function fillCameraRig(out, verticalFovRad, opts) {
272
+ const { convergence = 0, ipdFactor = 1, parallaxFactor = 1, metersToVirtual = 1 } = opts;
239
273
  out.ipdFactor = ipdFactor;
240
274
  out.parallaxFactor = parallaxFactor;
241
275
  // Diopters, not distance: the wire unit is 1/distance so that "infinity" is representable as
242
276
  // a finite 0 instead of a sentinel.
243
277
  out.convergenceDiopters = convergence > 0 ? 1 / convergence : 0;
244
- out.verticalFov = THREE.MathUtils.degToRad(camera.fov); // three's fov is the FULL angle, in degrees
278
+ out.verticalFov = verticalFovRad;
245
279
  out.metersToVirtual = metersToVirtual;
246
280
  return out;
247
281
  }
@@ -43,23 +43,27 @@
43
43
  // origin and scale it, instead of moving the display to the content. Identical framing, no
44
44
  // browser or runtime change.
45
45
 
46
- /**
47
- * Backstop on total subject depth, as a multiple of the display height. Generous on purpose:
48
- * depth placement is a z decision (see fitTo), not a scale one, so this only catches the
49
- * pathological case where a subject is so deep that no placement helps.
50
- */
51
- const DEFAULT_DEPTH_LIMIT = 4.0;
52
- /** Milliseconds of no interaction before the idle turntable starts. */
53
- const IDLE_DELAY_MS = 2500;
54
-
55
- /**
56
- * Per-frame easing factor for a focus change, matching the gallery's `EASE`.
57
- *
58
- * Deliberately per FRAME and not per second, because that is what the reference implementation
59
- * does and a focus change is a one-off gesture response rather than a continuous motion — the
60
- * difference between 60 and 120 Hz here is a settle that takes half as long, not a bug.
61
- */
62
- const FOCUS_EASE = 0.18;
46
+ // Every tuning constant — damping, idle delay, focus ease, wheel, zoom and pitch clamps, the
47
+ // mono camera lives in ./inline3d-splat-shared.js, where the PlayCanvas splat backend reads the
48
+ // same numbers. The reasoning behind each value is documented there.
49
+ import {
50
+ DEFAULT_DEPTH_LIMIT,
51
+ IDLE_DELAY_MS,
52
+ FOCUS_EASE,
53
+ DAMP_BASE,
54
+ MAX_DT_S,
55
+ PITCH_LIMIT,
56
+ DRAG_DEG_PER_TILE,
57
+ WHEEL_LINE_PX,
58
+ WHEEL_PAGE_PX,
59
+ WHEEL_MAX_PX,
60
+ ZOOM_PER_PX,
61
+ ZOOM_MIN,
62
+ ZOOM_MAX,
63
+ MONO_FOV,
64
+ MONO_NEAR,
65
+ MONO_FAR,
66
+ } from './inline3d-splat-shared.js';
63
67
 
64
68
  const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
65
69
  // NaN/Infinity into a transform silently blanks the tile — three propagates it into the
@@ -193,7 +197,7 @@ export class SceneViewer {
193
197
  idleSpin = 0,
194
198
  renderScale = 1,
195
199
  feather = 0,
196
- pitchLimit = [-60, 60],
200
+ pitchLimit = PITCH_LIMIT,
197
201
  } = opts;
198
202
 
199
203
  this._THREE = THREE;
@@ -286,7 +290,7 @@ export class SceneViewer {
286
290
 
287
291
  // Mono fallback camera. Deliberately a plain perspective camera: in 2D there is no display
288
292
  // plane to be in focus at, so we just look at the framed subject from the front.
289
- this.monoCamera = new THREE.PerspectiveCamera(35, 1, 0.001, 1000);
293
+ this.monoCamera = new THREE.PerspectiveCamera(MONO_FOV, 1, MONO_NEAR, MONO_FAR);
290
294
 
291
295
  // Coalesced: ResizeObserver and window resize both fire in BURSTS during a drag-resize or a
292
296
  // zoom, and every genuine resize reallocates (and clears) the backing store. One rAF per
@@ -891,7 +895,7 @@ export class SceneViewer {
891
895
  /** Damping + idle turntable. Called once per rendered frame, 3D or mono. */
892
896
  _tick() {
893
897
  const t = now();
894
- const dt = this._lastTick ? Math.min((t - this._lastTick) / 1000, 0.1) : 0;
898
+ const dt = this._lastTick ? Math.min((t - this._lastTick) / 1000, MAX_DT_S) : 0;
895
899
  this._lastTick = t;
896
900
 
897
901
  if (this.idleSpin && !this._reduceMotion && t - this._lastInput > IDLE_DELAY_MS) {
@@ -899,7 +903,7 @@ export class SceneViewer {
899
903
  }
900
904
  // Critically-damped-ish approach. Instant snapping reads as jitter on a head-tracked
901
905
  // display, where the viewer is already moving relative to the content.
902
- const k = dt > 0 ? 1 - Math.pow(0.001, dt) : 1;
906
+ const k = dt > 0 ? 1 - Math.pow(DAMP_BASE, dt) : 1;
903
907
  this._yaw += (this._targetYaw - this._yaw) * k;
904
908
  this._pitch += (this._targetPitch - this._pitch) * k;
905
909
  // Zoom eases on the same curve. Multiplicatively, because zoom is a ratio: approaching 2x
@@ -973,9 +977,9 @@ export class SceneViewer {
973
977
  // toward −y (down), so pitch must also ADD dy — subtracting it sends the face the wrong
974
978
  // way and reads as an inverted axis next to a correct one, which is worse than both being
975
979
  // inverted.
976
- this._targetYaw += ((ev.clientX - lastX) / Math.max(box.width, 1)) * 180;
980
+ this._targetYaw += ((ev.clientX - lastX) / Math.max(box.width, 1)) * DRAG_DEG_PER_TILE;
977
981
  this._targetPitch = clamp(
978
- this._targetPitch + ((ev.clientY - lastY) / Math.max(box.height, 1)) * 180,
982
+ this._targetPitch + ((ev.clientY - lastY) / Math.max(box.height, 1)) * DRAG_DEG_PER_TILE,
979
983
  this.pitchLimit[0],
980
984
  this.pitchLimit[1],
981
985
  );
@@ -1034,24 +1038,6 @@ export class SceneViewer {
1034
1038
  }
1035
1039
  }
1036
1040
 
1037
- /**
1038
- * Wheel-zoom tuning.
1039
- *
1040
- * ZOOM_PER_PX is set so one ordinary mouse notch (~100 px in Chrome) is about a 10% step, which
1041
- * puts a trackpad's 1-10 px events at a fraction of a percent each — small enough that the easing
1042
- * reads as continuous rather than as a stack of jumps.
1043
- */
1044
- // A deltaMode-1 "line" is sized to match a wheel DETENT, not a line of text. Firefox reports a
1045
- // notch as deltaY 3 in lines where Chrome reports it as ~100 in pixels, so 33 makes one physical
1046
- // notch feel the same in both; 16 (a text line) would make Firefox roughly half as responsive as
1047
- // Chrome for identical hardware.
1048
- const WHEEL_LINE_PX = 33;
1049
- const WHEEL_PAGE_PX = 400; // a "page" in deltaMode 2; rare, but it must not be unbounded
1050
- const WHEEL_MAX_PX = 120; // per-event ceiling, against OS pointer acceleration spikes
1051
- const ZOOM_PER_PX = 0.001;
1052
- const ZOOM_MIN = 0.2;
1053
- const ZOOM_MAX = 6;
1054
-
1055
1041
  function now() {
1056
1042
  return typeof performance !== 'undefined' ? performance.now() : Date.now();
1057
1043
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.7.1",
3
+ "version": "1.8.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",
@@ -43,6 +43,8 @@
43
43
  "js/inline3d-sog.js",
44
44
  "js/inline3d-splat-perf.js",
45
45
  "js/inline3d-splat-rig.js",
46
+ "js/inline3d-splat-playcanvas.js",
47
+ "js/inline3d-splat-shared.js",
46
48
  "js/inline3d-model.js",
47
49
  "index.d.ts",
48
50
  "three.d.ts",
@@ -74,7 +76,8 @@
74
76
  "license": "Apache-2.0",
75
77
  "peerDependencies": {
76
78
  "three": ">=0.150.0",
77
- "@sparkjsdev/spark": ">=2.0.0"
79
+ "@sparkjsdev/spark": ">=2.0.0",
80
+ "playcanvas": ">=2.22.3 <3"
78
81
  },
79
82
  "peerDependenciesMeta": {
80
83
  "three": {
@@ -82,6 +85,9 @@
82
85
  },
83
86
  "@sparkjsdev/spark": {
84
87
  "optional": true
88
+ },
89
+ "playcanvas": {
90
+ "optional": true
85
91
  }
86
92
  },
87
93
  "publishConfig": {