@displayxr/inline3d 1.7.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +13 -0
- package/js/inline3d-splat-perf.js +126 -0
- package/js/inline3d-splat-playcanvas.js +1908 -0
- package/js/inline3d-splat-rig.js +268 -3
- package/js/inline3d-splat-shared.js +269 -0
- package/js/inline3d-splat.js +174 -118
- package/js/inline3d-three.js +43 -9
- package/js/inline3d-viewer.js +27 -41
- package/package.json +8 -2
- package/splat.d.ts +137 -7
- package/three.d.ts +17 -0
package/js/inline3d-splat-rig.js
CHANGED
|
@@ -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 (
|
|
293
|
-
point =
|
|
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
|
+
}
|