@displayxr/inline3d 1.4.0 → 1.5.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 +65 -0
- package/index.d.ts +47 -0
- package/js/inline3d-mode-switch.js +246 -0
- package/js/inline3d-undock.js +55 -27
- package/js/inline3d.js +394 -21
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,71 @@ 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.5.1 — 2026-09-06
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Undock: `ended` now means the viewer exited.** The browser contract (browser-pvt#25) resolves
|
|
13
|
+
`layer.undock()` on a successful LAUNCH and reports the viewer's exit separately as the XRSession's
|
|
14
|
+
`undockend` event; 1.4.0/1.5.0 derived `ended` from the launch promise, so it resolved a frame after
|
|
15
|
+
the window opened. The helper now arms an `undockend` listener before launching and resolves
|
|
16
|
+
`ended` on it; the browser's DOMException names (NotAllowedError, NotSupportedError,
|
|
17
|
+
SecurityError, InvalidStateError, OperationError) map onto the four contract names. The
|
|
18
|
+
fallback path is unchanged (`detached === true`, `ended` immediate).
|
|
19
|
+
- README: vendoring note — `inline3d-mode-switch.js` is a static dependency of `inline3d.js`.
|
|
20
|
+
|
|
21
|
+
## 1.5.0 — 2026-09-06
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **The 2D↔3D switch is EASED, and every page gets it for free.** 1.4.0 made the panel's mode a
|
|
26
|
+
page-facing control and collapsed the stereo rig the moment a 1-view mode went active — correct,
|
|
27
|
+
and a snap. The transition now ramps: a **page-initiated** switch walks every window's
|
|
28
|
+
`ipdFactor`/`parallaxFactor` between 0 and what the page asked for over **180 ms**, **smoothstep**
|
|
29
|
+
(Hermite `3t^2 - 2t^3`) — the defaults the native DisplayXR apps configure, because this is a port
|
|
30
|
+
of the sequencer they already use (`dxr::ModeSwitch`, displayxr-common) rather than a second
|
|
31
|
+
design. New dependency-free module `js/inline3d-mode-switch.js` holds the state machine;
|
|
32
|
+
`test/mode-switch.test.mjs` mirrors the C++ smoke test case for case. *(core tier — additive)*
|
|
33
|
+
|
|
34
|
+
- **The ORDER is the feature, and it is asymmetric.** Going flat (a `viewCount === 1` target) ramps
|
|
35
|
+
the disparity **out first** and forwards the mode request only when it lands, so the panel flips
|
|
36
|
+
on already-flat content instead of snapping a stereo image flat. Coming back (a 2-view target)
|
|
37
|
+
forwards the request **first** and eases the disparity in **only once the panel REPORTS 3D** —
|
|
38
|
+
ramping up any earlier would put stereo on a still-flat panel, which is the blurry double image
|
|
39
|
+
the whole mode API exists to make unreachable. A hand-rolled tween gets exactly this wrong.
|
|
40
|
+
|
|
41
|
+
- **`createInline3D({ modeSwitch: { durationMs, easing, enabled } })`** — `durationMs` default
|
|
42
|
+
`180` (`0` keeps the ordering and lands in one frame), `easing` default `'smoothstep'` (also
|
|
43
|
+
`'linear'`, `'easeoutcubic'`; an unknown name warns once and falls back), `enabled: false`
|
|
44
|
+
restores 1.4.0's snap exactly. **`wall.modeSwitch`** is the read-only live state
|
|
45
|
+
`{active, factor}` for a page that wants to move its own 2D chrome alongside the panel. The SDK
|
|
46
|
+
adds **no UI** — which key or button toggles the display stays the page's call.
|
|
47
|
+
*(core tier — additive)*
|
|
48
|
+
|
|
49
|
+
- **What a page can feel, spelled out.** The restore is to the **configured** steady factors (each
|
|
50
|
+
window's own rig, never a hardcoded 1) and the ramp is a **copy** on the way to the layer, so the
|
|
51
|
+
1.4.0 guarantees hold unchanged: a per-frame `setViewRig` loop cannot walk the page out of 2D,
|
|
52
|
+
and a lazy tile that rebuilds mid-transition comes back at the current factor. The ramp is driven
|
|
53
|
+
by **wall-clock dt** from the session's frame loop (never frame counts, so it lasts the same at
|
|
54
|
+
30 fps and 144 fps), with a timer fallback so a held request still lands when frames stop.
|
|
55
|
+
Reversing mid-flight retargets from the disparity **in force** — the first press never snaps —
|
|
56
|
+
and a reversed going-flat switch **never fires**: nothing is asked of the display at all.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **`requestRenderingMode(i)` / `setStereoEnabled(false)` for a 1-view target now resolve when the
|
|
61
|
+
request has been FORWARDED**, i.e. after the ramp (~`durationMs`), not on the call. They reject
|
|
62
|
+
as before if the browser refuses — and a refusal ramps the disparity back **up**, because a
|
|
63
|
+
refused switch must leave the page in 3D rather than flat. One new failure: a request dropped by
|
|
64
|
+
a reversal before it ever fired rejects with an `Error` named **`superseded`**. Nothing changes
|
|
65
|
+
for a page that only awaits the promise it already awaited; `{ enabled: false }` restores the old
|
|
66
|
+
timing. *(core tier — behaviour change, opt-out)*
|
|
67
|
+
|
|
68
|
+
- A mode change the page did **not** request (another tab, the shell, a page that opens with the
|
|
69
|
+
panel already flat) still **snaps** — there is nothing to ramp from, and the reported state stays
|
|
70
|
+
the sole authority for the rig. `wall.stereoCollapsed` continues to mean what the display last
|
|
71
|
+
**reported**, never what is mid-ramp.
|
|
72
|
+
|
|
8
73
|
## 1.4.0 — 2026-09-06
|
|
9
74
|
|
|
10
75
|
### Added
|
package/index.d.ts
CHANGED
|
@@ -250,6 +250,11 @@ export interface TileHandle {
|
|
|
250
250
|
* by the session's `renderingmodechange` event, not by this promise.
|
|
251
251
|
*
|
|
252
252
|
* A `viewCount === 1` mode is requestable and is how a page goes flat.
|
|
253
|
+
*
|
|
254
|
+
* With the eased transition on (the default — see {@link ModeSwitchOptions}) a going-flat
|
|
255
|
+
* request is HELD while the disparity ramps out, so the promise resolves when the request has
|
|
256
|
+
* been forwarded rather than on the call; a request dropped by a reversal in that window
|
|
257
|
+
* rejects with an `Error` named `superseded`.
|
|
253
258
|
*/
|
|
254
259
|
requestRenderingMode(modeIndex: number): Promise<void>;
|
|
255
260
|
/**
|
|
@@ -325,6 +330,13 @@ export interface Inline3D {
|
|
|
325
330
|
readonly activeMode: { modeIndex: number; viewCount: number };
|
|
326
331
|
/** True while the SDK is holding every window's rig flat because a 1-view mode is active. */
|
|
327
332
|
readonly stereoCollapsed: boolean;
|
|
333
|
+
/**
|
|
334
|
+
* The eased 2D<->3D transition, live. `factor` is what every window's
|
|
335
|
+
* `ipdFactor`/`parallaxFactor` is being multiplied by on the way to the layer (`1` in 3D, `0`
|
|
336
|
+
* flat, in between mid-ramp); `active` is true while a page-initiated switch is in any of its
|
|
337
|
+
* phases. Read-only and purely informational — the SDK adds no UI of its own for this.
|
|
338
|
+
*/
|
|
339
|
+
readonly modeSwitch: { active: boolean; factor: number };
|
|
328
340
|
|
|
329
341
|
/**
|
|
330
342
|
* What this build can lift into the floating native viewer, or `null` on a browser with no
|
|
@@ -411,6 +423,41 @@ export interface CreateInline3DOptions {
|
|
|
411
423
|
* SDK never touches your DOM's `will-change`, because the chrome already occludes the tiles.
|
|
412
424
|
*/
|
|
413
425
|
autoChrome?: boolean;
|
|
426
|
+
/** The eased 2D<->3D transition. On by default; see {@link ModeSwitchOptions}. */
|
|
427
|
+
modeSwitch?: ModeSwitchOptions;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The eased 2D<->3D transition — on by default, and the same sequencer (and the same defaults)
|
|
432
|
+
* the native DisplayXR apps use.
|
|
433
|
+
*
|
|
434
|
+
* Instead of snapping the stereo rig the moment the panel's mode changes, a **page-initiated**
|
|
435
|
+
* switch ramps every window's `ipdFactor`/`parallaxFactor` between 0 and what the page asked for,
|
|
436
|
+
* in the order that looks right:
|
|
437
|
+
*
|
|
438
|
+
* - **going flat** (a `viewCount === 1` target): the disparity ramps OUT first, and the mode
|
|
439
|
+
* request is forwarded only when it lands — so the panel flips on already-flat content. That is
|
|
440
|
+
* why `requestRenderingMode()` / `setStereoEnabled(false)` resolve a ramp later than they used
|
|
441
|
+
* to: they resolve when the request has actually been forwarded.
|
|
442
|
+
* - **coming back** (a 2-view target): the request goes out at once, and the disparity eases in
|
|
443
|
+
* only once the panel REPORTS 3D — disparity on a still-flat panel is the double image the
|
|
444
|
+
* whole mode API exists to prevent.
|
|
445
|
+
*
|
|
446
|
+
* Interruptible: pressing the toggle again mid-ramp retargets from the disparity in force, and
|
|
447
|
+
* reversing a going-flat switch that has not fired yet simply ramps back up without ever asking
|
|
448
|
+
* the panel for anything (the dropped request rejects with an `Error` named `superseded`).
|
|
449
|
+
*
|
|
450
|
+
* A mode change the page did **not** request (another tab, the shell, a panel that opens flat)
|
|
451
|
+
* always snaps — there is nothing to ramp from. This is aesthetic policy only; correctness is the
|
|
452
|
+
* runtime's either way.
|
|
453
|
+
*/
|
|
454
|
+
export interface ModeSwitchOptions {
|
|
455
|
+
/** Ramp duration in ms (default `180`, matching the native default of 0.18 s). `0` = instant. */
|
|
456
|
+
durationMs?: number;
|
|
457
|
+
/** Easing curve (default `'smoothstep'`, Hermite `3t^2 - 2t^3`). */
|
|
458
|
+
easing?: 'smoothstep' | 'linear' | 'easeoutcubic';
|
|
459
|
+
/** `false` restores the plain snap of 1.4.0 (default `true`). */
|
|
460
|
+
enabled?: boolean;
|
|
414
461
|
}
|
|
415
462
|
|
|
416
463
|
/** The return of {@link startInline3D}. */
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// inline3d-mode-switch.js — the eased 2D<->3D rendering-mode transition, as a state machine.
|
|
2
|
+
//
|
|
3
|
+
// A dependency-free port of `dxr::ModeSwitch` (displayxr-common `common/mode_switch.h/.cpp`), the
|
|
4
|
+
// sequencer the native DisplayXR apps already use. It owns nothing but a scalar ramp and one
|
|
5
|
+
// decision, and that decision is the whole point — the SEQUENCING ASYMMETRY that hand-rolled
|
|
6
|
+
// versions get wrong:
|
|
7
|
+
//
|
|
8
|
+
// 3D -> 2D : ramp the disparity to 0 FIRST, and only then issue the mode request, so the panel
|
|
9
|
+
// flips on already-flat content.
|
|
10
|
+
// 2D -> 3D : issue the mode request FIRST (the first 3D frame is flat), then ease the disparity
|
|
11
|
+
// up to the app's steady value.
|
|
12
|
+
//
|
|
13
|
+
// It is aesthetic policy, never correctness: the runtime keeps the eye set coherent whatever the
|
|
14
|
+
// page does, and a browser or a page that skips this sees exactly the old snap.
|
|
15
|
+
//
|
|
16
|
+
// Driven by WALL-CLOCK dt, not frame counts, so the ramp takes the same time at 30 fps and 144 fps.
|
|
17
|
+
// Interruptible: calling request() mid-flight retargets seamlessly from the value in force right
|
|
18
|
+
// now, and a not-yet-fired ->2D that gets reversed simply ramps back up and NEVER fires.
|
|
19
|
+
//
|
|
20
|
+
// This module knows nothing about WebXR, the SDK, or the DOM — it is a pure state machine so it
|
|
21
|
+
// can be unit-tested on its own (`test/mode-switch.test.mjs`, mirroring the C++ smoke test).
|
|
22
|
+
|
|
23
|
+
/** The C++ default ramp duration (`XrSessionUpdateModeSwitch` configures 0.18 s). */
|
|
24
|
+
export const MODE_SWITCH_DEFAULT_DURATION_MS = 180;
|
|
25
|
+
|
|
26
|
+
/** The C++ default curve (`ModeSwitchEasing::SmoothStep`). */
|
|
27
|
+
export const MODE_SWITCH_DEFAULT_EASING = 'smoothstep';
|
|
28
|
+
|
|
29
|
+
/** Every easing this understands — the three of `dxr::ModeSwitchEasing`. */
|
|
30
|
+
export const MODE_SWITCH_EASINGS = ['linear', 'smoothstep', 'easeoutcubic'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalise an easing name to one of {@link MODE_SWITCH_EASINGS}, or `null` when it is not one of
|
|
34
|
+
* them. Case- and separator-insensitive (`'ease-out-cubic'`, `'easeOutCubic'`), so a caller may
|
|
35
|
+
* spell it the way its own config does. Returning null rather than a default is what lets the
|
|
36
|
+
* caller decide whether an unknown name is worth a warning.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} [easing]
|
|
39
|
+
* @returns {string|null}
|
|
40
|
+
*/
|
|
41
|
+
export function normaliseModeSwitchEasing(easing) {
|
|
42
|
+
if (typeof easing !== 'string') return null;
|
|
43
|
+
const key = easing.toLowerCase().replace(/[-_\s]/g, '');
|
|
44
|
+
return MODE_SWITCH_EASINGS.includes(key) ? key : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The curves themselves. `t` is already clamped to [0,1] by the caller. */
|
|
48
|
+
function ease(easing, t) {
|
|
49
|
+
if (t <= 0) return 0;
|
|
50
|
+
if (t >= 1) return 1;
|
|
51
|
+
if (easing === 'linear') return t;
|
|
52
|
+
if (easing === 'easeoutcubic') {
|
|
53
|
+
const u = 1 - t;
|
|
54
|
+
return 1 - u * u * u;
|
|
55
|
+
}
|
|
56
|
+
return t * t * (3 - 2 * t); // smoothstep — Hermite 3t^2 - 2t^3
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const IDLE = 'idle';
|
|
60
|
+
const RAMP_DOWN_THEN_FIRE = 'rampDownThenFire';
|
|
61
|
+
const FIRE_THEN_RAMP_UP = 'fireThenRampUp';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The 2D<->3D mode-switch sequencer. One instance per session.
|
|
65
|
+
*
|
|
66
|
+
* The values it ramps are DIMENSIONLESS here: the SDK drives it with `steady: 1`, so `factor` is
|
|
67
|
+
* the fraction of each window's OWN configured `ipdFactor`/`parallaxFactor` to send this frame
|
|
68
|
+
* (0 = flat, 1 = exactly what the page asked for). The C++ original ramps an absolute ipdFactor
|
|
69
|
+
* instead; the state machine is identical either way, which is why `steady` is a parameter rather
|
|
70
|
+
* than a constant.
|
|
71
|
+
*/
|
|
72
|
+
export class ModeSwitch {
|
|
73
|
+
constructor(durationS, easing) {
|
|
74
|
+
this._phase = IDLE;
|
|
75
|
+
this._targetMode = null;
|
|
76
|
+
this._firePending = false; // fireThenRampUp: emit `fire` on the next update()
|
|
77
|
+
this._fireAtEnd = false; // rampDownThenFire: emit `fire` when the ramp lands
|
|
78
|
+
this._from = 0;
|
|
79
|
+
this._to = 0;
|
|
80
|
+
this._cur = 0; // last evaluated factor
|
|
81
|
+
this._t = 1; // normalised progress; 1 = landed/idle
|
|
82
|
+
this._dur = MODE_SWITCH_DEFAULT_DURATION_MS / 1000;
|
|
83
|
+
this._easing = MODE_SWITCH_DEFAULT_EASING;
|
|
84
|
+
if (durationS !== undefined || easing !== undefined) this.configure(durationS, easing);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Ramp duration in SECONDS and easing curve. `durationS <= 0` means instant — the switch fires
|
|
89
|
+
* and the value reaches its endpoint on the first `update()`, which is the honest way to say
|
|
90
|
+
* "no transition" without a second code path. An unknown easing name falls back to the default
|
|
91
|
+
* silently (the caller is the right place to warn about its own option).
|
|
92
|
+
*
|
|
93
|
+
* @param {number} [durationS] default 0.18
|
|
94
|
+
* @param {string} [easing] `'smoothstep'` (default) | `'linear'` | `'easeoutcubic'`
|
|
95
|
+
*/
|
|
96
|
+
configure(durationS, easing) {
|
|
97
|
+
if (durationS !== undefined) {
|
|
98
|
+
const d = Number(durationS);
|
|
99
|
+
this._dur = Number.isFinite(d) && d > 0 ? d : 0;
|
|
100
|
+
}
|
|
101
|
+
if (easing !== undefined) {
|
|
102
|
+
this._easing = normaliseModeSwitchEasing(easing) || MODE_SWITCH_DEFAULT_EASING;
|
|
103
|
+
}
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The ramp duration in seconds (0 = instant). */
|
|
108
|
+
get durationS() {
|
|
109
|
+
return this._dur;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The easing name in force. */
|
|
113
|
+
get easing() {
|
|
114
|
+
return this._easing;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Begin — or, mid-flight, seamlessly REDIRECT — a transition to `targetMode`.
|
|
119
|
+
*
|
|
120
|
+
* Safe to call on every toggle: a mid-ramp call fully resets the phase from the value in force
|
|
121
|
+
* right now, so a pending (un-fired) ->2D that the user reverses is simply dropped.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} req
|
|
124
|
+
* @param {number|null} [req.targetMode] the rendering-mode index wanted (reported back by `update`)
|
|
125
|
+
* @param {number} req.targetViewCount that mode's view count (1 = 2D/mono, >1 = 3D)
|
|
126
|
+
* @param {number|null} [req.currentMode] the mode index active right now
|
|
127
|
+
* @param {number} req.currentViewCount its view count
|
|
128
|
+
* @param {number} [req.current] the value in force RIGHT NOW — the last `update()` factor while a
|
|
129
|
+
* ramp runs, and the STEADY value when idle. Passing an internal 0 while idle is the
|
|
130
|
+
* classic first-press snap: there is nothing to ramp down from.
|
|
131
|
+
* @param {number} [req.steady] the value to restore to (the page's own configured factors; the
|
|
132
|
+
* SDK passes 1 because it scales each window's own numbers by the result)
|
|
133
|
+
*/
|
|
134
|
+
request({
|
|
135
|
+
targetMode = null,
|
|
136
|
+
targetViewCount,
|
|
137
|
+
currentMode = null,
|
|
138
|
+
currentViewCount,
|
|
139
|
+
current,
|
|
140
|
+
steady = 1,
|
|
141
|
+
} = {}) {
|
|
142
|
+
const toMono = !(targetViewCount > 1);
|
|
143
|
+
const fromMono = !(currentViewCount > 1);
|
|
144
|
+
const steadyValue = Number.isFinite(steady) ? steady : 1;
|
|
145
|
+
const currentValue = Number.isFinite(current) ? current : steadyValue;
|
|
146
|
+
|
|
147
|
+
this._targetMode = targetMode;
|
|
148
|
+
this._from = currentValue;
|
|
149
|
+
|
|
150
|
+
if (toMono && !fromMono) {
|
|
151
|
+
// 3D -> 2D: flatten first, switch on landing, so 2D engages on already-mono content. The
|
|
152
|
+
// mode request is HELD until the ramp completes.
|
|
153
|
+
this._phase = RAMP_DOWN_THEN_FIRE;
|
|
154
|
+
this._to = 0;
|
|
155
|
+
this._fireAtEnd = true;
|
|
156
|
+
this._firePending = false;
|
|
157
|
+
} else if (!toMono && fromMono) {
|
|
158
|
+
// 2D -> 3D: switch now so the first 3D frame is flat (`from` is forced to 0 regardless of
|
|
159
|
+
// any stale value), then ease up to steady.
|
|
160
|
+
this._phase = FIRE_THEN_RAMP_UP;
|
|
161
|
+
this._from = 0;
|
|
162
|
+
this._to = steadyValue;
|
|
163
|
+
this._firePending = true;
|
|
164
|
+
this._fireAtEnd = false;
|
|
165
|
+
} else {
|
|
166
|
+
// Same dimensionality: 2D->2D, 3D->3D, or the REVERSAL of a not-yet-fired ->2D (nothing
|
|
167
|
+
// fired, so the display is still 3D and `currentViewCount` is still > 1). No flatten: switch
|
|
168
|
+
// now — skipping the fire when the target is already the current mode, which is exactly what
|
|
169
|
+
// makes a reversal never issue a stale request — and restore steady disparity for a 3D
|
|
170
|
+
// target. For a 2D target the value is irrelevant (mono), so leave it where it is.
|
|
171
|
+
this._phase = FIRE_THEN_RAMP_UP;
|
|
172
|
+
this._to = toMono ? currentValue : steadyValue;
|
|
173
|
+
this._firePending = !(targetMode !== null && targetMode === currentMode);
|
|
174
|
+
this._fireAtEnd = false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this._t = this._dur > 0 ? 0 : 1;
|
|
178
|
+
this._cur = this._from;
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Advance the ramp by `dtS` seconds and report this frame's outputs.
|
|
184
|
+
*
|
|
185
|
+
* @param {number} dtS wall-clock seconds since the last call
|
|
186
|
+
* @returns {{factor:number, fire:boolean, mode:(number|null)}} `factor` is the value to submit
|
|
187
|
+
* this frame; `fire` is true on EXACTLY ONE update — the frame on which the caller should
|
|
188
|
+
* issue the real mode request for `mode`.
|
|
189
|
+
*/
|
|
190
|
+
update(dtS) {
|
|
191
|
+
let fire = false;
|
|
192
|
+
|
|
193
|
+
if (this._phase !== IDLE) {
|
|
194
|
+
const dt = Number.isFinite(dtS) && dtS > 0 ? dtS : 0;
|
|
195
|
+
if (this._t < 1 && this._dur > 0) {
|
|
196
|
+
this._t += dt / this._dur;
|
|
197
|
+
if (this._t > 1) this._t = 1;
|
|
198
|
+
} else {
|
|
199
|
+
this._t = 1;
|
|
200
|
+
}
|
|
201
|
+
this._cur = this._from + (this._to - this._from) * ease(this._easing, this._t);
|
|
202
|
+
|
|
203
|
+
if (this._phase === FIRE_THEN_RAMP_UP) {
|
|
204
|
+
if (this._firePending) {
|
|
205
|
+
this._firePending = false;
|
|
206
|
+
fire = true;
|
|
207
|
+
}
|
|
208
|
+
if (this._t >= 1) this._phase = IDLE;
|
|
209
|
+
} else {
|
|
210
|
+
if (this._t >= 1) {
|
|
211
|
+
if (this._fireAtEnd) {
|
|
212
|
+
this._fireAtEnd = false;
|
|
213
|
+
fire = true;
|
|
214
|
+
}
|
|
215
|
+
this._phase = IDLE;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { factor: this._cur, fire, mode: this._targetMode };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** True while a ramp is in flight or a held mode request has not fired yet. */
|
|
224
|
+
active() {
|
|
225
|
+
return this._phase !== IDLE;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The current factor, without advancing the clock. */
|
|
229
|
+
value() {
|
|
230
|
+
return this._cur;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** True while a mode request is being HELD until the ramp lands (a ->2D that has not fired). */
|
|
234
|
+
firePending() {
|
|
235
|
+
return this._phase === RAMP_DOWN_THEN_FIRE ? this._fireAtEnd : this._firePending;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Drop everything in flight. The factor is left where it is — the caller owns what to do next. */
|
|
239
|
+
cancel() {
|
|
240
|
+
this._phase = IDLE;
|
|
241
|
+
this._firePending = false;
|
|
242
|
+
this._fireAtEnd = false;
|
|
243
|
+
this._t = 1;
|
|
244
|
+
return this;
|
|
245
|
+
}
|
|
246
|
+
}
|
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.js
CHANGED
|
@@ -32,6 +32,17 @@
|
|
|
32
32
|
import { undock, undockAvailable, undockUrl, tileScreenRect, setUndockLayerResolver } from './inline3d-undock.js';
|
|
33
33
|
export { undock, undockAvailable, undockUrl, tileScreenRect };
|
|
34
34
|
|
|
35
|
+
// The eased 2D<->3D transition. A pure state machine (no DOM, no WebXR) ported from the native
|
|
36
|
+
// `dxr::ModeSwitch`, so the browser eases the disparity around a mode switch the same way — and in
|
|
37
|
+
// the same ORDER — as the native apps and the demos. See _requestRenderingModeEased.
|
|
38
|
+
import {
|
|
39
|
+
ModeSwitch,
|
|
40
|
+
MODE_SWITCH_DEFAULT_DURATION_MS,
|
|
41
|
+
MODE_SWITCH_DEFAULT_EASING,
|
|
42
|
+
MODE_SWITCH_EASINGS,
|
|
43
|
+
normaliseModeSwitchEasing,
|
|
44
|
+
} from './inline3d-mode-switch.js';
|
|
45
|
+
|
|
35
46
|
// The document's single live manager. The browser's per-frame element-rect report is a
|
|
36
47
|
// WHOLE-WIDGET setter — each live session pushes the complete list of rects to weave — so two
|
|
37
48
|
// managers in one document overwrite each other frame by frame and neither one's tiles hold
|
|
@@ -48,13 +59,15 @@ setUndockLayerResolver((el) => {
|
|
|
48
59
|
// The canvas itself, then a woven canvas INSIDE the element (a card wrapping its tile), then
|
|
49
60
|
// the element sitting inside a window's own container (a button in the tile's box). Anything
|
|
50
61
|
// further away is not this window's rect and takes the fallback.
|
|
51
|
-
|
|
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);
|
|
52
65
|
for (const win of m._windows.values()) {
|
|
53
|
-
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);
|
|
54
67
|
}
|
|
55
68
|
for (const win of m._windows.values()) {
|
|
56
69
|
const box = win.canvas.parentElement;
|
|
57
|
-
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);
|
|
58
71
|
}
|
|
59
72
|
return null;
|
|
60
73
|
});
|
|
@@ -329,14 +342,56 @@ function defaultDisplayRig(win) {
|
|
|
329
342
|
};
|
|
330
343
|
}
|
|
331
344
|
|
|
332
|
-
// The page's rig with the stereo dialled
|
|
333
|
-
// both eyes
|
|
345
|
+
// The page's rig with the stereo dialled DOWN by `factor`: eye separation and head-tracking
|
|
346
|
+
// response scaled together, so `factor` 0 renders both eyes from the SAME place (the woven atlas
|
|
347
|
+
// carries one image twice) and `factor` 1 is exactly what the page asked for. Everything between
|
|
348
|
+
// is the eased 2D<->3D transition (see ModeSwitch) — which is why this is a scale and not a
|
|
349
|
+
// boolean: a flat panel and a full-disparity one are the two ENDS of one continuum.
|
|
334
350
|
//
|
|
335
351
|
// A COPY, never a mutation. A page driving a rig per frame reuses one descriptor object
|
|
336
|
-
// (cameraRigFromCamera's `out`), so
|
|
352
|
+
// (cameraRigFromCamera's `out`), so scaling the factors in place would write the flattening into
|
|
337
353
|
// the page's own state and it would never come back — the restore would restore 0.
|
|
338
|
-
|
|
339
|
-
|
|
354
|
+
//
|
|
355
|
+
// An unset factor is the runtime's default of 1, so it scales like an explicit 1 rather than
|
|
356
|
+
// staying absent: a rig that says nothing about disparity still goes flat.
|
|
357
|
+
function scaledRig(rig, factor) {
|
|
358
|
+
const ipd = Number.isFinite(rig.ipdFactor) ? rig.ipdFactor : 1;
|
|
359
|
+
const parallax = Number.isFinite(rig.parallaxFactor) ? rig.parallaxFactor : 1;
|
|
360
|
+
return { ...rig, ipdFactor: ipd * factor, parallaxFactor: parallax * factor };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// How often the fallback tick advances a transition when session frames are NOT arriving (a
|
|
364
|
+
// background tab, every tile scrolled away). Roughly one 60 Hz frame — the ramp is time-based, so
|
|
365
|
+
// this is a floor on smoothness, never on duration.
|
|
366
|
+
const MODE_SWITCH_TICK_MS = 16;
|
|
367
|
+
|
|
368
|
+
// Wall clock for the transition ramp, in ms. Read through the global on every call (never
|
|
369
|
+
// captured) so a test can install its own clock, and so a page that runs before `performance`
|
|
370
|
+
// exists still gets a monotonic-enough source. Frame COUNTS are deliberately not used: the ramp
|
|
371
|
+
// has to take the same time at 30 fps and at 144 fps.
|
|
372
|
+
function nowMs() {
|
|
373
|
+
return typeof performance !== 'undefined' && performance && typeof performance.now === 'function'
|
|
374
|
+
? performance.now()
|
|
375
|
+
: Date.now();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The easing option, validated here rather than in the state machine: the sequencer falls back
|
|
379
|
+
// silently (it has no opinion about a caller's config), but a typo in `createInline3D` is worth
|
|
380
|
+
// exactly one warning — a page that asked for 'ease-in-out' and got smoothstep should know.
|
|
381
|
+
let notedModeSwitchEasing = false;
|
|
382
|
+
function resolveModeSwitchEasing(easing) {
|
|
383
|
+
if (easing === undefined || easing === null) return MODE_SWITCH_DEFAULT_EASING;
|
|
384
|
+
const known = normaliseModeSwitchEasing(easing);
|
|
385
|
+
if (known) return known;
|
|
386
|
+
if (!notedModeSwitchEasing) {
|
|
387
|
+
notedModeSwitchEasing = true;
|
|
388
|
+
console.warn(
|
|
389
|
+
`[inline3d] createInline3D({ modeSwitch: { easing: ${JSON.stringify(easing)} } }) is not a ` +
|
|
390
|
+
`curve this SDK knows (${MODE_SWITCH_EASINGS.join(' / ')}); using ` +
|
|
391
|
+
`'${MODE_SWITCH_DEFAULT_EASING}'.`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return MODE_SWITCH_DEFAULT_EASING;
|
|
340
395
|
}
|
|
341
396
|
|
|
342
397
|
// Why the SDK collapses the rig behind the page's back, said once, where the code is.
|
|
@@ -439,13 +494,30 @@ function noteRigWinsOverHeight() {
|
|
|
439
494
|
* exclusively via addGlobalOverlay()/data-inline3d-overlay. Ignored (nothing is
|
|
440
495
|
* scanned, no `will-change` is set on your DOM) on a browser with draw-order
|
|
441
496
|
* occlusion, where chrome occludes tiles by itself.
|
|
497
|
+
* @param {object} [opts.modeSwitch] The EASED 2D<->3D transition, on by default.
|
|
498
|
+
* `{ durationMs=180, easing='smoothstep'|'linear'|'easeoutcubic', enabled=true }` — the
|
|
499
|
+
* same defaults the native DisplayXR apps configure. Instead of snapping the stereo rig
|
|
500
|
+
* the moment the panel's mode changes, a page-initiated switch ramps every window's
|
|
501
|
+
* `ipdFactor`/`parallaxFactor` between 0 and what the page asked for, in the order that
|
|
502
|
+
* looks right: going FLAT ramps the disparity out first and only then asks the panel to
|
|
503
|
+
* switch, and coming BACK asks first and eases the disparity in once the panel reports
|
|
504
|
+
* 3D. `enabled:false` restores the plain snap. It is aesthetic policy only — the runtime
|
|
505
|
+
* keeps the eye set coherent either way — and a mode change the page did NOT request
|
|
506
|
+
* (another tab, the shell, a panel opening flat) always snaps, because there is nothing
|
|
507
|
+
* to ramp from. Read the live state on `wall.modeSwitch`.
|
|
442
508
|
* @returns {Promise<Inline3D | {supported:false, error?:Error}>} the manager, which also carries
|
|
443
509
|
* the display API (`getDisplayInfo` / `getRenderingModes` / `requestRenderingMode` /
|
|
444
510
|
* `setStereoEnabled`, `on`/`off`) and `undock` — `{model, splat}` on a browser with
|
|
445
511
|
* `XRDisplayLayer.undock`, `null` on one without.
|
|
446
512
|
*/
|
|
447
513
|
export async function createInline3D(opts = {}) {
|
|
448
|
-
const {
|
|
514
|
+
const {
|
|
515
|
+
referenceSpace = 'viewer',
|
|
516
|
+
lazy = true,
|
|
517
|
+
rootMargin = '50% 0px',
|
|
518
|
+
autoChrome = true,
|
|
519
|
+
modeSwitch = null,
|
|
520
|
+
} = opts;
|
|
449
521
|
if (!inline3DAvailable()) return { supported: false };
|
|
450
522
|
let session;
|
|
451
523
|
try {
|
|
@@ -461,7 +533,7 @@ export async function createInline3D(opts = {}) {
|
|
|
461
533
|
} catch {
|
|
462
534
|
/* rAF still fires without a ref space; views are just null (fine for image/video). */
|
|
463
535
|
}
|
|
464
|
-
return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome });
|
|
536
|
+
return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome, modeSwitch });
|
|
465
537
|
}
|
|
466
538
|
|
|
467
539
|
/**
|
|
@@ -513,7 +585,7 @@ function chromeTextPlates(root) {
|
|
|
513
585
|
}
|
|
514
586
|
|
|
515
587
|
class Inline3D {
|
|
516
|
-
constructor(session, refSpace, { lazy, rootMargin, autoChrome = true }) {
|
|
588
|
+
constructor(session, refSpace, { lazy, rootMargin, autoChrome = true, modeSwitch = null }) {
|
|
517
589
|
this.supported = true;
|
|
518
590
|
this.session = session;
|
|
519
591
|
this.refSpace = refSpace;
|
|
@@ -553,6 +625,23 @@ class Inline3D {
|
|
|
553
625
|
this._stereoCollapsed = false;
|
|
554
626
|
this._displayListeners = new Map(); // event type -> Set(callback), for on()/off()
|
|
555
627
|
this._primedDisplayState = false;
|
|
628
|
+
// ── the eased 2D<->3D transition (opts.modeSwitch) ───────────────────────────────
|
|
629
|
+
// The collapse above is a LATCH; what actually reaches each layer is that latch turned into a
|
|
630
|
+
// SCALE — `_stereoFactor`, 0 (flat) to 1 (exactly the rig the page set). With the sequencer
|
|
631
|
+
// off, or for a mode change the page did not request, the scale is only ever 0 or 1 and
|
|
632
|
+
// nothing looks different. With it on, a page-initiated switch walks the scale across that
|
|
633
|
+
// range over `durationMs` and holds the mode request until the right end of the ramp.
|
|
634
|
+
const msOpts = modeSwitch && typeof modeSwitch === 'object' ? modeSwitch : {};
|
|
635
|
+
this._msEnabled = msOpts.enabled !== false;
|
|
636
|
+
this._modeSwitch = new ModeSwitch(
|
|
637
|
+
(Number.isFinite(msOpts.durationMs) ? Math.max(0, msOpts.durationMs) : MODE_SWITCH_DEFAULT_DURATION_MS) / 1000,
|
|
638
|
+
resolveModeSwitchEasing(msOpts.easing)
|
|
639
|
+
);
|
|
640
|
+
this._stereoFactor = 1; // what every window's ipd/parallax is multiplied by on the way out
|
|
641
|
+
this._msFire = null; // a ->2D request HELD until the ramp-down lands
|
|
642
|
+
this._msArmedUp = false; // a ->3D request went out; the up-ramp waits for the panel to say 3D
|
|
643
|
+
this._msLastMs = null; // wall clock of the previous advance (null = the ramp has not ticked)
|
|
644
|
+
this._msTick = null; // the frames-stopped fallback timer; see _armModeSwitchTick
|
|
556
645
|
// Undock capabilities, refreshed off the first live layer (see _refreshUndock). Null is the
|
|
557
646
|
// load-bearing value: it means this browser has no XRDisplayLayer.undock at all.
|
|
558
647
|
this.undock = hasUndock() ? { model: false, splat: false } : null;
|
|
@@ -833,7 +922,8 @@ class Inline3D {
|
|
|
833
922
|
* pushed FLAT (ipd/parallax 0) — the flattening is a latch on the way out, not a value
|
|
834
923
|
* written into your descriptor, so a page driving a rig every frame cannot undo the 2D
|
|
835
924
|
* state by simply carrying on, and `setStereoEnabled(true)` restores exactly what you last
|
|
836
|
-
* asked for.
|
|
925
|
+
* asked for. During the eased 2D<->3D transition the same applies with a FRACTION in place
|
|
926
|
+
* of the 0: what leaves for the layer is your rig scaled by `wall.modeSwitch.factor`.
|
|
837
927
|
*/
|
|
838
928
|
setViewRig: (rig) => {
|
|
839
929
|
win.viewRig = rig || null;
|
|
@@ -884,10 +974,18 @@ class Inline3D {
|
|
|
884
974
|
* On success the session fires `renderingmodechange` — see {@link on}. That event, not
|
|
885
975
|
* this promise, is when the new mode is in effect.
|
|
886
976
|
*
|
|
977
|
+
* EASED BY DEFAULT (`createInline3D({modeSwitch})`). A GOING-FLAT request (`viewCount === 1`)
|
|
978
|
+
* is HELD while the disparity ramps out, and forwarded only when it lands — so this promise
|
|
979
|
+
* resolves when the browser actually got the request, roughly `durationMs` later, and the
|
|
980
|
+
* panel flips on already-flat content. A request that a reversal drops in that window
|
|
981
|
+
* rejects with an `Error` named `superseded`; nothing was ever asked of the display. Coming
|
|
982
|
+
* BACK is unchanged in timing: the request goes out at once and the disparity eases in when
|
|
983
|
+
* the panel reports 3D.
|
|
984
|
+
*
|
|
887
985
|
* @param {number} modeIndex
|
|
888
986
|
* @returns {Promise<void>}
|
|
889
987
|
*/
|
|
890
|
-
requestRenderingMode: (modeIndex) => this.
|
|
988
|
+
requestRenderingMode: (modeIndex) => this._requestRenderingModeEased(modeIndex, win),
|
|
891
989
|
/**
|
|
892
990
|
* SUGAR over {@link requestRenderingMode}, and nothing more. `false` requests the first
|
|
893
991
|
* mode with `viewCount === 1 && isRequestable`; `true` requests the first with
|
|
@@ -910,6 +1008,11 @@ class Inline3D {
|
|
|
910
1008
|
* otherwise exactly as `requestRenderingMode` does. Resolves to the boolean asked for —
|
|
911
1009
|
* the request was accepted; the mode is in force when the event says so.
|
|
912
1010
|
*
|
|
1011
|
+
* Eased by default, exactly as {@link requestRenderingMode} is: `false` ramps the disparity
|
|
1012
|
+
* out before the request goes anywhere, `true` requests first and eases the disparity back
|
|
1013
|
+
* in once the panel reports 3D, and pressing the pair in quick succession reverses cleanly
|
|
1014
|
+
* rather than firing a stale switch.
|
|
1015
|
+
*
|
|
913
1016
|
* @param {boolean} enabled
|
|
914
1017
|
* @returns {Promise<boolean>}
|
|
915
1018
|
*/
|
|
@@ -968,7 +1071,7 @@ class Inline3D {
|
|
|
968
1071
|
|
|
969
1072
|
/** Ask the runtime to switch the display to `modeIndex`. Pass-through; see the handle's doc. */
|
|
970
1073
|
requestRenderingMode(modeIndex) {
|
|
971
|
-
return this.
|
|
1074
|
+
return this._requestRenderingModeEased(modeIndex, null);
|
|
972
1075
|
}
|
|
973
1076
|
|
|
974
1077
|
/** Sugar over {@link requestRenderingMode}: false -> a 1-view mode, true -> the 2-view mode. */
|
|
@@ -994,6 +1097,22 @@ class Inline3D {
|
|
|
994
1097
|
return this._stereoCollapsed;
|
|
995
1098
|
}
|
|
996
1099
|
|
|
1100
|
+
/**
|
|
1101
|
+
* The eased 2D<->3D transition, live: `{active, factor}`.
|
|
1102
|
+
*
|
|
1103
|
+
* `factor` is what every window's `ipdFactor`/`parallaxFactor` is being multiplied by on the way
|
|
1104
|
+
* to the layer — `1` in 3D, `0` flat, in between mid-ramp. `active` is true while a
|
|
1105
|
+
* page-initiated switch is in any of its phases: ramping the disparity out, holding the ->2D
|
|
1106
|
+
* request until it lands, waiting for the panel to report 3D, or easing back in.
|
|
1107
|
+
*
|
|
1108
|
+
* Read-only and purely informational — a page that wants to grey a button or cross-fade some 2D
|
|
1109
|
+
* chrome alongside the panel can, and one that does not care never has to look. The SDK adds no
|
|
1110
|
+
* UI of its own for this, and never will: which key or button toggles the display is the page's.
|
|
1111
|
+
*/
|
|
1112
|
+
get modeSwitch() {
|
|
1113
|
+
return { active: this._msTransitionActive(), factor: this._stereoFactor };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
997
1116
|
close() {
|
|
998
1117
|
try {
|
|
999
1118
|
this.session.end();
|
|
@@ -1009,14 +1128,15 @@ class Inline3D {
|
|
|
1009
1128
|
|
|
1010
1129
|
/**
|
|
1011
1130
|
* The rig this window's layer should actually be holding right now: what the page asked for,
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1131
|
+
* scaled by the manager's current stereo factor (0 while a 1-view mode is active, 1 in 3D, and
|
|
1132
|
+
* everything between during an eased transition). Null means "say nothing" — leave the browser
|
|
1133
|
+
* on the `virtualDisplayHeight` shorthand it was built with.
|
|
1014
1134
|
*
|
|
1015
1135
|
* Used in BOTH directions (push at a live layer, build a new one), which is the point: a tile
|
|
1016
1136
|
* that scrolls away and rebuilds while stereo is off must not come back in 3D.
|
|
1017
1137
|
*/
|
|
1018
1138
|
_effectiveViewRig(win) {
|
|
1019
|
-
if (
|
|
1139
|
+
if (this._stereoFactor >= 1) {
|
|
1020
1140
|
// `stereoSynthRig`: this window never had a rig of its own, so going flat had to SEND one
|
|
1021
1141
|
// (there is no way to say "the default, but flat" as a scalar). Coming back therefore has
|
|
1022
1142
|
// to send the un-flat version explicitly too — returning null here would leave the layer
|
|
@@ -1030,7 +1150,7 @@ class Inline3D {
|
|
|
1030
1150
|
// hold the flattened rig forever. Recorded here rather than in the collapse itself because a
|
|
1031
1151
|
// window CREATED while the panel is already flat goes down this path on its first activate.
|
|
1032
1152
|
if (!win.viewRig) win.stereoSynthRig = true;
|
|
1033
|
-
return
|
|
1153
|
+
return scaledRig(win.viewRig || defaultDisplayRig(win), this._stereoFactor);
|
|
1034
1154
|
}
|
|
1035
1155
|
|
|
1036
1156
|
/**
|
|
@@ -1039,12 +1159,25 @@ class Inline3D {
|
|
|
1039
1159
|
* Driven ONLY by what the display reports — the first `getRenderingModes()` read and every
|
|
1040
1160
|
* `renderingmodechange` — never by a request. That is what makes a refused request a no-op in
|
|
1041
1161
|
* both directions: nothing here runs unless the mode actually changed.
|
|
1162
|
+
*
|
|
1163
|
+
* THE REPORT OWNS THE FACTOR ONLY WHEN THE SEQUENCER DOES NOT. A mode change the page did not
|
|
1164
|
+
* ask for (another tab, the shell, a panel that opened flat) snaps, because there is nothing to
|
|
1165
|
+
* ramp FROM — the transition is a page-initiated aesthetic, not a correctness step. The two
|
|
1166
|
+
* exceptions are the two halves of a page-initiated switch: while a ramp is in flight it owns
|
|
1167
|
+
* the factor outright, and a report of 3D that a `->3D` request armed starts the up-ramp here
|
|
1168
|
+
* rather than snapping (the whole reason that request fires first and eases second).
|
|
1042
1169
|
*/
|
|
1043
1170
|
_setStereoCollapsed(collapsed) {
|
|
1044
1171
|
const next = !!collapsed;
|
|
1045
1172
|
if (this._stereoCollapsed === next) return;
|
|
1046
1173
|
this._stereoCollapsed = next;
|
|
1047
1174
|
if (next) noteAutoCollapse();
|
|
1175
|
+
if (!next && this._msArmedUp) {
|
|
1176
|
+
this._msArmedUp = false;
|
|
1177
|
+
this._startUpRamp(); // the panel is in 3D at last — ease the disparity back in
|
|
1178
|
+
} else if (!this._modeSwitch.active()) {
|
|
1179
|
+
this._stereoFactor = next ? 0 : 1;
|
|
1180
|
+
}
|
|
1048
1181
|
for (const win of this._windows.values()) {
|
|
1049
1182
|
// Diagnostics only: the factors that were in force when the panel went flat. The restore
|
|
1050
1183
|
// itself just re-pushes `win.viewRig`, which was never mutated.
|
|
@@ -1161,9 +1294,236 @@ class Inline3D {
|
|
|
1161
1294
|
);
|
|
1162
1295
|
}
|
|
1163
1296
|
|
|
1297
|
+
// ── the eased 2D<->3D transition ──────────────────────────────────────────────────────
|
|
1298
|
+
//
|
|
1299
|
+
// Every PAGE-INITIATED mode request goes through here; a mode change reported from elsewhere
|
|
1300
|
+
// does not (see _setStereoCollapsed). The asymmetry below is the whole helper, and it is the
|
|
1301
|
+
// native `dxr::ModeSwitch` contract, unchanged:
|
|
1302
|
+
//
|
|
1303
|
+
// -> 2D : ramp the disparity out FIRST, and fire the request only when it lands, so the panel
|
|
1304
|
+
// flips on already-flat content instead of snapping a stereo image flat.
|
|
1305
|
+
// -> 3D : fire the request FIRST and ease the disparity in afterwards — and in the browser,
|
|
1306
|
+
// only once the panel REPORTS 3D, because until then the disparity would be going up
|
|
1307
|
+
// on a flat panel, which is the double-image the whole mode API exists to prevent.
|
|
1308
|
+
//
|
|
1309
|
+
// Everything else is fall-through: the sequencer disabled, a browser with no `setViewRig` (there
|
|
1310
|
+
// is nothing to ramp), an unknown target or current view count, a `viewCount > 2` mode the
|
|
1311
|
+
// browser will refuse anyway, and a same-dimensionality change (2D->2D, 3D->3D) which needs no
|
|
1312
|
+
// flatten at all.
|
|
1313
|
+
|
|
1314
|
+
/** True while a page-initiated transition is in flight in any of its phases. */
|
|
1315
|
+
_msTransitionActive() {
|
|
1316
|
+
return this._modeSwitch.active() || this._msArmedUp || this._msFire !== null;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* `requestRenderingMode()` with the transition applied. Resolves when the request has actually
|
|
1321
|
+
* been FORWARDED to the browser (so, for a ->2D switch, after the ramp) and rejects exactly as
|
|
1322
|
+
* the pass-through does — plus one new failure: an `Error` named `superseded` when a second
|
|
1323
|
+
* request replaced this one before it ever fired.
|
|
1324
|
+
*/
|
|
1325
|
+
async _requestRenderingModeEased(modeIndex, win) {
|
|
1326
|
+
if (!this._msEnabled || !hasViewRig()) return this._requestRenderingMode(modeIndex, win);
|
|
1327
|
+
// The mode table is what says whether this index is 2D or 3D. It is normally already cached
|
|
1328
|
+
// (the first activation primes it), and a read that fails just means the sequencer has no
|
|
1329
|
+
// opinion — the request still goes out.
|
|
1330
|
+
let modes = this._modes;
|
|
1331
|
+
if (!Array.isArray(modes) || modes.length === 0) {
|
|
1332
|
+
try {
|
|
1333
|
+
modes = await this._getRenderingModes(win);
|
|
1334
|
+
} catch {
|
|
1335
|
+
modes = null;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
const target = (Array.isArray(modes) ? modes : []).find((m) => m && m.modeIndex === modeIndex);
|
|
1339
|
+
const targetViews = target && Number.isFinite(target.viewCount) ? target.viewCount : 0;
|
|
1340
|
+
const currentViews = this._activeViewCount;
|
|
1341
|
+
if (targetViews < 1 || targetViews > 2 || currentViews < 1) {
|
|
1342
|
+
return this._requestRenderingMode(modeIndex, win);
|
|
1343
|
+
}
|
|
1344
|
+
if (targetViews === 1) {
|
|
1345
|
+
if (currentViews === 1) return this._requestRenderingMode(modeIndex, win); // 2D -> 2D
|
|
1346
|
+
return this._rampDownThenRequest(modeIndex, win);
|
|
1347
|
+
}
|
|
1348
|
+
return this._requestThenRampUp(modeIndex, win);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* 3D -> 2D. Ramp the disparity to 0, THEN forward the request (see _advanceModeSwitch, which is
|
|
1353
|
+
* what actually fires it). The returned promise is the page's, and it settles on the forwarded
|
|
1354
|
+
* request — so `await wall.setStereoEnabled(false)` still means "the browser has it".
|
|
1355
|
+
*
|
|
1356
|
+
* A second ->2D request for the SAME mode mid-ramp is idempotent: the page gets the promise
|
|
1357
|
+
* already in flight rather than a superseded rejection, because mashing one button twice is not
|
|
1358
|
+
* an error. A different target retargets from the CURRENT disparity, seamlessly.
|
|
1359
|
+
*/
|
|
1360
|
+
_rampDownThenRequest(modeIndex, win) {
|
|
1361
|
+
if (this._msFire && this._msFire.modeIndex === modeIndex) return this._msFire.promise;
|
|
1362
|
+
this._settlePendingDown('superseded', `a request for mode ${modeIndex} replaced it`);
|
|
1363
|
+
const pending = { modeIndex, win, resolve: null, reject: null, promise: null };
|
|
1364
|
+
pending.promise = new Promise((resolve, reject) => {
|
|
1365
|
+
pending.resolve = resolve;
|
|
1366
|
+
pending.reject = reject;
|
|
1367
|
+
});
|
|
1368
|
+
this._msFire = pending;
|
|
1369
|
+
this._modeSwitch.request({
|
|
1370
|
+
targetMode: modeIndex,
|
|
1371
|
+
targetViewCount: 1,
|
|
1372
|
+
currentMode: this._activeModeIndex,
|
|
1373
|
+
currentViewCount: this._activeViewCount,
|
|
1374
|
+
// The value ON SCREEN right now: the ramp's own output mid-flight, and the page's steady
|
|
1375
|
+
// rig (factor 1) when idle. Passing the sequencer's internal 0 while idle is the classic
|
|
1376
|
+
// first-press snap — there would be nothing to ramp down from.
|
|
1377
|
+
current: this._stereoFactor,
|
|
1378
|
+
steady: 1,
|
|
1379
|
+
});
|
|
1380
|
+
this._msLastMs = nowMs();
|
|
1381
|
+
this._armModeSwitchTick();
|
|
1382
|
+
return pending.promise;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* -> 3D. Forward the request NOW (the browser needs the panel moving before the disparity can
|
|
1387
|
+
* mean anything), then ease the disparity in — starting only when the panel REPORTS 3D, which
|
|
1388
|
+
* is `_setStereoCollapsed(false)` releasing the latch.
|
|
1389
|
+
*
|
|
1390
|
+
* The one case that does not wait: a REVERSAL of a ramp-down that never fired. The panel never
|
|
1391
|
+
* left 3D, so there is no report coming; the disparity just walks back up from wherever the
|
|
1392
|
+
* ramp got to, and the stale 2D request is dropped rather than fired.
|
|
1393
|
+
*/
|
|
1394
|
+
_requestThenRampUp(modeIndex, win) {
|
|
1395
|
+
const reversal = this._msFire !== null;
|
|
1396
|
+
const noopReversal = reversal && modeIndex === this._activeModeIndex;
|
|
1397
|
+
this._settlePendingDown('superseded', `a request for mode ${modeIndex} reversed it`);
|
|
1398
|
+
// A reversal back to the mode that is STILL active asks the browser for nothing: the runtime
|
|
1399
|
+
// never changed mode, so the only thing owed is the disparity.
|
|
1400
|
+
const forwarded = noopReversal
|
|
1401
|
+
? Promise.resolve(undefined)
|
|
1402
|
+
: this._requestRenderingMode(modeIndex, win);
|
|
1403
|
+
if (this._stereoFactor < 1) {
|
|
1404
|
+
if (this._stereoCollapsed) {
|
|
1405
|
+
// The panel is really flat: hold at 0 and wait for it to say otherwise.
|
|
1406
|
+
this._msArmedUp = true;
|
|
1407
|
+
this._modeSwitch.cancel();
|
|
1408
|
+
this._stereoFactor = 0;
|
|
1409
|
+
} else {
|
|
1410
|
+
this._startUpRamp();
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return forwarded.catch((err) => {
|
|
1414
|
+
// Refused. Nothing about the panel moved, so neither may the disparity: drop the armed
|
|
1415
|
+
// up-ramp and settle back on whatever the display last REPORTED.
|
|
1416
|
+
if (this._msArmedUp) {
|
|
1417
|
+
this._msArmedUp = false;
|
|
1418
|
+
if (!this._modeSwitch.active()) this._stereoFactor = this._stereoCollapsed ? 0 : 1;
|
|
1419
|
+
}
|
|
1420
|
+
throw err;
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Start (or restart) the up-ramp from the current disparity to the page's steady rig. Used both
|
|
1426
|
+
* when the panel reports 3D after a `->3D` request and when a ->2D request was REFUSED — a
|
|
1427
|
+
* refusal must leave the page in 3D, not flat.
|
|
1428
|
+
*
|
|
1429
|
+
* No request is ever fired from here: whatever there was to send went out before the ramp
|
|
1430
|
+
* started, which is why `_msFire` is empty by construction.
|
|
1431
|
+
*/
|
|
1432
|
+
_startUpRamp() {
|
|
1433
|
+
this._settlePendingDown('superseded', 'the display returned to 3D');
|
|
1434
|
+
this._modeSwitch.request({
|
|
1435
|
+
targetMode: this._activeModeIndex,
|
|
1436
|
+
targetViewCount: 2,
|
|
1437
|
+
currentMode: this._activeModeIndex, // equal ⇒ the sequencer fires nothing
|
|
1438
|
+
currentViewCount: 2,
|
|
1439
|
+
current: this._stereoFactor,
|
|
1440
|
+
steady: 1,
|
|
1441
|
+
});
|
|
1442
|
+
this._msLastMs = nowMs();
|
|
1443
|
+
this._armModeSwitchTick();
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/** Settle a held ->2D request that will now never fire. Never throws into the caller. */
|
|
1447
|
+
_settlePendingDown(name, why) {
|
|
1448
|
+
const pending = this._msFire;
|
|
1449
|
+
if (!pending) return;
|
|
1450
|
+
this._msFire = null;
|
|
1451
|
+
const err = new Error(
|
|
1452
|
+
`[inline3d] the request for rendering mode ${pending.modeIndex} was never forwarded: ${why}. ` +
|
|
1453
|
+
'A ->2D switch is held until the disparity has ramped out, so a request that is reversed ' +
|
|
1454
|
+
'or replaced in that window is dropped rather than fired late.'
|
|
1455
|
+
);
|
|
1456
|
+
err.name = name;
|
|
1457
|
+
pending.reject(err);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Advance the transition by WALL-CLOCK dt and act on what it says. Called from the session's
|
|
1462
|
+
* frame loop and from the fallback tick; both are safe because the ramp is time-based, so a
|
|
1463
|
+
* double advance in one frame moves it by dt = 0.
|
|
1464
|
+
*
|
|
1465
|
+
* Rigs are pushed only when the factor actually MOVED — an idle manager must not re-push every
|
|
1466
|
+
* frame, and a landed ramp pushes its last value once.
|
|
1467
|
+
*/
|
|
1468
|
+
_advanceModeSwitch() {
|
|
1469
|
+
if (!this._modeSwitch.active()) {
|
|
1470
|
+
this._msLastMs = null;
|
|
1471
|
+
this._disarmModeSwitchTick();
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const now = nowMs();
|
|
1475
|
+
const dt = typeof this._msLastMs === 'number' ? Math.max(0, (now - this._msLastMs) / 1000) : 0;
|
|
1476
|
+
this._msLastMs = now;
|
|
1477
|
+
const out = this._modeSwitch.update(dt);
|
|
1478
|
+
if (out.factor !== this._stereoFactor) {
|
|
1479
|
+
this._stereoFactor = out.factor;
|
|
1480
|
+
for (const win of this._windows.values()) this._pushViewRig(win);
|
|
1481
|
+
}
|
|
1482
|
+
if (out.fire && this._msFire) {
|
|
1483
|
+
const pending = this._msFire;
|
|
1484
|
+
this._msFire = null;
|
|
1485
|
+
this._requestRenderingMode(pending.modeIndex, pending.win).then(
|
|
1486
|
+
(v) => pending.resolve(v),
|
|
1487
|
+
(err) => {
|
|
1488
|
+
// The panel refused to go flat, so the page must not be left flat either — ease the
|
|
1489
|
+
// disparity back to steady before handing the rejection on.
|
|
1490
|
+
this._startUpRamp();
|
|
1491
|
+
pending.reject(err);
|
|
1492
|
+
}
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
if (!this._modeSwitch.active()) {
|
|
1496
|
+
this._msLastMs = null;
|
|
1497
|
+
this._disarmModeSwitchTick();
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* A timer that advances the ramp when SESSION FRAMES are not arriving. The frame loop is the
|
|
1503
|
+
* normal driver, but a held ->2D request must not sit forever because every tile scrolled away,
|
|
1504
|
+
* the tab went background, or the page simply has no live layer — the page awaited a promise
|
|
1505
|
+
* and the browser is owed a request.
|
|
1506
|
+
*/
|
|
1507
|
+
_armModeSwitchTick() {
|
|
1508
|
+
if (this._msTick !== null || typeof setTimeout !== 'function') return;
|
|
1509
|
+
this._msTick = setTimeout(() => {
|
|
1510
|
+
this._msTick = null;
|
|
1511
|
+
if (!this._running) return;
|
|
1512
|
+
this._advanceModeSwitch();
|
|
1513
|
+
if (this._modeSwitch.active()) this._armModeSwitchTick();
|
|
1514
|
+
}, MODE_SWITCH_TICK_MS);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
_disarmModeSwitchTick() {
|
|
1518
|
+
if (this._msTick === null) return;
|
|
1519
|
+
if (typeof clearTimeout === 'function') clearTimeout(this._msTick);
|
|
1520
|
+
this._msTick = null;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1164
1523
|
/**
|
|
1165
1524
|
* The sugar behind `setStereoEnabled` — pick a mode by view count and request it. Nothing
|
|
1166
|
-
* else: the rig follows the resulting `renderingmodechange
|
|
1525
|
+
* else: the rig follows the resulting `renderingmodechange` (eased, when a transition is
|
|
1526
|
+
* configured), not this call.
|
|
1167
1527
|
*/
|
|
1168
1528
|
async _setStereoEnabled(enabled, win) {
|
|
1169
1529
|
const want = enabled ? 2 : 1;
|
|
@@ -1177,8 +1537,11 @@ class Inline3D {
|
|
|
1177
1537
|
'and cannot invent one — read getRenderingModes() and drive the list yourself.'
|
|
1178
1538
|
);
|
|
1179
1539
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1540
|
+
// Already there ⇒ the request would be a no-op... UNLESS a transition is in flight, in which
|
|
1541
|
+
// case this is the user reversing the toggle and the disparity still has to walk back. Taking
|
|
1542
|
+
// the early-out there would leave a page that pressed 2D then 3D stuck part-way flat.
|
|
1543
|
+
if (mode.isActive && !this._msTransitionActive()) return !!enabled;
|
|
1544
|
+
await this._requestRenderingModeEased(mode.modeIndex, win);
|
|
1182
1545
|
return !!enabled;
|
|
1183
1546
|
}
|
|
1184
1547
|
|
|
@@ -1897,6 +2260,10 @@ class Inline3D {
|
|
|
1897
2260
|
_frame(t, f) {
|
|
1898
2261
|
if (!this._running) return;
|
|
1899
2262
|
this._requestFrame();
|
|
2263
|
+
// The 2D<->3D ramp, on WALL-CLOCK dt (never a frame count, so it lasts the same wall time at
|
|
2264
|
+
// 30 fps and 144 fps). Before the windows, so the rig this frame's views are located against
|
|
2265
|
+
// is the ramped one. No-op — and pushes nothing — when no transition is in flight.
|
|
2266
|
+
this._advanceModeSwitch();
|
|
1900
2267
|
const pose = this.refSpace ? f.getViewerPose(this.refSpace) : null;
|
|
1901
2268
|
const views = pose ? pose.views : null;
|
|
1902
2269
|
for (const win of this._windows.values()) {
|
|
@@ -2045,6 +2412,12 @@ class Inline3D {
|
|
|
2045
2412
|
if (liveManager === this) liveManager = null;
|
|
2046
2413
|
this._unbindLifecycle();
|
|
2047
2414
|
this._disarmDprWatch();
|
|
2415
|
+
// A transition in flight dies with the session: nothing will drive the ramp, and the held
|
|
2416
|
+
// request has nowhere to go — so the page's promise is settled rather than left pending.
|
|
2417
|
+
this._disarmModeSwitchTick();
|
|
2418
|
+
this._modeSwitch.cancel();
|
|
2419
|
+
this._msArmedUp = false;
|
|
2420
|
+
this._settlePendingDown('closed', 'the inline-3D session closed first');
|
|
2048
2421
|
if (this._observer) this._observer.disconnect();
|
|
2049
2422
|
for (const win of this._windows.values()) {
|
|
2050
2423
|
this._stopOverlayScan(win);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"files": [
|
|
37
37
|
"js/inline3d.js",
|
|
38
38
|
"js/inline3d-undock.js",
|
|
39
|
+
"js/inline3d-mode-switch.js",
|
|
39
40
|
"js/inline3d-three.js",
|
|
40
41
|
"js/inline3d-viewer.js",
|
|
41
42
|
"js/inline3d-splat.js",
|