@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.
@@ -0,0 +1,330 @@
1
+ // inline3d-splat-rig.js — how a splat decides which rig it is on, what lens it was taken with,
2
+ // and what it is looking at. The WATERFALL, and the arithmetic under it.
3
+ //
4
+ // EXPERIMENTAL. Internal to `./splat`. Not covered by the SDK's 1.x semver promise.
5
+ //
6
+ // Three questions have to be answered before a splat can be drawn, and each of them has a good
7
+ // answer, a worse answer and a last resort. Writing that as a waterfall — with the step that
8
+ // answered it recorded next to the value — is the whole design:
9
+ //
10
+ // RIG caller › the block's `rig` › (a block at all ? camera : display)
11
+ // INTRINSICS the block › caller › ESTIMATED from the cloud › 28 mm-eq
12
+ // FOCUS caller › the block's `focus.point` › MEDIAN DISPARITY › 2 m
13
+ //
14
+ // The two capitalised steps are the interesting ones, and they exist because the fallbacks
15
+ // underneath them are bad in a specific, silent way. A splat with no intrinsics rendered through
16
+ // a guessed lens is drawn at the wrong SIZE — a splat built at focal f_s and viewed at f_v is
17
+ // scaled by f_v/f_s about the frame centre, nothing else changes, so there is no artefact to
18
+ // notice, only a picture that "feels zoomed out". And a focus picked as the middle of the
19
+ // measured bounds lands ~40 m away on an open scene (sky and ground are in those bounds),
20
+ // which puts every bit of actual subject in front of the glass.
21
+ //
22
+ // Everything here is PLAIN ARITHMETIC on numbers and arrays — no three.js types — so the whole
23
+ // waterfall is unit-testable without a GPU, a canvas or a renderer. The caller does the one
24
+ // thing that needs the library: walking the cloud once.
25
+
26
+ /** Rotate `v` by the CONJUGATE of quaternion `q` (xyzw) — i.e. world → the frame `q` defines. */
27
+ export function unrotate(q, v) {
28
+ const [x, y, z, w] = q;
29
+ // q* · v · q, expanded. Conjugating is negating the vector part.
30
+ const ix = -x;
31
+ const iy = -y;
32
+ const iz = -z;
33
+ const tx = 2 * (iy * v[2] - iz * v[1]);
34
+ const ty = 2 * (iz * v[0] - ix * v[2]);
35
+ const tz = 2 * (ix * v[1] - iy * v[0]);
36
+ return [
37
+ v[0] + w * tx + (iy * tz - iz * ty),
38
+ v[1] + w * ty + (iz * tx - ix * tz),
39
+ v[2] + w * tz + (ix * ty - iy * tx),
40
+ ];
41
+ }
42
+
43
+ /** Rotate `v` BY quaternion `q` (xyzw) — the frame `q` defines → world. */
44
+ export function rotate(q, v) {
45
+ const [x, y, z, w] = q;
46
+ const tx = 2 * (y * v[2] - z * v[1]);
47
+ const ty = 2 * (z * v[0] - x * v[2]);
48
+ const tz = 2 * (x * v[1] - y * v[0]);
49
+ return [
50
+ v[0] + w * tx + (y * tz - z * ty),
51
+ v[1] + w * ty + (z * tx - x * tz),
52
+ v[2] + w * tz + (x * ty - y * tx),
53
+ ];
54
+ }
55
+
56
+ /** A model-space point in the rest camera's own frame (OpenCV: +x right, +y down, +z forward). */
57
+ export function toRestSpace(rest, p) {
58
+ const t = rest.position;
59
+ return unrotate(rest.rotation, [p[0] - t[0], p[1] - t[1], p[2] - t[2]]);
60
+ }
61
+
62
+ /** A point `d` metres straight ahead of the rest camera, back in model space. */
63
+ export function aheadOfRest(rest, d) {
64
+ const f = rotate(rest.rotation, [0, 0, 1]);
65
+ return [rest.position[0] + f[0] * d, rest.position[1] + f[1] * d, rest.position[2] + f[2] * d];
66
+ }
67
+
68
+ /** Distance from the rest camera to a model-space point, along the view axis (the PLANE). */
69
+ export function planeDistance(rest, p) {
70
+ return toRestSpace(rest, p)[2];
71
+ }
72
+
73
+ /** `p`-th percentile of an ALREADY SORTED array, linearly interpolated. */
74
+ function pct(sorted, p) {
75
+ if (!sorted.length) return NaN;
76
+ const i = (sorted.length - 1) * p;
77
+ const lo = Math.floor(i);
78
+ const hi = Math.ceil(i);
79
+ return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
80
+ }
81
+
82
+ /** Half the 35 mm frame's diagonal, in mm — the constant behind every "35 mm equivalent". */
83
+ const HALF_DIAGONAL_35MM = Math.hypot(36, 24) / 2;
84
+
85
+ /** The 35 mm-equivalent focal implied by a pair of half-tangents. */
86
+ export function focalEq35(hTan, vTan) {
87
+ const diag = Math.hypot(hTan, vTan);
88
+ return diag > 0 ? HALF_DIAGONAL_35MM / diag : Infinity;
89
+ }
90
+
91
+ /**
92
+ * Sanity band on an ESTIMATED lens, in 35 mm-equivalent mm.
93
+ *
94
+ * Outside it the estimate is not a lens, it is a statement about the CLOUD: a scene that wraps
95
+ * around the camera (a 360 capture, a scan the viewer is inside) has an angular extent of most
96
+ * of a sphere and implies a sub-fisheye focal, while a single distant object subtends almost
97
+ * nothing and implies a telescope. Neither is the camera the picture was taken with, so both
98
+ * fall through to the default rather than being believed.
99
+ */
100
+ const FOCAL_EQ_MIN_MM = 14;
101
+ const FOCAL_EQ_MAX_MM = 85;
102
+
103
+ /** What the fallback claims when the cloud cannot be trusted: a phone's main camera. */
104
+ const DEFAULT_FOCAL_EQ_MM = 28;
105
+
106
+ /** The pixel height every estimated lens is expressed against. Only ratios matter. */
107
+ const NOMINAL_HEIGHT_PX = 1000;
108
+
109
+ /**
110
+ * Build intrinsics from four tangent limits (left/right/top/bottom, in the OpenCV frame where
111
+ * +y is DOWN).
112
+ *
113
+ * Pixels come out square by construction — `width` is chosen from the tangent aspect — because
114
+ * nothing in a point cloud distinguishes a non-square pixel from a differently shaped frame, and
115
+ * inventing one would be a claim the data does not support.
116
+ */
117
+ export function intrinsicsFromTangents(txLo, txHi, tyLo, tyHi) {
118
+ const dtx = txHi - txLo;
119
+ const dty = tyHi - tyLo;
120
+ if (!(dtx > 0) || !(dty > 0)) return null;
121
+ const height = NOMINAL_HEIGHT_PX;
122
+ const width = Math.max(1, Math.round((height * dtx) / dty));
123
+ const fy = height / dty;
124
+ const fx = width / dtx;
125
+ return { fx, fy, cx: -txLo * fx, cy: -tyLo * fy, width, height };
126
+ }
127
+
128
+ /**
129
+ * Estimate the capture lens from the cloud's ANGULAR EXTENT about the rest camera.
130
+ *
131
+ * A capture's gaussians only exist where the camera could see them, so the cloud's own extent in
132
+ * tangent space IS the frustum that made it — read the edges and you have read the lens. The
133
+ * edges are taken as P1/P99 rather than min/max because a lifted capture always has a few
134
+ * gaussians outside the frame (the refinement hallucinates a little past the edges, and the
135
+ * depth cap scatters some), and one of those would otherwise set the field of view for the whole
136
+ * asset.
137
+ *
138
+ * Validated against a capture whose true half-tangents are ±0.857 horizontal and ±0.482
139
+ * vertical: this returns −0.854/+0.863 and −0.480/+0.505. The asymmetry is real and is why the
140
+ * limits are kept separately rather than symmetrised — it falls straight out as `cx`/`cy`.
141
+ *
142
+ * @param {Float64Array|number[]} tx x/z per sampled splat, in rest-camera space.
143
+ * @param {Float64Array|number[]} ty y/z per sampled splat, same order.
144
+ * @param {number} n how many entries are populated.
145
+ * @returns {{intrinsics:object, focalEqMm:number}|null} null when the cloud says something that
146
+ * is not a camera — see FOCAL_EQ_MIN_MM.
147
+ */
148
+ export function estimateIntrinsics(tx, ty, n) {
149
+ if (!n || n < 64) return null;
150
+ const sx = Array.prototype.slice.call(tx, 0, n).sort((a, b) => a - b);
151
+ const sy = Array.prototype.slice.call(ty, 0, n).sort((a, b) => a - b);
152
+ const txLo = pct(sx, 0.01);
153
+ const txHi = pct(sx, 0.99);
154
+ const tyLo = pct(sy, 0.01);
155
+ const tyHi = pct(sy, 0.99);
156
+ const intrinsics = intrinsicsFromTangents(txLo, txHi, tyLo, tyHi);
157
+ if (!intrinsics) return null;
158
+ const focalEqMm = focalEq35((txHi - txLo) / 2, (tyHi - tyLo) / 2);
159
+ if (!(focalEqMm >= FOCAL_EQ_MIN_MM) || !(focalEqMm <= FOCAL_EQ_MAX_MM)) return null;
160
+ return { intrinsics, focalEqMm, tangents: { txLo, txHi, tyLo, tyHi } };
161
+ }
162
+
163
+ /**
164
+ * The last resort: a 28 mm-equivalent lens, in the orientation the cloud's extent suggests.
165
+ *
166
+ * The aspect is worth keeping even when the focal was refused, because portrait-vs-landscape is
167
+ * the one thing a wildly wrong extent still gets right, and getting it wrong crops the picture
168
+ * along the wrong axis.
169
+ */
170
+ export function fallbackIntrinsics(aspect = 4 / 3) {
171
+ const a = Number.isFinite(aspect) && aspect > 0 ? aspect : 4 / 3;
172
+ const diag = HALF_DIAGONAL_35MM / DEFAULT_FOCAL_EQ_MM;
173
+ const vTan = diag / Math.hypot(a, 1);
174
+ const hTan = a * vTan;
175
+ return intrinsicsFromTangents(-hTan, hTan, -vTan, vTan);
176
+ }
177
+
178
+ /**
179
+ * Focus distance from MEDIAN DISPARITY — the median of 1/z, inverted.
180
+ *
181
+ * Not the median of z, and the difference is the point. Disparity is what a stereo pair actually
182
+ * measures and what the depth was derived from, so its median is the scene's typical depth in the
183
+ * space where the errors are symmetric; in metres the same distribution is a long tail to
184
+ * infinity that drags any average outwards. On the reference capture this gives 2.159 m against
185
+ * the gallery's own 2.138 m from its stored median disparity — the same number by a different
186
+ * route, which is the check that matters.
187
+ *
188
+ * @param {Float64Array|number[]} invz 1/z per sampled splat (z forward, in metres).
189
+ */
190
+ export function medianDisparityDistance(invz, n) {
191
+ if (!n) return null;
192
+ const s = Array.prototype.slice.call(invz, 0, n).sort((a, b) => a - b);
193
+ const m = s.length % 2 ? s[s.length >> 1] : 0.5 * (s[s.length / 2 - 1] + s[s.length / 2]);
194
+ return m > 0 ? 1 / m : null;
195
+ }
196
+
197
+ /** Where a focus ends up when there is nothing at all to go on. */
198
+ export const DEFAULT_FOCUS_M = 2.0;
199
+
200
+ /** Sane band on a derived focus distance, in metres. */
201
+ export const FOCUS_MIN_M = 0.2;
202
+ export const FOCUS_MAX_M = 60;
203
+
204
+ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
205
+ const isVec3 = (v) => Array.isArray(v) && v.length >= 3 && v.slice(0, 3).every(Number.isFinite);
206
+
207
+ /**
208
+ * Run the whole waterfall.
209
+ *
210
+ * @param {object} args
211
+ * @param {object|null} args.camera the parsed `.sog` camera block, or null.
212
+ * @param {object} args.opts the caller's `addSplat` options.
213
+ * @param {object|null} args.cloud `{ tx, ty, invz, n }` from one pass over the splats in
214
+ * rest-camera space, or null if there was nothing to walk.
215
+ * @param {number} args.canvasAspect fallback orientation when the cloud cannot supply one.
216
+ * @returns {object} the resolved rig, every field beside the step that produced it.
217
+ */
218
+ export function resolveRig({ camera = null, opts = {}, cloud = null, canvasAspect = 4 / 3 }) {
219
+ const rest = camera?.rest ?? { position: [0, 0, 0], rotation: [0, 0, 0, 1] };
220
+
221
+ // ── rig ───────────────────────────────────────────────────────────────────────────────
222
+ let type;
223
+ let typeSource;
224
+ if (opts.rig === 'camera' || opts.rig === 'display') {
225
+ type = opts.rig;
226
+ typeSource = 'caller';
227
+ } else if (camera?.rig) {
228
+ type = camera.rig;
229
+ typeSource = 'block';
230
+ } else {
231
+ // A block at all means a camera was recorded, which only happens for a capture. `rig:
232
+ // "display"` alongside a `rest` is how an asset says "a display rig, opened at this
233
+ // viewpoint" — so the presence of the block is the default, not the override.
234
+ type = camera ? 'camera' : 'display';
235
+ typeSource = camera ? 'block-present' : 'default';
236
+ }
237
+
238
+ // ── intrinsics ────────────────────────────────────────────────────────────────────────
239
+ let intrinsics = null;
240
+ let intrinsicsSource = null;
241
+ let focalEqMm = null;
242
+ if (camera?.intrinsics) {
243
+ intrinsics = camera.intrinsics;
244
+ intrinsicsSource = 'block';
245
+ } else if (opts.intrinsics && Number.isFinite(opts.intrinsics.fx)) {
246
+ intrinsics = opts.intrinsics;
247
+ intrinsicsSource = 'caller';
248
+ } else if (cloud) {
249
+ const est = estimateIntrinsics(cloud.tx, cloud.ty, cloud.n);
250
+ if (est) {
251
+ intrinsics = est.intrinsics;
252
+ intrinsicsSource = 'estimated';
253
+ focalEqMm = est.focalEqMm;
254
+ }
255
+ }
256
+ if (!intrinsics) {
257
+ // Keep the ORIENTATION the cloud implies even when its focal was refused: portrait vs
258
+ // landscape is the part a bad extent still gets right, and getting it wrong crops the
259
+ // picture along the wrong axis.
260
+ let aspect = canvasAspect;
261
+ if (cloud && cloud.n >= 64) {
262
+ const est = intrinsicsFromTangents(
263
+ ...(() => {
264
+ const sx = Array.prototype.slice.call(cloud.tx, 0, cloud.n).sort((a, b) => a - b);
265
+ const sy = Array.prototype.slice.call(cloud.ty, 0, cloud.n).sort((a, b) => a - b);
266
+ return [pct(sx, 0.01), pct(sx, 0.99), pct(sy, 0.01), pct(sy, 0.99)];
267
+ })(),
268
+ );
269
+ if (est) aspect = est.width / est.height;
270
+ }
271
+ intrinsics = fallbackIntrinsics(aspect);
272
+ intrinsicsSource = 'fallback-28mm';
273
+ focalEqMm = DEFAULT_FOCAL_EQ_MM;
274
+ }
275
+ if (focalEqMm === null && intrinsics) {
276
+ focalEqMm = focalEq35(intrinsics.width / 2 / intrinsics.fx, intrinsics.height / 2 / intrinsics.fy);
277
+ }
278
+
279
+ // ── focus ─────────────────────────────────────────────────────────────────────────────
280
+ //
281
+ // ONE point, and it is the orbit centre, the pivot plane and the convergence distance at
282
+ // once. Resolved as a point in model space; the distance falls out of it, never the reverse.
283
+ let point = null;
284
+ let focusSource = null;
285
+ if (isVec3(opts.focus)) {
286
+ point = opts.focus.slice(0, 3);
287
+ focusSource = 'caller';
288
+ } else if (Number.isFinite(opts.convergence) && opts.convergence > 0) {
289
+ // The scalar shorthand: a focus straight ahead at this distance.
290
+ point = aheadOfRest(rest, opts.convergence);
291
+ focusSource = 'caller-convergence';
292
+ } else if (isVec3(camera?.focus?.point)) {
293
+ point = camera.focus.point.slice(0, 3);
294
+ focusSource = 'block';
295
+ } else if (cloud) {
296
+ const d = medianDisparityDistance(cloud.invz, cloud.n);
297
+ if (d) {
298
+ point = aheadOfRest(rest, clamp(d, FOCUS_MIN_M, FOCUS_MAX_M));
299
+ focusSource = 'median-disparity';
300
+ }
301
+ }
302
+ if (!point) {
303
+ point = aheadOfRest(rest, DEFAULT_FOCUS_M);
304
+ focusSource = 'default';
305
+ }
306
+
307
+ const convergence = planeDistance(rest, point);
308
+
309
+ return {
310
+ type,
311
+ typeSource,
312
+ rest,
313
+ intrinsics,
314
+ intrinsicsSource,
315
+ focalEqMm,
316
+ focus: point,
317
+ focusSource,
318
+ // Advisory, straight from the block — a host page's depth budget or HUD may want them.
319
+ focusDistances: camera?.focus
320
+ ? { subject_m: camera.focus.subject_m, near_m: camera.focus.near_m, far_m: camera.focus.far_m }
321
+ : null,
322
+ convergence,
323
+ // ABSOLUTE on a camera rig, and they stay absolute: normalising them against the convergence
324
+ // would make the scene's depth breathe every time the viewer re-focused.
325
+ ipdFactor: Number.isFinite(opts.ipdFactor) ? opts.ipdFactor : (camera?.dxr?.ipdFactor ?? 1),
326
+ parallaxFactor: Number.isFinite(opts.parallaxFactor)
327
+ ? opts.parallaxFactor
328
+ : (camera?.dxr?.parallaxFactor ?? 1),
329
+ };
330
+ }