@displayxr/inline3d 1.7.1 → 1.9.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,27 @@ 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
+ isStreamedUrl,
36
+ STREAMED_NEEDS_PLAYCANVAS,
37
+ } from './inline3d-splat-shared.js';
38
+ import {
39
+ resolveRig,
40
+ planeDistance,
41
+ sampleCloudRestSpace,
42
+ rigNeedsCloud,
43
+ sampleCloudCentres,
44
+ RIG_SAMPLE_CAP,
45
+ RIG_MIN_OPACITY,
46
+ resolveSplatEngine,
47
+ } from './inline3d-splat-rig.js';
28
48
 
29
49
  export { applySplatPerf, SPLAT_PERF_PRESETS } from './inline3d-splat-perf.js';
30
50
  export { readSogCamera, readSogMeta } from './inline3d-sog.js';
@@ -40,9 +60,6 @@ export { resolveRig } from './inline3d-splat-rig.js';
40
60
  */
41
61
  const DEFAULT_SORT_INTERVAL_MS = 16;
42
62
 
43
- /** Cap on how many splat centres the fallback framing pass inspects. */
44
- const FRAME_SAMPLE_CAP = 200000;
45
-
46
63
  /**
47
64
  * three.js floor for THIS subpath — Spark's own floor, above the package-wide >=0.150 that the
48
65
  * core and ./three ask for.
@@ -140,6 +157,27 @@ function sniffFileType(bytes) {
140
157
  * good enough for a clean, isolated capture, weaker on a scene with a background wall.
141
158
  */
142
159
  export function addSplat(wall, canvas, src, opts = {}) {
160
+ if (opts.captureFit !== undefined && !CAPTURE_FITS.includes(opts.captureFit)) {
161
+ throw new Error(
162
+ `@displayxr/inline3d/splat: captureFit "${opts.captureFit}" — expected ` +
163
+ `${CAPTURE_FITS.map((f) => `'${f}'`).join(' or ')}.`,
164
+ );
165
+ }
166
+ // WHICH ENGINE. Unset (or 'spark') is everything below, untouched. 'playcanvas' goes to
167
+ // ./inline3d-splat-playcanvas.js, imported DYNAMICALLY so a page that never asks for it never
168
+ // resolves `playcanvas` — see addSplatDeferred.
169
+ if (resolveSplatEngine(opts) === 'playcanvas') {
170
+ // A format that engine provably cannot read (a .spz URL, gzip bytes, a Spark-only fileType)
171
+ // is a page bug: say so NOW rather than fail inside a loader later.
172
+ const why = playcanvasCannotRead(src, opts);
173
+ if (why) throw new Error(`@displayxr/inline3d/splat: ${why}`);
174
+ return addSplatDeferred(wall, canvas, src, opts);
175
+ }
176
+
177
+ // A Streamed SOG on Spark is a page bug (Spark has no lod-meta.json reader): say so now, by
178
+ // name, rather than let Spark fail on an "unknown file type" that reads like a corrupt asset.
179
+ if (isStreamedUrl(src)) throw new Error(`@displayxr/inline3d/splat: ${STREAMED_NEEDS_PLAYCANVAS}`);
180
+
143
181
  // Fail here, synchronously, and not through `ready`: a peer too old is an install-time mistake
144
182
  // in the page's dependencies, not a condition of this asset, and it will be true of every call.
145
183
  // Surfacing it as a load rejection would let a caller render an "asset unavailable" placeholder
@@ -168,6 +206,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
168
206
  sortIntervalMs = DEFAULT_SORT_INTERVAL_MS,
169
207
  perf = null,
170
208
  rig = 'auto',
209
+ captureFit = 'height',
171
210
  focusInput = true,
172
211
  convergence,
173
212
  fileName,
@@ -211,6 +250,19 @@ export function addSplat(wall, canvas, src, opts = {}) {
211
250
  // the console and a fit pipeline that provably never executed.
212
251
  let handle = null;
213
252
  const out = {
253
+ backend: 'spark',
254
+ /**
255
+ * ADVANCED, not covered by the semver promise: the three.js objects behind this window, for
256
+ * a page that wants to add its own content. `camera` is whichever camera draws the current
257
+ * frame (an eye in 3D, the mono camera flat).
258
+ */
259
+ engine: Object.freeze({
260
+ renderer: viewer.renderer,
261
+ scene: viewer.scene,
262
+ get camera() {
263
+ return (viewer.is3D && viewer._eye?.camera) || viewer.monoCamera;
264
+ },
265
+ }),
214
266
  viewer,
215
267
  // null until the bytes are read and the mesh is constructed; use `ready` to await it.
216
268
  mesh: null,
@@ -275,6 +327,21 @@ export function addSplat(wall, canvas, src, opts = {}) {
275
327
  },
276
328
  exclude: (el) => handle?.exclude(el),
277
329
  unexclude: (el) => handle?.unexclude(el),
330
+ /** Where the window is pointed, in the SPLAT's own space; null before load. */
331
+ getFocus(o) {
332
+ if (!out.mesh) return null;
333
+ const f = viewer.getFocus(o);
334
+ return toModelSpace(out.mesh, viewer.content.localToWorld(new THREE.Vector3(f.x, f.y, f.z)), THREE);
335
+ },
336
+ /** Called with the live focus, in the splat's own space, whenever it moves. */
337
+ onFocusChange: null,
338
+ /** Not on this backend: the crossfading asset swap is a PlayCanvas-backend feature. */
339
+ setSource() {
340
+ throw new Error(
341
+ "@displayxr/inline3d/splat: setSource() is implemented on the PlayCanvas backend " +
342
+ "only; with the Spark backend, remove() this handle and addSplat() the new asset.",
343
+ );
344
+ },
278
345
  };
279
346
 
280
347
  // `src` may be a URL or the bytes themselves.
@@ -362,7 +429,11 @@ export function addSplat(wall, canvas, src, opts = {}) {
362
429
  });
363
430
  handle?.setViewRig(out.viewRig);
364
431
  }
365
- viewer.onFocusChange = () => pushViewRig(false);
432
+ viewer.onFocusChange = () => {
433
+ pushViewRig(false);
434
+ const cb = out.onFocusChange;
435
+ if (typeof cb === 'function' && out.mesh) cb(out.getFocus(), { focusSource: out.rig?.focusSource ?? null });
436
+ };
366
437
 
367
438
  /**
368
439
  * What is under a point on the canvas.
@@ -382,15 +453,9 @@ export function addSplat(wall, canvas, src, opts = {}) {
382
453
  function pickPoint(clientX, clientY) {
383
454
  const mesh = out.mesh;
384
455
  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
- };
456
+ // NDC from the CSS box (./inline3d-splat-shared.js says why the box and not the store).
457
+ const ndc = canvasNdc(canvas, clientX, clientY);
458
+ if (!ndc) return null;
394
459
  const cam = (viewer.is3D && viewer._eye?.camera) || viewer.monoCamera;
395
460
  if (!raycaster) raycaster = new THREE.Raycaster();
396
461
  raycaster.setFromCamera(ndc, cam);
@@ -412,46 +477,27 @@ export function addSplat(wall, canvas, src, opts = {}) {
412
477
  // ── input ─────────────────────────────────────────────────────────────────────────────
413
478
  let unbindFocusInput = null;
414
479
  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
- };
480
+ if (focusInput === false || unbindFocusInput) return;
481
+ const off = bindFocusGestures(canvas, {
482
+ onDoubleClick: (e) => {
483
+ const world = pickPoint(e.clientX, e.clientY);
484
+ if (!world) return false;
485
+ out.mesh.updateWorldMatrix(true, false);
486
+ out.rig.focus = toArray3(out.mesh.worldToLocal(world.clone()));
487
+ out.rig.focusSource = 'picked';
488
+ out.rig.convergence = planeDistance(out.rig.rest, out.rig.focus);
489
+ viewer.content.updateWorldMatrix(true, false);
490
+ viewer.setFocus(toArray3(viewer.content.worldToLocal(world.clone())));
491
+ return true;
492
+ },
493
+ onReset: () => out.setFocus(null),
494
+ });
495
+ unbindFocusInput =
496
+ off &&
497
+ (() => {
498
+ off();
499
+ unbindFocusInput = null;
500
+ });
455
501
  }
456
502
 
457
503
  // Await the MESH first, then its load. Reading `mesh.initialized` here directly would
@@ -489,7 +535,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
489
535
  // is the lens, and the median of 1/z is the focus. Skipped entirely when the block already
490
536
  // answers both, so an asset that carries a full camera pays nothing for it.
491
537
  const cloud =
492
- out.camera?.intrinsics && out.camera?.focus
538
+ !rigNeedsCloud(out.camera)
493
539
  ? null
494
540
  : sampleRestSpace(out.mesh, out.camera?.rest);
495
541
  const box = canvas.getBoundingClientRect();
@@ -510,7 +556,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
510
556
  // A turntable on a photograph is nonsense, so the default spin stops here — but only the
511
557
  // DEFAULT: a page that asked for one still gets it.
512
558
  if (!('idleSpin' in opts)) viewer.idleSpin = 0;
513
- applyCaptureCamera(viewer, resolved, flipY);
559
+ applyCaptureCamera(viewer, resolved, flipY, THREE, captureFit, () => pushViewRig(true));
514
560
  // The capture does not move; only what the rotation turns about does. Snapped, because
515
561
  // this is the asset arriving, not a gesture.
516
562
  viewer.setFocus(toContentSpace(out.mesh, resolved.focus, THREE), {
@@ -555,6 +601,60 @@ export function addSplat(wall, canvas, src, opts = {}) {
555
601
  return out;
556
602
  }
557
603
 
604
+ /**
605
+ * The `engine: 'playcanvas'` handle, returned SYNCHRONOUSLY like the Spark one.
606
+ *
607
+ * The adapter module is loaded on demand, so for a moment the handle exists before its
608
+ * implementation does. Rather than make every caller await something new, the handle starts as
609
+ * stubs that QUEUE: `exclude()` (which a product page calls on the very next line), `setPose`,
610
+ * `setFocus`, `remove` — and the adapter replays the queue into the real implementation, on the
611
+ * SAME object, the moment it arrives. `ready` resolves to this object, as it does on Spark.
612
+ * Fields (`viewer`, `mesh`, `rig`, …) are null until then; `viewer` in particular appears one
613
+ * module-load later than on Spark.
614
+ */
615
+ function addSplatDeferred(wall, canvas, src, opts) {
616
+ const pending = [];
617
+ const queue = (name) => (...args) => {
618
+ pending.push([name, args]);
619
+ return name === 'setFocus' ? out : undefined;
620
+ };
621
+ const out = {
622
+ backend: 'playcanvas',
623
+ engine: null,
624
+ viewer: null,
625
+ mesh: null,
626
+ frame: null,
627
+ camera: null,
628
+ rig: null,
629
+ perf: null,
630
+ setPose: queue('setPose'),
631
+ resetPose: queue('resetPose'),
632
+ setFocus: queue('setFocus'),
633
+ // A swap requested before the first asset has landed runs once it has (the adapter's own
634
+ // setSource replaces this stub on the same object by then).
635
+ setSource: (...args) => out.ready.then(() => out.setSource(...args)),
636
+ getFocus: () => null,
637
+ // A plain data slot the adapter reads at call time, so a callback assigned on the very next
638
+ // line after addSplat — before the module has loaded — is the one that fires.
639
+ onFocusChange: null,
640
+ pick: () => null,
641
+ // Splat accounting (resident / budget / first frame); null until the adapter has loaded.
642
+ stats: () => null,
643
+ remove: queue('remove'),
644
+ exclude: queue('exclude'),
645
+ unexclude: queue('unexclude'),
646
+ };
647
+ // The ONE owner of `ready`: the adapter returns its load promise and never touches this field.
648
+ out.ready = import('./inline3d-splat-playcanvas.js')
649
+ .then((m) => m.attachPlayCanvasSplat(out, wall, canvas, src, opts, pending))
650
+ .catch((err) => {
651
+ // The adapter warns about its own load failures; this is for the module not arriving.
652
+ if (!out.viewer) console.warn('[inline3d/splat] engine:playcanvas failed to start', err);
653
+ throw err;
654
+ });
655
+ return out;
656
+ }
657
+
558
658
  /**
559
659
  * Percentile bounds from the loaded splats — the fallback when no sidecar was supplied.
560
660
  *
@@ -594,10 +694,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
594
694
  * resize, and three's symmetric version would silently throw the off-axis window away on the
595
695
  * first layout nudge.
596
696
  */
597
- function applyCaptureCamera(viewer, rig, flipY, THREE_) {
697
+ function applyCaptureCamera(viewer, rig, flipY, THREE_, captureFit = 'height', onFov = null) {
598
698
  const three = THREE_ || THREE;
599
699
  const camera = viewer.monoCamera;
600
- const { fx, fy, cx, cy, width, height } = rig.intrinsics;
700
+ const { width, height } = rig.intrinsics;
601
701
 
602
702
  const q = new three.Quaternion(
603
703
  rig.rest.rotation[0],
@@ -626,7 +726,7 @@ function applyCaptureCamera(viewer, rig, flipY, THREE_) {
626
726
  q.multiply(flip);
627
727
  camera.position.copy(p);
628
728
  camera.quaternion.copy(q);
629
- camera.fov = (2 * Math.atan(height / (2 * fy)) * 180) / Math.PI;
729
+ camera.fov = captureVerticalFovDeg(rig.intrinsics, NaN, camera.near, 'height');
630
730
  // FAR, and why it is not the viewer's default. A deconverged capture parks its sky at the
631
731
  // lifter's depth cap and the refinement scatters some gaussians beyond it (239 m measured on a
632
732
  // street scene); anything past the far plane is CLIPPED in Spark's vertex shader and pops out
@@ -637,70 +737,45 @@ function applyCaptureCamera(viewer, rig, flipY, THREE_) {
637
737
 
638
738
  camera.updateProjectionMatrix = () => {
639
739
  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
740
  const box = viewer.canvas.getBoundingClientRect();
644
741
  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);
742
+ // The window is shared with the PlayCanvas backend (./inline3d-splat-shared.js), `captureFit`
743
+ // included; on 'height' it is the same arithmetic this function always did.
744
+ const w = captureWindow(rig.intrinsics, aspect, near, captureFit);
745
+ camera.projectionMatrix.makePerspective(w.left, w.right, w.top, w.bottom, near, camera.far);
648
746
  camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
747
+ if (captureFit !== 'height') {
748
+ const fov = captureVerticalFovDeg(rig.intrinsics, aspect, near, captureFit);
749
+ if (fov !== camera.fov) {
750
+ camera.fov = fov;
751
+ onFov?.();
752
+ }
753
+ }
649
754
  };
650
755
  camera.updateProjectionMatrix();
651
756
  }
652
757
 
653
758
 
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
759
  /**
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.
760
+ * ONE walk over the cloud, in the REST CAMERA's frame see `sampleCloudRestSpace` in
761
+ * ./inline3d-splat-rig.js, which holds the sampling rules for every backend. This is only the
762
+ * Spark adapter onto it: `forEachSplat` reports centres before the mesh's own transform, so
763
+ * this is the file's own OpenCV frame, which is the frame `rest` and `intrinsics` are in.
671
764
  *
672
765
  * @returns {{tx:Float64Array,ty:Float64Array,invz:Float64Array,n:number}|null}
673
766
  */
674
767
  function sampleRestSpace(mesh, rest) {
675
768
  const total = mesh?.numSplats || 0;
676
769
  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;
770
+ return sampleCloudRestSpace(total, sparkCentres(mesh), rest);
699
771
  }
700
772
 
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];
773
+ /** Spark's `forEachSplat` as the backend-neutral centre visitor ./inline3d-splat-rig.js walks. */
774
+ function sparkCentres(mesh) {
775
+ return (visit) =>
776
+ mesh.forEachSplat((index, center, scales, quaternion, opacity) =>
777
+ visit(index, center.x, center.y, center.z, opacity),
778
+ );
704
779
  }
705
780
 
706
781
  /** A point in the splat's own (model) space, in the viewer's CONTENT space. */
@@ -784,18 +859,7 @@ export function measureSplatBounds(mesh, three = THREE) {
784
859
  function measureBounds(mesh, THREE) {
785
860
  const total = mesh.numSplats || 0;
786
861
  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));
862
+ const local = boundsFromPositions(sampleCloudCentres(total, sparkCentres(mesh)));
799
863
  if (!local) return null;
800
864
 
801
865
  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.9.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": {