@displayxr/inline3d 1.40.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.
@@ -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,38 @@
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). */
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). */
23
38
  export const PAIR_CURSOR_MARGIN = 0.002;
24
- /** Comfort clamp on the sprite's disparity, as a fraction of the eye width, either way. */
39
+ /** Comfort cap on how far in front the arrow comes, as a fraction of the eye width. */
25
40
  export const PAIR_CURSOR_CLAMP = 0.08;
26
41
 
27
42
  /**
@@ -33,9 +48,10 @@ export const PAIR_CURSOR_CLAMP = 0.08;
33
48
  * @param {{rL: {sx:number, sy:number, sw:number, sh:number}, rR: {sx:number}, outW: number, mirror?: boolean}} geo
34
49
  * each eye's source crop (relative to its half, eyeCropRect) and the output eye width; a
35
50
  * 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)
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
39
55
  * @param {number} aspect the tile's width / height, for a round footprint
40
56
  * @returns {number|null} output px, + = in front of the glass
41
57
  */
@@ -49,7 +65,8 @@ export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
49
65
  const { rL, rR } = geo;
50
66
  const k = geo.outW / rL.sw; // output px per source px
51
67
  let best = null;
52
- for (const [pu, pv] of cursorFootprint(u, v, height, aspect)) {
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)) {
53
70
  if (!(pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1)) continue;
54
71
  const su = geo.mirror ? 1 - pu : pu;
55
72
  const x = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
@@ -64,42 +81,39 @@ export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
64
81
  return best;
65
82
  }
66
83
 
67
- /** The sprite's target disparity: the content plus the margin, clamped; the glass with nothing under it. */
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
+ */
68
89
  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));
90
+ if (contentPx === null || !Number.isFinite(contentPx) || !(contentPx > 0)) return 0;
91
+ return Math.min(PAIR_CURSOR_CLAMP * outW, contentPx + PAIR_CURSOR_MARGIN * outW);
72
92
  }
73
93
 
74
94
  /**
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.
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
78
99
  */
79
- export function drawPairCrosshair(g, outW, outH, u, v, disparityPx, height) {
80
- const size = height * outH;
100
+ export function drawPairArrow(g, outW, outH, u, v, disparityPx, sizePx) {
81
101
  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
102
  for (const eye of [0, 1]) {
84
103
  const cx = eye * outW + u * outW + (eye === 0 ? disparityPx / 2 : -disparityPx / 2);
85
104
  g.save();
86
105
  g.beginPath();
87
106
  g.rect(eye * outW, 0, outW, outH);
88
107
  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
- }
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();
103
117
  g.restore();
104
118
  }
105
119
  }
@@ -113,12 +127,13 @@ export class PairCursor {
113
127
  /** @param {HTMLElement} canvas the tile — the pointer counts over its box */
114
128
  constructor(canvas, { height, pointerScope = 'window' } = {}) {
115
129
  this.pointer = new CursorPointer(canvas, { scope: pointerScope });
116
- this.height = height > 0 ? height : PAIR_CURSOR_HEIGHT;
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;
117
132
  this.filter = {};
118
133
  this._luma = null;
119
134
  this._uv = null;
120
135
  this._content = null;
121
- /** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx }` or null. */
136
+ /** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx, drawn }` or null. */
122
137
  this.last = null;
123
138
  }
124
139
 
@@ -127,6 +142,8 @@ export class PairCursor {
127
142
  }
128
143
 
129
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.
130
147
  * @param {CanvasRenderingContext2D} g the woven buffer (2·outW × outH), already painted
131
148
  * @param {object|null} luma the latest grayscale source copy (pairFootprintDisparity)
132
149
  * @param {{rL: object, rR: object, outW: number, outH: number, mirror?: boolean}} geo
@@ -136,21 +153,29 @@ export class PairCursor {
136
153
  const uv = this.pointer.uv;
137
154
  if (!uv) return this.idle();
138
155
  const [u, v] = uv;
156
+ const hf = this.height ?? this._osArrowFraction();
139
157
  // Match only when there is something new to match: a new frame copy or a moved pointer.
140
158
  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);
159
+ this._content = pairFootprintDisparity(luma, geo, u, v, hf, geo.outW / geo.outH);
142
160
  this._luma = luma;
143
161
  this._uv = uv;
144
162
  }
145
163
  const target = pairCursorTarget(this._content, geo.outW);
146
164
  // The shared filter rises fast toward SMALLER values (nearer, in its convention): negate.
147
165
  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 };
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 };
151
170
  return this.last;
152
171
  }
153
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
+
154
179
  /** Not drawing this frame (pointer away, tile not woven): the CSS cursor comes back. */
155
180
  idle() {
156
181
  this.pointer.hideCss(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.40.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",