@displayxr/inline3d 1.13.1 → 1.15.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.
@@ -369,6 +369,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
369
369
  "only; with the Spark backend, remove() this handle and addSplat() the new asset.",
370
370
  );
371
371
  },
372
+ /** Not on this backend: prepareSource() feeds setSource(), a PlayCanvas-backend feature. */
373
+ prepareSource() {
374
+ throw new Error("@displayxr/inline3d/splat: prepareSource() is implemented on the PlayCanvas backend only.");
375
+ },
372
376
  /** Not on this backend (splat effects are PlayCanvas-only in this version). */
373
377
  playEffect() {
374
378
  throw effectsNotOnSpark('playEffect()');
@@ -706,6 +710,7 @@ function addSplatDeferred(wall, canvas, src, opts) {
706
710
  // A swap requested before the first asset has landed runs once it has (the adapter's own
707
711
  // setSource replaces this stub on the same object by then).
708
712
  setSource: (...args) => out.ready.then(() => out.setSource(...args)),
713
+ prepareSource: (...args) => out.ready.then(() => out.prepareSource(...args)),
709
714
  // Effects before the adapter has loaded: validated NOW (a bad call throws at its own line),
710
715
  // then run once the first asset is on screen.
711
716
  playEffect: (name, o) => {
@@ -152,7 +152,30 @@ function swap(a, i, j) {
152
152
  * Set 0 to get the old percentile-only box back.
153
153
  * @returns {{center:number[], extent:number[]}|null} null if there is nothing to measure.
154
154
  */
155
- export function boundsFromPositions(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 } = {}) {
155
+ export function boundsFromPositions(xyz, opts) {
156
+ const it = boundsSteps(xyz, opts);
157
+ let r = it.next();
158
+ while (!r.done) r = it.next();
159
+ return r.value;
160
+ }
161
+
162
+ /**
163
+ * boundsFromPositions, in steps: the same code, the same numbers bit for bit, but a caller can
164
+ * give the main thread back between the per-axis selections and the window pass. On a 1.18M
165
+ * gaussian cloud under 4× CPU throttling the one-shot version was a single 60-80 ms task.
166
+ * `await boundsFromPositionsAsync(xyz, opts, yielder)` runs it with `yielder()` between steps.
167
+ */
168
+ export async function boundsFromPositionsAsync(xyz, opts, yielder) {
169
+ const it = boundsSteps(xyz, opts);
170
+ let r = it.next();
171
+ while (!r.done) {
172
+ await yielder();
173
+ r = it.next();
174
+ }
175
+ return r.value;
176
+ }
177
+
178
+ function* boundsSteps(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 } = {}) {
156
179
  const n = Math.floor(xyz.length / 3);
157
180
  if (n < 1) return null;
158
181
  // Below a few hundred points the percentiles are noise — just use the true box.
@@ -171,6 +194,7 @@ export function boundsFromPositions(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 }
171
194
  const hiV = selectKth(axisVals, n, kHi);
172
195
  center[axis] = 0.5 * (loV + hiV);
173
196
  extent[axis] = Math.max(hiV - loV, 1e-6);
197
+ yield;
174
198
  }
175
199
  // Untrimmed already IS the true box, and expand 0 asks for the old behaviour.
176
200
  if (!trim || expand <= 0) return { center, extent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.13.1",
3
+ "version": "1.15.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",
@@ -51,6 +51,7 @@
51
51
  "js/inline3d-splat-playcanvas.js",
52
52
  "js/inline3d-splat-shared.js",
53
53
  "js/inline3d-splat-effects.js",
54
+ "js/inline3d-splat-live.js",
54
55
  "js/inline3d-model.js",
55
56
  "js/inline3d-model-entry.js",
56
57
  "js/inline3d-model-playcanvas.js",
package/splat.d.ts CHANGED
@@ -336,7 +336,66 @@ export interface SplatOptions {
336
336
  }
337
337
 
338
338
  /** The transition effects `reveal` accepts. */
339
- export type SplatRevealName = 'inflate' | 'sweep' | 'dissolve' | 'fade';
339
+ export type SplatRevealName = 'inflate' | 'sweep' | 'dissolve' | 'fade' | SplatParticleRevealName;
340
+
341
+ /** The particle reveals: every gaussian is a particle with its own start time (docs/splat-effects.md §Particle reveals). */
342
+ export type SplatParticleRevealName = 'assemble' | 'dissolve-in' | 'converge' | 'shimmer';
343
+
344
+ /**
345
+ * Options every particle reveal takes (plus its timing). All sizes are in PICTURE units, so the
346
+ * look is the same on a 2 cm object and a 40 m street.
347
+ */
348
+ export interface SplatParticleOptions {
349
+ /**
350
+ * What staggers the particles: `'radial'` (distance in the picture from the origin), `'depth'`
351
+ * (near first), `'noise'` (fbm patches), `'random'`, or `'layers'` (a SHARP photo's grid order:
352
+ * layer 0, the visible surface, then layer 1, the disocclusion fill, each outward from the
353
+ * origin — reads `splat.index`, so it forces `scope: 'entity'`).
354
+ */
355
+ order?: 'radial' | 'depth' | 'noise' | 'random' | 'layers';
356
+ /** Fraction of the duration spent launching particles, 0..0.95. */
357
+ stagger?: number;
358
+ /** How much of each particle's key is random, 0..1. */
359
+ jitter?: number;
360
+ /** A particle in flight is a dot this size (a fraction of the view width). */
361
+ dotSize?: number;
362
+ /** A particle grows back to its own splat over the last (1 − grow) of its flight. */
363
+ grow?: number;
364
+ /** In-flight alpha multiplier, 0..1. */
365
+ flightAlpha?: number;
366
+ /** In-flight tint, added as `color · glow`. */
367
+ color?: [number, number, number];
368
+ glow?: number;
369
+ /** Size of the noise patches, per half-view-width. */
370
+ noiseScale?: number;
371
+ /**
372
+ * Comfort cap: no particle is ever nearer the eyes than its home depth by more than this much
373
+ * extra disparity, a fraction of the eye view's width (default 0.004; 0 = never nearer than
374
+ * home; max 0.05). In 2D a nominal 64 mm-at-1.7 m separation is assumed.
375
+ */
376
+ maxDisparity?: number;
377
+ /** Fade a particle over the first `vanish` of its flight (0 = off, the reveals' default; the particle transitions use ~0.3–0.45). */
378
+ vanish?: number;
379
+ /** Share of the gaussians drawn while in flight, 0..1 (1 = all, the reveals' default; the swarm uses 0.2). */
380
+ density?: number;
381
+ /** `order: 'layers'`: gaussians per layer (default 768² = 589 824, SHARP). */
382
+ layerSize?: number;
383
+ /** `assemble`: how far out the swarm starts (half-view-widths), its spiral (rad), its curl turbulence, how coherent its start field is (0..1), and how much of it reaches back in depth (0..1). */
384
+ spread?: number;
385
+ swirl?: number;
386
+ turbulence?: number;
387
+ coherence?: number;
388
+ depth?: number;
389
+ /** `dissolve-in`: the wind's lift and the random drift (half-view-widths). */
390
+ lift?: number;
391
+ drift?: number;
392
+ /** `converge`: the spiral (rad) and the launch ball's radius (half-view-widths). */
393
+ spin?: number;
394
+ burst?: number;
395
+ /** `shimmer`: twinkle rate (rad/s) and sparkle strength. */
396
+ twinkle?: number;
397
+ sparkle?: number;
398
+ }
340
399
 
341
400
  /** Named easings; a function `(x) => y` on [0, 1] also works. */
342
401
  export type SplatEasing =
@@ -397,7 +456,7 @@ export interface SplatCustomEffect extends SplatEffectTiming {
397
456
  hold?: boolean;
398
457
  }
399
458
 
400
- export type SplatEffectName = 'inflate' | 'deflate' | 'sweep' | 'dissolve' | 'fade' | 'pulse' | 'grade' | 'clip' | 'custom' | `custom:${string}`;
459
+ export type SplatEffectName = 'inflate' | 'deflate' | 'sweep' | 'dissolve' | 'fade' | SplatParticleRevealName | 'pulse' | 'grade' | 'clip' | 'custom' | `custom:${string}`;
401
460
 
402
461
  /** `setEffect('grade', …)`. */
403
462
  export interface SplatGradeParams {
@@ -442,8 +501,15 @@ export interface SplatSourceOptions {
442
501
  * inflates out of its own; default 2200 ms), `'wavefront'` (a soft front crosses left → right
443
502
  * with a thin depth ridge riding it; default 2000 ms, ease-in-out; falls back to the crossfade
444
503
  * in a hidden tab). For photo slideshows: `crossfade` or `wavefront`.
445
- */
446
- transition?: 'cut' | 'crossfade' | 'flip' | 'wavefront';
504
+ *
505
+ * The particle transitions (docs/splat-effects.md §Particle transitions): `'swarm'` (the old
506
+ * photo disperses into a curl-noise swarm while the new one assembles out of one), `'burst'`
507
+ * (the old one collapses into its focus, the new one bursts out of its own), `'shimmer-cross'`
508
+ * (nothing moves: twinkling points out, twinkling points in) and `'dust'` (drifting dust out,
509
+ * gathering dust in). 2.6–2.8 s, linear shared clock (each particle eases itself). The old
510
+ * photo is live, in 2D too; a hidden tab gets the crossfade.
511
+ */
512
+ transition?: 'cut' | 'crossfade' | 'flip' | 'wavefront' | SplatParticleTransitionName;
447
513
  durationMs?: number;
448
514
  easing?: SplatEasing;
449
515
  /** `cut`/`crossfade` only: reveal the INCOMING asset (entity scope) while the old one fades. */
@@ -454,6 +520,55 @@ export interface SplatSourceOptions {
454
520
  ridge?: number;
455
521
  /** `wavefront`: the ridge's disparity cap, a fraction of the eye view's width (default 0.004, max 0.05). */
456
522
  ridgeMaxDisparity?: number;
523
+ /**
524
+ * `crossfade` / `wavefront`: how the OUTGOING photo is shown during the window.
525
+ * `'live'` (the default in a woven 3D session): it stays resident and keeps rendering every frame
526
+ * through the same eye views (head motion included) into its own target, blended per eye with
527
+ * the live incoming one — about 2× draw for the window, both assets resident. `'frozen'` (the
528
+ * default in 2D): its last frame, frozen into a texture (1.12.1); one draw, but no head parallax
529
+ * on the outgoing photo — on a tracked panel it reads as tracking pausing. Engine builds without
530
+ * the single-camera RenderView path always use `'frozen'`.
531
+ */
532
+ outgoing?: 'live' | 'frozen';
533
+ /** Particle transitions: particle options for BOTH photos (the shared ones of the particle reveals). */
534
+ order?: SplatParticleOptions['order'];
535
+ stagger?: number;
536
+ jitter?: number;
537
+ maxDisparity?: number;
538
+ dotSize?: number;
539
+ noiseScale?: number;
540
+ layerSize?: number;
541
+ origin?: SplatEffectOrigin;
542
+ /** Particle transitions: how much of the clock the two photos' spans share, 0..1 (0 = one after the other). */
543
+ overlap?: number;
544
+ /** Particle transitions: option overrides for the OUTGOING photo's effect. */
545
+ outgoingFx?: SplatParticleOptions & Record<string, unknown>;
546
+ /** Particle transitions: option overrides for the INCOMING photo's effect. */
547
+ incomingFx?: SplatParticleOptions & Record<string, unknown>;
548
+ }
549
+
550
+ /**
551
+ * `prepareSource`'s options: the `setSource` options the prepared asset will be swapped in with.
552
+ * For a particle transition, its shaders are compiled and linked NOW, in the dwell — otherwise the
553
+ * first transition of each kind in a page blocks its first frame on the link (tens of ms, hundreds
554
+ * the first time a machine sees the variant). Validated like setSource's.
555
+ */
556
+ export type SplatPrepareOptions = SplatSourceOptions;
557
+
558
+ /** setSource's particle transitions (docs/splat-effects.md §Particle transitions). */
559
+ export type SplatParticleTransitionName = 'swarm' | 'burst' | 'shimmer-cross' | 'dust';
560
+
561
+ /**
562
+ * What `prepareSource()` resolves to: an opaque, single-use handle for `setSource`. The asset is
563
+ * fully resident (GPU textures + the engine's centre array) until used or disposed.
564
+ */
565
+ export interface SplatPreparedSource {
566
+ /** The asset's own count (every splat of a flat source). */
567
+ readonly numSplats: number;
568
+ /** `'ready'` until `setSource` uses it (`'used'`) or `dispose()` drops it (`'disposed'`). */
569
+ readonly state: 'ready' | 'used' | 'disposed';
570
+ /** Release the prepared asset (no-op once used or disposed). */
571
+ dispose(): void;
457
572
  }
458
573
 
459
574
  /** What `onBeforeFrame` receives. */
@@ -592,20 +707,32 @@ export interface SplatHandle {
592
707
  /**
593
708
  * Swap the asset (URL or bytes) in place. PlayCanvas backend only — throws on Spark.
594
709
  *
595
- * The new file loads BEHIND the current one; then the two crossfade over `fadeMs` (0 = a cut)
596
- * and the old one is released. The crossfade blends the two IMAGES linearly: the last frame of
597
- * the old asset is frozen (both eyes) and lerped with the live new one, so the mix is exactly
598
- * `t` whatever the two photos' depth order (the old asset's own motion stops during the fade). The rig waterfall re-runs for the new file (rig, lens, focus and
710
+ * The new file loads BEHIND the current one (or pass a `prepareSource()` result: no load at all
711
+ * on this path); then the two crossfade over `fadeMs` (0 = a cut) and the old one is released.
712
+ * The crossfade blends the two IMAGES linearly, per eye, so the mix is exactly `t` whatever the
713
+ * two photos' depth order. The old image is LIVE in a woven 3D session (it keeps rendering with
714
+ * head motion; `outgoing: 'frozen'` restores the 1.12.1 frozen last frame, the 2D default). The
715
+ * end state is exactly a plain swap. The rig waterfall re-runs for the new file (rig, lens, focus and
599
716
  * frame update; `onFocusChange` fires). The pose (yaw/pitch/zoom/depth) is kept unless
600
717
  * `resetPose`. A newer call supersedes an older one still loading. Resolves once the fade has
601
718
  * finished; rejects if the new asset cannot be loaded (the current one stays on screen).
602
719
  */
603
- setSource(src: string | Blob | ArrayBuffer | Uint8Array, opts?: SplatSourceOptions): Promise<SplatHandle>;
720
+ setSource(src: string | Blob | ArrayBuffer | Uint8Array | SplatPreparedSource, opts?: SplatSourceOptions): Promise<SplatHandle>;
721
+ /**
722
+ * PlayCanvas backend only — throws on Spark. Fetch, decode and upload `src` now, in the
723
+ * background (the SDK's own passes in idle periods; the engine's end-of-load unpack runs when
724
+ * it must), without rendering it. `setSource(prepared, opts)` then starts on the next frame with
725
+ * no load on the transition path. Single use; `dispose()` it if the page changes its mind.
726
+ * Memory: two full assets resident until the swap (≈ +70 MB of GPU textures per 1.18M-gaussian
727
+ * SOG). `remove()` disposes any still unused.
728
+ */
729
+ prepareSource(src: string | Blob | ArrayBuffer | Uint8Array, opts?: SplatPrepareOptions): Promise<SplatPreparedSource>;
604
730
  /**
605
731
  * `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
606
732
  * validated at the call, run once the first asset is on screen. Resolves `{ finished }` —
607
733
  * false when stopped or replaced. docs/splat-effects.md.
608
734
  */
735
+ playEffect(name: SplatParticleRevealName, opts?: Omit<SplatEffectTiming, 'direction'> & SplatParticleOptions): Promise<{ finished: boolean }>;
609
736
  playEffect(
610
737
  name: Exclude<SplatEffectName, 'grade' | 'clip'>,
611
738
  opts?: SplatEffectTiming & Record<string, unknown>,