@displayxr/inline3d 1.43.1 → 1.45.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.
@@ -33,12 +33,14 @@
33
33
  // on a head-tracked display reads as "at the glass";
34
34
  // 'world' — C = S - f (1 - t) eyeToCanvas, straight in front of the pointer's canvas point:
35
35
  // parallaxes like content, drifts ~mm off the pointer from an off-axis viewer;
36
- // 'hybrid' (default) — 'screen' while the pointer moves (exact aim), and world-FIXED while it
37
- // is still (correct parallax): the line of sight at the last pointer move (E0 → S0)
38
- // is kept, and the sprite sits on it at the current depth — the point whose
36
+ // 'hybrid' (default) — 'screen' while the pointer moves (exact aim), and DISPLAY-fixed while
37
+ // it is still (correct parallax): the line of sight at the last pointer move
38
+ // (E0 → S0) is kept, IN THE DISPLAY'S FRAME (relative to the canvas, in canvas
39
+ // heights), and the sprite sits on it at the current depth — the point whose
39
40
  // distance in front of the glass is (1 - t) eyeToCanvas. A head move leaves it
40
41
  // put; a depth change slides it along that line, so it stays on the click point
41
- // for the viewer who placed it. Continuous at the stop frame.
42
+ // for the viewer who placed it; a rig move (orbit, pan, zoom) carries it with the
43
+ // canvas, so it stays on the pointer. Continuous at the stop frame.
42
44
  // The height is scaled by t either way, so the apparent size is constant.
43
45
  //
44
46
  // THE POLICY (ADR-046 Amendment 2, the same for every DisplayXR depth cursor — this module, ./three's
@@ -51,11 +53,61 @@
51
53
  // CURSOR_DRAW_THRESHOLD (-0.005, ~0.3 mm) or under CURSOR_DRAW_MIN_PX on the panel
52
54
  // (cursorShouldDraw), and the CSS cursor is left alone;
53
55
  // - 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).
56
+ // (CURSOR_ARROW_CSS_PX tall on the page, cursorDefaultHeight) — or, mirroring the page's CSS
57
+ // cursor: every CSS keyword and url() images (./inline3d-cursor-shape.js);
58
+ // - crosstalk softening (Amendment 2 §B.7): a LIFTED arrow is blurred by k × its ghost offset and
59
+ // faded toward alphaMin with its lift, both exactly zero on the glass (cursorSoften and below).
55
60
 
56
61
  import { CURSOR_ANCHOR_MODES } from './inline3d-cursor-option.js';
57
62
 
58
- export { CURSOR_ANCHOR_MODES, CURSOR_OPTION_KEYS, resolveCursorOption } from './inline3d-cursor-option.js';
63
+ export {
64
+ CURSOR_ANCHOR_MODES,
65
+ CURSOR_OPTION_KEYS,
66
+ CURSOR_SOFTEN_DEFAULTS,
67
+ CURSOR_SOFTEN_KEYS,
68
+ CURSOR_SOFTEN_PRESETS,
69
+ parseCursorSoften,
70
+ resolveCursorOption,
71
+ resolveCursorSoften,
72
+ } from './inline3d-cursor-option.js';
73
+ import { CURSOR_SOFTEN_DEFAULTS } from './inline3d-cursor-option.js';
74
+ import { CURSOR_HIDDEN_ATTR, ensureCursorHiddenRule } from './inline3d-cursor-shape.js';
75
+
76
+ // Which sprite: the page's CSS cursor mirrored (shapes, read-back) — its own module, re-exported.
77
+ export {
78
+ CURSOR_CSS_KEYWORDS,
79
+ CURSOR_CSS_READ_MS,
80
+ CURSOR_HIDDEN_ATTR,
81
+ CURSOR_HIDDEN_RULE,
82
+ CURSOR_IMAGE_MAX_PX,
83
+ CURSOR_SHAPES,
84
+ CURSOR_SHAPE_OPTIONS,
85
+ CURSOR_SHAPE_TEXTURE_PX,
86
+ CursorCssReader,
87
+ CursorImageCache,
88
+ CursorShapeSelector,
89
+ cssCursorToShape,
90
+ cssKeywordToShape,
91
+ curFileHotspot,
92
+ cursorImageCache,
93
+ cursorPickShape,
94
+ cursorShape,
95
+ cursorShapeBox,
96
+ cursorShapePolys,
97
+ cursorShapeRaster,
98
+ cursorShapeSample,
99
+ cursorShapeTexture,
100
+ cursorSoftenPixels,
101
+ cursorSpriteBox,
102
+ cursorSpriteGLSL,
103
+ cursorSpriteRect,
104
+ drawCursorShape2D,
105
+ ensureCursorHiddenRule,
106
+ parseCssCursor,
107
+ readPageCursor,
108
+ resolveCursorSprite,
109
+ spriteForName,
110
+ } from './inline3d-cursor-shape.js';
59
111
 
60
112
  /**
61
113
  * Fallback sprite height, as a fraction of the canvas height, when the canvas's on-page size is
@@ -183,7 +235,9 @@ export function solveCursorGeometry(viewA, viewB, u, v) {
183
235
  const canvasHeight = dot(sub(canvasPoint, p1), forward) * (2 / viewA.projectionMatrix[5]);
184
236
  if (!(eyeToCanvas > 0) || !(canvasHeight > 0) || !Number.isFinite(canvasHeight)) return null;
185
237
 
186
- return { eye, canvasPoint, forward, eyeToCanvas, canvasHeight, basis: orthonormalBasis(ma) };
238
+ // Canvas aspect (width / height) from the same frustum: (2 / a) / (2 / b).
239
+ const aspect = viewA.projectionMatrix[5] / viewA.projectionMatrix[0];
240
+ return { eye, canvasPoint, forward, eyeToCanvas, canvasHeight, aspect, basis: orthonormalBasis(ma) };
187
241
  }
188
242
 
189
243
  /** The view's rotation as unit X/Y/Z columns (a transform with parent scale still works). */
@@ -249,6 +303,8 @@ export function placeCursor(g, disparity, heightFraction) {
249
303
 
250
304
  /** A pointer moving less than this (canvas-normalised) between frames counts as still. */
251
305
  const POINTER_STILL_EPS = 1e-4;
306
+ /** The runtime's U_CURSOR_DEPTH_ANCHOR_PLANE_EPS: a canvas whose shape moves more than this re-anchors. */
307
+ const CURSOR_ANCHOR_PLANE_EPS = 1e-3;
252
308
 
253
309
  /**
254
310
  * The whole pipeline with its state: one per cursor.
@@ -313,15 +369,42 @@ export class CursorDepthPlacer {
313
369
  position = addScaled(g.canvasPoint, fw, out);
314
370
  } else if (this.anchor === 'hybrid') {
315
371
  const last = this._last;
316
- const moved =
372
+ let moved =
317
373
  reprimed || !this._line || !last || Math.abs(hint.u - last[0]) > POINTER_STILL_EPS || Math.abs(hint.v - last[1]) > POINTER_STILL_EPS;
374
+ // The kept line lives in the DISPLAY's frame, never the scene's: origin S (the canvas point
375
+ // under the pointer — the same canvas point while the pointer is still), axes the canvas's
376
+ // (g.basis), unit the canvas height. A rig that orbits, pans or zooms the scene (./model's
377
+ // idle spin, damped drag, a scripted camera) moves the views and the canvas together, so in
378
+ // this frame nothing changed and the sprite stays on the pointer; a head move changes the
379
+ // eye's position relative to the canvas, so it still parallaxes like content. (The runtime's
380
+ // u_cursor_depth gets this for free: it works in display space.)
381
+ let k = 0;
382
+ let L = this._line;
383
+ if (!moved) {
384
+ // #1951's rule: the line means something only on the canvas it was taken against. In the
385
+ // display frame a rig move is not a change; a canvas whose SHAPE changed (resize) is.
386
+ moved = !(Math.abs(g.aspect / L.aspect - 1) <= CURSOR_ANCHOR_PLANE_EPS);
387
+ }
388
+ if (!moved) {
389
+ // On the kept line E0 → S0, the point (1 - t)·eyeToCanvas in front of the glass, all in
390
+ // canvas heights: s = 1 - lift / D0, C = S0 + (1 - s)(E0 - S0) = S0 + k E0.
391
+ const lift = ((1 - t) * g.eyeToCanvas) / g.canvasHeight;
392
+ k = lift / L.D;
393
+ // s <= 0 (the head moved back past E0): never place it at or behind the anchor eye.
394
+ if (!(k < 1) || !Number.isFinite(k)) moved = true;
395
+ }
318
396
  if (moved) {
319
- this._line = { E: g.eye, S: g.canvasPoint, D: g.eyeToCanvas };
397
+ const e = sub(g.eye, g.canvasPoint);
398
+ const h = g.canvasHeight;
399
+ const B = g.basis;
400
+ L = this._line = { E: [dot(e, B.x) / h, dot(e, B.y) / h, dot(e, B.z) / h], D: g.eyeToCanvas / h, aspect: g.aspect };
320
401
  } else {
321
- // On the kept line E0 → S0, the point (1 - t)·eyeToCanvas in front of the glass.
322
- const L = this._line;
323
- const k = 1 - ((1 - t) * g.eyeToCanvas) / L.D;
324
- position = addScaled(L.E, sub(L.S, L.E), k);
402
+ const B = g.basis;
403
+ const h = g.canvasHeight * k;
404
+ position = g.canvasPoint;
405
+ position = addScaled(position, B.x, L.E[0] * h);
406
+ position = addScaled(position, B.y, L.E[1] * h);
407
+ position = addScaled(position, B.z, L.E[2] * h);
325
408
  anchored = true;
326
409
  }
327
410
  }
@@ -348,33 +431,42 @@ function asView(v) {
348
431
  export class CursorPointer {
349
432
  /**
350
433
  * @param {HTMLElement} canvas
351
- * @param {{scope?: 'canvas'|'window'}} [opts] 'canvas' (default): the pointer counts only while
434
+ * @param {{scope?: 'canvas'|'window', keepCss?: boolean}} [opts] 'canvas' (default): the pointer counts only while
352
435
  * it is over the canvas element itself. 'window': while it is inside the canvas's BOX,
353
436
  * even over DOM layered on top of it (overlay buttons, labels) — and the CSS cursor is then
354
437
  * hidden page-wide while the sprite shows, since the overlay has its own cursor.
438
+ * `keepCss`: never hide the CSS cursor (`cursor: { debug: 'both' }`, the latency check
439
+ * that films the sprite next to the OS cursor).
355
440
  */
356
- constructor(canvas, { scope = 'canvas' } = {}) {
441
+ constructor(canvas, { scope = 'canvas', keepCss = false } = {}) {
357
442
  if (scope !== 'canvas' && scope !== 'window') {
358
443
  throw new Error(`@displayxr/inline3d: cursor pointerScope "${scope}" — expected 'canvas' or 'window'.`);
359
444
  }
360
445
  this.canvas = canvas;
361
446
  this.scope = scope;
362
- /** `[u, v]` while the pointer is over the canvas, else null. */
363
- this.uv = null;
447
+ this.keepCss = !!keepCss;
448
+ this._uv = null;
449
+ /** Time (event timeStamp, ms) of the newest pointer sample; 0 before the first. */
450
+ this.sampleTime = 0;
451
+ /** Pointer samples received so far (diagnostics). */
452
+ this.samples = 0;
364
453
  this._hidden = false;
365
- this._prev = '';
366
- this._onMove = (e) => {
367
- const r = canvas.getBoundingClientRect();
368
- if (!(r.width > 0 && r.height > 0)) return void (this.uv = null);
369
- const u = (e.clientX - r.left) / r.width;
370
- const v = (e.clientY - r.top) / r.height;
371
- this.uv = u >= 0 && u <= 1 && v >= 0 && v <= 1 ? [u, v] : null;
372
- };
454
+ this._cx = 0;
455
+ this._cy = 0;
456
+ this._pending = false; // a sample not yet resolved to uv (resolved once, on the next read)
457
+ this._onMove = (e) => this._sample(e);
373
458
  this._onLeave = () => {
374
459
  this.uv = null;
375
460
  };
376
461
  this._target = scope === 'window' ? globalThis : canvas;
462
+ // Freshest pointer: `pointerrawupdate` (Chromium, secure contexts) fires as each input arrives,
463
+ // not once per frame, so the frame reads the newest position instead of one up to a frame old.
464
+ // `pointermove` stays as the fallback (and for synthetic events) — a sample older than one
465
+ // already held is ignored, so the two never fight.
466
+ /** Which event feeds the position: 'pointerrawupdate' where supported, else 'pointermove'. */
467
+ this.source = supportsRawUpdate(this._target) ? 'pointerrawupdate' : 'pointermove';
377
468
  this._target.addEventListener('pointermove', this._onMove);
469
+ if (this.source === 'pointerrawupdate') this._target.addEventListener('pointerrawupdate', this._onMove);
378
470
  if (scope === 'window') {
379
471
  // Leaving the window (relatedTarget null) or losing focus mid-hover: nothing to hover.
380
472
  this._onOut = (e) => {
@@ -387,6 +479,41 @@ export class CursorPointer {
387
479
  }
388
480
  }
389
481
 
482
+ /** `[u, v]` while the pointer is over the canvas, else null. */
483
+ get uv() {
484
+ if (this._pending) {
485
+ this._pending = false;
486
+ const r = this.canvas.getBoundingClientRect();
487
+ if (!(r.width > 0 && r.height > 0)) {
488
+ this._uv = null;
489
+ } else {
490
+ const u = (this._cx - r.left) / r.width;
491
+ const v = (this._cy - r.top) / r.height;
492
+ this._uv = u >= 0 && u <= 1 && v >= 0 && v <= 1 ? [u, v] : null;
493
+ }
494
+ }
495
+ return this._uv;
496
+ }
497
+
498
+ set uv(x) {
499
+ this._pending = false;
500
+ this._uv = x;
501
+ }
502
+
503
+ // One pointer event: keep its NEWEST coalesced sample (getCoalescedEvents(), last entry), unless
504
+ // a newer one is already held. No layout read here — uv resolves on the next read, once a frame.
505
+ _sample(e) {
506
+ const list = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : null;
507
+ const s = list && list.length ? list[list.length - 1] : e;
508
+ const t = s.timeStamp > 0 ? s.timeStamp : e.timeStamp > 0 ? e.timeStamp : globalThis.performance ? globalThis.performance.now() : Date.now();
509
+ if (t < this.sampleTime) return;
510
+ this.samples++;
511
+ this.sampleTime = t;
512
+ this._cx = s.clientX;
513
+ this._cy = s.clientY;
514
+ this._pending = true;
515
+ }
516
+
390
517
  /** Set the pointer directly (canvas-normalised, v down), or null — for scripted input. */
391
518
  set(u, v) {
392
519
  this.uv = u === null || u === undefined ? null : [u, v];
@@ -409,28 +536,40 @@ export class CursorPointer {
409
536
  return this.cssHeight() * (globalThis.devicePixelRatio > 0 ? globalThis.devicePixelRatio : 1);
410
537
  }
411
538
 
539
+ /** The canvas's `[width, height]` in panel (device) pixels, `[0, 0]` when unknown. */
540
+ panelSize() {
541
+ const r = this.canvas.getBoundingClientRect();
542
+ const k = globalThis.devicePixelRatio > 0 ? globalThis.devicePixelRatio : 1;
543
+ return r.width > 0 && r.height > 0 ? [r.width * k, r.height * k] : [0, 0];
544
+ }
545
+
412
546
  /**
413
547
  * 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.)
548
+ * it. The hide is a data attribute (CURSOR_HIDDEN_ATTR) on the canvas — on <html> for
549
+ * pointerScope 'window' — plus one injected `cursor: none !important` rule for it: the page's
550
+ * own cursor (`pointer`, `grab`, a `url(…)` image, inline or from a stylesheet) is never
551
+ * written, so it is what shows the rest of the time, and what the sprite mirrors (CursorCssReader
552
+ * lifts the attribute for its read).
418
553
  */
419
554
  hideCss(hide) {
555
+ if (this.keepCss) hide = false;
420
556
  if (hide === this._hidden) return;
421
557
  if (this.scope === 'window') {
422
558
  pageCursorHidden(hide);
423
- } else if (hide) {
424
- this._prev = this.canvas.style.cursor || '';
425
- this.canvas.style.cursor = 'none';
426
- } else if (this.canvas.style.cursor === 'none') {
427
- this.canvas.style.cursor = this._prev; // only if it is still ours
559
+ } else if (this.canvas.setAttribute) {
560
+ if (hide) {
561
+ ensureCursorHiddenRule();
562
+ this.canvas.setAttribute(CURSOR_HIDDEN_ATTR, '');
563
+ } else {
564
+ this.canvas.removeAttribute(CURSOR_HIDDEN_ATTR);
565
+ }
428
566
  }
429
567
  this._hidden = hide;
430
568
  }
431
569
 
432
570
  dispose() {
433
571
  this._target.removeEventListener('pointermove', this._onMove);
572
+ if (this.source === 'pointerrawupdate') this._target.removeEventListener('pointerrawupdate', this._onMove);
434
573
  if (this.scope === 'window') {
435
574
  globalThis.removeEventListener('pointerout', this._onOut);
436
575
  globalThis.removeEventListener('blur', this._onLeave);
@@ -441,21 +580,23 @@ export class CursorPointer {
441
580
  }
442
581
  }
443
582
 
583
+ /** Does `target` dispatch `pointerrawupdate`? (Chromium; only in a secure context.) */
584
+ export function supportsRawUpdate(target) {
585
+ return !!target && 'onpointerrawupdate' in target && globalThis.isSecureContext !== false;
586
+ }
587
+
444
588
  // pointerScope 'window': the overlay DOM has its own cursor, so hiding it on the canvas alone is
445
- // not enough. A class on <html> plus one injected rule hides it everywhere while any depth cursor
446
- // shows; a count keeps two tiles from un-hiding each other.
589
+ // not enough. The attribute on <html> (the injected rule covers its descendants) hides it
590
+ // everywhere while any depth cursor shows; a count keeps two tiles from un-hiding each other.
447
591
  let _pageHides = 0;
448
592
  function pageCursorHidden(hide) {
449
593
  const doc = globalThis.document;
450
594
  if (!doc || !doc.documentElement) return;
451
- if (hide && !doc.getElementById('inline3d-cursor-style')) {
452
- const st = doc.createElement('style');
453
- st.id = 'inline3d-cursor-style';
454
- st.textContent = 'html.inline3d-cursor-hidden, html.inline3d-cursor-hidden * { cursor: none !important; }';
455
- (doc.head || doc.documentElement).appendChild(st);
456
- }
595
+ if (hide) ensureCursorHiddenRule(doc);
457
596
  _pageHides = Math.max(0, _pageHides + (hide ? 1 : -1));
458
- doc.documentElement.classList.toggle('inline3d-cursor-hidden', _pageHides > 0);
597
+ const el = doc.documentElement;
598
+ if (_pageHides > 0) el.setAttribute?.(CURSOR_HIDDEN_ATTR, '');
599
+ else el.removeAttribute?.(CURSOR_HIDDEN_ATTR);
459
600
  }
460
601
 
461
602
  /**
@@ -592,3 +733,316 @@ export function cursorModelMatrix(placement, out = new Array(16)) {
592
733
  out[12] = e[0]; out[13] = e[1]; out[14] = e[2]; out[15] = 1;
593
734
  return out;
594
735
  }
736
+
737
+
738
+ // ── Crosstalk softening of the lifted cursor (runtime ADR-046 Amendment 2 §B.7) ─────────────
739
+ //
740
+ // A port of the runtime's u_cursor_draw_soften_sized / u_cursor_draw_ghost_px and its 13-tap
741
+ // kernel (comp_metal_compositor.m `cursor_fragment_soft`, the same on every backend). The blur
742
+ // and the fade live in the CURSOR'S OWN material: a quad whose fragment shader evaluates the arrow
743
+ // analytically at the kernel's 13 taps (cursorSoftArrowGLSL), or, on a 2D pair, the same function
744
+ // rasterised in JS (cursorSoftArrowRaster). Never a CSS filter or CSS opacity. A frame whose
745
+ // softening is (0 px, 1.0) draws the crisp arrow mesh exactly as before.
746
+
747
+ /**
748
+ * One frame's softening (u_cursor_draw_soften_sized): the blur radius in panel px and the opacity,
749
+ * for a cursor drawn at filtered disparity `d` (eye baselines) whose neighbouring views sit
750
+ * `ghostPx` panel px apart. Blur = blurK × |ghost|, capped at blurMaxPx and — when `spritePx`, the
751
+ * drawn arrow's height in panel px, is known — at blurMaxFrac × spritePx. Opacity eases 1 → alphaMin
752
+ * by a smoothstep over |d| up to fullD. Both are (0, 1) on the glass; non-finite input = none.
753
+ * @param {number} d
754
+ * @param {number} ghostPx
755
+ * @param {number} [spritePx]
756
+ * @param {object} [params=CURSOR_SOFTEN_DEFAULTS] resolveCursorSoften's result.
757
+ * @returns {{blurPx: number, alpha: number}}
758
+ */
759
+ export function cursorSoften(d, ghostPx, spritePx, params = CURSOR_SOFTEN_DEFAULTS) {
760
+ const s = { blurPx: 0, alpha: 1 };
761
+ const p = params;
762
+ if (!p) return s;
763
+ if (p.blur && Number.isFinite(ghostPx) && p.blurK > 0 && p.blurMaxPx > 0) {
764
+ let cap = p.blurMaxPx;
765
+ if (p.blurMaxFrac > 0 && Number.isFinite(spritePx) && spritePx > 0) {
766
+ const rel = p.blurMaxFrac * spritePx; // proportional to the drawn cursor
767
+ cap = rel < cap ? rel : cap;
768
+ }
769
+ const b = p.blurK * Math.abs(ghostPx);
770
+ s.blurPx = b < cap ? b : cap;
771
+ }
772
+ if (p.alpha && Number.isFinite(d) && p.fullD > 0) {
773
+ let x = Math.abs(d) / p.fullD;
774
+ x = x < 1 ? x : 1;
775
+ const ease = x * x * (3 - 2 * x); // smoothstep(0, fullD, |d|)
776
+ const amin = p.alphaMin < 0 ? 0 : p.alphaMin > 1 ? 1 : p.alphaMin;
777
+ s.alpha = 1 - (1 - amin) * ease;
778
+ }
779
+ return s;
780
+ }
781
+
782
+ /**
783
+ * The ghost offset (u_cursor_draw_ghost_px): the largest distance, in panel px, between the
784
+ * hotspot's positions in two NEIGHBOURING views (i, i + 1) — what crosstalk leaks.
785
+ * @param {ArrayLike<ArrayLike<number>|null>} origins the hotspot per view, canvas-normalised [u, v]
786
+ * @param {number} pxW panel px that 0..1 of u spans (the canvas width)
787
+ * @param {number} pxH panel px that 0..1 of v spans
788
+ */
789
+ export function cursorGhostPx(origins, pxW, pxH) {
790
+ if (!origins || !(pxW > 0) || !(pxH > 0)) return 0;
791
+ let ghost = 0;
792
+ for (let i = 0; i + 1 < origins.length; i++) {
793
+ const a = origins[i], b = origins[i + 1];
794
+ if (!a || !b) continue;
795
+ const g = Math.hypot((b[0] - a[0]) * pxW, (b[1] - a[1]) * pxH);
796
+ if (Number.isFinite(g) && g > ghost) ghost = g;
797
+ }
798
+ return ghost;
799
+ }
800
+
801
+ /**
802
+ * Where a world point lands on the canvas in one view: canvas-normalised [u, v] (v down), or null
803
+ * when it is not in front of the view. The view's transform may carry a parent scale.
804
+ */
805
+ export function cursorProjectToCanvas(view, point) {
806
+ const v = asView(view);
807
+ const m = v.transformMatrix, p = v.projectionMatrix;
808
+ // Inverse of the transform's 3×3 (cofactors) applied to (point − origin).
809
+ const a = m[0], b = m[4], c = m[8], d = m[1], e = m[5], f = m[9], g = m[2], h = m[6], k = m[10];
810
+ const A = e * k - f * h, B = -(d * k - f * g), C = d * h - e * g;
811
+ const det = a * A + b * B + c * C;
812
+ if (!(Math.abs(det) > 1e-30)) return null;
813
+ const x = point[0] - m[12], y = point[1] - m[13], z = point[2] - m[14];
814
+ const lx = (A * x + (c * h - b * k) * y + (b * f - c * e) * z) / det;
815
+ const ly = (B * x + (a * k - c * g) * y + (c * d - a * f) * z) / det;
816
+ const lz = (C * x + (b * g - a * h) * y + (a * e - b * d) * z) / det;
817
+ const cx = p[0] * lx + p[4] * ly + p[8] * lz + p[12];
818
+ const cy = p[1] * lx + p[5] * ly + p[9] * lz + p[13];
819
+ const cw = p[3] * lx + p[7] * ly + p[11] * lz + p[15];
820
+ if (!(cw > 0)) return null;
821
+ const out = [(cx / cw + 1) / 2, (1 - cy / cw) / 2];
822
+ return Number.isFinite(out[0]) && Number.isFinite(out[1]) ? out : null;
823
+ }
824
+
825
+ /**
826
+ * The softening for a placement (CursorDepthPlacer.update's result) drawn with `views` on a canvas
827
+ * `panelW` × `panelH` panel px, the arrow `heightFraction` of the canvas tall. The ghost offset is
828
+ * measured from where the placement's hotspot lands in each view — right for every rig and view
829
+ * count. Inactive placements are never softened.
830
+ * @returns {{blurPx: number, alpha: number, ghostPx: number, spritePx: number, radius: number, aa: number, soft: boolean}}
831
+ * `radius` / `aa`: the blur radius and one panel px in ARROW units (its height = 1), for the
832
+ * material; `soft`: false = draw the crisp arrow (bit-identical to the unsoftened draw).
833
+ */
834
+ export function cursorPlacementSoften(placement, views, panelW, panelH, heightFraction, params = CURSOR_SOFTEN_DEFAULTS) {
835
+ const none = { blurPx: 0, alpha: 1, ghostPx: 0, spritePx: 0, radius: 0, aa: 0, soft: false };
836
+ if (!placement || !placement.active || !placement.position || !views) return none;
837
+ const origins = Array.from(views, (v) => cursorProjectToCanvas(v, placement.position));
838
+ const ghostPx = cursorGhostPx(origins, panelW, panelH);
839
+ const spritePx = heightFraction > 0 && panelH > 0 ? heightFraction * panelH : 0;
840
+ const s = cursorSoften(placement.disparity, ghostPx, spritePx, params);
841
+ const soft = s.blurPx > 0 || s.alpha < 1;
842
+ return {
843
+ blurPx: s.blurPx,
844
+ alpha: s.alpha,
845
+ ghostPx,
846
+ spritePx,
847
+ radius: spritePx > 0 ? s.blurPx / spritePx : 0,
848
+ aa: spritePx > 0 ? 1 / spritePx : 0,
849
+ soft,
850
+ };
851
+ }
852
+
853
+ /**
854
+ * The runtime's blur kernel, in units of the radius: `[dx, dy, weight]` — the centre, 4 taps at
855
+ * r/2 and 8 at r (a Gaussian, sigma ≈ 0.6 r). Taps outside the arrow read as transparent.
856
+ */
857
+ export const CURSOR_SOFTEN_KERNEL = Object.freeze([
858
+ Object.freeze([0, 0, 1]),
859
+ ...[[0.5, 0], [-0.5, 0], [0, 0.5], [0, -0.5]].map(([x, y]) => Object.freeze([x, y, 0.7066])),
860
+ ...Array.from({ length: 8 }, (_, k) => {
861
+ const a = 0.3927 + 0.7854 * k;
862
+ return Object.freeze([Math.cos(a), Math.sin(a), 0.2494]);
863
+ }),
864
+ ]);
865
+
866
+ /** The arrow's extent in its own units (height = 1, x right, y down from the tip). */
867
+ export const CURSOR_ARROW_SIZE = Object.freeze([
868
+ Math.max(...CURSOR_ARROW.map((p) => p[0])),
869
+ Math.max(...CURSOR_ARROW.map((p) => p[1])),
870
+ ]);
871
+
872
+ /**
873
+ * The softened arrow's quad in arrow units (y down, the tip = the hotspot at the origin): the
874
+ * arrow's box padded by the blur radius (plus one AA width) on every side, so the soft edge is not
875
+ * clipped. The hotspot does not move — the box only grows around it.
876
+ * @returns {[number, number, number, number]} [x0, y0, x1, y1]
877
+ */
878
+ export function cursorSoftArrowBox(radius, aa = 0) {
879
+ const pad = (radius > 0 && Number.isFinite(radius) ? radius : 0) + (aa > 0 && Number.isFinite(aa) ? aa : 0);
880
+ return [-pad, -pad, CURSOR_ARROW_SIZE[0] + pad, CURSOR_ARROW_SIZE[1] + pad];
881
+ }
882
+
883
+ /** Signed distance to a polygon (negative inside), any winding — the crossing-number form. */
884
+ export function cursorPolygonSd(poly, x, y) {
885
+ let d = (x - poly[0][0]) ** 2 + (y - poly[0][1]) ** 2;
886
+ let s = 1;
887
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i++) {
888
+ const vi = poly[i], vj = poly[j];
889
+ const ex = vj[0] - vi[0], ey = vj[1] - vi[1];
890
+ const wx = x - vi[0], wy = y - vi[1];
891
+ const t = Math.min(1, Math.max(0, (wx * ex + wy * ey) / (ex * ex + ey * ey)));
892
+ const bx = wx - ex * t, by = wy - ey * t;
893
+ d = Math.min(d, bx * bx + by * by);
894
+ const c1 = y >= vi[1], c2 = y < vj[1], c3 = ex * wy > ey * wx;
895
+ if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) s = -s;
896
+ }
897
+ return s * Math.sqrt(d);
898
+ }
899
+
900
+ const coverage = (sd, aa) => (aa > 0 ? Math.min(1, Math.max(0, 0.5 - sd / aa)) : sd < 0 ? 1 : 0);
901
+
902
+ /**
903
+ * The softened arrow at point (x, y) of its own units — the reference the shader mirrors: the
904
+ * kernel's 13 taps of the crisp arrow (dark outline, the fill over it, each edge anti-aliased over
905
+ * `aa`), accumulated PREMULTIPLIED, so clear taps add no colour. `radius` 0 = the centre tap only.
906
+ * @returns {[number, number, number, number]} straight RGB and alpha (before the opacity)
907
+ */
908
+ export function cursorSoftArrowSample(x, y, radius, aa, fill = CURSOR_FILL_COLOR, outline = CURSOR_OUTLINE_COLOR, polys = [CURSOR_ARROW, CURSOR_ARROW_FILL]) {
909
+ let r = 0, g = 0, b = 0, a = 0, ws = 0;
910
+ const taps = radius > 0 ? CURSOR_SOFTEN_KERNEL : CURSOR_SOFTEN_KERNEL.slice(0, 1);
911
+ for (const [dx, dy, w] of taps) {
912
+ const px = x + dx * radius, py = y + dy * radius;
913
+ const co = coverage(cursorPolygonSd(polys[0], px, py), aa);
914
+ const cf = co > 0 ? coverage(cursorPolygonSd(polys[1], px, py), aa) : 0;
915
+ r += w * (outline[0] * co + (fill[0] - outline[0]) * cf);
916
+ g += w * (outline[1] * co + (fill[1] - outline[1]) * cf);
917
+ b += w * (outline[2] * co + (fill[2] - outline[2]) * cf);
918
+ a += w * co;
919
+ ws += w;
920
+ }
921
+ a /= ws;
922
+ if (!(a > 1e-5)) return [0, 0, 0, 0];
923
+ return [r / ws / a, g / ws / a, b / ws / a, a];
924
+ }
925
+
926
+ const glf = (v) => {
927
+ const s = (+v).toPrecision(9);
928
+ return /[.eE]/.test(s) ? s : `${s}.0`;
929
+ };
930
+
931
+ /**
932
+ * GLSL (ES 1.00 and 3.00 alike: no arrays, no loops) for the softened arrow, generated from the
933
+ * same polygons and kernel as cursorSoftArrowSample:
934
+ *
935
+ * vec4 inline3dSoftArrow(vec2 p, float radius, float aa, vec3 fill, vec3 outline)
936
+ *
937
+ * `p` in arrow units (height 1, y down, tip at the origin). Returns straight RGB and alpha; the
938
+ * caller multiplies the alpha by the opacity.
939
+ */
940
+ export function cursorSoftArrowGLSL(polys = [CURSOR_ARROW, CURSOR_ARROW_FILL]) {
941
+ const v2 = (q) => `vec2(${glf(q[0])}, ${glf(q[1])})`;
942
+ const sdFn = (name, poly) => {
943
+ const lines = [`float ${name}(vec2 p) {`, ` float d = dot(p - ${v2(poly[0])}, p - ${v2(poly[0])});`, ' float s = 1.0;'];
944
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i++) lines.push(` i3dCursorEdge(p, ${v2(poly[i])}, ${v2(poly[j])}, d, s);`);
945
+ lines.push(' return s * sqrt(d);', '}');
946
+ return lines.join('\n');
947
+ };
948
+ const taps = CURSOR_SOFTEN_KERNEL.slice(1)
949
+ .map(([dx, dy, w]) => ` acc += ${glf(w)} * i3dCursorTap(p + vec2(${glf(dx)}, ${glf(dy)}) * radius, aa, fill, outline);`)
950
+ .join('\n');
951
+ const wsum = CURSOR_SOFTEN_KERNEL.slice(1).reduce((t, k) => t + k[2], 0);
952
+ return `
953
+ void i3dCursorEdge(vec2 p, vec2 vi, vec2 vj, inout float d, inout float s) {
954
+ vec2 e = vj - vi;
955
+ vec2 w = p - vi;
956
+ vec2 b = w - e * clamp(dot(w, e) / dot(e, e), 0.0, 1.0);
957
+ d = min(d, dot(b, b));
958
+ bool c1 = p.y >= vi.y;
959
+ bool c2 = p.y < vj.y;
960
+ bool c3 = e.x * w.y > e.y * w.x;
961
+ if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) s = -s;
962
+ }
963
+ ${sdFn('i3dCursorSdOutline', polys[0])}
964
+ ${sdFn('i3dCursorSdFill', polys[1])}
965
+ float i3dCursorCov(float sd, float aa) {
966
+ return aa > 0.0 ? clamp(0.5 - sd / aa, 0.0, 1.0) : (sd < 0.0 ? 1.0 : 0.0);
967
+ }
968
+ vec4 i3dCursorTap(vec2 p, float aa, vec3 fill, vec3 outline) {
969
+ float co = i3dCursorCov(i3dCursorSdOutline(p), aa);
970
+ float cf = i3dCursorCov(i3dCursorSdFill(p), aa);
971
+ return vec4(outline * co + (fill - outline) * cf, co);
972
+ }
973
+ vec4 inline3dSoftArrow(vec2 p, float radius, float aa, vec3 fill, vec3 outline) {
974
+ vec4 acc = i3dCursorTap(p, aa, fill, outline);
975
+ float wsum = 1.0;
976
+ if (radius > 0.0) {
977
+ ${taps}
978
+ wsum += ${glf(wsum)};
979
+ }
980
+ acc /= wsum;
981
+ return vec4(acc.a > 1e-5 ? acc.rgb / acc.a : vec3(0.0), acc.a);
982
+ }
983
+ `;
984
+ }
985
+
986
+ /**
987
+ * The softened arrow rasterised for a 2D canvas (the stereo-pair cursor): pixel (i, j) of the
988
+ * result covers canvas px (x0 + i, y0 + j), sampled at its centre, with the arrow `sizePx` tall and
989
+ * its tip at canvas point (cx, cy). Straight-alpha RGBA bytes (ImageData layout), the opacity
990
+ * applied. One canvas px is the AA width.
991
+ * @returns {{x0: number, y0: number, width: number, height: number, data: Uint8ClampedArray}}
992
+ */
993
+ export function cursorSoftArrowRaster(cx, cy, sizePx, radius, alpha, fill = CURSOR_FILL_COLOR, outline = CURSOR_OUTLINE_COLOR, polys = [CURSOR_ARROW, CURSOR_ARROW_FILL]) {
994
+ const size = sizePx > 0 && Number.isFinite(sizePx) ? sizePx : 1;
995
+ const aa = 1 / size;
996
+ const [bx0, by0, bx1, by1] = cursorSoftArrowBox(radius, aa);
997
+ const x0 = Math.floor(cx + bx0 * size), y0 = Math.floor(cy + by0 * size);
998
+ const width = Math.max(1, Math.ceil(cx + bx1 * size) - x0), height = Math.max(1, Math.ceil(cy + by1 * size) - y0);
999
+ const data = new Uint8ClampedArray(width * height * 4);
1000
+ const op = alpha >= 0 && alpha <= 1 ? alpha : 1;
1001
+ for (let j = 0; j < height; j++) {
1002
+ const y = (y0 + j + 0.5 - cy) / size;
1003
+ for (let i = 0; i < width; i++) {
1004
+ const c = cursorSoftArrowSample((x0 + i + 0.5 - cx) / size, y, radius, aa, fill, outline, polys);
1005
+ if (!(c[3] > 0)) continue;
1006
+ const o = (j * width + i) * 4;
1007
+ data[o] = Math.round(c[0] * 255);
1008
+ data[o + 1] = Math.round(c[1] * 255);
1009
+ data[o + 2] = Math.round(c[2] * 255);
1010
+ data[o + 3] = Math.round(c[3] * op * 255);
1011
+ }
1012
+ }
1013
+ return { x0, y0, width, height, data };
1014
+ }
1015
+
1016
+ /**
1017
+ * PlayCanvas `pc.ShaderMaterial` opt-in to the depth cursor's pick (`cursor: 'depth'` on ./model
1018
+ * and ./splat). The pick pass draws into TWO colour targets — the pick id, then the pick depth — and
1019
+ * leaves out any ShaderMaterial whose fragment code does not write the second under
1020
+ * DEPTH_PICK_PASS (WebGL would reject the draw), so such a mesh is never under the cursor. Paste
1021
+ * `declarations` above `main` and `output` inside `#ifdef PICK_PASS … #else <your colour> #endif`:
1022
+ *
1023
+ * fragmentGLSL: `${CURSOR_DEPTH_PICK_GLSL.declarations}
1024
+ * void main() {
1025
+ * #ifdef PICK_PASS
1026
+ * ${CURSOR_DEPTH_PICK_GLSL.output}
1027
+ * #else
1028
+ * gl_FragColor = vec4(0.2, 0.3, 0.4, 1.0);
1029
+ * #endif
1030
+ * }`
1031
+ *
1032
+ * The engine's own `pickPS` chunk does the work (getPickOutput / getPickDepth), so the id and the
1033
+ * linear depth match its lit and gsplat materials exactly. Discard where your shader discards.
1034
+ */
1035
+ export const CURSOR_DEPTH_PICK_GLSL = Object.freeze({
1036
+ declarations: '#ifdef PICK_PASS\n#include "pickPS"\n#endif\n',
1037
+ output: 'pcFragColor0 = getPickOutput();\n#ifdef DEPTH_PICK_PASS\npcFragColor1 = getPickDepth();\n#endif\n',
1038
+ });
1039
+
1040
+ /**
1041
+ * The WGSL twin of CURSOR_DEPTH_PICK_GLSL (a ShaderMaterial with `fragmentWGSL`, WebGPU): `output`
1042
+ * writes `output.color` / `output.color1` of the engine's `FragmentOutput`, inside
1043
+ * `#ifdef PICK_PASS … #else … #endif` in `fragmentMain`.
1044
+ */
1045
+ export const CURSOR_DEPTH_PICK_WGSL = Object.freeze({
1046
+ declarations: '#ifdef PICK_PASS\n#include "pickPS"\n#endif\n',
1047
+ output: 'output.color = getPickOutput();\n#ifdef DEPTH_PICK_PASS\noutput.color1 = getPickDepth();\n#endif\n',
1048
+ });