@displayxr/inline3d 1.3.0 → 1.5.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 +139 -0
- package/index.d.ts +295 -0
- package/js/inline3d-mode-switch.js +246 -0
- package/js/inline3d-model.js +1 -1
- package/js/inline3d-undock.js +251 -0
- package/js/inline3d.js +1151 -25
- package/package.json +7 -1
|
@@ -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-model.js
CHANGED
|
@@ -478,7 +478,7 @@ function boundsOf(object3d) {
|
|
|
478
478
|
* This is the default because the alternative is silently wrong. `addStudioLights` is punctual
|
|
479
479
|
* only, and a punctual light contributes a specular highlight without filling a metallic BRDF —
|
|
480
480
|
* so a `metalness: 1` surface has nothing to reflect and resolves to BLACK. Chrome bells render as
|
|
481
|
-
* a dark disc, glass
|
|
481
|
+
* a dark disc, clear-glass optics as opaque holes, and the result reads as a corrupt asset rather than a
|
|
482
482
|
* lighting choice. It has cost real debugging time more than once.
|
|
483
483
|
*
|
|
484
484
|
* RoomEnvironment is generated in memory — a small box of emissive panels — so this buys IBL with
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// inline3d-undock.js — lift a window's 3D asset OUT of the page into a floating, transparent,
|
|
2
|
+
// click-through native viewer over the desktop. Dependency-free, and usable on its own.
|
|
3
|
+
//
|
|
4
|
+
// TWO PATHS, ONE CONTRACT. Where the browser exposes `XRDisplayLayer.undock()` the request goes
|
|
5
|
+
// straight through it: the layer already knows its element's rect, and the viewer opens with no
|
|
6
|
+
// prompt. Everywhere else the page spawns the same viewer through the `displayxr-view:` OS
|
|
7
|
+
// protocol — the spawn primitive every browser hands a page (Chrome asks once, "Open DisplayXR
|
|
8
|
+
// …?", with an "Always allow" tick). The URL grammar below is the contract both paths share, and
|
|
9
|
+
// it is the one parsed by displayxr-common's `launch_args.h`.
|
|
10
|
+
//
|
|
11
|
+
// WHY A NATIVE PROCESS AND NOT A WINDOW. The browser's inline-3D weave is bound to ONE window per
|
|
12
|
+
// process and hands back opaque pixels into the page's own compositing, so no browser window can
|
|
13
|
+
// be the transparent floating one. The floating window is a native process; the page's only job
|
|
14
|
+
// is to spawn it with the asset URL and the tile's screen rect.
|
|
15
|
+
//
|
|
16
|
+
// NOTHING HERE TOUCHES THE TILE. Undock READS an element's rect and never writes to it, so it
|
|
17
|
+
// cannot disturb a woven window — the button that calls it lives in the page's own chrome.
|
|
18
|
+
|
|
19
|
+
/** @typedef {'model'|'splat'} UndockType */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How the API-first path finds the live `XRDisplayLayer` behind an element. `inline3d.js`
|
|
23
|
+
* registers one when it is imported (only it knows the canvas -> layer map); with no resolver —
|
|
24
|
+
* this module used standalone — every call takes the protocol fallback, which is the correct
|
|
25
|
+
* degradation rather than a failure.
|
|
26
|
+
*
|
|
27
|
+
* @param {(el: Element) => object|null} fn
|
|
28
|
+
*/
|
|
29
|
+
let layerResolver = null;
|
|
30
|
+
export function setUndockLayerResolver(fn) {
|
|
31
|
+
layerResolver = typeof fn === 'function' ? fn : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True where a native DisplayXR viewer can exist at all. The viewers are Windows-only today, so
|
|
36
|
+
* this is a PLATFORM probe, not a capability one — a page uses it to decide whether to show an
|
|
37
|
+
* undock affordance. It says nothing about whether the viewer is installed: that is only knowable
|
|
38
|
+
* when the launch is attempted (a `not-installed` Error on the API path; silently nothing on the
|
|
39
|
+
* protocol path, which is exactly why the API path is worth having).
|
|
40
|
+
*/
|
|
41
|
+
export function undockAvailable() {
|
|
42
|
+
if (typeof navigator === 'undefined') return false;
|
|
43
|
+
const uad = navigator.userAgentData;
|
|
44
|
+
if (uad && uad.platform) return uad.platform === 'Windows';
|
|
45
|
+
return /Windows/i.test(navigator.userAgent);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The element's rect in PHYSICAL SCREEN pixels — the space the viewer places its window in.
|
|
50
|
+
*
|
|
51
|
+
* `screenX/Y` and `outerWidth/Height` are CSS px in the browser's own DIP space; `devicePixelRatio`
|
|
52
|
+
* folds the OS scale AND the page zoom together. At 100 % zoom the arithmetic is exact; with page
|
|
53
|
+
* zoom it drifts by the zoom factor, and the viewer clamps the rect into the panel anyway. `dpr`
|
|
54
|
+
* travels along so a calibration session can read both numbers from the viewer's log instead of
|
|
55
|
+
* reverse-engineering the DIP space.
|
|
56
|
+
*
|
|
57
|
+
* @param {Element} el
|
|
58
|
+
* @returns {{x:number, y:number, w:number, h:number, dpr:number}}
|
|
59
|
+
*/
|
|
60
|
+
export function tileScreenRect(el) {
|
|
61
|
+
const r = el.getBoundingClientRect();
|
|
62
|
+
const dpr = window.devicePixelRatio || 1;
|
|
63
|
+
const chromeX = Math.max(0, (window.outerWidth - window.innerWidth) / 2);
|
|
64
|
+
const chromeY = Math.max(0, window.outerHeight - window.innerHeight);
|
|
65
|
+
return {
|
|
66
|
+
x: Math.round((window.screenX + chromeX + r.left) * dpr),
|
|
67
|
+
y: Math.round((window.screenY + chromeY + r.top) * dpr),
|
|
68
|
+
w: Math.max(64, Math.round(r.width * dpr)),
|
|
69
|
+
h: Math.max(64, Math.round(r.height * dpr)),
|
|
70
|
+
dpr,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The `displayxr-view:` URL for undocking `opts` at `el`'s screen rect — the fallback path's
|
|
76
|
+
* whole payload, exported so a page can log or test it without launching anything.
|
|
77
|
+
*
|
|
78
|
+
* displayxr-view://open?src=<pct>&type=model|splat&rect=X,Y,W,H&dpr=<f>&title=<pct>
|
|
79
|
+
* &env=room&pose=<yaw>,<pitch>&margin=<f>&transparent=1&v=1
|
|
80
|
+
*
|
|
81
|
+
* `src` is resolved to an ABSOLUTE url here and must be https (or http on loopback): the viewer
|
|
82
|
+
* refuses file:/UNC/local paths from a protocol launch by design.
|
|
83
|
+
*
|
|
84
|
+
* NO `vh`. The grammar has one, and sending it is wrong: it is a raw pin that DISABLES the
|
|
85
|
+
* viewer's auto-fit, so an asset authored at 0.2 m arrives at native scale in a small window (far
|
|
86
|
+
* too zoomed in). Left unpinned the viewer auto-fits to ~80 % of the window height — the same
|
|
87
|
+
* rule a page's own fit applies to the same pixel box — so the apparent size matches. Apparent
|
|
88
|
+
* size is a property of the window rect, not of vH.
|
|
89
|
+
*
|
|
90
|
+
* @param {Element} el
|
|
91
|
+
* @param {object} opts {src, type, env?, pose?, margin?, title?}
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function undockUrl(el, opts) {
|
|
95
|
+
const src = new URL(opts.src, window.location.href).href;
|
|
96
|
+
const rect = tileScreenRect(el);
|
|
97
|
+
const pairs = [];
|
|
98
|
+
const put = (k, v) => pairs.push(`${k}=${encodeURIComponent(v)}`);
|
|
99
|
+
put('src', src);
|
|
100
|
+
put('type', opts.type);
|
|
101
|
+
put('rect', `${rect.x},${rect.y},${rect.w},${rect.h}`);
|
|
102
|
+
put('dpr', rect.dpr.toFixed(3));
|
|
103
|
+
if (opts.title) put('title', String(opts.title).slice(0, 64));
|
|
104
|
+
if (opts.env) put('env', opts.env);
|
|
105
|
+
// The page's opening angle: a model seen at yaw -40 has a very different silhouette from the
|
|
106
|
+
// same model face-on, and "it looks bigger undocked" is usually exactly that (the fit rules
|
|
107
|
+
// agree; the pose did not). Same convention as the SDK's setPose({yaw, pitch, zoom}).
|
|
108
|
+
if (opts.pose) {
|
|
109
|
+
const z = opts.pose.zoom !== undefined && opts.pose.zoom !== 1 ? `,${opts.pose.zoom}` : '';
|
|
110
|
+
put('pose', `${opts.pose.yaw},${opts.pose.pitch ?? 0}${z}`);
|
|
111
|
+
}
|
|
112
|
+
if (opts.margin !== undefined) put('margin', String(opts.margin));
|
|
113
|
+
// Transparent is the protocol's default; stated explicitly so the intent is visible in the URL.
|
|
114
|
+
put('transparent', '1');
|
|
115
|
+
put('v', '1');
|
|
116
|
+
// Built by hand rather than with URLSearchParams, which encodes a space as '+' — a form the
|
|
117
|
+
// viewer deliberately does NOT decode. encodeURIComponent is the spec form and never emits '+'.
|
|
118
|
+
return `displayxr-view://open?${pairs.join('&')}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fire the protocol from a user gesture. A hidden iframe rather than `location.href`: the page
|
|
123
|
+
* never unloads mid-demo, and a "no handler installed" outcome is contained in the frame — which
|
|
124
|
+
* is also why this path can never REPORT that outcome. Chrome only shows the external-protocol
|
|
125
|
+
* dialog under a transient user activation, so it has to run synchronously in the click.
|
|
126
|
+
*/
|
|
127
|
+
function launchProtocol(url) {
|
|
128
|
+
const frame = document.createElement('iframe');
|
|
129
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
130
|
+
frame.style.display = 'none';
|
|
131
|
+
frame.src = url;
|
|
132
|
+
document.body.appendChild(frame);
|
|
133
|
+
window.setTimeout(() => frame.remove(), 1500);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const UNDOCK_ERRORS = ['not-installed', 'src-not-allowed', 'no-activation', 'busy'];
|
|
137
|
+
|
|
138
|
+
/** Give a rejection one of the four contract names, keeping the browser's own where it has one. */
|
|
139
|
+
function undockError(e, fallbackName, message) {
|
|
140
|
+
const name = e && UNDOCK_ERRORS.includes(e.name) ? e.name : fallbackName;
|
|
141
|
+
const err = new Error(message || (e && e.message) || `[inline3d] undock failed (${name}).`);
|
|
142
|
+
err.name = name;
|
|
143
|
+
if (e) err.cause = e;
|
|
144
|
+
return err;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ONE LIVE UNDOCK AT A TIME. The viewer is a single floating window and the browser refuses a
|
|
148
|
+
// second request while one is in flight ('busy'); the fallback path has no such guard, so the
|
|
149
|
+
// module keeps its own — two protocol launches from one click would spawn two viewers.
|
|
150
|
+
let inFlight = false;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Undock `target`'s asset into the floating native viewer.
|
|
154
|
+
*
|
|
155
|
+
* CALL IT SYNCHRONOUSLY INSIDE THE CLICK. Both paths need the transient user activation — the
|
|
156
|
+
* API path to be allowed at all (`no-activation`), the fallback to get Chrome's protocol dialog —
|
|
157
|
+
* and an `await` before this call spends it. Nothing here awaits before the launch, so the
|
|
158
|
+
* activation is intact when it matters.
|
|
159
|
+
*
|
|
160
|
+
* @param {Element} target the element whose SCREEN RECT the viewer opens over (the tile).
|
|
161
|
+
* @param {object} opts
|
|
162
|
+
* @param {string} opts.src absolute https URL (or http on loopback) of the asset.
|
|
163
|
+
* @param {UndockType} opts.type
|
|
164
|
+
* @param {'room'|'studio'|'sky'|'none'} [opts.env] lighting the page rendered with.
|
|
165
|
+
* @param {{yaw:number, pitch?:number, zoom?:number}} [opts.pose] the angle the page opened at.
|
|
166
|
+
* @param {number} [opts.margin] the page's fit margin, when it overrides the default.
|
|
167
|
+
* @param {string} [opts.title]
|
|
168
|
+
* @returns {Promise<{ended:Promise<void>, viewer:UndockType, detached?:boolean}>}
|
|
169
|
+
* `ended` resolves when the viewer exits (fallback path: immediately, with
|
|
170
|
+
* `detached === true` — a protocol launch is fire-and-forget and the page never hears back).
|
|
171
|
+
* Rejects with an Error named `not-installed` | `src-not-allowed` | `no-activation` | `busy`.
|
|
172
|
+
*/
|
|
173
|
+
export function undock(target, opts) {
|
|
174
|
+
if (!target || typeof target.getBoundingClientRect !== 'function') {
|
|
175
|
+
return Promise.reject(new TypeError('[inline3d] undock() takes an Element and options.'));
|
|
176
|
+
}
|
|
177
|
+
if (!opts || typeof opts.src !== 'string' || !opts.src) {
|
|
178
|
+
return Promise.reject(new TypeError('[inline3d] undock() needs opts.src (an absolute URL).'));
|
|
179
|
+
}
|
|
180
|
+
if (opts.type !== 'model' && opts.type !== 'splat') {
|
|
181
|
+
return Promise.reject(
|
|
182
|
+
new TypeError(`[inline3d] undock() opts.type is 'model' or 'splat', got ${JSON.stringify(opts.type)}.`)
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (inFlight) return Promise.reject(undockError(null, 'busy', '[inline3d] an undock is already in flight.'));
|
|
186
|
+
|
|
187
|
+
const layer = layerResolver ? layerResolver(target) : null;
|
|
188
|
+
const viewer = opts.type;
|
|
189
|
+
|
|
190
|
+
// ── fallback: the OS protocol ──────────────────────────────────────────────────────────
|
|
191
|
+
if (!layer || typeof layer.undock !== 'function') {
|
|
192
|
+
let url;
|
|
193
|
+
try {
|
|
194
|
+
url = undockUrl(target, opts);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return Promise.reject(undockError(e, 'src-not-allowed'));
|
|
197
|
+
}
|
|
198
|
+
launchProtocol(url);
|
|
199
|
+
// Fire-and-forget by construction: the iframe swallows "no handler installed" and nothing
|
|
200
|
+
// comes back from a spawned process, so `ended` is honest only about THIS page's part being
|
|
201
|
+
// over. `detached` is how a caller tells the two paths apart.
|
|
202
|
+
return Promise.resolve({ ended: Promise.resolve(), viewer, detached: true, url });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── API path ───────────────────────────────────────────────────────────────────────────
|
|
206
|
+
// Synchronous, first thing, activation intact. The layer knows its own element rect, so only
|
|
207
|
+
// the content half of the contract travels.
|
|
208
|
+
const init = { src: opts.src, type: opts.type };
|
|
209
|
+
if (opts.env) init.env = opts.env;
|
|
210
|
+
if (opts.pose) init.pose = opts.pose;
|
|
211
|
+
if (opts.margin !== undefined) init.margin = opts.margin;
|
|
212
|
+
if (opts.title) init.title = opts.title;
|
|
213
|
+
|
|
214
|
+
let call;
|
|
215
|
+
try {
|
|
216
|
+
call = Promise.resolve(layer.undock(init));
|
|
217
|
+
} catch (e) {
|
|
218
|
+
// A synchronous throw is the same failure as a rejection; one .catch() should cover both.
|
|
219
|
+
return Promise.reject(undockError(e, 'src-not-allowed'));
|
|
220
|
+
}
|
|
221
|
+
inFlight = true;
|
|
222
|
+
const settled = call.finally(() => {
|
|
223
|
+
inFlight = false;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// TWO PROMISE SHAPES, ONE ANSWER. `undock()` either rejects PROMPTLY — every refusal
|
|
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',
|
|
234
|
+
(e) => {
|
|
235
|
+
throw undockError(e, 'not-installed');
|
|
236
|
+
}
|
|
237
|
+
);
|
|
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
|
+
}
|