@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.
- package/CHANGELOG.md +86 -0
- package/README.md +22 -0
- package/js/inline3d-splat-perf.js +231 -0
- package/js/inline3d-splat-playcanvas.js +2101 -0
- package/js/inline3d-splat-rig.js +268 -3
- package/js/inline3d-splat-shared.js +318 -0
- package/js/inline3d-splat.js +182 -118
- package/js/inline3d-three.js +43 -9
- package/js/inline3d-viewer.js +27 -41
- package/package.json +8 -2
- package/splat.d.ts +191 -7
- package/three.d.ts +17 -0
package/js/inline3d-splat.js
CHANGED
|
@@ -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 {
|
|
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 = () =>
|
|
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
|
-
|
|
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
|
-
};
|
|
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
|
|
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
|
-
};
|
|
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
|
|
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 {
|
|
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 = (
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
702
|
-
function
|
|
703
|
-
return
|
|
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
|
|
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();
|
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.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": {
|