@displayxr/inline3d 0.0.1 → 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.
@@ -0,0 +1,553 @@
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 { SceneViewer } from '@displayxr/inline3d/viewer';
24
+ //
25
+ // const viewer = new SceneViewer(THREE, canvas, { virtualDisplayHeight: 0.18 });
26
+ // viewer.content.add(myMesh);
27
+ // viewer.fitTo(center, extent); // model-space bounds of the subject
28
+ // const wall = await createInline3D();
29
+ // if (wall.supported) wall.addScene(canvas, viewer.onFrame, { virtualDisplayHeight: 0.18 });
30
+ // else viewer.startMono();
31
+ //
32
+ // WHY FRAMING IS SCENE-GRAPH WORK AND NOT A RIG FIELD. The native display rig
33
+ // (XrDisplayRigDXR) carries a POSE as well as a virtual display height, and the native viewers
34
+ // auto-frame by setting both: pose.position = the subject's centre, virtualDisplayHeight = its
35
+ // extent. The web session exposes only the height. That costs nothing, because the SDK's
36
+ // authoring contract already says "put focused content at z=0" — so we move the content to the
37
+ // origin and scale it, instead of moving the display to the content. Identical framing, no
38
+ // browser or runtime change.
39
+
40
+ /**
41
+ * Backstop on total subject depth, as a multiple of the display height. Generous on purpose:
42
+ * depth placement is a z decision (see fitTo), not a scale one, so this only catches the
43
+ * pathological case where a subject is so deep that no placement helps.
44
+ */
45
+ const DEFAULT_DEPTH_LIMIT = 4.0;
46
+ /** Milliseconds of no interaction before the idle turntable starts. */
47
+ const IDLE_DELAY_MS = 2500;
48
+
49
+ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
50
+
51
+ /**
52
+ * Robust model-space bounds from a flat array of splat/vertex centres.
53
+ *
54
+ * TWO STAGES, because one percentile box cannot do both jobs. A raw min/max is useless on
55
+ * captured content — one stray floater a hundred metres out and the subject shrinks to a speck —
56
+ * but a trimmed box is equally useless as an EXTENT, because the tail it drops on a dense subject
57
+ * is the subject's own outer shell. Trimming 5% per axis under-reported seven scanned products by
58
+ * 10-15%, which the fit then faithfully turned into a subject overflowing its tile.
59
+ *
60
+ * So: percentiles REJECT, true min/max MEASURES.
61
+ * 1. Percentile core (lo..hi per axis) — an outlier-proof estimate of where the subject is.
62
+ * 2. True min/max over centres inside `expand` x that core, centred on it.
63
+ * A floater sits orders of magnitude outside the core and is still rejected; a shell splat sits
64
+ * just past the percentile cut and is now kept.
65
+ *
66
+ * This is the CHEAP path. The native viewers additionally run an opacity-weighted voxel
67
+ * flood-fill (`getMainObjectBounds`) that isolates the dominant contiguous object from an
68
+ * air-gap-separated background — which matters on image→splat scenes, where the background
69
+ * wall is part of the reconstruction. That is deliberately NOT reimplemented here: it wants
70
+ * every centre resident and a 64³ pass before the first frame. Compute it at conversion time
71
+ * and pass the result to `fitTo()` instead; fall back to this when there is no such sidecar.
72
+ * (Precedent: the Adreno/mobile native renderer ships exactly this percentile-only path.)
73
+ *
74
+ * @param {ArrayLike<number>} xyz flat [x,y,z, x,y,z, …] centres in model space.
75
+ * @param {object} [opts]
76
+ * @param {number} [opts.lo=0.05] lower percentile bounding the rejection core.
77
+ * @param {number} [opts.hi=0.95] upper percentile bounding the rejection core.
78
+ * @param {number} [opts.expand=2.5] how many core-extents wide the acceptance window is. A real
79
+ * subject reaches well past its own percentile core; a floater does not sit at 2.5x it.
80
+ * Set 0 to get the old percentile-only box back.
81
+ * @returns {{center:number[], extent:number[]}|null} null if there is nothing to measure.
82
+ */
83
+ export function boundsFromPositions(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 } = {}) {
84
+ const n = Math.floor(xyz.length / 3);
85
+ if (n < 1) return null;
86
+ // Below a few hundred points the percentiles are noise — just use the true box.
87
+ const trim = n >= 512;
88
+ const center = [0, 0, 0];
89
+ const extent = [0, 0, 0];
90
+ const axisVals = new Float64Array(n);
91
+ for (let axis = 0; axis < 3; axis++) {
92
+ for (let i = 0; i < n; i++) axisVals[i] = xyz[i * 3 + axis];
93
+ // TypedArray sort is numeric and in-place — no comparator, no copy. That matters here:
94
+ // this runs over every splat centre, and a boxed Array round-trip on a 500k-splat model
95
+ // is the difference between a hitch and an imperceptible pause.
96
+ const sorted = axisVals.sort();
97
+ const loV = trim ? sorted[Math.floor(lo * (n - 1))] : sorted[0];
98
+ const hiV = trim ? sorted[Math.floor(hi * (n - 1))] : sorted[n - 1];
99
+ center[axis] = 0.5 * (loV + hiV);
100
+ extent[axis] = Math.max(hiV - loV, 1e-6);
101
+ }
102
+ // Untrimmed already IS the true box, and expand 0 asks for the old behaviour.
103
+ if (!trim || expand <= 0) return { center, extent };
104
+
105
+ // Stage 2. Note the window is per-axis but membership is joint: a point must be inside on all
106
+ // three axes to count, so a distant floater cannot widen one axis while sitting far off another.
107
+ const wLo = [0, 0, 0];
108
+ const wHi = [0, 0, 0];
109
+ for (let a = 0; a < 3; a++) {
110
+ const half = 0.5 * expand * extent[a];
111
+ wLo[a] = center[a] - half;
112
+ wHi[a] = center[a] + half;
113
+ }
114
+ const tLo = [Infinity, Infinity, Infinity];
115
+ const tHi = [-Infinity, -Infinity, -Infinity];
116
+ let kept = 0;
117
+ for (let i = 0; i < n; i++) {
118
+ const x = xyz[i * 3], y = xyz[i * 3 + 1], z = xyz[i * 3 + 2];
119
+ if (x < wLo[0] || x > wHi[0] || y < wLo[1] || y > wHi[1] || z < wLo[2] || z > wHi[2]) continue;
120
+ kept++;
121
+ if (x < tLo[0]) tLo[0] = x; if (x > tHi[0]) tHi[0] = x;
122
+ if (y < tLo[1]) tLo[1] = y; if (y > tHi[1]) tHi[1] = y;
123
+ if (z < tLo[2]) tLo[2] = z; if (z > tHi[2]) tHi[2] = z;
124
+ }
125
+ // A window that somehow caught nothing leaves the core standing rather than returning junk.
126
+ if (kept === 0) return { center, extent };
127
+ const c2 = [0, 0, 0];
128
+ const e2 = [0, 0, 0];
129
+ for (let a = 0; a < 3; a++) {
130
+ c2[a] = 0.5 * (tLo[a] + tHi[a]);
131
+ e2[a] = Math.max(tHi[a] - tLo[a], 1e-6);
132
+ }
133
+ return { center: c2, extent: e2 };
134
+ }
135
+
136
+ /**
137
+ * A single framed object in an inline-3D window: SBS render loop, auto-framing, orbit, idle
138
+ * turntable, and a mono fallback.
139
+ */
140
+ export class SceneViewer {
141
+ /**
142
+ * @param {object} THREE your imported three.js module namespace.
143
+ * @param {HTMLCanvasElement} canvas
144
+ * @param {object} [opts]
145
+ * @param {number} [opts.virtualDisplayHeight=0.24] metres of world the tile's HEIGHT spans.
146
+ * Pass the SAME value to addScene — this module frames against it but does not set it.
147
+ * @param {'contain'|'height'|'cover'|'none'} [opts.fit='contain'] how fitTo() sizes the
148
+ * subject. `contain` caps BOTH dimensions at `margin` of the tile — neither width nor
149
+ * height exceeds it, whatever the subject's proportions. `height` instead pins the
150
+ * height to `margin` and only guards against running off the sides, which gives a
151
+ * consistent apparent size across a catalogue at the cost of letting wide subjects run
152
+ * to the edges.
153
+ * @param {number} [opts.margin=0.8] fraction of the tile the subject may occupy.
154
+ * @param {number} [opts.depthLimit=4.0] backstop on total subject depth, in display heights.
155
+ * Rarely binds — depth placement is a z decision, not a scale one. See fitTo().
156
+ * @param {boolean} [opts.fitSweep=true] fit the horizontal against the box's DIAGONAL
157
+ * (width and depth), so a long subject still fits once the turntable turns it.
158
+ * @param {boolean} [opts.orbit=true] drag to spin, wheel/pinch to zoom.
159
+ * @param {number} [opts.idleSpin=0] degrees/second of turntable after IDLE_DELAY_MS.
160
+ * Ignored under prefers-reduced-motion.
161
+ * @param {number} [opts.renderScale=1] per-eye buffer scale. After the interlace each eye
162
+ * receives roughly half the panel's samples, so 0.5–0.7 is usually free on a splat.
163
+ * @param {number} [opts.feather=0] edge fade in buffer px (needs ./three's EdgeFeather).
164
+ * @param {number[]} [opts.pitchLimit=[-60,60]] degrees; stops the viewer rolling under the
165
+ * subject, which reads as broken rather than as a feature.
166
+ */
167
+ constructor(THREE, canvas, opts = {}) {
168
+ const {
169
+ virtualDisplayHeight = 0.24,
170
+ fit = 'contain',
171
+ margin = 0.8,
172
+ depthLimit = DEFAULT_DEPTH_LIMIT,
173
+ fitSweep = true,
174
+ orbit = true,
175
+ idleSpin = 0,
176
+ renderScale = 1,
177
+ feather = 0,
178
+ pitchLimit = [-60, 60],
179
+ } = opts;
180
+
181
+ this._THREE = THREE;
182
+ this.canvas = canvas;
183
+ this.vH = virtualDisplayHeight;
184
+ this.fit = fit;
185
+ this.margin = margin;
186
+ this.depthLimit = depthLimit;
187
+ this.fitSweep = fitSweep;
188
+ this.renderScale = renderScale;
189
+ this.pitchLimit = pitchLimit;
190
+ this.idleSpin = idleSpin;
191
+
192
+ // alpha + a zero-alpha clear so the tile can dissolve into the page rather than ending at
193
+ // a hard rectangle. An opaque scene.background would defeat both this and the feather.
194
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
195
+ this.renderer.setClearColor(0x000000, 0);
196
+ // MUST be 1. layer.getViewport() reports BACKING-STORE px, but three.js multiplies whatever
197
+ // you pass setViewport()/setScissor() by the renderer's pixelRatio — so any other value
198
+ // silently scales every eye viewport (at dpr 2 the left eye covers the whole canvas). It
199
+ // fails deceptively: the scene still head-tracks perfectly, it is merely zoomed and
200
+ // off-centre, so it reads as a projection bug. We size the backing store ourselves below.
201
+ this.renderer.setPixelRatio(1);
202
+ this.renderer.autoClear = false;
203
+
204
+ this.scene = new THREE.Scene();
205
+ this.scene.background = null;
206
+
207
+ // pivot ── rotated + scaled by orbit/fit
208
+ // └── centering ── translated by -subjectCentre
209
+ // └── content ── YOUR object goes here
210
+ // Rotating the pivot therefore orbits about the SUBJECT, not the model's arbitrary origin.
211
+ this._pivot = new THREE.Group();
212
+ this._centering = new THREE.Group();
213
+ this.content = new THREE.Group();
214
+ this._centering.add(this.content);
215
+ this._pivot.add(this._centering);
216
+ this.scene.add(this._pivot);
217
+
218
+ this._fitScale = 1;
219
+ this._zoom = 1;
220
+ this._yaw = 0;
221
+ this._pitch = 0;
222
+ this._targetYaw = 0;
223
+ this._targetPitch = 0;
224
+ this._lastInput = now(); // so the turntable waits out the load-in rather than starting mid-pop
225
+ this._lastTick = 0;
226
+ this._monoRaf = 0;
227
+ this._mode = '3d'; // drives the backing-store shape; see _resize
228
+ this._disposed = false;
229
+
230
+ this._reduceMotion =
231
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
232
+
233
+ this._eye = null; // lazily built (needs ./three); see _ensureEye
234
+ this._feather = null;
235
+ this._featherPx = feather;
236
+
237
+ // Mono fallback camera. Deliberately a plain perspective camera: in 2D there is no display
238
+ // plane to be in focus at, so we just look at the framed subject from the front.
239
+ this.monoCamera = new THREE.PerspectiveCamera(35, 1, 0.001, 1000);
240
+
241
+ this._onResize = () => this._resize();
242
+ this._ro = typeof ResizeObserver === 'function' ? new ResizeObserver(this._onResize) : null;
243
+ if (this._ro) this._ro.observe(canvas);
244
+ else addEventListener('resize', this._onResize);
245
+
246
+ if (orbit) this._bindOrbit();
247
+ this._resize();
248
+
249
+ // Bound so it can be passed straight to addScene without a wrapper closure.
250
+ this.onFrame = this.onFrame.bind(this);
251
+ }
252
+
253
+ /**
254
+ * Frame the subject: centre it on the zero-disparity plane and scale it to the tile.
255
+ *
256
+ * Two clamps, not one. The obvious one fits the subject's width and height to the tile. The
257
+ * second clamps its DEPTH: a deep object scaled to fill the tile's height can extend a metre
258
+ * of virtual space through the glass, which is uncomfortable to look at and pushes content
259
+ * past where the display can hold focus. `depthLimit` caps that in display-height units.
260
+ *
261
+ * @param {number[]|{x:number,y:number,z:number}} center subject centre, model space.
262
+ * @param {number[]|{x:number,y:number,z:number}} extent subject size, model space.
263
+ */
264
+ fitTo(center, extent) {
265
+ const c = Array.isArray(center) ? center : [center.x, center.y, center.z];
266
+ const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
267
+
268
+ this._centering.position.set(-c[0], -c[1], -c[2]);
269
+
270
+ if (this.fit === 'none') {
271
+ this._fitScale = 1;
272
+ this._pivot.position.z = 0;
273
+ } else {
274
+ const box = this.canvas.getBoundingClientRect();
275
+ const aspect = box.height > 0 ? box.width / box.height : 1;
276
+ const vH = this.vH;
277
+ const vW = vH * aspect;
278
+ const ex = Math.max(e[0], 1e-6);
279
+ const ey = Math.max(e[1], 1e-6);
280
+ const ez = Math.max(e[2], 1e-6);
281
+
282
+ // THE HORIZONTAL EXTENT IS NOT THE WIDTH — it is the width the subject will occupy once
283
+ // it turns. Both the idle turntable and drag-orbit rotate about Y, which swings DEPTH into
284
+ // the horizontal, so fitting to `ex` alone means anything long fits face-on and then hangs
285
+ // out of the tile the moment it moves. A fox 25 wide and 155 deep is 1.57x the tile width
286
+ // at 90 degrees. Use the box's horizontal diagonal, which bounds every yaw.
287
+ const horiz = this.fitSweep ? Math.hypot(ex, ez) : ex;
288
+
289
+ let s;
290
+ if (this.fit === 'cover') {
291
+ s = Math.max((this.margin * vH) / ey, (this.margin * vW) / horiz);
292
+ } else if (this.fit === 'contain') {
293
+ s = Math.min((this.margin * vH) / ey, (this.margin * vW) / horiz);
294
+ } else {
295
+ // 'height' (the default): the subject occupies `margin` of the tile's HEIGHT, whatever
296
+ // its proportions. This is the only mode that gives a consistent APPARENT SIZE across a
297
+ // catalogue — 'contain' hands the decision to whichever axis happens to bind, so a wide
298
+ // subject and a deep one end up visibly different sizes for no reason a shopper can see.
299
+ s = (this.margin * vH) / ey;
300
+ // Hard guard at the full tile width (not margin-reduced): a wide subject may run to the
301
+ // edges, it may not run past them.
302
+ if (horiz * s > vW) s = vW / horiz;
303
+ }
304
+
305
+ // DEPTH: the subject sits CENTRED on the zero-disparity plane, and that is the whole rule.
306
+ //
307
+ // It is the native convention — displayxr-demo-gaussiansplat sets the rig pose to the
308
+ // subject centre on all three axes, and displayxr-demo-modelviewer states it outright:
309
+ // "subject stays pinned + centered at the ZDP". Those apps also take vH straight from the
310
+ // subject height (`kAutoFitVerticalComfort = 1.0`) with no width or depth constraint; the
311
+ // margin and the swept-width fit above are this SDK's refinement, but the z convention is
312
+ // theirs and matching it keeps web and native looking alike.
313
+ //
314
+ // A biased variant that slid the subject behind the glass was tried and dropped: on
315
+ // hardware it read WORSE, and it moved content the wrong way besides. Do not re-add it
316
+ // without a hardware comparison.
317
+ this._pivot.position.z = 0;
318
+
319
+ // Backstop only: something pathologically deep still gets scaled down.
320
+ const sz = (this.depthLimit * vH) / ez;
321
+ if (sz < s) s = sz;
322
+ this._fitScale = s;
323
+ }
324
+ this._applyTransform();
325
+ // Frame the mono camera on the same subject. Distance to make the frustum exactly vH tall
326
+ // at z=0; the subject is vH×margin tall after the fit, so it lands with an even border.
327
+ const fov = (this.monoCamera.fov * Math.PI) / 180;
328
+ this.monoCamera.position.set(0, 0, 0.5 * this.vH / Math.tan(fov / 2));
329
+ this.monoCamera.lookAt(0, 0, 0);
330
+ }
331
+
332
+ /** Set the orbit pose directly. Angles in degrees; zoom is a multiplier on the fit scale. */
333
+ setPose({ yaw, pitch, zoom } = {}) {
334
+ if (yaw !== undefined) this._targetYaw = this._yaw = yaw;
335
+ if (pitch !== undefined) {
336
+ this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
337
+ }
338
+ if (zoom !== undefined) this._zoom = clamp(zoom, 0.2, 6);
339
+ this._applyTransform();
340
+ }
341
+
342
+ /** Return to the framed default pose. */
343
+ resetPose() {
344
+ this.setPose({ yaw: 0, pitch: 0, zoom: 1 });
345
+ this._lastInput = now();
346
+ }
347
+
348
+ /**
349
+ * The per-frame callback for `wall.addScene`. Renders the scene once per eye into the
350
+ * side-by-side halves the layer reports.
351
+ */
352
+ onFrame(views, layer) {
353
+ if (this._disposed) return;
354
+ // A lazily-activated tile can start weaving after the page already fell back to mono (or
355
+ // after a scroll-away/scroll-back). Take the buffer back to the SBS shape when that happens
356
+ // — otherwise the first 3D frames render into a 1:1 store and each eye is half a subject.
357
+ if (this._mode !== '3d') this.stopMono();
358
+ this._tick();
359
+ const r = this.renderer;
360
+ const eye = this._ensureEye();
361
+ r.clear();
362
+ r.setScissorTest(true);
363
+ for (const view of views) {
364
+ const vp = layer.getViewport(view);
365
+ if (!vp) continue;
366
+ r.setViewport(vp.x, vp.y, vp.width, vp.height);
367
+ r.setScissor(vp.x, vp.y, vp.width, vp.height);
368
+ if (eye) {
369
+ eye.setFromView(view);
370
+ r.render(this.scene, eye.camera);
371
+ }
372
+ if (this._feather) this._feather.render(r, vp);
373
+ }
374
+ r.setScissorTest(false);
375
+ }
376
+
377
+ /**
378
+ * Supply the ./three glue. Optional: without it the 3D path cannot build its eye camera, so
379
+ * `./splat` and `./model` pass it for you. Kept injectable so this module never imports
380
+ * three.js itself and stays usable with any EyeCamera-shaped object.
381
+ */
382
+ useEyeCamera(EyeCameraClass, EdgeFeatherClass) {
383
+ this._EyeCamera = EyeCameraClass;
384
+ if (EdgeFeatherClass && this._featherPx > 0) {
385
+ this._feather = new EdgeFeatherClass(this._THREE, { px: this._featherPx });
386
+ }
387
+ return this;
388
+ }
389
+
390
+ /** Drive a flat, single-camera render loop for browsers without inline-3D. */
391
+ startMono() {
392
+ if (this._monoRaf || this._disposed) return;
393
+ this._mode = 'mono'; // BEFORE the resize — the mode is what picks the buffer shape
394
+ this._resize();
395
+ const loop = () => {
396
+ if (this._disposed) return;
397
+ this._monoRaf = requestAnimationFrame(loop);
398
+ this._tick();
399
+ const r = this.renderer;
400
+ r.clear();
401
+ r.setViewport(0, 0, this.canvas.width, this.canvas.height);
402
+ r.render(this.scene, this.monoCamera);
403
+ };
404
+ this._monoRaf = requestAnimationFrame(loop);
405
+ }
406
+
407
+ stopMono() {
408
+ if (this._monoRaf) cancelAnimationFrame(this._monoRaf);
409
+ this._monoRaf = 0;
410
+ this._mode = '3d';
411
+ this._resize();
412
+ }
413
+
414
+ /** True while the side-by-side backing store is in use (the 3D path is driving this viewer). */
415
+ get is3D() {
416
+ return this._mode === '3d';
417
+ }
418
+
419
+ dispose() {
420
+ this._disposed = true;
421
+ this.stopMono();
422
+ if (this._ro) this._ro.disconnect();
423
+ else removeEventListener('resize', this._onResize);
424
+ this._unbindOrbit();
425
+ this.renderer.dispose();
426
+ }
427
+
428
+ // ── internals ─────────────────────────────────────────────────────────────────────────
429
+
430
+ _ensureEye() {
431
+ if (!this._eye && this._EyeCamera) this._eye = new this._EyeCamera(this._THREE);
432
+ return this._eye;
433
+ }
434
+
435
+ _applyTransform() {
436
+ const s = this._fitScale * this._zoom;
437
+ this._pivot.scale.setScalar(s);
438
+ // Order 'XYZ' == R = Rx(pitch) · Ry(yaw), and the order is the whole point.
439
+ //
440
+ // Yaw must act in the subject's OWN frame (spin it on its axis); pitch must act in the
441
+ // VIEWER's frame (tilt it toward or away from you), and stay screen-horizontal however far
442
+ // the subject has been spun. Rx outermost gives exactly that: Ry never moves the Y axis, so
443
+ // the subject's up-vector after the pair is Rx(pitch)·(0,1,0) — independent of yaw.
444
+ //
445
+ // 'YXZ' (R = Ry · Rx) was the bug: it applies pitch INSIDE the yawed frame, so the pitch
446
+ // axis is itself yawed. At yaw 90° that axis has swung onto world Z and dragging up/down
447
+ // rolls the subject instead of tilting it. Correct head-on, wrong the moment you turn it —
448
+ // which is why it survived review and only showed up when two controls were combined.
449
+ this._pivot.rotation.set((this._pitch * Math.PI) / 180, (this._yaw * Math.PI) / 180, 0, 'XYZ');
450
+ }
451
+
452
+ /**
453
+ * Size the drawing buffer. In 3D it is DOUBLE-WIDTH in device pixels, because
454
+ * getViewport() splits canvas.width in half for the two eyes — the browser squashing that
455
+ * 2:1 buffer into the 1:1 CSS box IS the side-by-side squeeze, and the weave un-squeezes it.
456
+ * In mono it must stay 1:1 or the flat render is stretched.
457
+ */
458
+ _resize() {
459
+ if (this._disposed) return;
460
+ const box = this.canvas.getBoundingClientRect();
461
+ if (box.width < 1 || box.height < 1) return;
462
+ const dpr = Math.min(window.devicePixelRatio || 1, 2) * this.renderScale;
463
+ const w = Math.max(1, Math.round(box.width * dpr));
464
+ const h = Math.max(1, Math.round(box.height * dpr));
465
+ this.renderer.setSize(this._mode === 'mono' ? w : w * 2, h, false);
466
+ this.monoCamera.aspect = box.width / box.height;
467
+ this.monoCamera.updateProjectionMatrix();
468
+ }
469
+
470
+ /** Damping + idle turntable. Called once per rendered frame, 3D or mono. */
471
+ _tick() {
472
+ const t = now();
473
+ const dt = this._lastTick ? Math.min((t - this._lastTick) / 1000, 0.1) : 0;
474
+ this._lastTick = t;
475
+
476
+ if (this.idleSpin && !this._reduceMotion && t - this._lastInput > IDLE_DELAY_MS) {
477
+ this._targetYaw += this.idleSpin * dt;
478
+ }
479
+ // Critically-damped-ish approach. Instant snapping reads as jitter on a head-tracked
480
+ // display, where the viewer is already moving relative to the content.
481
+ const k = dt > 0 ? 1 - Math.pow(0.001, dt) : 1;
482
+ this._yaw += (this._targetYaw - this._yaw) * k;
483
+ this._pitch += (this._targetPitch - this._pitch) * k;
484
+ this._applyTransform();
485
+ }
486
+
487
+ _bindOrbit() {
488
+ const el = this.canvas;
489
+ let dragging = false;
490
+ let lastX = 0;
491
+ let lastY = 0;
492
+
493
+ this._onDown = (ev) => {
494
+ dragging = true;
495
+ lastX = ev.clientX;
496
+ lastY = ev.clientY;
497
+ this._lastInput = now();
498
+ el.setPointerCapture?.(ev.pointerId);
499
+ };
500
+ this._onMove = (ev) => {
501
+ if (!dragging) return;
502
+ const box = el.getBoundingClientRect();
503
+ // A full drag across the tile is a half turn — predictable regardless of tile size.
504
+ //
505
+ // BOTH axes must make the near face follow the cursor, and the signs are not symmetric.
506
+ // Ry(+yaw) swings the near face toward +x (right), so yaw ADDS dx. Rx(+pitch) swings it
507
+ // toward −y (down), so pitch must also ADD dy — subtracting it sends the face the wrong
508
+ // way and reads as an inverted axis next to a correct one, which is worse than both being
509
+ // inverted.
510
+ this._targetYaw += ((ev.clientX - lastX) / Math.max(box.width, 1)) * 180;
511
+ this._targetPitch = clamp(
512
+ this._targetPitch + ((ev.clientY - lastY) / Math.max(box.height, 1)) * 180,
513
+ this.pitchLimit[0],
514
+ this.pitchLimit[1],
515
+ );
516
+ lastX = ev.clientX;
517
+ lastY = ev.clientY;
518
+ this._lastInput = now();
519
+ };
520
+ this._onUp = (ev) => {
521
+ dragging = false;
522
+ this._lastInput = now();
523
+ el.releasePointerCapture?.(ev.pointerId);
524
+ };
525
+ this._onWheel = (ev) => {
526
+ ev.preventDefault();
527
+ this._zoom = clamp(this._zoom * (ev.deltaY > 0 ? 0.92 : 1.087), 0.2, 6);
528
+ this._lastInput = now();
529
+ this._applyTransform();
530
+ };
531
+
532
+ el.style.touchAction = 'none'; // or the browser eats the drag as a scroll
533
+ el.addEventListener('pointerdown', this._onDown);
534
+ el.addEventListener('pointermove', this._onMove);
535
+ el.addEventListener('pointerup', this._onUp);
536
+ el.addEventListener('pointercancel', this._onUp);
537
+ el.addEventListener('wheel', this._onWheel, { passive: false });
538
+ }
539
+
540
+ _unbindOrbit() {
541
+ const el = this.canvas;
542
+ if (!this._onDown) return;
543
+ el.removeEventListener('pointerdown', this._onDown);
544
+ el.removeEventListener('pointermove', this._onMove);
545
+ el.removeEventListener('pointerup', this._onUp);
546
+ el.removeEventListener('pointercancel', this._onUp);
547
+ el.removeEventListener('wheel', this._onWheel);
548
+ }
549
+ }
550
+
551
+ function now() {
552
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
553
+ }