@displayxr/inline3d 1.40.2 → 1.42.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.
@@ -40,28 +40,74 @@
40
40
  // put; a depth change slides it along that line, so it stays on the click point
41
41
  // for the viewer who placed it. Continuous at the stop frame.
42
42
  // The height is scaled by t either way, so the apparent size is constant.
43
+ //
44
+ // THE POLICY (ADR-046 Amendment 2, the same for every DisplayXR depth cursor — this module, ./three's
45
+ // DepthCursor, `cursor: 'depth'` on ./model and ./splat, and the stereo-tile cursor on ./call and
46
+ // ./camera, which calls the pieces exported here):
47
+ // - never behind the glass: target = d < 0 ? max(d - margin, dMin) : 0 — it only comes forward,
48
+ // to stay ahead of content that comes OUT of the screen (cursorTarget);
49
+ // - margin 0.015 eye baselines (~1 mm of crossed on-screen disparity at a 63-65 mm IPD);
50
+ // - on the plane it IS the OS cursor: nothing is drawn while the filtered disparity is above
51
+ // CURSOR_DRAW_THRESHOLD (-0.005, ~0.3 mm) or under CURSOR_DRAW_MIN_PX on the panel
52
+ // (cursorShouldDraw), and the CSS cursor is left alone;
53
+ // - the look: the classic arrow (CURSOR_ARROW, tip = hotspot), at the OS cursor's size
54
+ // (CURSOR_ARROW_CSS_PX tall on the page, cursorDefaultHeight).
43
55
 
44
56
  import { CURSOR_ANCHOR_MODES } from './inline3d-cursor-option.js';
45
57
 
46
58
  export { CURSOR_ANCHOR_MODES, CURSOR_OPTION_KEYS, resolveCursorOption } from './inline3d-cursor-option.js';
47
59
 
48
- /** Default sprite height, as a fraction of the canvas height. */
60
+ /**
61
+ * Fallback sprite height, as a fraction of the canvas height, when the canvas's on-page size is
62
+ * unknown. With a known size the default is the OS cursor's size (cursorDefaultHeight).
63
+ */
49
64
  export const CURSOR_DEFAULT_HEIGHT = 0.03;
50
65
 
66
+ /** The OS arrow's height in CSS px: the drawn arrow's default size, so the hand-over is seamless. */
67
+ export const CURSOR_ARROW_CSS_PX = 19;
68
+
69
+ /**
70
+ * The default sprite height for a canvas `cssHeight` CSS px tall: the OS arrow's 19 CSS px, as a
71
+ * fraction of it (capped at half the canvas). Recompute it when the canvas resizes.
72
+ */
73
+ export function cursorDefaultHeight(cssHeight) {
74
+ return cssHeight > 0 && Number.isFinite(cssHeight) ? Math.min(0.5, CURSOR_ARROW_CSS_PX / cssHeight) : CURSOR_DEFAULT_HEIGHT;
75
+ }
76
+
77
+ /**
78
+ * The draw threshold (eye-baseline units): the sprite is drawn only while the FILTERED disparity is
79
+ * at or below this (~0.3 mm of on-screen disparity at a 65 mm IPD). Above it, the OS cursor is the
80
+ * cursor and nothing is drawn.
81
+ */
82
+ export const CURSOR_DRAW_THRESHOLD = -0.005;
83
+ /** …and never while the on-screen disparity is under this many panel pixels (where they are knowable). */
84
+ export const CURSOR_DRAW_MIN_PX = 0.5;
85
+
86
+ /**
87
+ * Should the sprite be drawn at filtered `disparity`? Only while it is in front of the glass by at
88
+ * least the draw threshold, and — when `pxPerBaseline` (panel pixels of on-screen disparity per
89
+ * eye-baseline unit) is known — by at least CURSOR_DRAW_MIN_PX.
90
+ */
91
+ export function cursorShouldDraw(disparity, pxPerBaseline) {
92
+ if (!(disparity <= CURSOR_DRAW_THRESHOLD)) return false;
93
+ return !(pxPerBaseline > 0 && Number.isFinite(pxPerBaseline)) || -disparity * pxPerBaseline >= CURSOR_DRAW_MIN_PX;
94
+ }
95
+
51
96
  /**
52
97
  * The runtime's defaults (u_cursor_depth_tuning_defaults), in the same units.
53
98
  * Disparities are in eye-baseline units; times in seconds.
54
99
  */
55
100
  export const CURSOR_DEFAULT_TUNING = Object.freeze({
56
101
  /**
57
- * How far in front of the content the cursor floats. 0.005 baseline is ~1.5 mm at a 60 cm
58
- * viewing distance: it RESTS on what it hovers. (1.36.0 shipped 0.03, ~1–2 cm, which a tester
59
- * on a panel found floated visibly off shallow content.)
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).
60
105
  */
61
- margin: 0.005,
106
+ margin: 0.015,
62
107
  /** Comfort clamp: t >= 0.625, never more than ~3/8 of the way to the eye. */
63
108
  minDisparity: -0.6,
64
- maxDisparity: 0.6,
109
+ /** Never behind the glass: 0, and cursorTarget never goes above 0 whatever this says. */
110
+ maxDisparity: 0,
65
111
  /** Rising toward the viewer is fast — never lag behind content that comes forward. */
66
112
  riseTau: 0.03,
67
113
  /** Sinking away is slow — no flicker as the footprint crosses an edge. */
@@ -161,10 +207,15 @@ export function cursorPointDisparity(g, point) {
161
207
  return 1 - 1 / t;
162
208
  }
163
209
 
164
- /** Target disparity: content minus the margin, clamped; 0 (the canvas) with nothing under it. */
210
+ /**
211
+ * Target disparity — the policy: `d < 0 ? max(d - margin, dMin) : 0`. Content in front of the glass
212
+ * (d < 0) puts the cursor the margin in front of it (clamped); content on or behind the glass, or
213
+ * nothing under it, leaves it ON the glass. Never behind it: the result is never above 0.
214
+ */
165
215
  export function cursorTarget(tuning, hasContent, contentDisparity) {
166
- if (!hasContent || !Number.isFinite(contentDisparity)) return 0;
167
- return Math.min(tuning.maxDisparity, Math.max(tuning.minDisparity, contentDisparity - tuning.margin));
216
+ if (!hasContent || !Number.isFinite(contentDisparity) || !(contentDisparity < 0)) return 0;
217
+ const hi = Math.min(0, Number.isFinite(tuning.maxDisparity) ? tuning.maxDisparity : 0);
218
+ return Math.min(hi, Math.max(tuning.minDisparity, contentDisparity - tuning.margin));
168
219
  }
169
220
 
170
221
  /**
@@ -204,12 +255,17 @@ const POINTER_STILL_EPS = 1e-4;
204
255
  *
205
256
  * const placer = new CursorDepthPlacer();
206
257
  * // per frame, with the views you are about to render:
207
- * const p = placer.update(views, { u, v, nearestPoint, cursorHeight: 0.03 }, now / 1000);
208
- * if (p.active) drawSprite(p.position, p.basis, p.height);
258
+ * const p = placer.update(views, { u, v, nearestPoint, cursorHeight, canvasPx }, now / 1000);
259
+ * if (p.active) drawSprite(p.position, p.basis, p.height); // else: the OS cursor, untouched
209
260
  *
210
261
  * `views` are `{projectionMatrix, transformMatrix}` (or XRViews — `transform.matrix` is read
211
262
  * when `transformMatrix` is absent). `nearestPoint` is the content point under the cursor
212
- * FOOTPRINT nearest the viewer, in the views' space, or null for "nothing under it".
263
+ * FOOTPRINT nearest the viewer, in the views' space, or null for "nothing under it". `canvasPx`
264
+ * (optional) is the canvas's height in panel pixels, for the draw threshold's pixel floor.
265
+ *
266
+ * `active` is the DRAW decision (cursorShouldDraw on the filtered disparity): false on the plane,
267
+ * where the OS cursor is the cursor. The filter and the anchor keep running either way, and
268
+ * `solved` says the geometry was (position etc. are then filled for diagnostics).
213
269
  */
214
270
  export class CursorDepthPlacer {
215
271
  /**
@@ -228,7 +284,7 @@ export class CursorDepthPlacer {
228
284
  }
229
285
 
230
286
  update(views, hint, nowSec) {
231
- const inactive = { active: false, position: null, basis: null, height: 0, disparity: 0, targetDisparity: 0 };
287
+ const inactive = { active: false, solved: false, position: null, basis: null, height: 0, disparity: 0, targetDisparity: 0 };
232
288
  if (!views || views.length < 2 || !hint) {
233
289
  this._line = null;
234
290
  this._last = null;
@@ -270,7 +326,13 @@ export class CursorDepthPlacer {
270
326
  }
271
327
  }
272
328
  this._last = [hint.u, hint.v];
273
- return { active: true, position, basis: g.basis, height, disparity, targetDisparity: target, anchored, geometry: g };
329
+ // Panel px of on-screen disparity per baseline unit: the outer views' baseline over the canvas
330
+ // height, in the views' space, times the canvas height in panel px.
331
+ const ra = a.transformMatrix, rb = b.transformMatrix;
332
+ const baseline = Math.hypot(ra[12] - rb[12], ra[13] - rb[13], ra[14] - rb[14]);
333
+ const pxPerBaseline = hint.canvasPx > 0 ? (baseline / g.canvasHeight) * hint.canvasPx : null;
334
+ const active = cursorShouldDraw(disparity, pxPerBaseline);
335
+ return { active, solved: true, position, basis: g.basis, height, disparity, targetDisparity: target, anchored, geometry: g, pxPerBaseline };
274
336
  }
275
337
  }
276
338
 
@@ -336,7 +398,24 @@ export class CursorPointer {
336
398
  return r.height > 0 ? r.width / r.height : 1;
337
399
  }
338
400
 
339
- /** Hide (true) or restore (false) the CSS cursor; idempotent. */
401
+ /** The canvas's on-page height, CSS px (0 when unknown). */
402
+ cssHeight() {
403
+ const r = this.canvas.getBoundingClientRect();
404
+ return r.height > 0 ? r.height : 0;
405
+ }
406
+
407
+ /** The canvas's height in panel (device) pixels, or 0 when unknown. */
408
+ panelPx() {
409
+ return this.cssHeight() * (globalThis.devicePixelRatio > 0 ? globalThis.devicePixelRatio : 1);
410
+ }
411
+
412
+ /**
413
+ * Hide (true) or restore (false) the CSS cursor; idempotent. Only ever while a sprite replaces
414
+ * it: whatever cursor the page set (`pointer`, `grab`, a `url(…)` image) is the cursor the rest of
415
+ * the time, and a cursor the page changes while it is hidden is not overwritten on restore.
416
+ * (Mirroring the page's CSS cursor INTO the drawn sprite is future work — ADR-046 Amendment 2 C,
417
+ * the browser feeding the computed CSS cursor to the runtime; today the sprite is the arrow.)
418
+ */
340
419
  hideCss(hide) {
341
420
  if (hide === this._hidden) return;
342
421
  if (this.scope === 'window') {
@@ -344,8 +423,8 @@ export class CursorPointer {
344
423
  } else if (hide) {
345
424
  this._prev = this.canvas.style.cursor || '';
346
425
  this.canvas.style.cursor = 'none';
347
- } else {
348
- this.canvas.style.cursor = this._prev;
426
+ } else if (this.canvas.style.cursor === 'none') {
427
+ this.canvas.style.cursor = this._prev; // only if it is still ours
349
428
  }
350
429
  this._hidden = hide;
351
430
  }
@@ -380,9 +459,9 @@ function pageCursorHidden(hide) {
380
459
  }
381
460
 
382
461
  /**
383
- * The footprint ring every backend samples: the hotspot plus 8 points on a circle of radius
384
- * 0.75 × the sprite height (canvas heights), so the sprite and a margin are covered.
385
- * @returns {Array<[number, number]>} canvas-normalised points, the hotspot first.
462
+ * A footprint ring: the point (u, v) plus 8 points on a circle of radius 0.75 × the sprite
463
+ * height (canvas heights) around it.
464
+ * @returns {Array<[number, number]>} canvas-normalised points, (u, v) first.
386
465
  */
387
466
  export function cursorFootprint(u, v, height, aspect) {
388
467
  const r = 0.75 * height;
@@ -395,34 +474,98 @@ export function cursorFootprint(u, v, height, aspect) {
395
474
  }
396
475
 
397
476
  /**
398
- * The crosshair every backend draws: 8 segments in the sprite's own XY plane, unit = sprite
399
- * height (a "+" with a gap and a small square at the hotspot), as [x0, y0, x1, y1] tuples.
477
+ * The footprint every backend samples for the ARROW: the hotspot (its tip) first, then a ring
478
+ * around the arrow's BODY, which hangs down-right of the tip — that is what content must not
479
+ * overlap. Canvas-normalised, v down.
480
+ * @returns {Array<[number, number]>}
400
481
  */
401
- export const CURSOR_CROSSHAIR = Object.freeze([
402
- [-0.5, 0, -0.15, 0], [0.15, 0, 0.5, 0], [0, -0.5, 0, -0.15], [0, 0.15, 0, 0.5],
403
- [-0.15, -0.15, 0.15, -0.15], [0.15, -0.15, 0.15, 0.15], [0.15, 0.15, -0.15, 0.15], [-0.15, 0.15, -0.15, -0.15],
404
- ]);
482
+ export function cursorArrowFootprint(u, v, height, aspect) {
483
+ return [[u, v], ...cursorFootprint(u + (0.3 * height) / aspect, v + 0.5 * height, height, aspect)];
484
+ }
485
+
486
+ /**
487
+ * The classic pointer arrow every backend draws (ADR-046 Amendment 2: the depth cursor is the
488
+ * cursor the user already has; where its shape cannot be read, this). The OS arrow's outline,
489
+ * 12 × 19 px normalised: unit = its height, x right, y DOWN, the hotspot = the TIP at (0, 0). This
490
+ * is the DARK outline's polygon; the white fill is it inset by one px (CURSOR_ARROW_FILL).
491
+ */
492
+ export const CURSOR_ARROW = Object.freeze(
493
+ [[0, 0], [0, 16], [4.2, 12.4], [6.9, 18.6], [9.5, 17.5], [6.8, 11.5], [11.7, 11.5]].map(([x, y]) => Object.freeze([x / 19, y / 19])),
494
+ );
495
+
496
+ /** Triangles over CURSOR_ARROW's vertices (indices): a fan from the tip over the head, the tail as two. */
497
+ const ARROW_TRIS = Object.freeze([[0, 1, 2], [0, 2, 5], [0, 5, 6], [2, 3, 4], [2, 4, 5]]);
498
+
499
+ /**
500
+ * CURSOR_ARROW inset by `d` (units of its height) — mitred, so every edge moves in by exactly d.
501
+ * @returns {Array<[number, number]>}
502
+ */
503
+ export function cursorArrowInset(d, poly = CURSOR_ARROW) {
504
+ let area = 0;
505
+ for (let i = 0; i < poly.length; i++) {
506
+ const [x0, y0] = poly[i], [x1, y1] = poly[(i + 1) % poly.length];
507
+ area += x0 * y1 - x1 * y0;
508
+ }
509
+ const sgn = area > 0 ? 1 : -1;
510
+ const n = poly.map((p, i) => {
511
+ const q = poly[(i + 1) % poly.length];
512
+ const dx = q[0] - p[0], dy = q[1] - p[1];
513
+ const l = Math.hypot(dx, dy) || 1;
514
+ return [(-dy / l) * sgn, (dx / l) * sgn]; // inward normal of edge i → i+1
515
+ });
516
+ return poly.map((p, i) => {
517
+ const a = n[(i + poly.length - 1) % poly.length], b = n[i];
518
+ const k = d / (1 + a[0] * b[0] + a[1] * b[1]);
519
+ return [p[0] + (a[0] + b[0]) * k, p[1] + (a[1] + b[1]) * k];
520
+ });
521
+ }
405
522
 
406
- /** Fill and outline stroke widths, in sprite heights; and their colours (RGBA, 0..1). */
407
- const STROKE_FILL = 0.07;
408
- const STROKE_OUTLINE = 0.15;
409
- export const CURSOR_FILL_COLOR = Object.freeze([1, 0.84, 0.1, 1]);
410
- export const CURSOR_OUTLINE_COLOR = Object.freeze([0.04, 0.04, 0.06, 0.85]);
523
+ /** The white fill: the arrow inset by one px of its 19. */
524
+ export const CURSOR_ARROW_FILL = Object.freeze(cursorArrowInset(1 / 19).map((p) => Object.freeze(p)));
525
+
526
+ /** The arrow's colours (RGBA, 0..1): white fill, dark outline — the OS arrow. */
527
+ export const CURSOR_FILL_COLOR = Object.freeze([1, 1, 1, 1]);
528
+ export const CURSOR_OUTLINE_COLOR = Object.freeze([0.02, 0.02, 0.03, 1]);
411
529
 
412
530
  /**
413
- * The crosshair as FILLED strokes in its own XY plane (unit = sprite height): a dark outline
414
- * pass first, the coloured fill over it — the order a depth-test-off draw needs. A 1 px line
415
- * reads too thin through a lenticular panel. Triangles, non-indexed.
531
+ * The arrow as a filled 3D mesh in its own XY plane (unit = sprite height, x right, y UP, the tip
532
+ * = the hotspot at the origin, so cursorModelMatrix puts the tip on the placement): the dark
533
+ * outline polygon first, the white fill over it — the order a depth-test-off draw needs.
534
+ * Triangles, non-indexed. Shared by three, PlayCanvas and (in 2D) the stereo-tile cursor.
416
535
  * @returns {{positions: Float32Array, colors: Float32Array, count: number}}
417
536
  */
418
- export function cursorCrosshairMesh(fill = CURSOR_FILL_COLOR, outline = CURSOR_OUTLINE_COLOR) {
537
+ export function cursorArrowMesh(fill = CURSOR_FILL_COLOR, outline = CURSOR_OUTLINE_COLOR) {
538
+ const pos = [];
539
+ const col = [];
540
+ for (const [poly, c] of [[CURSOR_ARROW, outline], [CURSOR_ARROW_FILL, fill]]) {
541
+ for (const tri of ARROW_TRIS) {
542
+ for (const k of tri) {
543
+ pos.push(poly[k][0], -poly[k][1], 0);
544
+ col.push(c[0], c[1], c[2], c[3]);
545
+ }
546
+ }
547
+ }
548
+ return { positions: new Float32Array(pos), colors: new Float32Array(col), count: pos.length / 3 };
549
+ }
550
+
551
+ // The retired crosshair (1.36–1.40), kept only behind the deprecated cursorCrosshairMesh.
552
+ const CROSSHAIR = [
553
+ [-0.5, 0, -0.15, 0], [0.15, 0, 0.5, 0], [0, -0.5, 0, -0.15], [0, 0.15, 0, 0.5],
554
+ [-0.15, -0.15, 0.15, -0.15], [0.15, -0.15, 0.15, 0.15], [0.15, 0.15, -0.15, 0.15], [-0.15, 0.15, -0.15, -0.15],
555
+ ];
556
+
557
+ /**
558
+ * @deprecated The crosshair is retired (ADR-046 Amendment 2): every depth cursor draws the arrow,
559
+ * cursorArrowMesh(). Kept for pages that built their own sprite from it; removed in 2.0.
560
+ */
561
+ export function cursorCrosshairMesh(fill = [1, 0.84, 0.1, 1], outline = [0.04, 0.04, 0.06, 0.85]) {
419
562
  const pos = [];
420
563
  const col = [];
421
564
  const quad = ([x0, y0, x1, y1], w, ext, c) => {
422
565
  const dx = x1 - x0, dy = y1 - y0;
423
566
  const l = Math.hypot(dx, dy) || 1;
424
- const ux = dx / l, uy = dy / l; // along
425
- const nx = -uy * (w / 2), ny = ux * (w / 2); // across
567
+ const ux = dx / l, uy = dy / l;
568
+ const nx = -uy * (w / 2), ny = ux * (w / 2);
426
569
  const ax = x0 - ux * ext, ay = y0 - uy * ext, bx = x1 + ux * ext, by = y1 + uy * ext;
427
570
  const v = [[ax - nx, ay - ny], [bx - nx, by - ny], [bx + nx, by + ny], [ax - nx, ay - ny], [bx + nx, by + ny], [ax + nx, ay + ny]];
428
571
  for (const [x, y] of v) {
@@ -430,14 +573,14 @@ export function cursorCrosshairMesh(fill = CURSOR_FILL_COLOR, outline = CURSOR_O
430
573
  col.push(c[0], c[1], c[2], c[3]);
431
574
  }
432
575
  };
433
- for (const seg of CURSOR_CROSSHAIR) quad(seg, STROKE_OUTLINE, (STROKE_OUTLINE - STROKE_FILL) / 2, outline);
434
- for (const seg of CURSOR_CROSSHAIR) quad(seg, STROKE_FILL, 0, fill);
576
+ for (const seg of CROSSHAIR) quad(seg, 0.15, 0.04, outline);
577
+ for (const seg of CROSSHAIR) quad(seg, 0.07, 0, fill);
435
578
  return { positions: new Float32Array(pos), colors: new Float32Array(col), count: pos.length / 3 };
436
579
  }
437
580
 
438
581
  /**
439
- * A column-major model matrix placing the unit crosshair for a placement: the sprite's basis
440
- * scaled by its height, at its position.
582
+ * A column-major model matrix placing the unit sprite (the arrow, tip at its origin) for a
583
+ * placement: the sprite's basis scaled by its height, its origin (the hotspot) at its position.
441
584
  */
442
585
  export function cursorModelMatrix(placement, out = new Array(16)) {
443
586
  const { x, y, z } = placement.basis;
@@ -449,23 +592,3 @@ export function cursorModelMatrix(placement, out = new Array(16)) {
449
592
  out[12] = e[0]; out[13] = e[1]; out[14] = e[2]; out[15] = 1;
450
593
  return out;
451
594
  }
452
-
453
- /**
454
- * The crosshair's segment endpoints in the views' space for a placement, as a flat
455
- * [x, y, z, x, y, z, …] array (16 points) — what a line renderer takes.
456
- */
457
- export function cursorCrosshairPoints(placement, out = []) {
458
- out.length = 0;
459
- const { position: p, basis: b, height: h } = placement;
460
- for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) {
461
- for (const [x, y] of [[x0, y0], [x1, y1]]) {
462
- out.push(
463
- p[0] + (b.x[0] * x + b.y[0] * y) * h,
464
- p[1] + (b.x[1] * x + b.y[1] * y) * h,
465
- p[2] + (b.x[2] * x + b.y[2] * y) * h,
466
- );
467
- }
468
- }
469
- return out;
470
- }
471
-
@@ -55,12 +55,13 @@
55
55
  import {
56
56
  CursorDepthPlacer,
57
57
  CursorPointer,
58
- cursorCrosshairMesh,
59
- cursorFootprint,
58
+ cursorArrowMesh,
59
+ cursorArrowFootprint,
60
+ cursorDefaultHeight,
60
61
  cursorModelMatrix,
61
62
  cursorViewRay,
62
- CURSOR_DEFAULT_HEIGHT,
63
63
  CURSOR_DEFAULT_TUNING,
64
+ CURSOR_OUTLINE_COLOR,
64
65
  } from './inline3d-cursor-depth.js';
65
66
 
66
67
  /**
@@ -451,9 +452,13 @@ export function raycastHitTest(THREE, targets) {
451
452
  * A cursor that rises to the depth of the content under it, so it is never drawn behind
452
453
  * something that pops out of the glass (a depth violation). The page supplies the content —
453
454
  * a hit test — and this does the rest: tracks the pointer over the canvas, hit-tests the
454
- * cursor FOOTPRINT (not just the hotspot), places a crosshair sprite with the same maths and
455
- * defaults as the runtime's XR_DXR_cursor_depth (ADR-046), and hides the CSS cursor exactly
456
- * while the sprite replaces it.
455
+ * cursor FOOTPRINT (not just the hotspot), places the arrow sprite with the same maths and
456
+ * policy as the runtime's XR_DXR_cursor_depth (ADR-046 Amendment 2), and hides the CSS cursor
457
+ * exactly while the sprite replaces it.
458
+ *
459
+ * Front-only: the sprite is drawn only while content under it comes OUT of the glass, the margin
460
+ * in front of it. Over content on or behind the glass nothing is drawn — the CSS/OS cursor is the
461
+ * cursor, untouched. The sprite is the classic arrow at the OS cursor's size, tip on the hotspot.
457
462
  *
458
463
  * Opt-in: nothing runs unless you construct one and call update(). In 2D (one view) or with the
459
464
  * pointer off the canvas it is inactive, the sprite is hidden and the CSS cursor is back.
@@ -481,10 +486,11 @@ export class DepthCursor {
481
486
  * @param {(origin:number[], direction:number[]) => number[]|null} opts.hitTest nearest
482
487
  * content point along a ray in WORLD space, or null. {@link raycastHitTest} for meshes;
483
488
  * for splats return the renderer's expected depth along the ray (never raycast splats).
484
- * @param {number} [opts.height=0.03] sprite height as a fraction of the canvas height.
485
- * @param {number} [opts.color=0xffd61a] fill colour (the outline is always dark).
486
- * @param {number} [opts.margin=0.005] how far in front of the content it floats, in eye-baseline
487
- * units (0.005 ≈ 1.5 mm at 60 cm: it rests on the content).
489
+ * @param {number} [opts.height] sprite height as a fraction of the canvas height. Default: the
490
+ * OS arrow's size, 19 CSS px of the canvas's on-page height (re-read every frame).
491
+ * @param {number} [opts.color=0xffffff] the arrow's fill colour (the outline is always dark).
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).
488
494
  * @param {'hybrid'|'screen'|'world'} [opts.anchor='hybrid'] where along that depth: 'hybrid'
489
495
  * follows the pointer exactly while it moves and stays world-fixed (parallaxes with the
490
496
  * content) while it is still; 'screen' never parallaxes; 'world' always does.
@@ -498,12 +504,13 @@ export class DepthCursor {
498
504
  */
499
505
  constructor(
500
506
  THREE,
501
- { canvas, hitTest, height = CURSOR_DEFAULT_HEIGHT, color = 0xffd61a, viewSpace = null, raysPerFrame = 0, margin, anchor = 'hybrid', pointerScope = 'canvas' },
507
+ { canvas, hitTest, height = null, color = 0xffffff, viewSpace = null, raysPerFrame = 0, margin, anchor = 'hybrid', pointerScope = 'canvas' },
502
508
  ) {
503
509
  this._THREE = THREE;
504
510
  this.canvas = canvas;
505
511
  this.hitTest = hitTest;
506
- this.height = height;
512
+ /** Sprite height (canvas heights) when set; null = the OS arrow's size, from the canvas's on-page height. */
513
+ this.height = height > 0 && Number.isFinite(height) ? height : null;
507
514
  this.viewSpace = viewSpace;
508
515
  this.raysPerFrame = raysPerFrame;
509
516
  this._ring = []; // amortised mode: the last hit per footprint point (null = a miss)
@@ -514,9 +521,9 @@ export class DepthCursor {
514
521
  /** The last placement (diagnostics): `{active, position, height, disparity, targetDisparity, anchored}`. */
515
522
  this.placement = { active: false };
516
523
 
517
- // Filled strokes with a dark outline (a 1 px line reads too thin through the lens).
524
+ // The arrow: a dark outline polygon, the fill inset over it; tip at the origin (the hotspot).
518
525
  const fill = [((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255, 1];
519
- const m = cursorCrosshairMesh(fill);
526
+ const m = cursorArrowMesh(fill, CURSOR_OUTLINE_COLOR);
520
527
  const geom = new THREE.BufferGeometry();
521
528
  geom.setAttribute('position', new THREE.Float32BufferAttribute(m.positions, 3));
522
529
  geom.setAttribute('color', new THREE.Float32BufferAttribute(m.colors, 4));
@@ -552,8 +559,9 @@ export class DepthCursor {
552
559
  let p = { active: false };
553
560
  if (uv && vs.length >= 2) {
554
561
  const [u, v] = uv;
555
- const nearestPoint = this._footprintHit(vs, u, v);
556
- p = this.placer.update(vs, { u, v, nearestPoint, cursorHeight: this.height }, nowMs / 1000);
562
+ const h = this.height ?? cursorDefaultHeight(this.pointer.cssHeight());
563
+ const nearestPoint = this._footprintHit(vs, u, v, h);
564
+ p = this.placer.update(vs, { u, v, nearestPoint, cursorHeight: h, canvasPx: this.pointer.panelPx() }, nowMs / 1000);
557
565
  }
558
566
  this.placement = p;
559
567
  this.object.visible = !!p.active;
@@ -575,12 +583,12 @@ export class DepthCursor {
575
583
 
576
584
  // Nearest content point under the footprint, from the two outer views (or, amortised, from
577
585
  // the first eye over the last cycle of footprint points).
578
- _footprintHit(views, u, v) {
586
+ _footprintHit(views, u, v, height) {
579
587
  const a = views[0], b = views[views.length - 1];
580
588
  const m = a.transformMatrix;
581
589
  const fl = Math.hypot(m[8], m[9], m[10]) || 1;
582
590
  const f = [-m[8] / fl, -m[9] / fl, -m[10] / fl];
583
- const fp = cursorFootprint(u, v, this.height, this.pointer.aspect());
591
+ const fp = cursorArrowFootprint(u, v, height, this.pointer.aspect());
584
592
  let candidates;
585
593
  if (this.raysPerFrame > 0) {
586
594
  const ring = this._ring;
package/model.d.ts CHANGED
@@ -50,10 +50,12 @@ export interface ModelOptions {
50
50
  */
51
51
  viewerEase?: ViewerEaseOption;
52
52
  /**
53
- * `'depth'`: a cursor that rises to the subject under it instead of being drawn on the glass,
54
- * behind content that pops out (ADR-046, same maths as native `XR_DXR_cursor_depth`). A
55
- * crosshair just in front of the nearest content under the pointer's footprint; the CSS cursor
56
- * is hidden while it shows and comes back in 2D and off the canvas. OPT-IN: without it nothing
53
+ * `'depth'`: a cursor that is never drawn on the glass behind content that pops out (ADR-046 +
54
+ * Amendment 2, the same policy as native `XR_DXR_cursor_depth`). While the subject under the
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.015 eye baselines, ~1 mm) in front of it and the CSS cursor
57
+ * is hidden; over anything on or behind the glass nothing is drawn and the CSS cursor is the
58
+ * cursor, untouched. Never behind the glass. OPT-IN: without it nothing
57
59
  * is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
58
60
  * one low-res pass every other frame while the pointer is over the tile). three: a raycast
59
61
  * against the subject. Pass an object to tune it: `{ margin, anchor, height, pointerScope }`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.40.2",
3
+ "version": "1.42.0",
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
@@ -474,10 +474,12 @@ export interface SplatOptions {
474
474
  */
475
475
  viewerEase?: ViewerEaseOption;
476
476
  /**
477
- * `'depth'`: a cursor that rises to the subject under it instead of being drawn on the glass,
478
- * behind content that pops out (ADR-046, same maths as native `XR_DXR_cursor_depth`). A
479
- * crosshair just in front of the nearest content under the pointer's footprint; the CSS cursor
480
- * is hidden while it shows and comes back in 2D and off the canvas. OPT-IN: without it nothing
477
+ * `'depth'`: a cursor that is never drawn on the glass behind content that pops out (ADR-046 +
478
+ * Amendment 2, the same policy as native `XR_DXR_cursor_depth`). While the subject under the
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.015 eye baselines, ~1 mm) in front of it and the CSS cursor
481
+ * is hidden; over anything on or behind the glass nothing is drawn and the CSS cursor is the
482
+ * cursor, untouched. Never behind the glass. OPT-IN: without it nothing
481
483
  * is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
482
484
  * one low-res pass every other frame while the pointer is over the tile). three: a raycast
483
485
  * against the subject. Pass an object to tune it: `{ margin, anchor, height, pointerScope }`
package/three.d.ts CHANGED
@@ -157,12 +157,15 @@ export function raycastHitTest(
157
157
  /**
158
158
  * A cursor that rises to the depth of the content under it, so it is never drawn behind something
159
159
  * that pops out of the glass. Same maths and defaults as the runtime's XR_DXR_cursor_depth
160
- * (ADR-046). Tracks the pointer over `canvas`, hit-tests the cursor FOOTPRINT, places a crosshair
161
- * (`object`, add it to your scene) and hides the CSS cursor exactly while the crosshair replaces it.
160
+ * (ADR-046, Amendment 2's policy). Tracks the pointer over `canvas`, hit-tests the cursor FOOTPRINT,
161
+ * and — only while content under it comes OUT of the glass — draws the classic arrow (`object`, add
162
+ * it to your scene; tip on the hotspot, the OS cursor's size) the margin in front of it, hiding the
163
+ * CSS cursor exactly then. Over content on or behind the glass nothing is drawn: the CSS cursor is
164
+ * the cursor, untouched. Never behind the glass.
162
165
  * Opt-in: nothing runs unless you construct one and call `update(views)` each frame.
163
166
  *
164
- * Head motion leaves the crosshair's image on the glass where it is (it stays under the mouse like
165
- * the OS cursor); only its depth follows the content under that line of sight.
167
+ * Head motion: while the pointer moves the arrow sits on the line of sight through it; while it is
168
+ * still, it is held world-fixed (default `anchor: 'hybrid'`), so it parallaxes like content.
166
169
  */
167
170
  export class DepthCursor {
168
171
  constructor(
@@ -172,11 +175,11 @@ export class DepthCursor {
172
175
  canvas: HTMLElement;
173
176
  /** Nearest content point along a WORLD ray, or null. For splats return the expected depth along the ray. */
174
177
  hitTest: (origin: number[], direction: number[]) => number[] | null;
175
- /** Sprite height as a fraction of the canvas height (default 0.03). */
178
+ /** Sprite height as a fraction of the canvas height (default: the OS arrow's 19 CSS px of the canvas, re-read each frame). */
176
179
  height?: number;
177
- /** Fill colour of the crosshair (default 0xffd61a; the outline is always dark). */
180
+ /** Fill colour of the arrow (default 0xffffff; the outline is always dark). */
178
181
  color?: number;
179
- /** How far in front of the content it floats, in eye-baseline units (default 0.005 ≈ 1.5 mm at 60 cm). */
182
+ /** How far in front of out-of-screen content it floats, in eye-baseline units (default 0.03 ≈ 2 mm at a 65 mm IPD). */
180
183
  margin?: number;
181
184
  /** Default `'hybrid'`: exact while the pointer moves, world-fixed (parallaxing) while it is still. */
182
185
  anchor?: CursorAnchorMode;
@@ -188,7 +191,7 @@ export class DepthCursor {
188
191
  viewSpace?: unknown;
189
192
  },
190
193
  );
191
- /** The crosshair (a `THREE.Mesh` of filled, outlined strokes; depth test off, drawn last). Add it to the scene root. */
194
+ /** The arrow (a `THREE.Mesh`: dark outline, white fill, tip at its origin; depth test off, drawn last). Add it to the scene root. */
192
195
  readonly object: unknown;
193
196
  /** The last placement, for diagnostics. */
194
197
  readonly placement: CursorPlacement | { active: false };