@displayxr/inline3d 1.43.1 → 1.44.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 +63 -0
- package/THIRD_PARTY_NOTICES.md +26 -2
- package/camera.d.ts +3 -0
- package/cursor-depth.d.ts +124 -1
- package/dist/call.js +4 -4
- package/js/camera/pair-cursor.js +74 -5
- package/js/inline3d-cursor-bvh.js +147 -0
- package/js/inline3d-cursor-depth-playcanvas.js +150 -4
- package/js/inline3d-cursor-depth.js +407 -24
- package/js/inline3d-cursor-option.js +121 -6
- package/js/inline3d-cursor-perf.js +199 -0
- package/js/inline3d-model.js +30 -1
- package/js/inline3d-playcanvas-engine.js +1 -0
- package/js/inline3d-three.js +149 -27
- package/js/vendor/three-mesh-bvh.js +3 -0
- package/model.d.ts +1 -1
- package/package.json +6 -1
- package/splat.d.ts +1 -1
- package/three.d.ts +33 -4
|
@@ -8,18 +8,129 @@
|
|
|
8
8
|
export const CURSOR_ANCHOR_MODES = Object.freeze(['hybrid', 'screen', 'world']);
|
|
9
9
|
|
|
10
10
|
/** The keys `cursor: { … }` accepts on ./model and ./splat. */
|
|
11
|
-
export const CURSOR_OPTION_KEYS = Object.freeze(['height', 'margin', 'anchor', 'pointerScope']);
|
|
11
|
+
export const CURSOR_OPTION_KEYS = Object.freeze(['height', 'margin', 'anchor', 'pointerScope', 'debug', 'soften']);
|
|
12
|
+
|
|
13
|
+
// ── Crosstalk softening (runtime ADR-046 Amendment 2 §B.7) ─────────────────────────────────────
|
|
14
|
+
// On a lenticular panel a little of every view leaks into its neighbours. A LIFTED cursor's leaked
|
|
15
|
+
// copy sits one ghost offset away, and how visible it is scales with contrast × edge sharpness; on
|
|
16
|
+
// the glass the views coincide and there is no ghost. So the drawn arrow is softened with lift:
|
|
17
|
+
// an edge blur of k × |ghost offset| panel px (capped) and an opacity easing to alphaMin. Both are
|
|
18
|
+
// exactly zero on the glass. The numbers are the runtime's U_CURSOR_SOFTEN_DEFAULT_*, tuned by eye
|
|
19
|
+
// on a Windows Leia panel and confirmed on a Mac (2026-10-11/12), so web and native match.
|
|
20
|
+
|
|
21
|
+
/** `cursor.soften` presets: both effects (default), neither, or one of them (the A/B presets). */
|
|
22
|
+
export const CURSOR_SOFTEN_PRESETS = Object.freeze(['both', 'off', 'alpha', 'blur']);
|
|
23
|
+
|
|
24
|
+
/** The runtime's softening defaults (u_cursor_soften_params_default). */
|
|
25
|
+
export const CURSOR_SOFTEN_DEFAULTS = Object.freeze({
|
|
26
|
+
/** Edge blur on. */
|
|
27
|
+
blur: true,
|
|
28
|
+
/** Opacity easing on. */
|
|
29
|
+
alpha: true,
|
|
30
|
+
/** Panel px of blur per panel px of ghost offset. */
|
|
31
|
+
blurK: 0.1,
|
|
32
|
+
/** Hard ceiling on the blur radius, panel px (the real cap is blurMaxFrac). */
|
|
33
|
+
blurMaxPx: 8,
|
|
34
|
+
/** Blur cap as a fraction of the DRAWN arrow's height (~4 px on a 64 px arrow), so it reads the same at any DPI. */
|
|
35
|
+
blurMaxFrac: 0.06,
|
|
36
|
+
/** Opacity at full lift. */
|
|
37
|
+
alphaMin: 0.5,
|
|
38
|
+
/** |disparity| (eye baselines) at which the opacity reaches alphaMin: the lift over typical content. */
|
|
39
|
+
fullD: 0.03,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// The numeric knobs and their legal ranges [lo, hi, loOpen] — the runtime's parse ranges.
|
|
43
|
+
const SOFTEN_NUMBERS = Object.freeze({
|
|
44
|
+
blurK: [0, 1000, false],
|
|
45
|
+
blurMaxPx: [0, 8, false],
|
|
46
|
+
blurMaxFrac: [0, 0.5, false],
|
|
47
|
+
alphaMin: [0, 1, false],
|
|
48
|
+
fullD: [0, 1000, true],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** The keys `cursor.soften: { … }` accepts: an optional preset `mode`, then the numbers. */
|
|
52
|
+
export const CURSOR_SOFTEN_KEYS = Object.freeze(['mode', ...Object.keys(SOFTEN_NUMBERS)]);
|
|
53
|
+
|
|
54
|
+
const inSoftenRange = (k, v) => {
|
|
55
|
+
const [lo, hi, loOpen] = SOFTEN_NUMBERS[k];
|
|
56
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi && !(loOpen && v <= lo);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const presetFlags = (mode) => ({ blur: mode === 'both' || mode === 'blur', alpha: mode === 'both' || mode === 'alpha' });
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `cursor.soften` as the API accepts it, resolved to the full parameter set: a preset
|
|
63
|
+
* (`'both'` — the default — `'off'`, `'alpha'`, `'blur'`), or `{ mode?, blurK?, blurMaxPx?,
|
|
64
|
+
* blurMaxFrac?, alphaMin?, fullD? }` over the defaults. `undefined` / `null` / `true` = the
|
|
65
|
+
* defaults, `false` = `'off'`. Throws on a bad value, like every other option, at the call.
|
|
66
|
+
* @returns {{blur: boolean, alpha: boolean, blurK: number, blurMaxPx: number, blurMaxFrac: number, alphaMin: number, fullD: number}}
|
|
67
|
+
*/
|
|
68
|
+
export function resolveCursorSoften(opt, who = '@displayxr/inline3d') {
|
|
69
|
+
if (opt === undefined || opt === null || opt === true) return { ...CURSOR_SOFTEN_DEFAULTS };
|
|
70
|
+
if (opt === false) opt = 'off';
|
|
71
|
+
const bad = () => new Error(`${who}: cursor.soften ${JSON.stringify(opt)} — expected ${CURSOR_SOFTEN_PRESETS.map((m) => `'${m}'`).join(' | ')} or { ${CURSOR_SOFTEN_KEYS.join(', ')} }.`);
|
|
72
|
+
if (typeof opt === 'string') {
|
|
73
|
+
if (!CURSOR_SOFTEN_PRESETS.includes(opt)) throw bad();
|
|
74
|
+
return { ...CURSOR_SOFTEN_DEFAULTS, ...presetFlags(opt) };
|
|
75
|
+
}
|
|
76
|
+
if (typeof opt !== 'object' || Array.isArray(opt)) throw bad();
|
|
77
|
+
const unknown = Object.keys(opt).filter((k) => !CURSOR_SOFTEN_KEYS.includes(k));
|
|
78
|
+
if (unknown.length) throw new Error(`${who}: cursor.soften — unknown option(s) ${unknown.join(', ')}; expected ${CURSOR_SOFTEN_KEYS.join(', ')}.`);
|
|
79
|
+
if (opt.mode !== undefined && !CURSOR_SOFTEN_PRESETS.includes(opt.mode)) throw bad();
|
|
80
|
+
const out = { ...CURSOR_SOFTEN_DEFAULTS, ...presetFlags(opt.mode ?? 'both') };
|
|
81
|
+
for (const k of Object.keys(SOFTEN_NUMBERS)) {
|
|
82
|
+
if (opt[k] === undefined) continue;
|
|
83
|
+
const [lo, hi, loOpen] = SOFTEN_NUMBERS[k];
|
|
84
|
+
if (!inSoftenRange(k, opt[k])) throw new Error(`${who}: cursor.soften.${k} must be a number in ${loOpen ? '(' : '['}${lo}, ${hi}].`);
|
|
85
|
+
out[k] = opt[k];
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// strtod-like: optional leading whitespace, a decimal float, optional trailing spaces/tabs.
|
|
91
|
+
const SOFTEN_FLOAT = /^\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?[ \t]*$/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The runtime's lenient knob parse (u_cursor_soften_params_parse), for string sources such as URL
|
|
95
|
+
* parameters: `mode` 'off' | 'alpha' | 'blur' | 'both' (its leading word; anything else = both),
|
|
96
|
+
* the numbers as strings. Each may be null/undefined (= default); a value that does not parse or
|
|
97
|
+
* is out of range keeps its default instead of throwing.
|
|
98
|
+
* @returns {{params: object, changed: boolean}} `changed`: any knob differs from its default.
|
|
99
|
+
*/
|
|
100
|
+
export function parseCursorSoften(mode, blurK, blurMaxPx, blurMaxFrac, alphaMin, fullD) {
|
|
101
|
+
const params = { ...CURSOR_SOFTEN_DEFAULTS };
|
|
102
|
+
let changed = false;
|
|
103
|
+
if (mode !== null && mode !== undefined) {
|
|
104
|
+
const word = String(mode).split(/[ \t]/)[0];
|
|
105
|
+
if (word === 'off') params.blur = params.alpha = false;
|
|
106
|
+
else if (word === 'alpha') params.blur = false;
|
|
107
|
+
else if (word === 'blur') params.alpha = false;
|
|
108
|
+
changed = !params.blur || !params.alpha;
|
|
109
|
+
}
|
|
110
|
+
const raw = { blurK, blurMaxPx, blurMaxFrac, alphaMin, fullD };
|
|
111
|
+
for (const k of Object.keys(SOFTEN_NUMBERS)) {
|
|
112
|
+
const s = raw[k];
|
|
113
|
+
if (s === null || s === undefined || s === '' || !SOFTEN_FLOAT.test(String(s))) continue;
|
|
114
|
+
const v = parseFloat(String(s));
|
|
115
|
+
if (!inSoftenRange(k, v)) continue;
|
|
116
|
+
params[k] = v;
|
|
117
|
+
changed = true;
|
|
118
|
+
}
|
|
119
|
+
return { params, changed };
|
|
120
|
+
}
|
|
12
121
|
|
|
13
122
|
/**
|
|
14
123
|
* `cursor` as ./model and ./splat accept it: `'depth'`, or an options object (which also means
|
|
15
124
|
* depth). Anything falsy is OFF — the zero-cost default. Validated here, at the call.
|
|
16
|
-
*
|
|
125
|
+
* `debug`: true logs the cursor's per-frame cost (rolling stats, at most once a second); 'both' also
|
|
126
|
+
* keeps the OS cursor visible beside the sprite (the latency check). Off by default, and free.
|
|
127
|
+
* @returns {null | {height?: number, margin?: number, anchor?: string, pointerScope?: string, debug?: boolean|'both', soften?: string | object}}
|
|
17
128
|
*/
|
|
18
129
|
export function resolveCursorOption(opt, who = '@displayxr/inline3d') {
|
|
19
130
|
if (opt === undefined || opt === null || opt === false) return null;
|
|
20
131
|
if (opt === 'depth' || opt === true) return {};
|
|
21
132
|
if (typeof opt !== 'object') {
|
|
22
|
-
throw new Error(`${who}: cursor ${JSON.stringify(opt)} — expected 'depth' or { height, margin, anchor, pointerScope }.`);
|
|
133
|
+
throw new Error(`${who}: cursor ${JSON.stringify(opt)} — expected 'depth' or { height, margin, anchor, pointerScope, debug, soften }.`);
|
|
23
134
|
}
|
|
24
135
|
const unknown = Object.keys(opt).filter((k) => !CURSOR_OPTION_KEYS.includes(k));
|
|
25
136
|
if (unknown.length) throw new Error(`${who}: cursor — unknown option(s) ${unknown.join(', ')}; expected ${CURSOR_OPTION_KEYS.join(', ')}.`);
|
|
@@ -29,23 +140,27 @@ export function resolveCursorOption(opt, who = '@displayxr/inline3d') {
|
|
|
29
140
|
if (opt.pointerScope !== undefined && opt.pointerScope !== 'canvas' && opt.pointerScope !== 'window') {
|
|
30
141
|
throw new Error(`${who}: cursor.pointerScope "${opt.pointerScope}" — expected 'canvas' or 'window'.`);
|
|
31
142
|
}
|
|
143
|
+
if (opt.debug !== undefined && typeof opt.debug !== 'boolean' && opt.debug !== 'both') {
|
|
144
|
+
throw new Error(`${who}: cursor.debug ${JSON.stringify(opt.debug)} — expected true, false or 'both'.`);
|
|
145
|
+
}
|
|
32
146
|
for (const k of ['height', 'margin']) {
|
|
33
147
|
if (opt[k] !== undefined && !(typeof opt[k] === 'number' && opt[k] > 0 && Number.isFinite(opt[k]))) {
|
|
34
148
|
throw new Error(`${who}: cursor.${k} must be a positive number.`);
|
|
35
149
|
}
|
|
36
150
|
}
|
|
151
|
+
if (opt.soften !== undefined) resolveCursorSoften(opt.soften, who); // validate at the call
|
|
37
152
|
return { ...opt };
|
|
38
153
|
}
|
|
39
154
|
|
|
40
155
|
/** The keys `cursor: { … }` accepts on a stereo PAIR (./camera's addCameraView, ./call's tiles). */
|
|
41
|
-
export const PAIR_CURSOR_OPTION_KEYS = Object.freeze(['height', 'pointerScope']);
|
|
156
|
+
export const PAIR_CURSOR_OPTION_KEYS = Object.freeze(['height', 'pointerScope', 'soften']);
|
|
42
157
|
|
|
43
158
|
/**
|
|
44
|
-
* `cursor` on a stereo pair — a camera view or a call tile: `'depth'`, or `{ height, pointerScope }`.
|
|
159
|
+
* `cursor` on a stereo pair — a camera view or a call tile: `'depth'`, or `{ height, pointerScope, soften }`.
|
|
45
160
|
* A pair has no scene: its depth is MEASURED off the two halves, and the sprite is drawn into them,
|
|
46
161
|
* so there is no anchor mode (it is always on the line of sight) and the margin is the SDK's. Falsy
|
|
47
162
|
* is OFF, the zero-cost default.
|
|
48
|
-
* @returns {null | {height?: number, pointerScope?: string}}
|
|
163
|
+
* @returns {null | {height?: number, pointerScope?: string, soften?: string | object}}
|
|
49
164
|
*/
|
|
50
165
|
export function resolvePairCursorOption(opt, who = '@displayxr/inline3d') {
|
|
51
166
|
const o = resolveCursorOption(opt, who);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// inline3d-cursor-perf.js — what keeps the depth cursor responsive, dependency-free and internal
|
|
2
|
+
// (not a package subpath; ./three's DepthCursor is its only user):
|
|
3
|
+
//
|
|
4
|
+
// - CursorAmortisedPick: the footprint pick spread over frames. The sprite is placed every frame
|
|
5
|
+
// at the NEWEST pointer position with the last known depth; the pick only refreshes that depth,
|
|
6
|
+
// a few rays a frame, so it never stretches the frame it runs in. The depth filter (rise 30 ms,
|
|
7
|
+
// sink 250 ms) absorbs the cadence.
|
|
8
|
+
// - CursorDebugStats: `cursor: { debug: true }` — per-frame cost, rays and frame interval as
|
|
9
|
+
// rolling stats, printed at most once a second. Nothing is built without the option.
|
|
10
|
+
//
|
|
11
|
+
// The policy (ADR-046 Amendment 2: never behind the glass, the OS cursor on it, the hybrid anchor
|
|
12
|
+
// exact while moving) is untouched: it lives in CursorDepthPlacer, downstream of the pick.
|
|
13
|
+
|
|
14
|
+
/** Rays a frame ./model's three.js cursor casts (with a BVH, a ray costs a few microseconds). */
|
|
15
|
+
export const CURSOR_MODEL_RAYS_PER_FRAME = 3;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The footprint pick, amortised: `raysPerFrame` rays a frame, cycling every (footprint point, eye)
|
|
19
|
+
* slot. The slot order covers every footprint point once (alternating eyes) before it repeats a
|
|
20
|
+
* point, so a full spatial sweep takes ceil(points / N) frames and both eyes twice that.
|
|
21
|
+
*
|
|
22
|
+
* Each slot keeps its last hit (a world point, or null for a miss) and the hotspot it was cast
|
|
23
|
+
* from. A slot cast from a hotspot more than `reach` away (canvas heights) is stale — the pointer
|
|
24
|
+
* has moved on — and is ignored, so a fast-moving pointer is judged on its freshest rays only and
|
|
25
|
+
* a still one on the whole footprint.
|
|
26
|
+
*/
|
|
27
|
+
export class CursorAmortisedPick {
|
|
28
|
+
/** @param {number} raysPerFrame >= 1. */
|
|
29
|
+
constructor(raysPerFrame) {
|
|
30
|
+
this.raysPerFrame = Math.max(1, Math.floor(raysPerFrame) || 1);
|
|
31
|
+
this._slots = []; // { point, u, v } | undefined (never cast)
|
|
32
|
+
this._next = 0;
|
|
33
|
+
this._n = 0; // slot count the ring was sized for
|
|
34
|
+
/** Rays cast by the last step (diagnostics). */
|
|
35
|
+
this.lastRays = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Forget every slot (the next step starts a fresh sweep). */
|
|
39
|
+
reset() {
|
|
40
|
+
this._slots.length = 0;
|
|
41
|
+
this._next = 0;
|
|
42
|
+
this._n = 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The (point, eye) of slot `k` among `points` × `eyes`: eye = (point + sweep) mod eyes, so each
|
|
47
|
+
* sweep of `points` slots alternates eyes and the next sweep takes the other ones.
|
|
48
|
+
*/
|
|
49
|
+
static slot(k, points, eyes) {
|
|
50
|
+
const p = k % points;
|
|
51
|
+
return [p, (p + Math.floor(k / points)) % eyes];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Cast this frame's rays and return the nearest live hit, or null.
|
|
56
|
+
* @param {Array<[number, number]>} footprint canvas-normalised points, the hotspot first.
|
|
57
|
+
* @param {number} eyes how many views to cast from (1 or 2).
|
|
58
|
+
* @param {(eye: number, u: number, v: number) => number[]|null} cast one ray → its hit.
|
|
59
|
+
* @param {number[]} forward the viewing direction (unit): smaller dot = nearer the viewer.
|
|
60
|
+
* @param {number} aspect canvas width / height (u distances are scaled by it).
|
|
61
|
+
* @param {number} reach how far (canvas heights) the hotspot may have moved since a slot was cast.
|
|
62
|
+
*/
|
|
63
|
+
step(footprint, eyes, cast, forward, aspect, reach) {
|
|
64
|
+
const P = footprint.length;
|
|
65
|
+
const E = Math.max(1, eyes | 0);
|
|
66
|
+
const n = P * E;
|
|
67
|
+
if (n !== this._n) {
|
|
68
|
+
this.reset();
|
|
69
|
+
this._n = n;
|
|
70
|
+
}
|
|
71
|
+
const [u0, v0] = footprint[0];
|
|
72
|
+
const rays = Math.min(this.raysPerFrame, n);
|
|
73
|
+
for (let i = 0; i < rays; i++) {
|
|
74
|
+
const k = this._next;
|
|
75
|
+
this._next = (k + 1) % n;
|
|
76
|
+
const [p, e] = CursorAmortisedPick.slot(k, P, E);
|
|
77
|
+
this._slots[k] = { point: cast(e, footprint[p][0], footprint[p][1]), u: u0, v: v0 };
|
|
78
|
+
}
|
|
79
|
+
this.lastRays = rays;
|
|
80
|
+
const r2 = reach * reach;
|
|
81
|
+
let best = Infinity, nearest = null;
|
|
82
|
+
for (let k = 0; k < n; k++) {
|
|
83
|
+
const s = this._slots[k];
|
|
84
|
+
if (!s || !s.point) continue;
|
|
85
|
+
const du = (s.u - u0) * aspect, dv = s.v - v0;
|
|
86
|
+
if (du * du + dv * dv > r2) continue; // cast from where the pointer no longer is
|
|
87
|
+
const q = s.point;
|
|
88
|
+
const depth = q[0] * forward[0] + q[1] * forward[1] + q[2] * forward[2];
|
|
89
|
+
if (depth < best) {
|
|
90
|
+
best = depth;
|
|
91
|
+
nearest = q;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return nearest;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `cursor.debug` → the stats mode: false (off), 'stats', or 'both' (stats + the OS cursor kept visible). */
|
|
99
|
+
export function cursorDebugMode(debug) {
|
|
100
|
+
if (debug === 'both') return 'both';
|
|
101
|
+
return debug ? 'stats' : false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const mean = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0);
|
|
105
|
+
const quantile = (a, q) => {
|
|
106
|
+
if (!a.length) return 0;
|
|
107
|
+
const s = Float64Array.from(a).sort();
|
|
108
|
+
return s[Math.min(s.length - 1, Math.floor(q * s.length))];
|
|
109
|
+
};
|
|
110
|
+
const max = (a) => (a.length ? Math.max(...a) : 0);
|
|
111
|
+
const stat = (a) => ({ mean: mean(a), p95: quantile(a, 0.95), max: max(a), n: a.length });
|
|
112
|
+
const f = (x, d = 2) => x.toFixed(d);
|
|
113
|
+
|
|
114
|
+
function describePick(p) {
|
|
115
|
+
if (p.mode !== 'bvh') return p.mode;
|
|
116
|
+
return `bvh (${p.built} built in ${f(p.buildMs, 1)} ms${p.refits ? `, ${p.refits} refits` : ''}${p.failed ? `, ${p.failed} failed` : ''}, ${p.plainCasts} plain casts)`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Rolling per-frame stats for `cursor: { debug }`, summarised and logged at most once per
|
|
121
|
+
* `periodMs` — never per frame. `history` keeps the last 120 summaries (two minutes) for a
|
|
122
|
+
* harness to read; `last` is the newest.
|
|
123
|
+
*/
|
|
124
|
+
export class CursorDebugStats {
|
|
125
|
+
/**
|
|
126
|
+
* @param {{tag?: string, periodMs?: number, log?: (line: string) => void, info?: (() => object)|null}} [opts]
|
|
127
|
+
* `info`: the hit test's own diagnostics (which pick answers), copied into each summary as `pick`.
|
|
128
|
+
*/
|
|
129
|
+
constructor({ tag = '[inline3d] cursor', periodMs = 1000, log, info = null } = {}) {
|
|
130
|
+
this.tag = tag;
|
|
131
|
+
this.info = info;
|
|
132
|
+
/** Which event feeds the pointer ('pointerrawupdate' / 'pointermove'), when the owner knows. */
|
|
133
|
+
this.source = null;
|
|
134
|
+
this.periodMs = periodMs;
|
|
135
|
+
this.log = log || ((line) => console.info(line));
|
|
136
|
+
/** @type {object|null} */
|
|
137
|
+
this.last = null;
|
|
138
|
+
/** @type {object[]} */
|
|
139
|
+
this.history = [];
|
|
140
|
+
this._start = null;
|
|
141
|
+
this._prev = null;
|
|
142
|
+
this._clear();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_clear() {
|
|
146
|
+
this._update = [];
|
|
147
|
+
this._rays = [];
|
|
148
|
+
this._frame = [];
|
|
149
|
+
this._age = [];
|
|
150
|
+
this._samples = 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One frame.
|
|
155
|
+
* @param {number} updateMs what DepthCursor.update() took.
|
|
156
|
+
* @param {number} rays rays it cast.
|
|
157
|
+
* @param {number} nowMs the frame time (performance.now()).
|
|
158
|
+
* @param {number|null} pointerAgeMs age of the pointer sample this frame used, when it is NEW
|
|
159
|
+
* this frame (null when the pointer has not moved since the last frame).
|
|
160
|
+
* @param {number} [samples=0] pointer samples received since the last frame.
|
|
161
|
+
* @returns {object|null} the summary when one was emitted this frame.
|
|
162
|
+
*/
|
|
163
|
+
frame(updateMs, rays, nowMs, pointerAgeMs = null, samples = 0) {
|
|
164
|
+
if (this._start === null) this._start = nowMs;
|
|
165
|
+
if (this._prev !== null && nowMs > this._prev) this._frame.push(nowMs - this._prev);
|
|
166
|
+
this._prev = nowMs;
|
|
167
|
+
this._update.push(updateMs);
|
|
168
|
+
this._rays.push(rays);
|
|
169
|
+
if (Number.isFinite(pointerAgeMs) && pointerAgeMs >= 0) this._age.push(pointerAgeMs); // null (no new sample) is not finite
|
|
170
|
+
this._samples += samples;
|
|
171
|
+
if (nowMs - this._start < this.periodMs) return null;
|
|
172
|
+
const s = {
|
|
173
|
+
frames: this._update.length,
|
|
174
|
+
seconds: (nowMs - this._start) / 1000,
|
|
175
|
+
updateMs: stat(this._update),
|
|
176
|
+
rays: stat(this._rays),
|
|
177
|
+
frameMs: stat(this._frame),
|
|
178
|
+
pointerAgeMs: stat(this._age),
|
|
179
|
+
samplesPerFrame: this._update.length ? this._samples / this._update.length : 0,
|
|
180
|
+
pointerSource: this.source,
|
|
181
|
+
pick: this.info ? this.info() : null,
|
|
182
|
+
};
|
|
183
|
+
this.last = s;
|
|
184
|
+
this.history.push(s);
|
|
185
|
+
if (this.history.length > 120) this.history.shift();
|
|
186
|
+
this._start = nowMs;
|
|
187
|
+
this._clear();
|
|
188
|
+
this.log(
|
|
189
|
+
`${this.tag} debug: ${s.frames} frames` +
|
|
190
|
+
` · update ${f(s.updateMs.mean)} ms avg / ${f(s.updateMs.p95)} p95 / ${f(s.updateMs.max)} max` +
|
|
191
|
+
` · rays ${f(s.rays.mean, 1)}/frame (max ${s.rays.max})` +
|
|
192
|
+
` · frame ${f(s.frameMs.mean, 1)} ms avg / ${f(s.frameMs.p95, 1)} p95 / ${f(s.frameMs.max, 1)} max` +
|
|
193
|
+
(s.pointerAgeMs.n ? ` · pointer age ${f(s.pointerAgeMs.mean, 1)} ms avg / ${f(s.pointerAgeMs.max, 1)} max` : '') +
|
|
194
|
+
` · ${f(s.samplesPerFrame, 1)} pointer samples/frame${s.pointerSource ? ` (${s.pointerSource})` : ''}` +
|
|
195
|
+
(s.pick ? ` · pick ${describePick(s.pick)}` : ''),
|
|
196
|
+
);
|
|
197
|
+
return s;
|
|
198
|
+
}
|
|
199
|
+
}
|
package/js/inline3d-model.js
CHANGED
|
@@ -42,11 +42,38 @@ import { guardedAttach } from './inline3d-splat-shared.js';
|
|
|
42
42
|
import * as THREE from 'three';
|
|
43
43
|
import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest } from './inline3d-three.js';
|
|
44
44
|
import { resolveCursorOption } from './inline3d-cursor-depth.js';
|
|
45
|
+
import { CURSOR_MODEL_RAYS_PER_FRAME } from './inline3d-cursor-perf.js';
|
|
45
46
|
import { SceneViewer } from './inline3d-viewer.js';
|
|
46
47
|
// Procedural — built in memory, no asset to serve. Imported eagerly rather than lazily because it
|
|
47
48
|
// is the default lighting path, so deferring it would only add a frame of unlit content.
|
|
48
49
|
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* `cursor: 'depth'`'s hit test over the model: three's plain raycast at once, then the
|
|
53
|
+
* BVH-accelerated one (./inline3d-cursor-bvh.js, with its vendored three-mesh-bvh slice) as soon as
|
|
54
|
+
* that module arrives. Called on opt-in ONLY — the one place the module is imported, dynamically,
|
|
55
|
+
* so a page without the cursor never downloads it (ADR-046 §0). If it cannot load, the plain
|
|
56
|
+
* raycast simply stays.
|
|
57
|
+
*/
|
|
58
|
+
function cursorHitTest(content) {
|
|
59
|
+
const plain = raycastHitTest(THREE, [content]);
|
|
60
|
+
let fast = null;
|
|
61
|
+
let failed = false;
|
|
62
|
+
import('./inline3d-cursor-bvh.js').then(
|
|
63
|
+
(m) => {
|
|
64
|
+
fast = m.bvhHitTest(THREE, [content]);
|
|
65
|
+
},
|
|
66
|
+
(err) => {
|
|
67
|
+
failed = true;
|
|
68
|
+
console.warn('[inline3d/model] cursor: depth: the BVH pick could not load; using the plain raycast.', err);
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
const hitTest = (origin, direction) => (fast ? fast(origin, direction) : plain(origin, direction));
|
|
72
|
+
/** Diagnostics (`cursor: { debug }`): which pick is answering. */
|
|
73
|
+
hitTest.info = () => (fast ? fast.info() : { mode: failed ? 'plain (bvh failed to load)' : 'plain (bvh loading)' });
|
|
74
|
+
return hitTest;
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
/** Cached across calls so a grid of models resolves the loader module once. */
|
|
51
78
|
let _GLTFLoader = null;
|
|
52
79
|
|
|
@@ -391,7 +418,9 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
|
|
|
391
418
|
viewerEase: opts.viewerEase,
|
|
392
419
|
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
393
420
|
const cursorOpts = resolveCursorOption(opts.cursor, '@displayxr/inline3d/model');
|
|
394
|
-
|
|
421
|
+
// Amortised pick (a few rays a frame): the sprite follows the newest pointer position every
|
|
422
|
+
// frame and the pick only refreshes its depth, so a heavy mesh never stretches the frame.
|
|
423
|
+
if (cursorOpts) viewer.useDepthCursor(DepthCursor, cursorHitTest, { raysPerFrame: CURSOR_MODEL_RAYS_PER_FRAME, ...cursorOpts });
|
|
395
424
|
|
|
396
425
|
if (envMap) viewer.scene.environment = envMap;
|
|
397
426
|
else if (environment === 'room') addRoomEnvironment(viewer);
|