@nakednous/host 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,4833 @@
1
+ /**
2
+ * @file Core constants — zero dependencies.
3
+ * @module tree/constants
4
+ * @license AGPL-3.0-only
5
+ */
6
+
7
+ // Coordinate spaces
8
+ const WORLD = 'WORLD';
9
+ const EYE = 'EYE';
10
+ const NDC = 'NDC';
11
+ const SCREEN = 'SCREEN';
12
+ const MATRIX = 'MATRIX';
13
+
14
+ // Integrator frame source (helm `from` only): the construct's own current pose
15
+ // — body-relative; not a general mapDirection space.
16
+ const SELF = 'SELF';
17
+
18
+ // NDC Z convention (only difference between backends)
19
+ const WEBGL = -1; // z ∈ [−1, 1]
20
+
21
+ // Handle constraint kinds
22
+ const SPHERE = 0;
23
+ const PLANE = 1;
24
+ const AXIS = 2;
25
+ const DIAL = 3;
26
+
27
+ // Handle report modes
28
+ const POINT = 0;
29
+ const DIRECTION = 1;
30
+
31
+ /** Normalise quaternion in-place. @returns {number[]} out */
32
+ const qNormalize = (out) => {
33
+ const l = Math.sqrt(out[0]*out[0]+out[1]*out[1]+out[2]*out[2]+out[3]*out[3]) || 1;
34
+ out[0]/=l; out[1]/=l; out[2]/=l; out[3]/=l; return out;
35
+ };
36
+
37
+ /** Hamilton product out = a * b. Alias-safe: out may be a or b. @returns {number[]} out */
38
+ const qMul = (out, a, b) => {
39
+ const ax=a[0],ay=a[1],az=a[2],aw=a[3], bx=b[0],by=b[1],bz=b[2],bw=b[3];
40
+ out[0]=aw*bx+ax*bw+ay*bz-az*by;
41
+ out[1]=aw*by-ax*bz+ay*bw+az*bx;
42
+ out[2]=aw*bz+ax*by-ay*bx+az*bw;
43
+ out[3]=aw*bw-ax*bx-ay*by-az*bz;
44
+ return out;
45
+ };
46
+
47
+ // =========================================================================
48
+ // Interpolation
49
+ // =========================================================================
50
+
51
+ /** Spherical linear interpolation. @returns {number[]} out */
52
+ const qSlerp = (out, a, b, t) => {
53
+ let bx=b[0],by=b[1],bz=b[2],bw=b[3];
54
+ let d = a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw;
55
+ if (d < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; d=-d; }
56
+ let f0, f1;
57
+ if (1-d > 1e-10) {
58
+ const th=Math.acos(d), st=Math.sin(th);
59
+ f0=Math.sin((1-t)*th)/st; f1=Math.sin(t*th)/st;
60
+ } else {
61
+ f0=1-t; f1=t;
62
+ }
63
+ out[0]=a[0]*f0+bx*f1; out[1]=a[1]*f0+by*f1;
64
+ out[2]=a[2]*f0+bz*f1; out[3]=a[3]*f0+bw*f1;
65
+ return qNormalize(out);
66
+ };
67
+
68
+ /**
69
+ * Normalised linear interpolation (nlerp).
70
+ * Cheaper than slerp; slightly non-constant angular velocity.
71
+ * Handles antipodal quats by flipping b when dot < 0.
72
+ * @returns {number[]} out
73
+ */
74
+ const qNlerp = (out, a, b, t) => {
75
+ let bx=b[0],by=b[1],bz=b[2],bw=b[3];
76
+ if (a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; }
77
+ out[0]=a[0]+t*(bx-a[0]); out[1]=a[1]+t*(by-a[1]);
78
+ out[2]=a[2]+t*(bz-a[2]); out[3]=a[3]+t*(bw-a[3]);
79
+ return qNormalize(out);
80
+ };
81
+
82
+ /**
83
+ * Build a quaternion from axis-angle.
84
+ * @param {number[]} out
85
+ * @param {number} ax @param {number} ay @param {number} az Axis (need not be unit).
86
+ * @param {number} angle Radians.
87
+ * @returns {number[]} out
88
+ */
89
+ const qFromAxisAngle = (out, ax, ay, az, angle) => {
90
+ const half = angle * 0.5;
91
+ const s = Math.sin(half);
92
+ const len = Math.sqrt(ax*ax + ay*ay + az*az) || 1;
93
+ out[0] = s * ax / len; out[1] = s * ay / len; out[2] = s * az / len;
94
+ out[3] = Math.cos(half);
95
+ return out;
96
+ };
97
+
98
+ /**
99
+ * Build a quaternion from a look direction (−Z forward) and optional up (default +Y).
100
+ * `dir` need not be unit. Right = dir × up, up re-orthogonalised as right × dir;
101
+ * when `dir` is parallel to `up` the up hint is re-seeded from the world axis
102
+ * least aligned with `dir` (the same seed qFromUnitVectors uses), so every
103
+ * direction yields a proper rotation.
104
+ * @param {number[]} out
105
+ * @param {number[]} dir Forward direction [x,y,z].
106
+ * @param {number[]} [up] Up vector [x,y,z].
107
+ * @returns {number[]} out
108
+ */
109
+ const qFromLookDir = (out, dir, up) => {
110
+ let fx=dir[0],fy=dir[1],fz=dir[2];
111
+ const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
112
+ fx/=fl; fy/=fl; fz/=fl;
113
+ let ux=up?up[0]:0, uy=up?up[1]:1, uz=up?up[2]:0;
114
+ let rx=fy*uz-fz*uy, ry=fz*ux-fx*uz, rz=fx*uy-fy*ux; // right = dir × up
115
+ let rl=Math.sqrt(rx*rx+ry*ry+rz*rz);
116
+ if (rl < 1e-8) { // dir ∥ up: re-seed up
117
+ const ax=Math.abs(fx), ay=Math.abs(fy), az=Math.abs(fz);
118
+ ux=0; uy=0; uz=0;
119
+ if (ax <= ay && ax <= az) ux=1; else if (ay <= az) uy=1; else uz=1;
120
+ rx=fy*uz-fz*uy; ry=fz*ux-fx*uz; rz=fx*uy-fy*ux;
121
+ rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1;
122
+ }
123
+ rx/=rl; ry/=rl; rz/=rl;
124
+ ux=ry*fz-rz*fy; uy=rz*fx-rx*fz; uz=rx*fy-ry*fx; // up = right × dir
125
+ return qFromRotMat3x3(out, rx,ux,-fx, ry,uy,-fy, rz,uz,-fz); // columns: right, up, back
126
+ };
127
+
128
+ /**
129
+ * Build a quaternion from a 3×3 rotation matrix (9 row-major scalars).
130
+ * @returns {number[]} out (normalised)
131
+ */
132
+ const qFromRotMat3x3 = (out, m00,m01,m02, m10,m11,m12, m20,m21,m22) => {
133
+ const tr = m00+m11+m22;
134
+ if (tr > 0) {
135
+ const s=0.5/Math.sqrt(tr+1);
136
+ out[3]=0.25/s; out[0]=(m21-m12)*s; out[1]=(m02-m20)*s; out[2]=(m10-m01)*s;
137
+ } else if (m00>m11 && m00>m22) {
138
+ const s=2*Math.sqrt(1+m00-m11-m22);
139
+ out[3]=(m21-m12)/s; out[0]=0.25*s; out[1]=(m01+m10)/s; out[2]=(m02+m20)/s;
140
+ } else if (m11>m22) {
141
+ const s=2*Math.sqrt(1+m11-m00-m22);
142
+ out[3]=(m02-m20)/s; out[0]=(m01+m10)/s; out[1]=0.25*s; out[2]=(m12+m21)/s;
143
+ } else {
144
+ const s=2*Math.sqrt(1+m22-m00-m11);
145
+ out[3]=(m10-m01)/s; out[0]=(m02+m20)/s; out[1]=(m12+m21)/s; out[2]=0.25*s;
146
+ }
147
+ return qNormalize(out);
148
+ };
149
+
150
+ /**
151
+ * Write a quaternion into the rotation block of a column-major mat4.
152
+ * Translation and perspective rows/cols are set to identity values.
153
+ * @param {Float32Array|number[]} out 16-element array.
154
+ * @param {number[]} q [x,y,z,w].
155
+ * @returns {Float32Array|number[]} out
156
+ */
157
+ const qToMat4 = (out, q) => {
158
+ const x=q[0],y=q[1],z=q[2],w=q[3];
159
+ const x2=x+x,y2=y+y,z2=z+z;
160
+ 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;
161
+ out[0]=1-(yy+zz); out[1]=xy+wz; out[2]=xz-wy; out[3]=0;
162
+ out[4]=xy-wz; out[5]=1-(xx+zz); out[6]=yz+wx; out[7]=0;
163
+ out[8]=xz+wy; out[9]=yz-wx; out[10]=1-(xx+yy); out[11]=0;
164
+ out[12]=0; out[13]=0; out[14]=0; out[15]=1;
165
+ return out;
166
+ };
167
+
168
+ // Lookat basis scratch — right (0–2), up (3–5), back (6–8): unit, world space.
169
+ const _lb = new Float64Array(9);
170
+
171
+ /**
172
+ * Orthonormal lookat basis into `_lb`: back = eye − center, right = up × back,
173
+ * up = back × right. When the view direction is parallel to the up hint the
174
+ * hint is re-seeded from the world axis least aligned with it — the seed
175
+ * qFromUnitVectors and qFromLookDir use — so the basis is always a rotation.
176
+ */
177
+ function _lookBasis(ex,ey,ez, cx,cy,cz, ux,uy,uz) {
178
+ let zx=ex-cx, zy=ey-cy, zz=ez-cz;
179
+ const zl=Math.sqrt(zx*zx+zy*zy+zz*zz)||1;
180
+ zx/=zl; zy/=zl; zz/=zl;
181
+ let xx=uy*zz-uz*zy, xy=uz*zx-ux*zz, xz=ux*zy-uy*zx;
182
+ let xl=Math.sqrt(xx*xx+xy*xy+xz*xz);
183
+ if (xl < 1e-8) { // view ∥ up: re-seed up
184
+ const ax=Math.abs(zx), ay=Math.abs(zy), az=Math.abs(zz);
185
+ ux=0; uy=0; uz=0;
186
+ if (ax <= ay && ax <= az) ux=1; else if (ay <= az) uy=1; else uz=1;
187
+ xx=uy*zz-uz*zy; xy=uz*zx-ux*zz; xz=ux*zy-uy*zx;
188
+ xl=Math.sqrt(xx*xx+xy*xy+xz*xz)||1;
189
+ }
190
+ xx/=xl; xy/=xl; xz/=xl;
191
+ _lb[0]=xx; _lb[1]=xy; _lb[2]=xz;
192
+ _lb[3]=zy*xz-zz*xy; _lb[4]=zz*xx-zx*xz; _lb[5]=zx*xy-zy*xx;
193
+ _lb[6]=zx; _lb[7]=zy; _lb[8]=zz;
194
+ }
195
+
196
+ /**
197
+ * View matrix (world→eye) from lookat parameters.
198
+ * Camera looks along −Z in eye space; right = normalize(up × (−Z)).
199
+ * Cheaper than building the eye matrix and inverting.
200
+ *
201
+ * @param {Float32Array|number[]} out 16-element destination.
202
+ * @param {number} ex,ey,ez Eye (camera) position.
203
+ * @param {number} cx,cy,cz Look-at target.
204
+ * @param {number} ux,uy,uz World up hint (need not be unit; re-seeded when
205
+ * parallel to the view direction).
206
+ */
207
+ function mat4View(out, ex,ey,ez, cx,cy,cz, ux,uy,uz) {
208
+ _lookBasis(ex,ey,ez, cx,cy,cz, ux,uy,uz);
209
+ 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];
210
+ out[0]=xx; out[1]=yx; out[2]=zx; out[3]=0;
211
+ out[4]=xy; out[5]=yy; out[6]=zy; out[7]=0;
212
+ out[8]=xz; out[9]=yz; out[10]=zz; out[11]=0;
213
+ out[12]=-(xx*ex+xy*ey+xz*ez);
214
+ out[13]=-(yx*ex+yy*ey+yz*ez);
215
+ out[14]=-(zx*ex+zy*ey+zz*ez);
216
+ out[15]=1;
217
+ return out;
218
+ }
219
+
220
+ /**
221
+ * Eye matrix (eye→world) from lookat parameters.
222
+ * Transpose of the rotation block + direct translation column.
223
+ * Same parameters as mat4View.
224
+ *
225
+ * @param {Float32Array|number[]} out 16-element destination.
226
+ * @param {number} ex,ey,ez Eye position.
227
+ * @param {number} cx,cy,cz Look-at target.
228
+ * @param {number} ux,uy,uz World up hint (need not be unit; re-seeded when
229
+ * parallel to the view direction).
230
+ */
231
+ function mat4Eye(out, ex,ey,ez, cx,cy,cz, ux,uy,uz) {
232
+ _lookBasis(ex,ey,ez, cx,cy,cz, ux,uy,uz);
233
+ 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];
234
+ out[0]=xx; out[1]=xy; out[2]=xz; out[3]=0;
235
+ out[4]=yx; out[5]=yy; out[6]=yz; out[7]=0;
236
+ out[8]=zx; out[9]=zy; out[10]=zz; out[11]=0;
237
+ out[12]=ex; out[13]=ey; out[14]=ez; out[15]=1;
238
+ return out;
239
+ }
240
+
241
+ // =========================================================================
242
+ // Projection construction
243
+ // =========================================================================
244
+
245
+ /**
246
+ * Orthographic projection matrix.
247
+ *
248
+ * @param {Float32Array|number[]} out 16-element destination.
249
+ * @param {number} left,right,bottom,top Frustum extents.
250
+ * @param {number} near,far Clip plane distances (positive).
251
+ * @param {number} ndcZMin −1 (WEBGL) or 0 (WEBGPU).
252
+ * @param {number} [ndcYSign=1] +1 = NDC y-up (default); −1 = NDC y-down (native Vulkan).
253
+ */
254
+ function mat4Ortho(out, left, right, bottom, top, near, far, ndcZMin, ndcYSign=1) {
255
+ const rl=1/(right-left), tb=1/(top-bottom), fn=1/(far-near);
256
+ out[0]=2*rl; out[1]=0; out[2]=0; out[3]=0;
257
+ out[4]=0; out[5]=ndcYSign*2*tb; out[6]=0; out[7]=0;
258
+ out[8]=0; out[9]=0; out[10]=(ndcZMin-1)*fn; out[11]=0;
259
+ out[12]=-(right+left)*rl; out[13]=ndcYSign*(-(top+bottom)*tb);
260
+ out[14]=(ndcZMin*far-near)*fn;
261
+ out[15]=1;
262
+ return out;
263
+ }
264
+
265
+ /**
266
+ * Perspective projection matrix (general / off-centre frustum).
267
+ * Symmetric case: left=-right, bottom=-top — derive from fov+aspect in user space:
268
+ * top = near * Math.tan(fov / 2); right = top * aspect
269
+ * mat4Persp(out, -right, right, -top, top, near, far, ndcZMin)
270
+ *
271
+ * @param {Float32Array|number[]} out 16-element destination.
272
+ * @param {number} left,right,bottom,top Near-plane extents (signed, y-up: top>0, bottom<0).
273
+ * @param {number} near,far Clip plane distances (positive).
274
+ * @param {number} ndcZMin −1 (WEBGL) or 0 (WEBGPU).
275
+ * @param {number} [ndcYSign=1] +1 = NDC y-up (default); −1 = NDC y-down (native Vulkan).
276
+ */
277
+ function mat4Persp(out, left, right, bottom, top, near, far, ndcZMin, ndcYSign=1) {
278
+ const rl=1/(right-left), tb=1/(top-bottom);
279
+ out[0]=2*near*rl; out[1]=0; out[2]=0; out[3]=0;
280
+ out[4]=0; out[5]=ndcYSign*2*near*tb; out[6]=0; out[7]=0;
281
+ out[8]=(right+left)*rl; out[9]=ndcYSign*(top+bottom)*tb;
282
+ out[10]=(ndcZMin*near-far)/(far-near);
283
+ out[11]=-1;
284
+ out[12]=0; out[13]=0;
285
+ out[14]=(ndcZMin-1)*far*near/(far-near);
286
+ out[15]=0;
287
+ return out;
288
+ }
289
+
290
+ /**
291
+ * @file Matrix arithmetic, space-transform dispatch, and projection queries.
292
+ * @module tree/query
293
+ * @license AGPL-3.0-only
294
+ *
295
+ * The operative layer — receives matrices and extracts information.
296
+ * Contrast with form.js which constructs matrices from specs.
297
+ *
298
+ * form.js — specs → matrix
299
+ * query.js — matrix → information
300
+ *
301
+ * Storage: column-major Float32Array / ArrayLike<number>.
302
+ * Multiply: mat4Mul(out, A, B) = A · B (standard math order).
303
+ * Pipeline: clip = P · V · M · v
304
+ *
305
+ * ── NDC Z convention ──────────────────────────────────────────────────────
306
+ * Passed as `ndcZMin` to every space-transform function:
307
+ * WEBGL = −1 z ∈ [−1, 1]
308
+ * WEBGPU = 0 z ∈ [ 0, 1]
309
+ *
310
+ * ── NDC Y convention ──────────────────────────────────────────────────────
311
+ * Standard (OpenGL / WebGL / WebGPU browser / Three.js / p5v2):
312
+ * NDC y-up — y = +1 at top, y = −1 at bottom.
313
+ * Native Vulkan: NDC y-down — projections constructed with ndcYSign = −1
314
+ * (see form.js). Query functions are convention-agnostic: they work on
315
+ * whatever matrices are passed in.
316
+ *
317
+ * ── Viewport convention ───────────────────────────────────────────────────
318
+ * vp = [x, y, w, h] — w and h are SIGNED.
319
+ *
320
+ * The sign of h encodes the relationship between NDC y and screen y:
321
+ * h < 0 (e.g. −canvasH): screen y-DOWN (DOM / p5 mouseX·mouseY / Vulkan surface)
322
+ * NDC y=+1 → screen y=0 (top)
323
+ * NDC y=−1 → screen y=H (bottom)
324
+ * h > 0 (e.g. +canvasH): screen y-UP (OpenGL desktop / WebGL gl_FragCoord)
325
+ * NDC y=−1 → screen y=0 (bottom)
326
+ * NDC y=+1 → screen y=H (top)
327
+ *
328
+ * Pass [0, canvasH, canvasW, −canvasH] for p5/DOM coordinates.
329
+ * Pass [0, 0, canvasW, canvasH] for WebGL gl_FragCoord / OpenGL bottom-left.
330
+ * All helpers use vp[2]/vp[3] signed — no Math.abs — so both conventions
331
+ * work automatically without any branching.
332
+ *
333
+ * All functions follow the out-first, zero-allocation contract.
334
+ * Returns null on degeneracy (singular matrix, etc.).
335
+ */
336
+
337
+
338
+ // ═══════════════════════════════════════════════════════════════════════════
339
+ // Mat4 arithmetic
340
+ // ═══════════════════════════════════════════════════════════════════════════
341
+
342
+ /** out = A · B (column-major) */
343
+ function mat4Mul(out, A, B) {
344
+ const a0=A[0],a1=A[1],a2=A[2],a3=A[3],
345
+ a4=A[4],a5=A[5],a6=A[6],a7=A[7],
346
+ a8=A[8],a9=A[9],a10=A[10],a11=A[11],
347
+ a12=A[12],a13=A[13],a14=A[14],a15=A[15];
348
+ let b0=B[0],b1=B[1],b2=B[2],b3=B[3];
349
+ out[0]=a0*b0+a4*b1+a8*b2+a12*b3;
350
+ out[1]=a1*b0+a5*b1+a9*b2+a13*b3;
351
+ out[2]=a2*b0+a6*b1+a10*b2+a14*b3;
352
+ out[3]=a3*b0+a7*b1+a11*b2+a15*b3;
353
+ b0=B[4];b1=B[5];b2=B[6];b3=B[7];
354
+ out[4]=a0*b0+a4*b1+a8*b2+a12*b3;
355
+ out[5]=a1*b0+a5*b1+a9*b2+a13*b3;
356
+ out[6]=a2*b0+a6*b1+a10*b2+a14*b3;
357
+ out[7]=a3*b0+a7*b1+a11*b2+a15*b3;
358
+ b0=B[8];b1=B[9];b2=B[10];b3=B[11];
359
+ out[8]=a0*b0+a4*b1+a8*b2+a12*b3;
360
+ out[9]=a1*b0+a5*b1+a9*b2+a13*b3;
361
+ out[10]=a2*b0+a6*b1+a10*b2+a14*b3;
362
+ out[11]=a3*b0+a7*b1+a11*b2+a15*b3;
363
+ b0=B[12];b1=B[13];b2=B[14];b3=B[15];
364
+ out[12]=a0*b0+a4*b1+a8*b2+a12*b3;
365
+ out[13]=a1*b0+a5*b1+a9*b2+a13*b3;
366
+ out[14]=a2*b0+a6*b1+a10*b2+a14*b3;
367
+ out[15]=a3*b0+a7*b1+a11*b2+a15*b3;
368
+ return out;
369
+ }
370
+
371
+ /** out = inverse(src). Returns null if singular (|det| < 1e-12). */
372
+ function mat4Invert(out, src) {
373
+ const s0=src[0],s1=src[1],s2=src[2],s3=src[3],
374
+ s4=src[4],s5=src[5],s6=src[6],s7=src[7],
375
+ s8=src[8],s9=src[9],s10=src[10],s11=src[11],
376
+ s12=src[12],s13=src[13],s14=src[14],s15=src[15];
377
+ const b0=s0*s5-s1*s4, b1=s0*s6-s2*s4, b2=s0*s7-s3*s4,
378
+ b3=s1*s6-s2*s5, b4=s1*s7-s3*s5, b5=s2*s7-s3*s6,
379
+ b6=s8*s13-s9*s12, b7=s8*s14-s10*s12, b8=s8*s15-s11*s12,
380
+ b9=s9*s14-s10*s13, b10=s9*s15-s11*s13, b11=s10*s15-s11*s14;
381
+ let det=b0*b11-b1*b10+b2*b9+b3*b8-b4*b7+b5*b6;
382
+ if (Math.abs(det) < 1e-12) return null;
383
+ det = 1/det;
384
+ out[0]=(s5*b11-s6*b10+s7*b9)*det;
385
+ out[1]=(s2*b10-s1*b11-s3*b9)*det;
386
+ out[2]=(s13*b5-s14*b4+s15*b3)*det;
387
+ out[3]=(s10*b4-s9*b5-s11*b3)*det;
388
+ out[4]=(s6*b8-s4*b11-s7*b7)*det;
389
+ out[5]=(s0*b11-s2*b8+s3*b7)*det;
390
+ out[6]=(s14*b2-s12*b5-s15*b1)*det;
391
+ out[7]=(s8*b5-s10*b2+s11*b1)*det;
392
+ out[8]=(s4*b10-s5*b8+s7*b6)*det;
393
+ out[9]=(s1*b8-s0*b10-s3*b6)*det;
394
+ out[10]=(s12*b4-s13*b2+s15*b0)*det;
395
+ out[11]=(s9*b2-s8*b4-s11*b0)*det;
396
+ out[12]=(s5*b7-s4*b9-s6*b6)*det;
397
+ out[13]=(s0*b9-s1*b7+s2*b6)*det;
398
+ out[14]=(s13*b1-s12*b3-s14*b0)*det;
399
+ out[15]=(s8*b3-s9*b1+s10*b0)*det;
400
+ return out;
401
+ }
402
+
403
+ /** out = mat4 * [x,y,z,1], perspective-divides, writes xyz. */
404
+ function mat4MulPoint(out, m, x, y, z) {
405
+ const rx=m[0]*x+m[4]*y+m[8]*z+m[12], ry=m[1]*x+m[5]*y+m[9]*z+m[13],
406
+ rz=m[2]*x+m[6]*y+m[10]*z+m[14], rw=m[3]*x+m[7]*y+m[11]*z+m[15];
407
+ if (rw!==0&&rw!==1) { out[0]=rx/rw; out[1]=ry/rw; out[2]=rz/rw; }
408
+ else { out[0]=rx; out[1]=ry; out[2]=rz; }
409
+ return out;
410
+ }
411
+
412
+ // ═══════════════════════════════════════════════════════════════════════════
413
+ // Projection queries
414
+ // ═══════════════════════════════════════════════════════════════════════════
415
+
416
+ /** @returns {boolean} true if orthographic. */
417
+ function projIsOrtho(p) { return p[15] !== 0; }
418
+
419
+ /**
420
+ * Near plane distance.
421
+ * @param {ArrayLike<number>} p Projection mat4.
422
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
423
+ */
424
+ function projNear(p, ndcZMin) {
425
+ return p[15]===0 ? p[14]/(p[10]+ndcZMin) : (p[14]-ndcZMin)/p[10];
426
+ }
427
+
428
+ /**
429
+ * Top extent of the near plane in camera space (y_max, positive for standard y-up camera).
430
+ * Sign-normalized: returns the larger of the two y boundaries regardless of whether
431
+ * the projection was built with ndcYSign = +1 or −1.
432
+ */
433
+ function projTop(p, ndcZMin) {
434
+ return p[15]===1
435
+ ? ( Math.sign(p[5]) - p[13]) / p[5] // ortho
436
+ : projNear(p,ndcZMin)*(Math.sign(p[5])+p[9])/p[5]; // perspective
437
+ }
438
+
439
+ /**
440
+ * Bottom extent of the near plane in camera space (y_min, negative for standard y-up camera).
441
+ */
442
+ function projBottom(p, ndcZMin) {
443
+ return p[15]===1
444
+ ? (-Math.sign(p[5]) - p[13]) / p[5] // ortho
445
+ : projNear(p,ndcZMin)*(p[9]-Math.sign(p[5]))/p[5]; // perspective
446
+ }
447
+
448
+ /** Vertical field of view in radians (perspective only). */
449
+ function projFov (p) { return Math.abs(2*Math.atan(1/p[5])); }
450
+
451
+ // ═══════════════════════════════════════════════════════════════════════════
452
+ // Space transforms — mapLocation / mapDirection
453
+ // ═══════════════════════════════════════════════════════════════════════════
454
+ //
455
+ // Flat dispatch: every from→to pair is a self-contained leaf with only stack
456
+ // locals — no reentrancy, no shared state between calls.
457
+ //
458
+ // Matrices bag `m`:
459
+ // mat4Proj Float32Array(16) projection (eye → clip)
460
+ // mat4View Float32Array(16) view (world → eye)
461
+ // mat4Eye? Float32Array(16) eye (eye → world); caller fills before passing
462
+ // mat4PV? Float32Array(16) P · V; caller fills or _ensurePV allocates once
463
+ // mat4PVInv? Float32Array(16) inv(P · V); caller fills
464
+ // fromFrame? Float32Array(16) MATRIX source frame
465
+ // toFrameInv? Float32Array(16) inv(MATRIX dest frame)
466
+ //
467
+ // Viewport `vp` = [x, y, w, h]:
468
+ // Use SIGNED h to encode screen-y direction (see module header).
469
+ // Core formula: screen = (ndc*0.5+0.5)*vp[k] + vp[k-2] (k=2 for x, k=3 for y)
470
+ // Inverse: ndc = ((screen-vp[k-2])/vp[k])*2 - 1
471
+ // Negative vp[3] flips NDC y-up to screen y-down automatically.
472
+ //
473
+
474
+ // ── Location helpers ─────────────────────────────────────────────────────
475
+
476
+ function _worldToScreen(out, px, py, pz, pv, vp, ndcZMin) {
477
+ const x=pv[0]*px+pv[4]*py+pv[8]*pz+pv[12], y=pv[1]*px+pv[5]*py+pv[9]*pz+pv[13],
478
+ z=pv[2]*px+pv[6]*py+pv[10]*pz+pv[14], w=pv[3]*px+pv[7]*py+pv[11]*pz+pv[15];
479
+ const xi=(w!==0&&w!==1)?1/w:1;
480
+ out[0]=(x*xi*0.5+0.5)*vp[2]+vp[0];
481
+ out[1]=(y*xi*0.5+0.5)*vp[3]+vp[1];
482
+ out[2]=(z*xi-ndcZMin)/(1-ndcZMin);
483
+ return out;
484
+ }
485
+
486
+ function _screenToWorld(out, sx, sy, sz, ipv, vp, ndcZMin) {
487
+ return mat4MulPoint(out, ipv,
488
+ ((sx-vp[0])/vp[2])*2-1,
489
+ ((sy-vp[1])/vp[3])*2-1,
490
+ sz*(1-ndcZMin)+ndcZMin);
491
+ }
492
+
493
+ function _worldToNDC(out, px, py, pz, pv) {
494
+ const x=pv[0]*px+pv[4]*py+pv[8]*pz+pv[12], y=pv[1]*px+pv[5]*py+pv[9]*pz+pv[13],
495
+ z=pv[2]*px+pv[6]*py+pv[10]*pz+pv[14], w=pv[3]*px+pv[7]*py+pv[11]*pz+pv[15];
496
+ const xi=(w!==0&&w!==1)?1/w:1;
497
+ out[0]=x*xi; out[1]=y*xi; out[2]=z*xi;
498
+ return out;
499
+ }
500
+
501
+ function _ensurePV(m) {
502
+ if (m.mat4PV) return m.mat4PV;
503
+ m.mat4PV = new Float32Array(16);
504
+ mat4Mul(m.mat4PV, m.mat4Proj, m.mat4View);
505
+ return m.mat4PV;
506
+ }
507
+
508
+ /**
509
+ * Map a point between named coordinate spaces.
510
+ *
511
+ * @param {number[]} out 3-element destination — written and returned.
512
+ * @param {number} px,py,pz Input point.
513
+ * @param {string} from Source space (WORLD, EYE, SCREEN, NDC, MATRIX).
514
+ * @param {string} to Destination space.
515
+ * @param {object} m Matrices bag — see module header.
516
+ * @param {number[]} vp Viewport [x, y, w, h]; sign of h encodes screen-y direction.
517
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
518
+ * @returns {number[]} out
519
+ */
520
+ function mapLocation(out, px, py, pz, from, to, m, vp, ndcZMin) {
521
+ if (to===SCREEN) return _worldToScreen(out,px,py,pz,_ensurePV(m),vp,ndcZMin);
522
+
523
+ if (to===NDC) return _worldToNDC(out,px,py,pz,_ensurePV(m));
524
+
525
+ if (to===EYE) return mat4MulPoint(out,m.mat4View,px,py,pz);
526
+ if (to===MATRIX) return mat4MulPoint(out,m.toFrameInv,px,py,pz);
527
+
528
+ out[0]=px; out[1]=py; out[2]=pz;
529
+ return out;
530
+ }
531
+
532
+ // ── Direction helpers ────────────────────────────────────────────────────
533
+ //
534
+ // Directions use only the linear 3×3 block — no translation, no w-divide.
535
+ // The signed vp[2]/vp[3] carries the y-convention automatically.
536
+ //
537
+
538
+ function _applyDir(out, m, dx, dy, dz) {
539
+ out[0]=m[0]*dx+m[4]*dy+m[8]*dz;
540
+ out[1]=m[1]*dx+m[5]*dy+m[9]*dz;
541
+ out[2]=m[2]*dx+m[6]*dy+m[10]*dz;
542
+ return out;
543
+ }
544
+
545
+ function _worldToScreenDir(out, dx, dy, dz, proj, view, vpW, vpH, ndcZMin) {
546
+ const vx=view[0]*dx+view[4]*dy+view[8]*dz,
547
+ vy=view[1]*dx+view[5]*dy+view[9]*dz,
548
+ vz=view[2]*dx+view[6]*dy+view[10]*dz;
549
+ // vpH is signed — negative flips y component automatically.
550
+ out[0]=(proj[0]*vx+proj[4]*vy+proj[8]*vz)*vpW*0.5;
551
+ out[1]=(proj[1]*vx+proj[5]*vy+proj[9]*vz)*vpH*0.5;
552
+ out[2]=(proj[2]*vx+proj[6]*vy+proj[10]*vz)*(1-ndcZMin)*0.5;
553
+ return out;
554
+ }
555
+
556
+ function _screenToWorldDir(out, dx, dy, dz, proj, eye, vpW, vpH, ndcZMin) {
557
+ // Inverse of _worldToScreenDir: undo the viewport scale, then the projection's
558
+ // upper-triangular 3×3 block (p[8], p[9] carry an off-centre frustum), then
559
+ // rotate eye→world. Signed vpW/vpH cancel the y-flip.
560
+ const cz = dz/((1-ndcZMin)*0.5)/proj[10];
561
+ const cy = (dy/(vpH*0.5) - proj[9]*cz)/proj[5];
562
+ const cx = (dx/(vpW*0.5) - proj[8]*cz)/proj[0];
563
+ return _applyDir(out, eye, cx, cy, cz);
564
+ }
565
+
566
+ function _screenToNDCDir(out, dx, dy, dz, vpW, vpH, ndcZMin) {
567
+ out[0]=dx/(vpW*0.5); out[1]=dy/(vpH*0.5); out[2]=dz/((1-ndcZMin)*0.5);
568
+ return out;
569
+ }
570
+
571
+ function _ndcToScreenDir(out, dx, dy, dz, vpW, vpH, ndcZMin) {
572
+ out[0]=dx*vpW*0.5; out[1]=dy*vpH*0.5; out[2]=dz*(1-ndcZMin)*0.5;
573
+ return out;
574
+ }
575
+
576
+ /**
577
+ * Map a direction between named coordinate spaces.
578
+ * Same bag and viewport contract as mapLocation.
579
+ *
580
+ * @param {number[]} out 3-element destination.
581
+ * @param {number} dx,dy,dz Input direction.
582
+ * @param {string} from Source space.
583
+ * @param {string} to Destination space.
584
+ * @param {object} m Matrices bag — see module header.
585
+ * @param {number[]} vp Viewport [x, y, w, h]; sign of h encodes screen-y direction.
586
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
587
+ * @returns {number[]} out
588
+ */
589
+ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
590
+ const vpW=vp[2], vpH=vp[3]; // signed — carry y-convention through all helpers
591
+
592
+ if (from===EYE && to===WORLD) return _applyDir(out,m.mat4Eye, dx,dy,dz);
593
+ if (from===WORLD && to===EYE) return _applyDir(out,m.mat4View,dx,dy,dz);
594
+
595
+ if (from===WORLD && to===SCREEN) return _worldToScreenDir(out,dx,dy,dz,m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
596
+ if (from===SCREEN && to===WORLD) return _screenToWorldDir(out,dx,dy,dz,m.mat4Proj,m.mat4Eye, vpW,vpH,ndcZMin);
597
+
598
+ if (from===SCREEN && to===NDC) return _screenToNDCDir(out,dx,dy,dz,vpW,vpH,ndcZMin);
599
+ if (from===NDC && to===SCREEN) return _ndcToScreenDir(out,dx,dy,dz,vpW,vpH,ndcZMin);
600
+
601
+ if (from===WORLD && to===NDC) {
602
+ _worldToScreenDir(out,dx,dy,dz,m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
603
+ return _screenToNDCDir(out,out[0],out[1],out[2],vpW,vpH,ndcZMin);
604
+ }
605
+ if (from===NDC && to===WORLD) {
606
+ _ndcToScreenDir(out,dx,dy,dz,vpW,vpH,ndcZMin);
607
+ return _screenToWorldDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4Eye,vpW,vpH,ndcZMin);
608
+ }
609
+
610
+ if (from===EYE && to===SCREEN) {
611
+ _applyDir(out,m.mat4Eye,dx,dy,dz);
612
+ return _worldToScreenDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
613
+ }
614
+ if (from===SCREEN && to===EYE) {
615
+ _screenToWorldDir(out,dx,dy,dz,m.mat4Proj,m.mat4Eye,vpW,vpH,ndcZMin);
616
+ return _applyDir(out,m.mat4View,out[0],out[1],out[2]);
617
+ }
618
+
619
+ if (from===EYE && to===NDC) {
620
+ _applyDir(out,m.mat4Eye,dx,dy,dz);
621
+ _worldToScreenDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
622
+ return _screenToNDCDir(out,out[0],out[1],out[2],vpW,vpH,ndcZMin);
623
+ }
624
+ if (from===NDC && to===EYE) {
625
+ _ndcToScreenDir(out,dx,dy,dz,vpW,vpH,ndcZMin);
626
+ _screenToWorldDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4Eye,vpW,vpH,ndcZMin);
627
+ return _applyDir(out,m.mat4View,out[0],out[1],out[2]);
628
+ }
629
+
630
+ if (from===MATRIX && to===WORLD) return _applyDir(out,m.fromFrame, dx,dy,dz);
631
+ if (from===WORLD && to===MATRIX) return _applyDir(out,m.toFrameInv,dx,dy,dz);
632
+
633
+ if (from===MATRIX && to===EYE) {
634
+ _applyDir(out,m.fromFrame,dx,dy,dz);
635
+ return _applyDir(out,m.mat4View,out[0],out[1],out[2]);
636
+ }
637
+ if (from===EYE && to===MATRIX) {
638
+ _applyDir(out,m.mat4Eye,dx,dy,dz);
639
+ return _applyDir(out,m.toFrameInv,out[0],out[1],out[2]);
640
+ }
641
+
642
+ if (from===MATRIX && to===SCREEN) {
643
+ _applyDir(out,m.fromFrame,dx,dy,dz);
644
+ return _worldToScreenDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
645
+ }
646
+ if (from===SCREEN && to===MATRIX) {
647
+ _screenToWorldDir(out,dx,dy,dz,m.mat4Proj,m.mat4Eye,vpW,vpH,ndcZMin);
648
+ return _applyDir(out,m.toFrameInv,out[0],out[1],out[2]);
649
+ }
650
+
651
+ if (from===MATRIX && to===NDC) {
652
+ _applyDir(out,m.fromFrame,dx,dy,dz);
653
+ _worldToScreenDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4View,vpW,vpH,ndcZMin);
654
+ return _screenToNDCDir(out,out[0],out[1],out[2],vpW,vpH,ndcZMin);
655
+ }
656
+ if (from===NDC && to===MATRIX) {
657
+ _ndcToScreenDir(out,dx,dy,dz,vpW,vpH,ndcZMin);
658
+ _screenToWorldDir(out,out[0],out[1],out[2],m.mat4Proj,m.mat4Eye,vpW,vpH,ndcZMin);
659
+ return _applyDir(out,m.toFrameInv,out[0],out[1],out[2]);
660
+ }
661
+
662
+ if (from===MATRIX && to===MATRIX) {
663
+ _applyDir(out,m.fromFrame,dx,dy,dz);
664
+ return _applyDir(out,m.toFrameInv,out[0],out[1],out[2]);
665
+ }
666
+
667
+ out[0]=dx; out[1]=dy; out[2]=dz;
668
+ return out;
669
+ }
670
+
671
+ // ═══════════════════════════════════════════════════════════════════════════
672
+ // Rays and pointer hits
673
+ // ═══════════════════════════════════════════════════════════════════════════
674
+
675
+ /**
676
+ * Screen point → world ray: origin on the near plane (screen depth 0), unit
677
+ * direction toward the far plane (depth 1). Same bag and viewport contract as
678
+ * mapLocation; `m.mat4PVInv` must be filled by the caller.
679
+ *
680
+ * @param {number[]} outO 3-element origin.
681
+ * @param {number[]} outD 3-element unit direction.
682
+ * @param {number} sx,sy Screen point.
683
+ * @param {object} m Matrices bag — see module header.
684
+ * @param {number[]} vp Viewport [x, y, w, h]; sign of h encodes screen-y direction.
685
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
686
+ * @returns {number[]|null} outD, or null when the bag carries no mat4PVInv
687
+ * (singular P·V) or the ray has no length.
688
+ */
689
+ function unproject(outO, outD, sx, sy, m, vp, ndcZMin) {
690
+ const ipv = m.mat4PVInv;
691
+ if (!ipv) return null;
692
+ _screenToWorld(outO, sx, sy, 0, ipv, vp, ndcZMin);
693
+ _screenToWorld(outD, sx, sy, 1, ipv, vp, ndcZMin);
694
+ const dx=outD[0]-outO[0], dy=outD[1]-outO[1], dz=outD[2]-outO[2];
695
+ const l=Math.sqrt(dx*dx+dy*dy+dz*dz);
696
+ if (!(l > 0)) return null;
697
+ outD[0]=dx/l; outD[1]=dy/l; outD[2]=dz/l;
698
+ return outD;
699
+ }
700
+
701
+ // ═══════════════════════════════════════════════════════════════════════════
702
+ // pixelRatio
703
+ // ═══════════════════════════════════════════════════════════════════════════
704
+
705
+ /**
706
+ * World-units-per-pixel at a given eye-space Z depth.
707
+ * @param {ArrayLike<number>} proj Projection mat4.
708
+ * @param {number} vpH Viewport height in pixels (positive).
709
+ * @param {number} eyeZ Eye-space Z — negative means in front of camera.
710
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
711
+ */
712
+ function pixelRatio(proj, vpH, eyeZ, ndcZMin) {
713
+ return projIsOrtho(proj)
714
+ ? Math.abs(projTop(proj,ndcZMin)-projBottom(proj,ndcZMin)) / vpH
715
+ : 2*Math.abs(eyeZ)*Math.tan(projFov(proj)/2) / vpH;
716
+ }
717
+
718
+ /**
719
+ * @file Spline math and keyframe animation state machines.
720
+ * @module tree/track
721
+ * @license AGPL-3.0-only
722
+ *
723
+ * Quaternion algebra is provided by quat.js; projection matrix construction
724
+ * by form.js. Spline helpers (hermiteVec3, lerpVec3) and TRS↔mat4 conversions
725
+ * (transformToMat4, mat4ToTransform) live here because they are tightly
726
+ * coupled to the PoseTrack keyframe shape.
727
+ *
728
+ * Zero dependencies on p5, DOM, WebGL, or WebGPU.
729
+ *
730
+ * ── Exports ──────────────────────────────────────────────────────────────
731
+ * Quaternion helpers (re-exported from quat.js)
732
+ * qSet qCopy qDot qNormalize qNegate qMul qConjugate qRotateVec3
733
+ * qSlerp qNlerp qFromUnitVectors qFromAxisAngle qFromLookDir
734
+ * qFromRotMat3x3 qFromMat4 qToMat4 qToAxisAngle
735
+ * Spline / vector helpers
736
+ * hermiteVec3 lerpVec3
737
+ * Transform / mat4 helpers
738
+ * transformToMat4 mat4ToTransform
739
+ * Tracks
740
+ * PoseTrack — { pos, rot, scl } TRS keyframes
741
+ * CameraTrack — { eye, center, up, fov?, halfHeight?, near, far }
742
+ * lookat keyframes
743
+ *
744
+ * ── Public path access (all zero-alloc, no cursor side effects) ──────────
745
+ * PoseTrack
746
+ * samplePos (out) | (out, seg, t) vec3
747
+ * mat4Model (out) | (out, seg, t) mat4 — TRS model
748
+ * tangents (outIn, outOut, index) vec3 × 2 at keyframe
749
+ * eval (out?) TRS object at cursor
750
+ *
751
+ * CameraTrack
752
+ * sampleEye (out) | (out, seg, t) vec3
753
+ * sampleCenter (out) | (out, seg, t) vec3
754
+ * mat4Eye (out) | (out, seg, t) mat4 — lookat frame
755
+ * eyeTangents (outIn, outOut, index) vec3 × 2 at keyframe
756
+ * centerTangents (outIn, outOut, index) vec3 × 2 at keyframe
757
+ * eval (out?) { eye, center, up,
758
+ * fov, halfHeight,
759
+ * near, far }
760
+ *
761
+ * Two arities for the continuous family:
762
+ * (out) cursor form — reads track.seg / track.f. Useful when the
763
+ * track's own transport is driving the animation.
764
+ * (out, seg, t) explicit form — continuous (seg, t) coordinate, no cursor
765
+ * side effects. seg ∈ [0, segments−1] integer, t ∈ [0, 1]
766
+ * local to that segment. As a convenience, seg === segments
767
+ * is accepted and rewritten to (segments−1, 1) — so the
768
+ * idiom sampleX(out, i, 0) uniformly addresses keyframe i
769
+ * for every i ∈ [0, keyframes.length−1].
770
+ *
771
+ * All honour the track's interpolation mode (hermite / linear / step) and
772
+ * the same stored-tangent → centripetal-CR fallback chain that eval() uses.
773
+ *
774
+ * `tangents` / `eyeTangents` / `centerTangents` are keyframe-indexed — a
775
+ * keyframe's incoming tangent belongs to the previous segment, outgoing to
776
+ * the next. At boundary keyframes the missing side mirrors the present one
777
+ * so drawing arrows at endpoints always yields a visible vector.
778
+ *
779
+ * Projection matrices are deliberately not exposed as a track method. Each
780
+ * CameraTrack keyframe stores `fov` (perspective) or `halfHeight` (ortho)
781
+ * directly on track.keyframes[i] — callers wanting a projection build one
782
+ * with the free mat4Persp / mat4Ortho constructors. Animated fov in
783
+ * sketches flows through the bridge's camera-binding via eval().fov and
784
+ * eval().halfHeight (the bridge calls cam.perspective() / cam.ortho() each
785
+ * frame).
786
+ *
787
+ * ── Class hierarchy ──────────────────────────────────────────────────────
788
+ * Track (unexported, never instantiated directly)
789
+ * └── PoseTrack (exported)
790
+ * └── CameraTrack (exported)
791
+ *
792
+ * ── Hook architecture ────────────────────────────────────────────────────
793
+ * Lib-space hooks (underscore prefix — host layer / UI layer):
794
+ * _onActivate / _onDeactivate — fire on playing false→true / true→false.
795
+ * _onPlay / _onEnd / _onStop — mirror the user-space hooks.
796
+ *
797
+ * User-space hooks:
798
+ * onPlay : fires in play() on false→true transition.
799
+ * onEnd : fires in tick() at natural boundary (once mode only).
800
+ * onStop : fires in stop() / reset().
801
+ *
802
+ * Firing order:
803
+ * play() → onPlay → _onPlay → _onActivate
804
+ * tick() → onEnd → _onEnd → _onDeactivate
805
+ * stop() → onStop → _onStop → _onDeactivate
806
+ * reset() → onStop → _onStop → _onDeactivate
807
+ *
808
+ * ── Loop modes ───────────────────────────────────────────────────────────
809
+ * loop:false, bounce:false — play once, stop at end (fires onEnd)
810
+ * loop:true, bounce:false — repeat, wrap back to start
811
+ * loop:true, bounce:true — bounce forever at boundaries
812
+ * loop:false, bounce:true — bounce once: flip at far boundary, stop at origin
813
+ *
814
+ * ── Playback semantics (rate + _dir) ─────────────────────────────────────
815
+ * rate > 0 forward rate < 0 backward rate === 0 frozen
816
+ *
817
+ * play() is the sole setter of playing = true.
818
+ * stop() is the sole setter of playing = false.
819
+ * Assigning rate never starts or stops playback.
820
+ *
821
+ * _dir (internal, ±1) tracks the current bounce travel direction.
822
+ * tick() advances by rate * _dir and flips _dir at boundaries.
823
+ * _dir is reset to 1 only in reset().
824
+ *
825
+ * ── One-keyframe behaviour ───────────────────────────────────────────────
826
+ * play() with exactly one keyframe snaps eval() to that keyframe without
827
+ * setting playing = true and without firing hooks.
828
+ */
829
+
830
+
831
+ // =========================================================================
832
+ // S2 Spline / vector helpers
833
+ // =========================================================================
834
+
835
+ function _dist3(a, b) {
836
+ const dx=a[0]-b[0], dy=a[1]-b[1], dz=a[2]-b[2];
837
+ return Math.sqrt(dx*dx+dy*dy+dz*dz);
838
+ }
839
+
840
+ /**
841
+ * Cubic Hermite interpolation between p0 and p1 with explicit tangents.
842
+ * @param {number[]} out 3-element result.
843
+ * @param {number[]} p0 Segment start.
844
+ * @param {number[]} m0 Outgoing tangent at p0.
845
+ * @param {number[]} p1 Segment end.
846
+ * @param {number[]} m1 Incoming tangent at p1.
847
+ * @param {number} t Blend [0, 1].
848
+ * @returns {number[]} out
849
+ */
850
+ const hermiteVec3 = (out, p0, m0, p1, m1, t) => {
851
+ const t2=t*t, t3=t2*t;
852
+ const h00=2*t3-3*t2+1, h10=t3-2*t2+t, h01=-2*t3+3*t2, h11=t3-t2;
853
+ out[0]=h00*p0[0]+h10*m0[0]+h01*p1[0]+h11*m1[0];
854
+ out[1]=h00*p0[1]+h10*m0[1]+h01*p1[1]+h11*m1[1];
855
+ out[2]=h00*p0[2]+h10*m0[2]+h01*p1[2]+h11*m1[2];
856
+ return out;
857
+ };
858
+
859
+ // Centripetal CR outgoing tangent at p1 for segment p1→p2.
860
+ // Signature: (out, p0, p1, p2). Returns tangent AT p1 (the middle point).
861
+ const _crTanOut = (out, p0, p1, p2) => {
862
+ const dt0=Math.pow(_dist3(p0,p1),0.5)||1, dt1=Math.pow(_dist3(p1,p2),0.5)||1;
863
+ for (let i=0;i<3;i++) out[i]=((p1[i]-p0[i])/dt0-(p2[i]-p0[i])/(dt0+dt1)+(p2[i]-p1[i])/dt1)*dt1;
864
+ return out;
865
+ };
866
+
867
+ // Centripetal CR incoming tangent at p2 for segment p1→p2.
868
+ // Signature: (out, p1, p2, p3). Returns tangent AT p2 (the middle point).
869
+ const _crTanIn = (out, p1, p2, p3) => {
870
+ const dt1=Math.pow(_dist3(p1,p2),0.5)||1, dt2=Math.pow(_dist3(p2,p3),0.5)||1;
871
+ for (let i=0;i<3;i++) out[i]=((p2[i]-p1[i])/dt1-(p3[i]-p1[i])/(dt1+dt2)+(p3[i]-p2[i])/dt2)*dt1;
872
+ return out;
873
+ };
874
+
875
+ // Module-level scratch — shared across track instances (non-reentrant hot path).
876
+ const _m0=[0,0,0], _m1=[0,0,0];
877
+ const _trsScratch = { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] };
878
+ const _eyeScratch = { eye:[0,0,0], center:[0,0,0], up:[0,1,0] };
879
+
880
+ /**
881
+ * Linear interpolation between two vec3s.
882
+ */
883
+ const lerpVec3 = (out, a, b, t) => {
884
+ out[0]=a[0]+t*(b[0]-a[0]);
885
+ out[1]=a[1]+t*(b[1]-a[1]);
886
+ out[2]=a[2]+t*(b[2]-a[2]);
887
+ return out;
888
+ };
889
+
890
+ // =========================================================================
891
+ // S2b Path samplers — shared core
892
+ // =========================================================================
893
+
894
+ /** @private — sample interpolated vec3 path at (seg, t) into out. */
895
+ function _samplePathCore(out, kfs, interp, field, tanInName, tanOutName, seg, t) {
896
+ const n = kfs.length;
897
+ if (n === 0) { out[0]=0; out[1]=0; out[2]=0; return out; }
898
+ if (n === 1) {
899
+ const p = kfs[0][field];
900
+ out[0]=p[0]; out[1]=p[1]; out[2]=p[2];
901
+ return out;
902
+ }
903
+ const nSeg = n - 1;
904
+ seg = seg | 0;
905
+ if (seg >= nSeg) { seg = nSeg - 1; t = 1; }
906
+ else if (seg < 0) { seg = 0; t = 0; }
907
+ t = _clamp01(t);
908
+ const k0 = kfs[seg];
909
+ const k1 = kfs[seg + 1];
910
+
911
+ if (interp === 'step') {
912
+ const p = k0[field];
913
+ out[0]=p[0]; out[1]=p[1]; out[2]=p[2];
914
+ return out;
915
+ }
916
+ if (interp === 'linear') {
917
+ return lerpVec3(out, k0[field], k1[field], t);
918
+ }
919
+
920
+ const p0 = seg > 0 ? kfs[seg - 1][field] : k0[field];
921
+ const p3 = seg + 2 < n ? kfs[seg + 2][field] : k1[field];
922
+ const m0 = k0[tanOutName] != null ? k0[tanOutName]
923
+ : k0[tanInName] != null ? k0[tanInName]
924
+ : _crTanOut(_m0, p0, k0[field], k1[field]);
925
+ const m1 = k1[tanInName] != null ? k1[tanInName]
926
+ : k1[tanOutName] != null ? k1[tanOutName]
927
+ : _crTanIn(_m1, k0[field], k1[field], p3);
928
+ return hermiteVec3(out, k0[field], m0, k1[field], m1, t);
929
+ }
930
+
931
+ /** @private — write effective in/out tangents at keyframe i. */
932
+ function _sampleTangentsCore(outIn, outOut, kfs, field, tanInName, tanOutName, i) {
933
+ const n = kfs.length;
934
+ if (n === 0) {
935
+ outIn[0]=outIn[1]=outIn[2]=0; outOut[0]=outOut[1]=outOut[2]=0;
936
+ return;
937
+ }
938
+ i = _clampS(i | 0, 0, n - 1);
939
+ const ki = kfs[i];
940
+ const hasTI = ki[tanInName] != null;
941
+ const hasTO = ki[tanOutName] != null;
942
+
943
+ if (hasTO) {
944
+ outOut[0]=ki[tanOutName][0]; outOut[1]=ki[tanOutName][1]; outOut[2]=ki[tanOutName][2];
945
+ } else if (hasTI) {
946
+ outOut[0]=ki[tanInName][0]; outOut[1]=ki[tanInName][1]; outOut[2]=ki[tanInName][2];
947
+ } else if (i < n - 1) {
948
+ const k1 = kfs[i + 1];
949
+ const p0 = i > 0 ? kfs[i - 1][field] : ki[field];
950
+ _crTanOut(outOut, p0, ki[field], k1[field]);
951
+ } else {
952
+ outOut[0]=0; outOut[1]=0; outOut[2]=0;
953
+ }
954
+
955
+ if (hasTI) {
956
+ outIn[0]=ki[tanInName][0]; outIn[1]=ki[tanInName][1]; outIn[2]=ki[tanInName][2];
957
+ } else if (hasTO) {
958
+ outIn[0]=ki[tanOutName][0]; outIn[1]=ki[tanOutName][1]; outIn[2]=ki[tanOutName][2];
959
+ } else if (i > 0) {
960
+ const k0 = kfs[i - 1];
961
+ const p3 = i + 1 < n ? kfs[i + 1][field] : ki[field];
962
+ _crTanIn(outIn, k0[field], ki[field], p3);
963
+ } else {
964
+ outIn[0]=outOut[0]; outIn[1]=outOut[1]; outIn[2]=outOut[2];
965
+ }
966
+
967
+ if (i === n - 1 && !hasTO && !hasTI) {
968
+ outOut[0]=outIn[0]; outOut[1]=outIn[1]; outOut[2]=outIn[2];
969
+ }
970
+ }
971
+
972
+ // =========================================================================
973
+ // S3 Transform <-> Mat4
974
+ // =========================================================================
975
+
976
+ /**
977
+ * Write a TRS transform into a column-major mat4.
978
+ */
979
+ const transformToMat4 = (out, xform) => {
980
+ qToMat4(out, xform.rot);
981
+ const sx=xform.scl[0], sy=xform.scl[1], sz=xform.scl[2];
982
+ out[0]*=sx; out[1]*=sx; out[2]*=sx;
983
+ out[4]*=sy; out[5]*=sy; out[6]*=sy;
984
+ out[8]*=sz; out[9]*=sz; out[10]*=sz;
985
+ out[12]=xform.pos[0]; out[13]=xform.pos[1]; out[14]=xform.pos[2];
986
+ return out;
987
+ };
988
+
989
+ /**
990
+ * Decompose a column-major mat4 into a TRS transform.
991
+ */
992
+ const mat4ToTransform = (out, m) => {
993
+ out.pos[0]=m[12]; out.pos[1]=m[13]; out.pos[2]=m[14];
994
+ const sx=Math.sqrt(m[0]*m[0]+m[1]*m[1]+m[2]*m[2]);
995
+ const sy=Math.sqrt(m[4]*m[4]+m[5]*m[5]+m[6]*m[6]);
996
+ const sz=Math.sqrt(m[8]*m[8]+m[9]*m[9]+m[10]*m[10]);
997
+ out.scl[0]=sx; out.scl[1]=sy; out.scl[2]=sz;
998
+ qFromRotMat3x3(out.rot,
999
+ m[0]/sx,m[4]/sy,m[8]/sz,
1000
+ m[1]/sx,m[5]/sy,m[9]/sz,
1001
+ m[2]/sx,m[6]/sy,m[10]/sz);
1002
+ return out;
1003
+ };
1004
+
1005
+ // =========================================================================
1006
+ // S4a Spec parser — PoseTrack
1007
+ // =========================================================================
1008
+
1009
+ const _isNum$1 = (x) => typeof x === 'number' && Number.isFinite(x);
1010
+ const _clamp01 = (x) => x < 0 ? 0 : (x > 1 ? 1 : x);
1011
+ const _clampS = (x, lo, hi) => x < lo ? lo : (x > hi ? hi : x);
1012
+
1013
+ function _parseVec3(v) {
1014
+ if (!v) return null;
1015
+ if (ArrayBuffer.isView(v) && v.length >= 3) return [v[0], v[1], v[2]];
1016
+ if (Array.isArray(v) && v.length >= 3 && v.every(n => typeof n === 'number')) return [v[0], v[1], v[2]];
1017
+ if (typeof v === 'object' && 'x' in v) return [v.x || 0, v.y || 0, v.z || 0];
1018
+ return null;
1019
+ }
1020
+
1021
+ const _EULER_AXES = { X:[1,0,0], Y:[0,1,0], Z:[0,0,1] };
1022
+ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1023
+
1024
+ /**
1025
+ * Parse any rotation representation into a unit quaternion [x,y,z,w].
1026
+ *
1027
+ * Accepted forms:
1028
+ * [x,y,z,w] — raw quaternion array
1029
+ * { axis:[x,y,z], angle } — axis-angle (angle in radians)
1030
+ * { dir:[x,y,z], up?:[x,y,z] } — forward direction (−Z) with optional up
1031
+ * { mat4Eye: mat4 } — rotation block of an eye matrix
1032
+ * { mat3: mat3 } — column-major 3×3 rotation matrix
1033
+ * { euler:[rx,ry,rz], order? } — intrinsic Euler (default order: YXZ)
1034
+ * { from:[x,y,z], to:[x,y,z] } — shortest-arc rotation between two vectors
1035
+ *
1036
+ * @param {*} v
1037
+ * @returns {number[]|null} [x,y,z,w] or null if unparseable.
1038
+ */
1039
+ function _parseQuat(v) {
1040
+ if (!v) return null;
1041
+
1042
+ // [x,y,z,w] — normalised like every other form
1043
+ if (Array.isArray(v) && v.length === 4) return qNormalize([v[0],v[1],v[2],v[3]]);
1044
+ if (ArrayBuffer.isView(v) && v.length >= 4) return qNormalize([v[0],v[1],v[2],v[3]]);
1045
+
1046
+ if (typeof v !== 'object') return null;
1047
+
1048
+ // { axis, angle }
1049
+ if (v.axis != null && v.angle != null) {
1050
+ const ax = Array.isArray(v.axis) ? v.axis : [v.axis.x||0, v.axis.y||0, v.axis.z||0];
1051
+ return qFromAxisAngle([0,0,0,1], ax[0],ax[1],ax[2], v.angle);
1052
+ }
1053
+
1054
+ // { dir, up? }
1055
+ if (v.dir != null) {
1056
+ const d = Array.isArray(v.dir) ? v.dir : [v.dir.x||0, v.dir.y||0, v.dir.z||0];
1057
+ const u = v.up ? (Array.isArray(v.up) ? v.up : [v.up.x||0, v.up.y||0, v.up.z||0]) : null;
1058
+ return qFromLookDir([0,0,0,1], d, u);
1059
+ }
1060
+
1061
+ // { mat4Eye }
1062
+ if (v.mat4Eye != null) {
1063
+ const m = (ArrayBuffer.isView(v.mat4Eye) || Array.isArray(v.mat4Eye))
1064
+ ? v.mat4Eye : (v.mat4Eye.mat4 ?? null);
1065
+ if (!m || m.length < 16) return null;
1066
+ return qFromRotMat3x3([0,0,0,1], m[0],m[4],m[8], m[1],m[5],m[9], m[2],m[6],m[10]);
1067
+ }
1068
+
1069
+ // { mat3 }
1070
+ if (v.mat3 != null) {
1071
+ const m = (ArrayBuffer.isView(v.mat3) || Array.isArray(v.mat3)) ? v.mat3 : null;
1072
+ if (!m || m.length < 9) return null;
1073
+ return qFromRotMat3x3([0,0,0,1], m[0],m[3],m[6], m[1],m[4],m[7], m[2],m[5],m[8]);
1074
+ }
1075
+
1076
+ // { euler, order? }
1077
+ if (v.euler != null) {
1078
+ const e = v.euler;
1079
+ if (!Array.isArray(e) || e.length < 3) return null;
1080
+ const order = (v.order && _EULER_ORDERS.has(v.order)) ? v.order : 'YXZ';
1081
+ const q = [0,0,0,1], s = [0,0,0,1];
1082
+ for (let i = 0; i < 3; i++) {
1083
+ const ax = _EULER_AXES[order[i]];
1084
+ qMul(q, q, qFromAxisAngle(s, ax[0],ax[1],ax[2], e[i]));
1085
+ }
1086
+ return q;
1087
+ }
1088
+
1089
+ // { from, to }
1090
+ if (v.from != null && v.to != null) {
1091
+ const f = Array.isArray(v.from) ? v.from : [v.from.x||0, v.from.y||0, v.from.z||0];
1092
+ const t = Array.isArray(v.to) ? v.to : [v.to.x||0, v.to.y||0, v.to.z||0];
1093
+ const fl = Math.sqrt(f[0]*f[0]+f[1]*f[1]+f[2]*f[2]) || 1;
1094
+ const tl = Math.sqrt(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]) || 1;
1095
+ const fx=f[0]/fl, fy=f[1]/fl, fz=f[2]/fl;
1096
+ const tx=t[0]/tl, ty=t[1]/tl, tz=t[2]/tl;
1097
+ const dot = fx*tx + fy*ty + fz*tz;
1098
+ if (dot >= 1 - 1e-8) return [0,0,0,1];
1099
+ if (dot <= -1 + 1e-8) {
1100
+ let px=0, py=fz, pz=-fy;
1101
+ let pl = Math.sqrt(px*px+py*py+pz*pz);
1102
+ if (pl < 1e-8) { px=fy; py=-fx; pz=0; pl = Math.sqrt(px*px+py*py+pz*pz); }
1103
+ if (pl < 1e-8) return [0,0,0,1];
1104
+ return qFromAxisAngle([0,0,0,1], px/pl,py/pl,pz/pl, Math.PI);
1105
+ }
1106
+ let ax=fy*tz-fz*ty, ay=fz*tx-fx*tz, az=fx*ty-fy*tx;
1107
+ const al = Math.sqrt(ax*ax+ay*ay+az*az) || 1;
1108
+ return qFromAxisAngle([0,0,0,1], ax/al,ay/al,az/al,
1109
+ Math.acos(Math.max(-1, Math.min(1, dot))));
1110
+ }
1111
+
1112
+ return null;
1113
+ }
1114
+
1115
+ /**
1116
+ * Parse a PoseTrack keyframe spec into internal form.
1117
+ *
1118
+ * Accepted forms:
1119
+ * { mat4Model }
1120
+ * Decompose a column-major mat4 into TRS via mat4ToTransform.
1121
+ * Float32Array(16), plain Array, or { mat4 } wrapper.
1122
+ *
1123
+ * { pos?, rot?, scl?, tanIn?, tanOut? }
1124
+ * Explicit TRS. pos and scl are vec3; rot accepts any form from _parseQuat.
1125
+ * All fields are optional — missing pos/scl default to [0,0,0] / [1,1,1],
1126
+ * missing rot defaults to identity.
1127
+ * tanIn/tanOut are optional vec3 tangents for Hermite interpolation.
1128
+ *
1129
+ * @param {Object} spec
1130
+ * @returns {{ pos:number[], rot:number[], scl:number[],
1131
+ * tanIn:number[]|null, tanOut:number[]|null } | null}
1132
+ */
1133
+ function _parseSpec(spec) {
1134
+ if (!spec || typeof spec !== 'object') return null;
1135
+
1136
+ // { mat4Model } — full TRS decomposition from model matrix
1137
+ if (spec.mat4Model != null) {
1138
+ const m = (ArrayBuffer.isView(spec.mat4Model) || Array.isArray(spec.mat4Model))
1139
+ ? spec.mat4Model : (spec.mat4Model.mat4 ?? null);
1140
+ if (!m || m.length < 16) return null;
1141
+ const kf = mat4ToTransform({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }, m);
1142
+ kf.tanIn = null; kf.tanOut = null;
1143
+ return kf;
1144
+ }
1145
+
1146
+ // { pos?, rot?, scl?, tanIn?, tanOut? } — explicit TRS
1147
+ const pos = _parseVec3(spec.pos) || [0,0,0];
1148
+ const rot = _parseQuat(spec.rot) || [0,0,0,1];
1149
+ const scl = _parseVec3(spec.scl) || [1,1,1];
1150
+ const tanIn = _parseVec3(spec.tanIn) || null;
1151
+ const tanOut = _parseVec3(spec.tanOut) || null;
1152
+ return { pos, rot, scl, tanIn, tanOut };
1153
+ }
1154
+
1155
+ function _sameTransform(a, b) {
1156
+ for (let i=0;i<3;i++) if (a.pos[i]!==b.pos[i]||a.scl[i]!==b.scl[i]) return false;
1157
+ for (let i=0;i<4;i++) if (a.rot[i]!==b.rot[i]) return false;
1158
+ return true;
1159
+ }
1160
+
1161
+ // =========================================================================
1162
+ // S4b Spec parser — CameraTrack
1163
+ // =========================================================================
1164
+
1165
+ // Lens defaults used when a spec omits near/far. Match the three.js /
1166
+ // Bevy conventions and are safe for typical p5 v2 scene scales.
1167
+ const _DEFAULT_NEAR = 0.1;
1168
+ const _DEFAULT_FAR = 1000;
1169
+
1170
+ /**
1171
+ * Parse a CameraTrack keyframe spec into internal form.
1172
+ *
1173
+ * Required: eye (vec3). Everything else is optional.
1174
+ * center defaults to [0, 0, 0]
1175
+ * up defaults to [0, 1, 0] and is normalised
1176
+ * fov vertical fov (radians), perspective only — null if absent
1177
+ * halfHeight world-unit half-height of ortho frustum — null if absent
1178
+ * near near clip distance (positive) — defaults to 0.1
1179
+ * far far clip distance (positive) — defaults to 1000
1180
+ * eyeTanIn/Out optional Hermite tangents for the eye path
1181
+ * centerTanIn/Out optional Hermite tangents for the center path
1182
+ *
1183
+ * fov and halfHeight are mutually exclusive (perspective xor ortho) and
1184
+ * therefore left nullable; eval() lerps each only when both adjacent
1185
+ * keyframes carry a non-null value, passing null through otherwise.
1186
+ *
1187
+ * near and far are always meaningful regardless of projection type, so
1188
+ * they receive real defaults and are linearly interpolated unconditionally.
1189
+ *
1190
+ * @param {Object} spec
1191
+ * @returns {Object|null} Parsed keyframe or null if eye is missing/malformed.
1192
+ */
1193
+ function _parseCameraSpec(spec) {
1194
+ if (!spec || typeof spec !== 'object') return null;
1195
+ const eye = _parseVec3(spec.eye);
1196
+ if (!eye) return null;
1197
+ const center = _parseVec3(spec.center) || [0,0,0];
1198
+ const upRaw = spec.up ? _parseVec3(spec.up) : null;
1199
+ const up = upRaw || [0,1,0];
1200
+ const ul = Math.sqrt(up[0]*up[0]+up[1]*up[1]+up[2]*up[2]) || 1;
1201
+ return {
1202
+ eye, center,
1203
+ up: [up[0]/ul, up[1]/ul, up[2]/ul],
1204
+ fov: typeof spec.fov === 'number' ? spec.fov : null,
1205
+ halfHeight: typeof spec.halfHeight === 'number' ? spec.halfHeight : null,
1206
+ near: typeof spec.near === 'number' ? spec.near : _DEFAULT_NEAR,
1207
+ far: typeof spec.far === 'number' ? spec.far : _DEFAULT_FAR,
1208
+ eyeTanIn: _parseVec3(spec.eyeTanIn) || null,
1209
+ eyeTanOut: _parseVec3(spec.eyeTanOut) || null,
1210
+ centerTanIn: _parseVec3(spec.centerTanIn) || null,
1211
+ centerTanOut: _parseVec3(spec.centerTanOut)|| null,
1212
+ };
1213
+ }
1214
+
1215
+ function _sameCameraKeyframe(a, b) {
1216
+ for (let i=0;i<3;i++) {
1217
+ if (a.eye[i]!==b.eye[i]) return false;
1218
+ if (a.center[i]!==b.center[i]) return false;
1219
+ if (a.up[i]!==b.up[i]) return false;
1220
+ }
1221
+ if (a.fov !== b.fov) return false;
1222
+ if (a.halfHeight !== b.halfHeight) return false;
1223
+ if (a.near !== b.near) return false;
1224
+ if (a.far !== b.far) return false;
1225
+ return true;
1226
+ }
1227
+
1228
+ // =========================================================================
1229
+ // S5 Track — unexported base class (transport machinery only)
1230
+ // =========================================================================
1231
+
1232
+ class Track {
1233
+ constructor() {
1234
+ /** @type {Array} Keyframe array — shape depends on subclass. */
1235
+ this.keyframes = [];
1236
+ /** Whether playback is currently active. @type {boolean} */
1237
+ this.playing = false;
1238
+ /** Loop at boundaries. @type {boolean} */
1239
+ this.loop = false;
1240
+ /** Ping-pong bounce at boundaries (independent of loop). @type {boolean} */
1241
+ this.bounce = false;
1242
+ /** Frames per segment (≥1). @type {number} */
1243
+ this.duration = 30;
1244
+ /** Current segment index. @type {number} */
1245
+ this.seg = 0;
1246
+ /** Frame offset within current segment (can be fractional). @type {number} */
1247
+ this.f = 0;
1248
+
1249
+ // Playback rate (signed: negative = reverse; 0 = frozen).
1250
+ this._rate = 1;
1251
+ // Current bounce travel direction (±1). Reset to 1 only on reset().
1252
+ this._dir = 1;
1253
+ // Whether a bounce-once has already flipped direction.
1254
+ this._bounced = false;
1255
+
1256
+ /** User hook: fires on play() false→true transition. @type {Function|null} */
1257
+ this.onPlay = null;
1258
+ /** User hook: fires on natural boundary in once mode. @type {Function|null} */
1259
+ this.onEnd = null;
1260
+ /** User hook: fires on stop() / reset() / end-of-bounce-once. @type {Function|null} */
1261
+ this.onStop = null;
1262
+
1263
+ // Lib-space hooks (underscore prefix — host layer / UI layer only).
1264
+ this._onActivate = null; this._onDeactivate = null;
1265
+ this._onPlay = null; this._onEnd = null; this._onStop = null;
1266
+ }
1267
+
1268
+ /** Playback rate. Signed: negative reverses, 0 freezes. Assigning never starts or stops playback. @type {number} */
1269
+ get rate() { return this._rate; }
1270
+ set rate(v) { this._rate = (_isNum$1(v)) ? v : 1; }
1271
+
1272
+ /** Number of interpolatable segments (keyframes.length − 1, min 0). @type {number} */
1273
+ get segments() { return Math.max(0, this.keyframes.length - 1); }
1274
+
1275
+ /**
1276
+ * @private — resolve cursor (seg, f) into continuous (seg, t).
1277
+ * Shared backing of every cursor-form sampler / matrix method.
1278
+ */
1279
+ _cursorSegT() {
1280
+ const nSeg = this.segments;
1281
+ const dur = Math.max(1, this.duration | 0);
1282
+ const seg = nSeg > 0 ? _clampS(this.seg, 0, nSeg - 1) : 0;
1283
+ const t = _clamp01(this.f / dur);
1284
+ return [seg, t];
1285
+ }
1286
+
1287
+ /**
1288
+ * Start or update playback. Sole setter of `playing = true`.
1289
+ *
1290
+ * Fires `onPlay → _onPlay → _onActivate` only on a false→true transition.
1291
+ * Zero keyframes: no-op. Exactly one keyframe: snaps eval() to it but does
1292
+ * not set `playing` and fires no hooks.
1293
+ *
1294
+ * @param {number|{duration?:number,loop?:boolean,bounce?:boolean,
1295
+ * rate?:number,onPlay?:Function,onEnd?:Function,
1296
+ * onStop?:Function}} [rateOrOpts]
1297
+ * A bare number is taken as `rate`; an object configures multiple
1298
+ * fields in one call.
1299
+ * @returns {Track} this
1300
+ */
1301
+ play(rateOrOpts) {
1302
+ if (this.keyframes.length === 0) return this;
1303
+ if (this.keyframes.length === 1) { this.seg = 0; this.f = 0; return this; }
1304
+
1305
+ if (typeof rateOrOpts === 'number' && Number.isFinite(rateOrOpts)) {
1306
+ this._rate = rateOrOpts;
1307
+ } else if (rateOrOpts && typeof rateOrOpts === 'object') {
1308
+ const o = rateOrOpts;
1309
+ if (_isNum$1(o.duration)) this.duration = Math.max(1, o.duration | 0);
1310
+ if ('loop' in o) this.loop = !!o.loop;
1311
+ if ('bounce' in o) this.bounce = !!o.bounce;
1312
+ if (typeof o.onPlay === 'function') this.onPlay = o.onPlay;
1313
+ if (typeof o.onEnd === 'function') this.onEnd = o.onEnd;
1314
+ if (typeof o.onStop === 'function') this.onStop = o.onStop;
1315
+ if (_isNum$1(o.rate)) this._rate = o.rate;
1316
+ }
1317
+
1318
+ const nSeg = this.segments, dur = Math.max(1, this.duration | 0);
1319
+ if (this.seg < 0) this.seg = 0;
1320
+ if (this.seg >= nSeg) this.seg = nSeg - 1;
1321
+ if (this.f < 0) this.f = 0;
1322
+ if (this.f > dur) this.f = dur;
1323
+
1324
+ const wasPlaying = this.playing;
1325
+ this.playing = true;
1326
+ if (!wasPlaying) {
1327
+ this._bounced = false;
1328
+ if (typeof this.onPlay === 'function') { try { this.onPlay(this); } catch (_) {} }
1329
+ this._onPlay?.();
1330
+ this._onActivate?.();
1331
+ }
1332
+ return this;
1333
+ }
1334
+
1335
+ /**
1336
+ * Stop playback. Sole setter of `playing = false`. Fires
1337
+ * `onStop → _onStop → _onDeactivate` on a true→false transition.
1338
+ *
1339
+ * @param {boolean} [rewind] If true, seek to the origin end after stopping
1340
+ * (0 when playing forward, 1 when playing backward).
1341
+ * @returns {Track} this
1342
+ */
1343
+ stop(rewind) {
1344
+ const wasPlaying = this.playing;
1345
+ this.playing = false;
1346
+ if (wasPlaying) {
1347
+ this._bounced = false;
1348
+ if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1349
+ this._onStop?.();
1350
+ this._onDeactivate?.();
1351
+ if (rewind && this.keyframes.length > 1) this.seek(this._rate * this._dir < 0 ? 1 : 0);
1352
+ }
1353
+ return this;
1354
+ }
1355
+
1356
+ /**
1357
+ * Clear all keyframes and stop. Fires stop-side hooks if it was playing.
1358
+ * Unlike stop(), this also resets `_dir` to +1.
1359
+ * @returns {Track} this
1360
+ */
1361
+ reset() {
1362
+ const wasPlaying = this.playing;
1363
+ this.playing = false;
1364
+ if (wasPlaying) {
1365
+ if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1366
+ this._onStop?.();
1367
+ this._onDeactivate?.();
1368
+ }
1369
+ this.keyframes.length = 0;
1370
+ this.seg = 0; this.f = 0; this._dir = 1; this._bounced = false;
1371
+ return this;
1372
+ }
1373
+
1374
+ /**
1375
+ * Remove the keyframe at `index`. Adjusts the cursor if the removal shrinks
1376
+ * the track below the current segment.
1377
+ * @param {number} index
1378
+ * @returns {boolean} true if removed; false if index was invalid.
1379
+ */
1380
+ remove(index) {
1381
+ if (!_isNum$1(index)) return false;
1382
+ const i = index | 0;
1383
+ if (i < 0 || i >= this.keyframes.length) return false;
1384
+ this.keyframes.splice(i, 1);
1385
+ const nSeg = this.segments;
1386
+ if (nSeg === 0) { this.seg = 0; this.f = 0; }
1387
+ else if (this.seg >= nSeg) { this.seg = nSeg - 1; }
1388
+ return true;
1389
+ }
1390
+
1391
+ /**
1392
+ * Move the cursor.
1393
+ * seek(t) Scrub to normalised position t ∈ [0, 1] across the
1394
+ * whole track.
1395
+ * seek(t, segIndex) Position within a specific segment — t is local to
1396
+ * that segment.
1397
+ * Does not affect `playing`.
1398
+ *
1399
+ * @param {number} t
1400
+ * @param {number} [segIndex]
1401
+ * @returns {Track} this
1402
+ */
1403
+ seek(t, segIndex) {
1404
+ const nSeg = this.segments;
1405
+ if (nSeg === 0) { this.seg = 0; this.f = 0; return this; }
1406
+ const dur = Math.max(1, this.duration | 0);
1407
+ if (_isNum$1(segIndex)) {
1408
+ this.seg = _clampS(segIndex | 0, 0, nSeg - 1);
1409
+ this.f = _clamp01(t) * dur;
1410
+ } else {
1411
+ this._setCursorFromScalar(_clamp01(t) * nSeg * dur);
1412
+ }
1413
+ return this;
1414
+ }
1415
+
1416
+ /**
1417
+ * Normalised cursor position across the whole track.
1418
+ * @returns {number} ∈ [0, 1]; 0 when there are no segments.
1419
+ */
1420
+ time() {
1421
+ const nSeg = this.segments;
1422
+ if (nSeg === 0) return 0;
1423
+ const dur = Math.max(1, this.duration | 0);
1424
+ return _clamp01((this.seg * dur + this.f) / (nSeg * dur));
1425
+ }
1426
+
1427
+ /**
1428
+ * Snapshot of transport state. Allocates a new object per call — intended
1429
+ * for UI / debugging, not hot loops.
1430
+ * @returns {{keyframes:number, segments:number, seg:number, f:number,
1431
+ * playing:boolean, loop:boolean, bounce:boolean, rate:number,
1432
+ * duration:number, time:number}}
1433
+ */
1434
+ info() {
1435
+ return {
1436
+ keyframes: this.keyframes.length,
1437
+ segments: this.segments,
1438
+ seg: this.seg,
1439
+ f: this.f,
1440
+ playing: this.playing,
1441
+ loop: this.loop,
1442
+ bounce: this.bounce,
1443
+ rate: this._rate,
1444
+ duration: this.duration,
1445
+ time: this.segments > 0 ? this.time() : 0
1446
+ };
1447
+ }
1448
+
1449
+ /**
1450
+ * Advance the cursor by `rate * _dir` in frames. Handles loop / bounce /
1451
+ * once modes per the table in the module header. Fires `onEnd → _onEnd →
1452
+ * _onDeactivate` at a natural boundary in once mode.
1453
+ *
1454
+ * Intended to be called once per animation frame by the bridge / UI layer.
1455
+ * rate === 0 freezes the cursor but keeps `playing` unchanged.
1456
+ *
1457
+ * @returns {boolean} Current `playing` state after advancing.
1458
+ */
1459
+ tick() {
1460
+ if (!this.playing) return false;
1461
+ const nSeg = this.segments;
1462
+ if (nSeg === 0) { this.playing = false; this._onDeactivate?.(); return false; }
1463
+ if (this._rate === 0) return true;
1464
+
1465
+ const dur = Math.max(1, this.duration | 0);
1466
+ const total = nSeg * dur;
1467
+ const s = _clampS(this.seg * dur + this.f, 0, total);
1468
+ const next = s + this._rate * this._dir;
1469
+
1470
+ if (this.loop && this.bounce) {
1471
+ let pos = next, flips = 0;
1472
+ while (pos < 0 || pos > total) {
1473
+ if (pos < 0) { pos = -pos; flips++; }
1474
+ else { pos = 2 * total - pos; flips++; }
1475
+ }
1476
+ if (flips & 1) this._dir = -this._dir;
1477
+ this._setCursorFromScalar(pos);
1478
+ return true;
1479
+ }
1480
+
1481
+ if (!this.loop && this.bounce) {
1482
+ if (next >= total) {
1483
+ this._setCursorFromScalar(Math.min(total, 2 * total - next));
1484
+ this._dir = -this._dir;
1485
+ this._bounced = true;
1486
+ return true;
1487
+ }
1488
+ if (next <= 0) {
1489
+ this._setCursorFromScalar(0);
1490
+ this.playing = false;
1491
+ this._dir = 1; this._bounced = false;
1492
+ if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1493
+ this._onEnd?.();
1494
+ this._onDeactivate?.();
1495
+ return false;
1496
+ }
1497
+ this._setCursorFromScalar(next);
1498
+ return true;
1499
+ }
1500
+
1501
+ if (this.loop) {
1502
+ this._setCursorFromScalar(((next % total) + total) % total);
1503
+ return true;
1504
+ }
1505
+
1506
+ if (next <= 0) {
1507
+ this._setCursorFromScalar(0);
1508
+ this.playing = false;
1509
+ if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1510
+ this._onEnd?.();
1511
+ this._onDeactivate?.();
1512
+ return false;
1513
+ }
1514
+ if (next >= total) {
1515
+ this._setCursorFromScalar(total);
1516
+ this.playing = false;
1517
+ if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1518
+ this._onEnd?.();
1519
+ this._onDeactivate?.();
1520
+ return false;
1521
+ }
1522
+
1523
+ this._setCursorFromScalar(next);
1524
+ return true;
1525
+ }
1526
+
1527
+ /** @private */
1528
+ _setCursorFromScalar(s) {
1529
+ const dur = Math.max(1, this.duration | 0);
1530
+ const nSeg = this.segments;
1531
+ this.seg = Math.floor(s / dur);
1532
+ this.f = s - this.seg * dur;
1533
+ if (this.seg >= nSeg) { this.seg = nSeg - 1; this.f = dur; }
1534
+ if (this.seg < 0) { this.seg = 0; this.f = 0; }
1535
+ }
1536
+ }
1537
+
1538
+ // =========================================================================
1539
+ // S6 PoseTrack
1540
+ // =========================================================================
1541
+
1542
+ /**
1543
+ * Renderer-agnostic TRS keyframe track.
1544
+ *
1545
+ * Keyframe shape: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z],
1546
+ * tanIn?:[x,y,z], tanOut?:[x,y,z] }
1547
+ *
1548
+ * tanIn — incoming position tangent at this keyframe (Hermite mode).
1549
+ * tanOut — outgoing position tangent at this keyframe (Hermite mode).
1550
+ * When only one is supplied, the other mirrors it at sample time.
1551
+ * When neither is supplied, centripetal Catmull-Rom tangents are auto-computed.
1552
+ */
1553
+ class PoseTrack extends Track {
1554
+ constructor() {
1555
+ super();
1556
+ /**
1557
+ * Position interpolation mode.
1558
+ * - 'hermite' — cubic Hermite; auto-computes centripetal Catmull-Rom
1559
+ * tangents when none are stored (default)
1560
+ * - 'linear' — lerp
1561
+ * - 'step' — snap to k0; useful for discrete state changes
1562
+ * @type {'hermite'|'linear'|'step'}
1563
+ */
1564
+ this.posInterp = 'hermite';
1565
+ /**
1566
+ * Rotation interpolation mode.
1567
+ * - 'slerp' — constant angular velocity (default)
1568
+ * - 'nlerp' — normalised lerp; cheaper, slightly non-constant speed
1569
+ * - 'step' — snap to k0 quaternion
1570
+ * @type {'slerp'|'nlerp'|'step'}
1571
+ */
1572
+ this.rotInterp = 'slerp';
1573
+ }
1574
+
1575
+ /**
1576
+ * Append one or more keyframes. Adjacent duplicates are skipped by default.
1577
+ * Accepts any spec form understood by _parseSpec, or an array of them.
1578
+ * @param {Object|Object[]} spec
1579
+ * @param {{ deduplicate?: boolean }} [opts]
1580
+ */
1581
+ add(spec, opts) {
1582
+ if (Array.isArray(spec)) { for (const s of spec) this.add(s, opts); return; }
1583
+ const kf = _parseSpec(spec);
1584
+ if (!kf) return;
1585
+ const dedup = !opts || opts.deduplicate !== false;
1586
+ if (dedup && this.keyframes.length > 0) {
1587
+ if (_sameTransform(this.keyframes[this.keyframes.length - 1], kf)) return;
1588
+ }
1589
+ this.keyframes.push(kf);
1590
+ }
1591
+
1592
+ /**
1593
+ * Replace the keyframe at `index`, or append at the end if `index` equals
1594
+ * the current keyframe count.
1595
+ * @param {number} index
1596
+ * @param {Object} spec Any spec form understood by _parseSpec.
1597
+ * @returns {boolean} true on success; false for invalid index or spec.
1598
+ */
1599
+ set(index, spec) {
1600
+ if (!_isNum$1(index)) return false;
1601
+ const i = index | 0, kf = _parseSpec(spec);
1602
+ if (!kf || i < 0 || i > this.keyframes.length) return false;
1603
+ if (i === this.keyframes.length) this.keyframes.push(kf);
1604
+ else this.keyframes[i] = kf;
1605
+ return true;
1606
+ }
1607
+
1608
+ /**
1609
+ * Sample the position path. Zero-alloc, no cursor side effects. Honours
1610
+ * posInterp and the stored-tangent → auto-CR fallback chain.
1611
+ *
1612
+ * Two signatures:
1613
+ * samplePos(out) cursor form — reads current seg/f
1614
+ * samplePos(out, seg, t) explicit (seg, t), continuous on the path
1615
+ *
1616
+ * @param {number[]} out 3-element result buffer.
1617
+ * @param {number} [seg] Segment index in [0, segments−1]. Omit for cursor.
1618
+ * @param {number} [t] Local parameter in [0, 1]. Omit for cursor.
1619
+ * @returns {number[]} out
1620
+ */
1621
+ samplePos(out, seg, t) {
1622
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
1623
+ return _samplePathCore(out, this.keyframes, this.posInterp, 'pos', 'tanIn', 'tanOut', seg, t);
1624
+ }
1625
+
1626
+ /**
1627
+ * Write the TRS pose as a column-major model mat4. Zero-alloc, no cursor
1628
+ * side effects.
1629
+ *
1630
+ * Two signatures:
1631
+ * mat4Model(out) cursor form — reads current seg/f
1632
+ * mat4Model(out, seg, t) explicit (seg, t), continuous on the path
1633
+ *
1634
+ * Replaces the previous toMatrix() method.
1635
+ *
1636
+ * @param {Float32Array|number[]} out 16-element result buffer.
1637
+ * @param {number} [seg] Segment index in [0, segments−1]. Omit for cursor.
1638
+ * @param {number} [t] Local parameter in [0, 1]. Omit for cursor.
1639
+ * @returns {Float32Array|number[]} out
1640
+ */
1641
+ mat4Model(out, seg, t) {
1642
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
1643
+ this._sampleTRS(_trsScratch, seg, t);
1644
+ return transformToMat4(out, _trsScratch);
1645
+ }
1646
+
1647
+ /**
1648
+ * Effective incoming / outgoing position tangents at keyframe `index`.
1649
+ * Stored tanIn/tanOut take precedence; each mirrors the other when only
1650
+ * one is stored; else centripetal Catmull-Rom tangents are auto-computed
1651
+ * from neighbours. Boundary keyframes mirror across the missing side.
1652
+ *
1653
+ * @param {number[]} outIn 3-element result — incoming tangent.
1654
+ * @param {number[]} outOut 3-element result — outgoing tangent.
1655
+ * @param {number} index Keyframe index.
1656
+ * @returns {PoseTrack} this
1657
+ */
1658
+ tangents(outIn, outOut, index) {
1659
+ _sampleTangentsCore(outIn, outOut, this.keyframes, 'pos', 'tanIn', 'tanOut', index);
1660
+ return this;
1661
+ }
1662
+
1663
+ /** @private — write interpolated TRS at (seg, t). */
1664
+ _sampleTRS(out, seg, t) {
1665
+ const n = this.keyframes.length;
1666
+ if (n === 0) return out;
1667
+ if (n === 1) {
1668
+ const k = this.keyframes[0];
1669
+ out.pos[0]=k.pos[0]; out.pos[1]=k.pos[1]; out.pos[2]=k.pos[2];
1670
+ out.rot[0]=k.rot[0]; out.rot[1]=k.rot[1]; out.rot[2]=k.rot[2]; out.rot[3]=k.rot[3];
1671
+ out.scl[0]=k.scl[0]; out.scl[1]=k.scl[1]; out.scl[2]=k.scl[2];
1672
+ return out;
1673
+ }
1674
+ const nSeg = n - 1;
1675
+ seg = seg | 0;
1676
+ if (seg >= nSeg) { seg = nSeg - 1; t = 1; }
1677
+ else if (seg < 0) { seg = 0; t = 0; }
1678
+ t = _clamp01(t);
1679
+ const k0 = this.keyframes[seg];
1680
+ const k1 = this.keyframes[seg + 1];
1681
+
1682
+ _samplePathCore(out.pos, this.keyframes, this.posInterp, 'pos', 'tanIn', 'tanOut', seg, t);
1683
+
1684
+ if (this.rotInterp === 'step') {
1685
+ out.rot[0]=k0.rot[0]; out.rot[1]=k0.rot[1]; out.rot[2]=k0.rot[2]; out.rot[3]=k0.rot[3];
1686
+ } else if (this.rotInterp === 'nlerp') {
1687
+ qNlerp(out.rot, k0.rot, k1.rot, t);
1688
+ } else {
1689
+ qSlerp(out.rot, k0.rot, k1.rot, t);
1690
+ }
1691
+
1692
+ lerpVec3(out.scl, k0.scl, k1.scl, t);
1693
+ return out;
1694
+ }
1695
+
1696
+ /**
1697
+ * Evaluate interpolated TRS pose at current cursor.
1698
+ * @param {{ pos:number[], rot:number[], scl:number[] }} [out]
1699
+ * @returns {{ pos:number[], rot:number[], scl:number[] }} out
1700
+ */
1701
+ eval(out) {
1702
+ out = out || { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] };
1703
+ const n = this.keyframes.length;
1704
+ if (n === 0) return out;
1705
+ if (n === 1) return this._sampleTRS(out, 0, 0);
1706
+ const [seg, t] = this._cursorSegT();
1707
+ return this._sampleTRS(out, seg, t);
1708
+ }
1709
+ }
1710
+
1711
+ // =========================================================================
1712
+ // S7 CameraTrack
1713
+ // =========================================================================
1714
+
1715
+ /**
1716
+ * Lookat camera keyframe track.
1717
+ *
1718
+ * Keyframe shape: { eye:[x,y,z], center:[x,y,z], up:[x,y,z],
1719
+ * fov?:number, halfHeight?:number,
1720
+ * near:number, far:number,
1721
+ * eyeTanIn?:[x,y,z], eyeTanOut?:[x,y,z],
1722
+ * centerTanIn?:[x,y,z], centerTanOut?:[x,y,z] }
1723
+ *
1724
+ * fov — vertical fov (radians) for perspective cameras; null for ortho.
1725
+ * halfHeight — world-unit half-height of ortho frustum; null for perspective.
1726
+ * Both are optional and nullable because exactly one is meaningful per
1727
+ * keyframe (perspective xor ortho). eval() lerps each only when both
1728
+ * adjacent keyframes carry a non-null value for that field; mixed or
1729
+ * missing entries pass `null` through.
1730
+ *
1731
+ * near, far — clip plane distances (positive, world units). Always real
1732
+ * numbers. Defaults: near = 0.1, far = 1000 (three.js / Bevy convention).
1733
+ * Linearly interpolated between keyframes without null-passthrough.
1734
+ *
1735
+ * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite
1736
+ * interpolation of the eye and center paths respectively. When absent,
1737
+ * centripetal Catmull-Rom tangents are auto-computed at sample time.
1738
+ *
1739
+ * Missing fields default to: center → [0,0,0], up → [0,1,0],
1740
+ * near → 0.1, far → 1000.
1741
+ *
1742
+ * For matrix-based capture of a camera-like pose use
1743
+ * PoseTrack.add({ mat4Model: mat4Eye }) for full TRS fidelity including roll,
1744
+ * or a lookat spec here for camera-style interpolation.
1745
+ */
1746
+ class CameraTrack extends Track {
1747
+ constructor() {
1748
+ super();
1749
+ /**
1750
+ * Eye-path interpolation mode.
1751
+ * @type {'hermite'|'linear'|'step'}
1752
+ */
1753
+ this.eyeInterp = 'hermite';
1754
+ /**
1755
+ * Center-path interpolation mode.
1756
+ * @type {'hermite'|'linear'|'step'}
1757
+ */
1758
+ this.centerInterp = 'linear';
1759
+ }
1760
+
1761
+ /**
1762
+ * Append one or more camera keyframes. Adjacent duplicates are skipped by
1763
+ * default.
1764
+ * @param {Object|Object[]} spec
1765
+ * @param {{ deduplicate?: boolean }} [opts]
1766
+ */
1767
+ add(spec, opts) {
1768
+ if (Array.isArray(spec)) { for (const s of spec) this.add(s, opts); return; }
1769
+ const kf = _parseCameraSpec(spec);
1770
+ if (!kf) return;
1771
+ const dedup = !opts || opts.deduplicate !== false;
1772
+ if (dedup && this.keyframes.length > 0) {
1773
+ if (_sameCameraKeyframe(this.keyframes[this.keyframes.length - 1], kf)) return;
1774
+ }
1775
+ this.keyframes.push(kf);
1776
+ }
1777
+
1778
+ /**
1779
+ * Replace the camera keyframe at `index`, or append at the end if `index`
1780
+ * equals the current keyframe count.
1781
+ * @param {number} index
1782
+ * @param {Object} spec
1783
+ * @returns {boolean}
1784
+ */
1785
+ set(index, spec) {
1786
+ if (!_isNum$1(index)) return false;
1787
+ const i = index | 0, kf = _parseCameraSpec(spec);
1788
+ if (!kf || i < 0 || i > this.keyframes.length) return false;
1789
+ if (i === this.keyframes.length) this.keyframes.push(kf);
1790
+ else this.keyframes[i] = kf;
1791
+ return true;
1792
+ }
1793
+
1794
+ /**
1795
+ * Sample the eye path. Zero-alloc, no cursor side effects.
1796
+ *
1797
+ * Two signatures:
1798
+ * sampleEye(out) cursor form — reads current seg/f
1799
+ * sampleEye(out, seg, t) explicit (seg, t), continuous on the path
1800
+ *
1801
+ * @param {number[]} out
1802
+ * @param {number} [seg]
1803
+ * @param {number} [t]
1804
+ * @returns {number[]} out
1805
+ */
1806
+ sampleEye(out, seg, t) {
1807
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
1808
+ return _samplePathCore(out, this.keyframes, this.eyeInterp, 'eye', 'eyeTanIn', 'eyeTanOut', seg, t);
1809
+ }
1810
+
1811
+ /**
1812
+ * Sample the center path. Zero-alloc, no cursor side effects.
1813
+ *
1814
+ * Two signatures:
1815
+ * sampleCenter(out) cursor form — reads current seg/f
1816
+ * sampleCenter(out, seg, t) explicit (seg, t)
1817
+ *
1818
+ * @param {number[]} out
1819
+ * @param {number} [seg]
1820
+ * @param {number} [t]
1821
+ * @returns {number[]} out
1822
+ */
1823
+ sampleCenter(out, seg, t) {
1824
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
1825
+ return _samplePathCore(out, this.keyframes, this.centerInterp, 'center', 'centerTanIn', 'centerTanOut', seg, t);
1826
+ }
1827
+
1828
+ /**
1829
+ * Write the interpolated lookat eye matrix as a column-major mat4
1830
+ * (eye→world rigid frame). Zero-alloc, no cursor side effects.
1831
+ *
1832
+ * Two signatures:
1833
+ * mat4Eye(out) cursor form — reads current seg/f
1834
+ * mat4Eye(out, seg, t) explicit (seg, t), continuous on the path
1835
+ *
1836
+ * @param {Float32Array|number[]} out 16-element result buffer.
1837
+ * @param {number} [seg]
1838
+ * @param {number} [t]
1839
+ * @returns {Float32Array|number[]} out
1840
+ */
1841
+ mat4Eye(out, seg, t) {
1842
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
1843
+ this._sampleEyePose(_eyeScratch, seg, t);
1844
+ const e = _eyeScratch;
1845
+ return mat4Eye(out,
1846
+ e.eye[0], e.eye[1], e.eye[2],
1847
+ e.center[0], e.center[1], e.center[2],
1848
+ e.up[0], e.up[1], e.up[2]);
1849
+ }
1850
+
1851
+ /**
1852
+ * Effective in/out eye tangents at keyframe `index`.
1853
+ */
1854
+ eyeTangents(outIn, outOut, index) {
1855
+ _sampleTangentsCore(outIn, outOut, this.keyframes, 'eye', 'eyeTanIn', 'eyeTanOut', index);
1856
+ return this;
1857
+ }
1858
+
1859
+ /**
1860
+ * Effective in/out center tangents at keyframe `index`.
1861
+ */
1862
+ centerTangents(outIn, outOut, index) {
1863
+ _sampleTangentsCore(outIn, outOut, this.keyframes, 'center', 'centerTanIn', 'centerTanOut', index);
1864
+ return this;
1865
+ }
1866
+
1867
+ /** @private — write interpolated { eye, center, up } at (seg, t). */
1868
+ _sampleEyePose(out, seg, t) {
1869
+ const n = this.keyframes.length;
1870
+ if (n === 0) return out;
1871
+ if (n === 1) {
1872
+ const k = this.keyframes[0];
1873
+ out.eye[0]=k.eye[0]; out.eye[1]=k.eye[1]; out.eye[2]=k.eye[2];
1874
+ out.center[0]=k.center[0]; out.center[1]=k.center[1]; out.center[2]=k.center[2];
1875
+ out.up[0]=k.up[0]; out.up[1]=k.up[1]; out.up[2]=k.up[2];
1876
+ return out;
1877
+ }
1878
+ const nSeg = n - 1;
1879
+ seg = seg | 0;
1880
+ if (seg >= nSeg) { seg = nSeg - 1; t = 1; }
1881
+ else if (seg < 0) { seg = 0; t = 0; }
1882
+ t = _clamp01(t);
1883
+ const k0 = this.keyframes[seg];
1884
+ const k1 = this.keyframes[seg + 1];
1885
+
1886
+ _samplePathCore(out.eye, this.keyframes, this.eyeInterp, 'eye', 'eyeTanIn', 'eyeTanOut', seg, t);
1887
+ _samplePathCore(out.center, this.keyframes, this.centerInterp, 'center', 'centerTanIn', 'centerTanOut', seg, t);
1888
+
1889
+ lerpVec3(out.up, k0.up, k1.up, t);
1890
+ const ul = Math.sqrt(out.up[0]*out.up[0]+out.up[1]*out.up[1]+out.up[2]*out.up[2]) || 1;
1891
+ out.up[0]/=ul; out.up[1]/=ul; out.up[2]/=ul;
1892
+ return out;
1893
+ }
1894
+
1895
+ /**
1896
+ * Evaluate interpolated camera pose at current cursor.
1897
+ *
1898
+ * `fov` / `halfHeight` are lerped only when both adjacent keyframes carry
1899
+ * a non-null value; mixed entries pass `null` through so the bridge can
1900
+ * leave the projection unchanged.
1901
+ *
1902
+ * `near` / `far` are always real numbers and are linearly interpolated
1903
+ * unconditionally.
1904
+ *
1905
+ * @param {{ eye:number[], center:number[], up:number[],
1906
+ * fov:number|null, halfHeight:number|null,
1907
+ * near:number, far:number }} [out]
1908
+ * @returns {{ eye:number[], center:number[], up:number[],
1909
+ * fov:number|null, halfHeight:number|null,
1910
+ * near:number, far:number }} out
1911
+ */
1912
+ eval(out) {
1913
+ out = out || { eye:[0,0,0], center:[0,0,0], up:[0,1,0],
1914
+ fov:null, halfHeight:null,
1915
+ near:_DEFAULT_NEAR, far:_DEFAULT_FAR };
1916
+ const n = this.keyframes.length;
1917
+ if (n === 0) return out;
1918
+ if (n === 1) {
1919
+ const k = this.keyframes[0];
1920
+ this._sampleEyePose(out, 0, 0);
1921
+ out.fov = k.fov; out.halfHeight = k.halfHeight;
1922
+ out.near = k.near; out.far = k.far;
1923
+ return out;
1924
+ }
1925
+ const [seg, t] = this._cursorSegT();
1926
+ const k0 = this.keyframes[seg];
1927
+ const k1 = this.keyframes[seg + 1];
1928
+
1929
+ this._sampleEyePose(out, seg, t);
1930
+
1931
+ out.fov = (k0.fov != null && k1.fov != null)
1932
+ ? k0.fov + t * (k1.fov - k0.fov) : (k0.fov ?? k1.fov ?? null);
1933
+ out.halfHeight = (k0.halfHeight != null && k1.halfHeight != null)
1934
+ ? k0.halfHeight + t * (k1.halfHeight - k0.halfHeight)
1935
+ : (k0.halfHeight ?? k1.halfHeight ?? null);
1936
+
1937
+ // near / far carry real defaults on every keyframe — always lerp.
1938
+ out.near = k0.near + t * (k1.near - k0.near);
1939
+ out.far = k0.far + t * (k1.far - k0.far);
1940
+
1941
+ return out;
1942
+ }
1943
+ }
1944
+
1945
+ /**
1946
+ * @file Constraint solver, canonical handle state, and ray-primitive
1947
+ * intersections for interactive manipulators.
1948
+ * @module tree/handle
1949
+ * @license AGPL-3.0-only
1950
+ *
1951
+ * The numeric brain of the manipulator handle. Renderer- and frame-agnostic:
1952
+ * inputs are plain numbers in ONE working space chosen by the caller (the
1953
+ * bridge), and the solver never learns world vs eye — it solves in whatever
1954
+ * space the ray and geometry are expressed in. The bridge converts the pointer
1955
+ * ray into the working frame before calling solve(), and converts the value
1956
+ * back out via mapLocation / mapDirection.
1957
+ *
1958
+ * Zero dependencies on p5, DOM, WebGL, or WebGPU. Out-first throughout; no
1959
+ * allocation in solve() / value(). Vectors are passed as flat scalars (matching
1960
+ * form.js), state is held as plain number[] (matching track.js keyframes).
1961
+ *
1962
+ * ── Storage convention ─────────────────────────────────────────────────────
1963
+ * The core distinguishes two value shapes deliberately:
1964
+ * mat4 — 16-element ArrayLike, typically Float32Array, because matrices
1965
+ * cross the GL boundary (contiguous f32 is what the renderer wants).
1966
+ * vec3 / quat — plain number[] (f64), because they are authoring/state values
1967
+ * and because the frozen basis-vector constants (i, j, k, ORIGIN)
1968
+ * cannot be expressed as frozen typed arrays. Handle state follows
1969
+ * the vec3 rule.
1970
+ *
1971
+ * ── Constraint kinds ───────────────────────────────────────────────────────
1972
+ * SPHERE 2-DOF heading on a sphere of radius r about an anchor.
1973
+ * Canonical state is a UNIT DIRECTION (renormalised every solve) —
1974
+ * no stored Euler angles, so no gimbal degeneracy at the poles.
1975
+ * az/el are derived on request via azEl().
1976
+ * PLANE 2-DOF point on a fixed plane (anchor + unit normal).
1977
+ * AXIS 1-DOF point on a line (anchor + unit dir), scalar t clamped to extent.
1978
+ * DIAL 1-DOF angle on a circle of radius r in the plane (anchor, axis).
1979
+ * Canonical state is an ACCUMULATED angle θ (multi-turn winding is
1980
+ * preserved: each solve applies the smaller signed arc, so θ counts
1981
+ * full turns — essential for bind targets like "rotate this 720°").
1982
+ * θ=0 sits at the reference direction `zero` (derived from the axis
1983
+ * when not supplied); θ grows right-handed about the axis. Near
1984
+ * edge-on view (the ray almost parallel to the dial plane, where a
1985
+ * plane hit races to infinity — the classic rotate-gizmo failure)
1986
+ * the solve falls back to the TANGENT LINE of the circle at the
1987
+ * current angle, converting linear drag to dθ: bounded, monotone,
1988
+ * and still pure ray geometry.
1989
+ *
1990
+ * VIEW (the camera-facing free-translate constraint) is NOT a core kind: the
1991
+ * bridge implements it with PLANE, feeding a fresh camera-derived normal and
1992
+ * anchor each frame. The core stays oblivious to the camera.
1993
+ *
1994
+ * ── Report modes ───────────────────────────────────────────────────────────
1995
+ * DIRECTION value() writes the unit direction: SPHERE → the heading;
1996
+ * DIAL → the radial unit at θ.
1997
+ * POINT value() writes a position: SPHERE → anchor + dir·radius;
1998
+ * DIAL → the point on the circle; PLANE / AXIS → the constrained
1999
+ * point.
2000
+ *
2001
+ * ── State ownership & portability ──────────────────────────────────────────
2002
+ * Each Constraint owns its own state; there is no module-level scratch. solve()
2003
+ * writes into instance fields, never shared globals. This keeps the type a
2004
+ * plain-data state machine: it ports 1:1 to a Rust enum + impl that is
2005
+ * `Send + Sync` for free, with `&mut self` on solve() making concurrent
2006
+ * mutation a compile error. The binding closure deliberately lives in the
2007
+ * bridge, not here — a stored callback would forfeit that guarantee.
2008
+ *
2009
+ * ── Extension contract ─────────────────────────────────────────────────────
2010
+ * A constraint is any object exposing: `kind` (integer discriminant),
2011
+ * `solve(ox,oy,oz, dx,dy,dz)`, `value(out, report)`, `seed(x,y,z)`, and
2012
+ * optionally `scalar()` / `azEl(out2)` / `aim(ax,ay,az[, zx,zy,zz])` — the
2013
+ * basis re-aim seam the bridge's deferred `from` frame drives — and
2014
+ * `proxy(ox,oy,oz, dx,dy,dz, radius)`, the analytic pick: the ray parameter
2015
+ * at which a ray in the working space meets the grab proxy of `radius`
2016
+ * working units, or Infinity (a kind without one gets a sphere at its
2017
+ * POINT). The handle controller drives any conforming constraint
2018
+ * (lifecycle, frame conversion, bind, hooks, pick); a new kind — 6-DOF, or
2019
+ * app-specific — implements this contract here (portable, draw-free) plus a
2020
+ * bridge-side locus draw, rather than forking the controller. The classes
2021
+ * below are the reference implementation. See handle-design.md §9–§10.
2022
+ *
2023
+ * ── Conventions ────────────────────────────────────────────────────────────
2024
+ * Ray direction `d` is assumed unit (the bridge normalises). Plane / axis
2025
+ * normals and directions are normalised at construction. The angular utilities
2026
+ * use a right-handed convention: az about +Y, el measured from the XZ plane.
2027
+ * solve() does NOT depend on this convention — it stores the hit direction
2028
+ * directly; az/el are a readout/authoring convenience only. DIAL's θ is
2029
+ * right-handed about its own axis, measured from `zero`.
2030
+ */
2031
+
2032
+
2033
+ const EPS = 1e-6;
2034
+ const TWO_PI$1 = Math.PI * 2;
2035
+
2036
+ // Edge-on threshold for DIAL: below this |d·n| the plane hit is ill-conditioned
2037
+ // (dθ per pixel diverges) and the solve switches to the tangent-line fallback.
2038
+ // ≈ 4.6° of incidence. Tune empirically (handle-experiments/e2).
2039
+ const DIAL_EDGE = 0.08;
2040
+
2041
+ // =========================================================================
2042
+ // H1 Small private helpers
2043
+ // =========================================================================
2044
+
2045
+ const _isNum = (x) => typeof x === 'number' && Number.isFinite(x);
2046
+ const _clamp = (x, lo, hi) => x < lo ? lo : (x > hi ? hi : x);
2047
+ const _num = (x, d) => _isNum(x) ? x : d;
2048
+
2049
+ /** Wrap an angle to (−π, π]. */
2050
+ const _wrapPi = (a) => a - TWO_PI$1 * Math.round(a / TWO_PI$1);
2051
+
2052
+ /** Parse a vec3 from array / typed array / {x,y,z}. Returns a fresh [x,y,z] or null. */
2053
+ function _vec3$1(v) {
2054
+ if (!v) return null;
2055
+ if (ArrayBuffer.isView(v) && v.length >= 3) return [v[0], v[1], v[2]];
2056
+ if (Array.isArray(v) && v.length >= 3) return [v[0], v[1], v[2]];
2057
+ if (typeof v === 'object' && 'x' in v) return [v.x || 0, v.y || 0, v.z || 0];
2058
+ return null;
2059
+ }
2060
+
2061
+ /** Normalise a vec3 in place; zero-length falls back to the given default axis. */
2062
+ function _unit$1(v, dx, dy, dz) {
2063
+ const l = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
2064
+ if (l < EPS) { v[0]=dx; v[1]=dy; v[2]=dz; return v; }
2065
+ v[0]/=l; v[1]/=l; v[2]/=l;
2066
+ return v;
2067
+ }
2068
+
2069
+ /**
2070
+ * Orthonormal in-plane basis for a unit normal n, written into ub, vb. Seeds
2071
+ * from the world axis least aligned with n so the first cross can't degenerate.
2072
+ * (Same derivation the p5 bridge uses for its plane quad — duplicated here
2073
+ * because the core cannot depend on the bridge.)
2074
+ */
2075
+ function _basis$1(n, ub, vb) {
2076
+ const ax = Math.abs(n[0]), ay = Math.abs(n[1]), az = Math.abs(n[2]);
2077
+ let rx = 0, ry = 0, rz = 0;
2078
+ if (ax <= ay && ax <= az) rx = 1; else if (ay <= az) ry = 1; else rz = 1;
2079
+ ub[0] = ry*n[2] - rz*n[1]; ub[1] = rz*n[0] - rx*n[2]; ub[2] = rx*n[1] - ry*n[0];
2080
+ _unit$1(ub, 1, 0, 0);
2081
+ 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];
2082
+ }
2083
+
2084
+ // =========================================================================
2085
+ // H2 Ray-primitive intersections (pure, out-first, scalar params)
2086
+ // =========================================================================
2087
+
2088
+ /**
2089
+ * Nearest ray–sphere intersection. The ray direction is assumed unit, so the
2090
+ * quadratic's leading coefficient is 1. On a miss, writes the closest-approach
2091
+ * point projected onto the sphere (so the handle tracks the limb gracefully
2092
+ * rather than snapping away).
2093
+ *
2094
+ * @param {number[]} out 3-element destination (hit point).
2095
+ * @param {number} ox,oy,oz Ray origin.
2096
+ * @param {number} dx,dy,dz Ray direction (unit).
2097
+ * @param {number} cx,cy,cz Sphere centre.
2098
+ * @param {number} r Sphere radius.
2099
+ * @returns {number} Ray parameter t at the written hit.
2100
+ */
2101
+ function raySphere(out, ox,oy,oz, dx,dy,dz, cx,cy,cz, r) {
2102
+ const lx=ox-cx, ly=oy-cy, lz=oz-cz;
2103
+ const b = lx*dx + ly*dy + lz*dz;
2104
+ const cc = lx*lx + ly*ly + lz*lz - r*r;
2105
+ const disc = b*b - cc;
2106
+ let t;
2107
+ if (disc >= 0) {
2108
+ const s = Math.sqrt(disc);
2109
+ t = -b - s;
2110
+ if (t < 0) t = -b + s; // origin inside the sphere: take far root
2111
+ out[0]=ox+t*dx; out[1]=oy+t*dy; out[2]=oz+t*dz;
2112
+ } else {
2113
+ t = -b; // closest approach along the ray
2114
+ let hx=ox+t*dx-cx, hy=oy+t*dy-cy, hz=oz+t*dz-cz;
2115
+ const hl = Math.sqrt(hx*hx + hy*hy + hz*hz) || 1;
2116
+ const k = r/hl;
2117
+ out[0]=cx+hx*k; out[1]=cy+hy*k; out[2]=cz+hz*k;
2118
+ }
2119
+ return t;
2120
+ }
2121
+
2122
+ /**
2123
+ * Ray–plane intersection. A near-parallel ray (|d·n| < EPS) returns Infinity
2124
+ * and leaves `out` untouched — the caller keeps the previous value.
2125
+ *
2126
+ * @param {number[]} out 3-element destination (hit point).
2127
+ * @param {number} ox,oy,oz Ray origin.
2128
+ * @param {number} dx,dy,dz Ray direction (unit).
2129
+ * @param {number} px,py,pz A point on the plane.
2130
+ * @param {number} nx,ny,nz Plane normal (unit).
2131
+ * @returns {number} Ray parameter t, or Infinity if parallel.
2132
+ */
2133
+ function rayPlane(out, ox,oy,oz, dx,dy,dz, px,py,pz, nx,ny,nz) {
2134
+ const den = dx*nx + dy*ny + dz*nz;
2135
+ if (den < EPS && den > -EPS) return Infinity;
2136
+ const t = ((px-ox)*nx + (py-oy)*ny + (pz-oz)*nz) / den;
2137
+ out[0]=ox+t*dx; out[1]=oy+t*dy; out[2]=oz+t*dz;
2138
+ return t;
2139
+ }
2140
+
2141
+ /**
2142
+ * Closest point on the infinite line (p, u) to the ray (o, d). Both `d` and
2143
+ * `u` are assumed unit. For a ray parallel to the line, projects the ray
2144
+ * origin onto the line.
2145
+ *
2146
+ * @param {number[]} out 3-element destination (closest point on the line).
2147
+ * @param {number} ox,oy,oz Ray origin.
2148
+ * @param {number} dx,dy,dz Ray direction (unit).
2149
+ * @param {number} px,py,pz A point on the line.
2150
+ * @param {number} ux,uy,uz Line direction (unit).
2151
+ * @returns {number} Signed parameter s along u at the written point (unclamped).
2152
+ */
2153
+ function rayClosestPointOnAxis(out, ox,oy,oz, dx,dy,dz, px,py,pz, ux,uy,uz) {
2154
+ const wx=ox-px, wy=oy-py, wz=oz-pz;
2155
+ const b = dx*ux + dy*uy + dz*uz; // d·u
2156
+ const dd = dx*wx + dy*wy + dz*wz; // d·w
2157
+ const e = ux*wx + uy*wy + uz*wz; // u·w
2158
+ const den = 1 - b*b; // (d·d)(u·u) − (d·u)² with d,u unit
2159
+ const s = (den > EPS) ? (e - b*dd) / den : e;
2160
+ out[0]=px+s*ux; out[1]=py+s*uy; out[2]=pz+s*uz;
2161
+ return s;
2162
+ }
2163
+
2164
+ // =========================================================================
2165
+ // H2b Ray-primitive hit tests (pure, no out: the nearest t, or Infinity)
2166
+ // =========================================================================
2167
+ //
2168
+ // Beside the solve primitives above, which always write a point, these are
2169
+ // TESTS: they write nothing and return the ray parameter of the nearest hit
2170
+ // with t ≥ 0, or Infinity on a miss. A ray whose origin lies inside the
2171
+ // primitive hits at its exit, so a press from inside a proxy still grabs.
2172
+ // The ray direction is assumed unit.
2173
+
2174
+ /**
2175
+ * Ray–sphere hit test.
2176
+ * @param {number} ox,oy,oz Ray origin.
2177
+ * @param {number} dx,dy,dz Ray direction (unit).
2178
+ * @param {number} cx,cy,cz Sphere centre.
2179
+ * @param {number} r Sphere radius.
2180
+ * @returns {number} The nearest t ≥ 0, or Infinity.
2181
+ */
2182
+ function rayHitSphere(ox,oy,oz, dx,dy,dz, cx,cy,cz, r) {
2183
+ const lx=ox-cx, ly=oy-cy, lz=oz-cz;
2184
+ const b = lx*dx + ly*dy + lz*dz;
2185
+ const cc = lx*lx + ly*ly + lz*lz - r*r;
2186
+ const disc = b*b - cc;
2187
+ if (disc < 0) return Infinity;
2188
+ const s = Math.sqrt(disc);
2189
+ let t = -b - s;
2190
+ if (t < 0) t = -b + s; // origin inside: the exit
2191
+ return t < 0 ? Infinity : t; // both roots behind the origin
2192
+ }
2193
+
2194
+ // One end cap of a capsule: the sphere at (cx,cy,cz), a root accepted only
2195
+ // where the hit's axial coordinate h = wu + t·du lies beyond the segment on
2196
+ // that cap's side (h ≤ 0 at A, h ≥ L at B), so the point is on the capsule's
2197
+ // surface and not inside its cylinder.
2198
+ function _capHit(ox,oy,oz, dx,dy,dz, cx,cy,cz, r, wu, du, lim, endB) {
2199
+ const lx=ox-cx, ly=oy-cy, lz=oz-cz;
2200
+ const b = lx*dx + ly*dy + lz*dz;
2201
+ const cc = lx*lx + ly*ly + lz*lz - r*r;
2202
+ const disc = b*b - cc;
2203
+ if (disc < 0) return Infinity;
2204
+ const s = Math.sqrt(disc);
2205
+ let t = -b - s, h = wu + t*du;
2206
+ if (t < 0 || (endB ? h < lim : h > lim)) { t = -b + s; h = wu + t*du; }
2207
+ return (t >= 0 && (endB ? h >= lim : h <= lim)) ? t : Infinity;
2208
+ }
2209
+
2210
+ /**
2211
+ * Ray–capsule hit test: the segment A→B swept by radius r. The cylinder wall
2212
+ * counts within the segment's extent, each end sphere beyond it; the nearest
2213
+ * of the three wins. A zero-length segment is the sphere at A.
2214
+ * @param {number} ox,oy,oz Ray origin.
2215
+ * @param {number} dx,dy,dz Ray direction (unit).
2216
+ * @param {number} ax,ay,az Segment start.
2217
+ * @param {number} bx,by,bz Segment end.
2218
+ * @param {number} r Capsule radius.
2219
+ * @returns {number} The nearest t ≥ 0, or Infinity.
2220
+ */
2221
+ function rayHitCapsule(ox,oy,oz, dx,dy,dz, ax,ay,az, bx,by,bz, r) {
2222
+ let ux=bx-ax, uy=by-ay, uz=bz-az;
2223
+ const L = Math.sqrt(ux*ux + uy*uy + uz*uz);
2224
+ if (L < EPS) return rayHitSphere(ox,oy,oz, dx,dy,dz, ax,ay,az, r);
2225
+ ux/=L; uy/=L; uz/=L;
2226
+ const wx=ox-ax, wy=oy-ay, wz=oz-az;
2227
+ const du = dx*ux + dy*uy + dz*uz; // d·u
2228
+ const wu = wx*ux + wy*uy + wz*uz; // w·u
2229
+ let best = Infinity;
2230
+ // Cylinder wall: the quadratic in the components perpendicular to u.
2231
+ const a = 1 - du*du;
2232
+ if (a > EPS) {
2233
+ const b = (dx*wx + dy*wy + dz*wz) - du*wu;
2234
+ const c = (wx*wx + wy*wy + wz*wz) - wu*wu - r*r;
2235
+ const disc = b*b - a*c;
2236
+ if (disc >= 0) {
2237
+ const s = Math.sqrt(disc);
2238
+ let t = (-b - s)/a, h = wu + t*du;
2239
+ if (t < 0 || h < 0 || h > L) { t = (-b + s)/a; h = wu + t*du; }
2240
+ if (t >= 0 && h >= 0 && h <= L) best = t;
2241
+ }
2242
+ }
2243
+ const ta = _capHit(ox,oy,oz, dx,dy,dz, ax,ay,az, r, wu, du, 0, false);
2244
+ if (ta < best) best = ta;
2245
+ const tb = _capHit(ox,oy,oz, dx,dy,dz, bx,by,bz, r, wu, du, L, true);
2246
+ if (tb < best) best = tb;
2247
+ return best;
2248
+ }
2249
+
2250
+ // Ring scratch — the plane normal and its in-plane basis. Function-local
2251
+ // working values, never state: rayHitRing runs to completion.
2252
+ const _rn = [0, 0, 1], _r0 = [1, 0, 0], _r1 = [0, 1, 0];
2253
+
2254
+ /**
2255
+ * Ray–ring hit test: the circle of radius R about c in the plane
2256
+ * perpendicular to u, swept by tube radius r — the analytic torus proxy as a
2257
+ * capsule chain, the circle polygonised into `detail` segments and each
2258
+ * tested with rayHitCapsule. The chain has volume in every direction, so it
2259
+ * never degenerates edge-on; the chordal error is R · (1 − cos(π / detail)).
2260
+ * @param {number} ox,oy,oz Ray origin.
2261
+ * @param {number} dx,dy,dz Ray direction (unit).
2262
+ * @param {number} cx,cy,cz Ring centre.
2263
+ * @param {number} ux,uy,uz Ring plane normal (normalised here).
2264
+ * @param {number} R Ring radius.
2265
+ * @param {number} r Tube radius.
2266
+ * @param {number} [detail=32] Chain segments (at least 3).
2267
+ * @returns {number} The nearest t ≥ 0, or Infinity.
2268
+ */
2269
+ function rayHitRing(ox,oy,oz, dx,dy,dz, cx,cy,cz, ux,uy,uz, R, r, detail = 32) {
2270
+ const n = _isNum(detail) && detail >= 3 ? Math.floor(detail) : 32;
2271
+ _rn[0]=ux; _rn[1]=uy; _rn[2]=uz;
2272
+ _unit$1(_rn, 0, 0, 1);
2273
+ _basis$1(_rn, _r0, _r1);
2274
+ const step = TWO_PI$1 / n;
2275
+ let best = Infinity;
2276
+ let ax = cx + R*_r0[0], ay = cy + R*_r0[1], az = cz + R*_r0[2]; // vertex at angle 0
2277
+ for (let i = 1; i <= n; i++) {
2278
+ const th = i === n ? 0 : i*step; // close the chain exactly
2279
+ const cs = Math.cos(th)*R, sn = Math.sin(th)*R;
2280
+ const bx = cx + cs*_r0[0] + sn*_r1[0];
2281
+ const by = cy + cs*_r0[1] + sn*_r1[1];
2282
+ const bz = cz + cs*_r0[2] + sn*_r1[2];
2283
+ const t = rayHitCapsule(ox,oy,oz, dx,dy,dz, ax,ay,az, bx,by,bz, r);
2284
+ if (t < best) best = t;
2285
+ ax = bx; ay = by; az = bz;
2286
+ }
2287
+ return best;
2288
+ }
2289
+
2290
+ // =========================================================================
2291
+ // H3 Angular utilities (readout / authoring convenience)
2292
+ // =========================================================================
2293
+
2294
+ /**
2295
+ * Unit direction from azimuth/elevation (right-handed: az about +Y, el from XZ).
2296
+ * @param {number[]} out 3-element destination.
2297
+ * @param {number} az Azimuth (radians).
2298
+ * @param {number} el Elevation (radians).
2299
+ * @returns {number[]} out
2300
+ */
2301
+ function dirFromAzEl(out, az, el) {
2302
+ const ce = Math.cos(el);
2303
+ out[0]=ce*Math.cos(az); out[1]=Math.sin(el); out[2]=ce*Math.sin(az);
2304
+ return out;
2305
+ }
2306
+
2307
+ /**
2308
+ * Azimuth/elevation from a unit direction. Inverse of dirFromAzEl.
2309
+ * @param {number[]} out2 2-element destination [az, el] (radians).
2310
+ * @param {number} dx,dy,dz Unit direction.
2311
+ * @returns {number[]} out2
2312
+ */
2313
+ function azElFromDir(out2, dx, dy, dz) {
2314
+ out2[0] = Math.atan2(dz, dx); // az
2315
+ out2[1] = Math.asin(_clamp(dy, -1, 1)); // el
2316
+ return out2;
2317
+ }
2318
+
2319
+ // =========================================================================
2320
+ // H4 Constraint — tagged state machine (one class, kind discriminant)
2321
+ // =========================================================================
2322
+
2323
+ /**
2324
+ * A draggable constraint: maps a ray to a constrained value and holds the
2325
+ * canonical state between drags. One class with a `kind` discriminant (never
2326
+ * per-constraint subclasses) so it maps cleanly to a Rust enum.
2327
+ *
2328
+ * Construction options (all optional unless noted):
2329
+ * SPHERE — { radius = 1, report = DIRECTION, anchor = [0,0,0] }
2330
+ * PLANE — { anchor = [0,0,0], normal = [0,1,0] }
2331
+ * AXIS — { anchor = [0,0,0], axis = [1,0,0], extent = [-1, 1] }
2332
+ * DIAL — { anchor = [0,0,0], axis = [0,1,0], radius = 1, zero?,
2333
+ * extent = [-∞, ∞] }
2334
+ * `axis` is the dial-plane normal (θ is right-handed about it);
2335
+ * `zero` is the in-plane reference direction for θ=0 (projected
2336
+ * onto the plane and normalised; derived from the axis when
2337
+ * absent); `extent` clamps θ in radians — unbounded by default,
2338
+ * so the dial winds multiple turns.
2339
+ *
2340
+ * @param {number} kind SPHERE | PLANE | AXIS | DIAL.
2341
+ * @param {Object} [opts]
2342
+ */
2343
+ class Constraint {
2344
+ constructor(kind, opts = {}) {
2345
+ /** Constraint kind discriminant. @type {number} */
2346
+ this.kind = kind;
2347
+
2348
+ /** Constraint origin (sphere centre / plane point / axis anchor / dial centre). @type {number[]} */
2349
+ this.anchor = _vec3$1(opts.anchor) || [0, 0, 0];
2350
+ /** Canonical unit direction — SPHERE. @type {number[]} */
2351
+ this.dir = [0, 0, 1];
2352
+ /** Constrained point — PLANE / AXIS / DIAL (and SPHERE scratch). @type {number[]} */
2353
+ this.pt = [this.anchor[0], this.anchor[1], this.anchor[2]];
2354
+ /** Plane normal (unit) — PLANE. @type {number[]} */
2355
+ this.n = _unit$1(_vec3$1(opts.normal) || [0, 1, 0], 0, 1, 0);
2356
+ /** Axis / dial-plane normal (unit) — AXIS / DIAL. @type {number[]} */
2357
+ this.u = kind === DIAL
2358
+ ? _unit$1(_vec3$1(opts.axis) || [0, 1, 0], 0, 1, 0)
2359
+ : _unit$1(_vec3$1(opts.axis) || [1, 0, 0], 1, 0, 0);
2360
+ /** Current scalar parameter — AXIS: t along the line; DIAL: accumulated θ. @type {number} */
2361
+ this.s = 0;
2362
+
2363
+ // DIAL in-plane basis: r0 (the θ=0 reference) and r1 = u × r0, so the
2364
+ // point at θ is anchor + radius·(cosθ·r0 + sinθ·r1) — right-handed about u.
2365
+ /** θ=0 reference direction (unit, in-plane) — DIAL. @type {number[]} */
2366
+ this.r0 = [1, 0, 0];
2367
+ /** In-plane binormal u × r0 (unit) — DIAL. @type {number[]} */
2368
+ this.r1 = [0, 0, 1];
2369
+ if (kind === DIAL) {
2370
+ const z = _vec3$1(opts.zero);
2371
+ this._dialBasis(z ? z[0] : NaN, z ? z[1] : NaN, z ? z[2] : NaN);
2372
+ }
2373
+
2374
+ // Extent: AXIS clamps t (default [-1, 1]); DIAL clamps θ in radians
2375
+ // (default unbounded, so the dial winds freely).
2376
+ const ext = Array.isArray(opts.extent) ? opts.extent : null;
2377
+ const dial = kind === DIAL;
2378
+ /** Minimum scalar parameter. @type {number} */
2379
+ this.min = ext && _isNum(ext[0]) ? ext[0] : (dial ? -Infinity : -1);
2380
+ /** Maximum scalar parameter. @type {number} */
2381
+ this.max = ext && _isNum(ext[1]) ? ext[1] : (dial ? Infinity : 1);
2382
+
2383
+ // Sphere / dial radius (private backing — see radius getter/setter).
2384
+ this._radius = _num(opts.radius, 1);
2385
+
2386
+ /**
2387
+ * Default report mode. SPHERE defaults to DIRECTION; PLANE / AXIS / DIAL
2388
+ * report a POINT.
2389
+ * @type {number}
2390
+ */
2391
+ this.report = (opts.report === POINT || opts.report === DIRECTION)
2392
+ ? opts.report
2393
+ : (kind === SPHERE ? DIRECTION : POINT);
2394
+
2395
+ if (kind === DIAL) this._dialPoint();
2396
+ }
2397
+
2398
+ /** Sphere / dial radius. @type {number} */
2399
+ get radius() { return this._radius; }
2400
+ set radius(r) { this._radius = _isNum(r) ? r : this._radius; if (this.kind === DIAL) this._dialPoint(); }
2401
+
2402
+ // Recompute the DIAL point from θ (anchor + ρ(cosθ·r0 + sinθ·r1)).
2403
+ _dialPoint() {
2404
+ const c = Math.cos(this.s) * this._radius, sn = Math.sin(this.s) * this._radius;
2405
+ this.pt[0] = this.anchor[0] + c*this.r0[0] + sn*this.r1[0];
2406
+ this.pt[1] = this.anchor[1] + c*this.r0[1] + sn*this.r1[1];
2407
+ this.pt[2] = this.anchor[2] + c*this.r0[2] + sn*this.r1[2];
2408
+ }
2409
+
2410
+ // Build the DIAL in-plane basis (r0, r1) from the current axis u and an
2411
+ // optional θ=0 reference (zx,zy,zz): the reference is projected onto the
2412
+ // dial plane and normalised; absent (NaN) or degenerate, r0 derives from u
2413
+ // via the least-aligned-axis seed. r1 = u × r0, right-handed about u.
2414
+ _dialBasis(zx, zy, zz) {
2415
+ if (_isNum(zx) && _isNum(zy) && _isNum(zz)) {
2416
+ const d = zx*this.u[0] + zy*this.u[1] + zz*this.u[2];
2417
+ this.r0[0] = zx - d*this.u[0];
2418
+ this.r0[1] = zy - d*this.u[1];
2419
+ this.r0[2] = zz - d*this.u[2];
2420
+ const l = Math.sqrt(this.r0[0]**2 + this.r0[1]**2 + this.r0[2]**2);
2421
+ if (l < EPS) _basis$1(this.u, this.r0, this.r1);
2422
+ else { this.r0[0]/=l; this.r0[1]/=l; this.r0[2]/=l; }
2423
+ } else {
2424
+ _basis$1(this.u, this.r0, this.r1);
2425
+ }
2426
+ // r1 = u × r0 (recomputed even when _basis ran — same result, one rule).
2427
+ this.r1[0] = this.u[1]*this.r0[2] - this.u[2]*this.r0[1];
2428
+ this.r1[1] = this.u[2]*this.r0[0] - this.u[0]*this.r0[2];
2429
+ this.r1[2] = this.u[0]*this.r0[1] - this.u[1]*this.r0[0];
2430
+ }
2431
+
2432
+ /**
2433
+ * Update the canonical state from a ray in the working space. The ray
2434
+ * direction is assumed unit. Chainable.
2435
+ *
2436
+ * @param {number} ox,oy,oz Ray origin.
2437
+ * @param {number} dx,dy,dz Ray direction (unit).
2438
+ * @returns {Constraint} this
2439
+ */
2440
+ solve(ox, oy, oz, dx, dy, dz) {
2441
+ if (this.kind === SPHERE) {
2442
+ // Hit the sphere into pt, then derive the unit heading from the anchor.
2443
+ raySphere(this.pt, ox,oy,oz, dx,dy,dz,
2444
+ this.anchor[0], this.anchor[1], this.anchor[2], this._radius);
2445
+ this.dir[0] = this.pt[0] - this.anchor[0];
2446
+ this.dir[1] = this.pt[1] - this.anchor[1];
2447
+ this.dir[2] = this.pt[2] - this.anchor[2];
2448
+ _unit$1(this.dir, this.dir[0], this.dir[1], this.dir[2]);
2449
+ } else if (this.kind === PLANE) {
2450
+ // Parallel ray returns Infinity and leaves pt unchanged (keep last).
2451
+ rayPlane(this.pt, ox,oy,oz, dx,dy,dz,
2452
+ this.anchor[0], this.anchor[1], this.anchor[2],
2453
+ this.n[0], this.n[1], this.n[2]);
2454
+ } else if (this.kind === AXIS) {
2455
+ let s = rayClosestPointOnAxis(this.pt, ox,oy,oz, dx,dy,dz,
2456
+ this.anchor[0], this.anchor[1], this.anchor[2],
2457
+ this.u[0], this.u[1], this.u[2]);
2458
+ s = _clamp(s, this.min, this.max);
2459
+ this.s = s;
2460
+ this.pt[0] = this.anchor[0] + s*this.u[0]; // clamped point
2461
+ this.pt[1] = this.anchor[1] + s*this.u[1];
2462
+ this.pt[2] = this.anchor[2] + s*this.u[2];
2463
+ } else if (this.kind === DIAL) {
2464
+ this._solveDial(ox, oy, oz, dx, dy, dz);
2465
+ }
2466
+ return this;
2467
+ }
2468
+
2469
+ // DIAL solve. Face-on: hit the dial plane, derive the raw angle of the hit
2470
+ // about the anchor, and accumulate the smaller signed arc into θ (winding
2471
+ // preserved). Edge-on (|d·u| < DIAL_EDGE): the plane hit diverges, so solve
2472
+ // the ray against the TANGENT LINE of the circle at the current θ instead —
2473
+ // the signed tangent parameter over the radius is dθ, bounded and monotone.
2474
+ _solveDial(ox, oy, oz, dx, dy, dz) {
2475
+ const u = this.u, r0 = this.r0, r1 = this.r1, a = this.anchor;
2476
+ const den = dx*u[0] + dy*u[1] + dz*u[2];
2477
+ let dth;
2478
+ if (den >= DIAL_EDGE || den <= -DIAL_EDGE) {
2479
+ // Well-conditioned: plane hit → raw angle → smaller signed arc.
2480
+ const t = rayPlane(this.pt, ox,oy,oz, dx,dy,dz,
2481
+ a[0], a[1], a[2], u[0], u[1], u[2]);
2482
+ if (t === Infinity) return; // keep last (paranoia; den guards)
2483
+ const vx = this.pt[0]-a[0], vy = this.pt[1]-a[1], vz = this.pt[2]-a[2];
2484
+ const raw = Math.atan2(vx*r1[0] + vy*r1[1] + vz*r1[2],
2485
+ vx*r0[0] + vy*r0[1] + vz*r0[2]);
2486
+ dth = _wrapPi(raw - _wrapPi(this.s));
2487
+ } else {
2488
+ // Edge-on fallback: tangent at the current point, w = −sinθ·r0 + cosθ·r1.
2489
+ const cs = Math.cos(this.s), sn = Math.sin(this.s);
2490
+ const wx = -sn*r0[0] + cs*r1[0];
2491
+ const wy = -sn*r0[1] + cs*r1[1];
2492
+ const wz = -sn*r0[2] + cs*r1[2];
2493
+ this._dialPoint(); // tangent anchored at the current point
2494
+ const sl = rayClosestPointOnAxis(this.pt, ox,oy,oz, dx,dy,dz,
2495
+ this.pt[0], this.pt[1], this.pt[2],
2496
+ wx, wy, wz);
2497
+ dth = sl / (this._radius || 1);
2498
+ }
2499
+ this.s = _clamp(this.s + dth, this.min, this.max);
2500
+ this._dialPoint();
2501
+ }
2502
+
2503
+ /**
2504
+ * Write the current value into `out`. `report` overrides the default for
2505
+ * this call (e.g. read a SPHERE's point even when its default is DIRECTION).
2506
+ * SPHERE+POINT and DIAL+POINT are derived from the angle/heading and the
2507
+ * live radius, so they stay correct after a radius change without
2508
+ * re-solving. DIAL+DIRECTION writes the radial unit at θ.
2509
+ *
2510
+ * @param {number[]} out 3-element destination.
2511
+ * @param {number} [report] POINT | DIRECTION override.
2512
+ * @returns {number[]} out
2513
+ */
2514
+ value(out, report) {
2515
+ const r = (report === POINT || report === DIRECTION) ? report : this.report;
2516
+ if (this.kind === SPHERE) {
2517
+ if (r === DIRECTION) {
2518
+ out[0]=this.dir[0]; out[1]=this.dir[1]; out[2]=this.dir[2];
2519
+ } else {
2520
+ out[0]=this.anchor[0]+this.dir[0]*this._radius;
2521
+ out[1]=this.anchor[1]+this.dir[1]*this._radius;
2522
+ out[2]=this.anchor[2]+this.dir[2]*this._radius;
2523
+ }
2524
+ } else if (this.kind === DIAL) {
2525
+ const cs = Math.cos(this.s), sn = Math.sin(this.s);
2526
+ if (r === DIRECTION) {
2527
+ out[0]=cs*this.r0[0]+sn*this.r1[0];
2528
+ out[1]=cs*this.r0[1]+sn*this.r1[1];
2529
+ out[2]=cs*this.r0[2]+sn*this.r1[2];
2530
+ } else {
2531
+ out[0]=this.anchor[0]+(cs*this.r0[0]+sn*this.r1[0])*this._radius;
2532
+ out[1]=this.anchor[1]+(cs*this.r0[1]+sn*this.r1[1])*this._radius;
2533
+ out[2]=this.anchor[2]+(cs*this.r0[2]+sn*this.r1[2])*this._radius;
2534
+ }
2535
+ } else {
2536
+ out[0]=this.pt[0]; out[1]=this.pt[1]; out[2]=this.pt[2];
2537
+ }
2538
+ return out;
2539
+ }
2540
+
2541
+ /**
2542
+ * Current scalar parameter: AXIS → signed t along the line; DIAL →
2543
+ * accumulated angle θ in radians (multi-turn).
2544
+ * @returns {number} The parameter, or NaN for non-scalar constraints.
2545
+ */
2546
+ scalar() {
2547
+ return (this.kind === AXIS || this.kind === DIAL) ? this.s : NaN;
2548
+ }
2549
+
2550
+ /**
2551
+ * Derive [az, el] from the current SPHERE direction (SPHERE only).
2552
+ * @param {number[]} out2 2-element destination [az, el].
2553
+ * @returns {number[]} out2
2554
+ */
2555
+ azEl(out2) {
2556
+ return azElFromDir(out2, this.dir[0], this.dir[1], this.dir[2]);
2557
+ }
2558
+
2559
+ /**
2560
+ * Seed the canonical state from a value (used by the bridge on bind() so the
2561
+ * handle starts at the bound target's value). Chainable.
2562
+ * SPHERE — sets the unit direction (a point value recovers its heading).
2563
+ * PLANE — projects the value onto the plane.
2564
+ * AXIS — projects the value onto the line, clamped to extent.
2565
+ * DIAL — projects the value onto the dial plane, derives the wrapped
2566
+ * angle, and picks the WINDING NEAREST the current θ (so an
2567
+ * external sync doesn't unwind accumulated turns), clamped.
2568
+ *
2569
+ * @param {number} x,y,z Seed value.
2570
+ * @returns {Constraint} this
2571
+ */
2572
+ seed(x, y, z) {
2573
+ if (this.kind === SPHERE) {
2574
+ const vx=x-this.anchor[0], vy=y-this.anchor[1], vz=z-this.anchor[2];
2575
+ const l = Math.sqrt(vx*vx + vy*vy + vz*vz);
2576
+ if (l >= EPS) { this.dir[0]=vx/l; this.dir[1]=vy/l; this.dir[2]=vz/l; }
2577
+ } else if (this.kind === PLANE) {
2578
+ const wx=x-this.anchor[0], wy=y-this.anchor[1], wz=z-this.anchor[2];
2579
+ const d = wx*this.n[0] + wy*this.n[1] + wz*this.n[2];
2580
+ this.pt[0]=x-d*this.n[0]; this.pt[1]=y-d*this.n[1]; this.pt[2]=z-d*this.n[2];
2581
+ } else if (this.kind === AXIS) {
2582
+ const wx=x-this.anchor[0], wy=y-this.anchor[1], wz=z-this.anchor[2];
2583
+ const s = _clamp(wx*this.u[0] + wy*this.u[1] + wz*this.u[2], this.min, this.max);
2584
+ this.s = s;
2585
+ this.pt[0]=this.anchor[0]+s*this.u[0];
2586
+ this.pt[1]=this.anchor[1]+s*this.u[1];
2587
+ this.pt[2]=this.anchor[2]+s*this.u[2];
2588
+ } else if (this.kind === DIAL) {
2589
+ const wx=x-this.anchor[0], wy=y-this.anchor[1], wz=z-this.anchor[2];
2590
+ const px = wx*this.r0[0] + wy*this.r0[1] + wz*this.r0[2];
2591
+ const py = wx*this.r1[0] + wy*this.r1[1] + wz*this.r1[2];
2592
+ if (px*px + py*py >= EPS*EPS) {
2593
+ const a = Math.atan2(py, px);
2594
+ // Nearest winding to the current θ preserves accumulated turns.
2595
+ this.s = _clamp(a + TWO_PI$1 * Math.round((this.s - a) / TWO_PI$1),
2596
+ this.min, this.max);
2597
+ }
2598
+ this._dialPoint();
2599
+ }
2600
+ return this;
2601
+ }
2602
+
2603
+ /**
2604
+ * Re-aim the constraint basis in the working space — the deferred-frame
2605
+ * seam (the p5.tree bridge's `from` opt resolves its symbolic basis through
2606
+ * mapDirection and calls this). Per kind:
2607
+ * PLANE — new normal; the point is re-projected onto the new plane.
2608
+ * AXIS — new direction; the scalar t is preserved, the point recomputed.
2609
+ * DIAL — new plane normal + optional θ=0 reference; θ is preserved, the
2610
+ * in-plane basis rebuilt (reference re-derived when omitted),
2611
+ * the point recomputed.
2612
+ * SPHERE — no basis; no-op.
2613
+ * Inputs are normalised; a zero-length axis keeps the previous one.
2614
+ * Chainable.
2615
+ *
2616
+ * @param {number} ax,ay,az New normal (PLANE) / direction (AXIS) / dial-plane normal (DIAL).
2617
+ * @param {number} [zx,zy,zz] DIAL only — θ=0 reference (re-derived when omitted).
2618
+ * @returns {Constraint} this
2619
+ */
2620
+ aim(ax, ay, az, zx, zy, zz) {
2621
+ if (this.kind === PLANE) {
2622
+ const px = this.n[0], py = this.n[1], pz = this.n[2];
2623
+ this.n[0] = ax; this.n[1] = ay; this.n[2] = az;
2624
+ _unit$1(this.n, px, py, pz);
2625
+ this.seed(this.pt[0], this.pt[1], this.pt[2]);
2626
+ } else if (this.kind === AXIS) {
2627
+ const px = this.u[0], py = this.u[1], pz = this.u[2];
2628
+ this.u[0] = ax; this.u[1] = ay; this.u[2] = az;
2629
+ _unit$1(this.u, px, py, pz);
2630
+ this.pt[0] = this.anchor[0] + this.s*this.u[0];
2631
+ this.pt[1] = this.anchor[1] + this.s*this.u[1];
2632
+ this.pt[2] = this.anchor[2] + this.s*this.u[2];
2633
+ } else if (this.kind === DIAL) {
2634
+ const px = this.u[0], py = this.u[1], pz = this.u[2];
2635
+ this.u[0] = ax; this.u[1] = ay; this.u[2] = az;
2636
+ _unit$1(this.u, px, py, pz);
2637
+ this._dialBasis(zx, zy, zz);
2638
+ this._dialPoint();
2639
+ }
2640
+ return this;
2641
+ }
2642
+
2643
+ /**
2644
+ * The analytic pick proxy: the ray parameter t at which a ray in the
2645
+ * working space meets the grab proxy, or Infinity. `radius` is the grab
2646
+ * size in working-space units — a constant pixel size the caller converted
2647
+ * through pixelRatio at the proxy's depth. SPHERE / PLANE / AXIS: a sphere
2648
+ * of `radius` at the reported POINT. DIAL: the ring at the anchor with
2649
+ * tube radius `radius` (rayHitRing), so a grab lands anywhere on the ring.
2650
+ *
2651
+ * @param {number} ox,oy,oz Ray origin.
2652
+ * @param {number} dx,dy,dz Ray direction (unit).
2653
+ * @param {number} radius Grab radius, working units.
2654
+ * @returns {number} The nearest t ≥ 0, or Infinity.
2655
+ */
2656
+ proxy(ox, oy, oz, dx, dy, dz, radius) {
2657
+ const a = this.anchor;
2658
+ if (this.kind === DIAL) {
2659
+ return rayHitRing(ox,oy,oz, dx,dy,dz, a[0], a[1], a[2],
2660
+ this.u[0], this.u[1], this.u[2], this._radius, radius);
2661
+ }
2662
+ let px, py, pz;
2663
+ if (this.kind === SPHERE) {
2664
+ px = a[0] + this.dir[0]*this._radius;
2665
+ py = a[1] + this.dir[1]*this._radius;
2666
+ pz = a[2] + this.dir[2]*this._radius;
2667
+ } else {
2668
+ px = this.pt[0]; py = this.pt[1]; pz = this.pt[2];
2669
+ }
2670
+ return rayHitSphere(ox,oy,oz, dx,dy,dz, px,py,pz, radius);
2671
+ }
2672
+ }
2673
+
2674
+ /**
2675
+ * Convenience factory mirroring the constructor. Handy for headless tests and
2676
+ * for the bridge, which otherwise calls `new Constraint(...)` directly.
2677
+ * @param {number} kind SPHERE | PLANE | AXIS | DIAL.
2678
+ * @param {Object} [opts]
2679
+ * @returns {Constraint}
2680
+ */
2681
+ function createConstraint(kind, opts) {
2682
+ return new Constraint(kind, opts);
2683
+ }
2684
+
2685
+ /**
2686
+ * @file Source-agnostic 6-DOF pose helm — integrates a live rate stream into a
2687
+ * { pos, rot } pose. Renderer- and transport-agnostic. Zero dependencies.
2688
+ * @module tree/helm
2689
+ * @license AGPL-3.0-only
2690
+ *
2691
+ * A helm is the rate-stream sibling of the Track family: where a Track produces
2692
+ * a pose from keyframes over time, a helm produces a pose from a live 6-DOF
2693
+ * delta stream (a SpaceNavigator, a tracked hand, an agent policy). It is NOT a
2694
+ * handle — a handle reports a `vec3` solved from a pointer ray; a helm reports a
2695
+ * pose (position + orientation).
2696
+ *
2697
+ * Family placement (peer of PoseTrack, NOT a Track subclass — no timeline):
2698
+ * feed(translation, rotation) push the latest raw device rate (input, as Track.add)
2699
+ * step(out, dt, basis) integrate the rate by dt into the pose (BRIDGE-DRIVEN,
2700
+ * as Track.tick) — writes the new pose into out
2701
+ * eval(out) read the current { pos, rot } (zero-alloc, as Track.eval)
2702
+ * home(pose?) re-home the integrated pose (NOT reset — no keyframes to clear)
2703
+ *
2704
+ * Value layer (opt-in, §10): a per-helm `fullScale` keeps the read-outs honest
2705
+ * across input scales; an opt-in `filter` (oneEuro) conditions the fed rates
2706
+ * before the deadzone inside step; `poseDelta` differences two absolute poses
2707
+ * into a feedable rate. With `filter` null and the default `fullScale`, the
2708
+ * clean rate-native path is unchanged.
2709
+ *
2710
+ * Quaternion algebra is provided by quat.js. Out-first throughout; no allocation
2711
+ * in feed/step/eval. Storage convention (matches the rest of the core): vec3 and
2712
+ * quat state are plain number[] (f64) — the same shape as track.js keyframes and
2713
+ * the frozen basis-vector constants.
2714
+ *
2715
+ * ── Frame discipline (`from` / `basis`) ──────────────────────────────────────
2716
+ * The space the rates are interpreted in is named by `from` (WORLD | EYE | mat4),
2717
+ * a declaration the BRIDGE reads — the core never learns about a camera. The
2718
+ * resolved orientation arrives per-step as `basis`: an eye→world mat4 (or null
2719
+ * for WORLD ≡ the identity basis). Both the linear and angular rates are rotated
2720
+ * through that basis, then the quaternion is composed world-frame. This is the
2721
+ * single code path that covers both manipulation conventions:
2722
+ *
2723
+ * - camera body-fly — basis is the driven camera's own eye matrix (which this
2724
+ * helm produced last frame ⇒ equals the integrated `q`,
2725
+ * zero staleness); rotating a body delta through `q` then
2726
+ * world-composing is algebraically a body-frame compose.
2727
+ * - screen-relative — basis is the *viewing* camera's eye matrix (an external
2728
+ * frame); a push moves the target relative to the screen.
2729
+ *
2730
+ * Per-channel `from` ({ translation, rotation }) is a deferred, non-breaking
2731
+ * extension (one basis suffices today — pose-helm-design.md §8.3).
2732
+ */
2733
+
2734
+
2735
+ // =========================================================================
2736
+ // Module-level scratch — shared across helm instances (non-reentrant; step
2737
+ // is synchronous and called at most once per helm per frame).
2738
+ // =========================================================================
2739
+
2740
+ const _dq = [0, 0, 0, 1];
2741
+
2742
+ // Scratch for the optional input filter (helm.filter). The fed rates are packed
2743
+ // into one 6-vector, conditioned, and unpacked to lin/ang triples before the
2744
+ // deadzone — so a single filter carries one state per helm. Shared across
2745
+ // instances (step is synchronous, at most once per helm per frame).
2746
+ const _f6raw = [0, 0, 0, 0, 0, 0];
2747
+ const _f6out = [0, 0, 0, 0, 0, 0];
2748
+ const _fLin = [0, 0, 0];
2749
+ const _fAng = [0, 0, 0];
2750
+
2751
+ /**
2752
+ * Default full-deflection input magnitude — the fed rate a read-out shows as
2753
+ * full (the SpaceNavigator's saturated lane). Private to core: a transport on a
2754
+ * different raw scale sets helm.fullScale and never reads this, so there is no
2755
+ * public HELM_FULL export. Display-only; integration uses profile.sens directly.
2756
+ */
2757
+ const HELM_FULL_SCALE = 500;
2758
+
2759
+ // Deadzone gate — |v| > dz keeps v, else 0. Module-level so step allocates no
2760
+ // closure. Strictly-greater matches the e7 reference (rest reads exact 0).
2761
+ const _dz = (v, dz) => (v > dz || v < -dz) ? v : 0;
2762
+
2763
+ /**
2764
+ * Build the canonical default profile. SpaceNavigator-tuned lane permutation
2765
+ * and the eye-frame manipulation feel found in the e7 experiments; it is the
2766
+ * per-app / per-device mapping layer and is meant to be overwritten wholesale
2767
+ * (`helm.profile = { … }`) for a different transport.
2768
+ *
2769
+ * sign — per-app direction (camera-fly vs object-grab invert; flip live).
2770
+ * sens — per-axis sensitivity (tame roll without touching the rest).
2771
+ * lane — input-channel permutation: which fed channel drives this DOF.
2772
+ * T* lanes index the translation triple; R* lanes the rotation triple.
2773
+ */
2774
+ function _defaultProfile() {
2775
+ return {
2776
+ Tx: { sign: 1, sens: 0.30, lane: 0 },
2777
+ Ty: { sign: 1, sens: 0.30, lane: 2 },
2778
+ Tz: { sign: -1, sens: 0.30, lane: 1 },
2779
+ Rp: { sign: -1, sens: 0.0025, lane: 0 },
2780
+ Ry: { sign: -1, sens: 0.0025, lane: 2 },
2781
+ Rr: { sign: 1, sens: 0.0018, lane: 1 },
2782
+ };
2783
+ }
2784
+
2785
+ // =========================================================================
2786
+ // PoseHelm
2787
+ // =========================================================================
2788
+
2789
+ /**
2790
+ * Source-agnostic 6-DOF pose producer. Holds a profile + the integrated pose;
2791
+ * no timeline (no keyframes, no play/seek/loop).
2792
+ */
2793
+ class PoseHelm {
2794
+ constructor() {
2795
+ /**
2796
+ * DOF mapping table. Six channels, each `{ sign, sens, lane }`.
2797
+ * Public and mutable — set wholesale to re-map a device or app.
2798
+ * @type {{ Tx:Object, Ty:Object, Tz:Object, Rp:Object, Ry:Object, Rr:Object }}
2799
+ */
2800
+ this.profile = _defaultProfile();
2801
+
2802
+ /**
2803
+ * Rest-drift floor. A fed rate with |value| ≤ deadzone reads as 0. Devices
2804
+ * that rest at exact 0 need only a small floor. @type {number}
2805
+ */
2806
+ this.deadzone = 8;
2807
+
2808
+ /**
2809
+ * Full-deflection input magnitude for the read-outs (gizmo overlay + panel
2810
+ * meters): a fed rate of |fullScale| reads as a full bar / arrow. Set by a
2811
+ * transport whose saturated rate differs from the default (a gamepad stick
2812
+ * that saturates at 1 sets fullScale = 1). Display-only — integration uses
2813
+ * profile.sens directly, so this never affects flight. @type {number}
2814
+ */
2815
+ this.fullScale = HELM_FULL_SCALE;
2816
+
2817
+ /**
2818
+ * Optional input conditioner applied to the fed rates BEFORE the deadzone,
2819
+ * inside step — a oneEuro filter, or any f(out, raw, dt) carrying function
2820
+ * with a reset(). null (the default) is the clean rate-native path: the
2821
+ * filter branch is skipped, so a clean device pays nothing. Set for noisy /
2822
+ * absolute sources; home() resets it. @type {?Function}
2823
+ */
2824
+ this.filter = null;
2825
+
2826
+ /**
2827
+ * The space fed rates are interpreted in: a space constant (WORLD | EYE) or
2828
+ * a mat4 frame. Declarative only — the bridge reads this to resolve the
2829
+ * per-step `basis`; the core stays camera-agnostic. @type {string|ArrayLike<number>}
2830
+ */
2831
+ this.from = 'EYE';
2832
+
2833
+ // Latest fed raw rates (refreshed by feed; persist until the next feed —
2834
+ // the transport is responsible for feeding 0 when motion should stop).
2835
+ this._lin = [0, 0, 0];
2836
+ this._ang = [0, 0, 0];
2837
+
2838
+ // Integrated pose — the source of truth.
2839
+ this._pos = [0, 0, 0];
2840
+ this._q = [0, 0, 0, 1];
2841
+ }
2842
+
2843
+ /**
2844
+ * Push the latest raw 6-DOF device rate. Either argument may be omitted to
2845
+ * leave that half unchanged (devices report the two halves on separate
2846
+ * frames). The value persists until the next feed. Raw device units — the
2847
+ * profile's `sens` does the scaling, so the same feed() suits any transport.
2848
+ *
2849
+ * @param {ArrayLike<number>} [translation] [tx, ty, tz] raw lane rates.
2850
+ * @param {ArrayLike<number>} [rotation] [rx, ry, rz] raw lane rates.
2851
+ * @returns {PoseHelm} this
2852
+ */
2853
+ feed(translation, rotation) {
2854
+ if (translation) {
2855
+ this._lin[0] = translation[0] || 0;
2856
+ this._lin[1] = translation[1] || 0;
2857
+ this._lin[2] = translation[2] || 0;
2858
+ }
2859
+ if (rotation) {
2860
+ this._ang[0] = rotation[0] || 0;
2861
+ this._ang[1] = rotation[1] || 0;
2862
+ this._ang[2] = rotation[2] || 0;
2863
+ }
2864
+ return this;
2865
+ }
2866
+
2867
+ /**
2868
+ * Integrate the latest fed rate by `dt` seconds into the pose, then write the
2869
+ * pose into `out`. Bridge-driven (the player calls it each frame; a sketch
2870
+ * never does, as it never calls track.tick()).
2871
+ *
2872
+ * `basis` is the resolved `from` frame: an eye→world column-major mat4
2873
+ * (16-element ArrayLike), or null/omitted for WORLD (the identity basis). Its
2874
+ * columns supply the right/up/back axes; forward is −col2. Both the linear and
2875
+ * angular rates are rotated through it, then `q` is composed world-frame.
2876
+ *
2877
+ * Zero-allocation. `out` is `{ pos:number[3], rot:number[4] }`; omit it for a
2878
+ * fresh object.
2879
+ *
2880
+ * @param {{ pos:number[], rot:number[] }} [out]
2881
+ * @param {number} dt Elapsed time in seconds.
2882
+ * @param {ArrayLike<number>} [basis] Eye→world mat4, or null for WORLD.
2883
+ * @returns {{ pos:number[], rot:number[] }} out
2884
+ */
2885
+ step(out, dt, basis) {
2886
+ const p = this.profile, dz = this.deadzone;
2887
+ let lin = this._lin, ang = this._ang;
2888
+
2889
+ // Value layer — condition the fed rates BEFORE the deadzone (the filter
2890
+ // narrows the jitter band; the deadzone then gates the residual to exact
2891
+ // zero). The filter carries state, so it is ticked exactly once per step;
2892
+ // activity() reads the raw fed rate and is left unfiltered (the read-out
2893
+ // shows device input, normalized by fullScale). Skipped when filter is null.
2894
+ const filter = this.filter;
2895
+ if (filter) {
2896
+ _f6raw[0] = lin[0]; _f6raw[1] = lin[1]; _f6raw[2] = lin[2];
2897
+ _f6raw[3] = ang[0]; _f6raw[4] = ang[1]; _f6raw[5] = ang[2];
2898
+ filter(_f6out, _f6raw, dt);
2899
+ _fLin[0] = _f6out[0]; _fLin[1] = _f6out[1]; _fLin[2] = _f6out[2];
2900
+ _fAng[0] = _f6out[3]; _fAng[1] = _f6out[4]; _fAng[2] = _f6out[5];
2901
+ lin = _fLin; ang = _fAng;
2902
+ }
2903
+
2904
+ // Basis axes (right / up / forward). No basis ⇒ the identity eye matrix:
2905
+ // right +X, up +Y, forward −Z. Forward is −col2 (an eye→world matrix stores
2906
+ // the camera's BACK in col2), and the identity's col2 is +Z, so forward is
2907
+ // −Z — making step(out, dt, null) identical to step(out, dt, IDENTITY_MAT4),
2908
+ // as the `from` contract ("null ≡ the identity basis") promises.
2909
+ let rX = 1, rY = 0, rZ = 0;
2910
+ let uX = 0, uY = 1, uZ = 0;
2911
+ let fX = 0, fY = 0, fZ = -1;
2912
+ if (basis) {
2913
+ rX = basis[0]; rY = basis[1]; rZ = basis[2];
2914
+ uX = basis[4]; uY = basis[5]; uZ = basis[6];
2915
+ fX = -basis[8]; fY = -basis[9]; fZ = -basis[10];
2916
+ }
2917
+
2918
+ // Angular: three rates → one delta quat, rotated into world, world-composed.
2919
+ const wx = _dz(ang[p.Rp.lane], dz) * p.Rp.sign * p.Rp.sens;
2920
+ const wy = _dz(ang[p.Ry.lane], dz) * p.Ry.sign * p.Ry.sens;
2921
+ const wz = _dz(ang[p.Rr.lane], dz) * p.Rr.sign * p.Rr.sens;
2922
+ const w = Math.sqrt(wx * wx + wy * wy + wz * wz);
2923
+ if (w > 0) {
2924
+ // Axis in world = basis · (wx,wy,wz); its length is w (basis orthonormal),
2925
+ // so the rotation angle is w·dt. qFromAxisAngle renormalizes the axis.
2926
+ const ax = wx * rX + wy * uX + wz * fX;
2927
+ const ay = wx * rY + wy * uY + wz * fY;
2928
+ const az = wx * rZ + wy * uZ + wz * fZ;
2929
+ qFromAxisAngle(_dq, ax, ay, az, w * dt);
2930
+ qMul(this._q, _dq, this._q); // delta already in world → world-frame compose
2931
+ qNormalize(this._q);
2932
+ }
2933
+
2934
+ // Linear: three rates rotated into world, integrated by dt.
2935
+ const tx = _dz(lin[p.Tx.lane], dz) * p.Tx.sign * p.Tx.sens;
2936
+ const ty = _dz(lin[p.Ty.lane], dz) * p.Ty.sign * p.Ty.sens;
2937
+ const tz = _dz(lin[p.Tz.lane], dz) * p.Tz.sign * p.Tz.sens;
2938
+ this._pos[0] += (tx * rX + ty * uX + tz * fX) * dt;
2939
+ this._pos[1] += (tx * rY + ty * uY + tz * fY) * dt;
2940
+ this._pos[2] += (tx * rZ + ty * uZ + tz * fZ) * dt;
2941
+
2942
+ return this.eval(out);
2943
+ }
2944
+
2945
+ /**
2946
+ * Read the current pose into `out` (zero-alloc). The shared output contract
2947
+ * that feeds applyPose — mirrors Track.eval.
2948
+ *
2949
+ * @param {{ pos:number[], rot:number[] }} [out]
2950
+ * @returns {{ pos:number[], rot:number[] }} out
2951
+ */
2952
+ eval(out) {
2953
+ out = out || { pos: [0, 0, 0], rot: [0, 0, 0, 1] };
2954
+ out.pos[0] = this._pos[0]; out.pos[1] = this._pos[1]; out.pos[2] = this._pos[2];
2955
+ out.rot[0] = this._q[0]; out.rot[1] = this._q[1];
2956
+ out.rot[2] = this._q[2]; out.rot[3] = this._q[3];
2957
+ return out;
2958
+ }
2959
+
2960
+ /**
2961
+ * Write the six effective channel rates (post deadzone·sign·sens) into `out6`,
2962
+ * in channel order [Tx, Ty, Tz, Rp, Ry, Rr]. The gizmo reads this to light the
2963
+ * DOFs being driven; readouts can show signed magnitudes. Zero-alloc.
2964
+ *
2965
+ * @param {number[]} out6 6-element destination.
2966
+ * @returns {number[]} out6
2967
+ */
2968
+ activity(out6) {
2969
+ const p = this.profile, dz = this.deadzone, lin = this._lin, ang = this._ang;
2970
+ out6[0] = _dz(lin[p.Tx.lane], dz) * p.Tx.sign * p.Tx.sens;
2971
+ out6[1] = _dz(lin[p.Ty.lane], dz) * p.Ty.sign * p.Ty.sens;
2972
+ out6[2] = _dz(lin[p.Tz.lane], dz) * p.Tz.sign * p.Tz.sens;
2973
+ out6[3] = _dz(ang[p.Rp.lane], dz) * p.Rp.sign * p.Rp.sens;
2974
+ out6[4] = _dz(ang[p.Ry.lane], dz) * p.Ry.sign * p.Ry.sens;
2975
+ out6[5] = _dz(ang[p.Rr.lane], dz) * p.Rr.sign * p.Rr.sens;
2976
+ return out6;
2977
+ }
2978
+
2979
+ /**
2980
+ * Re-home the integrated pose. Sets position + orientation to `pose` (or the
2981
+ * identity pose when omitted) and clears any pending rate so the helm rests at
2982
+ * a clean, known state. NOT a reset — there are no keyframes to clear.
2983
+ *
2984
+ * @param {{ pos?:ArrayLike<number>, rot?:ArrayLike<number> }} [pose]
2985
+ * @returns {PoseHelm} this
2986
+ */
2987
+ home(pose) {
2988
+ if (pose && pose.pos) {
2989
+ this._pos[0] = pose.pos[0]; this._pos[1] = pose.pos[1]; this._pos[2] = pose.pos[2];
2990
+ } else {
2991
+ this._pos[0] = 0; this._pos[1] = 0; this._pos[2] = 0;
2992
+ }
2993
+ if (pose && pose.rot) {
2994
+ this._q[0] = pose.rot[0]; this._q[1] = pose.rot[1];
2995
+ this._q[2] = pose.rot[2]; this._q[3] = pose.rot[3];
2996
+ } else {
2997
+ this._q[0] = 0; this._q[1] = 0; this._q[2] = 0; this._q[3] = 1;
2998
+ }
2999
+ this._lin[0] = this._lin[1] = this._lin[2] = 0;
3000
+ this._ang[0] = this._ang[1] = this._ang[2] = 0;
3001
+ if (this.filter) this.filter.reset(); // drop filter state at the discontinuity
3002
+ return this;
3003
+ }
3004
+ }
3005
+ const _d = [0, 0, 0]; // view-direction scratch
3006
+
3007
+ /** Gaze distance |center − eye|, or 1 when the state is degenerate. */
3008
+ function _gaze(cam) {
3009
+ const dx = cam.center[0]-cam.eye[0], dy = cam.center[1]-cam.eye[1], dz = cam.center[2]-cam.eye[2];
3010
+ const d = Math.sqrt(dx*dx+dy*dy+dz*dz);
3011
+ return d > 0 ? d : 1;
3012
+ }
3013
+
3014
+ // =========================================================================
3015
+ // Builders — state → matrices
3016
+ // =========================================================================
3017
+
3018
+ /**
3019
+ * View matrix (world→eye) from the state's lookat.
3020
+ * @param {Float32Array|number[]} out 16-element destination.
3021
+ * @param {object} cam
3022
+ * @returns {Float32Array|number[]} out
3023
+ */
3024
+ function cameraView(out, cam) {
3025
+ const e = cam.eye, c = cam.center, u = cam.up;
3026
+ return mat4View(out, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
3027
+ }
3028
+
3029
+ /**
3030
+ * Eye matrix (eye→world) from the state's lookat.
3031
+ * @param {Float32Array|number[]} out 16-element destination.
3032
+ * @param {object} cam
3033
+ * @returns {Float32Array|number[]} out
3034
+ */
3035
+ function cameraEye(out, cam) {
3036
+ const e = cam.eye, c = cam.center, u = cam.up;
3037
+ return mat4Eye(out, e[0],e[1],e[2], c[0],c[1],c[2], u[0],u[1],u[2]);
3038
+ }
3039
+
3040
+ /**
3041
+ * Projection matrix from the state's lens: mat4Persp from fov, or mat4Ortho
3042
+ * from halfHeight, with symmetric extents (top = near · tan(fov / 2) or
3043
+ * halfHeight; right = top · aspect).
3044
+ *
3045
+ * @param {Float32Array|number[]} out 16-element destination.
3046
+ * @param {object} cam
3047
+ * @param {number} aspect Viewport width / height.
3048
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
3049
+ * @param {number} [ndcYSign=1] +1 NDC y-up; −1 NDC y-down.
3050
+ * @returns {Float32Array|number[]|null} out, or null (out untouched) when
3051
+ * both fov and halfHeight are null.
3052
+ */
3053
+ function cameraProj(out, cam, aspect, ndcZMin, ndcYSign = 1) {
3054
+ const near = cam.near, far = cam.far;
3055
+ if (cam.fov != null) {
3056
+ const top = near * Math.tan(cam.fov / 2), right = top * aspect;
3057
+ return mat4Persp(out, -right, right, -top, top, near, far, ndcZMin, ndcYSign);
3058
+ }
3059
+ if (cam.halfHeight != null) {
3060
+ const top = cam.halfHeight, right = top * aspect;
3061
+ return mat4Ortho(out, -right, right, -top, top, near, far, ndcZMin, ndcYSign);
3062
+ }
3063
+ return null;
3064
+ }
3065
+
3066
+ /**
3067
+ * Drive the lookat from a TRS pose: eye ← pos, up and forward from the
3068
+ * rotation's columns 1 and −2, center ← eye + forward · d with d the current
3069
+ * gaze distance (1 when degenerate). The lens is untouched; `scl` is ignored.
3070
+ *
3071
+ * @param {object} cam State written in place.
3072
+ * @param {{ pos:number[], rot:number[] }} pose rot is a unit quaternion [x,y,z,w].
3073
+ * @returns {object} cam
3074
+ */
3075
+ function cameraFromPose(cam, pose) {
3076
+ const d = _gaze(cam);
3077
+ const q = pose.rot, x=q[0], y=q[1], z=q[2], w=q[3];
3078
+ const x2=x+x, y2=y+y, z2=z+z;
3079
+ 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;
3080
+ const ux=xy-wz, uy=1-(xx+zz), uz=yz+wx; // column 1 of qToMat4(rot): up
3081
+ const bx=xz+wy, by=yz-wx, bz=1-(xx+yy); // column 2: back
3082
+ cam.eye[0]=pose.pos[0]; cam.eye[1]=pose.pos[1]; cam.eye[2]=pose.pos[2];
3083
+ cam.up[0]=ux; cam.up[1]=uy; cam.up[2]=uz;
3084
+ cam.center[0]=cam.eye[0]-bx*d; cam.center[1]=cam.eye[1]-by*d; cam.center[2]=cam.eye[2]-bz*d;
3085
+ return cam;
3086
+ }
3087
+
3088
+ /**
3089
+ * The lookat as a TRS pose: pos ← eye, rot ← qFromLookDir(center − eye, up) —
3090
+ * the rotation of cameraEye, so a helm seeded from it continues the frame.
3091
+ *
3092
+ * @param {{ pos:number[], rot:number[] }} pose Written in place.
3093
+ * @param {object} cam
3094
+ * @returns {{ pos:number[], rot:number[] }} pose
3095
+ */
3096
+ function cameraToPose(pose, cam) {
3097
+ pose.pos[0]=cam.eye[0]; pose.pos[1]=cam.eye[1]; pose.pos[2]=cam.eye[2];
3098
+ _d[0]=cam.center[0]-cam.eye[0]; _d[1]=cam.center[1]-cam.eye[1]; _d[2]=cam.center[2]-cam.eye[2];
3099
+ qFromLookDir(pose.rot, _d, cam.up);
3100
+ return pose;
3101
+ }
3102
+
3103
+ /**
3104
+ * @file The view bag — the matrices every pick, solve, label and orbit reads.
3105
+ * @module host/view
3106
+ * @license AGPL-3.0-only
3107
+ *
3108
+ * The matrices bag mapLocation takes, plus the signed viewport and the NDC
3109
+ * convention:
3110
+ *
3111
+ * view = {
3112
+ * mat4Proj, mat4View, mat4PV, mat4PVInv, // Float32Array(16) each, host-owned
3113
+ * mat4Eye, // inverse of mat4View — EYE → WORLD
3114
+ * vp, // [0, h, w, −h] — y-down, logical canvas px
3115
+ * ndcZMin, // WEBGL | WEBGPU, set once at creation
3116
+ * stale, // true until a set() with an invertible P · V
3117
+ * set(P, V) // copy P and V, recompute PV and PVInv
3118
+ * setCamera(cam) // build V and P from a camera state, then set
3119
+ * resize(width, height) // the canvas observer's write into vp
3120
+ * }
3121
+ *
3122
+ * mat4PVInv and mat4Eye are recomputed on set, once per frame, so unproject
3123
+ * and every mapping from SCREEN or EYE share them. A degenerate P · V or V
3124
+ * leaves the previous inverses and marks the bag stale; consumers treat a
3125
+ * stale bag as "no pick this frame".
3126
+ */
3127
+
3128
+
3129
+ const _inv = new Float32Array(16); // inverse scratch: the bag keeps its previous inverse on failure
3130
+ const _P = new Float32Array(16); // setCamera's projection
3131
+ const _V = new Float32Array(16); // setCamera's view
3132
+
3133
+ function _identity(m) {
3134
+ m.fill(0);
3135
+ m[0] = m[5] = m[10] = m[15] = 1;
3136
+ return m;
3137
+ }
3138
+
3139
+ /**
3140
+ * Create a view bag. The matrices start at identity and the bag stale, so a
3141
+ * consumer reading it before the first set() picks nothing.
3142
+ *
3143
+ * @param {{ ndcZMin?:number, width?:number, height?:number }} [opts]
3144
+ * ndcZMin: WEBGL (−1, default) or WEBGPU (0) — written once here, never
3145
+ * detected. width / height: the initial logical canvas size.
3146
+ * @returns {object} The view bag.
3147
+ */
3148
+ function createView(opts) {
3149
+ const o = opts || {};
3150
+ const w = o.width || 0, h = o.height || 0;
3151
+ const view = {
3152
+ mat4Proj: _identity(new Float32Array(16)),
3153
+ mat4View: _identity(new Float32Array(16)),
3154
+ mat4PV: _identity(new Float32Array(16)),
3155
+ mat4PVInv: _identity(new Float32Array(16)),
3156
+ mat4Eye: _identity(new Float32Array(16)),
3157
+ vp: [0, h, w, -h],
3158
+ ndcZMin: o.ndcZMin === 0 ? 0 : WEBGL,
3159
+ stale: true,
3160
+
3161
+ /**
3162
+ * Install a projection and a view: both copied, P · V with its inverse
3163
+ * and the eye matrix (V's inverse) recomputed. A singular product or
3164
+ * view keeps the previous inverses and marks the bag stale.
3165
+ * @param {ArrayLike<number>} P Projection mat4.
3166
+ * @param {ArrayLike<number>} V View mat4 (world → eye).
3167
+ * @returns {object} this
3168
+ */
3169
+ set(P, V) {
3170
+ for (let i = 0; i < 16; i++) { view.mat4Proj[i] = P[i]; view.mat4View[i] = V[i]; }
3171
+ mat4Mul(view.mat4PV, view.mat4Proj, view.mat4View);
3172
+ if (mat4Invert(_inv, view.mat4PV) === null) { view.stale = true; return view; }
3173
+ view.mat4PVInv.set(_inv);
3174
+ if (mat4Invert(_inv, view.mat4View) === null) { view.stale = true; return view; }
3175
+ view.mat4Eye.set(_inv);
3176
+ view.stale = false;
3177
+ return view;
3178
+ },
3179
+
3180
+ /**
3181
+ * Install a camera state: cameraView and cameraProj at the viewport's
3182
+ * aspect under the bag's ndcZMin, then set(). A state with both fov and
3183
+ * halfHeight null keeps the projection already installed.
3184
+ * @param {object} cam Camera state.
3185
+ * @returns {object} this
3186
+ */
3187
+ setCamera(cam) {
3188
+ const aspect = view.vp[3] !== 0 ? view.vp[2] / -view.vp[3] : 1;
3189
+ cameraView(_V, cam);
3190
+ const P = cameraProj(_P, cam, aspect, view.ndcZMin) || view.mat4Proj;
3191
+ return view.set(P, _V);
3192
+ },
3193
+
3194
+ /**
3195
+ * Write a new logical canvas size into vp: [0, height, width, −height].
3196
+ * @param {number} width
3197
+ * @param {number} height
3198
+ * @returns {object} this
3199
+ */
3200
+ resize(width, height) {
3201
+ view.vp[1] = height; view.vp[2] = width; view.vp[3] = -height;
3202
+ return view;
3203
+ },
3204
+ };
3205
+ return view;
3206
+ }
3207
+
3208
+ /**
3209
+ * @file The canvas observer — logical size and device pixel ratio.
3210
+ * @module host/canvas
3211
+ * @license AGPL-3.0-only
3212
+ *
3213
+ * A ResizeObserver on the canvas element plus devicePixelRatio tracking:
3214
+ * on change the observer reports the logical size in CSS px and the dpr to
3215
+ * its callback, which the context uses to update host.width / host.height /
3216
+ * host.dpr, the view bag's vp, the label layer and the bridge's onSize. The
3217
+ * observer never touches canvas.width / canvas.height — that is the
3218
+ * renderer's decision.
3219
+ *
3220
+ * Without a ResizeObserver (a headless run) the size is measured once from
3221
+ * the element's bounding rectangle, falling back to its width / height
3222
+ * attributes.
3223
+ */
3224
+
3225
+
3226
+ /**
3227
+ * Measure a canvas: logical CSS size from its bounding rectangle when laid
3228
+ * out, else its width / height attributes; dpr from the window.
3229
+ * @param {HTMLCanvasElement} canvas
3230
+ * @param {number[]} out3 [width, height, dpr] destination.
3231
+ * @returns {number[]} out3
3232
+ */
3233
+ function measureCanvas(canvas, out3) {
3234
+ const r = typeof canvas.getBoundingClientRect === 'function' ? canvas.getBoundingClientRect() : null;
3235
+ const laidOut = r && r.width > 0 && r.height > 0;
3236
+ out3[0] = laidOut ? r.width : (canvas.width || 0);
3237
+ out3[1] = laidOut ? r.height : (canvas.height || 0);
3238
+ out3[2] = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
3239
+ return out3;
3240
+ }
3241
+
3242
+ /**
3243
+ * Observe a canvas's logical size and dpr. `onChange(width, height, dpr)`
3244
+ * fires once immediately with the current measure and again on every
3245
+ * change; a change is reported only when a value differs.
3246
+ *
3247
+ * @param {HTMLCanvasElement} canvas
3248
+ * @param {function(number, number, number):void} onChange
3249
+ * @returns {{ width:number, height:number, dpr:number, measure():void, dispose():void }}
3250
+ */
3251
+ function observeCanvas(canvas, onChange) {
3252
+ const m = [0, 0, 1];
3253
+ const obs = { width: -1, height: 0, dpr: 1, measure, dispose };
3254
+ let ro = null;
3255
+ const onWindow = () => measure();
3256
+
3257
+ function measure() {
3258
+ measureCanvas(canvas, m);
3259
+ if (m[0] === obs.width && m[1] === obs.height && m[2] === obs.dpr) return;
3260
+ obs.width = m[0]; obs.height = m[1]; obs.dpr = m[2];
3261
+ onChange(obs.width, obs.height, obs.dpr);
3262
+ }
3263
+
3264
+ function dispose() {
3265
+ if (ro) { ro.disconnect(); ro = null; }
3266
+ if (typeof window !== 'undefined' && window.removeEventListener) window.removeEventListener('resize', onWindow);
3267
+ }
3268
+
3269
+ measure(); // width −1 makes the first measure report
3270
+ if (typeof ResizeObserver !== 'undefined') {
3271
+ ro = new ResizeObserver(() => measure());
3272
+ ro.observe(canvas);
3273
+ }
3274
+ // A zoom changes devicePixelRatio and fires resize; the observer alone would miss it.
3275
+ if (typeof window !== 'undefined' && window.addEventListener) window.addEventListener('resize', onWindow);
3276
+ return obs;
3277
+ }
3278
+
3279
+ /**
3280
+ * @file The pointer source — a typed event buffer with claims, one per canvas.
3281
+ * @module host/pointer
3282
+ * @license AGPL-3.0-only
3283
+ *
3284
+ * Shared by every handle, router and orbit on a canvas. Listeners only
3285
+ * record; nothing solves in a listener:
3286
+ *
3287
+ * - pointerdown · pointermove · pointerup · pointercancel on the canvas,
3288
+ * keydown (Esc) on the window.
3289
+ * - Coordinates in logical canvas px through the element's bounding
3290
+ * rectangle — (clientX − rect.left) · (width / rect.width), the logical
3291
+ * size read off the view bag's vp — the numbers mapLocation(SCREEN) and vp
3292
+ * expect, so a CSS-scaled canvas maps correctly.
3293
+ * - Per pointer: an entry { id, x, y, seq, down, up, cancel, owner }, kept
3294
+ * from its press until the frame after its release; `seq` counts moves,
3295
+ * so a consumer remembers the last seq it consumed and reads only new
3296
+ * motion. Multitouch is per-pointer by construction; the mouse is one
3297
+ * more pointer.
3298
+ * - Presses queue in order for the frame (`presses`); consumers read the
3299
+ * queue inside the frame and claim what they take. Several same-frame
3300
+ * presses on different members all land.
3301
+ * - Claims: claim(id, owner) captures the pointer on the canvas so a drag
3302
+ * that leaves it keeps flowing; release(id), up and cancel release the
3303
+ * capture. An unclaimed pointer is what the orbit sees.
3304
+ * - Esc sets the cancel flag on every claimed pointer.
3305
+ * - flush() ends the frame: the press queue empties and released pointers
3306
+ * leave the map. The loop calls it after onFrame; an external loop calls
3307
+ * it after its consumers ran (the p5 adapter from postdraw).
3308
+ */
3309
+
3310
+
3311
+ /**
3312
+ * Create the pointer source of a canvas.
3313
+ *
3314
+ * @param {HTMLCanvasElement} canvas
3315
+ * @param {object} view The view bag; vp[2] and −vp[3] are the logical size.
3316
+ * @returns {object} The source: { presses, pointers, get, claim, release,
3317
+ * ownerOf, flush, dispose }.
3318
+ */
3319
+ function createPointer(canvas, view) {
3320
+ const pointers = new Map(); // id → entry
3321
+ const presses = []; // this frame's presses, in order: { id, x, y }
3322
+ const xy = [0, 0];
3323
+
3324
+ const toCanvas = (e, out) => {
3325
+ const r = canvas.getBoundingClientRect ? canvas.getBoundingClientRect() : null;
3326
+ const w = view.vp[2], h = -view.vp[3];
3327
+ if (r && r.width > 0 && r.height > 0 && w > 0 && h > 0) {
3328
+ out[0] = (e.clientX - r.left) * (w / r.width);
3329
+ out[1] = (e.clientY - r.top) * (h / r.height);
3330
+ } else {
3331
+ out[0] = e.clientX; out[1] = e.clientY;
3332
+ }
3333
+ return out;
3334
+ };
3335
+
3336
+ const entry = (id) => {
3337
+ let p = pointers.get(id);
3338
+ if (!p) { p = { id, x: 0, y: 0, seq: 0, down: false, up: false, cancel: false, owner: null }; pointers.set(id, p); }
3339
+ return p;
3340
+ };
3341
+
3342
+ const capture = (id, on) => {
3343
+ const f = on ? canvas.setPointerCapture : canvas.releasePointerCapture;
3344
+ if (typeof f !== 'function') return;
3345
+ try { f.call(canvas, id); } catch (_) { /* best effort: a synthetic or already-released id */ }
3346
+ };
3347
+
3348
+ const onDown = (e) => {
3349
+ const p = entry(e.pointerId);
3350
+ toCanvas(e, xy);
3351
+ p.x = xy[0]; p.y = xy[1]; p.seq++;
3352
+ p.down = true; p.up = false; p.cancel = false;
3353
+ presses.push({ id: p.id, x: p.x, y: p.y });
3354
+ };
3355
+ const onMove = (e) => {
3356
+ const p = entry(e.pointerId);
3357
+ toCanvas(e, xy);
3358
+ p.x = xy[0]; p.y = xy[1]; p.seq++;
3359
+ };
3360
+ const onUp = (e) => {
3361
+ const p = pointers.get(e.pointerId);
3362
+ if (!p) return;
3363
+ toCanvas(e, xy);
3364
+ p.x = xy[0]; p.y = xy[1]; p.seq++;
3365
+ p.up = true;
3366
+ capture(p.id, false);
3367
+ };
3368
+ const onCancel = (e) => {
3369
+ const p = pointers.get(e.pointerId);
3370
+ if (!p) return;
3371
+ p.cancel = true;
3372
+ capture(p.id, false);
3373
+ };
3374
+ const onKey = (e) => {
3375
+ if (e.key !== 'Escape') return;
3376
+ for (const p of pointers.values()) if (p.owner !== null) p.cancel = true;
3377
+ };
3378
+
3379
+ canvas.addEventListener('pointerdown', onDown);
3380
+ canvas.addEventListener('pointermove', onMove);
3381
+ canvas.addEventListener('pointerup', onUp);
3382
+ canvas.addEventListener('pointercancel', onCancel);
3383
+ if (typeof window !== 'undefined' && window.addEventListener) window.addEventListener('keydown', onKey);
3384
+
3385
+ const source = {
3386
+ /** This frame's presses in order, each { id, x, y }; emptied by flush(). */
3387
+ presses,
3388
+ /** The live pointer entries by id. */
3389
+ pointers,
3390
+
3391
+ /**
3392
+ * The entry of a pointer, or null when it is not on the canvas.
3393
+ * @param {number} id
3394
+ * @returns {object|null}
3395
+ */
3396
+ get(id) { return pointers.get(id) || null; },
3397
+
3398
+ /**
3399
+ * Claim a pointer for an owner and capture it on the canvas. Fails when
3400
+ * another owner holds it or the pointer is unknown.
3401
+ * @param {number} id
3402
+ * @param {object} owner
3403
+ * @returns {boolean} claimed
3404
+ */
3405
+ claim(id, owner) {
3406
+ const p = pointers.get(id);
3407
+ if (!p || (p.owner !== null && p.owner !== owner)) return false;
3408
+ p.owner = owner;
3409
+ capture(id, true);
3410
+ return true;
3411
+ },
3412
+
3413
+ /**
3414
+ * Release a claim and the capture with it. No-op for an unclaimed id.
3415
+ * @param {number} id
3416
+ */
3417
+ release(id) {
3418
+ const p = pointers.get(id);
3419
+ if (!p || p.owner === null) return;
3420
+ p.owner = null;
3421
+ capture(id, false);
3422
+ },
3423
+
3424
+ /**
3425
+ * The owner holding a pointer, or null.
3426
+ * @param {number} id
3427
+ * @returns {object|null}
3428
+ */
3429
+ ownerOf(id) { const p = pointers.get(id); return p ? p.owner : null; },
3430
+
3431
+ /**
3432
+ * End the frame: the press queue empties; pointers that released or
3433
+ * cancelled leave the map (their claims dropped), the rest keep their
3434
+ * position and seq.
3435
+ */
3436
+ flush() {
3437
+ presses.length = 0;
3438
+ for (const [id, p] of pointers) {
3439
+ if (p.up || p.cancel) { p.owner = null; pointers.delete(id); }
3440
+ }
3441
+ },
3442
+
3443
+ /** Remove every listener and release every capture. */
3444
+ dispose() {
3445
+ canvas.removeEventListener('pointerdown', onDown);
3446
+ canvas.removeEventListener('pointermove', onMove);
3447
+ canvas.removeEventListener('pointerup', onUp);
3448
+ canvas.removeEventListener('pointercancel', onCancel);
3449
+ if (typeof window !== 'undefined' && window.removeEventListener) window.removeEventListener('keydown', onKey);
3450
+ for (const p of pointers.values()) if (p.owner !== null) capture(p.id, false);
3451
+ pointers.clear();
3452
+ presses.length = 0;
3453
+ },
3454
+ };
3455
+ return source;
3456
+ }
3457
+
3458
+ /**
3459
+ * @file The players registry and the frame loop.
3460
+ * @module host/loop
3461
+ * @license AGPL-3.0-only
3462
+ *
3463
+ * A player is { tick(dt) → boolean }, removed from the registry when it
3464
+ * returns false. Iteration snapshots the set, so a player may remove itself
3465
+ * or add another during a tick. Tracks register through their activation
3466
+ * hooks, helms until disposed.
3467
+ *
3468
+ * The loop is optional: with it, requestAnimationFrame (overridable — the
3469
+ * seam a WebXR session fills) drives onFrame(dt) with dt in seconds clamped
3470
+ * to 50 ms, the stalled-tab guard; without it the application ticks the
3471
+ * players from its own loop.
3472
+ */
3473
+
3474
+
3475
+ /**
3476
+ * Create a players registry.
3477
+ * @returns {{ add(player):object, remove(player):object, has(player):boolean,
3478
+ * tick(dt:number):void, size:number, clear():void }}
3479
+ */
3480
+ function createPlayers() {
3481
+ const set = new Set();
3482
+ const players = {
3483
+ /** Register a player; adding one already present is a no-op. */
3484
+ add(player) { if (player) set.add(player); return players; },
3485
+ /** Unregister a player. */
3486
+ remove(player) { set.delete(player); return players; },
3487
+ /** Whether a player is registered. */
3488
+ has(player) { return set.has(player); },
3489
+ /** Tick every player with dt (seconds); one returning false is removed. */
3490
+ tick(dt) {
3491
+ for (const p of [...set]) {
3492
+ if (!set.has(p)) continue; // removed by an earlier player this tick
3493
+ if (p.tick(dt) === false) set.delete(p);
3494
+ }
3495
+ },
3496
+ /** Registered count. */
3497
+ get size() { return set.size; },
3498
+ /** Unregister every player. */
3499
+ clear() { set.clear(); },
3500
+ };
3501
+ return players;
3502
+ }
3503
+
3504
+ const _now$1 = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
3505
+
3506
+ /**
3507
+ * Create a frame loop. `onFrame(dt)` runs once per animation frame with the
3508
+ * clamped period in seconds; start() schedules, stop() cancels.
3509
+ *
3510
+ * @param {{ onFrame:function(number):void, raf?:function, caf?:function,
3511
+ * clampMs?:number, now?:function():number }} opts
3512
+ * raf / caf default to the window's requestAnimationFrame /
3513
+ * cancelAnimationFrame; clampMs defaults to 50.
3514
+ * @returns {{ start():object, stop():object, running:boolean, dt:number }}
3515
+ */
3516
+ function createLoop(opts) {
3517
+ const o = opts || {};
3518
+ const raf = o.raf || ((cb) => requestAnimationFrame(cb));
3519
+ const caf = o.caf || ((id) => cancelAnimationFrame(id));
3520
+ const now = o.now || _now$1;
3521
+ const clamp = (o.clampMs ?? 50) / 1000;
3522
+ let handle = null, last = 0;
3523
+ const loop = {
3524
+ running: false,
3525
+ dt: 0,
3526
+ start() {
3527
+ if (loop.running) return loop;
3528
+ loop.running = true;
3529
+ last = now();
3530
+ const frame = () => {
3531
+ if (!loop.running) return;
3532
+ const t = now();
3533
+ loop.dt = Math.min((t - last) / 1000, clamp);
3534
+ last = t;
3535
+ o.onFrame(loop.dt);
3536
+ if (loop.running) handle = raf(frame);
3537
+ };
3538
+ handle = raf(frame);
3539
+ return loop;
3540
+ },
3541
+ stop() {
3542
+ loop.running = false;
3543
+ if (handle !== null) { caf(handle); handle = null; }
3544
+ return loop;
3545
+ },
3546
+ };
3547
+ return loop;
3548
+ }
3549
+
3550
+ /**
3551
+ * @file The handle controller — a draggable constraint driven from the pointer source.
3552
+ * @module host/handle
3553
+ * @license AGPL-3.0-only
3554
+ *
3555
+ * Wraps a core Constraint (@nakednous/tree) with the transport a draggable
3556
+ * 3D control needs: presses, moves and releases from the host's pointer
3557
+ * source, a pixel → ray unprojection through the host's view bag, an
3558
+ * analytic pick against the constraint's proxy, and a host-driven update()
3559
+ * lifecycle. No drawing: a bridge draws the locus (locusLines) and the dot.
3560
+ *
3561
+ * ── update() ordering contract ─────────────────────────────────────────────
3562
+ * update() is host-driven, before the orbit, because the orbit gate depends
3563
+ * on the grab resolving first:
3564
+ *
3565
+ * onFrame() { setCamera(cam); if (!h.update()) orbit.update(); scene() }
3566
+ *
3567
+ * ── Pick ───────────────────────────────────────────────────────────────────
3568
+ * The pointer's ray is unproject(x, y, view) in WORLD; the grab size is
3569
+ * grabPx converted to world units through pixelRatio at the proxy's depth;
3570
+ * the constraint's proxy(ray, radius) answers t or Infinity. Built-in kinds
3571
+ * carry their proxy (a sphere at the point, the DIAL's ring); a custom kind
3572
+ * supplies one or gets the sphere. solve() runs in WORLD; value() converts.
3573
+ *
3574
+ * ── Pointer source ─────────────────────────────────────────────────────────
3575
+ * update() reads the frame's press queue and the tracked pointer's entry
3576
+ * (seq counts moves; up / cancel end the gesture; Esc cancels every claimed
3577
+ * pointer). A grab claims the pointer, which captures it on the canvas; a
3578
+ * miss leaves it unclaimed for the orbit. The whole gesture keys to one
3579
+ * pointerId, so on a shared surface each handle tracks its own finger.
3580
+ *
3581
+ * ── Constraint kinds ───────────────────────────────────────────────────────
3582
+ * Core SPHERE / PLANE / AXIS / DIAL pass straight through. VIEW is a core
3583
+ * PLANE re-aimed at the camera each solve — a screen-parallel drag plane
3584
+ * through the current point — reported as a world position; its constraint
3585
+ * carries `view: true`, the flag locusLines reads. A custom kind is a
3586
+ * contract-conforming object (kind / solve / value / seed, optional scalar /
3587
+ * azEl / aim / proxy / locus) passed as `constraint`.
3588
+ *
3589
+ * ── Snap / hover / cancel / from ───────────────────────────────────────────
3590
+ * snap quantises at the solve seam: an angular step for SPHERE (az/el) and
3591
+ * DIAL (θ), a world grid for PLANE / AXIS / VIEW (PLANE re-projecting).
3592
+ * hover is a lone-handle opt-in (a router provides it shared). Cancel — Esc,
3593
+ * pointercancel, cancel() — reverts to the value captured at grab, restores
3594
+ * the binding and fires onCancel instead of onRelease. `from` names the space
3595
+ * the symbolic basis (axis / normal / zero) resolves FROM into WORLD each idle
3596
+ * frame through mapDirection on the view bag — WORLD, EYE, or a mat4 —
3597
+ * frozen at grab.
3598
+ */
3599
+
3600
+
3601
+ /** The screen-parallel free-translate constraint (a core PLANE re-aimed each solve). */
3602
+ const VIEW = 4;
3603
+
3604
+ // Module-level scratch — update() and value() run to completion within one
3605
+ // frame with no reentrancy across handles.
3606
+ const _rayO = [0, 0, 0]; // pick ray origin, WORLD
3607
+ const _rayD = [0, 0, 0]; // pick ray unit direction, WORLD
3608
+ /** The ray scratch a handle's _ray(x, y) fills — the router's shared pick reads it. */
3609
+ const rayScratch = { o: _rayO, d: _rayD };
3610
+ const _v3 = [0, 0, 0]; // value() extraction scratch
3611
+ const _q2 = [0, 0]; // az/el snap scratch
3612
+ const _b0 = [0, 0, 0]; // resolved θ = 0 reference
3613
+ const _b2 = [0, 0, 0]; // resolved axis / normal
3614
+ const _frameInv = new Float32Array(16);
3615
+
3616
+ // The mapping bag mapLocation / mapDirection take: the view's matrices plus
3617
+ // the frames a call names (a mat4 `from` or `to` is the MATRIX space).
3618
+ const _bag = { mat4Proj: null, mat4View: null, mat4PV: null, mat4PVInv: null, mat4Eye: null,
3619
+ fromFrame: null, toFrame: null, toFrameInv: null };
3620
+ const _isMat = (s) => s != null && typeof s !== 'string';
3621
+ const _space = (s) => (_isMat(s) ? MATRIX : s);
3622
+
3623
+ function _bagOf(view, from, to) {
3624
+ _bag.mat4Proj = view.mat4Proj; _bag.mat4View = view.mat4View;
3625
+ _bag.mat4PV = view.mat4PV; _bag.mat4PVInv = view.mat4PVInv; _bag.mat4Eye = view.mat4Eye;
3626
+ _bag.fromFrame = _isMat(from) ? from : null;
3627
+ if (_isMat(to)) { _bag.toFrame = to; _bag.toFrameInv = mat4Invert(_frameInv, to) ? _frameInv : null; }
3628
+ else { _bag.toFrame = null; _bag.toFrameInv = null; }
3629
+ return _bag;
3630
+ }
3631
+
3632
+ const _norm3 = (o) => {
3633
+ const l = Math.hypot(o[0], o[1], o[2]) || 1;
3634
+ o[0] /= l; o[1] /= l; o[2] /= l;
3635
+ return o;
3636
+ };
3637
+
3638
+ // Orthonormal in-plane basis for a unit normal n, seeded from the world axis
3639
+ // least aligned with it (a DIAL's θ = 0 reference when `from` gives none).
3640
+ const _basisFromNormal = (n, ub, vb) => {
3641
+ const ax = Math.abs(n[0]), ay = Math.abs(n[1]), az = Math.abs(n[2]);
3642
+ let rx = 0, ry = 0, rz = 0;
3643
+ if (ax <= ay && ax <= az) rx = 1; else if (ay <= az) ry = 1; else rz = 1;
3644
+ ub[0] = ry*n[2] - rz*n[1]; ub[1] = rz*n[0] - rx*n[2]; ub[2] = rx*n[1] - ry*n[0];
3645
+ _norm3(ub);
3646
+ 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];
3647
+ };
3648
+
3649
+ // Read component i of an array or {x, y, z}, falling back to d.
3650
+ const _vx$1 = (v, i, d) => {
3651
+ if (v == null) return d;
3652
+ const c = i === 0 ? v.x : i === 1 ? v.y : v.z;
3653
+ return c ?? v[i] ?? d;
3654
+ };
3655
+
3656
+ /** Contract check for a custom constraint object. */
3657
+ const isConstraint = (c) =>
3658
+ c && typeof c === 'object' &&
3659
+ typeof c.solve === 'function' &&
3660
+ typeof c.value === 'function' &&
3661
+ typeof c.seed === 'function';
3662
+
3663
+ /**
3664
+ * The handle controller. Stateful and long-lived, like a track; driven from
3665
+ * the frame through update(), read with value(out, opts).
3666
+ */
3667
+ class Handle {
3668
+ /**
3669
+ * @param {object} host The host context (its pointer source and view bag).
3670
+ * @param {object} opts See host.handle().
3671
+ */
3672
+ constructor(host, opts) {
3673
+ this._host = host;
3674
+ this._pointer = host.pointer;
3675
+ this._view = host.view;
3676
+
3677
+ const kind = opts.constraint;
3678
+ this._isView = (kind === VIEW);
3679
+ if (isConstraint(kind)) {
3680
+ this._constraint = kind;
3681
+ } else {
3682
+ const coreKind = this._isView ? PLANE : kind;
3683
+ // Vector opts pass straight through — the core duck-types arrays and {x, y, z}.
3684
+ this._constraint = createConstraint(coreKind, {
3685
+ radius: opts.radius, report: opts.report, anchor: opts.anchor,
3686
+ normal: opts.normal, axis: opts.axis, zero: opts.zero, extent: opts.extent,
3687
+ });
3688
+ if (this._isView) this._constraint.view = true; // the flag locusLines reads
3689
+ }
3690
+
3691
+ // Deferred constraint frame: the symbolic basis and the space it resolves from.
3692
+ this._from = null; this._fromDir = null; this._fromZero = null;
3693
+ if (opts.from != null && opts.from !== WORLD) {
3694
+ const custom = isConstraint(kind);
3695
+ const dirOpt = (kind === PLANE) ? opts.normal : (opts.axis ?? opts.normal);
3696
+ const ok = !this._isView &&
3697
+ (kind === PLANE || kind === AXIS || kind === DIAL ||
3698
+ (custom && typeof this._constraint.aim === 'function'));
3699
+ if (!ok) {
3700
+ console.error('[host] handle: `from` needs an aimable constraint — PLANE, AXIS, DIAL, or a custom kind exposing aim(); ignoring.');
3701
+ } else if (custom && dirOpt == null) {
3702
+ console.error('[host] handle: `from` on a custom kind needs a symbolic `axis` (or `normal`) to resolve; ignoring.');
3703
+ } else {
3704
+ this._from = opts.from;
3705
+ this._fromDir = [_vx$1(dirOpt, 0, kind === AXIS ? 1 : 0), _vx$1(dirOpt, 1, kind === AXIS ? 0 : 1), _vx$1(dirOpt, 2, 0)];
3706
+ if (opts.zero != null) {
3707
+ this._fromZero = [_vx$1(opts.zero, 0, 1), _vx$1(opts.zero, 1, 0), _vx$1(opts.zero, 2, 0)];
3708
+ } else if (kind === DIAL) {
3709
+ // Derive the θ = 0 reference once, in the FROM space, so axis and zero co-rotate.
3710
+ _b2[0] = this._fromDir[0]; _b2[1] = this._fromDir[1]; _b2[2] = this._fromDir[2];
3711
+ _norm3(_b2);
3712
+ const r0 = [0, 0, 0], r1 = [0, 0, 0];
3713
+ _basisFromNormal(_b2, r0, r1);
3714
+ this._fromZero = r0;
3715
+ }
3716
+ }
3717
+ }
3718
+
3719
+ this._enabled = opts.enabled !== false;
3720
+ this._grabPx = Number.isFinite(opts.grabPx) ? opts.grabPx : 12;
3721
+ this._snap = opts.snap ?? null;
3722
+ this._hover = opts.hover === true;
3723
+ this._hovered = false;
3724
+ this._hid = null; this._hseq = -1; // the pointer last hover-tested, and its seq then
3725
+
3726
+ // Transport state: one pointerId per gesture, its last consumed move seq
3727
+ // and its position in logical canvas px; _routed hands the down step to a router.
3728
+ this._grabbed = false;
3729
+ this._pid = null;
3730
+ this._seq = -1;
3731
+ this._ptr = [0, 0];
3732
+ this._routed = false;
3733
+
3734
+ // The proxy: its world position and the grab radius in world units.
3735
+ this._proxyPos = [0, 0, 0];
3736
+ this._proxyRad = 0;
3737
+
3738
+ // Cancel state — the value (and scalar, for winding) captured at grab.
3739
+ this._saved = [0, 0, 0];
3740
+ this._savedS = 0;
3741
+
3742
+ // Hooks — user-facing, then the lib-space seams a router or a track owns.
3743
+ this.onGrab = typeof opts.onGrab === 'function' ? opts.onGrab : null;
3744
+ this.onRelease = typeof opts.onRelease === 'function' ? opts.onRelease : null;
3745
+ this.onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
3746
+ this.onCancel = typeof opts.onCancel === 'function' ? opts.onCancel : null;
3747
+ this._onGrab = null; this._onRelease = null; this._onChange = null; this._onCancel = null;
3748
+
3749
+ // Binding — a normalised { get, set } accessor; _bindVal the reused value array.
3750
+ this._binder = null;
3751
+ this._bindVal = [0, 0, 0];
3752
+
3753
+ if (opts.bind != null) this.bind(opts.bind);
3754
+ }
3755
+
3756
+ // ── Lifecycle ───────────────────────────────────────────────────────────
3757
+
3758
+ /**
3759
+ * Resolve the grab and re-solve from the pointer. Call first in the frame,
3760
+ * before the orbit; a routed handle is updated by its router. Returns the
3761
+ * grabbed state so the orbit gate can short-circuit; a disabled handle
3762
+ * returns false.
3763
+ * @returns {boolean} grabbed
3764
+ */
3765
+ update() {
3766
+ if (!this._enabled) {
3767
+ if (this._pid !== null && this._pointer) this._pointer.release(this._pid);
3768
+ this._grabbed = false; this._hovered = false; this._pid = null;
3769
+ return false;
3770
+ }
3771
+ const src = this._pointer;
3772
+ if (!src) return false;
3773
+ if (this._from && !this._grabbed) this._resolveFrame();
3774
+
3775
+ // Fresh press (unrouted): the first unclaimed press this frame is
3776
+ // hit-tested at its pixel; a hit claims the pointer, a miss leaves it.
3777
+ if (!this._routed && this._pid === null) {
3778
+ for (const pr of src.presses) {
3779
+ if (src.ownerOf(pr.id) !== null) continue;
3780
+ this._ptr[0] = pr.x; this._ptr[1] = pr.y;
3781
+ if (this._pickAt(pr.x, pr.y)) { this._adopt(pr.id, pr.x, pr.y); break; }
3782
+ }
3783
+ }
3784
+
3785
+ if (this._pid !== null) {
3786
+ const e = src.get(this._pid);
3787
+ if (!e) {
3788
+ this._cancelNow(); // the entry left before this handle saw its end
3789
+ } else {
3790
+ if (this._grabbed && e.seq !== this._seq && !e.cancel) {
3791
+ this._seq = e.seq;
3792
+ this._ptr[0] = e.x; this._ptr[1] = e.y;
3793
+ this._solveFromPointer(e.x, e.y);
3794
+ this._applySnap();
3795
+ this._afterSolve();
3796
+ }
3797
+ if (e.cancel) {
3798
+ this._cancelNow();
3799
+ } else if (e.up) {
3800
+ src.release(this._pid);
3801
+ this._grabbed = false;
3802
+ this._pid = null;
3803
+ this.onRelease && this.onRelease(this);
3804
+ this._onRelease && this._onRelease(this);
3805
+ }
3806
+ }
3807
+ }
3808
+
3809
+ // Hover: a lone handle tests the latest unclaimed pointer motion.
3810
+ if (this._grabbed) {
3811
+ this._hovered = true;
3812
+ } else if (this._hover && !this._routed) {
3813
+ for (const e of src.pointers.values()) {
3814
+ if (e.owner !== null) continue;
3815
+ if (e.id === this._hid && e.seq === this._hseq) continue;
3816
+ this._hid = e.id; this._hseq = e.seq;
3817
+ this._hovered = this._pickAt(e.x, e.y);
3818
+ break;
3819
+ }
3820
+ }
3821
+ return this._grabbed;
3822
+ }
3823
+
3824
+ _beginGrab() {
3825
+ const c = this._constraint;
3826
+ c.value(this._saved, POINT);
3827
+ this._savedS = typeof c.scalar === 'function' ? c.scalar() : 0;
3828
+ this._grabbed = true;
3829
+ this.onGrab && this.onGrab(this);
3830
+ this._onGrab && this._onGrab(this);
3831
+ }
3832
+
3833
+ /** Router seam: adopt a pointer a shared pick decided; claims it through the source. */
3834
+ _adopt(pointerId, x, y) {
3835
+ const e = this._pointer.get(pointerId);
3836
+ this._pid = pointerId;
3837
+ this._seq = e ? e.seq : -1;
3838
+ this._ptr[0] = x; this._ptr[1] = y;
3839
+ this._pointer.claim(pointerId, this);
3840
+ this._beginGrab();
3841
+ }
3842
+
3843
+ /**
3844
+ * Revert the drag in flight to the value captured at grab (exact θ winding
3845
+ * included), restore the binding, fire onCancel. No-op when not grabbed.
3846
+ * Chainable.
3847
+ * @returns {Handle} this
3848
+ */
3849
+ cancel() {
3850
+ if (this._grabbed) this._cancelNow();
3851
+ return this;
3852
+ }
3853
+
3854
+ _cancelNow() {
3855
+ const c = this._constraint;
3856
+ if (this._isView) {
3857
+ c.pt[0] = this._saved[0]; c.pt[1] = this._saved[1]; c.pt[2] = this._saved[2];
3858
+ } else if (c.kind === DIAL) {
3859
+ c.s = this._savedS;
3860
+ c._dialPoint();
3861
+ } else {
3862
+ c.seed(this._saved[0], this._saved[1], this._saved[2]);
3863
+ }
3864
+ if (this._binder) this._binder.set(this.value(this._bindVal));
3865
+ this._grabbed = false;
3866
+ if (this._pid !== null && this._pointer) this._pointer.release(this._pid);
3867
+ this._pid = null;
3868
+ this.onCancel && this.onCancel(this);
3869
+ this._onCancel && this._onCancel(this);
3870
+ }
3871
+
3872
+ // ── Frame, pick, solve ──────────────────────────────────────────────────
3873
+
3874
+ // Resolve the symbolic basis from its space into WORLD through the view
3875
+ // bag and re-aim the constraint; idle frames only.
3876
+ _resolveFrame() {
3877
+ const v = this._view, bag = _bagOf(v, this._from, null), from = _space(this._from);
3878
+ const d = this._fromDir;
3879
+ mapDirection(_b2, d[0], d[1], d[2], from, WORLD, bag, v.vp, v.ndcZMin);
3880
+ if (this._fromZero) {
3881
+ const z = this._fromZero;
3882
+ mapDirection(_b0, z[0], z[1], z[2], from, WORLD, bag, v.vp, v.ndcZMin);
3883
+ this._constraint.aim(_b2[0], _b2[1], _b2[2], _b0[0], _b0[1], _b0[2]);
3884
+ } else {
3885
+ this._constraint.aim(_b2[0], _b2[1], _b2[2]);
3886
+ }
3887
+ }
3888
+
3889
+ // The proxy's world position and its grab radius in world units — a
3890
+ // constant grabPx screen size at the proxy's depth.
3891
+ _proxyPrep() {
3892
+ const c = this._constraint, v = this._view, p = this._proxyPos;
3893
+ if (this._from && !this._grabbed) this._resolveFrame();
3894
+ if (c.kind === DIAL && !this._isView) {
3895
+ p[0] = c.anchor[0]; p[1] = c.anchor[1]; p[2] = c.anchor[2];
3896
+ } else {
3897
+ c.value(p, POINT);
3898
+ }
3899
+ const V = v.mat4View;
3900
+ const eyeZ = V[2]*p[0] + V[6]*p[1] + V[10]*p[2] + V[14];
3901
+ this._proxyRad = this._grabPx * pixelRatio(v.mat4Proj, -v.vp[3], eyeZ, v.ndcZMin);
3902
+ }
3903
+
3904
+ // The analytic pick: t along the ray, or Infinity.
3905
+ _proxyT(o, d) {
3906
+ this._proxyPrep();
3907
+ const c = this._constraint;
3908
+ if (typeof c.proxy === 'function') return c.proxy(o[0], o[1], o[2], d[0], d[1], d[2], this._proxyRad);
3909
+ const p = this._proxyPos;
3910
+ return rayHitSphere(o[0], o[1], o[2], d[0], d[1], d[2], p[0], p[1], p[2], this._proxyRad);
3911
+ }
3912
+
3913
+ /** Unproject a canvas pixel into the ray scratch; false on a stale view. */
3914
+ _ray(x, y) {
3915
+ const v = this._view;
3916
+ if (v.stale) return false;
3917
+ return unproject(_rayO, _rayD, x, y, v, v.vp, v.ndcZMin) !== null;
3918
+ }
3919
+
3920
+ /** Hit-test the proxy at a canvas pixel. */
3921
+ _pickAt(x, y) {
3922
+ return this._ray(x, y) && this._proxyT(_rayO, _rayD) < Infinity;
3923
+ }
3924
+
3925
+ _solveFromPointer(x, y) {
3926
+ if (!this._ray(x, y)) return;
3927
+ if (this._isView) this._viewUpdatePlane();
3928
+ this._constraint.solve(_rayO[0], _rayO[1], _rayO[2], _rayD[0], _rayD[1], _rayD[2]);
3929
+ }
3930
+
3931
+ // VIEW: re-aim the plane at the camera through the current point — its
3932
+ // normal the look direction (−Z of the eye matrix), its anchor the point.
3933
+ _viewUpdatePlane() {
3934
+ const c = this._constraint, E = this._view.mat4Eye;
3935
+ c.n[0] = -E[8]; c.n[1] = -E[9]; c.n[2] = -E[10];
3936
+ _norm3(c.n);
3937
+ c.anchor[0] = c.pt[0]; c.anchor[1] = c.pt[1]; c.anchor[2] = c.pt[2];
3938
+ }
3939
+
3940
+ // ── Snap ────────────────────────────────────────────────────────────────
3941
+
3942
+ /** Snap step — angular (rad) for SPHERE / DIAL, a world grid for PLANE / AXIS / VIEW; null off. */
3943
+ get snap() { return this._snap; }
3944
+ set snap(v) { this._snap = v ?? null; }
3945
+
3946
+ _applySnap() {
3947
+ const sn = this._snap;
3948
+ if (sn == null) return;
3949
+ const c = this._constraint;
3950
+ if (this._isView || c.kind === PLANE) {
3951
+ const gx = Array.isArray(sn) ? sn[0] : sn, gy = Array.isArray(sn) ? sn[1] : sn, gz = Array.isArray(sn) ? sn[2] : sn;
3952
+ _v3[0] = gx > 0 ? Math.round(c.pt[0] / gx) * gx : c.pt[0];
3953
+ _v3[1] = gy > 0 ? Math.round(c.pt[1] / gy) * gy : c.pt[1];
3954
+ _v3[2] = gz > 0 ? Math.round(c.pt[2] / gz) * gz : c.pt[2];
3955
+ if (this._isView) { c.pt[0] = _v3[0]; c.pt[1] = _v3[1]; c.pt[2] = _v3[2]; }
3956
+ else c.seed(_v3[0], _v3[1], _v3[2]);
3957
+ } else if (c.kind === AXIS || c.kind === DIAL) {
3958
+ const step = Array.isArray(sn) ? sn[0] : sn;
3959
+ if (!(step > 0)) return;
3960
+ const q = Math.round(c.s / step) * step;
3961
+ c.s = q < c.min ? c.min : (q > c.max ? c.max : q);
3962
+ if (c.kind === DIAL) c._dialPoint();
3963
+ else {
3964
+ c.pt[0] = c.anchor[0] + c.s * c.u[0];
3965
+ c.pt[1] = c.anchor[1] + c.s * c.u[1];
3966
+ c.pt[2] = c.anchor[2] + c.s * c.u[2];
3967
+ }
3968
+ } else if (c.kind === SPHERE) {
3969
+ const step = Array.isArray(sn) ? sn[0] : sn;
3970
+ if (!(step > 0)) return;
3971
+ c.azEl(_q2);
3972
+ dirFromAzEl(c.dir, Math.round(_q2[0] / step) * step, Math.round(_q2[1] / step) * step);
3973
+ }
3974
+ }
3975
+
3976
+ // ── Value and binding ───────────────────────────────────────────────────
3977
+
3978
+ /**
3979
+ * Write the current value into `out`. `report` overrides the constraint's
3980
+ * (POINT | DIRECTION); `to` converts from WORLD to EYE, SCREEN, NDC, or a
3981
+ * mat4 frame through the view bag. Out-first, out required.
3982
+ * @param {number[]} out 3-element destination.
3983
+ * @param {{ to?:string|ArrayLike<number>, report?:number }} [opts]
3984
+ * @returns {number[]} out
3985
+ */
3986
+ value(out, opts) {
3987
+ const o = opts || {}, c = this._constraint;
3988
+ const report = (o.report === POINT || o.report === DIRECTION) ? o.report : c.report;
3989
+ const to = o.to ?? WORLD;
3990
+ c.value(_v3, report);
3991
+ if (to === WORLD) { out[0] = _v3[0]; out[1] = _v3[1]; out[2] = _v3[2]; return out; }
3992
+ const v = this._view, bag = _bagOf(v, null, to), space = _space(to);
3993
+ return report === DIRECTION
3994
+ ? mapDirection(out, _v3[0], _v3[1], _v3[2], WORLD, space, bag, v.vp, v.ndcZMin)
3995
+ : mapLocation(out, _v3[0], _v3[1], _v3[2], WORLD, space, bag, v.vp, v.ndcZMin);
3996
+ }
3997
+
3998
+ /**
3999
+ * Bind the handle to a target it drives while dragging: a `{ get, set }`
4000
+ * accessor, or a vec3 array mutated in place (a camera state's eye or
4001
+ * center is one). get() seeds the constraint now; each solve calls
4002
+ * set(value) with the reused value array, then fires onChange. Chainable.
4003
+ * @param {number[]|{ get:Function, set:Function }} target
4004
+ * @returns {Handle} this
4005
+ */
4006
+ bind(target) {
4007
+ let binder = null;
4008
+ if (target && typeof target.get === 'function' && typeof target.set === 'function') {
4009
+ binder = target;
4010
+ } else if (target && typeof target === 'object' && typeof target.length === 'number' && target.length >= 3) {
4011
+ binder = { get: () => target, set: (v) => { target[0] = v[0]; target[1] = v[1]; target[2] = v[2]; } };
4012
+ } else {
4013
+ console.error('[host] handle.bind: unrecognised target — pass a vec3 array or an { get, set } accessor. Leaving unbound.');
4014
+ return this;
4015
+ }
4016
+ this._binder = binder;
4017
+ if (this._from && !this._grabbed) this._resolveFrame();
4018
+ this._seedFromBinding();
4019
+ return this;
4020
+ }
4021
+
4022
+ /** Re-seed the constraint from the bound target after it changed externally. Chainable. */
4023
+ sync() {
4024
+ if (this._binder) this._seedFromBinding();
4025
+ return this;
4026
+ }
4027
+
4028
+ _seedFromBinding() {
4029
+ const g = this._binder.get();
4030
+ if (g == null) return;
4031
+ const x = _vx$1(g, 0, 0), y = _vx$1(g, 1, 0), z = _vx$1(g, 2, 0);
4032
+ if (this._isView) { const pt = this._constraint.pt; pt[0] = x; pt[1] = y; pt[2] = z; }
4033
+ else this._constraint.seed(x, y, z);
4034
+ }
4035
+
4036
+ // Push the freshly solved value to the binding and fire onChange, once per solve.
4037
+ _afterSolve() {
4038
+ const bound = this._binder !== null;
4039
+ const notify = !!(this.onChange || this._onChange);
4040
+ if (!bound && !notify) return;
4041
+ const v = this.value(this._bindVal);
4042
+ if (bound) this._binder.set(v);
4043
+ if (notify) {
4044
+ this.onChange && this.onChange(v, this);
4045
+ this._onChange && this._onChange(v, this);
4046
+ }
4047
+ }
4048
+
4049
+ // ── Readouts and edits ──────────────────────────────────────────────────
4050
+
4051
+ /** AXIS: the signed distance along the rail; DIAL: the accumulated angle; NaN otherwise. */
4052
+ scalar() { return typeof this._constraint.scalar === 'function' ? this._constraint.scalar() : NaN; }
4053
+
4054
+ /** SPHERE: [az, el] of the current direction, into out2. */
4055
+ azEl(out2) {
4056
+ const o = out2 || [0, 0];
4057
+ return typeof this._constraint.azEl === 'function' ? this._constraint.azEl(o) : o;
4058
+ }
4059
+
4060
+ /** True between grab and release. */
4061
+ grabbed() { return this._grabbed; }
4062
+
4063
+ /** True while the pointer rests on the proxy, and while grabbed. */
4064
+ hovered() { return this._hovered; }
4065
+
4066
+ /**
4067
+ * Move the reference point — sphere centre, plane point, axis anchor,
4068
+ * dial centre, or a VIEW handle's point; the stored point rides along.
4069
+ * Chainable.
4070
+ * @param {number[]|{x:number,y:number,z:number}} v
4071
+ * @returns {Handle} this
4072
+ */
4073
+ anchor(v) {
4074
+ const c = this._constraint;
4075
+ const t = this._isView ? c.pt : c.anchor;
4076
+ if (!t) return this;
4077
+ t[0] = _vx$1(v, 0, t[0]); t[1] = _vx$1(v, 1, t[1]); t[2] = _vx$1(v, 2, t[2]);
4078
+ if (!this._isView) {
4079
+ if (c.kind === DIAL) c._dialPoint();
4080
+ else if (c.kind === AXIS) {
4081
+ c.pt[0] = c.anchor[0] + c.s * c.u[0];
4082
+ c.pt[1] = c.anchor[1] + c.s * c.u[1];
4083
+ c.pt[2] = c.anchor[2] + c.s * c.u[2];
4084
+ } else if (c.kind === PLANE) c.seed(c.pt[0], c.pt[1], c.pt[2]);
4085
+ }
4086
+ return this;
4087
+ }
4088
+
4089
+ /** Runtime gate — false suspends grab / solve without disposing. */
4090
+ get enabled() { return this._enabled; }
4091
+ set enabled(v) {
4092
+ this._enabled = !!v;
4093
+ if (!this._enabled) {
4094
+ if (this._pid !== null && this._pointer) this._pointer.release(this._pid);
4095
+ this._grabbed = false; this._hovered = false; this._pid = null;
4096
+ }
4097
+ }
4098
+
4099
+ /** Release the pointer claim and leave the host. */
4100
+ dispose() {
4101
+ if (this._pid !== null && this._pointer) this._pointer.release(this._pid);
4102
+ this._pid = null;
4103
+ this._grabbed = false;
4104
+ this._pointer = null;
4105
+ this._host.unregister(this);
4106
+ }
4107
+ }
4108
+
4109
+ /**
4110
+ * Validate the constraint option: a built-in kind, VIEW, or a contract object.
4111
+ * @param {*} kind
4112
+ * @returns {boolean}
4113
+ */
4114
+ function validConstraint(kind) {
4115
+ return kind === SPHERE || kind === PLANE || kind === AXIS || kind === DIAL || kind === VIEW || !!isConstraint(kind);
4116
+ }
4117
+
4118
+ /**
4119
+ * @file The pointer router — shared arbitration for overlapping handles.
4120
+ * @module host/router
4121
+ * @license AGPL-3.0-only
4122
+ *
4123
+ * Per-handle self-picking double-grabs on overlap: two proxies under one
4124
+ * finger each test only themselves and both hit. The router replaces the
4125
+ * members' down step with one shared pick: the pointer's ray once, every
4126
+ * enabled member's proxy tested, the nearest t wins (a tie keeps the
4127
+ * earlier member, as draw order would). Members keep their own move /
4128
+ * solve / release machinery, grabbed through their injected _adopt; every
4129
+ * unclaimed press of the frame resolves, so several same-frame presses on
4130
+ * different members all land. Hover is the same test on unclaimed pointer
4131
+ * motion, shared, setting at most one hovered member.
4132
+ */
4133
+
4134
+
4135
+ /** Shared pick over a set of handles on one host. */
4136
+ class PointerRouter {
4137
+ /**
4138
+ * @param {object} host The host context.
4139
+ * @param {object[]} handles Members.
4140
+ * @param {{ hover?:boolean }} [opts] hover defaults to true.
4141
+ */
4142
+ constructor(host, handles, opts) {
4143
+ const o = opts || {};
4144
+ this._host = host;
4145
+ this._pointer = host.pointer;
4146
+ this._handles = [];
4147
+ this._hover = o.hover !== false;
4148
+ this._hoveredH = null;
4149
+ this._hid = null; this._hseq = -1;
4150
+ for (const h of handles || []) this.add(h);
4151
+ }
4152
+
4153
+ /**
4154
+ * Route a handle: its own pointerdown adoption is disabled and the shared
4155
+ * pick grabs it through _adopt. Chainable.
4156
+ * @param {object} h
4157
+ * @returns {PointerRouter} this
4158
+ */
4159
+ add(h) {
4160
+ if (!h || typeof h._proxyT !== 'function') {
4161
+ console.error('[host] router.add: not a handle — ignoring.');
4162
+ return this;
4163
+ }
4164
+ if (this._handles.includes(h)) return this;
4165
+ h._routed = true;
4166
+ h._onRelease = h._onCancel = () => this._unclaim(h);
4167
+ this._handles.push(h);
4168
+ return this;
4169
+ }
4170
+
4171
+ /**
4172
+ * Un-route a handle; it self-picks again. Chainable.
4173
+ * @param {object} h
4174
+ * @returns {PointerRouter} this
4175
+ */
4176
+ remove(h) {
4177
+ const i = this._handles.indexOf(h);
4178
+ if (i < 0) return this;
4179
+ this._handles.splice(i, 1);
4180
+ this._unclaim(h);
4181
+ h._routed = false;
4182
+ h._onRelease = h._onCancel = null;
4183
+ if (this._hoveredH === h) { this._hoveredH = null; h._hovered = false; }
4184
+ return this;
4185
+ }
4186
+
4187
+ /**
4188
+ * Resolve the frame's presses with one shared pick each, refresh hover on
4189
+ * pointer motion, then update every member. Call first in the frame, in
4190
+ * place of the members' own updates.
4191
+ * @returns {boolean} true if any member is grabbed.
4192
+ */
4193
+ update() {
4194
+ const hs = this._handles, src = this._pointer;
4195
+ if (!src) return false;
4196
+ for (const pr of src.presses) {
4197
+ if (src.ownerOf(pr.id) !== null) continue;
4198
+ const win = this._sharedPick(pr.x, pr.y);
4199
+ if (win && win._pid === null && win.enabled) win._adopt(pr.id, pr.x, pr.y);
4200
+ }
4201
+ if (this._hover) {
4202
+ for (const e of src.pointers.values()) {
4203
+ if (e.owner !== null) continue;
4204
+ if (e.id === this._hid && e.seq === this._hseq) continue;
4205
+ this._hid = e.id; this._hseq = e.seq;
4206
+ const win = this._sharedPick(e.x, e.y);
4207
+ this._hoveredH = win;
4208
+ for (const h of hs) h._hovered = (h === win) || h._grabbed;
4209
+ break;
4210
+ }
4211
+ }
4212
+ let g = false;
4213
+ for (const h of hs) g = h.update() || g;
4214
+ return g;
4215
+ }
4216
+
4217
+ // One ray, every enabled member's proxy, the nearest t.
4218
+ _sharedPick(x, y) {
4219
+ const hs = this._handles;
4220
+ const first = hs.find((h) => h.enabled);
4221
+ if (!first || !first._ray(x, y)) return null;
4222
+ let win = null, best = Infinity;
4223
+ for (const h of hs) {
4224
+ if (!h.enabled) continue;
4225
+ const t = h._proxyT(rayScratch.o, rayScratch.d);
4226
+ if (t < best) { best = t; win = h; }
4227
+ }
4228
+ return win;
4229
+ }
4230
+
4231
+ _unclaim(h) {
4232
+ const src = this._pointer;
4233
+ if (!src) return;
4234
+ for (const e of src.pointers.values()) if (e.owner === h) src.release(e.id);
4235
+ }
4236
+
4237
+ /** The member under the pointer, or null. */
4238
+ hovered() { return this._hoveredH; }
4239
+
4240
+ /** Un-route every member and leave the host. */
4241
+ dispose() {
4242
+ for (const h of [...this._handles]) this.remove(h);
4243
+ this._pointer = null;
4244
+ this._host.unregister(this);
4245
+ }
4246
+ }
4247
+
4248
+ /**
4249
+ * @file Helm factories — a core PoseHelm driven from the host's players.
4250
+ * @module host/helm
4251
+ * @license AGPL-3.0-only
4252
+ *
4253
+ * cameraHelm(host, cam, opts) flies a camera state body-relative: the helm is
4254
+ * seeded from the state through cameraToPose, integrates each tick in the
4255
+ * state's own eye matrix (which equals the pose written last tick, so a
4256
+ * forward push flies forward) and writes back through cameraFromPose at the
4257
+ * state's gaze distance. poseHelm(host, opts) produces a pose in a declared
4258
+ * frame — WORLD, EYE (the host's view bag), SELF (its own pose) or a mat4 —
4259
+ * and drives whatever bind() names: a camera state, an { applyPose } sink, a
4260
+ * { get, set } accessor or a { pos, rot } object. Both register a player
4261
+ * with the host until disposed.
4262
+ */
4263
+
4264
+
4265
+ const _pose = { pos: [0, 0, 0], rot: [0, 0, 0, 1] }; // step() output
4266
+ const _self = { pos: [0, 0, 0], rot: [0, 0, 0, 1] }; // eval() readout for SELF
4267
+ const _em = new Float32Array(16); // the resolved basis
4268
+
4269
+ const _rawMat4 = (m) => (m != null && m.mat4 != null) ? m.mat4 : m;
4270
+ const _isCameraState = (c) => !!(c && c.eye && c.center && c.up);
4271
+
4272
+ function _applyOpts(helm, opts) {
4273
+ if (!opts) return;
4274
+ if (opts.profile) helm.profile = opts.profile;
4275
+ if (opts.deadzone != null) helm.deadzone = opts.deadzone;
4276
+ if (opts.filter != null) helm.filter = opts.filter;
4277
+ if (opts.fullScale != null) helm.fullScale = opts.fullScale;
4278
+ }
4279
+
4280
+ /**
4281
+ * The basis a pose helm's `from` resolves to: null for WORLD, the view bag's
4282
+ * eye matrix for EYE, the helm's own rotation for SELF, a mat4 as given.
4283
+ * Exported for a bridge's rig gizmo.
4284
+ * @param {PoseHelm} helm
4285
+ * @param {object} view The host's view bag.
4286
+ * @param {Float32Array} em 16-element scratch.
4287
+ * @returns {ArrayLike<number>|null}
4288
+ */
4289
+ function helmBasis(helm, view, em) {
4290
+ const from = helm.from;
4291
+ if (from != null && typeof from !== 'string') return _rawMat4(from);
4292
+ if (from === EYE) return view ? view.mat4Eye : null;
4293
+ if (from === SELF) return qToMat4(em, helm.eval(_self).rot);
4294
+ if (from === WORLD || from == null) return null;
4295
+ console.error('[host] poseHelm: `from` must be EYE, WORLD, SELF, or a mat4 frame. Falling back to WORLD.');
4296
+ return null;
4297
+ }
4298
+
4299
+ /**
4300
+ * Fly a camera state from a live 6-DOF rate stream, body-relative.
4301
+ * @param {object} host
4302
+ * @param {object} cam A camera state ({ eye, center, up, … }).
4303
+ * @param {{ profile?:object, deadzone?:number, filter?:object, fullScale?:number }} [opts]
4304
+ * @returns {PoseHelm} The helm, with dispose().
4305
+ */
4306
+ function cameraHelm(host, cam, opts) {
4307
+ const helm = new PoseHelm();
4308
+ _applyOpts(helm, opts);
4309
+ if (opts && opts.from != null) {
4310
+ console.error('[host] cameraHelm: a camera helm is always body-fly and has no `from`. Ignoring it; bind the camera to a poseHelm for screen- or world-relative motion.');
4311
+ }
4312
+ if (_isCameraState(cam)) helm.home(cameraToPose(_pose, cam));
4313
+ const player = {
4314
+ tick(dt) {
4315
+ helm.step(_pose, dt, _isCameraState(cam) ? cameraEye(_em, cam) : null);
4316
+ if (_isCameraState(cam)) {
4317
+ cameraFromPose(cam, _pose);
4318
+ if (helm._onApply) helm._onApply(cam);
4319
+ }
4320
+ return true;
4321
+ },
4322
+ };
4323
+ helm._onApply = null; // lib-space seam: a bridge pushes the written state to its camera
4324
+ host.players.add(player);
4325
+ helm.dispose = () => { host.players.remove(player); host.unregister(helm); return helm; };
4326
+ return host.register(helm);
4327
+ }
4328
+
4329
+ /**
4330
+ * Integrate a live 6-DOF rate stream into a pose in a declared frame and
4331
+ * drive a bound target with it.
4332
+ * @param {object} host
4333
+ * @param {{ profile?:object, deadzone?:number, filter?:object, fullScale?:number,
4334
+ * from?:string|ArrayLike<number>, bind?:object }} [opts]
4335
+ * @returns {PoseHelm} The helm, with bind() and dispose().
4336
+ */
4337
+ function poseHelm(host, opts) {
4338
+ const o = opts || {};
4339
+ const helm = new PoseHelm();
4340
+ _applyOpts(helm, o);
4341
+ if (o.from != null) helm.from = o.from;
4342
+ let sink = null;
4343
+
4344
+ const player = {
4345
+ tick(dt) {
4346
+ if (sink) {
4347
+ helm.step(_pose, dt, helmBasis(helm, host.view, _em));
4348
+ sink(_pose);
4349
+ }
4350
+ return true;
4351
+ },
4352
+ };
4353
+ host.players.add(player);
4354
+
4355
+ /**
4356
+ * Bind the target the helm drives: a camera state (seeded from it, written
4357
+ * through cameraFromPose), an { applyPose } sink, a { get, set } accessor
4358
+ * (get() seeds, set(pose) writes) or a { pos, rot } object mutated in
4359
+ * place. Chainable.
4360
+ * @param {object} target
4361
+ * @returns {PoseHelm} this
4362
+ */
4363
+ helm.bind = (target) => {
4364
+ if (_isCameraState(target)) {
4365
+ helm.home(cameraToPose(_pose, target));
4366
+ sink = (pose) => cameraFromPose(target, pose);
4367
+ } else if (target && typeof target.get === 'function' && typeof target.set === 'function') {
4368
+ helm.home(target.get());
4369
+ sink = (pose) => target.set(pose);
4370
+ } else if (target && typeof target.applyPose === 'function') {
4371
+ sink = (pose) => target.applyPose(pose);
4372
+ } else if (target && target.pos && target.rot) {
4373
+ helm.home(target);
4374
+ sink = (pose) => {
4375
+ target.pos[0] = pose.pos[0]; target.pos[1] = pose.pos[1]; target.pos[2] = pose.pos[2];
4376
+ target.rot[0] = pose.rot[0]; target.rot[1] = pose.rot[1];
4377
+ target.rot[2] = pose.rot[2]; target.rot[3] = pose.rot[3];
4378
+ };
4379
+ } else {
4380
+ console.error('[host] poseHelm: bind() target must be a camera state, an { applyPose } sink, a { get, set } accessor, or a { pos, rot } object. Leaving unbound.');
4381
+ }
4382
+ return helm;
4383
+ };
4384
+ helm.dispose = () => { host.players.remove(player); host.unregister(helm); return helm; };
4385
+ if (o.bind != null) helm.bind(o.bind);
4386
+ return host.register(helm);
4387
+ }
4388
+
4389
+ /**
4390
+ * @file Track factories and TrackHandles — core tracks wired to the host's players.
4391
+ * @module host/track
4392
+ * @license AGPL-3.0-only
4393
+ *
4394
+ * poseTrack(host, opts) is a core PoseTrack whose activation hooks add and
4395
+ * remove its player. cameraTrack(host, cam, opts) is a core CameraTrack whose
4396
+ * player evaluates straight into the camera state `cam` — the state is the
4397
+ * keyframe shape, so track.eval(cam) is the write — and once more on
4398
+ * deactivate so the camera rests on the path; add() with no argument
4399
+ * captures `cam`, add({ camera }) any camera-state object. { handles } on
4400
+ * either decorates the track with TrackHandles: one VIEW handle per
4401
+ * draggable keyframe field (pos / eye / center), an optional rot DIAL per
4402
+ * keyframe about a declared axis, all on one router.
4403
+ */
4404
+
4405
+
4406
+ // ── Players ────────────────────────────────────────────────────────────────
4407
+
4408
+ function _wirePoseTrack(host, track) {
4409
+ let player = null;
4410
+ track._onActivate = () => {
4411
+ player = player || { tick() { track.tick(); return track.playing; } };
4412
+ host.players.add(player);
4413
+ };
4414
+ track._onDeactivate = () => { if (player) host.players.remove(player); };
4415
+ }
4416
+
4417
+ /**
4418
+ * A core PoseTrack ticked by the host while it plays.
4419
+ * @param {object} host
4420
+ * @param {{ handles?:boolean|object }} [opts]
4421
+ * @returns {PoseTrack}
4422
+ */
4423
+ function poseTrack(host, opts) {
4424
+ const o = opts || {};
4425
+ const track = new PoseTrack();
4426
+ _wirePoseTrack(host, track);
4427
+ if (o.handles) track.handles = new TrackHandles(host, track, o.handles, false);
4428
+ return track;
4429
+ }
4430
+
4431
+ /**
4432
+ * A core CameraTrack evaluating into the camera state `cam` while it plays.
4433
+ * @param {object} host
4434
+ * @param {object} cam A camera state ({ eye, center, up, fov, halfHeight, near, far }).
4435
+ * @param {{ handles?:boolean|object }} [opts]
4436
+ * @returns {CameraTrack}
4437
+ */
4438
+ function cameraTrack(host, cam, opts) {
4439
+ const o = opts || {};
4440
+ const track = new CameraTrack();
4441
+ const state = cam || null; // the evaluation target; track.camera is informational
4442
+ track.camera = state;
4443
+ track._onApply = null; // lib-space seam: a bridge pushes the evaluated state to its camera
4444
+
4445
+ const coreAdd = track.add.bind(track);
4446
+ track.add = function (spec, addOpts) {
4447
+ if (spec == null) {
4448
+ if (!state) return;
4449
+ spec = state;
4450
+ } else if (Array.isArray(spec)) {
4451
+ for (const s of spec) track.add(s, addOpts);
4452
+ return;
4453
+ } else if (spec.camera != null) {
4454
+ spec = spec.camera;
4455
+ }
4456
+ coreAdd(spec, addOpts);
4457
+ };
4458
+
4459
+ const apply = () => {
4460
+ if (!state || track.keyframes.length === 0) return;
4461
+ track.eval(state);
4462
+ if (track._onApply) track._onApply(state);
4463
+ };
4464
+ const player = {
4465
+ tick() {
4466
+ if (!track.playing) return false;
4467
+ track.tick();
4468
+ apply();
4469
+ return track.playing;
4470
+ },
4471
+ };
4472
+ track._onActivate = () => host.players.add(player);
4473
+ track._onDeactivate = () => { host.players.remove(player); apply(); };
4474
+
4475
+ if (o.handles) track.handles = new TrackHandles(host, track, o.handles, true);
4476
+ return track;
4477
+ }
4478
+
4479
+ // ── TrackHandles ───────────────────────────────────────────────────────────
4480
+
4481
+ const _vx = (v, i) => (v[i] !== undefined ? v[i] : (i === 0 ? v.x : i === 1 ? v.y : v.z));
4482
+
4483
+ /**
4484
+ * Per-keyframe manipulators of a track — the factories' `handles` opt,
4485
+ * stored at track.handles. Members are internal handles on one router;
4486
+ * their hooks are re-exposed with keyframe coordinates: onGrab(index, field,
4487
+ * h) / onChange(value, index, field, h) / onRelease / onCancel, field one of
4488
+ * 'pos' | 'eye' | 'center' | 'rot'. update() rebuilds the members when the
4489
+ * keyframe count changes and re-seeds every idle member from its keyframe.
4490
+ * A bridge draws the members itself (`members`).
4491
+ */
4492
+ class TrackHandles {
4493
+ /**
4494
+ * @param {object} host
4495
+ * @param {object} track PoseTrack | CameraTrack.
4496
+ * @param {true|object} opts `true` for the defaults, or
4497
+ * { center?, rot?, rotRadius?, rotSnap?, grabPx?, snap?, hover? }.
4498
+ * @param {boolean} isCamera CameraTrack (eye / center) vs PoseTrack (pos / rot).
4499
+ */
4500
+ constructor(host, track, opts, isCamera) {
4501
+ const o = opts === true ? {} : (opts || {});
4502
+ this._host = host;
4503
+ this._track = track;
4504
+ this._isCamera = !!isCamera;
4505
+ this._members = [];
4506
+ this._rotByIndex = new Map();
4507
+ this._n = -1;
4508
+ this._enabled = true;
4509
+ /** Last-grabbed keyframe index, null until a grab. @type {number|null} */
4510
+ this.selected = null;
4511
+
4512
+ this._grabPx = Number.isFinite(o.grabPx) ? o.grabPx : 12;
4513
+ this._snap = o.snap ?? null;
4514
+ this._rotSnap = o.rotSnap ?? null;
4515
+ this._center = this._isCamera ? (o.center !== false) : false;
4516
+ if (!this._isCamera && o.center !== undefined) console.error('[host] track handles: `center` is CameraTrack-only — ignoring.');
4517
+ this._rotAxis = null;
4518
+ this._rotRadius = Number.isFinite(o.rotRadius) ? o.rotRadius : 40;
4519
+ if (o.rot != null) {
4520
+ if (this._isCamera) {
4521
+ console.error('[host] track handles: `rot` is PoseTrack-only — a camera keyframe\'s orientation is its center; drag that instead. Ignoring.');
4522
+ } else {
4523
+ const ax = _vx(o.rot, 0) ?? 0, ay = _vx(o.rot, 1) ?? 1, az = _vx(o.rot, 2) ?? 0;
4524
+ const l = Math.hypot(ax, ay, az) || 1;
4525
+ this._rotAxis = [ax / l, ay / l, az / l];
4526
+ }
4527
+ }
4528
+
4529
+ this.onGrab = null; this.onChange = null; this.onRelease = null; this.onCancel = null;
4530
+ this._hover = o.hover !== false;
4531
+ this._router = null; // made on the first rebuild, once a subclass is constructed
4532
+ }
4533
+
4534
+ // The construction seams a bridge subclass overrides so the members are
4535
+ // handles it can draw.
4536
+ _makeHandle(opts) { return this._host.handle(opts); }
4537
+ _makeRouter(opts) { return this._host.router([], opts); }
4538
+
4539
+ /** The members, { h, index, field } each, for a bridge's draw. */
4540
+ get members() { return this._members; }
4541
+ /** Dot radius in pixels, the members' grabPx. */
4542
+ get grabPx() { return this._grabPx; }
4543
+
4544
+ /**
4545
+ * Rebuild if the keyframe count changed, re-seed idle members, then route.
4546
+ * Call first in the frame, before the orbit.
4547
+ * @returns {boolean} true while any member is grabbed.
4548
+ */
4549
+ update() {
4550
+ if (this._track.keyframes.length !== this._n) this._rebuild();
4551
+ if (this._enabled) this._syncIdle();
4552
+ return this._router.update();
4553
+ }
4554
+
4555
+ /** The members' router, made on the first update(). */
4556
+ get router() { return this._router; }
4557
+
4558
+ /** Runtime gate — false suspends grab / solve and empties the pick. */
4559
+ get enabled() { return this._enabled; }
4560
+ set enabled(v) {
4561
+ this._enabled = !!v;
4562
+ for (const m of this._members) m.h.enabled = this._enabled;
4563
+ }
4564
+
4565
+ /** True while any member is grabbed. */
4566
+ grabbed() {
4567
+ for (const m of this._members) if (m.h.grabbed()) return true;
4568
+ return false;
4569
+ }
4570
+
4571
+ /** The keyframe index under the pointer (or grabbed), else null. */
4572
+ hovered() {
4573
+ for (const m of this._members) if (m.h.hovered()) return m.index;
4574
+ return null;
4575
+ }
4576
+
4577
+ /** Re-seed every idle member from its keyframe. Chainable. */
4578
+ sync() { this._syncIdle(); return this; }
4579
+
4580
+ /** Dispose the members and the router, detach from the track. */
4581
+ dispose() {
4582
+ this._teardown();
4583
+ if (this._router) this._router.dispose();
4584
+ if (this._track.handles === this) this._track.handles = null;
4585
+ }
4586
+
4587
+ _rebuild() {
4588
+ if (!this._router) this._router = this._makeRouter({ hover: this._hover });
4589
+ this._teardown();
4590
+ const n = this._track.keyframes.length;
4591
+ this._n = n;
4592
+ for (let i = 0; i < n; i++) {
4593
+ this._addViewMember(i, this._isCamera ? 'eye' : 'pos');
4594
+ if (this._center) this._addViewMember(i, 'center');
4595
+ if (this._rotAxis) this._addRotMember(i);
4596
+ }
4597
+ if (this.selected != null && this.selected >= n) this.selected = null;
4598
+ }
4599
+
4600
+ _teardown() {
4601
+ for (const m of this._members) { this._router.remove(m.h); m.h.dispose(); }
4602
+ this._members.length = 0;
4603
+ this._rotByIndex.clear();
4604
+ }
4605
+
4606
+ // A VIEW member bound to kf[field] by index, so set(i, spec) replacing the
4607
+ // keyframe never strands it.
4608
+ _addViewMember(index, field) {
4609
+ const track = this._track;
4610
+ const h = this._makeHandle({
4611
+ constraint: VIEW, grabPx: this._grabPx, snap: this._snap,
4612
+ bind: {
4613
+ get: () => track.keyframes[index] ? track.keyframes[index][field] : null,
4614
+ set: (v) => {
4615
+ const k = track.keyframes[index];
4616
+ if (!k) return;
4617
+ const a = k[field];
4618
+ a[0] = _vx(v, 0); a[1] = _vx(v, 1); a[2] = _vx(v, 2);
4619
+ },
4620
+ },
4621
+ });
4622
+ if (!h) return;
4623
+ this._wire(h, index, field);
4624
+ this._members.push({ h, index, field });
4625
+ this._router.add(h);
4626
+ }
4627
+
4628
+ // A rot member: one DIAL about the declared axis at the keyframe's
4629
+ // position, unbound — θ lands in kf.rot through the hooks.
4630
+ _addRotMember(index) {
4631
+ const kf = this._track.keyframes[index];
4632
+ const h = this._makeHandle({
4633
+ constraint: DIAL, anchor: [kf.pos[0], kf.pos[1], kf.pos[2]],
4634
+ axis: this._rotAxis, radius: this._rotRadius, grabPx: this._grabPx, snap: this._rotSnap,
4635
+ });
4636
+ if (!h) return;
4637
+ this._wire(h, index, 'rot');
4638
+ const m = { h, index, field: 'rot' };
4639
+ this._members.push(m);
4640
+ this._rotByIndex.set(index, m);
4641
+ this._router.add(h);
4642
+ this._syncRot(m);
4643
+ }
4644
+
4645
+ _wire(h, index, field) {
4646
+ const rotWrite = () => {
4647
+ const k = this._track.keyframes[index];
4648
+ if (k) { const u = this._rotAxis; qFromAxisAngle(k.rot, u[0], u[1], u[2], h.scalar()); }
4649
+ };
4650
+ h.onGrab = () => {
4651
+ this.selected = index;
4652
+ if (this.onGrab) this.onGrab(index, field, h);
4653
+ };
4654
+ h.onChange = (v) => {
4655
+ if (field === 'rot') rotWrite();
4656
+ else if (field === 'pos') {
4657
+ // Forward the dragged position into the keyframe's rot ring now, so
4658
+ // the ring never trails the dot by a frame.
4659
+ const rm = this._rotByIndex.get(index);
4660
+ const k = this._track.keyframes[index];
4661
+ if (rm && k) rm.h.anchor(k.pos);
4662
+ }
4663
+ if (this.onChange) this.onChange(v, index, field, h);
4664
+ };
4665
+ h.onRelease = () => { if (this.onRelease) this.onRelease(index, field, h); };
4666
+ h.onCancel = () => {
4667
+ if (field === 'rot') rotWrite(); // a DIAL is unbound: re-derive from the reverted θ
4668
+ if (this.onCancel) this.onCancel(index, field, h);
4669
+ };
4670
+ }
4671
+
4672
+ _syncIdle() {
4673
+ for (const m of this._members) {
4674
+ if (m.h.grabbed()) continue;
4675
+ if (m.field === 'rot') this._syncRot(m);
4676
+ else m.h.sync();
4677
+ }
4678
+ }
4679
+
4680
+ // Anchor the ring at the live keyframe position and set θ to the twist of
4681
+ // kf.rot about the axis: θ = 2·atan2(q.xyz · u, q.w).
4682
+ _syncRot(m) {
4683
+ const k = this._track.keyframes[m.index];
4684
+ if (!k) return;
4685
+ m.h.anchor(k.pos);
4686
+ const u = this._rotAxis;
4687
+ const th = 2 * Math.atan2(k.rot[0] * u[0] + k.rot[1] * u[1] + k.rot[2] * u[2], k.rot[3]);
4688
+ const c = m.h._constraint;
4689
+ if (c.s !== th) { c.s = th; c._dialPoint(); }
4690
+ }
4691
+ }
4692
+
4693
+ /**
4694
+ * @file The host context — one per canvas: pointer, view bag, players, loop, observer.
4695
+ * @module host
4696
+ * @license AGPL-3.0-only
4697
+ *
4698
+ * The signal host of a program: what it needs from the browser that is
4699
+ * neither math nor GPU — a canvas element's pointer input, the frame clock,
4700
+ * its size — and the registry its constructs tick from. Pure vanilla DOM;
4701
+ * everything it computes goes through @nakednous/tree, nothing it draws.
4702
+ *
4703
+ * const host = createHost(canvas, { onFrame })
4704
+ * host.pointer · host.view · host.players
4705
+ * host.width · host.height · host.dpr · host.dt · host.clock()
4706
+ * host.handle(opts) · host.router(handles, opts)
4707
+ * host.cameraHelm(cam, opts) · host.poseHelm(opts)
4708
+ * host.poseTrack(opts) · host.cameraTrack(cam, opts)
4709
+ * host.tick(dt) // external-loop mode
4710
+ * host.dispose()
4711
+ *
4712
+ * Two loop modes. With { onFrame } the host runs requestAnimationFrame
4713
+ * (overridable through { raf, caf }): each frame computes dt (clamped to
4714
+ * 50 ms), ticks the players, calls onFrame(dt, host), then flushes the
4715
+ * pointer source. Without it the application, or the p5 adapter from
4716
+ * predraw, calls host.tick(dt) = players.tick(dt) and host.pointer.flush()
4717
+ * once its consumers ran. Nothing else in the host cares who owns the loop.
4718
+ *
4719
+ * The frame order inside onFrame: install the camera (which fills
4720
+ * host.view), update handles or routers, fall through to the orbit when
4721
+ * nothing grabbed, draw.
4722
+ */
4723
+
4724
+
4725
+ const _now = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
4726
+
4727
+ /**
4728
+ * Create the host context of a canvas.
4729
+ *
4730
+ * @param {HTMLCanvasElement} canvas
4731
+ * @param {{ ndcZMin?:number, onFrame?:function(number, object):void, raf?:function,
4732
+ * caf?:function, onSize?:function(number, number, number):void }} [opts]
4733
+ * ndcZMin: the GPU API's NDC-z convention, WEBGL (default) or WEBGPU —
4734
+ * written once, never detected. onFrame: the host's own loop; absent,
4735
+ * external-tick mode. onSize(width, height, dpr): the bridge's chance
4736
+ * to resize its drawing buffer.
4737
+ * @returns {object} The host.
4738
+ */
4739
+ function createHost(canvas, opts) {
4740
+ const o = opts || {};
4741
+ const t0 = _now();
4742
+ const constructs = new Set();
4743
+ let loop = null, observer = null;
4744
+ const host = {
4745
+ canvas,
4746
+ width: 0, height: 0, dpr: 1,
4747
+ dt: 0,
4748
+ view: null, pointer: null, players: null,
4749
+
4750
+ /** Seconds since the host was created. */
4751
+ clock() { return (_now() - t0) / 1000; },
4752
+
4753
+ /**
4754
+ * External-loop mode: tick the players with dt (seconds). The caller
4755
+ * flushes the pointer source once its consumers ran.
4756
+ * @param {number} dt
4757
+ */
4758
+ tick(dt) { host.dt = dt; host.players.tick(dt); },
4759
+
4760
+ /**
4761
+ * A draggable handle on this canvas — see host/handle. Returns null on
4762
+ * an invalid `constraint`.
4763
+ * @param {object} opts
4764
+ * @returns {Handle|null}
4765
+ */
4766
+ handle(opts) {
4767
+ const o = opts || {};
4768
+ if (!validConstraint(o.constraint)) {
4769
+ console.error('[host] handle: `constraint` must be SPHERE, PLANE, AXIS, DIAL, VIEW, or a contract-conforming constraint object; got ' + String(o.constraint) + '.');
4770
+ return null;
4771
+ }
4772
+ return host.register(new Handle(host, o));
4773
+ },
4774
+
4775
+ /**
4776
+ * A shared pick over overlapping handles — see host/router.
4777
+ * @param {Handle[]} handles
4778
+ * @param {{ hover?:boolean }} [opts]
4779
+ * @returns {PointerRouter}
4780
+ */
4781
+ router(handles, opts) { return host.register(new PointerRouter(host, handles, opts)); },
4782
+
4783
+ /** Fly a camera state body-relative from a 6-DOF rate stream — see host/helm. */
4784
+ cameraHelm(cam, opts) { return cameraHelm(host, cam, opts); },
4785
+ /** Integrate a 6-DOF rate stream into a pose in a declared frame — see host/helm. */
4786
+ poseHelm(opts) { return poseHelm(host, opts); },
4787
+ /** A core PoseTrack ticked while it plays — see host/track. */
4788
+ poseTrack(opts) { return poseTrack(host, opts); },
4789
+ /** A core CameraTrack evaluating into a camera state while it plays — see host/track. */
4790
+ cameraTrack(cam, opts) { return cameraTrack(host, cam, opts); },
4791
+
4792
+ /** Register a construct with dispose() so host.dispose() releases it. */
4793
+ register(c) { if (c) constructs.add(c); return c; },
4794
+ /** Unregister a construct. */
4795
+ unregister(c) { constructs.delete(c); },
4796
+
4797
+ /** Stop the loop, disconnect the observer, remove every listener, dispose every construct. */
4798
+ dispose() {
4799
+ if (loop) loop.stop();
4800
+ for (const c of [...constructs]) { if (typeof c.dispose === 'function') c.dispose(); }
4801
+ constructs.clear();
4802
+ observer.dispose();
4803
+ host.pointer.dispose();
4804
+ host.players.clear();
4805
+ },
4806
+ };
4807
+
4808
+ host.view = createView({ ndcZMin: o.ndcZMin });
4809
+ host.players = createPlayers();
4810
+ host.pointer = createPointer(canvas, host.view);
4811
+ observer = observeCanvas(canvas, (w, h, dpr) => {
4812
+ host.width = w; host.height = h; host.dpr = dpr;
4813
+ host.view.resize(w, h);
4814
+ if (typeof o.onSize === 'function') o.onSize(w, h, dpr);
4815
+ });
4816
+
4817
+ if (typeof o.onFrame === 'function') {
4818
+ loop = createLoop({
4819
+ raf: o.raf, caf: o.caf,
4820
+ onFrame: (dt) => {
4821
+ host.dt = dt;
4822
+ host.players.tick(dt);
4823
+ o.onFrame(dt, host);
4824
+ host.pointer.flush();
4825
+ },
4826
+ });
4827
+ loop.start();
4828
+ }
4829
+ return host;
4830
+ }
4831
+
4832
+ export { Handle, PointerRouter, TrackHandles, VIEW, cameraHelm, cameraTrack, createHost, createLoop, createPlayers, createPointer, createView, helmBasis, isConstraint, measureCanvas, observeCanvas, poseHelm, poseTrack, validConstraint };
4833
+ //# sourceMappingURL=index.js.map