@displayxr/inline3d 0.0.1 → 1.1.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 +132 -0
- package/README.md +100 -8
- package/index.d.ts +221 -0
- package/js/inline3d-model.js +169 -0
- package/js/inline3d-splat.js +333 -0
- package/js/inline3d-three.js +154 -0
- package/js/inline3d-viewer.js +553 -0
- package/js/inline3d.js +1221 -0
- package/model.d.ts +64 -0
- package/package.json +82 -8
- package/splat.d.ts +91 -0
- package/three.d.ts +37 -0
- package/viewer.d.ts +94 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Changelog — `@displayxr/inline3d`
|
|
2
|
+
|
|
3
|
+
Versioning follows [`docs/sdk-stability.md`](docs/sdk-stability.md). Read that first: the core
|
|
4
|
+
entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (`./viewer`,
|
|
5
|
+
`./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
|
|
6
|
+
which tier they touch, because that is what tells you whether an upgrade can move your pixels.
|
|
7
|
+
|
|
8
|
+
## 1.1.0 — 2026-08-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`inline3dOcclusionByDrawOrder()` — and the whole overlay-exclusion machinery turns itself off
|
|
13
|
+
where it's true.** The browser's Phase-2 compositor path composites ANY 2D content over woven
|
|
14
|
+
tiles per-pixel by draw order: a header, a badge, a dropdown, a translucent scrim, even a
|
|
15
|
+
full-tile plate occludes a tile with nothing declared. On such a browser this SDK stops working
|
|
16
|
+
around it — no auto-chrome DOM scan (a `querySelectorAll` + `getComputedStyle` sweep at every
|
|
17
|
+
layer activation), no `MutationObserver` per live tile for `data-inline3d-overlay`, and no
|
|
18
|
+
`will-change: transform` promotions written onto the page's own elements. `exclude()`,
|
|
19
|
+
`addGlobalOverlay()` and their `remove`/`unexclude` pairs still accept and store their argument
|
|
20
|
+
and simply do nothing, so ONE page runs unchanged on both browser generations; one
|
|
21
|
+
`console.info` says so the first time a page calls one.
|
|
22
|
+
|
|
23
|
+
The probe is a **capability, not a version**: the browser change is compositor-side and leaves
|
|
24
|
+
the JS API untouched, so `excludeElement` is present on both generations and only its effect
|
|
25
|
+
differs — its presence cannot tell them apart, and neither can `inline3dOverlaySupported()`,
|
|
26
|
+
whose question ("does 2D on a tile composite as crisp 2D?") is true on both. The gate is a
|
|
27
|
+
readonly capability flag the browser exposes on `XRDisplayLayer` —
|
|
28
|
+
`typeof XRDisplayLayer.occlusionByDrawOrder === 'boolean' ? XRDisplayLayer.occlusionByDrawOrder : …`,
|
|
29
|
+
falling back to the same-named per-layer attribute read off the first live layer if that is the
|
|
30
|
+
shape it lands in. **DisplayXR Browser 0.1.11 is the first build to expose it**, so on 0.1.11 and
|
|
31
|
+
newer this release stands the machinery down; on 0.1.10 and earlier the legacy path runs, byte for
|
|
32
|
+
byte as in 1.0 — verified by replaying one page against both SDK builds and diffing every
|
|
33
|
+
exclusion call, promotion, warning and registration. A user-agent or version gate was rejected: a
|
|
34
|
+
page pins an SDK for years, and a version string cannot describe a compositor behaviour that is
|
|
35
|
+
switch-gated — which is also why the flag reads `false` on a 0.1.11 launched with
|
|
36
|
+
`--disable-inline-3d-occlusion`, and the SDK correctly resumes the legacy path there.
|
|
37
|
+
|
|
38
|
+
Note what the *obvious* probe would have done.
|
|
39
|
+
`!!XRDisplayLayer.prototype.occlusionByDrawOrder` **throws** — a Blink IDL attribute getter
|
|
40
|
+
raises `TypeError: Illegal invocation` when its receiver is the prototype rather than an
|
|
41
|
+
instance — so the natural one-liner would have failed on precisely the browser it was looking
|
|
42
|
+
for. Presence is therefore probed with `in` (which calls no getter) and every value read has a
|
|
43
|
+
legal receiver: the interface object, or a real layer.
|
|
44
|
+
|
|
45
|
+
Effects on an element that overlaps a tile remain the exception on both generations: a
|
|
46
|
+
`backdrop-filter` (a function of what is behind it, and what is behind it is the woven buffer),
|
|
47
|
+
and — new small print for the Phase-2 path — a pixel-moving `filter`, a non-normal blend mode or
|
|
48
|
+
a 3D sorting context, none of which draw as the plain quad the split can lift. Plain chrome is
|
|
49
|
+
unaffected. *(core tier — additive: one new helper, no behaviour change on current browsers. The
|
|
50
|
+
exclusion APIs are deprecated-but-covered; see the stability policy.)*
|
|
51
|
+
|
|
52
|
+
- **`./viewer`, `./splat` and `./model` are now published exports.** 1.0.0 shipped `exports` for
|
|
53
|
+
`.` and `./three` only, so `import { addSplat } from '@displayxr/inline3d/splat'` failed on an
|
|
54
|
+
npm install even though the modules existed in the repo — vendoring the files was the only way to
|
|
55
|
+
use a splat or a mesh tile. Additive, so nothing in 1.0.0 changes.
|
|
56
|
+
*(preview tier — see the stability policy before depending on their option shapes)*
|
|
57
|
+
- `boundsFromPositions` takes `expand` (default 2.5), the width of the outlier-rejection window in
|
|
58
|
+
core extents. `expand: 0` restores the 1.0-era percentile-only box. *(preview)*
|
|
59
|
+
- `addSplat` checks `THREE.REVISION` and throws a named error when three is older than 0.180,
|
|
60
|
+
Spark's floor. npm cannot express a peer range per export, so the manifest states the
|
|
61
|
+
package-wide `>=0.150` and an install on 0.16x succeeds; the failure used to surface from inside
|
|
62
|
+
a Spark worker as something unrelated to versions. *(preview)*
|
|
63
|
+
- **A live window now tracks its own box and `devicePixelRatio`.** `addImage`/`addVideo` windows
|
|
64
|
+
get a `ResizeObserver` while active, plus a `(resolution: Ndppx)` media query for the changes a
|
|
65
|
+
`ResizeObserver` cannot see (browser zoom, a drag to a different-scale monitor); the
|
|
66
|
+
side-by-side buffer is re-derived and repainted on the next animation frame. `addScene`
|
|
67
|
+
canvases and windows given an explicit `{ width, height }` are box-independent and untouched.
|
|
68
|
+
*(core tier — additive; no API change)*
|
|
69
|
+
- **Creating a second manager while one is live warns.** The browser's element-rect channel is a
|
|
70
|
+
whole-widget setter, so two live sessions in one document overwrite each other's rect list
|
|
71
|
+
every frame and neither one's tiles hold still. One `console.warn` says so; nothing is refused,
|
|
72
|
+
because a route change that closes one manager and opens the next is the normal case.
|
|
73
|
+
*(core tier)*
|
|
74
|
+
- **A full-tile overlay is refused with an explanation instead of destroying the tile.** The
|
|
75
|
+
browser matches an excluded element to a composited quad by ≥70% area overlap, so a plate
|
|
76
|
+
congruent with its own canvas matches the **canvas** — which then leaves the weave input and
|
|
77
|
+
presents its raw side-by-side buffer. Both the imperative `exclude()` and the
|
|
78
|
+
`data-inline3d-overlay` scan now measure mutual overlap and skip such an element. The test is
|
|
79
|
+
mutual, so page-global chrome that fully covers a small tile is unaffected. Make the overlay a
|
|
80
|
+
partial region of the tile, or page chrome via `addGlobalOverlay()`. *(core tier)*
|
|
81
|
+
|
|
82
|
+
### Changed — this moves existing pixels
|
|
83
|
+
|
|
84
|
+
- **A splat's framing changes: subjects that were 10–15% too large now render smaller.**
|
|
85
|
+
`boundsFromPositions` returned a percentile-trimmed box as the subject's extent. Trimming is
|
|
86
|
+
essential on captured content — one floater a hundred metres out and the subject is a speck — but
|
|
87
|
+
the tail it drops on a DENSE subject is that subject's own outer shell, so the box came back
|
|
88
|
+
small and the fit faithfully turned that into a subject overflowing its tile. A uniform cube of
|
|
89
|
+
20k points measured 0.899 of its real size with no outliers present at all.
|
|
90
|
+
|
|
91
|
+
Percentiles now bound a rejection window and the returned extent is the true min/max inside it;
|
|
92
|
+
the same cube measures 1.000 and the floater is still rejected. Measured across seven scanned
|
|
93
|
+
products, rendered silhouettes went from 0.849–0.980 of the tile to 0.739–0.880, against 0.856
|
|
94
|
+
for the `./model` path whose `Box3` bounds were always exact.
|
|
95
|
+
|
|
96
|
+
If a page compensated for the old behaviour with a reduced `margin`, remove that compensation.
|
|
97
|
+
*(preview tier — `./viewer`, and `./splat` through it. `./model` is unaffected: its bounds were
|
|
98
|
+
never percentile-based.)*
|
|
99
|
+
|
|
100
|
+
### Fixed
|
|
101
|
+
|
|
102
|
+
- **Back-navigation left ghost 3D windows woven over the next page.** A window's rect reaches the
|
|
103
|
+
compositor from the session's own animation frames, and the only way to clear a rect is to push
|
|
104
|
+
a list without it — so a page frozen into the bfcache mid-loop leaves its last list standing and
|
|
105
|
+
its tiles keep weaving over whatever is on screen now (context:
|
|
106
|
+
[displayxr-browser#87](https://github.com/DisplayXR/displayxr-browser/issues/87)). Every live
|
|
107
|
+
window is now released on `pagehide` (and `freeze`, for a tab frozen without one) while frames
|
|
108
|
+
still run, so the outgoing frames report an empty list, and re-armed on `pageshow`/`resume`
|
|
109
|
+
through the existing lazy logic — re-observing re-delivers the current intersection state, so a
|
|
110
|
+
tile scrolled away before leaving stays dark. Page chrome is rescanned on restore. *(core tier)*
|
|
111
|
+
- **A restored page could come back alive but never paint.** A bfcache restore can hand back a
|
|
112
|
+
session whose pending animation frame never arrives, leaving the manager nominally running with
|
|
113
|
+
a dead loop. A persisted `pageshow` now gives it a second to prove otherwise and then starts a
|
|
114
|
+
fresh loop; loops carry an id and only the current one re-arms, so a stalled predecessor cannot
|
|
115
|
+
double the loop if it later fires. *(core tier)*
|
|
116
|
+
- `addSplat` threw a `ReferenceError` on the **URL path** — every ordinary page — because the
|
|
117
|
+
loader assigned `out.mesh` before `const out` was initialised. An async body runs synchronously
|
|
118
|
+
to its first `await`, and the URL path has none. The throw escaped into `ready` *after* the mesh
|
|
119
|
+
had joined the scene, so the splat rendered at raw model scale and never got framed: the symptom
|
|
120
|
+
was "the fit is wrong" when the fit had never run. The Blob path awaited `arrayBuffer()` and so
|
|
121
|
+
was unaffected, which is how it survived a commit about the bytes path. *(preview)*
|
|
122
|
+
- A rejected splat load now detaches its mesh, so a failed tile is empty as documented rather than
|
|
123
|
+
an unframed subject spilling out of the window under the caller's error state. *(preview)*
|
|
124
|
+
- `addSplat` warns when no usable bounds could be measured, instead of silently drawing at model
|
|
125
|
+
scale. *(preview)*
|
|
126
|
+
|
|
127
|
+
## 1.0.0 — 2026-07-20
|
|
128
|
+
|
|
129
|
+
First published release. Freezes the imperative authoring API — `createInline3D`, the `Inline3D`
|
|
130
|
+
manager (`addImage` / `addVideo` / `addScene`, global overlays), the `TileHandle`, the detection
|
|
131
|
+
helpers, the `data-inline3d-overlay` contract, and the side-by-side buffer contract — as the
|
|
132
|
+
supported surface for 1.x. Exports `.` and `./three`.
|
package/README.md
CHANGED
|
@@ -1,16 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# displayxr-web
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
Inline-3D **web samples** and a small JS SDK for the
|
|
4
|
+
[DisplayXR Browser](https://github.com/DisplayXR/displayxr-browser) — the DisplayXR analog of
|
|
5
|
+
[`immersive-web/webxr-samples`](https://github.com/immersive-web/webxr-samples). This is the canonical
|
|
6
|
+
repo web developers clone to build glasses-free 3D pages, and the site the browser navigates to for the
|
|
7
|
+
live demos.
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
**▶ See it live:** <https://displayxr.github.io/displayxr-web/> — open in the
|
|
10
|
+
[DisplayXR Browser](https://github.com/DisplayXR/displayxr-browser/releases) on DisplayXR hardware for
|
|
11
|
+
glasses-free 3D; in any other browser the pages render as a normal 2D fallback, so they're safe to view
|
|
12
|
+
anywhere.
|
|
9
13
|
|
|
10
|
-
Install
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
The SDK is published as **[`@displayxr/inline3d`](https://www.npmjs.com/package/@displayxr/inline3d)**
|
|
17
|
+
(dependency-free ESM, ships its own TypeScript types):
|
|
11
18
|
|
|
12
19
|
```sh
|
|
13
20
|
npm install @displayxr/inline3d
|
|
14
21
|
```
|
|
15
22
|
|
|
16
|
-
|
|
23
|
+
```js
|
|
24
|
+
import { createInline3D } from '@displayxr/inline3d';
|
|
25
|
+
import { EyeCamera, EdgeFeather } from '@displayxr/inline3d/three'; // optional three.js glue
|
|
26
|
+
import { addSplat } from '@displayxr/inline3d/splat'; // experimental: 3DGS in a tile
|
|
27
|
+
import { SceneViewer } from '@displayxr/inline3d/viewer'; // experimental: framing + orbit
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
No build step or bundler required — it's plain ES modules. You can also import a pinned version by
|
|
31
|
+
URL from a CDN (jsDelivr / unpkg) without npm. The samples in this repo import the SDK by relative
|
|
32
|
+
path (`./js/inline3d.js`) so they run straight off GitHub Pages; in your own app prefer the package.
|
|
33
|
+
|
|
34
|
+
`three` and `@sparkjsdev/spark` are **optional peer dependencies** — the core is dependency-free
|
|
35
|
+
and only the `/three`, `/viewer` and `/splat` subpaths need them. The two viewer subpaths are
|
|
36
|
+
**experimental**: they turn "one object in a tile, look around it, drag to spin" into a single
|
|
37
|
+
call (auto-framing on the zero-disparity plane, orbit, idle turntable, mono fallback), but their
|
|
38
|
+
API is not yet covered by the semver promise below.
|
|
39
|
+
|
|
40
|
+
Stability & what's covered by semver (and the deferred N-view / web-components / CSS-native roadmap
|
|
41
|
+
that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stability.md).
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
One SDK call turns a `<canvas>` into a glasses-free-3D window. Everything degrades to plain 2D on a
|
|
46
|
+
non-DisplayXR browser, so a page is safe to ship anywhere.
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
import { createInline3D } from '@displayxr/inline3d';
|
|
50
|
+
|
|
51
|
+
const wall = await createInline3D(); // opens an inline-3d session (detects support)
|
|
52
|
+
if (!wall.supported) {
|
|
53
|
+
// Not the DisplayXR Browser (or no 3D display) — your page's normal 2D content shows. Done.
|
|
54
|
+
} else {
|
|
55
|
+
// Woven, glasses-free 3D. Add content — one call per element:
|
|
56
|
+
wall.addImage(canvas, 'photo-sbs.png'); // a still side-by-side 3D photo
|
|
57
|
+
wall.addVideo(canvas, videoEl); // an SBS 3D video
|
|
58
|
+
wall.addScene(canvas, (views, layer) => { /* render */ });// a live three.js / WebGL stereo scene
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The browser weaves each element's stereo pair at its on-screen rect; the surrounding DOM stays flat.
|
|
63
|
+
The runtime batches every visible window into one weave per frame, so it scales to a wall of elements.
|
|
64
|
+
|
|
65
|
+
> **Detection:** call `createInline3D()` and check `wall.supported` — do **not** gate on
|
|
66
|
+
> `navigator.xr.isSessionSupported('inline-3d')`. That async probe resolves `false` if it runs before the
|
|
67
|
+
> OS weave service has bound (typically at page load), a false-negative that silently drops you to 2D.
|
|
68
|
+
> `createInline3D()` detects by actually acquiring a session, which is authoritative.
|
|
69
|
+
|
|
70
|
+
Full API + authoring guidance: [`docs/authoring-inline-3d.md`](docs/authoring-inline-3d.md).
|
|
71
|
+
Three.js glue (an off-axis `EyeCamera`) in [`js/inline3d-three.js`](js/inline3d-three.js).
|
|
72
|
+
|
|
73
|
+
## What's here
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
index.html landing (Pages entry point)
|
|
77
|
+
samples/
|
|
78
|
+
windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
|
|
79
|
+
each woven with one SDK call, all on one session
|
|
80
|
+
splat/ a 3D Gaussian splat in a tile, auto-framed, with a 2D price plate over it
|
|
81
|
+
js/
|
|
82
|
+
inline3d.js the SDK: createInline3D() → { addImage, addVideo, addScene }, feature-detect,
|
|
83
|
+
SBS buffer management, and a lazy create/close lifecycle for many windows
|
|
84
|
+
inline3d-three.js optional three.js helper (EyeCamera: off-axis projection from the session's eyes)
|
|
85
|
+
inline3d-viewer.js experimental: SceneViewer — framing, orbit, idle turntable, mono fallback
|
|
86
|
+
inline3d-splat.js experimental: addSplat() — a Gaussian splat window via Spark
|
|
87
|
+
docs/
|
|
88
|
+
authoring-inline-3d.md the authoring guide
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## The inline-3D model (under the SDK)
|
|
92
|
+
|
|
93
|
+
If you want the raw WebXR surface the SDK wraps, an inline-3d element:
|
|
94
|
+
|
|
95
|
+
1. `const session = await navigator.xr.requestSession('inline-3d')` — a sensorless inline session
|
|
96
|
+
(feature-detect by whether this resolves; falls back to plain 2D).
|
|
97
|
+
2. `const layer = new XRDisplayLayer(session, canvas)` — binds the weave to that element.
|
|
98
|
+
3. Each XR frame: render the scene as a **side-by-side stereo pair** into the canvas, re-projected
|
|
99
|
+
**off-axis** (asymmetric-frustum / Kooima) from the eye positions the session reports that frame —
|
|
100
|
+
so moving your head looks *around* the 3D content.
|
|
101
|
+
|
|
102
|
+
See the [WebXR inline-3D explainer](https://github.com/DisplayXR/displayxr-runtime/blob/main/docs/roadmap/webxr-displayxr-explainer.md).
|
|
103
|
+
|
|
104
|
+
## Local preview
|
|
105
|
+
|
|
106
|
+
Any static server, e.g. `python -m http.server 8080`, then open `http://localhost:8080/`.
|
|
107
|
+
(Loading over `file://` is fine for pure-2D, but WebXR requires a **secure context** — use
|
|
108
|
+
`http://localhost` or `https://`.)
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Type definitions for @displayxr/inline3d — the DisplayXR inline-3D SDK.
|
|
2
|
+
// Public 1.0 surface. See docs/sdk-stability.md for the semver contract.
|
|
3
|
+
|
|
4
|
+
/** Options shared by every add*() call. */
|
|
5
|
+
export interface TileOptions {
|
|
6
|
+
/** Per-eye buffer resolution in px (defaults to the CSS box × devicePixelRatio, dpr capped at 2). */
|
|
7
|
+
width?: number;
|
|
8
|
+
/** Per-eye buffer height in px (see `width`). */
|
|
9
|
+
height?: number;
|
|
10
|
+
/** Round each eye's corners, in BUFFER px (CSS radii can't cross the packed side-by-side pair). */
|
|
11
|
+
cornerRadius?: number;
|
|
12
|
+
/** Fade each eye's outer edges to transparent over this many buffer px. */
|
|
13
|
+
feather?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Extra options for {@link Inline3D.addScene}. */
|
|
17
|
+
export interface SceneOptions extends TileOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Metre height of the virtual display this scene is authored for (default 0.24). The runtime
|
|
20
|
+
* scales the eye poses it reports so the z=0 plane spans a display this tall — author in metres
|
|
21
|
+
* and render the reported views as-is. Halving it doubles how much of the window an object fills.
|
|
22
|
+
*/
|
|
23
|
+
virtualDisplayHeight?: number;
|
|
24
|
+
/** Element whose visibility drives the lazy create/close lifecycle (defaults to the canvas). */
|
|
25
|
+
observe?: Element;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The per-frame render callback passed to {@link Inline3D.addScene}. */
|
|
29
|
+
export type SceneFrameCallback = (
|
|
30
|
+
views: readonly XRView[],
|
|
31
|
+
layer: XRDisplayLayer,
|
|
32
|
+
frame: XRFrame,
|
|
33
|
+
) => void;
|
|
34
|
+
|
|
35
|
+
/** The handle returned by every add*() call. */
|
|
36
|
+
export interface TileHandle {
|
|
37
|
+
/** Remove this window: close its weave layer and stop driving it. */
|
|
38
|
+
remove(): void;
|
|
39
|
+
/**
|
|
40
|
+
* Mark a 2D element painted OVER this window so the weave leaves it crisp 2D instead of
|
|
41
|
+
* garbling it (browser#18). No-op on browsers without overlay exclusion.
|
|
42
|
+
*
|
|
43
|
+
* @deprecated Legacy-browser mechanism. A browser with draw-order occlusion
|
|
44
|
+
* ({@link inline3dOcclusionByDrawOrder}) composites 2D over woven 3D per-pixel with nothing
|
|
45
|
+
* declared, so the call is accepted and ignored there — harmless everywhere, and still
|
|
46
|
+
* needed on older DisplayXR Browsers. Keep it unless you ship to Phase-2 browsers only.
|
|
47
|
+
*/
|
|
48
|
+
exclude(el: Element): void;
|
|
49
|
+
/**
|
|
50
|
+
* Stop excluding `el` from this window's weave.
|
|
51
|
+
*
|
|
52
|
+
* @deprecated See {@link TileHandle.exclude} — no-op on browsers with draw-order occlusion.
|
|
53
|
+
*/
|
|
54
|
+
unexclude(el: Element): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An open inline-3D session you add weaved windows to. Returned by {@link createInline3D}. */
|
|
58
|
+
export interface Inline3D {
|
|
59
|
+
readonly supported: true;
|
|
60
|
+
/** The underlying WebXR session. */
|
|
61
|
+
readonly session: XRSession;
|
|
62
|
+
/** The reference space the eye poses are reported in (may be null if none could be acquired). */
|
|
63
|
+
readonly refSpace: XRReferenceSpace | null;
|
|
64
|
+
/** Number of currently-active (weaving) windows. */
|
|
65
|
+
readonly liveCount: number;
|
|
66
|
+
|
|
67
|
+
/** Weave a still side-by-side 3D photo from a URL or decoded image source. */
|
|
68
|
+
addImage(
|
|
69
|
+
canvas: HTMLCanvasElement,
|
|
70
|
+
source: string | HTMLImageElement | ImageBitmap | HTMLCanvasElement,
|
|
71
|
+
opts?: TileOptions,
|
|
72
|
+
): TileHandle;
|
|
73
|
+
|
|
74
|
+
/** Weave a side-by-side 3D video element (re-drawn each decoded frame). */
|
|
75
|
+
addVideo(
|
|
76
|
+
canvas: HTMLCanvasElement,
|
|
77
|
+
video: HTMLVideoElement,
|
|
78
|
+
opts?: TileOptions,
|
|
79
|
+
): TileHandle;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Weave a live-rendered stereo scene. Your callback receives the two eye views + the layer;
|
|
83
|
+
* render each `layer.getViewport(view)` into the canvas's SBS backing (three.js: see the
|
|
84
|
+
* `@displayxr/inline3d/three` helpers).
|
|
85
|
+
*/
|
|
86
|
+
addScene(
|
|
87
|
+
canvas: HTMLCanvasElement,
|
|
88
|
+
onFrame: SceneFrameCallback,
|
|
89
|
+
opts?: SceneOptions,
|
|
90
|
+
): TileHandle;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Register a PAGE-GLOBAL 2D overlay (a fixed/sticky header, a floating toolbar) excluded from
|
|
94
|
+
* EVERY window's weave and re-applied when a window lazily re-activates. Register once instead
|
|
95
|
+
* of calling {@link TileHandle.exclude} per tile. No-op without overlay exclusion (browser#18).
|
|
96
|
+
*
|
|
97
|
+
* @deprecated Legacy-browser mechanism. Where {@link inline3dOcclusionByDrawOrder} is true,
|
|
98
|
+
* page chrome occludes every tile by itself: the element is stored and nothing is done to it
|
|
99
|
+
* (no `will-change` promotion). Harmless everywhere; still required on older browsers.
|
|
100
|
+
*/
|
|
101
|
+
addGlobalOverlay(el: Element): void;
|
|
102
|
+
/**
|
|
103
|
+
* Stop treating `el` as a page-global overlay and drop it from every live window.
|
|
104
|
+
*
|
|
105
|
+
* @deprecated See {@link Inline3D.addGlobalOverlay} — no-op with draw-order occlusion.
|
|
106
|
+
*/
|
|
107
|
+
removeGlobalOverlay(el: Element): void;
|
|
108
|
+
|
|
109
|
+
/** Close the session and remove every window. */
|
|
110
|
+
close(): void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The shape {@link createInline3D} resolves to when inline-3D is unavailable. */
|
|
114
|
+
export interface Inline3DUnsupported {
|
|
115
|
+
supported: false;
|
|
116
|
+
error?: unknown;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Options for {@link createInline3D}. */
|
|
120
|
+
export interface CreateInline3DOptions {
|
|
121
|
+
/** WebXR reference space for the eye poses (default `"viewer"`). */
|
|
122
|
+
referenceSpace?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Create each window's weave layer only while it is (near-)visible and close it when it scrolls
|
|
125
|
+
* away, so a long wall only pays for what's on screen (default `true`). Set `false` for a single
|
|
126
|
+
* always-on element.
|
|
127
|
+
*/
|
|
128
|
+
lazy?: boolean;
|
|
129
|
+
/** IntersectionObserver margin for lazy mode (default `"50% 0px"`). */
|
|
130
|
+
rootMargin?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Auto-exclude page chrome (default `true`): sticky/fixed elements near the top of
|
|
133
|
+
* the DOM (headers, toolbars) are registered as page-global overlays automatically —
|
|
134
|
+
* the bar plus its text/replaced descendants — so woven windows scroll UNDER the
|
|
135
|
+
* chrome with no per-app wiring. Opt an element (and its subtree) out with
|
|
136
|
+
* `data-inline3d-no-overlay`; set `false` to manage chrome exclusively via
|
|
137
|
+
* `addGlobalOverlay()` / `data-inline3d-overlay`.
|
|
138
|
+
*
|
|
139
|
+
* Ignored where {@link inline3dOcclusionByDrawOrder} is true: nothing is scanned and the
|
|
140
|
+
* SDK never touches your DOM's `will-change`, because the chrome already occludes the tiles.
|
|
141
|
+
*/
|
|
142
|
+
autoChrome?: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The return of {@link startInline3D}. */
|
|
146
|
+
export interface StartInline3DResult {
|
|
147
|
+
supported: boolean;
|
|
148
|
+
/** The manager (present when supported). */
|
|
149
|
+
wall?: Inline3D;
|
|
150
|
+
/** The underlying WebXR session (present when supported). */
|
|
151
|
+
session?: XRSession;
|
|
152
|
+
/** Close the session (present when supported). */
|
|
153
|
+
close?: () => void;
|
|
154
|
+
error?: unknown;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Cheap, synchronous "can this browser even attempt inline-3D?" gate — true only in the DisplayXR
|
|
159
|
+
* Browser with the feature enabled. Use it to decide page UI up front.
|
|
160
|
+
*/
|
|
161
|
+
export function inline3DAvailable(): boolean;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* True when a 2D element painted ON a woven tile composites as crisp 2D over the woven 3D
|
|
165
|
+
* instead of being woven — by declaration (browser#18 overlay exclusion) or automatically
|
|
166
|
+
* ({@link inline3dOcclusionByDrawOrder}). Same answer on both generations, so it stays true on
|
|
167
|
+
* a draw-order-occlusion browser. Implies {@link inline3DAvailable}. Sync + cheap.
|
|
168
|
+
*/
|
|
169
|
+
export function inline3dOverlaySupported(): boolean;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* True when the browser occludes woven tiles with 2D content AUTOMATICALLY — anything that
|
|
173
|
+
* paints over a tile (header, badge, dropdown, translucent scrim) composites per-pixel by draw
|
|
174
|
+
* order, with nothing declared. When true this SDK's exclusion machinery is off: `autoChrome`
|
|
175
|
+
* does not scan, `data-inline3d-overlay` is not watched, and {@link TileHandle.exclude} /
|
|
176
|
+
* {@link Inline3D.addGlobalOverlay} are accepted but do nothing (no `will-change` promotion).
|
|
177
|
+
*
|
|
178
|
+
* You do not have to branch on it — the legacy calls are harmless where it is true and still
|
|
179
|
+
* required where it is false. Branch only to skip work of your own. Reads a readonly capability
|
|
180
|
+
* flag on `XRDisplayLayer`, never a version or UA string, and is `false` on any browser that
|
|
181
|
+
* does not expose the flag (the safe answer: the legacy path runs).
|
|
182
|
+
*/
|
|
183
|
+
export function inline3dOcclusionByDrawOrder(): boolean;
|
|
184
|
+
|
|
185
|
+
/** Open the page's inline-3D session and return a manager you add windows to. */
|
|
186
|
+
export function createInline3D(
|
|
187
|
+
opts?: CreateInline3DOptions,
|
|
188
|
+
): Promise<Inline3D | Inline3DUnsupported>;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Back-compatible single-scene helper: open a session, weave one canvas, drive a render callback
|
|
192
|
+
* each frame. Equivalent to `createInline3D({lazy:false})` then `addScene(canvas, onFrame)`.
|
|
193
|
+
*/
|
|
194
|
+
export function startInline3D(
|
|
195
|
+
canvas: HTMLCanvasElement,
|
|
196
|
+
opts?: {
|
|
197
|
+
onFrame?: SceneFrameCallback;
|
|
198
|
+
referenceSpace?: string;
|
|
199
|
+
virtualDisplayHeight?: number;
|
|
200
|
+
},
|
|
201
|
+
): Promise<StartInline3DResult>;
|
|
202
|
+
|
|
203
|
+
// XRDisplayLayer is a DisplayXR-Browser extension to WebXR; declare the minimum the SDK exposes.
|
|
204
|
+
export interface XRDisplayLayer {
|
|
205
|
+
getViewport(view: XRView): { x: number; y: number; width: number; height: number } | null;
|
|
206
|
+
/**
|
|
207
|
+
* @deprecated Legacy-browser overlay exclusion (browser#18). Present-but-no-op on a browser
|
|
208
|
+
* with draw-order occlusion, which is exactly why its presence cannot be used to detect the
|
|
209
|
+
* generation — use {@link inline3dOcclusionByDrawOrder} (i.e. `occlusionByDrawOrder`).
|
|
210
|
+
*/
|
|
211
|
+
excludeElement?(el: Element): void;
|
|
212
|
+
/** @deprecated See {@link XRDisplayLayer.excludeElement}. */
|
|
213
|
+
unexcludeElement?(el: Element): void;
|
|
214
|
+
/**
|
|
215
|
+
* Readonly capability flag: `true` when this browser composites 2D over woven 3D per-pixel by
|
|
216
|
+
* draw order, making overlay exclusion unnecessary. Optional because it is absent on every
|
|
217
|
+
* browser shipped so far — the SDK treats absent as `false` and runs the legacy path.
|
|
218
|
+
*/
|
|
219
|
+
readonly occlusionByDrawOrder?: boolean;
|
|
220
|
+
close(): void;
|
|
221
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// inline3d-model.js — a glTF/GLB model as an inline-3D window, in one call.
|
|
2
|
+
//
|
|
3
|
+
// EXPERIMENTAL. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
|
|
4
|
+
//
|
|
5
|
+
// import { createInline3D } from '@displayxr/inline3d';
|
|
6
|
+
// import { addModel } from '@displayxr/inline3d/model';
|
|
7
|
+
//
|
|
8
|
+
// const wall = await createInline3D();
|
|
9
|
+
// const lamp = addModel(wall, canvas, 'lamp.glb', { virtualDisplayHeight: 0.3 });
|
|
10
|
+
// lamp.exclude(document.getElementById('buy'));
|
|
11
|
+
//
|
|
12
|
+
// Deliberately the same options, the same handle and the same framing behaviour as ./splat, so a
|
|
13
|
+
// catalogue can switch a product between a captured splat and a vendor mesh by changing one word.
|
|
14
|
+
// That symmetry is the point: retailers already hold glTF for a slice of their catalogue, and
|
|
15
|
+
// rendering those unchanged is a far stronger claim than "re-capture everything".
|
|
16
|
+
//
|
|
17
|
+
// Requires `three` as a peer, and resolves GLTFLoader from `three/addons/`. That mapping is
|
|
18
|
+
// already mandatory for anyone using ./splat (Spark reaches into three/addons internally), so
|
|
19
|
+
// this adds no new requirement — but on a bare importmap it must be declared:
|
|
20
|
+
//
|
|
21
|
+
// "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
|
|
22
|
+
//
|
|
23
|
+
// You can also hand the class in directly (`opts.GLTFLoader`) and skip the specifier entirely.
|
|
24
|
+
|
|
25
|
+
import * as THREE from 'three';
|
|
26
|
+
import { EyeCamera, EdgeFeather } from './inline3d-three.js';
|
|
27
|
+
import { SceneViewer } from './inline3d-viewer.js';
|
|
28
|
+
|
|
29
|
+
/** Cached across calls so a grid of models resolves the loader module once. */
|
|
30
|
+
let _GLTFLoader = null;
|
|
31
|
+
|
|
32
|
+
async function resolveLoader(injected) {
|
|
33
|
+
if (injected) return injected;
|
|
34
|
+
if (!_GLTFLoader) {
|
|
35
|
+
const mod = await import('three/addons/loaders/GLTFLoader.js');
|
|
36
|
+
_GLTFLoader = mod.GLTFLoader;
|
|
37
|
+
}
|
|
38
|
+
return _GLTFLoader;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load a glTF/GLB into an inline-3D window.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} wall the manager from createInline3D(), supported or not.
|
|
45
|
+
* @param {HTMLCanvasElement} canvas
|
|
46
|
+
* @param {string} src URL of a .glb / .gltf.
|
|
47
|
+
* @param {object} [opts] every option ./splat takes, plus:
|
|
48
|
+
* @param {'studio'|'none'} [opts.environment='studio'] built-in three-point lighting. Meshes
|
|
49
|
+
* arrive unlit otherwise — unlike splats, which carry their own baked appearance.
|
|
50
|
+
* @param {object} [opts.envMap] a PMREM-processed environment texture, if you have one. Better
|
|
51
|
+
* than `environment` for metal and glass; overrides it.
|
|
52
|
+
* @param {unknown} [opts.GLTFLoader] hand in the class instead of resolving `three/addons/`.
|
|
53
|
+
* @returns {object} the same handle shape as addSplat: a TileHandle plus `viewer`, `model`,
|
|
54
|
+
* `setPose`, `resetPose`, `frame`, and `ready`.
|
|
55
|
+
*/
|
|
56
|
+
export function addModel(wall, canvas, src, opts = {}) {
|
|
57
|
+
const {
|
|
58
|
+
virtualDisplayHeight = 0.24,
|
|
59
|
+
frame = null,
|
|
60
|
+
idleSpin = 8,
|
|
61
|
+
orbit = true,
|
|
62
|
+
fit = 'contain',
|
|
63
|
+
margin = 0.8,
|
|
64
|
+
depthLimit = 4.0,
|
|
65
|
+
fitSweep = true,
|
|
66
|
+
renderScale = 1,
|
|
67
|
+
feather = 0,
|
|
68
|
+
environment = 'studio',
|
|
69
|
+
envMap = null,
|
|
70
|
+
GLTFLoader: injectedLoader = null,
|
|
71
|
+
observe,
|
|
72
|
+
} = opts;
|
|
73
|
+
|
|
74
|
+
const viewer = new SceneViewer(THREE, canvas, {
|
|
75
|
+
virtualDisplayHeight,
|
|
76
|
+
fit,
|
|
77
|
+
margin,
|
|
78
|
+
depthLimit,
|
|
79
|
+
fitSweep,
|
|
80
|
+
orbit,
|
|
81
|
+
idleSpin,
|
|
82
|
+
renderScale,
|
|
83
|
+
feather,
|
|
84
|
+
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
85
|
+
|
|
86
|
+
if (envMap) viewer.scene.environment = envMap;
|
|
87
|
+
else if (environment === 'studio') addStudioLights(viewer.scene);
|
|
88
|
+
|
|
89
|
+
const out = {
|
|
90
|
+
viewer,
|
|
91
|
+
model: null,
|
|
92
|
+
frame: null,
|
|
93
|
+
setPose: (p) => viewer.setPose(p),
|
|
94
|
+
resetPose: () => viewer.resetPose(),
|
|
95
|
+
remove() {
|
|
96
|
+
handle?.remove();
|
|
97
|
+
viewer.dispose();
|
|
98
|
+
if (out.model) disposeTree(out.model);
|
|
99
|
+
},
|
|
100
|
+
exclude: (el) => handle?.exclude(el),
|
|
101
|
+
unexclude: (el) => handle?.unexclude(el),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Window first, content when it lands — same reasoning as ./splat: a grid should not appear
|
|
105
|
+
// one tile at a time in download order.
|
|
106
|
+
let handle = null;
|
|
107
|
+
if (wall && wall.supported) {
|
|
108
|
+
handle = wall.addScene(canvas, viewer.onFrame, {
|
|
109
|
+
virtualDisplayHeight,
|
|
110
|
+
...(observe ? { observe } : {}),
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
viewer.startMono();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
out.ready = (async () => {
|
|
117
|
+
const Loader = await resolveLoader(injectedLoader);
|
|
118
|
+
const gltf = await new Loader().loadAsync(src);
|
|
119
|
+
out.model = gltf.scene;
|
|
120
|
+
viewer.content.add(gltf.scene);
|
|
121
|
+
|
|
122
|
+
// Meshes have exact bounds, so unlike a splat there is nothing to be robust ABOUT: no
|
|
123
|
+
// percentile trim, no flood-fill, no sidecar needed. Box3 is the whole story.
|
|
124
|
+
const bounds = frame || boundsOf(gltf.scene);
|
|
125
|
+
if (bounds) {
|
|
126
|
+
out.frame = bounds;
|
|
127
|
+
viewer.fitTo(bounds.center, bounds.extent);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
})().catch((err) => {
|
|
131
|
+
console.warn('[inline3d/model] failed to load', src, err);
|
|
132
|
+
throw err;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Exact model-space bounds of an object tree. */
|
|
139
|
+
function boundsOf(object3d) {
|
|
140
|
+
const box = new THREE.Box3().setFromObject(object3d);
|
|
141
|
+
if (!isFinite(box.min.x) || box.isEmpty()) return null;
|
|
142
|
+
const c = box.getCenter(new THREE.Vector3());
|
|
143
|
+
const e = box.getSize(new THREE.Vector3());
|
|
144
|
+
return { center: [c.x, c.y, c.z], extent: [Math.max(e.x, 1e-6), Math.max(e.y, 1e-6), Math.max(e.z, 1e-6)] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A neutral three-point rig. Not a substitute for a real environment map on metal or glass, but
|
|
149
|
+
* it has no external dependency and no download, which matters for a tile that may be one of
|
|
150
|
+
* several on a page.
|
|
151
|
+
*/
|
|
152
|
+
function addStudioLights(scene) {
|
|
153
|
+
const key = new THREE.DirectionalLight(0xffffff, 2.2);
|
|
154
|
+
key.position.set(1, 1.4, 1.6);
|
|
155
|
+
const fill = new THREE.DirectionalLight(0xffffff, 0.7);
|
|
156
|
+
fill.position.set(-1.4, 0.4, 0.8);
|
|
157
|
+
const rim = new THREE.DirectionalLight(0xffffff, 1.0);
|
|
158
|
+
rim.position.set(-0.4, 0.8, -1.6);
|
|
159
|
+
scene.add(key, fill, rim, new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function disposeTree(root) {
|
|
163
|
+
root.traverse((o) => {
|
|
164
|
+
o.geometry?.dispose?.();
|
|
165
|
+
const m = o.material;
|
|
166
|
+
if (Array.isArray(m)) m.forEach((x) => x?.dispose?.());
|
|
167
|
+
else m?.dispose?.();
|
|
168
|
+
});
|
|
169
|
+
}
|