@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.
- package/CHANGELOG.md +98 -0
- package/README.md +1 -0
- package/js/inline3d-sog.js +295 -0
- package/js/inline3d-splat-perf.js +255 -0
- package/js/inline3d-splat-rig.js +330 -0
- package/js/inline3d-splat.js +481 -8
- package/js/inline3d-viewer.js +125 -2
- package/package.json +4 -1
- package/splat.d.ts +164 -0
- package/viewer.d.ts +16 -0
|
@@ -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
|
+
}
|