@displayxr/inline3d 1.0.0 → 1.1.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 +132 -0
- package/README.md +10 -1
- package/index.d.ts +62 -5
- package/js/inline3d-model.js +169 -0
- package/js/inline3d-splat.js +333 -0
- package/js/inline3d-viewer.js +553 -0
- package/js/inline3d.js +628 -19
- package/model.d.ts +64 -0
- package/package.json +25 -2
- package/splat.d.ts +91 -0
- package/viewer.d.ts +94 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// inline3d-splat.js — a 3D Gaussian splat as an inline-3D window, in one call.
|
|
2
|
+
//
|
|
3
|
+
// EXPERIMENTAL. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
|
|
4
|
+
//
|
|
5
|
+
// import { createInline3D } from '@displayxr/inline3d';
|
|
6
|
+
// import { addSplat } from '@displayxr/inline3d/splat';
|
|
7
|
+
//
|
|
8
|
+
// const wall = await createInline3D();
|
|
9
|
+
// const shoe = await addSplat(wall, canvas, 'trail-runner.sog', { virtualDisplayHeight: 0.18 });
|
|
10
|
+
// shoe.exclude(document.getElementById('buy')); // crisp 2D button over the woven 3D
|
|
11
|
+
//
|
|
12
|
+
// Pass the wall whether or not inline-3D is supported: on an ordinary browser this renders a
|
|
13
|
+
// flat, orbitable view of the same asset, so a page needs no branch. Splats are photoreal in a
|
|
14
|
+
// way meshes are not for captured goods — leather grain, knit mesh, foil, glitter — which is
|
|
15
|
+
// exactly the material range that sells a product.
|
|
16
|
+
//
|
|
17
|
+
// Requires `three` (>=0.180, Spark's floor) and `@sparkjsdev/spark` as peers. Both are declared
|
|
18
|
+
// OPTIONAL in package.json: the core SDK stays dependency-free and only pages that import this
|
|
19
|
+
// subpath pay for them.
|
|
20
|
+
|
|
21
|
+
import * as THREE from 'three';
|
|
22
|
+
import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
|
|
23
|
+
import { EyeCamera, EdgeFeather } from './inline3d-three.js';
|
|
24
|
+
import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sort at most this often, in ms. THE stereo optimisation in this module.
|
|
28
|
+
*
|
|
29
|
+
* Spark sorts splats back-to-front per render() call, and a stereo frame renders twice — so
|
|
30
|
+
* the default of 0 buys two full sorts per frame. The eyes are ~63 mm apart; that does not
|
|
31
|
+
* meaningfully change back-to-front order for a tabletop-sized subject, so one sort serves
|
|
32
|
+
* both. 16 ms lands it at one per frame at 60 Hz.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_SORT_INTERVAL_MS = 16;
|
|
35
|
+
|
|
36
|
+
/** Cap on how many splat centres the fallback framing pass inspects. */
|
|
37
|
+
const FRAME_SAMPLE_CAP = 200000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* three.js floor for THIS subpath — Spark's own floor, above the package-wide >=0.150 that the
|
|
41
|
+
* core and ./three ask for.
|
|
42
|
+
*
|
|
43
|
+
* npm cannot express a peer range per export, so the manifest has to state the LOWER bound and a
|
|
44
|
+
* consumer on 0.16x installs cleanly, then fails somewhere inside a Spark worker with a message
|
|
45
|
+
* about neither three nor versions. Checking here turns that into one sentence naming the actual
|
|
46
|
+
* problem. Kept as a number: THREE.REVISION is a bare string like "180", not a semver triple.
|
|
47
|
+
*/
|
|
48
|
+
const THREE_MIN_REVISION = 180;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Identify a splat container from its first bytes.
|
|
52
|
+
*
|
|
53
|
+
* Spark resolves a file's format from the URL PATH, and has a magic-byte sniffer it does not
|
|
54
|
+
* apply to the fileBytes route — so bytes arrive as "Unknown file type" unless someone says what
|
|
55
|
+
* they are. That is a trap for exactly the interesting case: a URL ending in `.sog` loads fine
|
|
56
|
+
* while the identical bytes in a Blob do not.
|
|
57
|
+
*
|
|
58
|
+
* Rather than make every caller know Spark's type names (which are not the file extensions —
|
|
59
|
+
* a `.sog` is `pcsogszip`), work it out here.
|
|
60
|
+
*/
|
|
61
|
+
function sniffFileType(bytes) {
|
|
62
|
+
if (!bytes || bytes.length < 4) return undefined;
|
|
63
|
+
const [b0, b1, b2, b3] = bytes;
|
|
64
|
+
// PK 03 04 — a PKZip. A .sog from splat-transform is a zip of webp planes + meta.json.
|
|
65
|
+
if (b0 === 0x50 && b1 === 0x4b && b2 === 0x03 && b3 === 0x04) return 'pcsogszip';
|
|
66
|
+
// "ply" — ASCII header
|
|
67
|
+
if (b0 === 0x70 && b1 === 0x6c && b2 === 0x79) return 'ply';
|
|
68
|
+
// gzip — .spz is gzipped
|
|
69
|
+
if (b0 === 0x1f && b1 === 0x8b) return 'spz';
|
|
70
|
+
// "RAD0"
|
|
71
|
+
if (b0 === 0x52 && b1 === 0x41 && b2 === 0x44 && b3 === 0x30) return 'rad';
|
|
72
|
+
// .splat / .ksplat are raw arrays with no magic — indistinguishable by content, which is
|
|
73
|
+
// exactly what `fileName` is for.
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load a splat into an inline-3D window.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} wall the manager from createInline3D(), supported or not.
|
|
81
|
+
* @param {HTMLCanvasElement} canvas
|
|
82
|
+
* @param {string} src URL of a .sog / .spz / .ply / .splat / .ksplat.
|
|
83
|
+
* @param {object} [opts]
|
|
84
|
+
* @param {number} [opts.virtualDisplayHeight=0.24] metres of world the tile's height spans.
|
|
85
|
+
* @param {{center:number[],extent:number[]}} [opts.frame] precomputed subject bounds. STRONGLY
|
|
86
|
+
* preferred — see "Framing" below.
|
|
87
|
+
* @param {boolean} [opts.flipY=true] apply the 180° X flip that most splat exports need.
|
|
88
|
+
* @param {number} [opts.idleSpin=8] degrees/second of turntable once idle. 0 to disable.
|
|
89
|
+
* @param {boolean} [opts.orbit=true] drag to spin, wheel to zoom.
|
|
90
|
+
* @param {'contain'|'height'|'cover'|'none'} [opts.fit='contain']
|
|
91
|
+
* @param {number} [opts.margin=0.8] fraction of the tile the subject may occupy — neither its
|
|
92
|
+
* width nor its height exceeds this, whatever its proportions.
|
|
93
|
+
* @param {number} [opts.depthLimit=4.0] backstop on total depth; rarely binds.
|
|
94
|
+
* @param {boolean} [opts.fitSweep=true] fit the horizontal against the box's diagonal, so a
|
|
95
|
+
* long subject still fits once the turntable turns it.
|
|
96
|
+
* @param {number} [opts.renderScale=1] per-eye buffer scale; 0.5–0.7 is usually free.
|
|
97
|
+
* @param {number} [opts.feather=0] edge fade in buffer px.
|
|
98
|
+
* @param {number} [opts.sortIntervalMs=16] see DEFAULT_SORT_INTERVAL_MS.
|
|
99
|
+
* @param {Element} [opts.observe=canvas] element whose visibility gates the lazy lifecycle.
|
|
100
|
+
* @returns {object} a TileHandle (remove/exclude/unexclude) plus `viewer`, `mesh`, `setPose`,
|
|
101
|
+
* `resetPose`, `frame` (the bounds used, null until loaded) and `ready` (a promise).
|
|
102
|
+
* SYNCHRONOUS on purpose — it mirrors addImage, so a caller can wire up overlays and
|
|
103
|
+
* controls immediately instead of awaiting a download first.
|
|
104
|
+
*
|
|
105
|
+
* FRAMING. A splat has no natural "front" or size, so something must decide where the subject
|
|
106
|
+
* is and how big to draw it. Pass `opts.frame` when you can: the native pipeline already
|
|
107
|
+
* computes exactly these bounds with an opacity-weighted voxel flood-fill that separates the
|
|
108
|
+
* subject from an air-gapped background, and baking that into a sidecar at conversion time
|
|
109
|
+
* costs the page nothing. Without it we fall back to trimmed percentile bounds computed here —
|
|
110
|
+
* good enough for a clean, isolated capture, weaker on a scene with a background wall.
|
|
111
|
+
*/
|
|
112
|
+
export function addSplat(wall, canvas, src, opts = {}) {
|
|
113
|
+
// Fail here, synchronously, and not through `ready`: a peer too old is an install-time mistake
|
|
114
|
+
// in the page's dependencies, not a condition of this asset, and it will be true of every call.
|
|
115
|
+
// Surfacing it as a load rejection would let a caller render an "asset unavailable" placeholder
|
|
116
|
+
// over what is really a version problem.
|
|
117
|
+
const rev = parseInt(THREE.REVISION, 10);
|
|
118
|
+
if (Number.isFinite(rev) && rev < THREE_MIN_REVISION) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`@displayxr/inline3d/splat needs three >= 0.${THREE_MIN_REVISION} (Spark's floor); ` +
|
|
121
|
+
`found 0.${THREE.REVISION}. The package-wide peer range is >=0.150 because the core and ` +
|
|
122
|
+
`./three work there — this subpath does not. Upgrade three, or use ./model for meshes.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const {
|
|
127
|
+
virtualDisplayHeight = 0.24,
|
|
128
|
+
frame = null,
|
|
129
|
+
flipY = true,
|
|
130
|
+
idleSpin = 8,
|
|
131
|
+
orbit = true,
|
|
132
|
+
fit = 'contain',
|
|
133
|
+
margin = 0.8,
|
|
134
|
+
depthLimit = 4.0,
|
|
135
|
+
fitSweep = true,
|
|
136
|
+
renderScale = 1,
|
|
137
|
+
feather = 0,
|
|
138
|
+
sortIntervalMs = DEFAULT_SORT_INTERVAL_MS,
|
|
139
|
+
fileName,
|
|
140
|
+
fileType,
|
|
141
|
+
observe,
|
|
142
|
+
} = opts;
|
|
143
|
+
|
|
144
|
+
const viewer = new SceneViewer(THREE, canvas, {
|
|
145
|
+
virtualDisplayHeight,
|
|
146
|
+
fit,
|
|
147
|
+
margin,
|
|
148
|
+
depthLimit,
|
|
149
|
+
fitSweep,
|
|
150
|
+
orbit,
|
|
151
|
+
idleSpin,
|
|
152
|
+
renderScale,
|
|
153
|
+
feather,
|
|
154
|
+
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
155
|
+
|
|
156
|
+
// Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
|
|
157
|
+
// sort against each other — which is what lets a product page mix a captured hero with a
|
|
158
|
+
// GLB accessory in one scene.
|
|
159
|
+
const spark = new SparkRenderer({ renderer: viewer.renderer, minSortIntervalMs: sortIntervalMs });
|
|
160
|
+
viewer.scene.add(spark);
|
|
161
|
+
|
|
162
|
+
// THE HANDLE IS DECLARED BEFORE THE LOADER, and that is load-bearing — not style.
|
|
163
|
+
//
|
|
164
|
+
// The loader below is an async IIFE that assigns `out.mesh`. An async function body runs
|
|
165
|
+
// SYNCHRONOUSLY up to its first `await`, and the URL path has no await at all: `init = {url}`,
|
|
166
|
+
// construct, add to the scene, assign. So with `out` declared after it, that assignment lands
|
|
167
|
+
// in `out`'s temporal dead zone and throws ReferenceError — on the URL path only, which is
|
|
168
|
+
// every ordinary page, while the Blob path (which awaits arrayBuffer()) sails through.
|
|
169
|
+
//
|
|
170
|
+
// The failure was near-invisible and cost days: the throw escapes into meshReady, so `ready`
|
|
171
|
+
// rejects while the mesh is ALREADY in the scene from the line above — the splat renders, just
|
|
172
|
+
// never framed, i.e. at raw model scale. A subject that reads "far too large" with no error on
|
|
173
|
+
// the console and a fit pipeline that provably never executed.
|
|
174
|
+
let handle = null;
|
|
175
|
+
const out = {
|
|
176
|
+
viewer,
|
|
177
|
+
// null until the bytes are read and the mesh is constructed; use `ready` to await it.
|
|
178
|
+
mesh: null,
|
|
179
|
+
spark,
|
|
180
|
+
frame: null,
|
|
181
|
+
setPose: (p) => viewer.setPose(p),
|
|
182
|
+
resetPose: () => viewer.resetPose(),
|
|
183
|
+
remove() {
|
|
184
|
+
handle?.remove();
|
|
185
|
+
viewer.dispose();
|
|
186
|
+
out.mesh?.dispose?.();
|
|
187
|
+
},
|
|
188
|
+
exclude: (el) => handle?.exclude(el),
|
|
189
|
+
unexclude: (el) => handle?.unexclude(el),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// `src` may be a URL or the bytes themselves.
|
|
193
|
+
//
|
|
194
|
+
// Bytes matter for anything GENERATED rather than fetched: a freshly converted splat lives in
|
|
195
|
+
// a Blob, and the obvious move — URL.createObjectURL() — hands Spark a `blob:…` URL with no
|
|
196
|
+
// extension. Spark infers format partly from the URL, so that fails with "Unknown file type"
|
|
197
|
+
// from inside its worker, which reads like a corrupt file rather than a missing hint. Passing
|
|
198
|
+
// fileBytes lets it sniff the content instead. `fileName` is only needed to disambiguate
|
|
199
|
+
// .splat/.ksplat, which content-sniffing cannot separate.
|
|
200
|
+
let mesh = null;
|
|
201
|
+
const meshReady = (async () => {
|
|
202
|
+
let init;
|
|
203
|
+
if (typeof src === 'string') {
|
|
204
|
+
init = { url: src };
|
|
205
|
+
} else {
|
|
206
|
+
const buf = src instanceof Blob ? await src.arrayBuffer() : src;
|
|
207
|
+
const fileBytes = new Uint8Array(buf);
|
|
208
|
+
const sniffed = fileType || sniffFileType(fileBytes);
|
|
209
|
+
init = {
|
|
210
|
+
fileBytes,
|
|
211
|
+
...(sniffed ? { fileType: sniffed } : {}),
|
|
212
|
+
...(fileName ? { fileName } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
mesh = new SplatMesh(init);
|
|
216
|
+
// Most exporters write splats Y-down (the original 3DGS convention); three.js is Y-up.
|
|
217
|
+
// Without this every capture arrives upside down, which reads as a broken asset rather than
|
|
218
|
+
// a convention mismatch. w=0,x=1 is a half turn about X.
|
|
219
|
+
if (flipY) mesh.quaternion.set(1, 0, 0, 0);
|
|
220
|
+
viewer.content.add(mesh);
|
|
221
|
+
out.mesh = mesh;
|
|
222
|
+
return mesh;
|
|
223
|
+
})();
|
|
224
|
+
|
|
225
|
+
// Create the window NOW and frame it when the asset lands. Waiting for the load first would
|
|
226
|
+
// mean a grid of tiles appears one at a time in download order — and it is how addImage
|
|
227
|
+
// already behaves: return a handle immediately, paint when the source is ready.
|
|
228
|
+
if (wall && wall.supported) {
|
|
229
|
+
handle = wall.addScene(canvas, viewer.onFrame, {
|
|
230
|
+
virtualDisplayHeight,
|
|
231
|
+
...(observe ? { observe } : {}),
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
viewer.startMono();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Await the MESH first, then its load. Reading `mesh.initialized` here directly would
|
|
238
|
+
// dereference null: constructing from bytes is async (the Blob has to be read), so `mesh` does
|
|
239
|
+
// not exist yet on this line — only inside meshReady.
|
|
240
|
+
out.ready = meshReady
|
|
241
|
+
.then((m) => m.initialized)
|
|
242
|
+
.then(() => {
|
|
243
|
+
// MEASURE FIRST, always. `frame` is only a fallback.
|
|
244
|
+
//
|
|
245
|
+
// A supplied frame has to survive two coordinate changes to be usable — the converter's
|
|
246
|
+
// space to the file's, and the file's to whatever the loader normalises to internally —
|
|
247
|
+
// and getting either wrong produces a subject that is mis-scaled and off-centre with no
|
|
248
|
+
// error anywhere. That was got wrong twice here. Measuring the splats as they actually
|
|
249
|
+
// sit in the loaded mesh cannot be in the wrong space by construction: it reads the same
|
|
250
|
+
// positions the renderer draws. It costs one pass over (a sample of) the centres at load,
|
|
251
|
+
// which is what the working reference sample has always done.
|
|
252
|
+
const bounds = measureBounds(out.mesh, THREE) || (frame ? liftBounds(frame, out.mesh, THREE) : null);
|
|
253
|
+
if (bounds) {
|
|
254
|
+
out.frame = bounds;
|
|
255
|
+
viewer.fitTo(bounds.center, bounds.extent);
|
|
256
|
+
} else {
|
|
257
|
+
// Unframed means drawn at raw MODEL scale, which for a typical capture is several times
|
|
258
|
+
// the tile. Say so: silence here is what made the same condition read as a fit bug.
|
|
259
|
+
console.warn('[inline3d/splat] no usable bounds — subject is UNFRAMED (model scale)', src);
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
})
|
|
263
|
+
.catch((err) => {
|
|
264
|
+
// A failed load must not take the page down: `ready` rejects and the caller decides whether
|
|
265
|
+
// that is a placeholder or an error state.
|
|
266
|
+
//
|
|
267
|
+
// Detach the mesh, because failure can happen AFTER it joined the scene — and an unframed
|
|
268
|
+
// mesh is not a blank tile, it is a subject at model scale spilling out of the window. An
|
|
269
|
+
// error state the caller paints over a giant splat is worse than an empty one.
|
|
270
|
+
if (mesh) viewer.content.remove(mesh);
|
|
271
|
+
console.warn('[inline3d/splat] failed to load', src, err);
|
|
272
|
+
throw err;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Percentile bounds from the loaded splats — the fallback when no sidecar was supplied.
|
|
280
|
+
*
|
|
281
|
+
* Two cheats keep this off the critical path. Near-transparent splats are skipped: they are
|
|
282
|
+
* overwhelmingly haze and floaters, and including them drags the box outwards. And above
|
|
283
|
+
* FRAME_SAMPLE_CAP we stride: percentiles of a uniform subsample of 200k points are
|
|
284
|
+
* indistinguishable from percentiles of two million, at a tenth of the cost.
|
|
285
|
+
*
|
|
286
|
+
* The result is lifted out of the mesh's LOCAL space through its own matrix, because by the
|
|
287
|
+
* time this runs the Y-flip is already on the mesh — and the viewer centres content one level
|
|
288
|
+
* above it. Skip that and every flipped capture frames to a point mirrored through the origin,
|
|
289
|
+
* which looks like the subject drifting off the tile for no reason. Extents ride the matrix
|
|
290
|
+
* columns rather than being re-projected onto world axes: same convention the native
|
|
291
|
+
* ComputeAutoFrame uses, and exact for the axis-aligned flips that actually occur.
|
|
292
|
+
*/
|
|
293
|
+
/** Map model-space bounds through a mesh's own transform, matching the native ComputeAutoFrame. */
|
|
294
|
+
function liftBounds(b, mesh, THREE) {
|
|
295
|
+
if (!b || !mesh) return b;
|
|
296
|
+
mesh.updateMatrix();
|
|
297
|
+
const m = mesh.matrix;
|
|
298
|
+
const c = new THREE.Vector3(b.center[0], b.center[1], b.center[2]).applyMatrix4(m);
|
|
299
|
+
const col = new THREE.Vector3();
|
|
300
|
+
const e = [0, 1, 2].map((axis) => col.setFromMatrixColumn(m, axis).length() * b.extent[axis]);
|
|
301
|
+
return { center: [c.x, c.y, c.z], extent: e };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function measureSplatBounds(mesh, three = THREE) {
|
|
305
|
+
return measureBounds(mesh, three);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function measureBounds(mesh, THREE) {
|
|
309
|
+
const total = mesh.numSplats || 0;
|
|
310
|
+
if (!total) return null;
|
|
311
|
+
const stride = Math.max(1, Math.ceil(total / FRAME_SAMPLE_CAP));
|
|
312
|
+
const xyz = new Float32Array(Math.ceil(total / stride) * 3);
|
|
313
|
+
let k = 0;
|
|
314
|
+
mesh.forEachSplat((index, center, scales, quaternion, opacity) => {
|
|
315
|
+
if (index % stride !== 0) return;
|
|
316
|
+
if (opacity !== undefined && opacity < 0.05) return;
|
|
317
|
+
if (k + 3 > xyz.length) return;
|
|
318
|
+
xyz[k++] = center.x;
|
|
319
|
+
xyz[k++] = center.y;
|
|
320
|
+
xyz[k++] = center.z;
|
|
321
|
+
});
|
|
322
|
+
const local = boundsFromPositions(xyz.subarray(0, k));
|
|
323
|
+
if (!local) return null;
|
|
324
|
+
|
|
325
|
+
mesh.updateMatrix();
|
|
326
|
+
const m = mesh.matrix;
|
|
327
|
+
const c = new THREE.Vector3(local.center[0], local.center[1], local.center[2]).applyMatrix4(m);
|
|
328
|
+
const col = new THREE.Vector3();
|
|
329
|
+
const e = [0, 1, 2].map(
|
|
330
|
+
(axis) => col.setFromMatrixColumn(m, axis).length() * local.extent[axis],
|
|
331
|
+
);
|
|
332
|
+
return { center: [c.x, c.y, c.z], extent: e };
|
|
333
|
+
}
|