@displayxr/inline3d 1.0.0 → 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 +10 -1
- package/index.d.ts +62 -5
- package/js/inline3d-model.js +169 -0
- package/js/inline3d-splat.js +333 -0
- package/js/inline3d-viewer.js +553 -0
- package/js/inline3d.js +628 -19
- package/model.d.ts +64 -0
- package/package.json +25 -2
- package/splat.d.ts +91 -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
|
@@ -23,13 +23,19 @@ npm install @displayxr/inline3d
|
|
|
23
23
|
```js
|
|
24
24
|
import { createInline3D } from '@displayxr/inline3d';
|
|
25
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
|
|
26
28
|
```
|
|
27
29
|
|
|
28
30
|
No build step or bundler required — it's plain ES modules. You can also import a pinned version by
|
|
29
31
|
URL from a CDN (jsDelivr / unpkg) without npm. The samples in this repo import the SDK by relative
|
|
30
32
|
path (`./js/inline3d.js`) so they run straight off GitHub Pages; in your own app prefer the package.
|
|
31
33
|
|
|
32
|
-
`three`
|
|
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.
|
|
33
39
|
|
|
34
40
|
Stability & what's covered by semver (and the deferred N-view / web-components / CSS-native roadmap
|
|
35
41
|
that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stability.md).
|
|
@@ -71,10 +77,13 @@ index.html landing (Pages entry point)
|
|
|
71
77
|
samples/
|
|
72
78
|
windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
|
|
73
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
|
|
74
81
|
js/
|
|
75
82
|
inline3d.js the SDK: createInline3D() → { addImage, addVideo, addScene }, feature-detect,
|
|
76
83
|
SBS buffer management, and a lazy create/close lifecycle for many windows
|
|
77
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
|
|
78
87
|
docs/
|
|
79
88
|
authoring-inline-3d.md the authoring guide
|
|
80
89
|
```
|
package/index.d.ts
CHANGED
|
@@ -39,9 +39,18 @@ export interface TileHandle {
|
|
|
39
39
|
/**
|
|
40
40
|
* Mark a 2D element painted OVER this window so the weave leaves it crisp 2D instead of
|
|
41
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.
|
|
42
47
|
*/
|
|
43
48
|
exclude(el: Element): void;
|
|
44
|
-
/**
|
|
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
|
+
*/
|
|
45
54
|
unexclude(el: Element): void;
|
|
46
55
|
}
|
|
47
56
|
|
|
@@ -84,9 +93,17 @@ export interface Inline3D {
|
|
|
84
93
|
* Register a PAGE-GLOBAL 2D overlay (a fixed/sticky header, a floating toolbar) excluded from
|
|
85
94
|
* EVERY window's weave and re-applied when a window lazily re-activates. Register once instead
|
|
86
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.
|
|
87
100
|
*/
|
|
88
101
|
addGlobalOverlay(el: Element): void;
|
|
89
|
-
/**
|
|
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
|
+
*/
|
|
90
107
|
removeGlobalOverlay(el: Element): void;
|
|
91
108
|
|
|
92
109
|
/** Close the session and remove every window. */
|
|
@@ -111,6 +128,18 @@ export interface CreateInline3DOptions {
|
|
|
111
128
|
lazy?: boolean;
|
|
112
129
|
/** IntersectionObserver margin for lazy mode (default `"50% 0px"`). */
|
|
113
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;
|
|
114
143
|
}
|
|
115
144
|
|
|
116
145
|
/** The return of {@link startInline3D}. */
|
|
@@ -132,12 +161,27 @@ export interface StartInline3DResult {
|
|
|
132
161
|
export function inline3DAvailable(): boolean;
|
|
133
162
|
|
|
134
163
|
/**
|
|
135
|
-
* True when
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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.
|
|
138
168
|
*/
|
|
139
169
|
export function inline3dOverlaySupported(): boolean;
|
|
140
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
|
+
|
|
141
185
|
/** Open the page's inline-3D session and return a manager you add windows to. */
|
|
142
186
|
export function createInline3D(
|
|
143
187
|
opts?: CreateInline3DOptions,
|
|
@@ -159,6 +203,19 @@ export function startInline3D(
|
|
|
159
203
|
// XRDisplayLayer is a DisplayXR-Browser extension to WebXR; declare the minimum the SDK exposes.
|
|
160
204
|
export interface XRDisplayLayer {
|
|
161
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
|
+
*/
|
|
162
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;
|
|
163
220
|
close(): void;
|
|
164
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
|
+
}
|