@nakednous/tree 0.0.2 → 0.0.4

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
@@ -65,45 +65,36 @@ const _k = Object.freeze([0, 0, -1]);
65
65
  *
66
66
  * Every function uses only stack locals for intermediates (zero shared state).
67
67
  * Every mutating function writes to a caller-provided `out` and returns `out`.
68
+ * Returns null on degeneracy (singular matrix, etc.).
68
69
  */
69
70
 
70
71
 
71
72
  // ═══════════════════════════════════════════════════════════════════════════
72
- // Mat4 operations
73
+ // Mat4 math
73
74
  // ═══════════════════════════════════════════════════════════════════════════
74
75
 
75
- /** out = identity 4×4 */
76
- function mat4Identity(out) {
77
- out[0]=1;out[1]=0;out[2]=0;out[3]=0;
78
- out[4]=0;out[5]=1;out[6]=0;out[7]=0;
79
- out[8]=0;out[9]=0;out[10]=1;out[11]=0;
80
- out[12]=0;out[13]=0;out[14]=0;out[15]=1;
81
- return out;
82
- }
83
-
84
- /** out = a * b (column-major) */
85
- function mat4Mul(out, a, b) {
86
- const a0=a[0],a1=a[1],a2=a[2],a3=a[3],
87
- a4=a[4],a5=a[5],a6=a[6],a7=a[7],
88
- a8=a[8],a9=a[9],a10=a[10],a11=a[11],
89
- a12=a[12],a13=a[13],a14=a[14],a15=a[15];
90
- let b0,b1,b2,b3;
91
- b0=b[0];b1=b[1];b2=b[2];b3=b[3];
76
+ /** out = A · B (column-major, standard math order) */
77
+ function mat4Mul(out, A, B) {
78
+ const a0=A[0],a1=A[1],a2=A[2],a3=A[3],
79
+ a4=A[4],a5=A[5],a6=A[6],a7=A[7],
80
+ a8=A[8],a9=A[9],a10=A[10],a11=A[11],
81
+ a12=A[12],a13=A[13],a14=A[14],a15=A[15];
82
+ let b0=B[0],b1=B[1],b2=B[2],b3=B[3];
92
83
  out[0]=a0*b0+a4*b1+a8*b2+a12*b3;
93
84
  out[1]=a1*b0+a5*b1+a9*b2+a13*b3;
94
85
  out[2]=a2*b0+a6*b1+a10*b2+a14*b3;
95
86
  out[3]=a3*b0+a7*b1+a11*b2+a15*b3;
96
- b0=b[4];b1=b[5];b2=b[6];b3=b[7];
87
+ b0=B[4];b1=B[5];b2=B[6];b3=B[7];
97
88
  out[4]=a0*b0+a4*b1+a8*b2+a12*b3;
98
89
  out[5]=a1*b0+a5*b1+a9*b2+a13*b3;
99
90
  out[6]=a2*b0+a6*b1+a10*b2+a14*b3;
100
91
  out[7]=a3*b0+a7*b1+a11*b2+a15*b3;
101
- b0=b[8];b1=b[9];b2=b[10];b3=b[11];
92
+ b0=B[8];b1=B[9];b2=B[10];b3=B[11];
102
93
  out[8]=a0*b0+a4*b1+a8*b2+a12*b3;
103
94
  out[9]=a1*b0+a5*b1+a9*b2+a13*b3;
104
95
  out[10]=a2*b0+a6*b1+a10*b2+a14*b3;
105
96
  out[11]=a3*b0+a7*b1+a11*b2+a15*b3;
106
- b0=b[12];b1=b[13];b2=b[14];b3=b[15];
97
+ b0=B[12];b1=B[13];b2=B[14];b3=B[15];
107
98
  out[12]=a0*b0+a4*b1+a8*b2+a12*b3;
108
99
  out[13]=a1*b0+a5*b1+a9*b2+a13*b3;
109
100
  out[14]=a2*b0+a6*b1+a10*b2+a14*b3;
@@ -111,7 +102,7 @@ function mat4Mul(out, a, b) {
111
102
  return out;
112
103
  }
113
104
 
114
- /** out = inverse(src). Returns null if singular. */
105
+ /** out = inverse(src). Returns null if singular. */
115
106
  function mat4Invert(out, src) {
116
107
  const s=src;
117
108
  const a00=s[0],a01=s[1],a02=s[2],a03=s[3],
@@ -202,32 +193,8 @@ function mat4MulPoint(out, m, x, y, z) {
202
193
  return out;
203
194
  }
204
195
 
205
- /** out = mat4 * [x,y,z,0] (direction, no translation) */
206
- function mat4MulDir(out, m, x, y, z) {
207
- out[0] = m[0]*x + m[4]*y + m[8]*z;
208
- out[1] = m[1]*x + m[5]*y + m[9]*z;
209
- out[2] = m[2]*x + m[6]*y + m[10]*z;
210
- return out;
211
- }
212
-
213
- /** out = upper-left 3×3 transposed from mat4 (direction / dMatrix extraction) */
214
- function mat3FromMat4T(out, m) {
215
- out[0]=m[0]; out[1]=m[4]; out[2]=m[8];
216
- out[3]=m[1]; out[4]=m[5]; out[5]=m[9];
217
- out[6]=m[2]; out[7]=m[6]; out[8]=m[10];
218
- return out;
219
- }
220
-
221
- /** out = mat3 * vec3 */
222
- function mat3MulVec3(out, m, x, y, z) {
223
- out[0] = m[0]*x + m[3]*y + m[6]*z;
224
- out[1] = m[1]*x + m[4]*y + m[7]*z;
225
- out[2] = m[2]*x + m[5]*y + m[8]*z;
226
- return out;
227
- }
228
-
229
196
  // ═══════════════════════════════════════════════════════════════════════════
230
- // Projection queries
197
+ // Projection queries (read scalars from a projection mat4)
231
198
  // ═══════════════════════════════════════════════════════════════════════════
232
199
 
233
200
  /** @returns {boolean} true if orthographic */
@@ -235,8 +202,8 @@ function projIsOrtho(p) { return p[15] !== 0; }
235
202
 
236
203
  /**
237
204
  * Near plane distance.
238
- * @param {ArrayLike<number>} p Projection Mat4
239
- * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0)
205
+ * @param {ArrayLike<number>} p Projection Mat4.
206
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
240
207
  */
241
208
  function projNear(p, ndcZMin) {
242
209
  return p[15] === 0
@@ -289,107 +256,34 @@ function projHfov(p) {
289
256
  // Derived matrices (convenience)
290
257
  // ═══════════════════════════════════════════════════════════════════════════
291
258
 
292
- /** out = proj * view = P · V (standard GL) */
259
+ /** out = P · V */
293
260
  function mat4PV(out, proj, view) { return mat4Mul(out, proj, view); }
294
261
 
295
- /** out = view * model = V · M (standard GL) */
262
+ /** out = V · M */
296
263
  function mat4MV(out, model, view) { return mat4Mul(out, view, model); }
297
264
 
298
- /** out = proj * view * model = P · V · M (standard GL) */
299
- function mat4PMV(out, proj, model, view) {
300
- // MV = view * model (V · M)
301
- const t0=view[0],t1=view[1],t2=view[2],t3=view[3],
302
- t4=view[4],t5=view[5],t6=view[6],t7=view[7],
303
- t8=view[8],t9=view[9],t10=view[10],t11=view[11],
304
- t12=view[12],t13=view[13],t14=view[14],t15=view[15];
305
- let b0,b1,b2,b3;
306
- b0=model[0];b1=model[1];b2=model[2];b3=model[3];
307
- const mv0=t0*b0+t4*b1+t8*b2+t12*b3, mv1=t1*b0+t5*b1+t9*b2+t13*b3,
308
- mv2=t2*b0+t6*b1+t10*b2+t14*b3, mv3=t3*b0+t7*b1+t11*b2+t15*b3;
309
- b0=model[4];b1=model[5];b2=model[6];b3=model[7];
310
- const mv4=t0*b0+t4*b1+t8*b2+t12*b3, mv5=t1*b0+t5*b1+t9*b2+t13*b3,
311
- mv6=t2*b0+t6*b1+t10*b2+t14*b3, mv7=t3*b0+t7*b1+t11*b2+t15*b3;
312
- b0=model[8];b1=model[9];b2=model[10];b3=model[11];
313
- const mv8=t0*b0+t4*b1+t8*b2+t12*b3, mv9=t1*b0+t5*b1+t9*b2+t13*b3,
314
- mv10=t2*b0+t6*b1+t10*b2+t14*b3, mv11=t3*b0+t7*b1+t11*b2+t15*b3;
315
- b0=model[12];b1=model[13];b2=model[14];b3=model[15];
316
- const mv12=t0*b0+t4*b1+t8*b2+t12*b3, mv13=t1*b0+t5*b1+t9*b2+t13*b3,
317
- mv14=t2*b0+t6*b1+t10*b2+t14*b3, mv15=t3*b0+t7*b1+t11*b2+t15*b3;
318
- // PMV = proj * MV (P · V · M)
319
- const p0=proj[0],p1=proj[1],p2=proj[2],p3=proj[3],
320
- p4=proj[4],p5=proj[5],p6=proj[6],p7=proj[7],
321
- p8=proj[8],p9=proj[9],p10=proj[10],p11=proj[11],
322
- p12=proj[12],p13=proj[13],p14=proj[14],p15=proj[15];
323
- out[0]=p0*mv0+p4*mv1+p8*mv2+p12*mv3;
324
- out[1]=p1*mv0+p5*mv1+p9*mv2+p13*mv3;
325
- out[2]=p2*mv0+p6*mv1+p10*mv2+p14*mv3;
326
- out[3]=p3*mv0+p7*mv1+p11*mv2+p15*mv3;
327
- out[4]=p0*mv4+p4*mv5+p8*mv6+p12*mv7;
328
- out[5]=p1*mv4+p5*mv5+p9*mv6+p13*mv7;
329
- out[6]=p2*mv4+p6*mv5+p10*mv6+p14*mv7;
330
- out[7]=p3*mv4+p7*mv5+p11*mv6+p15*mv7;
331
- out[8]=p0*mv8+p4*mv9+p8*mv10+p12*mv11;
332
- out[9]=p1*mv8+p5*mv9+p9*mv10+p13*mv11;
333
- out[10]=p2*mv8+p6*mv9+p10*mv10+p14*mv11;
334
- out[11]=p3*mv8+p7*mv9+p11*mv10+p15*mv11;
335
- out[12]=p0*mv12+p4*mv13+p8*mv14+p12*mv15;
336
- out[13]=p1*mv12+p5*mv13+p9*mv14+p13*mv15;
337
- out[14]=p2*mv12+p6*mv13+p10*mv14+p14*mv15;
338
- out[15]=p3*mv12+p7*mv13+p11*mv14+p15*mv15;
339
- return out;
340
- }
341
-
342
265
  // ═══════════════════════════════════════════════════════════════════════════
343
- // Space transforms mapLocation / mapDirection
266
+ // Location / Direction transforms
344
267
  // ═══════════════════════════════════════════════════════════════════════════
345
- //
346
- // FLAT DISPATCH: every from→to pair is a self-contained leaf.
347
- // No path calls back into mapLocation/mapDirection (no reentrancy).
348
- // All intermediates are stack locals (zero shared state).
349
- //
350
-
351
- // ── Location Transform ───────────────────────────────────────────────────
352
268
 
353
269
  /**
354
- * Relative transform for locations (points).
355
- *
356
- * out = inv(to) · from
357
- *
358
- * Maps a point from the `from` frame into the `to` frame:
359
- *
360
- * p_to = out · p_from
361
- *
362
- * @param {ArrayLike<number>} out Destination 4×4 matrix (length 16).
363
- * @param {ArrayLike<number>} from Source frame transform.
364
- * @param {ArrayLike<number>} to Destination frame transform.
365
- * @returns {ArrayLike<number>|null} `out`, or `null` if `to` is singular.
270
+ * Relative transform for locations (points): out = inv(to) · from.
271
+ * @param {ArrayLike<number>} out 16-element destination.
272
+ * @param {ArrayLike<number>} from Source frame transform.
273
+ * @param {ArrayLike<number>} to Destination frame transform.
274
+ * @returns {ArrayLike<number>|null} out, or null if to is singular.
366
275
  */
367
276
  function mat4Location(out, from, to) {
368
277
  return mat4Invert(out, to) && mat4Mul(out, out, from);
369
278
  }
370
279
 
371
- // ── Direction Transform ──────────────────────────────────────────────────
372
-
373
280
  /**
374
- * Relative transform for directions (vectors).
375
- *
281
+ * Relative transform for directions (vectors): out = to₃ · inv(from₃).
376
282
  * Uses only the upper-left 3×3 blocks, ignoring translation.
377
- *
378
- * Corresponds to:
379
- *
380
- * out = to₃ · inv(from₃)
381
- *
382
- * and maps directions as:
383
- *
384
- * d_to = out · d_from
385
- *
386
- * Note: the final write is transposed so the result matches this module's
387
- * matrix layout and multiplication convention.
388
- *
389
- * @param {ArrayLike<number>} out Destination 3×3 matrix (length 9).
390
- * @param {ArrayLike<number>} from Source frame transform.
391
- * @param {ArrayLike<number>} to Destination frame transform.
392
- * @returns {ArrayLike<number>|null} `out`, or `null` if `from` is singular.
283
+ * @param {ArrayLike<number>} out 9-element destination.
284
+ * @param {ArrayLike<number>} from Source frame transform.
285
+ * @param {ArrayLike<number>} to Destination frame transform.
286
+ * @returns {ArrayLike<number>|null} out, or null if from is singular.
393
287
  */
394
288
  function mat3Direction(out, from, to) {
395
289
  const a00=from[0], a01=from[1], a02=from[2],
@@ -401,33 +295,41 @@ function mat3Direction(out, from, to) {
401
295
  let det=a00*b01+a01*b11+a02*b21;
402
296
  if (Math.abs(det) < 1e-12) return null;
403
297
  det=1/det;
404
- const i00=b01*det;
405
- const i01=(a02*a21-a22*a01)*det;
406
- const i02=(a12*a01-a02*a11)*det;
407
- const i10=b11*det;
408
- const i11=(a22*a00-a02*a20)*det;
409
- const i12=(a02*a10-a12*a00)*det;
410
- const i20=b21*det;
411
- const i21=(a01*a20-a21*a00)*det;
412
- const i22=(a11*a00-a01*a10)*det;
298
+ const i00=b01*det, i01=(a02*a21-a22*a01)*det, i02=(a12*a01-a02*a11)*det;
299
+ const i10=b11*det, i11=(a22*a00-a02*a20)*det, i12=(a02*a10-a12*a00)*det;
300
+ const i20=b21*det, i21=(a01*a20-a21*a00)*det, i22=(a11*a00-a01*a10)*det;
413
301
  const t00=to[0], t01=to[1], t02=to[2],
414
302
  t10=to[4], t11=to[5], t12=to[6],
415
303
  t20=to[8], t21=to[9], t22=to[10];
416
- const m00=t00*i00+t10*i01+t20*i02;
417
- const m01=t01*i00+t11*i01+t21*i02;
418
- const m02=t02*i00+t12*i01+t22*i02;
419
- const m10=t00*i10+t10*i11+t20*i12;
420
- const m11=t01*i10+t11*i11+t21*i12;
421
- const m12=t02*i10+t12*i11+t22*i12;
422
- const m20=t00*i20+t10*i21+t20*i22;
423
- const m21=t01*i20+t11*i21+t21*i22;
424
- const m22=t02*i20+t12*i21+t22*i22;
304
+ const m00=t00*i00+t10*i01+t20*i02, m01=t01*i00+t11*i01+t21*i02, m02=t02*i00+t12*i01+t22*i02;
305
+ const m10=t00*i10+t10*i11+t20*i12, m11=t01*i10+t11*i11+t21*i12, m12=t02*i10+t12*i11+t22*i12;
306
+ const m20=t00*i20+t10*i21+t20*i22, m21=t01*i20+t11*i21+t21*i22, m22=t02*i20+t12*i21+t22*i22;
425
307
  out[0]=m00; out[1]=m10; out[2]=m20;
426
308
  out[3]=m01; out[4]=m11; out[5]=m21;
427
309
  out[6]=m02; out[7]=m12; out[8]=m22;
428
310
  return out;
429
311
  }
430
312
 
313
+ // ═══════════════════════════════════════════════════════════════════════════
314
+ // Space transforms — mapLocation / mapDirection
315
+ // ═══════════════════════════════════════════════════════════════════════════
316
+ //
317
+ // FLAT DISPATCH: every from→to pair is a self-contained leaf.
318
+ // No path calls back into mapLocation/mapDirection (no reentrancy).
319
+ // All intermediates are stack locals (zero shared state).
320
+ //
321
+ // Matrices bag m:
322
+ // {
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)
330
+ // }
331
+ //
332
+
431
333
  // ── Location leaf helpers ────────────────────────────────────────────────
432
334
 
433
335
  function _worldToScreen(out, px, py, pz, pv, vp, ndcZMin) {
@@ -435,45 +337,43 @@ function _worldToScreen(out, px, py, pz, pv, vp, ndcZMin) {
435
337
  const y = pv[1]*px+pv[5]*py+pv[9]*pz+pv[13];
436
338
  const z = pv[2]*px+pv[6]*py+pv[10]*pz+pv[14];
437
339
  const w = pv[3]*px+pv[7]*py+pv[11]*pz+pv[15];
438
- if (w === 0) { out[0]=px; out[1]=py; out[2]=pz; return out; }
439
- const nx=x/w, ny=y/w, nz=z/w;
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;
440
343
  const ndcZRange = 1 - ndcZMin;
441
- out[0] = (nx*0.5+0.5)*vp[2]+vp[0];
442
- out[1] = (ny*0.5+0.5)*vp[3]+vp[1];
443
- out[2] = (nz - ndcZMin) / ndcZRange;
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;
444
347
  return out;
445
348
  }
446
349
 
447
350
  function _screenToWorld(out, px, py, pz, ipv, vp, ndcZMin) {
448
- const sx=(px-vp[0])/vp[2], sy=(py-vp[1])/vp[3];
449
- const nx=sx*2-1, ny=sy*2-1;
450
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;
451
354
  const nz = pz * ndcZRange + ndcZMin;
452
- const x=ipv[0]*nx+ipv[4]*ny+ipv[8]*nz+ipv[12];
453
- const y=ipv[1]*nx+ipv[5]*ny+ipv[9]*nz+ipv[13];
454
- const z=ipv[2]*nx+ipv[6]*ny+ipv[10]*nz+ipv[14];
455
- const w=ipv[3]*nx+ipv[7]*ny+ipv[11]*nz+ipv[15];
456
- if (w === 0) { out[0]=px; out[1]=py; out[2]=pz; return out; }
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];
457
359
  out[0]=x/w; out[1]=y/w; out[2]=z/w;
458
360
  return out;
459
361
  }
460
362
 
461
363
  function _worldToNDC(out, px, py, pz, pv) {
462
- const x=pv[0]*px+pv[4]*py+pv[8]*pz+pv[12];
463
- const y=pv[1]*px+pv[5]*py+pv[9]*pz+pv[13];
464
- const z=pv[2]*px+pv[6]*py+pv[10]*pz+pv[14];
465
- const w=pv[3]*px+pv[7]*py+pv[11]*pz+pv[15];
466
- if (w === 0) { out[0]=px; out[1]=py; out[2]=pz; return out; }
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];
467
368
  out[0]=x/w; out[1]=y/w; out[2]=z/w;
468
369
  return out;
469
370
  }
470
371
 
471
372
  function _ndcToWorld(out, px, py, pz, ipv) {
472
- const x=ipv[0]*px+ipv[4]*py+ipv[8]*pz+ipv[12];
473
- const y=ipv[1]*px+ipv[5]*py+ipv[9]*pz+ipv[13];
474
- const z=ipv[2]*px+ipv[6]*py+ipv[10]*pz+ipv[14];
475
- const w=ipv[3]*px+ipv[7]*py+ipv[11]*pz+ipv[15];
476
- if (w === 0) { out[0]=px; out[1]=py; out[2]=pz; return out; }
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];
477
377
  out[0]=x/w; out[1]=y/w; out[2]=z/w;
478
378
  return out;
479
379
  }
@@ -494,12 +394,11 @@ function _ndcToScreen(out, px, py, pz, vp, ndcZMin) {
494
394
  return out;
495
395
  }
496
396
 
497
- // ── Inline PV and IPV helpers (stack-local, for paths that need them) ────
397
+ // ── _ensurePV return pvMatrix from bag, computing inline if absent ──────
498
398
 
499
399
  function _ensurePV(m) {
500
- if (m.pv) return m.pv;
501
- // Inline P · V (standard GL: clip = P · V · world_point)
502
- const p = m.proj, v = m.view;
400
+ if (m.pvMatrix) return m.pvMatrix;
401
+ const p = m.pMatrix, v = m.vMatrix;
503
402
  return [
504
403
  p[0]*v[0]+p[4]*v[1]+p[8]*v[2]+p[12]*v[3],
505
404
  p[1]*v[0]+p[5]*v[1]+p[9]*v[2]+p[13]*v[3],
@@ -523,27 +422,27 @@ function _ensurePV(m) {
523
422
  /**
524
423
  * Map a point between coordinate spaces.
525
424
  *
526
- * @param {Vec3} out Result written here.
527
- * @param {number} px,py,pz Input point.
528
- * @param {string} from Source space.
529
- * @param {string} to Target space.
530
- * @param {object} m Matrices bag { proj, view, eye?, pv?, ipv?, model?,
531
- * fromFrame?, toFrameInv? }.
532
- * @param {Vec4} vp Viewport [x, y, width, height].
533
- * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
425
+ * @param {Vec3} out Result written here.
426
+ * @param {number} px,py,pz Input point.
427
+ * @param {string} from Source space constant.
428
+ * @param {string} to Target space constant.
429
+ * @param {object} m Matrices bag:
430
+ * { pMatrix, vMatrix, eMatrix?, pvMatrix?, ipvMatrix?, fromFrame?, toFrameInv? }
431
+ * @param {Vec4} vp Viewport [x, y, width, height].
432
+ * @param {number} ndcZMin WEBGL (−1) or WEBGPU (0).
534
433
  */
535
434
  function mapLocation(out, px, py, pz, from, to, m, vp, ndcZMin) {
536
435
  // WORLD ↔ SCREEN
537
436
  if (from === WORLD && to === SCREEN)
538
437
  return _worldToScreen(out, px,py,pz, _ensurePV(m), vp, ndcZMin);
539
438
  if (from === SCREEN && to === WORLD)
540
- return _screenToWorld(out, px,py,pz, m.ipv, vp, ndcZMin);
439
+ return _screenToWorld(out, px,py,pz, m.ipvMatrix, vp, ndcZMin);
541
440
 
542
441
  // WORLD ↔ NDC
543
442
  if (from === WORLD && to === NDC)
544
443
  return _worldToNDC(out, px,py,pz, _ensurePV(m));
545
444
  if (from === NDC && to === WORLD)
546
- return _ndcToWorld(out, px,py,pz, m.ipv);
445
+ return _ndcToWorld(out, px,py,pz, m.ipvMatrix);
547
446
 
548
447
  // SCREEN ↔ NDC
549
448
  if (from === SCREEN && to === NDC)
@@ -553,34 +452,36 @@ function mapLocation(out, px, py, pz, from, to, m, vp, ndcZMin) {
553
452
 
554
453
  // WORLD ↔ EYE
555
454
  if (from === WORLD && to === EYE)
556
- return mat4MulPoint(out, m.view, px,py,pz);
455
+ return mat4MulPoint(out, m.vMatrix, px,py,pz);
557
456
  if (from === EYE && to === WORLD)
558
- return mat4MulPoint(out, m.eye, px,py,pz);
457
+ return mat4MulPoint(out, m.eMatrix, px,py,pz);
559
458
 
560
- // EYE ↔ SCREEN (inline: eye→world→screen / screen→world→eye)
459
+ // EYE ↔ SCREEN
561
460
  if (from === EYE && to === SCREEN) {
562
- const ex=m.eye[0]*px+m.eye[4]*py+m.eye[8]*pz+m.eye[12],
563
- ey=m.eye[1]*px+m.eye[5]*py+m.eye[9]*pz+m.eye[13],
564
- ez=m.eye[2]*px+m.eye[6]*py+m.eye[10]*pz+m.eye[14];
461
+ const e = m.eMatrix;
462
+ const ex=e[0]*px+e[4]*py+e[8]*pz+e[12],
463
+ ey=e[1]*px+e[5]*py+e[9]*pz+e[13],
464
+ ez=e[2]*px+e[6]*py+e[10]*pz+e[14];
565
465
  return _worldToScreen(out, ex,ey,ez, _ensurePV(m), vp, ndcZMin);
566
466
  }
567
467
  if (from === SCREEN && to === EYE) {
568
- _screenToWorld(out, px,py,pz, m.ipv, vp, ndcZMin);
468
+ _screenToWorld(out, px,py,pz, m.ipvMatrix, vp, ndcZMin);
569
469
  const wx=out[0],wy=out[1],wz=out[2];
570
- return mat4MulPoint(out, m.view, wx,wy,wz);
470
+ return mat4MulPoint(out, m.vMatrix, wx,wy,wz);
571
471
  }
572
472
 
573
- // EYE ↔ NDC (inline: eye→world→ndc / ndc→world→eye)
473
+ // EYE ↔ NDC
574
474
  if (from === EYE && to === NDC) {
575
- const ex=m.eye[0]*px+m.eye[4]*py+m.eye[8]*pz+m.eye[12],
576
- ey=m.eye[1]*px+m.eye[5]*py+m.eye[9]*pz+m.eye[13],
577
- ez=m.eye[2]*px+m.eye[6]*py+m.eye[10]*pz+m.eye[14];
475
+ const e = m.eMatrix;
476
+ const ex=e[0]*px+e[4]*py+e[8]*pz+e[12],
477
+ ey=e[1]*px+e[5]*py+e[9]*pz+e[13],
478
+ ez=e[2]*px+e[6]*py+e[10]*pz+e[14];
578
479
  return _worldToNDC(out, ex,ey,ez, _ensurePV(m));
579
480
  }
580
481
  if (from === NDC && to === EYE) {
581
- _ndcToWorld(out, px,py,pz, m.ipv);
482
+ _ndcToWorld(out, px,py,pz, m.ipvMatrix);
582
483
  const wx=out[0],wy=out[1],wz=out[2];
583
- return mat4MulPoint(out, m.view, wx,wy,wz);
484
+ return mat4MulPoint(out, m.vMatrix, wx,wy,wz);
584
485
  }
585
486
 
586
487
  // MATRIX (custom frame) ↔ WORLD
@@ -591,49 +492,54 @@ function mapLocation(out, px, py, pz, from, to, m, vp, ndcZMin) {
591
492
 
592
493
  // MATRIX ↔ EYE
593
494
  if (from === MATRIX && to === EYE) {
594
- const fx=m.fromFrame[0]*px+m.fromFrame[4]*py+m.fromFrame[8]*pz+m.fromFrame[12],
595
- fy=m.fromFrame[1]*px+m.fromFrame[5]*py+m.fromFrame[9]*pz+m.fromFrame[13],
596
- fz=m.fromFrame[2]*px+m.fromFrame[6]*py+m.fromFrame[10]*pz+m.fromFrame[14];
597
- return mat4MulPoint(out, m.view, fx,fy,fz);
495
+ const f = m.fromFrame;
496
+ const fx=f[0]*px+f[4]*py+f[8]*pz+f[12],
497
+ fy=f[1]*px+f[5]*py+f[9]*pz+f[13],
498
+ fz=f[2]*px+f[6]*py+f[10]*pz+f[14];
499
+ return mat4MulPoint(out, m.vMatrix, fx,fy,fz);
598
500
  }
599
501
  if (from === EYE && to === MATRIX) {
600
- const ex=m.eye[0]*px+m.eye[4]*py+m.eye[8]*pz+m.eye[12],
601
- ey=m.eye[1]*px+m.eye[5]*py+m.eye[9]*pz+m.eye[13],
602
- ez=m.eye[2]*px+m.eye[6]*py+m.eye[10]*pz+m.eye[14];
502
+ const e = m.eMatrix;
503
+ const ex=e[0]*px+e[4]*py+e[8]*pz+e[12],
504
+ ey=e[1]*px+e[5]*py+e[9]*pz+e[13],
505
+ ez=e[2]*px+e[6]*py+e[10]*pz+e[14];
603
506
  return mat4MulPoint(out, m.toFrameInv, ex,ey,ez);
604
507
  }
605
508
 
606
509
  // MATRIX ↔ SCREEN
607
510
  if (from === MATRIX && to === SCREEN) {
608
- const fx=m.fromFrame[0]*px+m.fromFrame[4]*py+m.fromFrame[8]*pz+m.fromFrame[12],
609
- fy=m.fromFrame[1]*px+m.fromFrame[5]*py+m.fromFrame[9]*pz+m.fromFrame[13],
610
- fz=m.fromFrame[2]*px+m.fromFrame[6]*py+m.fromFrame[10]*pz+m.fromFrame[14];
511
+ const f = m.fromFrame;
512
+ const fx=f[0]*px+f[4]*py+f[8]*pz+f[12],
513
+ fy=f[1]*px+f[5]*py+f[9]*pz+f[13],
514
+ fz=f[2]*px+f[6]*py+f[10]*pz+f[14];
611
515
  return _worldToScreen(out, fx,fy,fz, _ensurePV(m), vp, ndcZMin);
612
516
  }
613
517
  if (from === SCREEN && to === MATRIX) {
614
- _screenToWorld(out, px,py,pz, m.ipv, vp, ndcZMin);
518
+ _screenToWorld(out, px,py,pz, m.ipvMatrix, vp, ndcZMin);
615
519
  const wx=out[0],wy=out[1],wz=out[2];
616
520
  return mat4MulPoint(out, m.toFrameInv, wx,wy,wz);
617
521
  }
618
522
 
619
523
  // MATRIX ↔ NDC
620
524
  if (from === MATRIX && to === NDC) {
621
- const fx=m.fromFrame[0]*px+m.fromFrame[4]*py+m.fromFrame[8]*pz+m.fromFrame[12],
622
- fy=m.fromFrame[1]*px+m.fromFrame[5]*py+m.fromFrame[9]*pz+m.fromFrame[13],
623
- fz=m.fromFrame[2]*px+m.fromFrame[6]*py+m.fromFrame[10]*pz+m.fromFrame[14];
525
+ const f = m.fromFrame;
526
+ const fx=f[0]*px+f[4]*py+f[8]*pz+f[12],
527
+ fy=f[1]*px+f[5]*py+f[9]*pz+f[13],
528
+ fz=f[2]*px+f[6]*py+f[10]*pz+f[14];
624
529
  return _worldToNDC(out, fx,fy,fz, _ensurePV(m));
625
530
  }
626
531
  if (from === NDC && to === MATRIX) {
627
- _ndcToWorld(out, px,py,pz, m.ipv);
532
+ _ndcToWorld(out, px,py,pz, m.ipvMatrix);
628
533
  const wx=out[0],wy=out[1],wz=out[2];
629
534
  return mat4MulPoint(out, m.toFrameInv, wx,wy,wz);
630
535
  }
631
536
 
632
537
  // MATRIX ↔ MATRIX
633
538
  if (from === MATRIX && to === MATRIX) {
634
- const fx=m.fromFrame[0]*px+m.fromFrame[4]*py+m.fromFrame[8]*pz+m.fromFrame[12],
635
- fy=m.fromFrame[1]*px+m.fromFrame[5]*py+m.fromFrame[9]*pz+m.fromFrame[13],
636
- fz=m.fromFrame[2]*px+m.fromFrame[6]*py+m.fromFrame[10]*pz+m.fromFrame[14];
539
+ const f = m.fromFrame;
540
+ const fx=f[0]*px+f[4]*py+f[8]*pz+f[12],
541
+ fy=f[1]*px+f[5]*py+f[9]*pz+f[13],
542
+ fz=f[2]*px+f[6]*py+f[10]*pz+f[14];
637
543
  return mat4MulPoint(out, m.toFrameInv, fx,fy,fz);
638
544
  }
639
545
 
@@ -644,7 +550,7 @@ function mapLocation(out, px, py, pz, from, to, m, vp, ndcZMin) {
644
550
 
645
551
  // ── Direction helpers ────────────────────────────────────────────────────
646
552
 
647
- /** Apply the 3×3 linear part of a mat4 (rotation/scale, no translation) */
553
+ /** Apply the 3×3 linear part of a mat4 (rotation/scale, no translation). */
648
554
  function _applyDir(out, mat, dx, dy, dz) {
649
555
  out[0]=mat[0]*dx+mat[4]*dy+mat[8]*dz;
650
556
  out[1]=mat[1]*dx+mat[5]*dy+mat[9]*dz;
@@ -652,37 +558,24 @@ function _applyDir(out, mat, dx, dy, dz) {
652
558
  return out;
653
559
  }
654
560
 
655
- /**
656
- * World→Screen direction. Self-contained: reads proj scalars + view mat.
657
- * The existing p5.tree code nested _direction and _location calls here;
658
- * this version inlines all math with stack locals.
659
- */
660
561
  function _worldToScreenDir(out, dx, dy, dz, proj, view, vpW, vpH, ndcZMin) {
661
- // 1. World → Eye direction: R · d (standard column-major mat × vec)
662
562
  const edx = view[0]*dx + view[4]*dy + view[8]*dz;
663
563
  const edy = view[1]*dx + view[5]*dy + view[9]*dz;
664
564
  const edz = view[2]*dx + view[6]*dy + view[10]*dz;
665
-
666
565
  const isPersp = proj[15] === 0;
667
566
  let sdx = edx, sdy = edy;
668
-
669
567
  if (isPersp) {
670
- // Camera-eye Z of world origin (inline WORLD→EYE for [0,0,0]):
671
- // view * [0,0,0,1] = column 3 of view
672
- const zEye = view[8]*0 + view[9]*0 + view[10]*0 + view[14]; // = view[14]
568
+ const zEye = view[14];
673
569
  const halfTan = Math.tan(projFov(proj) / 2);
674
570
  const k = Math.abs(zEye * halfTan);
675
571
  const pixPerUnit = vpH / (2 * k);
676
572
  sdx *= pixPerUnit;
677
573
  sdy *= pixPerUnit;
678
574
  } else {
679
- // Ortho: pixels per world unit along X/Y
680
575
  const orthoW = Math.abs(projRight(proj, ndcZMin) - projLeft(proj, ndcZMin));
681
576
  sdx *= vpW / orthoW;
682
577
  sdy *= vpH / Math.abs(projTop(proj, ndcZMin) - projBottom(proj, ndcZMin));
683
578
  }
684
-
685
- // Z: map eye-space dz to screen-space dz
686
579
  const near = projNear(proj, ndcZMin), far = projFar(proj);
687
580
  const depthRange = near - far;
688
581
  let sdz;
@@ -691,7 +584,6 @@ function _worldToScreenDir(out, dx, dy, dz, proj, view, vpW, vpH, ndcZMin) {
691
584
  } else {
692
585
  sdz = edz / (depthRange / (Math.abs(projRight(proj, ndcZMin) - projLeft(proj, ndcZMin)) / vpW));
693
586
  }
694
-
695
587
  out[0] = sdx; out[1] = sdy; out[2] = sdz;
696
588
  return out;
697
589
  }
@@ -699,7 +591,6 @@ function _worldToScreenDir(out, dx, dy, dz, proj, view, vpW, vpH, ndcZMin) {
699
591
  function _screenToWorldDir(out, dx, dy, dz, proj, view, eye, vpW, vpH, ndcZMin) {
700
592
  const isPersp = proj[15] === 0;
701
593
  let edx = dx, edy = dy;
702
-
703
594
  if (isPersp) {
704
595
  const zEye = view[14];
705
596
  const halfTan = Math.tan(projFov(proj) / 2);
@@ -711,7 +602,6 @@ function _screenToWorldDir(out, dx, dy, dz, proj, view, eye, vpW, vpH, ndcZMin)
711
602
  edx *= orthoW / vpW;
712
603
  edy *= Math.abs(projTop(proj, ndcZMin) - projBottom(proj, ndcZMin)) / vpH;
713
604
  }
714
-
715
605
  const near = projNear(proj, ndcZMin), far = projFar(proj);
716
606
  const depthRange = near - far;
717
607
  let edz;
@@ -720,8 +610,6 @@ function _screenToWorldDir(out, dx, dy, dz, proj, view, eye, vpW, vpH, ndcZMin)
720
610
  } else {
721
611
  edz = dz * (depthRange / (Math.abs(projRight(proj, ndcZMin) - projLeft(proj, ndcZMin)) / vpW));
722
612
  }
723
-
724
- // Eye → World direction (dMatrix = upper-left 3×3 of eye = inv(view))
725
613
  _applyDir(out, eye, edx, edy, edz);
726
614
  return out;
727
615
  }
@@ -743,21 +631,21 @@ function _ndcToScreenDir(out, dx, dy, dz, vpW, vpH, ndcZMin) {
743
631
  }
744
632
 
745
633
  /**
746
- * Map a direction between coordinate spaces.
747
- * Same flat-dispatch as mapLocation.
634
+ * Map a direction vector between coordinate spaces.
635
+ * Same bag contract as mapLocation.
748
636
  */
749
637
  function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
750
638
  const vpW = Math.abs(vp[2]), vpH = Math.abs(vp[3]);
751
639
 
752
- // EYE ↔ WORLD (most common: dMatrix operation)
753
- if (from === EYE && to === WORLD) return _applyDir(out, m.eye, dx, dy, dz);
754
- if (from === WORLD && to === EYE) return _applyDir(out, m.view, dx, dy, dz);
640
+ // EYE ↔ WORLD (most common)
641
+ if (from === EYE && to === WORLD) return _applyDir(out, m.eMatrix, dx, dy, dz);
642
+ if (from === WORLD && to === EYE) return _applyDir(out, m.vMatrix, dx, dy, dz);
755
643
 
756
644
  // WORLD ↔ SCREEN
757
645
  if (from === WORLD && to === SCREEN)
758
- return _worldToScreenDir(out, dx,dy,dz, m.proj, m.view, vpW, vpH, ndcZMin);
646
+ return _worldToScreenDir(out, dx,dy,dz, m.pMatrix, m.vMatrix, vpW, vpH, ndcZMin);
759
647
  if (from === SCREEN && to === WORLD)
760
- return _screenToWorldDir(out, dx,dy,dz, m.proj, m.view, m.eye, vpW, vpH, ndcZMin);
648
+ return _screenToWorldDir(out, dx,dy,dz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
761
649
 
762
650
  // SCREEN ↔ NDC
763
651
  if (from === SCREEN && to === NDC)
@@ -765,45 +653,44 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
765
653
  if (from === NDC && to === SCREEN)
766
654
  return _ndcToScreenDir(out, dx,dy,dz, vpW, vpH, ndcZMin);
767
655
 
768
- // WORLD ↔ NDC (chain: world→screen→ndc / ndc→screen→world)
656
+ // WORLD ↔ NDC
769
657
  if (from === WORLD && to === NDC) {
770
- _worldToScreenDir(out, dx,dy,dz, m.proj, m.view, vpW, vpH, ndcZMin);
658
+ _worldToScreenDir(out, dx,dy,dz, m.pMatrix, m.vMatrix, vpW, vpH, ndcZMin);
771
659
  const sx=out[0],sy=out[1],sz=out[2];
772
660
  return _screenToNDCDir(out, sx,sy,sz, vpW, vpH, ndcZMin);
773
661
  }
774
662
  if (from === NDC && to === WORLD) {
775
663
  _ndcToScreenDir(out, dx,dy,dz, vpW, vpH, ndcZMin);
776
664
  const sx=out[0],sy=out[1],sz=out[2];
777
- return _screenToWorldDir(out, sx,sy,sz, m.proj, m.view, m.eye, vpW, vpH, ndcZMin);
665
+ return _screenToWorldDir(out, sx,sy,sz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
778
666
  }
779
667
 
780
668
  // EYE ↔ SCREEN
781
669
  if (from === EYE && to === SCREEN) {
782
- // eye→world→screen
783
- _applyDir(out, m.eye, dx,dy,dz);
670
+ _applyDir(out, m.eMatrix, dx,dy,dz);
784
671
  const wx=out[0],wy=out[1],wz=out[2];
785
- return _worldToScreenDir(out, wx,wy,wz, m.proj, m.view, vpW, vpH, ndcZMin);
672
+ return _worldToScreenDir(out, wx,wy,wz, m.pMatrix, m.vMatrix, vpW, vpH, ndcZMin);
786
673
  }
787
674
  if (from === SCREEN && to === EYE) {
788
- _screenToWorldDir(out, dx,dy,dz, m.proj, m.view, m.eye, vpW, vpH, ndcZMin);
675
+ _screenToWorldDir(out, dx,dy,dz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
789
676
  const wx=out[0],wy=out[1],wz=out[2];
790
- return _applyDir(out, m.view, wx,wy,wz);
677
+ return _applyDir(out, m.vMatrix, wx,wy,wz);
791
678
  }
792
679
 
793
680
  // EYE ↔ NDC
794
681
  if (from === EYE && to === NDC) {
795
- _applyDir(out, m.eye, dx,dy,dz);
682
+ _applyDir(out, m.eMatrix, dx,dy,dz);
796
683
  const wx=out[0],wy=out[1],wz=out[2];
797
- _worldToScreenDir(out, wx,wy,wz, m.proj, m.view, vpW, vpH, ndcZMin);
684
+ _worldToScreenDir(out, wx,wy,wz, m.pMatrix, m.vMatrix, vpW, vpH, ndcZMin);
798
685
  const sx=out[0],sy=out[1],sz=out[2];
799
686
  return _screenToNDCDir(out, sx,sy,sz, vpW, vpH, ndcZMin);
800
687
  }
801
688
  if (from === NDC && to === EYE) {
802
689
  _ndcToScreenDir(out, dx,dy,dz, vpW, vpH, ndcZMin);
803
690
  const sx=out[0],sy=out[1],sz=out[2];
804
- _screenToWorldDir(out, sx,sy,sz, m.proj, m.view, m.eye, vpW, vpH, ndcZMin);
691
+ _screenToWorldDir(out, sx,sy,sz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
805
692
  const wx=out[0],wy=out[1],wz=out[2];
806
- return _applyDir(out, m.view, wx,wy,wz);
693
+ return _applyDir(out, m.vMatrix, wx,wy,wz);
807
694
  }
808
695
 
809
696
  // MATRIX ↔ WORLD
@@ -814,10 +701,10 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
814
701
  if (from === MATRIX && to === EYE) {
815
702
  _applyDir(out, m.fromFrame, dx,dy,dz);
816
703
  const wx=out[0],wy=out[1],wz=out[2];
817
- return _applyDir(out, m.view, wx,wy,wz);
704
+ return _applyDir(out, m.vMatrix, wx,wy,wz);
818
705
  }
819
706
  if (from === EYE && to === MATRIX) {
820
- _applyDir(out, m.eye, dx,dy,dz);
707
+ _applyDir(out, m.eMatrix, dx,dy,dz);
821
708
  const wx=out[0],wy=out[1],wz=out[2];
822
709
  return _applyDir(out, m.toFrameInv, wx,wy,wz);
823
710
  }
@@ -826,10 +713,10 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
826
713
  if (from === MATRIX && to === SCREEN) {
827
714
  _applyDir(out, m.fromFrame, dx,dy,dz);
828
715
  const wx=out[0],wy=out[1],wz=out[2];
829
- return _worldToScreenDir(out, wx,wy,wz, m.proj, m.view, vpW, vpH, ndcZMin);
716
+ return _worldToScreenDir(out, wx,wy,wz, m.pMatrix, m.vMatrix, vpW, vpH, ndcZMin);
830
717
  }
831
718
  if (from === SCREEN && to === MATRIX) {
832
- _screenToWorldDir(out, dx,dy,dz, m.proj, m.view, m.eye, vpW, vpH, ndcZMin);
719
+ _screenToWorldDir(out, dx,dy,dz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
833
720
  const wx=out[0],wy=out[1],wz=out[2];
834
721
  return _applyDir(out, m.toFrameInv, wx,wy,wz);
835
722
  }
@@ -838,14 +725,14 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
838
725
  if (from === MATRIX && to === NDC) {
839
726
  _applyDir(out, m.fromFrame, dx,dy,dz);
840
727
  const wx=out[0],wy=out[1],wz=out[2];
841
- _worldToScreenDir(out, wx,wy,wz, m.proj, m.view, vpW, vpH, ndcZMin);
728
+ _worldToScreenDir(out, wx,wy,wz, m.pMatrix, m.vMatrix, vpW, vpH, ndcZMin);
842
729
  const sx=out[0],sy=out[1],sz=out[2];
843
730
  return _screenToNDCDir(out, sx,sy,sz, vpW, vpH, ndcZMin);
844
731
  }
845
732
  if (from === NDC && to === MATRIX) {
846
733
  _ndcToScreenDir(out, dx,dy,dz, vpW, vpH, ndcZMin);
847
734
  const sx=out[0],sy=out[1],sz=out[2];
848
- _screenToWorldDir(out, sx,sy,sz, m.proj, m.view, m.eye, vpW, vpH, ndcZMin);
735
+ _screenToWorldDir(out, sx,sy,sz, m.pMatrix, m.vMatrix, m.eMatrix, vpW, vpH, ndcZMin);
849
736
  const wx=out[0],wy=out[1],wz=out[2];
850
737
  return _applyDir(out, m.toFrameInv, wx,wy,wz);
851
738
  }
@@ -868,9 +755,9 @@ function mapDirection(out, dx, dy, dz, from, to, m, vp, ndcZMin) {
868
755
 
869
756
  /**
870
757
  * World-units-per-pixel at a given eye-space Z depth.
871
- * @param {ArrayLike<number>} proj Projection Mat4.
758
+ * @param {ArrayLike<number>} proj Projection mat4.
872
759
  * @param {number} vpH Viewport height (pixels).
873
- * @param {number} eyeZ Eye-space Z of the point (negative for in-front-of camera).
760
+ * @param {number} eyeZ Eye-space Z (negative for in-front-of camera).
874
761
  * @param {number} ndcZMin WEBGL or WEBGPU.
875
762
  */
876
763
  function pixelRatio(proj, vpH, eyeZ, ndcZMin) {
@@ -881,58 +768,62 @@ function pixelRatio(proj, vpH, eyeZ, ndcZMin) {
881
768
  }
882
769
 
883
770
  /**
884
- * @file Pure quaternion/spline math + PoseTrack state machine.
771
+ * @file Pure quaternion/spline math + track state machines.
885
772
  * @module tree/track
886
773
  * @license GPL-3.0-only
887
774
  *
888
775
  * Zero dependencies. No p5, DOM, WebGL, or WebGPU usage.
889
- * All quaternion operations use flat [x,y,z,w] arrays (w-last = glTF layout).
890
- * PoseTrack is a pure state machine that stores {pos,rot,scl} keyframes
891
- * and advances a cursor via tick().
892
776
  *
893
777
  * ── Exports ──────────────────────────────────────────────────────────────────
894
778
  * Quaternion helpers
895
- * qSet qCopy qDot qNormalize qNegate qMul qSlerp
779
+ * qSet qCopy qDot qNormalize qNegate qMul qSlerp qNlerp
896
780
  * qFromAxisAngle qFromLookDir qFromRotMat3x3 qFromMat4 qToMat4
897
781
  * quatToAxisAngle
898
782
  * Spline / vector helpers
899
783
  * catmullRomVec3 lerpVec3
900
784
  * Transform / mat4 helpers
901
785
  * transformToMat4 mat4ToTransform
902
- * Track
903
- * PoseTrack
786
+ * Tracks
787
+ * PoseTrack — { pos, rot, scl } TRS keyframes
788
+ * CameraTrack — { eye, center, up } lookat keyframes
789
+ *
790
+ * ── Class hierarchy ───────────────────────────────────────────────────────────
791
+ * Track (unexported, never instantiated directly)
792
+ * └── PoseTrack (exported)
793
+ * └── CameraTrack (exported)
794
+ *
795
+ * Track holds all transport machinery: cursor, play/stop/seek/tick,
796
+ * hooks, rate semantics. Subclasses add only keyframe storage and
797
+ * add() / eval() for their respective data shape.
904
798
  *
905
799
  * ── Hook architecture ─────────────────────────────────────────────────────────
906
800
  * _onActivate / _onDeactivate — lib-space (underscore, set by host layer)
907
- * Fire exactly on playing transitions: false→true / true→false.
908
- * Used by the addon to register/unregister from the draw-loop tick set.
801
+ * Fire on playing transitions: false→true / true→false.
909
802
  *
910
- * onPlay / onEnd / onStop — user-space (public, set by user)
911
- * onPlay : fires in play() when playback starts (false→true transition).
912
- * onEnd : fires in tick() when cursor reaches a natural boundary (once mode).
913
- * onStop : fires in stop() and reset() — explicit, user-initiated deactivation.
914
- *
915
- * onEnd and onStop are mutually exclusive per deactivation event.
916
- * To react to any deactivation, chain both.
803
+ * onPlay / onEnd / onStop — user-space (public)
804
+ * onPlay : fires in play() on false→true transition.
805
+ * onEnd : fires in tick() at natural boundary (once mode only).
806
+ * onStop : fires in stop() / reset() — explicit deactivation.
807
+ * onEnd and onStop are mutually exclusive per event.
917
808
  *
918
809
  * Firing order:
919
810
  * play() → onPlay → _onActivate
920
- * tick() → onEnd → _onDeactivate (natural boundary, once mode)
811
+ * tick() → onEnd → _onDeactivate
921
812
  * stop() → onStop → _onDeactivate
922
813
  * reset() → onStop → _onDeactivate
923
814
  *
924
815
  * ── Playback semantics (rate) ─────────────────────────────────────────────────
925
- * rate > 0 forward playback
926
- * rate < 0 backward playback
927
- * rate === 0 frozen: tick() is a no-op; the playing flag is NOT changed.
816
+ * rate > 0 forward
817
+ * rate < 0 backward
818
+ * rate === 0 frozen: tick() no-op; playing unchanged
928
819
  *
929
- * play() is the sole method that sets playing = true.
930
- * stop() is the sole method that sets playing = false.
931
- * Assigning rate never implicitly starts or stops playback.
820
+ * play() is the sole setter of playing = true.
821
+ * stop() is the sole setter of playing = false.
822
+ * Assigning rate never starts or stops playback.
932
823
  *
933
824
  * ── One-keyframe behaviour ────────────────────────────────────────────────────
934
825
  * play() with exactly one keyframe snaps eval() to that keyframe without
935
- * setting playing = true and without animating.
826
+ * setting playing = true and without firing hooks.
936
827
  */
937
828
 
938
829
 
@@ -953,68 +844,62 @@ const qCopy = (out, a) => {
953
844
  /** Dot product of two quaternions. */
954
845
  const qDot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
955
846
 
956
- /** Normalise in-place. @returns {number[]} out */
847
+ /** Normalise quaternion in-place. @returns {number[]} out */
957
848
  const qNormalize = (out) => {
958
- const len = Math.sqrt(qDot(out, out)) || 1;
959
- out[0] /= len; out[1] /= len; out[2] /= len; out[3] /= len;
960
- return out;
849
+ const l = Math.sqrt(out[0]*out[0]+out[1]*out[1]+out[2]*out[2]+out[3]*out[3]) || 1;
850
+ out[0]/=l; out[1]/=l; out[2]/=l; out[3]/=l; return out;
961
851
  };
962
852
 
963
- /** Negate all components in-place. @returns {number[]} out */
964
- const qNegate = (out) => {
965
- out[0] = -out[0]; out[1] = -out[1]; out[2] = -out[2]; out[3] = -out[3];
966
- return out;
853
+ /** Negate quaternion (same rotation, different hemisphere). @returns {number[]} out */
854
+ const qNegate = (out, a) => {
855
+ out[0]=-a[0]; out[1]=-a[1]; out[2]=-a[2]; out[3]=-a[3]; return out;
967
856
  };
968
857
 
969
- /** out = a * b (Hamilton product). @returns {number[]} out */
858
+ /** Hamilton product out = a * b. @returns {number[]} out */
970
859
  const qMul = (out, a, b) => {
971
- const ax = a[0], ay = a[1], az = a[2], aw = a[3];
972
- const bx = b[0], by = b[1], bz = b[2], bw = b[3];
973
- out[0] = aw*bx + ax*bw + ay*bz - az*by;
974
- out[1] = aw*by - ax*bz + ay*bw + az*bx;
975
- out[2] = aw*bz + ax*by - ay*bx + az*bw;
976
- out[3] = aw*bw - ax*bx - ay*by - az*bz;
860
+ 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];
861
+ out[0]=aw*bx+ax*bw+ay*bz-az*by;
862
+ out[1]=aw*by-ax*bz+ay*bw+az*bx;
863
+ out[2]=aw*bz+ax*by-ay*bx+az*bw;
864
+ out[3]=aw*bw-ax*bx-ay*by-az*bz;
977
865
  return out;
978
866
  };
979
867
 
868
+ /** Spherical linear interpolation. @returns {number[]} out */
869
+ const qSlerp = (out, a, b, t) => {
870
+ let bx=b[0],by=b[1],bz=b[2],bw=b[3];
871
+ let d = a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw;
872
+ if (d < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; d=-d; }
873
+ let f0, f1;
874
+ if (1-d > 1e-10) {
875
+ const th=Math.acos(d), st=Math.sin(th);
876
+ f0=Math.sin((1-t)*th)/st; f1=Math.sin(t*th)/st;
877
+ } else {
878
+ f0=1-t; f1=t;
879
+ }
880
+ out[0]=a[0]*f0+bx*f1; out[1]=a[1]*f0+by*f1;
881
+ out[2]=a[2]*f0+bz*f1; out[3]=a[3]*f0+bw*f1;
882
+ return qNormalize(out);
883
+ };
884
+
980
885
  /**
981
- * SLERP between quaternions a and b at parameter t.
982
- * Shortest-arc: negates b when dot < 0.
983
- * Near-equal fallback: nlerp when dot ~= 1.
984
- * @param {number[]} out 4-element result array.
985
- * @param {number[]} a Start quaternion [x,y,z,w].
986
- * @param {number[]} b End quaternion [x,y,z,w].
987
- * @param {number} t Blend [0, 1].
886
+ * Normalised linear interpolation (nlerp).
887
+ * Cheaper than slerp; slightly non-constant angular velocity.
888
+ * Handles antipodal quats by flipping b when dot < 0.
988
889
  * @returns {number[]} out
989
890
  */
990
- const qSlerp = (out, a, b, t) => {
991
- let d = qDot(a, b);
992
- let bx = b[0], by = b[1], bz = b[2], bw = b[3];
993
- if (d < 0) { d = -d; bx = -bx; by = -by; bz = -bz; bw = -bw; }
994
- if (d > 0.9995) {
995
- out[0] = a[0] + t*(bx - a[0]);
996
- out[1] = a[1] + t*(by - a[1]);
997
- out[2] = a[2] + t*(bz - a[2]);
998
- out[3] = a[3] + t*(bw - a[3]);
999
- return qNormalize(out);
1000
- }
1001
- const theta = Math.acos(d), sinT = Math.sin(theta);
1002
- const s0 = Math.sin((1 - t) * theta) / sinT;
1003
- const s1 = Math.sin(t * theta) / sinT;
1004
- out[0] = s0*a[0] + s1*bx;
1005
- out[1] = s0*a[1] + s1*by;
1006
- out[2] = s0*a[2] + s1*bz;
1007
- out[3] = s0*a[3] + s1*bw;
1008
- return out;
891
+ const qNlerp = (out, a, b, t) => {
892
+ let bx=b[0],by=b[1],bz=b[2],bw=b[3];
893
+ if (a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; }
894
+ out[0]=a[0]+t*(bx-a[0]); out[1]=a[1]+t*(by-a[1]);
895
+ out[2]=a[2]+t*(bz-a[2]); out[3]=a[3]+t*(bw-a[3]);
896
+ return qNormalize(out);
1009
897
  };
1010
898
 
1011
899
  /**
1012
- * Build a quaternion from an axis-angle rotation.
1013
- * The axis need not be normalised.
900
+ * Build a quaternion from axis-angle.
1014
901
  * @param {number[]} out
1015
- * @param {number} ax Axis x.
1016
- * @param {number} ay Axis y.
1017
- * @param {number} az Axis z.
902
+ * @param {number} ax @param {number} ay @param {number} az Axis (need not be unit).
1018
903
  * @param {number} angle Radians.
1019
904
  * @returns {number[]} out
1020
905
  */
@@ -1022,93 +907,76 @@ const qFromAxisAngle = (out, ax, ay, az, angle) => {
1022
907
  const half = angle * 0.5;
1023
908
  const s = Math.sin(half);
1024
909
  const len = Math.sqrt(ax*ax + ay*ay + az*az) || 1;
1025
- out[0] = s * ax / len;
1026
- out[1] = s * ay / len;
1027
- out[2] = s * az / len;
910
+ out[0] = s * ax / len; out[1] = s * ay / len; out[2] = s * az / len;
1028
911
  out[3] = Math.cos(half);
1029
912
  return out;
1030
913
  };
1031
914
 
1032
915
  /**
1033
- * Build a quaternion from a look direction (negative-Z forward convention)
1034
- * and an optional up vector (defaults to +Y).
916
+ * Build a quaternion from a look direction (Z forward) and optional up (default +Y).
1035
917
  * @param {number[]} out
1036
918
  * @param {number[]} dir Forward direction [x,y,z].
1037
919
  * @param {number[]} [up] Up vector [x,y,z].
1038
920
  * @returns {number[]} out
1039
921
  */
1040
922
  const qFromLookDir = (out, dir, up) => {
1041
- let fx = dir[0], fy = dir[1], fz = dir[2];
1042
- const fLen = Math.sqrt(fx*fx + fy*fy + fz*fz) || 1;
1043
- fx /= fLen; fy /= fLen; fz /= fLen;
1044
- let ux = up ? up[0] : 0, uy = up ? up[1] : 1, uz = up ? up[2] : 0;
1045
- let rx = uy*fz - uz*fy, ry = uz*fx - ux*fz, rz = ux*fy - uy*fx;
1046
- const rLen = Math.sqrt(rx*rx + ry*ry + rz*rz) || 1;
1047
- rx /= rLen; ry /= rLen; rz /= rLen;
1048
- ux = fy*rz - fz*ry; uy = fz*rx - fx*rz; uz = fx*ry - fy*rx;
1049
- return qFromRotMat3x3(out, rx, ry, rz, ux, uy, uz, -fx, -fy, -fz);
923
+ let fx=dir[0],fy=dir[1],fz=dir[2];
924
+ const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
925
+ fx/=fl; fy/=fl; fz/=fl;
926
+ let ux=up?up[0]:0, uy=up?up[1]:1, uz=up?up[2]:0;
927
+ let rx=uy*fz-uz*fy, ry=uz*fx-ux*fz, rz=ux*fy-uy*fx;
928
+ const rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1;
929
+ rx/=rl; ry/=rl; rz/=rl;
930
+ ux=fy*rz-fz*ry; uy=fz*rx-fx*rz; uz=fx*ry-fy*rx;
931
+ return qFromRotMat3x3(out, rx,ry,rz, ux,uy,uz, -fx,-fy,-fz);
1050
932
  };
1051
933
 
1052
934
  /**
1053
- * Build a quaternion from a 3x3 rotation matrix supplied as 9 row-major scalars.
935
+ * Build a quaternion from a 3×3 rotation matrix (9 row-major scalars).
1054
936
  * @returns {number[]} out (normalised)
1055
937
  */
1056
- const qFromRotMat3x3 = (out, m00, m01, m02, m10, m11, m12, m20, m21, m22) => {
1057
- const tr = m00 + m11 + m22;
938
+ const qFromRotMat3x3 = (out, m00,m01,m02, m10,m11,m12, m20,m21,m22) => {
939
+ const tr = m00+m11+m22;
1058
940
  if (tr > 0) {
1059
- const s = 0.5 / Math.sqrt(tr + 1);
1060
- out[3] = 0.25 / s;
1061
- out[0] = (m21 - m12) * s;
1062
- out[1] = (m02 - m20) * s;
1063
- out[2] = (m10 - m01) * s;
1064
- } else if (m00 > m11 && m00 > m22) {
1065
- const s = 2 * Math.sqrt(1 + m00 - m11 - m22);
1066
- out[3] = (m21 - m12) / s;
1067
- out[0] = 0.25 * s;
1068
- out[1] = (m01 + m10) / s;
1069
- out[2] = (m02 + m20) / s;
1070
- } else if (m11 > m22) {
1071
- const s = 2 * Math.sqrt(1 + m11 - m00 - m22);
1072
- out[3] = (m02 - m20) / s;
1073
- out[0] = (m01 + m10) / s;
1074
- out[1] = 0.25 * s;
1075
- out[2] = (m12 + m21) / s;
941
+ const s=0.5/Math.sqrt(tr+1);
942
+ out[3]=0.25/s; out[0]=(m21-m12)*s; out[1]=(m02-m20)*s; out[2]=(m10-m01)*s;
943
+ } else if (m00>m11 && m00>m22) {
944
+ const s=2*Math.sqrt(1+m00-m11-m22);
945
+ out[3]=(m21-m12)/s; out[0]=0.25*s; out[1]=(m01+m10)/s; out[2]=(m02+m20)/s;
946
+ } else if (m11>m22) {
947
+ const s=2*Math.sqrt(1+m11-m00-m22);
948
+ out[3]=(m02-m20)/s; out[0]=(m01+m10)/s; out[1]=0.25*s; out[2]=(m12+m21)/s;
1076
949
  } else {
1077
- const s = 2 * Math.sqrt(1 + m22 - m00 - m11);
1078
- out[3] = (m10 - m01) / s;
1079
- out[0] = (m02 + m20) / s;
1080
- out[1] = (m12 + m21) / s;
1081
- out[2] = 0.25 * s;
950
+ const s=2*Math.sqrt(1+m22-m00-m11);
951
+ out[3]=(m10-m01)/s; out[0]=(m02+m20)/s; out[1]=(m12+m21)/s; out[2]=0.25*s;
1082
952
  }
1083
953
  return qNormalize(out);
1084
954
  };
1085
955
 
1086
956
  /**
1087
- * Extract a unit quaternion from the upper-left 3x3 of a column-major mat4.
957
+ * Extract a unit quaternion from the upper-left 3×3 of a column-major mat4.
1088
958
  * @param {number[]} out
1089
959
  * @param {Float32Array|number[]} m Column-major mat4.
1090
960
  * @returns {number[]} out
1091
961
  */
1092
962
  const qFromMat4 = (out, m) =>
1093
- qFromRotMat3x3(out, m[0], m[4], m[8], m[1], m[5], m[9], m[2], m[6], m[10]);
963
+ qFromRotMat3x3(out, m[0],m[4],m[8], m[1],m[5],m[9], m[2],m[6],m[10]);
1094
964
 
1095
965
  /**
1096
- * Write a quaternion into a column-major mat4 (rotation block only;
1097
- * translation and perspective rows/cols are set to identity values).
966
+ * Write a quaternion into the rotation block of a column-major mat4.
967
+ * Translation and perspective rows/cols are set to identity values.
1098
968
  * @param {Float32Array|number[]} out 16-element array.
1099
969
  * @param {number[]} q [x,y,z,w].
1100
970
  * @returns {Float32Array|number[]} out
1101
971
  */
1102
972
  const qToMat4 = (out, q) => {
1103
- const x = q[0], y = q[1], z = q[2], w = q[3];
1104
- const x2 = x+x, y2 = y+y, z2 = z+z;
1105
- const xx = x*x2, xy = x*y2, xz = x*z2;
1106
- const yy = y*y2, yz = y*z2, zz = z*z2;
1107
- const wx = w*x2, wy = w*y2, wz = w*z2;
1108
- out[0] = 1-(yy+zz); out[1] = xy+wz; out[2] = xz-wy; out[3] = 0;
1109
- out[4] = xy-wz; out[5] = 1-(xx+zz); out[6] = yz+wx; out[7] = 0;
1110
- out[8] = xz+wy; out[9] = yz-wx; out[10] = 1-(xx+yy); out[11] = 0;
1111
- out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1;
973
+ const x=q[0],y=q[1],z=q[2],w=q[3];
974
+ const x2=x+x,y2=y+y,z2=z+z;
975
+ 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;
976
+ out[0]=1-(yy+zz); out[1]=xy+wz; out[2]=xz-wy; out[3]=0;
977
+ out[4]=xy-wz; out[5]=1-(xx+zz); out[6]=yz+wx; out[7]=0;
978
+ out[8]=xz+wy; out[9]=yz-wx; out[10]=1-(xx+yy); out[11]=0;
979
+ out[12]=0; out[13]=0; out[14]=0; out[15]=1;
1112
980
  return out;
1113
981
  };
1114
982
 
@@ -1120,11 +988,11 @@ const qToMat4 = (out, q) => {
1120
988
  */
1121
989
  const quatToAxisAngle = (q, out) => {
1122
990
  out = out || {};
1123
- const x = q[0], y = q[1], z = q[2], w = q[3];
1124
- const sinHalf = Math.sqrt(x*x + y*y + z*z);
1125
- if (sinHalf < 1e-8) { out.axis = [0, 1, 0]; out.angle = 0; return out; }
1126
- out.angle = 2 * Math.atan2(sinHalf, w);
1127
- out.axis = [x / sinHalf, y / sinHalf, z / sinHalf];
991
+ const x=q[0],y=q[1],z=q[2],w=q[3];
992
+ const sinHalf = Math.sqrt(x*x+y*y+z*z);
993
+ if (sinHalf < 1e-8) { out.axis=[0,1,0]; out.angle=0; return out; }
994
+ out.angle = 2*Math.atan2(sinHalf, w);
995
+ out.axis = [x/sinHalf, y/sinHalf, z/sinHalf];
1128
996
  return out;
1129
997
  };
1130
998
 
@@ -1133,35 +1001,34 @@ const quatToAxisAngle = (q, out) => {
1133
1001
  // =========================================================================
1134
1002
 
1135
1003
  function _dist3(a, b) {
1136
- const dx = a[0]-b[0], dy = a[1]-b[1], dz = a[2]-b[2];
1137
- return Math.sqrt(dx*dx + dy*dy + dz*dz);
1004
+ const dx=a[0]-b[0], dy=a[1]-b[1], dz=a[2]-b[2];
1005
+ return Math.sqrt(dx*dx+dy*dy+dz*dz);
1138
1006
  }
1139
1007
 
1140
1008
  /**
1141
- * Centripetal Catmull-Rom interpolation (alpha = 0.5, Barry-Goldman).
1142
- * out = interp(p0, p1, p2, p3, t) where t in [0,1] maps p1→p2.
1143
- * Boundary: when p0===p1 or p2===p3 the chord is reused (clamped end tangents).
1009
+ * Centripetal Catmull-Rom interpolation (alpha=0.5, Barry-Goldman).
1010
+ * out = interp(p0, p1, p2, p3, t) where t[0,1] maps p1→p2.
1011
+ * Boundary: p0===p1 or p2===p3 clamps the end tangent.
1144
1012
  * @param {number[]} out 3-element result.
1145
- * @param {number[]} p0 Control point before p1.
1146
- * @param {number[]} p1 Segment start.
1147
- * @param {number[]} p2 Segment end.
1148
- * @param {number[]} p3 Control point after p2.
1149
- * @param {number} t Blend [0, 1].
1013
+ * @param {number[]} p0 Control point before p1.
1014
+ * @param {number[]} p1 Segment start.
1015
+ * @param {number[]} p2 Segment end.
1016
+ * @param {number[]} p3 Control point after p2.
1017
+ * @param {number} t Blend [0, 1].
1150
1018
  * @returns {number[]} out
1151
1019
  */
1152
1020
  const catmullRomVec3 = (out, p0, p1, p2, p3, t) => {
1153
1021
  const alpha = 0.5;
1154
- const dt0 = Math.pow(_dist3(p0, p1), alpha) || 1;
1155
- const dt1 = Math.pow(_dist3(p1, p2), alpha) || 1;
1156
- const dt2 = Math.pow(_dist3(p2, p3), alpha) || 1;
1022
+ const dt0 = Math.pow(_dist3(p0,p1), alpha) || 1;
1023
+ const dt1 = Math.pow(_dist3(p1,p2), alpha) || 1;
1024
+ const dt2 = Math.pow(_dist3(p2,p3), alpha) || 1;
1157
1025
  for (let i = 0; i < 3; i++) {
1158
1026
  const t1_0 = (p1[i]-p0[i])/dt0 - (p2[i]-p0[i])/(dt0+dt1) + (p2[i]-p1[i])/dt1;
1159
1027
  const t2_0 = (p2[i]-p1[i])/dt1 - (p3[i]-p1[i])/(dt1+dt2) + (p3[i]-p2[i])/dt2;
1160
- const m1 = t1_0 * dt1;
1161
- const m2 = t2_0 * dt1;
1162
- const a = 2*p1[i] - 2*p2[i] + m1 + m2;
1163
- const b = -3*p1[i] + 3*p2[i] - 2*m1 - m2;
1164
- out[i] = a*t*t*t + b*t*t + m1*t + p1[i];
1028
+ const m1=t1_0*dt1, m2=t2_0*dt1;
1029
+ const a= 2*p1[i]-2*p2[i]+m1+m2;
1030
+ const b=-3*p1[i]+3*p2[i]-2*m1-m2;
1031
+ out[i] = a*t*t*t + b*t*t + m1*t + p1[i];
1165
1032
  }
1166
1033
  return out;
1167
1034
  };
@@ -1175,9 +1042,9 @@ const catmullRomVec3 = (out, p0, p1, p2, p3, t) => {
1175
1042
  * @returns {number[]} out
1176
1043
  */
1177
1044
  const lerpVec3 = (out, a, b, t) => {
1178
- out[0] = a[0] + t*(b[0]-a[0]);
1179
- out[1] = a[1] + t*(b[1]-a[1]);
1180
- out[2] = a[2] + t*(b[2]-a[2]);
1045
+ out[0]=a[0]+t*(b[0]-a[0]);
1046
+ out[1]=a[1]+t*(b[1]-a[1]);
1047
+ out[2]=a[2]+t*(b[2]-a[2]);
1181
1048
  return out;
1182
1049
  };
1183
1050
 
@@ -1187,45 +1054,42 @@ const lerpVec3 = (out, a, b, t) => {
1187
1054
 
1188
1055
  /**
1189
1056
  * Write a TRS transform into a column-major mat4.
1190
- * Rotation is encoded as a quaternion; scale is baked into rotation columns.
1191
1057
  * @param {Float32Array|number[]} out 16-element column-major mat4.
1192
1058
  * @param {{ pos:number[], rot:number[], scl:number[] }} xform
1193
1059
  * @returns {Float32Array|number[]} out
1194
1060
  */
1195
1061
  const transformToMat4 = (out, xform) => {
1196
1062
  qToMat4(out, xform.rot);
1197
- const sx = xform.scl[0], sy = xform.scl[1], sz = xform.scl[2];
1198
- out[0] *= sx; out[1] *= sx; out[2] *= sx;
1199
- out[4] *= sy; out[5] *= sy; out[6] *= sy;
1200
- out[8] *= sz; out[9] *= sz; out[10] *= sz;
1201
- out[12] = xform.pos[0];
1202
- out[13] = xform.pos[1];
1203
- out[14] = xform.pos[2];
1063
+ const sx=xform.scl[0], sy=xform.scl[1], sz=xform.scl[2];
1064
+ out[0]*=sx; out[1]*=sx; out[2]*=sx;
1065
+ out[4]*=sy; out[5]*=sy; out[6]*=sy;
1066
+ out[8]*=sz; out[9]*=sz; out[10]*=sz;
1067
+ out[12]=xform.pos[0]; out[13]=xform.pos[1]; out[14]=xform.pos[2];
1204
1068
  return out;
1205
1069
  };
1206
1070
 
1207
1071
  /**
1208
1072
  * Decompose a column-major mat4 into a TRS transform.
1209
- * Assumes no shear. Scale is extracted from column lengths.
1073
+ * Assumes no shear. Scale extracted from column lengths.
1210
1074
  * @param {{ pos:number[], rot:number[], scl:number[] }} out
1211
1075
  * @param {Float32Array|number[]} m Column-major mat4.
1212
1076
  * @returns {{ pos:number[], rot:number[], scl:number[] }} out
1213
1077
  */
1214
1078
  const mat4ToTransform = (out, m) => {
1215
- out.pos[0] = m[12]; out.pos[1] = m[13]; out.pos[2] = m[14];
1216
- const sx = Math.sqrt(m[0]*m[0] + m[1]*m[1] + m[2]*m[2]);
1217
- const sy = Math.sqrt(m[4]*m[4] + m[5]*m[5] + m[6]*m[6]);
1218
- const sz = Math.sqrt(m[8]*m[8] + m[9]*m[9] + m[10]*m[10]);
1219
- out.scl[0] = sx; out.scl[1] = sy; out.scl[2] = sz;
1079
+ out.pos[0]=m[12]; out.pos[1]=m[13]; out.pos[2]=m[14];
1080
+ const sx=Math.sqrt(m[0]*m[0]+m[1]*m[1]+m[2]*m[2]);
1081
+ const sy=Math.sqrt(m[4]*m[4]+m[5]*m[5]+m[6]*m[6]);
1082
+ const sz=Math.sqrt(m[8]*m[8]+m[9]*m[9]+m[10]*m[10]);
1083
+ out.scl[0]=sx; out.scl[1]=sy; out.scl[2]=sz;
1220
1084
  qFromRotMat3x3(out.rot,
1221
- m[0]/sx, m[4]/sy, m[8]/sz,
1222
- m[1]/sx, m[5]/sy, m[9]/sz,
1223
- m[2]/sx, m[6]/sy, m[10]/sz);
1085
+ m[0]/sx,m[4]/sy,m[8]/sz,
1086
+ m[1]/sx,m[5]/sy,m[9]/sz,
1087
+ m[2]/sx,m[6]/sy,m[10]/sz);
1224
1088
  return out;
1225
1089
  };
1226
1090
 
1227
1091
  // =========================================================================
1228
- // S4 Spec parser (keyframe input normalisation)
1092
+ // S4a Spec parser PoseTrack
1229
1093
  // =========================================================================
1230
1094
 
1231
1095
  const _isNum = (x) => typeof x === 'number' && Number.isFinite(x);
@@ -1234,198 +1098,303 @@ const _clampS = (x, lo, hi) => x < lo ? lo : (x > hi ? hi : x);
1234
1098
 
1235
1099
  function _parseVec3(v) {
1236
1100
  if (!v) return null;
1237
- if (Array.isArray(v) && v.length >= 3 && v.every(n => typeof n === 'number')) return [v[0], v[1], v[2]];
1238
- if (typeof v === 'object' && 'x' in v) return [v.x || 0, v.y || 0, v.z || 0];
1101
+ if (Array.isArray(v) && v.length >= 3 && v.every(n => typeof n === 'number')) return [v[0],v[1],v[2]];
1102
+ if (typeof v === 'object' && 'x' in v) return [v.x||0, v.y||0, v.z||0];
1239
1103
  return null;
1240
1104
  }
1241
1105
 
1106
+ // Euler: unit axis vectors and the six valid intrinsic orderings.
1107
+ const _EULER_AXES = { X:[1,0,0], Y:[0,1,0], Z:[0,0,1] };
1108
+ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1109
+
1110
+ /**
1111
+ * Parse any rotation representation into a unit quaternion [x,y,z,w].
1112
+ *
1113
+ * Accepted forms:
1114
+ *
1115
+ * [x,y,z,w]
1116
+ * Raw quaternion array.
1117
+ *
1118
+ * { axis:[x,y,z], angle }
1119
+ * Axis-angle. Axis need not be unit.
1120
+ *
1121
+ * { dir:[x,y,z], up?:[x,y,z] }
1122
+ * Object orientation — forward direction (−Z) with optional up hint.
1123
+ *
1124
+ * { eMatrix: mat4 }
1125
+ * Extract rotation block from an eye (eye→world) matrix.
1126
+ * Column-major Float32Array(16), plain Array, or { mat4 } wrapper.
1127
+ *
1128
+ * { mat3: mat3 }
1129
+ * Column-major 3×3 rotation matrix — Float32Array(9) or plain Array.
1130
+ *
1131
+ * { euler:[rx,ry,rz], order?:'YXZ' }
1132
+ * Intrinsic Euler angles (radians). Angles are indexed by order position:
1133
+ * e[0] rotates around order[0] axis, e[1] around order[1], e[2] around order[2].
1134
+ * Supported orders: YXZ (default), XYZ, ZYX, ZXY, XZY, YZX.
1135
+ * Note: intrinsic ABC = extrinsic CBA with the same angles — to use
1136
+ * extrinsic order ABC, reverse the string and use intrinsic CBA.
1137
+ *
1138
+ * { from:[x,y,z], to:[x,y,z] }
1139
+ * Shortest-arc rotation from one direction onto another.
1140
+ * Both vectors are normalised internally.
1141
+ * Antiparallel input: 180° rotation around a perpendicular axis.
1142
+ *
1143
+ * @param {*} v
1144
+ * @returns {number[]|null} [x,y,z,w] or null if unparseable.
1145
+ */
1242
1146
  function _parseQuat(v) {
1243
1147
  if (!v) return null;
1244
- if (Array.isArray(v) && v.length === 4 && v.every(n => typeof n === 'number')) return [v[0], v[1], v[2], v[3]];
1148
+
1149
+ // raw [x,y,z,w]
1150
+ if (Array.isArray(v) && v.length === 4 && v.every(n => typeof n === 'number'))
1151
+ return [v[0],v[1],v[2],v[3]];
1152
+
1153
+ // { axis, angle }
1245
1154
  if (v.axis && typeof v.angle === 'number') {
1246
- const a = Array.isArray(v.axis) ? v.axis : [v.axis.x || 0, v.axis.y || 0, v.axis.z || 0];
1247
- return qFromAxisAngle([0, 0, 0, 1], a[0], a[1], a[2], v.angle);
1155
+ const a = Array.isArray(v.axis) ? v.axis : [v.axis.x||0, v.axis.y||0, v.axis.z||0];
1156
+ return qFromAxisAngle([0,0,0,1], a[0],a[1],a[2], v.angle);
1248
1157
  }
1158
+
1159
+ // { dir, up? }
1249
1160
  if (v.dir) {
1250
- const d = Array.isArray(v.dir) ? v.dir : [v.dir.x || 0, v.dir.y || 0, v.dir.z || 0];
1251
- const u = v.up ? (Array.isArray(v.up) ? v.up : [v.up.x || 0, v.up.y || 0, v.up.z || 0]) : null;
1252
- return qFromLookDir([0, 0, 0, 1], d, u);
1161
+ const d = Array.isArray(v.dir) ? v.dir : [v.dir.x||0, v.dir.y||0, v.dir.z||0];
1162
+ const u = v.up ? (Array.isArray(v.up) ? v.up : [v.up.x||0, v.up.y||0, v.up.z||0]) : null;
1163
+ return qFromLookDir([0,0,0,1], d, u);
1164
+ }
1165
+
1166
+ // { eMatrix } — rotation block from eye (eye→world) matrix, col-major mat4
1167
+ if (v.eMatrix != null) {
1168
+ const m = (ArrayBuffer.isView(v.eMatrix) || Array.isArray(v.eMatrix))
1169
+ ? v.eMatrix : (v.eMatrix.mat4 ?? null);
1170
+ if (m && m.length >= 16) return qFromMat4([0,0,0,1], m);
1171
+ }
1172
+
1173
+ // { mat3 } — column-major 3×3 rotation matrix
1174
+ // col0=[m0,m1,m2], col1=[m3,m4,m5], col2=[m6,m7,m8]
1175
+ // row-major for qFromRotMat3x3: row0=[m0,m3,m6], row1=[m1,m4,m7], row2=[m2,m5,m8]
1176
+ if (v.mat3 != null) {
1177
+ const m = v.mat3;
1178
+ if ((ArrayBuffer.isView(m) || Array.isArray(m)) && m.length >= 9)
1179
+ return qFromRotMat3x3([0,0,0,1], m[0],m[3],m[6], m[1],m[4],m[7], m[2],m[5],m[8]);
1180
+ }
1181
+
1182
+ // { euler, order? } — intrinsic Euler angles (radians), default order YXZ
1183
+ if (v.euler != null) {
1184
+ const e = v.euler;
1185
+ if (!Array.isArray(e) || e.length < 3) return null;
1186
+ const order = (typeof v.order === 'string' && _EULER_ORDERS.has(v.order))
1187
+ ? v.order : 'YXZ';
1188
+ const q = [0,0,0,1];
1189
+ const s = [0,0,0,1]; // scratch — reused each step
1190
+ for (let i = 0; i < 3; i++) {
1191
+ const ax = _EULER_AXES[order[i]];
1192
+ qMul(q, q, qFromAxisAngle(s, ax[0],ax[1],ax[2], e[i]));
1193
+ }
1194
+ return q;
1253
1195
  }
1196
+
1197
+ // { from, to } — shortest-arc rotation from one direction onto another
1198
+ if (v.from != null && v.to != null) {
1199
+ const f = Array.isArray(v.from) ? v.from : [v.from.x||0, v.from.y||0, v.from.z||0];
1200
+ const t = Array.isArray(v.to) ? v.to : [v.to.x||0, v.to.y||0, v.to.z||0];
1201
+ const fl = Math.sqrt(f[0]*f[0]+f[1]*f[1]+f[2]*f[2]) || 1;
1202
+ const tl = Math.sqrt(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]) || 1;
1203
+ const fx=f[0]/fl, fy=f[1]/fl, fz=f[2]/fl;
1204
+ const tx=t[0]/tl, ty=t[1]/tl, tz=t[2]/tl;
1205
+ const dot = fx*tx + fy*ty + fz*tz;
1206
+ // parallel — identity
1207
+ if (dot >= 1 - 1e-8) return [0,0,0,1];
1208
+ // antiparallel — 180° around any perpendicular axis
1209
+ if (dot <= -1 + 1e-8) {
1210
+ // cross(from, X=[1,0,0]) = [0, fz, -fy]
1211
+ let px=0, py=fz, pz=-fy;
1212
+ let pl = Math.sqrt(px*px+py*py+pz*pz);
1213
+ if (pl < 1e-8) {
1214
+ // from ≈ ±X; try cross(from, Z=[0,0,1]) = [fy, -fx, 0]
1215
+ px=fy; py=-fx; pz=0;
1216
+ pl = Math.sqrt(px*px+py*py+pz*pz);
1217
+ }
1218
+ if (pl < 1e-8) return [0,0,0,1];
1219
+ return qFromAxisAngle([0,0,0,1], px/pl,py/pl,pz/pl, Math.PI);
1220
+ }
1221
+ // general case — axis = normalize(cross(from, to))
1222
+ let ax=fy*tz-fz*ty, ay=fz*tx-fx*tz, az=fx*ty-fy*tx;
1223
+ const al = Math.sqrt(ax*ax+ay*ay+az*az) || 1;
1224
+ return qFromAxisAngle([0,0,0,1], ax/al,ay/al,az/al,
1225
+ Math.acos(Math.max(-1, Math.min(1, dot))));
1226
+ }
1227
+
1254
1228
  return null;
1255
1229
  }
1256
1230
 
1231
+ /**
1232
+ * Parse a PoseTrack keyframe spec.
1233
+ *
1234
+ * Accepted forms:
1235
+ *
1236
+ * { mMatrix }
1237
+ * Decompose a column-major mat4 into TRS via mat4ToTransform.
1238
+ * Float32Array(16), plain Array, or { mat4 } wrapper.
1239
+ * pos from col3, scl from column lengths, rot from normalised rotation block.
1240
+ *
1241
+ * { pos, rot, scl }
1242
+ * Explicit TRS. pos and scl are vec3, rot accepts any form from _parseQuat.
1243
+ * All fields are optional — missing pos/scl default to [0,0,0] / [1,1,1],
1244
+ * missing rot defaults to identity.
1245
+ *
1246
+ * @param {Object} spec
1247
+ * @returns {{ pos:number[], rot:number[], scl:number[] }|null}
1248
+ */
1257
1249
  function _parseSpec(spec) {
1258
1250
  if (!spec || typeof spec !== 'object') return null;
1259
- const pos = _parseVec3(spec.pos) || [0, 0, 0];
1260
- const rot = _parseQuat(spec.rot) || [0, 0, 0, 1];
1261
- const scl = _parseVec3(spec.scl) || [1, 1, 1];
1251
+
1252
+ // { mMatrix } full TRS decomposition from model matrix
1253
+ if (spec.mMatrix != null) {
1254
+ const m = (ArrayBuffer.isView(spec.mMatrix) || Array.isArray(spec.mMatrix))
1255
+ ? spec.mMatrix : (spec.mMatrix.mat4 ?? null);
1256
+ if (!m || m.length < 16) return null;
1257
+ return mat4ToTransform({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }, m);
1258
+ }
1259
+
1260
+ const pos = _parseVec3(spec.pos) || [0,0,0];
1261
+ const rot = _parseQuat(spec.rot) || [0,0,0,1];
1262
+ const scl = _parseVec3(spec.scl) || [1,1,1];
1262
1263
  return { pos, rot, scl };
1263
1264
  }
1264
1265
 
1265
1266
  function _sameTransform(a, b) {
1266
- for (let i = 0; i < 3; i++) if (a.pos[i] !== b.pos[i] || a.scl[i] !== b.scl[i]) return false;
1267
- for (let i = 0; i < 4; i++) if (a.rot[i] !== b.rot[i]) return false;
1267
+ for (let i=0;i<3;i++) if (a.pos[i]!==b.pos[i]||a.scl[i]!==b.scl[i]) return false;
1268
+ for (let i=0;i<4;i++) if (a.rot[i]!==b.rot[i]) return false;
1268
1269
  return true;
1269
1270
  }
1270
1271
 
1271
1272
  // =========================================================================
1272
- // S5 PoseTrack
1273
+ // S4b Spec parser — CameraTrack
1273
1274
  // =========================================================================
1274
1275
 
1275
1276
  /**
1276
- * Renderer-agnostic keyframe animation track.
1277
+ * Parse a camera keyframe spec into internal { eye, center, up } form.
1277
1278
  *
1278
- * Keyframes are TRS pose objects: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z] }.
1279
- * The track maintains a scalar cursor (seg, f) that advances each tick().
1279
+ * Accepted forms:
1280
1280
  *
1281
- * Position uses centripetal Catmull-Rom spline by default (posInterp = 'catmullrom');
1282
- * set posInterp = 'linear' to switch to lerp. Rotation uses SLERP. Scale uses LERP.
1281
+ * { eye, center, up? }
1282
+ * Explicit lookat. up defaults to [0,1,0] and is normalised on storage.
1283
+ * Note: eye here is a vec3 — distinguished from { eMatrix } by length.
1283
1284
  *
1284
- * Rate semantics:
1285
- * rate > 0 forward
1286
- * rate < 0 backward
1287
- * rate === 0 frozen: tick() is a no-op; playing is NOT changed
1285
+ * { vMatrix: mat4 }
1286
+ * Column-major view matrix (world→eye).
1287
+ * eye reconstructed via -R^T·t; center = eye + forward·1; up = [0,1,0].
1288
+ * The matrix's up_ortho (col1) is intentionally NOT used as up
1289
+ * passing it to cam.camera() shifts orbitControl's orbit reference.
1290
+ * Float32Array(16), plain Array, or { mat4 } wrapper.
1288
1291
  *
1289
- * Assigning rate never starts or stops playback.
1290
- * Only play() sets playing = true. Only stop() / reset() set it to false.
1292
+ * { eMatrix: mat4 }
1293
+ * Column-major eye matrix (eye→world, i.e. inverse view).
1294
+ * eye read directly from col3; center = eye + forward·1; up = [0,1,0].
1295
+ * Simpler extraction than vMatrix; prefer this form when eMatrix is available.
1296
+ * Float32Array(16), plain Array, or { mat4 } wrapper.
1291
1297
  *
1292
- * One-keyframe behaviour:
1293
- * play() with exactly one keyframe snaps eval() to that keyframe
1294
- * without setting playing = true and without firing hooks.
1295
- *
1296
- * @example
1297
- * const track = new PoseTrack()
1298
- * track.add({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] })
1299
- * track.add({ pos:[0,100,0], rot:[0,0,0,1], scl:[1,1,1] })
1300
- * track.play({ loop: true, onStop: t => console.log('stopped at', t.time()) })
1298
+ * @param {Object} spec
1299
+ * @returns {{ eye:number[], center:number[], up:number[] }|null}
1301
1300
  */
1302
- class PoseTrack {
1301
+ function _parseCameraSpec(spec) {
1302
+ if (!spec || typeof spec !== 'object') return null;
1303
+
1304
+ // { vMatrix } — view matrix (world→eye); reconstruct eye via -R^T·t
1305
+ if (spec.vMatrix != null) {
1306
+ const m = (ArrayBuffer.isView(spec.vMatrix) || Array.isArray(spec.vMatrix))
1307
+ ? spec.vMatrix : (spec.vMatrix.mat4 ?? null);
1308
+ if (!m || m.length < 16) return null;
1309
+ const ex = -(m[0]*m[12] + m[4]*m[13] + m[8]*m[14]);
1310
+ const ey = -(m[1]*m[12] + m[5]*m[13] + m[9]*m[14]);
1311
+ const ez = -(m[2]*m[12] + m[6]*m[13] + m[10]*m[14]);
1312
+ const fx=-m[8], fy=-m[9], fz=-m[10];
1313
+ const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1314
+ return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0] };
1315
+ }
1316
+
1317
+ // { eMatrix } — eye matrix (eye→world); eye = col3, forward = -col2
1318
+ if (spec.eMatrix != null) {
1319
+ const m = (ArrayBuffer.isView(spec.eMatrix) || Array.isArray(spec.eMatrix))
1320
+ ? spec.eMatrix : (spec.eMatrix.mat4 ?? null);
1321
+ if (!m || m.length < 16) return null;
1322
+ const ex=m[12], ey=m[13], ez=m[14];
1323
+ const fx=-m[8], fy=-m[9], fz=-m[10];
1324
+ const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1325
+ return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0] };
1326
+ }
1327
+
1328
+ // { eye, center, up? } — explicit lookat (eye is a vec3, not a mat4)
1329
+ const eye = _parseVec3(spec.eye);
1330
+ const center = _parseVec3(spec.center);
1331
+ if (!eye || !center) return null;
1332
+ const upRaw = spec.up ? _parseVec3(spec.up) : null;
1333
+ const up = upRaw || [0,1,0];
1334
+ const ul = Math.sqrt(up[0]*up[0]+up[1]*up[1]+up[2]*up[2]) || 1;
1335
+ return { eye, center, up:[up[0]/ul, up[1]/ul, up[2]/ul] };
1336
+ }
1337
+
1338
+ function _sameCameraKeyframe(a, b) {
1339
+ for (let i=0;i<3;i++) {
1340
+ if (a.eye[i]!==b.eye[i]) return false;
1341
+ if (a.center[i]!==b.center[i]) return false;
1342
+ if (a.up[i]!==b.up[i]) return false;
1343
+ }
1344
+ return true;
1345
+ }
1346
+
1347
+ // =========================================================================
1348
+ // S5 Track — unexported base class (transport machinery only)
1349
+ // =========================================================================
1350
+
1351
+ class Track {
1303
1352
  constructor() {
1304
- /** @type {Array<{pos:number[],rot:number[],scl:number[]}>} */
1353
+ /** @type {Array} Keyframe array — shape depends on subclass. */
1305
1354
  this.keyframes = [];
1306
1355
  /** Whether playback is active. @type {boolean} */
1307
1356
  this.playing = false;
1308
- /** Loop flag (overridden by pingPong). @type {boolean} */
1357
+ /** Loop at boundaries. @type {boolean} */
1309
1358
  this.loop = false;
1310
- /** Ping-pong bounce mode (takes precedence over loop). @type {boolean} */
1359
+ /** Ping-pong bounce (takes precedence over loop). @type {boolean} */
1311
1360
  this.pingPong = false;
1312
- /** Frames per segment (>=1). @type {number} */
1361
+ /** Frames per segment (1). @type {number} */
1313
1362
  this.duration = 30;
1314
1363
  /** Current segment index. @type {number} */
1315
1364
  this.seg = 0;
1316
- /** Frame offset within current segment (can be fractional). @type {number} */
1365
+ /** Frame offset within segment (can be fractional). @type {number} */
1317
1366
  this.f = 0;
1318
- /**
1319
- * Position interpolation mode.
1320
- * @type {'catmullrom'|'linear'}
1321
- */
1322
- this.posInterp = 'catmullrom';
1323
1367
 
1324
- // Scratch arrays reused by eval() / toMatrix() — avoids hot-path allocations
1325
- this._pos = [0, 0, 0];
1326
- this._rot = [0, 0, 0, 1];
1327
- this._scl = [1, 1, 1];
1328
-
1329
- // Internal rate — assigning never touches playing
1368
+ // Internal rate never directly starts/stops playback
1330
1369
  this._rate = 1;
1331
1370
 
1332
- // User-space hooks — fired on playback state transitions
1333
- /** Fires when play() starts a false→true transition. @type {Function|null} */
1334
- this.onPlay = null;
1335
- /** Fires in tick() when cursor hits a natural boundary (once mode only). @type {Function|null} */
1336
- this.onEnd = null;
1337
- /** Fires on explicit stop() or reset(). Mutually exclusive with onEnd per event. @type {Function|null} */
1338
- this.onStop = null;
1371
+ // User-space hooks
1372
+ /** @type {Function|null} */ this.onPlay = null;
1373
+ /** @type {Function|null} */ this.onEnd = null;
1374
+ /** @type {Function|null} */ this.onStop = null;
1339
1375
 
1340
- // Lib-space hooks (set by host layer e.g. p5 bridge)
1341
- /** @type {Function|null} */
1342
- this._onActivate = null;
1343
- /** @type {Function|null} */
1344
- this._onDeactivate = null;
1376
+ // Lib-space hooks (set by host layer, e.g. p5 bridge)
1377
+ /** @type {Function|null} */ this._onActivate = null;
1378
+ /** @type {Function|null} */ this._onDeactivate = null;
1345
1379
  }
1346
1380
 
1347
- // ── rate ────────────────────────────────────────────────────────────────
1348
- // Getter/setter so future consumers get the right value from track.rate,
1349
- // while the setter intentionally has NO side effects on playing.
1350
-
1351
- /** Playback rate. 0 = frozen (playing flag unchanged). @type {number} */
1381
+ /** Playback rate. Assigning never starts/stops playback. @type {number} */
1352
1382
  get rate() { return this._rate; }
1353
- set rate(v) {
1354
- this._rate = (typeof v === 'number' && Number.isFinite(v)) ? v : 1;
1355
- // Intentionally does NOT start or stop playback.
1356
- }
1383
+ set rate(v) { this._rate = (_isNum(v)) ? v : 1; }
1357
1384
 
1358
- /** Number of interpolatable segments (keyframes.length - 1, min 0). @type {number} */
1385
+ /** Number of interpolatable segments (keyframes.length 1, min 0). @type {number} */
1359
1386
  get segments() { return Math.max(0, this.keyframes.length - 1); }
1360
1387
 
1361
- // ── Keyframe management ──────────────────────────────────────────────────
1362
-
1363
- /**
1364
- * Append a keyframe. Adjacent duplicates are skipped by default.
1365
- * @param {{ pos?, rot?, scl? }} spec pos/rot/scl arrays, {x,y,z}, axis-angle, look-dir.
1366
- * @param {{ deduplicate?: boolean }} [opts]
1367
- */
1368
- add(spec, opts) {
1369
- const kf = _parseSpec(spec);
1370
- if (!kf) return;
1371
- const dedup = !opts || opts.deduplicate !== false;
1372
- if (dedup && this.keyframes.length > 0) {
1373
- if (_sameTransform(this.keyframes[this.keyframes.length - 1], kf)) return;
1374
- }
1375
- this.keyframes.push(kf);
1376
- }
1377
-
1378
- /**
1379
- * Replace (or append at end) the keyframe at index.
1380
- * @param {number} index Existing index or keyframes.length to append.
1381
- * @param {{ pos?, rot?, scl? }} spec
1382
- * @returns {boolean}
1383
- */
1384
- set(index, spec) {
1385
- if (!_isNum(index)) return false;
1386
- const i = index | 0;
1387
- const kf = _parseSpec(spec);
1388
- if (!kf || i < 0 || i > this.keyframes.length) return false;
1389
- if (i === this.keyframes.length) { this.keyframes.push(kf); }
1390
- else { this.keyframes[i] = kf; }
1391
- return true;
1392
- }
1393
-
1394
- /**
1395
- * Remove the keyframe at index. Adjusts cursor if needed.
1396
- * @param {number} index
1397
- * @returns {boolean}
1398
- */
1399
- remove(index) {
1400
- if (!_isNum(index)) return false;
1401
- const i = index | 0;
1402
- if (i < 0 || i >= this.keyframes.length) return false;
1403
- this.keyframes.splice(i, 1);
1404
- const nSeg = this.segments;
1405
- if (nSeg === 0) { this.seg = 0; this.f = 0; }
1406
- else if (this.seg >= nSeg) { this.seg = nSeg - 1; }
1407
- return true;
1408
- }
1409
-
1410
- // ── Transport ────────────────────────────────────────────────────────────
1411
-
1412
1388
  /**
1413
1389
  * Start or update playback.
1414
- * Accepts a numeric rate or an options object:
1415
- * { rate, duration, loop, pingPong, onPlay, onEnd, onStop }.
1416
- *
1417
- * Zero keyframes: no-op.
1418
- * One keyframe: snaps cursor (seg=0, f=0); no playing=true, no hooks.
1419
- * Already playing: updates params in place; hooks are not re-fired.
1420
- * rate=0 is valid: track will be playing but frozen until rate changes.
1421
- *
1422
- * @param {number|Object} [rateOrOpts]
1423
- * @returns {PoseTrack} this
1390
+ * @param {number|Object} [rateOrOpts] Numeric rate or options object:
1391
+ * { rate, duration, loop, pingPong, onPlay, onEnd, onStop }
1392
+ * @returns {Track} this
1424
1393
  */
1425
1394
  play(rateOrOpts) {
1426
1395
  if (this.keyframes.length === 0) return this;
1427
1396
 
1428
- // One keyframe: snap only, no animation, no hooks
1397
+ // One keyframe: snap cursor, no animation
1429
1398
  if (this.keyframes.length === 1) {
1430
1399
  this.seg = 0; this.f = 0;
1431
1400
  return this;
@@ -1444,9 +1413,7 @@ class PoseTrack {
1444
1413
  if (_isNum(o.rate)) this._rate = o.rate;
1445
1414
  }
1446
1415
 
1447
- // Clamp cursor into valid range
1448
- const nSeg = this.segments;
1449
- const dur = Math.max(1, this.duration | 0);
1416
+ const nSeg = this.segments, dur = Math.max(1, this.duration | 0);
1450
1417
  if (this.seg < 0) this.seg = 0;
1451
1418
  if (this.seg >= nSeg) this.seg = nSeg - 1;
1452
1419
  if (this.f < 0) this.f = 0;
@@ -1454,7 +1421,6 @@ class PoseTrack {
1454
1421
 
1455
1422
  const wasPlaying = this.playing;
1456
1423
  this.playing = true;
1457
-
1458
1424
  if (!wasPlaying) {
1459
1425
  if (typeof this.onPlay === 'function') { try { this.onPlay(this); } catch (_) {} }
1460
1426
  this._onActivate?.();
@@ -1463,11 +1429,9 @@ class PoseTrack {
1463
1429
  }
1464
1430
 
1465
1431
  /**
1466
- * Stop playback. No-op if already stopped.
1467
- * Fires `onStop` `_onDeactivate`, then optionally seeks to the
1468
- * logical start (rate > 0 → t=0, rate < 0 → t=1).
1469
- * @param {boolean} [rewind=false] Seek to playback origin after stopping.
1470
- * @returns {PoseTrack} this
1432
+ * Stop playback.
1433
+ * @param {boolean} [rewind=false] Seek to origin after stopping.
1434
+ * @returns {Track} this
1471
1435
  */
1472
1436
  stop(rewind) {
1473
1437
  const wasPlaying = this.playing;
@@ -1482,8 +1446,7 @@ class PoseTrack {
1482
1446
 
1483
1447
  /**
1484
1448
  * Clear all keyframes and stop.
1485
- * Fires `onStop` then `_onDeactivate` if was playing.
1486
- * @returns {PoseTrack} this
1449
+ * @returns {Track} this
1487
1450
  */
1488
1451
  reset() {
1489
1452
  const wasPlaying = this.playing;
@@ -1497,13 +1460,27 @@ class PoseTrack {
1497
1460
  return this;
1498
1461
  }
1499
1462
 
1463
+ /**
1464
+ * Remove the keyframe at index. Adjusts cursor if needed.
1465
+ * @param {number} index
1466
+ * @returns {boolean}
1467
+ */
1468
+ remove(index) {
1469
+ if (!_isNum(index)) return false;
1470
+ const i = index | 0;
1471
+ if (i < 0 || i >= this.keyframes.length) return false;
1472
+ this.keyframes.splice(i, 1);
1473
+ const nSeg = this.segments;
1474
+ if (nSeg === 0) { this.seg = 0; this.f = 0; }
1475
+ else if (this.seg >= nSeg) { this.seg = nSeg - 1; }
1476
+ return true;
1477
+ }
1478
+
1500
1479
  /**
1501
1480
  * Seek to a normalised position [0,1] across the full path.
1502
- * Can optionally target a specific segment (t is then local to that segment).
1503
- * Does not change the playing flag.
1504
1481
  * @param {number} t Normalised time [0, 1].
1505
1482
  * @param {number} [segIndex] Optional segment override.
1506
- * @returns {PoseTrack} this
1483
+ * @returns {Track} this
1507
1484
  */
1508
1485
  seek(t, segIndex) {
1509
1486
  const nSeg = this.segments;
@@ -1519,8 +1496,7 @@ class PoseTrack {
1519
1496
  }
1520
1497
 
1521
1498
  /**
1522
- * Normalised playback time across the full path [0, 1].
1523
- * Returns 0 when fewer than 2 keyframes exist.
1499
+ * Normalised playback position [0,1].
1524
1500
  * @returns {number}
1525
1501
  */
1526
1502
  time() {
@@ -1531,10 +1507,8 @@ class PoseTrack {
1531
1507
  }
1532
1508
 
1533
1509
  /**
1534
- * Snapshot of the current transport state.
1535
- * @returns {{ keyframes:number, segments:number, seg:number, f:number,
1536
- * time:number, playing:boolean, loop:boolean, pingPong:boolean,
1537
- * rate:number, duration:number }}
1510
+ * Snapshot of transport state.
1511
+ * @returns {Object}
1538
1512
  */
1539
1513
  info() {
1540
1514
  return {
@@ -1552,12 +1526,8 @@ class PoseTrack {
1552
1526
  }
1553
1527
 
1554
1528
  /**
1555
- * Advance the cursor by rate frames.
1556
- *
1557
- * rate === 0: frozen — returns this.playing without moving (no-op).
1558
- * Returns false and fires onEnd → _onDeactivate when a once-mode boundary is hit.
1559
- * Returns true while playing and continuing.
1560
- *
1529
+ * Advance cursor by rate frames.
1530
+ * Returns true while playing, false when stopping.
1561
1531
  * @returns {boolean}
1562
1532
  */
1563
1533
  tick() {
@@ -1566,8 +1536,6 @@ class PoseTrack {
1566
1536
  if (nSeg === 0) {
1567
1537
  this.playing = false; this._onDeactivate?.(); return false;
1568
1538
  }
1569
-
1570
- // Frozen: position does not advance, playing stays true
1571
1539
  if (this._rate === 0) return true;
1572
1540
 
1573
1541
  const dur = Math.max(1, this.duration | 0);
@@ -1575,25 +1543,22 @@ class PoseTrack {
1575
1543
  const s = _clampS(this.seg * dur + this.f, 0, total);
1576
1544
  const next = s + this._rate;
1577
1545
 
1578
- // ── pingPong ──
1579
1546
  if (this.pingPong) {
1580
1547
  let pos = next, flips = 0;
1581
1548
  while (pos < 0 || pos > total) {
1582
- if (pos < 0) { pos = -pos; flips++; }
1583
- else { pos = 2 * total - pos; flips++; }
1549
+ if (pos < 0) { pos = -pos; flips++; }
1550
+ else { pos = 2 * total - pos; flips++; }
1584
1551
  }
1585
1552
  if (flips & 1) this._rate = -this._rate;
1586
1553
  this._setCursorFromScalar(pos);
1587
1554
  return true;
1588
1555
  }
1589
1556
 
1590
- // ── loop ──
1591
1557
  if (this.loop) {
1592
1558
  this._setCursorFromScalar(((next % total) + total) % total);
1593
1559
  return true;
1594
1560
  }
1595
1561
 
1596
- // ── once — boundary check ──
1597
1562
  if (next <= 0) {
1598
1563
  this._setCursorFromScalar(0);
1599
1564
  this.playing = false;
@@ -1613,15 +1578,117 @@ class PoseTrack {
1613
1578
  return true;
1614
1579
  }
1615
1580
 
1581
+ /** @private */
1582
+ _setCursorFromScalar(s) {
1583
+ const dur = Math.max(1, this.duration | 0);
1584
+ const nSeg = this.segments;
1585
+ this.seg = Math.floor(s / dur);
1586
+ this.f = s - this.seg * dur;
1587
+ if (this.seg >= nSeg) { this.seg = nSeg - 1; this.f = dur; }
1588
+ if (this.seg < 0) { this.seg = 0; this.f = 0; }
1589
+ }
1590
+ }
1591
+
1592
+ // =========================================================================
1593
+ // S6 PoseTrack
1594
+ // =========================================================================
1595
+
1596
+ /**
1597
+ * Renderer-agnostic TRS keyframe track.
1598
+ *
1599
+ * Keyframe shape: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z] }
1600
+ *
1601
+ * add() accepts individual specs or a bulk array of specs:
1602
+ *
1603
+ * { mMatrix } — full TRS from model matrix
1604
+ * { pos, rot, scl } — direct TRS
1605
+ * { pos, rot: [x,y,z,w] } — explicit quaternion
1606
+ * { pos, rot: { axis, angle } } — axis-angle
1607
+ * { pos, rot: { dir, up? } } — look direction
1608
+ * { pos, rot: { eMatrix: mat4 } } — rotation from eye matrix
1609
+ * { pos, rot: { mat3 } } — column-major 3×3 rotation matrix
1610
+ * { pos, rot: { euler, order? } } — intrinsic Euler angles (default YXZ)
1611
+ * { pos, rot: { from, to } } — shortest-arc between two directions
1612
+ * [ spec, spec, ... ] — bulk
1613
+ *
1614
+ * eval() writes { pos, rot, scl }:
1615
+ * pos — Catmull-Rom (posInterp='catmullrom') or lerp
1616
+ * rot — slerp (rotInterp='slerp') or nlerp
1617
+ * scl — lerp
1618
+ *
1619
+ * @example
1620
+ * const track = new PoseTrack()
1621
+ * track.add({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] })
1622
+ * track.add({ pos:[100,0,0], rot: { euler:[0, Math.PI/2, 0] } })
1623
+ * track.add({ mMatrix: someModelMatrix })
1624
+ * track.play({ loop: true })
1625
+ * // per frame:
1626
+ * track.tick()
1627
+ * const out = { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }
1628
+ * track.eval(out)
1629
+ */
1630
+ class PoseTrack extends Track {
1631
+ constructor() {
1632
+ super();
1633
+ /**
1634
+ * Position interpolation mode.
1635
+ * @type {'catmullrom'|'linear'}
1636
+ */
1637
+ this.posInterp = 'catmullrom';
1638
+ /**
1639
+ * Rotation interpolation mode.
1640
+ * - 'slerp' — constant angular velocity (default)
1641
+ * - 'nlerp' — normalised lerp; cheaper, slightly non-constant speed
1642
+ * @type {'slerp'|'nlerp'}
1643
+ */
1644
+ this.rotInterp = 'slerp';
1645
+ // Scratch for toMatrix() — avoids hot-path allocations
1646
+ this._pos = [0,0,0];
1647
+ this._rot = [0,0,0,1];
1648
+ this._scl = [1,1,1];
1649
+ }
1650
+
1651
+ /**
1652
+ * Append one or more keyframes. Adjacent duplicates are skipped by default.
1653
+ * @param {Object|Object[]} spec
1654
+ * @param {{ deduplicate?: boolean }} [opts]
1655
+ */
1656
+ add(spec, opts) {
1657
+ if (Array.isArray(spec)) {
1658
+ for (const s of spec) this.add(s, opts);
1659
+ return;
1660
+ }
1661
+ const kf = _parseSpec(spec);
1662
+ if (!kf) return;
1663
+ const dedup = !opts || opts.deduplicate !== false;
1664
+ if (dedup && this.keyframes.length > 0) {
1665
+ if (_sameTransform(this.keyframes[this.keyframes.length - 1], kf)) return;
1666
+ }
1667
+ this.keyframes.push(kf);
1668
+ }
1669
+
1670
+ /**
1671
+ * Replace (or append at end) the keyframe at index.
1672
+ * @param {number} index
1673
+ * @param {Object} spec
1674
+ * @returns {boolean}
1675
+ */
1676
+ set(index, spec) {
1677
+ if (!_isNum(index)) return false;
1678
+ const i = index | 0, kf = _parseSpec(spec);
1679
+ if (!kf || i < 0 || i > this.keyframes.length) return false;
1680
+ if (i === this.keyframes.length) this.keyframes.push(kf);
1681
+ else this.keyframes[i] = kf;
1682
+ return true;
1683
+ }
1684
+
1616
1685
  /**
1617
- * Evaluate the interpolated pose at the current cursor into out.
1618
- * If out is omitted a new object is allocated (avoid in hot paths).
1619
- * Uses centripetal Catmull-Rom for position (posInterp === 'catmullrom') or lerp.
1686
+ * Evaluate interpolated TRS pose at current cursor.
1620
1687
  * @param {{ pos:number[], rot:number[], scl:number[] }} [out]
1621
1688
  * @returns {{ pos:number[], rot:number[], scl:number[] }} out
1622
1689
  */
1623
1690
  eval(out) {
1624
- out = out || { pos: [0, 0, 0], rot: [0, 0, 0, 1], scl: [1, 1, 1] };
1691
+ out = out || { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] };
1625
1692
  const n = this.keyframes.length;
1626
1693
  if (n === 0) return out;
1627
1694
 
@@ -1640,27 +1707,30 @@ class PoseTrack {
1640
1707
  const k0 = this.keyframes[seg];
1641
1708
  const k1 = this.keyframes[seg + 1];
1642
1709
 
1643
- // Position
1710
+ // pos — Catmull-Rom or lerp
1644
1711
  if (this.posInterp === 'catmullrom') {
1645
- const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1712
+ const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1646
1713
  const p3 = seg + 2 < n ? this.keyframes[seg + 2].pos : k1.pos;
1647
1714
  catmullRomVec3(out.pos, p0, k0.pos, k1.pos, p3, t);
1648
1715
  } else {
1649
1716
  lerpVec3(out.pos, k0.pos, k1.pos, t);
1650
1717
  }
1651
1718
 
1652
- // RotationSLERP
1653
- qSlerp(out.rot, k0.rot, k1.rot, t);
1719
+ // rotslerp or nlerp
1720
+ if (this.rotInterp === 'nlerp') {
1721
+ qNlerp(out.rot, k0.rot, k1.rot, t);
1722
+ } else {
1723
+ qSlerp(out.rot, k0.rot, k1.rot, t);
1724
+ }
1654
1725
 
1655
- // ScaleLERP
1726
+ // scllerp
1656
1727
  lerpVec3(out.scl, k0.scl, k1.scl, t);
1657
1728
 
1658
1729
  return out;
1659
1730
  }
1660
1731
 
1661
1732
  /**
1662
- * Evaluate the current cursor into an existing column-major mat4.
1663
- * Reuses internal scratch arrays — no allocation per call.
1733
+ * Evaluate into an existing column-major mat4.
1664
1734
  * @param {Float32Array|number[]} outMat4 16-element array.
1665
1735
  * @returns {Float32Array|number[]} outMat4
1666
1736
  */
@@ -1668,17 +1738,163 @@ class PoseTrack {
1668
1738
  const xf = this.eval({ pos: this._pos, rot: this._rot, scl: this._scl });
1669
1739
  return transformToMat4(outMat4, xf);
1670
1740
  }
1741
+ }
1671
1742
 
1672
- // ── Private ──────────────────────────────────────────────────────────────
1743
+ // =========================================================================
1744
+ // S7 CameraTrack
1745
+ // =========================================================================
1673
1746
 
1674
- /** @private */
1675
- _setCursorFromScalar(s) {
1747
+ /**
1748
+ * Lookat camera keyframe track.
1749
+ *
1750
+ * Keyframe shape: { eye:[x,y,z], center:[x,y,z], up:[x,y,z] }
1751
+ *
1752
+ * Each field is independently interpolated — eye and center along their
1753
+ * own paths, up nlerped on the unit sphere. This correctly handles cameras
1754
+ * that always look at a fixed target (center stays at origin throughout)
1755
+ * as well as free-fly paths where center moves independently.
1756
+ *
1757
+ * add() accepts individual specs or a bulk array of specs:
1758
+ *
1759
+ * { eye, center, up? } explicit lookat; up defaults to [0,1,0]
1760
+ * { vMatrix: mat4 } view matrix (world→eye); eye reconstructed via -R^T·t
1761
+ * { eMatrix: mat4 } eye matrix (eye→world); eye read from col3 directly
1762
+ * [ spec, spec, ... ] bulk
1763
+ *
1764
+ * Note on up for matrix forms:
1765
+ * up is always [0,1,0]. The matrix's col1 (up_ortho) is intentionally
1766
+ * not used — it differs from the hint [0,1,0] for upright cameras and
1767
+ * passing it to cam.camera() shifts orbitControl's orbit reference.
1768
+ * Use capturePose() (p5.tree bridge) when the real up hint is needed.
1769
+ *
1770
+ * eval() writes { eye, center, up }:
1771
+ * eye — Catmull-Rom (eyeInterp='catmullrom') or lerp
1772
+ * center — Catmull-Rom (centerInterp='catmullrom') or lerp
1773
+ * up — nlerp (normalize-after-lerp on unit sphere)
1774
+ *
1775
+ * @example
1776
+ * const track = new CameraTrack()
1777
+ * track.add({ eye:[0,0,500], center:[0,0,0] })
1778
+ * track.add({ eMatrix: myEyeMatrix })
1779
+ * track.add({ vMatrix: myViewMatrix })
1780
+ * track.play({ loop: true })
1781
+ * // per frame:
1782
+ * track.tick()
1783
+ * const out = { eye:[0,0,0], center:[0,0,0], up:[0,1,0] }
1784
+ * track.eval(out)
1785
+ * cam.camera(out.eye[0],out.eye[1],out.eye[2],
1786
+ * out.center[0],out.center[1],out.center[2],
1787
+ * out.up[0],out.up[1],out.up[2])
1788
+ */
1789
+ class CameraTrack extends Track {
1790
+ constructor() {
1791
+ super();
1792
+ /**
1793
+ * Eye position interpolation mode.
1794
+ * @type {'catmullrom'|'linear'}
1795
+ */
1796
+ this.eyeInterp = 'catmullrom';
1797
+ /**
1798
+ * Center (lookat target) interpolation mode.
1799
+ * 'linear' suits fixed or predictably moving targets.
1800
+ * 'catmullrom' gives smoother paths when center is also flying freely.
1801
+ * @type {'catmullrom'|'linear'}
1802
+ */
1803
+ this.centerInterp = 'linear';
1804
+ // Scratch for toCamera() — avoids hot-path allocations
1805
+ this._eye = [0,0,0];
1806
+ this._center = [0,0,0];
1807
+ this._up = [0,1,0];
1808
+ }
1809
+
1810
+ /**
1811
+ * Append one or more camera keyframes. Adjacent duplicates are skipped by default.
1812
+ *
1813
+ * @param {Object|Object[]} spec
1814
+ * { eye, center, up? } or { vMatrix: mat4 } or { eMatrix: mat4 } or an array of either.
1815
+ * @param {{ deduplicate?: boolean }} [opts]
1816
+ */
1817
+ add(spec, opts) {
1818
+ if (Array.isArray(spec)) {
1819
+ for (const s of spec) this.add(s, opts);
1820
+ return;
1821
+ }
1822
+ const kf = _parseCameraSpec(spec);
1823
+ if (!kf) return;
1824
+ const dedup = !opts || opts.deduplicate !== false;
1825
+ if (dedup && this.keyframes.length > 0) {
1826
+ if (_sameCameraKeyframe(this.keyframes[this.keyframes.length - 1], kf)) return;
1827
+ }
1828
+ this.keyframes.push(kf);
1829
+ }
1830
+
1831
+ /**
1832
+ * Replace (or append at end) the keyframe at index.
1833
+ * @param {number} index
1834
+ * @param {Object} spec
1835
+ * @returns {boolean}
1836
+ */
1837
+ set(index, spec) {
1838
+ if (!_isNum(index)) return false;
1839
+ const i = index | 0, kf = _parseCameraSpec(spec);
1840
+ if (!kf || i < 0 || i > this.keyframes.length) return false;
1841
+ if (i === this.keyframes.length) this.keyframes.push(kf);
1842
+ else this.keyframes[i] = kf;
1843
+ return true;
1844
+ }
1845
+
1846
+ /**
1847
+ * Evaluate interpolated camera pose at current cursor.
1848
+ *
1849
+ * @param {{ eye:number[], center:number[], up:number[] }} [out]
1850
+ * @returns {{ eye:number[], center:number[], up:number[] }} out
1851
+ */
1852
+ eval(out) {
1853
+ out = out || { eye:[0,0,0], center:[0,0,0], up:[0,1,0] };
1854
+ const n = this.keyframes.length;
1855
+ if (n === 0) return out;
1856
+
1857
+ if (n === 1) {
1858
+ const k = this.keyframes[0];
1859
+ out.eye[0]=k.eye[0]; out.eye[1]=k.eye[1]; out.eye[2]=k.eye[2];
1860
+ out.center[0]=k.center[0]; out.center[1]=k.center[1]; out.center[2]=k.center[2];
1861
+ out.up[0]=k.up[0]; out.up[1]=k.up[1]; out.up[2]=k.up[2];
1862
+ return out;
1863
+ }
1864
+
1865
+ const nSeg = n - 1;
1676
1866
  const dur = Math.max(1, this.duration | 0);
1677
- const nSeg = this.segments;
1678
- this.seg = Math.floor(s / dur);
1679
- this.f = s - this.seg * dur;
1680
- if (this.seg >= nSeg) { this.seg = nSeg - 1; this.f = dur; }
1681
- if (this.seg < 0) { this.seg = 0; this.f = 0; }
1867
+ const seg = _clampS(this.seg, 0, nSeg - 1);
1868
+ const t = _clamp01(this.f / dur);
1869
+ const k0 = this.keyframes[seg];
1870
+ const k1 = this.keyframes[seg + 1];
1871
+
1872
+ // eye — Catmull-Rom or lerp
1873
+ if (this.eyeInterp === 'catmullrom') {
1874
+ const p0 = seg > 0 ? this.keyframes[seg - 1].eye : k0.eye;
1875
+ const p3 = seg + 2 < n ? this.keyframes[seg + 2].eye : k1.eye;
1876
+ catmullRomVec3(out.eye, p0, k0.eye, k1.eye, p3, t);
1877
+ } else {
1878
+ lerpVec3(out.eye, k0.eye, k1.eye, t);
1879
+ }
1880
+
1881
+ // center — Catmull-Rom or lerp (independent lookat target)
1882
+ if (this.centerInterp === 'catmullrom') {
1883
+ const c0 = seg > 0 ? this.keyframes[seg - 1].center : k0.center;
1884
+ const c3 = seg + 2 < n ? this.keyframes[seg + 2].center : k1.center;
1885
+ catmullRomVec3(out.center, c0, k0.center, k1.center, c3, t);
1886
+ } else {
1887
+ lerpVec3(out.center, k0.center, k1.center, t);
1888
+ }
1889
+
1890
+ // up — nlerp (normalize after lerp; correct for typical near-upright cameras)
1891
+ const ux = k0.up[0] + t*(k1.up[0]-k0.up[0]);
1892
+ const uy = k0.up[1] + t*(k1.up[1]-k0.up[1]);
1893
+ const uz = k0.up[2] + t*(k1.up[2]-k0.up[2]);
1894
+ const ul = Math.sqrt(ux*ux+uy*uy+uz*uz) || 1;
1895
+ out.up[0]=ux/ul; out.up[1]=uy/ul; out.up[2]=uz/ul;
1896
+
1897
+ return out;
1682
1898
  }
1683
1899
  }
1684
1900
 
@@ -1839,5 +2055,5 @@ function boxVisibility(planes, x0, y0, z0, x1, y1, z1) {
1839
2055
  return allIn ? VISIBLE : SEMIVISIBLE;
1840
2056
  }
1841
2057
 
1842
- export { 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, catmullRomVec3, distanceToPlane, frustumPlanes, i, j, k, lerpVec3, mapDirection, mapLocation, mat3Direction, mat3FromMat4T, mat3MulVec3, mat3NormalFromMat4, mat4Identity, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulDir, mat4MulPoint, mat4PMV, mat4PV, mat4ToTransform, mat4Transpose, pixelRatio, pointVisibility, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qMul, qNegate, qNormalize, qSet, qSlerp, qToMat4, quatToAxisAngle, sphereVisibility, transformToMat4 };
2058
+ 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, catmullRomVec3, distanceToPlane, frustumPlanes, 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 };
1843
2059
  //# sourceMappingURL=index.js.map