@displayxr/inline3d 1.40.0 → 1.40.2

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.
@@ -90,8 +90,9 @@ export function normalizeCallOptions(opts = {}) {
90
90
  // peer's eyes and shift the eyes so it sits at the display plane. The depth slider stays an
91
91
  // offset on top. Off = the pair as sent (plus any `hint`).
92
92
  autoConverge: opts.autoConverge === undefined ? true : !!opts.autoConverge,
93
- // `cursor: 'depth'` (runtime ADR-046 on a pair): a crosshair just in front of the person under
94
- // the pointer, measured off each stereo tile's two halves. Off by default (zero cost).
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).
95
96
  cursor: resolvePairCursorOption(opts.cursor, '@displayxr/inline3d/call'),
96
97
  mono3D: normalizeMono3D(opts.mono3D),
97
98
  maxPeers: clampMaxPeers(opts.maxPeers === undefined ? DEFAULT_MAX_PEERS : opts.maxPeers),
@@ -5,23 +5,48 @@
5
5
  // up are all in front), it is drawn on top of pixels that say they are nearer — the depth
6
6
  // violation runtime ADR-046 is about. A pair has no scene to raycast and no depth buffer, but its
7
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, take the nearest confident match, and draw a crosshair into both
9
- // halves with that disparity plus a small margin. The browser weaves the pair as usual, so the
10
- // sprite lands just in front of the content under it.
8
+ // footprint between the eyes and take the nearest confident match.
11
9
  //
12
- // Same policy as the scene cursor (./inline3d-cursor-depth.js): the footprint ring, the fast-rise /
13
- // slow-sink filter, the display plane when nothing matches. Disparities here are in OUTPUT pixels
14
- // of one eye of the woven buffer, + = crossed (in front of the glass), the sign the rest of
15
- // camera/ uses. Loaded only when a page asks for `cursor: 'depth'` — zero cost otherwise.
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.
16
20
 
17
- import { CursorPointer, cursorFootprint, cursorFilterStep, CURSOR_DEFAULT_TUNING, CURSOR_CROSSHAIR, CURSOR_FILL_COLOR, CURSOR_OUTLINE_COLOR } from '../inline3d-cursor-depth.js';
21
+ import { CursorPointer, cursorFootprint, cursorFilterStep, CURSOR_DEFAULT_TUNING } from '../inline3d-cursor-depth.js';
18
22
  import { matchBlock, MIN_NCC } from './disparity.js';
19
23
 
20
- /** Sprite height, as a fraction of the tile height (a call tile is small; 3% would be a speck). */
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. */
21
27
  export const PAIR_CURSOR_HEIGHT = 0.06;
22
- /** How far in front of the content the sprite floats, as a fraction of the eye width (crossed). */
23
- export const PAIR_CURSOR_MARGIN = 0.002;
24
- /** Comfort clamp on the sprite's disparity, as a fraction of the eye width, either way. */
28
+ /**
29
+ * Below this far in front of the glass (metres of on-screen disparity, ~0.3 mm = 0.005 of the eye
30
+ * baseline, the runtime's rule) the drawn arrow hands back to the OS cursor. Never under 0.5 px.
31
+ */
32
+ export const PAIR_CURSOR_SHOW_M = 0.0003;
33
+ export const PAIR_CURSOR_SHOW_MIN_PX = 0.5;
34
+ /**
35
+ * The standard pointer arrow, hotspot at the tip (0, 0), unit = its height, x right, y down: the
36
+ * OS arrow's outline (a 19-px-tall Windows/macOS arrow, normalised).
37
+ */
38
+ export const PAIR_CURSOR_ARROW = Object.freeze([
39
+ [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],
40
+ ]);
41
+ /**
42
+ * How far in front of out-of-screen content the arrow floats: 2 mm of on-screen disparity — the
43
+ * runtime's 0.03 of the eye baseline (ADR-046 / runtime #1908), so a lifted tile's cursor and a
44
+ * stereo tile's sit the same way. Converted to buffer px through the tile's `displayMetrics()`.
45
+ */
46
+ export const PAIR_CURSOR_MARGIN_M = 0.002;
47
+ /** One CSS px in metres when the panel cannot say (the CSS reference pixel, 1/96 in). */
48
+ export const PAIR_CURSOR_CSS_PX_M = 0.0254 / 96;
49
+ /** Comfort cap on how far in front the arrow comes, as a fraction of the eye width. */
25
50
  export const PAIR_CURSOR_CLAMP = 0.08;
26
51
 
27
52
  /**
@@ -33,9 +58,10 @@ export const PAIR_CURSOR_CLAMP = 0.08;
33
58
  * @param {{rL: {sx:number, sy:number, sw:number, sh:number}, rR: {sx:number}, outW: number, mirror?: boolean}} geo
34
59
  * each eye's source crop (relative to its half, eyeCropRect) and the output eye width; a
35
60
  * mirrored view (the self view) flips the pointer, never the disparity.
36
- * @param {number} u pointer, tile-normalised (0..1, left → right as the viewer sees it)
37
- * @param {number} v pointer, tile-normalised (0..1, top → bottom)
38
- * @param {number} height sprite height (fraction of the tile height)
61
+ * @param {number} u pointer hotspot, tile-normalised (0..1, left → right as the viewer sees it)
62
+ * @param {number} v pointer hotspot, tile-normalised (0..1, top → bottom)
63
+ * @param {number} height arrow height (fraction of the tile height); the footprint covers the
64
+ * arrow's body, which hangs down-right of the hotspot
39
65
  * @param {number} aspect the tile's width / height, for a round footprint
40
66
  * @returns {number|null} output px, + = in front of the glass
41
67
  */
@@ -49,7 +75,8 @@ export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
49
75
  const { rL, rR } = geo;
50
76
  const k = geo.outW / rL.sw; // output px per source px
51
77
  let best = null;
52
- for (const [pu, pv] of cursorFootprint(u, v, height, aspect)) {
78
+ // The ring is centred on the arrow's body, not its tip: that is what content must not overlap.
79
+ for (const [pu, pv] of cursorFootprint(u + (0.3 * height) / aspect, v + 0.5 * height, height, aspect)) {
53
80
  if (!(pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1)) continue;
54
81
  const su = geo.mirror ? 1 - pu : pu;
55
82
  const x = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
@@ -64,42 +91,49 @@ export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
64
91
  return best;
65
92
  }
66
93
 
67
- /** The sprite's target disparity: the content plus the margin, clamped; the glass with nothing under it. */
68
- export function pairCursorTarget(contentPx, outW) {
69
- if (contentPx === null || !Number.isFinite(contentPx)) return 0;
70
- const lim = PAIR_CURSOR_CLAMP * outW;
71
- return Math.max(-lim, Math.min(lim, contentPx + PAIR_CURSOR_MARGIN * outW));
94
+ /**
95
+ * The arrow's target disparity: in front of content that comes OUT of the screen (its disparity
96
+ * plus the margin, clamped), else the glass. Never behind the glass — the cursor does not go inside
97
+ * the display.
98
+ * @param {number} marginPx the margin in output px (pairCursorScale × PAIR_CURSOR_MARGIN_M)
99
+ */
100
+ export function pairCursorTarget(contentPx, outW, marginPx) {
101
+ if (contentPx === null || !Number.isFinite(contentPx) || !(contentPx > 0)) return 0;
102
+ return Math.min(PAIR_CURSOR_CLAMP * outW, contentPx + marginPx);
72
103
  }
73
104
 
74
105
  /**
75
- * Draw the crosshair into both halves of a woven buffer: the left eye's copy `disparityPx / 2`
76
- * right of the pointer, the right eye's as far left (crossed = in front). Each copy is clipped to
77
- * its own half, so a sprite at the edge never bleeds into the other eye.
106
+ * Output px of one eye per metre on the glass: the buffer eye is stretched over the tile's CSS
107
+ * width, and one CSS px is `metersPerCssPx` on the panel.
78
108
  */
79
- export function drawPairCrosshair(g, outW, outH, u, v, disparityPx, height) {
80
- const size = height * outH;
109
+ export function pairCursorScale(outW, tileCssWidth, metersPerCssPx) {
110
+ const m = metersPerCssPx > 0 && Number.isFinite(metersPerCssPx) ? metersPerCssPx : PAIR_CURSOR_CSS_PX_M;
111
+ return tileCssWidth > 0 ? outW / tileCssWidth / m : outW / (1000 * m);
112
+ }
113
+
114
+ /**
115
+ * Draw the arrow into both halves of a woven buffer: the left eye's copy `disparityPx / 2` right of
116
+ * the pointer, the right eye's as far left (crossed = in front). White with a dark outline, like the
117
+ * OS arrow. Each copy is clipped to its own half, so an arrow at the edge never bleeds into the other eye.
118
+ * @param {number} sizePx arrow height in output px
119
+ */
120
+ export function drawPairArrow(g, outW, outH, u, v, disparityPx, sizePx) {
81
121
  const cy = v * outH;
82
- const rgba = (c) => `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${c[3]})`;
83
122
  for (const eye of [0, 1]) {
84
123
  const cx = eye * outW + u * outW + (eye === 0 ? disparityPx / 2 : -disparityPx / 2);
85
124
  g.save();
86
125
  g.beginPath();
87
126
  g.rect(eye * outW, 0, outW, outH);
88
127
  g.clip();
89
- for (const [width, color, cap] of [
90
- [0.15, CURSOR_OUTLINE_COLOR, 'square'],
91
- [0.07, CURSOR_FILL_COLOR, 'butt'],
92
- ]) {
93
- g.lineWidth = width * size;
94
- g.lineCap = cap;
95
- g.strokeStyle = rgba(color);
96
- g.beginPath();
97
- for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) {
98
- g.moveTo(cx + x0 * size, cy + y0 * size);
99
- g.lineTo(cx + x1 * size, cy + y1 * size);
100
- }
101
- g.stroke();
102
- }
128
+ g.beginPath();
129
+ PAIR_CURSOR_ARROW.forEach(([x, y], k) => (k ? g.lineTo : g.moveTo).call(g, cx + x * sizePx, cy + y * sizePx));
130
+ g.closePath();
131
+ g.fillStyle = '#fff';
132
+ g.fill();
133
+ g.lineWidth = Math.max(1, sizePx / 19);
134
+ g.lineJoin = 'miter';
135
+ g.strokeStyle = '#000';
136
+ g.stroke();
103
137
  g.restore();
104
138
  }
105
139
  }
@@ -113,12 +147,16 @@ export class PairCursor {
113
147
  /** @param {HTMLElement} canvas the tile — the pointer counts over its box */
114
148
  constructor(canvas, { height, pointerScope = 'window' } = {}) {
115
149
  this.pointer = new CursorPointer(canvas, { scope: pointerScope });
116
- this.height = height > 0 ? height : PAIR_CURSOR_HEIGHT;
150
+ // A fraction of the tile height when given; by default the OS arrow's size (CSS px → tile).
151
+ this.height = height > 0 ? height : null;
117
152
  this.filter = {};
118
153
  this._luma = null;
119
154
  this._uv = null;
120
155
  this._content = null;
121
- /** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx }` or null. */
156
+ this._metricsFor = null; // the tile handle whose displayMetrics() was asked for
157
+ /** One CSS px on the panel, metres, once the tile's handle has said (else the CSS reference px). */
158
+ this.metersPerCssPx = null;
159
+ /** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx, drawn }` or null. */
122
160
  this.last = null;
123
161
  }
124
162
 
@@ -127,30 +165,55 @@ export class PairCursor {
127
165
  }
128
166
 
129
167
  /**
168
+ * Draws the arrow only while content in front of the glass is under it (the OS cursor is hidden
169
+ * exactly then); otherwise leaves the buffer alone and the OS arrow shows, on the plane.
130
170
  * @param {CanvasRenderingContext2D} g the woven buffer (2·outW × outH), already painted
131
171
  * @param {object|null} luma the latest grayscale source copy (pairFootprintDisparity)
132
- * @param {{rL: object, rR: object, outW: number, outH: number, mirror?: boolean}} geo
172
+ * @param {{rL: object, rR: object, outW: number, outH: number, mirror?: boolean, handle?: object}} geo
173
+ * `handle` = the tile's woven handle: its `displayMetrics()` gives the panel's pixel pitch
133
174
  * @param {number} nowSec
134
175
  */
135
176
  draw(g, luma, geo, nowSec) {
136
177
  const uv = this.pointer.uv;
137
178
  if (!uv) return this.idle();
138
179
  const [u, v] = uv;
180
+ const hf = this.height ?? this._osArrowFraction();
139
181
  // Match only when there is something new to match: a new frame copy or a moved pointer.
140
182
  if (luma !== this._luma || !this._uv || this._uv[0] !== u || this._uv[1] !== v) {
141
- this._content = pairFootprintDisparity(luma, geo, u, v, this.height, geo.outW / geo.outH);
183
+ this._content = pairFootprintDisparity(luma, geo, u, v, hf, geo.outW / geo.outH);
142
184
  this._luma = luma;
143
185
  this._uv = uv;
144
186
  }
145
- const target = pairCursorTarget(this._content, geo.outW);
187
+ this._askMetrics(geo.handle);
188
+ const r = this.pointer.canvas.getBoundingClientRect?.();
189
+ const pxPerM = pairCursorScale(geo.outW, r && r.width, this.metersPerCssPx);
190
+ const target = pairCursorTarget(this._content, geo.outW, PAIR_CURSOR_MARGIN_M * pxPerM);
146
191
  // The shared filter rises fast toward SMALLER values (nearer, in its convention): negate.
147
192
  const disparity = -cursorFilterStep(this.filter, CURSOR_DEFAULT_TUNING, -target, nowSec);
148
- drawPairCrosshair(g, geo.outW, geo.outH, u, v, disparity, this.height);
149
- this.pointer.hideCss(true);
150
- this.last = { contentPx: this._content, targetPx: target, disparityPx: disparity };
193
+ const drawn = disparity >= Math.max(PAIR_CURSOR_SHOW_MIN_PX, PAIR_CURSOR_SHOW_M * pxPerM);
194
+ if (drawn) drawPairArrow(g, geo.outW, geo.outH, u, v, disparity, hf * geo.outH);
195
+ this.pointer.hideCss(drawn); // the OS arrow IS the cursor on the plane
196
+ this.last = { contentPx: this._content, targetPx: target, disparityPx: drawn ? disparity : 0, drawn };
151
197
  return this.last;
152
198
  }
153
199
 
200
+ /** Ask the tile's handle once for the panel's pixel pitch; until it answers, the CSS reference px. */
201
+ _askMetrics(handle) {
202
+ if (!handle || handle === this._metricsFor || typeof handle.displayMetrics !== 'function') return;
203
+ this._metricsFor = handle;
204
+ Promise.resolve(handle.displayMetrics())
205
+ .then((m) => {
206
+ if (this._metricsFor === handle && m && m.metersPerCssPx > 0) this.metersPerCssPx = m.metersPerCssPx;
207
+ })
208
+ .catch(() => {});
209
+ }
210
+
211
+ /** The OS arrow's height as a fraction of the tile's on-page height. */
212
+ _osArrowFraction() {
213
+ const r = this.pointer.canvas.getBoundingClientRect?.();
214
+ return r && r.height > 0 ? Math.min(0.5, PAIR_CURSOR_CSS_PX / r.height) : PAIR_CURSOR_HEIGHT;
215
+ }
216
+
154
217
  /** Not drawing this frame (pointer away, tile not woven): the CSS cursor comes back. */
155
218
  idle() {
156
219
  this.pointer.hideCss(false);
@@ -1715,7 +1715,7 @@ class Tile {
1715
1715
  const r = (rects[eye] = wire.eyeCropRect(eyeW, H, A, shift, eye));
1716
1716
  g.drawImage(v, eye * eyeW + r.sx, r.sy, r.sw, r.sh, eye * outW, 0, outW, outH);
1717
1717
  }
1718
- if (this.cursor) this.cursor.draw(g, this.luma, { rL: rects[0], rR: rects[1], outW, outH }, performance.now() / 1000);
1718
+ if (this.cursor) this.cursor.draw(g, this.luma, { rL: rects[0], rR: rects[1], outW, outH, handle: this.handle }, performance.now() / 1000);
1719
1719
  }
1720
1720
 
1721
1721
  /**
@@ -762,7 +762,7 @@ class CameraView {
762
762
  }
763
763
  }
764
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 };
765
+ const geo = { rL: eyeCropRect(eyeW, H, A, shift, 0), rR: eyeCropRect(eyeW, H, A, shift, 1), outW, outH, mirror: this.mirror, handle: this.handle };
766
766
  this.cursor.draw(g, this._luma, geo, performance.now() / 1000);
767
767
  }
768
768
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.40.0",
3
+ "version": "1.40.2",
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",