@displayxr/inline3d 1.9.0 → 1.10.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 +69 -0
- package/README.md +6 -0
- package/index.d.ts +36 -0
- package/js/inline3d-model.js +4 -0
- package/js/inline3d-splat-playcanvas.js +91 -4
- package/js/inline3d-splat.js +13 -0
- package/js/inline3d.js +110 -0
- package/model.d.ts +8 -0
- package/package.json +2 -1
- package/splat.d.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,75 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
|
|
|
5
5
|
`./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
|
|
6
6
|
which tier they touch, because that is what tells you whether an upgrade can move your pixels.
|
|
7
7
|
|
|
8
|
+
## 1.10.0 — 2026-09-23
|
|
9
|
+
|
|
10
|
+
Touches the **core tier** (`.`), additively. The frozen 1.x surface gains one handle member and
|
|
11
|
+
one option, and nothing that exists changes behaviour. The preview subpaths (`./splat`,
|
|
12
|
+
`./model`) forward the new member. No pixels move for any page that does not read it.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`handle.firstWoven`**: a promise that tells the page when it is safe to reveal a woven canvas
|
|
17
|
+
(core tier; web#36 follow-up).
|
|
18
|
+
- It resolves once and never rejects, with `{ woven, confirmed, reason, ms }`.
|
|
19
|
+
- `woven: true` means a real stereo frame is on a layer that has existed for
|
|
20
|
+
`firstWovenHoldMs`.
|
|
21
|
+
- `woven: false` (`'layer-failed'` / `'session-ended'` / `'removed'`) means the window will not
|
|
22
|
+
weave. The canvas is already flat, or the scene's `onLayerLost` has already run.
|
|
23
|
+
- `onFirstWoven(cb)` is the callback form.
|
|
24
|
+
- It replaces the worst-case `setTimeout` that pages kept to hide the raw side-by-side pair a
|
|
25
|
+
fresh canvas shows until the browser's compositor joins it.
|
|
26
|
+
- **`firstWovenHoldMs`** on every `add*()` (default **1200**, the browser's measured worst case
|
|
27
|
+
for a canvas that is fresh to its compositor).
|
|
28
|
+
- **It is approximate, by design, and says so.** No browser reports the join yet (checked
|
|
29
|
+
against the browser's JavaScript surface: the verdict exists only as a compositor log line). So
|
|
30
|
+
`confirmed` is always `false` and the result is the hold. When a browser reports joins it
|
|
31
|
+
becomes `confirmed: true` and earlier, with no change to the page. The browser ask is
|
|
32
|
+
[`docs/proposals/layer-joined-signal.md`](docs/proposals/layer-joined-signal.md).
|
|
33
|
+
- `addSplat` (both engines) and `addModel` handles carry `firstWoven` and accept
|
|
34
|
+
`firstWovenHoldMs` (preview tier). With no inline-3D session they resolve
|
|
35
|
+
`{ woven: false, reason: 'unsupported' }` at once.
|
|
36
|
+
- Docs: **[`docs/woven-canvas-rules.md`](docs/woven-canvas-rules.md)**: eight rules for never
|
|
37
|
+
showing a raw side-by-side frame. Each rule comes with its reason and the SDK call that
|
|
38
|
+
satisfies it, plus a hardware checklist keyed on the browser's
|
|
39
|
+
`withheld … ids=[<token>=<why>@<rect>]` log line. There is a summary section in the authoring
|
|
40
|
+
guide, and links from the porting guide and the README.
|
|
41
|
+
- It corrects two claims in circulation. **No shipping browser draws a flat frame instead of
|
|
42
|
+
the raw pair** for a fresh canvas: that fallback was measured and not shipped. And the
|
|
43
|
+
`withheld` line is logged at error level with throttling, so a missing line proves nothing.
|
|
44
|
+
|
|
45
|
+
## 1.9.1 — 2026-09-23
|
|
46
|
+
|
|
47
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), plus the package manifest.
|
|
48
|
+
Nothing changes for Spark callers or for splat-only pages. The fixes affect only what a page can
|
|
49
|
+
do with `handle.engine`.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **`handle.engine.root` is usable as documented.** The tile's `AppBase` now registers `Render`,
|
|
54
|
+
`Light` and `Anim` component systems and a `Container` resource handler, next to `Camera`,
|
|
55
|
+
`GSplat` and `Texture`/`GSplat`. That is exactly what a glTF needs, skinned and animated
|
|
56
|
+
included. Before, a page had to register them itself or `instantiateRenderEntity()` produced
|
|
57
|
+
nothing.
|
|
58
|
+
- A page that still registers them now gets the existing system back instead of an engine
|
|
59
|
+
"already registered" throw.
|
|
60
|
+
- Cost: +0.1 ms boot (`AppBase.init` 0.4 → 0.5 ms). +41.8 KB gzip only for a hand-tree-shaken
|
|
61
|
+
engine build; nothing for the SDK as shipped.
|
|
62
|
+
- Verified with a skinned, animated `.glb` over a splat: composited, depth-tested against nearer
|
|
63
|
+
splats, animation advancing.
|
|
64
|
+
- **`"./package.json"` is exported**, so `import pkg from '@displayxr/inline3d/package.json'` and
|
|
65
|
+
`require.resolve('@displayxr/inline3d/package.json')` work instead of
|
|
66
|
+
`ERR_PACKAGE_PATH_NOT_EXPORTED`.
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- **`nearClip` / `farClip`** (PlayCanvas): a floor on the projection's near plane and a cap on its
|
|
71
|
+
far plane, for depth precision in a mixed mesh + splat scene. The adapter still owns the
|
|
72
|
+
projections, and unset leaves them untouched.
|
|
73
|
+
- Docs: `handle.engine` in [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md). Covers what
|
|
74
|
+
is registered, the cost, and two engine gotchas (lights shine along local −Y; use `AnimTrack.name`
|
|
75
|
+
with `assignAnimation`).
|
|
76
|
+
|
|
8
77
|
## 1.9.0 — 2026-09-23
|
|
9
78
|
|
|
10
79
|
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive: nothing changes for
|
package/README.md
CHANGED
|
@@ -110,6 +110,9 @@ No projection math lands in your page or in the SDK — the off-axis frustum sta
|
|
|
110
110
|
> `createInline3D()` detects by actually acquiring a session, which is authoritative.
|
|
111
111
|
|
|
112
112
|
Full API + authoring guidance: [`docs/authoring-inline-3d.md`](docs/authoring-inline-3d.md).
|
|
113
|
+
Before you ship a page that navigates or remounts, read
|
|
114
|
+
[`docs/woven-canvas-rules.md`](docs/woven-canvas-rules.md): how to avoid a raw side-by-side
|
|
115
|
+
flash, and releasing a poster on `handle.firstWoven`.
|
|
113
116
|
Three.js glue (an off-axis `EyeCamera`) in [`js/inline3d-three.js`](js/inline3d-three.js).
|
|
114
117
|
|
|
115
118
|
## What's here
|
|
@@ -145,6 +148,9 @@ docs/
|
|
|
145
148
|
that is not the API
|
|
146
149
|
porting-three-js-apps.md porting an existing three.js app (WebXR or plain) to inline 3D —
|
|
147
150
|
the WebXR→inline-3d mapping table and the whole render loop
|
|
151
|
+
woven-canvas-rules.md never show a raw side-by-side frame: the join, the eight rules,
|
|
152
|
+
handle.firstWoven, reading the browser's `withheld` log line
|
|
153
|
+
proposals/ browser-side asks the SDK is waiting on
|
|
148
154
|
```
|
|
149
155
|
|
|
150
156
|
## The inline-3D model (under the SDK)
|
package/index.d.ts
CHANGED
|
@@ -11,6 +11,34 @@ export interface TileOptions {
|
|
|
11
11
|
cornerRadius?: number;
|
|
12
12
|
/** Fade each eye's outer edges to transparent over this many buffer px. */
|
|
13
13
|
feather?: number;
|
|
14
|
+
/**
|
|
15
|
+
* How long, in ms, this window's layer must have existed (and carried a stereo frame) before
|
|
16
|
+
* {@link TileHandle.firstWoven} resolves `woven: true`. Default 1200 — the browser's measured
|
|
17
|
+
* worst case for joining a canvas that is fresh to its compositor. Lower it only for a canvas
|
|
18
|
+
* you know is not fresh; 0 means "the first stereo frame".
|
|
19
|
+
*/
|
|
20
|
+
firstWovenHoldMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What {@link TileHandle.firstWoven} resolves to. Settles once and never rejects.
|
|
25
|
+
*
|
|
26
|
+
* - `woven: true, reason: 'hold-elapsed'` — a stereo frame is on a layer that has existed for
|
|
27
|
+
* `firstWovenHoldMs`. Drop the poster covering the canvas.
|
|
28
|
+
* - `woven: false` — the window will not weave (`'layer-failed'`, `'session-ended'`,
|
|
29
|
+
* `'removed'`; the subpaths add `'unsupported'`). The canvas is already flat (image/video) or
|
|
30
|
+
* its `onLayerLost` has run (scene). Drop the poster onto the 2D fallback.
|
|
31
|
+
*/
|
|
32
|
+
export interface FirstWovenResult {
|
|
33
|
+
readonly woven: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* `true` only when the BROWSER reported the join. Always `false` today: no browser exposes
|
|
36
|
+
* that, so the result is the SDK's worst-case hold rather than a report.
|
|
37
|
+
*/
|
|
38
|
+
readonly confirmed: boolean;
|
|
39
|
+
readonly reason: 'hold-elapsed' | 'layer-failed' | 'session-ended' | 'removed' | 'unsupported';
|
|
40
|
+
/** Milliseconds from the add*() call to settling. */
|
|
41
|
+
readonly ms: number;
|
|
14
42
|
}
|
|
15
43
|
|
|
16
44
|
/**
|
|
@@ -306,6 +334,14 @@ export interface TileHandle {
|
|
|
306
334
|
* Scene windows only — image/video windows always report `{ frames: 0, monoFrames: 0 }`.
|
|
307
335
|
*/
|
|
308
336
|
stats(): { frames: number; monoFrames: number };
|
|
337
|
+
/**
|
|
338
|
+
* Resolves once, when it is safe to reveal this canvas: see {@link FirstWovenResult}. THE way to
|
|
339
|
+
* release a poster held over a woven canvas — `await Promise.all([ready, handle.firstWoven])`
|
|
340
|
+
* and cut, never fade. Approximate until a browser reports joins (`confirmed` stays `false`).
|
|
341
|
+
*/
|
|
342
|
+
readonly firstWoven: Promise<FirstWovenResult>;
|
|
343
|
+
/** Callback form of {@link TileHandle.firstWoven}: called once, asynchronously. Returns an unsubscribe. */
|
|
344
|
+
onFirstWoven(cb: (result: FirstWovenResult) => void): () => void;
|
|
309
345
|
}
|
|
310
346
|
|
|
311
347
|
/** An open inline-3D session you add weaved windows to. Returned by {@link createInline3D}. */
|
package/js/inline3d-model.js
CHANGED
|
@@ -363,6 +363,7 @@ export function addModel(wall, canvas, src, opts = {}) {
|
|
|
363
363
|
KTX2Loader: injectedKtx2 = null,
|
|
364
364
|
meshoptDecoder: injectedMeshopt = null,
|
|
365
365
|
observe,
|
|
366
|
+
firstWovenHoldMs,
|
|
366
367
|
} = opts;
|
|
367
368
|
|
|
368
369
|
const paths = normalizeDecoderPath(decoderPath);
|
|
@@ -413,10 +414,13 @@ export function addModel(wall, canvas, src, opts = {}) {
|
|
|
413
414
|
// canvas flat rather than leave its last side-by-side frame on the page (web#28).
|
|
414
415
|
onLayerLost: viewer.onLayerLost,
|
|
415
416
|
...(observe ? { observe } : {}),
|
|
417
|
+
...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
|
|
416
418
|
});
|
|
417
419
|
} else {
|
|
418
420
|
viewer.startMono();
|
|
419
421
|
}
|
|
422
|
+
// The core handle's `firstWoven`, forwarded; a page with no session is told so at once.
|
|
423
|
+
out.firstWoven = handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 }));
|
|
420
424
|
|
|
421
425
|
out.ready = (async () => {
|
|
422
426
|
const Loader = await resolveLoader(injectedLoader);
|
|
@@ -84,6 +84,54 @@ import {
|
|
|
84
84
|
streamedBytesError,
|
|
85
85
|
} from './inline3d-splat-shared.js';
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The component systems the tile's `AppBase` registers. Camera + GSplat draw the splat; Render,
|
|
89
|
+
* Light and Anim are what a page needs to put a glTF — skinned and animated included — and its
|
|
90
|
+
* lights under `handle.engine.root` (1.9.1; before that the page had to register them itself).
|
|
91
|
+
* Nothing else from the engine's full `Application` list (physics, UI, audio, particles, scripts
|
|
92
|
+
* and so on are the page's own business if it wants them).
|
|
93
|
+
*/
|
|
94
|
+
export const PLAYCANVAS_SYSTEMS = Object.freeze([
|
|
95
|
+
'CameraComponentSystem',
|
|
96
|
+
'GSplatComponentSystem',
|
|
97
|
+
'RenderComponentSystem',
|
|
98
|
+
'LightComponentSystem',
|
|
99
|
+
'AnimComponentSystem',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The resource handlers the tile's loader registers. Texture + GSplat load a splat (a bundled
|
|
104
|
+
* .sog is a zip of webp planes the loader registers as textures); Container loads a .glb/.gltf.
|
|
105
|
+
* The container's sub-assets (render, material, animation) arrive already loaded, so they need no
|
|
106
|
+
* handler of their own.
|
|
107
|
+
*/
|
|
108
|
+
export const PLAYCANVAS_HANDLERS = Object.freeze(['TextureHandler', 'GSplatHandler', 'ContainerHandler']);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Registering a component system the app already has THROWS in the engine ("already
|
|
112
|
+
* registered"). Pages written before 1.9.1 add Render/Light/Anim themselves, so a second `add` of
|
|
113
|
+
* an id that exists is made a no-op that returns the registered system (the duplicate the page
|
|
114
|
+
* constructed is destroyed, so it leaves no listeners behind).
|
|
115
|
+
*/
|
|
116
|
+
function guardDuplicateSystems(app) {
|
|
117
|
+
const reg = app.systems;
|
|
118
|
+
if (!reg || typeof reg.add !== 'function' || reg._dxrGuarded) return;
|
|
119
|
+
const add = reg.add.bind(reg);
|
|
120
|
+
reg.add = (system) => {
|
|
121
|
+
const existing = system?.id ? reg[system.id] : null;
|
|
122
|
+
if (existing && existing !== system) {
|
|
123
|
+
try {
|
|
124
|
+
system.destroy?.();
|
|
125
|
+
} catch {
|
|
126
|
+
/* a half-built duplicate: nothing to release */
|
|
127
|
+
}
|
|
128
|
+
return existing;
|
|
129
|
+
}
|
|
130
|
+
return add(system);
|
|
131
|
+
};
|
|
132
|
+
reg._dxrGuarded = true;
|
|
133
|
+
}
|
|
134
|
+
|
|
87
135
|
/** The engine release this adapter was built and measured against (npm peer floor). */
|
|
88
136
|
export const PLAYCANVAS_TESTED = '2.22.3';
|
|
89
137
|
|
|
@@ -138,6 +186,24 @@ export function frustumFromProjection(P) {
|
|
|
138
186
|
};
|
|
139
187
|
}
|
|
140
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Raise a perspective projection's near plane to at least `nearFloor` and lower its far plane to
|
|
191
|
+
* at most `farCap`, IN PLACE, leaving the frustum's shape (fov, skew, principal point) untouched —
|
|
192
|
+
* only the depth mapping (P[10], P[14]) is rewritten. Idempotent. A null bound is left alone.
|
|
193
|
+
*/
|
|
194
|
+
export function clampProjectionDepth(P, nearFloor, farCap) {
|
|
195
|
+
const f0 = frustumFromProjection(P);
|
|
196
|
+
let n = f0.nearClip;
|
|
197
|
+
let f = f0.farClip;
|
|
198
|
+
if (nearFloor !== null && nearFloor !== undefined && nearFloor > n) n = nearFloor;
|
|
199
|
+
if (farCap !== null && farCap !== undefined && farCap < f) f = farCap;
|
|
200
|
+
if (!(f > n)) f = n * 1.0001 + 1e-6;
|
|
201
|
+
if (n === f0.nearClip && f === f0.farClip) return P;
|
|
202
|
+
P[10] = -(f + n) / (f - n);
|
|
203
|
+
P[14] = (-2 * f * n) / (f - n);
|
|
204
|
+
return P;
|
|
205
|
+
}
|
|
206
|
+
|
|
141
207
|
/** Rigid pose (position + unit quaternion xyzw) as a column-major 4×4. */
|
|
142
208
|
export function poseMatrix(p, q, out = new Float64Array(16)) {
|
|
143
209
|
const [x, y, z, w] = q;
|
|
@@ -467,8 +533,15 @@ export class PlayCanvasSplatViewer {
|
|
|
467
533
|
orbitEase = {},
|
|
468
534
|
feather = 0,
|
|
469
535
|
captureFit = 'height',
|
|
536
|
+
nearClip,
|
|
537
|
+
farClip,
|
|
470
538
|
} = opts;
|
|
471
539
|
this.canvas = canvas;
|
|
540
|
+
// Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
|
|
541
|
+
// other; splats only test against them). The projections' own near/far stay the adapter's —
|
|
542
|
+
// these only raise the near (floor) and lower the far (cap). Unset: untouched.
|
|
543
|
+
this.nearClip = Number.isFinite(nearClip) && nearClip > 0 ? nearClip : null;
|
|
544
|
+
this.farClip = Number.isFinite(farClip) && farClip > 0 ? farClip : null;
|
|
472
545
|
// The tilt-and-relax orbit (./inline3d-splat-shared.js §ORBIT): drag tilts up to ±orbitMaxDeg
|
|
473
546
|
// from where the press started, easing with τ = orbitEase.drag; release relaxes back with
|
|
474
547
|
// τ = orbitEase.rest. `_orbitMode` is 'drag' | 'rest' | null (null = ordinary damping).
|
|
@@ -784,12 +857,14 @@ export class PlayCanvasSplatViewer {
|
|
|
784
857
|
const opts = new pc.AppOptions();
|
|
785
858
|
opts.graphicsDevice = device;
|
|
786
859
|
// No xr (AppBase constructs XrManager only when asked, and XrManager is what probes and
|
|
787
|
-
// can request immersive sessions), no mouse/keyboard/touch: the SDK owns input.
|
|
788
|
-
|
|
789
|
-
//
|
|
790
|
-
opts.
|
|
860
|
+
// can request immersive sessions), no mouse/keyboard/touch: the SDK owns input. Beyond the
|
|
861
|
+
// splat itself, exactly what a glTF-with-animation under `handle.engine.root` needs — see
|
|
862
|
+
// PLAYCANVAS_SYSTEMS / PLAYCANVAS_HANDLERS.
|
|
863
|
+
opts.componentSystems = PLAYCANVAS_SYSTEMS.map((n) => pc[n]).filter(Boolean);
|
|
864
|
+
opts.resourceHandlers = PLAYCANVAS_HANDLERS.map((n) => pc[n]).filter(Boolean);
|
|
791
865
|
const app = new pc.AppBase(this.canvas);
|
|
792
866
|
app.init(opts);
|
|
867
|
+
guardDuplicateSystems(app);
|
|
793
868
|
// The SDK sizes the buffer (double-width in 3D, 1:1 in mono), so the engine must never
|
|
794
869
|
// resize it. RESOLUTION_FIXED is AppBase's DEFAULT, and with it `updateCanvasSize()` is a
|
|
795
870
|
// no-op. Deliberately NOT calling setCanvasResolution/setCanvasFillMode: without explicit
|
|
@@ -1047,6 +1122,9 @@ export class PlayCanvasSplatViewer {
|
|
|
1047
1122
|
const el = this.canvas;
|
|
1048
1123
|
const sx = cache && cache.bufW > 0 && el.width ? el.width / cache.bufW : 1;
|
|
1049
1124
|
const sy = cache && cache.bufH > 0 && el.height ? el.height / cache.bufH : 1;
|
|
1125
|
+
if (this.nearClip !== null || this.farClip !== null) {
|
|
1126
|
+
for (const e of entries) clampProjectionDepth(e.proj, this.nearClip, this.farClip);
|
|
1127
|
+
}
|
|
1050
1128
|
const rect = (e) =>
|
|
1051
1129
|
sx !== 1 || sy !== 1
|
|
1052
1130
|
? [Math.round(e.x * sx), Math.round(e.y * sy), Math.max(1, Math.round(e.width * sx)), Math.max(1, Math.round(e.height * sy))]
|
|
@@ -1607,6 +1685,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1607
1685
|
captureFit = 'height',
|
|
1608
1686
|
focusInput = true,
|
|
1609
1687
|
observe,
|
|
1688
|
+
firstWovenHoldMs,
|
|
1610
1689
|
preserveDrawingBuffer = false,
|
|
1611
1690
|
} = opts;
|
|
1612
1691
|
// `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
|
|
@@ -1627,6 +1706,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1627
1706
|
orbitEase: opts.orbitEase,
|
|
1628
1707
|
feather,
|
|
1629
1708
|
captureFit,
|
|
1709
|
+
nearClip: opts.nearClip,
|
|
1710
|
+
farClip: opts.farClip,
|
|
1630
1711
|
});
|
|
1631
1712
|
|
|
1632
1713
|
let handle = null;
|
|
@@ -1693,10 +1774,16 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1693
1774
|
virtualDisplayHeight,
|
|
1694
1775
|
onLayerLost: viewer.onLayerLost,
|
|
1695
1776
|
...(observe ? { observe } : {}),
|
|
1777
|
+
...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
|
|
1696
1778
|
});
|
|
1697
1779
|
} else {
|
|
1698
1780
|
viewer.startMono();
|
|
1699
1781
|
}
|
|
1782
|
+
// Settle the stub's `firstWoven` (addSplatDeferred) with the core handle's own.
|
|
1783
|
+
if (typeof out._resolveFirstWoven === 'function') {
|
|
1784
|
+
out._resolveFirstWoven(handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 })));
|
|
1785
|
+
delete out._resolveFirstWoven;
|
|
1786
|
+
}
|
|
1700
1787
|
|
|
1701
1788
|
// Replay what the page did before this module arrived — exclude() above all, which a product
|
|
1702
1789
|
// page calls on the very next line after addSplat.
|
package/js/inline3d-splat.js
CHANGED
|
@@ -212,6 +212,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
212
212
|
fileName,
|
|
213
213
|
fileType,
|
|
214
214
|
observe,
|
|
215
|
+
firstWovenHoldMs,
|
|
215
216
|
} = opts;
|
|
216
217
|
|
|
217
218
|
const viewer = new SceneViewer(THREE, canvas, {
|
|
@@ -392,10 +393,13 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
392
393
|
// canvas flat rather than leave its last side-by-side frame on the page (web#28).
|
|
393
394
|
onLayerLost: viewer.onLayerLost,
|
|
394
395
|
...(observe ? { observe } : {}),
|
|
396
|
+
...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
|
|
395
397
|
});
|
|
396
398
|
} else {
|
|
397
399
|
viewer.startMono();
|
|
398
400
|
}
|
|
401
|
+
// The core handle's `firstWoven`, forwarded; a page with no session is told so at once.
|
|
402
|
+
out.firstWoven = handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 }));
|
|
399
403
|
|
|
400
404
|
|
|
401
405
|
// ── focus: declaring it, and the two gestures that change it ──────────────────────────
|
|
@@ -644,12 +648,21 @@ function addSplatDeferred(wall, canvas, src, opts) {
|
|
|
644
648
|
exclude: queue('exclude'),
|
|
645
649
|
unexclude: queue('unexclude'),
|
|
646
650
|
};
|
|
651
|
+
// `firstWoven` exists from the first line, like every other field a page reads right away; the
|
|
652
|
+
// adapter settles it with the core handle's own once the module has loaded.
|
|
653
|
+
out.firstWoven = new Promise((resolve) => {
|
|
654
|
+
out._resolveFirstWoven = resolve;
|
|
655
|
+
});
|
|
647
656
|
// The ONE owner of `ready`: the adapter returns its load promise and never touches this field.
|
|
648
657
|
out.ready = import('./inline3d-splat-playcanvas.js')
|
|
649
658
|
.then((m) => m.attachPlayCanvasSplat(out, wall, canvas, src, opts, pending))
|
|
650
659
|
.catch((err) => {
|
|
651
660
|
// The adapter warns about its own load failures; this is for the module not arriving.
|
|
652
661
|
if (!out.viewer) console.warn('[inline3d/splat] engine:playcanvas failed to start', err);
|
|
662
|
+
if (out._resolveFirstWoven) {
|
|
663
|
+
out._resolveFirstWoven(Object.freeze({ woven: false, confirmed: false, reason: 'layer-failed', ms: 0 }));
|
|
664
|
+
delete out._resolveFirstWoven;
|
|
665
|
+
}
|
|
653
666
|
throw err;
|
|
654
667
|
});
|
|
655
668
|
return out;
|
package/js/inline3d.js
CHANGED
|
@@ -375,6 +375,15 @@ function nowMs() {
|
|
|
375
375
|
: Date.now();
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
// First-woven hold (web#36 follow-up): how long after a window's layer is constructed the SDK
|
|
379
|
+
// assumes the browser MAY still be failing to join the canvas, and therefore still showing the
|
|
380
|
+
// page's own raster of it — the raw side-by-side pair. The browser measures that window at
|
|
381
|
+
// 0.4–1.2 s for a canvas that is fresh to its compositor (a same-document navigation creates
|
|
382
|
+
// one); this is its upper bound. Nothing the page can observe today says when the join actually
|
|
383
|
+
// landed (docs/proposals/layer-joined-signal.md), so `handle.firstWoven` is this timer, not a
|
|
384
|
+
// report. Per-window override: `firstWovenHoldMs` on any add*() call.
|
|
385
|
+
const FIRST_WOVEN_HOLD_MS = 1200;
|
|
386
|
+
|
|
378
387
|
// The easing option, validated here rather than in the state machine: the sequencer falls back
|
|
379
388
|
// silently (it has no opinion about a caller's config), but a typo in `createInline3D` is worth
|
|
380
389
|
// exactly one warning — a page that asked for 'ease-in-out' and got smoothstep should know.
|
|
@@ -1055,6 +1064,44 @@ class Inline3D {
|
|
|
1055
1064
|
// Read-only counters, for pages that want to see the load-induced mono fallback rather
|
|
1056
1065
|
// than wait for a bug report about "blinking". Scene windows only; 0/0 elsewhere.
|
|
1057
1066
|
stats: () => ({ frames: win.frames, monoFrames: win.monoFrames }),
|
|
1067
|
+
/**
|
|
1068
|
+
* Resolves ONCE, never rejects: `{ woven, confirmed, reason, ms }`.
|
|
1069
|
+
*
|
|
1070
|
+
* `woven: true` — the window has drawn a stereo frame on a layer that has existed for
|
|
1071
|
+
* `firstWovenHoldMs` (default 1200). That is the moment to drop a poster covering the
|
|
1072
|
+
* canvas. `confirmed` is `false` today, always: no browser reports when its compositor
|
|
1073
|
+
* actually joined a canvas, so this is the browser's worst case, measured by the SDK so
|
|
1074
|
+
* pages stop measuring it themselves. It becomes a reported fact (`confirmed: true`, no
|
|
1075
|
+
* hold) when a browser can say so, with no change to the page.
|
|
1076
|
+
*
|
|
1077
|
+
* `woven: false` — this window will not weave: `reason` is `'layer-failed'`,
|
|
1078
|
+
* `'session-ended'` or `'removed'`. The SDK has already taken an image/video canvas flat,
|
|
1079
|
+
* and a scene's `onLayerLost` has already run. Release the poster onto the 2D fallback.
|
|
1080
|
+
*
|
|
1081
|
+
* `ms` is the time from the add*() call to settling.
|
|
1082
|
+
*/
|
|
1083
|
+
get firstWoven() {
|
|
1084
|
+
return win.fwPromise;
|
|
1085
|
+
},
|
|
1086
|
+
/**
|
|
1087
|
+
* Callback form of {@link firstWoven}: `cb(result)` once, asynchronously, even when it has
|
|
1088
|
+
* already settled. Returns an unsubscribe function.
|
|
1089
|
+
*/
|
|
1090
|
+
onFirstWoven: (cb) => {
|
|
1091
|
+
if (typeof cb !== 'function') throw new TypeError('[inline3d] onFirstWoven() takes a function.');
|
|
1092
|
+
let live = true;
|
|
1093
|
+
win.fwPromise.then((r) => {
|
|
1094
|
+
if (!live) return;
|
|
1095
|
+
try {
|
|
1096
|
+
cb(r);
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
console.error('[inline3d] onFirstWoven callback threw', err);
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
return () => {
|
|
1102
|
+
live = false;
|
|
1103
|
+
};
|
|
1104
|
+
},
|
|
1058
1105
|
};
|
|
1059
1106
|
}
|
|
1060
1107
|
|
|
@@ -1793,7 +1840,23 @@ class Inline3D {
|
|
|
1793
1840
|
// onFrame deliveries; monoFrames counts the ones that carried fewer than two views.
|
|
1794
1841
|
frames: 0,
|
|
1795
1842
|
monoFrames: 0,
|
|
1843
|
+
// handle.firstWoven (web#36 follow-up). One-shot per window: `fwResult` is the settled
|
|
1844
|
+
// value, null while pending. `fwLayerAt` is when the CURRENT layer was built (null = none);
|
|
1845
|
+
// `fwStereo` whether that layer has since carried a real stereo frame. _activate resets both
|
|
1846
|
+
// for every new layer of a pending window, so a lazy tile that scrolls away before settling
|
|
1847
|
+
// earns it again on its next layer rather than inheriting time from the closed one (a
|
|
1848
|
+
// window with no layer is skipped by _frame, so nothing ticks in between).
|
|
1849
|
+
fwResult: null,
|
|
1850
|
+
fwResolve: null,
|
|
1851
|
+
fwPromise: null,
|
|
1852
|
+
fwHoldMs: firstWovenHold(opts.firstWovenHoldMs),
|
|
1853
|
+
fwRegAt: nowMs(),
|
|
1854
|
+
fwLayerAt: null,
|
|
1855
|
+
fwStereo: false,
|
|
1796
1856
|
};
|
|
1857
|
+
win.fwPromise = new Promise((resolve) => {
|
|
1858
|
+
win.fwResolve = resolve;
|
|
1859
|
+
});
|
|
1797
1860
|
this._windows.set(canvas, win);
|
|
1798
1861
|
if (this._lazy && this._observer) {
|
|
1799
1862
|
this._observer.observe(win.observeEl);
|
|
@@ -1809,6 +1872,7 @@ class Inline3D {
|
|
|
1809
1872
|
if (this._observer) this._observer.unobserve(win.observeEl);
|
|
1810
1873
|
this._deactivate(win);
|
|
1811
1874
|
this._windows.delete(canvas);
|
|
1875
|
+
this._settleFirstWoven(win, false, 'removed');
|
|
1812
1876
|
}
|
|
1813
1877
|
|
|
1814
1878
|
_onIntersect(entries) {
|
|
@@ -1874,8 +1938,15 @@ class Inline3D {
|
|
|
1874
1938
|
}
|
|
1875
1939
|
this._paintMono(win);
|
|
1876
1940
|
this._notifyLayerLost(win);
|
|
1941
|
+
// After the mono paint and the scene's own notification, so a page that releases its
|
|
1942
|
+
// poster on this finds the canvas already flat underneath it.
|
|
1943
|
+
this._settleFirstWoven(win, false, 'layer-failed');
|
|
1877
1944
|
return;
|
|
1878
1945
|
}
|
|
1946
|
+
if (!win.fwResult) {
|
|
1947
|
+
win.fwLayerAt = nowMs();
|
|
1948
|
+
win.fwStereo = false;
|
|
1949
|
+
}
|
|
1879
1950
|
win.layerLostSent = false; // a live layer again: a future loss is worth reporting again
|
|
1880
1951
|
// Nothing about the hardware state is re-asserted here, and that is the point: the panel's
|
|
1881
1952
|
// mode is the DISPLAY's, it survives a tile scrolling away, and this SDK never requests it
|
|
@@ -2409,6 +2480,9 @@ class Inline3D {
|
|
|
2409
2480
|
// (see the note below), so one broken tile took its neighbours' weave with it (web#28).
|
|
2410
2481
|
try {
|
|
2411
2482
|
win.onFrame(views, win.layer, f);
|
|
2483
|
+
// A stereo frame the page drew without throwing. A short view list is the load
|
|
2484
|
+
// fallback (a mono frame), which is not what a poster is waiting for.
|
|
2485
|
+
if (views.length >= 2) win.fwStereo = true;
|
|
2412
2486
|
} catch (err) {
|
|
2413
2487
|
if (!win.frameThrewWarned) {
|
|
2414
2488
|
win.frameThrewWarned = true;
|
|
@@ -2427,10 +2501,40 @@ class Inline3D {
|
|
|
2427
2501
|
// sub-rect and the window flickers to a horizontal smear. A still image's
|
|
2428
2502
|
// redraw is one cheap GPU drawImage — keep it live.
|
|
2429
2503
|
this._paint(win, views);
|
|
2504
|
+
// An SBS paint with a real source behind it. Before the image has loaded (or while a
|
|
2505
|
+
// video has never had a frame) the tile holds nothing worth revealing yet.
|
|
2506
|
+
if (win.sbs && (win.kind === 'video' ? ((win.video && win.video.readyState) || 0) >= 2 : !!win.img)) {
|
|
2507
|
+
win.fwStereo = true;
|
|
2508
|
+
}
|
|
2430
2509
|
}
|
|
2510
|
+
this._tickFirstWoven(win);
|
|
2431
2511
|
}
|
|
2432
2512
|
}
|
|
2433
2513
|
|
|
2514
|
+
/**
|
|
2515
|
+
* Settle `firstWoven` as woven once BOTH halves hold: the current layer has carried a stereo
|
|
2516
|
+
* frame, and it has existed for the hold. Per session frame, per live window — two compares
|
|
2517
|
+
* while pending, one while settled.
|
|
2518
|
+
*
|
|
2519
|
+
* This is where a browser-reported join would plug in (a confirmed result, no hold). No
|
|
2520
|
+
* browser exposes one today; see docs/proposals/layer-joined-signal.md. It is deliberately
|
|
2521
|
+
* NOT inferred from anything the session does report: views arrive from the runtime's locate,
|
|
2522
|
+
* which knows nothing about whether the compositor has matched this canvas yet.
|
|
2523
|
+
*/
|
|
2524
|
+
_tickFirstWoven(win) {
|
|
2525
|
+
if (win.fwResult || !win.fwStereo || win.fwLayerAt === null) return;
|
|
2526
|
+
if (nowMs() - win.fwLayerAt < win.fwHoldMs) return;
|
|
2527
|
+
this._settleFirstWoven(win, true, 'hold-elapsed');
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/** One-shot: the first call wins, later ones are ignored. */
|
|
2531
|
+
_settleFirstWoven(win, woven, reason) {
|
|
2532
|
+
if (win.fwResult) return;
|
|
2533
|
+
win.fwResult = Object.freeze({ woven, confirmed: false, reason, ms: Math.round(nowMs() - win.fwRegAt) });
|
|
2534
|
+
win.fwResolve(win.fwResult);
|
|
2535
|
+
win.fwResolve = null;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2434
2538
|
// ── page lifecycle: bfcache, freeze, restore (browser#87) ───────────────────────────
|
|
2435
2539
|
//
|
|
2436
2540
|
// A weaved window's rect reaches the compositor from the session's own rAF: every frame the
|
|
@@ -2562,6 +2666,7 @@ class Inline3D {
|
|
|
2562
2666
|
// thing that must not be. AFTER the close, so the flat frame is the last thing committed.
|
|
2563
2667
|
this._paintMono(win);
|
|
2564
2668
|
this._notifyLayerLost(win);
|
|
2669
|
+
this._settleFirstWoven(win, false, 'session-ended');
|
|
2565
2670
|
}
|
|
2566
2671
|
this._windows.clear();
|
|
2567
2672
|
// Page listeners go with the session that fed them: a manager whose session has ended will
|
|
@@ -2572,6 +2677,11 @@ class Inline3D {
|
|
|
2572
2677
|
|
|
2573
2678
|
// ── small helpers ─────────────────────────────────────────────────────────────────────
|
|
2574
2679
|
|
|
2680
|
+
/** `firstWovenHoldMs`, validated: a finite number >= 0, else the default. */
|
|
2681
|
+
function firstWovenHold(v) {
|
|
2682
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : FIRST_WOVEN_HOLD_MS;
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2575
2685
|
function loadImage(source) {
|
|
2576
2686
|
if (typeof source !== 'string') return Promise.resolve(source); // element/bitmap/canvas
|
|
2577
2687
|
return new Promise((resolve, reject) => {
|
package/model.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
|
|
3
3
|
|
|
4
4
|
import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
|
|
5
|
+
import type { FirstWovenResult } from './index.js';
|
|
5
6
|
|
|
6
7
|
export interface ModelOptions {
|
|
7
8
|
/** Metres of world the tile's height spans (default 0.24). */
|
|
@@ -72,6 +73,8 @@ export interface ModelOptions {
|
|
|
72
73
|
|
|
73
74
|
/** Element whose visibility gates the lazy create/close lifecycle. */
|
|
74
75
|
observe?: Element;
|
|
76
|
+
/** Forwarded to the core window: see `TileOptions.firstWovenHoldMs`. */
|
|
77
|
+
firstWovenHoldMs?: number;
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
/** What {@link addModel} returns — the same shape as addSplat's handle. */
|
|
@@ -90,6 +93,11 @@ export interface ModelHandle {
|
|
|
90
93
|
remove(): void;
|
|
91
94
|
exclude(el: Element): void;
|
|
92
95
|
unexclude(el: Element): void;
|
|
96
|
+
/**
|
|
97
|
+
* The core window's `TileHandle.firstWoven`: when it is safe to reveal the canvas. Resolves
|
|
98
|
+
* `{ woven: false, reason: 'unsupported' }` at once where there is no inline-3D session.
|
|
99
|
+
*/
|
|
100
|
+
readonly firstWoven: Promise<FirstWovenResult>;
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"types": "./model.d.ts",
|
|
28
28
|
"import": "./js/inline3d-model.js"
|
|
29
29
|
},
|
|
30
|
+
"./package.json": "./package.json",
|
|
30
31
|
"./undock": {
|
|
31
32
|
"types": "./index.d.ts",
|
|
32
33
|
"import": "./js/inline3d-undock.js"
|
package/splat.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
|
|
3
3
|
|
|
4
4
|
import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
|
|
5
|
+
import type { FirstWovenResult } from './index.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The knobs behind `SplatOptions.perf`. Every one is a Spark 2.1.0 setting except `alphaRadius`,
|
|
@@ -259,6 +260,14 @@ export interface SplatOptions {
|
|
|
259
260
|
* `'height'`. Both backends; the 3D rig's vertical FOV follows the crop. Anything else throws.
|
|
260
261
|
*/
|
|
261
262
|
captureFit?: 'height' | 'cover';
|
|
263
|
+
/**
|
|
264
|
+
* PlayCanvas: a FLOOR on the projection's near plane, in world units (the adapter owns the
|
|
265
|
+
* projections; this only raises near, for depth precision when meshes share the scene under
|
|
266
|
+
* `handle.engine.root`). Anything nearer is clipped, splats included. Unset: untouched.
|
|
267
|
+
*/
|
|
268
|
+
nearClip?: number;
|
|
269
|
+
/** PlayCanvas: a CAP on the projection's far plane (only ever lowers it). Unset: untouched. */
|
|
270
|
+
farClip?: number;
|
|
262
271
|
/** Camera rig only: the distance in world metres that sits ON the glass. */
|
|
263
272
|
convergence?: number;
|
|
264
273
|
/**
|
|
@@ -288,6 +297,8 @@ export interface SplatOptions {
|
|
|
288
297
|
fileType?: 'ply' | 'spz' | 'splat' | 'ksplat' | 'pcsogs' | 'pcsogszip' | 'rad';
|
|
289
298
|
/** Element whose visibility gates the lazy create/close lifecycle. */
|
|
290
299
|
observe?: Element;
|
|
300
|
+
/** Forwarded to the core window: see `TileOptions.firstWovenHoldMs`. */
|
|
301
|
+
firstWovenHoldMs?: number;
|
|
291
302
|
}
|
|
292
303
|
|
|
293
304
|
/** `handle.stats()` on `engine: 'playcanvas'`. */
|
|
@@ -414,6 +425,11 @@ export interface SplatHandle {
|
|
|
414
425
|
/** Mark a 2D element painted over this window so the weave leaves it crisp. */
|
|
415
426
|
exclude(el: Element): void;
|
|
416
427
|
unexclude(el: Element): void;
|
|
428
|
+
/**
|
|
429
|
+
* The core window's `TileHandle.firstWoven`: when it is safe to reveal the canvas. Resolves
|
|
430
|
+
* `{ woven: false, reason: 'unsupported' }` at once where there is no inline-3D session.
|
|
431
|
+
*/
|
|
432
|
+
readonly firstWoven: Promise<FirstWovenResult>;
|
|
417
433
|
}
|
|
418
434
|
|
|
419
435
|
/**
|