@nakednous/tree 0.0.8 → 0.0.9

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/README.md CHANGED
@@ -58,11 +58,14 @@ track.eval(out) // writes interpolated TRS into out
58
58
  Interpolation modes:
59
59
 
60
60
  ```js
61
- track.posInterp = 'catmullrom' // default — centripetal Catmull-Rom
61
+ track.posInterp = 'hermite' // default — cubic Hermite; auto-computes centripetal
62
+ // Catmull-Rom tangents when none are stored
62
63
  track.posInterp = 'linear'
64
+ track.posInterp = 'step' // snap to k0; useful for discrete state changes
63
65
 
64
- track.rotInterp = 'slerp' // default — constant angular velocity
65
- track.rotInterp = 'nlerp' // normalised lerp; cheaper, slightly non-constant
66
+ track.rotInterp = 'slerp' // default — constant angular velocity
67
+ track.rotInterp = 'nlerp' // normalised lerp; cheaper, slightly non-constant speed
68
+ track.rotInterp = 'step' // snap to k0 quaternion
66
69
  ```
67
70
 
68
71
  Playback features: signed `rate` (negative reverses), `loop`, `pingPong`, `seek(t)` scrubbing, and lifecycle hooks (`onPlay`, `onEnd`, `onStop`). `_onActivate` / `_onDeactivate` are lib-space hooks for the host layer's draw-loop registry — not for user code.
@@ -70,9 +73,19 @@ Playback features: signed `rate` (negative reverses), `loop`, `pingPong`, `seek(
70
73
  `add()` accepts flexible specs. Top-level forms:
71
74
 
72
75
  ```js
73
- track.add({ pos, rot, scl }) // explicit TRS — rot accepts any form below
74
- track.add({ mMatrix: mat4 }) // decompose a column-major model matrix into TRS
75
- track.add([ spec, spec, ... ]) // bulk
76
+ track.add({ pos, rot, scl }) // explicit TRS — rot accepts any form below
77
+ track.add({ pos, rot, scl, tanIn, tanOut }) // with Hermite tangents (vec3, optional)
78
+ track.add({ mMatrix: mat4 }) // decompose a column-major model matrix into TRS
79
+ track.add([ spec, spec, ... ]) // bulk
80
+ ```
81
+
82
+ `tanIn` is the incoming position tangent at this keyframe; `tanOut` is the outgoing tangent. When only one is given, the other mirrors it. When neither is given, centripetal Catmull-Rom tangents are auto-computed from neighboring keyframes — identical to prior default behavior.
83
+
84
+ ```js
85
+ track.add({ pos:[0,0,0] }) // auto tangents
86
+ track.add({ pos:[100,0,0], tanOut:[0,50,0] }) // leave heading +Y
87
+ track.add({ pos:[200,0,0], tanIn:[0,50,0], tanOut:[-30,0,0] }) // arrive from +Y, leave heading -X
88
+ track.add({ pos:[300,0,0] }) // auto tangents
76
89
  ```
77
90
 
78
91
  `rot` sub-forms — all normalised internally:
@@ -116,20 +129,25 @@ track.eval(out)
116
129
  Interpolation modes:
117
130
 
118
131
  ```js
119
- track.eyeInterp = 'catmullrom' // default
132
+ track.eyeInterp = 'hermite' // default — auto-CR tangents when none stored
120
133
  track.eyeInterp = 'linear'
134
+ track.eyeInterp = 'step'
121
135
 
122
- track.centerInterp = 'linear' // default — suits fixed lookat targets
123
- track.centerInterp = 'catmullrom' // smoother when center is also moving freely
136
+ track.centerInterp = 'linear' // default — suits fixed lookat targets
137
+ track.centerInterp = 'hermite' // smoother when center is also moving freely
138
+ track.centerInterp = 'step'
124
139
  ```
125
140
 
126
141
  `add()` accepts:
127
142
 
128
143
  ```js
129
- track.add({ eye, center?, up?, fov?, halfHeight? })
144
+ track.add({ eye, center?, up?, fov?, halfHeight?,
145
+ eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? })
130
146
  // fov — vertical fov (radians) for perspective
131
147
  // halfHeight — world-unit half-height for ortho
132
148
  // both nullable; omit to leave projection unchanged
149
+ // eyeTanIn/Out — Hermite tangents for eye path
150
+ // centerTanIn/Out — Hermite tangents for center path
133
151
  track.add({ vMatrix: mat4 }) // view matrix (world→eye); eye reconstructed
134
152
  track.add({ eMatrix: mat4 }) // eye matrix (eye→world); eye read from col3
135
153
  track.add([ spec, spec, ... ]) // bulk
@@ -137,9 +155,7 @@ track.add([ spec, spec, ... ]) // bulk
137
155
 
138
156
  Note: both matrix forms default `up` to `[0,1,0]`. The matrix col1 (up_ortho) is intentionally not used — it differs from the hint for upright cameras and would shift orbitControl's orbit reference. Use `capturePose()` (p5.tree bridge) when the real up hint is needed.
139
157
 
140
- `fov` and `halfHeight` are lerped between keyframes only when both adjacent
141
- keyframes carry a non-null value for that field. Mixed or null entries pass
142
- `null` through — the bridge leaves the projection unchanged.
158
+ `fov` and `halfHeight` are lerped between keyframes only when both adjacent keyframes carry a non-null value for that field. Mixed or null entries pass `null` through — the bridge leaves the projection unchanged.
143
159
 
144
160
  ---
145
161
 
@@ -241,7 +257,7 @@ qFromAxisAngle qFromLookDir qFromRotMat3x3 qFromMat4 qToMat4
241
257
  quatToAxisAngle
242
258
  ```
243
259
 
244
- **Spline / vector:** `catmullRomVec3`, `lerpVec3`
260
+ **Spline / vector:** `hermiteVec3`, `lerpVec3`
245
261
 
246
262
  **Mat4:**
247
263
  ```
package/dist/index.js CHANGED
@@ -822,7 +822,7 @@ function applyPickMatrix(proj, px, py, W, H) {
822
822
  * qFromAxisAngle qFromLookDir qFromRotMat3x3 qFromMat4 qToMat4
823
823
  * quatToAxisAngle
824
824
  * Spline / vector helpers
825
- * catmullRomVec3 lerpVec3
825
+ * hermiteVec3 lerpVec3
826
826
  * Transform / mat4 helpers
827
827
  * transformToMat4 mat4ToTransform
828
828
  * Tracks
@@ -839,20 +839,22 @@ function applyPickMatrix(proj, px, py, W, H) {
839
839
  * add() / eval() for their respective data shape.
840
840
  *
841
841
  * ── Hook architecture ─────────────────────────────────────────────────────────
842
- * _onActivate / _onDeactivate — lib-space (underscore, set by host layer)
843
- * Fire on playing transitions: false→true / true→false.
842
+ * Lib-space hooks (underscore prefix reserved for host layer / UI layer):
843
+ * _onActivate / _onDeactivate — fire on playing transitions false→true / true→false.
844
+ * _onPlay / _onEnd / _onStop — mirror the user-space hooks; used by the UI layer
845
+ * so it can sync without chaining the public slots.
844
846
  *
845
- * onPlay / onEnd / onStop — user-space (public)
847
+ * User-space hooks (public):
846
848
  * onPlay : fires in play() on false→true transition.
847
849
  * onEnd : fires in tick() at natural boundary (once mode only).
848
850
  * onStop : fires in stop() / reset() — explicit deactivation.
849
851
  * onEnd and onStop are mutually exclusive per event.
850
852
  *
851
853
  * Firing order:
852
- * play() → onPlay → _onActivate
853
- * tick() → onEnd → _onDeactivate
854
- * stop() → onStop → _onDeactivate
855
- * reset() → onStop → _onDeactivate
854
+ * play() → onPlay → _onPlay → _onActivate
855
+ * tick() → onEnd → _onEnd → _onDeactivate
856
+ * stop() → onStop → _onStop → _onDeactivate
857
+ * reset() → onStop → _onStop → _onDeactivate
856
858
  *
857
859
  * ── Playback semantics (rate) ─────────────────────────────────────────────────
858
860
  * rate > 0 forward
@@ -1048,33 +1050,42 @@ function _dist3(a, b) {
1048
1050
  }
1049
1051
 
1050
1052
  /**
1051
- * Centripetal Catmull-Rom interpolation (alpha=0.5, Barry-Goldman).
1052
- * out = interp(p0, p1, p2, p3, t) where t∈[0,1] maps p1→p2.
1053
- * Boundary: p0===p1 or p2===p3 clamps the end tangent.
1053
+ * Cubic Hermite interpolation between p0 and p1 with explicit tangents.
1054
+ * Catmull-Rom is a special case where m0/m1 are auto-computed from neighbors.
1054
1055
  * @param {number[]} out 3-element result.
1055
- * @param {number[]} p0 Control point before p1.
1056
- * @param {number[]} p1 Segment start.
1057
- * @param {number[]} p2 Segment end.
1058
- * @param {number[]} p3 Control point after p2.
1059
- * @param {number} t Blend [0, 1].
1056
+ * @param {number[]} p0 Segment start.
1057
+ * @param {number[]} m0 Outgoing tangent at p0 (world-space, dp/dt scaled to segment).
1058
+ * @param {number[]} p1 Segment end.
1059
+ * @param {number[]} m1 Incoming tangent at p1 (world-space, dp/dt scaled to segment).
1060
+ * @param {number} t Blend [0, 1].
1060
1061
  * @returns {number[]} out
1061
1062
  */
1062
- const catmullRomVec3 = (out, p0, p1, p2, p3, t) => {
1063
- const alpha = 0.5;
1064
- const dt0 = Math.pow(_dist3(p0,p1), alpha) || 1;
1065
- const dt1 = Math.pow(_dist3(p1,p2), alpha) || 1;
1066
- const dt2 = Math.pow(_dist3(p2,p3), alpha) || 1;
1067
- for (let i = 0; i < 3; i++) {
1068
- const t1_0 = (p1[i]-p0[i])/dt0 - (p2[i]-p0[i])/(dt0+dt1) + (p2[i]-p1[i])/dt1;
1069
- const t2_0 = (p2[i]-p1[i])/dt1 - (p3[i]-p1[i])/(dt1+dt2) + (p3[i]-p2[i])/dt2;
1070
- const m1=t1_0*dt1, m2=t2_0*dt1;
1071
- const a= 2*p1[i]-2*p2[i]+m1+m2;
1072
- const b=-3*p1[i]+3*p2[i]-2*m1-m2;
1073
- out[i] = a*t*t*t + b*t*t + m1*t + p1[i];
1074
- }
1063
+ const hermiteVec3 = (out, p0, m0, p1, m1, t) => {
1064
+ const t2=t*t, t3=t2*t;
1065
+ const h00=2*t3-3*t2+1, h10=t3-2*t2+t, h01=-2*t3+3*t2, h11=t3-t2;
1066
+ out[0]=h00*p0[0]+h10*m0[0]+h01*p1[0]+h11*m1[0];
1067
+ out[1]=h00*p0[1]+h10*m0[1]+h01*p1[1]+h11*m1[1];
1068
+ out[2]=h00*p0[2]+h10*m0[2]+h01*p1[2]+h11*m1[2];
1069
+ return out;
1070
+ };
1071
+
1072
+ // Centripetal CR outgoing tangent at p1 for segment p1→p2, scaled by dt1.
1073
+ const _crTanOut = (out, p0, p1, p2, p3) => {
1074
+ const dt0=Math.pow(_dist3(p0,p1),0.5)||1, dt1=Math.pow(_dist3(p1,p2),0.5)||1; Math.pow(_dist3(p2,p3),0.5)||1;
1075
+ for (let i=0;i<3;i++) out[i]=((p1[i]-p0[i])/dt0-(p2[i]-p0[i])/(dt0+dt1)+(p2[i]-p1[i])/dt1)*dt1;
1076
+ return out;
1077
+ };
1078
+
1079
+ // Centripetal CR incoming tangent at p2 for segment p1→p2, scaled by dt1.
1080
+ const _crTanIn = (out, p0, p1, p2, p3) => {
1081
+ Math.pow(_dist3(p0,p1),0.5)||1; const dt1=Math.pow(_dist3(p1,p2),0.5)||1, dt2=Math.pow(_dist3(p2,p3),0.5)||1;
1082
+ for (let i=0;i<3;i++) out[i]=((p2[i]-p1[i])/dt1-(p3[i]-p1[i])/(dt1+dt2)+(p3[i]-p2[i])/dt2)*dt1;
1075
1083
  return out;
1076
1084
  };
1077
1085
 
1086
+ // Module-level scratch — shared by eval() across all track instances (non-reentrant hot path).
1087
+ const _m0=[0,0,0], _m1=[0,0,0];
1088
+
1078
1089
  /**
1079
1090
  * Linear interpolation between two vec3s.
1080
1091
  * @param {number[]} out
@@ -1188,7 +1199,7 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1188
1199
  */
1189
1200
  function _parseQuat(v) {
1190
1201
  if (!v) return null;
1191
-
1202
+
1192
1203
  // raw [x,y,z,w] — plain array or typed array
1193
1204
  if ((Array.isArray(v) || ArrayBuffer.isView(v)) && v.length === 4) return [v[0], v[1], v[2], v[3]];
1194
1205
 
@@ -1280,13 +1291,15 @@ function _parseQuat(v) {
1280
1291
  * Float32Array(16), plain Array, or { mat4 } wrapper.
1281
1292
  * pos from col3, scl from column lengths, rot from normalised rotation block.
1282
1293
  *
1283
- * { pos, rot, scl }
1294
+ * { pos?, rot?, scl?, tanIn?, tanOut? }
1284
1295
  * Explicit TRS. pos and scl are vec3, rot accepts any form from _parseQuat.
1285
1296
  * All fields are optional — missing pos/scl default to [0,0,0] / [1,1,1],
1286
1297
  * missing rot defaults to identity.
1298
+ * tanIn/tanOut are optional vec3 tangents for Hermite interpolation.
1299
+ * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1287
1300
  *
1288
1301
  * @param {Object} spec
1289
- * @returns {{ pos:number[], rot:number[], scl:number[] }|null}
1302
+ * @returns {{ pos:number[], rot:number[], scl:number[], tanIn:number[]|null, tanOut:number[]|null }|null}
1290
1303
  */
1291
1304
  function _parseSpec(spec) {
1292
1305
  if (!spec || typeof spec !== 'object') return null;
@@ -1296,13 +1309,17 @@ function _parseSpec(spec) {
1296
1309
  const m = (ArrayBuffer.isView(spec.mMatrix) || Array.isArray(spec.mMatrix))
1297
1310
  ? spec.mMatrix : (spec.mMatrix.mat4 ?? null);
1298
1311
  if (!m || m.length < 16) return null;
1299
- return mat4ToTransform({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }, m);
1312
+ const kf = mat4ToTransform({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }, m);
1313
+ kf.tanIn = null; kf.tanOut = null;
1314
+ return kf;
1300
1315
  }
1301
1316
 
1302
- const pos = _parseVec3(spec.pos) || [0,0,0];
1303
- const rot = _parseQuat(spec.rot) || [0,0,0,1];
1304
- const scl = _parseVec3(spec.scl) || [1,1,1];
1305
- return { pos, rot, scl };
1317
+ const pos = _parseVec3(spec.pos) || [0,0,0];
1318
+ const rot = _parseQuat(spec.rot) || [0,0,0,1];
1319
+ const scl = _parseVec3(spec.scl) || [1,1,1];
1320
+ const tanIn = _parseVec3(spec.tanIn) || null;
1321
+ const tanOut = _parseVec3(spec.tanOut) || null;
1322
+ return { pos, rot, scl, tanIn, tanOut };
1306
1323
  }
1307
1324
 
1308
1325
  function _sameTransform(a, b) {
@@ -1320,9 +1337,12 @@ function _sameTransform(a, b) {
1320
1337
  *
1321
1338
  * Accepted forms:
1322
1339
  *
1323
- * { eye, center?, up? }
1340
+ * { eye, center?, up?, fov?, halfHeight?,
1341
+ * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1324
1342
  * Explicit lookat. center defaults to [0,0,0], up defaults to [0,1,0].
1325
1343
  * Both are normalised/stored as-is. eye must be a vec3.
1344
+ * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite.
1345
+ * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1326
1346
  *
1327
1347
  * { vMatrix: mat4 }
1328
1348
  * Column-major view matrix (world→eye).
@@ -1338,7 +1358,10 @@ function _sameTransform(a, b) {
1338
1358
  * Float32Array(16), plain Array, or { mat4 } wrapper.
1339
1359
  *
1340
1360
  * @param {Object} spec
1341
- * @returns {{ eye:number[], center:number[], up:number[] }|null}
1361
+ * @returns {{ eye:number[], center:number[], up:number[],
1362
+ * fov:number|null, halfHeight:number|null,
1363
+ * eyeTanIn:number[]|null, eyeTanOut:number[]|null,
1364
+ * centerTanIn:number[]|null, centerTanOut:number[]|null }|null}
1342
1365
  */
1343
1366
  function _parseCameraSpec(spec) {
1344
1367
  if (!spec || typeof spec !== 'object') return null;
@@ -1354,7 +1377,8 @@ function _parseCameraSpec(spec) {
1354
1377
  const fx=-m[8], fy=-m[9], fz=-m[10];
1355
1378
  const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1356
1379
  return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0],
1357
- fov:null, halfHeight:null };
1380
+ fov:null, halfHeight:null,
1381
+ eyeTanIn:null, eyeTanOut:null, centerTanIn:null, centerTanOut:null };
1358
1382
  }
1359
1383
 
1360
1384
  // { eMatrix } — eye matrix (eye→world); eye = col3, forward = -col2
@@ -1366,21 +1390,26 @@ function _parseCameraSpec(spec) {
1366
1390
  const fx=-m[8], fy=-m[9], fz=-m[10];
1367
1391
  const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1368
1392
  return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0],
1369
- fov:null, halfHeight:null };
1393
+ fov:null, halfHeight:null,
1394
+ eyeTanIn:null, eyeTanOut:null, centerTanIn:null, centerTanOut:null };
1370
1395
  }
1371
1396
 
1372
1397
  // { eye, center?, up? } — explicit lookat (eye is a vec3, not a mat4)
1373
1398
  const eye = _parseVec3(spec.eye);
1374
1399
  if (!eye) return null;
1375
1400
  const center = _parseVec3(spec.center) || [0,0,0];
1376
- const upRaw = spec.up ? _parseVec3(spec.up) : null;
1377
- const up = upRaw || [0,1,0];
1378
- const ul = Math.sqrt(up[0]*up[0]+up[1]*up[1]+up[2]*up[2]) || 1;
1401
+ const upRaw = spec.up ? _parseVec3(spec.up) : null;
1402
+ const up = upRaw || [0,1,0];
1403
+ const ul = Math.sqrt(up[0]*up[0]+up[1]*up[1]+up[2]*up[2]) || 1;
1379
1404
  return {
1380
1405
  eye, center,
1381
1406
  up: [up[0]/ul, up[1]/ul, up[2]/ul],
1382
- fov: typeof spec.fov === 'number' ? spec.fov : null,
1383
- halfHeight: typeof spec.halfHeight === 'number' ? spec.halfHeight : null,
1407
+ fov: typeof spec.fov === 'number' ? spec.fov : null,
1408
+ halfHeight: typeof spec.halfHeight === 'number' ? spec.halfHeight : null,
1409
+ eyeTanIn: _parseVec3(spec.eyeTanIn) || null,
1410
+ eyeTanOut: _parseVec3(spec.eyeTanOut) || null,
1411
+ centerTanIn: _parseVec3(spec.centerTanIn) || null,
1412
+ centerTanOut:_parseVec3(spec.centerTanOut)|| null,
1384
1413
  };
1385
1414
  }
1386
1415
 
@@ -1427,6 +1456,10 @@ class Track {
1427
1456
  // Lib-space hooks (set by host layer, e.g. p5 bridge)
1428
1457
  /** @type {Function|null} */ this._onActivate = null;
1429
1458
  /** @type {Function|null} */ this._onDeactivate = null;
1459
+ // Lib-space event mirrors — set by UI layer (trackUI), never touched by user code
1460
+ /** @type {Function|null} */ this._onPlay = null;
1461
+ /** @type {Function|null} */ this._onEnd = null;
1462
+ /** @type {Function|null} */ this._onStop = null;
1430
1463
  }
1431
1464
 
1432
1465
  /** Playback rate. Assigning never starts/stops playback. @type {number} */
@@ -1474,6 +1507,7 @@ class Track {
1474
1507
  this.playing = true;
1475
1508
  if (!wasPlaying) {
1476
1509
  if (typeof this.onPlay === 'function') { try { this.onPlay(this); } catch (_) {} }
1510
+ this._onPlay?.();
1477
1511
  this._onActivate?.();
1478
1512
  }
1479
1513
  return this;
@@ -1489,6 +1523,7 @@ class Track {
1489
1523
  this.playing = false;
1490
1524
  if (wasPlaying) {
1491
1525
  if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1526
+ this._onStop?.();
1492
1527
  this._onDeactivate?.();
1493
1528
  if (rewind && this.keyframes.length > 1) this.seek(this._rate < 0 ? 1 : 0);
1494
1529
  }
@@ -1504,6 +1539,7 @@ class Track {
1504
1539
  this.playing = false;
1505
1540
  if (wasPlaying) {
1506
1541
  if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1542
+ this._onStop?.();
1507
1543
  this._onDeactivate?.();
1508
1544
  }
1509
1545
  this.keyframes.length = 0;
@@ -1614,6 +1650,7 @@ class Track {
1614
1650
  this._setCursorFromScalar(0);
1615
1651
  this.playing = false;
1616
1652
  if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1653
+ this._onEnd?.();
1617
1654
  this._onDeactivate?.();
1618
1655
  return false;
1619
1656
  }
@@ -1621,6 +1658,7 @@ class Track {
1621
1658
  this._setCursorFromScalar(total);
1622
1659
  this.playing = false;
1623
1660
  if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1661
+ this._onEnd?.();
1624
1662
  this._onDeactivate?.();
1625
1663
  return false;
1626
1664
  }
@@ -1647,12 +1685,18 @@ class Track {
1647
1685
  /**
1648
1686
  * Renderer-agnostic TRS keyframe track.
1649
1687
  *
1650
- * Keyframe shape: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z] }
1688
+ * Keyframe shape: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z],
1689
+ * tanIn?:[x,y,z], tanOut?:[x,y,z] }
1690
+ *
1691
+ * tanIn — incoming position tangent at this keyframe (Hermite mode).
1692
+ * tanOut — outgoing position tangent at this keyframe (Hermite mode).
1693
+ * When only one is supplied, the other mirrors it.
1694
+ * When neither is supplied, centripetal Catmull-Rom tangents are auto-computed.
1651
1695
  *
1652
1696
  * add() accepts individual specs or a bulk array of specs:
1653
1697
  *
1654
1698
  * { mMatrix } — full TRS from model matrix
1655
- * { pos?, rot?, scl? } — direct TRS; all fields optional
1699
+ * { pos?, rot?, scl?, tanIn?, tanOut? } — direct TRS; all fields optional
1656
1700
  * { pos?, rot: [x,y,z,w] } — explicit quaternion
1657
1701
  * { pos?, rot: { axis, angle } } — axis-angle
1658
1702
  * { pos?, rot: { dir, up? } } — look direction
@@ -1665,16 +1709,15 @@ class Track {
1665
1709
  * Missing fields default to: pos → [0,0,0], rot → [0,0,0,1], scl → [1,1,1].
1666
1710
  *
1667
1711
  * eval() writes { pos, rot, scl }:
1668
- * pos — Catmull-Rom (posInterp='catmullrom') or lerp
1669
- * rot — slerp (rotInterp='slerp') or nlerp
1712
+ * pos — Hermite (tanIn/tanOut per keyframe; auto-CR when absent) or linear or step
1713
+ * rot — slerp (rotInterp='slerp') or nlerp or step
1670
1714
  * scl — lerp
1671
1715
  *
1672
1716
  * @example
1673
1717
  * const track = new PoseTrack()
1674
- * track.add({ pos:[0,0,0] }) // identity rot, uniform scl
1675
- * track.add({ pos:[100,0,0], rot: { euler:[0, Math.PI/2, 0] } })
1676
- * track.add({ rot: { axis:[0,1,0], angle: Math.PI } }) // pos defaults to [0,0,0]
1677
- * track.add({ mMatrix: someModelMatrix })
1718
+ * track.add({ pos:[0,0,0] })
1719
+ * track.add({ pos:[100,0,0], tanOut:[0,50,0] }) // leave heading +Y
1720
+ * track.add({ pos:[200,0,0] })
1678
1721
  * track.play({ loop: true })
1679
1722
  * // per frame:
1680
1723
  * track.tick()
@@ -1686,14 +1729,19 @@ class PoseTrack extends Track {
1686
1729
  super();
1687
1730
  /**
1688
1731
  * Position interpolation mode.
1689
- * @type {'catmullrom'|'linear'}
1732
+ * - 'hermite' — cubic Hermite; uses tanIn/tanOut per keyframe when present,
1733
+ * auto-computes centripetal Catmull-Rom tangents when absent (default)
1734
+ * - 'linear' — lerp
1735
+ * - 'step' — snap to k0 value; useful for discrete state changes
1736
+ * @type {'hermite'|'linear'|'step'}
1690
1737
  */
1691
- this.posInterp = 'catmullrom';
1738
+ this.posInterp = 'hermite';
1692
1739
  /**
1693
1740
  * Rotation interpolation mode.
1694
1741
  * - 'slerp' — constant angular velocity (default)
1695
1742
  * - 'nlerp' — normalised lerp; cheaper, slightly non-constant speed
1696
- * @type {'slerp'|'nlerp'}
1743
+ * - 'step' — snap to k0 quaternion; useful for discrete state changes
1744
+ * @type {'slerp'|'nlerp'|'step'}
1697
1745
  */
1698
1746
  this.rotInterp = 'slerp';
1699
1747
  // Scratch for toMatrix() — avoids hot-path allocations
@@ -1761,17 +1809,29 @@ class PoseTrack extends Track {
1761
1809
  const k0 = this.keyframes[seg];
1762
1810
  const k1 = this.keyframes[seg + 1];
1763
1811
 
1764
- // pos — Catmull-Rom or lerp
1765
- if (this.posInterp === 'catmullrom') {
1766
- const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1767
- const p3 = seg + 2 < n ? this.keyframes[seg + 2].pos : k1.pos;
1768
- catmullRomVec3(out.pos, p0, k0.pos, k1.pos, p3, t);
1769
- } else {
1812
+ // pos — Hermite (auto-CR tangents when none stored), linear, or step
1813
+ if (this.posInterp === 'step') {
1814
+ out.pos[0]=k0.pos[0]; out.pos[1]=k0.pos[1]; out.pos[2]=k0.pos[2];
1815
+ } else if (this.posInterp === 'linear') {
1770
1816
  lerpVec3(out.pos, k0.pos, k1.pos, t);
1817
+ } else {
1818
+ const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1819
+ const p3 = seg + 2 < n ? this.keyframes[seg + 2].pos : k1.pos;
1820
+ // tanOut on k0: use stored, else symmetric from tanIn, else auto-CR
1821
+ const m0 = k0.tanOut != null ? k0.tanOut
1822
+ : k0.tanIn != null ? k0.tanIn
1823
+ : _crTanOut(_m0, p0, k0.pos, k1.pos, p3);
1824
+ // tanIn on k1: use stored, else symmetric from tanOut, else auto-CR
1825
+ const m1 = k1.tanIn != null ? k1.tanIn
1826
+ : k1.tanOut != null ? k1.tanOut
1827
+ : _crTanIn(_m1, p0, k0.pos, k1.pos, p3);
1828
+ hermiteVec3(out.pos, k0.pos, m0, k1.pos, m1, t);
1771
1829
  }
1772
1830
 
1773
- // rot — slerp or nlerp
1774
- if (this.rotInterp === 'nlerp') {
1831
+ // rot — step, slerp, or nlerp
1832
+ if (this.rotInterp === 'step') {
1833
+ out.rot[0]=k0.rot[0]; out.rot[1]=k0.rot[1]; out.rot[2]=k0.rot[2]; out.rot[3]=k0.rot[3];
1834
+ } else if (this.rotInterp === 'nlerp') {
1775
1835
  qNlerp(out.rot, k0.rot, k1.rot, t);
1776
1836
  } else {
1777
1837
  qSlerp(out.rot, k0.rot, k1.rot, t);
@@ -1801,23 +1861,31 @@ class PoseTrack extends Track {
1801
1861
  /**
1802
1862
  * Lookat camera keyframe track.
1803
1863
  *
1804
- * Keyframe shape: { eye:[x,y,z], center:[x,y,z], up:[x,y,z], fov?:number, halfHeight?:number }
1864
+ * Keyframe shape: { eye:[x,y,z], center:[x,y,z], up:[x,y,z],
1865
+ * fov?:number, halfHeight?:number,
1866
+ * eyeTanIn?:[x,y,z], eyeTanOut?:[x,y,z],
1867
+ * centerTanIn?:[x,y,z], centerTanOut?:[x,y,z] }
1805
1868
  *
1806
1869
  * fov — vertical fov (radians) for perspective cameras; null for ortho.
1807
1870
  * halfHeight — world-unit half-height of ortho frustum; null for perspective.
1808
1871
  * Both are optional and nullable. eval() lerps each only when both adjacent
1809
1872
  * keyframes carry a non-null value for that field.
1810
1873
  *
1874
+ * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite
1875
+ * interpolation of the eye and center paths respectively.
1876
+ * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1877
+ *
1811
1878
  * Each field is independently interpolated — eye and center along their
1812
1879
  * own paths, up nlerped on the unit sphere. This correctly handles cameras
1813
1880
  * that always look at a fixed target (center stays at origin throughout)
1814
1881
  * as well as free-fly paths where center moves independently.
1815
- *
1882
+ *
1816
1883
  * Missing fields default to: center → [0,0,0], up → [0,1,0].
1817
1884
  *
1818
1885
  * add() accepts individual specs or a bulk array of specs:
1819
1886
  *
1820
- * { eye, center?, up?, fov?, halfHeight? }
1887
+ * { eye, center?, up?, fov?, halfHeight?,
1888
+ * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1821
1889
  * explicit lookat; center defaults to [0,0,0], up to [0,1,0].
1822
1890
  * fov and halfHeight are mutually exclusive nullable scalars.
1823
1891
  * { vMatrix: mat4 } view matrix (world→eye); eye reconstructed via -R^T·t
@@ -1831,8 +1899,8 @@ class PoseTrack extends Track {
1831
1899
  * Use capturePose() (p5.tree bridge) when the real up hint is needed.
1832
1900
  *
1833
1901
  * eval() writes { eye, center, up, fov, halfHeight }:
1834
- * eye — Catmull-Rom (eyeInterp='catmullrom') or lerp
1835
- * center — Catmull-Rom (centerInterp='catmullrom') or lerp
1902
+ * eye — Hermite (auto-CR when no tangents stored) or linear or step
1903
+ * center — Hermite (auto-CR when no tangents stored) or linear or step
1836
1904
  * up — nlerp (normalize-after-lerp on unit sphere)
1837
1905
  * fov — lerp when both keyframes carry non-null fov; else null
1838
1906
  * halfHeight — lerp when both keyframes carry non-null halfHeight; else null
@@ -1857,14 +1925,20 @@ class CameraTrack extends Track {
1857
1925
  super();
1858
1926
  /**
1859
1927
  * Eye position interpolation mode.
1860
- * @type {'catmullrom'|'linear'}
1928
+ * - 'hermite' — cubic Hermite; auto-CR tangents when none stored (default)
1929
+ * - 'linear' — lerp
1930
+ * - 'step' — snap to k0 eye
1931
+ * @type {'hermite'|'linear'|'step'}
1861
1932
  */
1862
- this.eyeInterp = 'catmullrom';
1933
+ this.eyeInterp = 'hermite';
1863
1934
  /**
1864
1935
  * Center (lookat target) interpolation mode.
1865
- * 'linear' suits fixed or predictably moving targets.
1866
- * 'catmullrom' gives smoother paths when center is also flying freely.
1867
- * @type {'catmullrom'|'linear'}
1936
+ * 'linear' suits fixed or predictably moving targets (default).
1937
+ * 'hermite' gives smoother paths when center is also flying freely.
1938
+ * - 'hermite' — cubic Hermite; auto-CR tangents when none stored
1939
+ * - 'linear' — lerp
1940
+ * - 'step' — snap to k0 center
1941
+ * @type {'hermite'|'linear'|'step'}
1868
1942
  */
1869
1943
  this.centerInterp = 'linear';
1870
1944
  // Scratch for toCamera() — avoids hot-path allocations
@@ -1937,22 +2011,38 @@ class CameraTrack extends Track {
1937
2011
  const k0 = this.keyframes[seg];
1938
2012
  const k1 = this.keyframes[seg + 1];
1939
2013
 
1940
- // eye — Catmull-Rom or lerp
1941
- if (this.eyeInterp === 'catmullrom') {
1942
- const p0 = seg > 0 ? this.keyframes[seg - 1].eye : k0.eye;
1943
- const p3 = seg + 2 < n ? this.keyframes[seg + 2].eye : k1.eye;
1944
- catmullRomVec3(out.eye, p0, k0.eye, k1.eye, p3, t);
1945
- } else {
2014
+ // eye — Hermite (auto-CR tangents when none stored), linear, or step
2015
+ if (this.eyeInterp === 'step') {
2016
+ out.eye[0]=k0.eye[0]; out.eye[1]=k0.eye[1]; out.eye[2]=k0.eye[2];
2017
+ } else if (this.eyeInterp === 'linear') {
1946
2018
  lerpVec3(out.eye, k0.eye, k1.eye, t);
2019
+ } else {
2020
+ const p0 = seg > 0 ? this.keyframes[seg - 1].eye : k0.eye;
2021
+ const p3 = seg + 2 < n ? this.keyframes[seg + 2].eye : k1.eye;
2022
+ const m0 = k0.eyeTanOut != null ? k0.eyeTanOut
2023
+ : k0.eyeTanIn != null ? k0.eyeTanIn
2024
+ : _crTanOut(_m0, p0, k0.eye, k1.eye, p3);
2025
+ const m1 = k1.eyeTanIn != null ? k1.eyeTanIn
2026
+ : k1.eyeTanOut != null ? k1.eyeTanOut
2027
+ : _crTanIn(_m1, p0, k0.eye, k1.eye, p3);
2028
+ hermiteVec3(out.eye, k0.eye, m0, k1.eye, m1, t);
1947
2029
  }
1948
2030
 
1949
- // center — Catmull-Rom or lerp (independent lookat target)
1950
- if (this.centerInterp === 'catmullrom') {
1951
- const c0 = seg > 0 ? this.keyframes[seg - 1].center : k0.center;
1952
- const c3 = seg + 2 < n ? this.keyframes[seg + 2].center : k1.center;
1953
- catmullRomVec3(out.center, c0, k0.center, k1.center, c3, t);
1954
- } else {
2031
+ // center — Hermite, linear, or step (independent lookat target)
2032
+ if (this.centerInterp === 'step') {
2033
+ out.center[0]=k0.center[0]; out.center[1]=k0.center[1]; out.center[2]=k0.center[2];
2034
+ } else if (this.centerInterp === 'linear') {
1955
2035
  lerpVec3(out.center, k0.center, k1.center, t);
2036
+ } else {
2037
+ const c0 = seg > 0 ? this.keyframes[seg - 1].center : k0.center;
2038
+ const c3 = seg + 2 < n ? this.keyframes[seg + 2].center : k1.center;
2039
+ const m0 = k0.centerTanOut != null ? k0.centerTanOut
2040
+ : k0.centerTanIn != null ? k0.centerTanIn
2041
+ : _crTanOut(_m0, c0, k0.center, k1.center, c3);
2042
+ const m1 = k1.centerTanIn != null ? k1.centerTanIn
2043
+ : k1.centerTanOut != null ? k1.centerTanOut
2044
+ : _crTanIn(_m1, c0, k0.center, k1.center, c3);
2045
+ hermiteVec3(out.center, k0.center, m0, k1.center, m1, t);
1956
2046
  }
1957
2047
 
1958
2048
  // up — nlerp (normalize after lerp; correct for typical near-upright cameras)
@@ -2130,5 +2220,5 @@ function boxVisibility(planes, x0, y0, z0, x1, y1, z1) {
2130
2220
  return allIn ? VISIBLE : SEMIVISIBLE;
2131
2221
  }
2132
2222
 
2133
- export { CameraTrack, EYE, INVISIBLE, MATRIX, MODEL, NDC, ORIGIN, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, PoseTrack, SCREEN, SEMIVISIBLE, VISIBLE, WEBGL, WEBGPU, WORLD, _i, _j, _k, applyPickMatrix, boxVisibility, 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 };
2223
+ export { CameraTrack, EYE, INVISIBLE, MATRIX, MODEL, NDC, ORIGIN, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, PoseTrack, SCREEN, SEMIVISIBLE, VISIBLE, WEBGL, WEBGPU, WORLD, _i, _j, _k, applyPickMatrix, boxVisibility, distanceToPlane, frustumPlanes, hermiteVec3, i, j, k, lerpVec3, mapDirection, mapLocation, mat3Direction, mat3NormalFromMat4, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulPoint, mat4PV, mat4ToTransform, mat4Transpose, pixelRatio, pointVisibility, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qMul, qNegate, qNlerp, qNormalize, qSet, qSlerp, qToMat4, quatToAxisAngle, sphereVisibility, transformToMat4 };
2134
2224
  //# sourceMappingURL=index.js.map