@nakednous/tree 0.0.20 → 0.0.22

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