@displayxr/inline3d 1.33.1 → 1.35.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.
@@ -1127,6 +1127,9 @@ class Call {
1127
1127
  hello: this.hello(),
1128
1128
  transport: this.transport,
1129
1129
  peers: [...this.tiles.values()].map((tile) => tile.diag()),
1130
+ // The self view's ACTUAL weave state (web#131): route, woven, the fallback reason and the
1131
+ // tile's firstWoven result — null without a self view.
1132
+ self: this.self ? this.self.diag() : null,
1130
1133
  mono3D: { ...this._mono3DInfo(), lift: this.liftApi, pool: this.liftPool ? this.liftPool.list() : [] },
1131
1134
  speaker: this.speakerId,
1132
1135
  depth: this.depth,
@@ -1866,14 +1869,34 @@ class SelfTile {
1866
1869
  else {
1867
1870
  this.detach();
1868
1871
  this.cam = cam;
1869
- this.view = addCameraView(wall, this.canvas, cam, { mirror: true, autoConverge: false, aspect: this.call.o.tileAspect });
1872
+ // onRouteChange: the view drops to its flat left eye by itself when the wall says the tile
1873
+ // will not weave (firstWoven → woven:false, web#131), and the badge must follow it.
1874
+ this.view = addCameraView(wall, this.canvas, cam, {
1875
+ mirror: true,
1876
+ autoConverge: false,
1877
+ aspect: this.call.o.tileAspect,
1878
+ onRouteChange: (route, st) => {
1879
+ if (this.cam !== cam) return; // a view this tile already replaced
1880
+ this.call.log('self-route', { route, woven: st.woven, reason: st.reason });
1881
+ this._badge();
1882
+ },
1883
+ });
1870
1884
  }
1871
1885
  this._badge();
1872
1886
  }
1873
1887
 
1888
+ /** 3D only while the view is on the woven route — a layer the wall failed has already taken it flat. */
1874
1889
  _badge() {
1875
1890
  const t = this.call.t;
1876
- this.badge.replaceChildren(el('b', { text: `${t('you')} · ${this.route === 'woven-sbs' ? t('badge3D') : t('badge2D')}` }));
1891
+ const woven = !!(this.view && this.view.woven);
1892
+ this.badge.replaceChildren(el('b', { text: `${t('you')} · ${woven ? t('badge3D') : t('badge2D')}` }));
1893
+ this.badge.dataset.route = this.route || '';
1894
+ }
1895
+
1896
+ /** For `call.diagnostics().self`: what the self view actually shows. */
1897
+ diag() {
1898
+ if (!this.view) return { route: null, woven: false, reason: 'no-camera', firstWoven: null, layerRetries: 0 };
1899
+ return this.view.weaveState();
1877
1900
  }
1878
1901
 
1879
1902
  onWallLost() {
@@ -51,6 +51,11 @@ export const CAMERA_SDK = 'inline3d-camera/1';
51
51
  const AUTO_CONV_INTERVAL_MS = 200;
52
52
  const AUTO_CONV_EYE_WIDTH = 240;
53
53
  const FRAME_WAIT_MS = 4000;
54
+ // A self view whose layer the wall could not build (`firstWoven` → `woven:false, 'layer-failed'`)
55
+ // goes flat and re-registers a few times with backoff — the remote tile's rule (web#131).
56
+ const LAYER_RETRIES = 4;
57
+ const LAYER_RETRY_BASE_MS = 1500;
58
+ const LAYER_RETRY_MAX_MS = 20000;
54
59
  const RECORD_MIME_CANDIDATES = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4'];
55
60
 
56
61
  const hasDoc = () => typeof document !== 'undefined' && document && typeof document.createElement === 'function';
@@ -471,7 +476,12 @@ function sizeFlat(canvas) {
471
476
  * @param {object|null} wall a `createInline3D()` / `sharedInline3D()` result (may be unsupported) or null
472
477
  * @param {HTMLCanvasElement} canvas the tile — its CSS box is the shape the viewer sees
473
478
  * @param {StereoCamera} cam
474
- * @param {{ mirror?: boolean, autoConverge?: boolean, depth?: number, aspect?: number }} [opts]
479
+ * On a 3D wall the view follows its tile's `firstWoven`: a window the wall will not weave
480
+ * (`'layer-failed'`, `'session-ended'`) drops the view to the flat left eye — never the packed
481
+ * pair — with `fallbackReason` saying why, and `onRouteChange(route, state)` tells the page (a
482
+ * badge reads `route`). A refused layer is retried a few times; `_reroute(true, wall)` recovers.
483
+ *
484
+ * @param {{ mirror?: boolean, autoConverge?: boolean, depth?: number, aspect?: number, onRouteChange?: Function }} [opts]
475
485
  */
476
486
  export function addCameraView(wall, canvas, cam, opts = {}) {
477
487
  if (!canvas || typeof canvas.getContext !== 'function') throw new TypeError(`${TAG} addCameraView(wall, canvas, cam, opts) needs a canvas`);
@@ -494,6 +504,15 @@ class CameraView {
494
504
  this.setDepth(o.depth);
495
505
  this.route = null;
496
506
  this.handle = null;
507
+ // Why a pair on a 3D-capable wall is shown FLAT (web#131): the wall said this tile's window
508
+ // will not weave (`firstWoven` → woven:false — 'layer-failed', 'session-ended'). Null while
509
+ // nothing has failed. A packed pair is never the visible fallback: the view paints one eye.
510
+ this.fallbackReason = null;
511
+ // The current registration's settled `firstWoven`, null while pending / not registered.
512
+ this._fw = null;
513
+ this._onRouteChange = typeof o.onRouteChange === 'function' ? o.onRouteChange : null;
514
+ this._layerFails = 0;
515
+ this._layerTimer = 0;
497
516
  this.buffer = null; // the SBS canvas the wall repaints the tile from (woven route)
498
517
  this.removed = false;
499
518
  this._raf = 0;
@@ -507,11 +526,30 @@ class CameraView {
507
526
  if (this._loop) this._start();
508
527
  }
509
528
 
510
- /** `'woven-sbs'` (3D on the panel) | `'flat-left'` (a pair, shown flat) | `'flat'` (mono). */
529
+ /**
530
+ * True while the view is on the woven route: registered on a 3D wall, and the wall has not said
531
+ * the tile will not weave (a failed layer drops the view to `'flat-left'`). Route is
532
+ * `'woven-sbs'` (3D on the panel) | `'flat-left'` (a pair, shown flat) | `'flat'` (mono).
533
+ */
511
534
  get woven() {
512
535
  return this.route === 'woven-sbs';
513
536
  }
514
537
 
538
+ /**
539
+ * What is actually on the panel, for diagnostics: `{ route, woven, reason, firstWoven,
540
+ * layerRetries }`. `firstWoven` is the current registration's settled result, `'pending'`
541
+ * before it settles, null off the woven route.
542
+ */
543
+ weaveState() {
544
+ return {
545
+ route: this.route,
546
+ woven: this.woven,
547
+ reason: this.fallbackReason,
548
+ firstWoven: this.route === 'woven-sbs' ? (this._fw ? { ...this._fw } : 'pending') : null,
549
+ layerRetries: this._layerFails,
550
+ };
551
+ }
552
+
515
553
  /** The per-eye convergence shift currently painted, source px. */
516
554
  get convergencePx() {
517
555
  return this.conv.current;
@@ -543,20 +581,77 @@ class CameraView {
543
581
  return this.autoConverge;
544
582
  }
545
583
 
546
- /** Re-register on the wall (the call does this when the weave goes live, #172, or a session is recovered). */
584
+ /**
585
+ * Re-register on the wall (the call does this when the weave goes live, #172, or a session is
586
+ * recovered). A FORCED reroute also clears a layer fallback (web#131): a wall that came back
587
+ * gets a fresh registration. An unforced one keeps the view flat while a fallback stands.
588
+ */
547
589
  _reroute(force, wall) {
548
590
  if (wall !== undefined) this.wall = wall;
549
591
  if (this.removed) return;
592
+ if (force && this.fallbackReason) {
593
+ this.fallbackReason = null;
594
+ clearTimeout(this._layerTimer);
595
+ this._layerTimer = 0;
596
+ }
550
597
  const cam = this.cam;
551
598
  const live = cam.state === 'live' && cam.video;
552
- const route = cam.format === 'sbs' && live && this.wall && this.wall.supported ? 'woven-sbs' : cam.format === 'sbs' ? 'flat-left' : 'flat';
599
+ const wovenOk = cam.format === 'sbs' && live && this.wall && this.wall.supported && !this.fallbackReason;
600
+ const route = wovenOk ? 'woven-sbs' : cam.format === 'sbs' ? 'flat-left' : 'flat';
553
601
  if (!force && route === this.route) return;
602
+ const prev = this.route;
554
603
  this._unregister();
555
604
  this.route = route;
556
- if (route === 'woven-sbs') {
557
- if (!this.buffer) this.buffer = document.createElement('canvas');
558
- this._paintWoven(); // the first frame exists before the layer does
559
- this.handle = this.wall.addImage(this.canvas, this.buffer);
605
+ if (route === 'woven-sbs') this._registerWoven();
606
+ if (route !== prev) this._routeChanged();
607
+ }
608
+
609
+ _registerWoven() {
610
+ if (!this.buffer) this.buffer = document.createElement('canvas');
611
+ this._paintWoven(); // the first frame exists before the layer does
612
+ const handle = this.wall.addImage(this.canvas, this.buffer);
613
+ this.handle = handle;
614
+ this._fw = null;
615
+ const fw = handle && handle.firstWoven;
616
+ if (!fw || typeof fw.then !== 'function') return;
617
+ fw.then((res) => {
618
+ // A result for a registration we already left (removed, re-registered) says nothing now.
619
+ if (this.handle !== handle || this.removed || !res) return;
620
+ this._fw = { woven: !!res.woven, confirmed: !!res.confirmed, reason: res.reason || null, ms: res.ms };
621
+ this.cam.log?.('view-first-woven', { woven: !!res.woven, reason: res.reason || null });
622
+ if (res.woven) {
623
+ this._layerFails = 0;
624
+ this._routeChanged(); // the state settled; the route did not move
625
+ } else if (res.reason !== 'removed') this._layerLost(res.reason || 'layer-failed');
626
+ });
627
+ }
628
+
629
+ /**
630
+ * The wall said this window will not weave (web#131): the canvas must never be left showing the
631
+ * packed pair under a 3D badge. Go flat (one eye, mirrored) and say why. A layer the browser
632
+ * refused is retried a few times with backoff while the wall stays up (the remote tile's rule);
633
+ * a session that ended waits for `_reroute(true, wall)` with the new wall.
634
+ */
635
+ _layerLost(reason) {
636
+ this.fallbackReason = reason;
637
+ this._reroute(false);
638
+ if (reason !== 'layer-failed' || this._layerFails >= LAYER_RETRIES) return;
639
+ const delay = Math.min(LAYER_RETRY_MAX_MS, LAYER_RETRY_BASE_MS * Math.pow(2, this._layerFails++));
640
+ this.cam.log?.('view-layer-retry', { attempt: this._layerFails, inMs: delay });
641
+ clearTimeout(this._layerTimer);
642
+ this._layerTimer = setTimeout(() => {
643
+ this._layerTimer = 0;
644
+ if (this.removed || this.fallbackReason !== reason) return;
645
+ this._reroute(true);
646
+ }, delay);
647
+ }
648
+
649
+ _routeChanged() {
650
+ if (!this._onRouteChange) return;
651
+ try {
652
+ this._onRouteChange(this.route, this.weaveState());
653
+ } catch (err) {
654
+ console.error(`${TAG} onRouteChange threw`, err);
560
655
  }
561
656
  }
562
657
 
@@ -672,6 +767,8 @@ class CameraView {
672
767
  this.removed = true;
673
768
  if (this._raf) cancelAnimationFrame(this._raf);
674
769
  this._raf = 0;
770
+ clearTimeout(this._layerTimer);
771
+ this._layerTimer = 0;
675
772
  this._unregister();
676
773
  this.cam._views?.delete(this);
677
774
  }
@@ -54,7 +54,7 @@ export function resolveModelEngine(opts = {}) {
54
54
  }
55
55
 
56
56
  /** Options that exist only on the PlayCanvas backend: an error on an explicit engine:'three'. */
57
- const PLAYCANVAS_ONLY = ['playcanvas', 'environmentRotation', 'controls', 'comfortDepth', 'onBeforeFrame', 'antialias', 'preserveDrawingBuffer', 'nearClip', 'farClip', 'orbitMaxDeg', 'orbitEase', 'zoom'];
57
+ const PLAYCANVAS_ONLY = ['playcanvas', 'environmentRotation', 'controls', 'comfortDepth', 'onBeforeFrame', 'antialias', 'preserveDrawingBuffer', 'nearClip', 'farClip', 'orbitMaxDeg', 'orbitEase', 'orbitStyle', 'zoom'];
58
58
 
59
59
  /**
60
60
  * Everything that can be checked before a module loads. Throws at CALL time — a page bug is true
@@ -67,6 +67,9 @@ export function validateModelCall(canvas, src, opts, route) {
67
67
  if (typeof src !== 'string' || !src) {
68
68
  throw new Error('@displayxr/inline3d/model: addModel(wall, canvas, src) — `src` must be the URL of a .glb or .gltf.');
69
69
  }
70
+ if (opts.orbitStyle !== undefined && opts.orbitStyle !== 'turntable' && opts.orbitStyle !== 'tilt') {
71
+ throw new Error(`@displayxr/inline3d/model: orbitStyle must be 'turntable' or 'tilt' (got ${String(opts.orbitStyle)}).`);
72
+ }
70
73
  if (route.engine === 'three' && route.explicit) {
71
74
  const bad = PLAYCANVAS_ONLY.filter((k) => opts[k] !== undefined && !(k === 'controls' && opts[k] === 'viewer'));
72
75
  if (bad.length) {
@@ -459,6 +459,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
459
459
  flipY: false, // glTF is already Y-up
460
460
  orbitMaxDeg: opts.orbitMaxDeg,
461
461
  orbitEase: opts.orbitEase,
462
+ // A mesh turns all the way round, like the three path's SceneViewer: not the photo tilt.
463
+ orbitStyle: opts.orbitStyle ?? 'turntable',
462
464
  zoom: opts.zoom,
463
465
  feather,
464
466
  nearClip: opts.nearClip,
@@ -98,6 +98,7 @@ import {
98
98
  ORBIT_MAX_DEG,
99
99
  ORBIT_TAU_DRAG_S,
100
100
  ORBIT_TAU_REST_S,
101
+ DRAG_DEG_PER_TILE,
101
102
  WHEEL_LINE_PX,
102
103
  WHEEL_PAGE_PX,
103
104
  WHEEL_MAX_PX,
@@ -812,6 +813,7 @@ export class PlayCanvasSplatViewer {
812
813
  pitchLimit = PITCH_LIMIT,
813
814
  orbitMaxDeg = ORBIT_MAX_DEG,
814
815
  orbitEase = {},
816
+ orbitStyle = 'tilt',
815
817
  zoom,
816
818
  feather = 0,
817
819
  captureFit = 'height',
@@ -853,6 +855,10 @@ export class PlayCanvasSplatViewer {
853
855
  // from where the press started, easing with τ = orbitEase.drag; release relaxes back with
854
856
  // τ = orbitEase.rest. `_orbitMode` is 'drag' | 'rest' | null (null = ordinary damping).
855
857
  this.orbitMaxDeg = orbitMaxDeg;
858
+ // 'tilt' (the default; photo splats: a capped tilt that relaxes) or 'turntable' (./model: an
859
+ // object you turn all the way round — SceneViewer's cumulative drag, DRAG_DEG_PER_TILE per
860
+ // tile width, no relax).
861
+ this.orbitStyle = orbitStyle === 'turntable' ? 'turntable' : 'tilt';
856
862
  this.orbitEase = { drag: orbitEase.drag ?? ORBIT_TAU_DRAG_S, rest: orbitEase.rest ?? ORBIT_TAU_REST_S };
857
863
  this._orbitMode = null;
858
864
  // Zoom bounds + relax (./inline3d-splat-shared.js §ZOOM). Wheel and pinch clamp to
@@ -2414,6 +2420,7 @@ export class PlayCanvasSplatViewer {
2414
2420
  };
2415
2421
  const endDrag = () => {
2416
2422
  dragging = false;
2423
+ if (this.orbitStyle === 'turntable') return; // stays where it was turned
2417
2424
  this._targetYaw = restYaw;
2418
2425
  this._targetPitch = clamp(restPitch, this.pitchLimit[0], this.pitchLimit[1]);
2419
2426
  this._orbitMode = 'rest';
@@ -2443,7 +2450,8 @@ export class PlayCanvasSplatViewer {
2443
2450
  // Rest = where the pose was heading when pressed (an idle turntable's yaw included).
2444
2451
  restYaw = this._orbitMode ? restYaw : this._targetYaw;
2445
2452
  restPitch = this._orbitMode ? restPitch : this._targetPitch;
2446
- this._orbitMode = 'drag';
2453
+ // turntable: the ordinary damping (no drag ease, no relax), as SceneViewer
2454
+ this._orbitMode = this.orbitStyle === 'turntable' ? null : 'drag';
2447
2455
  this._lastInput = now();
2448
2456
  };
2449
2457
  this._onMove = (ev) => {
@@ -2457,6 +2465,20 @@ export class PlayCanvasSplatViewer {
2457
2465
  }
2458
2466
  if (!dragging) return;
2459
2467
  const box = el.getBoundingClientRect();
2468
+ if (this.orbitStyle === 'turntable') {
2469
+ // SceneViewer's drag (./inline3d-viewer.js): a full drag across the tile is a half turn,
2470
+ // accumulated from the last move, near face following the pointer on both axes.
2471
+ this._targetYaw += ((ev.clientX - startX) / Math.max(box.width, 1)) * DRAG_DEG_PER_TILE;
2472
+ this._targetPitch = clamp(
2473
+ this._targetPitch + ((ev.clientY - startY) / Math.max(box.height, 1)) * DRAG_DEG_PER_TILE,
2474
+ this.pitchLimit[0],
2475
+ this.pitchLimit[1],
2476
+ );
2477
+ startX = ev.clientX;
2478
+ startY = ev.clientY;
2479
+ this._lastInput = now();
2480
+ return;
2481
+ }
2460
2482
  const dx = (ev.clientX - startX) / Math.max(box.width, 1);
2461
2483
  const dy = (ev.clientY - startY) / Math.max(box.height, 1);
2462
2484
  const max = this.orbitMaxDeg;
package/model.d.ts CHANGED
@@ -126,6 +126,13 @@ export interface ModelOptions {
126
126
  farClip?: number;
127
127
  /** PlayCanvas only: the drag's tilt cap in degrees (default 15, tilt-and-relax). */
128
128
  orbitMaxDeg?: number;
129
+ /**
130
+ * PlayCanvas only: how a drag turns the model. `'turntable'` (the default for addModel, as on the
131
+ * three path): cumulative, a full drag across the tile is a half turn, and it stays where you
132
+ * leave it. `'tilt'` (addSplat's mapping for photo splats): capped at ±`orbitMaxDeg` from the
133
+ * press and relaxing back to rest on release.
134
+ */
135
+ orbitStyle?: 'turntable' | 'tilt';
129
136
  /** PlayCanvas only: the orbit's easing time constants, seconds. */
130
137
  orbitEase?: { drag?: number; rest?: number };
131
138
  /** PlayCanvas only: zoom bounds + relax (wheel, pinch, setPose) — see the splat option. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.33.1",
3
+ "version": "1.35.0",
4
4
  "description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",