@displayxr/inline3d 1.0.0 → 1.1.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,754 @@
1
+ // inline3d-viewer.js — a framed, orbitable three.js object inside an inline-3D window.
2
+ //
3
+ // EXPERIMENTAL. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
4
+ //
5
+ // inline3d.js hands a scene window the two eye XRViews each frame and stops there: what you
6
+ // render is your problem. That is the right boundary for the core, but every product that
7
+ // shows "one object in a tile, look around it, drag to spin" then rewrites the same five
8
+ // things — and gets at least one of them subtly wrong. This module is those five things:
9
+ //
10
+ // 1. The side-by-side render loop, with the pixelRatio/viewport rule that fails deceptively.
11
+ // 2. Auto-FRAMING: put the subject at z=0 (the zero-disparity plane, i.e. in focus) and size
12
+ // it to the tile — including the depth clamp that a naive "fit" forgets.
13
+ // 3. Orbit + zoom, rotating about the SUBJECT rather than the world origin.
14
+ // 4. An idle turntable, because a still product reads as a photo.
15
+ // 5. The mono fallback, so the same page works in any browser.
16
+ //
17
+ // It is content-agnostic: put anything in `viewer.content`. `./splat` and `./model` are thin
18
+ // wrappers that load an asset into it. Use this directly if you have your own three.js content
19
+ // and just want the framing and interaction.
20
+ //
21
+ // import * as THREE from 'three';
22
+ // import { createInline3D } from '@displayxr/inline3d';
23
+ // import { EyeCamera } from '@displayxr/inline3d/three';
24
+ // import { SceneViewer } from '@displayxr/inline3d/viewer';
25
+ //
26
+ // const viewer = new SceneViewer(THREE, canvas, { virtualDisplayHeight: 0.18 });
27
+ // viewer.useEyeCamera(EyeCamera); // REQUIRED for stereo; ./splat and ./model do it
28
+ // viewer.content.add(myMesh);
29
+ // viewer.fitTo(center, extent); // model-space bounds of the subject
30
+ // const wall = await createInline3D();
31
+ // if (wall.supported) wall.addScene(canvas, viewer.onFrame, { virtualDisplayHeight: 0.18 });
32
+ // else viewer.startMono();
33
+ //
34
+ // WHY FRAMING IS SCENE-GRAPH WORK AND NOT A RIG FIELD. The native display rig
35
+ // (XrDisplayRigDXR) carries a POSE as well as a virtual display height, and the native viewers
36
+ // auto-frame by setting both: pose.position = the subject's centre, virtualDisplayHeight = its
37
+ // extent. The web session exposes only the height. That costs nothing, because the SDK's
38
+ // authoring contract already says "put focused content at z=0" — so we move the content to the
39
+ // origin and scale it, instead of moving the display to the content. Identical framing, no
40
+ // browser or runtime change.
41
+
42
+ /**
43
+ * Backstop on total subject depth, as a multiple of the display height. Generous on purpose:
44
+ * depth placement is a z decision (see fitTo), not a scale one, so this only catches the
45
+ * pathological case where a subject is so deep that no placement helps.
46
+ */
47
+ const DEFAULT_DEPTH_LIMIT = 4.0;
48
+ /** Milliseconds of no interaction before the idle turntable starts. */
49
+ const IDLE_DELAY_MS = 2500;
50
+
51
+ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
52
+
53
+ /**
54
+ * Robust model-space bounds from a flat array of splat/vertex centres.
55
+ *
56
+ * TWO STAGES, because one percentile box cannot do both jobs. A raw min/max is useless on
57
+ * captured content — one stray floater a hundred metres out and the subject shrinks to a speck —
58
+ * but a trimmed box is equally useless as an EXTENT, because the tail it drops on a dense subject
59
+ * is the subject's own outer shell. Trimming 5% per axis under-reported seven scanned products by
60
+ * 10-15%, which the fit then faithfully turned into a subject overflowing its tile.
61
+ *
62
+ * So: percentiles REJECT, true min/max MEASURES.
63
+ * 1. Percentile core (lo..hi per axis) — an outlier-proof estimate of where the subject is.
64
+ * 2. True min/max over centres inside `expand` x that core, centred on it.
65
+ * A floater sits orders of magnitude outside the core and is still rejected; a shell splat sits
66
+ * just past the percentile cut and is now kept.
67
+ *
68
+ * This is the CHEAP path. The native viewers additionally run an opacity-weighted voxel
69
+ * flood-fill (`getMainObjectBounds`) that isolates the dominant contiguous object from an
70
+ * air-gap-separated background — which matters on image→splat scenes, where the background
71
+ * wall is part of the reconstruction. That is deliberately NOT reimplemented here: it wants
72
+ * every centre resident and a 64³ pass before the first frame. Compute it at conversion time
73
+ * and pass the result to `fitTo()` instead; fall back to this when there is no such sidecar.
74
+ * (Precedent: the Adreno/mobile native renderer ships exactly this percentile-only path.)
75
+ *
76
+ * @param {ArrayLike<number>} xyz flat [x,y,z, x,y,z, …] centres in model space.
77
+ * @param {object} [opts]
78
+ * @param {number} [opts.lo=0.05] lower percentile bounding the rejection core.
79
+ * @param {number} [opts.hi=0.95] upper percentile bounding the rejection core.
80
+ * @param {number} [opts.expand=2.5] how many core-extents wide the acceptance window is. A real
81
+ * subject reaches well past its own percentile core; a floater does not sit at 2.5x it.
82
+ * Set 0 to get the old percentile-only box back.
83
+ * @returns {{center:number[], extent:number[]}|null} null if there is nothing to measure.
84
+ */
85
+ export function boundsFromPositions(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 } = {}) {
86
+ const n = Math.floor(xyz.length / 3);
87
+ if (n < 1) return null;
88
+ // Below a few hundred points the percentiles are noise — just use the true box.
89
+ const trim = n >= 512;
90
+ const center = [0, 0, 0];
91
+ const extent = [0, 0, 0];
92
+ const axisVals = new Float64Array(n);
93
+ for (let axis = 0; axis < 3; axis++) {
94
+ for (let i = 0; i < n; i++) axisVals[i] = xyz[i * 3 + axis];
95
+ // TypedArray sort is numeric and in-place — no comparator, no copy. That matters here:
96
+ // this runs over every splat centre, and a boxed Array round-trip on a 500k-splat model
97
+ // is the difference between a hitch and an imperceptible pause.
98
+ const sorted = axisVals.sort();
99
+ const loV = trim ? sorted[Math.floor(lo * (n - 1))] : sorted[0];
100
+ const hiV = trim ? sorted[Math.floor(hi * (n - 1))] : sorted[n - 1];
101
+ center[axis] = 0.5 * (loV + hiV);
102
+ extent[axis] = Math.max(hiV - loV, 1e-6);
103
+ }
104
+ // Untrimmed already IS the true box, and expand 0 asks for the old behaviour.
105
+ if (!trim || expand <= 0) return { center, extent };
106
+
107
+ // Stage 2. Note the window is per-axis but membership is joint: a point must be inside on all
108
+ // three axes to count, so a distant floater cannot widen one axis while sitting far off another.
109
+ const wLo = [0, 0, 0];
110
+ const wHi = [0, 0, 0];
111
+ for (let a = 0; a < 3; a++) {
112
+ const half = 0.5 * expand * extent[a];
113
+ wLo[a] = center[a] - half;
114
+ wHi[a] = center[a] + half;
115
+ }
116
+ const tLo = [Infinity, Infinity, Infinity];
117
+ const tHi = [-Infinity, -Infinity, -Infinity];
118
+ let kept = 0;
119
+ for (let i = 0; i < n; i++) {
120
+ const x = xyz[i * 3], y = xyz[i * 3 + 1], z = xyz[i * 3 + 2];
121
+ if (x < wLo[0] || x > wHi[0] || y < wLo[1] || y > wHi[1] || z < wLo[2] || z > wHi[2]) continue;
122
+ kept++;
123
+ if (x < tLo[0]) tLo[0] = x; if (x > tHi[0]) tHi[0] = x;
124
+ if (y < tLo[1]) tLo[1] = y; if (y > tHi[1]) tHi[1] = y;
125
+ if (z < tLo[2]) tLo[2] = z; if (z > tHi[2]) tHi[2] = z;
126
+ }
127
+ // A window that somehow caught nothing leaves the core standing rather than returning junk.
128
+ if (kept === 0) return { center, extent };
129
+ const c2 = [0, 0, 0];
130
+ const e2 = [0, 0, 0];
131
+ for (let a = 0; a < 3; a++) {
132
+ c2[a] = 0.5 * (tLo[a] + tHi[a]);
133
+ e2[a] = Math.max(tHi[a] - tLo[a], 1e-6);
134
+ }
135
+ return { center: c2, extent: e2 };
136
+ }
137
+
138
+ /**
139
+ * A single framed object in an inline-3D window: SBS render loop, auto-framing, orbit, idle
140
+ * turntable, and a mono fallback.
141
+ */
142
+ export class SceneViewer {
143
+ /**
144
+ * @param {object} THREE your imported three.js module namespace.
145
+ * @param {HTMLCanvasElement} canvas
146
+ * @param {object} [opts]
147
+ * @param {number} [opts.virtualDisplayHeight=0.24] metres of world the tile's HEIGHT spans.
148
+ * Pass the SAME value to addScene — this module frames against it but does not set it.
149
+ * @param {'contain'|'height'|'cover'|'none'} [opts.fit='contain'] how fitTo() sizes the
150
+ * subject. `contain` caps BOTH dimensions at `margin` of the tile — neither width nor
151
+ * height exceeds it, whatever the subject's proportions. `height` instead pins the
152
+ * height to `margin` and only guards against running off the sides, which gives a
153
+ * consistent apparent size across a catalogue at the cost of letting wide subjects run
154
+ * to the edges.
155
+ * @param {number} [opts.margin=0.8] fraction of the tile the subject may occupy.
156
+ * @param {number} [opts.depthLimit=4.0] backstop on total subject depth, in display heights.
157
+ * Rarely binds — depth placement is a z decision, not a scale one. See fitTo().
158
+ * @param {boolean} [opts.fitSweep=true] fit the horizontal against the box's DIAGONAL
159
+ * (width and depth), so a long subject still fits once the turntable turns it.
160
+ * @param {boolean} [opts.orbit=true] drag to spin, wheel/pinch to zoom.
161
+ * @param {number} [opts.idleSpin=0] degrees/second of turntable after IDLE_DELAY_MS.
162
+ * Ignored under prefers-reduced-motion.
163
+ * @param {number} [opts.renderScale=1] per-eye buffer scale. After the interlace each eye
164
+ * receives roughly half the panel's samples, so 0.5–0.7 is usually free on a splat.
165
+ * @param {number} [opts.feather=0] edge fade in buffer px (needs ./three's EdgeFeather).
166
+ * @param {number[]} [opts.pitchLimit=[-60,60]] degrees; stops the viewer rolling under the
167
+ * subject, which reads as broken rather than as a feature.
168
+ */
169
+ constructor(THREE, canvas, opts = {}) {
170
+ const {
171
+ virtualDisplayHeight = 0.24,
172
+ fit = 'contain',
173
+ margin = 0.8,
174
+ depthLimit = DEFAULT_DEPTH_LIMIT,
175
+ fitSweep = true,
176
+ orbit = true,
177
+ idleSpin = 0,
178
+ renderScale = 1,
179
+ feather = 0,
180
+ pitchLimit = [-60, 60],
181
+ } = opts;
182
+
183
+ this._THREE = THREE;
184
+ this.canvas = canvas;
185
+ this.vH = virtualDisplayHeight;
186
+ this.fit = fit;
187
+ this.margin = margin;
188
+ this.depthLimit = depthLimit;
189
+ this.fitSweep = fitSweep;
190
+ this.renderScale = renderScale;
191
+ this.pitchLimit = pitchLimit;
192
+ this.idleSpin = idleSpin;
193
+
194
+ // alpha + a zero-alpha clear so the tile can dissolve into the page rather than ending at
195
+ // a hard rectangle. An opaque scene.background would defeat both this and the feather.
196
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
197
+ this.renderer.setClearColor(0x000000, 0);
198
+ // MUST be 1. layer.getViewport() reports BACKING-STORE px, but three.js multiplies whatever
199
+ // you pass setViewport()/setScissor() by the renderer's pixelRatio — so any other value
200
+ // silently scales every eye viewport (at dpr 2 the left eye covers the whole canvas). It
201
+ // fails deceptively: the scene still head-tracks perfectly, it is merely zoomed and
202
+ // off-centre, so it reads as a projection bug. We size the backing store ourselves below.
203
+ this.renderer.setPixelRatio(1);
204
+ this.renderer.autoClear = false;
205
+
206
+ this.scene = new THREE.Scene();
207
+ this.scene.background = null;
208
+
209
+ // pivot ── rotated + scaled by orbit/fit
210
+ // └── centering ── translated by -subjectCentre
211
+ // └── content ── YOUR object goes here
212
+ // Rotating the pivot therefore orbits about the SUBJECT, not the model's arbitrary origin.
213
+ this._pivot = new THREE.Group();
214
+ this._centering = new THREE.Group();
215
+ this.content = new THREE.Group();
216
+ this._centering.add(this.content);
217
+ this._pivot.add(this._centering);
218
+ this.scene.add(this._pivot);
219
+
220
+ this._fitScale = 1;
221
+ this._zoom = 1;
222
+ this._yaw = 0;
223
+ this._pitch = 0;
224
+ this._targetYaw = 0;
225
+ this._targetPitch = 0;
226
+ this._lastInput = now(); // so the turntable waits out the load-in rather than starting mid-pop
227
+ this._lastTick = 0;
228
+ this._monoRaf = 0;
229
+ this._mode = '3d'; // drives the backing-store shape; see _resize
230
+ this._disposed = false;
231
+ this._resizePending = false;
232
+ // Last frame this viewer actually DREW, as raw matrices + viewport rects — never XRViews,
233
+ // which are only valid inside their own frame callback. See _cacheGood / _replayLastGood.
234
+ this._lastGood = null;
235
+ this._vps = []; // scratch, reused per frame so validation allocates nothing
236
+ this._warnedNoEye = false;
237
+
238
+ this._reduceMotion =
239
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
240
+
241
+ this._eye = null; // lazily built (needs ./three); see _ensureEye
242
+ this._feather = null;
243
+ this._featherPx = feather;
244
+
245
+ // Mono fallback camera. Deliberately a plain perspective camera: in 2D there is no display
246
+ // plane to be in focus at, so we just look at the framed subject from the front.
247
+ this.monoCamera = new THREE.PerspectiveCamera(35, 1, 0.001, 1000);
248
+
249
+ // Coalesced: ResizeObserver and window resize both fire in BURSTS during a drag-resize or a
250
+ // zoom, and every genuine resize reallocates (and clears) the backing store. One rAF per
251
+ // burst, exactly as the core does for its own windows (inline3d.js _onBoxChange).
252
+ this._onResize = () => this._scheduleResize();
253
+ this._ro = typeof ResizeObserver === 'function' ? new ResizeObserver(this._onResize) : null;
254
+ if (this._ro) this._ro.observe(canvas);
255
+ else addEventListener('resize', this._onResize);
256
+
257
+ if (orbit) this._bindOrbit();
258
+ this._resize();
259
+
260
+ // Bound so it can be passed straight to addScene without a wrapper closure.
261
+ this.onFrame = this.onFrame.bind(this);
262
+ }
263
+
264
+ /**
265
+ * Frame the subject: centre it on the zero-disparity plane and scale it to the tile.
266
+ *
267
+ * Two clamps, not one. The obvious one fits the subject's width and height to the tile. The
268
+ * second clamps its DEPTH: a deep object scaled to fill the tile's height can extend a metre
269
+ * of virtual space through the glass, which is uncomfortable to look at and pushes content
270
+ * past where the display can hold focus. `depthLimit` caps that in display-height units.
271
+ *
272
+ * @param {number[]|{x:number,y:number,z:number}} center subject centre, model space.
273
+ * @param {number[]|{x:number,y:number,z:number}} extent subject size, model space.
274
+ */
275
+ fitTo(center, extent) {
276
+ const c = Array.isArray(center) ? center : [center.x, center.y, center.z];
277
+ const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
278
+
279
+ this._centering.position.set(-c[0], -c[1], -c[2]);
280
+
281
+ if (this.fit === 'none') {
282
+ this._fitScale = 1;
283
+ this._pivot.position.z = 0;
284
+ } else {
285
+ const box = this.canvas.getBoundingClientRect();
286
+ const aspect = box.height > 0 ? box.width / box.height : 1;
287
+ const vH = this.vH;
288
+ const vW = vH * aspect;
289
+ const ex = Math.max(e[0], 1e-6);
290
+ const ey = Math.max(e[1], 1e-6);
291
+ const ez = Math.max(e[2], 1e-6);
292
+
293
+ // THE HORIZONTAL EXTENT IS NOT THE WIDTH — it is the width the subject will occupy once
294
+ // it turns. Both the idle turntable and drag-orbit rotate about Y, which swings DEPTH into
295
+ // the horizontal, so fitting to `ex` alone means anything long fits face-on and then hangs
296
+ // out of the tile the moment it moves. A fox 25 wide and 155 deep is 1.57x the tile width
297
+ // at 90 degrees. Use the box's horizontal diagonal, which bounds every yaw.
298
+ const horiz = this.fitSweep ? Math.hypot(ex, ez) : ex;
299
+
300
+ let s;
301
+ if (this.fit === 'cover') {
302
+ s = Math.max((this.margin * vH) / ey, (this.margin * vW) / horiz);
303
+ } else if (this.fit === 'contain') {
304
+ s = Math.min((this.margin * vH) / ey, (this.margin * vW) / horiz);
305
+ } else {
306
+ // 'height' (the default): the subject occupies `margin` of the tile's HEIGHT, whatever
307
+ // its proportions. This is the only mode that gives a consistent APPARENT SIZE across a
308
+ // catalogue — 'contain' hands the decision to whichever axis happens to bind, so a wide
309
+ // subject and a deep one end up visibly different sizes for no reason a shopper can see.
310
+ s = (this.margin * vH) / ey;
311
+ // Hard guard at the full tile width (not margin-reduced): a wide subject may run to the
312
+ // edges, it may not run past them.
313
+ if (horiz * s > vW) s = vW / horiz;
314
+ }
315
+
316
+ // DEPTH: the subject sits CENTRED on the zero-disparity plane, and that is the whole rule.
317
+ //
318
+ // It is the native convention — displayxr-demo-gaussiansplat sets the rig pose to the
319
+ // subject centre on all three axes, and displayxr-demo-modelviewer states it outright:
320
+ // "subject stays pinned + centered at the ZDP". Those apps also take vH straight from the
321
+ // subject height (`kAutoFitVerticalComfort = 1.0`) with no width or depth constraint; the
322
+ // margin and the swept-width fit above are this SDK's refinement, but the z convention is
323
+ // theirs and matching it keeps web and native looking alike.
324
+ //
325
+ // A biased variant that slid the subject behind the glass was tried and dropped: on
326
+ // hardware it read WORSE, and it moved content the wrong way besides. Do not re-add it
327
+ // without a hardware comparison.
328
+ this._pivot.position.z = 0;
329
+
330
+ // Backstop only: something pathologically deep still gets scaled down.
331
+ const sz = (this.depthLimit * vH) / ez;
332
+ if (sz < s) s = sz;
333
+ this._fitScale = s;
334
+ }
335
+ this._applyTransform();
336
+ // Frame the mono camera on the same subject. Distance to make the frustum exactly vH tall
337
+ // at z=0; the subject is vH×margin tall after the fit, so it lands with an even border.
338
+ const fov = (this.monoCamera.fov * Math.PI) / 180;
339
+ this.monoCamera.position.set(0, 0, 0.5 * this.vH / Math.tan(fov / 2));
340
+ this.monoCamera.lookAt(0, 0, 0);
341
+ }
342
+
343
+ /** Set the orbit pose directly. Angles in degrees; zoom is a multiplier on the fit scale. */
344
+ setPose({ yaw, pitch, zoom } = {}) {
345
+ if (yaw !== undefined) this._targetYaw = this._yaw = yaw;
346
+ if (pitch !== undefined) {
347
+ this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
348
+ }
349
+ if (zoom !== undefined) this._zoom = clamp(zoom, 0.2, 6);
350
+ this._applyTransform();
351
+ }
352
+
353
+ /** Return to the framed default pose. */
354
+ resetPose() {
355
+ this.setPose({ yaw: 0, pitch: 0, zoom: 1 });
356
+ this._lastInput = now();
357
+ }
358
+
359
+ /**
360
+ * The per-frame callback for `wall.addScene`. Renders the scene once per eye into the
361
+ * side-by-side halves the layer reports.
362
+ *
363
+ * VALIDATE BEFORE YOU CLEAR — the dark-blink rule (web#12). `r.clear()` is the point of no
364
+ * return: after it the canvas is transparent-black, and if the frame then fails to draw
365
+ * anything over it, that empty buffer is what the weave consumes. Under GPU load the session
366
+ * can hand this callback a SHORT view list (one view, or none — a per-frame mono fallback),
367
+ * and the old loop cleared first and rendered what it could: a single origin-camera view whose
368
+ * content is entirely near-plane-clipped, i.e. a fully transparent side-by-side buffer, i.e.
369
+ * one dark woven tile. The blink was ours, not the weave's.
370
+ *
371
+ * So: everything that can disqualify a frame is checked while the canvas still holds the last
372
+ * good image, and only a frame that WILL draw is allowed to clear. A frame that cannot draw
373
+ * REPLAYS the last good one instead (see _replayLastGood) rather than skipping the commit —
374
+ * the SDK's every-frame-repaint invariant is real (inline3d.js `_frame`: a canvas that isn't
375
+ * redrawn can have its layer dropped from the aggregated frame and the weave then reads a
376
+ * stale sub-rect, which smears). A one-frame-stale eye pose is imperceptible; a smear and a
377
+ * black frame are not.
378
+ */
379
+ onFrame(views, layer) {
380
+ if (this._disposed) return;
381
+ // A lazily-activated tile can start weaving after the page already fell back to mono (or
382
+ // after a scroll-away/scroll-back). Take the buffer back to the SBS shape when that happens
383
+ // — otherwise the first 3D frames render into a 1:1 store and each eye is half a subject.
384
+ if (this._mode !== '3d') this.stopMono();
385
+ // Before the validation gate on purpose: a replayed frame still damps and still turns on the
386
+ // turntable, so only the EYE pose is one frame stale, not the whole scene.
387
+ this._tick();
388
+
389
+ // 1. A short view list is the load-induced mono fallback. Stereo needs two.
390
+ if (!views || views.length < 2) {
391
+ this._replayLastGood();
392
+ return;
393
+ }
394
+
395
+ // 2. No ./three glue: the 3D path has no eye camera to build. This used to clear and draw
396
+ // NOTHING, silently, forever — and this module's own header example omitted
397
+ // useEyeCamera() until now, so the failure was reachable by copy-paste. Both ends are
398
+ // fixed: the example passes it, and this says so once and renders the mono camera, which
399
+ // at least shows the subject (flat, both halves the same) instead of a dark tile.
400
+ const eye = this._ensureEye();
401
+ if (!eye && !this._warnedNoEye) {
402
+ this._warnedNoEye = true;
403
+ console.warn(
404
+ '[inline3d] SceneViewer.onFrame without useEyeCamera(): falling back to the mono camera. ' +
405
+ 'Pass the ./three glue — viewer.useEyeCamera(EyeCamera, EdgeFeather) — for real ' +
406
+ 'off-axis stereo. (./splat and ./model do this for you.)',
407
+ );
408
+ }
409
+
410
+ // 3. Every eye must have a viewport to render into. A missing or degenerate one means this
411
+ // frame cannot fill the buffer, so it must not empty it either.
412
+ const vps = this._vps;
413
+ vps.length = 0;
414
+ for (const view of views) {
415
+ const vp = layer && typeof layer.getViewport === 'function' ? layer.getViewport(view) : null;
416
+ if (!vp || !(vp.width > 0) || !(vp.height > 0)) {
417
+ this._replayLastGood();
418
+ return;
419
+ }
420
+ vps.push(vp);
421
+ }
422
+
423
+ // Validated: this frame WILL draw over everything it clears.
424
+ const r = this.renderer;
425
+ r.clear();
426
+ r.setScissorTest(true);
427
+ for (let i = 0; i < views.length; i++) {
428
+ const vp = vps[i];
429
+ r.setViewport(vp.x, vp.y, vp.width, vp.height);
430
+ r.setScissor(vp.x, vp.y, vp.width, vp.height);
431
+ if (eye) {
432
+ eye.setFromView(views[i]);
433
+ r.render(this.scene, eye.camera);
434
+ } else {
435
+ r.render(this.scene, this.monoCamera);
436
+ }
437
+ if (this._feather) this._feather.render(r, vp);
438
+ }
439
+ r.setScissorTest(false);
440
+ this._cacheGood(views, vps, !eye);
441
+ }
442
+
443
+ /**
444
+ * Supply the ./three glue. Optional: without it the 3D path cannot build its eye camera, so
445
+ * `./splat` and `./model` pass it for you. Kept injectable so this module never imports
446
+ * three.js itself and stays usable with any EyeCamera-shaped object.
447
+ */
448
+ useEyeCamera(EyeCameraClass, EdgeFeatherClass) {
449
+ this._EyeCamera = EyeCameraClass;
450
+ if (EdgeFeatherClass && this._featherPx > 0) {
451
+ this._feather = new EdgeFeatherClass(this._THREE, { px: this._featherPx });
452
+ }
453
+ return this;
454
+ }
455
+
456
+ /** Drive a flat, single-camera render loop for browsers without inline-3D. */
457
+ startMono() {
458
+ if (this._monoRaf || this._disposed) return;
459
+ this._mode = 'mono'; // BEFORE the resize — the mode is what picks the buffer shape
460
+ this._resize();
461
+ const loop = () => {
462
+ if (this._disposed) return;
463
+ this._monoRaf = requestAnimationFrame(loop);
464
+ this._tick();
465
+ const r = this.renderer;
466
+ r.clear();
467
+ r.setViewport(0, 0, this.canvas.width, this.canvas.height);
468
+ r.render(this.scene, this.monoCamera);
469
+ };
470
+ this._monoRaf = requestAnimationFrame(loop);
471
+ }
472
+
473
+ stopMono() {
474
+ if (this._monoRaf) cancelAnimationFrame(this._monoRaf);
475
+ this._monoRaf = 0;
476
+ this._mode = '3d';
477
+ this._resize();
478
+ }
479
+
480
+ /** True while the side-by-side backing store is in use (the 3D path is driving this viewer). */
481
+ get is3D() {
482
+ return this._mode === '3d';
483
+ }
484
+
485
+ dispose() {
486
+ this._disposed = true;
487
+ this._resizePending = false;
488
+ this._lastGood = null;
489
+ this.stopMono();
490
+ if (this._ro) this._ro.disconnect();
491
+ else removeEventListener('resize', this._onResize);
492
+ this._unbindOrbit();
493
+ this.renderer.dispose();
494
+ }
495
+
496
+ // ── internals ─────────────────────────────────────────────────────────────────────────
497
+
498
+ _ensureEye() {
499
+ if (!this._eye && this._EyeCamera) this._eye = new this._EyeCamera(this._THREE);
500
+ return this._eye;
501
+ }
502
+
503
+ /**
504
+ * Remember the frame just drawn, so a frame that CANNOT draw has something to put on the
505
+ * canvas instead of a clear (web#12).
506
+ *
507
+ * COPIES, never references. An `XRView` — and the `projectionMatrix` / `transform.matrix`
508
+ * hanging off it — is valid only inside the frame callback that delivered it; the UA is free
509
+ * to recycle that memory afterwards. Retaining one would give a replay that reads whatever
510
+ * the next frame happened to write there, which is a worse bug than the blink. So each eye
511
+ * gets two `Float32Array(16)` copies, allocated once and overwritten in place: the cache
512
+ * costs 128 bytes an eye and zero allocations per frame.
513
+ *
514
+ * The buffer dimensions go in too, so a replay after a resize can scale the rects (the SBS
515
+ * split is proportional, so the scaling is exact).
516
+ */
517
+ _cacheGood(views, vps, mono) {
518
+ const el = this.renderer.domElement || this.canvas;
519
+ let g = this._lastGood;
520
+ if (!g || g.entries.length !== views.length) {
521
+ g = this._lastGood = { entries: [], mono, bufW: 0, bufH: 0 };
522
+ for (let i = 0; i < views.length; i++) {
523
+ g.entries.push({
524
+ proj: new Float32Array(16),
525
+ pose: new Float32Array(16),
526
+ x: 0,
527
+ y: 0,
528
+ width: 0,
529
+ height: 0,
530
+ });
531
+ }
532
+ }
533
+ g.mono = mono;
534
+ g.bufW = el.width || 0;
535
+ g.bufH = el.height || 0;
536
+ for (let i = 0; i < views.length; i++) {
537
+ const e = g.entries[i];
538
+ const vp = vps[i];
539
+ if (!mono) {
540
+ const view = views[i];
541
+ e.proj.set(view.projectionMatrix);
542
+ e.pose.set(view.transform.matrix);
543
+ }
544
+ e.x = vp.x;
545
+ e.y = vp.y;
546
+ e.width = vp.width;
547
+ e.height = vp.height;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Re-render the last good frame from the cached matrices. Returns false when there is no
553
+ * cache yet — and the caller must then do NOTHING, not clear: before the first good frame
554
+ * the canvas holds either the page's own initial state or the mono fallback's output, both
555
+ * of which are better than black.
556
+ */
557
+ _replayLastGood() {
558
+ const g = this._lastGood;
559
+ if (!g || this._disposed) return false;
560
+ const r = this.renderer;
561
+ const eye = g.mono ? null : this._ensureEye();
562
+ const el = this.renderer.domElement || this.canvas;
563
+ // A resize between the cache and the replay changes the buffer, not the split.
564
+ const sx = g.bufW > 0 && el.width ? el.width / g.bufW : 1;
565
+ const sy = g.bufH > 0 && el.height ? el.height / g.bufH : 1;
566
+ const scaled = sx !== 1 || sy !== 1;
567
+ r.clear();
568
+ r.setScissorTest(true);
569
+ for (const e of g.entries) {
570
+ const vp = scaled
571
+ ? {
572
+ x: Math.round(e.x * sx),
573
+ y: Math.round(e.y * sy),
574
+ width: Math.max(1, Math.round(e.width * sx)),
575
+ height: Math.max(1, Math.round(e.height * sy)),
576
+ }
577
+ : e;
578
+ r.setViewport(vp.x, vp.y, vp.width, vp.height);
579
+ r.setScissor(vp.x, vp.y, vp.width, vp.height);
580
+ if (eye) {
581
+ eye.setFromMatrices(e.proj, e.pose);
582
+ r.render(this.scene, eye.camera);
583
+ } else {
584
+ r.render(this.scene, this.monoCamera);
585
+ }
586
+ if (this._feather) this._feather.render(r, vp);
587
+ }
588
+ r.setScissorTest(false);
589
+ return true;
590
+ }
591
+
592
+ /** One rAF per burst of observer callbacks. See the _onResize comment. */
593
+ _scheduleResize() {
594
+ if (this._disposed || this._resizePending) return;
595
+ this._resizePending = true;
596
+ const run = () => {
597
+ if (!this._resizePending) return;
598
+ this._resizePending = false;
599
+ this._resize();
600
+ };
601
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
602
+ else run();
603
+ }
604
+
605
+ /**
606
+ * Put the last good frame back on a buffer that was just cleared, NOW — not on the next
607
+ * animation frame. A ResizeObserver callback runs after rAF and before paint, so the frame
608
+ * that reallocated the buffer is the frame that gets committed: without this the tile weaves
609
+ * one black frame per box change, with nothing on the way to repaint it. Mirrors the core's
610
+ * "repaint NOW: setting canvas.width cleared the buffer" (inline3d.js _onBoxChange).
611
+ */
612
+ _repaintAfterResize() {
613
+ if (this._disposed) return;
614
+ if (this._mode === 'mono') {
615
+ const r = this.renderer;
616
+ r.clear();
617
+ r.setViewport(0, 0, this.canvas.width, this.canvas.height);
618
+ r.render(this.scene, this.monoCamera);
619
+ return;
620
+ }
621
+ this._replayLastGood();
622
+ }
623
+
624
+ _applyTransform() {
625
+ const s = this._fitScale * this._zoom;
626
+ this._pivot.scale.setScalar(s);
627
+ // Order 'XYZ' == R = Rx(pitch) · Ry(yaw), and the order is the whole point.
628
+ //
629
+ // Yaw must act in the subject's OWN frame (spin it on its axis); pitch must act in the
630
+ // VIEWER's frame (tilt it toward or away from you), and stay screen-horizontal however far
631
+ // the subject has been spun. Rx outermost gives exactly that: Ry never moves the Y axis, so
632
+ // the subject's up-vector after the pair is Rx(pitch)·(0,1,0) — independent of yaw.
633
+ //
634
+ // 'YXZ' (R = Ry · Rx) was the bug: it applies pitch INSIDE the yawed frame, so the pitch
635
+ // axis is itself yawed. At yaw 90° that axis has swung onto world Z and dragging up/down
636
+ // rolls the subject instead of tilting it. Correct head-on, wrong the moment you turn it —
637
+ // which is why it survived review and only showed up when two controls were combined.
638
+ this._pivot.rotation.set((this._pitch * Math.PI) / 180, (this._yaw * Math.PI) / 180, 0, 'XYZ');
639
+ }
640
+
641
+ /**
642
+ * Size the drawing buffer. In 3D it is DOUBLE-WIDTH in device pixels, because
643
+ * getViewport() splits canvas.width in half for the two eyes — the browser squashing that
644
+ * 2:1 buffer into the 1:1 CSS box IS the side-by-side squeeze, and the weave un-squeezes it.
645
+ * In mono it must stay 1:1 or the flat render is stretched.
646
+ *
647
+ * NON-DESTRUCTIVE (web#12). `setSize` writes `canvas.width`/`canvas.height` UNCONDITIONALLY,
648
+ * and writing either one reallocates and CLEARS the drawing buffer even when the value does
649
+ * not change. Since a ResizeObserver fires on plenty of things that leave the buffer's
650
+ * dimensions exactly where they were (a sub-pixel reflow, a scrollbar appearing and going, a
651
+ * sibling settling), the old unconditional call meant a black frame for every no-op. So:
652
+ * compare first, and when it IS a real change, put the picture back before the frame commits.
653
+ */
654
+ _resize() {
655
+ if (this._disposed) return;
656
+ const box = this.canvas.getBoundingClientRect();
657
+ if (box.width < 1 || box.height < 1) return;
658
+ const dpr = Math.min(window.devicePixelRatio || 1, 2) * this.renderScale;
659
+ const w = Math.max(1, Math.round(box.width * dpr));
660
+ const h = Math.max(1, Math.round(box.height * dpr));
661
+ const bufW = this._mode === 'mono' ? w : w * 2;
662
+ // Cheap and always correct to refresh, whether or not the backing store moves.
663
+ this.monoCamera.aspect = box.width / box.height;
664
+ this.monoCamera.updateProjectionMatrix();
665
+ const el = this.renderer.domElement || this.canvas;
666
+ if (el.width === bufW && el.height === h) return; // observer fired, geometry didn't move
667
+ this.renderer.setSize(bufW, h, false);
668
+ this._repaintAfterResize();
669
+ }
670
+
671
+ /** Damping + idle turntable. Called once per rendered frame, 3D or mono. */
672
+ _tick() {
673
+ const t = now();
674
+ const dt = this._lastTick ? Math.min((t - this._lastTick) / 1000, 0.1) : 0;
675
+ this._lastTick = t;
676
+
677
+ if (this.idleSpin && !this._reduceMotion && t - this._lastInput > IDLE_DELAY_MS) {
678
+ this._targetYaw += this.idleSpin * dt;
679
+ }
680
+ // Critically-damped-ish approach. Instant snapping reads as jitter on a head-tracked
681
+ // display, where the viewer is already moving relative to the content.
682
+ const k = dt > 0 ? 1 - Math.pow(0.001, dt) : 1;
683
+ this._yaw += (this._targetYaw - this._yaw) * k;
684
+ this._pitch += (this._targetPitch - this._pitch) * k;
685
+ this._applyTransform();
686
+ }
687
+
688
+ _bindOrbit() {
689
+ const el = this.canvas;
690
+ let dragging = false;
691
+ let lastX = 0;
692
+ let lastY = 0;
693
+
694
+ this._onDown = (ev) => {
695
+ dragging = true;
696
+ lastX = ev.clientX;
697
+ lastY = ev.clientY;
698
+ this._lastInput = now();
699
+ el.setPointerCapture?.(ev.pointerId);
700
+ };
701
+ this._onMove = (ev) => {
702
+ if (!dragging) return;
703
+ const box = el.getBoundingClientRect();
704
+ // A full drag across the tile is a half turn — predictable regardless of tile size.
705
+ //
706
+ // BOTH axes must make the near face follow the cursor, and the signs are not symmetric.
707
+ // Ry(+yaw) swings the near face toward +x (right), so yaw ADDS dx. Rx(+pitch) swings it
708
+ // toward −y (down), so pitch must also ADD dy — subtracting it sends the face the wrong
709
+ // way and reads as an inverted axis next to a correct one, which is worse than both being
710
+ // inverted.
711
+ this._targetYaw += ((ev.clientX - lastX) / Math.max(box.width, 1)) * 180;
712
+ this._targetPitch = clamp(
713
+ this._targetPitch + ((ev.clientY - lastY) / Math.max(box.height, 1)) * 180,
714
+ this.pitchLimit[0],
715
+ this.pitchLimit[1],
716
+ );
717
+ lastX = ev.clientX;
718
+ lastY = ev.clientY;
719
+ this._lastInput = now();
720
+ };
721
+ this._onUp = (ev) => {
722
+ dragging = false;
723
+ this._lastInput = now();
724
+ el.releasePointerCapture?.(ev.pointerId);
725
+ };
726
+ this._onWheel = (ev) => {
727
+ ev.preventDefault();
728
+ this._zoom = clamp(this._zoom * (ev.deltaY > 0 ? 0.92 : 1.087), 0.2, 6);
729
+ this._lastInput = now();
730
+ this._applyTransform();
731
+ };
732
+
733
+ el.style.touchAction = 'none'; // or the browser eats the drag as a scroll
734
+ el.addEventListener('pointerdown', this._onDown);
735
+ el.addEventListener('pointermove', this._onMove);
736
+ el.addEventListener('pointerup', this._onUp);
737
+ el.addEventListener('pointercancel', this._onUp);
738
+ el.addEventListener('wheel', this._onWheel, { passive: false });
739
+ }
740
+
741
+ _unbindOrbit() {
742
+ const el = this.canvas;
743
+ if (!this._onDown) return;
744
+ el.removeEventListener('pointerdown', this._onDown);
745
+ el.removeEventListener('pointermove', this._onMove);
746
+ el.removeEventListener('pointerup', this._onUp);
747
+ el.removeEventListener('pointercancel', this._onUp);
748
+ el.removeEventListener('wheel', this._onWheel);
749
+ }
750
+ }
751
+
752
+ function now() {
753
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
754
+ }