@displayxr/inline3d 1.39.0 → 1.40.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.
@@ -71,6 +71,7 @@ const str = (v) => (v === null || v === undefined || String(v).trim() === '' ? u
71
71
  * | `no-audio` | `audio: false` |
72
72
  * | `no-self-view` | `selfView: false` |
73
73
  * | `no-auto-converge` | `autoConverge: false` |
74
+ * | `cursor="depth"` | `cursor: 'depth'` |
74
75
  * | `tile-aspect` | `theme.tileAspect` (a number, or `w/h`) |
75
76
  * | `invite-base` | `invite.base` |
76
77
  * | `browser-url` | `landing.browserUrl` |
@@ -101,6 +102,7 @@ export function attrsToOpts(source) {
101
102
  if (isOn(get('no-audio'))) o.audio = false;
102
103
  if (isOn(get('no-self-view'))) o.selfView = false;
103
104
  if (isOn(get('no-auto-converge'))) o.autoConverge = false;
105
+ if (str(get('cursor')) === 'depth') o.cursor = 'depth';
104
106
  // The C2 groups (RFC 0003 §2): the attribute names are the C1 ones, the option shapes are new.
105
107
  const theme = { accent: str(get('accent')), tileAspect: num(get('tile-aspect')) };
106
108
  if (theme.accent !== undefined || theme.tileAspect !== undefined) o.theme = Object.fromEntries(Object.entries(theme).filter(([, v]) => v !== undefined));
@@ -8,6 +8,7 @@ import { clampMaxPeers } from './transport.js';
8
8
  import { normalizeMono3D } from './lift.js';
9
9
  import { dxrSignaling } from './signaling.js';
10
10
  import { resolveCallAccent, THEME_VARS } from './ui.js';
11
+ import { resolvePairCursorOption } from '../inline3d-cursor-option.js';
11
12
 
12
13
  const TAG = '[inline3d/call]';
13
14
  const DEFAULT_BROWSER_URL = 'https://github.com/DisplayXR/displayxr-browser';
@@ -89,6 +90,10 @@ export function normalizeCallOptions(opts = {}) {
89
90
  // peer's eyes and shift the eyes so it sits at the display plane. The depth slider stays an
90
91
  // offset on top. Off = the pair as sent (plus any `hint`).
91
92
  autoConverge: opts.autoConverge === undefined ? true : !!opts.autoConverge,
93
+ // `cursor: 'depth'` (runtime ADR-046 on a pair): the arrow comes in front of a person who pops
94
+ // out of the screen under it, measured off each stereo tile's two halves; else the plain OS
95
+ // arrow on the plane. Off by default (zero cost).
96
+ cursor: resolvePairCursorOption(opts.cursor, '@displayxr/inline3d/call'),
92
97
  mono3D: normalizeMono3D(opts.mono3D),
93
98
  maxPeers: clampMaxPeers(opts.maxPeers === undefined ? DEFAULT_MAX_PEERS : opts.maxPeers),
94
99
  // Extra lift() options for lifted tiles (models, ort, quality, providers). The call's own
@@ -0,0 +1,191 @@
1
+ // camera/pair-cursor.js — the depth-aware cursor on a STEREO PAIR: a call tile or a camera view.
2
+ //
3
+ // The OS cursor is drawn at zero disparity, on the glass. Over a person who sits in front of it
4
+ // (a converged stereo call puts the face AT the glass, so the shoulders, a raised hand, a cup held
5
+ // up are all in front), it is drawn on top of pixels that say they are nearer — the depth
6
+ // violation runtime ADR-046 is about. A pair has no scene to raycast and no depth buffer, but its
7
+ // two halves ARE the depth: this is ADR-046 Phase 3b done in the page. Block-match a cursor-sized
8
+ // footprint between the eyes and take the nearest confident match.
9
+ //
10
+ // The cursor only ever comes FORWARD (David, 2026-10-10): it never goes inside the display. While
11
+ // what is under it sits at or behind the glass, it is simply the OS arrow, on the screen plane.
12
+ // Only when content comes out of the screen under it is the arrow drawn into both halves, with that
13
+ // content's disparity plus a small margin (the OS cursor hidden meanwhile), so it is never overlapped
14
+ // by what pops out. It is the normal arrow either way, not a special sprite.
15
+ //
16
+ // Same filter as the scene cursor (./inline3d-cursor-depth.js): fast rise, slow sink, the plane when
17
+ // nothing matches. Disparities here are in OUTPUT pixels of one eye of the woven buffer, + = crossed
18
+ // (in front of the glass), the sign the rest of camera/ uses. Loaded only when a page asks for
19
+ // `cursor: 'depth'` — zero cost otherwise.
20
+
21
+ import { CursorPointer, cursorFootprint, cursorFilterStep, CURSOR_DEFAULT_TUNING } from '../inline3d-cursor-depth.js';
22
+ import { matchBlock, MIN_NCC } from './disparity.js';
23
+
24
+ /** The arrow's height in CSS px — the OS arrow's apparent size, so the swap to the drawn one is seamless. */
25
+ export const PAIR_CURSOR_CSS_PX = 19;
26
+ /** Arrow height as a fraction of the tile height when the tile's on-page size is unknown. */
27
+ export const PAIR_CURSOR_HEIGHT = 0.06;
28
+ /** Below this many output px in front of the glass the drawn arrow hands back to the OS cursor. */
29
+ export const PAIR_CURSOR_SHOW_PX = 0.5;
30
+ /**
31
+ * The standard pointer arrow, hotspot at the tip (0, 0), unit = its height, x right, y down: the
32
+ * OS arrow's outline (a 19-px-tall Windows/macOS arrow, normalised).
33
+ */
34
+ export const PAIR_CURSOR_ARROW = Object.freeze([
35
+ [0, 0], [0, 16 / 19], [4.2 / 19, 12.4 / 19], [6.9 / 19, 18.6 / 19], [9.5 / 19, 17.5 / 19], [6.8 / 19, 11.5 / 19], [11.7 / 19, 11.5 / 19],
36
+ ]);
37
+ /** How far in front of out-of-screen content the arrow floats, as a fraction of the eye width (crossed). */
38
+ export const PAIR_CURSOR_MARGIN = 0.002;
39
+ /** Comfort cap on how far in front the arrow comes, as a fraction of the eye width. */
40
+ export const PAIR_CURSOR_CLAMP = 0.08;
41
+
42
+ /**
43
+ * The displayed disparity of the nearest content under the cursor's footprint, or null when no
44
+ * point of the footprint matched confidently (flat, ambiguous, off the frame).
45
+ *
46
+ * @param {{img: ArrayLike<number>, w: number, h: number, scale: number}} luma a grayscale copy
47
+ * of the WHOLE side-by-side source frame; `scale` = copy px per source px.
48
+ * @param {{rL: {sx:number, sy:number, sw:number, sh:number}, rR: {sx:number}, outW: number, mirror?: boolean}} geo
49
+ * each eye's source crop (relative to its half, eyeCropRect) and the output eye width; a
50
+ * mirrored view (the self view) flips the pointer, never the disparity.
51
+ * @param {number} u pointer hotspot, tile-normalised (0..1, left → right as the viewer sees it)
52
+ * @param {number} v pointer hotspot, tile-normalised (0..1, top → bottom)
53
+ * @param {number} height arrow height (fraction of the tile height); the footprint covers the
54
+ * arrow's body, which hangs down-right of the hotspot
55
+ * @param {number} aspect the tile's width / height, for a round footprint
56
+ * @returns {number|null} output px, + = in front of the glass
57
+ */
58
+ export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
59
+ if (!luma || !luma.img || !geo || !geo.rL || !geo.rR || !(geo.outW > 0)) return null;
60
+ const { img, w, h, scale } = luma;
61
+ const E = w / 2;
62
+ const b = Math.max(8, Math.round(E / 20));
63
+ const dMax = Math.round(E * 0.3);
64
+ const dMin = -Math.round(E * 0.05);
65
+ const { rL, rR } = geo;
66
+ const k = geo.outW / rL.sw; // output px per source px
67
+ let best = null;
68
+ // The ring is centred on the arrow's body, not its tip: that is what content must not overlap.
69
+ for (const [pu, pv] of cursorFootprint(u + (0.3 * height) / aspect, v + 0.5 * height, height, aspect)) {
70
+ if (!(pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1)) continue;
71
+ const su = geo.mirror ? 1 - pu : pu;
72
+ const x = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
73
+ const y = Math.round((rL.sy + pv * rL.sh) * scale - b / 2);
74
+ if (x < 0 || x + b > E || y < 0 || y + b > h) continue;
75
+ const m = matchBlock(img, w, h, x, y, b, b, { dMin, dMax, dyMax: 2 });
76
+ if (!m || m.c < MIN_NCC) continue;
77
+ // Source disparity → displayed: each eye's crop moved its half by its own offset.
78
+ const d = (m.d / scale - (rL.sx - rR.sx)) * k;
79
+ if (best === null || d > best) best = d;
80
+ }
81
+ return best;
82
+ }
83
+
84
+ /**
85
+ * The arrow's target disparity: in front of content that comes OUT of the screen (its disparity
86
+ * plus the margin, clamped), else the glass. Never behind the glass — the cursor does not go inside
87
+ * the display.
88
+ */
89
+ export function pairCursorTarget(contentPx, outW) {
90
+ if (contentPx === null || !Number.isFinite(contentPx) || !(contentPx > 0)) return 0;
91
+ return Math.min(PAIR_CURSOR_CLAMP * outW, contentPx + PAIR_CURSOR_MARGIN * outW);
92
+ }
93
+
94
+ /**
95
+ * Draw the arrow into both halves of a woven buffer: the left eye's copy `disparityPx / 2` right of
96
+ * the pointer, the right eye's as far left (crossed = in front). White with a dark outline, like the
97
+ * OS arrow. Each copy is clipped to its own half, so an arrow at the edge never bleeds into the other eye.
98
+ * @param {number} sizePx arrow height in output px
99
+ */
100
+ export function drawPairArrow(g, outW, outH, u, v, disparityPx, sizePx) {
101
+ const cy = v * outH;
102
+ for (const eye of [0, 1]) {
103
+ const cx = eye * outW + u * outW + (eye === 0 ? disparityPx / 2 : -disparityPx / 2);
104
+ g.save();
105
+ g.beginPath();
106
+ g.rect(eye * outW, 0, outW, outH);
107
+ g.clip();
108
+ g.beginPath();
109
+ PAIR_CURSOR_ARROW.forEach(([x, y], k) => (k ? g.lineTo : g.moveTo).call(g, cx + x * sizePx, cy + y * sizePx));
110
+ g.closePath();
111
+ g.fillStyle = '#fff';
112
+ g.fill();
113
+ g.lineWidth = Math.max(1, sizePx / 19);
114
+ g.lineJoin = 'miter';
115
+ g.strokeStyle = '#000';
116
+ g.stroke();
117
+ g.restore();
118
+ }
119
+ }
120
+
121
+ /**
122
+ * One cursor over one woven pair. The owner calls `draw()` after painting the pair each frame
123
+ * (`idle()` on any frame it does not paint a woven pair), and hands it the latest grayscale copy
124
+ * of the source when it has one; `hovering` says when that copy is worth grabbing more often.
125
+ */
126
+ export class PairCursor {
127
+ /** @param {HTMLElement} canvas the tile — the pointer counts over its box */
128
+ constructor(canvas, { height, pointerScope = 'window' } = {}) {
129
+ this.pointer = new CursorPointer(canvas, { scope: pointerScope });
130
+ // A fraction of the tile height when given; by default the OS arrow's size (CSS px → tile).
131
+ this.height = height > 0 ? height : null;
132
+ this.filter = {};
133
+ this._luma = null;
134
+ this._uv = null;
135
+ this._content = null;
136
+ /** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx, drawn }` or null. */
137
+ this.last = null;
138
+ }
139
+
140
+ get hovering() {
141
+ return !!this.pointer.uv;
142
+ }
143
+
144
+ /**
145
+ * Draws the arrow only while content in front of the glass is under it (the OS cursor is hidden
146
+ * exactly then); otherwise leaves the buffer alone and the OS arrow shows, on the plane.
147
+ * @param {CanvasRenderingContext2D} g the woven buffer (2·outW × outH), already painted
148
+ * @param {object|null} luma the latest grayscale source copy (pairFootprintDisparity)
149
+ * @param {{rL: object, rR: object, outW: number, outH: number, mirror?: boolean}} geo
150
+ * @param {number} nowSec
151
+ */
152
+ draw(g, luma, geo, nowSec) {
153
+ const uv = this.pointer.uv;
154
+ if (!uv) return this.idle();
155
+ const [u, v] = uv;
156
+ const hf = this.height ?? this._osArrowFraction();
157
+ // Match only when there is something new to match: a new frame copy or a moved pointer.
158
+ if (luma !== this._luma || !this._uv || this._uv[0] !== u || this._uv[1] !== v) {
159
+ this._content = pairFootprintDisparity(luma, geo, u, v, hf, geo.outW / geo.outH);
160
+ this._luma = luma;
161
+ this._uv = uv;
162
+ }
163
+ const target = pairCursorTarget(this._content, geo.outW);
164
+ // The shared filter rises fast toward SMALLER values (nearer, in its convention): negate.
165
+ const disparity = -cursorFilterStep(this.filter, CURSOR_DEFAULT_TUNING, -target, nowSec);
166
+ const drawn = disparity >= PAIR_CURSOR_SHOW_PX;
167
+ if (drawn) drawPairArrow(g, geo.outW, geo.outH, u, v, disparity, hf * geo.outH);
168
+ this.pointer.hideCss(drawn); // the OS arrow IS the cursor on the plane
169
+ this.last = { contentPx: this._content, targetPx: target, disparityPx: drawn ? disparity : 0, drawn };
170
+ return this.last;
171
+ }
172
+
173
+ /** The OS arrow's height as a fraction of the tile's on-page height. */
174
+ _osArrowFraction() {
175
+ const r = this.pointer.canvas.getBoundingClientRect?.();
176
+ return r && r.height > 0 ? Math.min(0.5, PAIR_CURSOR_CSS_PX / r.height) : PAIR_CURSOR_HEIGHT;
177
+ }
178
+
179
+ /** Not drawing this frame (pointer away, tile not woven): the CSS cursor comes back. */
180
+ idle() {
181
+ this.pointer.hideCss(false);
182
+ this.filter = {};
183
+ this._uv = null;
184
+ this.last = null;
185
+ return null;
186
+ }
187
+
188
+ dispose() {
189
+ this.pointer.dispose();
190
+ }
191
+ }
@@ -117,6 +117,8 @@ const LIFT_RETRIES = 2;
117
117
  // disparity on a face; small enough to stay a few ms per measurement).
118
118
  const AUTO_CONV_INTERVAL_MS = 200;
119
119
  const AUTO_CONV_EYE_WIDTH = 240;
120
+ // While a depth cursor hovers a stereo tile, its source is copied this often (the cursor measures it).
121
+ const CURSOR_SAMPLE_MS = 66;
120
122
  // WebCodecs pixel formats whose plane 0 is luma (Y): read directly, no colour conversion.
121
123
  const Y_PLANE_FORMATS = new Set(['I420', 'I420A', 'I422', 'I444', 'NV12']);
122
124
  /**
@@ -1306,6 +1308,7 @@ class Tile {
1306
1308
  // Bumped on every stream change and on destroy(): an async copyTo() that resolves after either
1307
1309
  // belongs to a stream this tile no longer shows, and is dropped (review of #92).
1308
1310
  this.autoGen = 0;
1311
+ this.luma = null; // the latest grayscale source copy: auto-convergence and the depth cursor read it
1309
1312
  this.destroyed = false;
1310
1313
  this.conv.depth = call.depth;
1311
1314
  this.hintGate = rateGate(HINT_MAX_HZ);
@@ -1337,6 +1340,14 @@ class Tile {
1337
1340
  }
1338
1341
  call.ui.grid.appendChild(this.el);
1339
1342
  this._renderBadge();
1343
+ /** The depth cursor (`cursor: 'depth'`) once its module has loaded; null otherwise. */
1344
+ this.cursor = null;
1345
+ if (call.o.cursor) {
1346
+ call._pairCursorMod = call._pairCursorMod || import('./camera/pair-cursor.js');
1347
+ call._pairCursorMod.then((m) => {
1348
+ if (!this.destroyed) this.cursor = new m.PairCursor(this.canvas, call.o.cursor);
1349
+ });
1350
+ }
1340
1351
  }
1341
1352
 
1342
1353
  /** How this side shows the participant: `3D` (woven pair) | `2D→3D` (lifted) | `2D` (flat). */
@@ -1366,6 +1377,8 @@ class Tile {
1366
1377
  hello: this.hello,
1367
1378
  quality: this.quality,
1368
1379
  convergencePx: this.conv.current,
1380
+ // The depth cursor's last placement (output px, + = in front of the glass), null when not drawn.
1381
+ cursor: this.cursor ? this.cursor.last : null,
1369
1382
  // Auto-convergence: the measured disparity (source px, null until a lock) and the mean cost of
1370
1383
  // one measurement including the frame readback.
1371
1384
  autoConverge: {
@@ -1672,6 +1685,7 @@ class Tile {
1672
1685
 
1673
1686
  paint() {
1674
1687
  if (!this.route) return;
1688
+ if (this.route !== 'woven-sbs') this.cursor?.idle(); // only a woven pair has depth to measure
1675
1689
  if (this.route === 'woven-sbs') this._paintConv();
1676
1690
  else if (this.route === 'lifted') {
1677
1691
  if (!this.liftLive) paintFlat(this.canvas, this.video); // flat until lift is showing
@@ -1692,13 +1706,16 @@ class Tile {
1692
1706
  c.width = 2 * outW;
1693
1707
  c.height = outH;
1694
1708
  }
1695
- if (this.call.o.autoConverge) this._sampleDisparity(v, W, H);
1709
+ const hover = !!(this.cursor && this.cursor.hovering);
1710
+ if (this.call.o.autoConverge || hover) this._sampleDisparity(v, W, H, hover);
1696
1711
  const shift = this.conv.step(eyeW);
1697
1712
  const g = c.getContext('2d');
1713
+ const rects = [];
1698
1714
  for (const eye of [0, 1]) {
1699
- const r = wire.eyeCropRect(eyeW, H, A, shift, eye);
1715
+ const r = (rects[eye] = wire.eyeCropRect(eyeW, H, A, shift, eye));
1700
1716
  g.drawImage(v, eye * eyeW + r.sx, r.sy, r.sw, r.sh, eye * outW, 0, outW, outH);
1701
1717
  }
1718
+ if (this.cursor) this.cursor.draw(g, this.luma, { rL: rects[0], rR: rects[1], outW, outH }, performance.now() / 1000);
1702
1719
  }
1703
1720
 
1704
1721
  /**
@@ -1707,9 +1724,10 @@ class Tile {
1707
1724
  * feedback loop) and hand it to the convergence state, which halves it per eye and low-passes it.
1708
1725
  * A failed measurement holds the last good value.
1709
1726
  */
1710
- _sampleDisparity(v, W, H) {
1727
+ _sampleDisparity(v, W, H, hover = false) {
1711
1728
  const now = performance.now();
1712
- if (this.autoPending || now - this.autoAt < AUTO_CONV_INTERVAL_MS) return;
1729
+ // ~15 Hz while a depth cursor hovers: it measures the same copy, under the pointer.
1730
+ if (this.autoPending || now - this.autoAt < (hover ? CURSOR_SAMPLE_MS : AUTO_CONV_INTERVAL_MS)) return;
1713
1731
  this.autoAt = now;
1714
1732
  // Preferred: WebCodecs. `new VideoFrame(video)` + an ASYNC copyTo() of the decoded frame, reading
1715
1733
  // the luma (Y) plane directly: no synchronous GPU->CPU canvas readback on the paint path (that
@@ -1775,6 +1793,8 @@ class Tile {
1775
1793
  */
1776
1794
  _applyMeasurement(img, w, h, s, at, readMs, via) {
1777
1795
  if (this.destroyed) return;
1796
+ if (img) this.luma = { img, w, h, scale: s };
1797
+ if (!this.call.o.autoConverge) return; // the copy was for the depth cursor only
1778
1798
  const t = performance.now();
1779
1799
  const m = img ? this.autoFocus.measure(img, w, h, at) : null;
1780
1800
  const d = this.autoTrack.push(m ? m.d / s : null);
@@ -1800,6 +1820,7 @@ class Tile {
1800
1820
 
1801
1821
  _resetAutoConverge() {
1802
1822
  this.autoGen++;
1823
+ this.luma = null;
1803
1824
  this.autoTrack.reset();
1804
1825
  this.autoFocus.reset();
1805
1826
  this.conv.measuredPx = null;
@@ -1809,6 +1830,8 @@ class Tile {
1809
1830
  destroy() {
1810
1831
  this.destroyed = true;
1811
1832
  this.autoGen++;
1833
+ this.cursor?.dispose();
1834
+ this.cursor = null;
1812
1835
  clearTimeout(this.helloTimer);
1813
1836
  clearTimeout(this.layerTimer);
1814
1837
  clearTimeout(this.leaveTimer);
@@ -1874,6 +1897,7 @@ class SelfTile {
1874
1897
  this.view = addCameraView(wall, this.canvas, cam, {
1875
1898
  mirror: true,
1876
1899
  autoConverge: !!this.call.o.autoConverge, // the call option (default on) governs the self view too
1900
+ cursor: this.call.o.cursor || undefined, // and so does `cursor: 'depth'`
1877
1901
  aspect: this.call.o.tileAspect,
1878
1902
  onRouteChange: (route, st) => {
1879
1903
  if (this.cam !== cam) return; // a view this tile already replaced
@@ -39,6 +39,7 @@ import { openCamera as openDevice } from './camera/capture.js';
39
39
  import { eyeCropRect, eyeOutputSize, mirrorSwapOps } from './camera/geometry.js';
40
40
  import { createConvergence } from './camera/converge.js';
41
41
  import { lumaFromRgba, createDisparityTrack, createFocusTracker } from './camera/disparity.js';
42
+ import { resolvePairCursorOption } from './inline3d-cursor-option.js';
42
43
  import { buildStereoXmp, jpegWithXmp, webmWithTags, stereoTagEntries, readJpegStereoMeta, readWebmStereoMeta, parseStereoXmp, readJpegXmp } from './camera/metadata.js';
43
44
 
44
45
  export { readJpegStereoMeta, readWebmStereoMeta, parseStereoXmp, readJpegXmp };
@@ -50,6 +51,8 @@ export const CAMERA_SDK = 'inline3d-camera/1';
50
51
  // on a face, a few ms per measurement — the same numbers as the call's remote tiles).
51
52
  const AUTO_CONV_INTERVAL_MS = 200;
52
53
  const AUTO_CONV_EYE_WIDTH = 240;
54
+ /** While a depth cursor hovers the view, the source is copied this often (it is what the cursor measures). */
55
+ const CURSOR_SAMPLE_MS = 66;
53
56
  const FRAME_WAIT_MS = 4000;
54
57
  // A self view whose layer the wall could not build (`firstWoven` → `woven:false, 'layer-failed'`)
55
58
  // goes flat and re-registers a few times with backoff — the remote tile's rule (web#131).
@@ -521,7 +524,17 @@ class CameraView {
521
524
  this._track = createDisparityTrack();
522
525
  this._focus = createFocusTracker();
523
526
  this._scratch = null;
527
+ this._luma = null; // the latest grayscale source copy (auto-convergence and the depth cursor read it)
524
528
  this.disparityPx = null;
529
+ // `cursor: 'depth'` (ADR-046 on a pair): validated now, its module loaded only on opt-in.
530
+ this._cursorOpt = resolvePairCursorOption(o.cursor, '@displayxr/inline3d/camera');
531
+ /** The depth cursor once its module has loaded; null without `cursor: 'depth'`. */
532
+ this.cursor = null;
533
+ if (this._cursorOpt) {
534
+ import('./camera/pair-cursor.js').then((m) => {
535
+ if (!this.removed) this.cursor = new m.PairCursor(this.canvas, this._cursorOpt);
536
+ });
537
+ }
525
538
  this._reroute(false);
526
539
  if (this._loop) this._start();
527
540
  }
@@ -691,6 +704,7 @@ class CameraView {
691
704
  }
692
705
 
693
706
  _paintFlat(v) {
707
+ this.cursor?.idle(); // a flat picture has no depth to put a cursor at
694
708
  if (!sizeFlat(this.canvas)) return;
695
709
  const W = v.videoWidth;
696
710
  const H = v.videoHeight;
@@ -726,7 +740,8 @@ class CameraView {
726
740
  c.width = 2 * outW;
727
741
  c.height = outH;
728
742
  }
729
- if (this.autoConverge) this._sample(v);
743
+ const hover = !!(this.cursor && this.cursor.hovering);
744
+ if (this.autoConverge || hover) this._sample(v, hover);
730
745
  const shift = this.conv.step(eyeW);
731
746
  const g = c.getContext('2d');
732
747
  if (this.mirror) {
@@ -746,15 +761,24 @@ class CameraView {
746
761
  g.drawImage(v, eye * eyeW + r.sx, r.sy, r.sw, r.sh, eye * outW, 0, outW, outH);
747
762
  }
748
763
  }
764
+ if (this.cursor) {
765
+ const geo = { rL: eyeCropRect(eyeW, H, A, shift, 0), rR: eyeCropRect(eyeW, H, A, shift, 1), outW, outH, mirror: this.mirror };
766
+ this.cursor.draw(g, this._luma, geo, performance.now() / 1000);
767
+ }
749
768
  }
750
769
 
751
- /** Auto-convergence: ~5 Hz, a small grayscale copy of the SOURCE frame (before our shift). */
752
- _sample(v) {
770
+ /**
771
+ * A small grayscale copy of the SOURCE frame (before our shift): ~5 Hz for auto-convergence,
772
+ * ~15 Hz while a depth cursor hovers (it measures the same copy under the pointer).
773
+ */
774
+ _sample(v, hover = false) {
753
775
  const now = performance.now();
754
- if (now - this._autoAt < AUTO_CONV_INTERVAL_MS) return;
776
+ if (now - this._autoAt < (hover ? CURSOR_SAMPLE_MS : AUTO_CONV_INTERVAL_MS)) return;
755
777
  this._autoAt = now;
756
778
  this._scratch = this._scratch || document.createElement('canvas');
757
779
  const g = grabLuma(v, this._scratch);
780
+ if (g) this._luma = g;
781
+ if (!this.autoConverge) return;
758
782
  const m = g ? this._focus.measure(g.img, g.w, g.h, now) : null;
759
783
  const d = this._track.push(m ? m.d / g.scale : null);
760
784
  this.conv.measuredPx = d;
@@ -770,6 +794,8 @@ class CameraView {
770
794
  clearTimeout(this._layerTimer);
771
795
  this._layerTimer = 0;
772
796
  this._unregister();
797
+ this.cursor?.dispose();
798
+ this.cursor = null;
773
799
  this.cam._views?.delete(this);
774
800
  }
775
801
  }
@@ -36,3 +36,21 @@ export function resolveCursorOption(opt, who = '@displayxr/inline3d') {
36
36
  }
37
37
  return { ...opt };
38
38
  }
39
+
40
+ /** 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']);
42
+
43
+ /**
44
+ * `cursor` on a stereo pair — a camera view or a call tile: `'depth'`, or `{ height, pointerScope }`.
45
+ * A pair has no scene: its depth is MEASURED off the two halves, and the sprite is drawn into them,
46
+ * so there is no anchor mode (it is always on the line of sight) and the margin is the SDK's. Falsy
47
+ * is OFF, the zero-cost default.
48
+ * @returns {null | {height?: number, pointerScope?: string}}
49
+ */
50
+ export function resolvePairCursorOption(opt, who = '@displayxr/inline3d') {
51
+ const o = resolveCursorOption(opt, who);
52
+ if (!o) return null;
53
+ const unknown = Object.keys(o).filter((k) => !PAIR_CURSOR_OPTION_KEYS.includes(k));
54
+ if (unknown.length) throw new Error(`${who}: cursor — ${unknown.join(', ')} not supported on a stereo pair; expected ${PAIR_CURSOR_OPTION_KEYS.join(', ')}.`);
55
+ return o;
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.39.0",
3
+ "version": "1.40.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",