@displayxr/inline3d 1.41.0 → 1.42.1

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.
@@ -7,8 +7,12 @@
7
7
  /** Low-pass factor for the convergence shift (RFC §3): next = prev + α·(target − prev). */
8
8
  export const CONVERGENCE_ALPHA = 0.2;
9
9
 
10
- /** The convergence shift never exceeds this fraction of the per-eye width, either way. */
11
- export const CONVERGENCE_MAX_FRACTION = 0.12;
10
+ /**
11
+ * The convergence shift never exceeds this fraction of the per-eye width, either way. 0.2: a
12
+ * 120 mm-baseline camera (the SR laptop's) sees a face at 0.6 m at ~22% of the eye width, so it
13
+ * needs ~11% per eye — 0.12 left no room to lean in.
14
+ */
15
+ export const CONVERGENCE_MAX_FRACTION = 0.2;
12
16
 
13
17
  /** `setDepth(v)`: v in [-1, 1] adds v times this fraction of the per-eye width to the shift. */
14
18
  export const DEPTH_RANGE_FRACTION = 0.05;
@@ -144,7 +144,11 @@ export function matchBlock(img, W, H, x0, y0, bw, bh, { dMin, dMax, dyMax = 2, u
144
144
  export function blockDisparities(img, W, H, o = {}) {
145
145
  const E = W / 2;
146
146
  const b = o.block || Math.max(8, Math.round(E / 20));
147
- const step = o.step || Math.round(b * 1.5); // sparse grid: half the blocks, same modes
147
+ // One block per block width. A sparser grid (1.5 b) left a dim, grayscale SR-camera frame with
148
+ // ~9 matched blocks, 3-4 of them on the face — too few to form a mode — so the background won
149
+ // and auto-convergence flipped between the two. At 1 b: ~36 blocks, the face every frame, and a
150
+ // steadier value (±1 px instead of ±4); ~13 ms per full search at 240 px per eye.
151
+ const step = o.step || b;
148
152
  const dMax = o.dMax ?? Math.round(E * 0.3);
149
153
  const dMin = o.dMin ?? -Math.round(E * 0.05);
150
154
  const x0 = Math.round(E * (o.roiX0 ?? 0.15));
@@ -165,12 +169,17 @@ export function blockDisparities(img, W, H, o = {}) {
165
169
  * The subject = the NEAREST strong disparity mode: the largest `d` whose ±`tol` bin holds at least
166
170
  * `minFrac` of the matched blocks (and `minBlocks`). Returns its members, or null.
167
171
  */
168
- export function nearestMode(blocks, { tol = 2, minFrac = 0.12, minBlocks = 3 } = {}) {
172
+ export function nearestMode(blocks, { tol = 2, relTol = 0.1, minFrac = 0.12, minBlocks = 3 } = {}) {
169
173
  if (!blocks.length) return null;
170
174
  const need = Math.max(minBlocks, Math.ceil(blocks.length * minFrac));
171
175
  const ds = blocks.map((b) => b.d).sort((a, b) => b - a); // descending: nearest first
172
176
  for (const d of ds) {
173
- const members = blocks.filter((b) => Math.abs(b.d - d) <= tol);
177
+ // The bin widens with the disparity: a near subject's own relief spreads its blocks over
178
+ // ~relTol of its disparity (the spread scales with baseline / distance, as d does), so a
179
+ // fixed +-2 px split a face from a wide-baseline (120 mm) camera into groups too small to
180
+ // count, and the flat background won.
181
+ const t = Math.max(tol, relTol * Math.abs(d));
182
+ const members = blocks.filter((b) => Math.abs(b.d - d) <= t);
174
183
  if (members.length >= need) {
175
184
  const md = members.map((b) => b.d).sort((a, b) => a - b);
176
185
  return { d: md[md.length >> 1], members };
@@ -10,7 +10,7 @@
10
10
  // The policy is the one every DisplayXR depth cursor follows (runtime ADR-046 Amendment 2), taken
11
11
  // from the shared core (../inline3d-cursor-depth.js), not re-implemented here:
12
12
  // - never behind the glass: only content that comes OUT of the screen moves it, to the margin
13
- // (0.03 eye baselines, ~2 mm) in front of that content (cursorTarget);
13
+ // (0.015 eye baselines, ~1 mm) in front of that content (cursorTarget);
14
14
  // - on the plane it IS the OS cursor: the arrow is drawn into both halves only while the filtered
15
15
  // disparity is past the draw threshold (-0.005 baselines, and >= 0.5 px; cursorShouldDraw), the
16
16
  // OS cursor hidden exactly then;
@@ -72,21 +72,116 @@ export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
72
72
  const dMin = -Math.round(E * 0.05);
73
73
  const { rL, rR } = geo;
74
74
  const k = geo.outW / rL.sw; // output px per source px
75
- let best = null;
76
- // The tip, then a ring centred on the arrow's body: that is what content must not overlap.
75
+ const swp = swappedHalves(luma);
76
+ const found = []; // { d, x, y, eye }: a consistent match and WHERE it was taken
77
+ const seen = new Set();
78
+ const sample = (su, pv) => {
79
+ const y = Math.round((rL.sy + pv * rL.sh) * scale - b / 2);
80
+ if (y < 0 || y + b > h) return;
81
+ const xl = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
82
+ const xr = Math.round((rR.sx + su * rR.sw) * scale - b / 2);
83
+ // The arrow is far smaller than a block (~6 vs 12 copy px at the OS arrow's size), so
84
+ // neighbouring footprint points land on the SAME block: match each block once.
85
+ if (!seen.has(`L${xl},${y}`)) {
86
+ seen.add(`L${xl},${y}`);
87
+ const dl = consistentMatch(img, swp, w, h, xl, y, b, dMin, dMax);
88
+ if (dl !== null) found.push({ d: dl, x: xl, y, eye: 0 });
89
+ }
90
+ if (!seen.has(`R${xr},${y}`)) {
91
+ seen.add(`R${xr},${y}`);
92
+ const dr = consistentMatch(swp, img, w, h, xr, y, b, -dMax, -dMin); // right-anchored: d' = -d
93
+ if (dr !== null) found.push({ d: -dr, x: xr, y, eye: 1 });
94
+ }
95
+ };
96
+ // The tip, then a ring centred on the arrow's body: that is what content must not overlap. Each
97
+ // point is sampled from BOTH eyes' images (the pointer's spot in the left eye matched into the
98
+ // right, and the same spot in the right eye matched into the left), so neither eye is favoured:
99
+ // at a foreground edge one eye's block can sit on the subject and the other's on the background.
100
+ // Every match must also pass the left-right consistency check (consistentMatch).
101
+ let cu = 0, cv = 0, n = 0;
77
102
  for (const [pu, pv] of cursorArrowFootprint(u, v, height, aspect)) {
78
103
  if (!(pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1)) continue;
79
- const su = geo.mirror ? 1 - pu : pu;
80
- const x = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
81
- const y = Math.round((rL.sy + pv * rL.sh) * scale - b / 2);
82
- if (x < 0 || x + b > E || y < 0 || y + b > h) continue;
83
- const m = matchBlock(img, w, h, x, y, b, b, { dMin, dMax, dyMax: 2 });
84
- if (!m || m.c < MIN_NCC) continue;
85
- // Source disparity → displayed: each eye's crop moved its half by its own offset.
86
- const d = (m.d / scale - (rL.sx - rR.sx)) * k;
87
- if (best === null || d > best) best = d;
104
+ sample(geo.mirror ? 1 - pu : pu, pv);
105
+ cu += pu; cv += pv; n++;
106
+ }
107
+ if (!n) return null;
108
+ // ...plus four blocks half a block around the footprint's centre, so that real content under
109
+ // the arrow is seen by more than one distinct block (pairNearestSupported needs that).
110
+ const du = (b / 2 / scale) / rL.sw, dv = (b / 2 / scale) / rL.sh;
111
+ for (const [ou, ov] of [[-du, 0], [du, 0], [0, -dv], [0, dv]]) {
112
+ const pu = cu / n + ou, pv = cv / n + ov;
113
+ if (pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1) sample(geo.mirror ? 1 - pu : pu, pv);
114
+ }
115
+ // Source disparity → displayed: each eye's crop moved its half by its own offset.
116
+ for (const f of found) f.d = (f.d / scale - (rL.sx - rR.sx)) * k;
117
+ return pairNearestSupported(found, (PAIR_SUPPORT_COPY_PX / scale) * k, b / 2);
118
+ }
119
+
120
+ /**
121
+ * Two consistent footprint matches must agree (within this many px of the grayscale copy) before
122
+ * the nearest is believed: content that comes out of the screen covers several blocks, a lone
123
+ * block claiming something nearer than the rest is noise.
124
+ */
125
+ export const PAIR_SUPPORT_COPY_PX = 1.5;
126
+
127
+ /** The SBS copy with its halves swapped (cached per copy): matching it is matching right → left. */
128
+ const _swapped = new WeakMap();
129
+ function swappedHalves(luma) {
130
+ let s = _swapped.get(luma);
131
+ if (s) return s;
132
+ const { img, w, h } = luma;
133
+ const E = w / 2;
134
+ s = new Uint8Array(w * h);
135
+ for (let y = 0; y < h; y++) {
136
+ const o = y * w;
137
+ for (let x = 0; x < E; x++) {
138
+ s[o + x] = img[o + E + x];
139
+ s[o + E + x] = img[o + x];
140
+ }
141
+ }
142
+ _swapped.set(luma, s);
143
+ return s;
144
+ }
145
+
146
+ /**
147
+ * A block of image A's left half matched into its right half, kept only if the matched block,
148
+ * matched BACK (in B = A with its halves swapped), lands within 1 px of where it started — the
149
+ * classic left-right consistency check. A block straddling a depth edge (half subject, half
150
+ * background, each eye seeing a different half) fails it instead of returning a random depth.
151
+ * @returns {number|null} disparity in A's convention (left x − right x), copy px
152
+ */
153
+ export function consistentMatch(A, B, w, h, x, y, b, dMin, dMax) {
154
+ const E = w / 2;
155
+ if (x < 0 || x + b > E) return null;
156
+ const m = matchBlock(A, w, h, x, y, b, b, { dMin, dMax, dyMax: 2 });
157
+ if (!m || m.c < MIN_NCC) return null;
158
+ const xb = Math.round(x - m.d);
159
+ const yb = y + m.dy;
160
+ if (xb < 0 || xb + b > E || yb < 0 || yb + b > h) return null;
161
+ const back = matchBlock(B, w, h, xb, yb, b, b, { dMin: -Math.ceil(m.d) - 2, dMax: -Math.floor(m.d) + 2, dyMax: 1 });
162
+ if (!back || back.c < MIN_NCC || Math.abs(-back.d - m.d) > 1) return null;
163
+ return m.d;
164
+ }
165
+
166
+ /**
167
+ * The nearest disparity that an INDEPENDENT second sample corroborates (within `tol`), else null.
168
+ * Independent = taken from the other eye's image, or from a block at least `minSep` copy px away:
169
+ * two footprint points inside one block are one witness, not two. (A block straddling a face edge
170
+ * and a bright window matched at a bogus +49 px, consistently, and its twin counted as support.)
171
+ * @param {Array<{d:number, x?:number, y?:number, eye?:number}|number>} samples output px, + = in front
172
+ * @returns {number|null}
173
+ */
174
+ export function pairNearestSupported(samples, tol, minSep = 0) {
175
+ const S = samples.map((s) => (typeof s === 'number' ? { d: s, x: 0, y: 0, eye: -1 } : s));
176
+ if (S.length < 2) return null;
177
+ const sorted = [...S].sort((p, q) => q.d - p.d); // nearest first
178
+ const independent = (p, q) => p.eye === -1 || p.eye !== q.eye || Math.abs(p.x - q.x) + Math.abs(p.y - q.y) >= minSep;
179
+ for (const p of sorted) {
180
+ for (const q of sorted) {
181
+ if (q !== p && Math.abs(p.d - q.d) <= tol && independent(p, q)) return p.d;
182
+ }
88
183
  }
89
- return best;
184
+ return null;
90
185
  }
91
186
 
92
187
  /**
@@ -109,7 +204,7 @@ export function pairPxPerBaseline(outW, tileCssWidth, metersPerCssPx, eyeSeparat
109
204
  /**
110
205
  * The arrow's target disparity in output px (+ = in front): the core's policy (cursorTarget) on
111
206
  * the content's disparity, converted through `pxPerBaseline`. In front of content that comes OUT
112
- * of the screen by the margin (0.03 baselines), else the glass — never behind it.
207
+ * of the screen by the margin (0.015 baselines), else the glass — never behind it.
113
208
  */
114
209
  export function pairCursorTarget(contentPx, pxPerBaseline, tuning = CURSOR_DEFAULT_TUNING) {
115
210
  if (contentPx === null || !Number.isFinite(contentPx) || !(pxPerBaseline > 0)) return 0;
@@ -46,7 +46,7 @@
46
46
  // ./camera, which calls the pieces exported here):
47
47
  // - never behind the glass: target = d < 0 ? max(d - margin, dMin) : 0 — it only comes forward,
48
48
  // to stay ahead of content that comes OUT of the screen (cursorTarget);
49
- // - margin 0.03 eye baselines (~2 mm of crossed on-screen disparity at a 65 mm IPD);
49
+ // - margin 0.015 eye baselines (~1 mm of crossed on-screen disparity at a 63-65 mm IPD);
50
50
  // - on the plane it IS the OS cursor: nothing is drawn while the filtered disparity is above
51
51
  // CURSOR_DRAW_THRESHOLD (-0.005, ~0.3 mm) or under CURSOR_DRAW_MIN_PX on the panel
52
52
  // (cursorShouldDraw), and the CSS cursor is left alone;
@@ -99,10 +99,11 @@ export function cursorShouldDraw(disparity, pxPerBaseline) {
99
99
  */
100
100
  export const CURSOR_DEFAULT_TUNING = Object.freeze({
101
101
  /**
102
- * How far in front of out-of-screen content the cursor floats: 0.03 eye baselines, ~2 mm of
103
- * crossed on-screen disparity at a 65 mm IPD (ADR-046 Amendment 2; the reference app's value).
102
+ * How far in front of out-of-screen content the cursor floats: 0.015 eye baselines, ~1 mm of
103
+ * crossed on-screen disparity at a 63-65 mm IPD (ADR-046 Amendment 2 A.2; halved from 0.03 on
104
+ * 2026-10-11 after a Windows panel check: 0.03 floated visibly off the content).
104
105
  */
105
- margin: 0.03,
106
+ margin: 0.015,
106
107
  /** Comfort clamp: t >= 0.625, never more than ~3/8 of the way to the eye. */
107
108
  minDisparity: -0.6,
108
109
  /** Never behind the glass: 0, and cursorTarget never goes above 0 whatever this says. */
@@ -489,8 +489,8 @@ export class DepthCursor {
489
489
  * @param {number} [opts.height] sprite height as a fraction of the canvas height. Default: the
490
490
  * OS arrow's size, 19 CSS px of the canvas's on-page height (re-read every frame).
491
491
  * @param {number} [opts.color=0xffffff] the arrow's fill colour (the outline is always dark).
492
- * @param {number} [opts.margin=0.03] how far in front of out-of-screen content it floats, in
493
- * eye-baseline units (0.03 ≈ 2 mm of on-screen disparity at a 65 mm IPD).
492
+ * @param {number} [opts.margin=0.015] how far in front of out-of-screen content it floats, in
493
+ * eye-baseline units (0.015 ≈ 1 mm of on-screen disparity at a 63-65 mm IPD).
494
494
  * @param {'hybrid'|'screen'|'world'} [opts.anchor='hybrid'] where along that depth: 'hybrid'
495
495
  * follows the pointer exactly while it moves and stays world-fixed (parallaxes with the
496
496
  * content) while it is still; 'screen' never parallaxes; 'world' always does.
package/model.d.ts CHANGED
@@ -53,7 +53,7 @@ export interface ModelOptions {
53
53
  * `'depth'`: a cursor that is never drawn on the glass behind content that pops out (ADR-046 +
54
54
  * Amendment 2, the same policy as native `XR_DXR_cursor_depth`). While the subject under the
55
55
  * pointer's footprint comes OUT of the screen, the classic arrow (the OS cursor's size, tip on
56
- * the hotspot) is drawn the margin (0.03 eye baselines, ~2 mm) in front of it and the CSS cursor
56
+ * the hotspot) is drawn the margin (0.015 eye baselines, ~1 mm) in front of it and the CSS cursor
57
57
  * is hidden; over anything on or behind the glass nothing is drawn and the CSS cursor is the
58
58
  * cursor, untouched. Never behind the glass. OPT-IN: without it nothing
59
59
  * is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.41.0",
3
+ "version": "1.42.1",
4
4
  "description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
package/splat.d.ts CHANGED
@@ -477,7 +477,7 @@ export interface SplatOptions {
477
477
  * `'depth'`: a cursor that is never drawn on the glass behind content that pops out (ADR-046 +
478
478
  * Amendment 2, the same policy as native `XR_DXR_cursor_depth`). While the subject under the
479
479
  * pointer's footprint comes OUT of the screen, the classic arrow (the OS cursor's size, tip on
480
- * the hotspot) is drawn the margin (0.03 eye baselines, ~2 mm) in front of it and the CSS cursor
480
+ * the hotspot) is drawn the margin (0.015 eye baselines, ~1 mm) in front of it and the CSS cursor
481
481
  * is hidden; over anything on or behind the glass nothing is drawn and the CSS cursor is the
482
482
  * cursor, untouched. Never behind the glass. OPT-IN: without it nothing
483
483
  * is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,