@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.
@@ -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
- if (bounds) {
257
- out.frame = bounds;
258
- viewer.fitTo(bounds.center, bounds.extent);
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
- // Unframed means drawn at raw MODEL scale, which for a typical capture is several times
261
- // the tile. Say so: silence here is what made the same condition read as a fit bug.
262
- console.warn('[inline3d/splat] no usable bounds subject is UNFRAMED (model scale)', src);
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;