@displayxr/inline3d 1.5.0 → 1.6.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 +62 -0
- package/README.md +10 -3
- package/js/inline3d-three.js +22 -4
- package/js/inline3d-undock.js +55 -27
- package/js/inline3d-viewer.js +121 -7
- package/js/inline3d.js +5 -3
- package/package.json +1 -1
- package/viewer.d.ts +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,68 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
|
|
|
5
5
|
`./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
|
|
6
6
|
which tier they touch, because that is what tells you whether an upgrade can move your pixels.
|
|
7
7
|
|
|
8
|
+
## 1.6.0 — 2026-09-08
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./viewer`) and fixes a **documentation error in the core tier**.
|
|
11
|
+
Purely additive to the viewer's API: no framing behaviour changes and no existing option changes
|
|
12
|
+
meaning, so a page that ignores everything below renders identically to 1.5.1.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`SceneViewer` has an output surface** ([#26](https://github.com/DisplayXR/displayxr-web/issues/26)).
|
|
17
|
+
It could frame, scale and orbit a subject but never say where the subject ended up, so pages
|
|
18
|
+
that needed that — a pop-out readout, a depth-budget check, a HUD that must clear the model —
|
|
19
|
+
had to read `_pivot`, `_fitScale` and `_zoom`. Three additions replace all of it:
|
|
20
|
+
- **`getSubjectBounds()`** → `{center, extent, front, back, scale}` in **display metres**, for
|
|
21
|
+
the pose being drawn. `front` is the surface nearest the viewer (`> 0` = out of the glass),
|
|
22
|
+
`back` the far side, `scale` the model-unit → metre factor in force (fit × zoom).
|
|
23
|
+
**Call it per frame.** The orbit rotates the *subject*, so yaw swings its depth into the
|
|
24
|
+
display's `z`: a page-shaped subject 1 m × 0.02 m is 0.01 m deep face-on and 0.5 m deep
|
|
25
|
+
turned side-on. Anything measured once at load is correct at yaw 0 and wrong everywhere
|
|
26
|
+
else — and with `idleSpin` on, yaw 0 is a passing instant. The call allocates one object and
|
|
27
|
+
does no matrix work.
|
|
28
|
+
- **`depthOffset`** (get/set) — slides the subject along the depth axis in display metres,
|
|
29
|
+
`+` toward the viewer. It **translates and never rescales**, so it moves the depth budget
|
|
30
|
+
without resizing it.
|
|
31
|
+
- **`getPose({target})`** — the counterpart to `setPose`. Yaw/pitch/zoom are eased, so
|
|
32
|
+
mid-gesture "what is drawn" and "what it is settling toward" genuinely differ; the default
|
|
33
|
+
reports the drawn value, `target: true` the destination.
|
|
34
|
+
- `setPose()` now also accepts `depthOffset`.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **`fitTo()` no longer discards `depthOffset`.** It used to hardcode the pivot's z to 0, so
|
|
39
|
+
reframing a subject silently threw away where the author had placed it. `resetPose()` clears
|
|
40
|
+
it, which is where "back to default" belongs. **No effect on any 1.5.x page**: the offset is 0
|
|
41
|
+
unless something sets it, and 0 is what `fitTo` used to write.
|
|
42
|
+
- `samples/model` reads `getSubjectBounds()` instead of `_fitScale`, and prints its footprint and
|
|
43
|
+
pop-out **live** — the visible disagreement between that and the static fit numbers is the
|
|
44
|
+
point, and is why the call is per-frame.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **The depth axis was documented BACKWARDS.** `docs/authoring-inline-3d.md` and the header of
|
|
49
|
+
`inline3d-three.js` both said "`+z` behind the glass, `−z` in front". It is the other way
|
|
50
|
+
round: the runtime places the nominal viewer at `z = +0.6 m` with the glass at `z = 0`
|
|
51
|
+
(`dxr_view_math`'s `nomv`), so **`+z` is toward the viewer, out of the glass**. Every mono
|
|
52
|
+
fallback camera in this repo already sat at `+z` for that reason, so only the prose was wrong —
|
|
53
|
+
but the prose is what authors code against, and the symptom is a depth control whose labels are
|
|
54
|
+
inverted. Symmetric subjects hide it completely. New section:
|
|
55
|
+
[Which way is out](docs/authoring-inline-3d.md#which-way-is-out).
|
|
56
|
+
|
|
57
|
+
## 1.5.1 — 2026-09-06
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **Undock: `ended` now means the viewer exited.** The browser contract (browser-pvt#25) resolves
|
|
62
|
+
`layer.undock()` on a successful LAUNCH and reports the viewer's exit separately as the XRSession's
|
|
63
|
+
`undockend` event; 1.4.0/1.5.0 derived `ended` from the launch promise, so it resolved a frame after
|
|
64
|
+
the window opened. The helper now arms an `undockend` listener before launching and resolves
|
|
65
|
+
`ended` on it; the browser's DOMException names (NotAllowedError, NotSupportedError,
|
|
66
|
+
SecurityError, InvalidStateError, OperationError) map onto the four contract names. The
|
|
67
|
+
fallback path is unchanged (`detached === true`, `ended` immediate).
|
|
68
|
+
- README: vendoring note — `inline3d-mode-switch.js` is a static dependency of `inline3d.js`.
|
|
69
|
+
|
|
8
70
|
## 1.5.0 — 2026-09-06
|
|
9
71
|
|
|
10
72
|
### Added
|
package/README.md
CHANGED
|
@@ -65,8 +65,14 @@ if (!wall.supported) {
|
|
|
65
65
|
The browser weaves each element's stereo pair at its on-screen rect; the surrounding DOM stays flat.
|
|
66
66
|
The runtime batches every visible window into one weave per frame, so it scales to a wall of elements.
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
Most pages want the default — a **display rig**, where the canvas is a portal onto a virtual
|
|
69
|
+
display and the runtime places the eyes. That includes every scene that frames a *subject* (a model
|
|
70
|
+
or splat viewer, an avatar, a product hero) **even when the user orbits it**: rotate the subject,
|
|
71
|
+
not the camera, and the stereo comes out the same for a figurine and an airliner.
|
|
72
|
+
|
|
73
|
+
A scene whose viewpoint the user *moves through a world* — first person, a walkthrough, a game, a
|
|
74
|
+
map, an editor, a ported VR app — can instead hand its own camera to the runtime and let eye
|
|
75
|
+
tracking perturb that frustum. That is a **camera rig**:
|
|
70
76
|
|
|
71
77
|
```js
|
|
72
78
|
import { cameraRigFromCamera } from '@displayxr/inline3d/three';
|
|
@@ -104,7 +110,8 @@ js/
|
|
|
104
110
|
inline3d.js the SDK: createInline3D() → { addImage, addVideo, addScene }, feature-detect,
|
|
105
111
|
SBS buffer management, and a lazy create/close lifecycle for many windows
|
|
106
112
|
inline3d-three.js optional three.js helper (EyeCamera: off-axis projection from the session's eyes)
|
|
107
|
-
inline3d-viewer.js experimental: SceneViewer — framing, orbit, idle turntable, mono fallback
|
|
113
|
+
inline3d-viewer.js experimental: SceneViewer — framing, orbit, idle turntable, mono fallback,
|
|
114
|
+
and the placement readback (getSubjectBounds / getPose / depthOffset)
|
|
108
115
|
inline3d-splat.js experimental: addSplat() — a Gaussian splat window via Spark
|
|
109
116
|
inline3d-model.js experimental: addModel() — a glTF/GLB window; wires Draco / meshopt / KTX2
|
|
110
117
|
from what the asset declares (you serve the decoder files — see the guide)
|
package/js/inline3d-three.js
CHANGED
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
// are already scaled to your scene by the layer's `virtualDisplayHeight` (see addScene) — the
|
|
42
42
|
// runtime places each eye at eye_physical × (virtualDisplayHeight / element_physical_height),
|
|
43
43
|
// so the z=0 plane spans that virtual display. Author your scene in metres for a display that
|
|
44
|
-
// tall (0.24 m by default), put focused content at z=0 (
|
|
45
|
-
//
|
|
44
|
+
// tall (0.24 m by default), put focused content at z=0 (POSITIVE z is toward the viewer, out
|
|
45
|
+
// of the glass; negative z is behind it), and render `eye.camera` directly. No per-frame world scaling — that is the whole
|
|
46
46
|
// point of using the rig instead of re-deriving it in the app, and it mirrors the native
|
|
47
47
|
// reference apps (cube_handle), which supply one scale number and consume render-ready views.
|
|
48
48
|
//
|
|
@@ -167,10 +167,18 @@ function scratch(THREE) {
|
|
|
167
167
|
* anything here: this function only fills in a descriptor, and every off-axis projection stays
|
|
168
168
|
* in the runtime, where it is the same code the native apps use.
|
|
169
169
|
*
|
|
170
|
+
* WHICH RIG. Decide by what the USER moves, not by whether you hold a camera. If the user turns
|
|
171
|
+
* a SUBJECT — a model, a splat, an avatar, a product hero, and yes, an orbit around one — use a
|
|
172
|
+
* display rig and rotate the subject under a fixed portal: a display rig is scale-invariant, so a
|
|
173
|
+
* figurine and an airframe get the same stereo. A camera rig is literal (two eyes 63 mm apart at
|
|
174
|
+
* your camera), so its disparity falls off as baseline / framing distance and a big subject framed
|
|
175
|
+
* from far away renders FLAT. Use a camera rig for a viewpoint the user moves through a world:
|
|
176
|
+
* first person, a walkthrough, a game, a map, an editor, a ported VR app.
|
|
177
|
+
*
|
|
170
178
|
* CONVERGENCE IS THE ONE KNOB TO GET RIGHT. It is the distance at which content sits ON the
|
|
171
179
|
* glass; everything nearer pops out, everything further recedes. Point it at whatever the viewer
|
|
172
|
-
* is meant to be looking at (
|
|
173
|
-
* that is usually just the
|
|
180
|
+
* is meant to be looking at (the subject's centre, a hit-tested surface) — with a moving camera
|
|
181
|
+
* that is usually just the distance to it. Left at 0 it means infinity, which puts the entire
|
|
174
182
|
* scene in front of the display and is comfortable for almost nothing.
|
|
175
183
|
*
|
|
176
184
|
* COMFORT. The runtime's rule is `ipdFactor × metersToVirtual × convergenceDiopters × N <= 1`
|
|
@@ -180,6 +188,16 @@ function scratch(THREE) {
|
|
|
180
188
|
* runtime clamps out-of-range values itself, once, with a warning — but a scene authored in
|
|
181
189
|
* centimetres with a 0.1-unit convergence is the shape of the mistake.
|
|
182
190
|
*
|
|
191
|
+
* COMFORT IS NOT A DEPTH METER. It bounds where the depth budget SITS (it guards the background
|
|
192
|
+
* against divergence), not how big the budget is. The budget is
|
|
193
|
+
* `(baseline / tan(vFov/2)) * (1/z_near - 1/z_far)`, which convergence cancels out of exactly —
|
|
194
|
+
* convergence slides the scene relative to the glass and never resizes its depth, which is why
|
|
195
|
+
* ipdFactor/parallaxFactor here are ABSOLUTE rather than scaled by the convergence distance. A
|
|
196
|
+
* window that weaves but looks FLAT is a budget problem (a 63 mm baseline framing a large subject
|
|
197
|
+
* from far away) and comfort will report a healthy number while it happens. Do not rescale the
|
|
198
|
+
* baseline to paper over it: a camera rig needing a scale correction is a scene that wanted a
|
|
199
|
+
* display rig.
|
|
200
|
+
*
|
|
183
201
|
* @param {object} THREE your imported three.js module namespace.
|
|
184
202
|
* @param {object} camera a THREE.PerspectiveCamera (`.fov` in degrees, `.matrixWorld` current).
|
|
185
203
|
* @param {object} [opts]
|
package/js/inline3d-undock.js
CHANGED
|
@@ -135,9 +135,19 @@ function launchProtocol(url) {
|
|
|
135
135
|
|
|
136
136
|
const UNDOCK_ERRORS = ['not-installed', 'src-not-allowed', 'no-activation', 'busy'];
|
|
137
137
|
|
|
138
|
+
// The browser refuses with DOMException names (patch 0130); each maps onto one contract name.
|
|
139
|
+
const DOM_ERROR_NAMES = {
|
|
140
|
+
NotAllowedError: 'no-activation', // no transient user activation
|
|
141
|
+
NotSupportedError: 'not-installed', // no registered viewer for this type
|
|
142
|
+
SecurityError: 'src-not-allowed', // src outside the allowlist
|
|
143
|
+
InvalidStateError: 'busy', // an undock is already live in this frame
|
|
144
|
+
OperationError: 'not-installed', // the viewer failed to launch
|
|
145
|
+
};
|
|
146
|
+
|
|
138
147
|
/** Give a rejection one of the four contract names, keeping the browser's own where it has one. */
|
|
139
148
|
function undockError(e, fallbackName, message) {
|
|
140
|
-
const name =
|
|
149
|
+
const name =
|
|
150
|
+
e && UNDOCK_ERRORS.includes(e.name) ? e.name : (e && DOM_ERROR_NAMES[e.name]) || fallbackName;
|
|
141
151
|
const err = new Error(message || (e && e.message) || `[inline3d] undock failed (${name}).`);
|
|
142
152
|
err.name = name;
|
|
143
153
|
if (e) err.cause = e;
|
|
@@ -166,8 +176,10 @@ let inFlight = false;
|
|
|
166
176
|
* @param {number} [opts.margin] the page's fit margin, when it overrides the default.
|
|
167
177
|
* @param {string} [opts.title]
|
|
168
178
|
* @returns {Promise<{ended:Promise<void>, viewer:UndockType, detached?:boolean}>}
|
|
169
|
-
*
|
|
170
|
-
*
|
|
179
|
+
* Resolves once the viewer has LAUNCHED (API path: `layer.undock()` resolved, i.e. the viewer
|
|
180
|
+
* process was spawned; it never waits for the viewer). `ended` resolves when the viewer exits -
|
|
181
|
+
* the API path hears that as the XRSession's `undockend` event; the fallback path never hears
|
|
182
|
+
* back, so there `ended` resolves immediately and `detached === true`.
|
|
171
183
|
* Rejects with an Error named `not-installed` | `src-not-allowed` | `no-activation` | `busy`.
|
|
172
184
|
*/
|
|
173
185
|
export function undock(target, opts) {
|
|
@@ -184,7 +196,11 @@ export function undock(target, opts) {
|
|
|
184
196
|
}
|
|
185
197
|
if (inFlight) return Promise.reject(undockError(null, 'busy', '[inline3d] an undock is already in flight.'));
|
|
186
198
|
|
|
187
|
-
|
|
199
|
+
// The resolver hands back `{layer, session}` (the session carries the `undockend` event); a bare
|
|
200
|
+
// layer is accepted too, in which case the viewer's exit is simply not observable.
|
|
201
|
+
const found = layerResolver ? layerResolver(target) : null;
|
|
202
|
+
const layer = found && typeof found === 'object' && 'layer' in found ? found.layer : found;
|
|
203
|
+
const session = found && typeof found === 'object' && 'session' in found ? found.session : null;
|
|
188
204
|
const viewer = opts.type;
|
|
189
205
|
|
|
190
206
|
// ── fallback: the OS protocol ──────────────────────────────────────────────────────────
|
|
@@ -211,41 +227,53 @@ export function undock(target, opts) {
|
|
|
211
227
|
if (opts.margin !== undefined) init.margin = opts.margin;
|
|
212
228
|
if (opts.title) init.title = opts.title;
|
|
213
229
|
|
|
230
|
+
// THE BROWSER CONTRACT (browser-pvt#25 / patch 0130): `layer.undock(init)` RESOLVES ON A
|
|
231
|
+
// SUCCESSFUL LAUNCH - as soon as the viewer process is spawned - and never waits for it; every
|
|
232
|
+
// refusal is a prompt rejection (NotAllowedError / NotSupportedError / SecurityError /
|
|
233
|
+
// InvalidStateError / OperationError). The viewer's exit arrives separately, as the `undockend`
|
|
234
|
+
// event on the XRSession. One live undock per frame, so the NEXT `undockend` after a
|
|
235
|
+
// successful launch is this one's - no correlation id needed. The listener is armed BEFORE the
|
|
236
|
+
// launch so a viewer that exits immediately cannot slip between the two.
|
|
237
|
+
let endedResolve = null;
|
|
238
|
+
const ended = new Promise((resolve) => {
|
|
239
|
+
if (!session || typeof session.addEventListener !== 'function') {
|
|
240
|
+
// No session to listen on: the launch still works, the exit is simply not observable -
|
|
241
|
+
// so `ended` resolves at launch (as the fallback path does) rather than holding the
|
|
242
|
+
// one-live-undock guard for ever.
|
|
243
|
+
endedResolve = resolve;
|
|
244
|
+
resolve();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const onEnd = () => {
|
|
248
|
+
session.removeEventListener('undockend', onEnd);
|
|
249
|
+
resolve();
|
|
250
|
+
};
|
|
251
|
+
session.addEventListener('undockend', onEnd);
|
|
252
|
+
endedResolve = () => {
|
|
253
|
+
session.removeEventListener('undockend', onEnd);
|
|
254
|
+
resolve();
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
|
|
214
258
|
let call;
|
|
215
259
|
try {
|
|
216
260
|
call = Promise.resolve(layer.undock(init));
|
|
217
261
|
} catch (e) {
|
|
218
262
|
// A synchronous throw is the same failure as a rejection; one .catch() should cover both.
|
|
263
|
+
if (endedResolve) endedResolve();
|
|
219
264
|
return Promise.reject(undockError(e, 'src-not-allowed'));
|
|
220
265
|
}
|
|
221
266
|
inFlight = true;
|
|
222
|
-
|
|
267
|
+
ended.then(() => {
|
|
223
268
|
inFlight = false;
|
|
224
269
|
});
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
// (not-installed / src-not-allowed / no-activation / busy) is decided before any window
|
|
228
|
-
// exists — or it stays pending until the viewer exits. A short race tells them apart without
|
|
229
|
-
// inventing an event: whatever has not rejected by then launched. The launch has already
|
|
230
|
-
// happened synchronously above, so this wait costs the user nothing.
|
|
231
|
-
const LAUNCH_MS = 150;
|
|
232
|
-
const launchProbe = settled.then(
|
|
233
|
-
() => 'launched',
|
|
270
|
+
return call.then(
|
|
271
|
+
() => ({ ended, viewer, detached: false }),
|
|
234
272
|
(e) => {
|
|
273
|
+
// Refused before any window existed: nothing is in flight and nothing will end.
|
|
274
|
+
inFlight = false;
|
|
275
|
+
if (endedResolve) endedResolve();
|
|
235
276
|
throw undockError(e, 'not-installed');
|
|
236
277
|
}
|
|
237
278
|
);
|
|
238
|
-
// The probe LOSES the race whenever the viewer stays open, and a rejection arriving after that
|
|
239
|
-
// would otherwise be an unhandled one. Marked handled here; the same rejection still reaches
|
|
240
|
-
// the caller through `ended`, which is where a late failure belongs.
|
|
241
|
-
launchProbe.catch(() => {});
|
|
242
|
-
return Promise.race([
|
|
243
|
-
launchProbe,
|
|
244
|
-
new Promise((resolve) => window.setTimeout(() => resolve('launched'), LAUNCH_MS)),
|
|
245
|
-
]).then(() => ({
|
|
246
|
-
// A rejection AFTER the launch window is the viewer failing later, and it belongs on `ended`.
|
|
247
|
-
ended: settled.then(() => undefined),
|
|
248
|
-
viewer,
|
|
249
|
-
detached: false,
|
|
250
|
-
}));
|
|
251
279
|
}
|
package/js/inline3d-viewer.js
CHANGED
|
@@ -49,6 +49,9 @@ const DEFAULT_DEPTH_LIMIT = 4.0;
|
|
|
49
49
|
const IDLE_DELAY_MS = 2500;
|
|
50
50
|
|
|
51
51
|
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
|
52
|
+
// NaN/Infinity into a transform silently blanks the tile — three propagates it into the
|
|
53
|
+
// matrix and every vertex lands undefined. Reject at the setter instead.
|
|
54
|
+
const finite = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
|
|
52
55
|
|
|
53
56
|
/**
|
|
54
57
|
* Robust model-space bounds from a flat array of splat/vertex centres.
|
|
@@ -220,6 +223,14 @@ export class SceneViewer {
|
|
|
220
223
|
this._fitScale = 1;
|
|
221
224
|
this._zoom = 1;
|
|
222
225
|
this._targetZoom = 1;
|
|
226
|
+
// Author-driven slide along the depth axis, display metres, +z toward the viewer. Applied
|
|
227
|
+
// by _applyTransform, PRESERVED by fitTo, cleared by resetPose. Default 0 means every page
|
|
228
|
+
// that never touches it is bit-identical to 1.5.x.
|
|
229
|
+
this._depthOffset = 0;
|
|
230
|
+
// Subject half-extents in MODEL units, from the last fitTo. getSubjectBounds turns these
|
|
231
|
+
// into a display-space box under the live pose; without them it would have to re-measure
|
|
232
|
+
// the content every call.
|
|
233
|
+
this._subjectHalf = [0, 0, 0];
|
|
223
234
|
this._yaw = 0;
|
|
224
235
|
this._pitch = 0;
|
|
225
236
|
this._targetYaw = 0;
|
|
@@ -278,10 +289,12 @@ export class SceneViewer {
|
|
|
278
289
|
const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
|
|
279
290
|
|
|
280
291
|
this._centering.position.set(-c[0], -c[1], -c[2]);
|
|
292
|
+
// Recorded for getSubjectBounds(). Model units; the fit scale is applied at read time so a
|
|
293
|
+
// later zoom or orbit needs no re-measure.
|
|
294
|
+
this._subjectHalf = [Math.abs(e[0]) / 2, Math.abs(e[1]) / 2, Math.abs(e[2]) / 2];
|
|
281
295
|
|
|
282
296
|
if (this.fit === 'none') {
|
|
283
297
|
this._fitScale = 1;
|
|
284
|
-
this._pivot.position.z = 0;
|
|
285
298
|
} else {
|
|
286
299
|
const box = this.canvas.getBoundingClientRect();
|
|
287
300
|
const aspect = box.height > 0 ? box.width / box.height : 1;
|
|
@@ -325,8 +338,9 @@ export class SceneViewer {
|
|
|
325
338
|
//
|
|
326
339
|
// A biased variant that slid the subject behind the glass was tried and dropped: on
|
|
327
340
|
// hardware it read WORSE, and it moved content the wrong way besides. Do not re-add it
|
|
328
|
-
// without a hardware comparison.
|
|
329
|
-
|
|
341
|
+
// without a hardware comparison. `depthOffset` is NOT that variant: it is an author
|
|
342
|
+
// asking for a specific placement, and it stays 0 unless someone sets it — so the
|
|
343
|
+
// default framing this comment defends is unchanged.
|
|
330
344
|
|
|
331
345
|
// Backstop only: something pathologically deep still gets scaled down.
|
|
332
346
|
const sz = (this.depthLimit * vH) / ez;
|
|
@@ -341,19 +355,115 @@ export class SceneViewer {
|
|
|
341
355
|
this.monoCamera.lookAt(0, 0, 0);
|
|
342
356
|
}
|
|
343
357
|
|
|
344
|
-
/**
|
|
345
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Set the pose directly. Angles in degrees; zoom is a multiplier on the fit scale;
|
|
360
|
+
* depthOffset is display metres along the depth axis (+ toward the viewer).
|
|
361
|
+
*
|
|
362
|
+
* This SNAPS — it writes the eased value and its target together. The easing in _tick exists
|
|
363
|
+
* for input, not for programmatic placement.
|
|
364
|
+
*/
|
|
365
|
+
setPose({ yaw, pitch, zoom, depthOffset } = {}) {
|
|
346
366
|
if (yaw !== undefined) this._targetYaw = this._yaw = yaw;
|
|
347
367
|
if (pitch !== undefined) {
|
|
348
368
|
this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
|
|
349
369
|
}
|
|
350
370
|
if (zoom !== undefined) this._targetZoom = this._zoom = clamp(zoom, ZOOM_MIN, ZOOM_MAX);
|
|
371
|
+
if (depthOffset !== undefined) this._depthOffset = finite(depthOffset, this._depthOffset);
|
|
372
|
+
this._applyTransform();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* What the pose IS right now — the counterpart to setPose, and the reason an app no longer
|
|
377
|
+
* has to read `_zoom` to know where its subject sits.
|
|
378
|
+
*
|
|
379
|
+
* `_tick` eases yaw/pitch/zoom toward their targets, so during an orbit or a wheel-zoom the
|
|
380
|
+
* two answers genuinely differ and consumers want different ones: a readout that describes
|
|
381
|
+
* what is ON SCREEN wants the eased value (the default), while "remember this view" wants
|
|
382
|
+
* the target it is settling on. depthOffset never eases, so both agree.
|
|
383
|
+
*
|
|
384
|
+
* @param {object} [opts]
|
|
385
|
+
* @param {boolean} [opts.target=false] report the values being eased TOWARD.
|
|
386
|
+
* @returns {{yaw:number, pitch:number, zoom:number, depthOffset:number}} degrees / multiplier
|
|
387
|
+
* / metres.
|
|
388
|
+
*/
|
|
389
|
+
getPose({ target = false } = {}) {
|
|
390
|
+
return {
|
|
391
|
+
yaw: target ? this._targetYaw : this._yaw,
|
|
392
|
+
pitch: target ? this._targetPitch : this._pitch,
|
|
393
|
+
zoom: target ? this._targetZoom : this._zoom,
|
|
394
|
+
depthOffset: this._depthOffset,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Where the subject actually IS, in display metres, under the pose being drawn.
|
|
400
|
+
*
|
|
401
|
+
* This is the viewer's output surface. Everything a page needs in order to reason about
|
|
402
|
+
* depth — a pop-out readout, a depth-budget check, a HUD that must clear the subject — is a
|
|
403
|
+
* function of this box, and none of it is derivable from the outside: the fit scale, the
|
|
404
|
+
* live zoom and the orbit are all viewer state.
|
|
405
|
+
*
|
|
406
|
+
* THE ORBIT IS WHY THIS CANNOT BE CACHED. The pivot rotates about Y (and X), so yaw swings
|
|
407
|
+
* the subject's DEPTH into the display's z and its width out of it. A page that measures its
|
|
408
|
+
* model once at load and scales by zoom is correct at yaw 0 and wrong everywhere else — and
|
|
409
|
+
* with `idleSpin` on, yaw 0 is a passing instant. Call this per frame; it allocates one
|
|
410
|
+
* object and does no matrix work.
|
|
411
|
+
*
|
|
412
|
+
* SIGNS. Display space puts the viewer at +z and the glass at z = 0, so `front` (the surface
|
|
413
|
+
* nearest the viewer) is the LARGER z and a positive `front` means the subject pops out of
|
|
414
|
+
* the glass. `back` is the far side; a negative `back` is depth behind the glass. See
|
|
415
|
+
* docs/authoring-inline-3d.md § "Which way is out".
|
|
416
|
+
*
|
|
417
|
+
* The box is axis-aligned in display space and encloses the oriented subject — the standard
|
|
418
|
+
* conservative bound, so it never under-reports pop-out.
|
|
419
|
+
*
|
|
420
|
+
* @returns {{center:{x:number,y:number,z:number}, extent:{x:number,y:number,z:number},
|
|
421
|
+
* front:number, back:number, scale:number}} metres, except `scale` which is the
|
|
422
|
+
* model-unit → metre factor currently in force (fit x zoom).
|
|
423
|
+
*/
|
|
424
|
+
getSubjectBounds() {
|
|
425
|
+
const s = this._fitScale * this._zoom;
|
|
426
|
+
const [hx, hy, hz] = this._subjectHalf;
|
|
427
|
+
const p = (this._pitch * Math.PI) / 180;
|
|
428
|
+
const y = (this._yaw * Math.PI) / 180;
|
|
429
|
+
// Rows of R = Rx(pitch) . Ry(yaw) — the same product _applyTransform builds, and the same
|
|
430
|
+
// order, which is the part that matters (see its comment on why 'XYZ' and not 'YXZ').
|
|
431
|
+
// |row| . half gives the AABB half-extent along that world axis.
|
|
432
|
+
const cp = Math.cos(p);
|
|
433
|
+
const sp = Math.sin(p);
|
|
434
|
+
const cy = Math.cos(y);
|
|
435
|
+
const sy = Math.sin(y);
|
|
436
|
+
const ex = s * (Math.abs(cy) * hx + Math.abs(sy) * hz);
|
|
437
|
+
const ey = s * (Math.abs(sp * sy) * hx + Math.abs(cp) * hy + Math.abs(sp * cy) * hz);
|
|
438
|
+
const ez = s * (Math.abs(cp * sy) * hx + Math.abs(sp) * hy + Math.abs(cp * cy) * hz);
|
|
439
|
+
const cz = this._depthOffset;
|
|
440
|
+
return {
|
|
441
|
+
center: { x: 0, y: 0, z: cz },
|
|
442
|
+
extent: { x: 2 * ex, y: 2 * ey, z: 2 * ez },
|
|
443
|
+
front: cz + ez,
|
|
444
|
+
back: cz - ez,
|
|
445
|
+
scale: s,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Slide the whole subject along the depth axis, display metres, + toward the viewer.
|
|
451
|
+
*
|
|
452
|
+
* Survives `fitTo` — a refit reframes the subject without discarding where the author put
|
|
453
|
+
* it — and is cleared by `resetPose`, which is where "back to default" belongs.
|
|
454
|
+
*/
|
|
455
|
+
get depthOffset() {
|
|
456
|
+
return this._depthOffset;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
set depthOffset(m) {
|
|
460
|
+
this._depthOffset = finite(m, this._depthOffset);
|
|
351
461
|
this._applyTransform();
|
|
352
462
|
}
|
|
353
463
|
|
|
354
|
-
/** Return to the framed default pose. */
|
|
464
|
+
/** Return to the framed default pose, depth slide included. */
|
|
355
465
|
resetPose() {
|
|
356
|
-
this.setPose({ yaw: 0, pitch: 0, zoom: 1 });
|
|
466
|
+
this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
|
|
357
467
|
this._lastInput = now();
|
|
358
468
|
}
|
|
359
469
|
|
|
@@ -625,6 +735,10 @@ export class SceneViewer {
|
|
|
625
735
|
_applyTransform() {
|
|
626
736
|
const s = this._fitScale * this._zoom;
|
|
627
737
|
this._pivot.scale.setScalar(s);
|
|
738
|
+
// The depth slide lives here, not in fitTo, so it survives a refit and cannot be left
|
|
739
|
+
// stale by a code path that forgets it. x/y are never written: the fit centres the subject
|
|
740
|
+
// on the tile and sliding it sideways is a scene concern, not a viewer one.
|
|
741
|
+
this._pivot.position.z = this._depthOffset;
|
|
628
742
|
// Order 'XYZ' == R = Rx(pitch) · Ry(yaw), and the order is the whole point.
|
|
629
743
|
//
|
|
630
744
|
// Yaw must act in the subject's OWN frame (spin it on its axis); pitch must act in the
|
package/js/inline3d.js
CHANGED
|
@@ -59,13 +59,15 @@ setUndockLayerResolver((el) => {
|
|
|
59
59
|
// The canvas itself, then a woven canvas INSIDE the element (a card wrapping its tile), then
|
|
60
60
|
// the element sitting inside a window's own container (a button in the tile's box). Anything
|
|
61
61
|
// further away is not this window's rect and takes the fallback.
|
|
62
|
-
|
|
62
|
+
// The session rides along: the viewer's exit is the XRSession's `undockend` event.
|
|
63
|
+
const hit = (win) => ({ layer: win.layer, session: m.session });
|
|
64
|
+
for (const win of m._windows.values()) if (win.canvas === el && win.layer) return hit(win);
|
|
63
65
|
for (const win of m._windows.values()) {
|
|
64
|
-
if (win.layer && typeof el.contains === 'function' && el.contains(win.canvas)) return win
|
|
66
|
+
if (win.layer && typeof el.contains === 'function' && el.contains(win.canvas)) return hit(win);
|
|
65
67
|
}
|
|
66
68
|
for (const win of m._windows.values()) {
|
|
67
69
|
const box = win.canvas.parentElement;
|
|
68
|
-
if (win.layer && box && typeof box.contains === 'function' && box.contains(el)) return win
|
|
70
|
+
if (win.layer && box && typeof box.contains === 'function' && box.contains(el)) return hit(win);
|
|
69
71
|
}
|
|
70
72
|
return null;
|
|
71
73
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|
package/viewer.d.ts
CHANGED
|
@@ -38,9 +38,35 @@ export interface SceneViewerOptions {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export interface OrbitPose {
|
|
41
|
+
/** Degrees about Y. */
|
|
41
42
|
yaw?: number;
|
|
43
|
+
/** Degrees about X, clamped to `pitchLimit`. */
|
|
42
44
|
pitch?: number;
|
|
45
|
+
/** Multiplier on the fit scale. */
|
|
43
46
|
zoom?: number;
|
|
47
|
+
/** Display metres along the depth axis, `+` toward the viewer (out of the glass). */
|
|
48
|
+
depthOffset?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where the subject sits in DISPLAY METRES under the pose currently being drawn — an
|
|
53
|
+
* axis-aligned box enclosing the oriented subject.
|
|
54
|
+
*
|
|
55
|
+
* Display space puts the viewer at `+z` and the glass at `z = 0`, so `front` is the larger z:
|
|
56
|
+
* a positive `front` means the subject pops OUT of the glass, a negative `back` means depth
|
|
57
|
+
* behind it.
|
|
58
|
+
*/
|
|
59
|
+
export interface SubjectPlacement {
|
|
60
|
+
/** Box centre. x and y are always 0 — the fit centres the subject on the tile. */
|
|
61
|
+
center: { x: number; y: number; z: number };
|
|
62
|
+
/** Full box size (not half-extents). */
|
|
63
|
+
extent: { x: number; y: number; z: number };
|
|
64
|
+
/** z of the surface nearest the viewer. `> 0` = in front of the glass. */
|
|
65
|
+
front: number;
|
|
66
|
+
/** z of the surface furthest from the viewer. `< 0` = behind the glass. */
|
|
67
|
+
back: number;
|
|
68
|
+
/** Model units → display metres currently in force (fit scale × zoom). */
|
|
69
|
+
scale: number;
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
/**
|
|
@@ -77,7 +103,31 @@ export declare class SceneViewer {
|
|
|
77
103
|
extent: number[] | { x: number; y: number; z: number },
|
|
78
104
|
): void;
|
|
79
105
|
|
|
106
|
+
/** Snap the pose. Writes the eased value and its target together. */
|
|
80
107
|
setPose(pose?: OrbitPose): void;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The pose as it is right now. `target: true` reports what it is easing TOWARD, which differs
|
|
111
|
+
* mid-orbit — a readout wants the default (eased), "save this view" wants the target.
|
|
112
|
+
*/
|
|
113
|
+
getPose(opts?: { target?: boolean }): Required<OrbitPose>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Where the subject is, in display metres, under the live pose. Safe and cheap to call every
|
|
117
|
+
* frame; allocates one object and does no matrix work.
|
|
118
|
+
*
|
|
119
|
+
* Recompute per frame rather than caching: orbit swings the subject's depth into the
|
|
120
|
+
* display's z, so a value measured at load is only correct at yaw 0.
|
|
121
|
+
*/
|
|
122
|
+
getSubjectBounds(): SubjectPlacement;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Slide the subject along the depth axis, display metres, `+` toward the viewer. Survives
|
|
126
|
+
* `fitTo`; cleared by `resetPose`.
|
|
127
|
+
*/
|
|
128
|
+
depthOffset: number;
|
|
129
|
+
|
|
130
|
+
/** Return to the framed default pose, depth slide included. */
|
|
81
131
|
resetPose(): void;
|
|
82
132
|
|
|
83
133
|
/**
|