@displayxr/inline3d 1.46.0 → 1.47.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.
@@ -119,6 +119,12 @@ import {
119
119
  normalizeCameraPose,
120
120
  coverageExponent,
121
121
  declareViewRig,
122
+ parseClearColor,
123
+ validateSetStereo,
124
+ stereoScalarOf,
125
+ attachComfortDepth,
126
+ resolveDepthOption,
127
+ framedSubjectBounds,
122
128
  } from './inline3d-splat-shared.js';
123
129
  import { resolveCursorOption } from './inline3d-cursor-option.js';
124
130
 
@@ -827,6 +833,10 @@ export class PlayCanvasSplatViewer {
827
833
  cursor = null,
828
834
  } = opts;
829
835
  this.canvas = canvas;
836
+ // `clearColor` (opt-in): the eye cameras' clear, [r,g,b,a] in 0..1, or null = the transparent
837
+ // clear every tile had before it existed. An OPAQUE clear avoids stale / black pixels where the
838
+ // weave reads an alpha-0 hole (web#198).
839
+ this.clearColor = parseClearColor(opts.clearColor, opts.logTag || '[inline3d/splat]');
830
840
  // `cursor: 'depth'` (ADR-046): the depth-aware cursor. OPT-IN AND FREE OTHERWISE — its module
831
841
  // is imported only when asked for (attachEngine), and every per-frame hook is an optional call
832
842
  // on `cursorDepth`, which stays null without it.
@@ -1984,7 +1994,7 @@ export class PlayCanvasSplatViewer {
1984
1994
  const pc = this.pc;
1985
1995
  const e = new pc.Entity(name, this.app);
1986
1996
  e.addComponent('camera', {
1987
- clearColor: new pc.Color(0, 0, 0, 0),
1997
+ clearColor: this.clearColor ? new pc.Color(this.clearColor[0], this.clearColor[1], this.clearColor[2], this.clearColor[3]) : new pc.Color(0, 0, 0, 0),
1988
1998
  nearClip: MONO_NEAR,
1989
1999
  farClip: MONO_FAR,
1990
2000
  fov: MONO_FOV,
@@ -3125,41 +3135,9 @@ export function validateWarmEffects(list) {
3125
3135
  });
3126
3136
  }
3127
3137
 
3128
- /**
3129
- * handle.setStereo's argument, validated and merged onto the current override (throws at the
3130
- * call). Returns the new override, or null when nothing is overridden.
3131
- */
3132
- export function validateSetStereo(o, prev) {
3133
- if (o === null) return null;
3134
- if (typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setStereo takes an object or null.');
3135
- // Absolute (…Factor) or RELATIVE to the asset's own (…Scale); per scalar, the last one given wins.
3136
- const pairs = [
3137
- ['ipdFactor', 'ipdScale'],
3138
- ['parallaxFactor', 'parallaxScale'],
3139
- ];
3140
- const keys = pairs.flat();
3141
- const unknown = Object.keys(o).filter((k) => !keys.includes(k));
3142
- if (unknown.length) throw new Error(`@displayxr/inline3d/splat: setStereo — unknown option(s) ${unknown.join(', ')}.`);
3143
- for (const [a, b] of pairs) {
3144
- if (o[a] != null && o[b] != null) throw new Error(`@displayxr/inline3d/splat: setStereo — ${a} and ${b} are exclusive.`);
3145
- }
3146
- const next = { ...(prev || {}) };
3147
- for (const [a, b] of pairs) {
3148
- for (const k of [a, b]) {
3149
- if (!(k in o) || o[k] === undefined) continue;
3150
- if (o[k] === null) {
3151
- delete next[k];
3152
- continue;
3153
- }
3154
- if (typeof o[k] !== 'number' || !Number.isFinite(o[k]) || o[k] < 0) {
3155
- throw new RangeError(`@displayxr/inline3d/splat: setStereo — ${k} must be a finite number >= 0 (got ${o[k]}).`);
3156
- }
3157
- next[k] = o[k];
3158
- delete next[k === a ? b : a];
3159
- }
3160
- }
3161
- return Object.keys(next).length ? next : null;
3162
- }
3138
+ // validateSetStereo lives in ./inline3d-splat-shared.js now (./model's setStereo shares it); it is
3139
+ // re-exported from here so every name this module exported before still resolves.
3140
+ export { validateSetStereo };
3163
3141
 
3164
3142
  /**
3165
3143
  * handle.setRig's arguments, validated and resolved (throws at the call, before anything runs).
@@ -3403,6 +3381,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3403
3381
  // an advanced caller may reach this function directly.
3404
3382
  const ctl = resolveControls(opts);
3405
3383
  const pageMode = ctl.page;
3384
+ const depthOption = resolveDepthOption(opts.depth);
3406
3385
  if (pageMode && ctl.ignored.length) {
3407
3386
  console.info(
3408
3387
  `[inline3d/splat] controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ` +
@@ -3434,10 +3413,27 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3434
3413
  sky: opts.sky,
3435
3414
  viewerEase: opts.viewerEase,
3436
3415
  cursor: opts.cursor,
3416
+ // Opt-in; undefined = the viewer's own default (a transparent clear).
3417
+ clearColor: opts.clearColor,
3437
3418
  });
3438
3419
 
3439
3420
  let handle = null;
3440
3421
  let unbindFocusInput = null;
3422
+ // `depth: 'auto'` (opt-in): the comfort factor, multiplied under setStereo's on the DISPLAY rig
3423
+ // declareDisplay sends (camera rigs and setVideo's own rig never carry it). Without the option:
3424
+ // inert, `depthFactor` 1, nothing measured, nothing declared.
3425
+ const depth = attachComfortDepth(out, {
3426
+ option: depthOption,
3427
+ canvas,
3428
+ getTile: () => handle,
3429
+ getBounds: () => (out.rig && out.rig.type !== 'camera' && !vid?.on ? framedSubjectBounds(viewer) : null),
3430
+ getVH: () => viewer.vH,
3431
+ apply: () => {
3432
+ if (!pageMode && !vid?.on && out.rig?.type !== 'camera' && lastDisplayBase) declareDisplay(lastDisplayBase);
3433
+ },
3434
+ tag: '[inline3d/splat]',
3435
+ });
3436
+ if (pageMode) depth.ignore("controls:'page' (the page's camera rig)");
3441
3437
 
3442
3438
  // ── diagnostics (diag / ?dxrdiag — ./inline3d-splat-diag.js) ──
3443
3439
  const diagCfg = resolveDiag(opts.diag);
@@ -3705,6 +3701,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3705
3701
  effects: () => (fx ? fx.list() : []),
3706
3702
  remove() {
3707
3703
  removed = true;
3704
+ depth.dispose();
3708
3705
  dropVideo(); // before the app goes: the plane's rVFC, and a <video> this handle made
3709
3706
  current = null;
3710
3707
  for (const p of [...livePrepared]) p.dispose();
@@ -4369,9 +4366,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4369
4366
  // raw: setVideo's own display rig — not the page's rig, so neither setStereo's factors nor
4370
4367
  // the base setStereo re-declares from.
4371
4368
  if (!raw) lastDisplayBase = base;
4369
+ // The `depth` option's comfort factor (1 without it), × setStereo's: never on setVideo's rig.
4370
+ const kd = raw ? 1 : depth.factor;
4372
4371
  const f =
4373
- stereoOverride && !raw
4374
- ? { ...base, ipdFactor: stereoScalar(base.ipdFactor ?? 1, 'ipd'), parallaxFactor: stereoScalar(base.parallaxFactor ?? 1, 'parallax') }
4372
+ (stereoOverride || kd !== 1) && !raw
4373
+ ? { ...base, ipdFactor: stereoScalar(base.ipdFactor ?? 1, 'ipd') * kd, parallaxFactor: stereoScalar(base.parallaxFactor ?? 1, 'parallax') * kd }
4375
4374
  : base;
4376
4375
  const key = `${f.vH}|${f.ipdFactor ?? 1}|${f.parallaxFactor ?? 1}|${f.perspectiveFactor ?? 1}`;
4377
4376
  if (declaredDisplay === key) return;
@@ -4407,6 +4406,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4407
4406
  viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: false });
4408
4407
  lastConvergence = Number.NaN;
4409
4408
  pushViewRig(true);
4409
+ depth.ignore('a camera rig');
4410
4410
  return;
4411
4411
  }
4412
4412
  if (viewer.mono.capture) viewer.useDisplayCamera();
@@ -4425,6 +4425,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4425
4425
  resolved.focusDefaultSource = 'frame';
4426
4426
  resolved.frame = target;
4427
4427
  declareDisplay(disp);
4428
+ depth.resume();
4428
4429
  return;
4429
4430
  }
4430
4431
  // The splat itself, as a display-rig addSplat frames it (the load-time path).
@@ -4437,6 +4438,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4437
4438
  }
4438
4439
  if (disp) resolved.frame = bounds ? { center: bounds.center.slice(), extent: bounds.extent.slice(), source: 'splat' } : null;
4439
4440
  declareDisplay(disp || { vH: bootFraming.vH });
4441
+ if (bounds) depth.resume();
4440
4442
  }
4441
4443
 
4442
4444
  // ── handle.setStereo: live stereo strength on whatever rig is declared ──
@@ -4452,10 +4454,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4452
4454
  }
4453
4455
  /** One scalar under setStereo: its absolute value, else `base` × its scale, else `base`. */
4454
4456
  function stereoScalar(base, k) {
4455
- const o = stereoOverride;
4456
- if (o?.[`${k}Factor`] != null) return o[`${k}Factor`];
4457
- if (o?.[`${k}Scale`] != null) return base * o[`${k}Scale`];
4458
- return base;
4457
+ return stereoScalarOf(stereoOverride, base, k);
4459
4458
  }
4460
4459
 
4461
4460
  /**
@@ -4943,6 +4942,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4943
4942
  lastConvergence = s.lastConvergence;
4944
4943
  state.on = false;
4945
4944
  if (stereoOverride !== state.stereoAtEnter) redeclareStereo(); // a setStereo made while the video held the rig
4945
+ depth.refresh(); // the `depth` option holds still under a video; measure again (no-op without it)
4946
4946
 
4947
4947
  }
4948
4948
 
@@ -12,6 +12,8 @@
12
12
  // apart on how a drag, a wheel notch, an idle turntable or a focus change feels. Pinned by
13
13
  // test/splat-playcanvas.test.mjs (values, and a behavioural trace of both viewers side by side).
14
14
 
15
+ import { comfortDepthFactor, isComfortZone, DEPTH_DEFAULT_VIEWER_M } from './inline3d-depth-budget.js';
16
+
15
17
  /**
16
18
  * Double-attach detection around a scene subpath's public entry (`addSplat`, `addModel`).
17
19
  *
@@ -563,3 +565,312 @@ export function declareViewRig(handle, rig) {
563
565
  }
564
566
  return false;
565
567
  }
568
+
569
+ /**
570
+ * The `clearColor` option of SceneViewer / addModel / addSplat, as `[r, g, b, a]` in 0..1 — or
571
+ * null when absent (the viewer then keeps its transparent clear, exactly as before). Takes
572
+ * `[r, g, b]` / `[r, g, b, a]` (0..1; alpha defaults to 1) or a CSS colour string: `#rgb`,
573
+ * `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` / `rgba()` (comma or space syntax, `/ alpha`),
574
+ * `transparent`, and — where a DOM is present — any other CSS colour the browser can parse.
575
+ * Something it cannot read warns once per call (with `tag`) and returns null: the default clear
576
+ * stays.
577
+ */
578
+ export function parseClearColor(v, tag = '[inline3d]') {
579
+ if (v === undefined || v === null) return null;
580
+ const bad = () => {
581
+ console.warn(`${tag} clearColor ${JSON.stringify(v)} is not a colour this SDK can read ([r,g,b,a] in 0..1, or a CSS colour); the default (transparent) clear is kept.`);
582
+ return null;
583
+ };
584
+ if (Array.isArray(v) || ArrayBuffer.isView(v)) {
585
+ if (v.length < 3 || v.length > 4) return bad();
586
+ const c = [v[0], v[1], v[2], v.length === 4 ? v[3] : 1];
587
+ if (!c.every((x) => typeof x === 'number' && Number.isFinite(x))) return bad();
588
+ return c.map((x) => clamp(x, 0, 1));
589
+ }
590
+ if (typeof v !== 'string') return bad();
591
+ const s = v.trim().toLowerCase();
592
+ if (s === 'transparent') return [0, 0, 0, 0];
593
+ let m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s);
594
+ if (m) {
595
+ let h = m[1];
596
+ if (h.length <= 4) h = [...h].map((ch) => ch + ch).join('');
597
+ const n = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
598
+ return [n(0), n(2), n(4), h.length === 8 ? n(6) : 1];
599
+ }
600
+ m = /^rgba?\(\s*([^)]*)\)$/.exec(s);
601
+ if (m) {
602
+ const parts = m[1].split(/\s*[,/]\s*|\s+/).filter(Boolean);
603
+ if (parts.length < 3 || parts.length > 4) return bad();
604
+ const ch = (p) => (p.endsWith('%') ? parseFloat(p) / 100 : parseFloat(p) / 255);
605
+ const al = (p) => (p.endsWith('%') ? parseFloat(p) / 100 : parseFloat(p));
606
+ const c = [ch(parts[0]), ch(parts[1]), ch(parts[2]), parts.length === 4 ? al(parts[3]) : 1];
607
+ if (!c.every(Number.isFinite)) return bad();
608
+ return c.map((x) => clamp(x, 0, 1));
609
+ }
610
+ // Any other CSS colour (a name, hsl(), …): let the browser normalise it, where there is one.
611
+ // A 2D context's fillStyle reads back as #rrggbb or rgba(…), both handled above.
612
+ try {
613
+ const ctx = globalThis.document?.createElement?.('canvas')?.getContext?.('2d');
614
+ if (ctx) {
615
+ ctx.fillStyle = '#010203';
616
+ ctx.fillStyle = v;
617
+ const out = String(ctx.fillStyle);
618
+ if (out !== '#010203') return parseClearColor(out, tag);
619
+ }
620
+ } catch {
621
+ /* no DOM */
622
+ }
623
+ return bad();
624
+ }
625
+
626
+ /**
627
+ * `handle.setStereo`'s argument, validated and merged onto the current override (throws at the
628
+ * call). Returns the new override, or null when nothing is overridden. Shared by `./splat`
629
+ * (PlayCanvas) and `./model` (both engines); `tag` names the subpath in the errors.
630
+ */
631
+ export function validateSetStereo(o, prev, tag = '@displayxr/inline3d/splat') {
632
+ if (o === null) return null;
633
+ if (typeof o !== 'object') throw new TypeError(`${tag}: setStereo takes an object or null.`);
634
+ // Absolute (…Factor) or RELATIVE to the asset's own (…Scale); per scalar, the last one given wins.
635
+ const pairs = [
636
+ ['ipdFactor', 'ipdScale'],
637
+ ['parallaxFactor', 'parallaxScale'],
638
+ ];
639
+ const keys = pairs.flat();
640
+ const unknown = Object.keys(o).filter((k) => !keys.includes(k));
641
+ if (unknown.length) throw new Error(`${tag}: setStereo — unknown option(s) ${unknown.join(', ')}.`);
642
+ for (const [a, b] of pairs) {
643
+ if (o[a] != null && o[b] != null) throw new Error(`${tag}: setStereo — ${a} and ${b} are exclusive.`);
644
+ }
645
+ const next = { ...(prev || {}) };
646
+ for (const [a, b] of pairs) {
647
+ for (const k of [a, b]) {
648
+ if (!(k in o) || o[k] === undefined) continue;
649
+ if (o[k] === null) {
650
+ delete next[k];
651
+ continue;
652
+ }
653
+ if (typeof o[k] !== 'number' || !Number.isFinite(o[k]) || o[k] < 0) {
654
+ throw new RangeError(`${tag}: setStereo — ${k} must be a finite number >= 0 (got ${o[k]}).`);
655
+ }
656
+ next[k] = o[k];
657
+ delete next[k === a ? b : a];
658
+ }
659
+ }
660
+ return Object.keys(next).length ? next : null;
661
+ }
662
+
663
+ /** One scalar under a setStereo override: its absolute value, else `base` × its scale, else `base`. */
664
+ export function stereoScalarOf(o, base, k) {
665
+ if (o?.[`${k}Factor`] != null) return o[`${k}Factor`];
666
+ if (o?.[`${k}Scale`] != null) return base * o[`${k}Scale`];
667
+ return base;
668
+ }
669
+
670
+ /**
671
+ * `./model`'s `handle.setStereo` + `handle.rig`, for both engines: `out` the model handle,
672
+ * `getTile()` the core window handle (null without a session), `vH` the tile's
673
+ * virtualDisplayHeight, `makeDisplayRig` ./three's displayRig. A model has no stereo of its own,
674
+ * so the "asset's own" values are 1 and 1 — `ipdScale` and `ipdFactor` agree.
675
+ *
676
+ * The rig is the display rig addModel declared with its `virtualDisplayHeight` shorthand; a value
677
+ * set here re-declares it as an explicit display rig with those factors (RELATIVE: 1 = what the
678
+ * display shows by construction). `setStereo(null)` declares the plain rig again — the exact
679
+ * equivalent of the shorthand. Nothing is sent before the first call: a page that never calls it
680
+ * declares exactly what it did before. Under `controls: 'page'` the camera rig the page drives
681
+ * takes the values instead, from its next tick.
682
+ *
683
+ * `setBase(k)` is the `depth` option's comfort factor: both scalars × k, on top of setStereo's,
684
+ * declared like a setStereo call (and, like one, nothing until it is called with k ≠ 1).
685
+ */
686
+ export function attachModelStereo(out, { getTile, vH, makeDisplayRig, page = false, tag = '@displayxr/inline3d/model' }) {
687
+ let override = null;
688
+ let declared = false;
689
+ // The `depth` option's comfort factor (attachComfortDepth), multiplied under setStereo's values;
690
+ // 1 = nothing to declare on its account.
691
+ let base = 1;
692
+ // controls:'page' declares its own camera rig every tick and reads rig.ipdFactor / parallaxFactor
693
+ // there (ABSOLUTE on a camera rig, as addSplat's); nothing is declared from here.
694
+ const rig = { type: page ? 'camera' : 'display', ipdFactor: 1, parallaxFactor: 1, stereoDefault: Object.freeze({ ipdFactor: 1, parallaxFactor: 1 }) };
695
+ out.rig = rig;
696
+ const declare = () => {
697
+ const tile = getTile();
698
+ if (page || !tile || (!override && !declared && base === 1)) return;
699
+ declared = true;
700
+ out.viewRig = makeDisplayRig({ virtualDisplayHeight: vH, ipdFactor: rig.ipdFactor, parallaxFactor: rig.parallaxFactor, out: out.viewRig || {} });
701
+ declareViewRig(tile, out.viewRig);
702
+ };
703
+ const factors = () => {
704
+ rig.ipdFactor = stereoScalarOf(override, 1, 'ipd') * base;
705
+ rig.parallaxFactor = stereoScalarOf(override, 1, 'parallax') * base;
706
+ };
707
+ out.setStereo = (o) => {
708
+ override = validateSetStereo(o, override, tag);
709
+ factors();
710
+ declare();
711
+ return out;
712
+ };
713
+ return {
714
+ redeclare: declare,
715
+ get override() {
716
+ return override;
717
+ },
718
+ /** The comfort factor (both scalars move together), × setStereo's; re-declares when it changes. */
719
+ setBase(k) {
720
+ if (k === base) return;
721
+ base = k;
722
+ factors();
723
+ declare();
724
+ },
725
+ };
726
+ }
727
+
728
+ // ── the `depth` option: comfort depth (./model, ./splat) ──────────────────────────────────────
729
+
730
+ /**
731
+ * addModel / addSplat's `depth` option, validated (throws at the call, like every option check):
732
+ * null when absent — nothing about the tile changes — else `{ zone }`. `'auto'` is the Shibata
733
+ * zone; `{ zone }` takes `'shibata'`, `'percival'` or a number of diopters > 0.
734
+ */
735
+ export function resolveDepthOption(v, tag = '@displayxr/inline3d/splat') {
736
+ if (v === undefined || v === null) return null;
737
+ if (v === 'auto') return { zone: 'shibata' };
738
+ if (typeof v !== 'object' || Array.isArray(v)) {
739
+ throw new TypeError(`${tag}: depth must be 'auto' or { zone } (got ${JSON.stringify(v)}).`);
740
+ }
741
+ const unknown = Object.keys(v).filter((k) => k !== 'zone');
742
+ if (unknown.length) throw new Error(`${tag}: depth — unknown option(s) ${unknown.join(', ')}.`);
743
+ const zone = v.zone === undefined ? 'shibata' : v.zone;
744
+ if (!isComfortZone(zone)) {
745
+ throw new RangeError(`${tag}: depth.zone must be 'shibata', 'percival' or a number of diopters > 0 (got ${JSON.stringify(v.zone)}).`);
746
+ }
747
+ return { zone };
748
+ }
749
+
750
+ /**
751
+ * The subject's depth AS FRAMED — getSubjectBounds' `front` / `back` at zoom 1 with no orbit
752
+ * (the fit scale, the subject's half depth, the page's depthOffset). Zoom and orbit are left out
753
+ * on purpose: they are framing choices the viewer (or the page) makes live, and the `depth`
754
+ * option's factor must not breathe with them. Either viewer (SceneViewer, PlayCanvasSplatViewer).
755
+ */
756
+ export function framedSubjectBounds(viewer) {
757
+ const s = viewer._fitScale;
758
+ const hz = viewer._subjectHalf?.[2];
759
+ const cz = viewer._depthOffset || 0;
760
+ if (!(s > 0) || !Number.isFinite(hz)) return null;
761
+ return { front: cz + s * hz, back: cz - s * hz };
762
+ }
763
+
764
+ /**
765
+ * The `depth` option's live part, shared by every engine: measure the comfort factor
766
+ * (comfortDepthFactor) from the framed subject, the canvas's physical height and the NOMINAL viewer
767
+ * distance, and hand it to `apply(k)` — the engine multiplies it into the display rig it declares,
768
+ * on top of setStereo's values. `out.depthFactor` reads it (1 when the option is off or not in
769
+ * force).
770
+ *
771
+ * Re-measured when the engine calls `refresh()` (after it frames a subject), when the tile first
772
+ * weaves (its layer can answer getDisplayInfo from then), on a display event and when the canvas
773
+ * resizes — never per frame, and never on zoom or orbit (the box is framedSubjectBounds'). `apply` runs only when k changes. `ignore(why)` says once that the
774
+ * option does nothing on this rig (a camera rig, controls:'page') and holds the factor at 1;
775
+ * `resume()` measures again (a PlayCanvas splat back on a display rig).
776
+ * Without the option every method is a no-op: no listener, no observer, no async work.
777
+ *
778
+ * `getBounds()` → a framedSubjectBounds() box or null (null = nothing to measure now), `getVH()` →
779
+ * the virtualDisplayHeight that box is in, `getTile()` → the core window handle.
780
+ */
781
+ export function attachComfortDepth(out, { option, canvas, getTile, getBounds, getVH, apply, tag = '[inline3d]' }) {
782
+ let factor = 1;
783
+ Object.defineProperty(out, 'depthFactor', { get: () => factor, enumerable: true, configurable: true });
784
+ if (!option) return { refresh() {}, ignore() {}, resume() {}, dispose() {}, get factor() { return 1; } };
785
+ let ignoring = false;
786
+ let warned = false;
787
+ let disposed = false;
788
+ let running = false;
789
+ let again = false;
790
+ let bound = null;
791
+ const unbind = [];
792
+ const bind = (tile) => {
793
+ if (bound === tile) return;
794
+ bound = tile;
795
+ tile.firstWoven?.then?.(() => refresh(), () => {});
796
+ for (const type of ['renderingmodechange', 'hardwaredisplaystatechange']) {
797
+ try {
798
+ const off = tile.on?.(type, () => refresh());
799
+ if (typeof off === 'function') unbind.push(off);
800
+ } catch {
801
+ /* a core without display events: resize and refresh() still re-measure */
802
+ }
803
+ }
804
+ if (canvas && typeof ResizeObserver === 'function') {
805
+ const ro = new ResizeObserver(() => refresh());
806
+ ro.observe(canvas);
807
+ unbind.push(() => ro.disconnect());
808
+ }
809
+ };
810
+ async function refresh() {
811
+ if (disposed || ignoring) return;
812
+ const tile = getTile();
813
+ if (!tile || typeof tile.displayMetrics !== 'function') return;
814
+ bind(tile);
815
+ // One measurement at a time; a request during one runs once more after it (a resize burst).
816
+ if (running) {
817
+ again = true;
818
+ return;
819
+ }
820
+ running = true;
821
+ try {
822
+ do {
823
+ again = false;
824
+ let m = null;
825
+ try {
826
+ m = await tile.displayMetrics(); // never rejects; guarded for an older core
827
+ } catch {
828
+ m = null;
829
+ }
830
+ if (disposed || ignoring) return;
831
+ const b = getBounds();
832
+ const vH = getVH();
833
+ if (!m || !b || !(vH > 0)) continue;
834
+ const k = comfortDepthFactor({
835
+ frontH: Math.max(0, b.front / vH),
836
+ backH: Math.max(0, -b.back / vH),
837
+ tileHeightM: m.canvasSizeM?.[1],
838
+ // The NOMINAL distance (what the display is built for), never the live head: depth that
839
+ // breathed with the viewer's head would be a second, unasked-for parallax.
840
+ viewerM: m.source?.viewer === 'display' ? m.nominalViewerM : DEPTH_DEFAULT_VIEWER_M,
841
+ zone: option.zone,
842
+ });
843
+ if (k !== factor) {
844
+ factor = k;
845
+ apply(k);
846
+ }
847
+ } while (again);
848
+ } finally {
849
+ running = false;
850
+ }
851
+ }
852
+ return {
853
+ refresh,
854
+ /** This rig takes no comfort factor: say so once, and hold 1 (refresh() idle) until resume(). */
855
+ ignore(why) {
856
+ if (!warned) {
857
+ warned = true;
858
+ console.warn(`${tag} depth: 'auto' applies to the display rig only; ignored on ${why} (the rig is sent as declared).`);
859
+ }
860
+ ignoring = true;
861
+ factor = 1;
862
+ },
863
+ /** A display rig again (PlayCanvas setRig / setSource): measure from here. */
864
+ resume() {
865
+ ignoring = false;
866
+ return refresh();
867
+ },
868
+ dispose() {
869
+ disposed = true;
870
+ while (unbind.length) unbind.pop()();
871
+ },
872
+ get factor() {
873
+ return factor;
874
+ },
875
+ };
876
+ }
@@ -20,7 +20,7 @@
20
20
 
21
21
  import * as THREE from 'three';
22
22
  import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
23
- import { EyeCamera, EdgeFeather, cameraRigFromCamera, DepthCursor, raycastHitTest } from './inline3d-three.js';
23
+ import { EyeCamera, EdgeFeather, cameraRigFromCamera, DepthCursor, raycastHitTest, displayRig } from './inline3d-three.js';
24
24
  import { resolveCursorOption } from './inline3d-cursor-depth.js';
25
25
  import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
26
26
  import { readSogCamera } from './inline3d-sog.js';
@@ -40,6 +40,9 @@ import {
40
40
  resolveControls,
41
41
  declareViewRig,
42
42
  guardedAttach,
43
+ attachComfortDepth,
44
+ resolveDepthOption,
45
+ framedSubjectBounds,
43
46
  } from './inline3d-splat-shared.js';
44
47
  import {
45
48
  resolveRig,
@@ -178,6 +181,8 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
178
181
  // WHO OWNS THE CAMERA. Validated here, synchronously, for both engines (an unknown `controls`
179
182
  // or a comfortDepth out of range is a page bug true of every call).
180
183
  const { page: pageControls } = resolveControls(opts);
184
+ // Opt-in comfort depth, both engines: a bad value is a page bug, thrown at the call.
185
+ const depthOption = resolveDepthOption(opts.depth);
181
186
  // WHICH ENGINE. Unset (or 'spark') is everything below, untouched. 'playcanvas' goes to
182
187
  // ./inline3d-splat-playcanvas.js, imported DYNAMICALLY so a page that never asks for it never
183
188
  // resolves `playcanvas` — see addSplatDeferred.
@@ -255,6 +260,8 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
255
260
  feather,
256
261
  logTag: '[inline3d/splat]',
257
262
  viewerEase: opts.viewerEase,
263
+ // Opt-in; undefined = the viewer's own default (a transparent clear).
264
+ clearColor: opts.clearColor,
258
265
  }).useEyeCamera(EyeCamera, EdgeFeather);
259
266
  // A gaussian-splat raycast is ~8 ms a ray on Spark: one ray a frame, cycling the footprint.
260
267
  // (engine:'playcanvas' hit-tests with the engine's depth picker instead, which is far cheaper.)
@@ -356,6 +363,7 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
356
363
  remove() {
357
364
  unbindFocusInput?.();
358
365
  viewer.onFocusChange = null;
366
+ depth.dispose();
359
367
  handle?.remove();
360
368
  viewer.dispose();
361
369
  out.mesh?.dispose?.();
@@ -511,6 +519,23 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
511
519
  // The core handle's `firstWoven`, forwarded; a page with no session is told so at once.
512
520
  out.firstWoven = handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 }));
513
521
 
522
+ // `depth: 'auto'` (opt-in, display rig only): Spark has no setStereo, so the comfort factor is
523
+ // the display rig's whole ipdFactor / parallaxFactor, declared explicitly when it moves off 1
524
+ // (and back to the plain rig — the shorthand's exact equivalent — if it returns to 1).
525
+ // Without the option: inert, `depthFactor` 1, the shorthand stays the only declaration.
526
+ const depth = attachComfortDepth(out, {
527
+ option: depthOption,
528
+ canvas,
529
+ getTile: () => handle,
530
+ getBounds: () => (out.frame && out.rig?.type === 'display' ? framedSubjectBounds(viewer) : null),
531
+ getVH: () => virtualDisplayHeight,
532
+ apply: (k) => {
533
+ out.viewRig = displayRig({ virtualDisplayHeight, ipdFactor: k, parallaxFactor: k, out: out.viewRig || {} });
534
+ declareViewRig(handle, out.viewRig);
535
+ },
536
+ tag: '[inline3d/splat]',
537
+ });
538
+
514
539
 
515
540
  // ── focus: declaring it, and the two gestures that change it ──────────────────────────
516
541
  //
@@ -667,6 +692,7 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
667
692
  out.frame = bounds;
668
693
 
669
694
  if (resolved.type === 'camera') {
695
+ depth.ignore('a camera rig (this asset carries a capture camera)');
670
696
  // A turntable on a photograph is nonsense, so the default spin stops here — but only the
671
697
  // DEFAULT: a page that asked for one still gets it.
672
698
  if (!('idleSpin' in opts)) viewer.idleSpin = 0;
@@ -679,8 +705,10 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
679
705
  });
680
706
  pushViewRig(true);
681
707
  } else {
682
- if (bounds) viewer.fitTo(bounds.center, bounds.extent);
683
- else {
708
+ if (bounds) {
709
+ viewer.fitTo(bounds.center, bounds.extent);
710
+ depth.refresh();
711
+ } else {
684
712
  // Unframed means drawn at raw MODEL scale, which for a typical capture is several
685
713
  // times the tile. Say so: silence here is what made the same condition read as a fit
686
714
  // bug.
@@ -74,6 +74,17 @@ import {
74
74
  CURSOR_OUTLINE_COLOR,
75
75
  } from './inline3d-cursor-depth.js';
76
76
  import { CursorAmortisedPick, CursorDebugStats, cursorDebugMode } from './inline3d-cursor-perf.js';
77
+ import { noteBuiltCameraRig } from './inline3d-depth-budget.js';
78
+ // The depth-budget helpers, for a page that renders its own scene with these rigs: what disparity
79
+ // a camera rig or a display-rig depth gives, as % of the tile width. Pure; see the module.
80
+ export {
81
+ disparityPct,
82
+ cameraRigDisparityPct,
83
+ depthBudgetFromBounds,
84
+ comfortZone,
85
+ comfortDepthFactor,
86
+ cameraRigComfortFactor,
87
+ } from './inline3d-depth-budget.js';
77
88
 
78
89
  /**
79
90
  * A reusable three.js camera driven directly by an XRView's matrices. Construct once with
@@ -202,14 +213,19 @@ function scratch(THREE) {
202
213
  * glass; everything nearer pops out, everything further recedes. Point it at whatever the viewer
203
214
  * is meant to be looking at (the subject's centre, a hit-tested surface) — with a moving camera
204
215
  * that is usually just the distance to it. Left at 0 it means infinity, which puts the entire
205
- * scene in front of the display and is comfortable for almost nothing.
216
+ * scene in front of the display and is comfortable for almost nothing — so a 0 / unset
217
+ * convergence logs one console warning per page (the descriptor is still built as asked).
206
218
  *
207
- * COMFORT. The runtime's rule is `ipdFactor × metersToVirtual × convergenceDiopters × N <= 1`
208
- * (N = nominal viewing distance, ~0.5 m): at 1 the viewer's eyes are parallel on infinitely far
209
- * content, and past it they diverge, which no one can fuse. With the defaults (factors 1,
210
- * metersToVirtual 1) that is `convergence >= ~0.5` world units. Nothing here enforces it — the
211
- * runtime clamps out-of-range values itself, once, with a warning — but a scene authored in
212
- * centimetres with a 0.1-unit convergence is the shape of the mistake.
219
+ * COMFORT. The rule is `ipdFactor × metersToVirtual × convergenceDiopters × N <= 1` (N = the
220
+ * nominal viewer distance the display reports — 0.6 m on a desktop panel): at 1 the viewer's
221
+ * eyes are parallel on infinitely far content, and past it they diverge, which no one can fuse.
222
+ * With the defaults (factors 1, metersToVirtual 1) that is `convergence >= ~0.6` world units.
223
+ * NOTHING ENFORCES IT for a rig the app declares: the runtime takes the gain
224
+ * `metersToVirtual × convergenceDiopters × N` as given (XR_DXR_view_rig §3 — only a legacy
225
+ * camera profile is comfort-clamped) and clamps only values outside a field's sanity range
226
+ * (e.g. convergenceDiopters to [0, 20], verticalFov to [0.01, 3.13] rad), once, with a warning.
227
+ * A scene authored in centimetres with a 0.1-unit convergence is the shape of the mistake;
228
+ * `cameraRigDisparityPct` measures what a rig gives.
213
229
  *
214
230
  * COMFORT IS NOT A DEPTH METER. It bounds where the depth budget SITS (it guards the background
215
231
  * against divergence), not how big the budget is. The budget is
@@ -300,6 +316,8 @@ function fillCameraRig(out, verticalFovRad, opts) {
300
316
  out.convergenceDiopters = convergence > 0 ? 1 / convergence : 0;
301
317
  out.verticalFov = verticalFovRad;
302
318
  out.metersToVirtual = metersToVirtual;
319
+ // Infinity is almost never what was meant: say so once per page. The descriptor is unchanged.
320
+ if (!(out.convergenceDiopters > 0)) noteBuiltCameraRig(out);
303
321
  return out;
304
322
  }
305
323
 
@@ -315,9 +333,11 @@ function fillCameraRig(out, verticalFovRad, opts) {
315
333
  * The factors are RELATIVE here (unlike a camera rig, where ipd/parallax are absolute):
316
334
  * `ipdFactor` and `parallaxFactor` are [0,1] multipliers on what the display would naturally do
317
335
  * — 1 is correct-by-construction, 0 is flat/frozen, and the values between are a comfort dial,
318
- * not a correctness one. `perspectiveFactor` is [0.1,10] and exaggerates or flattens the
319
- * off-axis skew; it is the one knob with no physical justification, so treat it as an effect.
320
- * The runtime clamps anything out of range (once, with a warning) rather than refusing the rig.
336
+ * not a correctness one. Above 1 exaggerates depth past what the display shows by construction:
337
+ * the runtime accepts it (it clamps ipd/parallax only to [0, 10000]) and `handle.setViewRig` logs
338
+ * one console warning. `perspectiveFactor` is [0.1,10] (the runtime clamps it there, once, with a
339
+ * warning) and exaggerates or flattens the off-axis skew; it is the one knob with no physical
340
+ * justification, so treat it as an effect.
321
341
  *
322
342
  * @param {object} [opts]
323
343
  * @param {number} [opts.virtualDisplayHeight=0.24] metres of virtual display (the zoom knob).