@nakednous/tree 0.0.1 → 0.0.3

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
@@ -881,55 +881,62 @@ function pixelRatio(proj, vpH, eyeZ, ndcZMin) {
881
881
  }
882
882
 
883
883
  /**
884
- * @file Pure quaternion/spline math + PoseTrack state machine.
884
+ * @file Pure quaternion/spline math + track state machines.
885
885
  * @module tree/track
886
886
  * @license GPL-3.0-only
887
887
  *
888
888
  * Zero dependencies. No p5, DOM, WebGL, or WebGPU usage.
889
- * All quaternion operations use flat [x,y,z,w] arrays (w-last = glTF layout).
890
- * PoseTrack is a pure state machine that stores {pos,rot,scl} keyframes
891
- * and advances a cursor via tick().
892
889
  *
893
890
  * ── Exports ──────────────────────────────────────────────────────────────────
894
891
  * Quaternion helpers
895
- * qSet qCopy qDot qNormalize qNegate qMul qSlerp
892
+ * qSet qCopy qDot qNormalize qNegate qMul qSlerp qNlerp
896
893
  * qFromAxisAngle qFromLookDir qFromRotMat3x3 qFromMat4 qToMat4
897
894
  * quatToAxisAngle
898
895
  * Spline / vector helpers
899
896
  * catmullRomVec3 lerpVec3
900
897
  * Transform / mat4 helpers
901
898
  * transformToMat4 mat4ToTransform
902
- * Track
903
- * PoseTrack
899
+ * Tracks
900
+ * PoseTrack — { pos, rot, scl } TRS keyframes
901
+ * CameraTrack — { eye, center, up } lookat keyframes
902
+ *
903
+ * ── Class hierarchy ───────────────────────────────────────────────────────────
904
+ * Track (unexported, never instantiated directly)
905
+ * └── PoseTrack (exported)
906
+ * └── CameraTrack (exported)
907
+ *
908
+ * Track holds all transport machinery: cursor, play/stop/seek/tick,
909
+ * hooks, rate semantics. Subclasses add only keyframe storage and
910
+ * add() / eval() for their respective data shape.
904
911
  *
905
912
  * ── Hook architecture ─────────────────────────────────────────────────────────
906
913
  * _onActivate / _onDeactivate — lib-space (underscore, set by host layer)
907
- * Fire exactly on playing transitions: false→true / true→false.
908
- * Used by the addon to register/unregister from the draw-loop tick set.
914
+ * Fire on playing transitions: false→true / true→false.
909
915
  *
910
- * onPlay / onEnd — user-space (public, set by user)
911
- * onPlay : fires in play() when playback actually starts (transition).
912
- * onEnd : fires in tick() when cursor reaches a natural boundary.
913
- * onEnd does NOT fire on explicit stop() / reset().
916
+ * onPlay / onEnd / onStop — user-space (public)
917
+ * onPlay : fires in play() on false→true transition.
918
+ * onEnd : fires in tick() at natural boundary (once mode only).
919
+ * onStop : fires in stop() / reset() — explicit deactivation.
920
+ * onEnd and onStop are mutually exclusive per event.
914
921
  *
915
922
  * Firing order:
916
- * play() → user onPlay → lib _onActivate
917
- * tick() → user onEnd → lib _onDeactivate
918
- * stop() → lib _onDeactivate (no user hook)
919
- * reset() → lib _onDeactivate (no user hook)
923
+ * play() → onPlay → _onActivate
924
+ * tick() → onEnd → _onDeactivate
925
+ * stop() → onStop _onDeactivate
926
+ * reset() → onStop _onDeactivate
920
927
  *
921
928
  * ── Playback semantics (rate) ─────────────────────────────────────────────────
922
- * rate > 0 forward playback
923
- * rate < 0 backward playback
924
- * rate === 0 frozen: tick() is a no-op; the playing flag is NOT changed.
929
+ * rate > 0 forward
930
+ * rate < 0 backward
931
+ * rate === 0 frozen: tick() no-op; playing unchanged
925
932
  *
926
- * play() is the sole method that sets playing = true.
927
- * stop() is the sole method that sets playing = false.
928
- * Assigning rate never implicitly starts or stops playback.
933
+ * play() is the sole setter of playing = true.
934
+ * stop() is the sole setter of playing = false.
935
+ * Assigning rate never starts or stops playback.
929
936
  *
930
937
  * ── One-keyframe behaviour ────────────────────────────────────────────────────
931
938
  * play() with exactly one keyframe snaps eval() to that keyframe without
932
- * setting playing = true and without animating.
939
+ * setting playing = true and without firing hooks.
933
940
  */
934
941
 
935
942
 
@@ -950,174 +957,155 @@ const qCopy = (out, a) => {
950
957
  /** Dot product of two quaternions. */
951
958
  const qDot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
952
959
 
953
- /** Normalise in-place. @returns {number[]} out */
960
+ /** Normalise quaternion in-place. @returns {number[]} out */
954
961
  const qNormalize = (out) => {
955
- const len = Math.sqrt(qDot(out, out)) || 1;
956
- out[0] /= len; out[1] /= len; out[2] /= len; out[3] /= len;
957
- return out;
962
+ const l = Math.sqrt(out[0]*out[0]+out[1]*out[1]+out[2]*out[2]+out[3]*out[3]) || 1;
963
+ out[0]/=l; out[1]/=l; out[2]/=l; out[3]/=l; return out;
958
964
  };
959
965
 
960
- /** Negate all components in-place. @returns {number[]} out */
961
- const qNegate = (out) => {
962
- out[0] = -out[0]; out[1] = -out[1]; out[2] = -out[2]; out[3] = -out[3];
963
- return out;
966
+ /** Negate quaternion (same rotation, different hemisphere). @returns {number[]} out */
967
+ const qNegate = (out, a) => {
968
+ out[0]=-a[0]; out[1]=-a[1]; out[2]=-a[2]; out[3]=-a[3]; return out;
964
969
  };
965
970
 
966
- /** out = a * b (Hamilton product). @returns {number[]} out */
971
+ /** Hamilton product out = a * b. @returns {number[]} out */
967
972
  const qMul = (out, a, b) => {
968
- const ax = a[0], ay = a[1], az = a[2], aw = a[3];
969
- const bx = b[0], by = b[1], bz = b[2], bw = b[3];
970
- out[0] = aw*bx + ax*bw + ay*bz - az*by;
971
- out[1] = aw*by - ax*bz + ay*bw + az*bx;
972
- out[2] = aw*bz + ax*by - ay*bx + az*bw;
973
- out[3] = aw*bw - ax*bx - ay*by - az*bz;
973
+ const ax=a[0],ay=a[1],az=a[2],aw=a[3], bx=b[0],by=b[1],bz=b[2],bw=b[3];
974
+ out[0]=aw*bx+ax*bw+ay*bz-az*by;
975
+ out[1]=aw*by-ax*bz+ay*bw+az*bx;
976
+ out[2]=aw*bz+ax*by-ay*bx+az*bw;
977
+ out[3]=aw*bw-ax*bx-ay*by-az*bz;
974
978
  return out;
975
979
  };
976
980
 
981
+ /** Spherical linear interpolation. @returns {number[]} out */
982
+ const qSlerp = (out, a, b, t) => {
983
+ let bx=b[0],by=b[1],bz=b[2],bw=b[3];
984
+ let d = a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw;
985
+ if (d < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; d=-d; }
986
+ let f0, f1;
987
+ if (1-d > 1e-10) {
988
+ const th=Math.acos(d), st=Math.sin(th);
989
+ f0=Math.sin((1-t)*th)/st; f1=Math.sin(t*th)/st;
990
+ } else {
991
+ f0=1-t; f1=t;
992
+ }
993
+ out[0]=a[0]*f0+bx*f1; out[1]=a[1]*f0+by*f1;
994
+ out[2]=a[2]*f0+bz*f1; out[3]=a[3]*f0+bw*f1;
995
+ return qNormalize(out);
996
+ };
997
+
977
998
  /**
978
- * SLERP between quaternions a and b at parameter t.
979
- * Shortest-arc: negates b when dot < 0.
980
- * Near-equal fallback: nlerp when dot ~= 1.
981
- * @param {number[]} out 4-element result array.
982
- * @param {number[]} a Start quaternion [x,y,z,w].
983
- * @param {number[]} b End quaternion [x,y,z,w].
984
- * @param {number} t Blend [0, 1].
999
+ * Normalised linear interpolation (nlerp).
1000
+ * Cheaper than slerp; slightly non-constant angular velocity.
1001
+ * Handles antipodal quats by flipping b when dot < 0.
985
1002
  * @returns {number[]} out
986
1003
  */
987
- const qSlerp = (out, a, b, t) => {
988
- let d = qDot(a, b);
989
- let bx = b[0], by = b[1], bz = b[2], bw = b[3];
990
- if (d < 0) { d = -d; bx = -bx; by = -by; bz = -bz; bw = -bw; }
991
- if (d > 0.9995) {
992
- out[0] = a[0] + t*(bx - a[0]);
993
- out[1] = a[1] + t*(by - a[1]);
994
- out[2] = a[2] + t*(bz - a[2]);
995
- out[3] = a[3] + t*(bw - a[3]);
996
- return qNormalize(out);
997
- }
998
- const theta = Math.acos(d), sinT = Math.sin(theta);
999
- const s0 = Math.sin((1 - t) * theta) / sinT;
1000
- const s1 = Math.sin(t * theta) / sinT;
1001
- out[0] = s0*a[0] + s1*bx;
1002
- out[1] = s0*a[1] + s1*by;
1003
- out[2] = s0*a[2] + s1*bz;
1004
- out[3] = s0*a[3] + s1*bw;
1005
- return out;
1004
+ const qNlerp = (out, a, b, t) => {
1005
+ let bx=b[0],by=b[1],bz=b[2],bw=b[3];
1006
+ if (a[0]*bx+a[1]*by+a[2]*bz+a[3]*bw < 0) { bx=-bx; by=-by; bz=-bz; bw=-bw; }
1007
+ out[0]=a[0]+t*(bx-a[0]); out[1]=a[1]+t*(by-a[1]);
1008
+ out[2]=a[2]+t*(bz-a[2]); out[3]=a[3]+t*(bw-a[3]);
1009
+ return qNormalize(out);
1006
1010
  };
1007
1011
 
1008
1012
  /**
1009
- * Build a quaternion from an axis-angle rotation.
1010
- * The axis need not be normalised.
1013
+ * Build a quaternion from axis-angle.
1014
+ * @param {number[]} out
1015
+ * @param {number} ax @param {number} ay @param {number} az Axis (need not be unit).
1016
+ * @param {number} angle Radians.
1011
1017
  * @returns {number[]} out
1012
1018
  */
1013
1019
  const qFromAxisAngle = (out, ax, ay, az, angle) => {
1014
1020
  const half = angle * 0.5;
1015
1021
  const s = Math.sin(half);
1016
1022
  const len = Math.sqrt(ax*ax + ay*ay + az*az) || 1;
1017
- out[0] = s * ax / len;
1018
- out[1] = s * ay / len;
1019
- out[2] = s * az / len;
1023
+ out[0] = s * ax / len; out[1] = s * ay / len; out[2] = s * az / len;
1020
1024
  out[3] = Math.cos(half);
1021
1025
  return out;
1022
1026
  };
1023
1027
 
1024
1028
  /**
1025
- * Build a quaternion from a look direction (negative-Z forward convention)
1026
- * and an optional up vector (defaults to +Y).
1029
+ * Build a quaternion from a look direction (Z forward) and optional up (default +Y).
1027
1030
  * @param {number[]} out
1028
1031
  * @param {number[]} dir Forward direction [x,y,z].
1029
1032
  * @param {number[]} [up] Up vector [x,y,z].
1030
1033
  * @returns {number[]} out
1031
1034
  */
1032
1035
  const qFromLookDir = (out, dir, up) => {
1033
- let fx = dir[0], fy = dir[1], fz = dir[2];
1034
- const fLen = Math.sqrt(fx*fx + fy*fy + fz*fz) || 1;
1035
- fx /= fLen; fy /= fLen; fz /= fLen;
1036
- let ux = up ? up[0] : 0, uy = up ? up[1] : 1, uz = up ? up[2] : 0;
1037
- let rx = uy*fz - uz*fy, ry = uz*fx - ux*fz, rz = ux*fy - uy*fx;
1038
- const rLen = Math.sqrt(rx*rx + ry*ry + rz*rz) || 1;
1039
- rx /= rLen; ry /= rLen; rz /= rLen;
1040
- ux = fy*rz - fz*ry; uy = fz*rx - fx*rz; uz = fx*ry - fy*rx;
1041
- return qFromRotMat3x3(out, rx, ry, rz, ux, uy, uz, -fx, -fy, -fz);
1036
+ let fx=dir[0],fy=dir[1],fz=dir[2];
1037
+ const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1038
+ fx/=fl; fy/=fl; fz/=fl;
1039
+ let ux=up?up[0]:0, uy=up?up[1]:1, uz=up?up[2]:0;
1040
+ let rx=uy*fz-uz*fy, ry=uz*fx-ux*fz, rz=ux*fy-uy*fx;
1041
+ const rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1;
1042
+ rx/=rl; ry/=rl; rz/=rl;
1043
+ ux=fy*rz-fz*ry; uy=fz*rx-fx*rz; uz=fx*ry-fy*rx;
1044
+ return qFromRotMat3x3(out, rx,ry,rz, ux,uy,uz, -fx,-fy,-fz);
1042
1045
  };
1043
1046
 
1044
1047
  /**
1045
- * Build a quaternion from a 3x3 rotation matrix supplied as 9 row-major scalars.
1048
+ * Build a quaternion from a 3×3 rotation matrix (9 row-major scalars).
1046
1049
  * @returns {number[]} out (normalised)
1047
1050
  */
1048
- const qFromRotMat3x3 = (out, m00, m01, m02, m10, m11, m12, m20, m21, m22) => {
1049
- const tr = m00 + m11 + m22;
1051
+ const qFromRotMat3x3 = (out, m00,m01,m02, m10,m11,m12, m20,m21,m22) => {
1052
+ const tr = m00+m11+m22;
1050
1053
  if (tr > 0) {
1051
- const s = 0.5 / Math.sqrt(tr + 1);
1052
- out[3] = 0.25 / s;
1053
- out[0] = (m21 - m12) * s;
1054
- out[1] = (m02 - m20) * s;
1055
- out[2] = (m10 - m01) * s;
1056
- } else if (m00 > m11 && m00 > m22) {
1057
- const s = 2 * Math.sqrt(1 + m00 - m11 - m22);
1058
- out[3] = (m21 - m12) / s;
1059
- out[0] = 0.25 * s;
1060
- out[1] = (m01 + m10) / s;
1061
- out[2] = (m02 + m20) / s;
1062
- } else if (m11 > m22) {
1063
- const s = 2 * Math.sqrt(1 + m11 - m00 - m22);
1064
- out[3] = (m02 - m20) / s;
1065
- out[0] = (m01 + m10) / s;
1066
- out[1] = 0.25 * s;
1067
- out[2] = (m12 + m21) / s;
1054
+ const s=0.5/Math.sqrt(tr+1);
1055
+ out[3]=0.25/s; out[0]=(m21-m12)*s; out[1]=(m02-m20)*s; out[2]=(m10-m01)*s;
1056
+ } else if (m00>m11 && m00>m22) {
1057
+ const s=2*Math.sqrt(1+m00-m11-m22);
1058
+ out[3]=(m21-m12)/s; out[0]=0.25*s; out[1]=(m01+m10)/s; out[2]=(m02+m20)/s;
1059
+ } else if (m11>m22) {
1060
+ const s=2*Math.sqrt(1+m11-m00-m22);
1061
+ out[3]=(m02-m20)/s; out[0]=(m01+m10)/s; out[1]=0.25*s; out[2]=(m12+m21)/s;
1068
1062
  } else {
1069
- const s = 2 * Math.sqrt(1 + m22 - m00 - m11);
1070
- out[3] = (m10 - m01) / s;
1071
- out[0] = (m02 + m20) / s;
1072
- out[1] = (m12 + m21) / s;
1073
- out[2] = 0.25 * s;
1063
+ const s=2*Math.sqrt(1+m22-m00-m11);
1064
+ out[3]=(m10-m01)/s; out[0]=(m02+m20)/s; out[1]=(m12+m21)/s; out[2]=0.25*s;
1074
1065
  }
1075
1066
  return qNormalize(out);
1076
1067
  };
1077
1068
 
1078
1069
  /**
1079
- * Extract a unit quaternion from the upper-left 3x3 of a column-major mat4.
1070
+ * Extract a unit quaternion from the upper-left 3×3 of a column-major mat4.
1080
1071
  * @param {number[]} out
1081
1072
  * @param {Float32Array|number[]} m Column-major mat4.
1082
1073
  * @returns {number[]} out
1083
1074
  */
1084
1075
  const qFromMat4 = (out, m) =>
1085
- qFromRotMat3x3(out, m[0], m[4], m[8], m[1], m[5], m[9], m[2], m[6], m[10]);
1076
+ qFromRotMat3x3(out, m[0],m[4],m[8], m[1],m[5],m[9], m[2],m[6],m[10]);
1086
1077
 
1087
1078
  /**
1088
- * Write a quaternion into a column-major mat4 (rotation block only;
1089
- * translation and perspective rows/cols are set to identity values).
1079
+ * Write a quaternion into the rotation block of a column-major mat4.
1080
+ * Translation and perspective rows/cols are set to identity values.
1090
1081
  * @param {Float32Array|number[]} out 16-element array.
1091
1082
  * @param {number[]} q [x,y,z,w].
1092
1083
  * @returns {Float32Array|number[]} out
1093
1084
  */
1094
1085
  const qToMat4 = (out, q) => {
1095
- const x = q[0], y = q[1], z = q[2], w = q[3];
1096
- const x2 = x+x, y2 = y+y, z2 = z+z;
1097
- const xx = x*x2, xy = x*y2, xz = x*z2;
1098
- const yy = y*y2, yz = y*z2, zz = z*z2;
1099
- const wx = w*x2, wy = w*y2, wz = w*z2;
1100
- out[0] = 1-(yy+zz); out[1] = xy+wz; out[2] = xz-wy; out[3] = 0;
1101
- out[4] = xy-wz; out[5] = 1-(xx+zz); out[6] = yz+wx; out[7] = 0;
1102
- out[8] = xz+wy; out[9] = yz-wx; out[10] = 1-(xx+yy); out[11] = 0;
1103
- out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1;
1086
+ const x=q[0],y=q[1],z=q[2],w=q[3];
1087
+ const x2=x+x,y2=y+y,z2=z+z;
1088
+ const xx=x*x2,xy=x*y2,xz=x*z2,yy=y*y2,yz=y*z2,zz=z*z2,wx=w*x2,wy=w*y2,wz=w*z2;
1089
+ out[0]=1-(yy+zz); out[1]=xy+wz; out[2]=xz-wy; out[3]=0;
1090
+ out[4]=xy-wz; out[5]=1-(xx+zz); out[6]=yz+wx; out[7]=0;
1091
+ out[8]=xz+wy; out[9]=yz-wx; out[10]=1-(xx+yy); out[11]=0;
1092
+ out[12]=0; out[13]=0; out[14]=0; out[15]=1;
1104
1093
  return out;
1105
1094
  };
1106
1095
 
1107
1096
  /**
1108
1097
  * Decompose a unit quaternion into { axis:[x,y,z], angle } (radians).
1109
- * out is optional; a new object is returned if omitted.
1110
1098
  * @param {number[]} q [x,y,z,w].
1111
1099
  * @param {Object} [out]
1112
1100
  * @returns {{ axis: number[], angle: number }}
1113
1101
  */
1114
1102
  const quatToAxisAngle = (q, out) => {
1115
1103
  out = out || {};
1116
- const x = q[0], y = q[1], z = q[2], w = q[3];
1117
- const sinHalf = Math.sqrt(x*x + y*y + z*z);
1118
- if (sinHalf < 1e-8) { out.axis = [0, 1, 0]; out.angle = 0; return out; }
1119
- out.angle = 2 * Math.atan2(sinHalf, w);
1120
- out.axis = [x / sinHalf, y / sinHalf, z / sinHalf];
1104
+ const x=q[0],y=q[1],z=q[2],w=q[3];
1105
+ const sinHalf = Math.sqrt(x*x+y*y+z*z);
1106
+ if (sinHalf < 1e-8) { out.axis=[0,1,0]; out.angle=0; return out; }
1107
+ out.angle = 2*Math.atan2(sinHalf, w);
1108
+ out.axis = [x/sinHalf, y/sinHalf, z/sinHalf];
1121
1109
  return out;
1122
1110
  };
1123
1111
 
@@ -1126,36 +1114,34 @@ const quatToAxisAngle = (q, out) => {
1126
1114
  // =========================================================================
1127
1115
 
1128
1116
  function _dist3(a, b) {
1129
- const dx = a[0]-b[0], dy = a[1]-b[1], dz = a[2]-b[2];
1130
- return Math.sqrt(dx*dx + dy*dy + dz*dz);
1117
+ const dx=a[0]-b[0], dy=a[1]-b[1], dz=a[2]-b[2];
1118
+ return Math.sqrt(dx*dx+dy*dy+dz*dz);
1131
1119
  }
1132
1120
 
1133
1121
  /**
1134
- * Centripetal Catmull-Rom interpolation (alpha = 0.5, Barry-Goldman).
1135
- * out = interp(p0, p1, p2, p3, t) where t in [0,1] maps p1 -> p2.
1136
- * When p0 === p1 or p2 === p3 (boundary), the chord is reused, giving
1137
- * zero-tension clamped end tangents.
1122
+ * Centripetal Catmull-Rom interpolation (alpha=0.5, Barry-Goldman).
1123
+ * out = interp(p0, p1, p2, p3, t) where t[0,1] maps p1p2.
1124
+ * Boundary: p0===p1 or p2===p3 clamps the end tangent.
1138
1125
  * @param {number[]} out 3-element result.
1139
- * @param {number[]} p0 Control point before p1.
1140
- * @param {number[]} p1 Start of this segment.
1141
- * @param {number[]} p2 End of this segment.
1142
- * @param {number[]} p3 Control point after p2.
1143
- * @param {number} t Blend [0, 1].
1126
+ * @param {number[]} p0 Control point before p1.
1127
+ * @param {number[]} p1 Segment start.
1128
+ * @param {number[]} p2 Segment end.
1129
+ * @param {number[]} p3 Control point after p2.
1130
+ * @param {number} t Blend [0, 1].
1144
1131
  * @returns {number[]} out
1145
1132
  */
1146
1133
  const catmullRomVec3 = (out, p0, p1, p2, p3, t) => {
1147
1134
  const alpha = 0.5;
1148
- const dt0 = Math.pow(_dist3(p0, p1), alpha) || 1;
1149
- const dt1 = Math.pow(_dist3(p1, p2), alpha) || 1;
1150
- const dt2 = Math.pow(_dist3(p2, p3), alpha) || 1;
1135
+ const dt0 = Math.pow(_dist3(p0,p1), alpha) || 1;
1136
+ const dt1 = Math.pow(_dist3(p1,p2), alpha) || 1;
1137
+ const dt2 = Math.pow(_dist3(p2,p3), alpha) || 1;
1151
1138
  for (let i = 0; i < 3; i++) {
1152
1139
  const t1_0 = (p1[i]-p0[i])/dt0 - (p2[i]-p0[i])/(dt0+dt1) + (p2[i]-p1[i])/dt1;
1153
1140
  const t2_0 = (p2[i]-p1[i])/dt1 - (p3[i]-p1[i])/(dt1+dt2) + (p3[i]-p2[i])/dt2;
1154
- const m1 = t1_0 * dt1;
1155
- const m2 = t2_0 * dt1;
1156
- const a = 2*p1[i] - 2*p2[i] + m1 + m2;
1157
- const b = -3*p1[i] + 3*p2[i] - 2*m1 - m2;
1158
- out[i] = a*t*t*t + b*t*t + m1*t + p1[i];
1141
+ const m1=t1_0*dt1, m2=t2_0*dt1;
1142
+ const a= 2*p1[i]-2*p2[i]+m1+m2;
1143
+ const b=-3*p1[i]+3*p2[i]-2*m1-m2;
1144
+ out[i] = a*t*t*t + b*t*t + m1*t + p1[i];
1159
1145
  }
1160
1146
  return out;
1161
1147
  };
@@ -1169,9 +1155,9 @@ const catmullRomVec3 = (out, p0, p1, p2, p3, t) => {
1169
1155
  * @returns {number[]} out
1170
1156
  */
1171
1157
  const lerpVec3 = (out, a, b, t) => {
1172
- out[0] = a[0] + t*(b[0]-a[0]);
1173
- out[1] = a[1] + t*(b[1]-a[1]);
1174
- out[2] = a[2] + t*(b[2]-a[2]);
1158
+ out[0]=a[0]+t*(b[0]-a[0]);
1159
+ out[1]=a[1]+t*(b[1]-a[1]);
1160
+ out[2]=a[2]+t*(b[2]-a[2]);
1175
1161
  return out;
1176
1162
  };
1177
1163
 
@@ -1181,45 +1167,42 @@ const lerpVec3 = (out, a, b, t) => {
1181
1167
 
1182
1168
  /**
1183
1169
  * Write a TRS transform into a column-major mat4.
1184
- * Rotation is encoded as a quaternion; scale is baked into rotation columns.
1185
1170
  * @param {Float32Array|number[]} out 16-element column-major mat4.
1186
1171
  * @param {{ pos:number[], rot:number[], scl:number[] }} xform
1187
1172
  * @returns {Float32Array|number[]} out
1188
1173
  */
1189
1174
  const transformToMat4 = (out, xform) => {
1190
1175
  qToMat4(out, xform.rot);
1191
- const sx = xform.scl[0], sy = xform.scl[1], sz = xform.scl[2];
1192
- out[0] *= sx; out[1] *= sx; out[2] *= sx;
1193
- out[4] *= sy; out[5] *= sy; out[6] *= sy;
1194
- out[8] *= sz; out[9] *= sz; out[10] *= sz;
1195
- out[12] = xform.pos[0];
1196
- out[13] = xform.pos[1];
1197
- out[14] = xform.pos[2];
1176
+ const sx=xform.scl[0], sy=xform.scl[1], sz=xform.scl[2];
1177
+ out[0]*=sx; out[1]*=sx; out[2]*=sx;
1178
+ out[4]*=sy; out[5]*=sy; out[6]*=sy;
1179
+ out[8]*=sz; out[9]*=sz; out[10]*=sz;
1180
+ out[12]=xform.pos[0]; out[13]=xform.pos[1]; out[14]=xform.pos[2];
1198
1181
  return out;
1199
1182
  };
1200
1183
 
1201
1184
  /**
1202
1185
  * Decompose a column-major mat4 into a TRS transform.
1203
- * Assumes no shear. Scale is extracted from column lengths.
1186
+ * Assumes no shear. Scale extracted from column lengths.
1204
1187
  * @param {{ pos:number[], rot:number[], scl:number[] }} out
1205
1188
  * @param {Float32Array|number[]} m Column-major mat4.
1206
1189
  * @returns {{ pos:number[], rot:number[], scl:number[] }} out
1207
1190
  */
1208
1191
  const mat4ToTransform = (out, m) => {
1209
- out.pos[0] = m[12]; out.pos[1] = m[13]; out.pos[2] = m[14];
1210
- const sx = Math.sqrt(m[0]*m[0] + m[1]*m[1] + m[2]*m[2]);
1211
- const sy = Math.sqrt(m[4]*m[4] + m[5]*m[5] + m[6]*m[6]);
1212
- const sz = Math.sqrt(m[8]*m[8] + m[9]*m[9] + m[10]*m[10]);
1213
- out.scl[0] = sx; out.scl[1] = sy; out.scl[2] = sz;
1192
+ out.pos[0]=m[12]; out.pos[1]=m[13]; out.pos[2]=m[14];
1193
+ const sx=Math.sqrt(m[0]*m[0]+m[1]*m[1]+m[2]*m[2]);
1194
+ const sy=Math.sqrt(m[4]*m[4]+m[5]*m[5]+m[6]*m[6]);
1195
+ const sz=Math.sqrt(m[8]*m[8]+m[9]*m[9]+m[10]*m[10]);
1196
+ out.scl[0]=sx; out.scl[1]=sy; out.scl[2]=sz;
1214
1197
  qFromRotMat3x3(out.rot,
1215
- m[0]/sx, m[4]/sy, m[8]/sz,
1216
- m[1]/sx, m[5]/sy, m[9]/sz,
1217
- m[2]/sx, m[6]/sy, m[10]/sz);
1198
+ m[0]/sx,m[4]/sy,m[8]/sz,
1199
+ m[1]/sx,m[5]/sy,m[9]/sz,
1200
+ m[2]/sx,m[6]/sy,m[10]/sz);
1218
1201
  return out;
1219
1202
  };
1220
1203
 
1221
1204
  // =========================================================================
1222
- // S4 Spec parser (keyframe input normalisation)
1205
+ // S4a Spec parser PoseTrack
1223
1206
  // =========================================================================
1224
1207
 
1225
1208
  const _isNum = (x) => typeof x === 'number' && Number.isFinite(x);
@@ -1228,189 +1211,175 @@ const _clampS = (x, lo, hi) => x < lo ? lo : (x > hi ? hi : x);
1228
1211
 
1229
1212
  function _parseVec3(v) {
1230
1213
  if (!v) return null;
1231
- if (Array.isArray(v) && v.length >= 3 && v.every(n => typeof n === 'number')) return [v[0], v[1], v[2]];
1232
- if (typeof v === 'object' && 'x' in v) return [v.x || 0, v.y || 0, v.z || 0];
1214
+ if (Array.isArray(v) && v.length >= 3 && v.every(n => typeof n === 'number')) return [v[0],v[1],v[2]];
1215
+ if (typeof v === 'object' && 'x' in v) return [v.x||0, v.y||0, v.z||0];
1233
1216
  return null;
1234
1217
  }
1235
1218
 
1236
1219
  function _parseQuat(v) {
1237
1220
  if (!v) return null;
1238
- if (Array.isArray(v) && v.length === 4 && v.every(n => typeof n === 'number')) return [v[0], v[1], v[2], v[3]];
1221
+ if (Array.isArray(v) && v.length === 4 && v.every(n => typeof n === 'number')) return [v[0],v[1],v[2],v[3]];
1239
1222
  if (v.axis && typeof v.angle === 'number') {
1240
- const a = Array.isArray(v.axis) ? v.axis : [v.axis.x || 0, v.axis.y || 0, v.axis.z || 0];
1241
- return qFromAxisAngle([0, 0, 0, 1], a[0], a[1], a[2], v.angle);
1223
+ const a = Array.isArray(v.axis) ? v.axis : [v.axis.x||0, v.axis.y||0, v.axis.z||0];
1224
+ return qFromAxisAngle([0,0,0,1], a[0],a[1],a[2], v.angle);
1242
1225
  }
1243
1226
  if (v.dir) {
1244
- const d = Array.isArray(v.dir) ? v.dir : [v.dir.x || 0, v.dir.y || 0, v.dir.z || 0];
1245
- const u = v.up ? (Array.isArray(v.up) ? v.up : [v.up.x || 0, v.up.y || 0, v.up.z || 0]) : null;
1246
- return qFromLookDir([0, 0, 0, 1], d, u);
1227
+ const d = Array.isArray(v.dir) ? v.dir : [v.dir.x||0, v.dir.y||0, v.dir.z||0];
1228
+ const u = v.up ? (Array.isArray(v.up) ? v.up : [v.up.x||0, v.up.y||0, v.up.z||0]) : null;
1229
+ return qFromLookDir([0,0,0,1], d, u);
1230
+ }
1231
+ // { view } — column-major mat4 or {mat4} wrapper
1232
+ if (v.view != null) {
1233
+ const m = (ArrayBuffer.isView(v.view) || Array.isArray(v.view)) ? v.view : (v.view.mat4 ?? null);
1234
+ if (m && m.length === 16) return qFromMat4([0,0,0,1], m);
1235
+ }
1236
+ // { eye, center, up? } — lookat shorthand matching CameraTrack input
1237
+ if (v.eye && v.center) {
1238
+ const eye = _parseVec3(v.eye), ctr = _parseVec3(v.center);
1239
+ if (eye && ctr) {
1240
+ const up = (v.up ? _parseVec3(v.up) : null) || [0,1,0];
1241
+ let fx=ctr[0]-eye[0], fy=ctr[1]-eye[1], fz=ctr[2]-eye[2];
1242
+ const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1; fx/=fl; fy/=fl; fz/=fl;
1243
+ let rx=fy*up[2]-fz*up[1], ry=fz*up[0]-fx*up[2], rz=fx*up[1]-fy*up[0];
1244
+ const rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1; rx/=rl; ry/=rl; rz/=rl;
1245
+ const ux=ry*fz-rz*fy, uy=rz*fx-rx*fz, uz=rx*fy-ry*fx;
1246
+ return qFromRotMat3x3([0,0,0,1], rx,ux,-fx, ry,uy,-fy, rz,uz,-fz);
1247
+ }
1247
1248
  }
1248
1249
  return null;
1249
1250
  }
1250
1251
 
1251
1252
  function _parseSpec(spec) {
1252
1253
  if (!spec || typeof spec !== 'object') return null;
1253
- const pos = _parseVec3(spec.pos) || [0, 0, 0];
1254
- const rot = _parseQuat(spec.rot) || [0, 0, 0, 1];
1255
- const scl = _parseVec3(spec.scl) || [1, 1, 1];
1254
+ const pos = _parseVec3(spec.pos) || [0,0,0];
1255
+ const rot = _parseQuat(spec.rot) || [0,0,0,1];
1256
+ const scl = _parseVec3(spec.scl) || [1,1,1];
1256
1257
  return { pos, rot, scl };
1257
1258
  }
1258
1259
 
1259
1260
  function _sameTransform(a, b) {
1260
- for (let i = 0; i < 3; i++) if (a.pos[i] !== b.pos[i] || a.scl[i] !== b.scl[i]) return false;
1261
- for (let i = 0; i < 4; i++) if (a.rot[i] !== b.rot[i]) return false;
1261
+ for (let i=0;i<3;i++) if (a.pos[i]!==b.pos[i]||a.scl[i]!==b.scl[i]) return false;
1262
+ for (let i=0;i<4;i++) if (a.rot[i]!==b.rot[i]) return false;
1262
1263
  return true;
1263
1264
  }
1264
1265
 
1265
1266
  // =========================================================================
1266
- // S5 PoseTrack
1267
+ // S4b Spec parser — CameraTrack
1267
1268
  // =========================================================================
1268
1269
 
1269
1270
  /**
1270
- * Renderer-agnostic keyframe animation track.
1271
+ * Parse a camera keyframe spec into internal { eye, center, up } form.
1271
1272
  *
1272
- * Keyframes are TRS pose objects: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z] }.
1273
- * The track maintains a scalar cursor (seg, f) that advances each tick().
1273
+ * Accepted forms:
1274
1274
  *
1275
- * Position uses centripetal Catmull-Rom spline by default (posInterp = 'catmullrom');
1276
- * set posInterp = 'linear' to switch to lerp. Rotation uses SLERP. Scale uses LERP.
1275
+ * { eye, center, up? }
1276
+ * Explicit lookat. up defaults to [0,1,0] and is normalised on storage.
1277
1277
  *
1278
- * Rate semantics:
1279
- * rate > 0 forward
1280
- * rate < 0 backward
1281
- * rate === 0 frozen: tick() is a no-op; playing is NOT changed
1278
+ * { view: mat4 }
1279
+ * Column-major view matrix (Float32Array(16), plain Array, or {mat4} wrapper).
1280
+ * eye is extracted from the matrix translation block.
1281
+ * center = eye + forward * 1 (unit distance sufficient for interpolation).
1282
+ * up defaults to [0,1,0]. The matrix's up_ortho (col1) is intentionally
1283
+ * NOT extracted; using up_ortho as the hint causes orbitControl drift.
1284
+ * If you need to preserve roll from a rolled camera, pass the live
1285
+ * camera's up hint via capturePose() instead of using { view }.
1282
1286
  *
1283
- * Assigning rate never starts or stops playback.
1284
- * Only play() sets playing = true. Only stop() / reset() set it to false.
1285
- *
1286
- * One-keyframe behaviour:
1287
- * play() with exactly one keyframe snaps eval() to that keyframe
1288
- * without setting playing = true and without firing hooks.
1287
+ * @param {Object} spec
1288
+ * @returns {{ eye:number[], center:number[], up:number[] }|null}
1289
1289
  */
1290
- class PoseTrack {
1290
+ function _parseCameraSpec(spec) {
1291
+ if (!spec || typeof spec !== 'object') return null;
1292
+
1293
+ // { view } form
1294
+ if (spec.view != null) {
1295
+ const m = (ArrayBuffer.isView(spec.view) || Array.isArray(spec.view))
1296
+ ? spec.view
1297
+ : (spec.view.mat4 ?? null);
1298
+ if (!m || m.length < 16) return null;
1299
+ // Extract eye position: eye = -R^T * t
1300
+ // Column-major view mat: col0=right, col1=up_ortho, col2=-fwd, col3=translation
1301
+ const ex = -(m[0]*m[12] + m[4]*m[13] + m[8]*m[14]);
1302
+ const ey = -(m[1]*m[12] + m[5]*m[13] + m[9]*m[14]);
1303
+ const ez = -(m[2]*m[12] + m[6]*m[13] + m[10]*m[14]);
1304
+ // forward = -col2 = [-m[8], -m[9], -m[10]]
1305
+ const fx=-m[8], fy=-m[9], fz=-m[10];
1306
+ const fl=Math.sqrt(fx*fx+fy*fy+fz*fz)||1;
1307
+ // center = eye + normalized_fwd * 1
1308
+ const cx=ex+fx/fl, cy=ey+fy/fl, cz=ez+fz/fl;
1309
+ return { eye:[ex,ey,ez], center:[cx,cy,cz], up:[0,1,0] };
1310
+ }
1311
+
1312
+ // { eye, center, up? } form
1313
+ const eye = _parseVec3(spec.eye);
1314
+ const center = _parseVec3(spec.center);
1315
+ if (!eye || !center) return null;
1316
+
1317
+ const upRaw = spec.up ? _parseVec3(spec.up) : null;
1318
+ const up = upRaw || [0,1,0];
1319
+ const ul = Math.sqrt(up[0]*up[0]+up[1]*up[1]+up[2]*up[2]) || 1;
1320
+ return { eye, center, up: [up[0]/ul, up[1]/ul, up[2]/ul] };
1321
+ }
1322
+
1323
+ function _sameCameraKeyframe(a, b) {
1324
+ for (let i=0;i<3;i++) {
1325
+ if (a.eye[i]!==b.eye[i]) return false;
1326
+ if (a.center[i]!==b.center[i]) return false;
1327
+ if (a.up[i]!==b.up[i]) return false;
1328
+ }
1329
+ return true;
1330
+ }
1331
+
1332
+ // =========================================================================
1333
+ // S5 Track — unexported base class (transport machinery only)
1334
+ // =========================================================================
1335
+
1336
+ class Track {
1291
1337
  constructor() {
1292
- /** @type {Array<{pos:number[],rot:number[],scl:number[]}>} */
1338
+ /** @type {Array} Keyframe array — shape depends on subclass. */
1293
1339
  this.keyframes = [];
1294
1340
  /** Whether playback is active. @type {boolean} */
1295
1341
  this.playing = false;
1296
- /** Loop flag (overridden by pingPong). @type {boolean} */
1342
+ /** Loop at boundaries. @type {boolean} */
1297
1343
  this.loop = false;
1298
- /** Ping-pong bounce mode (takes precedence over loop). @type {boolean} */
1344
+ /** Ping-pong bounce (takes precedence over loop). @type {boolean} */
1299
1345
  this.pingPong = false;
1300
- /** Frames per segment (>=1). @type {number} */
1346
+ /** Frames per segment (1). @type {number} */
1301
1347
  this.duration = 30;
1302
1348
  /** Current segment index. @type {number} */
1303
1349
  this.seg = 0;
1304
- /** Frame offset within current segment (can be fractional). @type {number} */
1350
+ /** Frame offset within segment (can be fractional). @type {number} */
1305
1351
  this.f = 0;
1306
- /**
1307
- * Position interpolation mode.
1308
- * @type {'catmullrom'|'linear'}
1309
- */
1310
- this.posInterp = 'catmullrom';
1311
-
1312
- // Scratch arrays reused by eval() / toMatrix() — avoids hot-path allocations
1313
- this._pos = [0, 0, 0];
1314
- this._rot = [0, 0, 0, 1];
1315
- this._scl = [1, 1, 1];
1316
1352
 
1317
- // Internal rate — assigning never touches playing
1353
+ // Internal rate — never directly starts/stops playback
1318
1354
  this._rate = 1;
1319
1355
 
1320
1356
  // User-space hooks
1321
- /** Called once when play() starts a false->true transition. @type {Function|null} */
1322
- this.onPlay = null;
1323
- /** Called once by tick() when cursor reaches a natural boundary (once mode). @type {Function|null} */
1324
- this.onEnd = null;
1325
-
1326
- // Lib-space hooks (set by host layer — e.g. p5 bridge)
1327
- /** @type {Function|null} */
1328
- this._onActivate = null;
1329
- /** @type {Function|null} */
1330
- this._onDeactivate = null;
1331
- }
1357
+ /** @type {Function|null} */ this.onPlay = null;
1358
+ /** @type {Function|null} */ this.onEnd = null;
1359
+ /** @type {Function|null} */ this.onStop = null;
1332
1360
 
1333
- // ── rate ────────────────────────────────────────────────────────────────
1334
- // Getter/setter so future consumers get the right value from track.rate,
1335
- // while the setter intentionally has NO side effects on playing.
1361
+ // Lib-space hooks (set by host layer, e.g. p5 bridge)
1362
+ /** @type {Function|null} */ this._onActivate = null;
1363
+ /** @type {Function|null} */ this._onDeactivate = null;
1364
+ }
1336
1365
 
1337
- /** Playback rate. 0 = frozen (playing flag unchanged). @type {number} */
1366
+ /** Playback rate. Assigning never starts/stops playback. @type {number} */
1338
1367
  get rate() { return this._rate; }
1339
- set rate(v) {
1340
- this._rate = (typeof v === 'number' && Number.isFinite(v)) ? v : 1;
1341
- // Intentionally does NOT start or stop playback.
1342
- }
1368
+ set rate(v) { this._rate = (_isNum(v)) ? v : 1; }
1343
1369
 
1344
- /** Number of interpolatable segments (keyframes.length - 1, min 0). @type {number} */
1370
+ /** Number of interpolatable segments (keyframes.length 1, min 0). @type {number} */
1345
1371
  get segments() { return Math.max(0, this.keyframes.length - 1); }
1346
1372
 
1347
- // ── Keyframe management ──────────────────────────────────────────────────
1348
-
1349
- /**
1350
- * Append a keyframe. Adjacent duplicates are skipped by default.
1351
- * @param {{ pos?, rot?, scl? }} spec pos/rot/scl arrays, {x,y,z}, axis-angle, look-dir.
1352
- * @param {{ deduplicate?: boolean }} [opts]
1353
- */
1354
- add(spec, opts) {
1355
- const kf = _parseSpec(spec);
1356
- if (!kf) return;
1357
- const dedup = !opts || opts.deduplicate !== false;
1358
- if (dedup && this.keyframes.length > 0) {
1359
- if (_sameTransform(this.keyframes[this.keyframes.length - 1], kf)) return;
1360
- }
1361
- this.keyframes.push(kf);
1362
- }
1363
-
1364
- /**
1365
- * Replace (or append at end) the keyframe at index.
1366
- * @param {number} index Existing index or keyframes.length to append.
1367
- * @param {{ pos?, rot?, scl? }} spec
1368
- * @returns {boolean}
1369
- */
1370
- set(index, spec) {
1371
- if (!_isNum(index)) return false;
1372
- const i = index | 0;
1373
- const kf = _parseSpec(spec);
1374
- if (!kf || i < 0 || i > this.keyframes.length) return false;
1375
- if (i === this.keyframes.length) { this.keyframes.push(kf); }
1376
- else { this.keyframes[i] = kf; }
1377
- return true;
1378
- }
1379
-
1380
- /**
1381
- * Remove the keyframe at index. Adjusts cursor if needed.
1382
- * @param {number} index
1383
- * @returns {boolean}
1384
- */
1385
- remove(index) {
1386
- if (!_isNum(index)) return false;
1387
- const i = index | 0;
1388
- if (i < 0 || i >= this.keyframes.length) return false;
1389
- this.keyframes.splice(i, 1);
1390
- const nSeg = this.segments;
1391
- if (nSeg === 0) { this.seg = 0; this.f = 0; }
1392
- else if (this.seg >= nSeg) { this.seg = nSeg - 1; }
1393
- return true;
1394
- }
1395
-
1396
- // ── Transport ────────────────────────────────────────────────────────────
1397
-
1398
1373
  /**
1399
1374
  * Start or update playback.
1400
- * Accepts a numeric rate or an options object: { rate, duration, loop, pingPong, onPlay, onEnd }.
1401
- *
1402
- * Zero keyframes: no-op.
1403
- * One keyframe: snaps cursor (seg=0, f=0); no playing=true, no hooks.
1404
- * Already playing: updates params in place; hooks are not re-fired.
1405
- * rate=0 is valid: track will be playing but frozen until rate changes.
1406
- *
1407
- * @param {number|Object} [rateOrOpts]
1408
- * @returns {PoseTrack} this
1375
+ * @param {number|Object} [rateOrOpts] Numeric rate or options object:
1376
+ * { rate, duration, loop, pingPong, onPlay, onEnd, onStop }
1377
+ * @returns {Track} this
1409
1378
  */
1410
1379
  play(rateOrOpts) {
1411
1380
  if (this.keyframes.length === 0) return this;
1412
1381
 
1413
- // One keyframe: snap only, no animation, no hooks
1382
+ // One keyframe: snap cursor, no animation
1414
1383
  if (this.keyframes.length === 1) {
1415
1384
  this.seg = 0; this.f = 0;
1416
1385
  return this;
@@ -1425,12 +1394,11 @@ class PoseTrack {
1425
1394
  if ('pingPong' in o) this.pingPong = !!o.pingPong;
1426
1395
  if (typeof o.onPlay === 'function') this.onPlay = o.onPlay;
1427
1396
  if (typeof o.onEnd === 'function') this.onEnd = o.onEnd;
1397
+ if (typeof o.onStop === 'function') this.onStop = o.onStop;
1428
1398
  if (_isNum(o.rate)) this._rate = o.rate;
1429
1399
  }
1430
1400
 
1431
- // Clamp cursor into valid range
1432
- const nSeg = this.segments;
1433
- const dur = Math.max(1, this.duration | 0);
1401
+ const nSeg = this.segments, dur = Math.max(1, this.duration | 0);
1434
1402
  if (this.seg < 0) this.seg = 0;
1435
1403
  if (this.seg >= nSeg) this.seg = nSeg - 1;
1436
1404
  if (this.f < 0) this.f = 0;
@@ -1438,7 +1406,6 @@ class PoseTrack {
1438
1406
 
1439
1407
  const wasPlaying = this.playing;
1440
1408
  this.playing = true;
1441
-
1442
1409
  if (!wasPlaying) {
1443
1410
  if (typeof this.onPlay === 'function') { try { this.onPlay(this); } catch (_) {} }
1444
1411
  this._onActivate?.();
@@ -1447,39 +1414,58 @@ class PoseTrack {
1447
1414
  }
1448
1415
 
1449
1416
  /**
1450
- * Stop playback. Does NOT reset time unless reset is true.
1451
- * @param {boolean} [reset=false] If true, seek to start or end based on rate direction.
1452
- * @returns {PoseTrack} this
1417
+ * Stop playback.
1418
+ * @param {boolean} [rewind=false] Seek to origin after stopping.
1419
+ * @returns {Track} this
1453
1420
  */
1454
- stop(reset) {
1421
+ stop(rewind) {
1455
1422
  const wasPlaying = this.playing;
1456
1423
  this.playing = false;
1457
- if (wasPlaying) this._onDeactivate?.();
1458
- if (!reset || this.keyframes.length <= 1) return this;
1459
- this.seek(this._rate < 0 ? 1 : 0);
1424
+ if (wasPlaying) {
1425
+ if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1426
+ this._onDeactivate?.();
1427
+ if (rewind && this.keyframes.length > 1) this.seek(this._rate < 0 ? 1 : 0);
1428
+ }
1460
1429
  return this;
1461
1430
  }
1462
1431
 
1463
1432
  /**
1464
- * Clear all keyframes and stop. Fires _onDeactivate if was playing.
1465
- * @returns {PoseTrack} this
1433
+ * Clear all keyframes and stop.
1434
+ * @returns {Track} this
1466
1435
  */
1467
1436
  reset() {
1468
1437
  const wasPlaying = this.playing;
1469
1438
  this.playing = false;
1470
- if (wasPlaying) this._onDeactivate?.();
1439
+ if (wasPlaying) {
1440
+ if (typeof this.onStop === 'function') { try { this.onStop(this); } catch (_) {} }
1441
+ this._onDeactivate?.();
1442
+ }
1471
1443
  this.keyframes.length = 0;
1472
1444
  this.seg = 0; this.f = 0;
1473
1445
  return this;
1474
1446
  }
1475
1447
 
1448
+ /**
1449
+ * Remove the keyframe at index. Adjusts cursor if needed.
1450
+ * @param {number} index
1451
+ * @returns {boolean}
1452
+ */
1453
+ remove(index) {
1454
+ if (!_isNum(index)) return false;
1455
+ const i = index | 0;
1456
+ if (i < 0 || i >= this.keyframes.length) return false;
1457
+ this.keyframes.splice(i, 1);
1458
+ const nSeg = this.segments;
1459
+ if (nSeg === 0) { this.seg = 0; this.f = 0; }
1460
+ else if (this.seg >= nSeg) { this.seg = nSeg - 1; }
1461
+ return true;
1462
+ }
1463
+
1476
1464
  /**
1477
1465
  * Seek to a normalised position [0,1] across the full path.
1478
- * Can optionally target a specific segment (t is then local to that segment).
1479
- * Does not change the playing flag.
1480
1466
  * @param {number} t Normalised time [0, 1].
1481
1467
  * @param {number} [segIndex] Optional segment override.
1482
- * @returns {PoseTrack} this
1468
+ * @returns {Track} this
1483
1469
  */
1484
1470
  seek(t, segIndex) {
1485
1471
  const nSeg = this.segments;
@@ -1495,8 +1481,7 @@ class PoseTrack {
1495
1481
  }
1496
1482
 
1497
1483
  /**
1498
- * Normalised playback time across the full path [0, 1].
1499
- * Returns 0 when fewer than 2 keyframes exist.
1484
+ * Normalised playback position [0,1].
1500
1485
  * @returns {number}
1501
1486
  */
1502
1487
  time() {
@@ -1507,10 +1492,8 @@ class PoseTrack {
1507
1492
  }
1508
1493
 
1509
1494
  /**
1510
- * Snapshot of the current transport state.
1511
- * @returns {{ keyframes:number, segments:number, seg:number, f:number,
1512
- * time:number, playing:boolean, loop:boolean, pingPong:boolean,
1513
- * rate:number, duration:number }}
1495
+ * Snapshot of transport state.
1496
+ * @returns {Object}
1514
1497
  */
1515
1498
  info() {
1516
1499
  return {
@@ -1528,12 +1511,8 @@ class PoseTrack {
1528
1511
  }
1529
1512
 
1530
1513
  /**
1531
- * Advance the cursor by rate frames.
1532
- *
1533
- * rate === 0: frozen — returns this.playing without moving (no-op).
1534
- * Returns false and fires onEnd/_onDeactivate when a once-mode boundary is hit.
1535
- * Returns true while playing and continuing.
1536
- *
1514
+ * Advance cursor by rate frames.
1515
+ * Returns true while playing, false when stopping.
1537
1516
  * @returns {boolean}
1538
1517
  */
1539
1518
  tick() {
@@ -1542,8 +1521,6 @@ class PoseTrack {
1542
1521
  if (nSeg === 0) {
1543
1522
  this.playing = false; this._onDeactivate?.(); return false;
1544
1523
  }
1545
-
1546
- // Frozen: position does not advance, playing stays true
1547
1524
  if (this._rate === 0) return true;
1548
1525
 
1549
1526
  const dur = Math.max(1, this.duration | 0);
@@ -1551,25 +1528,22 @@ class PoseTrack {
1551
1528
  const s = _clampS(this.seg * dur + this.f, 0, total);
1552
1529
  const next = s + this._rate;
1553
1530
 
1554
- // ── pingPong ──
1555
1531
  if (this.pingPong) {
1556
1532
  let pos = next, flips = 0;
1557
1533
  while (pos < 0 || pos > total) {
1558
- if (pos < 0) { pos = -pos; flips++; }
1559
- else { pos = 2 * total - pos; flips++; }
1534
+ if (pos < 0) { pos = -pos; flips++; }
1535
+ else { pos = 2 * total - pos; flips++; }
1560
1536
  }
1561
1537
  if (flips & 1) this._rate = -this._rate;
1562
1538
  this._setCursorFromScalar(pos);
1563
1539
  return true;
1564
1540
  }
1565
1541
 
1566
- // ── loop ──
1567
1542
  if (this.loop) {
1568
1543
  this._setCursorFromScalar(((next % total) + total) % total);
1569
1544
  return true;
1570
1545
  }
1571
1546
 
1572
- // ── once — boundary check ──
1573
1547
  if (next <= 0) {
1574
1548
  this._setCursorFromScalar(0);
1575
1549
  this.playing = false;
@@ -1589,15 +1563,112 @@ class PoseTrack {
1589
1563
  return true;
1590
1564
  }
1591
1565
 
1566
+ /** @private */
1567
+ _setCursorFromScalar(s) {
1568
+ const dur = Math.max(1, this.duration | 0);
1569
+ const nSeg = this.segments;
1570
+ this.seg = Math.floor(s / dur);
1571
+ this.f = s - this.seg * dur;
1572
+ if (this.seg >= nSeg) { this.seg = nSeg - 1; this.f = dur; }
1573
+ if (this.seg < 0) { this.seg = 0; this.f = 0; }
1574
+ }
1575
+ }
1576
+
1577
+ // =========================================================================
1578
+ // S6 PoseTrack
1579
+ // =========================================================================
1580
+
1581
+ /**
1582
+ * Renderer-agnostic TRS keyframe track.
1583
+ *
1584
+ * Keyframe shape: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z] }
1585
+ *
1586
+ * add() accepts individual specs or a bulk array of specs:
1587
+ * { pos, rot, scl } direct TRS
1588
+ * { pos, rot: [x,y,z,w] } explicit quaternion
1589
+ * { pos, rot: { axis, angle } } axis-angle
1590
+ * { pos, rot: { dir, up? } } look direction (object orientation)
1591
+ * { pos, rot: { view: mat4 } } from view matrix rotation block
1592
+ * { pos, rot: { eye, center, up? } } lookat shorthand
1593
+ * [ spec, spec, ... ] bulk
1594
+ *
1595
+ * eval() writes { pos, rot, scl }:
1596
+ * pos — Catmull-Rom (posInterp='catmullrom') or lerp
1597
+ * rot — slerp (rotInterp='slerp') or nlerp
1598
+ * scl — lerp
1599
+ *
1600
+ * @example
1601
+ * const track = new PoseTrack()
1602
+ * track.add({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] })
1603
+ * track.add({ pos:[100,0,0], rot:[0,0,0,1], scl:[1,1,1] })
1604
+ * track.play({ loop: true })
1605
+ * // per frame:
1606
+ * track.tick()
1607
+ * const out = { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] }
1608
+ * track.eval(out)
1609
+ */
1610
+ class PoseTrack extends Track {
1611
+ constructor() {
1612
+ super();
1613
+ /**
1614
+ * Position interpolation mode.
1615
+ * @type {'catmullrom'|'linear'}
1616
+ */
1617
+ this.posInterp = 'catmullrom';
1618
+ /**
1619
+ * Rotation interpolation mode.
1620
+ * - 'slerp' — constant angular velocity (default)
1621
+ * - 'nlerp' — normalised lerp; cheaper, slightly non-constant speed
1622
+ * @type {'slerp'|'nlerp'}
1623
+ */
1624
+ this.rotInterp = 'slerp';
1625
+ // Scratch for toMatrix() — avoids hot-path allocations
1626
+ this._pos = [0,0,0];
1627
+ this._rot = [0,0,0,1];
1628
+ this._scl = [1,1,1];
1629
+ }
1630
+
1631
+ /**
1632
+ * Append one or more keyframes. Adjacent duplicates are skipped by default.
1633
+ * @param {Object|Object[]} spec
1634
+ * @param {{ deduplicate?: boolean }} [opts]
1635
+ */
1636
+ add(spec, opts) {
1637
+ if (Array.isArray(spec)) {
1638
+ for (const s of spec) this.add(s, opts);
1639
+ return;
1640
+ }
1641
+ const kf = _parseSpec(spec);
1642
+ if (!kf) return;
1643
+ const dedup = !opts || opts.deduplicate !== false;
1644
+ if (dedup && this.keyframes.length > 0) {
1645
+ if (_sameTransform(this.keyframes[this.keyframes.length - 1], kf)) return;
1646
+ }
1647
+ this.keyframes.push(kf);
1648
+ }
1649
+
1650
+ /**
1651
+ * Replace (or append at end) the keyframe at index.
1652
+ * @param {number} index
1653
+ * @param {Object} spec
1654
+ * @returns {boolean}
1655
+ */
1656
+ set(index, spec) {
1657
+ if (!_isNum(index)) return false;
1658
+ const i = index | 0, kf = _parseSpec(spec);
1659
+ if (!kf || i < 0 || i > this.keyframes.length) return false;
1660
+ if (i === this.keyframes.length) this.keyframes.push(kf);
1661
+ else this.keyframes[i] = kf;
1662
+ return true;
1663
+ }
1664
+
1592
1665
  /**
1593
- * Evaluate the interpolated pose at the current cursor into out.
1594
- * If out is omitted a new object is allocated (avoid in hot paths).
1595
- * Uses centripetal Catmull-Rom for position (posInterp === 'catmullrom') or lerp.
1666
+ * Evaluate interpolated TRS pose at current cursor.
1596
1667
  * @param {{ pos:number[], rot:number[], scl:number[] }} [out]
1597
1668
  * @returns {{ pos:number[], rot:number[], scl:number[] }} out
1598
1669
  */
1599
1670
  eval(out) {
1600
- out = out || { pos: [0, 0, 0], rot: [0, 0, 0, 1], scl: [1, 1, 1] };
1671
+ out = out || { pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] };
1601
1672
  const n = this.keyframes.length;
1602
1673
  if (n === 0) return out;
1603
1674
 
@@ -1616,27 +1687,30 @@ class PoseTrack {
1616
1687
  const k0 = this.keyframes[seg];
1617
1688
  const k1 = this.keyframes[seg + 1];
1618
1689
 
1619
- // Position
1690
+ // pos — Catmull-Rom or lerp
1620
1691
  if (this.posInterp === 'catmullrom') {
1621
- const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1692
+ const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1622
1693
  const p3 = seg + 2 < n ? this.keyframes[seg + 2].pos : k1.pos;
1623
1694
  catmullRomVec3(out.pos, p0, k0.pos, k1.pos, p3, t);
1624
1695
  } else {
1625
1696
  lerpVec3(out.pos, k0.pos, k1.pos, t);
1626
1697
  }
1627
1698
 
1628
- // RotationSLERP
1629
- qSlerp(out.rot, k0.rot, k1.rot, t);
1699
+ // rotslerp or nlerp
1700
+ if (this.rotInterp === 'nlerp') {
1701
+ qNlerp(out.rot, k0.rot, k1.rot, t);
1702
+ } else {
1703
+ qSlerp(out.rot, k0.rot, k1.rot, t);
1704
+ }
1630
1705
 
1631
- // ScaleLERP
1706
+ // scllerp
1632
1707
  lerpVec3(out.scl, k0.scl, k1.scl, t);
1633
1708
 
1634
1709
  return out;
1635
1710
  }
1636
1711
 
1637
1712
  /**
1638
- * Evaluate the current cursor into an existing column-major mat4.
1639
- * Reuses internal scratch arrays — no allocation per call.
1713
+ * Evaluate into an existing column-major mat4.
1640
1714
  * @param {Float32Array|number[]} outMat4 16-element array.
1641
1715
  * @returns {Float32Array|number[]} outMat4
1642
1716
  */
@@ -1644,17 +1718,161 @@ class PoseTrack {
1644
1718
  const xf = this.eval({ pos: this._pos, rot: this._rot, scl: this._scl });
1645
1719
  return transformToMat4(outMat4, xf);
1646
1720
  }
1721
+ }
1647
1722
 
1648
- // ── Private ──────────────────────────────────────────────────────────────
1723
+ // =========================================================================
1724
+ // S7 CameraTrack
1725
+ // =========================================================================
1649
1726
 
1650
- /** @private */
1651
- _setCursorFromScalar(s) {
1727
+ /**
1728
+ * Lookat camera keyframe track.
1729
+ *
1730
+ * Keyframe shape: { eye:[x,y,z], center:[x,y,z], up:[x,y,z] }
1731
+ *
1732
+ * Each field is independently interpolated — eye and center along their
1733
+ * own paths, up nlerped on the unit sphere. This correctly handles cameras
1734
+ * that always look at a fixed target (center stays at origin throughout)
1735
+ * as well as free-fly paths where center moves independently.
1736
+ *
1737
+ * add() accepts individual specs or a bulk array of specs:
1738
+ * { eye, center, up? } explicit lookat; up defaults to [0,1,0]
1739
+ * { view: mat4 } view matrix; eye extracted, center = eye+fwd*1,
1740
+ * up = [0,1,0] (safe default — see note below)
1741
+ * [ spec, spec, ... ] bulk
1742
+ *
1743
+ * Note on up for { view: mat4 }:
1744
+ * The view matrix's col1 (up_ortho) is intentionally not used as up.
1745
+ * For upright cameras up_ortho differs from the hint [0,1,0], and
1746
+ * passing it to cam.camera() would shift orbitControl's orbit reference.
1747
+ * Use capturePose() when you need to preserve the real up hint.
1748
+ *
1749
+ * eval() writes { eye, center, up }:
1750
+ * eye — Catmull-Rom (eyeInterp='catmullrom') or lerp
1751
+ * center — Catmull-Rom (centerInterp='catmullrom') or lerp
1752
+ * up — nlerp (normalize-after-lerp on unit sphere)
1753
+ *
1754
+ * @example
1755
+ * const track = new CameraTrack()
1756
+ * track.add({ eye:[0,0,500], center:[0,0,0] })
1757
+ * track.add({ eye:[300,-150,0], center:[0,0,0] })
1758
+ * track.play({ loop: true })
1759
+ * // per frame:
1760
+ * track.tick()
1761
+ * const out = { eye:[0,0,0], center:[0,0,0], up:[0,1,0] }
1762
+ * track.eval(out)
1763
+ * cam.camera(out.eye[0],out.eye[1],out.eye[2],
1764
+ * out.center[0],out.center[1],out.center[2],
1765
+ * out.up[0],out.up[1],out.up[2])
1766
+ */
1767
+ class CameraTrack extends Track {
1768
+ constructor() {
1769
+ super();
1770
+ /**
1771
+ * Eye position interpolation mode.
1772
+ * @type {'catmullrom'|'linear'}
1773
+ */
1774
+ this.eyeInterp = 'catmullrom';
1775
+ /**
1776
+ * Center (lookat target) interpolation mode.
1777
+ * 'linear' suits fixed or predictably moving targets.
1778
+ * 'catmullrom' gives smoother paths when center is also flying freely.
1779
+ * @type {'catmullrom'|'linear'}
1780
+ */
1781
+ this.centerInterp = 'linear';
1782
+ // Scratch for toCamera() — avoids hot-path allocations
1783
+ this._eye = [0,0,0];
1784
+ this._center = [0,0,0];
1785
+ this._up = [0,1,0];
1786
+ }
1787
+
1788
+ /**
1789
+ * Append one or more camera keyframes. Adjacent duplicates are skipped by default.
1790
+ *
1791
+ * @param {Object|Object[]} spec
1792
+ * { eye, center, up? } or { view: mat4 } or an array of either.
1793
+ * @param {{ deduplicate?: boolean }} [opts]
1794
+ */
1795
+ add(spec, opts) {
1796
+ if (Array.isArray(spec)) {
1797
+ for (const s of spec) this.add(s, opts);
1798
+ return;
1799
+ }
1800
+ const kf = _parseCameraSpec(spec);
1801
+ if (!kf) return;
1802
+ const dedup = !opts || opts.deduplicate !== false;
1803
+ if (dedup && this.keyframes.length > 0) {
1804
+ if (_sameCameraKeyframe(this.keyframes[this.keyframes.length - 1], kf)) return;
1805
+ }
1806
+ this.keyframes.push(kf);
1807
+ }
1808
+
1809
+ /**
1810
+ * Replace (or append at end) the keyframe at index.
1811
+ * @param {number} index
1812
+ * @param {Object} spec
1813
+ * @returns {boolean}
1814
+ */
1815
+ set(index, spec) {
1816
+ if (!_isNum(index)) return false;
1817
+ const i = index | 0, kf = _parseCameraSpec(spec);
1818
+ if (!kf || i < 0 || i > this.keyframes.length) return false;
1819
+ if (i === this.keyframes.length) this.keyframes.push(kf);
1820
+ else this.keyframes[i] = kf;
1821
+ return true;
1822
+ }
1823
+
1824
+ /**
1825
+ * Evaluate interpolated camera pose at current cursor.
1826
+ *
1827
+ * @param {{ eye:number[], center:number[], up:number[] }} [out]
1828
+ * @returns {{ eye:number[], center:number[], up:number[] }} out
1829
+ */
1830
+ eval(out) {
1831
+ out = out || { eye:[0,0,0], center:[0,0,0], up:[0,1,0] };
1832
+ const n = this.keyframes.length;
1833
+ if (n === 0) return out;
1834
+
1835
+ if (n === 1) {
1836
+ const k = this.keyframes[0];
1837
+ out.eye[0]=k.eye[0]; out.eye[1]=k.eye[1]; out.eye[2]=k.eye[2];
1838
+ out.center[0]=k.center[0]; out.center[1]=k.center[1]; out.center[2]=k.center[2];
1839
+ out.up[0]=k.up[0]; out.up[1]=k.up[1]; out.up[2]=k.up[2];
1840
+ return out;
1841
+ }
1842
+
1843
+ const nSeg = n - 1;
1652
1844
  const dur = Math.max(1, this.duration | 0);
1653
- const nSeg = this.segments;
1654
- this.seg = Math.floor(s / dur);
1655
- this.f = s - this.seg * dur;
1656
- if (this.seg >= nSeg) { this.seg = nSeg - 1; this.f = dur; }
1657
- if (this.seg < 0) { this.seg = 0; this.f = 0; }
1845
+ const seg = _clampS(this.seg, 0, nSeg - 1);
1846
+ const t = _clamp01(this.f / dur);
1847
+ const k0 = this.keyframes[seg];
1848
+ const k1 = this.keyframes[seg + 1];
1849
+
1850
+ // eye — Catmull-Rom or lerp
1851
+ if (this.eyeInterp === 'catmullrom') {
1852
+ const p0 = seg > 0 ? this.keyframes[seg - 1].eye : k0.eye;
1853
+ const p3 = seg + 2 < n ? this.keyframes[seg + 2].eye : k1.eye;
1854
+ catmullRomVec3(out.eye, p0, k0.eye, k1.eye, p3, t);
1855
+ } else {
1856
+ lerpVec3(out.eye, k0.eye, k1.eye, t);
1857
+ }
1858
+
1859
+ // center — Catmull-Rom or lerp (independent lookat target)
1860
+ if (this.centerInterp === 'catmullrom') {
1861
+ const c0 = seg > 0 ? this.keyframes[seg - 1].center : k0.center;
1862
+ const c3 = seg + 2 < n ? this.keyframes[seg + 2].center : k1.center;
1863
+ catmullRomVec3(out.center, c0, k0.center, k1.center, c3, t);
1864
+ } else {
1865
+ lerpVec3(out.center, k0.center, k1.center, t);
1866
+ }
1867
+
1868
+ // up — nlerp (normalize after lerp; correct for typical near-upright cameras)
1869
+ const ux = k0.up[0] + t*(k1.up[0]-k0.up[0]);
1870
+ const uy = k0.up[1] + t*(k1.up[1]-k0.up[1]);
1871
+ const uz = k0.up[2] + t*(k1.up[2]-k0.up[2]);
1872
+ const ul = Math.sqrt(ux*ux+uy*uy+uz*uz) || 1;
1873
+ out.up[0]=ux/ul; out.up[1]=uy/ul; out.up[2]=uz/ul;
1874
+
1875
+ return out;
1658
1876
  }
1659
1877
  }
1660
1878
 
@@ -1815,5 +2033,5 @@ function boxVisibility(planes, x0, y0, z0, x1, y1, z1) {
1815
2033
  return allIn ? VISIBLE : SEMIVISIBLE;
1816
2034
  }
1817
2035
 
1818
- export { EYE, INVISIBLE, MATRIX, MODEL, NDC, ORIGIN, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, PoseTrack, SCREEN, SEMIVISIBLE, VISIBLE, WEBGL, WEBGPU, WORLD, _i, _j, _k, boxVisibility, catmullRomVec3, distanceToPlane, frustumPlanes, i, j, k, lerpVec3, mapDirection, mapLocation, mat3Direction, mat3FromMat4T, mat3MulVec3, mat3NormalFromMat4, mat4Identity, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulDir, mat4MulPoint, mat4PMV, mat4PV, mat4ToTransform, mat4Transpose, pixelRatio, pointVisibility, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qMul, qNegate, qNormalize, qSet, qSlerp, qToMat4, quatToAxisAngle, sphereVisibility, transformToMat4 };
2036
+ export { CameraTrack, EYE, INVISIBLE, MATRIX, MODEL, NDC, ORIGIN, PLANE_BOTTOM, PLANE_FAR, PLANE_LEFT, PLANE_NEAR, PLANE_RIGHT, PLANE_TOP, PoseTrack, SCREEN, SEMIVISIBLE, VISIBLE, WEBGL, WEBGPU, WORLD, _i, _j, _k, boxVisibility, catmullRomVec3, distanceToPlane, frustumPlanes, i, j, k, lerpVec3, mapDirection, mapLocation, mat3Direction, mat3FromMat4T, mat3MulVec3, mat3NormalFromMat4, mat4Identity, mat4Invert, mat4Location, mat4MV, mat4Mul, mat4MulDir, mat4MulPoint, mat4PMV, mat4PV, mat4ToTransform, mat4Transpose, pixelRatio, pointVisibility, projBottom, projFar, projFov, projHfov, projIsOrtho, projLeft, projNear, projRight, projTop, qCopy, qDot, qFromAxisAngle, qFromLookDir, qFromMat4, qFromRotMat3x3, qMul, qNegate, qNlerp, qNormalize, qSet, qSlerp, qToMat4, quatToAxisAngle, sphereVisibility, transformToMat4 };
1819
2037
  //# sourceMappingURL=index.js.map