@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.
- package/CHANGELOG.md +132 -0
- package/README.md +100 -8
- package/index.d.ts +221 -0
- package/js/inline3d-model.js +169 -0
- package/js/inline3d-splat.js +333 -0
- package/js/inline3d-three.js +154 -0
- package/js/inline3d-viewer.js +553 -0
- package/js/inline3d.js +1221 -0
- package/model.d.ts +64 -0
- package/package.json +82 -8
- package/splat.d.ts +91 -0
- package/three.d.ts +37 -0
- package/viewer.d.ts +94 -0
|
@@ -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
|
+
}
|