@displayxr/inline3d 1.6.1 → 1.7.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 +98 -0
- package/README.md +1 -0
- package/js/inline3d-sog.js +295 -0
- package/js/inline3d-splat-perf.js +255 -0
- package/js/inline3d-splat-rig.js +330 -0
- package/js/inline3d-splat.js +481 -8
- package/js/inline3d-viewer.js +125 -2
- package/package.json +4 -1
- package/splat.d.ts +164 -0
- package/viewer.d.ts +16 -0
package/js/inline3d-splat.js
CHANGED
|
@@ -20,8 +20,15 @@
|
|
|
20
20
|
|
|
21
21
|
import * as THREE from 'three';
|
|
22
22
|
import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
|
|
23
|
-
import { EyeCamera, EdgeFeather } from './inline3d-three.js';
|
|
23
|
+
import { EyeCamera, EdgeFeather, cameraRigFromCamera } from './inline3d-three.js';
|
|
24
24
|
import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
|
|
25
|
+
import { readSogCamera } from './inline3d-sog.js';
|
|
26
|
+
import { applySplatPerf, splatPerfMeshOptions } from './inline3d-splat-perf.js';
|
|
27
|
+
import { resolveRig, toRestSpace, planeDistance } from './inline3d-splat-rig.js';
|
|
28
|
+
|
|
29
|
+
export { applySplatPerf, SPLAT_PERF_PRESETS } from './inline3d-splat-perf.js';
|
|
30
|
+
export { readSogCamera, readSogMeta } from './inline3d-sog.js';
|
|
31
|
+
export { resolveRig } from './inline3d-splat-rig.js';
|
|
25
32
|
|
|
26
33
|
/**
|
|
27
34
|
* Sort at most this often, in ms. THE stereo optimisation in this module.
|
|
@@ -96,6 +103,29 @@ function sniffFileType(bytes) {
|
|
|
96
103
|
* @param {number} [opts.renderScale=1] per-eye buffer scale; 0.5–0.7 is usually free.
|
|
97
104
|
* @param {number} [opts.feather=0] edge fade in buffer px.
|
|
98
105
|
* @param {number} [opts.sortIntervalMs=16] see DEFAULT_SORT_INTERVAL_MS.
|
|
106
|
+
* @param {true|'exact'|'balanced'|'aggressive'|object} [opts.perf] cut overdraw. Unset (the
|
|
107
|
+
* default) changes nothing: every Spark default stays where Spark put it. `'exact'` is the
|
|
108
|
+
* bit-exact pair (each quad shrunk to its own 1/255 alpha radius, plus the 1/255 opacity
|
|
109
|
+
* cull) and buys little on a mostly-opaque capture; `'balanced'` (also `true`) and
|
|
110
|
+
* `'aggressive'` tighten the quad extent, which is the axis that measured. Every knob,
|
|
111
|
+
* what it costs in pixels, and the measurements: ./inline3d-splat-perf.js.
|
|
112
|
+
* @param {'auto'|'display'|'camera'} [opts.rig='auto'] which view rig. `auto` asks the ASSET:
|
|
113
|
+
* the `camera` block's own `rig`, or — for a block that does not say — a camera rig,
|
|
114
|
+
* since a block at all means a camera was recorded. No block is a display rig with the
|
|
115
|
+
* auto-frame, which is what every existing page already has. Only read from BYTES. On the
|
|
116
|
+
* camera path the subject is NOT reframed and the idle turntable is off unless you asked
|
|
117
|
+
* for one. Full waterfall: ./inline3d-splat-rig.js.
|
|
118
|
+
* @param {number[]} [opts.focus] the point to converge on and orbit about, in the splat's own
|
|
119
|
+
* space. Top of the focus waterfall; below it the block's `focus.point`, then the median
|
|
120
|
+
* disparity of the cloud, then 2 m.
|
|
121
|
+
* @param {number} [opts.convergence] the straight-ahead shorthand for `focus`: a distance in
|
|
122
|
+
* world metres along the capture's view axis.
|
|
123
|
+
* @param {object} [opts.intrinsics] override the lens ({fx,fy,cx,cy,width,height}, one eye,
|
|
124
|
+
* OpenCV). Only consulted when the asset carries none.
|
|
125
|
+
* @param {number} [opts.ipdFactor=1] camera rig eye separation, ABSOLUTE.
|
|
126
|
+
* @param {number} [opts.parallaxFactor=1] camera rig head-tracking response, ABSOLUTE.
|
|
127
|
+
* @param {boolean} [opts.focusInput=true] bind double-click (focus what was clicked) and Space
|
|
128
|
+
* (back to the resolved focus).
|
|
99
129
|
* @param {Element} [opts.observe=canvas] element whose visibility gates the lazy lifecycle.
|
|
100
130
|
* @returns {object} a TileHandle (remove/exclude/unexclude) plus `viewer`, `mesh`, `setPose`,
|
|
101
131
|
* `resetPose`, `frame` (the bounds used, null until loaded) and `ready` (a promise).
|
|
@@ -136,6 +166,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
136
166
|
renderScale = 1,
|
|
137
167
|
feather = 0,
|
|
138
168
|
sortIntervalMs = DEFAULT_SORT_INTERVAL_MS,
|
|
169
|
+
perf = null,
|
|
170
|
+
rig = 'auto',
|
|
171
|
+
focusInput = true,
|
|
172
|
+
convergence,
|
|
139
173
|
fileName,
|
|
140
174
|
fileType,
|
|
141
175
|
observe,
|
|
@@ -158,6 +192,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
158
192
|
// GLB accessory in one scene.
|
|
159
193
|
const spark = new SparkRenderer({ renderer: viewer.renderer, minSortIntervalMs: sortIntervalMs });
|
|
160
194
|
viewer.scene.add(spark);
|
|
195
|
+
// Nothing happens unless the page asked: with no `perf` every Spark default stays where Spark
|
|
196
|
+
// put it, so an existing page's pixels do not move. See ./inline3d-splat-perf.js for the table
|
|
197
|
+
// of what each knob costs and whether it is bit-exact.
|
|
198
|
+
const perfApplied = perf ? applySplatPerf(spark, perf) : null;
|
|
161
199
|
|
|
162
200
|
// THE HANDLE IS DECLARED BEFORE THE LOADER, and that is load-bearing — not style.
|
|
163
201
|
//
|
|
@@ -178,9 +216,59 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
178
216
|
mesh: null,
|
|
179
217
|
spark,
|
|
180
218
|
frame: null,
|
|
219
|
+
/**
|
|
220
|
+
* The `.sog`'s `camera` block, once the bytes have been read — null for a URL source, a
|
|
221
|
+
* non-`.sog`, or an asset that carries no block (which is most of them, and means "this is
|
|
222
|
+
* an object, use the display rig"). See ./inline3d-sog.js.
|
|
223
|
+
*/
|
|
224
|
+
camera: null,
|
|
225
|
+
/**
|
|
226
|
+
* What the WATERFALL resolved — the rig, the lens and the focus, each beside the step that
|
|
227
|
+
* produced it (`rig.focusSource`, `rig.intrinsicsSource`, `rig.typeSource`). Null until
|
|
228
|
+
* `ready`. See ./inline3d-splat-rig.js.
|
|
229
|
+
*/
|
|
230
|
+
rig: null,
|
|
231
|
+
/** What `perf` actually applied, or null. Useful for a diagnostics readout. */
|
|
232
|
+
perf: perfApplied,
|
|
181
233
|
setPose: (p) => viewer.setPose(p),
|
|
182
234
|
resetPose: () => viewer.resetPose(),
|
|
235
|
+
/**
|
|
236
|
+
* Point the window at something — the orbit centre, the pivot plane and (on a camera rig)
|
|
237
|
+
* the convergence, which are one thing.
|
|
238
|
+
*
|
|
239
|
+
* @param {number[]|{x:number,y:number,z:number}|null} point in the SPLAT's own space: the
|
|
240
|
+
* same space the `camera` block's `focus.point` is in, so a host page can hand over a
|
|
241
|
+
* point it read from the asset's metadata without knowing anything about this SDK's
|
|
242
|
+
* scene graph. Null returns to whatever the waterfall resolved.
|
|
243
|
+
* @param {object} [o]
|
|
244
|
+
* @param {boolean} [o.snap=false] arrive immediately instead of easing.
|
|
245
|
+
*/
|
|
246
|
+
setFocus(point, o = {}) {
|
|
247
|
+
if (!out.mesh || !out.rig) return out;
|
|
248
|
+
const model = point == null ? out.rig.focusDefault : toArray3(point);
|
|
249
|
+
out.rig.focus = model;
|
|
250
|
+
out.rig.focusSource = point == null ? out.rig.focusDefaultSource : 'set';
|
|
251
|
+
// Keep the two in step on BOTH rigs. A camera rig re-derives this from the eased focus
|
|
252
|
+
// every frame (pushViewRig), but a display rig has no rig to push — and a `convergence`
|
|
253
|
+
// left describing a focus that has since moved is exactly the kind of quietly stale
|
|
254
|
+
// readback this handle exists to avoid.
|
|
255
|
+
out.rig.convergence = planeDistance(out.rig.rest, model);
|
|
256
|
+
viewer.setFocus(toContentSpace(out.mesh, model, THREE), o);
|
|
257
|
+
return out;
|
|
258
|
+
},
|
|
259
|
+
/**
|
|
260
|
+
* What is under a point on the canvas, in the splat's own space — the raycast behind the
|
|
261
|
+
* double-click, exposed so a page can build its own gesture.
|
|
262
|
+
*
|
|
263
|
+
* @returns {number[]|null}
|
|
264
|
+
*/
|
|
265
|
+
pick(clientX, clientY) {
|
|
266
|
+
const p = pickPoint(clientX, clientY);
|
|
267
|
+
return p ? toModelSpace(out.mesh, p, THREE) : null;
|
|
268
|
+
},
|
|
183
269
|
remove() {
|
|
270
|
+
unbindFocusInput?.();
|
|
271
|
+
viewer.onFocusChange = null;
|
|
184
272
|
handle?.remove();
|
|
185
273
|
viewer.dispose();
|
|
186
274
|
out.mesh?.dispose?.();
|
|
@@ -211,8 +299,13 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
211
299
|
...(sniffed ? { fileType: sniffed } : {}),
|
|
212
300
|
...(fileName ? { fileName } : {}),
|
|
213
301
|
};
|
|
302
|
+
// Read the camera block off the SAME bytes, before Spark takes them. Only possible on the
|
|
303
|
+
// bytes path — a URL source would need a second fetch of ten megabytes to learn 200 of
|
|
304
|
+
// them, so that is deliberately not done. (It is also not a limitation in practice: the
|
|
305
|
+
// asset that HAS a camera block is a generated/streamed one, which is the bytes path.)
|
|
306
|
+
if (rig !== 'display') out.camera = await readSogCamera(fileBytes);
|
|
214
307
|
}
|
|
215
|
-
mesh = new SplatMesh(init);
|
|
308
|
+
mesh = new SplatMesh({ ...init, ...splatPerfMeshOptions(perf) });
|
|
216
309
|
// Most exporters write splats Y-down (the original 3DGS convention); three.js is Y-up.
|
|
217
310
|
// Without this every capture arrives upside down, which reads as a broken asset rather than
|
|
218
311
|
// a convention mismatch. w=0,x=1 is a half turn about X.
|
|
@@ -237,6 +330,130 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
237
330
|
viewer.startMono();
|
|
238
331
|
}
|
|
239
332
|
|
|
333
|
+
|
|
334
|
+
// ── focus: declaring it, and the two gestures that change it ──────────────────────────
|
|
335
|
+
//
|
|
336
|
+
// The rig descriptor is re-DECLARED whenever the focus moves, which is every frame while it
|
|
337
|
+
// eases. That is the cheap half of the contract — `setViewRig` is a per-locate value, there is
|
|
338
|
+
// nothing to tween and nothing to tear down — and it is the only thing the runtime needs to
|
|
339
|
+
// re-converge. Nothing here computes an off-axis projection.
|
|
340
|
+
let lastConvergence = Number.NaN;
|
|
341
|
+
const rigScratch = { fwd: null, tmp: null };
|
|
342
|
+
function pushViewRig(force) {
|
|
343
|
+
if (!out.rig || out.rig.type !== 'camera') return;
|
|
344
|
+
const cam = viewer.monoCamera;
|
|
345
|
+
if (!rigScratch.fwd) {
|
|
346
|
+
rigScratch.fwd = new THREE.Vector3();
|
|
347
|
+
rigScratch.tmp = new THREE.Vector3();
|
|
348
|
+
}
|
|
349
|
+
const f = viewer.getFocus();
|
|
350
|
+
// three looks down -z; the convergence is the focus's distance along that axis — the PLANE,
|
|
351
|
+
// not the radius, because that is what a zero-disparity plane is.
|
|
352
|
+
rigScratch.fwd.set(0, 0, -1).applyQuaternion(cam.quaternion);
|
|
353
|
+
const d = rigScratch.tmp.set(f.x, f.y, f.z).sub(cam.position).dot(rigScratch.fwd);
|
|
354
|
+
if (!force && Math.abs(d - lastConvergence) < 1e-3) return;
|
|
355
|
+
lastConvergence = d;
|
|
356
|
+
out.rig.convergence = d;
|
|
357
|
+
out.viewRig = cameraRigFromCamera(THREE, cam, {
|
|
358
|
+
convergence: d > 0 ? d : 0,
|
|
359
|
+
ipdFactor: out.rig.ipdFactor,
|
|
360
|
+
parallaxFactor: out.rig.parallaxFactor,
|
|
361
|
+
out: out.viewRig || {},
|
|
362
|
+
});
|
|
363
|
+
handle?.setViewRig(out.viewRig);
|
|
364
|
+
}
|
|
365
|
+
viewer.onFocusChange = () => pushViewRig(false);
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* What is under a point on the canvas.
|
|
369
|
+
*
|
|
370
|
+
* Spark's `SplatMesh.raycast` is the real answer and is used when it produces one — it is the
|
|
371
|
+
* ordinary three.js hook, so it is `raycastable` (default true) and `minRaycastOpacity`
|
|
372
|
+
* (default 0.2) that decide what counts as solid. When it returns nothing (a thin or very
|
|
373
|
+
* transparent region, an older Spark, a mesh with raycasting turned off) this falls back to
|
|
374
|
+
* the NEAREST GAUSSIAN TO THE RAY by angular distance, preferring the closest one inside a
|
|
375
|
+
* small cone. That is an approximation and is documented as one: it picks a splat CENTRE
|
|
376
|
+
* rather than a surface, so on a thick soft surface it lands a little behind where the cursor
|
|
377
|
+
* appears to be. For a focus point — a plane to converge on and turn about — that is well
|
|
378
|
+
* within the tolerance; do not build a measuring tool on it.
|
|
379
|
+
*/
|
|
380
|
+
const PICK_CONE_RAD = 0.02;
|
|
381
|
+
let raycaster = null;
|
|
382
|
+
function pickPoint(clientX, clientY) {
|
|
383
|
+
const mesh = out.mesh;
|
|
384
|
+
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
|
+
};
|
|
394
|
+
const cam = (viewer.is3D && viewer._eye?.camera) || viewer.monoCamera;
|
|
395
|
+
if (!raycaster) raycaster = new THREE.Raycaster();
|
|
396
|
+
raycaster.setFromCamera(ndc, cam);
|
|
397
|
+
if (mesh.raycastable !== false && typeof mesh.raycast === 'function') {
|
|
398
|
+
const hits = [];
|
|
399
|
+
try {
|
|
400
|
+
mesh.raycast(raycaster, hits);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
console.warn('[inline3d/splat] Spark raycast threw; falling back to nearest gaussian', err);
|
|
403
|
+
}
|
|
404
|
+
if (hits.length) {
|
|
405
|
+
hits.sort((a, b) => a.distance - b.distance);
|
|
406
|
+
if (hits[0].point) return hits[0].point.clone();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return nearestGaussianToRay(mesh, raycaster.ray, THREE);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── input ─────────────────────────────────────────────────────────────────────────────
|
|
413
|
+
let unbindFocusInput = null;
|
|
414
|
+
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
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
240
457
|
// Await the MESH first, then its load. Reading `mesh.initialized` here directly would
|
|
241
458
|
// dereference null: constructing from bytes is async (the Blob has to be read), so `mesh` does
|
|
242
459
|
// not exist yet on this line — only inside meshReady.
|
|
@@ -253,14 +470,74 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
253
470
|
// positions the renderer draws. It costs one pass over (a sample of) the centres at load,
|
|
254
471
|
// which is what the working reference sample has always done.
|
|
255
472
|
const bounds = measureBounds(out.mesh, THREE) || (frame ? liftBounds(frame, out.mesh, THREE) : null);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
473
|
+
|
|
474
|
+
// WHICH RIG. `rig:'auto'` (the default) reads it off the ASSET: a `camera` block means the
|
|
475
|
+
// splat was lifted from a photograph, and a photograph has a viewpoint to conserve — the
|
|
476
|
+
// capture's own FOV, at the capture's own position, in a metric scene. No block means an
|
|
477
|
+
// object, which is the display rig, the auto-frame, and everything this module did before.
|
|
478
|
+
//
|
|
479
|
+
// That is the subject-vs-viewpoint test from docs/authoring-inline-3d.md §"Which rig",
|
|
480
|
+
// answered by the file instead of by the page. It is the one case where a splat viewer
|
|
481
|
+
// cannot decide for itself: the same call site loads a product turntable and a lifted
|
|
482
|
+
// photograph, and they want opposite rigs.
|
|
483
|
+
// THE WATERFALL. Three questions — which rig, what lens, what is it looking at — each
|
|
484
|
+
// answered by the best source that has an answer, with the step that answered it recorded
|
|
485
|
+
// next to the value (`handle.rig.focusSource` and friends). js/inline3d-splat-rig.js holds
|
|
486
|
+
// the arithmetic and the reasoning; this is the plumbing.
|
|
487
|
+
//
|
|
488
|
+
// One pass over the cloud feeds two of the three: the angular extent about the rest camera
|
|
489
|
+
// is the lens, and the median of 1/z is the focus. Skipped entirely when the block already
|
|
490
|
+
// answers both, so an asset that carries a full camera pays nothing for it.
|
|
491
|
+
const cloud =
|
|
492
|
+
out.camera?.intrinsics && out.camera?.focus
|
|
493
|
+
? null
|
|
494
|
+
: sampleRestSpace(out.mesh, out.camera?.rest);
|
|
495
|
+
const box = canvas.getBoundingClientRect();
|
|
496
|
+
const resolved = resolveRig({
|
|
497
|
+
camera: out.camera,
|
|
498
|
+
opts,
|
|
499
|
+
cloud,
|
|
500
|
+
canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
|
|
501
|
+
});
|
|
502
|
+
// What Space goes back to. Kept beside the live value so a pick can be undone without
|
|
503
|
+
// re-running the waterfall (which would walk the cloud again).
|
|
504
|
+
resolved.focusDefault = resolved.focus.slice();
|
|
505
|
+
resolved.focusDefaultSource = resolved.focusSource;
|
|
506
|
+
out.rig = resolved;
|
|
507
|
+
out.frame = bounds;
|
|
508
|
+
|
|
509
|
+
if (resolved.type === 'camera') {
|
|
510
|
+
// A turntable on a photograph is nonsense, so the default spin stops here — but only the
|
|
511
|
+
// DEFAULT: a page that asked for one still gets it.
|
|
512
|
+
if (!('idleSpin' in opts)) viewer.idleSpin = 0;
|
|
513
|
+
applyCaptureCamera(viewer, resolved, flipY);
|
|
514
|
+
// The capture does not move; only what the rotation turns about does. Snapped, because
|
|
515
|
+
// this is the asset arriving, not a gesture.
|
|
516
|
+
viewer.setFocus(toContentSpace(out.mesh, resolved.focus, THREE), {
|
|
517
|
+
snap: true,
|
|
518
|
+
recentre: false,
|
|
519
|
+
});
|
|
520
|
+
pushViewRig(true);
|
|
259
521
|
} else {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
522
|
+
if (bounds) viewer.fitTo(bounds.center, bounds.extent);
|
|
523
|
+
else {
|
|
524
|
+
// Unframed means drawn at raw MODEL scale, which for a typical capture is several
|
|
525
|
+
// times the tile. Say so: silence here is what made the same condition read as a fit
|
|
526
|
+
// bug.
|
|
527
|
+
console.warn('[inline3d/splat] no usable bounds — subject is UNFRAMED (model scale)', src);
|
|
528
|
+
}
|
|
529
|
+
// A display rig takes the focus as its ORBIT CENTRE, which is the one thing the two rigs
|
|
530
|
+
// share. Only when something actually said where to look: a bare `median-disparity`
|
|
531
|
+
// guess has no business overriding an auto-frame that measured the subject.
|
|
532
|
+
if (resolved.focusSource === 'caller' || resolved.focusSource === 'block') {
|
|
533
|
+
viewer.setFocus(toContentSpace(out.mesh, resolved.focus, THREE), {
|
|
534
|
+
snap: true,
|
|
535
|
+
recentre: true,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
263
538
|
}
|
|
539
|
+
bindFocusInput();
|
|
540
|
+
return out;
|
|
264
541
|
return out;
|
|
265
542
|
})
|
|
266
543
|
.catch((err) => {
|
|
@@ -293,6 +570,202 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
293
570
|
* columns rather than being re-projected onto world axes: same convention the native
|
|
294
571
|
* ComputeAutoFrame uses, and exact for the axis-aligned flips that actually occur.
|
|
295
572
|
*/
|
|
573
|
+
/**
|
|
574
|
+
* Pose and lens the viewer's mono camera AS THE RECORDING CAMERA.
|
|
575
|
+
*
|
|
576
|
+
* This is the 2D half of the camera rig, and the only place in this SDK that builds a projection
|
|
577
|
+
* matrix itself. That is not a contradiction of "declare the rig, never compute": the 3D path
|
|
578
|
+
* below sends a descriptor and consumes the runtime's views as always — but the mono fallback has
|
|
579
|
+
* no runtime and no stereo, so SOMETHING has to render the capture, and the honest thing to
|
|
580
|
+
* render is the capture's own frustum. Get it wrong and the flat view is a crop or a zoom of the
|
|
581
|
+
* photograph, which reads as a framing bug.
|
|
582
|
+
*
|
|
583
|
+
* THE FLIP MOVES THE CAMERA TOO. `flipY` puts a 180° X rotation on the MESH (most exports are
|
|
584
|
+
* Y-down; three is Y-up), so a rest pose recorded in the file's own frame has to ride the same
|
|
585
|
+
* rotation or the camera ends up mirrored through the origin — the identity pose the gallery's
|
|
586
|
+
* assets carry hides this completely, which is exactly why it is done properly here.
|
|
587
|
+
*
|
|
588
|
+
* ASPECT. The intrinsics fix the capture's aspect and the canvas has its own. The vertical is
|
|
589
|
+
* kept and the horizontal is widened or narrowed to the canvas — `fit:'height'`'s convention, and
|
|
590
|
+
* the one that keeps a face the same size whatever shape the tile is. The principal point rides
|
|
591
|
+
* along, so a deconverged capture (`cx` off centre) keeps its lens shift.
|
|
592
|
+
*
|
|
593
|
+
* `updateProjectionMatrix` is REPLACED, not just called: the viewer recomputes it on every
|
|
594
|
+
* resize, and three's symmetric version would silently throw the off-axis window away on the
|
|
595
|
+
* first layout nudge.
|
|
596
|
+
*/
|
|
597
|
+
function applyCaptureCamera(viewer, rig, flipY, THREE_) {
|
|
598
|
+
const three = THREE_ || THREE;
|
|
599
|
+
const camera = viewer.monoCamera;
|
|
600
|
+
const { fx, fy, cx, cy, width, height } = rig.intrinsics;
|
|
601
|
+
|
|
602
|
+
const q = new three.Quaternion(
|
|
603
|
+
rig.rest.rotation[0],
|
|
604
|
+
rig.rest.rotation[1],
|
|
605
|
+
rig.rest.rotation[2],
|
|
606
|
+
rig.rest.rotation[3],
|
|
607
|
+
);
|
|
608
|
+
const p = new three.Vector3(rig.rest.position[0], rig.rest.position[1], rig.rest.position[2]);
|
|
609
|
+
// TWO half-turns about X, and they are different things — conflating them points the camera
|
|
610
|
+
// backwards at an empty scene, which is what the first version did.
|
|
611
|
+
//
|
|
612
|
+
// · RIGHT-multiplied, ALWAYS: the convention change. The block's rotation is a camera pose
|
|
613
|
+
// in OpenCV axes (+y down, looking down +z); three's camera looks down -z with +y up. That
|
|
614
|
+
// is a rotation in the camera's OWN frame, so it composes on the right, and it applies
|
|
615
|
+
// whether or not the content was flipped.
|
|
616
|
+
// · LEFT-multiplied, only under `flipY`: the same rotation applied to the CONTENT, which is
|
|
617
|
+
// a world-space transform the camera has to ride along with.
|
|
618
|
+
//
|
|
619
|
+
// With the identity rest pose almost every capture carries, the two cancel exactly and the
|
|
620
|
+
// camera sits at the origin looking down -z at a scene the flip has just put there.
|
|
621
|
+
const flip = new three.Quaternion(1, 0, 0, 0);
|
|
622
|
+
if (flipY) {
|
|
623
|
+
p.applyQuaternion(flip);
|
|
624
|
+
q.premultiply(flip);
|
|
625
|
+
}
|
|
626
|
+
q.multiply(flip);
|
|
627
|
+
camera.position.copy(p);
|
|
628
|
+
camera.quaternion.copy(q);
|
|
629
|
+
camera.fov = (2 * Math.atan(height / (2 * fy)) * 180) / Math.PI;
|
|
630
|
+
// FAR, and why it is not the viewer's default. A deconverged capture parks its sky at the
|
|
631
|
+
// lifter's depth cap and the refinement scatters some gaussians beyond it (239 m measured on a
|
|
632
|
+
// street scene); anything past the far plane is CLIPPED in Spark's vertex shader and pops out
|
|
633
|
+
// as a black hole the moment an orbit pushes it over. Spark composites by SORTING, not by
|
|
634
|
+
// depth-testing, so there is no z precision to protect and a huge near:far ratio costs nothing.
|
|
635
|
+
camera.far = Math.max(camera.far, 5000);
|
|
636
|
+
camera.updateMatrixWorld(true);
|
|
637
|
+
|
|
638
|
+
camera.updateProjectionMatrix = () => {
|
|
639
|
+
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
|
+
const box = viewer.canvas.getBoundingClientRect();
|
|
644
|
+
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);
|
|
648
|
+
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
|
|
649
|
+
};
|
|
650
|
+
camera.updateProjectionMatrix();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
|
|
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
|
+
/**
|
|
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.
|
|
671
|
+
*
|
|
672
|
+
* @returns {{tx:Float64Array,ty:Float64Array,invz:Float64Array,n:number}|null}
|
|
673
|
+
*/
|
|
674
|
+
function sampleRestSpace(mesh, rest) {
|
|
675
|
+
const total = mesh?.numSplats || 0;
|
|
676
|
+
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;
|
|
699
|
+
}
|
|
700
|
+
|
|
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];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** A point in the splat's own (model) space, in the viewer's CONTENT space. */
|
|
707
|
+
function toContentSpace(mesh, model, three) {
|
|
708
|
+
const v = new three.Vector3(model[0], model[1], model[2]);
|
|
709
|
+
mesh.updateWorldMatrix(true, false);
|
|
710
|
+
mesh.localToWorld(v);
|
|
711
|
+
const content = mesh.parent;
|
|
712
|
+
if (content) {
|
|
713
|
+
content.updateWorldMatrix(true, false);
|
|
714
|
+
content.worldToLocal(v);
|
|
715
|
+
}
|
|
716
|
+
return [v.x, v.y, v.z];
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** The inverse: a WORLD point in the splat's own space. */
|
|
720
|
+
function toModelSpace(mesh, world, three) {
|
|
721
|
+
const v = world.isVector3 ? world.clone() : new three.Vector3(world[0], world[1], world[2]);
|
|
722
|
+
mesh.updateWorldMatrix(true, false);
|
|
723
|
+
return toArray3(mesh.worldToLocal(v));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* The pick fallback: the gaussian whose CENTRE is closest to the ray.
|
|
728
|
+
*
|
|
729
|
+
* Nearest by ANGLE, then nearest along the ray among everything inside a small cone — so a near
|
|
730
|
+
* surface wins over the sky behind it even when the sky happens to be a hair closer to the exact
|
|
731
|
+
* ray. An approximation of a hit test, not a hit test: it returns a splat centre, so on a thick
|
|
732
|
+
* soft surface it lands slightly behind the apparent one. Good enough to converge and orbit
|
|
733
|
+
* about, which is all a focus is.
|
|
734
|
+
*/
|
|
735
|
+
function nearestGaussianToRay(mesh, ray, three, coneRad = 0.02) {
|
|
736
|
+
const total = mesh?.numSplats || 0;
|
|
737
|
+
if (!total || typeof mesh.forEachSplat !== 'function') return null;
|
|
738
|
+
const stride = Math.max(1, Math.ceil(total / RIG_SAMPLE_CAP));
|
|
739
|
+
mesh.updateWorldMatrix(true, false);
|
|
740
|
+
const m = mesh.matrixWorld;
|
|
741
|
+
const p = new three.Vector3();
|
|
742
|
+
const rel = new three.Vector3();
|
|
743
|
+
let bestInCone = null;
|
|
744
|
+
let bestInConeT = Infinity;
|
|
745
|
+
let bestAngle = Infinity;
|
|
746
|
+
let bestAnyPoint = null;
|
|
747
|
+
mesh.forEachSplat((index, center, scales, quaternion, opacity) => {
|
|
748
|
+
if (index % stride !== 0) return;
|
|
749
|
+
if (opacity !== undefined && opacity < RIG_MIN_OPACITY) return;
|
|
750
|
+
p.copy(center).applyMatrix4(m);
|
|
751
|
+
rel.copy(p).sub(ray.origin);
|
|
752
|
+
const t = rel.dot(ray.direction);
|
|
753
|
+
if (!(t > 0)) return;
|
|
754
|
+
const perp = Math.sqrt(Math.max(0, rel.lengthSq() - t * t));
|
|
755
|
+
const angle = perp / t;
|
|
756
|
+
if (angle <= coneRad) {
|
|
757
|
+
if (t < bestInConeT) {
|
|
758
|
+
bestInConeT = t;
|
|
759
|
+
bestInCone = p.clone();
|
|
760
|
+
}
|
|
761
|
+
} else if (!bestInCone && angle < bestAngle) {
|
|
762
|
+
bestAngle = angle;
|
|
763
|
+
bestAnyPoint = p.clone();
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
return bestInCone || bestAnyPoint;
|
|
767
|
+
}
|
|
768
|
+
|
|
296
769
|
/** Map model-space bounds through a mesh's own transform, matching the native ComputeAutoFrame. */
|
|
297
770
|
function liftBounds(b, mesh, THREE) {
|
|
298
771
|
if (!b || !mesh) return b;
|