@displayxr/inline3d 1.40.2 → 1.41.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.
@@ -7,47 +7,45 @@
7
7
  // two halves ARE the depth: this is ADR-046 Phase 3b done in the page. Block-match a cursor-sized
8
8
  // footprint between the eyes and take the nearest confident match.
9
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
10
+ // The policy is the one every DisplayXR depth cursor follows (runtime ADR-046 Amendment 2), taken
11
+ // from the shared core (../inline3d-cursor-depth.js), not re-implemented here:
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);
14
+ // - on the plane it IS the OS cursor: the arrow is drawn into both halves only while the filtered
15
+ // disparity is past the draw threshold (-0.005 baselines, and >= 0.5 px; cursorShouldDraw), the
16
+ // OS cursor hidden exactly then;
17
+ // - the same filter (fast rise, slow sink) and the same arrow (CURSOR_ARROW, tip = hotspot, the
18
+ // OS arrow's 19 CSS px).
19
+ // What is specific to a pair: it has no 3D views, so it works in tile PIXEL disparity — OUTPUT
20
+ // pixels of one eye of the woven buffer, + = crossed (in front of the glass), the sign the rest of
21
+ // camera/ uses — and converts to the core's eye-baseline units through the panel's pixel pitch:
22
+ // buffer px per metre = outW / tileCssWidth / metersPerCssPx (the tile handle's displayMetrics()),
23
+ // times the eye separation (displayMetrics' default, 63 mm). Loaded only when a page asks for
19
24
  // `cursor: 'depth'` — zero cost otherwise.
20
25
 
21
- import { CursorPointer, cursorFootprint, cursorFilterStep, CURSOR_DEFAULT_TUNING } from '../inline3d-cursor-depth.js';
26
+ import {
27
+ CursorPointer,
28
+ cursorArrowFootprint,
29
+ cursorArrowInset,
30
+ cursorDefaultHeight,
31
+ cursorFilterStep,
32
+ cursorShouldDraw,
33
+ cursorTarget,
34
+ CURSOR_ARROW,
35
+ CURSOR_ARROW_CSS_PX,
36
+ CURSOR_DEFAULT_TUNING,
37
+ } from '../inline3d-cursor-depth.js';
38
+ import { DISPLAY_METRICS_DEFAULTS } from '../inline3d-display-metrics.js';
22
39
  import { matchBlock, MIN_NCC } from './disparity.js';
23
40
 
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;
41
+ /** The arrow's height in CSS px — the OS arrow's apparent size (the core's CURSOR_ARROW_CSS_PX). */
42
+ export const PAIR_CURSOR_CSS_PX = CURSOR_ARROW_CSS_PX;
26
43
  /** Arrow height as a fraction of the tile height when the tile's on-page size is unknown. */
27
44
  export const PAIR_CURSOR_HEIGHT = 0.06;
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;
45
+ /** The arrow every DisplayXR depth cursor draws (the core's CURSOR_ARROW): tip at (0, 0), unit = its height, y down. */
46
+ export const PAIR_CURSOR_ARROW = CURSOR_ARROW;
47
47
  /** One CSS px in metres when the panel cannot say (the CSS reference pixel, 1/96 in). */
48
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. */
50
- export const PAIR_CURSOR_CLAMP = 0.08;
51
49
 
52
50
  /**
53
51
  * The displayed disparity of the nearest content under the cursor's footprint, or null when no
@@ -75,8 +73,8 @@ export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
75
73
  const { rL, rR } = geo;
76
74
  const k = geo.outW / rL.sw; // output px per source px
77
75
  let best = null;
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)) {
76
+ // The tip, then a ring centred on the arrow's body: that is what content must not overlap.
77
+ for (const [pu, pv] of cursorArrowFootprint(u, v, height, aspect)) {
80
78
  if (!(pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1)) continue;
81
79
  const su = geo.mirror ? 1 - pu : pu;
82
80
  const x = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
@@ -91,49 +89,69 @@ export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
91
89
  return best;
92
90
  }
93
91
 
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);
103
- }
104
-
105
92
  /**
106
93
  * 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.
94
+ * width, and one CSS px is `metersPerCssPx` on the panel (the CSS reference px when unknown).
108
95
  */
109
96
  export function pairCursorScale(outW, tileCssWidth, metersPerCssPx) {
110
97
  const m = metersPerCssPx > 0 && Number.isFinite(metersPerCssPx) ? metersPerCssPx : PAIR_CURSOR_CSS_PX_M;
111
98
  return tileCssWidth > 0 ? outW / tileCssWidth / m : outW / (1000 * m);
112
99
  }
113
100
 
101
+ /**
102
+ * Output px of on-screen disparity per eye-baseline unit — the conversion between this module's
103
+ * pixels and the core's units: pairCursorScale × the eye separation.
104
+ */
105
+ export function pairPxPerBaseline(outW, tileCssWidth, metersPerCssPx, eyeSeparationM = DISPLAY_METRICS_DEFAULTS.eyeSeparationM) {
106
+ return pairCursorScale(outW, tileCssWidth, metersPerCssPx) * eyeSeparationM;
107
+ }
108
+
109
+ /**
110
+ * The arrow's target disparity in output px (+ = in front): the core's policy (cursorTarget) on
111
+ * 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.
113
+ */
114
+ export function pairCursorTarget(contentPx, pxPerBaseline, tuning = CURSOR_DEFAULT_TUNING) {
115
+ if (contentPx === null || !Number.isFinite(contentPx) || !(pxPerBaseline > 0)) return 0;
116
+ return -cursorTarget(tuning, true, -contentPx / pxPerBaseline) * pxPerBaseline || 0;
117
+ }
118
+
119
+ /** The arrow's fill, inset by one output px (at least 1/19 of its height), cached per size. */
120
+ const _fillBySize = new Map();
121
+ function arrowFill(sizePx) {
122
+ const d = sizePx > 0 ? Math.max(1 / 19, 1 / sizePx) : 1 / 19;
123
+ const key = Math.round(d * 1e4);
124
+ let f = _fillBySize.get(key);
125
+ if (!f) {
126
+ if (_fillBySize.size > 32) _fillBySize.clear();
127
+ _fillBySize.set(key, (f = cursorArrowInset(d)));
128
+ }
129
+ return f;
130
+ }
131
+
114
132
  /**
115
133
  * 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.
134
+ * the pointer, the right eye's as far left (crossed = in front). The shared arrow: its dark outline
135
+ * polygon, then the white fill inset by a px; the tip on the hotspot. Each copy is clipped to its
136
+ * own half, so an arrow at the edge never bleeds into the other eye.
118
137
  * @param {number} sizePx arrow height in output px
119
138
  */
120
139
  export function drawPairArrow(g, outW, outH, u, v, disparityPx, sizePx) {
121
140
  const cy = v * outH;
141
+ const fill = arrowFill(sizePx);
122
142
  for (const eye of [0, 1]) {
123
143
  const cx = eye * outW + u * outW + (eye === 0 ? disparityPx / 2 : -disparityPx / 2);
124
144
  g.save();
125
145
  g.beginPath();
126
146
  g.rect(eye * outW, 0, outW, outH);
127
147
  g.clip();
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();
148
+ for (const [poly, style] of [[CURSOR_ARROW, '#000'], [fill, '#fff']]) {
149
+ g.beginPath();
150
+ poly.forEach(([x, y], k) => (k ? g.lineTo : g.moveTo).call(g, cx + x * sizePx, cy + y * sizePx));
151
+ g.closePath();
152
+ g.fillStyle = style;
153
+ g.fill();
154
+ }
137
155
  g.restore();
138
156
  }
139
157
  }
@@ -156,7 +174,9 @@ export class PairCursor {
156
174
  this._metricsFor = null; // the tile handle whose displayMetrics() was asked for
157
175
  /** One CSS px on the panel, metres, once the tile's handle has said (else the CSS reference px). */
158
176
  this.metersPerCssPx = null;
159
- /** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx, drawn }` or null. */
177
+ /** The eye separation (metres) the baseline units convert with: displayMetrics' (a default today). */
178
+ this.eyeSeparationM = DISPLAY_METRICS_DEFAULTS.eyeSeparationM;
179
+ /** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx, filteredPx, drawn, pxPerBaseline }` (output px, + = in front) or null. */
160
180
  this.last = null;
161
181
  }
162
182
 
@@ -186,14 +206,16 @@ export class PairCursor {
186
206
  }
187
207
  this._askMetrics(geo.handle);
188
208
  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);
191
- // The shared filter rises fast toward SMALLER values (nearer, in its convention): negate.
192
- const disparity = -cursorFilterStep(this.filter, CURSOR_DEFAULT_TUNING, -target, nowSec);
193
- const drawn = disparity >= Math.max(PAIR_CURSOR_SHOW_MIN_PX, PAIR_CURSOR_SHOW_M * pxPerM);
209
+ const pxPerBaseline = pairPxPerBaseline(geo.outW, r && r.width, this.metersPerCssPx, this.eyeSeparationM);
210
+ // The core's policy and filter, in its units (eye baselines, < 0 = in front).
211
+ const content = this._content === null ? null : -this._content / pxPerBaseline;
212
+ const target = cursorTarget(CURSOR_DEFAULT_TUNING, content !== null, content);
213
+ const d = cursorFilterStep(this.filter, CURSOR_DEFAULT_TUNING, target, nowSec);
214
+ const drawn = cursorShouldDraw(d, pxPerBaseline);
215
+ const disparity = -d * pxPerBaseline; // output px, + = in front
194
216
  if (drawn) drawPairArrow(g, geo.outW, geo.outH, u, v, disparity, hf * geo.outH);
195
217
  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 };
218
+ this.last = { contentPx: this._content, targetPx: -target * pxPerBaseline || 0, disparityPx: drawn ? disparity : 0, filteredPx: disparity || 0, drawn, pxPerBaseline };
197
219
  return this.last;
198
220
  }
199
221
 
@@ -201,17 +223,20 @@ export class PairCursor {
201
223
  _askMetrics(handle) {
202
224
  if (!handle || handle === this._metricsFor || typeof handle.displayMetrics !== 'function') return;
203
225
  this._metricsFor = handle;
204
- Promise.resolve(handle.displayMetrics())
226
+ Promise.resolve()
227
+ .then(() => handle.displayMetrics())
205
228
  .then((m) => {
206
- if (this._metricsFor === handle && m && m.metersPerCssPx > 0) this.metersPerCssPx = m.metersPerCssPx;
229
+ if (this._metricsFor !== handle || !m) return;
230
+ if (m.metersPerCssPx > 0) this.metersPerCssPx = m.metersPerCssPx;
231
+ if (m.eyeSeparationM > 0) this.eyeSeparationM = m.eyeSeparationM;
207
232
  })
208
233
  .catch(() => {});
209
234
  }
210
235
 
211
- /** The OS arrow's height as a fraction of the tile's on-page height. */
236
+ /** The OS arrow's height as a fraction of the tile's on-page height (the core's default size). */
212
237
  _osArrowFraction() {
213
238
  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;
239
+ return r && r.height > 0 ? cursorDefaultHeight(r.height) : PAIR_CURSOR_HEIGHT;
215
240
  }
216
241
 
217
242
  /** Not drawing this frame (pointer away, tile not woven): the CSS cursor comes back. */
@@ -14,18 +14,20 @@
14
14
  // runtime's one-frame-late app hint.
15
15
  //
16
16
  // Placement: the same maths as everywhere else (./inline3d-cursor-depth.js, a port of the
17
- // runtime's u_cursor_depth, ADR-046), solved in WORLD space from this frame's views carried
18
- // through the rig node. Drawn as a filled mesh (strokes + a dark outline) in the UI layer with
19
- // depth test off, so every eye camera draws it last and nothing occludes it; the picker skips the
20
- // UI layer, so the cursor never picks itself.
17
+ // runtime's u_cursor_depth, ADR-046 + Amendment 2's policy), solved in WORLD space from this
18
+ // frame's views carried through the rig node. Drawn — only while content under it comes out of the
19
+ // glass — as the classic arrow (the shared cursorArrowMesh: dark outline, white fill, tip on the
20
+ // hotspot) in the UI layer with depth test off, so every eye camera draws it last and nothing
21
+ // occludes it; the picker skips the UI layer, so the cursor never picks itself. On the plane
22
+ // nothing is drawn and the CSS cursor is the cursor.
21
23
 
22
24
  import {
23
25
  CursorDepthPlacer,
24
26
  CursorPointer,
25
- CURSOR_DEFAULT_HEIGHT,
26
27
  CURSOR_DEFAULT_TUNING,
27
- cursorCrosshairMesh,
28
- cursorFootprint,
28
+ cursorArrowMesh,
29
+ cursorArrowFootprint,
30
+ cursorDefaultHeight,
29
31
  cursorViewRay,
30
32
  } from './inline3d-cursor-depth.js';
31
33
 
@@ -42,10 +44,11 @@ export class PlayCanvasDepthCursor {
42
44
  * @param {object} viewer the PlayCanvasSplatViewer (app, rigNode, eye are read lazily).
43
45
  * @param {{canvas: HTMLElement, height?: number, margin?: number, anchor?: string, pointerScope?: string}} opts
44
46
  */
45
- constructor(pc, viewer, { canvas, height = CURSOR_DEFAULT_HEIGHT, margin, anchor = 'hybrid', pointerScope = 'canvas' }) {
47
+ constructor(pc, viewer, { canvas, height, margin, anchor = 'hybrid', pointerScope = 'canvas' }) {
46
48
  this.pc = pc;
47
49
  this.viewer = viewer;
48
- this.height = height > 0 && Number.isFinite(height) ? height : CURSOR_DEFAULT_HEIGHT;
50
+ /** Sprite height (canvas heights) when set; null = the OS arrow's size, from the canvas's on-page height. */
51
+ this.height = height > 0 && Number.isFinite(height) ? height : null;
49
52
  this.pointer = new CursorPointer(canvas, { scope: pointerScope });
50
53
  const tuning = margin > 0 && Number.isFinite(margin) ? { ...CURSOR_DEFAULT_TUNING, margin } : CURSOR_DEFAULT_TUNING;
51
54
  this.placer = new CursorDepthPlacer(tuning, { anchor });
@@ -89,16 +92,17 @@ export class PlayCanvasDepthCursor {
89
92
  console.warn('[inline3d] cursor: depth: a PlayCanvas pick did not answer in time; retrying (cursor on the glass meanwhile).');
90
93
  }
91
94
  }
92
- if (this._frame++ % PICK_EVERY === 0 && !this._inFlight) this._pick(app, rig, entries[0], uv);
95
+ const h = this.height ?? cursorDefaultHeight(this.pointer.cssHeight());
96
+ if (this._frame++ % PICK_EVERY === 0 && !this._inFlight) this._pick(app, rig, entries[0], uv, h);
93
97
  const now = (globalThis.performance ? globalThis.performance.now() : Date.now()) / 1000;
94
- const p = this.placer.update(views, { u: uv[0], v: uv[1], nearestPoint: this.hit, cursorHeight: this.height }, now);
98
+ const p = this.placer.update(views, { u: uv[0], v: uv[1], nearestPoint: this.hit, cursorHeight: h, canvasPx: this.pointer.panelPx() }, now);
95
99
  this.placement = p;
96
100
  this.pointer.hideCss(!!p.active);
97
101
  const sprite = this._sprite || (p.active ? this._makeSprite(app) : null);
98
102
  if (!sprite) return;
99
103
  sprite.mi.visible = !!p.active;
100
104
  if (!p.active) return;
101
- // The unit crosshair in the sprite's XY plane: basis (rotation), height (scale), position.
105
+ // The unit arrow in the sprite's XY plane, tip at its origin: basis (rotation), height (scale), position = the hotspot.
102
106
  const { x, y, z } = p.basis;
103
107
  const q = quatFromMatrix([x[0], x[1], x[2], 0, y[0], y[1], y[2], 0, z[0], z[1], z[2], 0, 0, 0, 0, 1]);
104
108
  sprite.node.setLocalPosition(p.position[0], p.position[1], p.position[2]);
@@ -114,11 +118,11 @@ export class PlayCanvasDepthCursor {
114
118
  this.pointer.hideCss(false);
115
119
  }
116
120
 
117
- // The filled crosshair: one mesh (outline triangles first, fill after — the order a
118
- // depth-test-off draw needs), vertex-coloured, alpha-blended, in the UI layer.
121
+ // The arrow: one mesh (outline triangles first, fill after — the order a depth-test-off draw
122
+ // needs), vertex-coloured, alpha-blended, in the UI layer.
119
123
  _makeSprite(app) {
120
124
  const pc = this.pc;
121
- const m = cursorCrosshairMesh();
125
+ const m = cursorArrowMesh();
122
126
  const mesh = new pc.Mesh(app.graphicsDevice);
123
127
  mesh.setPositions(m.positions);
124
128
  mesh.setColors(m.colors);
@@ -179,7 +183,7 @@ export class PlayCanvasDepthCursor {
179
183
  }
180
184
 
181
185
  // Render the pick buffer from the first eye and read the footprint's world points.
182
- _pick(app, rig, e, uv) {
186
+ _pick(app, rig, e, uv, height) {
183
187
  const pc = this.pc;
184
188
  const aspect = e.width > 0 && e.height > 0 ? e.width / e.height : 1;
185
189
  const W = PICK_WIDTH;
@@ -240,7 +244,7 @@ export class PlayCanvasDepthCursor {
240
244
  const Mw = view.transformMatrix;
241
245
  const f = [-Mw[8], -Mw[9], -Mw[10]];
242
246
  const zNear = cam.camera.nearClip, zFar = cam.camera.farClip;
243
- const samples = cursorFootprint(uv[0], uv[1], this.height, this.pointer.aspect())
247
+ const samples = cursorArrowFootprint(uv[0], uv[1], height, this.pointer.aspect())
244
248
  .filter(([u, v]) => u >= 0 && u <= 1 && v >= 0 && v <= 1)
245
249
  .map(([u, v]) =>
246
250
  picker.getPointDepthAsync(Math.min(W - 1, Math.floor(u * W)), Math.min(H - 1, Math.floor(v * H))).then((lin) => {