@displayxr/inline3d 1.2.1 → 1.4.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 +155 -0
- package/README.md +20 -0
- package/index.d.ts +335 -0
- package/js/inline3d-model.js +1 -1
- package/js/inline3d-three.js +188 -0
- package/js/inline3d-undock.js +251 -0
- package/js/inline3d.js +885 -7
- package/package.json +6 -1
- package/three.d.ts +78 -0
package/js/inline3d-three.js
CHANGED
|
@@ -45,6 +45,12 @@
|
|
|
45
45
|
// in front), and render `eye.camera` directly. No per-frame world scaling — that is the whole
|
|
46
46
|
// point of using the rig instead of re-deriving it in the app, and it mirrors the native
|
|
47
47
|
// reference apps (cube_handle), which supply one scale number and consume render-ready views.
|
|
48
|
+
//
|
|
49
|
+
// VIEW RIGS. virtualDisplayHeight is one number out of a whole descriptor. cameraRigFromCamera()
|
|
50
|
+
// and displayRig() below build the full thing — a posed portal, or an app CAMERA whose frustum
|
|
51
|
+
// the runtime perturbs with the viewer's eyes — for handle.setViewRig(). They fill in a
|
|
52
|
+
// descriptor and nothing else: no Kooima, no off-axis math, no scale, here or anywhere in this
|
|
53
|
+
// SDK. That stays in the runtime, which is the point of the extension.
|
|
48
54
|
|
|
49
55
|
/**
|
|
50
56
|
* A reusable three.js camera driven directly by an XRView's matrices. Construct once with
|
|
@@ -89,6 +95,188 @@ export class EyeCamera {
|
|
|
89
95
|
cam.matrixWorldInverse.copy(cam.matrixWorld).invert();
|
|
90
96
|
return cam;
|
|
91
97
|
}
|
|
98
|
+
|
|
99
|
+
/** Set the camera's projection + LOCAL pose from an XRView — the attach pattern below. */
|
|
100
|
+
setLocalFromView(view) {
|
|
101
|
+
return this.setLocalFromMatrices(view.projectionMatrix, view.transform.matrix);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Like setFromMatrices, but the view's transform is written as the camera's LOCAL matrix and
|
|
106
|
+
* three composes `matrixWorld` from the parent — so the eye can hang off another object.
|
|
107
|
+
*
|
|
108
|
+
* WHY THIS EXISTS: the browser locates views BEFORE the page's rAF, so a view rig set during
|
|
109
|
+
* frame N drives the views delivered in frame N+1. Send a camera rig with an IDENTITY pose
|
|
110
|
+
* instead, parent both eye cameras under your app camera object, and the runtime's job shrinks
|
|
111
|
+
* to what it is uniquely good at (the eye offsets and the tracking-perturbed frustum, in rig
|
|
112
|
+
* space) while the app's own scene graph supplies the world pose — this frame's, not last
|
|
113
|
+
* frame's. A camera whipping around under the pointer then has zero rig lag.
|
|
114
|
+
*
|
|
115
|
+
* That is a SCENE-GRAPH parent and nothing more. No projection math moves into the page: the
|
|
116
|
+
* projectionMatrix is still the runtime's, untouched, and the local transform is still the eye
|
|
117
|
+
* pose the runtime reported — it is simply interpreted in rig space rather than world space,
|
|
118
|
+
* which is exactly what an identity-posed rig means.
|
|
119
|
+
*
|
|
120
|
+
* appCamera.add(eyeL.camera); appCamera.add(eyeR.camera); // once
|
|
121
|
+
* handle.setViewRig(cameraRigFromCamera(THREE, appCamera, { attach: true, convergence }));
|
|
122
|
+
* eyeL.setLocalFromView(views[0]); // per frame
|
|
123
|
+
*
|
|
124
|
+
* `matrixAutoUpdate` is false (the matrix is ours, not three's) but that does NOT opt out of
|
|
125
|
+
* world composition: `updateMatrixWorld` still multiplies parent × local. So the eye cameras
|
|
126
|
+
* must be reached by a normal traversal — `renderer.render(scene, eye.camera)` only
|
|
127
|
+
* auto-updates a camera whose `parent` is null, so make sure the app camera is IN the scene
|
|
128
|
+
* (or call `scene.updateMatrixWorld()` yourself) or the eyes will render at a stale pose.
|
|
129
|
+
*
|
|
130
|
+
* @param {ArrayLike<number>} projectionMatrix 16 floats, column-major (view.projectionMatrix).
|
|
131
|
+
* @param {ArrayLike<number>} transformMatrix 16 floats, column-major (view.transform.matrix),
|
|
132
|
+
* read as a pose in the RIG's space.
|
|
133
|
+
*/
|
|
134
|
+
setLocalFromMatrices(projectionMatrix, transformMatrix) {
|
|
135
|
+
const cam = this.camera;
|
|
136
|
+
cam.projectionMatrix.fromArray(projectionMatrix);
|
|
137
|
+
cam.projectionMatrixInverse.copy(cam.projectionMatrix).invert();
|
|
138
|
+
cam.matrix.fromArray(transformMatrix);
|
|
139
|
+
// Hand the world matrices back to three. Marking the flag is the whole handshake: with
|
|
140
|
+
// matrixAutoUpdate off, nothing else tells updateMatrixWorld that the local matrix moved,
|
|
141
|
+
// and a parent that happens not to move that frame would leave the eye at its old world
|
|
142
|
+
// pose (Camera.updateMatrixWorld re-derives matrixWorldInverse from matrixWorld, so both
|
|
143
|
+
// stay consistent once it runs).
|
|
144
|
+
cam.matrixWorldNeedsUpdate = true;
|
|
145
|
+
return cam;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Scratch for cameraRigFromCamera's decompose. Module-scoped and lazily built from the caller's
|
|
150
|
+
// THREE, so a per-frame rig costs no allocation — the values are copied straight out into the
|
|
151
|
+
// descriptor before anything else can observe them, so sharing is safe.
|
|
152
|
+
let _scratch = null;
|
|
153
|
+
function scratch(THREE) {
|
|
154
|
+
if (!_scratch) {
|
|
155
|
+
_scratch = { p: new THREE.Vector3(), q: new THREE.Quaternion(), s: new THREE.Vector3() };
|
|
156
|
+
}
|
|
157
|
+
return _scratch;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build a CAMERA-rig descriptor from a three.js PerspectiveCamera.
|
|
162
|
+
*
|
|
163
|
+
* A camera rig says "here is an app camera; perturb its frustum with the viewer's eyes" — the
|
|
164
|
+
* runtime keeps your vertical FOV, offsets the eyes, and skews each frustum so the convergence
|
|
165
|
+
* distance lands on the zero-disparity plane. Contrast the DISPLAY rig ({@link displayRig}),
|
|
166
|
+
* which says "the canvas is a portal onto a virtual display this tall". Neither computes
|
|
167
|
+
* anything here: this function only fills in a descriptor, and every off-axis projection stays
|
|
168
|
+
* in the runtime, where it is the same code the native apps use.
|
|
169
|
+
*
|
|
170
|
+
* CONVERGENCE IS THE ONE KNOB TO GET RIGHT. It is the distance at which content sits ON the
|
|
171
|
+
* glass; everything nearer pops out, everything further recedes. Point it at whatever the viewer
|
|
172
|
+
* is meant to be looking at (an orbit target, a hit-tested surface) — for an orbiting camera
|
|
173
|
+
* that is usually just the orbit radius. Left at 0 it means infinity, which puts the entire
|
|
174
|
+
* scene in front of the display and is comfortable for almost nothing.
|
|
175
|
+
*
|
|
176
|
+
* COMFORT. The runtime's rule is `ipdFactor × metersToVirtual × convergenceDiopters × N <= 1`
|
|
177
|
+
* (N = nominal viewing distance, ~0.5 m): at 1 the viewer's eyes are parallel on infinitely far
|
|
178
|
+
* content, and past it they diverge, which no one can fuse. With the defaults (factors 1,
|
|
179
|
+
* metersToVirtual 1) that is `convergence >= ~0.5` world units. Nothing here enforces it — the
|
|
180
|
+
* runtime clamps out-of-range values itself, once, with a warning — but a scene authored in
|
|
181
|
+
* centimetres with a 0.1-unit convergence is the shape of the mistake.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} THREE your imported three.js module namespace.
|
|
184
|
+
* @param {object} camera a THREE.PerspectiveCamera (`.fov` in degrees, `.matrixWorld` current).
|
|
185
|
+
* @param {object} [opts]
|
|
186
|
+
* @param {number} [opts.convergence=0] zero-disparity distance in WORLD units (0 = infinity).
|
|
187
|
+
* @param {boolean} [opts.attach=false] emit an IDENTITY pose, for the attach pattern above —
|
|
188
|
+
* you parent the eye cameras under this camera and three supplies the world pose.
|
|
189
|
+
* @param {number} [opts.ipdFactor=1] eye separation, ABSOLUTE on a camera rig (world units per
|
|
190
|
+
* metre of real IPD); 0 collapses to mono.
|
|
191
|
+
* @param {number} [opts.parallaxFactor=1] how far the rig tracks head motion, absolute likewise.
|
|
192
|
+
* @param {number} [opts.metersToVirtual=1] metres → world units on the eye.
|
|
193
|
+
* @param {object} [opts.out] a descriptor object to overwrite instead of allocating one.
|
|
194
|
+
* @returns {object} an XRViewRigInit-shaped plain object.
|
|
195
|
+
*/
|
|
196
|
+
export function cameraRigFromCamera(THREE, camera, opts = {}) {
|
|
197
|
+
const {
|
|
198
|
+
convergence = 0,
|
|
199
|
+
attach = false,
|
|
200
|
+
ipdFactor = 1,
|
|
201
|
+
parallaxFactor = 1,
|
|
202
|
+
metersToVirtual = 1,
|
|
203
|
+
out = {},
|
|
204
|
+
} = opts;
|
|
205
|
+
out.type = 'camera';
|
|
206
|
+
if (attach) {
|
|
207
|
+
// Identity pose: the rig IS the camera, so the runtime reports eyes in camera space and the
|
|
208
|
+
// scene graph does the rest. Deliberately not "the camera's pose from a frame ago".
|
|
209
|
+
out.position = { x: 0, y: 0, z: 0 };
|
|
210
|
+
out.orientation = { x: 0, y: 0, z: 0, w: 1 };
|
|
211
|
+
} else {
|
|
212
|
+
// World pose, decomposed from the matrix rather than read off .position/.quaternion: those
|
|
213
|
+
// are LOCAL, and an app camera parented under a rig/dolly (the usual way to build an orbit)
|
|
214
|
+
// would then send the runtime a pose in the wrong space.
|
|
215
|
+
camera.updateMatrixWorld();
|
|
216
|
+
const { p, q, s } = scratch(THREE);
|
|
217
|
+
camera.matrixWorld.decompose(p, q, s);
|
|
218
|
+
out.position = { x: p.x, y: p.y, z: p.z };
|
|
219
|
+
out.orientation = { x: q.x, y: q.y, z: q.z, w: q.w };
|
|
220
|
+
}
|
|
221
|
+
out.ipdFactor = ipdFactor;
|
|
222
|
+
out.parallaxFactor = parallaxFactor;
|
|
223
|
+
// Diopters, not distance: the wire unit is 1/distance so that "infinity" is representable as
|
|
224
|
+
// a finite 0 instead of a sentinel.
|
|
225
|
+
out.convergenceDiopters = convergence > 0 ? 1 / convergence : 0;
|
|
226
|
+
out.verticalFov = THREE.MathUtils.degToRad(camera.fov); // three's fov is the FULL angle, in degrees
|
|
227
|
+
out.metersToVirtual = metersToVirtual;
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build a DISPLAY-rig descriptor — the default rig, made explicit and posable.
|
|
233
|
+
*
|
|
234
|
+
* The display rig treats the canvas as a PORTAL: the element's plane is world z = 0 and the
|
|
235
|
+
* viewer looks through it at a virtual display `virtualDisplayHeight` metres tall (the m2v knob
|
|
236
|
+
* `addScene`'s scalar option sets). This adds what the scalar cannot say — a pose, so the portal
|
|
237
|
+
* can be tilted or offset, and the three factors, so eye separation, head-tracking response and
|
|
238
|
+
* perspective strength can be dialled independently.
|
|
239
|
+
*
|
|
240
|
+
* The factors are RELATIVE here (unlike a camera rig, where ipd/parallax are absolute):
|
|
241
|
+
* `ipdFactor` and `parallaxFactor` are [0,1] multipliers on what the display would naturally do
|
|
242
|
+
* — 1 is correct-by-construction, 0 is flat/frozen, and the values between are a comfort dial,
|
|
243
|
+
* not a correctness one. `perspectiveFactor` is [0.1,10] and exaggerates or flattens the
|
|
244
|
+
* off-axis skew; it is the one knob with no physical justification, so treat it as an effect.
|
|
245
|
+
* The runtime clamps anything out of range (once, with a warning) rather than refusing the rig.
|
|
246
|
+
*
|
|
247
|
+
* @param {object} [opts]
|
|
248
|
+
* @param {number} [opts.virtualDisplayHeight=0.24] metres of virtual display (the zoom knob).
|
|
249
|
+
* @param {{x?:number,y?:number,z?:number}} [opts.position] rig pose, app world units.
|
|
250
|
+
* @param {{x?:number,y?:number,z?:number,w?:number}} [opts.orientation] rig orientation quat.
|
|
251
|
+
* @param {number} [opts.ipdFactor=1] [opts.parallaxFactor=1] [opts.perspectiveFactor=1]
|
|
252
|
+
* @param {object} [opts.out] a descriptor object to overwrite instead of allocating one.
|
|
253
|
+
* @returns {object} an XRViewRigInit-shaped plain object.
|
|
254
|
+
*/
|
|
255
|
+
export function displayRig(opts = {}) {
|
|
256
|
+
const {
|
|
257
|
+
virtualDisplayHeight = 0.24,
|
|
258
|
+
position = { x: 0, y: 0, z: 0 },
|
|
259
|
+
orientation = { x: 0, y: 0, z: 0, w: 1 },
|
|
260
|
+
ipdFactor = 1,
|
|
261
|
+
parallaxFactor = 1,
|
|
262
|
+
perspectiveFactor = 1,
|
|
263
|
+
out = {},
|
|
264
|
+
} = opts;
|
|
265
|
+
out.type = 'display';
|
|
266
|
+
// Copied field by field, not aliased: a caller reusing `out` every frame must not end up
|
|
267
|
+
// holding a live reference to a THREE.Vector3 it is also mutating.
|
|
268
|
+
out.position = { x: position.x || 0, y: position.y || 0, z: position.z || 0 };
|
|
269
|
+
out.orientation = {
|
|
270
|
+
x: orientation.x || 0,
|
|
271
|
+
y: orientation.y || 0,
|
|
272
|
+
z: orientation.z || 0,
|
|
273
|
+
w: orientation.w === undefined ? 1 : orientation.w,
|
|
274
|
+
};
|
|
275
|
+
out.virtualDisplayHeight = virtualDisplayHeight;
|
|
276
|
+
out.ipdFactor = ipdFactor;
|
|
277
|
+
out.parallaxFactor = parallaxFactor;
|
|
278
|
+
out.perspectiveFactor = perspectiveFactor;
|
|
279
|
+
return out;
|
|
92
280
|
}
|
|
93
281
|
|
|
94
282
|
/**
|
|
@@ -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
|
+
}
|