@displayxr/inline3d 1.9.1 → 1.10.1

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 CHANGED
@@ -5,6 +5,70 @@ 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.1 — 2026-09-23
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), and `boundsFromPositions` in
11
+ `./viewer` (same results, less time). Nothing changes for Spark callers' pixels.
12
+
13
+ ### Fixed
14
+
15
+ - **No grey sky box behind the splat.** The engine draws a sky whenever the scene has something to
16
+ draw it from, and `scene.envAtlas` counts. A page that set one to light its meshes under
17
+ `handle.engine.root` got a grey gradient box the instant the splat was hidden (mid-`setSource`,
18
+ or a page showing only its own meshes). The eye camera now renders without the Skybox layer, so
19
+ the canvas stays transparent. Image-based lighting still lights the meshes. The new **`sky: true`**
20
+ brings the engine's sky back.
21
+ - Verified headless with `envAtlas` set and the splat hidden: the corner pixel reads
22
+ `[140,140,140,255]` in 1.10.0 and `[0,0,0,0]` now (and `[140,140,140,255]` again with
23
+ `sky: true`).
24
+ - **`setSource` no longer blocks input for its cloud passes.** The SDK's framing, rest-space sample
25
+ and pick-set passes ran in one main-thread task stacked on the engine's end-of-load work. Now
26
+ each runs in its own task (with `scheduler.yield()` where available).
27
+ - `boundsFromPositions` takes its percentiles with a linear-time select instead of full sorts.
28
+ The values are bit-identical, pinned by a test.
29
+ - On a 1.18M-gaussian swap (headless, M1 Pro), the longest main-thread task went from
30
+ **60–64 ms to under 50 ms** (none reported), and the longest frame gap from 65–77 to
31
+ 35–44 ms. At 4× CPU throttling it went from 218–238 ms to 60–65 ms, which is the engine's own
32
+ remainder, documented per 1M gaussians with the recommended pre-load pattern.
33
+ - The load stages are visible as `performance.measure` entries named `inline3d:*`.
34
+
35
+ ## 1.10.0 — 2026-09-23
36
+
37
+ Touches the **core tier** (`.`), additively. The frozen 1.x surface gains one handle member and
38
+ one option, and nothing that exists changes behaviour. The preview subpaths (`./splat`,
39
+ `./model`) forward the new member. No pixels move for any page that does not read it.
40
+
41
+ ### Added
42
+
43
+ - **`handle.firstWoven`**: a promise that tells the page when it is safe to reveal a woven canvas
44
+ (core tier; web#36 follow-up).
45
+ - It resolves once and never rejects, with `{ woven, confirmed, reason, ms }`.
46
+ - `woven: true` means a real stereo frame is on a layer that has existed for
47
+ `firstWovenHoldMs`.
48
+ - `woven: false` (`'layer-failed'` / `'session-ended'` / `'removed'`) means the window will not
49
+ weave. The canvas is already flat, or the scene's `onLayerLost` has already run.
50
+ - `onFirstWoven(cb)` is the callback form.
51
+ - It replaces the worst-case `setTimeout` that pages kept to hide the raw side-by-side pair a
52
+ fresh canvas shows until the browser's compositor joins it.
53
+ - **`firstWovenHoldMs`** on every `add*()` (default **1200**, the browser's measured worst case
54
+ for a canvas that is fresh to its compositor).
55
+ - **It is approximate, by design, and says so.** No browser reports the join yet (checked
56
+ against the browser's JavaScript surface: the verdict exists only as a compositor log line). So
57
+ `confirmed` is always `false` and the result is the hold. When a browser reports joins it
58
+ becomes `confirmed: true` and earlier, with no change to the page. The browser ask is
59
+ [`docs/proposals/layer-joined-signal.md`](docs/proposals/layer-joined-signal.md).
60
+ - `addSplat` (both engines) and `addModel` handles carry `firstWoven` and accept
61
+ `firstWovenHoldMs` (preview tier). With no inline-3D session they resolve
62
+ `{ woven: false, reason: 'unsupported' }` at once.
63
+ - Docs: **[`docs/woven-canvas-rules.md`](docs/woven-canvas-rules.md)**: eight rules for never
64
+ showing a raw side-by-side frame. Each rule comes with its reason and the SDK call that
65
+ satisfies it, plus a hardware checklist keyed on the browser's
66
+ `withheld … ids=[<token>=<why>@<rect>]` log line. There is a summary section in the authoring
67
+ guide, and links from the porting guide and the README.
68
+ - It corrects two claims in circulation. **No shipping browser draws a flat frame instead of
69
+ the raw pair** for a fresh canvas: that fallback was measured and not shipped. And the
70
+ `withheld` line is logged at error level with throttling, so a missing line proves nothing.
71
+
8
72
  ## 1.9.1 — 2026-09-23
9
73
 
10
74
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), plus the package manifest.
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}. */
@@ -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);
@@ -535,8 +535,15 @@ export class PlayCanvasSplatViewer {
535
535
  captureFit = 'height',
536
536
  nearClip,
537
537
  farClip,
538
+ sky = false,
538
539
  } = opts;
539
540
  this.canvas = canvas;
541
+ // The engine draws a sky box whenever the scene has something to draw it from — and
542
+ // `scene.envAtlas` counts: a page that sets one to light its own meshes under
543
+ // handle.engine.root got a grey gradient box behind the splat the instant the splat was
544
+ // hidden (mid-setSource, or a page showing only its meshes). The SDK's contract is a
545
+ // transparent canvas the page shows through, so the sky layer is off unless asked for.
546
+ this.sky = sky === true;
540
547
  // Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
541
548
  // other; splats only test against them). The projections' own near/far stay the adapter's —
542
549
  // these only raise the near (floor) and lower the far (cap). Unset: untouched.
@@ -1044,6 +1051,11 @@ export class PlayCanvasSplatViewer {
1044
1051
  fov: MONO_FOV,
1045
1052
  ...(rect ? { rect } : {}),
1046
1053
  });
1054
+ // No sky unless the page asked (see the constructor): the image-based lighting a page sets
1055
+ // still lights its meshes — only the BACKGROUND the sky layer would draw is dropped.
1056
+ if (!this.sky && Array.isArray(e.camera.layers)) {
1057
+ e.camera.layers = e.camera.layers.filter((id) => id !== pc.LAYERID_SKYBOX);
1058
+ }
1047
1059
  // The engine's default camera tonemap is LINEAR, which routes every splat colour through
1048
1060
  // decodeGamma → toneMap → gammaCorrectOutput. Spark writes the stored colour straight out;
1049
1061
  // NONE is the same thing here (GAMMA_SRGB alone leaves a gamma-space colour untouched).
@@ -1456,6 +1468,28 @@ function placeNode(node, m) {
1456
1468
  * @returns {Promise<{xyz:Float32Array, opacity:Float32Array|null, total:number, stride:number,
1457
1469
  * sourceTotal:number}|null>} `total` is the number of splats IN the sample.
1458
1470
  */
1471
+ /**
1472
+ * A `performance.measure` named `inline3d:<name>` from `t0` to now — so a load's stages show up in
1473
+ * DevTools' Performance panel and in `performance.getEntriesByType('measure')`. Never throws.
1474
+ */
1475
+ /**
1476
+ * Give the main thread back for one turn — input, rAF, the engine's own tick — before the next
1477
+ * chunk of cloud work. `scheduler.yield()` where the browser has it (keeps our task's priority),
1478
+ * else a macrotask.
1479
+ */
1480
+ function yieldToMain() {
1481
+ if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') return scheduler.yield();
1482
+ return new Promise((r) => setTimeout(r, 0));
1483
+ }
1484
+
1485
+ function perfSpan(name, t0) {
1486
+ try {
1487
+ performance.measure?.(`inline3d:${name}`, { start: t0, end: performance.now() });
1488
+ } catch {
1489
+ /* no User Timing L3 here */
1490
+ }
1491
+ }
1492
+
1459
1493
  export async function readCloud(resource) {
1460
1494
  if (!resource) return null;
1461
1495
  const centers = resource.centers;
@@ -1464,19 +1498,29 @@ export async function readCloud(resource) {
1464
1498
  if (!centers || !sourceTotal) return null;
1465
1499
  const stride = Math.max(1, Math.ceil(sourceTotal / FRAME_SAMPLE_CAP));
1466
1500
  const total = Math.ceil(sourceTotal / stride);
1501
+ // Not in the task that delivered the asset: the engine's own end-of-load work (its centre
1502
+ // readback unpack) runs there, and stacking ours on it made one long task.
1503
+ await yieldToMain();
1504
+ let t0 = performance.now();
1467
1505
  const xyz = new Float32Array(total * 3);
1468
1506
  for (let j = 0, i = 0; j < total; j++, i += stride) {
1469
1507
  xyz[j * 3] = centers[i * 3];
1470
1508
  xyz[j * 3 + 1] = centers[i * 3 + 1];
1471
1509
  xyz[j * 3 + 2] = centers[i * 3 + 2];
1472
1510
  }
1511
+ perfSpan('readCloud:copy', t0);
1512
+ await yieldToMain();
1473
1513
  let opacity = null;
1474
1514
  // Full-resolution peak opacity, ONE BYTE per splat, kept for the exact pick (which walks the
1475
1515
  // engine's own full centre set at pick time): 1.18 MB on the 1.18M bench asset.
1476
1516
  let alpha8 = null;
1477
1517
  try {
1478
1518
  if (data?.isSog && data.sh0?.read) {
1519
+ t0 = performance.now();
1479
1520
  const px = await data.sh0.read(0, 0, data.sh0.width, data.sh0.height, { mipLevel: 0, face: 0, immediate: true });
1521
+ perfSpan('readCloud:sh0-readback(async)', t0);
1522
+ await yieldToMain();
1523
+ t0 = performance.now();
1480
1524
  if (px && px.length >= sourceTotal * 4) {
1481
1525
  const v2 = data.meta?.version === 2;
1482
1526
  const mn = data.meta?.sh0?.mins?.[3];
@@ -1491,6 +1535,7 @@ export async function readCloud(resource) {
1491
1535
  if (v2 || mn === undefined) for (let i = 0; i < sourceTotal; i++) alpha8[i] = px[i * 4 + 3];
1492
1536
  else for (let i = 0; i < sourceTotal; i++) alpha8[i] = Math.round(op(i) * 255);
1493
1537
  }
1538
+ perfSpan('readCloud:opacity', t0);
1494
1539
  } else if (typeof data?.getProp === 'function') {
1495
1540
  const o = data.getProp('opacity');
1496
1541
  if (o && o.length >= sourceTotal) {
@@ -1685,6 +1730,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1685
1730
  captureFit = 'height',
1686
1731
  focusInput = true,
1687
1732
  observe,
1733
+ firstWovenHoldMs,
1688
1734
  preserveDrawingBuffer = false,
1689
1735
  } = opts;
1690
1736
  // `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
@@ -1707,6 +1753,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1707
1753
  captureFit,
1708
1754
  nearClip: opts.nearClip,
1709
1755
  farClip: opts.farClip,
1756
+ sky: opts.sky,
1710
1757
  });
1711
1758
 
1712
1759
  let handle = null;
@@ -1773,10 +1820,16 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1773
1820
  virtualDisplayHeight,
1774
1821
  onLayerLost: viewer.onLayerLost,
1775
1822
  ...(observe ? { observe } : {}),
1823
+ ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
1776
1824
  });
1777
1825
  } else {
1778
1826
  viewer.startMono();
1779
1827
  }
1828
+ // Settle the stub's `firstWoven` (addSplatDeferred) with the core handle's own.
1829
+ if (typeof out._resolveFirstWoven === 'function') {
1830
+ out._resolveFirstWoven(handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 })));
1831
+ delete out._resolveFirstWoven;
1832
+ }
1780
1833
 
1781
1834
  // Replay what the page did before this module arrived — exclude() above all, which a product
1782
1835
  // page calls on the very next line after addSplat.
@@ -1939,7 +1992,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1939
1992
  }
1940
1993
  // The camera block off the SAME bytes, before the engine takes them (as the Spark path).
1941
1994
  let camera = null;
1995
+ let t0 = performance.now();
1942
1996
  if (bytes && rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
1997
+ perfSpan('readSogMeta(async)', t0);
1943
1998
 
1944
1999
  const url = bytes
1945
2000
  ? `inline3d-bytes-${++byteSeq}-${++byteSeqLocal}.${fmt.ext}`
@@ -1951,18 +2006,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1951
2006
  : { url, filename: pathOf(url).split('/').pop() || url };
1952
2007
  const asset = new pc.Asset(url, 'gsplat', file);
1953
2008
  app.assets.add(asset);
2009
+ t0 = performance.now();
1954
2010
  await new Promise((resolve, reject) => {
1955
2011
  asset.ready(resolve);
1956
2012
  asset.once('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
1957
2013
  app.assets.load(asset);
1958
2014
  });
2015
+ perfSpan('engine-load(async)', t0);
1959
2016
  const res = asset.resource;
1960
2017
  const desc = describeResource(res);
1961
2018
  // A URL `.sog` carries its meta in the resource (the engine keeps unknown keys); a Streamed
1962
2019
  // SOG carries it at the top level of lod-meta.json. Both validated by the same reader.
1963
2020
  if (!bytes && rig !== 'display') camera = sogCameraFromMeta(desc.meta);
1964
2021
  const cloud = desc.kind === 'flat' ? await readCloud(res) : null;
1965
- return { asset, res, desc, camera, cloud };
2022
+ // The cloud passes, each in its OWN task: framing, the rest-space sample and the pick set
2023
+ // were one ~60 ms main-thread block on a 1.18M-gaussian swap — pointer input waited on it.
2024
+ // Split with a yield between them (and a linear-time percentile in boundsFromPositions),
2025
+ // no single step is a long task any more. Same numbers, same order.
2026
+ const pre = { local: null, rest: null, pickCentres: null };
2027
+ if (cloud) {
2028
+ const walk = centresVisitor(cloud.xyz, cloud.opacity, cloud.total);
2029
+ await yieldToMain();
2030
+ let t = performance.now();
2031
+ pre.local = boundsFromPositions(sampleCloudCentres(cloud.total, walk) || []);
2032
+ perfSpan('cloud:bounds', t);
2033
+ await yieldToMain();
2034
+ t = performance.now();
2035
+ if (rigNeedsCloud(camera)) pre.rest = sampleCloudRestSpace(cloud.total, walk, camera?.rest);
2036
+ perfSpan('cloud:rest-sample', t);
2037
+ await yieldToMain();
2038
+ t = performance.now();
2039
+ const s = sampleCloudCentres(cloud.total, walk, { cap: RIG_SAMPLE_CAP });
2040
+ pre.pickCentres = s ? s.slice() : null;
2041
+ perfSpan('cloud:pick-set', t);
2042
+ await yieldToMain();
2043
+ }
2044
+ return { asset, res, desc, camera, cloud, pre };
1966
2045
  }
1967
2046
 
1968
2047
  /**
@@ -1971,8 +2050,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1971
2050
  */
1972
2051
  function applyLoaded(loaded) {
1973
2052
  const { cloud, desc } = loaded;
1974
- const walk = cloud ? centresVisitor(cloud.xyz, cloud.opacity, cloud.total) : null;
1975
- const local = walk ? boundsFromPositions(sampleCloudCentres(cloud.total, walk) || []) : null;
2053
+ const tB = performance.now();
2054
+ const local = loaded.pre?.local || null;
1976
2055
  const lift = (b) => ({ center: modelToContent(b.center), extent: b.extent.slice(0, 3) });
1977
2056
  // Measured first — the Spark path's order (a supplied frame is only a fallback there too).
1978
2057
  // A Streamed SOG has no cloud: there a caller's `frame` beats the octree-derived bounds
@@ -1988,12 +2067,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1988
2067
  ? lift(frame)
1989
2068
  : null;
1990
2069
 
1991
- const sample =
1992
- !rigNeedsCloud(loaded.camera)
1993
- ? null
1994
- : walk
1995
- ? sampleCloudRestSpace(cloud.total, walk, loaded.camera?.rest)
1996
- : null;
2070
+ const sample = loaded.pre?.rest || null;
1997
2071
  const box = canvas.getBoundingClientRect();
1998
2072
  const resolved = resolveRig({
1999
2073
  camera: loaded.camera,
@@ -2003,6 +2077,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2003
2077
  });
2004
2078
  resolved.focusDefault = resolved.focus.slice();
2005
2079
  resolved.focusDefaultSource = resolved.focusSource;
2080
+ perfSpan('applyLoaded:rig', tB);
2006
2081
  out.camera = loaded.camera;
2007
2082
  out.rig = resolved;
2008
2083
  out.frame = bounds;
@@ -2015,11 +2090,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2015
2090
  }
2016
2091
 
2017
2092
  // The strided pick fallback (used only if the engine releases its full centre set).
2018
- let pickCentres = null;
2019
- if (walk) {
2020
- const s = sampleCloudCentres(cloud.total, walk, { cap: RIG_SAMPLE_CAP });
2021
- pickCentres = s ? s.slice() : null;
2022
- }
2093
+ const pickCentres = loaded.pre?.pickCentres || null;
2023
2094
 
2024
2095
  if (resolved.type === 'camera') {
2025
2096
  if (!('idleSpin' in opts)) viewer.idleSpin = 0;
@@ -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;
@@ -70,6 +70,56 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
70
70
  // matrix and every vertex lands undefined. Reject at the setter instead.
71
71
  const finite = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
72
72
 
73
+ /**
74
+ * The k-th smallest of the first `n` entries of `a` — exactly what `a.subarray(0, n).sort()[k]`
75
+ * returns, NaN placement included (a TypedArray sort puts NaN last) — by quickselect, reordering
76
+ * `a` in place. O(n) on average.
77
+ */
78
+ export function selectKth(a, n, k) {
79
+ // NaN to the end first, as the numeric TypedArray sort does; select among the rest.
80
+ let m = n;
81
+ for (let i = 0; i < m; ) {
82
+ if (a[i] !== a[i]) {
83
+ m--;
84
+ const t = a[i];
85
+ a[i] = a[m];
86
+ a[m] = t;
87
+ } else i++;
88
+ }
89
+ if (k >= m) return NaN;
90
+ let left = 0;
91
+ let right = m - 1;
92
+ while (right > left) {
93
+ // Median-of-three pivot keeps sorted / reverse-sorted input O(n).
94
+ const mid = (left + right) >> 1;
95
+ if (a[mid] < a[left]) swap(a, mid, left);
96
+ if (a[right] < a[left]) swap(a, right, left);
97
+ if (a[right] < a[mid]) swap(a, right, mid);
98
+ const pivot = a[mid];
99
+ let i = left;
100
+ let j = right;
101
+ while (i <= j) {
102
+ while (a[i] < pivot) i++;
103
+ while (a[j] > pivot) j--;
104
+ if (i <= j) {
105
+ swap(a, i, j);
106
+ i++;
107
+ j--;
108
+ }
109
+ }
110
+ if (k <= j) right = j;
111
+ else if (k >= i) left = i;
112
+ else return a[k];
113
+ }
114
+ return a[k];
115
+ }
116
+
117
+ function swap(a, i, j) {
118
+ const t = a[i];
119
+ a[i] = a[j];
120
+ a[j] = t;
121
+ }
122
+
73
123
  /**
74
124
  * Robust model-space bounds from a flat array of splat/vertex centres.
75
125
  *
@@ -110,14 +160,15 @@ export function boundsFromPositions(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 }
110
160
  const center = [0, 0, 0];
111
161
  const extent = [0, 0, 0];
112
162
  const axisVals = new Float64Array(n);
163
+ const kLo = trim ? Math.floor(lo * (n - 1)) : 0;
164
+ const kHi = trim ? Math.floor(hi * (n - 1)) : n - 1;
113
165
  for (let axis = 0; axis < 3; axis++) {
114
166
  for (let i = 0; i < n; i++) axisVals[i] = xyz[i * 3 + axis];
115
- // TypedArray sort is numeric and in-place no comparator, no copy. That matters here:
116
- // this runs over every splat centre, and a boxed Array round-trip on a 500k-splat model
117
- // is the difference between a hitch and an imperceptible pause.
118
- const sorted = axisVals.sort();
119
- const loV = trim ? sorted[Math.floor(lo * (n - 1))] : sorted[0];
120
- const hiV = trim ? sorted[Math.floor(hi * (n - 1))] : sorted[n - 1];
167
+ // Only two ORDER STATISTICS are needed, not a sorted array: selecting them is O(n) where the
168
+ // sort was O(n log n) — the same two values, bit for bit (a sort's k-th element IS the k-th
169
+ // order statistic), at a fraction of the main-thread time on a 200k-point sample (1.10.1).
170
+ const loV = selectKth(axisVals, n, kLo);
171
+ const hiV = selectKth(axisVals, n, kHi);
121
172
  center[axis] = 0.5 * (loV + hiV);
122
173
  extent[axis] = Math.max(hiV - loV, 1e-6);
123
174
  }
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.9.1",
3
+ "version": "1.10.1",
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",
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`,
@@ -265,6 +266,11 @@ export interface SplatOptions {
265
266
  * `handle.engine.root`). Anything nearer is clipped, splats included. Unset: untouched.
266
267
  */
267
268
  nearClip?: number;
269
+ /**
270
+ * PlayCanvas: draw the engine's sky box (default false). Off, nothing is drawn behind the splat
271
+ * even when a page sets `scene.envAtlas` to light its own meshes; the canvas stays transparent.
272
+ */
273
+ sky?: boolean;
268
274
  /** PlayCanvas: a CAP on the projection's far plane (only ever lowers it). Unset: untouched. */
269
275
  farClip?: number;
270
276
  /** Camera rig only: the distance in world metres that sits ON the glass. */
@@ -296,6 +302,8 @@ export interface SplatOptions {
296
302
  fileType?: 'ply' | 'spz' | 'splat' | 'ksplat' | 'pcsogs' | 'pcsogszip' | 'rad';
297
303
  /** Element whose visibility gates the lazy create/close lifecycle. */
298
304
  observe?: Element;
305
+ /** Forwarded to the core window: see `TileOptions.firstWovenHoldMs`. */
306
+ firstWovenHoldMs?: number;
299
307
  }
300
308
 
301
309
  /** `handle.stats()` on `engine: 'playcanvas'`. */
@@ -422,6 +430,11 @@ export interface SplatHandle {
422
430
  /** Mark a 2D element painted over this window so the weave leaves it crisp. */
423
431
  exclude(el: Element): void;
424
432
  unexclude(el: Element): void;
433
+ /**
434
+ * The core window's `TileHandle.firstWoven`: when it is safe to reveal the canvas. Resolves
435
+ * `{ woven: false, reason: 'unsupported' }` at once where there is no inline-3D session.
436
+ */
437
+ readonly firstWoven: Promise<FirstWovenResult>;
425
438
  }
426
439
 
427
440
  /**