@displayxr/inline3d 1.6.1 → 1.7.1

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,255 @@
1
+ // inline3d-splat-perf.js — cut a Gaussian splat's OVERDRAW, for `./splat`.
2
+ //
3
+ // EXPERIMENTAL. Internal to `./splat`, which re-exports `applySplatPerf` and takes `perf` as an
4
+ // option. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
5
+ //
6
+ // WHAT COSTS WHAT. On a 1.18M-gaussian photo-lifted scene the per-fragment composite is 75–85 %
7
+ // of the frame (measured natively on an M1 Pro, one eye at 1920×1080), and it is OVERDRAW, not
8
+ // resolution: a handful of enormous, nearly transparent sky splats cover the frame many times
9
+ // over. Splat COUNT is the axis everyone reaches for first and it is the wrong one — decimating
10
+ // this asset to 25 % breaks it visibly (bright stipple on near lit surfaces) while removing far
11
+ // less fill than the two changes below, which remove none of the picture at all.
12
+ //
13
+ // The two that matter, in the native renderer's words: **shrink each splat's quad to the radius
14
+ // where its alpha falls below 1/255**, instead of a fixed 3σ, and **cull gaussians whose peak
15
+ // opacity is already below 1/255**. This module is both of those on Spark.
16
+ //
17
+ // WHY THE FIRST ONE IS FREE. Spark draws every splat as a quad of `maxStdDev` σ (default √8 ≈
18
+ // 2.83σ) and its fragment shader then discards any fragment whose alpha has fallen under
19
+ // `minAlpha` — so for a splat of peak alpha `a` every fragment beyond
20
+ //
21
+ // r = sqrt(2 · ln(a / minAlpha))
22
+ //
23
+ // is ALREADY being discarded. It is rasterised, interpolated, shaded, and thrown away. Shrinking
24
+ // the quad to exactly that radius therefore removes fragments that contributed nothing: the
25
+ // output is bit-identical (see `alphaRadius` below for the two conditions), and the saving is
26
+ // biggest on exactly the splats that dominate the cost — a haze splat of a = 0.02 needs 1.81σ,
27
+ // not 2.83σ, which is 2.4× fewer fragments for the same pixels.
28
+ //
29
+ // Spark 2.1.0 has no option for that (`maxStdDev` is ONE global uniform, and nothing in the
30
+ // shader derives a radius from alpha), so this patches the vertex shader — through the supported
31
+ // `vertexShader` surface, and by REWRITING SPARK'S OWN SOURCE off the live material rather than
32
+ // shipping a copy of it, so a Spark upgrade brings its shader fixes with it instead of silently
33
+ // pinning this SDK to a fork of a 2.1.0 file. If the anchors ever stop matching, the patch
34
+ // declines with one warning and everything still renders.
35
+
36
+ /**
37
+ * Where a splat's alpha has to fall before its quad may be cut, as a fraction of full opacity.
38
+ *
39
+ * 1/255 is the native renderer's threshold and the point below which an 8-bit framebuffer cannot
40
+ * represent the contribution at all. Note Spark's own `minAlpha` default is HALF of this
41
+ * (0.5/255) — deliberately, so its discard is a shade conservative — which is why `minAlpha` is
42
+ * a knob here and not an assumption.
43
+ */
44
+ const ALPHA_FLOOR = 1 / 255;
45
+
46
+ /**
47
+ * Presets — chosen from measurements on this exact asset class, not from first principles.
48
+ *
49
+ * What the measurement said (1.18M-gaussian SHARP capture, M1 Pro, Chrome/ANGLE-Metal, GPU timer
50
+ * queries, configs interleaved PER FRAME so clock drift cannot bias one against another; full
51
+ * table in docs/authoring-inline-3d.md):
52
+ *
53
+ * - **Splat COUNT is not the cost.** 50 % and 25 % decimations of the same scene measured
54
+ * within noise of the full one (+5 %, +1 % at 1920×1080). Decimation drops the small
55
+ * gaussians; the few enormous ones that cover the frame survive it, and they are the bill.
56
+ * A decimated asset is a download and memory win, not a render-cost win.
57
+ * - **Quad extent is the cost.** `maxStdDev` √8→√6 is −5…−20 % and √8→√4 is −22 %.
58
+ * - **The bit-exact `alphaRadius` buys ~nothing HERE**, because it has nothing to shrink: 86 %
59
+ * of this asset's gaussians are near-opaque (mean peak alpha 0.86; Spark doubles the stored
60
+ * alpha on top), and an opaque splat's own 1/255 radius is 3.53σ, wider than the √8 ≈ 2.83σ
61
+ * it is already drawn at. It stays available and stays exact — a scene of large, low-alpha
62
+ * haze is exactly where it pays, and that is the scene the native renderer was tuned on.
63
+ *
64
+ * So the presets are honest about which axis works, and nothing is applied unless a caller asks:
65
+ * `addSplat` with no `perf` leaves every Spark default exactly where Spark put it.
66
+ */
67
+ export const SPLAT_PERF_PRESETS = {
68
+ /**
69
+ * The bit-exact one. No measurable win on a mostly-opaque capture; real on a scene whose cost is
70
+ * large low-alpha splats. Costs a little vertex ALU, so on a scene with nothing to shrink it can
71
+ * read as a wash or a shade slower.
72
+ */
73
+ exact: {
74
+ alphaRadius: true,
75
+ minAlpha: ALPHA_FLOOR,
76
+ },
77
+ /** −5…−20 % measured. Truncates every splat's tail at 2.45σ instead of 2.83σ. */
78
+ balanced: {
79
+ minAlpha: ALPHA_FLOOR,
80
+ maxStdDev: Math.sqrt(6),
81
+ },
82
+ /** −22 % measured. 2σ, plus the sub-pixel cull. For a phone, not for a hero. */
83
+ aggressive: {
84
+ minAlpha: ALPHA_FLOOR,
85
+ maxStdDev: 2,
86
+ // Both eigenaxes under a pixel. On the reference capture at 1280×720 and 1920×1080 this
87
+ // changed ZERO channel bytes — a quad that small usually covers no sample point at all — but
88
+ // it is framing-dependent by nature, so it is here and not in `balanced`.
89
+ minPixelRadius: 1,
90
+ },
91
+ };
92
+
93
+ /** Fields that are plain properties on SparkRenderer, copied into uniforms every frame. */
94
+ const SPARK_FIELDS = [
95
+ 'minAlpha',
96
+ 'maxStdDev',
97
+ 'minPixelRadius',
98
+ 'maxPixelRadius',
99
+ 'falloff',
100
+ // LOD budget knobs. Live, but inert unless the MESH was loaded with LOD data — see
101
+ // splatPerfMeshOptions().
102
+ 'lodSplatCount',
103
+ 'lodSplatScale',
104
+ 'lodRenderScale',
105
+ ];
106
+
107
+ // The two anchors the shader patch needs, quoted from Spark 2.1.0's `splatVertex.glsl`.
108
+ //
109
+ // `vRgba.a = rgba.a;` is the line AFTER the anti-aliasing blur has been folded into the alpha
110
+ // (`rgba.a *= blurAdjust`) — so at that point `rgba.a` is exactly the alpha the fragment shader
111
+ // will interpolate, which is what makes the derived radius exact rather than approximate. It is
112
+ // also after the eigen-decomposition's inputs are final and BEFORE `scale1`/`scale2` are taken
113
+ // from `adjustedStdDev`, which is the only window where writing it has any effect.
114
+ const ANCHOR_ALPHA = ' vRgba.a = rgba.a;\n';
115
+ const ANCHOR_UNIFORM = 'uniform float minAlpha;\n';
116
+
117
+ /**
118
+ * Rewrite Spark's splat vertex shader so each quad is only as big as its own alpha justifies.
119
+ *
120
+ * `falloff` is read (not assumed) because the whole argument rests on the fragment shader's
121
+ * `a·exp(−z²/2)` decay: at `falloff < 1` the alpha does NOT decay across the quad, nothing is
122
+ * being discarded, and cutting the quad would cut the picture. The uniform is shared between the
123
+ * two stages, so the vertex shader only has to declare it.
124
+ *
125
+ * @returns {boolean} whether the patch went in.
126
+ */
127
+ function patchAlphaRadius(material) {
128
+ const src = material?.vertexShader;
129
+ if (typeof src !== 'string') return false;
130
+ if (src.includes('dxrAlphaRadius')) return true; // idempotent — a shared material, or a re-apply
131
+ if (!src.includes(ANCHOR_ALPHA) || !src.includes(ANCHOR_UNIFORM)) {
132
+ console.warn(
133
+ '[inline3d/splat] perf.alphaRadius: this build of Spark does not have the shader lines ' +
134
+ 'this patch rewrites, so the quad-shrink is SKIPPED (everything else still applies, and ' +
135
+ 'the picture is unchanged). Report the Spark version — the anchors are versioned in ' +
136
+ 'js/inline3d-splat-perf.js.',
137
+ );
138
+ return false;
139
+ }
140
+ material.vertexShader = src
141
+ .replace(
142
+ ANCHOR_UNIFORM,
143
+ `${ANCHOR_UNIFORM}uniform float falloff;\nuniform bool dxrAlphaRadius;\nuniform float dxrAlphaFloor;\n`,
144
+ )
145
+ .replace(
146
+ ANCHOR_ALPHA,
147
+ `${ANCHOR_ALPHA}
148
+ // @displayxr/inline3d: shrink the quad to where this splat's own alpha reaches minAlpha.
149
+ // Every fragment outside that radius is discarded by the fragment shader anyway, so this
150
+ // removes work and not pixels. Guarded on falloff == 1, which is what makes that true.
151
+ if (dxrAlphaRadius && (falloff == 1.0) && (rgba.a <= 1.0)) {
152
+ float floorA = max(dxrAlphaFloor > 0.0 ? dxrAlphaFloor : minAlpha, 1e-6);
153
+ adjustedStdDev = min(adjustedStdDev, sqrt(max(0.0, 2.0 * log(rgba.a / floorA))));
154
+ vSplatUv = position.xy * adjustedStdDev;
155
+ }
156
+ `,
157
+ );
158
+ material.uniforms.dxrAlphaRadius = { value: true };
159
+ // 0 means "use minAlpha", which is the bit-exact cut. See `alphaFloor`.
160
+ material.uniforms.dxrAlphaFloor = { value: 0 };
161
+ material.needsUpdate = true;
162
+ return true;
163
+ }
164
+
165
+ /**
166
+ * Apply a perf profile to a live `SparkRenderer`.
167
+ *
168
+ * Exported for pages that build their own Spark renderer instead of going through `addSplat`
169
+ * — the knobs are all live, so this can be called at any time (a quality menu, a battery-saver
170
+ * toggle) and takes effect on the next frame.
171
+ *
172
+ * | option | default (Spark 2.1.0) | effect | safety |
173
+ * |---|---|---|---|
174
+ * | `alphaRadius` | — (no such thing) | quad shrunk to the splat's own `alphaFloor` radius | **bit-exact** at the default floor, see below |
175
+ * | `alphaFloor` | — (= `minAlpha`) | the alpha the tail may be cut at, PER SPLAT | lossy above `minAlpha`, and gently: it spends radius where the splat is opaque and takes it where it is not |
176
+ * | `minAlpha` | `0.5/255` | splats and fragments under this alpha are dropped | lossy under 1 LSB |
177
+ * | `maxStdDev` | `√8` | quad extent in σ, globally | lossy: truncates opaque tails |
178
+ * | `minPixelRadius` | `0` | drop splats under this size in px | lossy: drops fine grain |
179
+ * | `maxPixelRadius` | `512` | clamp on quad size in px | lossy, and it SQUASHES: the profile is compressed into the smaller quad, not clipped |
180
+ * | `falloff` | `1` | 1 = Gaussian, 0 = flat | NOT a perf knob — 0 makes the fragment discard stop firing, which costs MORE |
181
+ *
182
+ * BIT-EXACT, and the two conditions on that word. `alphaRadius` only removes fragments the
183
+ * fragment shader was already discarding, so the composited image does not change — provided
184
+ * `falloff` is 1 (the shader guards this itself) and `minPixelRadius` is 0. With a non-zero
185
+ * `minPixelRadius` the shrunken quad can fall under it and the splat is then dropped outright,
186
+ * which is a real (small) change; that is why `balanced` leaves it at 0. At the discard boundary
187
+ * itself the two sides can disagree by one float ULP, where the fragment's own contribution is
188
+ * below 1/255 by construction — invisible in 8 bits, but "bit-exact" is stated with that caveat
189
+ * rather than without it.
190
+ *
191
+ * @param {object} spark a SparkRenderer.
192
+ * @param {true|'balanced'|'aggressive'|object} perf
193
+ * @returns {object|null} the profile actually applied.
194
+ */
195
+ export function applySplatPerf(spark, perf) {
196
+ if (!spark || !perf) return null;
197
+ let profile;
198
+ if (perf === true) profile = SPLAT_PERF_PRESETS.balanced;
199
+ else if (typeof perf === 'string') profile = SPLAT_PERF_PRESETS[perf];
200
+ else if (typeof perf === 'object') profile = perf;
201
+ if (!profile) {
202
+ console.warn(
203
+ `[inline3d/splat] unknown perf preset "${perf}" — ignored. ` +
204
+ `Known: ${Object.keys(SPLAT_PERF_PRESETS).join(', ')}, or an options object.`,
205
+ );
206
+ return null;
207
+ }
208
+
209
+ const applied = {};
210
+ for (const key of SPARK_FIELDS) {
211
+ if (typeof profile[key] === 'number' && Number.isFinite(profile[key])) {
212
+ spark[key] = profile[key];
213
+ applied[key] = profile[key];
214
+ }
215
+ }
216
+ // `alphaFloor` is meaningless on its own — it is the floor the shrink cuts at — so asking for
217
+ // one asks for the shrink, unless the caller said otherwise in the same breath.
218
+ const wantRadius = profile.alphaRadius ?? (profile.alphaFloor !== undefined ? true : undefined);
219
+ if (wantRadius) patchAlphaRadius(spark.material);
220
+ const u = spark.material?.uniforms;
221
+ if (u?.dxrAlphaRadius && wantRadius !== undefined) {
222
+ // Set the value every time rather than relying on the patch: the patch is idempotent, so a
223
+ // second call asking to turn it back ON would otherwise return early and leave it off.
224
+ u.dxrAlphaRadius.value = !!wantRadius;
225
+ applied.alphaRadius = !!wantRadius;
226
+ }
227
+ if (u?.dxrAlphaFloor && profile.alphaFloor !== undefined) {
228
+ u.dxrAlphaFloor.value = Number.isFinite(profile.alphaFloor) ? profile.alphaFloor : 0;
229
+ applied.alphaFloor = u.dxrAlphaFloor.value;
230
+ }
231
+ return applied;
232
+ }
233
+
234
+ /**
235
+ * The half of a perf profile that has to go into the `SplatMesh` CONSTRUCTOR rather than onto the
236
+ * renderer — Spark builds level-of-detail data at load time or not at all.
237
+ *
238
+ * LOD is the splat-COUNT axis: Spark keeps a merged, decimated pyramid and picks a level against
239
+ * a budget (2.5M splats on desktop, 1M on Android) and a minimum on-screen splat size. It is
240
+ * genuinely lossy — it substitutes merged splats — and it is inert unless the mesh was loaded
241
+ * with it, which is why it cannot be switched on later from `applySplatPerf`.
242
+ *
243
+ * @param {object} perf
244
+ * @returns {object} extra SplatMesh options (empty when LOD was not asked for).
245
+ */
246
+ export function splatPerfMeshOptions(perf) {
247
+ const profile =
248
+ perf === true
249
+ ? SPLAT_PERF_PRESETS.balanced
250
+ : typeof perf === 'string'
251
+ ? SPLAT_PERF_PRESETS[perf]
252
+ : perf;
253
+ if (!profile || !profile.lod) return {};
254
+ return { lod: profile.lod === 'quality' ? 'quality' : true };
255
+ }
@@ -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
+ }