@nakednous/tree 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -31,40 +31,580 @@ const _j = Object.freeze([0, -1, 0]);
31
31
  const _k = Object.freeze([0, 0, -1]);
32
32
 
33
33
  /**
34
- * @file Pure numeric math mat4, mat3, projection queries, space transforms.
35
- * @module tree/math
34
+ * @file Quaternion algebra and mat4/mat3 conversions.
35
+ * @module tree/quat
36
36
  * @license AGPL-3.0-only
37
37
  *
38
- * CONVENTIONS (all functions in this module follow these):
38
+ * Quaternions are stored as flat [x, y, z, w] arrays (w-last, glTF layout).
39
39
  *
40
- * Storage: Column-major Float32Array / ArrayLike<number>.
41
- * Element [col*4 + row] = M[row, col].
40
+ * All functions follow the out-first, zero-allocation contract.
41
+ * Conversion functions bridge between quaternion and matrix representations
42
+ * but do not perform any higher-level graphics operations — those belong
43
+ * in form.js (matrix construction from specs) or track.js (animation).
44
+ */
45
+
46
+
47
+ // =========================================================================
48
+ // Basic ops
49
+ // =========================================================================
50
+
51
+ /** Set all four components. @returns {number[]} out */
52
+ const qSet = (out, x, y, z, w) => {
53
+ out[0] = x; out[1] = y; out[2] = z; out[3] = w; return out;
54
+ };
55
+
56
+ /** Copy quaternion a into out. @returns {number[]} out */
57
+ const qCopy = (out, a) => {
58
+ out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; return out;
59
+ };
60
+
61
+ /** Dot product of two quaternions. */
62
+ const qDot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
63
+
64
+ /** Normalise quaternion in-place. @returns {number[]} out */
65
+ const qNormalize = (out) => {
66
+ const l = Math.sqrt(out[0]*out[0]+out[1]*out[1]+out[2]*out[2]+out[3]*out[3]) || 1;
67
+ out[0]/=l; out[1]/=l; out[2]/=l; out[3]/=l; return out;
68
+ };
69
+
70
+ /** Negate quaternion (same rotation, different hemisphere). @returns {number[]} out */
71
+ const qNegate = (out, a) => {
72
+ out[0]=-a[0]; out[1]=-a[1]; out[2]=-a[2]; out[3]=-a[3]; return out;
73
+ };
74
+
75
+ /** Hamilton product out = a * b. @returns {number[]} out */
76
+ const qMul = (out, a, b) => {
77
+ 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];
78
+ out[0]=aw*bx+ax*bw+ay*bz-az*by;
79
+ out[1]=aw*by-ax*bz+ay*bw+az*bx;
80
+ out[2]=aw*bz+ax*by-ay*bx+az*bw;
81
+ out[3]=aw*bw-ax*bx-ay*by-az*bz;
82
+ return out;
83
+ };
84
+
85
+ // =========================================================================
86
+ // Interpolation
87
+ // =========================================================================
88
+
89
+ /** Spherical linear interpolation. @returns {number[]} out */
90
+ const qSlerp = (out, a, b, t) => {
91
+ let bx=b[0],by=b[1],bz=b[2],bw=b[3];
92
+ let d = a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw;
93
+ if (d < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; d=-d; }
94
+ let f0, f1;
95
+ if (1-d > 1e-10) {
96
+ const th=Math.acos(d), st=Math.sin(th);
97
+ f0=Math.sin((1-t)*th)/st; f1=Math.sin(t*th)/st;
98
+ } else {
99
+ f0=1-t; f1=t;
100
+ }
101
+ out[0]=a[0]*f0+bx*f1; out[1]=a[1]*f0+by*f1;
102
+ out[2]=a[2]*f0+bz*f1; out[3]=a[3]*f0+bw*f1;
103
+ return qNormalize(out);
104
+ };
105
+
106
+ /**
107
+ * Normalised linear interpolation (nlerp).
108
+ * Cheaper than slerp; slightly non-constant angular velocity.
109
+ * Handles antipodal quats by flipping b when dot < 0.
110
+ * @returns {number[]} out
111
+ */
112
+ const qNlerp = (out, a, b, t) => {
113
+ let bx=b[0],by=b[1],bz=b[2],bw=b[3];
114
+ if (a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; }
115
+ out[0]=a[0]+t*(bx-a[0]); out[1]=a[1]+t*(by-a[1]);
116
+ out[2]=a[2]+t*(bz-a[2]); out[3]=a[3]+t*(bw-a[3]);
117
+ return qNormalize(out);
118
+ };
119
+
120
+ // =========================================================================
121
+ // Construction
122
+ // =========================================================================
123
+
124
+ /**
125
+ * Build a quaternion from axis-angle.
126
+ * @param {number[]} out
127
+ * @param {number} ax @param {number} ay @param {number} az Axis (need not be unit).
128
+ * @param {number} angle Radians.
129
+ * @returns {number[]} out
130
+ */
131
+ const qFromAxisAngle = (out, ax, ay, az, angle) => {
132
+ const half = angle * 0.5;
133
+ const s = Math.sin(half);
134
+ const len = Math.sqrt(ax*ax + ay*ay + az*az) || 1;
135
+ out[0] = s * ax / len; out[1] = s * ay / len; out[2] = s * az / len;
136
+ out[3] = Math.cos(half);
137
+ return out;
138
+ };
139
+
140
+ /**
141
+ * Build a quaternion from a look direction (−Z forward) and optional up (default +Y).
142
+ * @param {number[]} out
143
+ * @param {number[]} dir Forward direction [x,y,z].
144
+ * @param {number[]} [up] Up vector [x,y,z].
145
+ * @returns {number[]} out
146
+ */
147
+ const qFromLookDir = (out, dir, up) => {
148
+ let fx=dir[0],fy=dir[1],fz=dir[2];
149
+ const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
150
+ fx/=fl; fy/=fl; fz/=fl;
151
+ let ux=up?up[0]:0, uy=up?up[1]:1, uz=up?up[2]:0;
152
+ let rx=uy*fz-uz*fy, ry=uz*fx-ux*fz, rz=ux*fy-uy*fx;
153
+ const rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1;
154
+ rx/=rl; ry/=rl; rz/=rl;
155
+ ux=fy*rz-fz*ry; uy=fz*rx-fx*rz; uz=fx*ry-fy*rx;
156
+ return qFromRotMat3x3(out, rx,ry,rz, ux,uy,uz, -fx,-fy,-fz);
157
+ };
158
+
159
+ /**
160
+ * Build a quaternion from a 3×3 rotation matrix (9 row-major scalars).
161
+ * @returns {number[]} out (normalised)
162
+ */
163
+ const qFromRotMat3x3 = (out, m00,m01,m02, m10,m11,m12, m20,m21,m22) => {
164
+ const tr = m00+m11+m22;
165
+ if (tr > 0) {
166
+ const s=0.5/Math.sqrt(tr+1);
167
+ out[3]=0.25/s; out[0]=(m21-m12)*s; out[1]=(m02-m20)*s; out[2]=(m10-m01)*s;
168
+ } else if (m00>m11 && m00>m22) {
169
+ const s=2*Math.sqrt(1+m00-m11-m22);
170
+ out[3]=(m21-m12)/s; out[0]=0.25*s; out[1]=(m01+m10)/s; out[2]=(m02+m20)/s;
171
+ } else if (m11>m22) {
172
+ const s=2*Math.sqrt(1+m11-m00-m22);
173
+ out[3]=(m02-m20)/s; out[0]=(m01+m10)/s; out[1]=0.25*s; out[2]=(m12+m21)/s;
174
+ } else {
175
+ const s=2*Math.sqrt(1+m22-m00-m11);
176
+ out[3]=(m10-m01)/s; out[0]=(m02+m20)/s; out[1]=(m12+m21)/s; out[2]=0.25*s;
177
+ }
178
+ return qNormalize(out);
179
+ };
180
+
181
+ /**
182
+ * Extract a unit quaternion from the upper-left 3×3 of a column-major mat4.
183
+ * @param {number[]} out
184
+ * @param {Float32Array|number[]} m Column-major mat4.
185
+ * @returns {number[]} out
186
+ */
187
+ const qFromMat4 = (out, m) =>
188
+ qFromRotMat3x3(out, m[0],m[4],m[8], m[1],m[5],m[9], m[2],m[6],m[10]);
189
+
190
+ /**
191
+ * Write a quaternion into the rotation block of a column-major mat4.
192
+ * Translation and perspective rows/cols are set to identity values.
193
+ * @param {Float32Array|number[]} out 16-element array.
194
+ * @param {number[]} q [x,y,z,w].
195
+ * @returns {Float32Array|number[]} out
196
+ */
197
+ const qToMat4 = (out, q) => {
198
+ const x=q[0],y=q[1],z=q[2],w=q[3];
199
+ const x2=x+x,y2=y+y,z2=z+z;
200
+ 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;
201
+ out[0]=1-(yy+zz); out[1]=xy+wz; out[2]=xz-wy; out[3]=0;
202
+ out[4]=xy-wz; out[5]=1-(xx+zz); out[6]=yz+wx; out[7]=0;
203
+ out[8]=xz+wy; out[9]=yz-wx; out[10]=1-(xx+yy); out[11]=0;
204
+ out[12]=0; out[13]=0; out[14]=0; out[15]=1;
205
+ return out;
206
+ };
207
+
208
+ // =========================================================================
209
+ // Decomposition
210
+ // =========================================================================
211
+
212
+ /**
213
+ * Decompose a unit quaternion into { axis:[x,y,z], angle } (radians).
214
+ * @param {number[]} q [x,y,z,w].
215
+ * @param {Object} [out]
216
+ * @returns {{ axis: number[], angle: number }}
217
+ */
218
+ const quatToAxisAngle = (q, out) => {
219
+ out = out || {};
220
+ const x=q[0],y=q[1],z=q[2],w=q[3];
221
+ const sinHalf = Math.sqrt(x*x+y*y+z*z);
222
+ if (sinHalf < 1e-8) { out.axis=[0,1,0]; out.angle=0; return out; }
223
+ out.angle = 2*Math.atan2(sinHalf, w);
224
+ out.axis = [x/sinHalf, y/sinHalf, z/sinHalf];
225
+ return out;
226
+ };
227
+
228
+ /**
229
+ * @file Matrix construction from geometric specs and partial decomposition.
230
+ * @module tree/form
231
+ * @license AGPL-3.0-only
232
+ *
233
+ * Constructs mat4s from higher-level specs: TRS transforms, orthonormal
234
+ * bases, lookat parameters, projection parameters, and special-purpose
235
+ * matrices (bias, reflection).
236
+ *
237
+ * Design invariant: form.js has no dependency on query.js. Construction
238
+ * from specs requires only scalar arithmetic and quaternion conversions.
239
+ * Callers compose the resulting matrices using query.js (mat4Mul etc.).
240
+ *
241
+ * Lookat constructors live here because a camera is just a frame — the eye
242
+ * matrix is the camera object's model matrix, not a camera-specific concept.
243
+ * There is no camera module; mat4View and mat4Eye are frame
244
+ * constructions that happen to use lookat parameterisation.
245
+ *
246
+ * Projection constructors live here because they construct matrices from
247
+ * geometric parameters. Projection scalar reads (projNear, projFov, etc.)
248
+ * live in query.js — they interrogate an existing projection matrix.
249
+ *
250
+ * Partial decomposers (mat4To___) are the inverse of construction — they
251
+ * extract a single component from an existing matrix. Kept alongside
252
+ * constructors because they are paired operations on the same components.
253
+ *
254
+ * Imports quat.js only. No dependency on query.js, visibility.js, or track.js.
255
+ *
256
+ * All functions follow the out-first, zero-allocation contract.
257
+ * Returns null on degeneracy where applicable.
258
+ */
259
+
260
+
261
+ // =========================================================================
262
+ // Frame construction
263
+ // =========================================================================
264
+
265
+ /**
266
+ * Rigid frame from orthonormal basis + translation.
267
+ * The primitive that lookat constructors use internally.
268
+ *
269
+ * Column-major layout: col0=right, col1=up, col2=forward, col3=translation.
270
+ *
271
+ * @param {Float32Array|number[]} out 16-element destination.
272
+ * @param {number} rx,ry,rz Right vector (col 0).
273
+ * @param {number} ux,uy,uz Up vector (col 1).
274
+ * @param {number} fx,fy,fz Forward vec (col 2).
275
+ * @param {number} tx,ty,tz Translation (col 3).
276
+ * @returns {Float32Array|number[]} out
277
+ */
278
+ function mat4FromBasis(out, rx,ry,rz, ux,uy,uz, fx,fy,fz, tx,ty,tz) {
279
+ out[0]=rx; out[1]=ry; out[2]=rz; out[3]=0;
280
+ out[4]=ux; out[5]=uy; out[6]=uz; out[7]=0;
281
+ out[8]=fx; out[9]=fy; out[10]=fz; out[11]=0;
282
+ out[12]=tx; out[13]=ty; out[14]=tz; out[15]=1;
283
+ return out;
284
+ }
285
+
286
+ /**
287
+ * View matrix (world→eye) from lookat parameters.
288
+ * Cheaper than building the eye matrix and inverting.
289
+ *
290
+ * Convention: −Z axis points toward center (camera looks along −Z in eye space).
291
+ *
292
+ * @param {Float32Array|number[]} out 16-element destination.
293
+ * @param {number} ex,ey,ez Eye (camera) position.
294
+ * @param {number} cx,cy,cz Center (look-at target).
295
+ * @param {number} ux,uy,uz World up hint (need not be unit).
296
+ * @returns {Float32Array|number[]} out
297
+ */
298
+ function mat4View(out, ex,ey,ez, cx,cy,cz, ux,uy,uz) {
299
+ // z = normalize(eye - center) (camera +Z away from target)
300
+ let zx=ex-cx, zy=ey-cy, zz=ez-cz;
301
+ const zl=Math.sqrt(zx*zx+zy*zy+zz*zz)||1;
302
+ zx/=zl; zy/=zl; zz/=zl;
303
+ // x = normalize(up × z) (right)
304
+ let xx=uy*zz-uz*zy, xy=uz*zx-ux*zz, xz=ux*zy-uy*zx;
305
+ const xl=Math.sqrt(xx*xx+xy*xy+xz*xz)||1;
306
+ xx/=xl; xy/=xl; xz/=xl;
307
+ // y = z × x (up_ortho, guaranteed perpendicular)
308
+ const yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx;
309
+ // View = [R | -R·t] (column-major)
310
+ out[0]=xx; out[1]=yx; out[2]=zx; out[3]=0;
311
+ out[4]=xy; out[5]=yy; out[6]=zy; out[7]=0;
312
+ out[8]=xz; out[9]=yz; out[10]=zz; out[11]=0;
313
+ out[12]=-(xx*ex+xy*ey+xz*ez);
314
+ out[13]=-(yx*ex+yy*ey+yz*ez);
315
+ out[14]=-(zx*ex+zy*ey+zz*ez);
316
+ out[15]=1;
317
+ return out;
318
+ }
319
+
320
+ /**
321
+ * Eye matrix (eye→world) from lookat parameters.
322
+ * Transpose of the rotation block + direct translation column.
323
+ * Same inputs as mat4View.
324
+ *
325
+ * @param {Float32Array|number[]} out 16-element destination.
326
+ * @param {number} ex,ey,ez Eye (camera) position.
327
+ * @param {number} cx,cy,cz Center (look-at target).
328
+ * @param {number} ux,uy,uz World up hint (need not be unit).
329
+ * @returns {Float32Array|number[]} out
330
+ */
331
+ function mat4Eye(out, ex,ey,ez, cx,cy,cz, ux,uy,uz) {
332
+ // Same basis computation as mat4View.
333
+ let zx=ex-cx, zy=ey-cy, zz=ez-cz;
334
+ const zl=Math.sqrt(zx*zx+zy*zy+zz*zz)||1;
335
+ zx/=zl; zy/=zl; zz/=zl;
336
+ let xx=uy*zz-uz*zy, xy=uz*zx-ux*zz, xz=ux*zy-uy*zx;
337
+ const xl=Math.sqrt(xx*xx+xy*xy+xz*xz)||1;
338
+ xx/=xl; xy/=xl; xz/=xl;
339
+ const yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx;
340
+ // Eye matrix = [R^T | t] (rotation transposed, translation = eye position)
341
+ out[0]=xx; out[1]=xy; out[2]=xz; out[3]=0;
342
+ out[4]=yx; out[5]=yy; out[6]=yz; out[7]=0;
343
+ out[8]=zx; out[9]=zy; out[10]=zz; out[11]=0;
344
+ out[12]=ex; out[13]=ey; out[14]=ez; out[15]=1;
345
+ return out;
346
+ }
347
+
348
+ // =========================================================================
349
+ // TRS construction
350
+ // =========================================================================
351
+
352
+ /**
353
+ * Column-major mat4 from flat TRS scalars.
354
+ * No struct allocation — all components passed as plain numbers.
355
+ *
356
+ * @param {Float32Array|number[]} out 16-element destination.
357
+ * @param {number} tx,ty,tz Translation.
358
+ * @param {number} qx,qy,qz,qw Rotation quaternion [x,y,z,w].
359
+ * @param {number} sx,sy,sz Scale.
360
+ * @returns {Float32Array|number[]} out
361
+ */
362
+ function mat4FromTRS(out, tx,ty,tz, qx,qy,qz,qw, sx,sy,sz) {
363
+ const x2=qx+qx,y2=qy+qy,z2=qz+qz;
364
+ const xx=qx*x2,xy=qx*y2,xz=qx*z2,yy=qy*y2,yz=qy*z2,zz=qz*z2;
365
+ const wx=qw*x2,wy=qw*y2,wz=qw*z2;
366
+ out[0]=(1-(yy+zz))*sx; out[1]=(xy+wz)*sx; out[2]=(xz-wy)*sx; out[3]=0;
367
+ out[4]=(xy-wz)*sy; out[5]=(1-(xx+zz))*sy; out[6]=(yz+wx)*sy; out[7]=0;
368
+ out[8]=(xz+wy)*sz; out[9]=(yz-wx)*sz; out[10]=(1-(xx+yy))*sz; out[11]=0;
369
+ out[12]=tx; out[13]=ty; out[14]=tz; out[15]=1;
370
+ return out;
371
+ }
372
+
373
+ /**
374
+ * Translation-only mat4.
375
+ * @param {Float32Array|number[]} out 16-element destination.
376
+ * @param {number} tx,ty,tz
377
+ * @returns {Float32Array|number[]} out
378
+ */
379
+ function mat4FromTranslation(out, tx,ty,tz) {
380
+ out[0]=1; out[1]=0; out[2]=0; out[3]=0;
381
+ out[4]=0; out[5]=1; out[6]=0; out[7]=0;
382
+ out[8]=0; out[9]=0; out[10]=1; out[11]=0;
383
+ out[12]=tx; out[13]=ty; out[14]=tz; out[15]=1;
384
+ return out;
385
+ }
386
+
387
+ /**
388
+ * Scale-only mat4.
389
+ * @param {Float32Array|number[]} out 16-element destination.
390
+ * @param {number} sx,sy,sz
391
+ * @returns {Float32Array|number[]} out
392
+ */
393
+ function mat4FromScale(out, sx,sy,sz) {
394
+ out[0]=sx; out[1]=0; out[2]=0; out[3]=0;
395
+ out[4]=0; out[5]=sy; out[6]=0; out[7]=0;
396
+ out[8]=0; out[9]=0; out[10]=sz; out[11]=0;
397
+ out[12]=0; out[13]=0; out[14]=0; out[15]=1;
398
+ return out;
399
+ }
400
+
401
+ // =========================================================================
402
+ // Projection construction
403
+ // =========================================================================
404
+
405
+ /**
406
+ * Perspective projection matrix.
407
+ *
408
+ * NDC convention: ndcZMin = WEBGL (−1) or WEBGPU (0).
409
+ * near maps to ndcZMin, far maps to +1.
410
+ *
411
+ * @param {Float32Array|number[]} out 16-element destination.
412
+ * @param {number} fov Vertical field of view (radians).
413
+ * @param {number} aspect Width / height.
414
+ * @param {number} near Near plane distance (positive).
415
+ * @param {number} far Far plane distance (positive, > near).
416
+ * @param {number} ndcZMin -1 (WEBGL) or 0 (WEBGPU).
417
+ * @returns {Float32Array|number[]} out
418
+ */
419
+ function mat4Perspective(out, fov, aspect, near, far, ndcZMin) {
420
+ const f = 1 / Math.tan(fov * 0.5);
421
+ out[0]=f/aspect; out[1]=0; out[2]=0; out[3]=0;
422
+ out[4]=0; out[5]=f; out[6]=0; out[7]=0;
423
+ out[8]=0; out[9]=0;
424
+ out[10]=(ndcZMin*near-far)/(far-near);
425
+ out[11]=-1;
426
+ out[12]=0; out[13]=0;
427
+ out[14]=(ndcZMin-1)*far*near/(far-near);
428
+ out[15]=0;
429
+ return out;
430
+ }
431
+
432
+ /**
433
+ * Orthographic projection matrix.
434
+ *
435
+ * NDC convention: ndcZMin = WEBGL (−1) or WEBGPU (0).
436
+ *
437
+ * @param {Float32Array|number[]} out 16-element destination.
438
+ * @param {number} left,right,bottom,top Frustum extents.
439
+ * @param {number} near,far Clip plane distances (positive).
440
+ * @param {number} ndcZMin -1 (WEBGL) or 0 (WEBGPU).
441
+ * @returns {Float32Array|number[]} out
442
+ */
443
+ function mat4Ortho(out, left, right, bottom, top, near, far, ndcZMin) {
444
+ const rl=1/(right-left), tb=1/(top-bottom), fn=1/(far-near);
445
+ out[0]=2*rl; out[1]=0; out[2]=0; out[3]=0;
446
+ out[4]=0; out[5]=2*tb; out[6]=0; out[7]=0;
447
+ out[8]=0; out[9]=0;
448
+ out[10]=(ndcZMin-1)*fn;
449
+ out[11]=0;
450
+ out[12]=-(right+left)*rl; out[13]=-(top+bottom)*tb;
451
+ out[14]=(ndcZMin*far-near)*fn;
452
+ out[15]=1;
453
+ return out;
454
+ }
455
+
456
+ /**
457
+ * Frustum (off-centre perspective) projection matrix.
458
+ *
459
+ * NDC convention: ndcZMin = WEBGL (−1) or WEBGPU (0).
460
+ *
461
+ * @param {Float32Array|number[]} out 16-element destination.
462
+ * @param {number} left,right,bottom,top Near-plane extents.
463
+ * @param {number} near,far Clip plane distances (positive).
464
+ * @param {number} ndcZMin -1 (WEBGL) or 0 (WEBGPU).
465
+ * @returns {Float32Array|number[]} out
466
+ */
467
+ function mat4Frustum(out, left, right, bottom, top, near, far, ndcZMin) {
468
+ const rl=1/(right-left), tb=1/(top-bottom);
469
+ out[0]=2*near*rl; out[1]=0; out[2]=0; out[3]=0;
470
+ out[4]=0; out[5]=2*near*tb; out[6]=0; out[7]=0;
471
+ out[8]=(right+left)*rl; out[9]=(top+bottom)*tb;
472
+ out[10]=(ndcZMin*near-far)/(far-near);
473
+ out[11]=-1;
474
+ out[12]=0; out[13]=0;
475
+ out[14]=(ndcZMin-1)*far*near/(far-near);
476
+ out[15]=0;
477
+ return out;
478
+ }
479
+
480
+ // =========================================================================
481
+ // Special-purpose construction
482
+ // =========================================================================
483
+
484
+ /**
485
+ * Bias matrix: remaps xyz from NDC to texture/UV space [0,1].
486
+ * xy always remap from [−1,1]; z remaps from [ndcZMin,1].
487
+ * Used to transform light-space NDC coordinates for shadow map sampling.
488
+ *
489
+ * Column-major (WebGL, ndcZMin=−1):
490
+ * [ 0.5 0 0 0.5 ]
491
+ * [ 0 0.5 0 0.5 ]
492
+ * [ 0 0 0.5 0.5 ]
493
+ * [ 0 0 0 1 ]
494
+ *
495
+ * Column-major (WebGPU, ndcZMin=0):
496
+ * [ 0.5 0 0 0.5 ]
497
+ * [ 0 0.5 0 0.5 ]
498
+ * [ 0 0 1 0 ]
499
+ * [ 0 0 0 1 ]
500
+ *
501
+ * @param {Float32Array|number[]} out 16-element destination.
502
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
503
+ * @returns {Float32Array|number[]} out
504
+ */
505
+ function mat4Bias(out, ndcZMin) {
506
+ const sz = 1 / (1 - ndcZMin);
507
+ const tz = -ndcZMin / (1 - ndcZMin);
508
+ out[0]=0.5; out[1]=0; out[2]=0; out[3]=0;
509
+ out[4]=0; out[5]=0.5; out[6]=0; out[7]=0;
510
+ out[8]=0; out[9]=0; out[10]=sz; out[11]=0;
511
+ out[12]=0.5; out[13]=0.5; out[14]=tz; out[15]=1;
512
+ return out;
513
+ }
514
+
515
+ /**
516
+ * Reflection matrix across a plane ax + by + cz = d.
517
+ * [nx, ny, nz] must be a unit normal.
518
+ *
519
+ * @param {Float32Array|number[]} out 16-element destination.
520
+ * @param {number} nx,ny,nz Unit plane normal.
521
+ * @param {number} d Plane offset (dot(point_on_plane, normal)).
522
+ * @returns {Float32Array|number[]} out
523
+ */
524
+ function mat4Reflect(out, nx,ny,nz,d) {
525
+ out[0]=1-2*nx*nx; out[1]=-2*ny*nx; out[2]=-2*nz*nx; out[3]=0;
526
+ out[4]=-2*nx*ny; out[5]=1-2*ny*ny; out[6]=-2*nz*ny; out[7]=0;
527
+ out[8]=-2*nx*nz; out[9]=-2*ny*nz; out[10]=1-2*nz*nz; out[11]=0;
528
+ out[12]=2*d*nx; out[13]=2*d*ny; out[14]=2*d*nz; out[15]=1;
529
+ return out;
530
+ }
531
+
532
+ // =========================================================================
533
+ // Partial decomposition (mat4To___ mirrors mat4From___)
534
+ // =========================================================================
535
+
536
+ /**
537
+ * Extract translation from a column-major mat4 (column 3).
538
+ * @param {Float32Array|number[]} out3 3-element destination.
539
+ * @param {Float32Array|number[]} m 16-element source.
540
+ * @returns {Float32Array|number[]} out3
541
+ */
542
+ function mat4ToTranslation(out3, m) {
543
+ out3[0]=m[12]; out3[1]=m[13]; out3[2]=m[14];
544
+ return out3;
545
+ }
546
+
547
+ /**
548
+ * Extract scale from a column-major mat4 (column lengths of rotation block).
549
+ * Assumes no shear.
550
+ * @param {Float32Array|number[]} out3 3-element destination.
551
+ * @param {Float32Array|number[]} m 16-element source.
552
+ * @returns {Float32Array|number[]} out3
553
+ */
554
+ function mat4ToScale(out3, m) {
555
+ out3[0]=Math.sqrt(m[0]*m[0]+m[1]*m[1]+m[2]*m[2]);
556
+ out3[1]=Math.sqrt(m[4]*m[4]+m[5]*m[5]+m[6]*m[6]);
557
+ out3[2]=Math.sqrt(m[8]*m[8]+m[9]*m[9]+m[10]*m[10]);
558
+ return out3;
559
+ }
560
+
561
+ /**
562
+ * Extract rotation as a unit quaternion from a column-major mat4.
563
+ * Scale is factored out from each column before extraction.
564
+ * Assumes no shear.
565
+ * @param {number[]} out4 4-element [x,y,z,w] destination.
566
+ * @param {Float32Array|number[]} m 16-element source.
567
+ * @returns {number[]} out4
568
+ */
569
+ function mat4ToRotation(out4, m) {
570
+ const sx=Math.sqrt(m[0]*m[0]+m[1]*m[1]+m[2]*m[2])||1;
571
+ const sy=Math.sqrt(m[4]*m[4]+m[5]*m[5]+m[6]*m[6])||1;
572
+ const sz=Math.sqrt(m[8]*m[8]+m[9]*m[9]+m[10]*m[10])||1;
573
+ return qFromRotMat3x3(out4,
574
+ m[0]/sx, m[4]/sy, m[8]/sz,
575
+ m[1]/sx, m[5]/sy, m[9]/sz,
576
+ m[2]/sx, m[6]/sy, m[10]/sz);
577
+ }
578
+
579
+ /**
580
+ * @file Matrix arithmetic, space-transform dispatch, and projection queries.
581
+ * @module tree/query
582
+ * @license AGPL-3.0-only
583
+ *
584
+ * The operative layer — receives existing matrices and extracts information.
585
+ * Contrast with form.js which constructs matrices from specs.
586
+ *
587
+ * form.js — you have specs, you want a matrix
588
+ * query.js — you have a matrix, you want information
42
589
  *
43
- * Multiply: mat4Mul(out, A, B) = A · B (standard math order).
590
+ * No dependency on form.js. Operating on matrices requires no knowledge
591
+ * of how they were constructed.
44
592
  *
45
- * Pipeline: clip = P · V · M · v
46
- * P = projection (eye clip)
47
- * V = view (world → eye)
48
- * M = model (local → world)
593
+ * Storage: column-major Float32Array / ArrayLike<number>.
594
+ * Element [col*4 + row] = M[row, col].
49
595
  *
50
- * PV: All functions expecting a "pv" matrix receive P · V.
51
- * This is what _worldToScreen, _ensurePV, etc. compute.
596
+ * Multiply: mat4Mul(out, A, B) = A · B (standard math order).
52
597
  *
53
- * Matrix stack (translate/rotate/scale in p5):
54
- * Each call post-multiplies: M = M · T, so:
55
- * translate(tx,ty,tz); rotateY(a); scale(s);
56
- * yields M = T · R · S. A vertex v is transformed as M·v = T·R·S·v
57
- * (scaled first, then rotated, then translated — last-written-first-applied).
598
+ * Pipeline: clip = P · V · M · v
599
+ * P = projection (eye clip)
600
+ * V = view (world eye)
601
+ * M = model (local world)
58
602
  *
59
- * p5 bridge note (for implementors of host layers):
60
- * p5.Matrix.mult(B) computes B · this (pre-multiply, arg on LEFT).
61
- * p5 translate/rotate/scale do this · T (post-multiply, GL stack).
62
- * So p5's pvMatrix() = V.clone().mult(P) = P · V — same as ours.
63
- * The bridge extracts .mat4 (Float32Array) and feeds it directly,
64
- * or uses mat4Mul(out, proj, view) for the non-cached path.
603
+ * NDC convention parameter (ndcZMin):
604
+ * WEBGL = -1 z [−1, 1]
605
+ * WEBGPU = 0 z [0, 1]
65
606
  *
66
- * Every function uses only stack locals for intermediates (zero shared state).
67
- * Every mutating function writes to a caller-provided `out` and returns `out`.
607
+ * All functions follow the out-first, zero-allocation contract.
68
608
  * Returns null on degeneracy (singular matrix, etc.).
69
609
  */
70
610
 
@@ -193,6 +733,24 @@ function mat4MulPoint(out, m, x, y, z) {
193
733
  return out;
194
734
  }
195
735
 
736
+ /**
737
+ * Apply only the 3×3 linear block of a mat4 to a direction vector.
738
+ * No translation, no perspective divide. Suitable for directions and normals
739
+ * when the matrix is known to be orthogonal (use mat3NormalFromMat4 for normals
740
+ * under non-uniform scale).
741
+ *
742
+ * @param {Float32Array|number[]} out 3-element destination.
743
+ * @param {Float32Array|number[]} m 16-element mat4.
744
+ * @param {number} dx,dy,dz Input direction.
745
+ * @returns {Float32Array|number[]} out
746
+ */
747
+ function mat4MulDir(out, m, dx, dy, dz) {
748
+ out[0] = m[0]*dx + m[4]*dy + m[8]*dz;
749
+ out[1] = m[1]*dx + m[5]*dy + m[9]*dz;
750
+ out[2] = m[2]*dx + m[6]*dy + m[10]*dz;
751
+ return out;
752
+ }
753
+
196
754
  // ═══════════════════════════════════════════════════════════════════════════
197
755
  // Projection queries (read scalars from a projection mat4)
198
756
  // ═══════════════════════════════════════════════════════════════════════════
@@ -320,13 +878,13 @@ function mat3Direction(out, from, to) {
320
878
  //
321
879
  // Matrices bag m:
322
880
  // {
323
- // pMatrix: Float32Array(16) — projection (eye → clip)
324
- // vMatrix: Float32Array(16) — view (world → eye)
325
- // eMatrix?: Float32Array(16) — eye (eye → world, inv view); lazy
326
- // pvMatrix?: Float32Array(16) — P · V; lazy
327
- // ipvMatrix?:Float32Array(16) — inv(P · V); lazy
328
- // fromFrame?:Float32Array(16) — MATRIX source frame (custom space)
329
- // toFrameInv?:Float32Array(16) — inv(MATRIX dest frame)
881
+ // pMatrix: Float32Array(16) — projection (eye → clip)
882
+ // vMatrix: Float32Array(16) — view (world → eye)
883
+ // eMatrix?: Float32Array(16) — eye (eye → world, inv view); lazy
884
+ // pvMatrix?: Float32Array(16) — P · V; lazy
885
+ // ipvMatrix?: Float32Array(16) — inv(P · V); lazy
886
+ // fromFrame?: Float32Array(16) — MATRIX source frame (custom space)
887
+ // toFrameInv?:Float32Array(16) — inv(MATRIX dest frame)
330
888
  // }
331
889
  //
332
890
 
@@ -337,90 +895,62 @@ function _worldToScreen(out, px, py, pz, pv, vp, ndcZMin) {
337
895
  const y = pv[1]*px+pv[5]*py+pv[9]*pz+pv[13];
338
896
  const z = pv[2]*px+pv[6]*py+pv[10]*pz+pv[14];
339
897
  const w = pv[3]*px+pv[7]*py+pv[11]*pz+pv[15];
340
- const xi = (w !== 0 && w !== 1) ? x/w : x;
341
- const yi = (w !== 0 && w !== 1) ? y/w : y;
342
- const zi = (w !== 0 && w !== 1) ? z/w : z;
343
- const ndcZRange = 1 - ndcZMin;
344
- out[0] = (xi*0.5+0.5)*vp[2]+vp[0];
345
- out[1] = (yi*0.5+0.5)*vp[3]+vp[1];
346
- out[2] = (zi - ndcZMin) / ndcZRange;
898
+ const xi = (w !== 0 && w !== 1) ? 1/w : 1;
899
+ const nx = x*xi, ny = y*xi, nz = z*xi;
900
+ const vpX=vp[0], vpY=vp[1], vpW=Math.abs(vp[2]), vpH=Math.abs(vp[3]);
901
+ out[0] = vpX + vpW * (nx + 1) * 0.5;
902
+ out[1] = vpY + vpH * (1 - (ny + 1) * 0.5);
903
+ out[2] = (nz - ndcZMin) / (1 - ndcZMin);
347
904
  return out;
348
905
  }
349
906
 
350
- function _screenToWorld(out, px, py, pz, ipv, vp, ndcZMin) {
351
- const ndcZRange = 1 - ndcZMin;
352
- const nx = ((px-vp[0])/vp[2])*2-1;
353
- const ny = ((py-vp[1])/vp[3])*2-1;
354
- const nz = pz * ndcZRange + ndcZMin;
355
- const x = ipv[0]*nx+ipv[4]*ny+ipv[8]*nz+ipv[12];
356
- const y = ipv[1]*nx+ipv[5]*ny+ipv[9]*nz+ipv[13];
357
- const z = ipv[2]*nx+ipv[6]*ny+ipv[10]*nz+ipv[14];
358
- const w = ipv[3]*nx+ipv[7]*ny+ipv[11]*nz+ipv[15];
359
- out[0]=x/w; out[1]=y/w; out[2]=z/w;
360
- return out;
907
+ function _screenToWorld(out, sx, sy, sz, ipv, vp, ndcZMin) {
908
+ const vpX=vp[0], vpY=vp[1], vpW=Math.abs(vp[2]), vpH=Math.abs(vp[3]);
909
+ const nx = (sx - vpX) / vpW * 2 - 1;
910
+ const ny = 1 - (sy - vpY) / vpH * 2;
911
+ const nz = sz * (1 - ndcZMin) + ndcZMin;
912
+ return mat4MulPoint(out, ipv, nx, ny, nz);
361
913
  }
362
914
 
363
915
  function _worldToNDC(out, px, py, pz, pv) {
364
- const x = pv[0]*px+pv[4]*py+pv[8]*pz+pv[12];
365
- const y = pv[1]*px+pv[5]*py+pv[9]*pz+pv[13];
366
- const z = pv[2]*px+pv[6]*py+pv[10]*pz+pv[14];
367
- const w = pv[3]*px+pv[7]*py+pv[11]*pz+pv[15];
368
- out[0]=x/w; out[1]=y/w; out[2]=z/w;
916
+ const x=pv[0]*px+pv[4]*py+pv[8]*pz+pv[12];
917
+ const y=pv[1]*px+pv[5]*py+pv[9]*pz+pv[13];
918
+ const z=pv[2]*px+pv[6]*py+pv[10]*pz+pv[14];
919
+ const w=pv[3]*px+pv[7]*py+pv[11]*pz+pv[15];
920
+ const xi = (w !== 0 && w !== 1) ? 1/w : 1;
921
+ out[0]=x*xi; out[1]=y*xi; out[2]=z*xi;
369
922
  return out;
370
923
  }
371
924
 
372
- function _ndcToWorld(out, px, py, pz, ipv) {
373
- const x = ipv[0]*px+ipv[4]*py+ipv[8]*pz+ipv[12];
374
- const y = ipv[1]*px+ipv[5]*py+ipv[9]*pz+ipv[13];
375
- const z = ipv[2]*px+ipv[6]*py+ipv[10]*pz+ipv[14];
376
- const w = ipv[3]*px+ipv[7]*py+ipv[11]*pz+ipv[15];
377
- out[0]=x/w; out[1]=y/w; out[2]=z/w;
378
- return out;
925
+ function _ndcToWorld(out, nx, ny, nz, ipv) {
926
+ return mat4MulPoint(out, ipv, nx, ny, nz);
379
927
  }
380
928
 
381
- function _screenToNDC(out, px, py, pz, vp, ndcZMin) {
382
- const ndcZRange = 1 - ndcZMin;
383
- out[0] = ((px-vp[0])/vp[2])*2-1;
384
- out[1] = ((py-vp[1])/vp[3])*2-1;
385
- out[2] = pz * ndcZRange + ndcZMin;
929
+ function _screenToNDC(out, sx, sy, sz, vp, ndcZMin) {
930
+ const vpX=vp[0], vpY=vp[1], vpW=Math.abs(vp[2]), vpH=Math.abs(vp[3]);
931
+ out[0] = (sx - vpX) / vpW * 2 - 1;
932
+ out[1] = 1 - (sy - vpY) / vpH * 2;
933
+ out[2] = sz * (1 - ndcZMin) + ndcZMin;
386
934
  return out;
387
935
  }
388
936
 
389
- function _ndcToScreen(out, px, py, pz, vp, ndcZMin) {
390
- const ndcZRange = 1 - ndcZMin;
391
- out[0] = (px*0.5+0.5)*vp[2]+vp[0];
392
- out[1] = (py*0.5+0.5)*vp[3]+vp[1];
393
- out[2] = (pz - ndcZMin) / ndcZRange;
937
+ function _ndcToScreen(out, nx, ny, nz, vp, ndcZMin) {
938
+ const vpX=vp[0], vpY=vp[1], vpW=Math.abs(vp[2]), vpH=Math.abs(vp[3]);
939
+ out[0] = vpX + vpW * (nx + 1) * 0.5;
940
+ out[1] = vpY + vpH * (1 - (ny + 1) * 0.5);
941
+ out[2] = (nz - ndcZMin) / (1 - ndcZMin);
394
942
  return out;
395
943
  }
396
944
 
397
- // ── _ensurePV — return pvMatrix from bag, computing inline if absent ──────
398
-
399
945
  function _ensurePV(m) {
400
946
  if (m.pvMatrix) return m.pvMatrix;
401
- const p = m.pMatrix, v = m.vMatrix;
402
- return [
403
- p[0]*v[0]+p[4]*v[1]+p[8]*v[2]+p[12]*v[3],
404
- p[1]*v[0]+p[5]*v[1]+p[9]*v[2]+p[13]*v[3],
405
- p[2]*v[0]+p[6]*v[1]+p[10]*v[2]+p[14]*v[3],
406
- p[3]*v[0]+p[7]*v[1]+p[11]*v[2]+p[15]*v[3],
407
- p[0]*v[4]+p[4]*v[5]+p[8]*v[6]+p[12]*v[7],
408
- p[1]*v[4]+p[5]*v[5]+p[9]*v[6]+p[13]*v[7],
409
- p[2]*v[4]+p[6]*v[5]+p[10]*v[6]+p[14]*v[7],
410
- p[3]*v[4]+p[7]*v[5]+p[11]*v[6]+p[15]*v[7],
411
- p[0]*v[8]+p[4]*v[9]+p[8]*v[10]+p[12]*v[11],
412
- p[1]*v[8]+p[5]*v[9]+p[9]*v[10]+p[13]*v[11],
413
- p[2]*v[8]+p[6]*v[9]+p[10]*v[10]+p[14]*v[11],
414
- p[3]*v[8]+p[7]*v[9]+p[11]*v[10]+p[15]*v[11],
415
- p[0]*v[12]+p[4]*v[13]+p[8]*v[14]+p[12]*v[15],
416
- p[1]*v[12]+p[5]*v[13]+p[9]*v[14]+p[13]*v[15],
417
- p[2]*v[12]+p[6]*v[13]+p[10]*v[14]+p[14]*v[15],
418
- p[3]*v[12]+p[7]*v[13]+p[11]*v[14]+p[15]*v[15],
419
- ];
947
+ m.pvMatrix = new Float32Array(16);
948
+ mat4Mul(m.pvMatrix, m.pMatrix, m.vMatrix);
949
+ return m.pvMatrix;
420
950
  }
421
951
 
422
952
  /**
423
- * Map a point between coordinate spaces.
953
+ * Map a point between named coordinate spaces.
424
954
  *
425
955
  * @param {Vec3} out Result written here.
426
956
  * @param {number} px,py,pz Input point.
@@ -548,90 +1078,55 @@ function mapLocation(out, px, py, pz, from, to, m, vp, ndcZMin) {
548
1078
  return out;
549
1079
  }
550
1080
 
551
- // ── Direction helpers ────────────────────────────────────────────────────
1081
+ // ── Direction leaf helpers ───────────────────────────────────────────────
552
1082
 
553
1083
  /** Apply the 3×3 linear part of a mat4 (rotation/scale, no translation). */
554
- function _applyDir(out, mat, dx, dy, dz) {
555
- out[0]=mat[0]*dx+mat[4]*dy+mat[8]*dz;
556
- out[1]=mat[1]*dx+mat[5]*dy+mat[9]*dz;
557
- out[2]=mat[2]*dx+mat[6]*dy+mat[10]*dz;
1084
+ function _applyDir(out, m, dx, dy, dz) {
1085
+ out[0]=m[0]*dx+m[4]*dy+m[8]*dz;
1086
+ out[1]=m[1]*dx+m[5]*dy+m[9]*dz;
1087
+ out[2]=m[2]*dx+m[6]*dy+m[10]*dz;
558
1088
  return out;
559
1089
  }
560
1090
 
561
1091
  function _worldToScreenDir(out, dx, dy, dz, proj, view, vpW, vpH, ndcZMin) {
562
- const edx = view[0]*dx + view[4]*dy + view[8]*dz;
563
- const edy = view[1]*dx + view[5]*dy + view[9]*dz;
564
- const edz = view[2]*dx + view[6]*dy + view[10]*dz;
565
- const isPersp = proj[15] === 0;
566
- let sdx = edx, sdy = edy;
567
- if (isPersp) {
568
- const zEye = view[14];
569
- const halfTan = Math.tan(projFov(proj) / 2);
570
- const k = Math.abs(zEye * halfTan);
571
- const pixPerUnit = vpH / (2 * k);
572
- sdx *= pixPerUnit;
573
- sdy *= pixPerUnit;
574
- } else {
575
- const orthoW = Math.abs(projRight(proj, ndcZMin) - projLeft(proj, ndcZMin));
576
- sdx *= vpW / orthoW;
577
- sdy *= vpH / Math.abs(projTop(proj, ndcZMin) - projBottom(proj, ndcZMin));
578
- }
579
- const near = projNear(proj, ndcZMin), far = projFar(proj);
580
- const depthRange = near - far;
581
- let sdz;
582
- if (isPersp) {
583
- sdz = edz / (depthRange / Math.tan(projFov(proj) / 2));
584
- } else {
585
- sdz = edz / (depthRange / (Math.abs(projRight(proj, ndcZMin) - projLeft(proj, ndcZMin)) / vpW));
586
- }
587
- out[0] = sdx; out[1] = sdy; out[2] = sdz;
1092
+ // Transform to clip space (no w divide for direction).
1093
+ const vx=view[0]*dx+view[4]*dy+view[8]*dz;
1094
+ const vy=view[1]*dx+view[5]*dy+view[9]*dz;
1095
+ const vz=view[2]*dx+view[6]*dy+view[10]*dz;
1096
+ const cx=proj[0]*vx+proj[4]*vy+proj[8]*vz;
1097
+ const cy=proj[1]*vx+proj[5]*vy+proj[9]*vz;
1098
+ const cz=proj[2]*vx+proj[6]*vy+proj[10]*vz;
1099
+ // NDC→screen scale (direction, no offset).
1100
+ out[0]=cx*vpW*0.5; out[1]=-cy*vpH*0.5;
1101
+ out[2]=cz*(1-ndcZMin)*0.5;
588
1102
  return out;
589
1103
  }
590
1104
 
591
- function _screenToWorldDir(out, dx, dy, dz, proj, view, eye, vpW, vpH, ndcZMin) {
592
- const isPersp = proj[15] === 0;
593
- let edx = dx, edy = dy;
594
- if (isPersp) {
595
- const zEye = view[14];
596
- const halfTan = Math.tan(projFov(proj) / 2);
597
- const k = Math.abs(zEye * halfTan);
598
- edx *= 2 * k / vpH;
599
- edy *= 2 * k / vpH;
600
- } else {
601
- const orthoW = Math.abs(projRight(proj, ndcZMin) - projLeft(proj, ndcZMin));
602
- edx *= orthoW / vpW;
603
- edy *= Math.abs(projTop(proj, ndcZMin) - projBottom(proj, ndcZMin)) / vpH;
604
- }
605
- const near = projNear(proj, ndcZMin), far = projFar(proj);
606
- const depthRange = near - far;
607
- let edz;
608
- if (isPersp) {
609
- edz = dz * (depthRange / Math.tan(projFov(proj) / 2));
610
- } else {
611
- edz = dz * (depthRange / (Math.abs(projRight(proj, ndcZMin) - projLeft(proj, ndcZMin)) / vpW));
612
- }
613
- _applyDir(out, eye, edx, edy, edz);
1105
+ function _screenToWorldDir(out, dx, dy, dz, proj, eMatrix, vpW, vpH, ndcZMin) {
1106
+ // Screen direction NDC direction.
1107
+ const nx=dx/(vpW*0.5), ny=-dy/(vpH*0.5);
1108
+ const nz=dz/((1-ndcZMin)*0.5);
1109
+ // NDC direction → eye direction (inverse projection, linear only).
1110
+ const ex=nx/proj[0], ey=ny/proj[5], ez=nz;
1111
+ // Eye direction world direction.
1112
+ _applyDir(out, eMatrix, ex, ey, ez);
614
1113
  return out;
615
1114
  }
616
1115
 
617
1116
  function _screenToNDCDir(out, dx, dy, dz, vpW, vpH, ndcZMin) {
618
- const ndcZRange = 1 - ndcZMin;
619
- out[0] = 2 * dx / vpW;
620
- out[1] = 2 * dy / vpH;
621
- out[2] = dz * ndcZRange;
1117
+ out[0]=dx/(vpW*0.5); out[1]=-dy/(vpH*0.5);
1118
+ out[2]=dz/((1-ndcZMin)*0.5);
622
1119
  return out;
623
1120
  }
624
1121
 
625
1122
  function _ndcToScreenDir(out, dx, dy, dz, vpW, vpH, ndcZMin) {
626
- const ndcZRange = 1 - ndcZMin;
627
- out[0] = vpW * dx / 2;
628
- out[1] = vpH * dy / 2;
629
- out[2] = dz / ndcZRange;
1123
+ out[0]=dx*vpW*0.5; out[1]=-dy*vpH*0.5;
1124
+ out[2]=dz*(1-ndcZMin)*0.5;
630
1125
  return out;
631
1126
  }
632
1127
 
633
1128
  /**
634
- * Map a direction vector between coordinate spaces.
1129
+ * Map a direction between named coordinate spaces.
635
1130
  * Same bag contract as mapLocation.
636
1131
  */
637
1132
  function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
@@ -645,7 +1140,7 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
645
1140
  if (from === WORLD && to === SCREEN)
646
1141
  return _worldToScreenDir(out, dx,dy,dz, m.pMatrix, m.vMatrix, vpW, vpH, ndcZMin);
647
1142
  if (from === SCREEN && to === WORLD)
648
- return _screenToWorldDir(out, dx,dy,dz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
1143
+ return _screenToWorldDir(out, dx,dy,dz, m.pMatrix, m.eMatrix, vpW, vpH, ndcZMin);
649
1144
 
650
1145
  // SCREEN ↔ NDC
651
1146
  if (from === SCREEN && to === NDC)
@@ -662,7 +1157,7 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
662
1157
  if (from === NDC && to === WORLD) {
663
1158
  _ndcToScreenDir(out, dx,dy,dz, vpW, vpH, ndcZMin);
664
1159
  const sx=out[0],sy=out[1],sz=out[2];
665
- return _screenToWorldDir(out, sx,sy,sz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
1160
+ return _screenToWorldDir(out, sx,sy,sz, m.pMatrix, m.eMatrix, vpW, vpH, ndcZMin);
666
1161
  }
667
1162
 
668
1163
  // EYE ↔ SCREEN
@@ -672,7 +1167,7 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
672
1167
  return _worldToScreenDir(out, wx,wy,wz, m.pMatrix, m.vMatrix, vpW, vpH, ndcZMin);
673
1168
  }
674
1169
  if (from === SCREEN && to === EYE) {
675
- _screenToWorldDir(out, dx,dy,dz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
1170
+ _screenToWorldDir(out, dx,dy,dz, m.pMatrix, m.eMatrix, vpW, vpH, ndcZMin);
676
1171
  const wx=out[0],wy=out[1],wz=out[2];
677
1172
  return _applyDir(out, m.vMatrix, wx,wy,wz);
678
1173
  }
@@ -688,7 +1183,7 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
688
1183
  if (from === NDC && to === EYE) {
689
1184
  _ndcToScreenDir(out, dx,dy,dz, vpW, vpH, ndcZMin);
690
1185
  const sx=out[0],sy=out[1],sz=out[2];
691
- _screenToWorldDir(out, sx,sy,sz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
1186
+ _screenToWorldDir(out, sx,sy,sz, m.pMatrix, m.eMatrix, vpW, vpH, ndcZMin);
692
1187
  const wx=out[0],wy=out[1],wz=out[2];
693
1188
  return _applyDir(out, m.vMatrix, wx,wy,wz);
694
1189
  }
@@ -716,7 +1211,7 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
716
1211
  return _worldToScreenDir(out, wx,wy,wz, m.pMatrix, m.vMatrix, vpW, vpH, ndcZMin);
717
1212
  }
718
1213
  if (from === SCREEN && to === MATRIX) {
719
- _screenToWorldDir(out, dx,dy,dz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
1214
+ _screenToWorldDir(out, dx,dy,dz, m.pMatrix, m.eMatrix, vpW, vpH, ndcZMin);
720
1215
  const wx=out[0],wy=out[1],wz=out[2];
721
1216
  return _applyDir(out, m.toFrameInv, wx,wy,wz);
722
1217
  }
@@ -732,7 +1227,7 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
732
1227
  if (from === NDC && to === MATRIX) {
733
1228
  _ndcToScreenDir(out, dx,dy,dz, vpW, vpH, ndcZMin);
734
1229
  const sx=out[0],sy=out[1],sz=out[2];
735
- _screenToWorldDir(out, sx,sy,sz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
1230
+ _screenToWorldDir(out, sx,sy,sz, m.pMatrix, m.eMatrix, vpW, vpH, ndcZMin);
736
1231
  const wx=out[0],wy=out[1],wz=out[2];
737
1232
  return _applyDir(out, m.toFrameInv, wx,wy,wz);
738
1233
  }
@@ -791,14 +1286,13 @@ function pixelRatio(proj, vpH, eyeZ, ndcZMin) {
791
1286
  * @param {number} H Canvas height (CSS pixels).
792
1287
  * @returns {Float32Array} proj (same reference)
793
1288
  */
794
- function applyPickMatrix(proj, px, py, W, H) {
1289
+ function mat4Pick(proj, px, py, W, H) {
795
1290
  const cx = 2 * (px + 0.5) / W - 1;
796
- const cy = -2 * (py + 0.5) / H + 1; // Y flip: screen-down → NDC-up
1291
+ const cy = -2 * (py + 0.5) / H + 1;
797
1292
  const sx = W;
798
1293
  const sy = H;
799
1294
  const tx = -cx * W;
800
1295
  const ty = -cy * H;
801
- // P_pick = M_pick * P_orig (rows 2 and 3 are unchanged)
802
1296
  for (let j = 0; j < 4; j++) {
803
1297
  const a = proj[j * 4];
804
1298
  const b = proj[j * 4 + 1];
@@ -810,14 +1304,19 @@ function applyPickMatrix(proj, px, py, W, H) {
810
1304
  }
811
1305
 
812
1306
  /**
813
- * @file Pure quaternion/spline math + track state machines.
1307
+ * @file Spline math and keyframe animation state machines.
814
1308
  * @module tree/track
815
1309
  * @license AGPL-3.0-only
816
1310
  *
817
- * Zero dependencies. No p5, DOM, WebGL, or WebGPU usage.
1311
+ * Quaternion algebra is provided by quat.js this module imports and uses
1312
+ * it but does not define it. Spline helpers (hermiteVec3, lerpVec3) and
1313
+ * TRS↔mat4 conversions (transformToMat4, mat4ToTransform) remain here
1314
+ * because they are tightly coupled to the PoseTrack keyframe shape.
1315
+ *
1316
+ * Zero dependencies on p5, DOM, WebGL, or WebGPU.
818
1317
  *
819
1318
  * ── Exports ──────────────────────────────────────────────────────────────────
820
- * Quaternion helpers
1319
+ * Quaternion helpers (re-exported from quat.js)
821
1320
  * qSet qCopy qDot qNormalize qNegate qMul qSlerp qNlerp
822
1321
  * qFromAxisAngle qFromLookDir qFromRotMat3x3 qFromMat4 qToMat4
823
1322
  * quatToAxisAngle
@@ -857,13 +1356,12 @@ function applyPickMatrix(proj, px, py, W, H) {
857
1356
  * reset() → onStop → _onStop → _onDeactivate
858
1357
  *
859
1358
  * ── Loop modes ────────────────────────────────────────────────────────────────
860
- * once loop=false, bounce=false — stop at end (fires onEnd)
861
- * repeat loop=true, bounce=false — wrap back to start
862
- * bounce loop=true, bounce=true — bounce at boundaries
1359
+ * loop:false, bounce:false — play once, stop at end (fires onEnd)
1360
+ * loop:true, bounce:false — repeat, wrap back to start
1361
+ * loop:true, bounce:true — bounce forever at boundaries
1362
+ * loop:false, bounce:true — bounce once: flip at far boundary, stop at origin
863
1363
  *
864
- * Exclusivity enforced in play():
865
- * bounce: true → loop is also set true
866
- * loop: false → bounce is also cleared
1364
+ * bounce and loop are fully independent flags — no exclusivity enforced.
867
1365
  *
868
1366
  * ── Playback semantics (rate + _dir) ─────────────────────────────────────────
869
1367
  * rate > 0 forward
@@ -886,175 +1384,6 @@ function applyPickMatrix(proj, px, py, W, H) {
886
1384
  */
887
1385
 
888
1386
 
889
- // =========================================================================
890
- // S1 Quaternion helpers (flat [x, y, z, w], w-last)
891
- // =========================================================================
892
-
893
- /** Set all four components. @returns {number[]} out */
894
- const qSet = (out, x, y, z, w) => {
895
- out[0] = x; out[1] = y; out[2] = z; out[3] = w; return out;
896
- };
897
-
898
- /** Copy quaternion a into out. @returns {number[]} out */
899
- const qCopy = (out, a) => {
900
- out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; return out;
901
- };
902
-
903
- /** Dot product of two quaternions. */
904
- const qDot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
905
-
906
- /** Normalise quaternion in-place. @returns {number[]} out */
907
- const qNormalize = (out) => {
908
- const l = Math.sqrt(out[0]*out[0]+out[1]*out[1]+out[2]*out[2]+out[3]*out[3]) || 1;
909
- out[0]/=l; out[1]/=l; out[2]/=l; out[3]/=l; return out;
910
- };
911
-
912
- /** Negate quaternion (same rotation, different hemisphere). @returns {number[]} out */
913
- const qNegate = (out, a) => {
914
- out[0]=-a[0]; out[1]=-a[1]; out[2]=-a[2]; out[3]=-a[3]; return out;
915
- };
916
-
917
- /** Hamilton product out = a * b. @returns {number[]} out */
918
- const qMul = (out, a, b) => {
919
- 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];
920
- out[0]=aw*bx+ax*bw+ay*bz-az*by;
921
- out[1]=aw*by-ax*bz+ay*bw+az*bx;
922
- out[2]=aw*bz+ax*by-ay*bx+az*bw;
923
- out[3]=aw*bw-ax*bx-ay*by-az*bz;
924
- return out;
925
- };
926
-
927
- /** Spherical linear interpolation. @returns {number[]} out */
928
- const qSlerp = (out, a, b, t) => {
929
- let bx=b[0],by=b[1],bz=b[2],bw=b[3];
930
- let d = a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw;
931
- if (d < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; d=-d; }
932
- let f0, f1;
933
- if (1-d > 1e-10) {
934
- const th=Math.acos(d), st=Math.sin(th);
935
- f0=Math.sin((1-t)*th)/st; f1=Math.sin(t*th)/st;
936
- } else {
937
- f0=1-t; f1=t;
938
- }
939
- out[0]=a[0]*f0+bx*f1; out[1]=a[1]*f0+by*f1;
940
- out[2]=a[2]*f0+bz*f1; out[3]=a[3]*f0+bw*f1;
941
- return qNormalize(out);
942
- };
943
-
944
- /**
945
- * Normalised linear interpolation (nlerp).
946
- * Cheaper than slerp; slightly non-constant angular velocity.
947
- * Handles antipodal quats by flipping b when dot < 0.
948
- * @returns {number[]} out
949
- */
950
- const qNlerp = (out, a, b, t) => {
951
- let bx=b[0],by=b[1],bz=b[2],bw=b[3];
952
- if (a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; }
953
- out[0]=a[0]+t*(bx-a[0]); out[1]=a[1]+t*(by-a[1]);
954
- out[2]=a[2]+t*(bz-a[2]); out[3]=a[3]+t*(bw-a[3]);
955
- return qNormalize(out);
956
- };
957
-
958
- /**
959
- * Build a quaternion from axis-angle.
960
- * @param {number[]} out
961
- * @param {number} ax @param {number} ay @param {number} az Axis (need not be unit).
962
- * @param {number} angle Radians.
963
- * @returns {number[]} out
964
- */
965
- const qFromAxisAngle = (out, ax, ay, az, angle) => {
966
- const half = angle * 0.5;
967
- const s = Math.sin(half);
968
- const len = Math.sqrt(ax*ax + ay*ay + az*az) || 1;
969
- out[0] = s * ax / len; out[1] = s * ay / len; out[2] = s * az / len;
970
- out[3] = Math.cos(half);
971
- return out;
972
- };
973
-
974
- /**
975
- * Build a quaternion from a look direction (−Z forward) and optional up (default +Y).
976
- * @param {number[]} out
977
- * @param {number[]} dir Forward direction [x,y,z].
978
- * @param {number[]} [up] Up vector [x,y,z].
979
- * @returns {number[]} out
980
- */
981
- const qFromLookDir = (out, dir, up) => {
982
- let fx=dir[0],fy=dir[1],fz=dir[2];
983
- const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
984
- fx/=fl; fy/=fl; fz/=fl;
985
- let ux=up?up[0]:0, uy=up?up[1]:1, uz=up?up[2]:0;
986
- let rx=uy*fz-uz*fy, ry=uz*fx-ux*fz, rz=ux*fy-uy*fx;
987
- const rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1;
988
- rx/=rl; ry/=rl; rz/=rl;
989
- ux=fy*rz-fz*ry; uy=fz*rx-fx*rz; uz=fx*ry-fy*rx;
990
- return qFromRotMat3x3(out, rx,ry,rz, ux,uy,uz, -fx,-fy,-fz);
991
- };
992
-
993
- /**
994
- * Build a quaternion from a 3×3 rotation matrix (9 row-major scalars).
995
- * @returns {number[]} out (normalised)
996
- */
997
- const qFromRotMat3x3 = (out, m00,m01,m02, m10,m11,m12, m20,m21,m22) => {
998
- const tr = m00+m11+m22;
999
- if (tr > 0) {
1000
- const s=0.5/Math.sqrt(tr+1);
1001
- out[3]=0.25/s; out[0]=(m21-m12)*s; out[1]=(m02-m20)*s; out[2]=(m10-m01)*s;
1002
- } else if (m00>m11 && m00>m22) {
1003
- const s=2*Math.sqrt(1+m00-m11-m22);
1004
- out[3]=(m21-m12)/s; out[0]=0.25*s; out[1]=(m01+m10)/s; out[2]=(m02+m20)/s;
1005
- } else if (m11>m22) {
1006
- const s=2*Math.sqrt(1+m11-m00-m22);
1007
- out[3]=(m02-m20)/s; out[0]=(m01+m10)/s; out[1]=0.25*s; out[2]=(m12+m21)/s;
1008
- } else {
1009
- const s=2*Math.sqrt(1+m22-m00-m11);
1010
- out[3]=(m10-m01)/s; out[0]=(m02+m20)/s; out[1]=(m12+m21)/s; out[2]=0.25*s;
1011
- }
1012
- return qNormalize(out);
1013
- };
1014
-
1015
- /**
1016
- * Extract a unit quaternion from the upper-left 3×3 of a column-major mat4.
1017
- * @param {number[]} out
1018
- * @param {Float32Array|number[]} m Column-major mat4.
1019
- * @returns {number[]} out
1020
- */
1021
- const qFromMat4 = (out, m) =>
1022
- qFromRotMat3x3(out, m[0],m[4],m[8], m[1],m[5],m[9], m[2],m[6],m[10]);
1023
-
1024
- /**
1025
- * Write a quaternion into the rotation block of a column-major mat4.
1026
- * Translation and perspective rows/cols are set to identity values.
1027
- * @param {Float32Array|number[]} out 16-element array.
1028
- * @param {number[]} q [x,y,z,w].
1029
- * @returns {Float32Array|number[]} out
1030
- */
1031
- const qToMat4 = (out, q) => {
1032
- const x=q[0],y=q[1],z=q[2],w=q[3];
1033
- const x2=x+x,y2=y+y,z2=z+z;
1034
- 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;
1035
- out[0]=1-(yy+zz); out[1]=xy+wz; out[2]=xz-wy; out[3]=0;
1036
- out[4]=xy-wz; out[5]=1-(xx+zz); out[6]=yz+wx; out[7]=0;
1037
- out[8]=xz+wy; out[9]=yz-wx; out[10]=1-(xx+yy); out[11]=0;
1038
- out[12]=0; out[13]=0; out[14]=0; out[15]=1;
1039
- return out;
1040
- };
1041
-
1042
- /**
1043
- * Decompose a unit quaternion into { axis:[x,y,z], angle } (radians).
1044
- * @param {number[]} q [x,y,z,w].
1045
- * @param {Object} [out]
1046
- * @returns {{ axis: number[], angle: number }}
1047
- */
1048
- const quatToAxisAngle = (q, out) => {
1049
- out = out || {};
1050
- const x=q[0],y=q[1],z=q[2],w=q[3];
1051
- const sinHalf = Math.sqrt(x*x+y*y+z*z);
1052
- if (sinHalf < 1e-8) { out.axis=[0,1,0]; out.angle=0; return out; }
1053
- out.angle = 2*Math.atan2(sinHalf, w);
1054
- out.axis = [x/sinHalf, y/sinHalf, z/sinHalf];
1055
- return out;
1056
- };
1057
-
1058
1387
  // =========================================================================
1059
1388
  // S2 Spline / vector helpers
1060
1389
  // =========================================================================
@@ -1086,14 +1415,13 @@ const hermiteVec3 = (out, p0, m0, p1, m1, t) => {
1086
1415
 
1087
1416
  // Centripetal CR outgoing tangent at p1 for segment p1→p2, scaled by dt1.
1088
1417
  const _crTanOut = (out, p0, p1, p2, p3) => {
1089
- const dt0=Math.pow(_dist3(p0,p1),0.5)||1, dt1=Math.pow(_dist3(p1,p2),0.5)||1; Math.pow(_dist3(p2,p3),0.5)||1;
1418
+ const dt0=Math.pow(_dist3(p0,p1),0.5)||1, dt1=Math.pow(_dist3(p1,p2),0.5)||1;
1090
1419
  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;
1091
1420
  return out;
1092
1421
  };
1093
1422
 
1094
- // Centripetal CR incoming tangent at p2 for segment p1→p2, scaled by dt1.
1095
1423
  const _crTanIn = (out, p0, p1, p2, p3) => {
1096
- Math.pow(_dist3(p0,p1),0.5)||1; const dt1=Math.pow(_dist3(p1,p2),0.5)||1, dt2=Math.pow(_dist3(p2,p3),0.5)||1;
1424
+ const dt1=Math.pow(_dist3(p1,p2),0.5)||1, dt2=Math.pow(_dist3(p2,p3),0.5)||1;
1097
1425
  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;
1098
1426
  return out;
1099
1427
  };
@@ -1181,33 +1509,13 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1181
1509
  *
1182
1510
  * Accepted forms:
1183
1511
  *
1184
- * [x,y,z,w]
1185
- * Raw quaternion array.
1186
- *
1187
- * { axis:[x,y,z], angle }
1188
- * Axis-angle. Axis need not be unit.
1189
- *
1190
- * { dir:[x,y,z], up?:[x,y,z] }
1191
- * Object orientation — forward direction (−Z) with optional up hint.
1192
- *
1193
- * { eMatrix: mat4 }
1194
- * Extract rotation block from an eye (eye→world) matrix.
1195
- * Column-major Float32Array(16), plain Array, or { mat4 } wrapper.
1196
- *
1197
- * { mat3: mat3 }
1198
- * Column-major 3×3 rotation matrix — Float32Array(9) or plain Array.
1199
- *
1200
- * { euler:[rx,ry,rz], order?:'YXZ' }
1201
- * Intrinsic Euler angles (radians). Angles are indexed by order position:
1202
- * e[0] rotates around order[0] axis, e[1] around order[1], e[2] around order[2].
1203
- * Supported orders: YXZ (default), XYZ, ZYX, ZXY, XZY, YZX.
1204
- * Note: intrinsic ABC = extrinsic CBA with the same angles — to use
1205
- * extrinsic order ABC, reverse the string and use intrinsic CBA.
1206
- *
1207
- * { from:[x,y,z], to:[x,y,z] }
1208
- * Shortest-arc rotation from one direction onto another.
1209
- * Both vectors are normalised internally.
1210
- * Antiparallel input: 180° rotation around a perpendicular axis.
1512
+ * [x,y,z,w] — raw quaternion array
1513
+ * { axis:[x,y,z], angle } — axis-angle
1514
+ * { dir:[x,y,z], up?:[x,y,z] } — forward direction (−Z) with optional up
1515
+ * { eMatrix: mat4 } — rotation block of an eye matrix
1516
+ * { mat3: mat3 } — column-major 3×3 rotation matrix
1517
+ * { euler:[rx,ry,rz], order? } — intrinsic Euler (default order: YXZ)
1518
+ * { from:[x,y,z], to:[x,y,z] } — shortest-arc rotation
1211
1519
  *
1212
1520
  * @param {*} v
1213
1521
  * @returns {number[]|null} [x,y,z,w] or null if unparseable.
@@ -1215,46 +1523,48 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1215
1523
  function _parseQuat(v) {
1216
1524
  if (!v) return null;
1217
1525
 
1218
- // raw [x,y,z,w] — plain array or typed array
1219
- if ((Array.isArray(v) || ArrayBuffer.isView(v)) && v.length === 4) return [v[0], v[1], v[2], v[3]];
1526
+ // Raw array [x,y,z,w]
1527
+ if (Array.isArray(v) && v.length === 4) return [v[0],v[1],v[2],v[3]];
1528
+ if (ArrayBuffer.isView(v) && v.length >= 4) return [v[0],v[1],v[2],v[3]];
1529
+
1530
+ if (typeof v !== 'object') return null;
1220
1531
 
1221
1532
  // { axis, angle }
1222
- if (v.axis && typeof v.angle === 'number') {
1223
- const a = Array.isArray(v.axis) ? v.axis : [v.axis.x||0, v.axis.y||0, v.axis.z||0];
1224
- return qFromAxisAngle([0,0,0,1], a[0],a[1],a[2], v.angle);
1533
+ if (v.axis != null && v.angle != null) {
1534
+ const ax = Array.isArray(v.axis) ? v.axis : [v.axis.x||0, v.axis.y||0, v.axis.z||0];
1535
+ return qFromAxisAngle([0,0,0,1], ax[0],ax[1],ax[2], v.angle);
1225
1536
  }
1226
1537
 
1227
1538
  // { dir, up? }
1228
- if (v.dir) {
1539
+ if (v.dir != null) {
1229
1540
  const d = Array.isArray(v.dir) ? v.dir : [v.dir.x||0, v.dir.y||0, v.dir.z||0];
1230
1541
  const u = v.up ? (Array.isArray(v.up) ? v.up : [v.up.x||0, v.up.y||0, v.up.z||0]) : null;
1231
1542
  return qFromLookDir([0,0,0,1], d, u);
1232
1543
  }
1233
1544
 
1234
- // { eMatrix } — rotation block from eye (eye→world) matrix, col-major mat4
1545
+ // { eMatrix }
1235
1546
  if (v.eMatrix != null) {
1236
1547
  const m = (ArrayBuffer.isView(v.eMatrix) || Array.isArray(v.eMatrix))
1237
1548
  ? v.eMatrix : (v.eMatrix.mat4 ?? null);
1238
- if (m && m.length >= 16) return qFromMat4([0,0,0,1], m);
1549
+ if (!m || m.length < 16) return null;
1550
+ return qFromRotMat3x3([0,0,0,1], m[0],m[4],m[8], m[1],m[5],m[9], m[2],m[6],m[10]);
1239
1551
  }
1240
1552
 
1241
- // { mat3 } — column-major 3×3 rotation matrix
1242
- // col0=[m0,m1,m2], col1=[m3,m4,m5], col2=[m6,m7,m8]
1243
- // row-major for qFromRotMat3x3: row0=[m0,m3,m6], row1=[m1,m4,m7], row2=[m2,m5,m8]
1553
+ // { mat3 }
1244
1554
  if (v.mat3 != null) {
1245
- const m = v.mat3;
1246
- if ((ArrayBuffer.isView(m) || Array.isArray(m)) && m.length >= 9)
1247
- return qFromRotMat3x3([0,0,0,1], m[0],m[3],m[6], m[1],m[4],m[7], m[2],m[5],m[8]);
1555
+ const m = (ArrayBuffer.isView(v.mat3) || Array.isArray(v.mat3))
1556
+ ? v.mat3 : null;
1557
+ if (!m || m.length < 9) return null;
1558
+ return qFromRotMat3x3([0,0,0,1], m[0],m[3],m[6], m[1],m[4],m[7], m[2],m[5],m[8]);
1248
1559
  }
1249
1560
 
1250
- // { euler, order? } — intrinsic Euler angles (radians), default order YXZ
1561
+ // { euler, order? }
1251
1562
  if (v.euler != null) {
1252
1563
  const e = v.euler;
1253
1564
  if (!Array.isArray(e) || e.length < 3) return null;
1254
- const order = (typeof v.order === 'string' && _EULER_ORDERS.has(v.order))
1255
- ? v.order : 'YXZ';
1565
+ const order = (v.order && _EULER_ORDERS.has(v.order)) ? v.order : 'YXZ';
1256
1566
  const q = [0,0,0,1];
1257
- const s = [0,0,0,1]; // scratch — reused each step
1567
+ const s = [0,0,0,1];
1258
1568
  for (let i = 0; i < 3; i++) {
1259
1569
  const ax = _EULER_AXES[order[i]];
1260
1570
  qMul(q, q, qFromAxisAngle(s, ax[0],ax[1],ax[2], e[i]));
@@ -1262,7 +1572,7 @@ function _parseQuat(v) {
1262
1572
  return q;
1263
1573
  }
1264
1574
 
1265
- // { from, to } — shortest-arc rotation from one direction onto another
1575
+ // { from, to }
1266
1576
  if (v.from != null && v.to != null) {
1267
1577
  const f = Array.isArray(v.from) ? v.from : [v.from.x||0, v.from.y||0, v.from.z||0];
1268
1578
  const t = Array.isArray(v.to) ? v.to : [v.to.x||0, v.to.y||0, v.to.z||0];
@@ -1271,22 +1581,14 @@ function _parseQuat(v) {
1271
1581
  const fx=f[0]/fl, fy=f[1]/fl, fz=f[2]/fl;
1272
1582
  const tx=t[0]/tl, ty=t[1]/tl, tz=t[2]/tl;
1273
1583
  const dot = fx*tx + fy*ty + fz*tz;
1274
- // parallel — identity
1275
1584
  if (dot >= 1 - 1e-8) return [0,0,0,1];
1276
- // antiparallel — 180° around any perpendicular axis
1277
1585
  if (dot <= -1 + 1e-8) {
1278
- // cross(from, X=[1,0,0]) = [0, fz, -fy]
1279
1586
  let px=0, py=fz, pz=-fy;
1280
1587
  let pl = Math.sqrt(px*px+py*py+pz*pz);
1281
- if (pl < 1e-8) {
1282
- // from ≈ ±X; try cross(from, Z=[0,0,1]) = [fy, -fx, 0]
1283
- px=fy; py=-fx; pz=0;
1284
- pl = Math.sqrt(px*px+py*py+pz*pz);
1285
- }
1588
+ if (pl < 1e-8) { px=fy; py=-fx; pz=0; pl = Math.sqrt(px*px+py*py+pz*pz); }
1286
1589
  if (pl < 1e-8) return [0,0,0,1];
1287
1590
  return qFromAxisAngle([0,0,0,1], px/pl,py/pl,pz/pl, Math.PI);
1288
1591
  }
1289
- // general case — axis = normalize(cross(from, to))
1290
1592
  let ax=fy*tz-fz*ty, ay=fz*tx-fx*tz, az=fx*ty-fy*tx;
1291
1593
  const al = Math.sqrt(ax*ax+ay*ay+az*az) || 1;
1292
1594
  return qFromAxisAngle([0,0,0,1], ax/al,ay/al,az/al,
@@ -1304,14 +1606,12 @@ function _parseQuat(v) {
1304
1606
  * { mMatrix }
1305
1607
  * Decompose a column-major mat4 into TRS via mat4ToTransform.
1306
1608
  * Float32Array(16), plain Array, or { mat4 } wrapper.
1307
- * pos from col3, scl from column lengths, rot from normalised rotation block.
1308
1609
  *
1309
1610
  * { pos?, rot?, scl?, tanIn?, tanOut? }
1310
1611
  * Explicit TRS. pos and scl are vec3, rot accepts any form from _parseQuat.
1311
1612
  * All fields are optional — missing pos/scl default to [0,0,0] / [1,1,1],
1312
1613
  * missing rot defaults to identity.
1313
1614
  * tanIn/tanOut are optional vec3 tangents for Hermite interpolation.
1314
- * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1315
1615
  *
1316
1616
  * @param {Object} spec
1317
1617
  * @returns {{ pos:number[], rot:number[], scl:number[], tanIn:number[]|null, tanOut:number[]|null }|null}
@@ -1355,22 +1655,12 @@ function _sameTransform(a, b) {
1355
1655
  * { eye, center?, up?, fov?, halfHeight?,
1356
1656
  * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1357
1657
  * Explicit lookat. center defaults to [0,0,0], up defaults to [0,1,0].
1358
- * Both are normalised/stored as-is. eye must be a vec3.
1359
1658
  * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite.
1360
1659
  * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1361
1660
  *
1362
- * { vMatrix: mat4 }
1363
- * Column-major view matrix (world→eye).
1364
- * eye reconstructed via -R^T·t; center = eye + forward·1; up = [0,1,0].
1365
- * The matrix's up_ortho (col1) is intentionally NOT used as up —
1366
- * passing it to cam.camera() shifts orbitControl's orbit reference.
1367
- * Float32Array(16), plain Array, or { mat4 } wrapper.
1368
- *
1369
- * { eMatrix: mat4 }
1370
- * Column-major eye matrix (eye→world, i.e. inverse view).
1371
- * eye read directly from col3; center = eye + forward·1; up = [0,1,0].
1372
- * Simpler extraction than vMatrix; prefer this form when eMatrix is available.
1373
- * Float32Array(16), plain Array, or { mat4 } wrapper.
1661
+ * Removed forms (task 2):
1662
+ * { vMatrix } and { eMatrix } — use PoseTrack.add({ mMatrix: eMatrix }) for
1663
+ * full-fidelity capture including roll, or cam.capturePose() for lookat-style.
1374
1664
  *
1375
1665
  * @param {Object} spec
1376
1666
  * @returns {{ eye:number[], center:number[], up:number[],
@@ -1381,36 +1671,8 @@ function _sameTransform(a, b) {
1381
1671
  function _parseCameraSpec(spec) {
1382
1672
  if (!spec || typeof spec !== 'object') return null;
1383
1673
 
1384
- // { vMatrix } — view matrix (world→eye); reconstruct eye via -R^T·t
1385
- if (spec.vMatrix != null) {
1386
- const m = (ArrayBuffer.isView(spec.vMatrix) || Array.isArray(spec.vMatrix))
1387
- ? spec.vMatrix : (spec.vMatrix.mat4 ?? null);
1388
- if (!m || m.length < 16) return null;
1389
- const ex = -(m[0]*m[12] + m[4]*m[13] + m[8]*m[14]);
1390
- const ey = -(m[1]*m[12] + m[5]*m[13] + m[9]*m[14]);
1391
- const ez = -(m[2]*m[12] + m[6]*m[13] + m[10]*m[14]);
1392
- const fx=-m[8], fy=-m[9], fz=-m[10];
1393
- const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1394
- return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0],
1395
- fov:null, halfHeight:null,
1396
- eyeTanIn:null, eyeTanOut:null, centerTanIn:null, centerTanOut:null };
1397
- }
1398
-
1399
- // { eMatrix } — eye matrix (eye→world); eye = col3, forward = -col2
1400
- if (spec.eMatrix != null) {
1401
- const m = (ArrayBuffer.isView(spec.eMatrix) || Array.isArray(spec.eMatrix))
1402
- ? spec.eMatrix : (spec.eMatrix.mat4 ?? null);
1403
- if (!m || m.length < 16) return null;
1404
- const ex=m[12], ey=m[13], ez=m[14];
1405
- const fx=-m[8], fy=-m[9], fz=-m[10];
1406
- const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1407
- return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0],
1408
- fov:null, halfHeight:null,
1409
- eyeTanIn:null, eyeTanOut:null, centerTanIn:null, centerTanOut:null };
1410
- }
1411
-
1412
- // { eye, center?, up? } — explicit lookat (eye is a vec3, not a mat4)
1413
- const eye = _parseVec3(spec.eye);
1674
+ // { eye, center?, up? } — explicit lookat
1675
+ const eye = _parseVec3(spec.eye);
1414
1676
  if (!eye) return null;
1415
1677
  const center = _parseVec3(spec.center) || [0,0,0];
1416
1678
  const upRaw = spec.up ? _parseVec3(spec.up) : null;
@@ -1452,7 +1714,7 @@ class Track {
1452
1714
  /** Loop at boundaries. @type {boolean} */
1453
1715
  this.loop = false;
1454
1716
  /** Ping-pong bounce (takes precedence over loop). @type {boolean} */
1455
- this.bounce = false;
1717
+ this.bounce = false;
1456
1718
  /** Frames per segment (≥1). @type {number} */
1457
1719
  this.duration = 30;
1458
1720
  /** Current segment index. @type {number} */
@@ -1463,9 +1725,9 @@ class Track {
1463
1725
  // Internal rate — never directly starts/stops playback
1464
1726
  this._rate = 1;
1465
1727
  // Internal bounce direction: +1 forward, -1 backward.
1466
- // Flipped by tick() at boundaries. Never exposed publicly.
1467
- // rate always holds the user-set value — only _dir changes.
1468
1728
  this._dir = 1;
1729
+ // Scratch: true once _dir has been flipped in bounce-once mode.
1730
+ this._bounced = false;
1469
1731
 
1470
1732
  // User-space hooks
1471
1733
  /** @type {Function|null} */ this.onPlay = null;
@@ -1508,8 +1770,8 @@ class Track {
1508
1770
  } else if (rateOrOpts && typeof rateOrOpts === 'object') {
1509
1771
  const o = rateOrOpts;
1510
1772
  if (_isNum(o.duration)) this.duration = Math.max(1, o.duration | 0);
1511
- if ('loop' in o) { this.loop = !!o.loop; if (!this.loop) this.bounce = false; }
1512
- if ('bounce' in o) { this.bounce = !!o.bounce; if (this.bounce) this.loop = true; }
1773
+ if ('loop' in o) this.loop = !!o.loop;
1774
+ if ('bounce' in o) this.bounce = !!o.bounce;
1513
1775
  if (typeof o.onPlay === 'function') this.onPlay = o.onPlay;
1514
1776
  if (typeof o.onEnd === 'function') this.onEnd = o.onEnd;
1515
1777
  if (typeof o.onStop === 'function') this.onStop = o.onStop;
@@ -1525,6 +1787,7 @@ class Track {
1525
1787
  const wasPlaying = this.playing;
1526
1788
  this.playing = true;
1527
1789
  if (!wasPlaying) {
1790
+ this._bounced = false;
1528
1791
  if (typeof this.onPlay === 'function') { try { this.onPlay(this); } catch (_) {} }
1529
1792
  this._onPlay?.();
1530
1793
  this._onActivate?.();
@@ -1541,6 +1804,7 @@ class Track {
1541
1804
  const wasPlaying = this.playing;
1542
1805
  this.playing = false;
1543
1806
  if (wasPlaying) {
1807
+ this._bounced = false;
1544
1808
  if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1545
1809
  this._onStop?.();
1546
1810
  this._onDeactivate?.();
@@ -1562,7 +1826,7 @@ class Track {
1562
1826
  this._onDeactivate?.();
1563
1827
  }
1564
1828
  this.keyframes.length = 0;
1565
- this.seg = 0; this.f = 0; this._dir = 1;
1829
+ this.seg = 0; this.f = 0; this._dir = 1; this._bounced = false;
1566
1830
  return this;
1567
1831
  }
1568
1832
 
@@ -1624,7 +1888,7 @@ class Track {
1624
1888
  f: this.f,
1625
1889
  playing: this.playing,
1626
1890
  loop: this.loop,
1627
- bounce: this.bounce,
1891
+ bounce: this.bounce,
1628
1892
  rate: this._rate,
1629
1893
  duration: this.duration,
1630
1894
  time: this.segments > 0 ? this.time() : 0
@@ -1649,7 +1913,8 @@ class Track {
1649
1913
  const s = _clampS(this.seg * dur + this.f, 0, total);
1650
1914
  const next = s + this._rate * this._dir;
1651
1915
 
1652
- if (this.bounce) {
1916
+ // ── loop:true, bounce:true — bounce forever ───────────────────────────
1917
+ if (this.loop && this.bounce) {
1653
1918
  let pos = next, flips = 0;
1654
1919
  while (pos < 0 || pos > total) {
1655
1920
  if (pos < 0) { pos = -pos; flips++; }
@@ -1660,11 +1925,36 @@ class Track {
1660
1925
  return true;
1661
1926
  }
1662
1927
 
1928
+ // ── loop:false, bounce:true — bounce once, stop at origin ────────────
1929
+ if (!this.loop && this.bounce) {
1930
+ if (next >= total) {
1931
+ // far boundary: reflect and flip direction once
1932
+ this._setCursorFromScalar(Math.min(total, 2 * total - next));
1933
+ this._dir = -this._dir;
1934
+ this._bounced = true;
1935
+ return true;
1936
+ }
1937
+ if (next <= 0) {
1938
+ // origin: stop (whether we bounced or started backward)
1939
+ this._setCursorFromScalar(0);
1940
+ this.playing = false;
1941
+ this._dir = 1; this._bounced = false;
1942
+ if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1943
+ this._onEnd?.();
1944
+ this._onDeactivate?.();
1945
+ return false;
1946
+ }
1947
+ this._setCursorFromScalar(next);
1948
+ return true;
1949
+ }
1950
+
1951
+ // ── loop:true, bounce:false — repeat forever ──────────────────────────
1663
1952
  if (this.loop) {
1664
1953
  this._setCursorFromScalar(((next % total) + total) % total);
1665
1954
  return true;
1666
1955
  }
1667
1956
 
1957
+ // ── loop:false, bounce:false — play once, stop at boundary ───────────
1668
1958
  if (next <= 0) {
1669
1959
  this._setCursorFromScalar(0);
1670
1960
  this.playing = false;
@@ -1711,47 +2001,16 @@ class Track {
1711
2001
  * tanOut — outgoing position tangent at this keyframe (Hermite mode).
1712
2002
  * When only one is supplied, the other mirrors it.
1713
2003
  * When neither is supplied, centripetal Catmull-Rom tangents are auto-computed.
1714
- *
1715
- * add() accepts individual specs or a bulk array of specs:
1716
- *
1717
- * { mMatrix } — full TRS from model matrix
1718
- * { pos?, rot?, scl?, tanIn?, tanOut? } — direct TRS; all fields optional
1719
- * { pos?, rot: [x,y,z,w] } — explicit quaternion
1720
- * { pos?, rot: { axis, angle } } — axis-angle
1721
- * { pos?, rot: { dir, up? } } — look direction
1722
- * { pos?, rot: { eMatrix: mat4 } } — rotation from eye matrix
1723
- * { pos?, rot: { mat3 } } — column-major 3×3 rotation matrix
1724
- * { pos?, rot: { euler, order? } } — intrinsic Euler angles (default YXZ)
1725
- * { pos?, rot: { from, to } } — shortest-arc between two directions
1726
- * [ spec, spec, ... ] — bulk
1727
- *
1728
- * Missing fields default to: pos → [0,0,0], rot → [0,0,0,1], scl → [1,1,1].
1729
- *
1730
- * eval() writes { pos, rot, scl }:
1731
- * pos — Hermite (tanIn/tanOut per keyframe; auto-CR when absent) or linear or step
1732
- * rot — slerp (rotInterp='slerp') or nlerp or step
1733
- * scl — lerp
1734
- *
1735
- * @example
1736
- * const track = new PoseTrack()
1737
- * track.add({ pos:[0,0,0] })
1738
- * track.add({ pos:[100,0,0], tanOut:[0,50,0] }) // leave heading +Y
1739
- * track.add({ pos:[200,0,0] })
1740
- * track.play({ loop: true })
1741
- * // per frame:
1742
- * track.tick()
1743
- * const out = { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }
1744
- * track.eval(out)
1745
2004
  */
1746
2005
  class PoseTrack extends Track {
1747
2006
  constructor() {
1748
2007
  super();
1749
2008
  /**
1750
2009
  * Position interpolation mode.
1751
- * - 'hermite' — cubic Hermite; uses tanIn/tanOut per keyframe when present,
1752
- * auto-computes centripetal Catmull-Rom tangents when absent (default)
2010
+ * - 'hermite' — cubic Hermite; auto-computes centripetal Catmull-Rom tangents
2011
+ * when none are stored (default)
1753
2012
  * - 'linear' — lerp
1754
- * - 'step' — snap to k0 value; useful for discrete state changes
2013
+ * - 'step' — snap to k0; useful for discrete state changes
1755
2014
  * @type {'hermite'|'linear'|'step'}
1756
2015
  */
1757
2016
  this.posInterp = 'hermite';
@@ -1836,11 +2095,9 @@ class PoseTrack extends Track {
1836
2095
  } else {
1837
2096
  const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1838
2097
  const p3 = seg + 2 < n ? this.keyframes[seg + 2].pos : k1.pos;
1839
- // tanOut on k0: use stored, else symmetric from tanIn, else auto-CR
1840
2098
  const m0 = k0.tanOut != null ? k0.tanOut
1841
2099
  : k0.tanIn != null ? k0.tanIn
1842
- : _crTanOut(_m0, p0, k0.pos, k1.pos, p3);
1843
- // tanIn on k1: use stored, else symmetric from tanOut, else auto-CR
2100
+ : _crTanOut(_m0, p0, k0.pos, k1.pos);
1844
2101
  const m1 = k1.tanIn != null ? k1.tanIn
1845
2102
  : k1.tanOut != null ? k1.tanOut
1846
2103
  : _crTanIn(_m1, p0, k0.pos, k1.pos, p3);
@@ -1894,83 +2151,34 @@ class PoseTrack extends Track {
1894
2151
  * interpolation of the eye and center paths respectively.
1895
2152
  * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1896
2153
  *
1897
- * Each field is independently interpolated — eye and center along their
1898
- * own paths, up nlerped on the unit sphere. This correctly handles cameras
1899
- * that always look at a fixed target (center stays at origin throughout)
1900
- * as well as free-fly paths where center moves independently.
1901
- *
1902
2154
  * Missing fields default to: center → [0,0,0], up → [0,1,0].
1903
2155
  *
1904
2156
  * add() accepts individual specs or a bulk array of specs:
1905
2157
  *
1906
2158
  * { eye, center?, up?, fov?, halfHeight?,
1907
2159
  * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1908
- * explicit lookat; center defaults to [0,0,0], up to [0,1,0].
1909
- * fov and halfHeight are mutually exclusive nullable scalars.
1910
- * { vMatrix: mat4 } view matrix (world→eye); eye reconstructed via -R^T·t
1911
- * { eMatrix: mat4 } eye matrix (eye→world); eye read from col3 directly
1912
- * [ spec, spec, ... ] bulk
1913
- *
1914
- * Note on up for matrix forms:
1915
- * up is always [0,1,0]. The matrix's col1 (up_ortho) is intentionally
1916
- * not used — it differs from the hint [0,1,0] for upright cameras and
1917
- * passing it to cam.camera() shifts orbitControl's orbit reference.
1918
- * Use capturePose() (p5.tree bridge) when the real up hint is needed.
1919
- *
1920
- * eval() writes { eye, center, up, fov, halfHeight }:
1921
- * eye — Hermite (auto-CR when no tangents stored) or linear or step
1922
- * center — Hermite (auto-CR when no tangents stored) or linear or step
1923
- * up — nlerp (normalize-after-lerp on unit sphere)
1924
- * fov — lerp when both keyframes carry non-null fov; else null
1925
- * halfHeight — lerp when both keyframes carry non-null halfHeight; else null
1926
- *
1927
- * @example
1928
- * const track = new CameraTrack()
1929
- * track.add({ eye:[0,0,500] }) // center defaults to [0,0,0]
1930
- * track.add({ eye:[300,-150,0], center:[0,0,0] })
1931
- * track.add({ eMatrix: myEyeMatrix })
1932
- * track.add({ vMatrix: myViewMatrix })
1933
- * track.play({ loop: true })
1934
- * // per frame:
1935
- * track.tick()
1936
- * const out = { eye:[0,0,0], center:[0,0,0], up:[0,1,0] }
1937
- * track.eval(out)
1938
- * cam.camera(out.eye[0],out.eye[1],out.eye[2],
1939
- * out.center[0],out.center[1],out.center[2],
1940
- * out.up[0],out.up[1],out.up[2])
2160
+ *
2161
+ * To capture a matrix-based pose, use PoseTrack.add({ mMatrix: eMatrix })
2162
+ * for full-fidelity including roll, or cam.capturePose() for lookat-style.
1941
2163
  */
1942
2164
  class CameraTrack extends Track {
1943
2165
  constructor() {
1944
2166
  super();
1945
2167
  /**
1946
- * Eye position interpolation mode.
1947
- * - 'hermite' — cubic Hermite; auto-CR tangents when none stored (default)
1948
- * - 'linear' — lerp
1949
- * - 'step' — snap to k0 eye
2168
+ * Eye-path interpolation mode.
1950
2169
  * @type {'hermite'|'linear'|'step'}
1951
2170
  */
1952
2171
  this.eyeInterp = 'hermite';
1953
2172
  /**
1954
- * Center (lookat target) interpolation mode.
1955
- * 'linear' suits fixed or predictably moving targets (default).
1956
- * 'hermite' gives smoother paths when center is also flying freely.
1957
- * - 'hermite' — cubic Hermite; auto-CR tangents when none stored
1958
- * - 'linear' — lerp
1959
- * - 'step' — snap to k0 center
2173
+ * Center-path interpolation mode.
1960
2174
  * @type {'hermite'|'linear'|'step'}
1961
2175
  */
1962
2176
  this.centerInterp = 'linear';
1963
- // Scratch for toCamera() — avoids hot-path allocations
1964
- this._eye = [0,0,0];
1965
- this._center = [0,0,0];
1966
- this._up = [0,1,0];
1967
2177
  }
1968
2178
 
1969
2179
  /**
1970
2180
  * Append one or more camera keyframes. Adjacent duplicates are skipped by default.
1971
- *
1972
2181
  * @param {Object|Object[]} spec
1973
- * { eye, center?, up? } or { vMatrix: mat4 } or { eMatrix: mat4 } or an array of either.
1974
2182
  * @param {{ deduplicate?: boolean }} [opts]
1975
2183
  */
1976
2184
  add(spec, opts) {
@@ -1988,7 +2196,7 @@ class CameraTrack extends Track {
1988
2196
  }
1989
2197
 
1990
2198
  /**
1991
- * Replace (or append at end) the keyframe at index.
2199
+ * Replace (or append at end) the camera keyframe at index.
1992
2200
  * @param {number} index
1993
2201
  * @param {Object} spec
1994
2202
  * @returns {boolean}
@@ -2040,7 +2248,7 @@ class CameraTrack extends Track {
2040
2248
  const p3 = seg + 2 < n ? this.keyframes[seg + 2].eye : k1.eye;
2041
2249
  const m0 = k0.eyeTanOut != null ? k0.eyeTanOut
2042
2250
  : k0.eyeTanIn != null ? k0.eyeTanIn
2043
- : _crTanOut(_m0, p0, k0.eye, k1.eye, p3);
2251
+ : _crTanOut(_m0, p0, k0.eye, k1.eye);
2044
2252
  const m1 = k1.eyeTanIn != null ? k1.eyeTanIn
2045
2253
  : k1.eyeTanOut != null ? k1.eyeTanOut
2046
2254
  : _crTanIn(_m1, p0, k0.eye, k1.eye, p3);
@@ -2050,33 +2258,31 @@ class CameraTrack extends Track {
2050
2258
  // center — Hermite, linear, or step (independent lookat target)
2051
2259
  if (this.centerInterp === 'step') {
2052
2260
  out.center[0]=k0.center[0]; out.center[1]=k0.center[1]; out.center[2]=k0.center[2];
2053
- } else if (this.centerInterp === 'linear') {
2054
- lerpVec3(out.center, k0.center, k1.center, t);
2055
- } else {
2261
+ } else if (this.centerInterp === 'hermite') {
2056
2262
  const c0 = seg > 0 ? this.keyframes[seg - 1].center : k0.center;
2057
2263
  const c3 = seg + 2 < n ? this.keyframes[seg + 2].center : k1.center;
2058
2264
  const m0 = k0.centerTanOut != null ? k0.centerTanOut
2059
2265
  : k0.centerTanIn != null ? k0.centerTanIn
2060
- : _crTanOut(_m0, c0, k0.center, k1.center, c3);
2266
+ : _crTanOut(_m0, c0, k0.center, k1.center);
2061
2267
  const m1 = k1.centerTanIn != null ? k1.centerTanIn
2062
2268
  : k1.centerTanOut != null ? k1.centerTanOut
2063
2269
  : _crTanIn(_m1, c0, k0.center, k1.center, c3);
2064
2270
  hermiteVec3(out.center, k0.center, m0, k1.center, m1, t);
2271
+ } else {
2272
+ lerpVec3(out.center, k0.center, k1.center, t);
2065
2273
  }
2066
2274
 
2067
- // up — nlerp (normalize after lerp; correct for typical near-upright cameras)
2068
- const ux = k0.up[0] + t*(k1.up[0]-k0.up[0]);
2069
- const uy = k0.up[1] + t*(k1.up[1]-k0.up[1]);
2070
- const uz = k0.up[2] + t*(k1.up[2]-k0.up[2]);
2071
- const ul = Math.sqrt(ux*ux+uy*uy+uz*uz) || 1;
2072
- out.up[0]=ux/ul; out.up[1]=uy/ul; out.up[2]=uz/ul;
2275
+ // up — nlerp on unit sphere
2276
+ lerpVec3(out.up, k0.up, k1.up, t);
2277
+ const ul=Math.sqrt(out.up[0]*out.up[0]+out.up[1]*out.up[1]+out.up[2]*out.up[2])||1;
2278
+ out.up[0]/=ul; out.up[1]/=ul; out.up[2]/=ul;
2073
2279
 
2074
- // fov — lerp (perspective); null when either keyframe lacks it
2075
- out.fov = (k0.fov !== null && k1.fov !== null)
2076
- ? k0.fov + t * (k1.fov - k0.fov) : null;
2077
- // halfHeight lerp (ortho); null when either keyframe lacks it
2078
- out.halfHeight = (k0.halfHeight !== null && k1.halfHeight !== null)
2079
- ? k0.halfHeight + t * (k1.halfHeight - k0.halfHeight) : null;
2280
+ // fov / halfHeight — lerp when both keyframes carry non-null values
2281
+ out.fov = (k0.fov != null && k1.fov != null)
2282
+ ? k0.fov + t * (k1.fov - k0.fov) : (k0.fov ?? k1.fov ?? null);
2283
+ out.halfHeight = (k0.halfHeight != null && k1.halfHeight != null)
2284
+ ? k0.halfHeight + t * (k1.halfHeight - k0.halfHeight)
2285
+ : (k0.halfHeight ?? k1.halfHeight ?? null);
2080
2286
 
2081
2287
  return out;
2082
2288
  }
@@ -2239,5 +2445,5 @@ function boxVisibility(planes, x0, y0, z0, x1, y1, z1) {
2239
2445
  return allIn ? VISIBLE : SEMIVISIBLE;
2240
2446
  }
2241
2447
 
2242
- export { CameraTrack, EYE, INVISIBLE, MATRIX, MODEL, NDC, ORIGIN, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, PoseTrack, SCREEN, SEMIVISIBLE, VISIBLE, WEBGL, WEBGPU, WORLD, _i, _j, _k, applyPickMatrix, boxVisibility, distanceToPlane, frustumPlanes, hermiteVec3, i, j, k, lerpVec3, mapDirection, mapLocation, mat3Direction, mat3NormalFromMat4, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulPoint, mat4PV, mat4ToTransform, mat4Transpose, pixelRatio, pointVisibility, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qMul, qNegate, qNlerp, qNormalize, qSet, qSlerp, qToMat4, quatToAxisAngle, sphereVisibility, transformToMat4 };
2448
+ export { CameraTrack, EYE, INVISIBLE, MATRIX, MODEL, NDC, ORIGIN, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, PoseTrack, SCREEN, SEMIVISIBLE, VISIBLE, WEBGL, WEBGPU, WORLD, _i, _j, _k, boxVisibility, distanceToPlane, frustumPlanes, hermiteVec3, i, j, k, lerpVec3, mapDirection, mapLocation, mat3Direction, mat3NormalFromMat4, mat4Bias, mat4Eye, mat4FromBasis, mat4FromScale, mat4FromTRS, mat4FromTranslation, mat4Frustum, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulDir, mat4MulPoint, mat4Ortho, mat4PV, mat4Perspective, mat4Pick, mat4Reflect, mat4ToRotation, mat4ToScale, mat4ToTransform, mat4ToTranslation, mat4Transpose, mat4View, pixelRatio, pointVisibility, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qMul, qNegate, qNlerp, qNormalize, qSet, qSlerp, qToMat4, quatToAxisAngle, sphereVisibility, transformToMat4 };
2243
2449
  //# sourceMappingURL=index.js.map