@nakednous/tree 0.0.26 → 0.0.28

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.
package/dist/index.js CHANGED
@@ -44,6 +44,59 @@ const DIAL = 3;
44
44
  const POINT = 0;
45
45
  const DIRECTION = 1;
46
46
 
47
+ // Pointer-hit shapes (pointerHit); bullsEyeLines' shape
48
+ const CIRCLE = 0;
49
+ const SQUARE = 1;
50
+
51
+ // Gizmo bits — one namespace per generator. The same value means different
52
+ // things to different generators, and no generator reads another's bits.
53
+ const NONE = 0;
54
+
55
+ // axesLines
56
+ const X = 1 << 0;
57
+ const _X = 1 << 1;
58
+ const Y = 1 << 2;
59
+ const _Y = 1 << 3;
60
+ const Z = 1 << 4;
61
+ const _Z = 1 << 5;
62
+ const LABELS = 1 << 6;
63
+
64
+ // frustumLines (LEFT … TOP also key a bounds object's planes)
65
+ const NEAR = 1 << 0;
66
+ const FAR = 1 << 1;
67
+ const LEFT = 1 << 2;
68
+ const RIGHT = 1 << 3;
69
+ const BOTTOM = 1 << 4;
70
+ const TOP = 1 << 5;
71
+ const BODY = 1 << 6;
72
+ const APEX = 1 << 7;
73
+
74
+ // pathLines
75
+ const PATH = 1 << 0;
76
+ const CENTER = 1 << 1;
77
+ const CONTROLS = 1 << 2;
78
+ const TANGENTS_IN = 1 << 3;
79
+ const TANGENTS_OUT = 1 << 4;
80
+ const TANGENTS = TANGENTS_IN | TANGENTS_OUT;
81
+ const HANDLES = 1 << 5;
82
+
83
+ // helmRigLines
84
+ const TRANSLATE = 1 << 0;
85
+ const ROTATE = 1 << 1;
86
+
87
+ // locusLines (HANDLE is the bridge's dot, not a line)
88
+ const HANDLE = 1 << 0;
89
+ const AIM = 1 << 1;
90
+ const LOCUS = 1 << 2;
91
+ const RING = 1 << 3;
92
+
93
+ // Semantic palette — normalised RGBA: red, lime, dodger blue; COLOR_DIM is
94
+ // the alpha of a dimmed stroke (the helm rig's baseline).
95
+ const COLOR_X = Object.freeze([1, 0, 0, 1]);
96
+ const COLOR_Y = Object.freeze([0, 1, 0, 1]);
97
+ const COLOR_Z = Object.freeze([30 / 255, 144 / 255, 1, 1]);
98
+ const COLOR_DIM = 110 / 255;
99
+
47
100
  /**
48
101
  * @file Quaternion algebra and mat4/mat3 conversions.
49
102
  * @module tree/quat
@@ -207,6 +260,10 @@ const qFromAxisAngle = (out, ax, ay, az, angle) => {
207
260
 
208
261
  /**
209
262
  * Build a quaternion from a look direction (−Z forward) and optional up (default +Y).
263
+ * `dir` need not be unit. Right = dir × up, up re-orthogonalised as right × dir;
264
+ * when `dir` is parallel to `up` the up hint is re-seeded from the world axis
265
+ * least aligned with `dir` (the same seed qFromUnitVectors uses), so every
266
+ * direction yields a proper rotation.
210
267
  * @param {number[]} out
211
268
  * @param {number[]} dir Forward direction [x,y,z].
212
269
  * @param {number[]} [up] Up vector [x,y,z].
@@ -217,11 +274,18 @@ const qFromLookDir = (out, dir, up) => {
217
274
  const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
218
275
  fx/=fl; fy/=fl; fz/=fl;
219
276
  let ux=up?up[0]:0, uy=up?up[1]:1, uz=up?up[2]:0;
220
- let rx=uy*fz-uz*fy, ry=uz*fx-ux*fz, rz=ux*fy-uy*fx;
221
- const rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1;
277
+ let rx=fy*uz-fz*uy, ry=fz*ux-fx*uz, rz=fx*uy-fy*ux; // right = dir × up
278
+ let rl=Math.sqrt(rx*rx+ry*ry+rz*rz);
279
+ if (rl < 1e-8) { // dir ∥ up: re-seed up
280
+ const ax=Math.abs(fx), ay=Math.abs(fy), az=Math.abs(fz);
281
+ ux=0; uy=0; uz=0;
282
+ if (ax <= ay && ax <= az) ux=1; else if (ay <= az) uy=1; else uz=1;
283
+ rx=fy*uz-fz*uy; ry=fz*ux-fx*uz; rz=fx*uy-fy*ux;
284
+ rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1;
285
+ }
222
286
  rx/=rl; ry/=rl; rz/=rl;
223
- ux=fy*rz-fz*ry; uy=fz*rx-fx*rz; uz=fx*ry-fy*rx;
224
- return qFromRotMat3x3(out, rx,ry,rz, ux,uy,uz, -fx,-fy,-fz);
287
+ ux=ry*fz-rz*fy; uy=rz*fx-rx*fz; uz=rx*fy-ry*fx; // up = right × dir
288
+ return qFromRotMat3x3(out, rx,ux,-fx, ry,uy,-fy, rz,uz,-fz); // columns: right, up, back
225
289
  };
226
290
 
227
291
  /**
@@ -293,6 +357,117 @@ const qToAxisAngle = (q, out) => {
293
357
  return out;
294
358
  };
295
359
 
360
+ /**
361
+ * @file 1€ input filter — speed-adaptive first-order low-pass for noisy input.
362
+ * @module tree/filter
363
+ * @license AGPL-3.0-only
364
+ *
365
+ * A jitter conditioner for noisy / absolute input streams (the helm's optional
366
+ * `filter` slot; sketch-side handle conditioning). It is NOT a deadzone
367
+ * replacement: a low-pass passes DC, so a constant rest bias survives it and
368
+ * still integrates to creep — only the deadzone's exact-zero clamp removes that.
369
+ * The 1€ removes zero-mean jitter; the two are orthogonal and coexist, applied
370
+ * filter → deadzone (condition the continuous signal first, then gate to zero).
371
+ *
372
+ * First-order low-pass whose cutoff rises with the signal's speed: slow (rest)
373
+ * → low cutoff → heavy smoothing; fast (motion) → high cutoff → low lag. Tuned
374
+ * by `minCutoff` (Hz, the rest cutoff) and `beta` (how fast the cutoff opens
375
+ * with speed). Reimplemented from the paper's equations — `tree` is zero-dep, so
376
+ * the ~one-page primitive is vendored, not depended on (the same call made for
377
+ * quaternions over gl-matrix).
378
+ *
379
+ * Reference: Casiez, G., Roussel, N., & Vogel, D. (2012). 1€ Filter: A Simple
380
+ * Speed-based Low-pass Filter for Noisy Input in Interactive Systems. CHI '12,
381
+ * 2527–2530. DOI 10.1145/2207676.2208639 · https://gery.casiez.net/1euro/
382
+ */
383
+
384
+
385
+ /**
386
+ * Build a stateful 1€ filter as an out-first carrying function.
387
+ *
388
+ * The returned `f` carries the previous filtered value and derivative across
389
+ * calls (one state slot per vector component), and is used in one of two forms:
390
+ *
391
+ * - vec form `f(out, raw, dt)` — `out`/`raw` are equal-length ArrayLikes;
392
+ * writes the filtered vector into `out` and returns it.
393
+ * Zero-allocation after the first call (state sized to `raw`).
394
+ * - scalar form `f(raw, dt)` — `raw` is a number; returns the filtered number.
395
+ *
396
+ * The form is chosen by the first argument's type (number ⇒ scalar). `minCutoff`,
397
+ * `beta`, and `dCutoff` are live-mutable on the returned function (`f.minCutoff`,
398
+ * `f.beta`, `f.dCutoff`) and read every call, so a panel can tune them against
399
+ * live noise. `f.reset()` drops the carried state at a discontinuity (a
400
+ * re-acquired source, a helm `home()`); smear across one and the output lurches.
401
+ *
402
+ * @param {Object} [opts]
403
+ * @param {number} [opts.minCutoff=1] Minimum (rest) cutoff frequency, in Hz.
404
+ * @param {number} [opts.beta=0] Speed coefficient (cutoff = minCutoff +
405
+ * beta·|filtered derivative|); unitless.
406
+ * @param {number} [opts.dCutoff=1] Derivative cutoff frequency, in Hz.
407
+ * @returns {Function} A carrying filter `f(out, raw, dt)` / `f(raw, dt)` with a
408
+ * `reset()` method and live `minCutoff` / `beta` / `dCutoff`.
409
+ */
410
+ function oneEuro(opts) {
411
+ opts = opts || {};
412
+
413
+ // Carried state: previous filtered value + previous filtered derivative.
414
+ // number (scalar form) | number[] (vec form). primed = state is seeded.
415
+ let xPrev = null;
416
+ let dxPrev = null;
417
+ let primed = false;
418
+
419
+ // Low-pass smoothing factor for a sample period `dt` and a cutoff frequency
420
+ // (the paper's α = 1 / (1 + τ/Te), τ = 1 / (2π·cutoff)).
421
+ const alpha = (dt, cutoff) => {
422
+ const tau = 1 / (2 * Math.PI * cutoff);
423
+ return 1 / (1 + tau / dt);
424
+ };
425
+ const smooth = (a, x, xp) => a * x + (1 - a) * xp;
426
+
427
+ const scalar = (x, dt) => {
428
+ if (!primed) { xPrev = x; dxPrev = 0; primed = true; return x; }
429
+ const dx = (x - xPrev) / dt;
430
+ const edx = smooth(alpha(dt, f.dCutoff), dx, dxPrev);
431
+ const a = alpha(dt, f.minCutoff + f.beta * Math.abs(edx));
432
+ const xh = smooth(a, x, xPrev);
433
+ xPrev = xh; dxPrev = edx;
434
+ return xh;
435
+ };
436
+
437
+ const vec = (out, raw, dt) => {
438
+ const n = raw.length;
439
+ if (!primed) {
440
+ if (!Array.isArray(xPrev) || xPrev.length !== n) {
441
+ xPrev = new Array(n); dxPrev = new Array(n);
442
+ }
443
+ for (let i = 0; i < n; i++) { xPrev[i] = raw[i]; dxPrev[i] = 0; out[i] = raw[i]; }
444
+ primed = true;
445
+ return out;
446
+ }
447
+ for (let i = 0; i < n; i++) {
448
+ const dx = (raw[i] - xPrev[i]) / dt;
449
+ const edx = smooth(alpha(dt, f.dCutoff), dx, dxPrev[i]);
450
+ const a = alpha(dt, f.minCutoff + f.beta * Math.abs(edx));
451
+ const xh = smooth(a, raw[i], xPrev[i]);
452
+ xPrev[i] = xh; dxPrev[i] = edx;
453
+ out[i] = xh;
454
+ }
455
+ return out;
456
+ };
457
+
458
+ const f = (out, raw, dt) =>
459
+ (typeof out === 'number') ? scalar(out, raw) : vec(out, raw, dt);
460
+
461
+ f.minCutoff = (opts.minCutoff != null) ? opts.minCutoff : 1;
462
+ f.beta = (opts.beta != null) ? opts.beta : 0;
463
+ f.dCutoff = (opts.dCutoff != null) ? opts.dCutoff : 1;
464
+
465
+ /** Drop the carried state — call at a discontinuity (re-acquire / home). */
466
+ f.reset = () => { primed = false; return f; };
467
+
468
+ return f;
469
+ }
470
+
296
471
  /**
297
472
  * @file Matrix construction from geometric specs and partial decomposition.
298
473
  * @module tree/form
@@ -352,6 +527,34 @@ function mat4FromBasis(out, rx,ry,rz, ux,uy,uz, fx,fy,fz, tx,ty,tz) {
352
527
  return out;
353
528
  }
354
529
 
530
+ // Lookat basis scratch — right (0–2), up (3–5), back (6–8): unit, world space.
531
+ const _lb = new Float64Array(9);
532
+
533
+ /**
534
+ * Orthonormal lookat basis into `_lb`: back = eye − center, right = up × back,
535
+ * up = back × right. When the view direction is parallel to the up hint the
536
+ * hint is re-seeded from the world axis least aligned with it — the seed
537
+ * qFromUnitVectors and qFromLookDir use — so the basis is always a rotation.
538
+ */
539
+ function _lookBasis(ex,ey,ez, cx,cy,cz, ux,uy,uz) {
540
+ let zx=ex-cx, zy=ey-cy, zz=ez-cz;
541
+ const zl=Math.sqrt(zx*zx+zy*zy+zz*zz)||1;
542
+ zx/=zl; zy/=zl; zz/=zl;
543
+ let xx=uy*zz-uz*zy, xy=uz*zx-ux*zz, xz=ux*zy-uy*zx;
544
+ let xl=Math.sqrt(xx*xx+xy*xy+xz*xz);
545
+ if (xl < 1e-8) { // view ∥ up: re-seed up
546
+ const ax=Math.abs(zx), ay=Math.abs(zy), az=Math.abs(zz);
547
+ ux=0; uy=0; uz=0;
548
+ if (ax <= ay && ax <= az) ux=1; else if (ay <= az) uy=1; else uz=1;
549
+ xx=uy*zz-uz*zy; xy=uz*zx-ux*zz; xz=ux*zy-uy*zx;
550
+ xl=Math.sqrt(xx*xx+xy*xy+xz*xz)||1;
551
+ }
552
+ xx/=xl; xy/=xl; xz/=xl;
553
+ _lb[0]=xx; _lb[1]=xy; _lb[2]=xz;
554
+ _lb[3]=zy*xz-zz*xy; _lb[4]=zz*xx-zx*xz; _lb[5]=zx*xy-zy*xx;
555
+ _lb[6]=zx; _lb[7]=zy; _lb[8]=zz;
556
+ }
557
+
355
558
  /**
356
559
  * View matrix (world→eye) from lookat parameters.
357
560
  * Camera looks along −Z in eye space; right = normalize(up × (−Z)).
@@ -360,16 +563,12 @@ function mat4FromBasis(out, rx,ry,rz, ux,uy,uz, fx,fy,fz, tx,ty,tz) {
360
563
  * @param {Float32Array|number[]} out 16-element destination.
361
564
  * @param {number} ex,ey,ez Eye (camera) position.
362
565
  * @param {number} cx,cy,cz Look-at target.
363
- * @param {number} ux,uy,uz World up hint (need not be unit).
566
+ * @param {number} ux,uy,uz World up hint (need not be unit; re-seeded when
567
+ * parallel to the view direction).
364
568
  */
365
569
  function mat4View(out, ex,ey,ez, cx,cy,cz, ux,uy,uz) {
366
- let zx=ex-cx, zy=ey-cy, zz=ez-cz;
367
- const zl=Math.sqrt(zx*zx+zy*zy+zz*zz)||1;
368
- zx/=zl; zy/=zl; zz/=zl;
369
- let xx=uy*zz-uz*zy, xy=uz*zx-ux*zz, xz=ux*zy-uy*zx;
370
- const xl=Math.sqrt(xx*xx+xy*xy+xz*xz)||1;
371
- xx/=xl; xy/=xl; xz/=xl;
372
- const yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx;
570
+ _lookBasis(ex,ey,ez, cx,cy,cz, ux,uy,uz);
571
+ const xx=_lb[0],xy=_lb[1],xz=_lb[2], yx=_lb[3],yy=_lb[4],yz=_lb[5], zx=_lb[6],zy=_lb[7],zz=_lb[8];
373
572
  out[0]=xx; out[1]=yx; out[2]=zx; out[3]=0;
374
573
  out[4]=xy; out[5]=yy; out[6]=zy; out[7]=0;
375
574
  out[8]=xz; out[9]=yz; out[10]=zz; out[11]=0;
@@ -388,16 +587,12 @@ function mat4View(out, ex,ey,ez, cx,cy,cz, ux,uy,uz) {
388
587
  * @param {Float32Array|number[]} out 16-element destination.
389
588
  * @param {number} ex,ey,ez Eye position.
390
589
  * @param {number} cx,cy,cz Look-at target.
391
- * @param {number} ux,uy,uz World up hint.
590
+ * @param {number} ux,uy,uz World up hint (need not be unit; re-seeded when
591
+ * parallel to the view direction).
392
592
  */
393
593
  function mat4Eye(out, ex,ey,ez, cx,cy,cz, ux,uy,uz) {
394
- let zx=ex-cx, zy=ey-cy, zz=ez-cz;
395
- const zl=Math.sqrt(zx*zx+zy*zy+zz*zz)||1;
396
- zx/=zl; zy/=zl; zz/=zl;
397
- let xx=uy*zz-uz*zy, xy=uz*zx-ux*zz, xz=ux*zy-uy*zx;
398
- const xl=Math.sqrt(xx*xx+xy*xy+xz*xz)||1;
399
- xx/=xl; xy/=xl; xz/=xl;
400
- const yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx;
594
+ _lookBasis(ex,ey,ez, cx,cy,cz, ux,uy,uz);
595
+ const xx=_lb[0],xy=_lb[1],xz=_lb[2], yx=_lb[3],yy=_lb[4],yz=_lb[5], zx=_lb[6],zy=_lb[7],zz=_lb[8];
401
596
  out[0]=xx; out[1]=xy; out[2]=xz; out[3]=0;
402
597
  out[4]=yx; out[5]=yy; out[6]=yz; out[7]=0;
403
598
  out[8]=zx; out[9]=zy; out[10]=zz; out[11]=0;
@@ -734,8 +929,8 @@ function projRight (p, ndcZMin) { return p[15]===1 ? (1-p[12])/p[0] : projNear
734
929
  */
735
930
  function projTop(p, ndcZMin) {
736
931
  return p[15]===1
737
- ? ( Math.sign(p[5]) - p[13]) / p[5] // ortho
738
- : projNear(p,ndcZMin)*(1+p[9])/p[5]; // perspective (p[5]>0 in practice)
932
+ ? ( Math.sign(p[5]) - p[13]) / p[5] // ortho
933
+ : projNear(p,ndcZMin)*(Math.sign(p[5])+p[9])/p[5]; // perspective
739
934
  }
740
935
 
741
936
  /**
@@ -743,8 +938,8 @@ function projTop(p, ndcZMin) {
743
938
  */
744
939
  function projBottom(p, ndcZMin) {
745
940
  return p[15]===1
746
- ? (-Math.sign(p[5]) - p[13]) / p[5] // ortho
747
- : projNear(p,ndcZMin)*(p[9]-1)/p[5]; // perspective
941
+ ? (-Math.sign(p[5]) - p[13]) / p[5] // ortho
942
+ : projNear(p,ndcZMin)*(p[9]-Math.sign(p[5]))/p[5]; // perspective
748
943
  }
749
944
 
750
945
  /** Vertical field of view in radians (perspective only). */
@@ -772,9 +967,10 @@ function mat4MV(out, model, view) { return mat4Mul(out, view, model); }
772
967
  */
773
968
  function mat4Location(out, from, to) {
774
969
  // Same as: return mat4Invert(out, to) && mat4Mul(out, out, from);
775
- const a00=to[0],a01=to[1],a02=to[2],
776
- a10=to[4],a11=to[5],a12=to[6],
777
- a20=to[8],a21=to[9],a22=to[10];
970
+ // a_rc reads column-major: element (row r, column c) is to[c*4 + r].
971
+ const a00=to[0],a01=to[4],a02=to[8],
972
+ a10=to[1],a11=to[5],a12=to[9],
973
+ a20=to[2],a21=to[6],a22=to[10];
778
974
  const b01=a22*a11-a12*a21, b11=a12*a20-a22*a10, b21=a21*a10-a11*a20;
779
975
  let det=a00*b01+a01*b11+a02*b21;
780
976
  if (Math.abs(det) < 1e-12) return null;
@@ -794,14 +990,17 @@ function mat4Location(out, from, to) {
794
990
  }
795
991
 
796
992
  /**
797
- * Direction transform between frames: out = to₃ · inv(from₃).
798
- * Uses only the upper-left 3×3 blocks (rotation/scale, no translation).
799
- * @returns {ArrayLike<number>|null} out, or null if `from` is singular.
993
+ * Direction transform between frames: out = inv(to₃) · from₃ — a direction's
994
+ * coordinates in `from` become its coordinates in `to`, the same conversion
995
+ * mat4Location and mapDirection perform, on the upper-left 3×3 blocks only
996
+ * (rotation / scale, no translation).
997
+ * @returns {ArrayLike<number>|null} out, or null if `to` is singular.
800
998
  */
801
999
  function mat3Direction(out, from, to) {
802
- const a00=from[0],a01=from[1],a02=from[2],
803
- a10=from[4],a11=from[5],a12=from[6],
804
- a20=from[8],a21=from[9],a22=from[10];
1000
+ // a_rc reads column-major: element (row r, column c) is to[c*4 + r].
1001
+ const a00=to[0],a01=to[4],a02=to[8],
1002
+ a10=to[1],a11=to[5],a12=to[9],
1003
+ a20=to[2],a21=to[6],a22=to[10];
805
1004
  const b01=a22*a11-a12*a21, b11=a12*a20-a22*a10, b21=a21*a10-a11*a20;
806
1005
  let det=a00*b01+a01*b11+a02*b21;
807
1006
  if (Math.abs(det) < 1e-12) return null;
@@ -809,10 +1008,13 @@ function mat3Direction(out, from, to) {
809
1008
  const i00=b01*det, i01=(a02*a21-a22*a01)*det, i02=(a12*a01-a02*a11)*det;
810
1009
  const i10=b11*det, i11=(a22*a00-a02*a20)*det, i12=(a02*a10-a12*a00)*det;
811
1010
  const i20=b21*det, i21=(a01*a20-a21*a00)*det, i22=(a11*a00-a01*a10)*det;
812
- const t00=to[0],t01=to[1],t02=to[2], t10=to[4],t11=to[5],t12=to[6], t20=to[8],t21=to[9],t22=to[10];
813
- out[0]=t00*i00+t10*i01+t20*i02; out[1]=t01*i00+t11*i01+t21*i02; out[2]=t02*i00+t12*i01+t22*i02;
814
- out[3]=t00*i10+t10*i11+t20*i12; out[4]=t01*i10+t11*i11+t21*i12; out[5]=t02*i10+t12*i11+t22*i12;
815
- out[6]=t00*i20+t10*i21+t20*i22; out[7]=t01*i20+t11*i21+t21*i22; out[8]=t02*i20+t12*i21+t22*i22;
1011
+ // out = inv(to₃) · from₃, column by column of from.
1012
+ for (let c = 0; c < 3; c++) {
1013
+ const f0=from[c*4], f1=from[c*4+1], f2=from[c*4+2];
1014
+ out[c*3] = i00*f0 + i01*f1 + i02*f2;
1015
+ out[c*3+1] = i10*f0 + i11*f1 + i12*f2;
1016
+ out[c*3+2] = i20*f0 + i21*f1 + i22*f2;
1017
+ }
816
1018
  return out;
817
1019
  }
818
1020
 
@@ -1010,9 +1212,13 @@ function _worldToScreenDir(out, dx, dy, dz, proj, view, vpW, vpH, ndcZMin) {
1010
1212
  }
1011
1213
 
1012
1214
  function _screenToWorldDir(out, dx, dy, dz, proj, eye, vpW, vpH, ndcZMin) {
1013
- // Inverse of _worldToScreenDir; signed vpW/vpH cancel the y-flip.
1014
- _applyDir(out, eye, dx/(vpW*0.5)/proj[0], dy/(vpH*0.5)/proj[5], dz/((1-ndcZMin)*0.5));
1015
- return out;
1215
+ // Inverse of _worldToScreenDir: undo the viewport scale, then the projection's
1216
+ // upper-triangular 3×3 block (p[8], p[9] carry an off-centre frustum), then
1217
+ // rotate eye→world. Signed vpW/vpH cancel the y-flip.
1218
+ const cz = dz/((1-ndcZMin)*0.5)/proj[10];
1219
+ const cy = (dy/(vpH*0.5) - proj[9]*cz)/proj[5];
1220
+ const cx = (dx/(vpW*0.5) - proj[8]*cz)/proj[0];
1221
+ return _applyDir(out, eye, cx, cy, cz);
1016
1222
  }
1017
1223
 
1018
1224
  function _screenToNDCDir(out, dx, dy, dz, vpW, vpH, ndcZMin) {
@@ -1120,6 +1326,63 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
1120
1326
  return out;
1121
1327
  }
1122
1328
 
1329
+ // ═══════════════════════════════════════════════════════════════════════════
1330
+ // Rays and pointer hits
1331
+ // ═══════════════════════════════════════════════════════════════════════════
1332
+
1333
+ /**
1334
+ * Screen point → world ray: origin on the near plane (screen depth 0), unit
1335
+ * direction toward the far plane (depth 1). Same bag and viewport contract as
1336
+ * mapLocation; `m.mat4PVInv` must be filled by the caller.
1337
+ *
1338
+ * @param {number[]} outO 3-element origin.
1339
+ * @param {number[]} outD 3-element unit direction.
1340
+ * @param {number} sx,sy Screen point.
1341
+ * @param {object} m Matrices bag — see module header.
1342
+ * @param {number[]} vp Viewport [x, y, w, h]; sign of h encodes screen-y direction.
1343
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
1344
+ * @returns {number[]|null} outD, or null when the bag carries no mat4PVInv
1345
+ * (singular P·V) or the ray has no length.
1346
+ */
1347
+ function unproject(outO, outD, sx, sy, m, vp, ndcZMin) {
1348
+ const ipv = m.mat4PVInv;
1349
+ if (!ipv) return null;
1350
+ _screenToWorld(outO, sx, sy, 0, ipv, vp, ndcZMin);
1351
+ _screenToWorld(outD, sx, sy, 1, ipv, vp, ndcZMin);
1352
+ const dx=outD[0]-outO[0], dy=outD[1]-outO[1], dz=outD[2]-outO[2];
1353
+ const l=Math.sqrt(dx*dx+dy*dy+dz*dz);
1354
+ if (!(l > 0)) return null;
1355
+ outD[0]=dx/l; outD[1]=dy/l; outD[2]=dz/l;
1356
+ return outD;
1357
+ }
1358
+
1359
+ const _hit = [0, 0, 0]; // pointerHit screen scratch
1360
+
1361
+ /**
1362
+ * Is the pointer within `radius` px of the projected world point (x, y, z)?
1363
+ * `shape` is CIRCLE (Euclidean, default) or SQUARE (Chebyshev); the boundary
1364
+ * hits. A point whose screen depth falls outside [0, 1] — behind the camera,
1365
+ * before the near plane or beyond the far plane — never hits.
1366
+ *
1367
+ * @param {number} px,py Pointer, screen px.
1368
+ * @param {number} x,y,z World point.
1369
+ * @param {number} radius Hit radius, px.
1370
+ * @param {object} m Matrices bag — see module header.
1371
+ * @param {number[]} vp Viewport [x, y, w, h]; sign of h encodes screen-y direction.
1372
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
1373
+ * @param {number} [shape=CIRCLE] CIRCLE or SQUARE.
1374
+ * @returns {boolean}
1375
+ */
1376
+ function pointerHit(px, py, x, y, z, radius, m, vp, ndcZMin, shape = CIRCLE) {
1377
+ _worldToScreen(_hit, x, y, z, _ensurePV(m), vp, ndcZMin);
1378
+ const d = _hit[2];
1379
+ if (!(d >= 0 && d <= 1)) return false;
1380
+ const dx = px - _hit[0], dy = py - _hit[1];
1381
+ return shape === SQUARE
1382
+ ? Math.abs(dx) <= radius && Math.abs(dy) <= radius
1383
+ : dx*dx + dy*dy <= radius*radius;
1384
+ }
1385
+
1123
1386
  // ═══════════════════════════════════════════════════════════════════════════
1124
1387
  // pixelRatio
1125
1388
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1173,6 +1436,34 @@ function mat4Pick(proj, px, py, vp) {
1173
1436
  }
1174
1437
  }
1175
1438
 
1439
+ // ═══════════════════════════════════════════════════════════════════════════
1440
+ // Pick-id codec
1441
+ // ═══════════════════════════════════════════════════════════════════════════
1442
+
1443
+ /**
1444
+ * Pick id → colour: the 24-bit id packed into r, g, b as normalised floats,
1445
+ * R the low byte, alpha 1. Id 0 is the background; ids run 1 … 2²⁴ − 1.
1446
+ * @param {number[]} out 4-element destination.
1447
+ * @param {number} id Integer id.
1448
+ * @returns {number[]} out
1449
+ */
1450
+ function idToRgba(out, id) {
1451
+ out[0] = (id & 255) / 255;
1452
+ out[1] = ((id >> 8) & 255) / 255;
1453
+ out[2] = ((id >> 16) & 255) / 255;
1454
+ out[3] = 1;
1455
+ return out;
1456
+ }
1457
+
1458
+ /**
1459
+ * Colour bytes → pick id, the inverse of idToRgba on a readback.
1460
+ * @param {number} r,g,b Bytes 0 … 255 (fractions truncated).
1461
+ * @returns {number} id
1462
+ */
1463
+ function rgbaToId(r, g, b) {
1464
+ return (r & 255) | ((g & 255) << 8) | ((b & 255) << 16);
1465
+ }
1466
+
1176
1467
  // =========================================================================
1177
1468
  // Decomposition
1178
1469
  // =========================================================================
@@ -1540,9 +1831,9 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1540
1831
  function _parseQuat(v) {
1541
1832
  if (!v) return null;
1542
1833
 
1543
- // [x,y,z,w]
1544
- if (Array.isArray(v) && v.length === 4) return [v[0],v[1],v[2],v[3]];
1545
- if (ArrayBuffer.isView(v) && v.length >= 4) return [v[0],v[1],v[2],v[3]];
1834
+ // [x,y,z,w] — normalised like every other form
1835
+ if (Array.isArray(v) && v.length === 4) return qNormalize([v[0],v[1],v[2],v[3]]);
1836
+ if (ArrayBuffer.isView(v) && v.length >= 4) return qNormalize([v[0],v[1],v[2],v[3]]);
1546
1837
 
1547
1838
  if (typeof v !== 'object') return null;
1548
1839
 
@@ -2511,13 +2802,15 @@ class CameraTrack extends Track {
2511
2802
  * A constraint is any object exposing: `kind` (integer discriminant),
2512
2803
  * `solve(ox,oy,oz, dx,dy,dz)`, `value(out, report)`, `seed(x,y,z)`, and
2513
2804
  * optionally `scalar()` / `azEl(out2)` / `aim(ax,ay,az[, zx,zy,zz])` — the
2514
- * basis re-aim seam the bridge's deferred `from` frame drives (§4.13).
2515
- * The p5.tree handle controller drives
2516
- * any conforming constraint (lifecycle, frame conversion, bind, hooks, pick);
2517
- * a new kind 6-DOF, or app-specific implements this contract here
2518
- * (portable, draw-free) plus a bridge-side locus/pick draw (`drawLocus` /
2519
- * `pickProxy` on createHandle), rather than forking the controller. The
2520
- * classes below are the reference implementation. See handle-design.md §9.
2805
+ * basis re-aim seam the bridge's deferred `from` frame drives — and
2806
+ * `proxy(ox,oy,oz, dx,dy,dz, radius)`, the analytic pick: the ray parameter
2807
+ * at which a ray in the working space meets the grab proxy of `radius`
2808
+ * working units, or Infinity (a kind without one gets a sphere at its
2809
+ * POINT). The handle controller drives any conforming constraint
2810
+ * (lifecycle, frame conversion, bind, hooks, pick); a new kind — 6-DOF, or
2811
+ * app-specific implements this contract here (portable, draw-free) plus a
2812
+ * bridge-side locus draw, rather than forking the controller. The classes
2813
+ * below are the reference implementation. See handle-design.md §9–§10.
2521
2814
  *
2522
2815
  * ── Conventions ────────────────────────────────────────────────────────────
2523
2816
  * Ray direction `d` is assumed unit (the bridge normalises). Plane / axis
@@ -2530,7 +2823,7 @@ class CameraTrack extends Track {
2530
2823
 
2531
2824
 
2532
2825
  const EPS = 1e-6;
2533
- const TWO_PI = Math.PI * 2;
2826
+ const TWO_PI$1 = Math.PI * 2;
2534
2827
 
2535
2828
  // Edge-on threshold for DIAL: below this |d·n| the plane hit is ill-conditioned
2536
2829
  // (dθ per pixel diverges) and the solve switches to the tangent-line fallback.
@@ -2546,10 +2839,10 @@ const _clamp = (x, lo, hi) => x < lo ? lo : (x > hi ? hi : x);
2546
2839
  const _num = (x, d) => _isNum(x) ? x : d;
2547
2840
 
2548
2841
  /** Wrap an angle to (−π, π]. */
2549
- const _wrapPi = (a) => a - TWO_PI * Math.round(a / TWO_PI);
2842
+ const _wrapPi = (a) => a - TWO_PI$1 * Math.round(a / TWO_PI$1);
2550
2843
 
2551
2844
  /** Parse a vec3 from array / typed array / {x,y,z}. Returns a fresh [x,y,z] or null. */
2552
- function _vec3(v) {
2845
+ function _vec3$1(v) {
2553
2846
  if (!v) return null;
2554
2847
  if (ArrayBuffer.isView(v) && v.length >= 3) return [v[0], v[1], v[2]];
2555
2848
  if (Array.isArray(v) && v.length >= 3) return [v[0], v[1], v[2]];
@@ -2558,7 +2851,7 @@ function _vec3(v) {
2558
2851
  }
2559
2852
 
2560
2853
  /** Normalise a vec3 in place; zero-length falls back to the given default axis. */
2561
- function _unit(v, dx, dy, dz) {
2854
+ function _unit$1(v, dx, dy, dz) {
2562
2855
  const l = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
2563
2856
  if (l < EPS) { v[0]=dx; v[1]=dy; v[2]=dz; return v; }
2564
2857
  v[0]/=l; v[1]/=l; v[2]/=l;
@@ -2571,12 +2864,12 @@ function _unit(v, dx, dy, dz) {
2571
2864
  * (Same derivation the p5 bridge uses for its plane quad — duplicated here
2572
2865
  * because the core cannot depend on the bridge.)
2573
2866
  */
2574
- function _basis(n, ub, vb) {
2867
+ function _basis$1(n, ub, vb) {
2575
2868
  const ax = Math.abs(n[0]), ay = Math.abs(n[1]), az = Math.abs(n[2]);
2576
2869
  let rx = 0, ry = 0, rz = 0;
2577
2870
  if (ax <= ay && ax <= az) rx = 1; else if (ay <= az) ry = 1; else rz = 1;
2578
2871
  ub[0] = ry*n[2] - rz*n[1]; ub[1] = rz*n[0] - rx*n[2]; ub[2] = rx*n[1] - ry*n[0];
2579
- _unit(ub, 1, 0, 0);
2872
+ _unit$1(ub, 1, 0, 0);
2580
2873
  vb[0] = n[1]*ub[2] - n[2]*ub[1]; vb[1] = n[2]*ub[0] - n[0]*ub[2]; vb[2] = n[0]*ub[1] - n[1]*ub[0];
2581
2874
  }
2582
2875
 
@@ -2660,6 +2953,132 @@ function rayClosestPointOnAxis(out, ox,oy,oz, dx,dy,dz, px,py,pz, ux,uy,uz) {
2660
2953
  return s;
2661
2954
  }
2662
2955
 
2956
+ // =========================================================================
2957
+ // H2b Ray-primitive hit tests (pure, no out: the nearest t, or Infinity)
2958
+ // =========================================================================
2959
+ //
2960
+ // Beside the solve primitives above, which always write a point, these are
2961
+ // TESTS: they write nothing and return the ray parameter of the nearest hit
2962
+ // with t ≥ 0, or Infinity on a miss. A ray whose origin lies inside the
2963
+ // primitive hits at its exit, so a press from inside a proxy still grabs.
2964
+ // The ray direction is assumed unit.
2965
+
2966
+ /**
2967
+ * Ray–sphere hit test.
2968
+ * @param {number} ox,oy,oz Ray origin.
2969
+ * @param {number} dx,dy,dz Ray direction (unit).
2970
+ * @param {number} cx,cy,cz Sphere centre.
2971
+ * @param {number} r Sphere radius.
2972
+ * @returns {number} The nearest t ≥ 0, or Infinity.
2973
+ */
2974
+ function rayHitSphere(ox,oy,oz, dx,dy,dz, cx,cy,cz, r) {
2975
+ const lx=ox-cx, ly=oy-cy, lz=oz-cz;
2976
+ const b = lx*dx + ly*dy + lz*dz;
2977
+ const cc = lx*lx + ly*ly + lz*lz - r*r;
2978
+ const disc = b*b - cc;
2979
+ if (disc < 0) return Infinity;
2980
+ const s = Math.sqrt(disc);
2981
+ let t = -b - s;
2982
+ if (t < 0) t = -b + s; // origin inside: the exit
2983
+ return t < 0 ? Infinity : t; // both roots behind the origin
2984
+ }
2985
+
2986
+ // One end cap of a capsule: the sphere at (cx,cy,cz), a root accepted only
2987
+ // where the hit's axial coordinate h = wu + t·du lies beyond the segment on
2988
+ // that cap's side (h ≤ 0 at A, h ≥ L at B), so the point is on the capsule's
2989
+ // surface and not inside its cylinder.
2990
+ function _capHit(ox,oy,oz, dx,dy,dz, cx,cy,cz, r, wu, du, lim, endB) {
2991
+ const lx=ox-cx, ly=oy-cy, lz=oz-cz;
2992
+ const b = lx*dx + ly*dy + lz*dz;
2993
+ const cc = lx*lx + ly*ly + lz*lz - r*r;
2994
+ const disc = b*b - cc;
2995
+ if (disc < 0) return Infinity;
2996
+ const s = Math.sqrt(disc);
2997
+ let t = -b - s, h = wu + t*du;
2998
+ if (t < 0 || (endB ? h < lim : h > lim)) { t = -b + s; h = wu + t*du; }
2999
+ return (t >= 0 && (endB ? h >= lim : h <= lim)) ? t : Infinity;
3000
+ }
3001
+
3002
+ /**
3003
+ * Ray–capsule hit test: the segment A→B swept by radius r. The cylinder wall
3004
+ * counts within the segment's extent, each end sphere beyond it; the nearest
3005
+ * of the three wins. A zero-length segment is the sphere at A.
3006
+ * @param {number} ox,oy,oz Ray origin.
3007
+ * @param {number} dx,dy,dz Ray direction (unit).
3008
+ * @param {number} ax,ay,az Segment start.
3009
+ * @param {number} bx,by,bz Segment end.
3010
+ * @param {number} r Capsule radius.
3011
+ * @returns {number} The nearest t ≥ 0, or Infinity.
3012
+ */
3013
+ function rayHitCapsule(ox,oy,oz, dx,dy,dz, ax,ay,az, bx,by,bz, r) {
3014
+ let ux=bx-ax, uy=by-ay, uz=bz-az;
3015
+ const L = Math.sqrt(ux*ux + uy*uy + uz*uz);
3016
+ if (L < EPS) return rayHitSphere(ox,oy,oz, dx,dy,dz, ax,ay,az, r);
3017
+ ux/=L; uy/=L; uz/=L;
3018
+ const wx=ox-ax, wy=oy-ay, wz=oz-az;
3019
+ const du = dx*ux + dy*uy + dz*uz; // d·u
3020
+ const wu = wx*ux + wy*uy + wz*uz; // w·u
3021
+ let best = Infinity;
3022
+ // Cylinder wall: the quadratic in the components perpendicular to u.
3023
+ const a = 1 - du*du;
3024
+ if (a > EPS) {
3025
+ const b = (dx*wx + dy*wy + dz*wz) - du*wu;
3026
+ const c = (wx*wx + wy*wy + wz*wz) - wu*wu - r*r;
3027
+ const disc = b*b - a*c;
3028
+ if (disc >= 0) {
3029
+ const s = Math.sqrt(disc);
3030
+ let t = (-b - s)/a, h = wu + t*du;
3031
+ if (t < 0 || h < 0 || h > L) { t = (-b + s)/a; h = wu + t*du; }
3032
+ if (t >= 0 && h >= 0 && h <= L) best = t;
3033
+ }
3034
+ }
3035
+ const ta = _capHit(ox,oy,oz, dx,dy,dz, ax,ay,az, r, wu, du, 0, false);
3036
+ if (ta < best) best = ta;
3037
+ const tb = _capHit(ox,oy,oz, dx,dy,dz, bx,by,bz, r, wu, du, L, true);
3038
+ if (tb < best) best = tb;
3039
+ return best;
3040
+ }
3041
+
3042
+ // Ring scratch — the plane normal and its in-plane basis. Function-local
3043
+ // working values, never state: rayHitRing runs to completion.
3044
+ const _rn = [0, 0, 1], _r0 = [1, 0, 0], _r1 = [0, 1, 0];
3045
+
3046
+ /**
3047
+ * Ray–ring hit test: the circle of radius R about c in the plane
3048
+ * perpendicular to u, swept by tube radius r — the analytic torus proxy as a
3049
+ * capsule chain, the circle polygonised into `detail` segments and each
3050
+ * tested with rayHitCapsule. The chain has volume in every direction, so it
3051
+ * never degenerates edge-on; the chordal error is R · (1 − cos(π / detail)).
3052
+ * @param {number} ox,oy,oz Ray origin.
3053
+ * @param {number} dx,dy,dz Ray direction (unit).
3054
+ * @param {number} cx,cy,cz Ring centre.
3055
+ * @param {number} ux,uy,uz Ring plane normal (normalised here).
3056
+ * @param {number} R Ring radius.
3057
+ * @param {number} r Tube radius.
3058
+ * @param {number} [detail=32] Chain segments (at least 3).
3059
+ * @returns {number} The nearest t ≥ 0, or Infinity.
3060
+ */
3061
+ function rayHitRing(ox,oy,oz, dx,dy,dz, cx,cy,cz, ux,uy,uz, R, r, detail = 32) {
3062
+ const n = _isNum(detail) && detail >= 3 ? Math.floor(detail) : 32;
3063
+ _rn[0]=ux; _rn[1]=uy; _rn[2]=uz;
3064
+ _unit$1(_rn, 0, 0, 1);
3065
+ _basis$1(_rn, _r0, _r1);
3066
+ const step = TWO_PI$1 / n;
3067
+ let best = Infinity;
3068
+ let ax = cx + R*_r0[0], ay = cy + R*_r0[1], az = cz + R*_r0[2]; // vertex at angle 0
3069
+ for (let i = 1; i <= n; i++) {
3070
+ const th = i === n ? 0 : i*step; // close the chain exactly
3071
+ const cs = Math.cos(th)*R, sn = Math.sin(th)*R;
3072
+ const bx = cx + cs*_r0[0] + sn*_r1[0];
3073
+ const by = cy + cs*_r0[1] + sn*_r1[1];
3074
+ const bz = cz + cs*_r0[2] + sn*_r1[2];
3075
+ const t = rayHitCapsule(ox,oy,oz, dx,dy,dz, ax,ay,az, bx,by,bz, r);
3076
+ if (t < best) best = t;
3077
+ ax = bx; ay = by; az = bz;
3078
+ }
3079
+ return best;
3080
+ }
3081
+
2663
3082
  // =========================================================================
2664
3083
  // H3 Angular utilities (readout / authoring convenience)
2665
3084
  // =========================================================================
@@ -2719,17 +3138,17 @@ class Constraint {
2719
3138
  this.kind = kind;
2720
3139
 
2721
3140
  /** Constraint origin (sphere centre / plane point / axis anchor / dial centre). @type {number[]} */
2722
- this.anchor = _vec3(opts.anchor) || [0, 0, 0];
3141
+ this.anchor = _vec3$1(opts.anchor) || [0, 0, 0];
2723
3142
  /** Canonical unit direction — SPHERE. @type {number[]} */
2724
3143
  this.dir = [0, 0, 1];
2725
3144
  /** Constrained point — PLANE / AXIS / DIAL (and SPHERE scratch). @type {number[]} */
2726
3145
  this.pt = [this.anchor[0], this.anchor[1], this.anchor[2]];
2727
3146
  /** Plane normal (unit) — PLANE. @type {number[]} */
2728
- this.n = _unit(_vec3(opts.normal) || [0, 1, 0], 0, 1, 0);
3147
+ this.n = _unit$1(_vec3$1(opts.normal) || [0, 1, 0], 0, 1, 0);
2729
3148
  /** Axis / dial-plane normal (unit) — AXIS / DIAL. @type {number[]} */
2730
3149
  this.u = kind === DIAL
2731
- ? _unit(_vec3(opts.axis) || [0, 1, 0], 0, 1, 0)
2732
- : _unit(_vec3(opts.axis) || [1, 0, 0], 1, 0, 0);
3150
+ ? _unit$1(_vec3$1(opts.axis) || [0, 1, 0], 0, 1, 0)
3151
+ : _unit$1(_vec3$1(opts.axis) || [1, 0, 0], 1, 0, 0);
2733
3152
  /** Current scalar parameter — AXIS: t along the line; DIAL: accumulated θ. @type {number} */
2734
3153
  this.s = 0;
2735
3154
 
@@ -2740,7 +3159,7 @@ class Constraint {
2740
3159
  /** In-plane binormal u × r0 (unit) — DIAL. @type {number[]} */
2741
3160
  this.r1 = [0, 0, 1];
2742
3161
  if (kind === DIAL) {
2743
- const z = _vec3(opts.zero);
3162
+ const z = _vec3$1(opts.zero);
2744
3163
  this._dialBasis(z ? z[0] : NaN, z ? z[1] : NaN, z ? z[2] : NaN);
2745
3164
  }
2746
3165
 
@@ -2791,10 +3210,10 @@ class Constraint {
2791
3210
  this.r0[1] = zy - d*this.u[1];
2792
3211
  this.r0[2] = zz - d*this.u[2];
2793
3212
  const l = Math.sqrt(this.r0[0]**2 + this.r0[1]**2 + this.r0[2]**2);
2794
- if (l < EPS) _basis(this.u, this.r0, this.r1);
3213
+ if (l < EPS) _basis$1(this.u, this.r0, this.r1);
2795
3214
  else { this.r0[0]/=l; this.r0[1]/=l; this.r0[2]/=l; }
2796
3215
  } else {
2797
- _basis(this.u, this.r0, this.r1);
3216
+ _basis$1(this.u, this.r0, this.r1);
2798
3217
  }
2799
3218
  // r1 = u × r0 (recomputed even when _basis ran — same result, one rule).
2800
3219
  this.r1[0] = this.u[1]*this.r0[2] - this.u[2]*this.r0[1];
@@ -2818,7 +3237,7 @@ class Constraint {
2818
3237
  this.dir[0] = this.pt[0] - this.anchor[0];
2819
3238
  this.dir[1] = this.pt[1] - this.anchor[1];
2820
3239
  this.dir[2] = this.pt[2] - this.anchor[2];
2821
- _unit(this.dir, this.dir[0], this.dir[1], this.dir[2]);
3240
+ _unit$1(this.dir, this.dir[0], this.dir[1], this.dir[2]);
2822
3241
  } else if (this.kind === PLANE) {
2823
3242
  // Parallel ray returns Infinity and leaves pt unchanged (keep last).
2824
3243
  rayPlane(this.pt, ox,oy,oz, dx,dy,dz,
@@ -2965,7 +3384,7 @@ class Constraint {
2965
3384
  if (px*px + py*py >= EPS*EPS) {
2966
3385
  const a = Math.atan2(py, px);
2967
3386
  // Nearest winding to the current θ preserves accumulated turns.
2968
- this.s = _clamp(a + TWO_PI * Math.round((this.s - a) / TWO_PI),
3387
+ this.s = _clamp(a + TWO_PI$1 * Math.round((this.s - a) / TWO_PI$1),
2969
3388
  this.min, this.max);
2970
3389
  }
2971
3390
  this._dialPoint();
@@ -2994,24 +3413,54 @@ class Constraint {
2994
3413
  if (this.kind === PLANE) {
2995
3414
  const px = this.n[0], py = this.n[1], pz = this.n[2];
2996
3415
  this.n[0] = ax; this.n[1] = ay; this.n[2] = az;
2997
- _unit(this.n, px, py, pz);
3416
+ _unit$1(this.n, px, py, pz);
2998
3417
  this.seed(this.pt[0], this.pt[1], this.pt[2]);
2999
3418
  } else if (this.kind === AXIS) {
3000
3419
  const px = this.u[0], py = this.u[1], pz = this.u[2];
3001
3420
  this.u[0] = ax; this.u[1] = ay; this.u[2] = az;
3002
- _unit(this.u, px, py, pz);
3421
+ _unit$1(this.u, px, py, pz);
3003
3422
  this.pt[0] = this.anchor[0] + this.s*this.u[0];
3004
3423
  this.pt[1] = this.anchor[1] + this.s*this.u[1];
3005
3424
  this.pt[2] = this.anchor[2] + this.s*this.u[2];
3006
3425
  } else if (this.kind === DIAL) {
3007
3426
  const px = this.u[0], py = this.u[1], pz = this.u[2];
3008
3427
  this.u[0] = ax; this.u[1] = ay; this.u[2] = az;
3009
- _unit(this.u, px, py, pz);
3428
+ _unit$1(this.u, px, py, pz);
3010
3429
  this._dialBasis(zx, zy, zz);
3011
3430
  this._dialPoint();
3012
3431
  }
3013
3432
  return this;
3014
3433
  }
3434
+
3435
+ /**
3436
+ * The analytic pick proxy: the ray parameter t at which a ray in the
3437
+ * working space meets the grab proxy, or Infinity. `radius` is the grab
3438
+ * size in working-space units — a constant pixel size the caller converted
3439
+ * through pixelRatio at the proxy's depth. SPHERE / PLANE / AXIS: a sphere
3440
+ * of `radius` at the reported POINT. DIAL: the ring at the anchor with
3441
+ * tube radius `radius` (rayHitRing), so a grab lands anywhere on the ring.
3442
+ *
3443
+ * @param {number} ox,oy,oz Ray origin.
3444
+ * @param {number} dx,dy,dz Ray direction (unit).
3445
+ * @param {number} radius Grab radius, working units.
3446
+ * @returns {number} The nearest t ≥ 0, or Infinity.
3447
+ */
3448
+ proxy(ox, oy, oz, dx, dy, dz, radius) {
3449
+ const a = this.anchor;
3450
+ if (this.kind === DIAL) {
3451
+ return rayHitRing(ox,oy,oz, dx,dy,dz, a[0], a[1], a[2],
3452
+ this.u[0], this.u[1], this.u[2], this._radius, radius);
3453
+ }
3454
+ let px, py, pz;
3455
+ if (this.kind === SPHERE) {
3456
+ px = a[0] + this.dir[0]*this._radius;
3457
+ py = a[1] + this.dir[1]*this._radius;
3458
+ pz = a[2] + this.dir[2]*this._radius;
3459
+ } else {
3460
+ px = this.pt[0]; py = this.pt[1]; pz = this.pt[2];
3461
+ }
3462
+ return rayHitSphere(ox,oy,oz, dx,dy,dz, px,py,pz, radius);
3463
+ }
3015
3464
  }
3016
3465
 
3017
3466
  /**
@@ -3044,6 +3493,12 @@ function createConstraint(kind, opts) {
3044
3493
  * eval(out) read the current { pos, rot } (zero-alloc, as Track.eval)
3045
3494
  * home(pose?) re-home the integrated pose (NOT reset — no keyframes to clear)
3046
3495
  *
3496
+ * Value layer (opt-in, §10): a per-helm `fullScale` keeps the read-outs honest
3497
+ * across input scales; an opt-in `filter` (oneEuro) conditions the fed rates
3498
+ * before the deadzone inside step; `poseDelta` differences two absolute poses
3499
+ * into a feedable rate. With `filter` null and the default `fullScale`, the
3500
+ * clean rate-native path is unchanged.
3501
+ *
3047
3502
  * Quaternion algebra is provided by quat.js. Out-first throughout; no allocation
3048
3503
  * in feed/step/eval. Storage convention (matches the rest of the core): vec3 and
3049
3504
  * quat state are plain number[] (f64) — the same shape as track.js keyframes and
@@ -3076,6 +3531,23 @@ function createConstraint(kind, opts) {
3076
3531
 
3077
3532
  const _dq = [0, 0, 0, 1];
3078
3533
 
3534
+ // Scratch for the optional input filter (helm.filter). The fed rates are packed
3535
+ // into one 6-vector, conditioned, and unpacked to lin/ang triples before the
3536
+ // deadzone — so a single filter carries one state per helm. Shared across
3537
+ // instances (step is synchronous, at most once per helm per frame).
3538
+ const _f6raw = [0, 0, 0, 0, 0, 0];
3539
+ const _f6out = [0, 0, 0, 0, 0, 0];
3540
+ const _fLin = [0, 0, 0];
3541
+ const _fAng = [0, 0, 0];
3542
+
3543
+ /**
3544
+ * Default full-deflection input magnitude — the fed rate a read-out shows as
3545
+ * full (the SpaceNavigator's saturated lane). Private to core: a transport on a
3546
+ * different raw scale sets helm.fullScale and never reads this, so there is no
3547
+ * public HELM_FULL export. Display-only; integration uses profile.sens directly.
3548
+ */
3549
+ const HELM_FULL_SCALE = 500;
3550
+
3079
3551
  // Deadzone gate — |v| > dz keeps v, else 0. Module-level so step allocates no
3080
3552
  // closure. Strictly-greater matches the e7 reference (rest reads exact 0).
3081
3553
  const _dz = (v, dz) => (v > dz || v < -dz) ? v : 0;
@@ -3128,6 +3600,24 @@ class PoseHelm {
3128
3600
  */
3129
3601
  this.deadzone = 8;
3130
3602
 
3603
+ /**
3604
+ * Full-deflection input magnitude for the read-outs (gizmo overlay + panel
3605
+ * meters): a fed rate of |fullScale| reads as a full bar / arrow. Set by a
3606
+ * transport whose saturated rate differs from the default (a gamepad stick
3607
+ * that saturates at 1 sets fullScale = 1). Display-only — integration uses
3608
+ * profile.sens directly, so this never affects flight. @type {number}
3609
+ */
3610
+ this.fullScale = HELM_FULL_SCALE;
3611
+
3612
+ /**
3613
+ * Optional input conditioner applied to the fed rates BEFORE the deadzone,
3614
+ * inside step — a oneEuro filter, or any f(out, raw, dt) carrying function
3615
+ * with a reset(). null (the default) is the clean rate-native path: the
3616
+ * filter branch is skipped, so a clean device pays nothing. Set for noisy /
3617
+ * absolute sources; home() resets it. @type {?Function}
3618
+ */
3619
+ this.filter = null;
3620
+
3131
3621
  /**
3132
3622
  * The space fed rates are interpreted in: a space constant (WORLD | EYE) or
3133
3623
  * a mat4 frame. Declarative only — the bridge reads this to resolve the
@@ -3189,7 +3679,22 @@ class PoseHelm {
3189
3679
  */
3190
3680
  step(out, dt, basis) {
3191
3681
  const p = this.profile, dz = this.deadzone;
3192
- const lin = this._lin, ang = this._ang;
3682
+ let lin = this._lin, ang = this._ang;
3683
+
3684
+ // Value layer — condition the fed rates BEFORE the deadzone (the filter
3685
+ // narrows the jitter band; the deadzone then gates the residual to exact
3686
+ // zero). The filter carries state, so it is ticked exactly once per step;
3687
+ // activity() reads the raw fed rate and is left unfiltered (the read-out
3688
+ // shows device input, normalized by fullScale). Skipped when filter is null.
3689
+ const filter = this.filter;
3690
+ if (filter) {
3691
+ _f6raw[0] = lin[0]; _f6raw[1] = lin[1]; _f6raw[2] = lin[2];
3692
+ _f6raw[3] = ang[0]; _f6raw[4] = ang[1]; _f6raw[5] = ang[2];
3693
+ filter(_f6out, _f6raw, dt);
3694
+ _fLin[0] = _f6out[0]; _fLin[1] = _f6out[1]; _fLin[2] = _f6out[2];
3695
+ _fAng[0] = _f6out[3]; _fAng[1] = _f6out[4]; _fAng[2] = _f6out[5];
3696
+ lin = _fLin; ang = _fAng;
3697
+ }
3193
3698
 
3194
3699
  // Basis axes (right / up / forward). No basis ⇒ the identity eye matrix:
3195
3700
  // right +X, up +Y, forward −Z. Forward is −col2 (an eye→world matrix stores
@@ -3288,10 +3793,76 @@ class PoseHelm {
3288
3793
  }
3289
3794
  this._lin[0] = this._lin[1] = this._lin[2] = 0;
3290
3795
  this._ang[0] = this._ang[1] = this._ang[2] = 0;
3796
+ if (this.filter) this.filter.reset(); // drop filter state at the discontinuity
3291
3797
  return this;
3292
3798
  }
3293
3799
  }
3294
3800
 
3801
+ // =========================================================================
3802
+ // poseDelta — absolute pose → 6-DOF rate
3803
+ // =========================================================================
3804
+
3805
+ /**
3806
+ * Difference two consecutive absolute poses into a 6-DOF rate the helm can be
3807
+ * fed — the bridge for absolute-pose transports (gesture / landmark / marker /
3808
+ * IMU) into feed(lin, ang). Out-first, zero-allocation.
3809
+ *
3810
+ * Linear rate is (cur.pos − prev.pos) / dt. Angular rate is the world-frame
3811
+ * angular velocity ω carrying prev.rot onto cur.rot over dt: the relative
3812
+ * rotation r = cur · conj(prev) (so cur = r · prev, the world-frame compose the
3813
+ * step integrates), read as axis · angle / dt.
3814
+ *
3815
+ * The double-cover guard is the whole reason to ship this rather than inline it:
3816
+ * a quaternion and its negation are the same orientation, so when
3817
+ * dot(prev.rot, cur.rot) < 0 the two samples sit on opposite hemispheres and a
3818
+ * naive difference takes the long way round — the angular rate spikes at the
3819
+ * crossing. Flipping cur into prev's hemisphere first keeps r the shortest arc.
3820
+ *
3821
+ * Fed through a helm in WORLD whose profile has unit `sens`, in-order lanes and
3822
+ * `Tz` / `Rr` signs of −1 — the helm's lanes are eye-frame and the null basis
3823
+ * is the identity eye matrix (forward −Z), so the two Z channels flip against
3824
+ * world axes — the integrated pose retraces the source (the round-trip e9
3825
+ * asserts). A 1:1, non-integrated consumer skips the helm and applyPoses the
3826
+ * absolute pose directly.
3827
+ *
3828
+ * @param {{ lin:number[], ang:number[] }} [out] Destination; omit for a fresh one.
3829
+ * @param {{ pos:ArrayLike<number>, rot:ArrayLike<number> }} prev Previous pose.
3830
+ * @param {{ pos:ArrayLike<number>, rot:ArrayLike<number> }} cur Current pose.
3831
+ * @param {number} dt Elapsed seconds between the two samples.
3832
+ * @returns {{ lin:number[], ang:number[] }} out
3833
+ */
3834
+ function poseDelta(out, prev, cur, dt) {
3835
+ out = out || { lin: [0, 0, 0], ang: [0, 0, 0] };
3836
+ const inv = 1 / dt;
3837
+
3838
+ out.lin[0] = (cur.pos[0] - prev.pos[0]) * inv;
3839
+ out.lin[1] = (cur.pos[1] - prev.pos[1]) * inv;
3840
+ out.lin[2] = (cur.pos[2] - prev.pos[2]) * inv;
3841
+
3842
+ const px = prev.rot[0], py = prev.rot[1], pz = prev.rot[2], pw = prev.rot[3];
3843
+ let cx = cur.rot[0], cy = cur.rot[1], cz = cur.rot[2], cw = cur.rot[3];
3844
+
3845
+ // Double-cover guard: bring cur into prev's hemisphere so r is the short arc.
3846
+ if (px * cx + py * cy + pz * cz + pw * cw < 0) { cx = -cx; cy = -cy; cz = -cz; cw = -cw; }
3847
+
3848
+ // Relative rotation r = cur · conj(prev), conj(prev) = (−px, −py, −pz, pw).
3849
+ const rx = -cw * px + cx * pw - cy * pz + cz * py;
3850
+ const ry = -cw * py + cx * pz + cy * pw - cz * px;
3851
+ const rz = -cw * pz - cx * py + cy * px + cz * pw;
3852
+ const rw = cw * pw + cx * px + cy * py + cz * pz; // = dot(prev, cur) ≥ 0 → angle ≤ π
3853
+
3854
+ // Axis · angle of r → ω = axis · angle / dt. Below the threshold the rotation
3855
+ // is negligible (no well-defined axis) and the angular rate is exact zero.
3856
+ const sinHalf = Math.sqrt(rx * rx + ry * ry + rz * rz);
3857
+ if (sinHalf < 1e-8) {
3858
+ out.ang[0] = 0; out.ang[1] = 0; out.ang[2] = 0;
3859
+ } else {
3860
+ const k = (2 * Math.atan2(sinHalf, rw)) * inv / sinHalf;
3861
+ out.ang[0] = rx * k; out.ang[1] = ry * k; out.ang[2] = rz * k;
3862
+ }
3863
+ return out;
3864
+ }
3865
+
3295
3866
  /**
3296
3867
  * @file Frustum planes and visibility tests — zero allocations.
3297
3868
  * @module tree/visibility
@@ -3451,5 +4022,1081 @@ function boxVisibility(planes, x0, y0, z0, x1, y1, z1) {
3451
4022
  return allIn ? VISIBLE : SEMIVISIBLE;
3452
4023
  }
3453
4024
 
3454
- export { AXIS, CameraTrack, Constraint, DIAL, DIRECTION, EYE, HELM_CHANNELS, INVISIBLE, MATRIX, MODEL, NDC, ORIGIN, PLANE, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, POINT, PoseHelm, PoseTrack, SCREEN, SELF, SEMIVISIBLE, SPHERE, VISIBLE, WEBGL, WEBGPU, WORLD, _i, _j, _k, azElFromDir, boxVisibility, createConstraint, dirFromAzEl, distanceToPlane, frustumPlanes, hermiteVec3, i, j, k, lerpVec3, mapDirection, mapLocation, mat3Direction, mat3NormalFromMat4, mat4Bias, mat4Eye, mat4FromBasis, mat4FromScale, mat4FromTRS, mat4FromTranslation, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulDir, mat4MulPoint, mat4Ortho, mat4PV, mat4Persp, mat4Pick, mat4Reflect, mat4ToRotation, mat4ToScale, mat4ToTransform, mat4ToTranslation, mat4View, pixelRatio, pointVisibility, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qConjugate, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qFromUnitVectors, qMul, qNegate, qNlerp, qNormalize, qRotateVec3, qSet, qSlerp, qToAxisAngle, qToMat4, rayClosestPointOnAxis, rayPlane, raySphere, sphereVisibility, transformToMat4 };
4025
+ /**
4026
+ * @file Camera state ↔ matrices: builders, decomposers, and in-place edits.
4027
+ * @module tree/camera
4028
+ * @license AGPL-3.0-only
4029
+ *
4030
+ * The camera is plain data — the CameraTrack keyframe shape:
4031
+ *
4032
+ * cam = {
4033
+ * eye: [x, y, z], // position
4034
+ * center: [x, y, z], // lookat target; |center − eye| is the gaze distance
4035
+ * up: [x, y, z], // up hint, need not be unit
4036
+ * fov: number | null, // vertical field of view, radians — perspective
4037
+ * halfHeight: number | null, // world-unit half-height at the near plane — orthographic
4038
+ * near: number, // > 0
4039
+ * far: number, // > near
4040
+ * }
4041
+ *
4042
+ * Exactly one of fov / halfHeight is meaningful; fov wins when both are set.
4043
+ * With both null the lens is "unchanged": cameraProj and cameraPlanes return
4044
+ * null and leave their output untouched, so a track that evaluates null into
4045
+ * the state keeps the last projection installed. A track writes the state
4046
+ * directly — `track.eval(cam)` — and a pose drives it through cameraFromPose.
4047
+ *
4048
+ * The state carries no aspect ratio — the viewport owns it — so one state is
4049
+ * portable between targets of different sizes; every builder that needs it
4050
+ * takes it as an argument.
4051
+ *
4052
+ * Vectors are plain number[] (f64, authoring state); matrices are written into
4053
+ * whatever 16-element buffer the caller passes. createCamera is the one
4054
+ * allocating call; everything else is out-first (or in-place) and zero-alloc.
4055
+ *
4056
+ * Every function that needs the camera frame derives it through mat4Eye, so
4057
+ * planes and edits agree with cameraEye / cameraView exactly — including the
4058
+ * up re-seed when the view direction is parallel to the up hint.
4059
+ */
4060
+
4061
+
4062
+ const _E$1 = new Float64Array(16); // eye→world frame scratch: right 0–2, up 4–6, back 8–10
4063
+ const _d = [0, 0, 0]; // view-direction scratch
4064
+
4065
+ /** Eye→world frame of `cam` into `_E`. */
4066
+ function _frame(cam) {
4067
+ const e = cam.eye, c = cam.center, u = cam.up;
4068
+ mat4Eye(_E$1, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
4069
+ }
4070
+
4071
+ /** Gaze distance |center − eye|, or 1 when the state is degenerate. */
4072
+ function _gaze(cam) {
4073
+ const dx = cam.center[0]-cam.eye[0], dy = cam.center[1]-cam.eye[1], dz = cam.center[2]-cam.eye[2];
4074
+ const d = Math.sqrt(dx*dx+dy*dy+dz*dz);
4075
+ return d > 0 ? d : 1;
4076
+ }
4077
+
4078
+ function _vec3(v, x, y, z) { return v != null ? [v[0], v[1], v[2]] : [x, y, z]; }
4079
+
4080
+ // =========================================================================
4081
+ // State
4082
+ // =========================================================================
4083
+
4084
+ /**
4085
+ * Allocate a camera state — the one allocating call, setup-time.
4086
+ * Defaults: eye [0, 0, 500], center [0, 0, 0], up [0, 1, 0], fov π/3,
4087
+ * halfHeight null, near 0.1, far 1000. Passing halfHeight without fov yields
4088
+ * an orthographic state (fov null).
4089
+ *
4090
+ * @param {{ eye?:number[], center?:number[], up?:number[], fov?:number|null,
4091
+ * halfHeight?:number|null, near?:number, far?:number }} [opts]
4092
+ * @returns {{ eye:number[], center:number[], up:number[], fov:number|null,
4093
+ * halfHeight:number|null, near:number, far:number }}
4094
+ */
4095
+ function createCamera(opts) {
4096
+ const o = opts || {};
4097
+ const ortho = o.halfHeight != null && o.fov === undefined;
4098
+ return {
4099
+ eye: _vec3(o.eye, 0, 0, 500),
4100
+ center: _vec3(o.center, 0, 0, 0),
4101
+ up: _vec3(o.up, 0, 1, 0),
4102
+ fov: o.fov !== undefined ? o.fov : (ortho ? null : Math.PI / 3),
4103
+ halfHeight: o.halfHeight !== undefined ? o.halfHeight : null,
4104
+ near: typeof o.near === 'number' ? o.near : 0.1,
4105
+ far: typeof o.far === 'number' ? o.far : 1000,
4106
+ };
4107
+ }
4108
+
4109
+ /**
4110
+ * Copy one camera state into another (an orbit's home, a track's capture).
4111
+ * @param {object} out Destination state.
4112
+ * @param {object} cam Source state.
4113
+ * @returns {object} out
4114
+ */
4115
+ function cameraCopy(out, cam) {
4116
+ out.eye[0]=cam.eye[0]; out.eye[1]=cam.eye[1]; out.eye[2]=cam.eye[2];
4117
+ out.center[0]=cam.center[0]; out.center[1]=cam.center[1]; out.center[2]=cam.center[2];
4118
+ out.up[0]=cam.up[0]; out.up[1]=cam.up[1]; out.up[2]=cam.up[2];
4119
+ out.fov = cam.fov; out.halfHeight = cam.halfHeight;
4120
+ out.near = cam.near; out.far = cam.far;
4121
+ return out;
4122
+ }
4123
+
4124
+ // =========================================================================
4125
+ // Builders — state → matrices
4126
+ // =========================================================================
4127
+
4128
+ /**
4129
+ * View matrix (world→eye) from the state's lookat.
4130
+ * @param {Float32Array|number[]} out 16-element destination.
4131
+ * @param {object} cam
4132
+ * @returns {Float32Array|number[]} out
4133
+ */
4134
+ function cameraView(out, cam) {
4135
+ const e = cam.eye, c = cam.center, u = cam.up;
4136
+ return mat4View(out, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
4137
+ }
4138
+
4139
+ /**
4140
+ * Eye matrix (eye→world) from the state's lookat.
4141
+ * @param {Float32Array|number[]} out 16-element destination.
4142
+ * @param {object} cam
4143
+ * @returns {Float32Array|number[]} out
4144
+ */
4145
+ function cameraEye(out, cam) {
4146
+ const e = cam.eye, c = cam.center, u = cam.up;
4147
+ return mat4Eye(out, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
4148
+ }
4149
+
4150
+ /**
4151
+ * Projection matrix from the state's lens: mat4Persp from fov, or mat4Ortho
4152
+ * from halfHeight, with symmetric extents (top = near · tan(fov / 2) or
4153
+ * halfHeight; right = top · aspect).
4154
+ *
4155
+ * @param {Float32Array|number[]} out 16-element destination.
4156
+ * @param {object} cam
4157
+ * @param {number} aspect Viewport width / height.
4158
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
4159
+ * @param {number} [ndcYSign=1] +1 NDC y-up; −1 NDC y-down.
4160
+ * @returns {Float32Array|number[]|null} out, or null (out untouched) when
4161
+ * both fov and halfHeight are null.
4162
+ */
4163
+ function cameraProj(out, cam, aspect, ndcZMin, ndcYSign = 1) {
4164
+ const near = cam.near, far = cam.far;
4165
+ if (cam.fov != null) {
4166
+ const top = near * Math.tan(cam.fov / 2), right = top * aspect;
4167
+ return mat4Persp(out, -right, right, -top, top, near, far, ndcZMin, ndcYSign);
4168
+ }
4169
+ if (cam.halfHeight != null) {
4170
+ const top = cam.halfHeight, right = top * aspect;
4171
+ return mat4Ortho(out, -right, right, -top, top, near, far, ndcZMin, ndcYSign);
4172
+ }
4173
+ return null;
4174
+ }
4175
+
4176
+ /**
4177
+ * The six frustum planes of the state, world space — frustumPlanes over the
4178
+ * lookat basis and the symmetric extents cameraProj uses, so visibility tests
4179
+ * run against a camera state without any matrix.
4180
+ *
4181
+ * @param {Float64Array} planes 24-element destination.
4182
+ * @param {object} cam
4183
+ * @param {number} aspect Viewport width / height.
4184
+ * @returns {Float64Array|null} planes, or null (planes untouched) when both
4185
+ * fov and halfHeight are null.
4186
+ */
4187
+ function cameraPlanes(planes, cam, aspect) {
4188
+ const ortho = cam.fov == null;
4189
+ if (ortho && cam.halfHeight == null) return null;
4190
+ const top = ortho ? cam.halfHeight : cam.near * Math.tan(cam.fov / 2), right = top * aspect;
4191
+ _frame(cam);
4192
+ const e = cam.eye;
4193
+ return frustumPlanes(planes,
4194
+ e[0], e[1], e[2],
4195
+ -_E$1[8], -_E$1[9], -_E$1[10],
4196
+ _E$1[4], _E$1[5], _E$1[6],
4197
+ _E$1[0], _E$1[1], _E$1[2],
4198
+ ortho, cam.near, cam.far, -right, right, top, -top);
4199
+ }
4200
+
4201
+ // =========================================================================
4202
+ // Decomposers — matrices and poses → state
4203
+ // =========================================================================
4204
+
4205
+ /**
4206
+ * Read a state back from an eye matrix and a projection: eye ← column 3,
4207
+ * up ← column 1, forward ← −column 2 of E; center ← eye + forward · d with d
4208
+ * the state's current gaze distance (1 when degenerate), so the distance
4209
+ * survives a round trip. The lens comes from the projection queries: fov or
4210
+ * halfHeight by projIsOrtho, near and far under `ndcZMin`.
4211
+ *
4212
+ * @param {object} cam State written in place.
4213
+ * @param {ArrayLike<number>} E Eye matrix (eye→world), 16 elements.
4214
+ * @param {ArrayLike<number>} P Projection matrix, 16 elements.
4215
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
4216
+ * @returns {object} cam
4217
+ */
4218
+ function cameraFromMat4(cam, E, P, ndcZMin) {
4219
+ const d = _gaze(cam);
4220
+ let fx = -E[8], fy = -E[9], fz = -E[10];
4221
+ const fl = Math.sqrt(fx*fx+fy*fy+fz*fz) || 1;
4222
+ fx /= fl; fy /= fl; fz /= fl;
4223
+ cam.eye[0]=E[12]; cam.eye[1]=E[13]; cam.eye[2]=E[14];
4224
+ cam.up[0]=E[4]; cam.up[1]=E[5]; cam.up[2]=E[6];
4225
+ cam.center[0]=cam.eye[0]+fx*d; cam.center[1]=cam.eye[1]+fy*d; cam.center[2]=cam.eye[2]+fz*d;
4226
+ if (projIsOrtho(P)) { cam.fov = null; cam.halfHeight = projTop(P, ndcZMin); }
4227
+ else { cam.fov = projFov(P); cam.halfHeight = null; }
4228
+ cam.near = projNear(P, ndcZMin);
4229
+ cam.far = projFar(P);
4230
+ return cam;
4231
+ }
4232
+
4233
+ /**
4234
+ * Drive the lookat from a TRS pose: eye ← pos, up and forward from the
4235
+ * rotation's columns 1 and −2, center ← eye + forward · d with d the current
4236
+ * gaze distance (1 when degenerate). The lens is untouched; `scl` is ignored.
4237
+ *
4238
+ * @param {object} cam State written in place.
4239
+ * @param {{ pos:number[], rot:number[] }} pose rot is a unit quaternion [x,y,z,w].
4240
+ * @returns {object} cam
4241
+ */
4242
+ function cameraFromPose(cam, pose) {
4243
+ const d = _gaze(cam);
4244
+ const q = pose.rot, x=q[0], y=q[1], z=q[2], w=q[3];
4245
+ const x2=x+x, y2=y+y, z2=z+z;
4246
+ const xx=x*x2, xy=x*y2, xz=x*z2, yy=y*y2, yz=y*z2, zz=z*z2, wx=w*x2, wy=w*y2, wz=w*z2;
4247
+ const ux=xy-wz, uy=1-(xx+zz), uz=yz+wx; // column 1 of qToMat4(rot): up
4248
+ const bx=xz+wy, by=yz-wx, bz=1-(xx+yy); // column 2: back
4249
+ cam.eye[0]=pose.pos[0]; cam.eye[1]=pose.pos[1]; cam.eye[2]=pose.pos[2];
4250
+ cam.up[0]=ux; cam.up[1]=uy; cam.up[2]=uz;
4251
+ cam.center[0]=cam.eye[0]-bx*d; cam.center[1]=cam.eye[1]-by*d; cam.center[2]=cam.eye[2]-bz*d;
4252
+ return cam;
4253
+ }
4254
+
4255
+ /**
4256
+ * The lookat as a TRS pose: pos ← eye, rot ← qFromLookDir(center − eye, up) —
4257
+ * the rotation of cameraEye, so a helm seeded from it continues the frame.
4258
+ *
4259
+ * @param {{ pos:number[], rot:number[] }} pose Written in place.
4260
+ * @param {object} cam
4261
+ * @returns {{ pos:number[], rot:number[] }} pose
4262
+ */
4263
+ function cameraToPose(pose, cam) {
4264
+ pose.pos[0]=cam.eye[0]; pose.pos[1]=cam.eye[1]; pose.pos[2]=cam.eye[2];
4265
+ _d[0]=cam.center[0]-cam.eye[0]; _d[1]=cam.center[1]-cam.eye[1]; _d[2]=cam.center[2]-cam.eye[2];
4266
+ qFromLookDir(pose.rot, _d, cam.up);
4267
+ return pose;
4268
+ }
4269
+
4270
+ // =========================================================================
4271
+ // Edits — in place, chainable, zero-alloc
4272
+ // =========================================================================
4273
+
4274
+ /**
4275
+ * Orbit the eye about the center: `dAz` rotates it about the up hint
4276
+ * (right-handed); `dEl` raises its elevation above the plane through the
4277
+ * center perpendicular to the hint, clamped to ±opts.maxEl so the view
4278
+ * direction never reaches the hint (the pole guard). `up` is left as the hint
4279
+ * it was, so an orbit never rolls. A state already at the pole is pulled
4280
+ * inside the guard by its first non-zero edit; (0, 0) is a no-op.
4281
+ *
4282
+ * @param {object} cam
4283
+ * @param {number} dAz Azimuth delta, radians.
4284
+ * @param {number} dEl Elevation delta, radians.
4285
+ * @param {{ maxEl?:number }} [opts] Elevation limit; default π/2 − 1e-3.
4286
+ * @returns {object} cam
4287
+ */
4288
+ function cameraOrbit(cam, dAz, dEl, opts) {
4289
+ if (dAz === 0 && dEl === 0) return cam;
4290
+ const maxEl = opts && typeof opts.maxEl === 'number' ? opts.maxEl : Math.PI / 2 - 1e-3;
4291
+ const e = cam.eye, c = cam.center;
4292
+ let ux=cam.up[0], uy=cam.up[1], uz=cam.up[2];
4293
+ const ul = Math.sqrt(ux*ux+uy*uy+uz*uz) || 1;
4294
+ ux/=ul; uy/=ul; uz/=ul;
4295
+ const dx=e[0]-c[0], dy=e[1]-c[1], dz=e[2]-c[2];
4296
+ const r = Math.sqrt(dx*dx+dy*dy+dz*dz);
4297
+ _frame(cam);
4298
+ const px=_E$1[4], py=_E$1[5], pz=_E$1[6]; // the eye's up
4299
+ const bx=_E$1[8], by=_E$1[9], bz=_E$1[10]; // back: center → eye, unit
4300
+ // elevation: raise by θ within the (back, up) plane, clamped
4301
+ const el = Math.asin(Math.max(-1, Math.min(1, bx*ux+by*uy+bz*uz)));
4302
+ const th = Math.max(-maxEl, Math.min(maxEl, el + dEl)) - el;
4303
+ const ct = Math.cos(th), st = Math.sin(th);
4304
+ const ox = r*(bx*ct+px*st), oy = r*(by*ct+py*st), oz = r*(bz*ct+pz*st);
4305
+ // azimuth: rotate about the hint (Rodrigues)
4306
+ const ca = Math.cos(dAz), sa = Math.sin(dAz), k = (ux*ox+uy*oy+uz*oz)*(1-ca);
4307
+ e[0] = c[0] + ox*ca + (uy*oz-uz*oy)*sa + ux*k;
4308
+ e[1] = c[1] + oy*ca + (uz*ox-ux*oz)*sa + uy*k;
4309
+ e[2] = c[2] + oz*ca + (ux*oy-uy*ox)*sa + uz*k;
4310
+ return cam;
4311
+ }
4312
+
4313
+ /**
4314
+ * Dolly: scale the gaze distance by `factor` — the eye moves along the view
4315
+ * direction under perspective; under orthographic (fov null, halfHeight set)
4316
+ * halfHeight is scaled instead, since moving the eye changes nothing on
4317
+ * screen. opts.min / opts.max clamp the scaled quantity. An edit that would
4318
+ * make it non-positive is a no-op.
4319
+ *
4320
+ * @param {object} cam
4321
+ * @param {number} factor
4322
+ * @param {{ min?:number, max?:number }} [opts]
4323
+ * @returns {object} cam
4324
+ */
4325
+ function cameraDolly(cam, factor, opts) {
4326
+ const min = opts && typeof opts.min === 'number' ? opts.min : 0;
4327
+ const max = opts && typeof opts.max === 'number' ? opts.max : Infinity;
4328
+ if (cam.fov == null && cam.halfHeight != null) {
4329
+ const h = Math.max(min, Math.min(max, cam.halfHeight * factor));
4330
+ if (h > 0) cam.halfHeight = h;
4331
+ return cam;
4332
+ }
4333
+ const e = cam.eye, c = cam.center;
4334
+ const dx=e[0]-c[0], dy=e[1]-c[1], dz=e[2]-c[2];
4335
+ const r = Math.sqrt(dx*dx+dy*dy+dz*dz);
4336
+ if (r === 0) return cam;
4337
+ const r1 = Math.max(min, Math.min(max, r * factor));
4338
+ if (!(r1 > 0)) return cam;
4339
+ const s = r1 / r;
4340
+ e[0]=c[0]+dx*s; e[1]=c[1]+dy*s; e[2]=c[2]+dz*s;
4341
+ return cam;
4342
+ }
4343
+
4344
+ /**
4345
+ * Pan: translate eye and center by `dx` along the eye's right and `dy` along
4346
+ * its up, world units. The caller converts pixels through pixelRatio at the
4347
+ * center's depth so a pan tracks the pointer.
4348
+ *
4349
+ * @param {object} cam
4350
+ * @param {number} dx
4351
+ * @param {number} dy
4352
+ * @returns {object} cam
4353
+ */
4354
+ function cameraPan(cam, dx, dy) {
4355
+ _frame(cam);
4356
+ const tx=_E$1[0]*dx+_E$1[4]*dy, ty=_E$1[1]*dx+_E$1[5]*dy, tz=_E$1[2]*dx+_E$1[6]*dy;
4357
+ const e = cam.eye, c = cam.center;
4358
+ e[0]+=tx; e[1]+=ty; e[2]+=tz;
4359
+ c[0]+=tx; c[1]+=ty; c[2]+=tz;
4360
+ return cam;
4361
+ }
4362
+
4363
+ /**
4364
+ * @file Gizmo line generators in the arrays shape.
4365
+ * @module tree/gizmo
4366
+ * @license AGPL-3.0-only
4367
+ *
4368
+ * A gizmo is geometry that explains: axes, a grid, a frustum, a path, a rig,
4369
+ * a handle's locus. This module generates its vertices — renderer-free, into
4370
+ * caller-owned arrays shaped the way twgl's createBufferInfoFromArrays
4371
+ * consumes them and a WebGPU vertex buffer is filled from — and nothing
4372
+ * else. Drawing, colour state, HUD mode, textures, the dot at a handle's
4373
+ * point, text: all the bridge's or the host's.
4374
+ *
4375
+ * ── The arrays shape ───────────────────────────────────────────────────────
4376
+ *
4377
+ * out = {
4378
+ * position: { numComponents: 3, data: Float32Array(3 · capacity) },
4379
+ * color: { numComponents: 4, data: Float32Array(4 · capacity) }, // optional
4380
+ * texcoord: { numComponents: 2, data: Float32Array(2 · capacity) }, // optional — panes
4381
+ * count: 0, // vertices written
4382
+ * labels: [], // optional — { x, y, z, text }
4383
+ * }
4384
+ *
4385
+ * Line generators write line lists — vertex pairs, no indices; paneTris
4386
+ * writes two triangles. capacity is position.data.length / 3.
4387
+ *
4388
+ * ── The contract — snprintf-style ──────────────────────────────────────────
4389
+ * Every generator gen(out, …) → n returns the vertex count it needs, writes
4390
+ * min(n, capacity) vertices, and sets out.count to what it wrote. A caller
4391
+ * sizes once and grows never in steady state:
4392
+ *
4393
+ * let n = axesLines(out, opts)
4394
+ * if (n > capacityOf(out)) { growArrays(out, n); axesLines(out, opts) }
4395
+ *
4396
+ * Each generator states its count formula so a caller can pre-size exactly.
4397
+ *
4398
+ * ── Colour ─────────────────────────────────────────────────────────────────
4399
+ * If out.color exists, every vertex written gets a colour: a generator with
4400
+ * semantic colouring (axes, the helm rig) writes its palette, every other
4401
+ * generator writes opts.color (default white). If out.color is absent
4402
+ * nothing is written and the bridge draws with a uniform colour.
4403
+ *
4404
+ * ── Frames ─────────────────────────────────────────────────────────────────
4405
+ * A generator writes in the frame the caller means — model space for scene
4406
+ * gizmos (the bridge's M places them), screen pixels for HUD gizmos.
4407
+ * Nothing here consults a camera except locusLines and frustumLines, which
4408
+ * take what they need explicitly. Signatures: out first, the subject second
4409
+ * where there is one, options last.
4410
+ */
4411
+
4412
+
4413
+ const TWO_PI = Math.PI * 2;
4414
+ const _AXIS_COLORS = [COLOR_X, COLOR_Y, COLOR_Z];
4415
+ const _U = [1, 0, 0], _V = [0, 1, 0]; // the HUD plane's basis
4416
+ const _E = new Float64Array(16); // a camera state's eye matrix
4417
+ const _p3 = [0, 0, 0]; // a transformed corner / a sampled point
4418
+ const _q3 = [0, 0, 0]; // the previous sampled point
4419
+ const _c24 = new Float64Array(24); // frustum corners scratch
4420
+ const _tIn = [0, 0, 0], _tOut = [0, 0, 0]; // a keyframe's tangents
4421
+ const _act = [0, 0, 0, 0, 0, 0]; // a helm's activity
4422
+ const _tip = [0, 0, 0], _ha = [0, 0, 0]; // an arrow's tip and head base
4423
+ const _AXES = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
4424
+ const _b0 = [0, 0, 0], _b1 = [0, 0, 0], _b2 = [0, 0, 0]; // a locus basis
4425
+ const _UV_DEFAULT = [0, 1, 1, 1, 1, 0, 0, 0]; // paneTris: p0 top-left → (0, 1), clockwise
4426
+
4427
+ // =========================================================================
4428
+ // G1 Arrays — the one allocating call, growth, capacity
4429
+ // =========================================================================
4430
+
4431
+ /**
4432
+ * Allocate an arrays object of `capacity` vertices — the one allocating
4433
+ * call, setup-time. Flags add the optional attributes and the label list.
4434
+ *
4435
+ * @param {number} capacity Vertices.
4436
+ * @param {{ color?:boolean, texcoord?:boolean, labels?:boolean }} [opts]
4437
+ * @returns {{ position:{numComponents:number,data:Float32Array},
4438
+ * color?:{numComponents:number,data:Float32Array},
4439
+ * texcoord?:{numComponents:number,data:Float32Array},
4440
+ * count:number, labels?:object[] }}
4441
+ */
4442
+ function createArrays(capacity, opts) {
4443
+ const o = opts || {};
4444
+ const n = Math.max(0, capacity | 0);
4445
+ const out = { position: { numComponents: 3, data: new Float32Array(3 * n) } };
4446
+ if (o.color) out.color = { numComponents: 4, data: new Float32Array(4 * n) };
4447
+ if (o.texcoord) out.texcoord = { numComponents: 2, data: new Float32Array(2 * n) };
4448
+ out.count = 0;
4449
+ if (o.labels) out.labels = [];
4450
+ return out;
4451
+ }
4452
+
4453
+ /**
4454
+ * Reallocate an arrays object to a new capacity, keeping its attribute set;
4455
+ * the data is fresh (a generator refills it) and count is 0.
4456
+ *
4457
+ * @param {object} out An arrays object from createArrays.
4458
+ * @param {number} capacity Vertices.
4459
+ * @returns {object} out
4460
+ */
4461
+ function growArrays(out, capacity) {
4462
+ const n = Math.max(0, capacity | 0);
4463
+ out.position.data = new Float32Array(3 * n);
4464
+ if (out.color) out.color.data = new Float32Array(4 * n);
4465
+ if (out.texcoord) out.texcoord.data = new Float32Array(2 * n);
4466
+ out.count = 0;
4467
+ if (out.labels) out.labels.length = 0;
4468
+ return out;
4469
+ }
4470
+
4471
+ /**
4472
+ * The vertex capacity of an arrays object: position.data.length / 3.
4473
+ * @param {object} out
4474
+ * @returns {number}
4475
+ */
4476
+ function capacityOf(out) {
4477
+ return (out.position.data.length / 3) | 0;
4478
+ }
4479
+
4480
+ // =========================================================================
4481
+ // G2 The writer — a cursor over out; counts every vertex, writes those
4482
+ // within capacity. Module-level scratch: a generator runs to completion.
4483
+ // =========================================================================
4484
+
4485
+ const _w = { pos: null, col: null, tex: null, cap: 0, n: 0, r: 1, g: 1, b: 1, a: 1 };
4486
+ const _WHITE = [1, 1, 1, 1];
4487
+
4488
+ function _begin(out) {
4489
+ _w.pos = out.position.data;
4490
+ _w.col = out.color ? out.color.data : null;
4491
+ _w.tex = out.texcoord ? out.texcoord.data : null;
4492
+ _w.cap = (_w.pos.length / 3) | 0;
4493
+ _w.n = 0;
4494
+ if (out.labels) out.labels.length = 0;
4495
+ }
4496
+
4497
+ /** Set the current colour from an RGB(A) array, with an alpha override. */
4498
+ function _color(c, alpha) {
4499
+ const v = c || _WHITE;
4500
+ _w.r = v[0]; _w.g = v[1]; _w.b = v[2];
4501
+ _w.a = alpha != null ? alpha : (v.length > 3 ? v[3] : 1);
4502
+ }
4503
+
4504
+ function _vertex(x, y, z) {
4505
+ const n = _w.n++;
4506
+ if (n >= _w.cap) return;
4507
+ const p = _w.pos, i = 3 * n;
4508
+ p[i] = x; p[i + 1] = y; p[i + 2] = z;
4509
+ const c = _w.col;
4510
+ if (c) { const j = 4 * n; c[j] = _w.r; c[j + 1] = _w.g; c[j + 2] = _w.b; c[j + 3] = _w.a; }
4511
+ }
4512
+
4513
+ function _line(x0, y0, z0, x1, y1, z1) {
4514
+ _vertex(x0, y0, z0);
4515
+ _vertex(x1, y1, z1);
4516
+ }
4517
+
4518
+ /** A vertex with a texture coordinate (written when out.texcoord exists). */
4519
+ function _vertexUV(x, y, z, u, v) {
4520
+ const n = _w.n;
4521
+ _vertex(x, y, z);
4522
+ if (_w.tex && n < _w.cap) { _w.tex[2*n] = u; _w.tex[2*n + 1] = v; }
4523
+ }
4524
+
4525
+ /** Normalise v in place; a zero vector becomes (dx, dy, dz). */
4526
+ function _unit(v, dx, dy, dz) {
4527
+ const l = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
4528
+ if (l < 1e-9) { v[0] = dx; v[1] = dy; v[2] = dz; return v; }
4529
+ v[0] /= l; v[1] /= l; v[2] /= l;
4530
+ return v;
4531
+ }
4532
+
4533
+ /** Orthonormal in-plane basis (ub, vb) for a unit normal n, seeded from the least-aligned axis. */
4534
+ function _basis(n, ub, vb) {
4535
+ const ax = Math.abs(n[0]), ay = Math.abs(n[1]), az = Math.abs(n[2]);
4536
+ let rx = 0, ry = 0, rz = 0;
4537
+ if (ax <= ay && ax <= az) rx = 1; else if (ay <= az) ry = 1; else rz = 1;
4538
+ ub[0] = ry*n[2] - rz*n[1]; ub[1] = rz*n[0] - rx*n[2]; ub[2] = rx*n[1] - ry*n[0];
4539
+ _unit(ub, 1, 0, 0);
4540
+ vb[0] = n[1]*ub[2] - n[2]*ub[1]; vb[1] = n[2]*ub[0] - n[0]*ub[2]; vb[2] = n[0]*ub[1] - n[1]*ub[0];
4541
+ }
4542
+
4543
+ /** The four edges of a square of half-extent h at c, spanned by u, v. */
4544
+ function _square(c, u, v, h) {
4545
+ const x0 = c[0] - h*u[0] - h*v[0], y0 = c[1] - h*u[1] - h*v[1], z0 = c[2] - h*u[2] - h*v[2];
4546
+ const x1 = c[0] + h*u[0] - h*v[0], y1 = c[1] + h*u[1] - h*v[1], z1 = c[2] + h*u[2] - h*v[2];
4547
+ const x2 = c[0] + h*u[0] + h*v[0], y2 = c[1] + h*u[1] + h*v[1], z2 = c[2] + h*u[2] + h*v[2];
4548
+ const x3 = c[0] - h*u[0] + h*v[0], y3 = c[1] - h*u[1] + h*v[1], z3 = c[2] - h*u[2] + h*v[2];
4549
+ _line(x0, y0, z0, x1, y1, z1);
4550
+ _line(x1, y1, z1, x2, y2, z2);
4551
+ _line(x2, y2, z2, x3, y3, z3);
4552
+ _line(x3, y3, z3, x0, y0, z0);
4553
+ }
4554
+
4555
+ /** Close the write: count ← what fits; return what was needed. */
4556
+ function _end(out) {
4557
+ out.count = _w.n < _w.cap ? _w.n : _w.cap;
4558
+ return _w.n;
4559
+ }
4560
+
4561
+ /** A sampled circle (or arc of `sweep`) of radius r at c, spanned by u, v: n segments. */
4562
+ function _ring(cx, cy, cz, r, u, v, n, sweep) {
4563
+ let px = 0, py = 0, pz = 0;
4564
+ for (let i = 0; i <= n; i++) {
4565
+ const t = (i / n) * sweep;
4566
+ const ct = Math.cos(t) * r, st = Math.sin(t) * r;
4567
+ const x = cx + ct*u[0] + st*v[0];
4568
+ const y = cy + ct*u[1] + st*v[1];
4569
+ const z = cz + ct*u[2] + st*v[2];
4570
+ if (i > 0) _line(px, py, pz, x, y, z);
4571
+ px = x; py = y; pz = z;
4572
+ }
4573
+ }
4574
+
4575
+ // =========================================================================
4576
+ // G3 Axes, grid, cross, bulls-eye, ring
4577
+ // =========================================================================
4578
+
4579
+ /**
4580
+ * A coordinate frame at the origin: six half-axes by bit and, with LABELS,
4581
+ * the X (2 lines) · Y (4) · Z (3) glyphs at 1.04 · size, sized size / 40 ×
4582
+ * size / 30. Semantic colour per axis and its glyph (COLOR_X / Y / Z), or
4583
+ * opts.color when `semantic` is false.
4584
+ *
4585
+ * Count: 2 · axes + 18 · (LABELS ? 1 : 0), at most 30.
4586
+ *
4587
+ * @param {object} out Arrays object.
4588
+ * @param {{ size?:number, bits?:number, semantic?:boolean, color?:number[] }} [opts]
4589
+ * @returns {number} Vertices needed.
4590
+ */
4591
+ function axesLines(out, opts) {
4592
+ const o = opts || {};
4593
+ const size = o.size ?? 100;
4594
+ const bits = o.bits ?? (LABELS | X | Y | Z);
4595
+ const semantic = o.semantic !== false;
4596
+ const axis = (i) => _color(semantic ? _AXIS_COLORS[i] : o.color);
4597
+ _begin(out);
4598
+ if (bits & LABELS) {
4599
+ const cw = size/40, ch = size/30, cs = 1.04*size;
4600
+ axis(0);
4601
+ _line(cs, cw, -ch, cs, -cw, ch);
4602
+ _line(cs, -cw, -ch, cs, cw, ch);
4603
+ axis(1);
4604
+ _line( cw, cs, ch, 0, cs, 0);
4605
+ _line( 0, cs, 0, -cw, cs, ch);
4606
+ _line(-cw, cs, ch, 0, cs, 0);
4607
+ _line( 0, cs, 0, 0, cs, -ch);
4608
+ axis(2);
4609
+ _line(-cw, -ch, cs, cw, -ch, cs);
4610
+ _line( cw, -ch, cs, -cw, ch, cs);
4611
+ _line(-cw, ch, cs, cw, ch, cs);
4612
+ }
4613
+ axis(0);
4614
+ if (bits & X) _line(0, 0, 0, size, 0, 0);
4615
+ if (bits & _X) _line(0, 0, 0, -size, 0, 0);
4616
+ axis(1);
4617
+ if (bits & Y) _line(0, 0, 0, 0, size, 0);
4618
+ if (bits & _Y) _line(0, 0, 0, 0, -size, 0);
4619
+ axis(2);
4620
+ if (bits & Z) _line(0, 0, 0, 0, 0, size);
4621
+ if (bits & _Z) _line(0, 0, 0, 0, 0, -size);
4622
+ return _end(out);
4623
+ }
4624
+
4625
+ /**
4626
+ * A grid in the XY plane: subdivisions + 1 lines each way spanning ±size.
4627
+ * Orientation is the caller's M (a ground plane is a rotation about X).
4628
+ *
4629
+ * Count: 4 · (subdivisions + 1).
4630
+ *
4631
+ * @param {object} out Arrays object.
4632
+ * @param {{ size?:number, subdivisions?:number, color?:number[] }} [opts]
4633
+ * @returns {number} Vertices needed.
4634
+ */
4635
+ function gridLines(out, opts) {
4636
+ const o = opts || {};
4637
+ const size = o.size ?? 100;
4638
+ const sub = Math.max(1, (o.subdivisions ?? 10) | 0);
4639
+ _begin(out);
4640
+ _color(o.color);
4641
+ for (let i = 0; i <= sub; i++) {
4642
+ const pos = size * (2*i/sub - 1);
4643
+ _line(pos, -size, 0, pos, size, 0);
4644
+ _line(-size, pos, 0, size, pos, 0);
4645
+ }
4646
+ return _end(out);
4647
+ }
4648
+
4649
+ /**
4650
+ * A crosshair in HUD space (z = 0, target pixels): two lines of `size`
4651
+ * through (x, y). The bridge projects a model origin and converts a world
4652
+ * size to pixels before this call.
4653
+ *
4654
+ * Count: 4.
4655
+ *
4656
+ * @param {object} out Arrays object.
4657
+ * @param {{ x?:number, y?:number, size?:number, color?:number[] }} [opts]
4658
+ * @returns {number} Vertices needed.
4659
+ */
4660
+ function crossLines(out, opts) {
4661
+ const o = opts || {};
4662
+ const x = o.x ?? 0, y = o.y ?? 0, half = (o.size ?? 50) / 2;
4663
+ _begin(out);
4664
+ _color(o.color);
4665
+ _line(x - half, y, 0, x + half, y, 0);
4666
+ _line(x, y - half, 0, x, y + half, 0);
4667
+ return _end(out);
4668
+ }
4669
+
4670
+ /**
4671
+ * A bulls-eye in HUD space (z = 0, target pixels): a sampled circle of
4672
+ * radius size / 2 (`detail` segments) or the cornered square (8 lines),
4673
+ * plus the central cross at 0.6 · half.
4674
+ *
4675
+ * Count: 2 · detail + 4 (CIRCLE) or 20 (SQUARE).
4676
+ *
4677
+ * @param {object} out Arrays object.
4678
+ * @param {{ x?:number, y?:number, size?:number, shape?:number, detail?:number, color?:number[] }} [opts]
4679
+ * @returns {number} Vertices needed.
4680
+ */
4681
+ function bullsEyeLines(out, opts) {
4682
+ const o = opts || {};
4683
+ const x = o.x ?? 0, y = o.y ?? 0, half = (o.size ?? 50) / 2;
4684
+ const shape = o.shape ?? CIRCLE;
4685
+ const detail = Math.max(3, (o.detail ?? 50) | 0);
4686
+ _begin(out);
4687
+ _color(o.color);
4688
+ if (shape === CIRCLE) {
4689
+ _ring(x, y, 0, half, _U, _V, detail, TWO_PI);
4690
+ } else {
4691
+ const c = 0.6 * half;
4692
+ _line(x-half, y-half+c, 0, x-half, y-half, 0);
4693
+ _line(x-half, y-half, 0, x-half+c, y-half, 0);
4694
+ _line(x+half-c, y-half, 0, x+half, y-half, 0);
4695
+ _line(x+half, y-half, 0, x+half, y-half+c, 0);
4696
+ _line(x+half, y+half-c, 0, x+half, y+half, 0);
4697
+ _line(x+half, y+half, 0, x+half-c, y+half, 0);
4698
+ _line(x-half+c, y+half, 0, x-half, y+half, 0);
4699
+ _line(x-half, y+half, 0, x-half, y+half-c, 0);
4700
+ }
4701
+ const ch = 0.6 * half;
4702
+ _line(x - ch, y, 0, x + ch, y, 0);
4703
+ _line(x, y - ch, 0, x, y + ch, 0);
4704
+ return _end(out);
4705
+ }
4706
+
4707
+ /**
4708
+ * A sampled circle of radius r at (cx, cy, cz) spanned by the orthonormal
4709
+ * u, v — the shared primitive; a partial `sweep` gives an arc from u.
4710
+ *
4711
+ * Count: 2 · detail.
4712
+ *
4713
+ * @param {object} out Arrays object.
4714
+ * @param {number} cx,cy,cz Centre.
4715
+ * @param {number} r Radius.
4716
+ * @param {number[]} u,v Orthonormal in-plane basis.
4717
+ * @param {{ detail?:number, sweep?:number, color?:number[] }} [opts]
4718
+ * @returns {number} Vertices needed.
4719
+ */
4720
+ function ringLines(out, cx, cy, cz, r, u, v, opts) {
4721
+ const o = opts || {};
4722
+ const detail = Math.max(1, (o.detail ?? 48) | 0);
4723
+ const sweep = o.sweep ?? TWO_PI;
4724
+ _begin(out);
4725
+ _color(o.color);
4726
+ _ring(cx, cy, cz, r, u, v, detail, sweep);
4727
+ return _end(out);
4728
+ }
4729
+
4730
+ // =========================================================================
4731
+ // G4 Frustum and Hermite
4732
+ // =========================================================================
4733
+
4734
+ const _isMat = (cam) => cam != null && cam.mat4Eye != null && cam.mat4Proj != null;
4735
+
4736
+ /** Corner i of out24 ← E · (x, y, z). */
4737
+ function _corner(out24, i, E, x, y, z) {
4738
+ mat4MulPoint(_p3, E, x, y, z);
4739
+ out24[3*i] = _p3[0]; out24[3*i + 1] = _p3[1]; out24[3*i + 2] = _p3[2];
4740
+ }
4741
+
4742
+ /**
4743
+ * The eight world-space corners of a camera's frustum: the near face 0–3
4744
+ * counter-clockwise from bottom-left (BL, BR, TR, TL), then the far face
4745
+ * 4–7 in the same order — so corners 3, 2, 1, 0 are a pane's TL, TR, BR,
4746
+ * BL. `cam` is a camera state (its symmetric extents from fov or
4747
+ * halfHeight and `aspect`), or { mat4Eye, mat4Proj, ndcZMin? } for a
4748
+ * matrix-captured camera (the extents read off the projection). The far
4749
+ * extents follow by similar triangles, or equal the near ones under
4750
+ * orthographic.
4751
+ *
4752
+ * @param {Float64Array|number[]} out24 24-element destination.
4753
+ * @param {object} cam Camera state, or { mat4Eye, mat4Proj, ndcZMin? }.
4754
+ * @param {number} [aspect=1] Viewport width / height (state form).
4755
+ * @param {number} [ndcZMin=WEBGL] NDC-z convention when the matrix form carries none.
4756
+ * @returns {Float64Array|number[]|null} out24, or null when the state's lens is unset.
4757
+ */
4758
+ function frustumCorners(out24, cam, aspect, ndcZMin) {
4759
+ let E, n, f, l, r, t, b, ortho;
4760
+ if (_isMat(cam)) {
4761
+ E = cam.mat4Eye;
4762
+ const P = cam.mat4Proj, z = cam.ndcZMin ?? ndcZMin ?? WEBGL;
4763
+ ortho = projIsOrtho(P);
4764
+ n = projNear(P, z); f = projFar(P);
4765
+ l = projLeft(P, z); r = projRight(P, z); t = projTop(P, z); b = projBottom(P, z);
4766
+ } else {
4767
+ ortho = cam.fov == null;
4768
+ if (ortho && cam.halfHeight == null) return null;
4769
+ n = cam.near; f = cam.far;
4770
+ t = ortho ? cam.halfHeight : n * Math.tan(cam.fov / 2);
4771
+ r = t * (aspect ?? 1);
4772
+ b = -t; l = -r;
4773
+ E = cameraEye(_E, cam);
4774
+ }
4775
+ const k = ortho ? 1 : f / n;
4776
+ _corner(out24, 0, E, l, b, -n);
4777
+ _corner(out24, 1, E, r, b, -n);
4778
+ _corner(out24, 2, E, r, t, -n);
4779
+ _corner(out24, 3, E, l, t, -n);
4780
+ _corner(out24, 4, E, k*l, k*b, -f);
4781
+ _corner(out24, 5, E, k*r, k*b, -f);
4782
+ _corner(out24, 6, E, k*r, k*t, -f);
4783
+ _corner(out24, 7, E, k*l, k*t, -f);
4784
+ return out24;
4785
+ }
4786
+
4787
+ /** Line between corners i and j of the scratch corners. */
4788
+ function _edge(i, j) {
4789
+ _line(_c24[3*i], _c24[3*i + 1], _c24[3*i + 2], _c24[3*j], _c24[3*j + 1], _c24[3*j + 2]);
4790
+ }
4791
+
4792
+ /**
4793
+ * A camera's frustum as edges by bit: NEAR and FAR the two rectangles,
4794
+ * BODY the four edges joining them, APEX (perspective only) the eye to
4795
+ * the near corners. `cam` as frustumCorners takes it.
4796
+ *
4797
+ * Count: 8 · (NEAR + FAR + BODY + APEX), at most 32.
4798
+ *
4799
+ * @param {object} out Arrays object.
4800
+ * @param {object} cam Camera state, or { mat4Eye, mat4Proj, ndcZMin? }.
4801
+ * @param {{ aspect?:number, ndcZMin?:number, bits?:number, color?:number[] }} [opts]
4802
+ * @returns {number} Vertices needed (0 when the state's lens is unset).
4803
+ */
4804
+ function frustumLines(out, cam, opts) {
4805
+ const o = opts || {};
4806
+ const bits = o.bits ?? (NEAR | FAR | BODY | APEX);
4807
+ _begin(out);
4808
+ _color(o.color);
4809
+ if (frustumCorners(_c24, cam, o.aspect ?? 1, o.ndcZMin ?? WEBGL) === null) return _end(out);
4810
+ if (bits & NEAR) { _edge(0, 1); _edge(1, 2); _edge(2, 3); _edge(3, 0); }
4811
+ if (bits & FAR) { _edge(4, 5); _edge(5, 6); _edge(6, 7); _edge(7, 4); }
4812
+ if (bits & BODY) { _edge(0, 4); _edge(1, 5); _edge(2, 6); _edge(3, 7); }
4813
+ const persp = _isMat(cam) ? !projIsOrtho(cam.mat4Proj) : cam.fov != null;
4814
+ if ((bits & APEX) && persp) {
4815
+ const E = _isMat(cam) ? cam.mat4Eye : null;
4816
+ const ex = E ? E[12] : cam.eye[0], ey = E ? E[13] : cam.eye[1], ez = E ? E[14] : cam.eye[2];
4817
+ for (let i = 0; i < 4; i++) _line(ex, ey, ez, _c24[3*i], _c24[3*i + 1], _c24[3*i + 2]);
4818
+ }
4819
+ return _end(out);
4820
+ }
4821
+
4822
+ /**
4823
+ * One cubic Hermite segment through hermiteVec3, as a polyline of
4824
+ * `samples` steps.
4825
+ *
4826
+ * Count: 2 · samples.
4827
+ *
4828
+ * @param {object} out Arrays object.
4829
+ * @param {number[]} p0,t0 Start point and its outgoing tangent.
4830
+ * @param {number[]} p1,t1 End point and its incoming tangent.
4831
+ * @param {{ samples?:number, color?:number[] }} [opts]
4832
+ * @returns {number} Vertices needed.
4833
+ */
4834
+ function hermiteLines(out, p0, t0, p1, t1, opts) {
4835
+ const o = opts || {};
4836
+ const N = Math.max(1, (o.samples ?? 32) | 0);
4837
+ _begin(out);
4838
+ _color(o.color);
4839
+ for (let i = 0; i <= N; i++) {
4840
+ hermiteVec3(_p3, p0, t0, p1, t1, i / N);
4841
+ if (i > 0) _line(_q3[0], _q3[1], _q3[2], _p3[0], _p3[1], _p3[2]);
4842
+ _q3[0] = _p3[0]; _q3[1] = _p3[1]; _q3[2] = _p3[2];
4843
+ }
4844
+ return _end(out);
4845
+ }
4846
+
4847
+ // =========================================================================
4848
+ // G5 Path, helm rig, locus, pane
4849
+ // =========================================================================
4850
+
4851
+ /**
4852
+ * A PoseTrack or CameraTrack's path by bit, over the track's own samplers
4853
+ * (samplePos / sampleEye / sampleCenter and the tangent readers), so the
4854
+ * interpolation modes are honoured: PATH the sampled polyline, `samples`
4855
+ * per segment; CONTROLS the straight control polygon; TANGENTS_IN /
4856
+ * TANGENTS_OUT the tangent at each keyframe scaled by `tangentScale`;
4857
+ * CENTER (camera tracks) the gaze line eye → center per keyframe and a
4858
+ * three-axis star of half-size `centerSize` at the center. `target`
4859
+ * ('eye' or 'center') picks a camera track's path for the first three
4860
+ * bits. Markers and handles are the bridge's composition.
4861
+ *
4862
+ * Count: 2 · samples · segments (PATH) + 2 · segments (CONTROLS) +
4863
+ * 2 · keyframes per tangent bit + 8 · keyframes (CENTER).
4864
+ *
4865
+ * @param {object} out Arrays object.
4866
+ * @param {object} track PoseTrack or CameraTrack.
4867
+ * @param {{ bits?:number, samples?:number, tangentScale?:number, target?:string,
4868
+ * centerSize?:number, color?:number[] }} [opts]
4869
+ * @returns {number} Vertices needed.
4870
+ */
4871
+ function pathLines(out, track, opts) {
4872
+ const o = opts || {};
4873
+ const bits = o.bits ?? (PATH | CONTROLS | TANGENTS_IN | TANGENTS_OUT);
4874
+ const N = Math.max(1, (o.samples ?? 32) | 0);
4875
+ const ts = o.tangentScale ?? 0.25;
4876
+ const cs = o.centerSize ?? 4;
4877
+ const kfs = track.keyframes, n = kfs.length;
4878
+ const isCamera = typeof track.sampleEye === 'function';
4879
+ const useCenter = isCamera && o.target === 'center';
4880
+ const field = isCamera ? (useCenter ? 'center' : 'eye') : 'pos';
4881
+ const sampler = isCamera ? (useCenter ? 'sampleCenter' : 'sampleEye') : 'samplePos';
4882
+ const tangents = isCamera ? (useCenter ? 'centerTangents' : 'eyeTangents') : 'tangents';
4883
+ _begin(out);
4884
+ _color(o.color);
4885
+ if ((bits & PATH) && n > 1) {
4886
+ for (let seg = 0; seg < n - 1; seg++) {
4887
+ for (let i = 0; i <= N; i++) {
4888
+ track[sampler](_p3, seg, i / N);
4889
+ if (i > 0) _line(_q3[0], _q3[1], _q3[2], _p3[0], _p3[1], _p3[2]);
4890
+ _q3[0] = _p3[0]; _q3[1] = _p3[1]; _q3[2] = _p3[2];
4891
+ }
4892
+ }
4893
+ }
4894
+ if (bits & CONTROLS) {
4895
+ for (let i = 0; i < n - 1; i++) {
4896
+ const a = kfs[i][field], b = kfs[i + 1][field];
4897
+ _line(a[0], a[1], a[2], b[0], b[1], b[2]);
4898
+ }
4899
+ }
4900
+ if (bits & (TANGENTS_IN | TANGENTS_OUT)) {
4901
+ for (let i = 0; i < n; i++) {
4902
+ track[tangents](_tIn, _tOut, i);
4903
+ const k = kfs[i][field];
4904
+ if (bits & TANGENTS_IN) _line(k[0] - ts*_tIn[0], k[1] - ts*_tIn[1], k[2] - ts*_tIn[2], k[0], k[1], k[2]);
4905
+ if (bits & TANGENTS_OUT) _line(k[0], k[1], k[2], k[0] + ts*_tOut[0], k[1] + ts*_tOut[1], k[2] + ts*_tOut[2]);
4906
+ }
4907
+ }
4908
+ if ((bits & CENTER) && isCamera) {
4909
+ for (let i = 0; i < n; i++) {
4910
+ const e = kfs[i].eye, c = kfs[i].center;
4911
+ _line(e[0], e[1], e[2], c[0], c[1], c[2]);
4912
+ _line(c[0] - cs, c[1], c[2], c[0] + cs, c[1], c[2]);
4913
+ _line(c[0], c[1] - cs, c[2], c[0], c[1] + cs, c[2]);
4914
+ _line(c[0], c[1], c[2] - cs, c[0], c[1], c[2] + cs);
4915
+ }
4916
+ }
4917
+ return _end(out);
4918
+ }
4919
+
4920
+ /** An arrow along principal axis `axis` (0 X, 1 Y, 2 Z): signed length L, head size h — 5 lines. */
4921
+ function _arrow(axis, L, h) {
4922
+ const a = (axis + 1) % 3, b = (axis + 2) % 3;
4923
+ _tip[0] = _tip[1] = _tip[2] = 0; _tip[axis] = L;
4924
+ _line(0, 0, 0, _tip[0], _tip[1], _tip[2]);
4925
+ const s = Math.sign(L) || 1;
4926
+ _ha[0] = _ha[1] = _ha[2] = 0; _ha[axis] = L - s*h;
4927
+ _ha[a] = h*0.5; _line(_tip[0], _tip[1], _tip[2], _ha[0], _ha[1], _ha[2]);
4928
+ _ha[a] = -h*0.5; _line(_tip[0], _tip[1], _tip[2], _ha[0], _ha[1], _ha[2]);
4929
+ _ha[a] = 0;
4930
+ _ha[b] = h*0.5; _line(_tip[0], _tip[1], _tip[2], _ha[0], _ha[1], _ha[2]);
4931
+ _ha[b] = -h*0.5; _line(_tip[0], _tip[1], _tip[2], _ha[0], _ha[1], _ha[2]);
4932
+ }
4933
+
4934
+ /**
4935
+ * A helm's rig, colour always semantic: per translation channel a dim
4936
+ * baseline arrow of signed length sign · size · sens / 0.30 and, while the
4937
+ * channel's activity is non-zero, a bright arrow of length ∝ |activity| /
4938
+ * (sens · fullScale); per rotation channel a dim ring of radius size / 2 ·
4939
+ * sens / 0.0025 and a bright arc sweeping π · f in the live direction.
4940
+ * Dim and bright are the alpha of the written colour (COLOR_DIM, 1). With
4941
+ * `identify`, one anchor per channel goes to out.labels as { x, y, z,
4942
+ * text: 'L' + lane }. Orientation is the caller's M.
4943
+ *
4944
+ * Count: 10 · 3 · 2 (arrows) + 96 · 3 (rings) + 48 · 3 (arcs), at most 492;
4945
+ * the bright half only while a channel is active.
4946
+ *
4947
+ * @param {object} out Arrays object.
4948
+ * @param {object} helm A PoseHelm (profile, fullScale, activity).
4949
+ * @param {{ size?:number, bits?:number, identify?:boolean }} [opts]
4950
+ * @returns {number} Vertices needed.
4951
+ */
4952
+ function helmRigLines(out, helm, opts) {
4953
+ const o = opts || {};
4954
+ const size = o.size ?? 100;
4955
+ const bits = o.bits ?? (TRANSLATE | ROTATE);
4956
+ const identify = o.identify === true;
4957
+ const prof = helm.profile;
4958
+ const head = size * 0.08, ringR0 = size * 0.5;
4959
+ const TREF = 0.30, RREF = 0.0025, FULL = helm.fullScale, ARC_FULL = Math.PI;
4960
+ helm.activity(_act);
4961
+ _begin(out);
4962
+ const labels = identify && out.labels ? out.labels : null;
4963
+ if (bits & TRANSLATE) {
4964
+ const T = [prof.Tx, prof.Ty, prof.Tz];
4965
+ for (let ax = 0; ax < 3; ax++) {
4966
+ const ch = T[ax];
4967
+ const L = ch.sign * size * (ch.sens / TREF);
4968
+ _color(_AXIS_COLORS[ax], COLOR_DIM);
4969
+ _arrow(ax, L, head);
4970
+ const a = _act[ax];
4971
+ if (a !== 0) {
4972
+ const f = Math.min(Math.abs(a) / (ch.sens * FULL), 1);
4973
+ _color(_AXIS_COLORS[ax], 1);
4974
+ _arrow(ax, Math.sign(a) * f * Math.abs(L), head);
4975
+ }
4976
+ if (labels) {
4977
+ const lp = [0, 0, 0]; lp[ax] = L + ch.sign * head * 1.5;
4978
+ labels.push({ x: lp[0], y: lp[1], z: lp[2], text: 'L' + ch.lane });
4979
+ }
4980
+ }
4981
+ }
4982
+ if (bits & ROTATE) {
4983
+ const R = [prof.Rp, prof.Ry, prof.Rr]; // pitch ⊥ X, yaw ⊥ Y, roll ⊥ Z
4984
+ for (let ax = 0; ax < 3; ax++) {
4985
+ const ch = R[ax];
4986
+ const r = ringR0 * (ch.sens / RREF);
4987
+ const u = _AXES[(ax + 1) % 3], v = _AXES[(ax + 2) % 3];
4988
+ _color(_AXIS_COLORS[ax], COLOR_DIM);
4989
+ _ring(0, 0, 0, r, u, v, 48, TWO_PI);
4990
+ const a = _act[3 + ax];
4991
+ if (a !== 0) {
4992
+ const f = Math.min(Math.abs(a) / (ch.sens * FULL), 1);
4993
+ _color(_AXIS_COLORS[ax], 1);
4994
+ _ring(0, 0, 0, r, u, v, 24, Math.sign(a) * f * ARC_FULL);
4995
+ }
4996
+ if (labels) {
4997
+ const lp = [0, 0, 0]; lp[(ax + 1) % 3] = r;
4998
+ labels.push({ x: lp[0], y: lp[1], z: lp[2], text: 'L' + ch.lane });
4999
+ }
5000
+ }
5001
+ }
5002
+ return _end(out);
5003
+ }
5004
+
5005
+ /**
5006
+ * A handle's stroked parts by bit. AIM: anchor → `point` (the handle's
5007
+ * current point, read by the caller with value). LOCUS by constraint.kind:
5008
+ * SPHERE three great circles about the anchor; PLANE a square of
5009
+ * half-extent 100 in the plane's basis; AXIS the segment anchor + [min,
5010
+ * max] · u; DIAL the ring in the dial plane; a constraint flagged `view`
5011
+ * (the host's VIEW) a screen-aligned square of half-extent 100 at `point`,
5012
+ * its basis the camera's right and up read off `mat4View`. RING: SPHERE
5013
+ * the view-facing limb, a ring perpendicular to anchor − eye (the eye from
5014
+ * `mat4View`); PLANE the border of the locus square (written once when
5015
+ * both bits ask for it). A constraint supplying locus(out, opts) is
5016
+ * dispatched to it. The HANDLE dot is not a line and not generated here.
5017
+ *
5018
+ * Count: AIM 2; LOCUS SPHERE 288 · PLANE 8 · AXIS 2 · DIAL 96 · view 8;
5019
+ * RING SPHERE 96 · PLANE 8 (shared with LOCUS); at most 386.
5020
+ *
5021
+ * @param {object} out Arrays object.
5022
+ * @param {object} constraint A contract-conforming constraint.
5023
+ * @param {{ bits?:number, mat4View?:ArrayLike<number>, point?:number[], color?:number[] }} [opts]
5024
+ * @returns {number} Vertices needed.
5025
+ */
5026
+ function locusLines(out, constraint, opts) {
5027
+ const o = opts || {};
5028
+ if (typeof constraint.locus === 'function') return constraint.locus(out, o);
5029
+ const bits = o.bits ?? (AIM | LOCUS);
5030
+ const c = constraint, a = c.anchor, pt = o.point, V = o.mat4View;
5031
+ _begin(out);
5032
+ _color(o.color);
5033
+ if ((bits & AIM) && a && pt) _line(a[0], a[1], a[2], pt[0], pt[1], pt[2]);
5034
+ if (c.view === true) {
5035
+ if ((bits & LOCUS) && pt && V) {
5036
+ _b0[0] = V[0]; _b0[1] = V[4]; _b0[2] = V[8]; // the camera's right
5037
+ _b1[0] = V[1]; _b1[1] = V[5]; _b1[2] = V[9]; // the camera's up
5038
+ _square(pt, _b0, _b1, 100);
5039
+ }
5040
+ } else if (c.kind === SPHERE && a) {
5041
+ if (bits & LOCUS) {
5042
+ _ring(a[0], a[1], a[2], c.radius, _AXES[0], _AXES[1], 48, TWO_PI);
5043
+ _ring(a[0], a[1], a[2], c.radius, _AXES[1], _AXES[2], 48, TWO_PI);
5044
+ _ring(a[0], a[1], a[2], c.radius, _AXES[2], _AXES[0], 48, TWO_PI);
5045
+ }
5046
+ if ((bits & RING) && V) {
5047
+ // eye = −Rᵀ t of the view matrix; the limb is ⊥ anchor − eye
5048
+ const tx = V[12], ty = V[13], tz = V[14];
5049
+ _b2[0] = a[0] + (V[0]*tx + V[1]*ty + V[2]*tz);
5050
+ _b2[1] = a[1] + (V[4]*tx + V[5]*ty + V[6]*tz);
5051
+ _b2[2] = a[2] + (V[8]*tx + V[9]*ty + V[10]*tz);
5052
+ _unit(_b2, 0, 0, 1);
5053
+ _basis(_b2, _b0, _b1);
5054
+ _ring(a[0], a[1], a[2], c.radius, _b0, _b1, 48, TWO_PI);
5055
+ }
5056
+ } else if (c.kind === PLANE && a) {
5057
+ if (bits & (LOCUS | RING)) {
5058
+ _basis(c.n, _b0, _b1);
5059
+ _square(a, _b0, _b1, 100);
5060
+ }
5061
+ } else if (c.kind === AXIS && a) {
5062
+ if (bits & LOCUS) {
5063
+ const u = c.u;
5064
+ _line(a[0] + c.min*u[0], a[1] + c.min*u[1], a[2] + c.min*u[2],
5065
+ a[0] + c.max*u[0], a[1] + c.max*u[1], a[2] + c.max*u[2]);
5066
+ }
5067
+ } else if (c.kind === DIAL && a) {
5068
+ if (bits & LOCUS) _ring(a[0], a[1], a[2], c.radius, c.r0, c.r1, 48, TWO_PI);
5069
+ }
5070
+ return _end(out);
5071
+ }
5072
+
5073
+ /**
5074
+ * A textured quad as two triangles (p0, p1, p2) (p0, p2, p3) — the winding
5075
+ * of the corner order — with texcoord written when the array exists.
5076
+ * Default uvs: p0 (top-left) → (0, 1), p1 → (1, 1), p2 → (1, 0), p3 →
5077
+ * (0, 0), so a texture in GL's bottom-up space reads upright with no
5078
+ * flip; `opts.uvs` overrides, four pairs flat in corner order.
5079
+ *
5080
+ * Count: 6.
5081
+ *
5082
+ * @param {object} out Arrays object.
5083
+ * @param {number[]} p0,p1,p2,p3 Corners, top-left clockwise.
5084
+ * @param {{ uvs?:number[], color?:number[] }} [opts]
5085
+ * @returns {number} Vertices needed.
5086
+ */
5087
+ function paneTris(out, p0, p1, p2, p3, opts) {
5088
+ const o = opts || {};
5089
+ const uv = o.uvs || _UV_DEFAULT;
5090
+ _begin(out);
5091
+ _color(o.color);
5092
+ _vertexUV(p0[0], p0[1], p0[2], uv[0], uv[1]);
5093
+ _vertexUV(p1[0], p1[1], p1[2], uv[2], uv[3]);
5094
+ _vertexUV(p2[0], p2[1], p2[2], uv[4], uv[5]);
5095
+ _vertexUV(p0[0], p0[1], p0[2], uv[0], uv[1]);
5096
+ _vertexUV(p2[0], p2[1], p2[2], uv[4], uv[5]);
5097
+ _vertexUV(p3[0], p3[1], p3[2], uv[6], uv[7]);
5098
+ return _end(out);
5099
+ }
5100
+
5101
+ export { AIM, APEX, AXIS, BODY, BOTTOM, CENTER, CIRCLE, COLOR_DIM, COLOR_X, COLOR_Y, COLOR_Z, CONTROLS, CameraTrack, Constraint, DIAL, DIRECTION, EYE, FAR, HANDLE, HANDLES, HELM_CHANNELS, INVISIBLE, LABELS, LEFT, LOCUS, MATRIX, MODEL, NDC, NEAR, NONE, ORIGIN, PATH, PLANE, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, POINT, PoseHelm, PoseTrack, RIGHT, RING, ROTATE, SCREEN, SELF, SEMIVISIBLE, SPHERE, SQUARE, TANGENTS, TANGENTS_IN, TANGENTS_OUT, TOP, TRANSLATE, VISIBLE, WEBGL, WEBGPU, WORLD, X, Y, Z, _X, _Y, _Z, _i, _j, _k, axesLines, azElFromDir, boxVisibility, bullsEyeLines, cameraCopy, cameraDolly, cameraEye, cameraFromMat4, cameraFromPose, cameraOrbit, cameraPan, cameraPlanes, cameraProj, cameraToPose, cameraView, capacityOf, createArrays, createCamera, createConstraint, crossLines, dirFromAzEl, distanceToPlane, frustumCorners, frustumLines, frustumPlanes, gridLines, growArrays, helmRigLines, hermiteLines, hermiteVec3, i, idToRgba, j, k, lerpVec3, locusLines, mapDirection, mapLocation, mat3Direction, mat3NormalFromMat4, mat4Bias, mat4Eye, mat4FromBasis, mat4FromScale, mat4FromTRS, mat4FromTranslation, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulDir, mat4MulPoint, mat4Ortho, mat4PV, mat4Persp, mat4Pick, mat4Reflect, mat4ToRotation, mat4ToScale, mat4ToTransform, mat4ToTranslation, mat4View, oneEuro, paneTris, pathLines, pixelRatio, pointVisibility, pointerHit, poseDelta, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qConjugate, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qFromUnitVectors, qMul, qNegate, qNlerp, qNormalize, qRotateVec3, qSet, qSlerp, qToAxisAngle, qToMat4, rayClosestPointOnAxis, rayHitCapsule, rayHitRing, rayHitSphere, rayPlane, raySphere, rgbaToId, ringLines, sphereVisibility, transformToMat4, unproject };
3455
5102
  //# sourceMappingURL=index.js.map