@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.
- package/CHANGELOG.md +198 -0
- package/README.md +10 -1
- package/index.d.ts +74 -5
- package/js/inline3d-model.js +169 -0
- package/js/inline3d-splat.js +333 -0
- package/js/inline3d-three.js +23 -2
- package/js/inline3d-viewer.js +754 -0
- package/js/inline3d.js +661 -20
- package/model.d.ts +64 -0
- package/package.json +26 -3
- package/splat.d.ts +91 -0
- package/three.d.ts +12 -0
- package/viewer.d.ts +101 -0
|
@@ -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
|
+
}
|