@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.
- package/CHANGELOG.md +61 -0
- package/README.md +13 -0
- package/js/inline3d-splat-perf.js +126 -0
- package/js/inline3d-splat-playcanvas.js +1908 -0
- package/js/inline3d-splat-rig.js +268 -3
- package/js/inline3d-splat-shared.js +269 -0
- package/js/inline3d-splat.js +174 -118
- package/js/inline3d-three.js +43 -9
- package/js/inline3d-viewer.js +27 -41
- package/package.json +8 -2
- package/splat.d.ts +137 -7
- package/three.d.ts +17 -0
package/js/inline3d-splat.js
CHANGED
|
@@ -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 {
|
|
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 = () =>
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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 {
|
|
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 = (
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
665
|
-
*
|
|
666
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
702
|
-
function
|
|
703
|
-
return
|
|
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
|
|
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();
|
package/js/inline3d-three.js
CHANGED
|
@@ -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 =
|
|
278
|
+
out.verticalFov = verticalFovRad;
|
|
245
279
|
out.metersToVirtual = metersToVirtual;
|
|
246
280
|
return out;
|
|
247
281
|
}
|
package/js/inline3d-viewer.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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(
|
|
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,
|
|
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(
|
|
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)) *
|
|
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)) *
|
|
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.
|
|
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": {
|