@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.
@@ -15,7 +15,11 @@
15
15
  // disparity is past the draw threshold (-0.005 baselines, and >= 0.5 px; cursorShouldDraw), the
16
16
  // OS cursor hidden exactly then;
17
17
  // - the same filter (fast rise, slow sink) and the same arrow (CURSOR_ARROW, tip = hotspot, the
18
- // OS arrow's 19 CSS px).
18
+ // OS arrow's 19 CSS px);
19
+ // - the same crosstalk softening (Amendment 2 §B.7, cursorSoften): a lifted arrow is blurred by
20
+ // 0.1 × its ghost offset (here: its disparity, the two halves being the two views) and faded
21
+ // toward 0.5, both zero on the glass. Rasterised into the buffer from the core's 13-tap kernel
22
+ // (cursorSoftArrowRaster) — inside the woven pixels, never a CSS filter or CSS opacity.
19
23
  // What is specific to a pair: it has no 3D views, so it works in tile PIXEL disparity — OUTPUT
20
24
  // pixels of one eye of the woven buffer, + = crossed (in front of the glass), the sign the rest of
21
25
  // camera/ uses — and converts to the core's eye-baseline units through the panel's pixel pitch:
@@ -25,12 +29,18 @@
25
29
 
26
30
  import {
27
31
  CursorPointer,
32
+ CursorShapeSelector,
28
33
  cursorArrowFootprint,
29
- cursorArrowInset,
34
+ cursorShapePolys,
35
+ cursorShapeRaster,
36
+ cursorSoftenPixels,
37
+ drawCursorShape2D,
30
38
  cursorDefaultHeight,
31
39
  cursorFilterStep,
32
40
  cursorShouldDraw,
41
+ cursorSoften,
33
42
  cursorTarget,
43
+ resolveCursorSoften,
34
44
  CURSOR_ARROW,
35
45
  CURSOR_ARROW_CSS_PX,
36
46
  CURSOR_DEFAULT_TUNING,
@@ -211,44 +221,114 @@ export function pairCursorTarget(contentPx, pxPerBaseline, tuning = CURSOR_DEFAU
211
221
  return -cursorTarget(tuning, true, -contentPx / pxPerBaseline) * pxPerBaseline || 0;
212
222
  }
213
223
 
214
- /** The arrow's fill, inset by one output px (at least 1/19 of its height), cached per size. */
215
- const _fillBySize = new Map();
216
- function arrowFill(sizePx) {
224
+ /** The shape's outline + fill inset by one output px (at least 1/19 of its height), cached per size. */
225
+ const _polysBySize = new Map();
226
+ function shapePolys(name, sizePx) {
217
227
  const d = sizePx > 0 ? Math.max(1 / 19, 1 / sizePx) : 1 / 19;
218
- const key = Math.round(d * 1e4);
219
- let f = _fillBySize.get(key);
228
+ const key = `${name}:${Math.round(d * 1e4)}`;
229
+ let f = _polysBySize.get(key);
220
230
  if (!f) {
221
- if (_fillBySize.size > 32) _fillBySize.clear();
222
- _fillBySize.set(key, (f = cursorArrowInset(d)));
231
+ if (_polysBySize.size > 64) _polysBySize.clear();
232
+ _polysBySize.set(key, (f = cursorShapePolys(name, d)));
223
233
  }
224
234
  return f;
225
235
  }
226
236
 
227
237
  /**
228
- * Draw the arrow into both halves of a woven buffer: the left eye's copy `disparityPx / 2` right of
229
- * the pointer, the right eye's as far left (crossed = in front). The shared arrow: its dark outline
230
- * polygon, then the white fill inset by a px; the tip on the hotspot. Each copy is clipped to its
231
- * own half, so an arrow at the edge never bleeds into the other eye.
232
- * @param {number} sizePx arrow height in output px
238
+ * Draw the cursor into both halves of a woven buffer: the left eye's copy `disparityPx / 2` right of
239
+ * the pointer, the right eye's as far left (crossed = in front). The shared sprite (the arrow by
240
+ * default, or `shape`): its dark outline, then the white fill inset by a px; the hotspot on the
241
+ * pointer. Each copy is clipped to its own half, so a sprite at the edge never bleeds into the
242
+ * other eye.
243
+ * @param {number} sizePx sprite height (one arrow height) in output px
244
+ * @param {string} [shape='arrow'] a CURSOR_SHAPES name
233
245
  */
234
- export function drawPairArrow(g, outW, outH, u, v, disparityPx, sizePx) {
246
+ export function drawPairArrow(g, outW, outH, u, v, disparityPx, sizePx, shape = 'arrow') {
235
247
  const cy = v * outH;
236
- const fill = arrowFill(sizePx);
248
+ const polys = shapePolys(shape, sizePx);
237
249
  for (const eye of [0, 1]) {
238
250
  const cx = eye * outW + u * outW + (eye === 0 ? disparityPx / 2 : -disparityPx / 2);
239
251
  g.save();
240
252
  g.beginPath();
241
253
  g.rect(eye * outW, 0, outW, outH);
242
254
  g.clip();
243
- for (const [poly, style] of [[CURSOR_ARROW, '#000'], [fill, '#fff']]) {
244
- g.beginPath();
245
- poly.forEach(([x, y], k) => (k ? g.lineTo : g.moveTo).call(g, cx + x * sizePx, cy + y * sizePx));
246
- g.closePath();
247
- g.fillStyle = style;
248
- g.fill();
255
+ drawCursorShape2D(g, shape, cx, cy, sizePx, polys);
256
+ g.restore();
257
+ }
258
+ }
259
+
260
+ // A scratch 2D canvas for the softened arrow (drawImage blends and honours the clip; putImageData
261
+ // would do neither). Null where no canvas can be made — then the crisp arrow is drawn.
262
+ let _scratch = null;
263
+ function scratchCanvas(w, h) {
264
+ if (!_scratch || (typeof OffscreenCanvas === 'function' && !(_scratch instanceof OffscreenCanvas))) {
265
+ try {
266
+ if (typeof OffscreenCanvas === 'function') _scratch = new OffscreenCanvas(1, 1);
267
+ else if (globalThis.document && document.createElement) _scratch = document.createElement('canvas');
268
+ } catch {
269
+ _scratch = null;
249
270
  }
271
+ }
272
+ if (!_scratch || typeof _scratch.getContext !== 'function') return null;
273
+ if (_scratch.width < w) _scratch.width = w;
274
+ if (_scratch.height < h) _scratch.height = h;
275
+ return _scratch.getContext('2d');
276
+ }
277
+
278
+ /**
279
+ * The SOFTENED arrow into both halves (ADR-046 Amendment 2 §B.7): as drawPairArrow — same
280
+ * placement, same tip, same clip to each half — but rasterised from the core's 13-tap blur of the
281
+ * arrow with `radius` (arrow heights) and `alpha` (opacity). Returns false (nothing drawn) when no
282
+ * scratch canvas is available, so the caller can fall back to the crisp arrow.
283
+ */
284
+ export function drawPairArrowSoft(g, outW, outH, u, v, disparityPx, sizePx, radius, alpha, shape = 'arrow') {
285
+ const cy = v * outH;
286
+ const polys = shapePolys(shape, sizePx);
287
+ const rasters = [0, 1].map((eye) => {
288
+ const cx = eye * outW + u * outW + (eye === 0 ? disparityPx / 2 : -disparityPx / 2);
289
+ return cursorShapeRaster(shape, cx, cy, sizePx, radius, alpha, [1, 1, 1, 1], [0, 0, 0, 1], polys);
290
+ });
291
+ const sg = scratchCanvas(Math.max(rasters[0].width, rasters[1].width), Math.max(rasters[0].height, rasters[1].height));
292
+ if (!sg || typeof sg.putImageData !== 'function' || typeof ImageData !== 'function') return false;
293
+ for (const eye of [0, 1]) {
294
+ const r = rasters[eye];
295
+ sg.putImageData(new ImageData(r.data, r.width, r.height), 0, 0);
296
+ g.save();
297
+ g.beginPath();
298
+ g.rect(eye * outW, 0, outW, outH);
299
+ g.clip();
300
+ g.drawImage(sg.canvas, 0, 0, r.width, r.height, r.x0, r.y0, r.width, r.height);
301
+ g.restore();
302
+ }
303
+ return true;
304
+ }
305
+
306
+ /**
307
+ * A url() image cursor into both halves: at its CSS size (one sprite unit = `sizePx` output px per
308
+ * 19 CSS px), its hotspot on the pointer, softened in the PIXELS (cursorSoftenPixels: `radiusPx`
309
+ * blur, `alpha` opacity) — never a CSS filter or opacity. Returns false when no scratch canvas.
310
+ */
311
+ export function drawPairImage(g, outW, outH, u, v, disparityPx, sizePx, sprite, radiusPx, alpha) {
312
+ const k = sizePx / 19; // output px per CSS px
313
+ const w = Math.max(1, Math.round(sprite.width * k)), h = Math.max(1, Math.round(sprite.height * k));
314
+ const sg = scratchCanvas(w + 2 * (Math.ceil(radiusPx) + 2), h + 2 * (Math.ceil(radiusPx) + 2));
315
+ if (!sg || typeof sg.getImageData !== 'function' || typeof ImageData !== 'function') return false;
316
+ sg.clearRect(0, 0, sg.canvas.width, sg.canvas.height);
317
+ sg.drawImage(sprite.entry.image, 0, 0, w, h);
318
+ const soft = cursorSoftenPixels(sg.getImageData(0, 0, w, h).data, w, h, radiusPx, alpha);
319
+ sg.clearRect(0, 0, sg.canvas.width, sg.canvas.height);
320
+ sg.putImageData(new ImageData(soft.data, soft.width, soft.height), 0, 0);
321
+ const cy = v * outH - sprite.hotspot[1] * k - soft.pad;
322
+ for (const eye of [0, 1]) {
323
+ const cx = eye * outW + u * outW + (eye === 0 ? disparityPx / 2 : -disparityPx / 2) - sprite.hotspot[0] * k - soft.pad;
324
+ g.save();
325
+ g.beginPath();
326
+ g.rect(eye * outW, 0, outW, outH);
327
+ g.clip();
328
+ g.drawImage(sg.canvas, 0, 0, soft.width, soft.height, Math.round(cx), Math.round(cy), soft.width, soft.height);
250
329
  g.restore();
251
330
  }
331
+ return true;
252
332
  }
253
333
 
254
334
  /**
@@ -258,8 +338,12 @@ export function drawPairArrow(g, outW, outH, u, v, disparityPx, sizePx) {
258
338
  */
259
339
  export class PairCursor {
260
340
  /** @param {HTMLElement} canvas the tile — the pointer counts over its box */
261
- constructor(canvas, { height, pointerScope = 'window' } = {}) {
341
+ constructor(canvas, { height, pointerScope = 'window', soften, shape = 'auto', shapeFor = null } = {}) {
262
342
  this.pointer = new CursorPointer(canvas, { scope: pointerScope });
343
+ /** Which sprite (CSS mirror / fixed / shapeFor); `.current` = the one drawn last. */
344
+ this.shapes = new CursorShapeSelector(this.pointer, { shape, shapeFor });
345
+ /** Crosstalk softening parameters (resolveCursorSoften). */
346
+ this.soften = resolveCursorSoften(soften, '@displayxr/inline3d pair cursor');
263
347
  // A fraction of the tile height when given; by default the OS arrow's size (CSS px → tile).
264
348
  this.height = height > 0 ? height : null;
265
349
  this.filter = {};
@@ -271,7 +355,7 @@ export class PairCursor {
271
355
  this.metersPerCssPx = null;
272
356
  /** The eye separation (metres) the baseline units convert with: displayMetrics' (a default today). */
273
357
  this.eyeSeparationM = DISPLAY_METRICS_DEFAULTS.eyeSeparationM;
274
- /** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx, filteredPx, drawn, pxPerBaseline }` (output px, + = in front) or null. */
358
+ /** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx, filteredPx, drawn, pxPerBaseline, soften }` (output px, + = in front) or null. */
275
359
  this.last = null;
276
360
  }
277
361
 
@@ -308,9 +392,27 @@ export class PairCursor {
308
392
  const d = cursorFilterStep(this.filter, CURSOR_DEFAULT_TUNING, target, nowSec);
309
393
  const drawn = cursorShouldDraw(d, pxPerBaseline);
310
394
  const disparity = -d * pxPerBaseline; // output px, + = in front
311
- if (drawn) drawPairArrow(g, geo.outW, geo.outH, u, v, disparity, hf * geo.outH);
395
+ let soften = null;
396
+ let shape = null;
397
+ if (drawn) {
398
+ // Softening in PANEL px: one output px of an eye spans (tile CSS width × dpr) / outW of them.
399
+ // The ghost offset of a pair IS its disparity (the two halves are the two views).
400
+ const sizePx = hf * geo.outH;
401
+ const dpr = globalThis.devicePixelRatio > 0 ? globalThis.devicePixelRatio : 1;
402
+ const k = r && r.width > 0 ? (r.width * dpr) / geo.outW : 1;
403
+ const spritePx = r && r.height > 0 ? hf * r.height * dpr : sizePx;
404
+ const s = cursorSoften(d, disparity * k, spritePx, this.soften);
405
+ const sprite = this.shapes.pick({ u, v, point: null }, nowSec * 1000);
406
+ shape = this.shapes.current;
407
+ soften = { blurPx: s.blurPx, alpha: s.alpha, ghostPx: Math.abs(disparity * k), spritePx, radius: spritePx > 0 ? s.blurPx / spritePx : 0, soft: s.blurPx > 0 || s.alpha < 1 };
408
+ if (sprite.kind === 'image') {
409
+ drawPairImage(g, geo.outW, geo.outH, u, v, disparity, sizePx, sprite, soften.soft ? soften.radius * sizePx : 0, soften.soft ? s.alpha : 1);
410
+ } else if (sprite.kind === 'shape' && (!soften.soft || !drawPairArrowSoft(g, geo.outW, geo.outH, u, v, disparity, sizePx, soften.radius, s.alpha, shape))) {
411
+ drawPairArrow(g, geo.outW, geo.outH, u, v, disparity, sizePx, shape);
412
+ } // 'none': the page hides its cursor; draw nothing (the OS cursor stays hidden)
413
+ }
312
414
  this.pointer.hideCss(drawn); // the OS arrow IS the cursor on the plane
313
- this.last = { contentPx: this._content, targetPx: -target * pxPerBaseline || 0, disparityPx: drawn ? disparity : 0, filteredPx: disparity || 0, drawn, pxPerBaseline };
415
+ this.last = { contentPx: this._content, targetPx: -target * pxPerBaseline || 0, disparityPx: drawn ? disparity : 0, filteredPx: disparity || 0, drawn, pxPerBaseline, soften, shape };
314
416
  return this.last;
315
417
  }
316
418
 
@@ -345,5 +447,6 @@ export class PairCursor {
345
447
 
346
448
  dispose() {
347
449
  this.pointer.dispose();
450
+ this.shapes.dispose();
348
451
  }
349
452
  }
@@ -0,0 +1,147 @@
1
+ // inline3d-cursor-bvh.js — the depth cursor's pick, BVH-accelerated, for three.js scenes.
2
+ //
3
+ // Loaded ONLY when a page opts into `cursor: 'depth'` (./model imports it dynamically on opt-in;
4
+ // ADR-046 §0: zero cost, no download otherwise). It brings the vendored MeshBVH slice of
5
+ // three-mesh-bvh (js/vendor/three-mesh-bvh.js, MIT), which imports the page's own `three`.
6
+ //
7
+ // Same answer as three's Raycaster.intersectObjects(targets, true)[0] — the nearest hit, the same
8
+ // layer test, the same traversal (an object's raycast returning false stops its subtree) — but each
9
+ // plain Mesh is tested against a bounds tree instead of every triangle. Nothing on the page is
10
+ // touched: no prototype patching, no `geometry.boundsTree`, no index reordering (`indirect: true`);
11
+ // the trees live in a WeakMap keyed by geometry.
12
+ //
13
+ // - Built lazily, the first time a ray reaches a mesh's bounding sphere, within a small time
14
+ // budget a frame (one big mesh may exceed it once; it cannot be split). A mesh not built yet is
15
+ // raycast the plain way meanwhile, so the answer never waits for a build.
16
+ // - CPU-animated vertices (the position attribute's version moves): the tree is refitted.
17
+ // A replaced attribute, a changed vertex count or index: rebuilt.
18
+ // - Skinned, morphing, instanced or batched meshes, a partial drawRange, and anything that is not
19
+ // a Mesh (points, lines, sprites, a splat's own raycast): three's own raycast, as before —
20
+ // its answer follows the deformation; a static tree would not.
21
+
22
+ import { MeshBVH } from './vendor/three-mesh-bvh.js';
23
+
24
+ /** Build time allowed per ~frame (ms) before further builds wait for the next one. */
25
+ const BUILD_BUDGET_MS = 4;
26
+ const FRAME_MS = 16;
27
+
28
+ /**
29
+ * A hit test for ./three's DepthCursor over `targets` (searched recursively): the nearest hit along
30
+ * a WORLD ray, as a world point, or null.
31
+ * @param {object} THREE the page's three.js namespace.
32
+ * @param {object[]} targets Object3Ds that count as content.
33
+ * @returns {((origin: number[], direction: number[]) => number[]|null) & {info: () => object}}
34
+ */
35
+ export function bvhHitTest(THREE, targets, { now = () => (globalThis.performance ? globalThis.performance.now() : Date.now()) } = {}) {
36
+ const raycaster = new THREE.Raycaster();
37
+ raycaster.firstHitOnly = true; // MeshBVH.raycastObject3D: the nearest hit only
38
+ const o = new THREE.Vector3();
39
+ const d = new THREE.Vector3();
40
+ const sphere = new THREE.Sphere();
41
+ const trees = new WeakMap(); // geometry → { bvh, pos, idx, count, posVersion, idxVersion } | false (cannot)
42
+ const hits = [];
43
+ const info = { mode: 'bvh', built: 0, refits: 0, failed: 0, buildMs: 0, plainCasts: 0 };
44
+ let windowStart = -Infinity;
45
+ let spent = 0;
46
+ const best = [0, 0, 0];
47
+ let bestDist = Infinity;
48
+
49
+ function treeFor(mesh) {
50
+ if (mesh.isSkinnedMesh || mesh.isInstancedMesh || mesh.isBatchedMesh) return null;
51
+ const g = mesh.geometry;
52
+ if (!g || !g.attributes) return null;
53
+ const pos = g.attributes.position;
54
+ if (!pos) return null;
55
+ if (g.morphAttributes && g.morphAttributes.position && g.morphAttributes.position.length) return null;
56
+ const idx = g.index;
57
+ const full = idx ? idx.count : pos.count;
58
+ const dr = g.drawRange;
59
+ if (dr && (dr.start > 0 || (dr.count !== Infinity && dr.count < full))) return null;
60
+ let t = trees.get(g);
61
+ if (t === false) return null;
62
+ if (t) {
63
+ if (t.pos !== pos || t.idx !== idx || t.count !== pos.count || (idx && idx.version !== t.idxVersion)) {
64
+ trees.delete(g); // replaced or resized: rebuild below
65
+ t = undefined;
66
+ } else {
67
+ if (pos.version !== t.posVersion) {
68
+ t.bvh.refit(); // vertices animated on the CPU
69
+ t.posVersion = pos.version;
70
+ info.refits++;
71
+ }
72
+ return t;
73
+ }
74
+ }
75
+ const t0 = now();
76
+ if (t0 - windowStart >= FRAME_MS) {
77
+ windowStart = t0;
78
+ spent = 0;
79
+ }
80
+ if (spent >= BUILD_BUDGET_MS) return null; // plain raycast this time; built on a later frame
81
+ try {
82
+ const bvh = new MeshBVH(g, { indirect: true, setBoundingBox: false, verbose: false });
83
+ t = { bvh, pos, idx, count: pos.count, posVersion: pos.version, idxVersion: idx ? idx.version : 0 };
84
+ trees.set(g, t);
85
+ info.built++;
86
+ } catch (err) {
87
+ trees.set(g, false); // e.g. a three.js older than the slice supports: plain raycast for good
88
+ info.failed++;
89
+ t = null;
90
+ }
91
+ const dt = now() - t0;
92
+ spent += dt;
93
+ info.buildMs += dt;
94
+ return t;
95
+ }
96
+
97
+ function castOne(obj) {
98
+ hits.length = 0;
99
+ let result;
100
+ const t = obj.isMesh ? treeFor(obj) : null;
101
+ if (t) {
102
+ const g = obj.geometry;
103
+ if (g.boundingSphere === null) g.computeBoundingSphere(); // as Mesh.raycast does
104
+ sphere.copy(g.boundingSphere).applyMatrix4(obj.matrixWorld);
105
+ if (!raycaster.ray.intersectsSphere(sphere)) return undefined;
106
+ t.bvh.raycastObject3D(obj, raycaster, hits);
107
+ } else {
108
+ if (obj.isMesh || obj.isPoints || obj.isLine || obj.isSprite) info.plainCasts++; // drawables only, not groups
109
+ result = obj.raycast(raycaster, hits);
110
+ }
111
+ for (let i = 0; i < hits.length; i++) {
112
+ const h = hits[i];
113
+ if (h.distance < bestDist) {
114
+ bestDist = h.distance;
115
+ best[0] = h.point.x;
116
+ best[1] = h.point.y;
117
+ best[2] = h.point.z;
118
+ }
119
+ }
120
+ return result;
121
+ }
122
+
123
+ // Raycaster.intersectObject's traversal: the layer test, and `false` from raycast stops the subtree.
124
+ function visit(obj) {
125
+ let propagate = true;
126
+ if (obj.layers.test(raycaster.layers)) {
127
+ if (castOne(obj) === false) propagate = false;
128
+ }
129
+ if (propagate) {
130
+ const c = obj.children;
131
+ for (let i = 0; i < c.length; i++) visit(c[i]);
132
+ }
133
+ }
134
+
135
+ const hitTest = (origin, direction) => {
136
+ o.set(origin[0], origin[1], origin[2]);
137
+ d.set(direction[0], direction[1], direction[2]).normalize();
138
+ raycaster.set(o, d);
139
+ bestDist = Infinity;
140
+ for (let i = 0; i < targets.length; i++) visit(targets[i]);
141
+ hits.length = 0;
142
+ return bestDist < Infinity ? [best[0], best[1], best[2]] : null;
143
+ };
144
+ /** Diagnostics: `{ mode: 'bvh', built, refits, failed, buildMs, plainCasts }` (cumulative). */
145
+ hitTest.info = () => ({ ...info });
146
+ return hitTest;
147
+ }