@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.
@@ -0,0 +1,962 @@
1
+ // inline3d-cursor-shape.js — WHICH sprite the depth cursor draws: the page's CSS cursor, mirrored.
2
+ //
3
+ // The depth cursor replaces the OS cursor while content under it comes out of the glass (ADR-046).
4
+ // Until 1.44 it always drew the arrow, so a page whose cursor is `grab` over a model showed an
5
+ // arrow there instead. This module closes that gap on the page side (ADR-046 Amendment 2 §C, done
6
+ // in the page): it reads the page's computed CSS cursor and draws what the browser would show.
7
+ //
8
+ // - Keywords (cssKeywordToShape): every standard CSS cursor keyword has a procedural sprite
9
+ // (CURSOR_SHAPES; aliases share one: `n-resize` → `ns-resize`, `no-drop` → `not-allowed`, …),
10
+ // `none` draws nothing (and the OS cursor stays hidden: the page hid it on purpose), anything
11
+ // unknown draws the arrow. No image assets: each sprite is a union of polygons on the arrow's
12
+ // 19-px grid (unit = the arrow's height, x right, y DOWN, the hotspot at the origin — the shape's
13
+ // natural point: a tip, the palm, the centre), drawn in the arrow's style: dark outline, white
14
+ // fill inset by one px of the 19, and dark `detail` marks on the fill (the ? of help, the + of
15
+ // copy and zoom-in). Parts that abut keep a dark seam between their fills (the finger lines).
16
+ // - Images (parseCssCursor, CursorImageCache): `url(a) x y, url(b), fallback` — the first url that
17
+ // loads as an image with crossOrigin 'anonymous' (same-origin and data: always do; a
18
+ // cross-origin one without CORS cannot be used, so the next item is tried), at its natural CSS
19
+ // size capped at 128 px, hotspot = the explicit x y, else a .cur file's own, else 0 0. Loaded
20
+ // async and cached per URL; the keyword fallback is drawn until the image is ready.
21
+ // - Drawing: the arrow keeps its analytic path (the crisp mesh / cursorSoftArrowGLSL). Every other
22
+ // sprite is a TEXTURE — keyword shapes rasterised once each (cursorShapeTexture), images as
23
+ // loaded — on one soft quad whose shader (cursorSpriteGLSL) applies the same 13-tap blur and
24
+ // opacity, so one program serves every sprite and a change never compiles anything. On a 2D
25
+ // pair the same rasters are drawn into the woven buffer (cursorShapeRaster, cursorImageRaster).
26
+ // Never a CSS filter, never CSS opacity.
27
+ // - Read-back (CursorCssReader): the depth cursor hides the CSS cursor while it draws, and that
28
+ // hide must not be what it reads back. So the hide is a data attribute plus ONE injected rule
29
+ // (CursorPointer.hideCss) — the page's inline `style.cursor` is never written — and a read
30
+ // lifts the attribute, takes getComputedStyle(element under the pointer).cursor and puts it
31
+ // back, synchronously in one task, so nothing paints in between. Reads happen only after
32
+ // pointer events (move throttled, down/up at once) and only on frames that draw the sprite.
33
+ // Loaded with the cursor, i.e. only on `cursor: 'depth'` (ADR-046 §0).
34
+
35
+ import {
36
+ CURSOR_ARROW,
37
+ CURSOR_ARROW_FILL,
38
+ CURSOR_FILL_COLOR,
39
+ CURSOR_OUTLINE_COLOR,
40
+ CURSOR_SOFTEN_KERNEL,
41
+ cursorArrowInset,
42
+ cursorPolygonSd,
43
+ } from './inline3d-cursor-depth.js';
44
+
45
+ import { CURSOR_SHAPES, CURSOR_SHAPE_OPTIONS } from './inline3d-cursor-option.js';
46
+
47
+ export { CURSOR_SHAPES, CURSOR_SHAPE_OPTIONS };
48
+
49
+ /** The data attribute the hide sets (on the canvas, or on <html> for pointerScope 'window'). */
50
+ export const CURSOR_HIDDEN_ATTR = 'data-inline3d-cursor-hidden';
51
+
52
+ /** The one rule injected for it. */
53
+ export const CURSOR_HIDDEN_RULE = `[${CURSOR_HIDDEN_ATTR}],[${CURSOR_HIDDEN_ATTR}] *{cursor:none!important}`;
54
+
55
+ /** An image cursor larger than this (CSS px, either side) is scaled down to it, as browsers cap it. */
56
+ export const CURSOR_IMAGE_MAX_PX = 128;
57
+
58
+ /** The CSS px of one sprite unit (the arrow's height at the default size). */
59
+ const UNIT_PX = 19;
60
+
61
+ // ── Keywords ─────────────────────────────────────────────────────────────────────────────────
62
+
63
+ const KEYWORD_TO_SHAPE = Object.freeze({
64
+ auto: 'arrow',
65
+ default: 'arrow',
66
+ none: 'none',
67
+ pointer: 'pointer',
68
+ grab: 'grab',
69
+ '-webkit-grab': 'grab',
70
+ grabbing: 'grabbing',
71
+ '-webkit-grabbing': 'grabbing',
72
+ move: 'move',
73
+ 'all-scroll': 'move',
74
+ text: 'text',
75
+ 'vertical-text': 'vertical-text',
76
+ crosshair: 'crosshair',
77
+ 'not-allowed': 'not-allowed',
78
+ 'no-drop': 'not-allowed',
79
+ help: 'help',
80
+ wait: 'wait',
81
+ progress: 'progress',
82
+ copy: 'copy',
83
+ alias: 'alias',
84
+ 'context-menu': 'context-menu',
85
+ cell: 'cell',
86
+ 'n-resize': 'ns-resize',
87
+ 's-resize': 'ns-resize',
88
+ 'ns-resize': 'ns-resize',
89
+ 'e-resize': 'ew-resize',
90
+ 'w-resize': 'ew-resize',
91
+ 'ew-resize': 'ew-resize',
92
+ 'ne-resize': 'nesw-resize',
93
+ 'sw-resize': 'nesw-resize',
94
+ 'nesw-resize': 'nesw-resize',
95
+ 'nw-resize': 'nwse-resize',
96
+ 'se-resize': 'nwse-resize',
97
+ 'nwse-resize': 'nwse-resize',
98
+ 'col-resize': 'col-resize',
99
+ 'row-resize': 'row-resize',
100
+ 'zoom-in': 'zoom-in',
101
+ '-webkit-zoom-in': 'zoom-in',
102
+ 'zoom-out': 'zoom-out',
103
+ '-webkit-zoom-out': 'zoom-out',
104
+ });
105
+
106
+ /** Every CSS keyword the map knows (diagnostics, tests). */
107
+ export const CURSOR_CSS_KEYWORDS = Object.freeze(Object.keys(KEYWORD_TO_SHAPE));
108
+
109
+ /**
110
+ * One CSS cursor keyword → its sprite name, `'none'`, or the arrow for anything unknown.
111
+ * @param {string} kw
112
+ * @returns {string}
113
+ */
114
+ export function cssKeywordToShape(kw) {
115
+ return (typeof kw === 'string' && KEYWORD_TO_SHAPE[kw.trim().toLowerCase()]) || 'arrow';
116
+ }
117
+
118
+ /** Split at top-level commas: commas inside quotes or parentheses (a data: URL) stay. */
119
+ function splitTopLevel(s) {
120
+ const out = [];
121
+ let depth = 0, quote = '', cur = '';
122
+ for (let i = 0; i < s.length; i++) {
123
+ const c = s[i];
124
+ if (quote) {
125
+ if (c === '\\' && i + 1 < s.length) {
126
+ cur += c + s[++i];
127
+ continue;
128
+ }
129
+ if (c === quote) quote = '';
130
+ } else if (c === '"' || c === "'") quote = c;
131
+ else if (c === '(') depth++;
132
+ else if (c === ')') depth = Math.max(0, depth - 1);
133
+ else if (c === ',' && depth === 0) {
134
+ out.push(cur.trim());
135
+ cur = '';
136
+ continue;
137
+ }
138
+ cur += c;
139
+ }
140
+ if (cur.trim()) out.push(cur.trim());
141
+ return out;
142
+ }
143
+
144
+ const _parsed = new Map();
145
+
146
+ /**
147
+ * A computed CSS `cursor` value, parsed: its `url(…)` images in order (each with its explicit
148
+ * hotspot, or null) and the keyword fallback (the last keyword; `auto` when there is none).
149
+ * `image-set(…)` entries are skipped (not drawable here: the next item is used).
150
+ * @param {string} css e.g. `url("a.cur"), url(b.png) 4 4, pointer`
151
+ * @returns {{images: Array<{url: string, hotspot: [number, number]|null}>, keyword: string}}
152
+ */
153
+ export function parseCssCursor(css) {
154
+ if (typeof css !== 'string') return { images: [], keyword: 'auto' };
155
+ let r = _parsed.get(css);
156
+ if (r) return r;
157
+ const images = [];
158
+ let keyword = 'auto';
159
+ for (const item of splitTopLevel(css)) {
160
+ const m = /^url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^)\s]*))\s*\)\s*(.*)$/is.exec(item);
161
+ if (m) {
162
+ const url = (m[1] ?? m[2] ?? m[3] ?? '').replace(/\\(.)/g, '$1');
163
+ const nums = (m[4] || '').trim().split(/\s+/).filter(Boolean).map(Number);
164
+ const hotspot = nums.length === 2 && nums.every(Number.isFinite) ? [nums[0], nums[1]] : null;
165
+ if (url) images.push({ url, hotspot });
166
+ continue;
167
+ }
168
+ if (/^(-webkit-)?image-set\(/i.test(item)) continue;
169
+ keyword = item.trim().toLowerCase();
170
+ }
171
+ r = Object.freeze({ images: Object.freeze(images), keyword });
172
+ if (_parsed.size > 64) _parsed.clear();
173
+ _parsed.set(css, r);
174
+ return r;
175
+ }
176
+
177
+ /**
178
+ * A computed CSS `cursor` value → the keyword sprite that stands for it, ignoring images (the
179
+ * keyword fallback decides): `'none'`, a CURSOR_SHAPES name, or the arrow.
180
+ * @returns {string}
181
+ */
182
+ export function cssCursorToShape(css) {
183
+ return cssKeywordToShape(parseCssCursor(css).keyword);
184
+ }
185
+
186
+ /**
187
+ * The sprite NAME for one frame: `shapeFor`'s answer when it names a sprite (or `'none'`), else
188
+ * the fixed `shape` option, else (`'auto'`) the page's (`css`, a name or null), else the arrow.
189
+ * @returns {string}
190
+ */
191
+ export function cursorPickShape(shape, fromShapeFor, css) {
192
+ const ok = (s) => s === 'none' || CURSOR_SHAPES.includes(s);
193
+ if (ok(fromShapeFor)) return fromShapeFor;
194
+ if (ok(shape)) return shape;
195
+ return ok(css) ? css : 'arrow';
196
+ }
197
+
198
+ // ── Images ───────────────────────────────────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * A Windows .cur file's hotspot for the entry `w` px wide (else its first entry), or null when
202
+ * the bytes are not a cursor file. (A .png has no hotspot: the spec's default is 0 0.)
203
+ * @param {ArrayBuffer} buf
204
+ */
205
+ export function curFileHotspot(buf, w = 0) {
206
+ if (!buf || buf.byteLength < 22) return null;
207
+ const v = new DataView(buf);
208
+ if (v.getUint16(0, true) !== 0 || v.getUint16(2, true) !== 2) return null;
209
+ const n = v.getUint16(4, true);
210
+ let pick = -1;
211
+ for (let i = 0; i < n && 6 + 16 * i + 16 <= buf.byteLength; i++) {
212
+ const ew = v.getUint8(6 + 16 * i) || 256;
213
+ if (pick < 0 || ew === w) pick = i;
214
+ if (ew === w) break;
215
+ }
216
+ if (pick < 0) return null;
217
+ const o = 6 + 16 * pick;
218
+ return [v.getUint16(o + 4, true), v.getUint16(o + 6, true)];
219
+ }
220
+
221
+ const looksLikeCur = (url) => /\.cur(?:[?#]|$)/i.test(url) || /^data:image\/(?:x-win-bitmap|x-icon|vnd\.microsoft\.icon)/i.test(url);
222
+
223
+ /**
224
+ * Cursor images by URL: loaded once (crossOrigin 'anonymous'), shared by every cursor on the page.
225
+ * An entry is `{ state: 'loading' | 'ready' | 'failed', image, width, height, curHotspot }` with
226
+ * the natural size in CSS px. `get(url)` starts the load on first sight.
227
+ */
228
+ export class CursorImageCache {
229
+ constructor({ ImageCtor = globalThis.Image, fetchFn = globalThis.fetch } = {}) {
230
+ this._Image = ImageCtor;
231
+ this._fetch = fetchFn;
232
+ this._m = new Map();
233
+ }
234
+
235
+ get(url) {
236
+ let e = this._m.get(url);
237
+ if (e) return e;
238
+ e = { url, state: 'loading', image: null, width: 0, height: 0, curHotspot: null };
239
+ this._m.set(url, e);
240
+ if (typeof this._Image !== 'function') {
241
+ e.state = 'failed';
242
+ return e;
243
+ }
244
+ const img = new this._Image();
245
+ img.crossOrigin = 'anonymous';
246
+ let pending = 1;
247
+ const done = () => {
248
+ if (--pending > 0 || e.state === 'failed') return;
249
+ e.state = 'ready';
250
+ };
251
+ img.onload = () => {
252
+ const w = img.naturalWidth || img.width, h = img.naturalHeight || img.height;
253
+ if (!(w > 0 && h > 0)) return void (e.state = 'failed');
254
+ e.image = img;
255
+ e.width = w;
256
+ e.height = h;
257
+ done();
258
+ };
259
+ img.onerror = () => {
260
+ e.state = 'failed';
261
+ };
262
+ if (looksLikeCur(url) && typeof this._fetch === 'function') {
263
+ pending++;
264
+ Promise.resolve()
265
+ .then(() => this._fetch(url, url.startsWith('data:') ? undefined : { mode: 'cors' }))
266
+ .then((r) => (r && r.ok !== false ? r.arrayBuffer() : null))
267
+ .then((buf) => {
268
+ e.curHotspot = buf ? curFileHotspot(buf, e.width) : null;
269
+ })
270
+ .catch(() => {})
271
+ .finally(done);
272
+ }
273
+ img.src = url;
274
+ return e;
275
+ }
276
+ }
277
+
278
+ /** The page-wide image cache the cursors share. */
279
+ export const cursorImageCache = new CursorImageCache();
280
+
281
+ /**
282
+ * What to draw for a computed CSS cursor value: the first READY image of its url list (skipping
283
+ * failed ones; while one is still loading, the keyword fallback), else the keyword's sprite.
284
+ * @returns {{kind: 'none'} | {kind: 'shape', name: string, key: string} |
285
+ * {kind: 'image', key: string, entry: object, width: number, height: number, hotspot: [number, number]}}
286
+ */
287
+ export function resolveCursorSprite(css, images = cursorImageCache) {
288
+ const p = parseCssCursor(css);
289
+ for (const it of p.images) {
290
+ const e = images.get(it.url);
291
+ if (e.state === 'failed') continue;
292
+ if (e.state !== 'ready') break; // the fallback until it is
293
+ const k = Math.min(1, CURSOR_IMAGE_MAX_PX / Math.max(e.width, e.height));
294
+ const hot = it.hotspot || e.curHotspot || [0, 0];
295
+ const width = e.width * k, height = e.height * k;
296
+ const hotspot = [Math.min(width, Math.max(0, hot[0] * k)), Math.min(height, Math.max(0, hot[1] * k))];
297
+ return { kind: 'image', key: it.url, entry: e, width, height, hotspot };
298
+ }
299
+ return spriteForName(cssKeywordToShape(p.keyword));
300
+ }
301
+
302
+ /** The sprite descriptor for a NAME (a CURSOR_SHAPES entry, or 'none'). */
303
+ export function spriteForName(name) {
304
+ if (name === 'none') return { kind: 'none' };
305
+ const n = CURSOR_SHAPES.includes(name) ? name : 'arrow';
306
+ return { kind: 'shape', name: n, key: `shape:${n}` };
307
+ }
308
+
309
+ /**
310
+ * The sprite's extent in sprite units (y down, hotspot at the origin): an image's CSS box around
311
+ * its hotspot; a shape's texture rect. [x0, y0, x1, y1].
312
+ */
313
+ export function cursorSpriteRect(sprite) {
314
+ if (sprite.kind === 'image') {
315
+ const [hx, hy] = sprite.hotspot;
316
+ return [-hx / UNIT_PX, -hy / UNIT_PX, (sprite.width - hx) / UNIT_PX, (sprite.height - hy) / UNIT_PX];
317
+ }
318
+ return cursorShapeTexture(sprite.name).rect;
319
+ }
320
+
321
+ /** The soft quad for a sprite: its rect padded by the blur radius plus one AA width. */
322
+ export function cursorSpriteBox(sprite, radius, aa = 0) {
323
+ const b = cursorSpriteRect(sprite);
324
+ const pad = (radius > 0 && Number.isFinite(radius) ? radius : 0) + (aa > 0 && Number.isFinite(aa) ? aa : 0);
325
+ return [b[0] - pad, b[1] - pad, b[2] + pad, b[3] + pad];
326
+ }
327
+
328
+ // ── The shapes ─────────────────────────────────────────────────────────────────────────────────
329
+ // Designed on the arrow's 19-px grid (x right, y down), then moved so the hotspot is the origin
330
+ // and scaled to sprite units. Parts that must read as ONE white area overlap by more than 2 px
331
+ // (each fill is inset by 1); parts meant to show a seam overlap by 1 (a 1 px dark line).
332
+
333
+ /** A box with chamfered corners [tl, tr, br, bl] (px), clockwise on screen (y down). */
334
+ function box(x0, y0, x1, y1, [tl = 0, tr = 0, br = 0, bl = 0] = []) {
335
+ const p = [];
336
+ const add = (x, y) => {
337
+ const q = p[p.length - 1];
338
+ if (!q || Math.abs(q[0] - x) > 1e-9 || Math.abs(q[1] - y) > 1e-9) p.push([x, y]);
339
+ };
340
+ add(x0 + tl, y0);
341
+ add(x1 - tr, y0);
342
+ add(x1, y0 + tr);
343
+ add(x1, y1 - br);
344
+ add(x1 - br, y1);
345
+ add(x0 + bl, y1);
346
+ add(x0, y1 - bl);
347
+ add(x0, y0 + tl);
348
+ if (p.length > 1 && Math.abs(p[0][0] - p[p.length - 1][0]) < 1e-9 && Math.abs(p[0][1] - p[p.length - 1][1]) < 1e-9) p.pop();
349
+ return p;
350
+ }
351
+
352
+ const clockwise = (pts) => {
353
+ let a = 0;
354
+ for (let i = 0; i < pts.length; i++) {
355
+ const [px, py] = pts[i], [qx, qy] = pts[(i + 1) % pts.length];
356
+ a += px * qy - qx * py;
357
+ }
358
+ return a > 0 ? pts : pts.reverse();
359
+ };
360
+
361
+ /** A limb from (x0, y0) (its base) to (x1, y1) (its tip), `w` px wide, the tip chamfered. */
362
+ function limb(x0, y0, x1, y1, w, c = w / 3.5) {
363
+ const dx = x1 - x0, dy = y1 - y0;
364
+ const l = Math.hypot(dx, dy);
365
+ const ux = dx / l, uy = dy / l; // along, base → tip
366
+ const nx = -uy * (w / 2), ny = ux * (w / 2); // across
367
+ const tx = x1 - ux * c, ty = y1 - uy * c; // where the chamfer starts
368
+ const k = 1 - (2 * c) / w; // the tip's flat, as a fraction of the width
369
+ return clockwise([
370
+ [x0 + nx, y0 + ny],
371
+ [tx + nx, ty + ny],
372
+ [x1 + nx * k, y1 + ny * k],
373
+ [x1 - nx * k, y1 - ny * k],
374
+ [tx - nx, ty - ny],
375
+ [x0 - nx, y0 - ny],
376
+ ]);
377
+ }
378
+
379
+ /** A straight bar from (x0, y0) to (x1, y1), `w` px wide (square ends). */
380
+ function bar(x0, y0, x1, y1, w) {
381
+ const dx = x1 - x0, dy = y1 - y0;
382
+ const l = Math.hypot(dx, dy);
383
+ const nx = (-dy / l) * (w / 2), ny = (dx / l) * (w / 2);
384
+ return clockwise([[x0 + nx, y0 + ny], [x1 + nx, y1 + ny], [x1 - nx, y1 - ny], [x0 - nx, y0 - ny]]);
385
+ }
386
+
387
+ /** A regular n-gon (a disc at this size). */
388
+ function disc(cx, cy, r, n = 20) {
389
+ return clockwise(Array.from({ length: n }, (_, k) => [cx + r * Math.cos((2 * Math.PI * k) / n), cy + r * Math.sin((2 * Math.PI * k) / n)]));
390
+ }
391
+
392
+ /**
393
+ * A ring as overlapping annular segments: each spans three steps, so a neighbour overlaps it by
394
+ * two (> the 2 px the two 1-px insets need) and the fills merge into one white band.
395
+ */
396
+ function ring(cx, cy, r0, r1, n = 24) {
397
+ const out = [];
398
+ const P = (r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];
399
+ for (let k = 0; k < n; k++) {
400
+ const as = [0, 1, 2, 3].map((i) => (2 * Math.PI * (k + i)) / n);
401
+ out.push(clockwise([...as.map((a) => P(r1, a)), ...as.reverse().map((a) => P(r0, a))]));
402
+ }
403
+ return out;
404
+ }
405
+
406
+ const tri = (a, b, c) => clockwise([a, b, c]);
407
+
408
+ /** Parts rotated by `deg` about (cx, cy). */
409
+ const rot = (parts, deg, cx, cy) => {
410
+ const a = (deg * Math.PI) / 180, c = Math.cos(a), s = Math.sin(a);
411
+ return parts.map((p) => clockwise(p.map(([x, y]) => [cx + (x - cx) * c - (y - cy) * s, cy + (x - cx) * s + (y - cy) * c])));
412
+ };
413
+ /** Parts scaled by `k` about the origin, then moved by (dx, dy). */
414
+ const xf = (parts, k, dx = 0, dy = 0) => parts.map((p) => p.map(([x, y]) => [x * k + dx, y * k + dy]));
415
+
416
+ const ARROW_PX = () => [CURSOR_ARROW.map(([x, y]) => [x * 19, y * 19])];
417
+
418
+ /** The double-headed arrow (vertical, centred on (9.5, 9.5)); rotate it for the other axes. */
419
+ const doubleArrow = () => [
420
+ box(8.0, 3.0, 11.0, 16.0),
421
+ tri([9.5, 0], [14.2, 5.0], [4.8, 5.0]),
422
+ tri([9.5, 19.0], [4.8, 14.0], [14.2, 14.0]),
423
+ ];
424
+
425
+ /** A splitter (col-resize): a bar with an arrow out of each side. */
426
+ const splitter = () => [
427
+ box(8.0, 1.0, 11.0, 18.0),
428
+ box(3.4, 8.0, 7.4, 11.0),
429
+ tri([0, 9.5], [4.4, 5.0], [4.4, 14.0]),
430
+ box(11.6, 8.0, 15.6, 11.0),
431
+ tri([19.0, 9.5], [14.6, 14.0], [14.6, 5.0]),
432
+ ];
433
+
434
+ const hourglass = () => [
435
+ box(3.0, 0, 16.0, 3.0),
436
+ box(3.0, 16.0, 16.0, 19.0),
437
+ tri([4.2, 0.5], [14.8, 0.5], [9.5, 10.0]),
438
+ tri([9.5, 9.0], [14.8, 18.5], [4.2, 18.5]),
439
+ ];
440
+
441
+ const magnifier = () => [disc(7.5, 7.5, 7.0), limb(11.6, 11.6, 18.4, 18.4, 3.8, 1.0)];
442
+
443
+ // Each design: `hot` (px), `parts` (outline), optional `detail` (dark marks on the fill).
444
+ function designs() {
445
+ return {
446
+ // The pointing hand: the index finger up (its tip = the hotspot), three curled fingers, thumb, palm.
447
+ // Chamfers stay under (width − 2.2 px) / 2, so the 1-px inset keeps every edge (a valid fill).
448
+ pointer: {
449
+ hot: [6.8, 0],
450
+ parts: [
451
+ box(5.0, 0, 8.6, 14.0, [1.2, 1.2, 0, 0]),
452
+ box(7.6, 5.4, 10.9, 14.0, [1.1, 1.1, 0, 0]),
453
+ box(9.9, 6.4, 13.2, 14.0, [1.1, 1.1, 0, 0]),
454
+ box(12.2, 7.6, 15.2, 14.0, [0.9, 0.9, 0, 0]),
455
+ box(4.2, 10.8, 15.2, 19.0, [0, 0, 3.0, 2.2]),
456
+ limb(7.4, 15.0, 1.0, 9.0, 3.6),
457
+ ],
458
+ },
459
+ // The open hand: four fingers spread, thumb out; the hotspot in the palm (below the finger seams).
460
+ grab: {
461
+ hot: [8.6, 12.5],
462
+ parts: [
463
+ box(3.0, 2.6, 6.6, 12.4, [1.2, 1.2, 0, 0]),
464
+ box(5.6, 0.6, 9.2, 12.4, [1.2, 1.2, 0, 0]),
465
+ box(8.2, 1.4, 11.8, 12.4, [1.2, 1.2, 0, 0]),
466
+ box(10.8, 3.6, 14.2, 12.4, [1.1, 1.1, 0, 0]),
467
+ box(3.0, 9.4, 14.2, 18.0, [0, 0, 2.6, 2.6]),
468
+ limb(6.2, 14.4, 0.4, 7.0, 3.6),
469
+ ],
470
+ },
471
+ // The closed hand: the same palm, the fingers curled to a row of knuckles.
472
+ grabbing: {
473
+ hot: [8.6, 13.0],
474
+ parts: [
475
+ box(3.0, 5.6, 6.6, 12.4, [1.2, 1.2, 0, 0]),
476
+ box(5.6, 5.0, 9.2, 12.4, [1.2, 1.2, 0, 0]),
477
+ box(8.2, 5.2, 11.8, 12.4, [1.2, 1.2, 0, 0]),
478
+ box(10.8, 6.0, 14.2, 12.4, [1.1, 1.1, 0, 0]),
479
+ box(3.0, 9.4, 14.2, 18.0, [0, 0, 2.6, 2.6]),
480
+ limb(6.0, 14.2, 1.2, 9.0, 3.6),
481
+ ],
482
+ },
483
+ // The four-way arrow.
484
+ move: {
485
+ hot: [9.5, 9.5],
486
+ parts: [
487
+ box(3.0, 7.8, 16.0, 11.2),
488
+ box(7.8, 3.0, 11.2, 16.0),
489
+ tri([9.5, 0], [14.0, 4.6], [5.0, 4.6]),
490
+ tri([9.5, 19.0], [5.0, 14.4], [14.0, 14.4]),
491
+ tri([0, 9.5], [4.6, 5.0], [4.6, 14.0]),
492
+ tri([19.0, 9.5], [14.4, 14.0], [14.4, 5.0]),
493
+ ],
494
+ },
495
+ // The I-beam: a stem with serifs; the hotspot at its middle.
496
+ text: { hot: [9.5, 9.5], parts: [box(8.0, 0.5, 11.0, 18.5), box(4.5, 0, 14.5, 3.0), box(4.5, 16.0, 14.5, 19.0)] },
497
+ 'vertical-text': { hot: [9.5, 9.5], parts: rot([box(8.0, 0.5, 11.0, 18.5), box(4.5, 0, 14.5, 3.0), box(4.5, 16.0, 14.5, 19.0)], 90, 9.5, 9.5) },
498
+ crosshair: { hot: [9.5, 9.5], parts: [box(0, 8.0, 19.0, 11.0), box(8.0, 0, 11.0, 19.0)] },
499
+ cell: { hot: [9.5, 9.5], parts: [box(1.5, 6.3, 17.5, 12.7), box(6.3, 1.5, 12.7, 17.5)] },
500
+ // The circle with a slash.
501
+ 'not-allowed': { hot: [9.5, 9.5], parts: [...ring(9.5, 9.5, 5.4, 8.8), bar(4.6, 4.6, 14.4, 14.4, 3.4)] },
502
+ // Arrow + a ? badge.
503
+ help: {
504
+ hot: [0, 0],
505
+ parts: [...xf(ARROW_PX(), 0.8), disc(13.6, 12.4, 5.4)],
506
+ detail: [box(11.6, 9.0, 15.4, 10.2), box(14.4, 9.0, 15.6, 12.6), box(12.9, 11.6, 15.6, 12.8), box(12.9, 11.6, 14.1, 13.8), box(12.9, 14.8, 14.1, 16.0)],
507
+ },
508
+ wait: { hot: [9.5, 9.5], parts: hourglass() },
509
+ // Arrow + a small hourglass (busy, still usable).
510
+ // (A mini hourglass drawn at its own size: half the big one's bars are too thin for the rim.)
511
+ progress: {
512
+ hot: [0, 0],
513
+ parts: [
514
+ ...xf(ARROW_PX(), 0.8),
515
+ box(10.6, 9.0, 18.8, 11.6),
516
+ box(10.6, 16.4, 18.8, 19.0),
517
+ tri([11.4, 10.2], [18.0, 10.2], [14.7, 14.4]),
518
+ tri([14.7, 13.6], [18.0, 17.8], [11.4, 17.8]),
519
+ ],
520
+ },
521
+ // Arrow + a + badge.
522
+ copy: {
523
+ hot: [0, 0],
524
+ parts: [...xf(ARROW_PX(), 0.8), box(9.6, 9.8, 18.8, 19.0, [1.2, 1.2, 1.2, 1.2])],
525
+ detail: [box(13.6, 11.8, 14.8, 17.0), box(11.6, 13.8, 16.8, 15.0)],
526
+ },
527
+ // Arrow + a shortcut-arrow badge.
528
+ alias: {
529
+ hot: [0, 0],
530
+ parts: [...xf(ARROW_PX(), 0.8), box(9.6, 9.8, 18.8, 19.0, [1.2, 1.2, 1.2, 1.2])],
531
+ detail: [bar(11.8, 16.8, 15.6, 13.0, 1.3), tri([16.8, 11.8], [16.8, 15.4], [13.2, 11.8])],
532
+ },
533
+ // Arrow + a menu.
534
+ 'context-menu': {
535
+ hot: [0, 0],
536
+ parts: [...xf(ARROW_PX(), 0.8), box(9.2, 8.6, 18.8, 19.0)],
537
+ detail: [box(11.0, 10.8, 17.0, 11.8), box(11.0, 13.3, 17.0, 14.3), box(11.0, 15.8, 17.0, 16.8)],
538
+ },
539
+ 'ns-resize': { hot: [9.5, 9.5], parts: doubleArrow() },
540
+ 'ew-resize': { hot: [9.5, 9.5], parts: rot(doubleArrow(), 90, 9.5, 9.5) },
541
+ // Diagonals: rotated, then grown 1.2× so the box is the OS cursor's ~16 px, not 13.
542
+ 'nesw-resize': { hot: [9.5, 9.5], parts: xf(rot(doubleArrow(), 45, 9.5, 9.5), 1.2, -1.9, -1.9) },
543
+ 'nwse-resize': { hot: [9.5, 9.5], parts: xf(rot(doubleArrow(), -45, 9.5, 9.5), 1.2, -1.9, -1.9) },
544
+ 'col-resize': { hot: [9.5, 9.5], parts: splitter() },
545
+ 'row-resize': { hot: [9.5, 9.5], parts: rot(splitter(), 90, 9.5, 9.5) },
546
+ // A magnifier with + / −; the hotspot at the lens centre.
547
+ 'zoom-in': { hot: [7.5, 7.5], parts: magnifier(), detail: [box(4.4, 6.9, 10.6, 8.1), box(6.9, 4.4, 8.1, 10.6)] },
548
+ 'zoom-out': { hot: [7.5, 7.5], parts: magnifier(), detail: [box(4.4, 6.9, 10.6, 8.1)] },
549
+ };
550
+ }
551
+
552
+ let _designs = null;
553
+ const _shapes = new Map();
554
+
555
+ /**
556
+ * One sprite's geometry (sprite units: the arrow's height = 1, x right, y DOWN, hotspot at the
557
+ * origin): `parts` (the dark outline, a union of polygons), `fill` (each part inset by one px of
558
+ * the 19), `detail` (dark marks over the fill), `box` [x0, y0, x1, y1]. Built once, lazily.
559
+ * @param {string} name a CURSOR_SHAPES entry (anything else: the arrow)
560
+ */
561
+ export function cursorShape(name) {
562
+ const key = CURSOR_SHAPES.includes(name) ? name : 'arrow';
563
+ let s = _shapes.get(key);
564
+ if (s) return s;
565
+ const freeze = (polys) => Object.freeze(polys.map((p) => Object.freeze(p.map((q) => Object.freeze(q)))));
566
+ let parts, fill, detail;
567
+ if (key === 'arrow') {
568
+ parts = [CURSOR_ARROW];
569
+ fill = [CURSOR_ARROW_FILL];
570
+ detail = [];
571
+ } else {
572
+ const d = (_designs ||= designs())[key];
573
+ const toUnits = (polys) => polys.map((poly) => poly.map(([x, y]) => [(x - d.hot[0]) / 19, (y - d.hot[1]) / 19]));
574
+ parts = freeze(toUnits(d.parts));
575
+ fill = freeze(parts.map((p) => cursorArrowInset(1 / 19, p)));
576
+ detail = freeze(toUnits(d.detail || []));
577
+ }
578
+ const xs = parts.flat().map((p) => p[0]);
579
+ const ys = parts.flat().map((p) => p[1]);
580
+ s = Object.freeze({
581
+ name: key,
582
+ parts: Object.freeze(parts),
583
+ fill: Object.freeze(fill),
584
+ detail: Object.freeze(detail),
585
+ box: Object.freeze([Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]),
586
+ });
587
+ _shapes.set(key, s);
588
+ return s;
589
+ }
590
+
591
+ /**
592
+ * The shape's outline parts, its fill inset by `d` (sprite units) and its detail marks — the 2D
593
+ * pair insets by one OUTPUT px, which can be more than one px of the 19.
594
+ * @returns {{outline: number[][][], fill: number[][][], detail: number[][][]}}
595
+ */
596
+ export function cursorShapePolys(name, d = 1 / 19) {
597
+ const s = cursorShape(name);
598
+ if (Math.abs(d - 1 / 19) < 1e-12) return { outline: s.parts, fill: s.fill, detail: s.detail };
599
+ return { outline: s.parts, fill: s.parts.map((p) => cursorArrowInset(d, p)), detail: s.detail };
600
+ }
601
+
602
+ /** The shape's box padded by the blur radius plus one AA width (sprite units, hotspot at the origin). */
603
+ export function cursorShapeBox(name, radius, aa = 0) {
604
+ const b = cursorShape(name).box;
605
+ const pad = (radius > 0 && Number.isFinite(radius) ? radius : 0) + (aa > 0 && Number.isFinite(aa) ? aa : 0);
606
+ return [b[0] - pad, b[1] - pad, b[2] + pad, b[3] + pad];
607
+ }
608
+
609
+ const unionSd = (polys, x, y) => {
610
+ let d = Infinity;
611
+ for (const p of polys) d = Math.min(d, cursorPolygonSd(p, x, y));
612
+ return d;
613
+ };
614
+ const coverage = (sd, aa) => (aa > 0 ? Math.min(1, Math.max(0, 0.5 - sd / aa)) : sd < 0 ? 1 : 0);
615
+
616
+ /**
617
+ * The softened shape at (x, y) of sprite units — for the arrow exactly cursorSoftArrowSample: the
618
+ * 13-tap kernel over the crisp shape (outline union, the fill union over it, the detail marks over
619
+ * that in the outline colour, each edge anti-aliased over `aa`), accumulated premultiplied.
620
+ * @param {{outline: number[][][], fill: number[][][], detail?: number[][][]}} [polys]
621
+ * @returns {[number, number, number, number]} straight RGB and alpha (before the opacity)
622
+ */
623
+ export function cursorShapeSample(name, x, y, radius, aa, fill = CURSOR_FILL_COLOR, outline = CURSOR_OUTLINE_COLOR, polys = cursorShapePolys(name)) {
624
+ let r = 0, g = 0, b = 0, a = 0, ws = 0;
625
+ const taps = radius > 0 ? CURSOR_SOFTEN_KERNEL : CURSOR_SOFTEN_KERNEL.slice(0, 1);
626
+ const det = polys.detail && polys.detail.length ? polys.detail : null;
627
+ for (const [dx, dy, w] of taps) {
628
+ const px = x + dx * radius, py = y + dy * radius;
629
+ const co = coverage(unionSd(polys.outline, px, py), aa);
630
+ let cf = co > 0 ? coverage(unionSd(polys.fill, px, py), aa) : 0;
631
+ if (det && cf > 0) cf *= 1 - coverage(unionSd(det, px, py), aa);
632
+ r += w * (outline[0] * co + (fill[0] - outline[0]) * cf);
633
+ g += w * (outline[1] * co + (fill[1] - outline[1]) * cf);
634
+ b += w * (outline[2] * co + (fill[2] - outline[2]) * cf);
635
+ a += w * co;
636
+ ws += w;
637
+ }
638
+ a /= ws;
639
+ if (!(a > 1e-5)) return [0, 0, 0, 0];
640
+ return [r / ws / a, g / ws / a, b / ws / a, a];
641
+ }
642
+
643
+ /**
644
+ * The softened shape rasterised for a 2D canvas: the hotspot at canvas point (cx, cy), one sprite
645
+ * unit = `sizePx`. Straight-alpha RGBA bytes, the opacity applied.
646
+ * @returns {{x0: number, y0: number, width: number, height: number, data: Uint8ClampedArray}}
647
+ */
648
+ export function cursorShapeRaster(name, cx, cy, sizePx, radius, alpha, fill = CURSOR_FILL_COLOR, outline = CURSOR_OUTLINE_COLOR, polys = cursorShapePolys(name)) {
649
+ const size = sizePx > 0 && Number.isFinite(sizePx) ? sizePx : 1;
650
+ const aa = 1 / size;
651
+ const [bx0, by0, bx1, by1] = cursorShapeBox(name, radius, aa);
652
+ const x0 = Math.floor(cx + bx0 * size), y0 = Math.floor(cy + by0 * size);
653
+ const width = Math.max(1, Math.ceil(cx + bx1 * size) - x0), height = Math.max(1, Math.ceil(cy + by1 * size) - y0);
654
+ const data = new Uint8ClampedArray(width * height * 4);
655
+ const op = alpha >= 0 && alpha <= 1 ? alpha : 1;
656
+ for (let j = 0; j < height; j++) {
657
+ const y = (y0 + j + 0.5 - cy) / size;
658
+ for (let i = 0; i < width; i++) {
659
+ const c = cursorShapeSample(name, (x0 + i + 0.5 - cx) / size, y, radius, aa, fill, outline, polys);
660
+ if (!(c[3] > 0)) continue;
661
+ const o = (j * width + i) * 4;
662
+ data[o] = Math.round(c[0] * 255);
663
+ data[o + 1] = Math.round(c[1] * 255);
664
+ data[o + 2] = Math.round(c[2] * 255);
665
+ data[o + 3] = Math.round(c[3] * op * 255);
666
+ }
667
+ }
668
+ return { x0, y0, width, height, data };
669
+ }
670
+
671
+ /** Texels per sprite unit of the keyword textures: ~3× a 19-CSS-px cursor at DPR 1. */
672
+ export const CURSOR_SHAPE_TEXTURE_PX = 64;
673
+ const _textures = new Map();
674
+
675
+ /**
676
+ * A keyword shape as a texture (crisp, straight-alpha RGBA, row 0 = the TOP), built once:
677
+ * `rect` = the sprite-unit area it covers [x0, y0, x1, y1] (the hotspot at the origin).
678
+ * @returns {{width: number, height: number, data: Uint8ClampedArray, rect: [number, number, number, number]}}
679
+ */
680
+ export function cursorShapeTexture(name) {
681
+ const key = CURSOR_SHAPES.includes(name) ? name : 'arrow';
682
+ let t = _textures.get(key);
683
+ if (t) return t;
684
+ const n = CURSOR_SHAPE_TEXTURE_PX;
685
+ const r = cursorShapeRaster(key, 0, 0, n, 0, 1);
686
+ t = Object.freeze({ width: r.width, height: r.height, data: r.data, rect: Object.freeze([r.x0 / n, r.y0 / n, (r.x0 + r.width) / n, (r.y0 + r.height) / n]) });
687
+ _textures.set(key, t);
688
+ return t;
689
+ }
690
+
691
+ /** Draw the crisp shape with a 2D context: the outline dark, the fill white, the detail dark. */
692
+ export function drawCursorShape2D(g, name, cx, cy, sizePx, polys = cursorShapePolys(name), fillStyle = '#fff', outlineStyle = '#000') {
693
+ for (const [list, style] of [[polys.outline, outlineStyle], [polys.fill, fillStyle], [polys.detail || [], outlineStyle]]) {
694
+ g.fillStyle = style;
695
+ for (const poly of list) {
696
+ g.beginPath();
697
+ poly.forEach(([x, y], k) => (k ? g.lineTo : g.moveTo).call(g, cx + x * sizePx, cy + y * sizePx));
698
+ g.closePath();
699
+ g.fill();
700
+ }
701
+ }
702
+ }
703
+
704
+ /**
705
+ * A straight-alpha RGBA image softened with the same 13-tap kernel (nearest-texel taps, `radiusPx`
706
+ * output px) and opacity `alpha`; the result is padded by the radius on every side.
707
+ * @returns {{pad: number, width: number, height: number, data: Uint8ClampedArray}}
708
+ */
709
+ export function cursorSoftenPixels(src, w, h, radiusPx, alpha) {
710
+ const pad = radiusPx > 0 && Number.isFinite(radiusPx) ? Math.ceil(radiusPx) + 1 : 0;
711
+ const W = w + 2 * pad, H = h + 2 * pad;
712
+ const out = new Uint8ClampedArray(W * H * 4);
713
+ const op = alpha >= 0 && alpha <= 1 ? alpha : 1;
714
+ const taps = pad ? CURSOR_SOFTEN_KERNEL : CURSOR_SOFTEN_KERNEL.slice(0, 1);
715
+ for (let j = 0; j < H; j++) {
716
+ for (let i = 0; i < W; i++) {
717
+ let r = 0, g = 0, b = 0, a = 0, ws = 0;
718
+ for (const [dx, dy, wt] of taps) {
719
+ ws += wt;
720
+ const x = Math.round(i - pad + dx * radiusPx), y = Math.round(j - pad + dy * radiusPx);
721
+ if (x < 0 || y < 0 || x >= w || y >= h) continue;
722
+ const o = (y * w + x) * 4, sa = (src[o + 3] / 255) * wt;
723
+ r += src[o] * sa;
724
+ g += src[o + 1] * sa;
725
+ b += src[o + 2] * sa;
726
+ a += sa;
727
+ }
728
+ if (!(a > 1e-5)) continue;
729
+ const o = (j * W + i) * 4;
730
+ out[o] = r / a;
731
+ out[o + 1] = g / a;
732
+ out[o + 2] = b / a;
733
+ out[o + 3] = (a / ws) * op * 255;
734
+ }
735
+ }
736
+ return { pad, width: W, height: H, data: out };
737
+ }
738
+
739
+ const glf = (v) => {
740
+ const s = (+v).toPrecision(9);
741
+ return /[.eE]/.test(s) ? s : `${s}.0`;
742
+ };
743
+
744
+ /**
745
+ * GLSL (ES 1.00 and 3.00: no loops, no arrays) for a softened TEXTURE sprite — every keyword shape
746
+ * but the arrow, and every url() image, through one program:
747
+ *
748
+ * vec4 inline3dSoftSprite(sampler2D tex, vec2 p, vec4 rect, float radius)
749
+ *
750
+ * `p` in sprite units (y down, hotspot at the origin); `rect` = the area the texture covers (row 0
751
+ * = its top, rect.y); `radius` = the blur radius in sprite units: the runtime's 13-tap kernel, taps
752
+ * outside the texture transparent, accumulated premultiplied. Straight RGB and alpha out; the
753
+ * caller applies the opacity.
754
+ */
755
+ export function cursorSpriteGLSL() {
756
+ const taps = CURSOR_SOFTEN_KERNEL.slice(1)
757
+ .map(([dx, dy, w]) => ` acc += ${glf(w)} * i3dSpriteTap(tex, uv + vec2(${glf(dx)}, ${glf(dy)}) * k);`)
758
+ .join('\n');
759
+ const wsum = CURSOR_SOFTEN_KERNEL.slice(1).reduce((t, k) => t + k[2], 0);
760
+ return `
761
+ vec4 i3dSpriteTap(sampler2D tex, vec2 uv) {
762
+ if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) return vec4(0.0);
763
+ vec4 c = texture2D(tex, uv);
764
+ return vec4(c.rgb * c.a, c.a);
765
+ }
766
+ vec4 inline3dSoftSprite(sampler2D tex, vec2 p, vec4 rect, float radius) {
767
+ vec2 sz = rect.zw - rect.xy;
768
+ vec2 uv = (p - rect.xy) / sz;
769
+ vec2 k = vec2(radius) / sz;
770
+ vec4 acc = i3dSpriteTap(tex, uv);
771
+ float wsum = 1.0;
772
+ if (radius > 0.0) {
773
+ ${taps}
774
+ wsum += ${glf(wsum)};
775
+ }
776
+ acc /= wsum;
777
+ return vec4(acc.a > 1e-5 ? acc.rgb / acc.a : vec3(0.0), acc.a);
778
+ }
779
+ `;
780
+ }
781
+
782
+ // ── Read-back of the page's CSS cursor ─────────────────────────────────────────────────────────
783
+
784
+ /** Pointer moves re-read the CSS cursor at most this often (ms); down/up re-read at once. */
785
+ export const CURSOR_CSS_READ_MS = 100;
786
+
787
+ /**
788
+ * Inject the hide rule once per document (idempotent).
789
+ * @returns {boolean} false when there is no document to inject into
790
+ */
791
+ export function ensureCursorHiddenRule(doc = globalThis.document) {
792
+ if (!doc || !doc.createElement) return false;
793
+ const id = 'inline3d-cursor-style';
794
+ if (doc.getElementById && doc.getElementById(id)) return true;
795
+ const st = doc.createElement('style');
796
+ st.id = id;
797
+ st.textContent = CURSOR_HIDDEN_RULE;
798
+ (doc.head || doc.documentElement)?.appendChild(st);
799
+ return true;
800
+ }
801
+
802
+ /**
803
+ * The page's CSS cursor at `el`, as the page set it — the depth cursor's own hide lifted for the
804
+ * read (the attribute off `holders`, getComputedStyle, the attribute back, all synchronous, so no
805
+ * frame can paint without it).
806
+ * @param {Element} el
807
+ * @param {Array<Element|null|undefined>} holders elements that may carry the hide attribute
808
+ * @returns {string|null} the computed `cursor`, or null when it cannot be read
809
+ */
810
+ export function readPageCursor(el, holders) {
811
+ const gcs = globalThis.getComputedStyle;
812
+ if (!el || typeof gcs !== 'function') return null;
813
+ const lifted = [];
814
+ for (const h of holders) {
815
+ if (h && h.hasAttribute && h.hasAttribute(CURSOR_HIDDEN_ATTR)) {
816
+ lifted.push([h, h.getAttribute(CURSOR_HIDDEN_ATTR)]);
817
+ h.removeAttribute(CURSOR_HIDDEN_ATTR);
818
+ }
819
+ }
820
+ try {
821
+ const v = gcs(el).cursor;
822
+ return typeof v === 'string' ? v : null;
823
+ } catch {
824
+ return null;
825
+ } finally {
826
+ for (const [h, v] of lifted) h.setAttribute(CURSOR_HIDDEN_ATTR, v ?? '');
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Tracks the page's CSS cursor under the pointer, cheaply: its listeners only note the element
832
+ * under the pointer and mark the cached value stale; `value()` — called by the backend on frames
833
+ * that DRAW the sprite — re-reads it when stale (a move: at most every CURSOR_CSS_READ_MS; a press
834
+ * or release: at once, so `grab` → `grabbing` follows the click). The read itself runs on the
835
+ * frame, after every page handler of the event has run (a page toggling a `grabbing` class on
836
+ * pointerdown is seen whatever the listener order).
837
+ */
838
+ export class CursorCssReader {
839
+ /**
840
+ * @param {{canvas: Element, scope: 'canvas'|'window'}} pointer the backend's CursorPointer
841
+ * @param {{throttleMs?: number}} [opts]
842
+ */
843
+ constructor(pointer, { throttleMs = CURSOR_CSS_READ_MS } = {}) {
844
+ this.pointer = pointer;
845
+ this.throttleMs = throttleMs;
846
+ /** The last computed CSS cursor read (diagnostics), or null before the first. */
847
+ this.css = null;
848
+ /** Reads performed so far (diagnostics). */
849
+ this.reads = 0;
850
+ this._el = null;
851
+ this._stale = true;
852
+ this._urgent = true;
853
+ this._lastRead = -Infinity;
854
+ const note = (urgent) => (e) => {
855
+ const path = typeof e.composedPath === 'function' ? e.composedPath() : null;
856
+ const t = path && path.length ? path[0] : e.target;
857
+ if (t && t.nodeType === 1) this._el = t;
858
+ this._stale = true;
859
+ if (urgent) this._urgent = true;
860
+ };
861
+ this._onMove = note(false);
862
+ this._onPress = note(true);
863
+ this._target = pointer.scope === 'window' ? globalThis : pointer.canvas;
864
+ if (this._target && this._target.addEventListener) {
865
+ this._target.addEventListener('pointermove', this._onMove);
866
+ this._target.addEventListener('pointerdown', this._onPress);
867
+ this._target.addEventListener('pointerup', this._onPress);
868
+ this._target.addEventListener('pointercancel', this._onPress);
869
+ }
870
+ }
871
+
872
+ /** The page's computed CSS cursor, re-read if stale and due ('auto' when unreadable). */
873
+ value(nowMs = globalThis.performance ? globalThis.performance.now() : Date.now()) {
874
+ if (this._stale && (this._urgent || this.css === null || nowMs - this._lastRead >= this.throttleMs)) this.read(nowMs);
875
+ return this.css ?? 'auto';
876
+ }
877
+
878
+ /** The page's cursor as a keyword sprite name (images ignored): see cssCursorToShape. */
879
+ shape(nowMs) {
880
+ return cssCursorToShape(this.value(nowMs));
881
+ }
882
+
883
+ /** Read now, whatever the throttle says. */
884
+ read(nowMs = globalThis.performance ? globalThis.performance.now() : Date.now()) {
885
+ const doc = globalThis.document;
886
+ const canvas = this.pointer.canvas;
887
+ let el = this._el;
888
+ if (el && el.isConnected === false) el = null;
889
+ const css = readPageCursor(el || canvas, [canvas, doc && doc.documentElement]);
890
+ this.reads++;
891
+ this._lastRead = nowMs;
892
+ this._stale = false;
893
+ this._urgent = false;
894
+ if (css !== null) this.css = css;
895
+ return this.css ?? 'auto';
896
+ }
897
+
898
+ dispose() {
899
+ if (this._target && this._target.removeEventListener) {
900
+ this._target.removeEventListener('pointermove', this._onMove);
901
+ this._target.removeEventListener('pointerdown', this._onPress);
902
+ this._target.removeEventListener('pointerup', this._onPress);
903
+ this._target.removeEventListener('pointercancel', this._onPress);
904
+ }
905
+ }
906
+ }
907
+
908
+ /**
909
+ * The per-cursor sprite state every backend keeps: the option, the optional `shapeFor`, and a
910
+ * reader only when something may read the CSS (`shape: 'auto'`, the default). `pick(hit)` is
911
+ * called on frames where the placement is active, and returns the sprite descriptor
912
+ * (resolveCursorSprite): `{kind: 'none'}`, a keyword shape, or a ready url() image.
913
+ */
914
+ export class CursorShapeSelector {
915
+ /**
916
+ * @param {{canvas: Element, scope: string}} pointer
917
+ * @param {{shape?: string, shapeFor?: Function, images?: CursorImageCache}} [opts]
918
+ */
919
+ constructor(pointer, { shape = 'auto', shapeFor = null, images = cursorImageCache } = {}) {
920
+ if (!CURSOR_SHAPE_OPTIONS.includes(shape)) {
921
+ throw new Error(`@displayxr/inline3d: cursor.shape "${shape}" — expected ${CURSOR_SHAPE_OPTIONS.join(', ')}.`);
922
+ }
923
+ this.option = shape;
924
+ this.shapeFor = typeof shapeFor === 'function' ? shapeFor : null;
925
+ this.images = images;
926
+ this.reader = shape === 'auto' ? new CursorCssReader(pointer) : null;
927
+ /** The sprite drawn last (diagnostics): its descriptor. */
928
+ this.sprite = spriteForName(shape === 'auto' ? 'arrow' : shape);
929
+ }
930
+
931
+ /** The sprite drawn last, as a label: a shape name, 'none', or 'image'. */
932
+ get current() {
933
+ return this.sprite.kind === 'shape' ? this.sprite.name : this.sprite.kind;
934
+ }
935
+
936
+ /** @param {{u: number, v: number, point: number[]|null}} hit */
937
+ pick(hit, nowMs) {
938
+ const cssValue = this.reader ? this.reader.value(nowMs) : null;
939
+ const auto = cssValue === null ? null : resolveCursorSprite(cssValue, this.images);
940
+ const label = auto ? (auto.kind === 'shape' ? auto.name : auto.kind) : null;
941
+ let mine;
942
+ if (this.shapeFor) {
943
+ try {
944
+ mine = this.shapeFor({ ...hit, css: label, cssValue });
945
+ } catch (err) {
946
+ if (!this._warned) {
947
+ this._warned = true;
948
+ console.warn('[inline3d] cursor.shapeFor threw; using the page cursor.', err);
949
+ }
950
+ }
951
+ }
952
+ const ok = (s) => s === 'none' || CURSOR_SHAPES.includes(s);
953
+ if (ok(mine)) this.sprite = spriteForName(mine);
954
+ else if (ok(this.option)) this.sprite = spriteForName(this.option);
955
+ else this.sprite = auto || spriteForName('arrow');
956
+ return this.sprite;
957
+ }
958
+
959
+ dispose() {
960
+ this.reader?.dispose();
961
+ }
962
+ }