@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.
- package/CHANGELOG.md +108 -0
- package/README.md +3 -0
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/cursor-depth.d.ts +41 -0
- package/depth.d.ts +184 -0
- package/dist/call.js +6 -6
- package/docs/making-3d-look-right.md +49 -0
- package/docs/woven-canvas-rules.md +378 -0
- package/index.d.ts +53 -3
- package/js/camera/pair-cursor.js +27 -1
- package/js/inline3d-cursor-depth-playcanvas.js +20 -2
- package/js/inline3d-cursor-depth.js +160 -1
- package/js/inline3d-depth-budget.js +342 -0
- package/js/inline3d-model-entry.js +15 -1
- package/js/inline3d-model-playcanvas.js +27 -2
- package/js/inline3d-model.js +22 -2
- package/js/inline3d-splat-deferred.js +2 -0
- package/js/inline3d-splat-pc-entry.js +2 -1
- package/js/inline3d-splat-playcanvas.js +46 -43
- package/js/inline3d-splat-shared.js +311 -0
- package/js/inline3d-splat.js +31 -3
- package/js/inline3d-three.js +75 -12
- package/js/inline3d-viewer.js +15 -1
- package/js/inline3d.js +71 -0
- package/llms.txt +21 -0
- package/model.d.ts +53 -0
- package/package.json +10 -1
- package/splat.d.ts +33 -0
- package/three.d.ts +19 -4
- package/viewer.d.ts +11 -0
|
@@ -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
|
+
}
|
package/js/inline3d-splat.js
CHANGED
|
@@ -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)
|
|
683
|
-
|
|
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.
|
package/js/inline3d-three.js
CHANGED
|
@@ -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
|
|
207
|
-
*
|
|
208
|
-
* content, and past it they diverge, which no one can fuse.
|
|
209
|
-
* metersToVirtual 1) that is `convergence >= ~0.
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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.
|
|
318
|
-
*
|
|
319
|
-
*
|
|
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} [
|
|
644
|
+
* @param {number|{nowMs?: number, layer?: object|null}} [opts] the time (ms), or `{ nowMs, layer }`.
|
|
611
645
|
*/
|
|
612
|
-
update(views,
|
|
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;
|
package/js/inline3d-viewer.js
CHANGED
|
@@ -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
|
-
|
|
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
|