@nakednous/tree 0.0.7 → 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/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;
@@ -1353,7 +1376,9 @@ function _parseCameraSpec(spec) {
1353
1376
  const ez = -(m[2]*m[12] + m[6]*m[13] + m[10]*m[14]);
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
- return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0] };
1379
+ return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0],
1380
+ fov:null, halfHeight:null,
1381
+ eyeTanIn:null, eyeTanOut:null, centerTanIn:null, centerTanOut:null };
1357
1382
  }
1358
1383
 
1359
1384
  // { eMatrix } — eye matrix (eye→world); eye = col3, forward = -col2
@@ -1364,17 +1389,28 @@ function _parseCameraSpec(spec) {
1364
1389
  const ex=m[12], ey=m[13], ez=m[14];
1365
1390
  const fx=-m[8], fy=-m[9], fz=-m[10];
1366
1391
  const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1367
- return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0] };
1392
+ return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0],
1393
+ fov:null, halfHeight:null,
1394
+ eyeTanIn:null, eyeTanOut:null, centerTanIn:null, centerTanOut:null };
1368
1395
  }
1369
1396
 
1370
1397
  // { eye, center?, up? } — explicit lookat (eye is a vec3, not a mat4)
1371
1398
  const eye = _parseVec3(spec.eye);
1372
1399
  if (!eye) return null;
1373
1400
  const center = _parseVec3(spec.center) || [0,0,0];
1374
- const upRaw = spec.up ? _parseVec3(spec.up) : null;
1375
- const up = upRaw || [0,1,0];
1376
- const ul = Math.sqrt(up[0]*up[0]+up[1]*up[1]+up[2]*up[2]) || 1;
1377
- return { eye, center, up:[up[0]/ul, up[1]/ul, up[2]/ul] };
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;
1404
+ return {
1405
+ eye, center,
1406
+ up: [up[0]/ul, up[1]/ul, up[2]/ul],
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,
1413
+ };
1378
1414
  }
1379
1415
 
1380
1416
  function _sameCameraKeyframe(a, b) {
@@ -1383,6 +1419,8 @@ function _sameCameraKeyframe(a, b) {
1383
1419
  if (a.center[i]!==b.center[i]) return false;
1384
1420
  if (a.up[i]!==b.up[i]) return false;
1385
1421
  }
1422
+ if (a.fov !== b.fov) return false;
1423
+ if (a.halfHeight !== b.halfHeight) return false;
1386
1424
  return true;
1387
1425
  }
1388
1426
 
@@ -1418,6 +1456,10 @@ class Track {
1418
1456
  // Lib-space hooks (set by host layer, e.g. p5 bridge)
1419
1457
  /** @type {Function|null} */ this._onActivate = null;
1420
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;
1421
1463
  }
1422
1464
 
1423
1465
  /** Playback rate. Assigning never starts/stops playback. @type {number} */
@@ -1465,6 +1507,7 @@ class Track {
1465
1507
  this.playing = true;
1466
1508
  if (!wasPlaying) {
1467
1509
  if (typeof this.onPlay === 'function') { try { this.onPlay(this); } catch (_) {} }
1510
+ this._onPlay?.();
1468
1511
  this._onActivate?.();
1469
1512
  }
1470
1513
  return this;
@@ -1480,6 +1523,7 @@ class Track {
1480
1523
  this.playing = false;
1481
1524
  if (wasPlaying) {
1482
1525
  if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1526
+ this._onStop?.();
1483
1527
  this._onDeactivate?.();
1484
1528
  if (rewind && this.keyframes.length > 1) this.seek(this._rate < 0 ? 1 : 0);
1485
1529
  }
@@ -1495,6 +1539,7 @@ class Track {
1495
1539
  this.playing = false;
1496
1540
  if (wasPlaying) {
1497
1541
  if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1542
+ this._onStop?.();
1498
1543
  this._onDeactivate?.();
1499
1544
  }
1500
1545
  this.keyframes.length = 0;
@@ -1605,6 +1650,7 @@ class Track {
1605
1650
  this._setCursorFromScalar(0);
1606
1651
  this.playing = false;
1607
1652
  if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1653
+ this._onEnd?.();
1608
1654
  this._onDeactivate?.();
1609
1655
  return false;
1610
1656
  }
@@ -1612,6 +1658,7 @@ class Track {
1612
1658
  this._setCursorFromScalar(total);
1613
1659
  this.playing = false;
1614
1660
  if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1661
+ this._onEnd?.();
1615
1662
  this._onDeactivate?.();
1616
1663
  return false;
1617
1664
  }
@@ -1638,12 +1685,18 @@ class Track {
1638
1685
  /**
1639
1686
  * Renderer-agnostic TRS keyframe track.
1640
1687
  *
1641
- * 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.
1642
1695
  *
1643
1696
  * add() accepts individual specs or a bulk array of specs:
1644
1697
  *
1645
1698
  * { mMatrix } — full TRS from model matrix
1646
- * { pos?, rot?, scl? } — direct TRS; all fields optional
1699
+ * { pos?, rot?, scl?, tanIn?, tanOut? } — direct TRS; all fields optional
1647
1700
  * { pos?, rot: [x,y,z,w] } — explicit quaternion
1648
1701
  * { pos?, rot: { axis, angle } } — axis-angle
1649
1702
  * { pos?, rot: { dir, up? } } — look direction
@@ -1656,16 +1709,15 @@ class Track {
1656
1709
  * Missing fields default to: pos → [0,0,0], rot → [0,0,0,1], scl → [1,1,1].
1657
1710
  *
1658
1711
  * eval() writes { pos, rot, scl }:
1659
- * pos — Catmull-Rom (posInterp='catmullrom') or lerp
1660
- * 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
1661
1714
  * scl — lerp
1662
1715
  *
1663
1716
  * @example
1664
1717
  * const track = new PoseTrack()
1665
- * track.add({ pos:[0,0,0] }) // identity rot, uniform scl
1666
- * track.add({ pos:[100,0,0], rot: { euler:[0, Math.PI/2, 0] } })
1667
- * track.add({ rot: { axis:[0,1,0], angle: Math.PI } }) // pos defaults to [0,0,0]
1668
- * 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] })
1669
1721
  * track.play({ loop: true })
1670
1722
  * // per frame:
1671
1723
  * track.tick()
@@ -1677,14 +1729,19 @@ class PoseTrack extends Track {
1677
1729
  super();
1678
1730
  /**
1679
1731
  * Position interpolation mode.
1680
- * @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'}
1681
1737
  */
1682
- this.posInterp = 'catmullrom';
1738
+ this.posInterp = 'hermite';
1683
1739
  /**
1684
1740
  * Rotation interpolation mode.
1685
1741
  * - 'slerp' — constant angular velocity (default)
1686
1742
  * - 'nlerp' — normalised lerp; cheaper, slightly non-constant speed
1687
- * @type {'slerp'|'nlerp'}
1743
+ * - 'step' — snap to k0 quaternion; useful for discrete state changes
1744
+ * @type {'slerp'|'nlerp'|'step'}
1688
1745
  */
1689
1746
  this.rotInterp = 'slerp';
1690
1747
  // Scratch for toMatrix() — avoids hot-path allocations
@@ -1752,17 +1809,29 @@ class PoseTrack extends Track {
1752
1809
  const k0 = this.keyframes[seg];
1753
1810
  const k1 = this.keyframes[seg + 1];
1754
1811
 
1755
- // pos — Catmull-Rom or lerp
1756
- if (this.posInterp === 'catmullrom') {
1757
- const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1758
- const p3 = seg + 2 < n ? this.keyframes[seg + 2].pos : k1.pos;
1759
- catmullRomVec3(out.pos, p0, k0.pos, k1.pos, p3, t);
1760
- } 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') {
1761
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);
1762
1829
  }
1763
1830
 
1764
- // rot — slerp or nlerp
1765
- 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') {
1766
1835
  qNlerp(out.rot, k0.rot, k1.rot, t);
1767
1836
  } else {
1768
1837
  qSlerp(out.rot, k0.rot, k1.rot, t);
@@ -1792,18 +1861,33 @@ class PoseTrack extends Track {
1792
1861
  /**
1793
1862
  * Lookat camera keyframe track.
1794
1863
  *
1795
- * Keyframe shape: { eye:[x,y,z], center:[x,y,z], up:[x,y,z] }
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] }
1868
+ *
1869
+ * fov — vertical fov (radians) for perspective cameras; null for ortho.
1870
+ * halfHeight — world-unit half-height of ortho frustum; null for perspective.
1871
+ * Both are optional and nullable. eval() lerps each only when both adjacent
1872
+ * keyframes carry a non-null value for that field.
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.
1796
1877
  *
1797
1878
  * Each field is independently interpolated — eye and center along their
1798
1879
  * own paths, up nlerped on the unit sphere. This correctly handles cameras
1799
1880
  * that always look at a fixed target (center stays at origin throughout)
1800
1881
  * as well as free-fly paths where center moves independently.
1801
- *
1882
+ *
1802
1883
  * Missing fields default to: center → [0,0,0], up → [0,1,0].
1803
1884
  *
1804
1885
  * add() accepts individual specs or a bulk array of specs:
1805
1886
  *
1806
- * { eye, center?, up? } explicit lookat; center defaults to [0,0,0], up to [0,1,0]
1887
+ * { eye, center?, up?, fov?, halfHeight?,
1888
+ * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1889
+ * explicit lookat; center defaults to [0,0,0], up to [0,1,0].
1890
+ * fov and halfHeight are mutually exclusive nullable scalars.
1807
1891
  * { vMatrix: mat4 } view matrix (world→eye); eye reconstructed via -R^T·t
1808
1892
  * { eMatrix: mat4 } eye matrix (eye→world); eye read from col3 directly
1809
1893
  * [ spec, spec, ... ] bulk
@@ -1814,10 +1898,12 @@ class PoseTrack extends Track {
1814
1898
  * passing it to cam.camera() shifts orbitControl's orbit reference.
1815
1899
  * Use capturePose() (p5.tree bridge) when the real up hint is needed.
1816
1900
  *
1817
- * eval() writes { eye, center, up }:
1818
- * eye Catmull-Rom (eyeInterp='catmullrom') or lerp
1819
- * center Catmull-Rom (centerInterp='catmullrom') or lerp
1820
- * up — nlerp (normalize-after-lerp on unit sphere)
1901
+ * eval() writes { eye, center, up, fov, halfHeight }:
1902
+ * eye Hermite (auto-CR when no tangents stored) or linear or step
1903
+ * center Hermite (auto-CR when no tangents stored) or linear or step
1904
+ * up — nlerp (normalize-after-lerp on unit sphere)
1905
+ * fov — lerp when both keyframes carry non-null fov; else null
1906
+ * halfHeight — lerp when both keyframes carry non-null halfHeight; else null
1821
1907
  *
1822
1908
  * @example
1823
1909
  * const track = new CameraTrack()
@@ -1839,14 +1925,20 @@ class CameraTrack extends Track {
1839
1925
  super();
1840
1926
  /**
1841
1927
  * Eye position interpolation mode.
1842
- * @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'}
1843
1932
  */
1844
- this.eyeInterp = 'catmullrom';
1933
+ this.eyeInterp = 'hermite';
1845
1934
  /**
1846
1935
  * Center (lookat target) interpolation mode.
1847
- * 'linear' suits fixed or predictably moving targets.
1848
- * 'catmullrom' gives smoother paths when center is also flying freely.
1849
- * @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'}
1850
1942
  */
1851
1943
  this.centerInterp = 'linear';
1852
1944
  // Scratch for toCamera() — avoids hot-path allocations
@@ -1898,7 +1990,7 @@ class CameraTrack extends Track {
1898
1990
  * @returns {{ eye:number[], center:number[], up:number[] }} out
1899
1991
  */
1900
1992
  eval(out) {
1901
- out = out || { eye:[0,0,0], center:[0,0,0], up:[0,1,0] };
1993
+ out = out || { eye:[0,0,0], center:[0,0,0], up:[0,1,0], fov:null, halfHeight:null };
1902
1994
  const n = this.keyframes.length;
1903
1995
  if (n === 0) return out;
1904
1996
 
@@ -1907,6 +1999,8 @@ class CameraTrack extends Track {
1907
1999
  out.eye[0]=k.eye[0]; out.eye[1]=k.eye[1]; out.eye[2]=k.eye[2];
1908
2000
  out.center[0]=k.center[0]; out.center[1]=k.center[1]; out.center[2]=k.center[2];
1909
2001
  out.up[0]=k.up[0]; out.up[1]=k.up[1]; out.up[2]=k.up[2];
2002
+ out.fov = k.fov;
2003
+ out.halfHeight = k.halfHeight;
1910
2004
  return out;
1911
2005
  }
1912
2006
 
@@ -1917,22 +2011,38 @@ class CameraTrack extends Track {
1917
2011
  const k0 = this.keyframes[seg];
1918
2012
  const k1 = this.keyframes[seg + 1];
1919
2013
 
1920
- // eye — Catmull-Rom or lerp
1921
- if (this.eyeInterp === 'catmullrom') {
1922
- const p0 = seg > 0 ? this.keyframes[seg - 1].eye : k0.eye;
1923
- const p3 = seg + 2 < n ? this.keyframes[seg + 2].eye : k1.eye;
1924
- catmullRomVec3(out.eye, p0, k0.eye, k1.eye, p3, t);
1925
- } 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') {
1926
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);
1927
2029
  }
1928
2030
 
1929
- // center — Catmull-Rom or lerp (independent lookat target)
1930
- if (this.centerInterp === 'catmullrom') {
1931
- const c0 = seg > 0 ? this.keyframes[seg - 1].center : k0.center;
1932
- const c3 = seg + 2 < n ? this.keyframes[seg + 2].center : k1.center;
1933
- catmullRomVec3(out.center, c0, k0.center, k1.center, c3, t);
1934
- } 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') {
1935
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);
1936
2046
  }
1937
2047
 
1938
2048
  // up — nlerp (normalize after lerp; correct for typical near-upright cameras)
@@ -1942,6 +2052,13 @@ class CameraTrack extends Track {
1942
2052
  const ul = Math.sqrt(ux*ux+uy*uy+uz*uz) || 1;
1943
2053
  out.up[0]=ux/ul; out.up[1]=uy/ul; out.up[2]=uz/ul;
1944
2054
 
2055
+ // fov — lerp (perspective); null when either keyframe lacks it
2056
+ out.fov = (k0.fov !== null && k1.fov !== null)
2057
+ ? k0.fov + t * (k1.fov - k0.fov) : null;
2058
+ // halfHeight — lerp (ortho); null when either keyframe lacks it
2059
+ out.halfHeight = (k0.halfHeight !== null && k1.halfHeight !== null)
2060
+ ? k0.halfHeight + t * (k1.halfHeight - k0.halfHeight) : null;
2061
+
1945
2062
  return out;
1946
2063
  }
1947
2064
  }
@@ -2103,5 +2220,5 @@ function boxVisibility(planes, x0, y0, z0, x1, y1, z1) {
2103
2220
  return allIn ? VISIBLE : SEMIVISIBLE;
2104
2221
  }
2105
2222
 
2106
- 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 };
2107
2224
  //# sourceMappingURL=index.js.map