@nakednous/tree 0.0.8 → 0.0.10

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,22 +839,33 @@ 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
- * ── Playback semantics (rate) ─────────────────────────────────────────────────
859
+ * ── Loop modes ────────────────────────────────────────────────────────────────
860
+ * once loop=false, bounce=false — stop at end (fires onEnd)
861
+ * repeat loop=true, bounce=false — wrap back to start
862
+ * bounce loop=true, bounce=true — bounce at boundaries
863
+ *
864
+ * Exclusivity enforced in play():
865
+ * bounce: true → loop is also set true
866
+ * loop: false → bounce is also cleared
867
+ *
868
+ * ── Playback semantics (rate + _dir) ─────────────────────────────────────────
858
869
  * rate > 0 forward
859
870
  * rate < 0 backward
860
871
  * rate === 0 frozen: tick() no-op; playing unchanged
@@ -863,6 +874,12 @@ function applyPickMatrix(proj, px, py, W, H) {
863
874
  * stop() is the sole setter of playing = false.
864
875
  * Assigning rate never starts or stops playback.
865
876
  *
877
+ * _dir (internal, ±1) tracks the current bounce travel direction.
878
+ * tick() advances by rate * _dir and flips _dir at boundaries.
879
+ * rate always holds the user-set value — it is never mutated by bounce.
880
+ * _dir is reset to 1 only in reset() (keyframes cleared) — stop/replay
881
+ * preserves the current travel direction.
882
+ *
866
883
  * ── One-keyframe behaviour ────────────────────────────────────────────────────
867
884
  * play() with exactly one keyframe snaps eval() to that keyframe without
868
885
  * setting playing = true and without firing hooks.
@@ -1048,33 +1065,42 @@ function _dist3(a, b) {
1048
1065
  }
1049
1066
 
1050
1067
  /**
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.
1068
+ * Cubic Hermite interpolation between p0 and p1 with explicit tangents.
1069
+ * Catmull-Rom is a special case where m0/m1 are auto-computed from neighbors.
1054
1070
  * @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].
1071
+ * @param {number[]} p0 Segment start.
1072
+ * @param {number[]} m0 Outgoing tangent at p0 (world-space, dp/dt scaled to segment).
1073
+ * @param {number[]} p1 Segment end.
1074
+ * @param {number[]} m1 Incoming tangent at p1 (world-space, dp/dt scaled to segment).
1075
+ * @param {number} t Blend [0, 1].
1060
1076
  * @returns {number[]} out
1061
1077
  */
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
- }
1078
+ const hermiteVec3 = (out, p0, m0, p1, m1, t) => {
1079
+ const t2=t*t, t3=t2*t;
1080
+ const h00=2*t3-3*t2+1, h10=t3-2*t2+t, h01=-2*t3+3*t2, h11=t3-t2;
1081
+ out[0]=h00*p0[0]+h10*m0[0]+h01*p1[0]+h11*m1[0];
1082
+ out[1]=h00*p0[1]+h10*m0[1]+h01*p1[1]+h11*m1[1];
1083
+ out[2]=h00*p0[2]+h10*m0[2]+h01*p1[2]+h11*m1[2];
1084
+ return out;
1085
+ };
1086
+
1087
+ // Centripetal CR outgoing tangent at p1 for segment p1→p2, scaled by dt1.
1088
+ const _crTanOut = (out, p0, p1, p2, p3) => {
1089
+ const dt0=Math.pow(_dist3(p0,p1),0.5)||1, dt1=Math.pow(_dist3(p1,p2),0.5)||1; Math.pow(_dist3(p2,p3),0.5)||1;
1090
+ 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;
1075
1091
  return out;
1076
1092
  };
1077
1093
 
1094
+ // Centripetal CR incoming tangent at p2 for segment p1→p2, scaled by dt1.
1095
+ const _crTanIn = (out, p0, p1, p2, p3) => {
1096
+ Math.pow(_dist3(p0,p1),0.5)||1; const dt1=Math.pow(_dist3(p1,p2),0.5)||1, dt2=Math.pow(_dist3(p2,p3),0.5)||1;
1097
+ for (let i=0;i<3;i++) out[i]=((p2[i]-p1[i])/dt1-(p3[i]-p1[i])/(dt1+dt2)+(p3[i]-p2[i])/dt2)*dt1;
1098
+ return out;
1099
+ };
1100
+
1101
+ // Module-level scratch — shared by eval() across all track instances (non-reentrant hot path).
1102
+ const _m0=[0,0,0], _m1=[0,0,0];
1103
+
1078
1104
  /**
1079
1105
  * Linear interpolation between two vec3s.
1080
1106
  * @param {number[]} out
@@ -1188,7 +1214,7 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1188
1214
  */
1189
1215
  function _parseQuat(v) {
1190
1216
  if (!v) return null;
1191
-
1217
+
1192
1218
  // raw [x,y,z,w] — plain array or typed array
1193
1219
  if ((Array.isArray(v) || ArrayBuffer.isView(v)) && v.length === 4) return [v[0], v[1], v[2], v[3]];
1194
1220
 
@@ -1280,13 +1306,15 @@ function _parseQuat(v) {
1280
1306
  * Float32Array(16), plain Array, or { mat4 } wrapper.
1281
1307
  * pos from col3, scl from column lengths, rot from normalised rotation block.
1282
1308
  *
1283
- * { pos, rot, scl }
1309
+ * { pos?, rot?, scl?, tanIn?, tanOut? }
1284
1310
  * Explicit TRS. pos and scl are vec3, rot accepts any form from _parseQuat.
1285
1311
  * All fields are optional — missing pos/scl default to [0,0,0] / [1,1,1],
1286
1312
  * missing rot defaults to identity.
1313
+ * tanIn/tanOut are optional vec3 tangents for Hermite interpolation.
1314
+ * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1287
1315
  *
1288
1316
  * @param {Object} spec
1289
- * @returns {{ pos:number[], rot:number[], scl:number[] }|null}
1317
+ * @returns {{ pos:number[], rot:number[], scl:number[], tanIn:number[]|null, tanOut:number[]|null }|null}
1290
1318
  */
1291
1319
  function _parseSpec(spec) {
1292
1320
  if (!spec || typeof spec !== 'object') return null;
@@ -1296,13 +1324,17 @@ function _parseSpec(spec) {
1296
1324
  const m = (ArrayBuffer.isView(spec.mMatrix) || Array.isArray(spec.mMatrix))
1297
1325
  ? spec.mMatrix : (spec.mMatrix.mat4 ?? null);
1298
1326
  if (!m || m.length < 16) return null;
1299
- return mat4ToTransform({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }, m);
1327
+ const kf = mat4ToTransform({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }, m);
1328
+ kf.tanIn = null; kf.tanOut = null;
1329
+ return kf;
1300
1330
  }
1301
1331
 
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 };
1332
+ const pos = _parseVec3(spec.pos) || [0,0,0];
1333
+ const rot = _parseQuat(spec.rot) || [0,0,0,1];
1334
+ const scl = _parseVec3(spec.scl) || [1,1,1];
1335
+ const tanIn = _parseVec3(spec.tanIn) || null;
1336
+ const tanOut = _parseVec3(spec.tanOut) || null;
1337
+ return { pos, rot, scl, tanIn, tanOut };
1306
1338
  }
1307
1339
 
1308
1340
  function _sameTransform(a, b) {
@@ -1320,9 +1352,12 @@ function _sameTransform(a, b) {
1320
1352
  *
1321
1353
  * Accepted forms:
1322
1354
  *
1323
- * { eye, center?, up? }
1355
+ * { eye, center?, up?, fov?, halfHeight?,
1356
+ * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1324
1357
  * Explicit lookat. center defaults to [0,0,0], up defaults to [0,1,0].
1325
1358
  * Both are normalised/stored as-is. eye must be a vec3.
1359
+ * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite.
1360
+ * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1326
1361
  *
1327
1362
  * { vMatrix: mat4 }
1328
1363
  * Column-major view matrix (world→eye).
@@ -1338,7 +1373,10 @@ function _sameTransform(a, b) {
1338
1373
  * Float32Array(16), plain Array, or { mat4 } wrapper.
1339
1374
  *
1340
1375
  * @param {Object} spec
1341
- * @returns {{ eye:number[], center:number[], up:number[] }|null}
1376
+ * @returns {{ eye:number[], center:number[], up:number[],
1377
+ * fov:number|null, halfHeight:number|null,
1378
+ * eyeTanIn:number[]|null, eyeTanOut:number[]|null,
1379
+ * centerTanIn:number[]|null, centerTanOut:number[]|null }|null}
1342
1380
  */
1343
1381
  function _parseCameraSpec(spec) {
1344
1382
  if (!spec || typeof spec !== 'object') return null;
@@ -1354,7 +1392,8 @@ function _parseCameraSpec(spec) {
1354
1392
  const fx=-m[8], fy=-m[9], fz=-m[10];
1355
1393
  const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1356
1394
  return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0],
1357
- fov:null, halfHeight:null };
1395
+ fov:null, halfHeight:null,
1396
+ eyeTanIn:null, eyeTanOut:null, centerTanIn:null, centerTanOut:null };
1358
1397
  }
1359
1398
 
1360
1399
  // { eMatrix } — eye matrix (eye→world); eye = col3, forward = -col2
@@ -1366,21 +1405,26 @@ function _parseCameraSpec(spec) {
1366
1405
  const fx=-m[8], fy=-m[9], fz=-m[10];
1367
1406
  const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1368
1407
  return { eye:[ex,ey,ez], center:[ex+fx/fl,ey+fy/fl,ez+fz/fl], up:[0,1,0],
1369
- fov:null, halfHeight:null };
1408
+ fov:null, halfHeight:null,
1409
+ eyeTanIn:null, eyeTanOut:null, centerTanIn:null, centerTanOut:null };
1370
1410
  }
1371
1411
 
1372
1412
  // { eye, center?, up? } — explicit lookat (eye is a vec3, not a mat4)
1373
1413
  const eye = _parseVec3(spec.eye);
1374
1414
  if (!eye) return null;
1375
1415
  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;
1416
+ const upRaw = spec.up ? _parseVec3(spec.up) : null;
1417
+ const up = upRaw || [0,1,0];
1418
+ const ul = Math.sqrt(up[0]*up[0]+up[1]*up[1]+up[2]*up[2]) || 1;
1379
1419
  return {
1380
1420
  eye, center,
1381
1421
  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,
1422
+ fov: typeof spec.fov === 'number' ? spec.fov : null,
1423
+ halfHeight: typeof spec.halfHeight === 'number' ? spec.halfHeight : null,
1424
+ eyeTanIn: _parseVec3(spec.eyeTanIn) || null,
1425
+ eyeTanOut: _parseVec3(spec.eyeTanOut) || null,
1426
+ centerTanIn: _parseVec3(spec.centerTanIn) || null,
1427
+ centerTanOut:_parseVec3(spec.centerTanOut)|| null,
1384
1428
  };
1385
1429
  }
1386
1430
 
@@ -1408,7 +1452,7 @@ class Track {
1408
1452
  /** Loop at boundaries. @type {boolean} */
1409
1453
  this.loop = false;
1410
1454
  /** Ping-pong bounce (takes precedence over loop). @type {boolean} */
1411
- this.pingPong = false;
1455
+ this.bounce = false;
1412
1456
  /** Frames per segment (≥1). @type {number} */
1413
1457
  this.duration = 30;
1414
1458
  /** Current segment index. @type {number} */
@@ -1418,6 +1462,10 @@ class Track {
1418
1462
 
1419
1463
  // Internal rate — never directly starts/stops playback
1420
1464
  this._rate = 1;
1465
+ // Internal bounce direction: +1 forward, -1 backward.
1466
+ // Flipped by tick() at boundaries. Never exposed publicly.
1467
+ // rate always holds the user-set value — only _dir changes.
1468
+ this._dir = 1;
1421
1469
 
1422
1470
  // User-space hooks
1423
1471
  /** @type {Function|null} */ this.onPlay = null;
@@ -1427,6 +1475,10 @@ class Track {
1427
1475
  // Lib-space hooks (set by host layer, e.g. p5 bridge)
1428
1476
  /** @type {Function|null} */ this._onActivate = null;
1429
1477
  /** @type {Function|null} */ this._onDeactivate = null;
1478
+ // Lib-space event mirrors — set by UI layer (trackUI), never touched by user code
1479
+ /** @type {Function|null} */ this._onPlay = null;
1480
+ /** @type {Function|null} */ this._onEnd = null;
1481
+ /** @type {Function|null} */ this._onStop = null;
1430
1482
  }
1431
1483
 
1432
1484
  /** Playback rate. Assigning never starts/stops playback. @type {number} */
@@ -1439,7 +1491,7 @@ class Track {
1439
1491
  /**
1440
1492
  * Start or update playback.
1441
1493
  * @param {number|Object} [rateOrOpts] Numeric rate or options object:
1442
- * { rate, duration, loop, pingPong, onPlay, onEnd, onStop }
1494
+ * { rate, duration, loop, bounce, onPlay, onEnd, onStop }
1443
1495
  * @returns {Track} this
1444
1496
  */
1445
1497
  play(rateOrOpts) {
@@ -1455,9 +1507,9 @@ class Track {
1455
1507
  this._rate = rateOrOpts;
1456
1508
  } else if (rateOrOpts && typeof rateOrOpts === 'object') {
1457
1509
  const o = rateOrOpts;
1458
- if (_isNum(o.duration)) this.duration = Math.max(1, o.duration | 0);
1459
- if ('loop' in o) this.loop = !!o.loop;
1460
- if ('pingPong' in o) this.pingPong = !!o.pingPong;
1510
+ if (_isNum(o.duration)) this.duration = Math.max(1, o.duration | 0);
1511
+ if ('loop' in o) { this.loop = !!o.loop; if (!this.loop) this.bounce = false; }
1512
+ if ('bounce' in o) { this.bounce = !!o.bounce; if (this.bounce) this.loop = true; }
1461
1513
  if (typeof o.onPlay === 'function') this.onPlay = o.onPlay;
1462
1514
  if (typeof o.onEnd === 'function') this.onEnd = o.onEnd;
1463
1515
  if (typeof o.onStop === 'function') this.onStop = o.onStop;
@@ -1474,6 +1526,7 @@ class Track {
1474
1526
  this.playing = true;
1475
1527
  if (!wasPlaying) {
1476
1528
  if (typeof this.onPlay === 'function') { try { this.onPlay(this); } catch (_) {} }
1529
+ this._onPlay?.();
1477
1530
  this._onActivate?.();
1478
1531
  }
1479
1532
  return this;
@@ -1489,8 +1542,9 @@ class Track {
1489
1542
  this.playing = false;
1490
1543
  if (wasPlaying) {
1491
1544
  if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1545
+ this._onStop?.();
1492
1546
  this._onDeactivate?.();
1493
- if (rewind && this.keyframes.length > 1) this.seek(this._rate < 0 ? 1 : 0);
1547
+ if (rewind && this.keyframes.length > 1) this.seek(this._rate * this._dir < 0 ? 1 : 0);
1494
1548
  }
1495
1549
  return this;
1496
1550
  }
@@ -1504,10 +1558,11 @@ class Track {
1504
1558
  this.playing = false;
1505
1559
  if (wasPlaying) {
1506
1560
  if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1561
+ this._onStop?.();
1507
1562
  this._onDeactivate?.();
1508
1563
  }
1509
1564
  this.keyframes.length = 0;
1510
- this.seg = 0; this.f = 0;
1565
+ this.seg = 0; this.f = 0; this._dir = 1;
1511
1566
  return this;
1512
1567
  }
1513
1568
 
@@ -1569,7 +1624,7 @@ class Track {
1569
1624
  f: this.f,
1570
1625
  playing: this.playing,
1571
1626
  loop: this.loop,
1572
- pingPong: this.pingPong,
1627
+ bounce: this.bounce,
1573
1628
  rate: this._rate,
1574
1629
  duration: this.duration,
1575
1630
  time: this.segments > 0 ? this.time() : 0
@@ -1592,15 +1647,15 @@ class Track {
1592
1647
  const dur = Math.max(1, this.duration | 0);
1593
1648
  const total = nSeg * dur;
1594
1649
  const s = _clampS(this.seg * dur + this.f, 0, total);
1595
- const next = s + this._rate;
1650
+ const next = s + this._rate * this._dir;
1596
1651
 
1597
- if (this.pingPong) {
1652
+ if (this.bounce) {
1598
1653
  let pos = next, flips = 0;
1599
1654
  while (pos < 0 || pos > total) {
1600
1655
  if (pos < 0) { pos = -pos; flips++; }
1601
1656
  else { pos = 2 * total - pos; flips++; }
1602
1657
  }
1603
- if (flips & 1) this._rate = -this._rate;
1658
+ if (flips & 1) this._dir = -this._dir;
1604
1659
  this._setCursorFromScalar(pos);
1605
1660
  return true;
1606
1661
  }
@@ -1614,6 +1669,7 @@ class Track {
1614
1669
  this._setCursorFromScalar(0);
1615
1670
  this.playing = false;
1616
1671
  if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1672
+ this._onEnd?.();
1617
1673
  this._onDeactivate?.();
1618
1674
  return false;
1619
1675
  }
@@ -1621,6 +1677,7 @@ class Track {
1621
1677
  this._setCursorFromScalar(total);
1622
1678
  this.playing = false;
1623
1679
  if (typeof this.onEnd === 'function') { try { this.onEnd(this); } catch (_) {} }
1680
+ this._onEnd?.();
1624
1681
  this._onDeactivate?.();
1625
1682
  return false;
1626
1683
  }
@@ -1647,12 +1704,18 @@ class Track {
1647
1704
  /**
1648
1705
  * Renderer-agnostic TRS keyframe track.
1649
1706
  *
1650
- * Keyframe shape: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z] }
1707
+ * Keyframe shape: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z],
1708
+ * tanIn?:[x,y,z], tanOut?:[x,y,z] }
1709
+ *
1710
+ * tanIn — incoming position tangent at this keyframe (Hermite mode).
1711
+ * tanOut — outgoing position tangent at this keyframe (Hermite mode).
1712
+ * When only one is supplied, the other mirrors it.
1713
+ * When neither is supplied, centripetal Catmull-Rom tangents are auto-computed.
1651
1714
  *
1652
1715
  * add() accepts individual specs or a bulk array of specs:
1653
1716
  *
1654
1717
  * { mMatrix } — full TRS from model matrix
1655
- * { pos?, rot?, scl? } — direct TRS; all fields optional
1718
+ * { pos?, rot?, scl?, tanIn?, tanOut? } — direct TRS; all fields optional
1656
1719
  * { pos?, rot: [x,y,z,w] } — explicit quaternion
1657
1720
  * { pos?, rot: { axis, angle } } — axis-angle
1658
1721
  * { pos?, rot: { dir, up? } } — look direction
@@ -1665,16 +1728,15 @@ class Track {
1665
1728
  * Missing fields default to: pos → [0,0,0], rot → [0,0,0,1], scl → [1,1,1].
1666
1729
  *
1667
1730
  * eval() writes { pos, rot, scl }:
1668
- * pos — Catmull-Rom (posInterp='catmullrom') or lerp
1669
- * rot — slerp (rotInterp='slerp') or nlerp
1731
+ * pos — Hermite (tanIn/tanOut per keyframe; auto-CR when absent) or linear or step
1732
+ * rot — slerp (rotInterp='slerp') or nlerp or step
1670
1733
  * scl — lerp
1671
1734
  *
1672
1735
  * @example
1673
1736
  * 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 })
1737
+ * track.add({ pos:[0,0,0] })
1738
+ * track.add({ pos:[100,0,0], tanOut:[0,50,0] }) // leave heading +Y
1739
+ * track.add({ pos:[200,0,0] })
1678
1740
  * track.play({ loop: true })
1679
1741
  * // per frame:
1680
1742
  * track.tick()
@@ -1686,14 +1748,19 @@ class PoseTrack extends Track {
1686
1748
  super();
1687
1749
  /**
1688
1750
  * Position interpolation mode.
1689
- * @type {'catmullrom'|'linear'}
1751
+ * - 'hermite' — cubic Hermite; uses tanIn/tanOut per keyframe when present,
1752
+ * auto-computes centripetal Catmull-Rom tangents when absent (default)
1753
+ * - 'linear' — lerp
1754
+ * - 'step' — snap to k0 value; useful for discrete state changes
1755
+ * @type {'hermite'|'linear'|'step'}
1690
1756
  */
1691
- this.posInterp = 'catmullrom';
1757
+ this.posInterp = 'hermite';
1692
1758
  /**
1693
1759
  * Rotation interpolation mode.
1694
1760
  * - 'slerp' — constant angular velocity (default)
1695
1761
  * - 'nlerp' — normalised lerp; cheaper, slightly non-constant speed
1696
- * @type {'slerp'|'nlerp'}
1762
+ * - 'step' — snap to k0 quaternion; useful for discrete state changes
1763
+ * @type {'slerp'|'nlerp'|'step'}
1697
1764
  */
1698
1765
  this.rotInterp = 'slerp';
1699
1766
  // Scratch for toMatrix() — avoids hot-path allocations
@@ -1761,17 +1828,29 @@ class PoseTrack extends Track {
1761
1828
  const k0 = this.keyframes[seg];
1762
1829
  const k1 = this.keyframes[seg + 1];
1763
1830
 
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 {
1831
+ // pos — Hermite (auto-CR tangents when none stored), linear, or step
1832
+ if (this.posInterp === 'step') {
1833
+ out.pos[0]=k0.pos[0]; out.pos[1]=k0.pos[1]; out.pos[2]=k0.pos[2];
1834
+ } else if (this.posInterp === 'linear') {
1770
1835
  lerpVec3(out.pos, k0.pos, k1.pos, t);
1836
+ } else {
1837
+ const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1838
+ const p3 = seg + 2 < n ? this.keyframes[seg + 2].pos : k1.pos;
1839
+ // tanOut on k0: use stored, else symmetric from tanIn, else auto-CR
1840
+ const m0 = k0.tanOut != null ? k0.tanOut
1841
+ : k0.tanIn != null ? k0.tanIn
1842
+ : _crTanOut(_m0, p0, k0.pos, k1.pos, p3);
1843
+ // tanIn on k1: use stored, else symmetric from tanOut, else auto-CR
1844
+ const m1 = k1.tanIn != null ? k1.tanIn
1845
+ : k1.tanOut != null ? k1.tanOut
1846
+ : _crTanIn(_m1, p0, k0.pos, k1.pos, p3);
1847
+ hermiteVec3(out.pos, k0.pos, m0, k1.pos, m1, t);
1771
1848
  }
1772
1849
 
1773
- // rot — slerp or nlerp
1774
- if (this.rotInterp === 'nlerp') {
1850
+ // rot — step, slerp, or nlerp
1851
+ if (this.rotInterp === 'step') {
1852
+ out.rot[0]=k0.rot[0]; out.rot[1]=k0.rot[1]; out.rot[2]=k0.rot[2]; out.rot[3]=k0.rot[3];
1853
+ } else if (this.rotInterp === 'nlerp') {
1775
1854
  qNlerp(out.rot, k0.rot, k1.rot, t);
1776
1855
  } else {
1777
1856
  qSlerp(out.rot, k0.rot, k1.rot, t);
@@ -1801,23 +1880,31 @@ class PoseTrack extends Track {
1801
1880
  /**
1802
1881
  * Lookat camera keyframe track.
1803
1882
  *
1804
- * Keyframe shape: { eye:[x,y,z], center:[x,y,z], up:[x,y,z], fov?:number, halfHeight?:number }
1883
+ * Keyframe shape: { eye:[x,y,z], center:[x,y,z], up:[x,y,z],
1884
+ * fov?:number, halfHeight?:number,
1885
+ * eyeTanIn?:[x,y,z], eyeTanOut?:[x,y,z],
1886
+ * centerTanIn?:[x,y,z], centerTanOut?:[x,y,z] }
1805
1887
  *
1806
1888
  * fov — vertical fov (radians) for perspective cameras; null for ortho.
1807
1889
  * halfHeight — world-unit half-height of ortho frustum; null for perspective.
1808
1890
  * Both are optional and nullable. eval() lerps each only when both adjacent
1809
1891
  * keyframes carry a non-null value for that field.
1810
1892
  *
1893
+ * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite
1894
+ * interpolation of the eye and center paths respectively.
1895
+ * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1896
+ *
1811
1897
  * Each field is independently interpolated — eye and center along their
1812
1898
  * own paths, up nlerped on the unit sphere. This correctly handles cameras
1813
1899
  * that always look at a fixed target (center stays at origin throughout)
1814
1900
  * as well as free-fly paths where center moves independently.
1815
- *
1901
+ *
1816
1902
  * Missing fields default to: center → [0,0,0], up → [0,1,0].
1817
1903
  *
1818
1904
  * add() accepts individual specs or a bulk array of specs:
1819
1905
  *
1820
- * { eye, center?, up?, fov?, halfHeight? }
1906
+ * { eye, center?, up?, fov?, halfHeight?,
1907
+ * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1821
1908
  * explicit lookat; center defaults to [0,0,0], up to [0,1,0].
1822
1909
  * fov and halfHeight are mutually exclusive nullable scalars.
1823
1910
  * { vMatrix: mat4 } view matrix (world→eye); eye reconstructed via -R^T·t
@@ -1831,8 +1918,8 @@ class PoseTrack extends Track {
1831
1918
  * Use capturePose() (p5.tree bridge) when the real up hint is needed.
1832
1919
  *
1833
1920
  * eval() writes { eye, center, up, fov, halfHeight }:
1834
- * eye — Catmull-Rom (eyeInterp='catmullrom') or lerp
1835
- * center — Catmull-Rom (centerInterp='catmullrom') or lerp
1921
+ * eye — Hermite (auto-CR when no tangents stored) or linear or step
1922
+ * center — Hermite (auto-CR when no tangents stored) or linear or step
1836
1923
  * up — nlerp (normalize-after-lerp on unit sphere)
1837
1924
  * fov — lerp when both keyframes carry non-null fov; else null
1838
1925
  * halfHeight — lerp when both keyframes carry non-null halfHeight; else null
@@ -1857,14 +1944,20 @@ class CameraTrack extends Track {
1857
1944
  super();
1858
1945
  /**
1859
1946
  * Eye position interpolation mode.
1860
- * @type {'catmullrom'|'linear'}
1947
+ * - 'hermite' — cubic Hermite; auto-CR tangents when none stored (default)
1948
+ * - 'linear' — lerp
1949
+ * - 'step' — snap to k0 eye
1950
+ * @type {'hermite'|'linear'|'step'}
1861
1951
  */
1862
- this.eyeInterp = 'catmullrom';
1952
+ this.eyeInterp = 'hermite';
1863
1953
  /**
1864
1954
  * 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'}
1955
+ * 'linear' suits fixed or predictably moving targets (default).
1956
+ * 'hermite' gives smoother paths when center is also flying freely.
1957
+ * - 'hermite' — cubic Hermite; auto-CR tangents when none stored
1958
+ * - 'linear' — lerp
1959
+ * - 'step' — snap to k0 center
1960
+ * @type {'hermite'|'linear'|'step'}
1868
1961
  */
1869
1962
  this.centerInterp = 'linear';
1870
1963
  // Scratch for toCamera() — avoids hot-path allocations
@@ -1937,22 +2030,38 @@ class CameraTrack extends Track {
1937
2030
  const k0 = this.keyframes[seg];
1938
2031
  const k1 = this.keyframes[seg + 1];
1939
2032
 
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 {
2033
+ // eye — Hermite (auto-CR tangents when none stored), linear, or step
2034
+ if (this.eyeInterp === 'step') {
2035
+ out.eye[0]=k0.eye[0]; out.eye[1]=k0.eye[1]; out.eye[2]=k0.eye[2];
2036
+ } else if (this.eyeInterp === 'linear') {
1946
2037
  lerpVec3(out.eye, k0.eye, k1.eye, t);
2038
+ } else {
2039
+ const p0 = seg > 0 ? this.keyframes[seg - 1].eye : k0.eye;
2040
+ const p3 = seg + 2 < n ? this.keyframes[seg + 2].eye : k1.eye;
2041
+ const m0 = k0.eyeTanOut != null ? k0.eyeTanOut
2042
+ : k0.eyeTanIn != null ? k0.eyeTanIn
2043
+ : _crTanOut(_m0, p0, k0.eye, k1.eye, p3);
2044
+ const m1 = k1.eyeTanIn != null ? k1.eyeTanIn
2045
+ : k1.eyeTanOut != null ? k1.eyeTanOut
2046
+ : _crTanIn(_m1, p0, k0.eye, k1.eye, p3);
2047
+ hermiteVec3(out.eye, k0.eye, m0, k1.eye, m1, t);
1947
2048
  }
1948
2049
 
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 {
2050
+ // center — Hermite, linear, or step (independent lookat target)
2051
+ if (this.centerInterp === 'step') {
2052
+ out.center[0]=k0.center[0]; out.center[1]=k0.center[1]; out.center[2]=k0.center[2];
2053
+ } else if (this.centerInterp === 'linear') {
1955
2054
  lerpVec3(out.center, k0.center, k1.center, t);
2055
+ } else {
2056
+ const c0 = seg > 0 ? this.keyframes[seg - 1].center : k0.center;
2057
+ const c3 = seg + 2 < n ? this.keyframes[seg + 2].center : k1.center;
2058
+ const m0 = k0.centerTanOut != null ? k0.centerTanOut
2059
+ : k0.centerTanIn != null ? k0.centerTanIn
2060
+ : _crTanOut(_m0, c0, k0.center, k1.center, c3);
2061
+ const m1 = k1.centerTanIn != null ? k1.centerTanIn
2062
+ : k1.centerTanOut != null ? k1.centerTanOut
2063
+ : _crTanIn(_m1, c0, k0.center, k1.center, c3);
2064
+ hermiteVec3(out.center, k0.center, m0, k1.center, m1, t);
1956
2065
  }
1957
2066
 
1958
2067
  // up — nlerp (normalize after lerp; correct for typical near-upright cameras)
@@ -2130,5 +2239,5 @@ function boxVisibility(planes, x0, y0, z0, x1, y1, z1) {
2130
2239
  return allIn ? VISIBLE : SEMIVISIBLE;
2131
2240
  }
2132
2241
 
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 };
2242
+ export { CameraTrack, EYE, INVISIBLE, MATRIX, MODEL, NDC, ORIGIN, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, PoseTrack, SCREEN, SEMIVISIBLE, VISIBLE, WEBGL, WEBGPU, WORLD, _i, _j, _k, applyPickMatrix, boxVisibility, distanceToPlane, frustumPlanes, hermiteVec3, i, j, k, lerpVec3, mapDirection, mapLocation, mat3Direction, mat3NormalFromMat4, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulPoint, mat4PV, mat4ToTransform, mat4Transpose, pixelRatio, pointVisibility, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qMul, qNegate, qNlerp, qNormalize, qSet, qSlerp, qToMat4, quatToAxisAngle, sphereVisibility, transformToMat4 };
2134
2243
  //# sourceMappingURL=index.js.map