@displayxr/inline3d 1.28.0 → 1.30.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.
@@ -0,0 +1,153 @@
1
+ // inline3d-splat-deferred.js — the engine:'playcanvas' handle, shared by both splat entries.
2
+ //
3
+ // Internal. ./splat (Spark by default) and ./splat/playcanvas (PlayCanvas only) both build their
4
+ // PlayCanvas handle here. This module must never import `three` or `@sparkjsdev/spark`, directly
5
+ // or through anything it imports: that is what lets ./splat/playcanvas keep both off the page
6
+ // (test/splat-pc-entry.test.mjs walks the import graph to hold it to that).
7
+
8
+ import { normalizeCameraPose } from './inline3d-splat-shared.js';
9
+ import { validateEffectCall } from './inline3d-splat-effects.js';
10
+
11
+ /**
12
+ * The `engine: 'playcanvas'` handle, returned SYNCHRONOUSLY like the Spark one.
13
+ *
14
+ * The adapter module is loaded on demand, so for a moment the handle exists before its
15
+ * implementation does. Rather than make every caller await something new, the handle starts as
16
+ * stubs that QUEUE: `exclude()` (which a product page calls on the very next line), `setPose`,
17
+ * `setFocus`, `remove` — and the adapter replays the queue into the real implementation, on the
18
+ * SAME object, the moment it arrives. `ready` resolves to this object, as it does on Spark.
19
+ * Fields (`viewer`, `mesh`, `rig`, …) are null until then; `viewer` in particular appears one
20
+ * module-load later than on Spark.
21
+ */
22
+ /** setRig's type/options shape, checked at the call before the adapter has loaded (it re-checks all of it). */
23
+ function validateSetRigArgs(type, o) {
24
+ if (!['display', 'camera', 'auto'].includes(type)) {
25
+ throw new Error(`@displayxr/inline3d/splat: setRig("${type}") — expected 'display', 'camera' or 'auto'.`);
26
+ }
27
+ if (o !== undefined && (o === null || typeof o !== 'object')) {
28
+ throw new TypeError('@displayxr/inline3d/splat: setRig options must be an object.');
29
+ }
30
+ }
31
+
32
+ export function addSplatDeferred(wall, canvas, src, opts) {
33
+ const pending = [];
34
+ const queue = (name) => (...args) => {
35
+ pending.push([name, args]);
36
+ return name === 'setFocus' ? out : undefined;
37
+ };
38
+ const page = opts.controls === 'page';
39
+ const pageOnly = (name) => () => {
40
+ throw new Error(
41
+ `@displayxr/inline3d/splat: ${name}() is not available with controls:'page' — the page owns the ` +
42
+ 'camera. Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).',
43
+ );
44
+ };
45
+ // Before the adapter lands, a page calling setCameraPose every frame keeps ONE pending pose
46
+ // (validated now, so a bad call throws at its own line): last call wins, as it will after.
47
+ let pendingPose = null;
48
+ const out = {
49
+ backend: 'playcanvas',
50
+ engine: null,
51
+ viewer: null,
52
+ mesh: null,
53
+ frame: null,
54
+ camera: null,
55
+ rig: null,
56
+ perf: null,
57
+ setPose: page ? pageOnly('setPose') : queue('setPose'),
58
+ resetPose: page ? pageOnly('resetPose') : queue('resetPose'),
59
+ setCameraPose(matrixWorld, o) {
60
+ if (!page) {
61
+ throw new Error(
62
+ "@displayxr/inline3d/splat: setCameraPose() needs addSplat(…, { controls:'page' }) — " +
63
+ 'with the default controls the SDK owns the camera (use setPose).',
64
+ );
65
+ }
66
+ const pose = normalizeCameraPose(matrixWorld, o);
67
+ if (pendingPose) pendingPose[1] = [pose.matrixWorld, pose];
68
+ else pending.push((pendingPose = ['setCameraPose', [pose.matrixWorld, pose]]));
69
+ return out;
70
+ },
71
+ getCameraPose: () =>
72
+ pendingPose
73
+ ? {
74
+ matrixWorld: Float32Array.from(pendingPose[1][1].matrixWorld),
75
+ verticalFovDeg: pendingPose[1][1].verticalFovDeg,
76
+ near: pendingPose[1][1].near,
77
+ far: pendingPose[1][1].far,
78
+ convergence: pendingPose[1][1].convergence,
79
+ }
80
+ : null,
81
+ setFocus: queue('setFocus'),
82
+ // A swap requested before the first asset has landed runs once it has (the adapter's own
83
+ // setSource replaces this stub on the same object by then).
84
+ setSource: (...args) => out.ready.then(() => out.setSource(...args)),
85
+ prepareSource: (...args) => out.ready.then(() => out.prepareSource(...args)),
86
+ // A rig switch before the first asset has landed: validated now (controls:'page' and a bad
87
+ // type throw at the call's own line), applied once it has.
88
+ setRig: page
89
+ ? pageOnly('setRig')
90
+ : (type, o) => {
91
+ validateSetRigArgs(type, o);
92
+ return out.ready.then(() => out.setRig(type, o));
93
+ },
94
+ // A video before the first asset has landed: controls:'page' throws at the call's own line,
95
+ // the rest is validated by the adapter's setVideo, which runs once the asset is on screen.
96
+ setVideo: page ? pageOnly('setVideo') : (src, o) => out.ready.then(() => out.setVideo(src, o)),
97
+ // Effects before the adapter has loaded: validated NOW (a bad call throws at its own line),
98
+ // then run once the first asset is on screen.
99
+ playEffect: (name, o) => {
100
+ validateEffectCall(name, o, 'play');
101
+ return out.ready.then(() => out.playEffect(name, o));
102
+ },
103
+ setEffect: (name, params) => {
104
+ validateEffectCall(name, params, 'set');
105
+ pending.push(['setEffect', [name, params]]);
106
+ return out;
107
+ },
108
+ stopEffect: queue('stopEffect'),
109
+ // A layer-rig request before the adapter has loaded: queued, replayed in order once it has
110
+ // (it needs no asset, only the engine's layers — which the adapter resolves lazily).
111
+ setLayerRig: (...args) => {
112
+ pending.push(['setLayerRig', args]);
113
+ return out;
114
+ },
115
+ setLayerRigOptions: (...args) => {
116
+ pending.push(['setLayerRigOptions', args]);
117
+ return out;
118
+ },
119
+ layerRigState: () => ({ display: [], disabled: false, path: null, engaged: false, rounded: false, reason: 'the PlayCanvas adapter has not loaded yet', viewerDistance: 0.6, gain: null, planeM: null, photoConvergenceM: null, planeOffset: 0, located: null }),
120
+ makeSbsMaterial: () => {
121
+ throw new Error('@displayxr/inline3d/splat: makeSbsMaterial() needs the engine — call it after `await handle.ready`.');
122
+ },
123
+ effects: () => [],
124
+ getFocus: () => null,
125
+ // A plain data slot the adapter reads at call time, so a callback assigned on the very next
126
+ // line after addSplat — before the module has loaded — is the one that fires.
127
+ onFocusChange: null,
128
+ pick: () => null,
129
+ // Splat accounting (resident / budget / first frame); null until the adapter has loaded.
130
+ stats: () => null,
131
+ remove: queue('remove'),
132
+ exclude: queue('exclude'),
133
+ unexclude: queue('unexclude'),
134
+ };
135
+ // `firstWoven` exists from the first line, like every other field a page reads right away; the
136
+ // adapter settles it with the core handle's own once the module has loaded.
137
+ out.firstWoven = new Promise((resolve) => {
138
+ out._resolveFirstWoven = resolve;
139
+ });
140
+ // The ONE owner of `ready`: the adapter returns its load promise and never touches this field.
141
+ out.ready = import('./inline3d-splat-playcanvas.js')
142
+ .then((m) => m.attachPlayCanvasSplat(out, wall, canvas, src, opts, pending))
143
+ .catch((err) => {
144
+ // The adapter warns about its own load failures; this is for the module not arriving.
145
+ if (!out.viewer) console.warn('[inline3d/splat] engine:playcanvas failed to start', err);
146
+ if (out._resolveFirstWoven) {
147
+ out._resolveFirstWoven(Object.freeze({ woven: false, confirmed: false, reason: 'layer-failed', ms: 0 }));
148
+ delete out._resolveFirstWoven;
149
+ }
150
+ throw err;
151
+ });
152
+ return out;
153
+ }
@@ -0,0 +1,45 @@
1
+ // inline3d-splat-pc-entry.js — `@displayxr/inline3d/splat/playcanvas`: addSplat on PlayCanvas only.
2
+ //
3
+ // EXPERIMENTAL, like ./splat. Same addSplat, same handle, same options, with the engine fixed to
4
+ // 'playcanvas'. The difference is what the page downloads: ./splat statically imports `three`
5
+ // and `@sparkjsdev/spark` because Spark is its default engine and its Spark path is synchronous,
6
+ // so a page passing engine:'playcanvas' there still pays for both (~1.7 MB gzipped). This entry
7
+ // imports neither, directly or transitively. `playcanvas` itself is still loaded on demand, when
8
+ // the first splat is added.
9
+ //
10
+ // import { createInline3D } from '@displayxr/inline3d';
11
+ // import { addSplat } from '@displayxr/inline3d/splat/playcanvas';
12
+ //
13
+ // const wall = await createInline3D();
14
+ // const scene = addSplat(wall, canvas, 'lake.sog', { reveal: 'sweep' });
15
+ //
16
+ // Not here: measureSplatBounds (it measures a Spark mesh — import it from ./splat).
17
+
18
+ import { resolveRevealOption } from './inline3d-splat-effects.js';
19
+ import { CAPTURE_FITS, playcanvasCannotRead, resolveControls } from './inline3d-splat-shared.js';
20
+ import { addSplatDeferred } from './inline3d-splat-deferred.js';
21
+
22
+ export { applySplatPerf, SPLAT_PERF_PRESETS } from './inline3d-splat-perf.js';
23
+ export { readSogCamera, readSogMeta } from './inline3d-sog.js';
24
+ export { resolveRig } from './inline3d-splat-rig.js';
25
+
26
+ /** addSplat with `engine: 'playcanvas'`; the same validation, at the call, as ./splat's. */
27
+ export function addSplat(wall, canvas, src, opts = {}) {
28
+ if (opts.engine !== undefined && opts.engine !== 'playcanvas') {
29
+ throw new Error(
30
+ `@displayxr/inline3d/splat/playcanvas: engine "${opts.engine}" — this entry is PlayCanvas ` +
31
+ "only. Import addSplat from '@displayxr/inline3d/splat' for Spark.",
32
+ );
33
+ }
34
+ if (opts.captureFit !== undefined && !CAPTURE_FITS.includes(opts.captureFit)) {
35
+ throw new Error(
36
+ `@displayxr/inline3d/splat: captureFit "${opts.captureFit}" — expected ` +
37
+ `${CAPTURE_FITS.map((f) => `'${f}'`).join(' or ')}.`,
38
+ );
39
+ }
40
+ resolveControls(opts);
41
+ resolveRevealOption(opts.reveal);
42
+ const why = playcanvasCannotRead(src, opts);
43
+ if (why) throw new Error(`@displayxr/inline3d/splat: ${why}`);
44
+ return addSplatDeferred(wall, canvas, src, { ...opts, engine: 'playcanvas' });
45
+ }
@@ -1915,6 +1915,9 @@ export class PlayCanvasSplatViewer {
1915
1915
  const box = this.canvas.getBoundingClientRect();
1916
1916
  const aspect = box.height > 0 ? box.width / box.height : 1;
1917
1917
  this.boxAspect = aspect; // setVideo's plane reads it per frame (no layout read in the draw)
1918
+ // and this, for its on-screen rect: a NEW object only when the size changed (the plane's rect
1919
+ // check compares identity, so an unchanged frame allocates nothing).
1920
+ if (!this.boxCss || this.boxCss.w !== (box.width || 0) || this.boxCss.h !== (box.height || 0)) this.boxCss = { w: box.width || 0, h: box.height || 0 };
1918
1921
  if (this.mono.capture) {
1919
1922
  captureProjection(this.mono.capture, aspect, this.mono.near, this.mono.far, this.mono.proj, this.captureFit);
1920
1923
  if (this.captureFit !== 'height') {
@@ -3125,6 +3128,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3125
3128
  // copy a getter's value once.
3126
3129
  Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
3127
3130
  Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
3131
+ // setVideo's current element: the <video> the plane is SHOWING (not one still waiting for its first
3132
+ // frame), else null. Read-only. A player attached to this handle (./player attachPlayer) reads it
3133
+ // to notice another setVideo taking the slot, which no event reports.
3134
+ Object.defineProperty(out, 'videoElement', { get: () => (vid && vid.on ? vid.el : null), enumerable: true, configurable: true });
3135
+ // The tile's canvas, for page chrome that sits beside it (attachPlayer's controls are its siblings).
3136
+ Object.defineProperty(out, 'canvas', { value: canvas, enumerable: true, configurable: true });
3128
3137
  // The current eye offset (after the unit-disc clamp), a fresh object per read.
3129
3138
  Object.defineProperty(out, 'viewOffset', {
3130
3139
  get: () => ({ x: viewer._viewOffset[0], y: viewer._viewOffset[1] }),
@@ -4405,7 +4414,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4405
4414
  * handle.setVideo(src, options) — play a stereo video on THIS handle: no new canvas, layer or
4406
4415
  * session. The splat is hidden and a screen-locked plane on the display rig shows the video, each
4407
4416
  * eye its own half (sbs: left/right, tb: top/bottom); flat (mono), the left half at full
4408
- * resolution. Applies on the first frame the video has. Resolves to `{ video, remove(), stats() }`;
4417
+ * resolution. Applies on the first frame the video has; `transition: 'crossfade'` (with
4418
+ * `durationMs`, `easing`) dissolves from a video already on the plane, on the GPU, and is a cut
4419
+ * from the splat. Resolves to `{ video, transition, remove(), stats() }`;
4409
4420
  * the page drives transport through `video`. setVideo(null) exits and restores the splat, the rig
4410
4421
  * and its declaration exactly as they were. Throws during an in-flight setSource.
4411
4422
  */
@@ -4432,7 +4443,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4432
4443
  if (owned ? r.autoplay !== false : r.autoplay === true) autoplay(el);
4433
4444
  // The guard state is taken NOW (setSource / setRig refuse from this call on), the pixels change
4434
4445
  // on the first frame the video has.
4435
- const state = { seq, el, owned, format: r.format, fit: r.fit, vH: r.vH ?? bootFraming.vH, on: false, saved: null, pending: true };
4446
+ const state = { seq, el, owned, format: r.format, fit: r.fit, band: r.band, vH: r.vH ?? bootFraming.vH, on: false, saved: null, pending: true, rectOffs: new Set() };
4436
4447
  if (!prev || !prev.on) vid = state; // a pending one it supersedes is cancelled above
4437
4448
  return first
4438
4449
  .catch(() => null)
@@ -4444,6 +4455,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4444
4455
  // Another video on screen: keep ITS snapshot (the pre-video state), swap the source.
4445
4456
  state.saved = live.saved;
4446
4457
  state.on = true;
4458
+ // S2: the plane outlives this swap; the replaced video's rect subscriptions go with it.
4459
+ for (const off of live.rectOffs) off();
4460
+ live.rectOffs.clear();
4447
4461
  if (live.owned && live.el !== el) releaseOwned(live.el);
4448
4462
  if (state.vH !== live.vH) {
4449
4463
  viewer.vH = state.vH;
@@ -4454,13 +4468,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4454
4468
  } else enterVideo(state);
4455
4469
  vid = state;
4456
4470
  state.pending = false;
4471
+ // A crossfade only dissolves between two videos: from the splat (entry) it is a cut.
4472
+ const fade = live && r.transition.type === 'crossfade' && r.transition.durationMs > 0 ? r.transition : null;
4457
4473
  viewer._videoPlane ||= new VideoPlane(viewer);
4458
- viewer._videoPlane.setSource(el, { format: state.format, fit: state.fit, vH: state.vH });
4474
+ const faded = viewer._videoPlane.setSource(el, { format: state.format, fit: state.fit, vH: state.vH, band: state.band }, fade);
4459
4475
  const plane = viewer._videoPlane;
4460
4476
  return Object.freeze({
4461
4477
  video: el,
4462
4478
  format: state.format,
4463
4479
  fit: state.fit,
4480
+ /** The letterbox band's aspect, or null. */
4481
+ band: state.band,
4482
+ /**
4483
+ * Where the picture is on screen, in CSS px from the canvas's top-left, clipped to the
4484
+ * canvas: `{ x, y, width, height }`. The bars are the canvas minus this. Null once
4485
+ * another setVideo replaced this one, or before the first drawn frame.
4486
+ */
4487
+ get rect() {
4488
+ return vid === state && viewer._videoPlane === plane ? plane.rect : null;
4489
+ },
4490
+ /** Called with the new rect whenever it changes (resize, band, a new size). Returns an unsubscribe. */
4491
+ onRectChange(cb) {
4492
+ if (typeof cb !== 'function') throw new TypeError('@displayxr/inline3d/splat: onRectChange(cb) — expected a function.');
4493
+ if (vid !== state) return () => {};
4494
+ // S2: never outlives this video — every subscription is dropped at the swap that
4495
+ // replaces it (and with the plane on setVideo(null)).
4496
+ // Each subscription is its own entry on the plane, so calling this twice is harmless.
4497
+ const off = plane.onRect(cb);
4498
+ const unsubscribe = () => {
4499
+ off();
4500
+ state.rectOffs.delete(unsubscribe);
4501
+ };
4502
+ state.rectOffs.add(unsubscribe);
4503
+ return unsubscribe;
4504
+ },
4505
+ /** What this swap did: 'crossfade' only when a fade actually started, else 'cut'. */
4506
+ transition: faded ? 'crossfade' : 'cut',
4464
4507
  /** Exit (setVideo(null)) — a no-op once another setVideo replaced this one. */
4465
4508
  remove: () => (vid === state ? setVideo(null) : Promise.resolve(null)),
4466
4509
  /** Upload accounting: frames drawn with the plane up, and texture uploads (new frames). */