@displayxr/inline3d 1.40.1 → 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.
@@ -40,28 +40,73 @@
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.03 eye baselines (~2 mm of crossed on-screen disparity at a 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.03 eye baselines, ~2 mm of
103
+ * crossed on-screen disparity at a 65 mm IPD (ADR-046 Amendment 2; the reference app's value).
60
104
  */
61
- margin: 0.005,
105
+ margin: 0.03,
62
106
  /** Comfort clamp: t >= 0.625, never more than ~3/8 of the way to the eye. */
63
107
  minDisparity: -0.6,
64
- maxDisparity: 0.6,
108
+ /** Never behind the glass: 0, and cursorTarget never goes above 0 whatever this says. */
109
+ maxDisparity: 0,
65
110
  /** Rising toward the viewer is fast — never lag behind content that comes forward. */
66
111
  riseTau: 0.03,
67
112
  /** Sinking away is slow — no flicker as the footprint crosses an edge. */
@@ -161,10 +206,15 @@ export function cursorPointDisparity(g, point) {
161
206
  return 1 - 1 / t;
162
207
  }
163
208
 
164
- /** Target disparity: content minus the margin, clamped; 0 (the canvas) with nothing under it. */
209
+ /**
210
+ * Target disparity — the policy: `d < 0 ? max(d - margin, dMin) : 0`. Content in front of the glass
211
+ * (d < 0) puts the cursor the margin in front of it (clamped); content on or behind the glass, or
212
+ * nothing under it, leaves it ON the glass. Never behind it: the result is never above 0.
213
+ */
165
214
  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));
215
+ if (!hasContent || !Number.isFinite(contentDisparity) || !(contentDisparity < 0)) return 0;
216
+ const hi = Math.min(0, Number.isFinite(tuning.maxDisparity) ? tuning.maxDisparity : 0);
217
+ return Math.min(hi, Math.max(tuning.minDisparity, contentDisparity - tuning.margin));
168
218
  }
169
219
 
170
220
  /**
@@ -204,12 +254,17 @@ const POINTER_STILL_EPS = 1e-4;
204
254
  *
205
255
  * const placer = new CursorDepthPlacer();
206
256
  * // 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);
257
+ * const p = placer.update(views, { u, v, nearestPoint, cursorHeight, canvasPx }, now / 1000);
258
+ * if (p.active) drawSprite(p.position, p.basis, p.height); // else: the OS cursor, untouched
209
259
  *
210
260
  * `views` are `{projectionMatrix, transformMatrix}` (or XRViews — `transform.matrix` is read
211
261
  * 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".
262
+ * FOOTPRINT nearest the viewer, in the views' space, or null for "nothing under it". `canvasPx`
263
+ * (optional) is the canvas's height in panel pixels, for the draw threshold's pixel floor.
264
+ *
265
+ * `active` is the DRAW decision (cursorShouldDraw on the filtered disparity): false on the plane,
266
+ * where the OS cursor is the cursor. The filter and the anchor keep running either way, and
267
+ * `solved` says the geometry was (position etc. are then filled for diagnostics).
213
268
  */
214
269
  export class CursorDepthPlacer {
215
270
  /**
@@ -228,7 +283,7 @@ export class CursorDepthPlacer {
228
283
  }
229
284
 
230
285
  update(views, hint, nowSec) {
231
- const inactive = { active: false, position: null, basis: null, height: 0, disparity: 0, targetDisparity: 0 };
286
+ const inactive = { active: false, solved: false, position: null, basis: null, height: 0, disparity: 0, targetDisparity: 0 };
232
287
  if (!views || views.length < 2 || !hint) {
233
288
  this._line = null;
234
289
  this._last = null;
@@ -270,7 +325,13 @@ export class CursorDepthPlacer {
270
325
  }
271
326
  }
272
327
  this._last = [hint.u, hint.v];
273
- return { active: true, position, basis: g.basis, height, disparity, targetDisparity: target, anchored, geometry: g };
328
+ // Panel px of on-screen disparity per baseline unit: the outer views' baseline over the canvas
329
+ // height, in the views' space, times the canvas height in panel px.
330
+ const ra = a.transformMatrix, rb = b.transformMatrix;
331
+ const baseline = Math.hypot(ra[12] - rb[12], ra[13] - rb[13], ra[14] - rb[14]);
332
+ const pxPerBaseline = hint.canvasPx > 0 ? (baseline / g.canvasHeight) * hint.canvasPx : null;
333
+ const active = cursorShouldDraw(disparity, pxPerBaseline);
334
+ return { active, solved: true, position, basis: g.basis, height, disparity, targetDisparity: target, anchored, geometry: g, pxPerBaseline };
274
335
  }
275
336
  }
276
337
 
@@ -336,7 +397,24 @@ export class CursorPointer {
336
397
  return r.height > 0 ? r.width / r.height : 1;
337
398
  }
338
399
 
339
- /** Hide (true) or restore (false) the CSS cursor; idempotent. */
400
+ /** The canvas's on-page height, CSS px (0 when unknown). */
401
+ cssHeight() {
402
+ const r = this.canvas.getBoundingClientRect();
403
+ return r.height > 0 ? r.height : 0;
404
+ }
405
+
406
+ /** The canvas's height in panel (device) pixels, or 0 when unknown. */
407
+ panelPx() {
408
+ return this.cssHeight() * (globalThis.devicePixelRatio > 0 ? globalThis.devicePixelRatio : 1);
409
+ }
410
+
411
+ /**
412
+ * Hide (true) or restore (false) the CSS cursor; idempotent. Only ever while a sprite replaces
413
+ * it: whatever cursor the page set (`pointer`, `grab`, a `url(…)` image) is the cursor the rest of
414
+ * the time, and a cursor the page changes while it is hidden is not overwritten on restore.
415
+ * (Mirroring the page's CSS cursor INTO the drawn sprite is future work — ADR-046 Amendment 2 C,
416
+ * the browser feeding the computed CSS cursor to the runtime; today the sprite is the arrow.)
417
+ */
340
418
  hideCss(hide) {
341
419
  if (hide === this._hidden) return;
342
420
  if (this.scope === 'window') {
@@ -344,8 +422,8 @@ export class CursorPointer {
344
422
  } else if (hide) {
345
423
  this._prev = this.canvas.style.cursor || '';
346
424
  this.canvas.style.cursor = 'none';
347
- } else {
348
- this.canvas.style.cursor = this._prev;
425
+ } else if (this.canvas.style.cursor === 'none') {
426
+ this.canvas.style.cursor = this._prev; // only if it is still ours
349
427
  }
350
428
  this._hidden = hide;
351
429
  }
@@ -380,9 +458,9 @@ function pageCursorHidden(hide) {
380
458
  }
381
459
 
382
460
  /**
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.
461
+ * A footprint ring: the point (u, v) plus 8 points on a circle of radius 0.75 × the sprite
462
+ * height (canvas heights) around it.
463
+ * @returns {Array<[number, number]>} canvas-normalised points, (u, v) first.
386
464
  */
387
465
  export function cursorFootprint(u, v, height, aspect) {
388
466
  const r = 0.75 * height;
@@ -395,34 +473,98 @@ export function cursorFootprint(u, v, height, aspect) {
395
473
  }
396
474
 
397
475
  /**
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.
476
+ * The footprint every backend samples for the ARROW: the hotspot (its tip) first, then a ring
477
+ * around the arrow's BODY, which hangs down-right of the tip — that is what content must not
478
+ * overlap. Canvas-normalised, v down.
479
+ * @returns {Array<[number, number]>}
400
480
  */
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
- ]);
481
+ export function cursorArrowFootprint(u, v, height, aspect) {
482
+ return [[u, v], ...cursorFootprint(u + (0.3 * height) / aspect, v + 0.5 * height, height, aspect)];
483
+ }
484
+
485
+ /**
486
+ * The classic pointer arrow every backend draws (ADR-046 Amendment 2: the depth cursor is the
487
+ * cursor the user already has; where its shape cannot be read, this). The OS arrow's outline,
488
+ * 12 × 19 px normalised: unit = its height, x right, y DOWN, the hotspot = the TIP at (0, 0). This
489
+ * is the DARK outline's polygon; the white fill is it inset by one px (CURSOR_ARROW_FILL).
490
+ */
491
+ export const CURSOR_ARROW = Object.freeze(
492
+ [[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])),
493
+ );
494
+
495
+ /** Triangles over CURSOR_ARROW's vertices (indices): a fan from the tip over the head, the tail as two. */
496
+ const ARROW_TRIS = Object.freeze([[0, 1, 2], [0, 2, 5], [0, 5, 6], [2, 3, 4], [2, 4, 5]]);
497
+
498
+ /**
499
+ * CURSOR_ARROW inset by `d` (units of its height) — mitred, so every edge moves in by exactly d.
500
+ * @returns {Array<[number, number]>}
501
+ */
502
+ export function cursorArrowInset(d, poly = CURSOR_ARROW) {
503
+ let area = 0;
504
+ for (let i = 0; i < poly.length; i++) {
505
+ const [x0, y0] = poly[i], [x1, y1] = poly[(i + 1) % poly.length];
506
+ area += x0 * y1 - x1 * y0;
507
+ }
508
+ const sgn = area > 0 ? 1 : -1;
509
+ const n = poly.map((p, i) => {
510
+ const q = poly[(i + 1) % poly.length];
511
+ const dx = q[0] - p[0], dy = q[1] - p[1];
512
+ const l = Math.hypot(dx, dy) || 1;
513
+ return [(-dy / l) * sgn, (dx / l) * sgn]; // inward normal of edge i → i+1
514
+ });
515
+ return poly.map((p, i) => {
516
+ const a = n[(i + poly.length - 1) % poly.length], b = n[i];
517
+ const k = d / (1 + a[0] * b[0] + a[1] * b[1]);
518
+ return [p[0] + (a[0] + b[0]) * k, p[1] + (a[1] + b[1]) * k];
519
+ });
520
+ }
405
521
 
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]);
522
+ /** The white fill: the arrow inset by one px of its 19. */
523
+ export const CURSOR_ARROW_FILL = Object.freeze(cursorArrowInset(1 / 19).map((p) => Object.freeze(p)));
524
+
525
+ /** The arrow's colours (RGBA, 0..1): white fill, dark outline — the OS arrow. */
526
+ export const CURSOR_FILL_COLOR = Object.freeze([1, 1, 1, 1]);
527
+ export const CURSOR_OUTLINE_COLOR = Object.freeze([0.02, 0.02, 0.03, 1]);
411
528
 
412
529
  /**
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.
530
+ * The arrow as a filled 3D mesh in its own XY plane (unit = sprite height, x right, y UP, the tip
531
+ * = the hotspot at the origin, so cursorModelMatrix puts the tip on the placement): the dark
532
+ * outline polygon first, the white fill over it — the order a depth-test-off draw needs.
533
+ * Triangles, non-indexed. Shared by three, PlayCanvas and (in 2D) the stereo-tile cursor.
416
534
  * @returns {{positions: Float32Array, colors: Float32Array, count: number}}
417
535
  */
418
- export function cursorCrosshairMesh(fill = CURSOR_FILL_COLOR, outline = CURSOR_OUTLINE_COLOR) {
536
+ export function cursorArrowMesh(fill = CURSOR_FILL_COLOR, outline = CURSOR_OUTLINE_COLOR) {
537
+ const pos = [];
538
+ const col = [];
539
+ for (const [poly, c] of [[CURSOR_ARROW, outline], [CURSOR_ARROW_FILL, fill]]) {
540
+ for (const tri of ARROW_TRIS) {
541
+ for (const k of tri) {
542
+ pos.push(poly[k][0], -poly[k][1], 0);
543
+ col.push(c[0], c[1], c[2], c[3]);
544
+ }
545
+ }
546
+ }
547
+ return { positions: new Float32Array(pos), colors: new Float32Array(col), count: pos.length / 3 };
548
+ }
549
+
550
+ // The retired crosshair (1.36–1.40), kept only behind the deprecated cursorCrosshairMesh.
551
+ const CROSSHAIR = [
552
+ [-0.5, 0, -0.15, 0], [0.15, 0, 0.5, 0], [0, -0.5, 0, -0.15], [0, 0.15, 0, 0.5],
553
+ [-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],
554
+ ];
555
+
556
+ /**
557
+ * @deprecated The crosshair is retired (ADR-046 Amendment 2): every depth cursor draws the arrow,
558
+ * cursorArrowMesh(). Kept for pages that built their own sprite from it; removed in 2.0.
559
+ */
560
+ export function cursorCrosshairMesh(fill = [1, 0.84, 0.1, 1], outline = [0.04, 0.04, 0.06, 0.85]) {
419
561
  const pos = [];
420
562
  const col = [];
421
563
  const quad = ([x0, y0, x1, y1], w, ext, c) => {
422
564
  const dx = x1 - x0, dy = y1 - y0;
423
565
  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
566
+ const ux = dx / l, uy = dy / l;
567
+ const nx = -uy * (w / 2), ny = ux * (w / 2);
426
568
  const ax = x0 - ux * ext, ay = y0 - uy * ext, bx = x1 + ux * ext, by = y1 + uy * ext;
427
569
  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
570
  for (const [x, y] of v) {
@@ -430,14 +572,14 @@ export function cursorCrosshairMesh(fill = CURSOR_FILL_COLOR, outline = CURSOR_O
430
572
  col.push(c[0], c[1], c[2], c[3]);
431
573
  }
432
574
  };
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);
575
+ for (const seg of CROSSHAIR) quad(seg, 0.15, 0.04, outline);
576
+ for (const seg of CROSSHAIR) quad(seg, 0.07, 0, fill);
435
577
  return { positions: new Float32Array(pos), colors: new Float32Array(col), count: pos.length / 3 };
436
578
  }
437
579
 
438
580
  /**
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.
581
+ * A column-major model matrix placing the unit sprite (the arrow, tip at its origin) for a
582
+ * placement: the sprite's basis scaled by its height, its origin (the hotspot) at its position.
441
583
  */
442
584
  export function cursorModelMatrix(placement, out = new Array(16)) {
443
585
  const { x, y, z } = placement.basis;
@@ -449,23 +591,3 @@ export function cursorModelMatrix(placement, out = new Array(16)) {
449
591
  out[12] = e[0]; out[13] = e[1]; out[14] = e[2]; out[15] = 1;
450
592
  return out;
451
593
  }
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.03] how far in front of out-of-screen content it floats, in
493
+ * eye-baseline units (0.03 ≈ 2 mm of on-screen disparity at a 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.03 eye baselines, ~2 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.1",
3
+ "version": "1.41.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.03 eye baselines, ~2 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 };