@displayxr/inline3d 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // RIG caller › the block's `rig` › (a block at all ? camera : display)
11
11
  // INTRINSICS the block › caller › ESTIMATED from the cloud › 28 mm-eq
12
- // FOCUS caller › the block's `focus.point` › MEDIAN DISPARITY › 2 m
12
+ // FOCUS caller › the block's `focus.point` › NEAREST CLUMP › a block median › MEDIAN DISPARITY › 2 m
13
13
  //
14
14
  // The two capitalised steps are the interesting ones, and they exist because the fallbacks
15
15
  // underneath them are bad in a specific, silent way. A splat with no intrinsics rendered through
@@ -194,6 +194,123 @@ export function medianDisparityDistance(invz, n) {
194
194
  return m > 0 ? 1 / m : null;
195
195
  }
196
196
 
197
+ /**
198
+ * NEAREST DISPARITY CLUMP — the nearest SUBSTANTIAL thing in the middle of the picture, put on the
199
+ * glass so the rest of the photograph recedes behind it (the pine trunk in front of the lake, the
200
+ * bowsprit in front of the harbour).
201
+ *
202
+ * Why it exists: a single-image lift is non-metric and its camera block carries a lens but no
203
+ * `focus`. The median-disparity rung below then answers "the typical depth of the WHOLE scene",
204
+ * which on an open landscape (tree, lake, mountains) lands tens of metres out (46.9 m measured) and
205
+ * puts every bit of actual subject in front of the display.
206
+ *
207
+ * Why not the nearest gaussian: the single nearest point is a floater or a grazing sliver. Why not
208
+ * the nearest point anywhere in frame: landscapes put their nearest content along the BOTTOM edge
209
+ * (grass, the dock at the photographer's feet). So — ported from the calibrated estimator the
210
+ * demo pages use (`nearestClumpPivot`, checked by eye on six photos) — the statistic is:
211
+ *
212
+ * 1. keep gaussians projecting into the CENTRAL HALF of the frame, each axis (needs the lens);
213
+ * 2. an OPACITY-weighted histogram of 1/z (disparity: well-behaved where depth has a long tail;
214
+ * footprint weighting was tried and picked the sky — reconstructions give far gaussians huge
215
+ * world scale);
216
+ * 3. a 3-bin moving average, so sampling noise between real plateaus is not a gap;
217
+ * 4. scan from the NEAR end for the first run of occupied bins (above a noise floor of 0.1 % of
218
+ * the crop's mass) carrying at least 3 % of the crop's mass;
219
+ * 5. the weighted centroid of 1/z over that run, inverted to metres.
220
+ *
221
+ * @param {{tx:ArrayLike<number>,ty:ArrayLike<number>,invz:ArrayLike<number>,w?:ArrayLike<number>,n:number}} cloud
222
+ * from sampleCloudRestSpace (rest-camera space, x/z, y/z, 1/z, opacity).
223
+ * @param {{fx:number,fy:number,cx:number,cy:number,width:number,height:number}} K the lens.
224
+ * @returns {{distance:number, massFrac:number}|null} null without a lens, or when nothing in the
225
+ * crop clears the mass floor.
226
+ */
227
+ export function nearestClumpDistance(cloud, K) {
228
+ if (!cloud || !cloud.n) return null;
229
+ if (!K || !(K.fx > 0) || !(K.fy > 0) || !(K.width > 0) || !(K.height > 0)) return null;
230
+ const half = CLUMP_CENTRAL_FRAC / 2;
231
+ const iv = [];
232
+ const wt = [];
233
+ for (let i = 0; i < cloud.n; i++) {
234
+ const u = (K.fx * cloud.tx[i] + K.cx) / K.width - 0.5;
235
+ const v = (K.fy * cloud.ty[i] + K.cy) / K.height - 0.5;
236
+ if (Math.abs(u) > half || Math.abs(v) > half) continue;
237
+ iv.push(cloud.invz[i]);
238
+ wt.push(cloud.w ? cloud.w[i] : 1);
239
+ }
240
+ const n = iv.length;
241
+ if (!n) return null;
242
+ let lo = Infinity;
243
+ let hi = -Infinity;
244
+ for (let i = 0; i < n; i++) {
245
+ if (iv[i] < lo) lo = iv[i];
246
+ if (iv[i] > hi) hi = iv[i];
247
+ }
248
+ if (!(hi > lo)) return null;
249
+ const B = CLUMP_N_BINS;
250
+ const binOf = (x) => Math.min(B - 1, Math.floor(((x - lo) / (hi - lo)) * B));
251
+ const bins = new Float64Array(B);
252
+ for (let i = 0; i < n; i++) bins[binOf(iv[i])] += wt[i];
253
+ let total = 0;
254
+ for (let i = 0; i < B; i++) total += bins[i];
255
+ if (!(total > 0)) return null;
256
+ const smooth = new Float64Array(B);
257
+ for (let i = 0; i < B; i++) smooth[i] = (bins[Math.max(0, i - 1)] + bins[i] + bins[Math.min(B - 1, i + 1)]) / 3;
258
+ const floor = total * CLUMP_NOISE_FLOOR_FRAC;
259
+ let i = B - 1; // the highest 1/z — the NEAREST
260
+ while (i >= 0) {
261
+ if (smooth[i] <= floor) {
262
+ i--;
263
+ continue;
264
+ }
265
+ let j = i;
266
+ let runMass = 0;
267
+ while (j >= 0 && smooth[j] > floor) {
268
+ runMass += bins[j];
269
+ j--;
270
+ }
271
+ if (runMass >= total * CLUMP_MIN_MASS_FRAC) {
272
+ let wSum = 0;
273
+ let vSum = 0;
274
+ for (let k = 0; k < n; k++) {
275
+ const b = binOf(iv[k]);
276
+ if (b <= i && b >= j + 1) {
277
+ wSum += wt[k];
278
+ vSum += wt[k] * iv[k];
279
+ }
280
+ }
281
+ const c = wSum > 0 ? vSum / wSum : (lo + hi) / 2;
282
+ if (!(c > 0)) return null;
283
+ return { distance: clamp(1 / c, FOCUS_MIN_M, FOCUS_MAX_M), massFrac: runMass / total };
284
+ }
285
+ i = j;
286
+ }
287
+ return null;
288
+ }
289
+
290
+ /** The central crop, each axis, as a fraction of the frame. */
291
+ export const CLUMP_CENTRAL_FRAC = 0.5;
292
+ /** 1/z histogram resolution. */
293
+ export const CLUMP_N_BINS = 120;
294
+ /** A clump must carry at least this fraction of the crop's opacity-weighted mass. */
295
+ export const CLUMP_MIN_MASS_FRAC = 0.03;
296
+ /** Below this fraction of the crop's mass a (smoothed) bin counts as empty. */
297
+ export const CLUMP_NOISE_FLOOR_FRAC = 0.001;
298
+
299
+ /**
300
+ * Block `focus.source` values that are themselves a WHOLE-CLOUD median — the same estimate as the
301
+ * median-disparity rung, written into the file by a converter. They rank BELOW the nearest clump:
302
+ * believing them first would reproduce the far-focus the clump exists to fix.
303
+ */
304
+ export const CLOUD_MEDIAN_FOCUS_SOURCES = Object.freeze(['cloud-median', 'median-disparity']);
305
+
306
+ /**
307
+ * Does the waterfall need a pass over the cloud for this block? Only a block with a lens AND a
308
+ * trusted focus answers every question itself.
309
+ */
310
+ export function rigNeedsCloud(camera) {
311
+ return !(camera?.intrinsics && camera?.focus && !CLOUD_MEDIAN_FOCUS_SOURCES.includes(camera.focus.source));
312
+ }
313
+
197
314
  /** Where a focus ends up when there is nothing at all to go on. */
198
315
  export const DEFAULT_FOCUS_M = 2.0;
199
316
 
@@ -280,8 +397,14 @@ export function resolveRig({ camera = null, opts = {}, cloud = null, canvasAspec
280
397
  //
281
398
  // ONE point, and it is the orbit centre, the pivot plane and the convergence distance at
282
399
  // once. Resolved as a point in model space; the distance falls out of it, never the reverse.
400
+ // caller › caller-convergence › block (a considered focus) › NEAREST CLUMP (needs a lens)
401
+ // › block (a whole-cloud median a converter wrote) › median disparity › 2 m
283
402
  let point = null;
284
403
  let focusSource = null;
404
+ const blockPoint = isVec3(camera?.focus?.point) ? camera.focus.point.slice(0, 3) : null;
405
+ const blockIsMedian = !!blockPoint && CLOUD_MEDIAN_FOCUS_SOURCES.includes(camera.focus.source);
406
+ const lens = intrinsicsSource === 'block' || intrinsicsSource === 'caller' ? intrinsics : null;
407
+ let clump = null;
285
408
  if (isVec3(opts.focus)) {
286
409
  point = opts.focus.slice(0, 3);
287
410
  focusSource = 'caller';
@@ -289,9 +412,15 @@ export function resolveRig({ camera = null, opts = {}, cloud = null, canvasAspec
289
412
  // The scalar shorthand: a focus straight ahead at this distance.
290
413
  point = aheadOfRest(rest, opts.convergence);
291
414
  focusSource = 'caller-convergence';
292
- } else if (isVec3(camera?.focus?.point)) {
293
- point = camera.focus.point.slice(0, 3);
415
+ } else if (blockPoint && !blockIsMedian) {
416
+ point = blockPoint;
294
417
  focusSource = 'block';
418
+ } else if (cloud && lens && (clump = nearestClumpDistance(cloud, lens))) {
419
+ point = aheadOfRest(rest, clump.distance);
420
+ focusSource = 'nearest-clump';
421
+ } else if (blockPoint) {
422
+ point = blockPoint;
423
+ focusSource = 'block-cloud-median';
295
424
  } else if (cloud) {
296
425
  const d = medianDisparityDistance(cloud.invz, cloud.n);
297
426
  if (d) {
@@ -315,6 +444,10 @@ export function resolveRig({ camera = null, opts = {}, cloud = null, canvasAspec
315
444
  focalEqMm,
316
445
  focus: point,
317
446
  focusSource,
447
+ /** The block's own `focus.source` (e.g. 'convergence', 'cloud-median'), for diagnostics. */
448
+ blockFocusSource: typeof camera?.focus?.source === 'string' ? camera.focus.source : null,
449
+ /** Fraction of the central crop's mass the winning clump carried (nearest-clump only). */
450
+ clumpMassFrac: clump ? clump.massFrac : null,
318
451
  // Advisory, straight from the block — a host page's depth budget or HUD may want them.
319
452
  focusDistances: camera?.focus
320
453
  ? { subject_m: camera.focus.subject_m, near_m: camera.focus.near_m, far_m: camera.focus.far_m }
@@ -328,3 +461,135 @@ export function resolveRig({ camera = null, opts = {}, cloud = null, canvasAspec
328
461
  : (camera?.dxr?.parallaxFactor ?? 1),
329
462
  };
330
463
  }
464
+
465
+ // ── walking the cloud, for any backend ──────────────────────────────────────────────────
466
+ //
467
+ // The waterfall needs ONE pass over the splat centres (in the file's own space), and the
468
+ // auto-frame needs another. Both used to be written against Spark's `mesh.forEachSplat`, which
469
+ // is the only thing in them that is Spark. A second backend (./inline3d-splat-playcanvas.js)
470
+ // has the same centres as a flat Float32Array instead, so the walk is expressed here against a
471
+ // VISITOR — `forEachCentre(visit)` calls `visit(index, x, y, z, opacity)` once per splat, in
472
+ // index order, with `opacity` undefined when the backend has none — and each backend supplies
473
+ // the two-line adapter from what it holds. The sampling rules (stride, cap, opacity floor) live
474
+ // here once, so the two backends cannot drift apart on what "the cloud" means.
475
+
476
+ /** Cap on how many splats the rig pass inspects. Percentiles of a uniform subsample converge. */
477
+ export const RIG_SAMPLE_CAP = 40000;
478
+
479
+ /** Below this, a splat is haze — it is not where the camera was pointed and not what it saw. */
480
+ export const RIG_MIN_OPACITY = 0.05;
481
+
482
+ /** Nearer than this, a splat is behind or on the lens and its x/z, y/z, 1/z are meaningless. */
483
+ export const RIG_MIN_Z = 0.05;
484
+
485
+ /** Cap on how many splat centres the fallback framing pass inspects. */
486
+ export const FRAME_SAMPLE_CAP = 200000;
487
+
488
+ /**
489
+ * A visitor over flat arrays: `xyz` is [x,y,z, x,y,z, …] and `opacity` (optional) is one peak
490
+ * opacity per splat in [0,1].
491
+ *
492
+ * @param {ArrayLike<number>} xyz
493
+ * @param {ArrayLike<number>|null} [opacity]
494
+ * @param {number} [count] splats to visit; defaults to what `xyz` holds.
495
+ * @returns {(visit: Function) => void}
496
+ */
497
+ export function centresVisitor(xyz, opacity = null, count = Math.floor(xyz.length / 3)) {
498
+ return (visit) => {
499
+ for (let i = 0; i < count; i++) {
500
+ visit(i, xyz[i * 3], xyz[i * 3 + 1], xyz[i * 3 + 2], opacity ? opacity[i] : undefined);
501
+ }
502
+ };
503
+ }
504
+
505
+ /**
506
+ * ONE walk over the cloud, in the REST CAMERA's frame, producing everything the waterfall needs
507
+ * that is not in the file: the angular extent that is the lens (x/z, y/z) and the disparities
508
+ * whose median is the focus (1/z).
509
+ *
510
+ * Model space, deliberately — the centres are the file's own OpenCV frame, before any display
511
+ * flip, which is the frame `rest` and `intrinsics` are expressed in. Doing it after the Y-flip
512
+ * would mean undoing the flip to compare with the block.
513
+ *
514
+ * @param {number} total how many splats `forEachCentre` will visit.
515
+ * @param {(visit: Function) => void} forEachCentre
516
+ * @param {{position:number[],rotation:number[]}|null} rest
517
+ * @returns {{tx:Float64Array,ty:Float64Array,invz:Float64Array,w:Float64Array,n:number}|null}
518
+ */
519
+ export function sampleCloudRestSpace(total, forEachCentre, rest) {
520
+ if (!total || typeof forEachCentre !== 'function') return null;
521
+ const r = rest || { position: [0, 0, 0], rotation: [0, 0, 0, 1] };
522
+ const stride = Math.max(1, Math.ceil(total / RIG_SAMPLE_CAP));
523
+ const cap = Math.ceil(total / stride) + 1;
524
+ const tx = new Float64Array(cap);
525
+ const ty = new Float64Array(cap);
526
+ const invz = new Float64Array(cap);
527
+ const w = new Float64Array(cap); // opacity (1 where the backend has none): the clump's weights
528
+ let n = 0;
529
+ const p = [0, 0, 0];
530
+ forEachCentre((index, x, y, z, opacity) => {
531
+ if (index % stride !== 0 || n >= cap) return;
532
+ if (opacity !== undefined && opacity < RIG_MIN_OPACITY) return;
533
+ p[0] = x;
534
+ p[1] = y;
535
+ p[2] = z;
536
+ const c = toRestSpace(r, p);
537
+ if (!(c[2] > RIG_MIN_Z)) return;
538
+ tx[n] = c[0] / c[2];
539
+ ty[n] = c[1] / c[2];
540
+ invz[n] = 1 / c[2];
541
+ w[n] = opacity !== undefined ? opacity : 1;
542
+ n++;
543
+ });
544
+ return n ? { tx, ty, invz, w, n } : null;
545
+ }
546
+
547
+ /**
548
+ * A strided, opacity-filtered subsample of the centres, flat [x,y,z, …] — the input to
549
+ * `boundsFromPositions` for the fallback auto-frame. Skips near-transparent splats (haze and
550
+ * floaters drag a box outwards) and strides above FRAME_SAMPLE_CAP (percentiles of a uniform
551
+ * subsample are indistinguishable from the full set's, at a fraction of the cost).
552
+ *
553
+ * @param {number} total
554
+ * @param {(visit: Function) => void} forEachCentre
555
+ * @param {{cap?: number}} [o] sample-size cap (default FRAME_SAMPLE_CAP; the pick set uses
556
+ * RIG_SAMPLE_CAP).
557
+ * @returns {Float32Array|null}
558
+ */
559
+ export function sampleCloudCentres(total, forEachCentre, { cap = FRAME_SAMPLE_CAP } = {}) {
560
+ if (!total || typeof forEachCentre !== 'function') return null;
561
+ const stride = Math.max(1, Math.ceil(total / cap));
562
+ const xyz = new Float32Array(Math.ceil(total / stride) * 3);
563
+ let k = 0;
564
+ forEachCentre((index, x, y, z, opacity) => {
565
+ if (index % stride !== 0) return;
566
+ if (opacity !== undefined && opacity < RIG_MIN_OPACITY) return;
567
+ if (k + 3 > xyz.length) return;
568
+ xyz[k++] = x;
569
+ xyz[k++] = y;
570
+ xyz[k++] = z;
571
+ });
572
+ return xyz.subarray(0, k);
573
+ }
574
+
575
+ /** The splat backends `addSplat` knows. The first is the default. */
576
+ export const SPLAT_ENGINES = ['spark', 'playcanvas'];
577
+
578
+ /**
579
+ * Which backend an `addSplat` call asked for. Unset is Spark — the path every page had before
580
+ * there was a choice. Anything unknown THROWS, synchronously: a typo'd engine name is a page bug
581
+ * that is true of every call, not a condition of one asset.
582
+ *
583
+ * @param {{engine?: string}} [opts]
584
+ * @returns {'spark'|'playcanvas'}
585
+ */
586
+ export function resolveSplatEngine(opts) {
587
+ const engine = opts?.engine ?? 'spark';
588
+ if (!SPLAT_ENGINES.includes(engine)) {
589
+ throw new Error(
590
+ `@displayxr/inline3d/splat: unknown engine "${engine}" — expected ` +
591
+ `${SPLAT_ENGINES.map((e) => `'${e}'`).join(' or ')} (default 'spark').`,
592
+ );
593
+ }
594
+ return engine;
595
+ }
@@ -0,0 +1,269 @@
1
+ // inline3d-splat-shared.js — the small pieces both splat backends use, written once.
2
+ //
3
+ // EXPERIMENTAL. Internal to `./splat` (Spark) and its `engine: 'playcanvas'` backend. Not covered
4
+ // by the SDK's 1.x semver promise.
5
+ //
6
+ // Everything here is renderer-free: no three.js, no engine. The Spark path
7
+ // (./inline3d-splat.js) and the PlayCanvas adapter (./inline3d-splat-playcanvas.js) both import
8
+ // it, so a gesture or a coordinate convention cannot drift between the two.
9
+ //
10
+ // The VIEWER CONSTANTS below are read by BOTH SceneViewer (./inline3d-viewer.js, the Spark path)
11
+ // and PlayCanvasSplatViewer (./inline3d-splat-playcanvas.js), so the two backends cannot drift
12
+ // apart on how a drag, a wheel notch, an idle turntable or a focus change feels. Pinned by
13
+ // test/splat-playcanvas.test.mjs (values, and a behavioural trace of both viewers side by side).
14
+
15
+ /**
16
+ * Backstop on total subject depth, as a multiple of the display height. Generous on purpose:
17
+ * depth placement is a z decision (see fitTo), not a scale one, so this only catches the
18
+ * pathological case where a subject is so deep that no placement helps.
19
+ */
20
+ export const DEFAULT_DEPTH_LIMIT = 4.0;
21
+ /** Milliseconds of no interaction before the idle turntable starts. */
22
+ export const IDLE_DELAY_MS = 2500;
23
+ /**
24
+ * Per-frame easing factor for a focus change, matching the gallery's `EASE`.
25
+ *
26
+ * Deliberately per FRAME and not per second, because that is what the reference implementation
27
+ * does and a focus change is a one-off gesture response rather than a continuous motion — the
28
+ * difference between 60 and 120 Hz here is a settle that takes half as long, not a bug.
29
+ */
30
+ export const FOCUS_EASE = 0.18;
31
+ /** Yaw/pitch/zoom damping: each frame closes `1 − DAMP_BASE^dt` of the gap (dt in seconds). */
32
+ export const DAMP_BASE = 0.001;
33
+ /** Largest frame step the damping will take, seconds — a stall must not become a lurch. */
34
+ export const MAX_DT_S = 0.1;
35
+ /** Default pitch clamp, degrees: stops the viewer rolling under the subject. */
36
+ export const PITCH_LIMIT = Object.freeze([-60, 60]);
37
+ /** A full drag across the tile is this many degrees — a half turn, whatever the tile size. */
38
+ export const DRAG_DEG_PER_TILE = 180;
39
+ /**
40
+ * Wheel-zoom tuning.
41
+ *
42
+ * ZOOM_PER_PX is set so one ordinary mouse notch (~100 px in Chrome) is about a 10% step, which
43
+ * puts a trackpad's 1-10 px events at a fraction of a percent each — small enough that the easing
44
+ * reads as continuous rather than as a stack of jumps.
45
+ *
46
+ * A deltaMode-1 "line" is sized to match a wheel DETENT, not a line of text. Firefox reports a
47
+ * notch as deltaY 3 in lines where Chrome reports it as ~100 in pixels, so 33 makes one physical
48
+ * notch feel the same in both; 16 (a text line) would make Firefox roughly half as responsive as
49
+ * Chrome for identical hardware.
50
+ */
51
+ export const WHEEL_LINE_PX = 33;
52
+ /** A "page" in deltaMode 2; rare, but it must not be unbounded. */
53
+ export const WHEEL_PAGE_PX = 400;
54
+ /** Per-event ceiling, against OS pointer acceleration spikes. */
55
+ export const WHEEL_MAX_PX = 120;
56
+ export const ZOOM_PER_PX = 0.001;
57
+ export const ZOOM_MIN = 0.2;
58
+ export const ZOOM_MAX = 6;
59
+ /** The mono fallback camera: a plain perspective camera, vertical FOV in degrees, near, far. */
60
+ export const MONO_FOV = 35;
61
+ export const MONO_NEAR = 0.001;
62
+ export const MONO_FAR = 1000;
63
+ /**
64
+ * The camera rig's far plane. A deconverged capture parks its sky at the lifter's depth cap and
65
+ * the refinement scatters some gaussians beyond it (239 m measured on a street scene); anything
66
+ * past the far plane is clipped and pops out as a black hole the moment an orbit pushes it over.
67
+ */
68
+ export const CAPTURE_FAR = 5000;
69
+
70
+ export const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
71
+
72
+ /** NaN/Infinity into a transform silently blanks the tile; reject at the setter instead. */
73
+ export const finite = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
74
+
75
+ export const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
76
+
77
+ /** [x,y,z] out of anything vector-shaped. */
78
+ export function toArray3(v) {
79
+ return Array.isArray(v) ? [v[0], v[1], v[2]] : [v.x, v.y, v.z];
80
+ }
81
+
82
+ /**
83
+ * NDC of a client point, from the canvas's CSS box — null for an empty box.
84
+ *
85
+ * The CSS box, not the backing store: on a woven canvas the store is double-width and each eye
86
+ * owns half of it, but what the VIEWER sees is one image filling the box, so the box is the
87
+ * right frame to pick in; the eye camera supplies the parallax-correct ray.
88
+ */
89
+ export function canvasNdc(canvas, clientX, clientY) {
90
+ const box = canvas.getBoundingClientRect();
91
+ if (!(box.width > 0) || !(box.height > 0)) return null;
92
+ return {
93
+ x: ((clientX - box.left) / box.width) * 2 - 1,
94
+ y: -(((clientY - box.top) / box.height) * 2 - 1),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * The two focus gestures: double-click focuses what was clicked, Space goes back to the resolved
100
+ * focus.
101
+ *
102
+ * Space is scoped to THIS window: a page with four splat tiles must not have one key reset all
103
+ * four. Hover OR keyboard focus, so it works with a pointer and with a keyboard.
104
+ *
105
+ * @param {HTMLCanvasElement} canvas
106
+ * @param {object} h
107
+ * @param {(e: MouseEvent) => boolean} h.onDoubleClick apply the pick; return true when something
108
+ * was hit (the event's default is then prevented), false to let it through.
109
+ * @param {() => void} h.onReset Space.
110
+ * @returns {(() => void)|null} unbind, or null when the canvas cannot take listeners.
111
+ */
112
+ export function bindFocusGestures(canvas, { onDoubleClick, onReset }) {
113
+ if (typeof canvas.addEventListener !== 'function') return null;
114
+ let hovering = false;
115
+ const onEnter = () => {
116
+ hovering = true;
117
+ };
118
+ const onLeave = () => {
119
+ hovering = false;
120
+ };
121
+ const onDblClick = (e) => {
122
+ if (onDoubleClick(e)) e.preventDefault();
123
+ };
124
+ const onKeyDown = (e) => {
125
+ if (e.code !== 'Space' && e.key !== ' ') return;
126
+ if (!hovering && document.activeElement !== canvas) return;
127
+ e.preventDefault();
128
+ onReset();
129
+ };
130
+ canvas.addEventListener('pointerenter', onEnter);
131
+ canvas.addEventListener('pointerleave', onLeave);
132
+ canvas.addEventListener('dblclick', onDblClick);
133
+ addEventListener('keydown', onKeyDown);
134
+ return () => {
135
+ canvas.removeEventListener('pointerenter', onEnter);
136
+ canvas.removeEventListener('pointerleave', onLeave);
137
+ canvas.removeEventListener('dblclick', onDblClick);
138
+ removeEventListener('keydown', onKeyDown);
139
+ };
140
+ }
141
+
142
+ /** URL path without query/hash; '' for non-strings. */
143
+ export function pathOf(u) {
144
+ return typeof u === 'string' ? u.split(/[?#]/)[0] : '';
145
+ }
146
+
147
+ function extOf(u) {
148
+ const m = /\.([a-z0-9]+)$/i.exec(pathOf(u));
149
+ return m ? m[1].toLowerCase() : '';
150
+ }
151
+
152
+ /** Spark's `fileType` names, as the PlayCanvas engine's parser extensions (null = unreadable). */
153
+ const PC_FILETYPE = { pcsogszip: 'sog', ply: 'ply' };
154
+
155
+ /**
156
+ * Which PlayCanvas loader a source needs. The engine picks its parser from the URL's extension;
157
+ * a byte source gets a synthetic name so it does too.
158
+ *
159
+ * @param {string|null} src the URL (ignored when `bytes` is given).
160
+ * @param {Uint8Array|null} [bytes]
161
+ * @param {string} [fileName] the `.splat`/`.ksplat` disambiguator; its extension is a hint here.
162
+ * @param {string} [fileType] Spark's type name, if the page passed one.
163
+ * @returns {{ext:'sog'|'ply'|'json', streamed:boolean}|null} null = not something the engine reads.
164
+ */
165
+ export function engineFormatFor(src, bytes, fileName, fileType) {
166
+ if (fileType !== undefined) {
167
+ const ext = PC_FILETYPE[fileType];
168
+ return ext ? { ext, streamed: false } : null;
169
+ }
170
+ if (bytes) {
171
+ if (bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04) {
172
+ return { ext: 'sog', streamed: false };
173
+ }
174
+ if (bytes.length >= 3 && bytes[0] === 0x70 && bytes[1] === 0x6c && bytes[2] === 0x79) return { ext: 'ply', streamed: false };
175
+ const e = extOf(fileName);
176
+ return e === 'sog' || e === 'ply' ? { ext: e, streamed: false } : null;
177
+ }
178
+ const e = extOf(src);
179
+ if (e === 'sog' || e === 'ply') return { ext: e, streamed: false };
180
+ if (e === 'json') return { ext: 'json', streamed: /lod-meta\.json$/i.test(pathOf(src)) };
181
+ return null;
182
+ }
183
+
184
+ // ── ORBIT: the PlayCanvas backend's built-in drag (tilt-and-relax) ──────────────────────────
185
+ //
186
+ // SceneViewer (the Spark path) still turns the subject cumulatively (DRAG_DEG_PER_TILE); switching
187
+ // it to this mapping later is reading these three constants.
188
+
189
+ /** Largest tilt a drag reaches, degrees, either axis; a half-width swipe gets there. */
190
+ export const ORBIT_MAX_DEG = 15;
191
+ /** Time constant while dragging, seconds: k = 1 − exp(−dt/τ) per frame toward the drag target. */
192
+ export const ORBIT_TAU_DRAG_S = 0.2;
193
+ /** Time constant of the relax back to rest after release, seconds. */
194
+ export const ORBIT_TAU_REST_S = 0.6;
195
+
196
+ /**
197
+ * The capture camera's off-axis WINDOW at the near plane — the one projection both backends'
198
+ * camera rigs draw the mono (flat) view through. Principal point honoured, so a deconverged
199
+ * capture (`cx` off centre) keeps its lens shift. OpenCV's y grows DOWN the image, so the TOP
200
+ * edge is the `cy` side.
201
+ *
202
+ * `captureFit` decides what gives when the canvas is not the capture's shape:
203
+ * 'height' (default) — the capture's VERTICAL extent is kept and the horizontal is widened or
204
+ * narrowed to the canvas. Keeps a face the same size whatever shape the tile is; a
205
+ * tile wider than the capture shows past the photograph's left/right edges.
206
+ * 'cover' — the tile is always filled by photograph: when the canvas is WIDER than the capture
207
+ * the horizontal extent is kept and the vertical is cropped (a 4:3 capture in a 16:9
208
+ * tile loses top and bottom); when it is narrower this is 'height' (which already
209
+ * crops the sides).
210
+ *
211
+ * @param {{fx:number,fy:number,cx:number,cy:number,width:number,height:number}} K intrinsics.
212
+ * @param {number} aspect canvas width / height (non-positive → the capture's own aspect).
213
+ * @param {number} near
214
+ * @param {'height'|'cover'} [captureFit='height']
215
+ * @returns {{left:number,right:number,top:number,bottom:number}}
216
+ */
217
+ export function captureWindow(K, aspect, near, captureFit = 'height') {
218
+ const { fx, fy, cx, cy, width, height } = K;
219
+ const top = (near * cy) / fy;
220
+ const bottom = -(near * (height - cy)) / fy;
221
+ const a = aspect > 0 ? aspect : width / height;
222
+ if (captureFit === 'cover') {
223
+ const left0 = -(near * cx) / fx;
224
+ const right0 = (near * (width - cx)) / fx;
225
+ const capAspect = (right0 - left0) / (top - bottom);
226
+ if (a > capAspect) {
227
+ const vmid = (top + bottom) / 2;
228
+ const halfV = (right0 - left0) / a / 2;
229
+ return { left: left0, right: right0, top: vmid + halfV, bottom: vmid - halfV };
230
+ }
231
+ }
232
+ const mid = (near * (width / 2 - cx)) / fx; // horizontal centre of the capture's frustum
233
+ const half = ((top - bottom) * a) / 2;
234
+ return { left: mid - half, right: mid + half, top, bottom };
235
+ }
236
+
237
+ /** The camera-rig fits `captureFit` accepts. Anything else throws at addSplat time. */
238
+ export const CAPTURE_FITS = Object.freeze(['height', 'cover']);
239
+
240
+ /**
241
+ * Full vertical FOV, DEGREES, of what the capture camera shows under `captureFit` — what the
242
+ * camera-rig descriptor sends the runtime, so 3D crops like the flat view does. On 'height' it is
243
+ * the lens's own `2·atan(h / 2fy)`, bit for bit.
244
+ */
245
+ export function captureVerticalFovDeg(K, aspect, near, captureFit = 'height') {
246
+ if (captureFit !== 'cover') return (2 * Math.atan(K.height / (2 * K.fy)) * 180) / Math.PI;
247
+ const w = captureWindow(K, aspect, near, captureFit);
248
+ // Symmetric-equivalent angle of an off-axis window: what `verticalFov` means on the wire.
249
+ return (2 * Math.atan((w.top - w.bottom) / (2 * near)) * 180) / Math.PI;
250
+ }
251
+
252
+ /**
253
+ * Can the PlayCanvas engine read this source — decidable WITHOUT loading anything? Returns a
254
+ * reason string when it provably cannot (so ./splat can throw at call time), null when it can or
255
+ * when that is only knowable later (a Blob, a URL with no extension).
256
+ */
257
+ export function playcanvasCannotRead(src, { fileType, fileName } = {}) {
258
+ let bytes = null;
259
+ if (src instanceof Uint8Array) bytes = src;
260
+ else if (src instanceof ArrayBuffer) bytes = new Uint8Array(src, 0, Math.min(8, src.byteLength));
261
+ if (typeof src !== 'string' && !bytes && fileType === undefined) return null; // a Blob: known at load
262
+ if (typeof src === 'string' && fileType === undefined && !extOf(src)) return null;
263
+ if (engineFormatFor(typeof src === 'string' ? src : null, bytes, fileName, fileType)) return null;
264
+ const what = fileType ?? (typeof src === 'string' ? `.${extOf(src)}` : 'these bytes');
265
+ return (
266
+ `the PlayCanvas engine (the default) reads .sog, .ply and a Streamed-SOG lod-meta.json, not ` +
267
+ `${what}. Pass engine:'spark' for .spz / .splat / .ksplat / .rad.`
268
+ );
269
+ }