@displayxr/inline3d 1.45.1 → 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.
@@ -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.
@@ -55,6 +55,7 @@
55
55
  import {
56
56
  CursorDepthPlacer,
57
57
  CursorPointer,
58
+ CursorRuntimeLink,
58
59
  cursorArrowFootprint,
59
60
  cursorDefaultHeight,
60
61
  cursorModelMatrix,
@@ -73,6 +74,17 @@ import {
73
74
  CURSOR_OUTLINE_COLOR,
74
75
  } from './inline3d-cursor-depth.js';
75
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';
76
88
 
77
89
  /**
78
90
  * A reusable three.js camera driven directly by an XRView's matrices. Construct once with
@@ -201,14 +213,19 @@ function scratch(THREE) {
201
213
  * glass; everything nearer pops out, everything further recedes. Point it at whatever the viewer
202
214
  * is meant to be looking at (the subject's centre, a hit-tested surface) — with a moving camera
203
215
  * that is usually just the distance to it. Left at 0 it means infinity, which puts the entire
204
- * 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).
205
218
  *
206
- * COMFORT. The runtime's rule is `ipdFactor × metersToVirtual × convergenceDiopters × N <= 1`
207
- * (N = nominal viewing distance, ~0.5 m): at 1 the viewer's eyes are parallel on infinitely far
208
- * content, and past it they diverge, which no one can fuse. With the defaults (factors 1,
209
- * metersToVirtual 1) that is `convergence >= ~0.5` world units. Nothing here enforces it — the
210
- * runtime clamps out-of-range values itself, once, with a warning — but a scene authored in
211
- * 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.
212
229
  *
213
230
  * COMFORT IS NOT A DEPTH METER. It bounds where the depth budget SITS (it guards the background
214
231
  * against divergence), not how big the budget is. The budget is
@@ -299,6 +316,8 @@ function fillCameraRig(out, verticalFovRad, opts) {
299
316
  out.convergenceDiopters = convergence > 0 ? 1 / convergence : 0;
300
317
  out.verticalFov = verticalFovRad;
301
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);
302
321
  return out;
303
322
  }
304
323
 
@@ -314,9 +333,11 @@ function fillCameraRig(out, verticalFovRad, opts) {
314
333
  * The factors are RELATIVE here (unlike a camera rig, where ipd/parallax are absolute):
315
334
  * `ipdFactor` and `parallaxFactor` are [0,1] multipliers on what the display would naturally do
316
335
  * — 1 is correct-by-construction, 0 is flat/frozen, and the values between are a comfort dial,
317
- * not a correctness one. `perspectiveFactor` is [0.1,10] and exaggerates or flattens the
318
- * off-axis skew; it is the one knob with no physical justification, so treat it as an effect.
319
- * 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.
320
341
  *
321
342
  * @param {object} [opts]
322
343
  * @param {number} [opts.virtualDisplayHeight=0.24] metres of virtual display (the zoom knob).
@@ -569,6 +590,13 @@ export class DepthCursor {
569
590
  this.shapes = new CursorShapeSelector(this.pointer, { shape, shapeFor });
570
591
  this._tex = null; // { geometry, material } — the textured quad, built on the first non-arrow sprite
571
592
  this._textures = new Map(); // sprite key → THREE texture
593
+ /**
594
+ * The runtime-drawn cursor (runtime ADR-046 Amendment 4): when the frame's layer says
595
+ * "runtime", the depth goes to the browser and no sprite is drawn. Inert without a layer.
596
+ */
597
+ this.runtime = new CursorRuntimeLink();
598
+ /** The XRDisplayLayer this canvas weaves through; `update(views, { layer })` sets it per frame. */
599
+ this.layer = null;
572
600
 
573
601
  // The arrow: a dark outline polygon, the fill inset over it; tip at the origin (the hotspot).
574
602
  const fill = [((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255, 1];
@@ -606,10 +634,22 @@ export class DepthCursor {
606
634
 
607
635
  /**
608
636
  * Place the sprite for this frame. Call with the views you are about to render.
637
+ *
638
+ * Pass the frame's `layer` (the second argument of wall.addScene's callback) as
639
+ * `update(views, { layer })`: where the browser and the runtime draw the web cursor
640
+ * (`layer.getCursorDepthSupport()` → "runtime", runtime ADR-046 Amendment 4) the depth under the
641
+ * pointer is handed to the layer and no sprite is drawn — the browser's own cursor, lifted by
642
+ * the runtime. Without a layer, or where it says "none", the sprite is drawn as always.
609
643
  * @param {ArrayLike<XRView|{projectionMatrix:ArrayLike<number>,transformMatrix:ArrayLike<number>}>} views
610
- * @param {number} [nowMs=performance.now()]
644
+ * @param {number|{nowMs?: number, layer?: object|null}} [opts] the time (ms), or `{ nowMs, layer }`.
611
645
  */
612
- update(views, nowMs = globalThis.performance ? globalThis.performance.now() : Date.now()) {
646
+ update(views, opts) {
647
+ let nowMs = opts;
648
+ if (opts && typeof opts === 'object') {
649
+ if ('layer' in opts) this.layer = opts.layer || null;
650
+ nowMs = opts.nowMs;
651
+ }
652
+ if (!(typeof nowMs === 'number' && Number.isFinite(nowMs))) nowMs = globalThis.performance ? globalThis.performance.now() : Date.now();
613
653
  if (this.debugStats) return this._updateMeasured(views, nowMs);
614
654
  return this._update(views, nowMs);
615
655
  }
@@ -631,6 +671,7 @@ export class DepthCursor {
631
671
  }
632
672
 
633
673
  _update(views, nowMs) {
674
+ if (this.runtime.bind(this.layer)) return this._updateRuntime(views, nowMs);
634
675
  const vs = this._worldViews(views);
635
676
  const uv = this.pointer.uv;
636
677
  let p = { active: false };
@@ -661,6 +702,27 @@ export class DepthCursor {
661
702
  return p;
662
703
  }
663
704
 
705
+ // The runtime draws (ADR-046 Amendment 4): the same pick and the same placement maths, but the
706
+ // RAW content disparity goes to the layer in device px, and nothing is drawn or hidden here.
707
+ _updateRuntime(views, nowMs) {
708
+ this.object.visible = false;
709
+ this.softening = null;
710
+ this.pointer.hideCss(false);
711
+ const uv = this.pointer.uv;
712
+ const vs = uv && views && views.length >= 2 ? this._worldViews(views) : null;
713
+ if (!vs) {
714
+ this.placer.update(null); // forget the hybrid anchor
715
+ this.runtime.withdraw();
716
+ return (this.placement = { active: false, runtime: true });
717
+ }
718
+ const [u, v] = uv;
719
+ const h = this.height ?? cursorDefaultHeight(this.pointer.cssHeight());
720
+ const nearestPoint = this._footprintHit(vs, u, v, h);
721
+ const p = this.placer.update(vs, { u, v, nearestPoint, cursorHeight: h, canvasPx: this.pointer.panelPx() }, nowMs / 1000);
722
+ this.runtime.report(u, v, p.solved ? p.contentDisparityPx : null);
723
+ return (this.placement = { ...p, active: false, runtime: true });
724
+ }
725
+
664
726
  // Crisp frames draw the arrow mesh exactly as without softening; softened ones swap in a quad
665
727
  // whose shader evaluates the arrow at the runtime's 13 blur taps, times the opacity.
666
728
  _useSoft(s) {
@@ -804,6 +866,7 @@ export class DepthCursor {
804
866
 
805
867
  /** Remove listeners, give the CSS cursor back, free the sprite's GPU resources. */
806
868
  dispose() {
869
+ this.runtime.dispose();
807
870
  this.pointer.dispose();
808
871
  this.shapes.dispose();
809
872
  this.object.visible = false;
@@ -73,6 +73,7 @@ import {
73
73
  mismatchWarning,
74
74
  } from './inline3d-buffer-limit.js';
75
75
  import { viewerEaseFor, frameTrackingState } from './inline3d-viewer-ease.js';
76
+ import { parseClearColor } from './inline3d-splat-shared.js';
76
77
 
77
78
  const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
78
79
  // NaN/Infinity into a transform silently blanks the tile — three propagates it into the
@@ -269,6 +270,10 @@ export class SceneViewer {
269
270
  * @param {number} [opts.feather=0] edge fade in buffer px (needs ./three's EdgeFeather).
270
271
  * @param {number[]} [opts.pitchLimit=[-60,60]] degrees; stops the viewer rolling under the
271
272
  * subject, which reads as broken rather than as a feature.
273
+ * @param {number[]|string} [opts.clearColor] OPT-IN eye clear, `[r,g,b,a]` in 0..1 or a CSS
274
+ * colour. Unset: the transparent clear (0,0,0,0), exactly as before. An opaque clear
275
+ * avoids stale / black pixels on the weave (web#198); match the page colour behind the
276
+ * canvas.
272
277
  */
273
278
  constructor(THREE, canvas, opts = {}) {
274
279
  const {
@@ -307,11 +312,19 @@ export class SceneViewer {
307
312
  this.logTag = opts.logTag || '[inline3d/viewer]';
308
313
  this.pitchLimit = pitchLimit;
309
314
  this.idleSpin = idleSpin;
315
+ // Opt-in (null = off, and then nothing below differs from before it existed).
316
+ this.clearColor = parseClearColor(opts.clearColor, this.logTag);
310
317
 
311
318
  // alpha + a zero-alpha clear so the tile can dissolve into the page rather than ending at
312
319
  // a hard rectangle. An opaque scene.background would defeat both this and the feather.
313
320
  this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
314
321
  this.renderer.setClearColor(0x000000, 0);
322
+ // clearColor (opt-in) replaces that clear. three reads a hex as sRGB, as a CSS colour is.
323
+ if (this.clearColor) {
324
+ const [r, g, b, a] = this.clearColor;
325
+ const hex = (Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255);
326
+ this.renderer.setClearColor(hex, a);
327
+ }
315
328
  // MUST be 1. layer.getViewport() reports BACKING-STORE px, but three.js multiplies whatever
316
329
  // you pass setViewport()/setScissor() by the renderer's pixelRatio — so any other value
317
330
  // silently scales every eye viewport (at dpr 2 the left eye covers the whole canvas). It
@@ -740,7 +753,8 @@ export class SceneViewer {
740
753
  // cursor: 'depth' (ADR-046) — only when a page opted in; null otherwise.
741
754
  if (this.cursorDepth) {
742
755
  this.scene.updateMatrixWorld(); // the hit test reads THIS frame's subject pose
743
- this.cursorDepth.update(g.entries.map((e) => ({ projectionMatrix: e.proj, transformMatrix: e.pose })));
756
+ // The layer too: where the runtime draws the web cursor (ADR-046 Amendment 4) the depth goes to it.
757
+ this.cursorDepth.update(g.entries.map((e) => ({ projectionMatrix: e.proj, transformMatrix: e.pose })), { layer });
744
758
  }
745
759
  const r = this.renderer;
746
760
  // getViewport() splits canvas.width; a drawing buffer the browser clamped behind our back