@nakednous/tree 0.0.19 → 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,28 +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
1175
- *
1176
- * ── Class hierarchy ───────────────────────────────────────────────────────────
1174
+ * CameraTrack — { eye, center, up, fov?, halfHeight? } lookat keyframes
1175
+ *
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
- * ── Hook architecture ─────────────────────────────────────────────────────────
1186
- * Lib-space hooks (underscore prefix — reserved for host layer / UI layer):
1187
- * _onActivate / _onDeactivate — fire on playing transitions false→true / true→false.
1188
- * _onPlay / _onEnd / _onStop — mirror the user-space hooks; used by the UI layer
1189
- * 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.
1190
1227
  *
1191
- * User-space hooks (public):
1228
+ * User-space hooks:
1192
1229
  * onPlay : fires in play() on false→true transition.
1193
1230
  * onEnd : fires in tick() at natural boundary (once mode only).
1194
- * onStop : fires in stop() / reset() — explicit deactivation.
1195
- * onEnd and onStop are mutually exclusive per event.
1231
+ * onStop : fires in stop() / reset().
1196
1232
  *
1197
1233
  * Firing order:
1198
1234
  * play() → onPlay → _onPlay → _onActivate
@@ -1200,18 +1236,14 @@ function mat4ToRotation(out4, m) {
1200
1236
  * stop() → onStop → _onStop → _onDeactivate
1201
1237
  * reset() → onStop → _onStop → _onDeactivate
1202
1238
  *
1203
- * ── Loop modes ────────────────────────────────────────────────────────────────
1239
+ * ── Loop modes ───────────────────────────────────────────────────────────
1204
1240
  * loop:false, bounce:false — play once, stop at end (fires onEnd)
1205
1241
  * loop:true, bounce:false — repeat, wrap back to start
1206
1242
  * loop:true, bounce:true — bounce forever at boundaries
1207
1243
  * loop:false, bounce:true — bounce once: flip at far boundary, stop at origin
1208
1244
  *
1209
- * bounce and loop are fully independent flags — no exclusivity enforced.
1210
- *
1211
- * ── Playback semantics (rate + _dir) ─────────────────────────────────────────
1212
- * rate > 0 forward
1213
- * rate < 0 backward
1214
- * rate === 0 frozen: tick() no-op; playing unchanged
1245
+ * ── Playback semantics (rate + _dir) ─────────────────────────────────────
1246
+ * rate > 0 forward rate < 0 backward rate === 0 frozen
1215
1247
  *
1216
1248
  * play() is the sole setter of playing = true.
1217
1249
  * stop() is the sole setter of playing = false.
@@ -1219,11 +1251,9 @@ function mat4ToRotation(out4, m) {
1219
1251
  *
1220
1252
  * _dir (internal, ±1) tracks the current bounce travel direction.
1221
1253
  * tick() advances by rate * _dir and flips _dir at boundaries.
1222
- * rate always holds the user-set value it is never mutated by bounce.
1223
- * _dir is reset to 1 only in reset() (keyframes cleared) — stop/replay
1224
- * preserves the current travel direction.
1254
+ * _dir is reset to 1 only in reset().
1225
1255
  *
1226
- * ── One-keyframe behaviour ────────────────────────────────────────────────────
1256
+ * ── One-keyframe behaviour ───────────────────────────────────────────────
1227
1257
  * play() with exactly one keyframe snaps eval() to that keyframe without
1228
1258
  * setting playing = true and without firing hooks.
1229
1259
  */
@@ -1240,12 +1270,11 @@ function _dist3(a, b) {
1240
1270
 
1241
1271
  /**
1242
1272
  * Cubic Hermite interpolation between p0 and p1 with explicit tangents.
1243
- * Catmull-Rom is a special case where m0/m1 are auto-computed from neighbors.
1244
1273
  * @param {number[]} out 3-element result.
1245
1274
  * @param {number[]} p0 Segment start.
1246
- * @param {number[]} m0 Outgoing tangent at p0 (world-space, dp/dt scaled to segment).
1275
+ * @param {number[]} m0 Outgoing tangent at p0.
1247
1276
  * @param {number[]} p1 Segment end.
1248
- * @param {number[]} m1 Incoming tangent at p1 (world-space, dp/dt scaled to segment).
1277
+ * @param {number[]} m1 Incoming tangent at p1.
1249
1278
  * @param {number} t Blend [0, 1].
1250
1279
  * @returns {number[]} out
1251
1280
  */
@@ -1258,29 +1287,29 @@ const hermiteVec3 = (out, p0, m0, p1, m1, t) => {
1258
1287
  return out;
1259
1288
  };
1260
1289
 
1261
- // Centripetal CR outgoing tangent at p1 for segment p1→p2, scaled by dt1.
1290
+ // Centripetal CR outgoing tangent at p1 for segment p1→p2.
1291
+ // Signature: (out, p0, p1, p2). Returns tangent AT p1 (the middle point).
1262
1292
  const _crTanOut = (out, p0, p1, p2) => {
1263
1293
  const dt0=Math.pow(_dist3(p0,p1),0.5)||1, dt1=Math.pow(_dist3(p1,p2),0.5)||1;
1264
1294
  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;
1265
1295
  return out;
1266
1296
  };
1267
1297
 
1298
+ // Centripetal CR incoming tangent at p2 for segment p1→p2.
1299
+ // Signature: (out, p1, p2, p3). Returns tangent AT p2 (the middle point).
1268
1300
  const _crTanIn = (out, p1, p2, p3) => {
1269
1301
  const dt1=Math.pow(_dist3(p1,p2),0.5)||1, dt2=Math.pow(_dist3(p2,p3),0.5)||1;
1270
1302
  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;
1271
1303
  return out;
1272
1304
  };
1273
1305
 
1274
- // Module-level scratch — shared by eval() across all track instances (non-reentrant hot path).
1306
+ // Module-level scratch — shared across track instances (non-reentrant hot path).
1275
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] };
1276
1310
 
1277
1311
  /**
1278
1312
  * Linear interpolation between two vec3s.
1279
- * @param {number[]} out
1280
- * @param {number[]} a
1281
- * @param {number[]} b
1282
- * @param {number} t Blend [0, 1].
1283
- * @returns {number[]} out
1284
1313
  */
1285
1314
  const lerpVec3 = (out, a, b, t) => {
1286
1315
  out[0]=a[0]+t*(b[0]-a[0]);
@@ -1289,15 +1318,94 @@ const lerpVec3 = (out, a, b, t) => {
1289
1318
  return out;
1290
1319
  };
1291
1320
 
1321
+ // =========================================================================
1322
+ // S2b Path samplers — shared core
1323
+ // =========================================================================
1324
+
1325
+ /** @private — sample interpolated vec3 path at (seg, t) into out. */
1326
+ function _samplePathCore(out, kfs, interp, field, tanInName, tanOutName, seg, t) {
1327
+ const n = kfs.length;
1328
+ if (n === 0) { out[0]=0; out[1]=0; out[2]=0; return out; }
1329
+ if (n === 1) {
1330
+ const p = kfs[0][field];
1331
+ out[0]=p[0]; out[1]=p[1]; out[2]=p[2];
1332
+ return out;
1333
+ }
1334
+ const nSeg = n - 1;
1335
+ seg = seg | 0;
1336
+ if (seg >= nSeg) { seg = nSeg - 1; t = 1; }
1337
+ else if (seg < 0) { seg = 0; t = 0; }
1338
+ t = _clamp01(t);
1339
+ const k0 = kfs[seg];
1340
+ const k1 = kfs[seg + 1];
1341
+
1342
+ if (interp === 'step') {
1343
+ const p = k0[field];
1344
+ out[0]=p[0]; out[1]=p[1]; out[2]=p[2];
1345
+ return out;
1346
+ }
1347
+ if (interp === 'linear') {
1348
+ return lerpVec3(out, k0[field], k1[field], t);
1349
+ }
1350
+
1351
+ const p0 = seg > 0 ? kfs[seg - 1][field] : k0[field];
1352
+ const p3 = seg + 2 < n ? kfs[seg + 2][field] : k1[field];
1353
+ const m0 = k0[tanOutName] != null ? k0[tanOutName]
1354
+ : k0[tanInName] != null ? k0[tanInName]
1355
+ : _crTanOut(_m0, p0, k0[field], k1[field]);
1356
+ const m1 = k1[tanInName] != null ? k1[tanInName]
1357
+ : k1[tanOutName] != null ? k1[tanOutName]
1358
+ : _crTanIn(_m1, k0[field], k1[field], p3);
1359
+ return hermiteVec3(out, k0[field], m0, k1[field], m1, t);
1360
+ }
1361
+
1362
+ /** @private — write effective in/out tangents at keyframe i. */
1363
+ function _sampleTangentsCore(outIn, outOut, kfs, field, tanInName, tanOutName, i) {
1364
+ const n = kfs.length;
1365
+ if (n === 0) {
1366
+ outIn[0]=outIn[1]=outIn[2]=0; outOut[0]=outOut[1]=outOut[2]=0;
1367
+ return;
1368
+ }
1369
+ i = _clampS(i | 0, 0, n - 1);
1370
+ const ki = kfs[i];
1371
+ const hasTI = ki[tanInName] != null;
1372
+ const hasTO = ki[tanOutName] != null;
1373
+
1374
+ if (hasTO) {
1375
+ outOut[0]=ki[tanOutName][0]; outOut[1]=ki[tanOutName][1]; outOut[2]=ki[tanOutName][2];
1376
+ } else if (hasTI) {
1377
+ outOut[0]=ki[tanInName][0]; outOut[1]=ki[tanInName][1]; outOut[2]=ki[tanInName][2];
1378
+ } else if (i < n - 1) {
1379
+ const k1 = kfs[i + 1];
1380
+ const p0 = i > 0 ? kfs[i - 1][field] : ki[field];
1381
+ _crTanOut(outOut, p0, ki[field], k1[field]);
1382
+ } else {
1383
+ outOut[0]=0; outOut[1]=0; outOut[2]=0;
1384
+ }
1385
+
1386
+ if (hasTI) {
1387
+ outIn[0]=ki[tanInName][0]; outIn[1]=ki[tanInName][1]; outIn[2]=ki[tanInName][2];
1388
+ } else if (hasTO) {
1389
+ outIn[0]=ki[tanOutName][0]; outIn[1]=ki[tanOutName][1]; outIn[2]=ki[tanOutName][2];
1390
+ } else if (i > 0) {
1391
+ const k0 = kfs[i - 1];
1392
+ const p3 = i + 1 < n ? kfs[i + 1][field] : ki[field];
1393
+ _crTanIn(outIn, k0[field], ki[field], p3);
1394
+ } else {
1395
+ outIn[0]=outOut[0]; outIn[1]=outOut[1]; outIn[2]=outOut[2];
1396
+ }
1397
+
1398
+ if (i === n - 1 && !hasTO && !hasTI) {
1399
+ outOut[0]=outIn[0]; outOut[1]=outIn[1]; outOut[2]=outIn[2];
1400
+ }
1401
+ }
1402
+
1292
1403
  // =========================================================================
1293
1404
  // S3 Transform <-> Mat4
1294
1405
  // =========================================================================
1295
1406
 
1296
1407
  /**
1297
1408
  * Write a TRS transform into a column-major mat4.
1298
- * @param {Float32Array|number[]} out 16-element column-major mat4.
1299
- * @param {{ pos:number[], rot:number[], scl:number[] }} xform
1300
- * @returns {Float32Array|number[]} out
1301
1409
  */
1302
1410
  const transformToMat4 = (out, xform) => {
1303
1411
  qToMat4(out, xform.rot);
@@ -1311,10 +1419,6 @@ const transformToMat4 = (out, xform) => {
1311
1419
 
1312
1420
  /**
1313
1421
  * Decompose a column-major mat4 into a TRS transform.
1314
- * Assumes no shear. Scale extracted from column lengths.
1315
- * @param {{ pos:number[], rot:number[], scl:number[] }} out
1316
- * @param {Float32Array|number[]} m Column-major mat4.
1317
- * @returns {{ pos:number[], rot:number[], scl:number[] }} out
1318
1422
  */
1319
1423
  const mat4ToTransform = (out, m) => {
1320
1424
  out.pos[0]=m[12]; out.pos[1]=m[13]; out.pos[2]=m[14];
@@ -1345,7 +1449,6 @@ function _parseVec3(v) {
1345
1449
  return null;
1346
1450
  }
1347
1451
 
1348
- // Euler: unit axis vectors and the six valid intrinsic orderings.
1349
1452
  const _EULER_AXES = { X:[1,0,0], Y:[0,1,0], Z:[0,0,1] };
1350
1453
  const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1351
1454
 
@@ -1353,14 +1456,13 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1353
1456
  * Parse any rotation representation into a unit quaternion [x,y,z,w].
1354
1457
  *
1355
1458
  * Accepted forms:
1356
- *
1357
1459
  * [x,y,z,w] — raw quaternion array
1358
- * { axis:[x,y,z], angle } — axis-angle
1460
+ * { axis:[x,y,z], angle } — axis-angle (angle in radians)
1359
1461
  * { dir:[x,y,z], up?:[x,y,z] } — forward direction (−Z) with optional up
1360
1462
  * { mat4Eye: mat4 } — rotation block of an eye matrix
1361
1463
  * { mat3: mat3 } — column-major 3×3 rotation matrix
1362
1464
  * { euler:[rx,ry,rz], order? } — intrinsic Euler (default order: YXZ)
1363
- * { 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
1364
1466
  *
1365
1467
  * @param {*} v
1366
1468
  * @returns {number[]|null} [x,y,z,w] or null if unparseable.
@@ -1368,7 +1470,7 @@ const _EULER_ORDERS = new Set(['XYZ','XZY','YXZ','YZX','ZXY','ZYX']);
1368
1470
  function _parseQuat(v) {
1369
1471
  if (!v) return null;
1370
1472
 
1371
- // Raw array [x,y,z,w]
1473
+ // [x,y,z,w]
1372
1474
  if (Array.isArray(v) && v.length === 4) return [v[0],v[1],v[2],v[3]];
1373
1475
  if (ArrayBuffer.isView(v) && v.length >= 4) return [v[0],v[1],v[2],v[3]];
1374
1476
 
@@ -1397,8 +1499,7 @@ function _parseQuat(v) {
1397
1499
 
1398
1500
  // { mat3 }
1399
1501
  if (v.mat3 != null) {
1400
- const m = (ArrayBuffer.isView(v.mat3) || Array.isArray(v.mat3))
1401
- ? v.mat3 : null;
1502
+ const m = (ArrayBuffer.isView(v.mat3) || Array.isArray(v.mat3)) ? v.mat3 : null;
1402
1503
  if (!m || m.length < 9) return null;
1403
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]);
1404
1505
  }
@@ -1408,8 +1509,7 @@ function _parseQuat(v) {
1408
1509
  const e = v.euler;
1409
1510
  if (!Array.isArray(e) || e.length < 3) return null;
1410
1511
  const order = (v.order && _EULER_ORDERS.has(v.order)) ? v.order : 'YXZ';
1411
- const q = [0,0,0,1];
1412
- const s = [0,0,0,1];
1512
+ const q = [0,0,0,1], s = [0,0,0,1];
1413
1513
  for (let i = 0; i < 3; i++) {
1414
1514
  const ax = _EULER_AXES[order[i]];
1415
1515
  qMul(q, q, qFromAxisAngle(s, ax[0],ax[1],ax[2], e[i]));
@@ -1444,22 +1544,22 @@ function _parseQuat(v) {
1444
1544
  }
1445
1545
 
1446
1546
  /**
1447
- * Parse a PoseTrack keyframe spec.
1547
+ * Parse a PoseTrack keyframe spec into internal form.
1448
1548
  *
1449
1549
  * Accepted forms:
1450
- *
1451
1550
  * { mat4Model }
1452
1551
  * Decompose a column-major mat4 into TRS via mat4ToTransform.
1453
1552
  * Float32Array(16), plain Array, or { mat4 } wrapper.
1454
1553
  *
1455
1554
  * { pos?, rot?, scl?, tanIn?, tanOut? }
1456
- * 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.
1457
1556
  * All fields are optional — missing pos/scl default to [0,0,0] / [1,1,1],
1458
1557
  * missing rot defaults to identity.
1459
1558
  * tanIn/tanOut are optional vec3 tangents for Hermite interpolation.
1460
1559
  *
1461
1560
  * @param {Object} spec
1462
- * @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}
1463
1563
  */
1464
1564
  function _parseSpec(spec) {
1465
1565
  if (!spec || typeof spec !== 'object') return null;
@@ -1474,6 +1574,7 @@ function _parseSpec(spec) {
1474
1574
  return kf;
1475
1575
  }
1476
1576
 
1577
+ // { pos?, rot?, scl?, tanIn?, tanOut? } — explicit TRS
1477
1578
  const pos = _parseVec3(spec.pos) || [0,0,0];
1478
1579
  const rot = _parseQuat(spec.rot) || [0,0,0,1];
1479
1580
  const scl = _parseVec3(spec.scl) || [1,1,1];
@@ -1493,30 +1594,21 @@ function _sameTransform(a, b) {
1493
1594
  // =========================================================================
1494
1595
 
1495
1596
  /**
1496
- * Parse a camera keyframe spec into internal { eye, center, up } form.
1497
- *
1498
- * Accepted forms:
1499
- *
1500
- * { eye, center?, up?, fov?, halfHeight?,
1501
- * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
1502
- * Explicit lookat. center defaults to [0,0,0], up defaults to [0,1,0].
1503
- * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite.
1504
- * When absent, centripetal Catmull-Rom tangents are auto-computed at eval time.
1597
+ * Parse a CameraTrack keyframe spec into internal form.
1505
1598
  *
1506
- * Removed forms (task 2):
1507
- * { mat4View } and { mat4Eye } — use PoseTrack.add({ mat4Model: mat4Eye }) for
1508
- * full-fidelity capture including roll, or cam.capturePose() for lookat-style.
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
1509
1606
  *
1510
1607
  * @param {Object} spec
1511
- * @returns {{ eye:number[], center:number[], up:number[],
1512
- * fov:number|null, halfHeight:number|null,
1513
- * eyeTanIn:number[]|null, eyeTanOut:number[]|null,
1514
- * centerTanIn:number[]|null, centerTanOut:number[]|null }|null}
1608
+ * @returns {Object|null} Parsed keyframe or null if eye is missing/malformed.
1515
1609
  */
1516
1610
  function _parseCameraSpec(spec) {
1517
1611
  if (!spec || typeof spec !== 'object') return null;
1518
-
1519
- // { eye, center?, up? } — explicit lookat
1520
1612
  const eye = _parseVec3(spec.eye);
1521
1613
  if (!eye) return null;
1522
1614
  const center = _parseVec3(spec.center) || [0,0,0];
@@ -1526,12 +1618,12 @@ function _parseCameraSpec(spec) {
1526
1618
  return {
1527
1619
  eye, center,
1528
1620
  up: [up[0]/ul, up[1]/ul, up[2]/ul],
1529
- fov: typeof spec.fov === 'number' ? spec.fov : null,
1530
- halfHeight: typeof spec.halfHeight === 'number' ? spec.halfHeight : null,
1531
- eyeTanIn: _parseVec3(spec.eyeTanIn) || null,
1532
- eyeTanOut: _parseVec3(spec.eyeTanOut) || null,
1533
- centerTanIn: _parseVec3(spec.centerTanIn) || null,
1534
- centerTanOut:_parseVec3(spec.centerTanOut)|| null,
1621
+ fov: typeof spec.fov === 'number' ? spec.fov : null,
1622
+ halfHeight: typeof spec.halfHeight === 'number' ? spec.halfHeight : null,
1623
+ eyeTanIn: _parseVec3(spec.eyeTanIn) || null,
1624
+ eyeTanOut: _parseVec3(spec.eyeTanOut) || null,
1625
+ centerTanIn: _parseVec3(spec.centerTanIn) || null,
1626
+ centerTanOut: _parseVec3(spec.centerTanOut)|| null,
1535
1627
  };
1536
1628
  }
1537
1629
 
@@ -1554,41 +1646,39 @@ class Track {
1554
1646
  constructor() {
1555
1647
  /** @type {Array} Keyframe array — shape depends on subclass. */
1556
1648
  this.keyframes = [];
1557
- /** Whether playback is active. @type {boolean} */
1649
+ /** Whether playback is currently active. @type {boolean} */
1558
1650
  this.playing = false;
1559
1651
  /** Loop at boundaries. @type {boolean} */
1560
1652
  this.loop = false;
1561
- /** Ping-pong bounce (takes precedence over loop). @type {boolean} */
1653
+ /** Ping-pong bounce at boundaries (independent of loop). @type {boolean} */
1562
1654
  this.bounce = false;
1563
1655
  /** Frames per segment (≥1). @type {number} */
1564
1656
  this.duration = 30;
1565
1657
  /** Current segment index. @type {number} */
1566
1658
  this.seg = 0;
1567
- /** Frame offset within segment (can be fractional). @type {number} */
1659
+ /** Frame offset within current segment (can be fractional). @type {number} */
1568
1660
  this.f = 0;
1569
1661
 
1570
- // Internal rate never directly starts/stops playback
1662
+ // Playback rate (signed: negative = reverse; 0 = frozen).
1571
1663
  this._rate = 1;
1572
- // Internal bounce direction: +1 forward, -1 backward.
1664
+ // Current bounce travel direction 1). Reset to 1 only on reset().
1573
1665
  this._dir = 1;
1574
- // Scratch: true once _dir has been flipped in bounce-once mode.
1666
+ // Whether a bounce-once has already flipped direction.
1575
1667
  this._bounced = false;
1576
1668
 
1577
- // User-space hooks
1578
- /** @type {Function|null} */ this.onPlay = null;
1579
- /** @type {Function|null} */ this.onEnd = null;
1580
- /** @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;
1581
1675
 
1582
- // Lib-space hooks (set by host layer, e.g. p5 bridge)
1583
- /** @type {Function|null} */ this._onActivate = null;
1584
- /** @type {Function|null} */ this._onDeactivate = null;
1585
- // Lib-space event mirrors — set by UI layer (trackUI), never touched by user code
1586
- /** @type {Function|null} */ this._onPlay = null;
1587
- /** @type {Function|null} */ this._onEnd = null;
1588
- /** @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;
1589
1679
  }
1590
1680
 
1591
- /** 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} */
1592
1682
  get rate() { return this._rate; }
1593
1683
  set rate(v) { this._rate = (_isNum(v)) ? v : 1; }
1594
1684
 
@@ -1596,31 +1686,46 @@ class Track {
1596
1686
  get segments() { return Math.max(0, this.keyframes.length - 1); }
1597
1687
 
1598
1688
  /**
1599
- * Start or update playback.
1600
- * @param {number|Object} [rateOrOpts] Numeric rate or options object:
1601
- * { 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.
1602
1712
  * @returns {Track} this
1603
1713
  */
1604
1714
  play(rateOrOpts) {
1605
1715
  if (this.keyframes.length === 0) return this;
1606
-
1607
- // One keyframe: snap cursor, no animation
1608
- if (this.keyframes.length === 1) {
1609
- this.seg = 0; this.f = 0;
1610
- return this;
1611
- }
1716
+ if (this.keyframes.length === 1) { this.seg = 0; this.f = 0; return this; }
1612
1717
 
1613
1718
  if (typeof rateOrOpts === 'number' && Number.isFinite(rateOrOpts)) {
1614
1719
  this._rate = rateOrOpts;
1615
1720
  } else if (rateOrOpts && typeof rateOrOpts === 'object') {
1616
1721
  const o = rateOrOpts;
1617
- 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);
1618
1723
  if ('loop' in o) this.loop = !!o.loop;
1619
1724
  if ('bounce' in o) this.bounce = !!o.bounce;
1620
- if (typeof o.onPlay === 'function') this.onPlay = o.onPlay;
1621
- if (typeof o.onEnd === 'function') this.onEnd = o.onEnd;
1622
- if (typeof o.onStop === 'function') this.onStop = o.onStop;
1623
- 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;
1624
1729
  }
1625
1730
 
1626
1731
  const nSeg = this.segments, dur = Math.max(1, this.duration | 0);
@@ -1641,8 +1746,11 @@ class Track {
1641
1746
  }
1642
1747
 
1643
1748
  /**
1644
- * Stop playback.
1645
- * @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).
1646
1754
  * @returns {Track} this
1647
1755
  */
1648
1756
  stop(rewind) {
@@ -1659,7 +1767,8 @@ class Track {
1659
1767
  }
1660
1768
 
1661
1769
  /**
1662
- * 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.
1663
1772
  * @returns {Track} this
1664
1773
  */
1665
1774
  reset() {
@@ -1676,9 +1785,10 @@ class Track {
1676
1785
  }
1677
1786
 
1678
1787
  /**
1679
- * 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.
1680
1790
  * @param {number} index
1681
- * @returns {boolean}
1791
+ * @returns {boolean} true if removed; false if index was invalid.
1682
1792
  */
1683
1793
  remove(index) {
1684
1794
  if (!_isNum(index)) return false;
@@ -1692,9 +1802,15 @@ class Track {
1692
1802
  }
1693
1803
 
1694
1804
  /**
1695
- * Seek to a normalised position [0,1] across the full path.
1696
- * @param {number} t Normalised time [0, 1].
1697
- * @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]
1698
1814
  * @returns {Track} this
1699
1815
  */
1700
1816
  seek(t, segIndex) {
@@ -1711,8 +1827,8 @@ class Track {
1711
1827
  }
1712
1828
 
1713
1829
  /**
1714
- * Normalised playback position [0,1].
1715
- * @returns {number}
1830
+ * Normalised cursor position across the whole track.
1831
+ * @returns {number} ∈ [0, 1]; 0 when there are no segments.
1716
1832
  */
1717
1833
  time() {
1718
1834
  const nSeg = this.segments;
@@ -1722,8 +1838,11 @@ class Track {
1722
1838
  }
1723
1839
 
1724
1840
  /**
1725
- * Snapshot of transport state.
1726
- * @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}}
1727
1846
  */
1728
1847
  info() {
1729
1848
  return {
@@ -1741,16 +1860,19 @@ class Track {
1741
1860
  }
1742
1861
 
1743
1862
  /**
1744
- * Advance cursor by rate frames.
1745
- * Returns true while playing, false when stopping.
1746
- * @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.
1747
1871
  */
1748
1872
  tick() {
1749
1873
  if (!this.playing) return false;
1750
1874
  const nSeg = this.segments;
1751
- if (nSeg === 0) {
1752
- this.playing = false; this._onDeactivate?.(); return false;
1753
- }
1875
+ if (nSeg === 0) { this.playing = false; this._onDeactivate?.(); return false; }
1754
1876
  if (this._rate === 0) return true;
1755
1877
 
1756
1878
  const dur = Math.max(1, this.duration | 0);
@@ -1758,7 +1880,6 @@ class Track {
1758
1880
  const s = _clampS(this.seg * dur + this.f, 0, total);
1759
1881
  const next = s + this._rate * this._dir;
1760
1882
 
1761
- // ── loop:true, bounce:true — bounce forever ───────────────────────────
1762
1883
  if (this.loop && this.bounce) {
1763
1884
  let pos = next, flips = 0;
1764
1885
  while (pos < 0 || pos > total) {
@@ -1770,17 +1891,14 @@ class Track {
1770
1891
  return true;
1771
1892
  }
1772
1893
 
1773
- // ── loop:false, bounce:true — bounce once, stop at origin ────────────
1774
1894
  if (!this.loop && this.bounce) {
1775
1895
  if (next >= total) {
1776
- // far boundary: reflect and flip direction once
1777
1896
  this._setCursorFromScalar(Math.min(total, 2 * total - next));
1778
1897
  this._dir = -this._dir;
1779
1898
  this._bounced = true;
1780
1899
  return true;
1781
1900
  }
1782
1901
  if (next <= 0) {
1783
- // origin: stop (whether we bounced or started backward)
1784
1902
  this._setCursorFromScalar(0);
1785
1903
  this.playing = false;
1786
1904
  this._dir = 1; this._bounced = false;
@@ -1793,13 +1911,11 @@ class Track {
1793
1911
  return true;
1794
1912
  }
1795
1913
 
1796
- // ── loop:true, bounce:false — repeat forever ──────────────────────────
1797
1914
  if (this.loop) {
1798
1915
  this._setCursorFromScalar(((next % total) + total) % total);
1799
1916
  return true;
1800
1917
  }
1801
1918
 
1802
- // ── loop:false, bounce:false — play once, stop at boundary ───────────
1803
1919
  if (next <= 0) {
1804
1920
  this._setCursorFromScalar(0);
1805
1921
  this.playing = false;
@@ -1844,7 +1960,7 @@ class Track {
1844
1960
  *
1845
1961
  * tanIn — incoming position tangent at this keyframe (Hermite mode).
1846
1962
  * tanOut — outgoing position tangent at this keyframe (Hermite mode).
1847
- * When only one is supplied, the other mirrors it.
1963
+ * When only one is supplied, the other mirrors it at sample time.
1848
1964
  * When neither is supplied, centripetal Catmull-Rom tangents are auto-computed.
1849
1965
  */
1850
1966
  class PoseTrack extends Track {
@@ -1852,8 +1968,8 @@ class PoseTrack extends Track {
1852
1968
  super();
1853
1969
  /**
1854
1970
  * Position interpolation mode.
1855
- * - 'hermite' — cubic Hermite; auto-computes centripetal Catmull-Rom tangents
1856
- * when none are stored (default)
1971
+ * - 'hermite' — cubic Hermite; auto-computes centripetal Catmull-Rom
1972
+ * tangents when none are stored (default)
1857
1973
  * - 'linear' — lerp
1858
1974
  * - 'step' — snap to k0; useful for discrete state changes
1859
1975
  * @type {'hermite'|'linear'|'step'}
@@ -1863,26 +1979,20 @@ class PoseTrack extends Track {
1863
1979
  * Rotation interpolation mode.
1864
1980
  * - 'slerp' — constant angular velocity (default)
1865
1981
  * - 'nlerp' — normalised lerp; cheaper, slightly non-constant speed
1866
- * - 'step' — snap to k0 quaternion; useful for discrete state changes
1982
+ * - 'step' — snap to k0 quaternion
1867
1983
  * @type {'slerp'|'nlerp'|'step'}
1868
1984
  */
1869
1985
  this.rotInterp = 'slerp';
1870
- // Scratch for toMatrix() — avoids hot-path allocations
1871
- this._pos = [0,0,0];
1872
- this._rot = [0,0,0,1];
1873
- this._scl = [1,1,1];
1874
1986
  }
1875
1987
 
1876
1988
  /**
1877
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.
1878
1991
  * @param {Object|Object[]} spec
1879
1992
  * @param {{ deduplicate?: boolean }} [opts]
1880
1993
  */
1881
1994
  add(spec, opts) {
1882
- if (Array.isArray(spec)) {
1883
- for (const s of spec) this.add(s, opts);
1884
- return;
1885
- }
1995
+ if (Array.isArray(spec)) { for (const s of spec) this.add(s, opts); return; }
1886
1996
  const kf = _parseSpec(spec);
1887
1997
  if (!kf) return;
1888
1998
  const dedup = !opts || opts.deduplicate !== false;
@@ -1893,10 +2003,11 @@ class PoseTrack extends Track {
1893
2003
  }
1894
2004
 
1895
2005
  /**
1896
- * 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.
1897
2008
  * @param {number} index
1898
- * @param {Object} spec
1899
- * @returns {boolean}
2009
+ * @param {Object} spec Any spec form understood by _parseSpec.
2010
+ * @returns {boolean} true on success; false for invalid index or spec.
1900
2011
  */
1901
2012
  set(index, spec) {
1902
2013
  if (!_isNum(index)) return false;
@@ -1908,15 +2019,64 @@ class PoseTrack extends Track {
1908
2019
  }
1909
2020
 
1910
2021
  /**
1911
- * Evaluate interpolated TRS pose at current cursor.
1912
- * @param {{ pos:number[], rot:number[], scl:number[] }} [out]
1913
- * @returns {{ pos:number[], rot:number[], scl:number[] }} out
2022
+ * Sample the position path. Zero-alloc, no cursor side effects. Honours
2023
+ * posInterp and the stored-tangent auto-CR fallback chain.
2024
+ *
2025
+ * Two signatures:
2026
+ * samplePos(out) cursor form — reads current seg/f
2027
+ * samplePos(out, seg, t) explicit (seg, t), continuous on the path
2028
+ *
2029
+ * @param {number[]} out 3-element result buffer.
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.
2032
+ * @returns {number[]} out
1914
2033
  */
1915
- eval(out) {
1916
- out = out || { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] };
2034
+ samplePos(out, seg, t) {
2035
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
2036
+ return _samplePathCore(out, this.keyframes, this.posInterp, 'pos', 'tanIn', 'tanOut', seg, t);
2037
+ }
2038
+
2039
+ /**
2040
+ * Write the TRS pose as a column-major model mat4. Zero-alloc, no cursor
2041
+ * side effects.
2042
+ *
2043
+ * Two signatures:
2044
+ * mat4Model(out) cursor form — reads 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
2053
+ */
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);
2058
+ }
2059
+
2060
+ /**
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
2070
+ */
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) {
1917
2078
  const n = this.keyframes.length;
1918
2079
  if (n === 0) return out;
1919
-
1920
2080
  if (n === 1) {
1921
2081
  const k = this.keyframes[0];
1922
2082
  out.pos[0]=k.pos[0]; out.pos[1]=k.pos[1]; out.pos[2]=k.pos[2];
@@ -1924,32 +2084,16 @@ class PoseTrack extends Track {
1924
2084
  out.scl[0]=k.scl[0]; out.scl[1]=k.scl[1]; out.scl[2]=k.scl[2];
1925
2085
  return out;
1926
2086
  }
1927
-
1928
2087
  const nSeg = n - 1;
1929
- const dur = Math.max(1, this.duration | 0);
1930
- const seg = _clampS(this.seg, 0, nSeg - 1);
1931
- const t = _clamp01(this.f / dur);
1932
- const k0 = this.keyframes[seg];
1933
- const k1 = this.keyframes[seg + 1];
1934
-
1935
- // pos — Hermite (auto-CR tangents when none stored), linear, or step
1936
- if (this.posInterp === 'step') {
1937
- out.pos[0]=k0.pos[0]; out.pos[1]=k0.pos[1]; out.pos[2]=k0.pos[2];
1938
- } else if (this.posInterp === 'linear') {
1939
- lerpVec3(out.pos, k0.pos, k1.pos, t);
1940
- } else {
1941
- const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1942
- seg + 2 < n ? this.keyframes[seg + 2].pos : k1.pos;
1943
- const m0 = k0.tanOut != null ? k0.tanOut
1944
- : k0.tanIn != null ? k0.tanIn
1945
- : _crTanOut(_m0, p0, k0.pos, k1.pos);
1946
- const m1 = k1.tanIn != null ? k1.tanIn
1947
- : k1.tanOut != null ? k1.tanOut
1948
- : _crTanIn(_m1, p0, k0.pos, k1.pos);
1949
- hermiteVec3(out.pos, k0.pos, m0, k1.pos, m1, t);
1950
- }
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];
2094
+
2095
+ _samplePathCore(out.pos, this.keyframes, this.posInterp, 'pos', 'tanIn', 'tanOut', seg, t);
1951
2096
 
1952
- // rot — step, slerp, or nlerp
1953
2097
  if (this.rotInterp === 'step') {
1954
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];
1955
2099
  } else if (this.rotInterp === 'nlerp') {
@@ -1958,20 +2102,22 @@ class PoseTrack extends Track {
1958
2102
  qSlerp(out.rot, k0.rot, k1.rot, t);
1959
2103
  }
1960
2104
 
1961
- // scl — lerp
1962
2105
  lerpVec3(out.scl, k0.scl, k1.scl, t);
1963
-
1964
2106
  return out;
1965
2107
  }
1966
2108
 
1967
2109
  /**
1968
- * Evaluate into an existing column-major mat4.
1969
- * @param {Float32Array|number[]} outMat4 16-element array.
1970
- * @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
1971
2113
  */
1972
- toMatrix(outMat4) {
1973
- const xf = this.eval({ pos: this._pos, rot: this._rot, scl: this._scl });
1974
- 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);
1975
2121
  }
1976
2122
  }
1977
2123
 
@@ -1990,21 +2136,18 @@ class PoseTrack extends Track {
1990
2136
  * fov — vertical fov (radians) for perspective cameras; null for ortho.
1991
2137
  * halfHeight — world-unit half-height of ortho frustum; null for perspective.
1992
2138
  * Both are optional and nullable. eval() lerps each only when both adjacent
1993
- * 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.
1994
2141
  *
1995
2142
  * eyeTanIn/Out and centerTanIn/Out are optional vec3 tangents for Hermite
1996
- * interpolation of the eye and center paths respectively.
1997
- * 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.
1998
2145
  *
1999
2146
  * Missing fields default to: center → [0,0,0], up → [0,1,0].
2000
2147
  *
2001
- * add() accepts individual specs or a bulk array of specs:
2002
- *
2003
- * { eye, center?, up?, fov?, halfHeight?,
2004
- * eyeTanIn?, eyeTanOut?, centerTanIn?, centerTanOut? }
2005
- *
2006
- * To capture a matrix-based pose, use PoseTrack.add({ mat4Model: mat4Eye })
2007
- * 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.
2008
2151
  */
2009
2152
  class CameraTrack extends Track {
2010
2153
  constructor() {
@@ -2022,15 +2165,13 @@ class CameraTrack extends Track {
2022
2165
  }
2023
2166
 
2024
2167
  /**
2025
- * 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.
2026
2170
  * @param {Object|Object[]} spec
2027
2171
  * @param {{ deduplicate?: boolean }} [opts]
2028
2172
  */
2029
2173
  add(spec, opts) {
2030
- if (Array.isArray(spec)) {
2031
- for (const s of spec) this.add(s, opts);
2032
- return;
2033
- }
2174
+ if (Array.isArray(spec)) { for (const s of spec) this.add(s, opts); return; }
2034
2175
  const kf = _parseCameraSpec(spec);
2035
2176
  if (!kf) return;
2036
2177
  const dedup = !opts || opts.deduplicate !== false;
@@ -2041,7 +2182,8 @@ class CameraTrack extends Track {
2041
2182
  }
2042
2183
 
2043
2184
  /**
2044
- * 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.
2045
2187
  * @param {number} index
2046
2188
  * @param {Object} spec
2047
2189
  * @returns {boolean}
@@ -2056,73 +2198,129 @@ class CameraTrack extends Track {
2056
2198
  }
2057
2199
 
2058
2200
  /**
2059
- * Evaluate interpolated camera pose at current cursor.
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
2060
2206
  *
2061
- * @param {{ eye:number[], center:number[], up:number[] }} [out]
2062
- * @returns {{ eye:number[], center:number[], up:number[] }} out
2207
+ * @param {number[]} out
2208
+ * @param {number} [seg]
2209
+ * @param {number} [t]
2210
+ * @returns {number[]} out
2063
2211
  */
2064
- eval(out) {
2065
- out = out || { eye:[0,0,0], center:[0,0,0], up:[0,1,0], fov:null, halfHeight:null };
2212
+ sampleEye(out, seg, t) {
2213
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
2214
+ return _samplePathCore(out, this.keyframes, this.eyeInterp, 'eye', 'eyeTanIn', 'eyeTanOut', seg, t);
2215
+ }
2216
+
2217
+ /**
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
+ *
2224
+ * @param {number[]} out
2225
+ * @param {number} [seg]
2226
+ * @param {number} [t]
2227
+ * @returns {number[]} out
2228
+ */
2229
+ sampleCenter(out, seg, t) {
2230
+ if (arguments.length < 3) [seg, t] = this._cursorSegT();
2231
+ return _samplePathCore(out, this.keyframes, this.centerInterp, 'center', 'centerTanIn', 'centerTanOut', seg, t);
2232
+ }
2233
+
2234
+ /**
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
2246
+ */
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]);
2255
+ }
2256
+
2257
+ /**
2258
+ * Effective in/out eye tangents at keyframe `index`.
2259
+ */
2260
+ eyeTangents(outIn, outOut, index) {
2261
+ _sampleTangentsCore(outIn, outOut, this.keyframes, 'eye', 'eyeTanIn', 'eyeTanOut', index);
2262
+ return this;
2263
+ }
2264
+
2265
+ /**
2266
+ * Effective in/out center tangents at keyframe `index`.
2267
+ */
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) {
2066
2275
  const n = this.keyframes.length;
2067
2276
  if (n === 0) return out;
2068
-
2069
2277
  if (n === 1) {
2070
2278
  const k = this.keyframes[0];
2071
2279
  out.eye[0]=k.eye[0]; out.eye[1]=k.eye[1]; out.eye[2]=k.eye[2];
2072
2280
  out.center[0]=k.center[0]; out.center[1]=k.center[1]; out.center[2]=k.center[2];
2073
2281
  out.up[0]=k.up[0]; out.up[1]=k.up[1]; out.up[2]=k.up[2];
2074
- out.fov = k.fov;
2075
- out.halfHeight = k.halfHeight;
2076
2282
  return out;
2077
2283
  }
2078
-
2079
2284
  const nSeg = n - 1;
2080
- const dur = Math.max(1, this.duration | 0);
2081
- const seg = _clampS(this.seg, 0, nSeg - 1);
2082
- const t = _clamp01(this.f / dur);
2083
- const k0 = this.keyframes[seg];
2084
- const k1 = this.keyframes[seg + 1];
2085
-
2086
- // eye — Hermite (auto-CR tangents when none stored), linear, or step
2087
- if (this.eyeInterp === 'step') {
2088
- out.eye[0]=k0.eye[0]; out.eye[1]=k0.eye[1]; out.eye[2]=k0.eye[2];
2089
- } else if (this.eyeInterp === 'linear') {
2090
- lerpVec3(out.eye, k0.eye, k1.eye, t);
2091
- } else {
2092
- const p0 = seg > 0 ? this.keyframes[seg - 1].eye : k0.eye;
2093
- seg + 2 < n ? this.keyframes[seg + 2].eye : k1.eye;
2094
- const m0 = k0.eyeTanOut != null ? k0.eyeTanOut
2095
- : k0.eyeTanIn != null ? k0.eyeTanIn
2096
- : _crTanOut(_m0, p0, k0.eye, k1.eye);
2097
- const m1 = k1.eyeTanIn != null ? k1.eyeTanIn
2098
- : k1.eyeTanOut != null ? k1.eyeTanOut
2099
- : _crTanIn(_m1, p0, k0.eye, k1.eye);
2100
- hermiteVec3(out.eye, k0.eye, m0, k1.eye, m1, t);
2101
- }
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];
2102
2291
 
2103
- // center — Hermite, linear, or step (independent lookat target)
2104
- if (this.centerInterp === 'step') {
2105
- out.center[0]=k0.center[0]; out.center[1]=k0.center[1]; out.center[2]=k0.center[2];
2106
- } else if (this.centerInterp === 'hermite') {
2107
- const c0 = seg > 0 ? this.keyframes[seg - 1].center : k0.center;
2108
- seg + 2 < n ? this.keyframes[seg + 2].center : k1.center;
2109
- const m0 = k0.centerTanOut != null ? k0.centerTanOut
2110
- : k0.centerTanIn != null ? k0.centerTanIn
2111
- : _crTanOut(_m0, c0, k0.center, k1.center);
2112
- const m1 = k1.centerTanIn != null ? k1.centerTanIn
2113
- : k1.centerTanOut != null ? k1.centerTanOut
2114
- : _crTanIn(_m1, c0, k0.center, k1.center);
2115
- hermiteVec3(out.center, k0.center, m0, k1.center, m1, t);
2116
- } else {
2117
- lerpVec3(out.center, k0.center, k1.center, t);
2118
- }
2292
+ _samplePathCore(out.eye, this.keyframes, this.eyeInterp, 'eye', 'eyeTanIn', 'eyeTanOut', seg, t);
2293
+ _samplePathCore(out.center, this.keyframes, this.centerInterp, 'center', 'centerTanIn', 'centerTanOut', seg, t);
2119
2294
 
2120
- // up — nlerp on unit sphere
2121
2295
  lerpVec3(out.up, k0.up, k1.up, t);
2122
- 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;
2123
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);
2124
2323
 
2125
- // fov / halfHeight — lerp when both keyframes carry non-null values
2126
2324
  out.fov = (k0.fov != null && k1.fov != null)
2127
2325
  ? k0.fov + t * (k1.fov - k0.fov) : (k0.fov ?? k1.fov ?? null);
2128
2326
  out.halfHeight = (k0.halfHeight != null && k1.halfHeight != null)