@nakednous/tree 0.0.20 → 0.0.21

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
@@ -1153,15 +1153,15 @@ function mat4ToRotation(out4, m) {
1153
1153
  * @module tree/track
1154
1154
  * @license AGPL-3.0-only
1155
1155
  *
1156
- * Quaternion algebra is provided by quat.js this module imports and uses
1157
- * it but does not define it. Spline helpers (hermiteVec3, lerpVec3) and
1158
- * TRS↔mat4 conversions (transformToMat4, mat4ToTransform) remain here
1159
- * because they are tightly coupled to the PoseTrack keyframe shape.
1156
+ * Quaternion algebra is provided by quat.js; projection matrix construction
1157
+ * by form.js. Spline helpers (hermiteVec3, lerpVec3) and TRS↔mat4 conversions
1158
+ * (transformToMat4, mat4ToTransform) live here because they are tightly
1159
+ * coupled to the PoseTrack keyframe shape.
1160
1160
  *
1161
1161
  * Zero dependencies on p5, DOM, WebGL, or WebGPU.
1162
1162
  *
1163
- * ── Exports ──────────────────────────────────────────────────────────────────
1164
- * Quaternion helpers (re-exported from quat.js)
1163
+ * ── Exports ──────────────────────────────────────────────────────────────
1164
+ * Quaternion helpers (re-exported from quat.js)
1165
1165
  * qSet qCopy qDot qNormalize qNegate qMul qSlerp qNlerp
1166
1166
  * qFromAxisAngle qFromLookDir qFromRotMat3x3 qFromMat4 qToMat4
1167
1167
  * qToAxisAngle
@@ -1171,41 +1171,64 @@ function mat4ToRotation(out4, m) {
1171
1171
  * transformToMat4 mat4ToTransform
1172
1172
  * Tracks
1173
1173
  * PoseTrack — { pos, rot, scl } TRS keyframes
1174
- * CameraTrack — { eye, center, up } lookat keyframes
1174
+ * CameraTrack — { eye, center, up, fov?, halfHeight? } lookat keyframes
1175
1175
  *
1176
- * ── Class hierarchy ───────────────────────────────────────────────────────────
1176
+ * ── Public path access (all zero-alloc, no cursor side effects) ──────────
1177
+ * PoseTrack
1178
+ * samplePos (out) | (out, seg, t) vec3
1179
+ * mat4Model (out) | (out, seg, t) mat4 — TRS model
1180
+ * tangents (outIn, outOut, index) vec3 × 2 at keyframe
1181
+ * eval (out?) TRS object at cursor
1182
+ *
1183
+ * CameraTrack
1184
+ * sampleEye (out) | (out, seg, t) vec3
1185
+ * sampleCenter (out) | (out, seg, t) vec3
1186
+ * mat4Eye (out) | (out, seg, t) mat4 — lookat frame
1187
+ * eyeTangents (outIn, outOut, index) vec3 × 2 at keyframe
1188
+ * centerTangents (outIn, outOut, index) vec3 × 2 at keyframe
1189
+ * eval (out?) { eye, center, up,
1190
+ * fov, halfHeight }
1191
+ *
1192
+ * Two arities for the continuous family:
1193
+ * (out) cursor form — reads track.seg / track.f. Useful when the
1194
+ * track's own transport is driving the animation.
1195
+ * (out, seg, t) explicit form — continuous (seg, t) coordinate, no cursor
1196
+ * side effects. seg ∈ [0, segments−1] integer, t ∈ [0, 1]
1197
+ * local to that segment. As a convenience, seg === segments
1198
+ * is accepted and rewritten to (segments−1, 1) — so the
1199
+ * idiom sampleX(out, i, 0) uniformly addresses keyframe i
1200
+ * for every i ∈ [0, keyframes.length−1].
1201
+ *
1202
+ * All honour the track's interpolation mode (hermite / linear / step) and
1203
+ * the same stored-tangent → centripetal-CR fallback chain that eval() uses.
1204
+ *
1205
+ * `tangents` / `eyeTangents` / `centerTangents` are keyframe-indexed — a
1206
+ * keyframe's incoming tangent belongs to the previous segment, outgoing to
1207
+ * the next. At boundary keyframes the missing side mirrors the present one
1208
+ * so drawing arrows at endpoints always yields a visible vector.
1209
+ *
1210
+ * Projection matrices are deliberately not exposed as a track method. Each
1211
+ * CameraTrack keyframe stores `fov` (perspective) or `halfHeight` (ortho)
1212
+ * directly on track.keyframes[i] — callers wanting a projection build one
1213
+ * with the free mat4Persp / mat4Ortho constructors. Animated fov in
1214
+ * sketches flows through the bridge's camera-binding via eval().fov and
1215
+ * eval().halfHeight (the bridge calls cam.perspective() / cam.ortho() each
1216
+ * frame).
1217
+ *
1218
+ * ── Class hierarchy ──────────────────────────────────────────────────────
1177
1219
  * Track (unexported, never instantiated directly)
1178
1220
  * └── PoseTrack (exported)
1179
1221
  * └── CameraTrack (exported)
1180
1222
  *
1181
- * Track holds all transport machinery: cursor, play/stop/seek/tick,
1182
- * hooks, rate semantics. Subclasses add only keyframe storage and
1183
- * add() / eval() for their respective data shape.
1184
- *
1185
- * ── Path samplers (public, zero-alloc, no cursor side effects) ──────────────
1186
- * PoseTrack
1187
- * samplePos(out, seg, t) writes interpolated pos at (seg, t∈[0,1])
1188
- * sampleTangents(outIn, outOut, i) effective in/out tangents at keyframe i
1189
- * CameraTrack
1190
- * sampleEye(out, seg, t)
1191
- * sampleCenter(out, seg, t)
1192
- * sampleEyeTangents(outIn, outOut, i)
1193
- * sampleCenterTangents(outIn, outOut, i)
1194
- *
1195
- * Samplers honour the corresponding interpolation mode (hermite/linear/step)
1196
- * and the stored-tangent / auto-CR fallback chain used by eval().
1197
- *
1198
- * ── Hook architecture ─────────────────────────────────────────────────────────
1199
- * Lib-space hooks (underscore prefix — reserved for host layer / UI layer):
1200
- * _onActivate / _onDeactivate — fire on playing transitions false→true / true→false.
1201
- * _onPlay / _onEnd / _onStop — mirror the user-space hooks; used by the UI layer
1202
- * so it can sync without chaining the public slots.
1223
+ * ── Hook architecture ────────────────────────────────────────────────────
1224
+ * Lib-space hooks (underscore prefix host layer / UI layer):
1225
+ * _onActivate / _onDeactivate — fire on playing false→true / true→false.
1226
+ * _onPlay / _onEnd / _onStop — mirror the user-space hooks.
1203
1227
  *
1204
- * User-space hooks (public):
1228
+ * User-space hooks:
1205
1229
  * onPlay : fires in play() on false→true transition.
1206
1230
  * onEnd : fires in tick() at natural boundary (once mode only).
1207
- * onStop : fires in stop() / reset() — explicit deactivation.
1208
- * onEnd and onStop are mutually exclusive per event.
1231
+ * onStop : fires in stop() / reset().
1209
1232
  *
1210
1233
  * Firing order:
1211
1234
  * play() → onPlay → _onPlay → _onActivate
@@ -1213,18 +1236,14 @@ function mat4ToRotation(out4, m) {
1213
1236
  * stop() → onStop → _onStop → _onDeactivate
1214
1237
  * reset() → onStop → _onStop → _onDeactivate
1215
1238
  *
1216
- * ── Loop modes ────────────────────────────────────────────────────────────────
1239
+ * ── Loop modes ───────────────────────────────────────────────────────────
1217
1240
  * loop:false, bounce:false — play once, stop at end (fires onEnd)
1218
1241
  * loop:true, bounce:false — repeat, wrap back to start
1219
1242
  * loop:true, bounce:true — bounce forever at boundaries
1220
1243
  * loop:false, bounce:true — bounce once: flip at far boundary, stop at origin
1221
1244
  *
1222
- * bounce and loop are fully independent flags — no exclusivity enforced.
1223
- *
1224
- * ── Playback semantics (rate + _dir) ─────────────────────────────────────────
1225
- * rate > 0 forward
1226
- * rate < 0 backward
1227
- * rate === 0 frozen: tick() no-op; playing unchanged
1245
+ * ── Playback semantics (rate + _dir) ─────────────────────────────────────
1246
+ * rate > 0 forward rate < 0 backward rate === 0 frozen
1228
1247
  *
1229
1248
  * play() is the sole setter of playing = true.
1230
1249
  * stop() is the sole setter of playing = false.
@@ -1232,11 +1251,9 @@ function mat4ToRotation(out4, m) {
1232
1251
  *
1233
1252
  * _dir (internal, ±1) tracks the current bounce travel direction.
1234
1253
  * tick() advances by rate * _dir and flips _dir at boundaries.
1235
- * rate always holds the user-set value it is never mutated by bounce.
1236
- * _dir is reset to 1 only in reset() (keyframes cleared) — stop/replay
1237
- * preserves the current travel direction.
1254
+ * _dir is reset to 1 only in reset().
1238
1255
  *
1239
- * ── One-keyframe behaviour ────────────────────────────────────────────────────
1256
+ * ── One-keyframe behaviour ───────────────────────────────────────────────
1240
1257
  * play() with exactly one keyframe snaps eval() to that keyframe without
1241
1258
  * setting playing = true and without firing hooks.
1242
1259
  */
@@ -1253,12 +1270,11 @@ function _dist3(a, b) {
1253
1270
 
1254
1271
  /**
1255
1272
  * Cubic Hermite interpolation between p0 and p1 with explicit tangents.
1256
- * Catmull-Rom is a special case where m0/m1 are auto-computed from neighbors.
1257
1273
  * @param {number[]} out 3-element result.
1258
1274
  * @param {number[]} p0 Segment start.
1259
- * @param {number[]} m0 Outgoing tangent at p0 (world-space, dp/dt scaled to segment).
1275
+ * @param {number[]} m0 Outgoing tangent at p0.
1260
1276
  * @param {number[]} p1 Segment end.
1261
- * @param {number[]} m1 Incoming tangent at p1 (world-space, dp/dt scaled to segment).
1277
+ * @param {number[]} m1 Incoming tangent at p1.
1262
1278
  * @param {number} t Blend [0, 1].
1263
1279
  * @returns {number[]} out
1264
1280
  */
@@ -1271,7 +1287,7 @@ const hermiteVec3 = (out, p0, m0, p1, m1, t) => {
1271
1287
  return out;
1272
1288
  };
1273
1289
 
1274
- // Centripetal CR outgoing tangent at p1 for segment p1→p2, scaled by dt1.
1290
+ // Centripetal CR outgoing tangent at p1 for segment p1→p2.
1275
1291
  // Signature: (out, p0, p1, p2). Returns tangent AT p1 (the middle point).
1276
1292
  const _crTanOut = (out, p0, p1, p2) => {
1277
1293
  const dt0=Math.pow(_dist3(p0,p1),0.5)||1, dt1=Math.pow(_dist3(p1,p2),0.5)||1;
@@ -1279,7 +1295,7 @@ const _crTanOut = (out, p0, p1, p2) => {
1279
1295
  return out;
1280
1296
  };
1281
1297
 
1282
- // Centripetal CR incoming tangent at p2 for segment p1→p2, scaled by dt1.
1298
+ // Centripetal CR incoming tangent at p2 for segment p1→p2.
1283
1299
  // Signature: (out, p1, p2, p3). Returns tangent AT p2 (the middle point).
1284
1300
  const _crTanIn = (out, p1, p2, p3) => {
1285
1301
  const dt1=Math.pow(_dist3(p1,p2),0.5)||1, dt2=Math.pow(_dist3(p2,p3),0.5)||1;
@@ -1287,16 +1303,13 @@ const _crTanIn = (out, p1, p2, p3) => {
1287
1303
  return out;
1288
1304
  };
1289
1305
 
1290
- // Module-level scratch — shared across all track instances (non-reentrant hot path).
1306
+ // Module-level scratch — shared across track instances (non-reentrant hot path).
1291
1307
  const _m0=[0,0,0], _m1=[0,0,0];
1308
+ const _trsScratch = { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] };
1309
+ const _eyeScratch = { eye:[0,0,0], center:[0,0,0], up:[0,1,0] };
1292
1310
 
1293
1311
  /**
1294
1312
  * Linear interpolation between two vec3s.
1295
- * @param {number[]} out
1296
- * @param {number[]} a
1297
- * @param {number[]} b
1298
- * @param {number} t Blend [0, 1].
1299
- * @returns {number[]} out
1300
1313
  */
1301
1314
  const lerpVec3 = (out, a, b, t) => {
1302
1315
  out[0]=a[0]+t*(b[0]-a[0]);
@@ -1308,27 +1321,8 @@ const lerpVec3 = (out, a, b, t) => {
1308
1321
  // =========================================================================
1309
1322
  // S2b Path samplers — shared core
1310
1323
  // =========================================================================
1311
- //
1312
- // These helpers factor out the per-field interpolation from eval(), so that
1313
- // the samplers (samplePos / sampleEye / sampleCenter / sampleTangents / ...)
1314
- // can write into caller buffers without touching the cursor state.
1315
- //
1316
- // The field name and its associated tangent field names are passed as keys
1317
- // so the same core serves 'pos'/'tanIn'/'tanOut' for PoseTrack and
1318
- // 'eye'|'center' + matching tangent keys for CameraTrack.
1319
1324
 
1320
- /**
1321
- * Sample interpolated vec3 path at (seg, t) into out.
1322
- * @private
1323
- * @param {number[]} out
1324
- * @param {Array} kfs keyframe array
1325
- * @param {string} interp 'hermite' | 'linear' | 'step'
1326
- * @param {string} field keyframe property holding the vec3 path point
1327
- * @param {string} tanInName keyframe property for incoming tangent
1328
- * @param {string} tanOutName keyframe property for outgoing tangent
1329
- * @param {number} seg segment index
1330
- * @param {number} t local parameter in [0,1]
1331
- */
1325
+ /** @private — sample interpolated vec3 path at (seg, t) into out. */
1332
1326
  function _samplePathCore(out, kfs, interp, field, tanInName, tanOutName, seg, t) {
1333
1327
  const n = kfs.length;
1334
1328
  if (n === 0) { out[0]=0; out[1]=0; out[2]=0; return out; }
@@ -1338,7 +1332,9 @@ function _samplePathCore(out, kfs, interp, field, tanInName, tanOutName, seg, t)
1338
1332
  return out;
1339
1333
  }
1340
1334
  const nSeg = n - 1;
1341
- seg = _clampS(seg | 0, 0, nSeg - 1);
1335
+ seg = seg | 0;
1336
+ if (seg >= nSeg) { seg = nSeg - 1; t = 1; }
1337
+ else if (seg < 0) { seg = 0; t = 0; }
1342
1338
  t = _clamp01(t);
1343
1339
  const k0 = kfs[seg];
1344
1340
  const k1 = kfs[seg + 1];
@@ -1352,7 +1348,6 @@ function _samplePathCore(out, kfs, interp, field, tanInName, tanOutName, seg, t)
1352
1348
  return lerpVec3(out, k0[field], k1[field], t);
1353
1349
  }
1354
1350
 
1355
- // hermite (default)
1356
1351
  const p0 = seg > 0 ? kfs[seg - 1][field] : k0[field];
1357
1352
  const p3 = seg + 2 < n ? kfs[seg + 2][field] : k1[field];
1358
1353
  const m0 = k0[tanOutName] != null ? k0[tanOutName]
@@ -1364,19 +1359,7 @@ function _samplePathCore(out, kfs, interp, field, tanInName, tanOutName, seg, t)
1364
1359
  return hermiteVec3(out, k0[field], m0, k1[field], m1, t);
1365
1360
  }
1366
1361
 
1367
- /**
1368
- * Write the effective in/out tangents at keyframe i.
1369
- *
1370
- * Stored tanIn/tanOut take precedence, then each mirrors the other when only
1371
- * one is stored, else centripetal Catmull-Rom tangents are auto-computed from
1372
- * neighbours.
1373
- *
1374
- * At endpoints one side has no adjacent segment; that side mirrors the other
1375
- * side's tangent. Callers drawing arrows at endpoints therefore see a vector
1376
- * that matches the curve's derivative into / out of the curve's boundary.
1377
- *
1378
- * @private
1379
- */
1362
+ /** @private — write effective in/out tangents at keyframe i. */
1380
1363
  function _sampleTangentsCore(outIn, outOut, kfs, field, tanInName, tanOutName, i) {
1381
1364
  const n = kfs.length;
1382
1365
  if (n === 0) {
@@ -1388,7 +1371,6 @@ function _sampleTangentsCore(outIn, outOut, kfs, field, tanInName, tanOutName, i
1388
1371
  const hasTI = ki[tanInName] != null;
1389
1372
  const hasTO = ki[tanOutName] != null;
1390
1373
 
1391
- // ── outgoing tangent at keyframe i (for segment i → i+1) ──────────────
1392
1374
  if (hasTO) {
1393
1375
  outOut[0]=ki[tanOutName][0]; outOut[1]=ki[tanOutName][1]; outOut[2]=ki[tanOutName][2];
1394
1376
  } else if (hasTI) {
@@ -1398,10 +1380,9 @@ function _sampleTangentsCore(outIn, outOut, kfs, field, tanInName, tanOutName, i
1398
1380
  const p0 = i > 0 ? kfs[i - 1][field] : ki[field];
1399
1381
  _crTanOut(outOut, p0, ki[field], k1[field]);
1400
1382
  } else {
1401
- outOut[0]=0; outOut[1]=0; outOut[2]=0; // filled below by mirror
1383
+ outOut[0]=0; outOut[1]=0; outOut[2]=0;
1402
1384
  }
1403
1385
 
1404
- // ── incoming tangent at keyframe i (for segment i-1 → i) ──────────────
1405
1386
  if (hasTI) {
1406
1387
  outIn[0]=ki[tanInName][0]; outIn[1]=ki[tanInName][1]; outIn[2]=ki[tanInName][2];
1407
1388
  } else if (hasTO) {
@@ -1414,7 +1395,6 @@ function _sampleTangentsCore(outIn, outOut, kfs, field, tanInName, tanOutName, i
1414
1395
  outIn[0]=outOut[0]; outIn[1]=outOut[1]; outIn[2]=outOut[2];
1415
1396
  }
1416
1397
 
1417
- // Boundary mirror the other way: last keyframe with no stored tangents.
1418
1398
  if (i === n - 1 && !hasTO && !hasTI) {
1419
1399
  outOut[0]=outIn[0]; outOut[1]=outIn[1]; outOut[2]=outIn[2];
1420
1400
  }
@@ -1426,9 +1406,6 @@ function _sampleTangentsCore(outIn, outOut, kfs, field, tanInName, tanOutName, i
1426
1406
 
1427
1407
  /**
1428
1408
  * Write a TRS transform into a column-major mat4.
1429
- * @param {Float32Array|number[]} out 16-element column-major mat4.
1430
- * @param {{ pos:number[], rot:number[], scl:number[] }} xform
1431
- * @returns {Float32Array|number[]} out
1432
1409
  */
1433
1410
  const transformToMat4 = (out, xform) => {
1434
1411
  qToMat4(out, xform.rot);
@@ -1442,10 +1419,6 @@ const transformToMat4 = (out, xform) => {
1442
1419
 
1443
1420
  /**
1444
1421
  * Decompose a column-major mat4 into a TRS transform.
1445
- * Assumes no shear. Scale extracted from column lengths.
1446
- * @param {{ pos:number[], rot:number[], scl:number[] }} out
1447
- * @param {Float32Array|number[]} m Column-major mat4.
1448
- * @returns {{ pos:number[], rot:number[], scl:number[] }} out
1449
1422
  */
1450
1423
  const mat4ToTransform = (out, m) => {
1451
1424
  out.pos[0]=m[12]; out.pos[1]=m[13]; out.pos[2]=m[14];
@@ -1476,7 +1449,6 @@ function _parseVec3(v) {
1476
1449
  return null;
1477
1450
  }
1478
1451
 
1479
- // Euler: unit axis vectors and the six valid intrinsic orderings.
1480
1452
  const _EULER_AXES = { X:[1,0,0], Y:[0,1,0], Z:[0,0,1] };
1481
1453
  const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1482
1454
 
@@ -1484,14 +1456,13 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1484
1456
  * Parse any rotation representation into a unit quaternion [x,y,z,w].
1485
1457
  *
1486
1458
  * Accepted forms:
1487
- *
1488
1459
  * [x,y,z,w] — raw quaternion array
1489
- * { axis:[x,y,z], angle } — axis-angle
1460
+ * { axis:[x,y,z], angle } — axis-angle (angle in radians)
1490
1461
  * { dir:[x,y,z], up?:[x,y,z] } — forward direction (−Z) with optional up
1491
1462
  * { mat4Eye: mat4 } — rotation block of an eye matrix
1492
1463
  * { mat3: mat3 } — column-major 3×3 rotation matrix
1493
1464
  * { euler:[rx,ry,rz], order? } — intrinsic Euler (default order: YXZ)
1494
- * { from:[x,y,z], to:[x,y,z] } — shortest-arc rotation
1465
+ * { from:[x,y,z], to:[x,y,z] } — shortest-arc rotation between two vectors
1495
1466
  *
1496
1467
  * @param {*} v
1497
1468
  * @returns {number[]|null} [x,y,z,w] or null if unparseable.
@@ -1499,7 +1470,7 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1499
1470
  function _parseQuat(v) {
1500
1471
  if (!v) return null;
1501
1472
 
1502
- // Raw array [x,y,z,w]
1473
+ // [x,y,z,w]
1503
1474
  if (Array.isArray(v) && v.length === 4) return [v[0],v[1],v[2],v[3]];
1504
1475
  if (ArrayBuffer.isView(v) && v.length >= 4) return [v[0],v[1],v[2],v[3]];
1505
1476
 
@@ -1528,8 +1499,7 @@ function _parseQuat(v) {
1528
1499
 
1529
1500
  // { mat3 }
1530
1501
  if (v.mat3 != null) {
1531
- const m = (ArrayBuffer.isView(v.mat3) || Array.isArray(v.mat3))
1532
- ? v.mat3 : null;
1502
+ const m = (ArrayBuffer.isView(v.mat3) || Array.isArray(v.mat3)) ? v.mat3 : null;
1533
1503
  if (!m || m.length < 9) return null;
1534
1504
  return qFromRotMat3x3([0,0,0,1], m[0],m[3],m[6], m[1],m[4],m[7], m[2],m[5],m[8]);
1535
1505
  }
@@ -1539,8 +1509,7 @@ function _parseQuat(v) {
1539
1509
  const e = v.euler;
1540
1510
  if (!Array.isArray(e) || e.length < 3) return null;
1541
1511
  const order = (v.order && _EULER_ORDERS.has(v.order)) ? v.order : 'YXZ';
1542
- const q = [0,0,0,1];
1543
- const s = [0,0,0,1];
1512
+ const q = [0,0,0,1], s = [0,0,0,1];
1544
1513
  for (let i = 0; i < 3; i++) {
1545
1514
  const ax = _EULER_AXES[order[i]];
1546
1515
  qMul(q, q, qFromAxisAngle(s, ax[0],ax[1],ax[2], e[i]));
@@ -1575,22 +1544,22 @@ function _parseQuat(v) {
1575
1544
  }
1576
1545
 
1577
1546
  /**
1578
- * Parse a PoseTrack keyframe spec.
1547
+ * Parse a PoseTrack keyframe spec into internal form.
1579
1548
  *
1580
1549
  * Accepted forms:
1581
- *
1582
1550
  * { mat4Model }
1583
1551
  * Decompose a column-major mat4 into TRS via mat4ToTransform.
1584
1552
  * Float32Array(16), plain Array, or { mat4 } wrapper.
1585
1553
  *
1586
1554
  * { pos?, rot?, scl?, tanIn?, tanOut? }
1587
- * Explicit TRS. pos and scl are vec3, rot accepts any form from _parseQuat.
1555
+ * Explicit TRS. pos and scl are vec3; rot accepts any form from _parseQuat.
1588
1556
  * All fields are optional — missing pos/scl default to [0,0,0] / [1,1,1],
1589
1557
  * missing rot defaults to identity.
1590
1558
  * tanIn/tanOut are optional vec3 tangents for Hermite interpolation.
1591
1559
  *
1592
1560
  * @param {Object} spec
1593
- * @returns {{ pos:number[], rot:number[], scl:number[], tanIn:number[]|null, tanOut:number[]|null }|null}
1561
+ * @returns {{ pos:number[], rot:number[], scl:number[],
1562
+ * tanIn:number[]|null, tanOut:number[]|null } | null}
1594
1563
  */
1595
1564
  function _parseSpec(spec) {
1596
1565
  if (!spec || typeof spec !== 'object') return null;
@@ -1605,6 +1574,7 @@ function _parseSpec(spec) {
1605
1574
  return kf;
1606
1575
  }
1607
1576
 
1577
+ // { pos?, rot?, scl?, tanIn?, tanOut? } — explicit TRS
1608
1578
  const pos = _parseVec3(spec.pos) || [0,0,0];
1609
1579
  const rot = _parseQuat(spec.rot) || [0,0,0,1];
1610
1580
  const scl = _parseVec3(spec.scl) || [1,1,1];
@@ -1624,26 +1594,21 @@ function _sameTransform(a, b) {
1624
1594
  // =========================================================================
1625
1595
 
1626
1596
  /**
1627
- * Parse a camera keyframe spec into internal { eye, center, up } form.
1597
+ * Parse a CameraTrack keyframe spec into internal form.
1628
1598
  *
1629
- * Accepted forms:
1630
- *
1631
- * { eye, center?, up?, fov?, halfHeight?,
1632
- * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1633
- * Explicit lookat. center defaults to [0,0,0], up defaults to [0,1,0].
1634
- * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite.
1635
- * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1599
+ * Required: eye (vec3). Everything else is optional.
1600
+ * center defaults to [0, 0, 0]
1601
+ * up defaults to [0, 1, 0] and is normalised
1602
+ * fov vertical fov (radians), perspective only — null if absent
1603
+ * halfHeight world-unit half-height of ortho frustum null if absent
1604
+ * eyeTanIn/Out optional Hermite tangents for the eye path
1605
+ * centerTanIn/Out optional Hermite tangents for the center path
1636
1606
  *
1637
1607
  * @param {Object} spec
1638
- * @returns {{ eye:number[], center:number[], up:number[],
1639
- * fov:number|null, halfHeight:number|null,
1640
- * eyeTanIn:number[]|null, eyeTanOut:number[]|null,
1641
- * centerTanIn:number[]|null, centerTanOut:number[]|null }|null}
1608
+ * @returns {Object|null} Parsed keyframe or null if eye is missing/malformed.
1642
1609
  */
1643
1610
  function _parseCameraSpec(spec) {
1644
1611
  if (!spec || typeof spec !== 'object') return null;
1645
-
1646
- // { eye, center?, up? } — explicit lookat
1647
1612
  const eye = _parseVec3(spec.eye);
1648
1613
  if (!eye) return null;
1649
1614
  const center = _parseVec3(spec.center) || [0,0,0];
@@ -1681,41 +1646,39 @@ class Track {
1681
1646
  constructor() {
1682
1647
  /** @type {Array} Keyframe array — shape depends on subclass. */
1683
1648
  this.keyframes = [];
1684
- /** Whether playback is active. @type {boolean} */
1649
+ /** Whether playback is currently active. @type {boolean} */
1685
1650
  this.playing = false;
1686
1651
  /** Loop at boundaries. @type {boolean} */
1687
1652
  this.loop = false;
1688
- /** Ping-pong bounce (independent of loop). @type {boolean} */
1653
+ /** Ping-pong bounce at boundaries (independent of loop). @type {boolean} */
1689
1654
  this.bounce = false;
1690
1655
  /** Frames per segment (≥1). @type {number} */
1691
1656
  this.duration = 30;
1692
1657
  /** Current segment index. @type {number} */
1693
1658
  this.seg = 0;
1694
- /** Frame offset within segment (can be fractional). @type {number} */
1659
+ /** Frame offset within current segment (can be fractional). @type {number} */
1695
1660
  this.f = 0;
1696
1661
 
1697
- // Internal rate never directly starts/stops playback
1662
+ // Playback rate (signed: negative = reverse; 0 = frozen).
1698
1663
  this._rate = 1;
1699
- // Internal bounce direction: +1 forward, -1 backward.
1664
+ // Current bounce travel direction 1). Reset to 1 only on reset().
1700
1665
  this._dir = 1;
1701
- // Scratch: true once _dir has been flipped in bounce-once mode.
1666
+ // Whether a bounce-once has already flipped direction.
1702
1667
  this._bounced = false;
1703
1668
 
1704
- // User-space hooks
1705
- /** @type {Function|null} */ this.onPlay = null;
1706
- /** @type {Function|null} */ this.onEnd = null;
1707
- /** @type {Function|null} */ this.onStop = null;
1669
+ /** User hook: fires on play() false→true transition. @type {Function|null} */
1670
+ this.onPlay = null;
1671
+ /** User hook: fires on natural boundary in once mode. @type {Function|null} */
1672
+ this.onEnd = null;
1673
+ /** User hook: fires on stop() / reset() / end-of-bounce-once. @type {Function|null} */
1674
+ this.onStop = null;
1708
1675
 
1709
- // Lib-space hooks (set by host layer, e.g. p5 bridge)
1710
- /** @type {Function|null} */ this._onActivate = null;
1711
- /** @type {Function|null} */ this._onDeactivate = null;
1712
- // Lib-space event mirrors — set by UI layer (trackUI), never touched by user code
1713
- /** @type {Function|null} */ this._onPlay = null;
1714
- /** @type {Function|null} */ this._onEnd = null;
1715
- /** @type {Function|null} */ this._onStop = null;
1676
+ // Lib-space hooks (underscore prefix host layer / UI layer only).
1677
+ this._onActivate = null; this._onDeactivate = null;
1678
+ this._onPlay = null; this._onEnd = null; this._onStop = null;
1716
1679
  }
1717
1680
 
1718
- /** Playback rate. Assigning never starts/stops playback. @type {number} */
1681
+ /** Playback rate. Signed: negative reverses, 0 freezes. Assigning never starts or stops playback. @type {number} */
1719
1682
  get rate() { return this._rate; }
1720
1683
  set rate(v) { this._rate = (_isNum(v)) ? v : 1; }
1721
1684
 
@@ -1723,31 +1686,46 @@ class Track {
1723
1686
  get segments() { return Math.max(0, this.keyframes.length - 1); }
1724
1687
 
1725
1688
  /**
1726
- * Start or update playback.
1727
- * @param {number|Object} [rateOrOpts] Numeric rate or options object:
1728
- * { rate, duration, loop, bounce, onPlay, onEnd, onStop }
1689
+ * @private resolve cursor (seg, f) into continuous (seg, t).
1690
+ * Shared backing of every cursor-form sampler / matrix method.
1691
+ */
1692
+ _cursorSegT() {
1693
+ const nSeg = this.segments;
1694
+ const dur = Math.max(1, this.duration | 0);
1695
+ const seg = nSeg > 0 ? _clampS(this.seg, 0, nSeg - 1) : 0;
1696
+ const t = _clamp01(this.f / dur);
1697
+ return [seg, t];
1698
+ }
1699
+
1700
+ /**
1701
+ * Start or update playback. Sole setter of `playing = true`.
1702
+ *
1703
+ * Fires `onPlay → _onPlay → _onActivate` only on a false→true transition.
1704
+ * Zero keyframes: no-op. Exactly one keyframe: snaps eval() to it but does
1705
+ * not set `playing` and fires no hooks.
1706
+ *
1707
+ * @param {number|{duration?:number,loop?:boolean,bounce?:boolean,
1708
+ * rate?:number,onPlay?:Function,onEnd?:Function,
1709
+ * onStop?:Function}} [rateOrOpts]
1710
+ * A bare number is taken as `rate`; an object configures multiple
1711
+ * fields in one call.
1729
1712
  * @returns {Track} this
1730
1713
  */
1731
1714
  play(rateOrOpts) {
1732
1715
  if (this.keyframes.length === 0) return this;
1733
-
1734
- // One keyframe: snap cursor, no animation
1735
- if (this.keyframes.length === 1) {
1736
- this.seg = 0; this.f = 0;
1737
- return this;
1738
- }
1716
+ if (this.keyframes.length === 1) { this.seg = 0; this.f = 0; return this; }
1739
1717
 
1740
1718
  if (typeof rateOrOpts === 'number' && Number.isFinite(rateOrOpts)) {
1741
1719
  this._rate = rateOrOpts;
1742
1720
  } else if (rateOrOpts && typeof rateOrOpts === 'object') {
1743
1721
  const o = rateOrOpts;
1744
- if (_isNum(o.duration)) this.duration = Math.max(1, o.duration | 0);
1722
+ if (_isNum(o.duration)) this.duration = Math.max(1, o.duration | 0);
1745
1723
  if ('loop' in o) this.loop = !!o.loop;
1746
1724
  if ('bounce' in o) this.bounce = !!o.bounce;
1747
- if (typeof o.onPlay === 'function') this.onPlay = o.onPlay;
1748
- if (typeof o.onEnd === 'function') this.onEnd = o.onEnd;
1749
- if (typeof o.onStop === 'function') this.onStop = o.onStop;
1750
- if (_isNum(o.rate)) this._rate = o.rate;
1725
+ if (typeof o.onPlay === 'function') this.onPlay = o.onPlay;
1726
+ if (typeof o.onEnd === 'function') this.onEnd = o.onEnd;
1727
+ if (typeof o.onStop === 'function') this.onStop = o.onStop;
1728
+ if (_isNum(o.rate)) this._rate = o.rate;
1751
1729
  }
1752
1730
 
1753
1731
  const nSeg = this.segments, dur = Math.max(1, this.duration | 0);
@@ -1768,8 +1746,11 @@ class Track {
1768
1746
  }
1769
1747
 
1770
1748
  /**
1771
- * Stop playback.
1772
- * @param {boolean} [rewind=false] Seek to origin after stopping.
1749
+ * Stop playback. Sole setter of `playing = false`. Fires
1750
+ * `onStop _onStop _onDeactivate` on a true→false transition.
1751
+ *
1752
+ * @param {boolean} [rewind] If true, seek to the origin end after stopping
1753
+ * (0 when playing forward, 1 when playing backward).
1773
1754
  * @returns {Track} this
1774
1755
  */
1775
1756
  stop(rewind) {
@@ -1786,7 +1767,8 @@ class Track {
1786
1767
  }
1787
1768
 
1788
1769
  /**
1789
- * Clear all keyframes and stop.
1770
+ * Clear all keyframes and stop. Fires stop-side hooks if it was playing.
1771
+ * Unlike stop(), this also resets `_dir` to +1.
1790
1772
  * @returns {Track} this
1791
1773
  */
1792
1774
  reset() {
@@ -1803,9 +1785,10 @@ class Track {
1803
1785
  }
1804
1786
 
1805
1787
  /**
1806
- * Remove the keyframe at index. Adjusts cursor if needed.
1788
+ * Remove the keyframe at `index`. Adjusts the cursor if the removal shrinks
1789
+ * the track below the current segment.
1807
1790
  * @param {number} index
1808
- * @returns {boolean}
1791
+ * @returns {boolean} true if removed; false if index was invalid.
1809
1792
  */
1810
1793
  remove(index) {
1811
1794
  if (!_isNum(index)) return false;
@@ -1819,9 +1802,15 @@ class Track {
1819
1802
  }
1820
1803
 
1821
1804
  /**
1822
- * Seek to a normalised position [0,1] across the full path.
1823
- * @param {number} t Normalised time [0, 1].
1824
- * @param {number} [segIndex] Optional segment override.
1805
+ * Move the cursor.
1806
+ * seek(t) Scrub to normalised position t [0, 1] across the
1807
+ * whole track.
1808
+ * seek(t, segIndex) Position within a specific segment — t is local to
1809
+ * that segment.
1810
+ * Does not affect `playing`.
1811
+ *
1812
+ * @param {number} t
1813
+ * @param {number} [segIndex]
1825
1814
  * @returns {Track} this
1826
1815
  */
1827
1816
  seek(t, segIndex) {
@@ -1838,8 +1827,8 @@ class Track {
1838
1827
  }
1839
1828
 
1840
1829
  /**
1841
- * Normalised playback position [0,1].
1842
- * @returns {number}
1830
+ * Normalised cursor position across the whole track.
1831
+ * @returns {number} ∈ [0, 1]; 0 when there are no segments.
1843
1832
  */
1844
1833
  time() {
1845
1834
  const nSeg = this.segments;
@@ -1849,8 +1838,11 @@ class Track {
1849
1838
  }
1850
1839
 
1851
1840
  /**
1852
- * Snapshot of transport state.
1853
- * @returns {Object}
1841
+ * Snapshot of transport state. Allocates a new object per call — intended
1842
+ * for UI / debugging, not hot loops.
1843
+ * @returns {{keyframes:number, segments:number, seg:number, f:number,
1844
+ * playing:boolean, loop:boolean, bounce:boolean, rate:number,
1845
+ * duration:number, time:number}}
1854
1846
  */
1855
1847
  info() {
1856
1848
  return {
@@ -1868,16 +1860,19 @@ class Track {
1868
1860
  }
1869
1861
 
1870
1862
  /**
1871
- * Advance cursor by rate frames.
1872
- * Returns true while playing, false when stopping.
1873
- * @returns {boolean}
1863
+ * Advance the cursor by `rate * _dir` in frames. Handles loop / bounce /
1864
+ * once modes per the table in the module header. Fires `onEnd → _onEnd →
1865
+ * _onDeactivate` at a natural boundary in once mode.
1866
+ *
1867
+ * Intended to be called once per animation frame by the bridge / UI layer.
1868
+ * rate === 0 freezes the cursor but keeps `playing` unchanged.
1869
+ *
1870
+ * @returns {boolean} Current `playing` state after advancing.
1874
1871
  */
1875
1872
  tick() {
1876
1873
  if (!this.playing) return false;
1877
1874
  const nSeg = this.segments;
1878
- if (nSeg === 0) {
1879
- this.playing = false; this._onDeactivate?.(); return false;
1880
- }
1875
+ if (nSeg === 0) { this.playing = false; this._onDeactivate?.(); return false; }
1881
1876
  if (this._rate === 0) return true;
1882
1877
 
1883
1878
  const dur = Math.max(1, this.duration | 0);
@@ -1885,7 +1880,6 @@ class Track {
1885
1880
  const s = _clampS(this.seg * dur + this.f, 0, total);
1886
1881
  const next = s + this._rate * this._dir;
1887
1882
 
1888
- // ── loop:true, bounce:true — bounce forever ───────────────────────────
1889
1883
  if (this.loop && this.bounce) {
1890
1884
  let pos = next, flips = 0;
1891
1885
  while (pos < 0 || pos > total) {
@@ -1897,7 +1891,6 @@ class Track {
1897
1891
  return true;
1898
1892
  }
1899
1893
 
1900
- // ── loop:false, bounce:true — bounce once, stop at origin ────────────
1901
1894
  if (!this.loop && this.bounce) {
1902
1895
  if (next >= total) {
1903
1896
  this._setCursorFromScalar(Math.min(total, 2 * total - next));
@@ -1918,13 +1911,11 @@ class Track {
1918
1911
  return true;
1919
1912
  }
1920
1913
 
1921
- // ── loop:true, bounce:false — repeat forever ──────────────────────────
1922
1914
  if (this.loop) {
1923
1915
  this._setCursorFromScalar(((next % total) + total) % total);
1924
1916
  return true;
1925
1917
  }
1926
1918
 
1927
- // ── loop:false, bounce:false — play once, stop at boundary ───────────
1928
1919
  if (next <= 0) {
1929
1920
  this._setCursorFromScalar(0);
1930
1921
  this.playing = false;
@@ -1969,7 +1960,7 @@ class Track {
1969
1960
  *
1970
1961
  * tanIn — incoming position tangent at this keyframe (Hermite mode).
1971
1962
  * tanOut — outgoing position tangent at this keyframe (Hermite mode).
1972
- * When only one is supplied, the other mirrors it.
1963
+ * When only one is supplied, the other mirrors it at sample time.
1973
1964
  * When neither is supplied, centripetal Catmull-Rom tangents are auto-computed.
1974
1965
  */
1975
1966
  class PoseTrack extends Track {
@@ -1977,8 +1968,8 @@ class PoseTrack extends Track {
1977
1968
  super();
1978
1969
  /**
1979
1970
  * Position interpolation mode.
1980
- * - 'hermite' — cubic Hermite; auto-computes centripetal Catmull-Rom tangents
1981
- * when none are stored (default)
1971
+ * - 'hermite' — cubic Hermite; auto-computes centripetal Catmull-Rom
1972
+ * tangents when none are stored (default)
1982
1973
  * - 'linear' — lerp
1983
1974
  * - 'step' — snap to k0; useful for discrete state changes
1984
1975
  * @type {'hermite'|'linear'|'step'}
@@ -1988,26 +1979,20 @@ class PoseTrack extends Track {
1988
1979
  * Rotation interpolation mode.
1989
1980
  * - 'slerp' — constant angular velocity (default)
1990
1981
  * - 'nlerp' — normalised lerp; cheaper, slightly non-constant speed
1991
- * - 'step' — snap to k0 quaternion; useful for discrete state changes
1982
+ * - 'step' — snap to k0 quaternion
1992
1983
  * @type {'slerp'|'nlerp'|'step'}
1993
1984
  */
1994
1985
  this.rotInterp = 'slerp';
1995
- // Scratch for toMatrix() — avoids hot-path allocations
1996
- this._pos = [0,0,0];
1997
- this._rot = [0,0,0,1];
1998
- this._scl = [1,1,1];
1999
1986
  }
2000
1987
 
2001
1988
  /**
2002
1989
  * Append one or more keyframes. Adjacent duplicates are skipped by default.
1990
+ * Accepts any spec form understood by _parseSpec, or an array of them.
2003
1991
  * @param {Object|Object[]} spec
2004
1992
  * @param {{ deduplicate?: boolean }} [opts]
2005
1993
  */
2006
1994
  add(spec, opts) {
2007
- if (Array.isArray(spec)) {
2008
- for (const s of spec) this.add(s, opts);
2009
- return;
2010
- }
1995
+ if (Array.isArray(spec)) { for (const s of spec) this.add(s, opts); return; }
2011
1996
  const kf = _parseSpec(spec);
2012
1997
  if (!kf) return;
2013
1998
  const dedup = !opts || opts.deduplicate !== false;
@@ -2018,10 +2003,11 @@ class PoseTrack extends Track {
2018
2003
  }
2019
2004
 
2020
2005
  /**
2021
- * Replace (or append at end) the keyframe at index.
2006
+ * Replace the keyframe at `index`, or append at the end if `index` equals
2007
+ * the current keyframe count.
2022
2008
  * @param {number} index
2023
- * @param {Object} spec
2024
- * @returns {boolean}
2009
+ * @param {Object} spec Any spec form understood by _parseSpec.
2010
+ * @returns {boolean} true on success; false for invalid index or spec.
2025
2011
  */
2026
2012
  set(index, spec) {
2027
2013
  if (!_isNum(index)) return false;
@@ -2033,48 +2019,64 @@ class PoseTrack extends Track {
2033
2019
  }
2034
2020
 
2035
2021
  /**
2036
- * Sample the position path at (seg, t).
2022
+ * Sample the position path. Zero-alloc, no cursor side effects. Honours
2023
+ * posInterp and the stored-tangent → auto-CR fallback chain.
2037
2024
  *
2038
- * Pure function of the keyframes — does not read or modify the transport
2039
- * cursor, fires no hooks, allocates nothing. seg is clamped to
2040
- * [0, segments-1] and t to [0, 1].
2025
+ * Two signatures:
2026
+ * samplePos(out) cursor form reads current seg/f
2027
+ * samplePos(out, seg, t) explicit (seg, t), continuous on the path
2041
2028
  *
2042
2029
  * @param {number[]} out 3-element result buffer.
2043
- * @param {number} seg Segment index.
2044
- * @param {number} t Local parameter in [0, 1].
2030
+ * @param {number} [seg] Segment index in [0, segments−1]. Omit for cursor.
2031
+ * @param {number} [t] Local parameter in [0, 1]. Omit for cursor.
2045
2032
  * @returns {number[]} out
2046
2033
  */
2047
2034
  samplePos(out, seg, t) {
2035
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
2048
2036
  return _samplePathCore(out, this.keyframes, this.posInterp, 'pos', 'tanIn', 'tanOut', seg, t);
2049
2037
  }
2050
2038
 
2051
2039
  /**
2052
- * Write the effective incoming / outgoing tangents at keyframe i.
2053
- * Stored tanIn/tanOut take precedence, then each mirrors the other when
2054
- * only one is stored, else centripetal Catmull-Rom tangents are auto-
2055
- * computed from neighbours. Endpoint tangents mirror across the missing
2056
- * side so drawing arrows at boundary keyframes produces a visible vector.
2040
+ * Write the TRS pose as a column-major model mat4. Zero-alloc, no cursor
2041
+ * side effects.
2057
2042
  *
2058
- * @param {number[]} outIn 3-element result — incoming tangent at kf i.
2059
- * @param {number[]} outOut 3-element result outgoing tangent at kf i.
2060
- * @param {number} i Keyframe index.
2061
- * @returns {PoseTrack} this
2043
+ * Two signatures:
2044
+ * mat4Model(out) cursor formreads current seg/f
2045
+ * mat4Model(out, seg, t) explicit (seg, t), continuous on the path
2046
+ *
2047
+ * Replaces the previous toMatrix() method.
2048
+ *
2049
+ * @param {Float32Array|number[]} out 16-element result buffer.
2050
+ * @param {number} [seg] Segment index in [0, segments−1]. Omit for cursor.
2051
+ * @param {number} [t] Local parameter in [0, 1]. Omit for cursor.
2052
+ * @returns {Float32Array|number[]} out
2062
2053
  */
2063
- sampleTangents(outIn, outOut, i) {
2064
- _sampleTangentsCore(outIn, outOut, this.keyframes, 'pos', 'tanIn', 'tanOut', i);
2065
- return this;
2054
+ mat4Model(out, seg, t) {
2055
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
2056
+ this._sampleTRS(_trsScratch, seg, t);
2057
+ return transformToMat4(out, _trsScratch);
2066
2058
  }
2067
2059
 
2068
2060
  /**
2069
- * Evaluate interpolated TRS pose at current cursor.
2070
- * @param {{ pos:number[], rot:number[], scl:number[] }} [out]
2071
- * @returns {{ pos:number[], rot:number[], scl:number[] }} out
2061
+ * Effective incoming / outgoing position tangents at keyframe `index`.
2062
+ * Stored tanIn/tanOut take precedence; each mirrors the other when only
2063
+ * one is stored; else centripetal Catmull-Rom tangents are auto-computed
2064
+ * from neighbours. Boundary keyframes mirror across the missing side.
2065
+ *
2066
+ * @param {number[]} outIn 3-element result — incoming tangent.
2067
+ * @param {number[]} outOut 3-element result — outgoing tangent.
2068
+ * @param {number} index Keyframe index.
2069
+ * @returns {PoseTrack} this
2072
2070
  */
2073
- eval(out) {
2074
- out = out || { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] };
2071
+ tangents(outIn, outOut, index) {
2072
+ _sampleTangentsCore(outIn, outOut, this.keyframes, 'pos', 'tanIn', 'tanOut', index);
2073
+ return this;
2074
+ }
2075
+
2076
+ /** @private — write interpolated TRS at (seg, t). */
2077
+ _sampleTRS(out, seg, t) {
2075
2078
  const n = this.keyframes.length;
2076
2079
  if (n === 0) return out;
2077
-
2078
2080
  if (n === 1) {
2079
2081
  const k = this.keyframes[0];
2080
2082
  out.pos[0]=k.pos[0]; out.pos[1]=k.pos[1]; out.pos[2]=k.pos[2];
@@ -2082,18 +2084,16 @@ class PoseTrack extends Track {
2082
2084
  out.scl[0]=k.scl[0]; out.scl[1]=k.scl[1]; out.scl[2]=k.scl[2];
2083
2085
  return out;
2084
2086
  }
2085
-
2086
2087
  const nSeg = n - 1;
2087
- const dur = Math.max(1, this.duration | 0);
2088
- const seg = _clampS(this.seg, 0, nSeg - 1);
2089
- const t = _clamp01(this.f / dur);
2090
- const k0 = this.keyframes[seg];
2091
- const k1 = this.keyframes[seg + 1];
2088
+ seg = seg | 0;
2089
+ if (seg >= nSeg) { seg = nSeg - 1; t = 1; }
2090
+ else if (seg < 0) { seg = 0; t = 0; }
2091
+ t = _clamp01(t);
2092
+ const k0 = this.keyframes[seg];
2093
+ const k1 = this.keyframes[seg + 1];
2092
2094
 
2093
- // pos — shared sampler (respects posInterp + tangent fallback chain)
2094
2095
  _samplePathCore(out.pos, this.keyframes, this.posInterp, 'pos', 'tanIn', 'tanOut', seg, t);
2095
2096
 
2096
- // rot — step, slerp, or nlerp
2097
2097
  if (this.rotInterp === 'step') {
2098
2098
  out.rot[0]=k0.rot[0]; out.rot[1]=k0.rot[1]; out.rot[2]=k0.rot[2]; out.rot[3]=k0.rot[3];
2099
2099
  } else if (this.rotInterp === 'nlerp') {
@@ -2102,20 +2102,22 @@ class PoseTrack extends Track {
2102
2102
  qSlerp(out.rot, k0.rot, k1.rot, t);
2103
2103
  }
2104
2104
 
2105
- // scl — lerp
2106
2105
  lerpVec3(out.scl, k0.scl, k1.scl, t);
2107
-
2108
2106
  return out;
2109
2107
  }
2110
2108
 
2111
2109
  /**
2112
- * Evaluate into an existing column-major mat4.
2113
- * @param {Float32Array|number[]} outMat4 16-element array.
2114
- * @returns {Float32Array|number[]} outMat4
2110
+ * Evaluate interpolated TRS pose at current cursor.
2111
+ * @param {{ pos:number[], rot:number[], scl:number[] }} [out]
2112
+ * @returns {{ pos:number[], rot:number[], scl:number[] }} out
2115
2113
  */
2116
- toMatrix(outMat4) {
2117
- const xf = this.eval({ pos: this._pos, rot: this._rot, scl: this._scl });
2118
- return transformToMat4(outMat4, xf);
2114
+ eval(out) {
2115
+ out = out || { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] };
2116
+ const n = this.keyframes.length;
2117
+ if (n === 0) return out;
2118
+ if (n === 1) return this._sampleTRS(out, 0, 0);
2119
+ const [seg, t] = this._cursorSegT();
2120
+ return this._sampleTRS(out, seg, t);
2119
2121
  }
2120
2122
  }
2121
2123
 
@@ -2134,21 +2136,18 @@ class PoseTrack extends Track {
2134
2136
  * fov — vertical fov (radians) for perspective cameras; null for ortho.
2135
2137
  * halfHeight — world-unit half-height of ortho frustum; null for perspective.
2136
2138
  * Both are optional and nullable. eval() lerps each only when both adjacent
2137
- * keyframes carry a non-null value for that field.
2139
+ * keyframes carry a non-null value for that field; mixed or missing entries
2140
+ * pass `null` through.
2138
2141
  *
2139
2142
  * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite
2140
- * interpolation of the eye and center paths respectively.
2141
- * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
2143
+ * interpolation of the eye and center paths respectively. When absent,
2144
+ * centripetal Catmull-Rom tangents are auto-computed at sample time.
2142
2145
  *
2143
2146
  * Missing fields default to: center → [0,0,0], up → [0,1,0].
2144
2147
  *
2145
- * add() accepts individual specs or a bulk array of specs:
2146
- *
2147
- * { eye, center?, up?, fov?, halfHeight?,
2148
- * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
2149
- *
2150
- * To capture a matrix-based pose, use PoseTrack.add({ mat4Model: mat4Eye })
2151
- * for full-fidelity including roll, or cam.capturePose() for lookat-style.
2148
+ * For matrix-based capture of a camera-like pose use
2149
+ * PoseTrack.add({ mat4Model: mat4Eye }) for full TRS fidelity including roll,
2150
+ * or a lookat spec here for camera-style interpolation.
2152
2151
  */
2153
2152
  class CameraTrack extends Track {
2154
2153
  constructor() {
@@ -2166,15 +2165,13 @@ class CameraTrack extends Track {
2166
2165
  }
2167
2166
 
2168
2167
  /**
2169
- * Append one or more camera keyframes. Adjacent duplicates are skipped by default.
2168
+ * Append one or more camera keyframes. Adjacent duplicates are skipped by
2169
+ * default.
2170
2170
  * @param {Object|Object[]} spec
2171
2171
  * @param {{ deduplicate?: boolean }} [opts]
2172
2172
  */
2173
2173
  add(spec, opts) {
2174
- if (Array.isArray(spec)) {
2175
- for (const s of spec) this.add(s, opts);
2176
- return;
2177
- }
2174
+ if (Array.isArray(spec)) { for (const s of spec) this.add(s, opts); return; }
2178
2175
  const kf = _parseCameraSpec(spec);
2179
2176
  if (!kf) return;
2180
2177
  const dedup = !opts || opts.deduplicate !== false;
@@ -2185,7 +2182,8 @@ class CameraTrack extends Track {
2185
2182
  }
2186
2183
 
2187
2184
  /**
2188
- * Replace (or append at end) the camera keyframe at index.
2185
+ * Replace the camera keyframe at `index`, or append at the end if `index`
2186
+ * equals the current keyframe count.
2189
2187
  * @param {number} index
2190
2188
  * @param {Object} spec
2191
2189
  * @returns {boolean}
@@ -2200,91 +2198,129 @@ class CameraTrack extends Track {
2200
2198
  }
2201
2199
 
2202
2200
  /**
2203
- * Sample the eye path at (seg, t). See PoseTrack.samplePos for semantics.
2201
+ * Sample the eye path. Zero-alloc, no cursor side effects.
2202
+ *
2203
+ * Two signatures:
2204
+ * sampleEye(out) cursor form — reads current seg/f
2205
+ * sampleEye(out, seg, t) explicit (seg, t), continuous on the path
2206
+ *
2204
2207
  * @param {number[]} out
2205
- * @param {number} seg
2206
- * @param {number} t
2208
+ * @param {number} [seg]
2209
+ * @param {number} [t]
2207
2210
  * @returns {number[]} out
2208
2211
  */
2209
2212
  sampleEye(out, seg, t) {
2213
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
2210
2214
  return _samplePathCore(out, this.keyframes, this.eyeInterp, 'eye', 'eyeTanIn', 'eyeTanOut', seg, t);
2211
2215
  }
2212
2216
 
2213
2217
  /**
2214
- * Sample the center path at (seg, t). See PoseTrack.samplePos for semantics.
2218
+ * Sample the center path. Zero-alloc, no cursor side effects.
2219
+ *
2220
+ * Two signatures:
2221
+ * sampleCenter(out) cursor form — reads current seg/f
2222
+ * sampleCenter(out, seg, t) explicit (seg, t)
2223
+ *
2215
2224
  * @param {number[]} out
2216
- * @param {number} seg
2217
- * @param {number} t
2225
+ * @param {number} [seg]
2226
+ * @param {number} [t]
2218
2227
  * @returns {number[]} out
2219
2228
  */
2220
2229
  sampleCenter(out, seg, t) {
2230
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
2221
2231
  return _samplePathCore(out, this.keyframes, this.centerInterp, 'center', 'centerTanIn', 'centerTanOut', seg, t);
2222
2232
  }
2223
2233
 
2224
2234
  /**
2225
- * Effective in/out eye tangents at keyframe i. See PoseTrack.sampleTangents.
2226
- * @param {number[]} outIn
2227
- * @param {number[]} outOut
2228
- * @param {number} i
2229
- * @returns {CameraTrack} this
2235
+ * Write the interpolated lookat eye matrix as a column-major mat4
2236
+ * (eye→world rigid frame). Zero-alloc, no cursor side effects.
2237
+ *
2238
+ * Two signatures:
2239
+ * mat4Eye(out) cursor form reads current seg/f
2240
+ * mat4Eye(out, seg, t) explicit (seg, t), continuous on the path
2241
+ *
2242
+ * @param {Float32Array|number[]} out 16-element result buffer.
2243
+ * @param {number} [seg]
2244
+ * @param {number} [t]
2245
+ * @returns {Float32Array|number[]} out
2230
2246
  */
2231
- sampleEyeTangents(outIn, outOut, i) {
2232
- _sampleTangentsCore(outIn, outOut, this.keyframes, 'eye', 'eyeTanIn', 'eyeTanOut', i);
2233
- return this;
2247
+ mat4Eye(out, seg, t) {
2248
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
2249
+ this._sampleEyePose(_eyeScratch, seg, t);
2250
+ const e = _eyeScratch;
2251
+ return mat4Eye(out,
2252
+ e.eye[0], e.eye[1], e.eye[2],
2253
+ e.center[0], e.center[1], e.center[2],
2254
+ e.up[0], e.up[1], e.up[2]);
2234
2255
  }
2235
2256
 
2236
2257
  /**
2237
- * Effective in/out center tangents at keyframe i. See PoseTrack.sampleTangents.
2238
- * @param {number[]} outIn
2239
- * @param {number[]} outOut
2240
- * @param {number} i
2241
- * @returns {CameraTrack} this
2258
+ * Effective in/out eye tangents at keyframe `index`.
2242
2259
  */
2243
- sampleCenterTangents(outIn, outOut, i) {
2244
- _sampleTangentsCore(outIn, outOut, this.keyframes, 'center', 'centerTanIn', 'centerTanOut', i);
2260
+ eyeTangents(outIn, outOut, index) {
2261
+ _sampleTangentsCore(outIn, outOut, this.keyframes, 'eye', 'eyeTanIn', 'eyeTanOut', index);
2245
2262
  return this;
2246
2263
  }
2247
2264
 
2248
2265
  /**
2249
- * Evaluate interpolated camera pose at current cursor.
2250
- *
2251
- * @param {{ eye:number[], center:number[], up:number[] }} [out]
2252
- * @returns {{ eye:number[], center:number[], up:number[] }} out
2266
+ * Effective in/out center tangents at keyframe `index`.
2253
2267
  */
2254
- eval(out) {
2255
- out = out || { eye:[0,0,0], center:[0,0,0], up:[0,1,0], fov:null, halfHeight:null };
2268
+ centerTangents(outIn, outOut, index) {
2269
+ _sampleTangentsCore(outIn, outOut, this.keyframes, 'center', 'centerTanIn', 'centerTanOut', index);
2270
+ return this;
2271
+ }
2272
+
2273
+ /** @private — write interpolated { eye, center, up } at (seg, t). */
2274
+ _sampleEyePose(out, seg, t) {
2256
2275
  const n = this.keyframes.length;
2257
2276
  if (n === 0) return out;
2258
-
2259
2277
  if (n === 1) {
2260
2278
  const k = this.keyframes[0];
2261
2279
  out.eye[0]=k.eye[0]; out.eye[1]=k.eye[1]; out.eye[2]=k.eye[2];
2262
2280
  out.center[0]=k.center[0]; out.center[1]=k.center[1]; out.center[2]=k.center[2];
2263
2281
  out.up[0]=k.up[0]; out.up[1]=k.up[1]; out.up[2]=k.up[2];
2264
- out.fov = k.fov;
2265
- out.halfHeight = k.halfHeight;
2266
2282
  return out;
2267
2283
  }
2268
-
2269
2284
  const nSeg = n - 1;
2270
- const dur = Math.max(1, this.duration | 0);
2271
- const seg = _clampS(this.seg, 0, nSeg - 1);
2272
- const t = _clamp01(this.f / dur);
2273
- const k0 = this.keyframes[seg];
2274
- const k1 = this.keyframes[seg + 1];
2275
-
2276
- // eye — shared sampler
2277
- _samplePathCore(out.eye, this.keyframes, this.eyeInterp, 'eye', 'eyeTanIn', 'eyeTanOut', seg, t);
2278
-
2279
- // center — shared sampler
2285
+ seg = seg | 0;
2286
+ if (seg >= nSeg) { seg = nSeg - 1; t = 1; }
2287
+ else if (seg < 0) { seg = 0; t = 0; }
2288
+ t = _clamp01(t);
2289
+ const k0 = this.keyframes[seg];
2290
+ const k1 = this.keyframes[seg + 1];
2291
+
2292
+ _samplePathCore(out.eye, this.keyframes, this.eyeInterp, 'eye', 'eyeTanIn', 'eyeTanOut', seg, t);
2280
2293
  _samplePathCore(out.center, this.keyframes, this.centerInterp, 'center', 'centerTanIn', 'centerTanOut', seg, t);
2281
2294
 
2282
- // up — nlerp on unit sphere
2283
2295
  lerpVec3(out.up, k0.up, k1.up, t);
2284
- const ul=Math.sqrt(out.up[0]*out.up[0]+out.up[1]*out.up[1]+out.up[2]*out.up[2])||1;
2296
+ const ul = Math.sqrt(out.up[0]*out.up[0]+out.up[1]*out.up[1]+out.up[2]*out.up[2]) || 1;
2285
2297
  out.up[0]/=ul; out.up[1]/=ul; out.up[2]/=ul;
2298
+ return out;
2299
+ }
2300
+
2301
+ /**
2302
+ * Evaluate interpolated camera pose at current cursor.
2303
+ * @param {{ eye:number[], center:number[], up:number[],
2304
+ * fov:number|null, halfHeight:number|null }} [out]
2305
+ * @returns {{ eye:number[], center:number[], up:number[],
2306
+ * fov:number|null, halfHeight:number|null }} out
2307
+ */
2308
+ eval(out) {
2309
+ out = out || { eye:[0,0,0], center:[0,0,0], up:[0,1,0], fov:null, halfHeight:null };
2310
+ const n = this.keyframes.length;
2311
+ if (n === 0) return out;
2312
+ if (n === 1) {
2313
+ const k = this.keyframes[0];
2314
+ this._sampleEyePose(out, 0, 0);
2315
+ out.fov = k.fov; out.halfHeight = k.halfHeight;
2316
+ return out;
2317
+ }
2318
+ const [seg, t] = this._cursorSegT();
2319
+ const k0 = this.keyframes[seg];
2320
+ const k1 = this.keyframes[seg + 1];
2321
+
2322
+ this._sampleEyePose(out, seg, t);
2286
2323
 
2287
- // fov / halfHeight — lerp when both keyframes carry non-null values
2288
2324
  out.fov = (k0.fov != null && k1.fov != null)
2289
2325
  ? k0.fov + t * (k1.fov - k0.fov) : (k0.fov ?? k1.fov ?? null);
2290
2326
  out.halfHeight = (k0.halfHeight != null && k1.halfHeight != null)