@displayxr/inline3d 1.44.0 → 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,7 +53,8 @@
53
53
  // CURSOR_DRAW_THRESHOLD (-0.005, ~0.3 mm) or under CURSOR_DRAW_MIN_PX on the panel
54
54
  // (cursorShouldDraw), and the CSS cursor is left alone;
55
55
  // - the look: the classic arrow (CURSOR_ARROW, tip = hotspot), at the OS cursor's size
56
- // (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);
57
58
  // - crosstalk softening (Amendment 2 §B.7): a LIFTED arrow is blurred by k × its ghost offset and
58
59
  // faded toward alphaMin with its lift, both exactly zero on the glass (cursorSoften and below).
59
60
 
@@ -70,6 +71,43 @@ export {
70
71
  resolveCursorSoften,
71
72
  } from './inline3d-cursor-option.js';
72
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';
73
111
 
74
112
  /**
75
113
  * Fallback sprite height, as a fraction of the canvas height, when the canvas's on-page size is
@@ -413,7 +451,6 @@ export class CursorPointer {
413
451
  /** Pointer samples received so far (diagnostics). */
414
452
  this.samples = 0;
415
453
  this._hidden = false;
416
- this._prev = '';
417
454
  this._cx = 0;
418
455
  this._cy = 0;
419
456
  this._pending = false; // a sample not yet resolved to uv (resolved once, on the next read)
@@ -421,6 +458,11 @@ export class CursorPointer {
421
458
  this._onLeave = () => {
422
459
  this.uv = null;
423
460
  };
461
+ // A touch (or a pen in contact) shows no cursor in the browser, so it must never drive the
462
+ // sprite: clear it on contact too, so a tap without a move cannot leave the last mouse hover up.
463
+ this._onDown = (e) => {
464
+ if (!cursorPointerHovers(e)) this.uv = null;
465
+ };
424
466
  this._target = scope === 'window' ? globalThis : canvas;
425
467
  // Freshest pointer: `pointerrawupdate` (Chromium, secure contexts) fires as each input arrives,
426
468
  // not once per frame, so the frame reads the newest position instead of one up to a frame old.
@@ -430,6 +472,7 @@ export class CursorPointer {
430
472
  this.source = supportsRawUpdate(this._target) ? 'pointerrawupdate' : 'pointermove';
431
473
  this._target.addEventListener('pointermove', this._onMove);
432
474
  if (this.source === 'pointerrawupdate') this._target.addEventListener('pointerrawupdate', this._onMove);
475
+ this._target.addEventListener('pointerdown', this._onDown);
433
476
  if (scope === 'window') {
434
477
  // Leaving the window (relatedTarget null) or losing focus mid-hover: nothing to hover.
435
478
  this._onOut = (e) => {
@@ -466,6 +509,12 @@ export class CursorPointer {
466
509
  // One pointer event: keep its NEWEST coalesced sample (getCoalescedEvents(), last entry), unless
467
510
  // a newer one is already held. No layout read here — uv resolves on the next read, once a frame.
468
511
  _sample(e) {
512
+ // Only a pointer the browser draws a cursor for: a mouse, or a pen hovering. A touch drag (or a
513
+ // pen stroke) clears the sprite — the browser shows no cursor there, so neither do we.
514
+ if (!cursorPointerHovers(e)) {
515
+ this.uv = null;
516
+ return;
517
+ }
469
518
  const list = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : null;
470
519
  const s = list && list.length ? list[list.length - 1] : e;
471
520
  const t = s.timeStamp > 0 ? s.timeStamp : e.timeStamp > 0 ? e.timeStamp : globalThis.performance ? globalThis.performance.now() : Date.now();
@@ -508,21 +557,24 @@ export class CursorPointer {
508
557
 
509
558
  /**
510
559
  * Hide (true) or restore (false) the CSS cursor; idempotent. Only ever while a sprite replaces
511
- * it: whatever cursor the page set (`pointer`, `grab`, a `url(…)` image) is the cursor the rest of
512
- * the time, and a cursor the page changes while it is hidden is not overwritten on restore.
513
- * (Mirroring the page's CSS cursor INTO the drawn sprite is future work — ADR-046 Amendment 2 C,
514
- * the browser feeding the computed CSS cursor to the runtime; today the sprite is the arrow.)
560
+ * it. The hide is a data attribute (CURSOR_HIDDEN_ATTR) on the canvas — on <html> for
561
+ * pointerScope 'window' — plus one injected `cursor: none !important` rule for it: the page's
562
+ * own cursor (`pointer`, `grab`, a `url(…)` image, inline or from a stylesheet) is never
563
+ * written, so it is what shows the rest of the time, and what the sprite mirrors (CursorCssReader
564
+ * lifts the attribute for its read).
515
565
  */
516
566
  hideCss(hide) {
517
567
  if (this.keepCss) hide = false;
518
568
  if (hide === this._hidden) return;
519
569
  if (this.scope === 'window') {
520
570
  pageCursorHidden(hide);
521
- } else if (hide) {
522
- this._prev = this.canvas.style.cursor || '';
523
- this.canvas.style.cursor = 'none';
524
- } else if (this.canvas.style.cursor === 'none') {
525
- this.canvas.style.cursor = this._prev; // only if it is still ours
571
+ } else if (this.canvas.setAttribute) {
572
+ if (hide) {
573
+ ensureCursorHiddenRule();
574
+ this.canvas.setAttribute(CURSOR_HIDDEN_ATTR, '');
575
+ } else {
576
+ this.canvas.removeAttribute(CURSOR_HIDDEN_ATTR);
577
+ }
526
578
  }
527
579
  this._hidden = hide;
528
580
  }
@@ -530,6 +582,7 @@ export class CursorPointer {
530
582
  dispose() {
531
583
  this._target.removeEventListener('pointermove', this._onMove);
532
584
  if (this.source === 'pointerrawupdate') this._target.removeEventListener('pointerrawupdate', this._onMove);
585
+ this._target.removeEventListener('pointerdown', this._onDown);
533
586
  if (this.scope === 'window') {
534
587
  globalThis.removeEventListener('pointerout', this._onOut);
535
588
  globalThis.removeEventListener('blur', this._onLeave);
@@ -540,26 +593,37 @@ export class CursorPointer {
540
593
  }
541
594
  }
542
595
 
596
+ /**
597
+ * Does this pointer event come from a pointer the browser shows a cursor for? A mouse, or a pen
598
+ * hovering in range (no buttons pressed). A touch never; a pen in contact neither. An event with
599
+ * no `pointerType` (a synthetic `new PointerEvent('pointermove')`, an old engine) counts as a mouse.
600
+ * Coalesced samples belong to their parent event, so the parent is what is tested.
601
+ * @param {{pointerType?: string, buttons?: number}} e
602
+ */
603
+ export function cursorPointerHovers(e) {
604
+ const t = e && e.pointerType;
605
+ if (!t || t === 'mouse') return true;
606
+ if (t === 'pen') return !(e.buttons > 0);
607
+ return false;
608
+ }
609
+
543
610
  /** Does `target` dispatch `pointerrawupdate`? (Chromium; only in a secure context.) */
544
611
  export function supportsRawUpdate(target) {
545
612
  return !!target && 'onpointerrawupdate' in target && globalThis.isSecureContext !== false;
546
613
  }
547
614
 
548
615
  // pointerScope 'window': the overlay DOM has its own cursor, so hiding it on the canvas alone is
549
- // not enough. A class on <html> plus one injected rule hides it everywhere while any depth cursor
550
- // shows; a count keeps two tiles from un-hiding each other.
616
+ // not enough. The attribute on <html> (the injected rule covers its descendants) hides it
617
+ // everywhere while any depth cursor shows; a count keeps two tiles from un-hiding each other.
551
618
  let _pageHides = 0;
552
619
  function pageCursorHidden(hide) {
553
620
  const doc = globalThis.document;
554
621
  if (!doc || !doc.documentElement) return;
555
- if (hide && !doc.getElementById('inline3d-cursor-style')) {
556
- const st = doc.createElement('style');
557
- st.id = 'inline3d-cursor-style';
558
- st.textContent = 'html.inline3d-cursor-hidden, html.inline3d-cursor-hidden * { cursor: none !important; }';
559
- (doc.head || doc.documentElement).appendChild(st);
560
- }
622
+ if (hide) ensureCursorHiddenRule(doc);
561
623
  _pageHides = Math.max(0, _pageHides + (hide ? 1 : -1));
562
- doc.documentElement.classList.toggle('inline3d-cursor-hidden', _pageHides > 0);
624
+ const el = doc.documentElement;
625
+ if (_pageHides > 0) el.setAttribute?.(CURSOR_HIDDEN_ATTR, '');
626
+ else el.removeAttribute?.(CURSOR_HIDDEN_ATTR);
563
627
  }
564
628
 
565
629
  /**
@@ -975,3 +1039,37 @@ export function cursorSoftArrowRaster(cx, cy, sizePx, radius, alpha, fill = CURS
975
1039
  }
976
1040
  return { x0, y0, width, height, data };
977
1041
  }
1042
+
1043
+ /**
1044
+ * PlayCanvas `pc.ShaderMaterial` opt-in to the depth cursor's pick (`cursor: 'depth'` on ./model
1045
+ * and ./splat). The pick pass draws into TWO colour targets — the pick id, then the pick depth — and
1046
+ * leaves out any ShaderMaterial whose fragment code does not write the second under
1047
+ * DEPTH_PICK_PASS (WebGL would reject the draw), so such a mesh is never under the cursor. Paste
1048
+ * `declarations` above `main` and `output` inside `#ifdef PICK_PASS … #else <your colour> #endif`:
1049
+ *
1050
+ * fragmentGLSL: `${CURSOR_DEPTH_PICK_GLSL.declarations}
1051
+ * void main() {
1052
+ * #ifdef PICK_PASS
1053
+ * ${CURSOR_DEPTH_PICK_GLSL.output}
1054
+ * #else
1055
+ * gl_FragColor = vec4(0.2, 0.3, 0.4, 1.0);
1056
+ * #endif
1057
+ * }`
1058
+ *
1059
+ * The engine's own `pickPS` chunk does the work (getPickOutput / getPickDepth), so the id and the
1060
+ * linear depth match its lit and gsplat materials exactly. Discard where your shader discards.
1061
+ */
1062
+ export const CURSOR_DEPTH_PICK_GLSL = Object.freeze({
1063
+ declarations: '#ifdef PICK_PASS\n#include "pickPS"\n#endif\n',
1064
+ output: 'pcFragColor0 = getPickOutput();\n#ifdef DEPTH_PICK_PASS\npcFragColor1 = getPickDepth();\n#endif\n',
1065
+ });
1066
+
1067
+ /**
1068
+ * The WGSL twin of CURSOR_DEPTH_PICK_GLSL (a ShaderMaterial with `fragmentWGSL`, WebGPU): `output`
1069
+ * writes `output.color` / `output.color1` of the engine's `FragmentOutput`, inside
1070
+ * `#ifdef PICK_PASS … #else … #endif` in `fragmentMain`.
1071
+ */
1072
+ export const CURSOR_DEPTH_PICK_WGSL = Object.freeze({
1073
+ declarations: '#ifdef PICK_PASS\n#include "pickPS"\n#endif\n',
1074
+ output: 'output.color = getPickOutput();\n#ifdef DEPTH_PICK_PASS\noutput.color1 = getPickDepth();\n#endif\n',
1075
+ });
@@ -8,7 +8,20 @@
8
8
  export const CURSOR_ANCHOR_MODES = Object.freeze(['hybrid', 'screen', 'world']);
9
9
 
10
10
  /** The keys `cursor: { … }` accepts on ./model and ./splat. */
11
- export const CURSOR_OPTION_KEYS = Object.freeze(['height', 'margin', 'anchor', 'pointerScope', 'debug', 'soften']);
11
+ export const CURSOR_OPTION_KEYS = Object.freeze(['height', 'margin', 'anchor', 'pointerScope', 'debug', 'soften', 'shape', 'shapeFor']);
12
+
13
+ /**
14
+ * The procedural sprites the depth cursor can draw (see ./inline3d-cursor-shape.js): one per
15
+ * standard CSS cursor keyword (aliases such as `n-resize` → `ns-resize` share one).
16
+ */
17
+ export const CURSOR_SHAPES = Object.freeze([
18
+ 'arrow', 'pointer', 'grab', 'grabbing', 'move', 'text', 'vertical-text', 'crosshair', 'not-allowed',
19
+ 'help', 'wait', 'progress', 'copy', 'alias', 'context-menu', 'cell', 'ns-resize', 'ew-resize',
20
+ 'nesw-resize', 'nwse-resize', 'col-resize', 'row-resize', 'zoom-in', 'zoom-out',
21
+ ]);
22
+
23
+ /** `cursor.shape` values: a sprite, `'none'` (draw nothing), or `'auto'` (default) — mirror the page's CSS cursor. */
24
+ export const CURSOR_SHAPE_OPTIONS = Object.freeze(['auto', 'none', ...CURSOR_SHAPES]);
12
25
 
13
26
  // ── Crosstalk softening (runtime ADR-046 Amendment 2 §B.7) ─────────────────────────────────────
14
27
  // On a lenticular panel a little of every view leaks into its neighbours. A LIFTED cursor's leaked
@@ -124,13 +137,13 @@ export function parseCursorSoften(mode, blurK, blurMaxPx, blurMaxFrac, alphaMin,
124
137
  * depth). Anything falsy is OFF — the zero-cost default. Validated here, at the call.
125
138
  * `debug`: true logs the cursor's per-frame cost (rolling stats, at most once a second); 'both' also
126
139
  * keeps the OS cursor visible beside the sprite (the latency check). Off by default, and free.
127
- * @returns {null | {height?: number, margin?: number, anchor?: string, pointerScope?: string, debug?: boolean|'both', soften?: string | object}}
140
+ * @returns {null | {height?: number, margin?: number, anchor?: string, pointerScope?: string, debug?: boolean|'both', soften?: string | object, shape?: string, shapeFor?: Function}}
128
141
  */
129
142
  export function resolveCursorOption(opt, who = '@displayxr/inline3d') {
130
143
  if (opt === undefined || opt === null || opt === false) return null;
131
144
  if (opt === 'depth' || opt === true) return {};
132
145
  if (typeof opt !== 'object') {
133
- throw new Error(`${who}: cursor ${JSON.stringify(opt)} — expected 'depth' or { height, margin, anchor, pointerScope, debug, soften }.`);
146
+ throw new Error(`${who}: cursor ${JSON.stringify(opt)} — expected 'depth' or { ${CURSOR_OPTION_KEYS.join(', ')} }.`);
134
147
  }
135
148
  const unknown = Object.keys(opt).filter((k) => !CURSOR_OPTION_KEYS.includes(k));
136
149
  if (unknown.length) throw new Error(`${who}: cursor — unknown option(s) ${unknown.join(', ')}; expected ${CURSOR_OPTION_KEYS.join(', ')}.`);
@@ -149,14 +162,20 @@ export function resolveCursorOption(opt, who = '@displayxr/inline3d') {
149
162
  }
150
163
  }
151
164
  if (opt.soften !== undefined) resolveCursorSoften(opt.soften, who); // validate at the call
165
+ if (opt.shape !== undefined && !CURSOR_SHAPE_OPTIONS.includes(opt.shape)) {
166
+ throw new Error(`${who}: cursor.shape ${JSON.stringify(opt.shape)} — expected ${CURSOR_SHAPE_OPTIONS.map((m) => `'${m}'`).join(' | ')}.`);
167
+ }
168
+ if (opt.shapeFor !== undefined && opt.shapeFor !== null && typeof opt.shapeFor !== 'function') {
169
+ throw new Error(`${who}: cursor.shapeFor must be a function (hit) => shape.`);
170
+ }
152
171
  return { ...opt };
153
172
  }
154
173
 
155
174
  /** The keys `cursor: { … }` accepts on a stereo PAIR (./camera's addCameraView, ./call's tiles). */
156
- export const PAIR_CURSOR_OPTION_KEYS = Object.freeze(['height', 'pointerScope', 'soften']);
175
+ export const PAIR_CURSOR_OPTION_KEYS = Object.freeze(['height', 'pointerScope', 'soften', 'shape', 'shapeFor']);
157
176
 
158
177
  /**
159
- * `cursor` on a stereo pair — a camera view or a call tile: `'depth'`, or `{ height, pointerScope, soften }`.
178
+ * `cursor` on a stereo pair — a camera view or a call tile: `'depth'`, or `{ height, pointerScope, soften, shape, shapeFor }`.
160
179
  * A pair has no scene: its depth is MEASURED off the two halves, and the sprite is drawn into them,
161
180
  * so there is no anchor mode (it is always on the line of sight) and the margin is the SDK's. Falsy
162
181
  * is OFF, the zero-cost default.