@displayxr/inline3d 1.24.0 → 1.25.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.
@@ -68,7 +68,7 @@ import {
68
68
  } from './inline3d-splat-effects.js';
69
69
  import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
70
70
  import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR, eyeSplit, EYE_SPLIT_UNIFORM, makeSbsMaterial } from './inline3d-splat-video.js';
71
- import { LayerRigCameras, validateLayerRig } from './inline3d-splat-layer-rig.js';
71
+ import { LayerRigCameras, validateLayerRig, validateLayerRigOptions, DEFAULT_VIEWER_DISTANCE_M } from './inline3d-splat-layer-rig.js';
72
72
  import { RigTracker, remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
73
73
  import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, DIAG_SWITCHES } from './inline3d-splat-diag.js';
74
74
  import {
@@ -1940,8 +1940,11 @@ export class PlayCanvasSplatViewer {
1940
1940
  // setSource's wavefront: this frame's eye views, as the engine is about to compose them.
1941
1941
  this.onBeforeRender?.(entries, rect);
1942
1942
  // handle.setLayerRig: the display / post run cameras, on the same views (display: rounded).
1943
- if (this.layerRigs && this.layerRigs.sync()) {
1944
- this.layerRigs.frame(eyeViews ? rf.cull : entries, rect, f, eyeViews ? rf.eye.rig : rf.located ? rf.located.rig : cache ? cache.rig : null);
1943
+ if (this.layerRigs) {
1944
+ this.layerRigs.frame(eyeViews ? rf.cull : entries, rect, f, eyeViews ? rf.eye.rig : rf.located ? rf.located.rig : cache ? cache.rig : null, {
1945
+ located: this.rigTrack && entries.length > 1 ? !!rf.located : null,
1946
+ residual: this.rigTrack?.lastResidual ?? null,
1947
+ });
1945
1948
  }
1946
1949
  } else {
1947
1950
  // Fallback: one camera per view, `rect` + `calculateProjection`.
@@ -1974,6 +1977,15 @@ export class PlayCanvasSplatViewer {
1974
1977
  cam.camera.rect = new pc.Vec4(x / W, y / H, w / W, h / H);
1975
1978
  placeNode(cam, e.pose);
1976
1979
  }
1980
+ // handle.setLayerRig on the N-camera path: N display / post cameras mirroring these, the
1981
+ // views as located (this path draws the photo through them too — no remap here).
1982
+ if (this.layerRigs) {
1983
+ const rf = this._mapViews(entries, cache ? cache.rigAt : null);
1984
+ this.layerRigs.frame(entries, rect, null, rf.located ? rf.located.rig : cache ? cache.rig : null, {
1985
+ located: this.rigTrack && entries.length > 1 ? !!rf.located : null,
1986
+ residual: this.rigTrack?.lastResidual ?? null,
1987
+ });
1988
+ }
1977
1989
  }
1978
1990
  this._updateFeather(entries[0].width * sx, entries[0].height * sy);
1979
1991
  // handle.makeSbsMaterial: the eye split, scene-wide (a material only declares the uniform).
@@ -2949,7 +2961,6 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2949
2961
  let removed = false;
2950
2962
  // handle.setLayerRig: the per-rig cameras and where they read the declared rig from.
2951
2963
  // Kill switch `nolayerrig`: requests are recorded but every layer stays on the eye camera.
2952
- let warnedLayerRigPath = false;
2953
2964
  function layerRigs() {
2954
2965
  if (!viewer.layerRigs) {
2955
2966
  viewer.layerRigs = new LayerRigCameras(viewer);
@@ -3044,26 +3055,31 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3044
3055
  * on the camera rig ('camera'). No second full pass: one extra camera over the named layers,
3045
3056
  * into the same target, in composition order. docs/playcanvas-adapter.md §setLayerRig and
3046
3057
  * docs/proposals/layer-display-rig.md (the exact mapping, and why it is not Kooima).
3047
- * `viewerDistance` (m) / `gain` set the rounding for the whole tile (last call wins).
3058
+ * Options (`viewerDistance`, `gain`, `planeOffset`, `planeDistance`) are tile-wide and MERGE
3059
+ * (a key given replaces; `null` clears) — see setLayerRigOptions.
3060
+ * Works on both view paths (RenderViews and the N-camera fallback); layerRigState() says
3061
+ * whether it engaged and, if not, why.
3048
3062
  */
3049
3063
  setLayerRig(layer, rig, o = {}) {
3050
3064
  const r = validateLayerRig(layer, rig, o);
3051
- if (viewer._viewPath === 'cameras' && !warnedLayerRigPath) {
3052
- warnedLayerRigPath = true;
3053
- console.warn('[inline3d/splat] setLayerRig needs the engine RenderView path; on the N-camera fallback the layer stays on the photo rig.');
3054
- }
3055
- layerRigs().set(layer, r.rig, { viewerDistance: r.viewerDistance, gain: r.gain });
3065
+ layerRigs().set(layer, r.rig, r.opts);
3066
+ return out;
3067
+ },
3068
+ /** Change the layer rig's tile-wide options live (no layer change): merge, `null` clears. */
3069
+ setLayerRigOptions(o = {}) {
3070
+ layerRigs().setOptions(validateLayerRigOptions(o, 'setLayerRigOptions'));
3056
3071
  return out;
3057
3072
  },
3058
- /** The layers on the display rig (as the page named them), and what the last frame did. */
3073
+ /**
3074
+ * What setLayerRig is doing: `path` ('renderviews' | 'ncamera' | 'mono') and `engaged` (the
3075
+ * display-rig views were applied on the last drawn frame), `reason` when not, and the numbers
3076
+ * (`viewerDistance`, `gain`, `planeM` — the plane that lands on the glass, as a distance from
3077
+ * the photo's camera — `photoConvergenceM`, `planeOffset`, `located`).
3078
+ */
3059
3079
  layerRigState() {
3060
3080
  const lr = viewer.layerRigs;
3061
- return {
3062
- display: lr ? [...lr.requests.keys()] : [],
3063
- disabled: !!lr?.disabled,
3064
- rounded: !!lr?.last.rounded,
3065
- gain: lr?.last.gain ?? null,
3066
- };
3081
+ if (lr) return lr.state();
3082
+ return { display: [], disabled: false, path: null, engaged: false, rounded: false, reason: 'no layer on the display rig', viewerDistance: DEFAULT_VIEWER_DISTANCE_M, gain: null, planeM: null, photoConvergenceM: null, planeOffset: 0, located: null };
3067
3083
  },
3068
3084
  /**
3069
3085
  * An unlit material showing the left half of `texture` to left-eye views and the right half to
@@ -3248,8 +3264,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3248
3264
  if (opts.displayRigLayers !== undefined) {
3249
3265
  const d = opts.displayRigLayers;
3250
3266
  const list = Array.isArray(d) ? d : d && Array.isArray(d.layers) ? d.layers : null;
3251
- if (!list) throw new TypeError('@displayxr/inline3d/splat: displayRigLayers — expected an array of layers, or { layers, viewerDistance?, gain? }.');
3252
- const o = Array.isArray(d) ? {} : { ...(d.viewerDistance !== undefined ? { viewerDistance: d.viewerDistance } : {}), ...(d.gain !== undefined ? { gain: d.gain } : {}) };
3267
+ if (!list) throw new TypeError('@displayxr/inline3d/splat: displayRigLayers — expected an array of layers, or { layers, viewerDistance?, gain?, planeOffset?, planeDistance? }.');
3268
+ const o = Array.isArray(d) ? {} : Object.fromEntries(Object.entries(d).filter(([k]) => k !== 'layers'));
3253
3269
  for (const l of list) out.setLayerRig(l, 'display', o);
3254
3270
  }
3255
3271
  // Replay what the page did before this module arrived — exclude() above all, which a product
@@ -385,6 +385,9 @@ export function addSplat(wall, canvas, src, opts = {}) {
385
385
  setLayerRig() {
386
386
  throw new Error("@displayxr/inline3d/splat: setLayerRig() is implemented on the PlayCanvas backend only (addSplat(…, { engine:'playcanvas' })).");
387
387
  },
388
+ setLayerRigOptions() {
389
+ throw new Error("@displayxr/inline3d/splat: setLayerRigOptions() is implemented on the PlayCanvas backend only (addSplat(…, { engine:'playcanvas' })).");
390
+ },
388
391
  /** Not on this backend: per-eye SBS materials are a PlayCanvas-backend feature. */
389
392
  makeSbsMaterial() {
390
393
  throw new Error("@displayxr/inline3d/splat: makeSbsMaterial() is implemented on the PlayCanvas backend only (addSplat(…, { engine:'playcanvas' })).");
@@ -773,7 +776,11 @@ function addSplatDeferred(wall, canvas, src, opts) {
773
776
  pending.push(['setLayerRig', args]);
774
777
  return out;
775
778
  },
776
- layerRigState: () => ({ display: [], disabled: false, rounded: false, gain: null }),
779
+ setLayerRigOptions: (...args) => {
780
+ pending.push(['setLayerRigOptions', args]);
781
+ return out;
782
+ },
783
+ 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 }),
777
784
  makeSbsMaterial: () => {
778
785
  throw new Error('@displayxr/inline3d/splat: makeSbsMaterial() needs the engine — call it after `await handle.ready`.');
779
786
  },
package/js/inline3d.js CHANGED
@@ -130,6 +130,34 @@ const DISPLAY_MODE_METHODS = ['getDisplayInfo', 'getRenderingModes', 'requestRen
130
130
  // mode or hardware-state change even while its tile's layer is closed (lazy mode), and one
131
131
  // subscription covers every window in the document.
132
132
  const DISPLAY_EVENTS = ['renderingmodechange', 'hardwaredisplaystatechange'];
133
+ // TRACKING STATE — is anyone in the display's 3D zone right now?
134
+ //
135
+ // `session.trackingState` is a DOMString ('tracking' | 'searching' | 'unknown', filled in on every
136
+ // frame) and `trackingstatechange` is a PLAIN Event with no payload, so the state is always READ
137
+ // off the session and never parsed out of the event. It comes from the DisplayXR runtime, which
138
+ // may hand the decision to the vendor plug-in (DisplayXR Browser patch 0195).
139
+ //
140
+ // 'searching' means the runtime's derived isTracking went false: nobody is in the display's
141
+ // supported 3D zone, or the display is in an untracked / 2D mode. It does NOT necessarily mean the
142
+ // tracker lost a face that is still there, and a page must not word its UI as if it did.
143
+ //
144
+ // An older browser has NEITHER the attribute NOR the event. That is not warning-worthy — it is
145
+ // every release up to and including 1.0.5 — so the SDK reports 'unknown', silently, and every
146
+ // consumer of this surface treats 'unknown' as "no opinion".
147
+ const TRACKING_EVENT = 'trackingstatechange';
148
+ const TRACKING_STATES = ['unknown', 'tracking', 'searching'];
149
+ // Everything on()/off() accept. `onDisplayModeChange` deliberately still fans out over
150
+ // DISPLAY_EVENTS alone: pages pass it one callback shaped for a mode event.
151
+ const MANAGER_EVENTS = [...DISPLAY_EVENTS, TRACKING_EVENT];
152
+ /** This session's tracking state, or null when the browser does not expose one at all. */
153
+ function readTrackingState(session) {
154
+ try {
155
+ const v = session ? session.trackingState : undefined;
156
+ return TRACKING_STATES.includes(v) ? v : null;
157
+ } catch {
158
+ return null; // an attribute that refuses to be read is not a capability
159
+ }
160
+ }
133
161
  const hasDisplayModes = () => {
134
162
  if (!hasLayer()) return false;
135
163
  try {
@@ -514,7 +542,20 @@ function noteRigWinsOverHeight() {
514
542
  * keeps the eye set coherent either way — and a mode change the page did NOT request
515
543
  * (another tab, the shell, a panel opening flat) always snaps, because there is nothing
516
544
  * to ramp from. Read the live state on `wall.modeSwitch`.
517
- * @returns {Promise<Inline3D | {supported:false, error?:Error}>} the manager, which also carries
545
+ * @param {'none'|'mono'} [opts.untrackedFallback='none'] What the SDK does to the image and video
546
+ * windows it owns the pixels of while NOBODY IS TRACKED (`wall.trackingState ===
547
+ * 'searching'`). WHOSE JOB THIS IS depends on the display's eye-tracking mode (runtime
548
+ * spec docs/specs/vendor/eye-tracking-modes.md). MANAGED — the default, and Leia's — the
549
+ * vendor owns tracking loss: it eases the eyes together and only reports 'searching' once
550
+ * the display is already 2D, so leave this at `'none'` (a flatten here would be a second
551
+ * transition). MANUAL — the vendor does nothing and the app handles it: set `'mono'`.
552
+ * `'none'` (default) changes nothing. `'mono'` eases each image/video window to its LEFT
553
+ * EYE IN BOTH HALVES of its side-by-side buffer on 'searching' (still a valid pair for the
554
+ * layer; no buffer reallocation) and back on 'tracking', over the mode switch's duration;
555
+ * 'unknown' leaves it where it is. Scene windows are never touched: the page owns those
556
+ * pixels — listen for `trackingstatechange` and do the same for a scene.
557
+ * @returns {Promise<Inline3D | {supported:false, trackingState:'unknown', error?:Error}>} the
558
+ * manager, which also carries
518
559
  * the display API (`getDisplayInfo` / `getRenderingModes` / `requestRenderingMode` /
519
560
  * `setStereoEnabled`, `on`/`off`) and `undock` — `{model, splat}` on a browser with
520
561
  * `XRDisplayLayer.undock`, `null` on one without.
@@ -526,15 +567,18 @@ export async function createInline3D(opts = {}) {
526
567
  rootMargin = '50% 0px',
527
568
  autoChrome = true,
528
569
  modeSwitch = null,
570
+ untrackedFallback = 'none',
529
571
  } = opts;
530
- if (!inline3DAvailable()) return { supported: false };
572
+ // The unsupported shapes carry `trackingState` too, so a page can read it without branching on
573
+ // `supported` first. There is no on() here to pair with it: nothing would ever fire.
574
+ if (!inline3DAvailable()) return { supported: false, trackingState: 'unknown' };
531
575
  let session;
532
576
  try {
533
577
  // requestSession is Blink-local and resolves immediately when the feature is present —
534
578
  // the correct detection path (see inline3DAvailable's note on isSessionSupported).
535
579
  session = await navigator.xr.requestSession('inline-3d');
536
580
  } catch (e) {
537
- return { supported: false, error: e };
581
+ return { supported: false, trackingState: 'unknown', error: e };
538
582
  }
539
583
  let refSpace = null;
540
584
  try {
@@ -542,7 +586,7 @@ export async function createInline3D(opts = {}) {
542
586
  } catch {
543
587
  /* rAF still fires without a ref space; views are just null (fine for image/video). */
544
588
  }
545
- return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome, modeSwitch });
589
+ return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome, modeSwitch, untrackedFallback });
546
590
  }
547
591
 
548
592
  /**
@@ -594,7 +638,11 @@ function chromeTextPlates(root) {
594
638
  }
595
639
 
596
640
  class Inline3D {
597
- constructor(session, refSpace, { lazy, rootMargin, autoChrome = true, modeSwitch = null }) {
641
+ constructor(
642
+ session,
643
+ refSpace,
644
+ { lazy, rootMargin, autoChrome = true, modeSwitch = null, untrackedFallback = 'none' }
645
+ ) {
598
646
  this.supported = true;
599
647
  this.session = session;
600
648
  this.refSpace = refSpace;
@@ -651,6 +699,25 @@ class Inline3D {
651
699
  this._msArmedUp = false; // a ->3D request went out; the up-ramp waits for the panel to say 3D
652
700
  this._msLastMs = null; // wall clock of the previous advance (null = the ramp has not ticked)
653
701
  this._msTick = null; // the frames-stopped fallback timer; see _armModeSwitchTick
702
+ // ── tracking state (see TRACKING_EVENT) ───────────────────────────────────────────
703
+ // Read once here so `wall.trackingState` is honest before any event arrives. A page can open
704
+ // with nobody in front of the display, and that is a starting value, not a CHANGE, so nothing
705
+ // is emitted for it. A browser without the attribute reads null and stays 'unknown'.
706
+ this._trackingSupported = readTrackingState(session) !== null;
707
+ this._trackingState = this._trackingSupported ? readTrackingState(session) : 'unknown';
708
+ // What to do to image/video windows while nobody is tracked. 'none' is the default so every
709
+ // page that predates the option renders bit-identically (and MANAGED displays need nothing).
710
+ this._untrackedFallback = untrackedFallback === 'mono' ? 'mono' : 'none';
711
+ // ── untrackedFallback:'mono' — baked stereo eased to flat (see _trackBakedStereo) ───────
712
+ // An image/video tile's stereo is in its source pixels, so neither the rig collapse above nor
713
+ // the runtime's own easing of the views can reach it. 0..1 = how much of that baked disparity
714
+ // to show, eased toward the target the tracking state sets, over the mode switch's duration.
715
+ this._bakedStereo = 1;
716
+ this._bakedTarget = 1;
717
+ this._bakedAt = null; // frame time (ms) of the last ease step
718
+ this._bakedEaseS =
719
+ (Number.isFinite(msOpts.durationMs) ? Math.max(0, msOpts.durationMs) : MODE_SWITCH_DEFAULT_DURATION_MS) / 1000;
720
+ this._applyUntrackedTarget(); // a page that opens with nobody tracked starts heading flat
654
721
  // Undock capabilities, refreshed off the first live layer (see _refreshUndock). Null is the
655
722
  // load-bearing value: it means this browser has no XRDisplayLayer.undock at all.
656
723
  this.undock = hasUndock() ? { model: false, splat: false } : null;
@@ -680,6 +747,7 @@ class Inline3D {
680
747
  liveManager = this;
681
748
  session.addEventListener('end', () => this._teardown());
682
749
  this._bindDisplayEvents();
750
+ this._bindTrackingEvents();
683
751
  this._scanChrome(); // page chrome usually exists before the session does
684
752
  this._bindLifecycle();
685
753
  this._armDprWatch();
@@ -901,6 +969,7 @@ class Inline3D {
901
969
  * 3D per-pixel, so exclude()/unexclude() are stored-and-ignored (see _applyExclusion).
902
970
  */
903
971
  _handle(canvas, win) {
972
+ const mgr = this; // for the getters below: an object-literal getter's `this` is the handle
904
973
  return {
905
974
  remove: () => this._remove(canvas),
906
975
  exclude: (el) => {
@@ -940,6 +1009,10 @@ class Inline3D {
940
1009
  * asked for. During the eased 2D<->3D transition the same applies with a FRACTION in place
941
1010
  * of the 0: what leaves for the layer is your rig scaled by `wall.modeSwitch.factor`.
942
1011
  */
1012
+ /** The manager's `trackingState`, on the tile handle too ('tracking' | 'searching' | 'unknown'). */
1013
+ get trackingState() {
1014
+ return mgr._trackingState;
1015
+ },
943
1016
  setViewRig: (rig) => {
944
1017
  win.viewRig = rig || null;
945
1018
  return this._pushViewRig(win);
@@ -1136,6 +1209,22 @@ class Inline3D {
1136
1209
  * The hardware display state as last REPORTED by `hardwaredisplaystatechange` — `'2d'`,
1137
1210
  * `'3d'`, or `null` when the browser has not said yet. Never what was last requested.
1138
1211
  */
1212
+ /**
1213
+ * Is anyone being tracked in front of this display right now?
1214
+ *
1215
+ * `'tracking'` a viewer is in the display's 3D zone.
1216
+ * `'searching'` the runtime's derived isTracking is FALSE — nobody is in the zone, or the
1217
+ * display is in an untracked / 2D mode. NOT "the tracker glitched".
1218
+ * `'unknown'` this browser has no tracking-state surface, or the session has ended.
1219
+ *
1220
+ * Mirrors `session.trackingState` as last read. Changes arrive as
1221
+ * `on('trackingstatechange', (state, ev) => …)`. Pages use it for "step back into view" hints;
1222
+ * `untrackedFallback: 'mono'` uses it to flatten image/video windows.
1223
+ */
1224
+ get trackingState() {
1225
+ return this._trackingState;
1226
+ }
1227
+
1139
1228
  get hardwareDisplayState() {
1140
1229
  return this._hardwareDisplayState;
1141
1230
  }
@@ -1711,13 +1800,20 @@ class Inline3D {
1711
1800
  });
1712
1801
  }
1713
1802
 
1714
- /** Deliver one normalised display event to every listener. A throwing page handler is contained. */
1715
- _emitDisplay(ev) {
1803
+ /**
1804
+ * Deliver one normalised event to every listener. A throwing page handler is contained.
1805
+ *
1806
+ * `args` overrides what the callback is CALLED with (the event object still selects the listener
1807
+ * set): `trackingstatechange` leads with the state string, because a page listening to it wants
1808
+ * the one value and not a wrapper.
1809
+ */
1810
+ _emitDisplay(ev, args) {
1716
1811
  const set = this._displayListeners.get(ev.type);
1717
1812
  if (!set) return;
1813
+ const argv = args || [ev];
1718
1814
  for (const cb of [...set]) {
1719
1815
  try {
1720
- cb(ev);
1816
+ cb(...argv);
1721
1817
  } catch (err) {
1722
1818
  console.error(`[inline3d] ${ev.type} listener threw`, err);
1723
1819
  }
@@ -1725,14 +1821,18 @@ class Inline3D {
1725
1821
  }
1726
1822
 
1727
1823
  /**
1728
- * Listen for `renderingmodechange` / `hardwaredisplaystatechange` on this manager. See the
1729
- * handle's `on()` doc for the payload shapes. Returns an unsubscribe function.
1824
+ * Listen for `renderingmodechange` / `hardwaredisplaystatechange` / `trackingstatechange` on
1825
+ * this manager. See the handle's `on()` doc for the payload shapes — the tracking callback is
1826
+ * handed the STATE STRING first: `(state, { type, state })`. Returns an unsubscribe function.
1827
+ *
1828
+ * Subscribing to `trackingstatechange` on a browser with no tracking state is legal and silent:
1829
+ * the listener is kept, nothing fires it, and `trackingState` stays 'unknown'.
1730
1830
  */
1731
1831
  on(type, cb) {
1732
1832
  if (typeof cb !== 'function') throw new TypeError('[inline3d] on() takes (type, function).');
1733
- if (!DISPLAY_EVENTS.includes(type)) {
1833
+ if (!MANAGER_EVENTS.includes(type)) {
1734
1834
  throw new TypeError(
1735
- `[inline3d] on() knows ${DISPLAY_EVENTS.join(' / ')}, got ${JSON.stringify(type)}.`
1835
+ `[inline3d] on() knows ${MANAGER_EVENTS.join(' / ')}, got ${JSON.stringify(type)}.`
1736
1836
  );
1737
1837
  }
1738
1838
  let set = this._displayListeners.get(type);
@@ -2380,6 +2480,17 @@ class Inline3D {
2380
2480
  return;
2381
2481
  }
2382
2482
  const halfDst = c.width / 2;
2483
+ // How much of the source's baked disparity to show: the eased 2D<->3D ramp (the mode) and
2484
+ // the runtime's tracking state (_trackBakedStereo), whichever is flatter. Below 1 the RIGHT half becomes
2485
+ // the left eye with the right eye blended over it, so 0 is the left eye in both halves —
2486
+ // flat, and still a valid SBS pair for the live layer (a 1:1 mono buffer here would be woven
2487
+ // as a squeezed pair). See _trackBakedStereo.
2488
+ const stereo = Math.min(this._stereoFactor, this._bakedStereo);
2489
+ if (stereo < 1) {
2490
+ drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, win.cornerRadius, win.feather); // L
2491
+ drawEyeBlend(ctx, src, srcW / 2, srcH, halfDst, c.height, stereo, win.cornerRadius, win.feather); // L+R
2492
+ return;
2493
+ }
2383
2494
  // A single stretched draw maps SBS source → SBS buffer (left→left, right→right); the
2384
2495
  // per-eye path is only needed to bake decoration (rounded corners / edge feather), which
2385
2496
  // MUST be applied to each eye separately — see drawEye/featherEye.
@@ -2391,6 +2502,68 @@ class Inline3D {
2391
2502
  }
2392
2503
  }
2393
2504
 
2505
+ /**
2506
+ * Subscribe to `trackingstatechange`, once per manager, and only where the ATTRIBUTE exists: the
2507
+ * event is payload-free, so a browser that fired it without `session.trackingState` would leave
2508
+ * nothing to read. Independent of `_bindDisplayEvents` — a browser may ship them in either order.
2509
+ */
2510
+ _bindTrackingEvents() {
2511
+ const session = this.session;
2512
+ if (!session || typeof session.addEventListener !== 'function') return;
2513
+ if (!this._trackingSupported) return;
2514
+ session.addEventListener(TRACKING_EVENT, () => {
2515
+ this._setTrackingState(readTrackingState(session) || 'unknown'); // READ, never parse
2516
+ });
2517
+ }
2518
+
2519
+ /**
2520
+ * Adopt a new tracking state: act on it first (the fallback target, so a listener sees the state
2521
+ * it is told about already in effect), then tell the page. On CHANGE only — a repeated state is
2522
+ * not an event, and the value read at construction is a starting value, not a change.
2523
+ */
2524
+ _setTrackingState(state) {
2525
+ const next = TRACKING_STATES.includes(state) ? state : 'unknown';
2526
+ if (next === this._trackingState) return;
2527
+ this._trackingState = next;
2528
+ this._applyUntrackedTarget();
2529
+ const ev = { type: TRACKING_EVENT, state: next };
2530
+ this._emitDisplay(ev, [next, ev]);
2531
+ }
2532
+
2533
+ /**
2534
+ * Where baked stereo is heading under `untrackedFallback`. `'none'`: always the full pair.
2535
+ * `'mono'`: `'searching'` heads to flat, `'tracking'` back; `'unknown'` (no display yet, the
2536
+ * first frame, a session that ended) leaves it where it is — never read as flat.
2537
+ */
2538
+ _applyUntrackedTarget() {
2539
+ if (this._untrackedFallback !== 'mono') {
2540
+ this._bakedTarget = 1;
2541
+ return;
2542
+ }
2543
+ if (this._trackingState === 'searching') this._bakedTarget = 0;
2544
+ else if (this._trackingState === 'tracking') this._bakedTarget = 1;
2545
+ }
2546
+
2547
+ /**
2548
+ * One frame of the baked-stereo ease (untrackedFallback:'mono'): toward the target the tracking
2549
+ * state set, at a rate that
2550
+ * crosses 0..1 in the mode switch's duration, so an untracked flatten looks like a page-requested
2551
+ * 2D switch. Scene tiles are not involved — they render from the views, which the runtime
2552
+ * eases itself.
2553
+ */
2554
+ _trackBakedStereo(t) {
2555
+ // `trackingState` is filled in on every frame; reading it here as well as on the event keeps
2556
+ // the state right even if an event was coalesced away. Emits only on a real change.
2557
+ if (this._trackingSupported) this._setTrackingState(readTrackingState(this.session) || 'unknown');
2558
+ const dt = this._bakedAt === null || !Number.isFinite(t) ? 0 : Math.max(0, (t - this._bakedAt) / 1000);
2559
+ this._bakedAt = Number.isFinite(t) ? t : this._bakedAt;
2560
+ const cur = this._bakedStereo;
2561
+ const tgt = this._bakedTarget;
2562
+ if (cur === tgt) return;
2563
+ const step = this._bakedEaseS > 0 ? dt / this._bakedEaseS : 1;
2564
+ this._bakedStereo = tgt > cur ? Math.min(tgt, cur + step) : Math.max(tgt, cur - step);
2565
+ }
2566
+
2394
2567
  /**
2395
2568
  * Re-commit the pixels the canvas already holds, unchanged — the cheapest "last decoded frame"
2396
2569
  * there is, because the last decoded frame is already in the backing store.
@@ -2447,6 +2620,7 @@ class Inline3D {
2447
2620
  this._advanceModeSwitch();
2448
2621
  const pose = this.refSpace ? f.getViewerPose(this.refSpace) : null;
2449
2622
  const views = pose ? pose.views : null;
2623
+ this._trackBakedStereo(t);
2450
2624
  for (const win of this._windows.values()) {
2451
2625
  if (!win.layer) continue;
2452
2626
  if (win.kind === 'scene') {
@@ -2669,6 +2843,10 @@ class Inline3D {
2669
2843
  this._settleFirstWoven(win, false, 'session-ended');
2670
2844
  }
2671
2845
  this._windows.clear();
2846
+ // Nobody is tracked through a session that has ended: say so ONCE, before the listeners are
2847
+ // dropped, so a page that gated its UI on 'tracking' is released rather than left latched. A
2848
+ // browser that never reported one is already 'unknown' and this is silent.
2849
+ this._setTrackingState('unknown');
2672
2850
  // Page listeners go with the session that fed them: a manager whose session has ended will
2673
2851
  // never emit again, and holding the callbacks would keep the page's closures alive.
2674
2852
  this._displayListeners.clear();
@@ -2711,6 +2889,24 @@ function drawEye(ctx, src, sx, sy, sw, sh, dx, dy, dw, dh, radius, feather) {
2711
2889
  }
2712
2890
  }
2713
2891
 
2892
+ // The right half of a partly flattened SBS paint: the LEFT eye, with the right eye over it at
2893
+ // `amount` (0 = left only). Clipped and feathered once, as one eye, for the same reason drawEye is.
2894
+ function drawEyeBlend(ctx, src, halfSrc, srcH, dx, dh, amount, radius, feather) {
2895
+ ctx.save();
2896
+ if (radius > 0 && ctx.roundRect) {
2897
+ ctx.beginPath();
2898
+ ctx.roundRect(dx, 0, dx, dh, radius);
2899
+ ctx.clip();
2900
+ }
2901
+ ctx.drawImage(src, 0, 0, halfSrc, srcH, dx, 0, dx, dh);
2902
+ if (amount > 0) {
2903
+ ctx.globalAlpha = amount;
2904
+ ctx.drawImage(src, halfSrc, 0, halfSrc, srcH, dx, 0, dx, dh);
2905
+ }
2906
+ ctx.restore();
2907
+ if (feather > 0) featherEye(ctx, dx, 0, dx, dh, feather);
2908
+ }
2909
+
2714
2910
  // Fade this EYE's outer edges to transparent, so the 3D window dissolves into the page
2715
2911
  // instead of ending at a hard rectangle. Same spirit as the runtime feathering a 3D zone's
2716
2912
  // edge — but note that is the hardware WISH MASK (it drives the hardware display state, never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.24.0",
3
+ "version": "1.25.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",
@@ -32,6 +32,10 @@
32
32
  "import": "./js/inline3d-model.js"
33
33
  },
34
34
  "./package.json": "./package.json",
35
+ "./player": {
36
+ "types": "./player.d.ts",
37
+ "import": "./js/inline3d-player.js"
38
+ },
35
39
  "./undock": {
36
40
  "types": "./index.d.ts",
37
41
  "import": "./js/inline3d-undock.js"
@@ -61,11 +65,13 @@
61
65
  "js/inline3d-model-playcanvas.js",
62
66
  "js/inline3d-pc-look.js",
63
67
  "js/inline3d-playcanvas-engine.js",
68
+ "js/inline3d-player.js",
64
69
  "index.d.ts",
65
70
  "three.d.ts",
66
71
  "viewer.d.ts",
67
72
  "splat.d.ts",
68
73
  "model.d.ts",
74
+ "player.d.ts",
69
75
  "CHANGELOG.md",
70
76
  "README.md",
71
77
  "LICENSE",