@nakednous/tree 0.0.9 → 0.0.11

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; mat4LookAt and mat4EyeMatrix 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 mat4LookAt(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 mat4LookAt.
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 mat4EyeMatrix(out, ex,ey,ez, cx,cy,cz, ux,uy,uz) {
332
+ // Same basis computation as mat4LookAt.
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.
42
518
  *
43
- * Multiply: mat4Mul(out, A, B) = A · B (standard math order).
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
44
589
  *
45
- * Pipeline: clip = P · V · M · v
46
- * P = projection (eye clip)
47
- * V = view (world → eye)
48
- * M = model (local → world)
590
+ * No dependency on form.js. Operating on matrices requires no knowledge
591
+ * of how they were constructed.
49
592
  *
50
- * PV: All functions expecting a "pv" matrix receive P · V.
51
- * This is what _worldToScreen, _ensurePV, etc. compute.
593
+ * Storage: column-major Float32Array / ArrayLike<number>.
594
+ * Element [col*4 + row] = M[row, col].
52
595
  *
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).
596
+ * Multiply: mat4Mul(out, A, B) = A · B (standard math order).
58
597
  *
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.
598
+ * Pipeline: clip = P · V · M · v
599
+ * P = projection (eye clip)
600
+ * V = view (world eye)
601
+ * M = model (local world)
65
602
  *
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`.
603
+ * NDC convention parameter (ndcZMin):
604
+ * WEBGL = -1 z [−1, 1]
605
+ * WEBGPU = 0 z ∈ [0, 1]
606
+ *
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
@@ -856,7 +1355,15 @@ function applyPickMatrix(proj, px, py, W, H) {
856
1355
  * stop() → onStop → _onStop → _onDeactivate
857
1356
  * reset() → onStop → _onStop → _onDeactivate
858
1357
  *
859
- * ── Playback semantics (rate) ─────────────────────────────────────────────────
1358
+ * ── Loop modes ────────────────────────────────────────────────────────────────
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
1363
+ *
1364
+ * bounce and loop are fully independent flags — no exclusivity enforced.
1365
+ *
1366
+ * ── Playback semantics (rate + _dir) ─────────────────────────────────────────
860
1367
  * rate > 0 forward
861
1368
  * rate < 0 backward
862
1369
  * rate === 0 frozen: tick() no-op; playing unchanged
@@ -865,181 +1372,18 @@ function applyPickMatrix(proj, px, py, W, H) {
865
1372
  * stop() is the sole setter of playing = false.
866
1373
  * Assigning rate never starts or stops playback.
867
1374
  *
1375
+ * _dir (internal, ±1) tracks the current bounce travel direction.
1376
+ * tick() advances by rate * _dir and flips _dir at boundaries.
1377
+ * rate always holds the user-set value — it is never mutated by bounce.
1378
+ * _dir is reset to 1 only in reset() (keyframes cleared) — stop/replay
1379
+ * preserves the current travel direction.
1380
+ *
868
1381
  * ── One-keyframe behaviour ────────────────────────────────────────────────────
869
1382
  * play() with exactly one keyframe snaps eval() to that keyframe without
870
1383
  * setting playing = true and without firing hooks.
871
1384
  */
872
1385
 
873
1386
 
874
- // =========================================================================
875
- // S1 Quaternion helpers (flat [x, y, z, w], w-last)
876
- // =========================================================================
877
-
878
- /** Set all four components. @returns {number[]} out */
879
- const qSet = (out, x, y, z, w) => {
880
- out[0] = x; out[1] = y; out[2] = z; out[3] = w; return out;
881
- };
882
-
883
- /** Copy quaternion a into out. @returns {number[]} out */
884
- const qCopy = (out, a) => {
885
- out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; return out;
886
- };
887
-
888
- /** Dot product of two quaternions. */
889
- const qDot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
890
-
891
- /** Normalise quaternion in-place. @returns {number[]} out */
892
- const qNormalize = (out) => {
893
- const l = Math.sqrt(out[0]*out[0]+out[1]*out[1]+out[2]*out[2]+out[3]*out[3]) || 1;
894
- out[0]/=l; out[1]/=l; out[2]/=l; out[3]/=l; return out;
895
- };
896
-
897
- /** Negate quaternion (same rotation, different hemisphere). @returns {number[]} out */
898
- const qNegate = (out, a) => {
899
- out[0]=-a[0]; out[1]=-a[1]; out[2]=-a[2]; out[3]=-a[3]; return out;
900
- };
901
-
902
- /** Hamilton product out = a * b. @returns {number[]} out */
903
- const qMul = (out, a, b) => {
904
- 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];
905
- out[0]=aw*bx+ax*bw+ay*bz-az*by;
906
- out[1]=aw*by-ax*bz+ay*bw+az*bx;
907
- out[2]=aw*bz+ax*by-ay*bx+az*bw;
908
- out[3]=aw*bw-ax*bx-ay*by-az*bz;
909
- return out;
910
- };
911
-
912
- /** Spherical linear interpolation. @returns {number[]} out */
913
- const qSlerp = (out, a, b, t) => {
914
- let bx=b[0],by=b[1],bz=b[2],bw=b[3];
915
- let d = a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw;
916
- if (d < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; d=-d; }
917
- let f0, f1;
918
- if (1-d > 1e-10) {
919
- const th=Math.acos(d), st=Math.sin(th);
920
- f0=Math.sin((1-t)*th)/st; f1=Math.sin(t*th)/st;
921
- } else {
922
- f0=1-t; f1=t;
923
- }
924
- out[0]=a[0]*f0+bx*f1; out[1]=a[1]*f0+by*f1;
925
- out[2]=a[2]*f0+bz*f1; out[3]=a[3]*f0+bw*f1;
926
- return qNormalize(out);
927
- };
928
-
929
- /**
930
- * Normalised linear interpolation (nlerp).
931
- * Cheaper than slerp; slightly non-constant angular velocity.
932
- * Handles antipodal quats by flipping b when dot < 0.
933
- * @returns {number[]} out
934
- */
935
- const qNlerp = (out, a, b, t) => {
936
- let bx=b[0],by=b[1],bz=b[2],bw=b[3];
937
- if (a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; }
938
- out[0]=a[0]+t*(bx-a[0]); out[1]=a[1]+t*(by-a[1]);
939
- out[2]=a[2]+t*(bz-a[2]); out[3]=a[3]+t*(bw-a[3]);
940
- return qNormalize(out);
941
- };
942
-
943
- /**
944
- * Build a quaternion from axis-angle.
945
- * @param {number[]} out
946
- * @param {number} ax @param {number} ay @param {number} az Axis (need not be unit).
947
- * @param {number} angle Radians.
948
- * @returns {number[]} out
949
- */
950
- const qFromAxisAngle = (out, ax, ay, az, angle) => {
951
- const half = angle * 0.5;
952
- const s = Math.sin(half);
953
- const len = Math.sqrt(ax*ax + ay*ay + az*az) || 1;
954
- out[0] = s * ax / len; out[1] = s * ay / len; out[2] = s * az / len;
955
- out[3] = Math.cos(half);
956
- return out;
957
- };
958
-
959
- /**
960
- * Build a quaternion from a look direction (−Z forward) and optional up (default +Y).
961
- * @param {number[]} out
962
- * @param {number[]} dir Forward direction [x,y,z].
963
- * @param {number[]} [up] Up vector [x,y,z].
964
- * @returns {number[]} out
965
- */
966
- const qFromLookDir = (out, dir, up) => {
967
- let fx=dir[0],fy=dir[1],fz=dir[2];
968
- const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
969
- fx/=fl; fy/=fl; fz/=fl;
970
- let ux=up?up[0]:0, uy=up?up[1]:1, uz=up?up[2]:0;
971
- let rx=uy*fz-uz*fy, ry=uz*fx-ux*fz, rz=ux*fy-uy*fx;
972
- const rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1;
973
- rx/=rl; ry/=rl; rz/=rl;
974
- ux=fy*rz-fz*ry; uy=fz*rx-fx*rz; uz=fx*ry-fy*rx;
975
- return qFromRotMat3x3(out, rx,ry,rz, ux,uy,uz, -fx,-fy,-fz);
976
- };
977
-
978
- /**
979
- * Build a quaternion from a 3×3 rotation matrix (9 row-major scalars).
980
- * @returns {number[]} out (normalised)
981
- */
982
- const qFromRotMat3x3 = (out, m00,m01,m02, m10,m11,m12, m20,m21,m22) => {
983
- const tr = m00+m11+m22;
984
- if (tr > 0) {
985
- const s=0.5/Math.sqrt(tr+1);
986
- out[3]=0.25/s; out[0]=(m21-m12)*s; out[1]=(m02-m20)*s; out[2]=(m10-m01)*s;
987
- } else if (m00>m11 && m00>m22) {
988
- const s=2*Math.sqrt(1+m00-m11-m22);
989
- out[3]=(m21-m12)/s; out[0]=0.25*s; out[1]=(m01+m10)/s; out[2]=(m02+m20)/s;
990
- } else if (m11>m22) {
991
- const s=2*Math.sqrt(1+m11-m00-m22);
992
- out[3]=(m02-m20)/s; out[0]=(m01+m10)/s; out[1]=0.25*s; out[2]=(m12+m21)/s;
993
- } else {
994
- const s=2*Math.sqrt(1+m22-m00-m11);
995
- out[3]=(m10-m01)/s; out[0]=(m02+m20)/s; out[1]=(m12+m21)/s; out[2]=0.25*s;
996
- }
997
- return qNormalize(out);
998
- };
999
-
1000
- /**
1001
- * Extract a unit quaternion from the upper-left 3×3 of a column-major mat4.
1002
- * @param {number[]} out
1003
- * @param {Float32Array|number[]} m Column-major mat4.
1004
- * @returns {number[]} out
1005
- */
1006
- const qFromMat4 = (out, m) =>
1007
- qFromRotMat3x3(out, m[0],m[4],m[8], m[1],m[5],m[9], m[2],m[6],m[10]);
1008
-
1009
- /**
1010
- * Write a quaternion into the rotation block of a column-major mat4.
1011
- * Translation and perspective rows/cols are set to identity values.
1012
- * @param {Float32Array|number[]} out 16-element array.
1013
- * @param {number[]} q [x,y,z,w].
1014
- * @returns {Float32Array|number[]} out
1015
- */
1016
- const qToMat4 = (out, q) => {
1017
- const x=q[0],y=q[1],z=q[2],w=q[3];
1018
- const x2=x+x,y2=y+y,z2=z+z;
1019
- 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;
1020
- out[0]=1-(yy+zz); out[1]=xy+wz; out[2]=xz-wy; out[3]=0;
1021
- out[4]=xy-wz; out[5]=1-(xx+zz); out[6]=yz+wx; out[7]=0;
1022
- out[8]=xz+wy; out[9]=yz-wx; out[10]=1-(xx+yy); out[11]=0;
1023
- out[12]=0; out[13]=0; out[14]=0; out[15]=1;
1024
- return out;
1025
- };
1026
-
1027
- /**
1028
- * Decompose a unit quaternion into { axis:[x,y,z], angle } (radians).
1029
- * @param {number[]} q [x,y,z,w].
1030
- * @param {Object} [out]
1031
- * @returns {{ axis: number[], angle: number }}
1032
- */
1033
- const quatToAxisAngle = (q, out) => {
1034
- out = out || {};
1035
- const x=q[0],y=q[1],z=q[2],w=q[3];
1036
- const sinHalf = Math.sqrt(x*x+y*y+z*z);
1037
- if (sinHalf < 1e-8) { out.axis=[0,1,0]; out.angle=0; return out; }
1038
- out.angle = 2*Math.atan2(sinHalf, w);
1039
- out.axis = [x/sinHalf, y/sinHalf, z/sinHalf];
1040
- return out;
1041
- };
1042
-
1043
1387
  // =========================================================================
1044
1388
  // S2 Spline / vector helpers
1045
1389
  // =========================================================================
@@ -1071,14 +1415,13 @@ const hermiteVec3 = (out, p0, m0, p1, m1, t) => {
1071
1415
 
1072
1416
  // Centripetal CR outgoing tangent at p1 for segment p1→p2, scaled by dt1.
1073
1417
  const _crTanOut = (out, p0, p1, p2, p3) => {
1074
- 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;
1075
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;
1076
1420
  return out;
1077
1421
  };
1078
1422
 
1079
- // Centripetal CR incoming tangent at p2 for segment p1→p2, scaled by dt1.
1080
1423
  const _crTanIn = (out, p0, p1, p2, p3) => {
1081
- 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;
1082
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;
1083
1426
  return out;
1084
1427
  };
@@ -1166,33 +1509,13 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1166
1509
  *
1167
1510
  * Accepted forms:
1168
1511
  *
1169
- * [x,y,z,w]
1170
- * Raw quaternion array.
1171
- *
1172
- * { axis:[x,y,z], angle }
1173
- * Axis-angle. Axis need not be unit.
1174
- *
1175
- * { dir:[x,y,z], up?:[x,y,z] }
1176
- * Object orientation — forward direction (−Z) with optional up hint.
1177
- *
1178
- * { eMatrix: mat4 }
1179
- * Extract rotation block from an eye (eye→world) matrix.
1180
- * Column-major Float32Array(16), plain Array, or { mat4 } wrapper.
1181
- *
1182
- * { mat3: mat3 }
1183
- * Column-major 3×3 rotation matrix — Float32Array(9) or plain Array.
1184
- *
1185
- * { euler:[rx,ry,rz], order?:'YXZ' }
1186
- * Intrinsic Euler angles (radians). Angles are indexed by order position:
1187
- * e[0] rotates around order[0] axis, e[1] around order[1], e[2] around order[2].
1188
- * Supported orders: YXZ (default), XYZ, ZYX, ZXY, XZY, YZX.
1189
- * Note: intrinsic ABC = extrinsic CBA with the same angles — to use
1190
- * extrinsic order ABC, reverse the string and use intrinsic CBA.
1191
- *
1192
- * { from:[x,y,z], to:[x,y,z] }
1193
- * Shortest-arc rotation from one direction onto another.
1194
- * Both vectors are normalised internally.
1195
- * 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
1196
1519
  *
1197
1520
  * @param {*} v
1198
1521
  * @returns {number[]|null} [x,y,z,w] or null if unparseable.
@@ -1200,46 +1523,48 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1200
1523
  function _parseQuat(v) {
1201
1524
  if (!v) return null;
1202
1525
 
1203
- // raw [x,y,z,w] — plain array or typed array
1204
- 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;
1205
1531
 
1206
1532
  // { axis, angle }
1207
- if (v.axis && typeof v.angle === 'number') {
1208
- const a = Array.isArray(v.axis) ? v.axis : [v.axis.x||0, v.axis.y||0, v.axis.z||0];
1209
- 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);
1210
1536
  }
1211
1537
 
1212
1538
  // { dir, up? }
1213
- if (v.dir) {
1539
+ if (v.dir != null) {
1214
1540
  const d = Array.isArray(v.dir) ? v.dir : [v.dir.x||0, v.dir.y||0, v.dir.z||0];
1215
1541
  const u = v.up ? (Array.isArray(v.up) ? v.up : [v.up.x||0, v.up.y||0, v.up.z||0]) : null;
1216
1542
  return qFromLookDir([0,0,0,1], d, u);
1217
1543
  }
1218
1544
 
1219
- // { eMatrix } — rotation block from eye (eye→world) matrix, col-major mat4
1545
+ // { eMatrix }
1220
1546
  if (v.eMatrix != null) {
1221
1547
  const m = (ArrayBuffer.isView(v.eMatrix) || Array.isArray(v.eMatrix))
1222
1548
  ? v.eMatrix : (v.eMatrix.mat4 ?? null);
1223
- 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]);
1224
1551
  }
1225
1552
 
1226
- // { mat3 } — column-major 3×3 rotation matrix
1227
- // col0=[m0,m1,m2], col1=[m3,m4,m5], col2=[m6,m7,m8]
1228
- // row-major for qFromRotMat3x3: row0=[m0,m3,m6], row1=[m1,m4,m7], row2=[m2,m5,m8]
1553
+ // { mat3 }
1229
1554
  if (v.mat3 != null) {
1230
- const m = v.mat3;
1231
- if ((ArrayBuffer.isView(m) || Array.isArray(m)) && m.length >= 9)
1232
- 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]);
1233
1559
  }
1234
1560
 
1235
- // { euler, order? } — intrinsic Euler angles (radians), default order YXZ
1561
+ // { euler, order? }
1236
1562
  if (v.euler != null) {
1237
1563
  const e = v.euler;
1238
1564
  if (!Array.isArray(e) || e.length < 3) return null;
1239
- const order = (typeof v.order === 'string' && _EULER_ORDERS.has(v.order))
1240
- ? v.order : 'YXZ';
1565
+ const order = (v.order && _EULER_ORDERS.has(v.order)) ? v.order : 'YXZ';
1241
1566
  const q = [0,0,0,1];
1242
- const s = [0,0,0,1]; // scratch — reused each step
1567
+ const s = [0,0,0,1];
1243
1568
  for (let i = 0; i < 3; i++) {
1244
1569
  const ax = _EULER_AXES[order[i]];
1245
1570
  qMul(q, q, qFromAxisAngle(s, ax[0],ax[1],ax[2], e[i]));
@@ -1247,7 +1572,7 @@ function _parseQuat(v) {
1247
1572
  return q;
1248
1573
  }
1249
1574
 
1250
- // { from, to } — shortest-arc rotation from one direction onto another
1575
+ // { from, to }
1251
1576
  if (v.from != null && v.to != null) {
1252
1577
  const f = Array.isArray(v.from) ? v.from : [v.from.x||0, v.from.y||0, v.from.z||0];
1253
1578
  const t = Array.isArray(v.to) ? v.to : [v.to.x||0, v.to.y||0, v.to.z||0];
@@ -1256,22 +1581,14 @@ function _parseQuat(v) {
1256
1581
  const fx=f[0]/fl, fy=f[1]/fl, fz=f[2]/fl;
1257
1582
  const tx=t[0]/tl, ty=t[1]/tl, tz=t[2]/tl;
1258
1583
  const dot = fx*tx + fy*ty + fz*tz;
1259
- // parallel — identity
1260
1584
  if (dot >= 1 - 1e-8) return [0,0,0,1];
1261
- // antiparallel — 180° around any perpendicular axis
1262
1585
  if (dot <= -1 + 1e-8) {
1263
- // cross(from, X=[1,0,0]) = [0, fz, -fy]
1264
1586
  let px=0, py=fz, pz=-fy;
1265
1587
  let pl = Math.sqrt(px*px+py*py+pz*pz);
1266
- if (pl < 1e-8) {
1267
- // from ≈ ±X; try cross(from, Z=[0,0,1]) = [fy, -fx, 0]
1268
- px=fy; py=-fx; pz=0;
1269
- pl = Math.sqrt(px*px+py*py+pz*pz);
1270
- }
1588
+ if (pl < 1e-8) { px=fy; py=-fx; pz=0; pl = Math.sqrt(px*px+py*py+pz*pz); }
1271
1589
  if (pl < 1e-8) return [0,0,0,1];
1272
1590
  return qFromAxisAngle([0,0,0,1], px/pl,py/pl,pz/pl, Math.PI);
1273
1591
  }
1274
- // general case — axis = normalize(cross(from, to))
1275
1592
  let ax=fy*tz-fz*ty, ay=fz*tx-fx*tz, az=fx*ty-fy*tx;
1276
1593
  const al = Math.sqrt(ax*ax+ay*ay+az*az) || 1;
1277
1594
  return qFromAxisAngle([0,0,0,1], ax/al,ay/al,az/al,
@@ -1289,14 +1606,12 @@ function _parseQuat(v) {
1289
1606
  * { mMatrix }
1290
1607
  * Decompose a column-major mat4 into TRS via mat4ToTransform.
1291
1608
  * Float32Array(16), plain Array, or { mat4 } wrapper.
1292
- * pos from col3, scl from column lengths, rot from normalised rotation block.
1293
1609
  *
1294
1610
  * { pos?, rot?, scl?, tanIn?, tanOut? }
1295
1611
  * Explicit TRS. pos and scl are vec3, rot accepts any form from _parseQuat.
1296
1612
  * All fields are optional — missing pos/scl default to [0,0,0] / [1,1,1],
1297
1613
  * missing rot defaults to identity.
1298
1614
  * tanIn/tanOut are optional vec3 tangents for Hermite interpolation.
1299
- * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1300
1615
  *
1301
1616
  * @param {Object} spec
1302
1617
  * @returns {{ pos:number[], rot:number[], scl:number[], tanIn:number[]|null, tanOut:number[]|null }|null}
@@ -1340,22 +1655,12 @@ function _sameTransform(a, b) {
1340
1655
  * { eye, center?, up?, fov?, halfHeight?,
1341
1656
  * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1342
1657
  * Explicit lookat. center defaults to [0,0,0], up defaults to [0,1,0].
1343
- * Both are normalised/stored as-is. eye must be a vec3.
1344
1658
  * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite.
1345
1659
  * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1346
1660
  *
1347
- * { vMatrix: mat4 }
1348
- * Column-major view matrix (world→eye).
1349
- * eye reconstructed via -R^T·t; center = eye + forward·1; up = [0,1,0].
1350
- * The matrix's up_ortho (col1) is intentionally NOT used as up —
1351
- * passing it to cam.camera() shifts orbitControl's orbit reference.
1352
- * Float32Array(16), plain Array, or { mat4 } wrapper.
1353
- *
1354
- * { eMatrix: mat4 }
1355
- * Column-major eye matrix (eye→world, i.e. inverse view).
1356
- * eye read directly from col3; center = eye + forward·1; up = [0,1,0].
1357
- * Simpler extraction than vMatrix; prefer this form when eMatrix is available.
1358
- * 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.
1359
1664
  *
1360
1665
  * @param {Object} spec
1361
1666
  * @returns {{ eye:number[], center:number[], up:number[],
@@ -1366,36 +1671,8 @@ function _sameTransform(a, b) {
1366
1671
  function _parseCameraSpec(spec) {
1367
1672
  if (!spec || typeof spec !== 'object') return null;
1368
1673
 
1369
- // { vMatrix } — view matrix (world→eye); reconstruct eye via -R^T·t
1370
- if (spec.vMatrix != null) {
1371
- const m = (ArrayBuffer.isView(spec.vMatrix) || Array.isArray(spec.vMatrix))
1372
- ? spec.vMatrix : (spec.vMatrix.mat4 ?? null);
1373
- if (!m || m.length < 16) return null;
1374
- const ex = -(m[0]*m[12] + m[4]*m[13] + m[8]*m[14]);
1375
- const ey = -(m[1]*m[12] + m[5]*m[13] + m[9]*m[14]);
1376
- const ez = -(m[2]*m[12] + m[6]*m[13] + m[10]*m[14]);
1377
- const fx=-m[8], fy=-m[9], fz=-m[10];
1378
- const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1379
- return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0],
1380
- fov:null, halfHeight:null,
1381
- eyeTanIn:null, eyeTanOut:null, centerTanIn:null, centerTanOut:null };
1382
- }
1383
-
1384
- // { eMatrix } — eye matrix (eye→world); eye = col3, forward = -col2
1385
- if (spec.eMatrix != null) {
1386
- const m = (ArrayBuffer.isView(spec.eMatrix) || Array.isArray(spec.eMatrix))
1387
- ? spec.eMatrix : (spec.eMatrix.mat4 ?? null);
1388
- if (!m || m.length < 16) return null;
1389
- const ex=m[12], ey=m[13], ez=m[14];
1390
- const fx=-m[8], fy=-m[9], fz=-m[10];
1391
- const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1392
- return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0],
1393
- fov:null, halfHeight:null,
1394
- eyeTanIn:null, eyeTanOut:null, centerTanIn:null, centerTanOut:null };
1395
- }
1396
-
1397
- // { eye, center?, up? } — explicit lookat (eye is a vec3, not a mat4)
1398
- const eye = _parseVec3(spec.eye);
1674
+ // { eye, center?, up? } — explicit lookat
1675
+ const eye = _parseVec3(spec.eye);
1399
1676
  if (!eye) return null;
1400
1677
  const center = _parseVec3(spec.center) || [0,0,0];
1401
1678
  const upRaw = spec.up ? _parseVec3(spec.up) : null;
@@ -1437,7 +1714,7 @@ class Track {
1437
1714
  /** Loop at boundaries. @type {boolean} */
1438
1715
  this.loop = false;
1439
1716
  /** Ping-pong bounce (takes precedence over loop). @type {boolean} */
1440
- this.pingPong = false;
1717
+ this.bounce = false;
1441
1718
  /** Frames per segment (≥1). @type {number} */
1442
1719
  this.duration = 30;
1443
1720
  /** Current segment index. @type {number} */
@@ -1447,6 +1724,10 @@ class Track {
1447
1724
 
1448
1725
  // Internal rate — never directly starts/stops playback
1449
1726
  this._rate = 1;
1727
+ // Internal bounce direction: +1 forward, -1 backward.
1728
+ this._dir = 1;
1729
+ // Scratch: true once _dir has been flipped in bounce-once mode.
1730
+ this._bounced = false;
1450
1731
 
1451
1732
  // User-space hooks
1452
1733
  /** @type {Function|null} */ this.onPlay = null;
@@ -1472,7 +1753,7 @@ class Track {
1472
1753
  /**
1473
1754
  * Start or update playback.
1474
1755
  * @param {number|Object} [rateOrOpts] Numeric rate or options object:
1475
- * { rate, duration, loop, pingPong, onPlay, onEnd, onStop }
1756
+ * { rate, duration, loop, bounce, onPlay, onEnd, onStop }
1476
1757
  * @returns {Track} this
1477
1758
  */
1478
1759
  play(rateOrOpts) {
@@ -1488,9 +1769,9 @@ class Track {
1488
1769
  this._rate = rateOrOpts;
1489
1770
  } else if (rateOrOpts && typeof rateOrOpts === 'object') {
1490
1771
  const o = rateOrOpts;
1491
- if (_isNum(o.duration)) this.duration = Math.max(1, o.duration | 0);
1492
- if ('loop' in o) this.loop = !!o.loop;
1493
- if ('pingPong' in o) this.pingPong = !!o.pingPong;
1772
+ if (_isNum(o.duration)) this.duration = Math.max(1, o.duration | 0);
1773
+ if ('loop' in o) this.loop = !!o.loop;
1774
+ if ('bounce' in o) this.bounce = !!o.bounce;
1494
1775
  if (typeof o.onPlay === 'function') this.onPlay = o.onPlay;
1495
1776
  if (typeof o.onEnd === 'function') this.onEnd = o.onEnd;
1496
1777
  if (typeof o.onStop === 'function') this.onStop = o.onStop;
@@ -1506,6 +1787,7 @@ class Track {
1506
1787
  const wasPlaying = this.playing;
1507
1788
  this.playing = true;
1508
1789
  if (!wasPlaying) {
1790
+ this._bounced = false;
1509
1791
  if (typeof this.onPlay === 'function') { try { this.onPlay(this); } catch (_) {} }
1510
1792
  this._onPlay?.();
1511
1793
  this._onActivate?.();
@@ -1522,10 +1804,11 @@ class Track {
1522
1804
  const wasPlaying = this.playing;
1523
1805
  this.playing = false;
1524
1806
  if (wasPlaying) {
1807
+ this._bounced = false;
1525
1808
  if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1526
1809
  this._onStop?.();
1527
1810
  this._onDeactivate?.();
1528
- if (rewind && this.keyframes.length > 1) this.seek(this._rate < 0 ? 1 : 0);
1811
+ if (rewind && this.keyframes.length > 1) this.seek(this._rate * this._dir < 0 ? 1 : 0);
1529
1812
  }
1530
1813
  return this;
1531
1814
  }
@@ -1543,7 +1826,7 @@ class Track {
1543
1826
  this._onDeactivate?.();
1544
1827
  }
1545
1828
  this.keyframes.length = 0;
1546
- this.seg = 0; this.f = 0;
1829
+ this.seg = 0; this.f = 0; this._dir = 1; this._bounced = false;
1547
1830
  return this;
1548
1831
  }
1549
1832
 
@@ -1605,7 +1888,7 @@ class Track {
1605
1888
  f: this.f,
1606
1889
  playing: this.playing,
1607
1890
  loop: this.loop,
1608
- pingPong: this.pingPong,
1891
+ bounce: this.bounce,
1609
1892
  rate: this._rate,
1610
1893
  duration: this.duration,
1611
1894
  time: this.segments > 0 ? this.time() : 0
@@ -1628,24 +1911,50 @@ class Track {
1628
1911
  const dur = Math.max(1, this.duration | 0);
1629
1912
  const total = nSeg * dur;
1630
1913
  const s = _clampS(this.seg * dur + this.f, 0, total);
1631
- const next = s + this._rate;
1914
+ const next = s + this._rate * this._dir;
1632
1915
 
1633
- if (this.pingPong) {
1916
+ // ── loop:true, bounce:true — bounce forever ───────────────────────────
1917
+ if (this.loop && this.bounce) {
1634
1918
  let pos = next, flips = 0;
1635
1919
  while (pos < 0 || pos > total) {
1636
1920
  if (pos < 0) { pos = -pos; flips++; }
1637
1921
  else { pos = 2 * total - pos; flips++; }
1638
1922
  }
1639
- if (flips & 1) this._rate = -this._rate;
1923
+ if (flips & 1) this._dir = -this._dir;
1640
1924
  this._setCursorFromScalar(pos);
1641
1925
  return true;
1642
1926
  }
1643
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 ──────────────────────────
1644
1952
  if (this.loop) {
1645
1953
  this._setCursorFromScalar(((next % total) + total) % total);
1646
1954
  return true;
1647
1955
  }
1648
1956
 
1957
+ // ── loop:false, bounce:false — play once, stop at boundary ───────────
1649
1958
  if (next <= 0) {
1650
1959
  this._setCursorFromScalar(0);
1651
1960
  this.playing = false;
@@ -1692,47 +2001,16 @@ class Track {
1692
2001
  * tanOut — outgoing position tangent at this keyframe (Hermite mode).
1693
2002
  * When only one is supplied, the other mirrors it.
1694
2003
  * When neither is supplied, centripetal Catmull-Rom tangents are auto-computed.
1695
- *
1696
- * add() accepts individual specs or a bulk array of specs:
1697
- *
1698
- * { mMatrix } — full TRS from model matrix
1699
- * { pos?, rot?, scl?, tanIn?, tanOut? } — direct TRS; all fields optional
1700
- * { pos?, rot: [x,y,z,w] } — explicit quaternion
1701
- * { pos?, rot: { axis, angle } } — axis-angle
1702
- * { pos?, rot: { dir, up? } } — look direction
1703
- * { pos?, rot: { eMatrix: mat4 } } — rotation from eye matrix
1704
- * { pos?, rot: { mat3 } } — column-major 3×3 rotation matrix
1705
- * { pos?, rot: { euler, order? } } — intrinsic Euler angles (default YXZ)
1706
- * { pos?, rot: { from, to } } — shortest-arc between two directions
1707
- * [ spec, spec, ... ] — bulk
1708
- *
1709
- * Missing fields default to: pos → [0,0,0], rot → [0,0,0,1], scl → [1,1,1].
1710
- *
1711
- * eval() writes { pos, rot, scl }:
1712
- * pos — Hermite (tanIn/tanOut per keyframe; auto-CR when absent) or linear or step
1713
- * rot — slerp (rotInterp='slerp') or nlerp or step
1714
- * scl — lerp
1715
- *
1716
- * @example
1717
- * const track = new PoseTrack()
1718
- * track.add({ pos:[0,0,0] })
1719
- * track.add({ pos:[100,0,0], tanOut:[0,50,0] }) // leave heading +Y
1720
- * track.add({ pos:[200,0,0] })
1721
- * track.play({ loop: true })
1722
- * // per frame:
1723
- * track.tick()
1724
- * const out = { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }
1725
- * track.eval(out)
1726
2004
  */
1727
2005
  class PoseTrack extends Track {
1728
2006
  constructor() {
1729
2007
  super();
1730
2008
  /**
1731
2009
  * Position interpolation mode.
1732
- * - 'hermite' — cubic Hermite; uses tanIn/tanOut per keyframe when present,
1733
- * 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)
1734
2012
  * - 'linear' — lerp
1735
- * - 'step' — snap to k0 value; useful for discrete state changes
2013
+ * - 'step' — snap to k0; useful for discrete state changes
1736
2014
  * @type {'hermite'|'linear'|'step'}
1737
2015
  */
1738
2016
  this.posInterp = 'hermite';
@@ -1817,11 +2095,9 @@ class PoseTrack extends Track {
1817
2095
  } else {
1818
2096
  const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1819
2097
  const p3 = seg + 2 < n ? this.keyframes[seg + 2].pos : k1.pos;
1820
- // tanOut on k0: use stored, else symmetric from tanIn, else auto-CR
1821
2098
  const m0 = k0.tanOut != null ? k0.tanOut
1822
2099
  : k0.tanIn != null ? k0.tanIn
1823
- : _crTanOut(_m0, p0, k0.pos, k1.pos, p3);
1824
- // tanIn on k1: use stored, else symmetric from tanOut, else auto-CR
2100
+ : _crTanOut(_m0, p0, k0.pos, k1.pos);
1825
2101
  const m1 = k1.tanIn != null ? k1.tanIn
1826
2102
  : k1.tanOut != null ? k1.tanOut
1827
2103
  : _crTanIn(_m1, p0, k0.pos, k1.pos, p3);
@@ -1875,83 +2151,34 @@ class PoseTrack extends Track {
1875
2151
  * interpolation of the eye and center paths respectively.
1876
2152
  * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1877
2153
  *
1878
- * Each field is independently interpolated — eye and center along their
1879
- * own paths, up nlerped on the unit sphere. This correctly handles cameras
1880
- * that always look at a fixed target (center stays at origin throughout)
1881
- * as well as free-fly paths where center moves independently.
1882
- *
1883
2154
  * Missing fields default to: center → [0,0,0], up → [0,1,0].
1884
2155
  *
1885
2156
  * add() accepts individual specs or a bulk array of specs:
1886
2157
  *
1887
2158
  * { eye, center?, up?, fov?, halfHeight?,
1888
2159
  * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1889
- * explicit lookat; center defaults to [0,0,0], up to [0,1,0].
1890
- * fov and halfHeight are mutually exclusive nullable scalars.
1891
- * { vMatrix: mat4 } view matrix (world→eye); eye reconstructed via -R^T·t
1892
- * { eMatrix: mat4 } eye matrix (eye→world); eye read from col3 directly
1893
- * [ spec, spec, ... ] bulk
1894
- *
1895
- * Note on up for matrix forms:
1896
- * up is always [0,1,0]. The matrix's col1 (up_ortho) is intentionally
1897
- * not used — it differs from the hint [0,1,0] for upright cameras and
1898
- * passing it to cam.camera() shifts orbitControl's orbit reference.
1899
- * Use capturePose() (p5.tree bridge) when the real up hint is needed.
1900
- *
1901
- * eval() writes { eye, center, up, fov, halfHeight }:
1902
- * eye — Hermite (auto-CR when no tangents stored) or linear or step
1903
- * center — Hermite (auto-CR when no tangents stored) or linear or step
1904
- * up — nlerp (normalize-after-lerp on unit sphere)
1905
- * fov — lerp when both keyframes carry non-null fov; else null
1906
- * halfHeight — lerp when both keyframes carry non-null halfHeight; else null
1907
- *
1908
- * @example
1909
- * const track = new CameraTrack()
1910
- * track.add({ eye:[0,0,500] }) // center defaults to [0,0,0]
1911
- * track.add({ eye:[300,-150,0], center:[0,0,0] })
1912
- * track.add({ eMatrix: myEyeMatrix })
1913
- * track.add({ vMatrix: myViewMatrix })
1914
- * track.play({ loop: true })
1915
- * // per frame:
1916
- * track.tick()
1917
- * const out = { eye:[0,0,0], center:[0,0,0], up:[0,1,0] }
1918
- * track.eval(out)
1919
- * cam.camera(out.eye[0],out.eye[1],out.eye[2],
1920
- * out.center[0],out.center[1],out.center[2],
1921
- * 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.
1922
2163
  */
1923
2164
  class CameraTrack extends Track {
1924
2165
  constructor() {
1925
2166
  super();
1926
2167
  /**
1927
- * Eye position interpolation mode.
1928
- * - 'hermite' — cubic Hermite; auto-CR tangents when none stored (default)
1929
- * - 'linear' — lerp
1930
- * - 'step' — snap to k0 eye
2168
+ * Eye-path interpolation mode.
1931
2169
  * @type {'hermite'|'linear'|'step'}
1932
2170
  */
1933
2171
  this.eyeInterp = 'hermite';
1934
2172
  /**
1935
- * Center (lookat target) interpolation mode.
1936
- * 'linear' suits fixed or predictably moving targets (default).
1937
- * 'hermite' gives smoother paths when center is also flying freely.
1938
- * - 'hermite' — cubic Hermite; auto-CR tangents when none stored
1939
- * - 'linear' — lerp
1940
- * - 'step' — snap to k0 center
2173
+ * Center-path interpolation mode.
1941
2174
  * @type {'hermite'|'linear'|'step'}
1942
2175
  */
1943
2176
  this.centerInterp = 'linear';
1944
- // Scratch for toCamera() — avoids hot-path allocations
1945
- this._eye = [0,0,0];
1946
- this._center = [0,0,0];
1947
- this._up = [0,1,0];
1948
2177
  }
1949
2178
 
1950
2179
  /**
1951
2180
  * Append one or more camera keyframes. Adjacent duplicates are skipped by default.
1952
- *
1953
2181
  * @param {Object|Object[]} spec
1954
- * { eye, center?, up? } or { vMatrix: mat4 } or { eMatrix: mat4 } or an array of either.
1955
2182
  * @param {{ deduplicate?: boolean }} [opts]
1956
2183
  */
1957
2184
  add(spec, opts) {
@@ -1969,7 +2196,7 @@ class CameraTrack extends Track {
1969
2196
  }
1970
2197
 
1971
2198
  /**
1972
- * Replace (or append at end) the keyframe at index.
2199
+ * Replace (or append at end) the camera keyframe at index.
1973
2200
  * @param {number} index
1974
2201
  * @param {Object} spec
1975
2202
  * @returns {boolean}
@@ -2021,7 +2248,7 @@ class CameraTrack extends Track {
2021
2248
  const p3 = seg + 2 < n ? this.keyframes[seg + 2].eye : k1.eye;
2022
2249
  const m0 = k0.eyeTanOut != null ? k0.eyeTanOut
2023
2250
  : k0.eyeTanIn != null ? k0.eyeTanIn
2024
- : _crTanOut(_m0, p0, k0.eye, k1.eye, p3);
2251
+ : _crTanOut(_m0, p0, k0.eye, k1.eye);
2025
2252
  const m1 = k1.eyeTanIn != null ? k1.eyeTanIn
2026
2253
  : k1.eyeTanOut != null ? k1.eyeTanOut
2027
2254
  : _crTanIn(_m1, p0, k0.eye, k1.eye, p3);
@@ -2031,33 +2258,31 @@ class CameraTrack extends Track {
2031
2258
  // center — Hermite, linear, or step (independent lookat target)
2032
2259
  if (this.centerInterp === 'step') {
2033
2260
  out.center[0]=k0.center[0]; out.center[1]=k0.center[1]; out.center[2]=k0.center[2];
2034
- } else if (this.centerInterp === 'linear') {
2035
- lerpVec3(out.center, k0.center, k1.center, t);
2036
- } else {
2261
+ } else if (this.centerInterp === 'hermite') {
2037
2262
  const c0 = seg > 0 ? this.keyframes[seg - 1].center : k0.center;
2038
2263
  const c3 = seg + 2 < n ? this.keyframes[seg + 2].center : k1.center;
2039
2264
  const m0 = k0.centerTanOut != null ? k0.centerTanOut
2040
2265
  : k0.centerTanIn != null ? k0.centerTanIn
2041
- : _crTanOut(_m0, c0, k0.center, k1.center, c3);
2266
+ : _crTanOut(_m0, c0, k0.center, k1.center);
2042
2267
  const m1 = k1.centerTanIn != null ? k1.centerTanIn
2043
2268
  : k1.centerTanOut != null ? k1.centerTanOut
2044
2269
  : _crTanIn(_m1, c0, k0.center, k1.center, c3);
2045
2270
  hermiteVec3(out.center, k0.center, m0, k1.center, m1, t);
2271
+ } else {
2272
+ lerpVec3(out.center, k0.center, k1.center, t);
2046
2273
  }
2047
2274
 
2048
- // up — nlerp (normalize after lerp; correct for typical near-upright cameras)
2049
- const ux = k0.up[0] + t*(k1.up[0]-k0.up[0]);
2050
- const uy = k0.up[1] + t*(k1.up[1]-k0.up[1]);
2051
- const uz = k0.up[2] + t*(k1.up[2]-k0.up[2]);
2052
- const ul = Math.sqrt(ux*ux+uy*uy+uz*uz) || 1;
2053
- 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;
2054
2279
 
2055
- // fov — lerp (perspective); null when either keyframe lacks it
2056
- out.fov = (k0.fov !== null && k1.fov !== null)
2057
- ? k0.fov + t * (k1.fov - k0.fov) : null;
2058
- // halfHeight lerp (ortho); null when either keyframe lacks it
2059
- out.halfHeight = (k0.halfHeight !== null && k1.halfHeight !== null)
2060
- ? 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);
2061
2286
 
2062
2287
  return out;
2063
2288
  }
@@ -2220,5 +2445,5 @@ function boxVisibility(planes, x0, y0, z0, x1, y1, z1) {
2220
2445
  return allIn ? VISIBLE : SEMIVISIBLE;
2221
2446
  }
2222
2447
 
2223
- 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, mat4EyeMatrix, mat4FromBasis, mat4FromScale, mat4FromTRS, mat4FromTranslation, mat4Frustum, mat4Invert, mat4Location, mat4LookAt, mat4MV, mat4Mul, mat4MulDir, mat4MulPoint, mat4Ortho, mat4PV, mat4Perspective, mat4Pick, mat4Reflect, mat4ToRotation, mat4ToScale, mat4ToTransform, mat4ToTranslation, 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 };
2224
2449
  //# sourceMappingURL=index.js.map