@nakednous/tree 0.0.2 → 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,58 +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 / onStop — user-space (public, set by user)
911
- * onPlay : fires in play() when playback starts (false→true transition).
912
- * onEnd : fires in tick() when cursor reaches a natural boundary (once mode).
913
- * onStop : fires in stop() and reset() — explicit, user-initiated deactivation.
914
- *
915
- * onEnd and onStop are mutually exclusive per deactivation event.
916
- * To react to any deactivation, chain both.
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.
917
921
  *
918
922
  * Firing order:
919
923
  * play() → onPlay → _onActivate
920
- * tick() → onEnd → _onDeactivate (natural boundary, once mode)
924
+ * tick() → onEnd → _onDeactivate
921
925
  * stop() → onStop → _onDeactivate
922
926
  * reset() → onStop → _onDeactivate
923
927
  *
924
928
  * ── Playback semantics (rate) ─────────────────────────────────────────────────
925
- * rate > 0 forward playback
926
- * rate < 0 backward playback
927
- * 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
928
932
  *
929
- * play() is the sole method that sets playing = true.
930
- * stop() is the sole method that sets playing = false.
931
- * 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.
932
936
  *
933
937
  * ── One-keyframe behaviour ────────────────────────────────────────────────────
934
938
  * play() with exactly one keyframe snaps eval() to that keyframe without
935
- * setting playing = true and without animating.
939
+ * setting playing = true and without firing hooks.
936
940
  */
937
941
 
938
942
 
@@ -953,68 +957,62 @@ const qCopy = (out, a) => {
953
957
  /** Dot product of two quaternions. */
954
958
  const qDot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
955
959
 
956
- /** Normalise in-place. @returns {number[]} out */
960
+ /** Normalise quaternion in-place. @returns {number[]} out */
957
961
  const qNormalize = (out) => {
958
- const len = Math.sqrt(qDot(out, out)) || 1;
959
- out[0] /= len; out[1] /= len; out[2] /= len; out[3] /= len;
960
- 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;
961
964
  };
962
965
 
963
- /** Negate all components in-place. @returns {number[]} out */
964
- const qNegate = (out) => {
965
- out[0] = -out[0]; out[1] = -out[1]; out[2] = -out[2]; out[3] = -out[3];
966
- 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;
967
969
  };
968
970
 
969
- /** out = a * b (Hamilton product). @returns {number[]} out */
971
+ /** Hamilton product out = a * b. @returns {number[]} out */
970
972
  const qMul = (out, a, b) => {
971
- const ax = a[0], ay = a[1], az = a[2], aw = a[3];
972
- const bx = b[0], by = b[1], bz = b[2], bw = b[3];
973
- out[0] = aw*bx + ax*bw + ay*bz - az*by;
974
- out[1] = aw*by - ax*bz + ay*bw + az*bx;
975
- out[2] = aw*bz + ax*by - ay*bx + az*bw;
976
- 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;
977
978
  return out;
978
979
  };
979
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
+
980
998
  /**
981
- * SLERP between quaternions a and b at parameter t.
982
- * Shortest-arc: negates b when dot < 0.
983
- * Near-equal fallback: nlerp when dot ~= 1.
984
- * @param {number[]} out 4-element result array.
985
- * @param {number[]} a Start quaternion [x,y,z,w].
986
- * @param {number[]} b End quaternion [x,y,z,w].
987
- * @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.
988
1002
  * @returns {number[]} out
989
1003
  */
990
- const qSlerp = (out, a, b, t) => {
991
- let d = qDot(a, b);
992
- let bx = b[0], by = b[1], bz = b[2], bw = b[3];
993
- if (d < 0) { d = -d; bx = -bx; by = -by; bz = -bz; bw = -bw; }
994
- if (d > 0.9995) {
995
- out[0] = a[0] + t*(bx - a[0]);
996
- out[1] = a[1] + t*(by - a[1]);
997
- out[2] = a[2] + t*(bz - a[2]);
998
- out[3] = a[3] + t*(bw - a[3]);
999
- return qNormalize(out);
1000
- }
1001
- const theta = Math.acos(d), sinT = Math.sin(theta);
1002
- const s0 = Math.sin((1 - t) * theta) / sinT;
1003
- const s1 = Math.sin(t * theta) / sinT;
1004
- out[0] = s0*a[0] + s1*bx;
1005
- out[1] = s0*a[1] + s1*by;
1006
- out[2] = s0*a[2] + s1*bz;
1007
- out[3] = s0*a[3] + s1*bw;
1008
- 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);
1009
1010
  };
1010
1011
 
1011
1012
  /**
1012
- * Build a quaternion from an axis-angle rotation.
1013
- * The axis need not be normalised.
1013
+ * Build a quaternion from axis-angle.
1014
1014
  * @param {number[]} out
1015
- * @param {number} ax Axis x.
1016
- * @param {number} ay Axis y.
1017
- * @param {number} az Axis z.
1015
+ * @param {number} ax @param {number} ay @param {number} az Axis (need not be unit).
1018
1016
  * @param {number} angle Radians.
1019
1017
  * @returns {number[]} out
1020
1018
  */
@@ -1022,93 +1020,76 @@ const qFromAxisAngle = (out, ax, ay, az, angle) => {
1022
1020
  const half = angle * 0.5;
1023
1021
  const s = Math.sin(half);
1024
1022
  const len = Math.sqrt(ax*ax + ay*ay + az*az) || 1;
1025
- out[0] = s * ax / len;
1026
- out[1] = s * ay / len;
1027
- out[2] = s * az / len;
1023
+ out[0] = s * ax / len; out[1] = s * ay / len; out[2] = s * az / len;
1028
1024
  out[3] = Math.cos(half);
1029
1025
  return out;
1030
1026
  };
1031
1027
 
1032
1028
  /**
1033
- * Build a quaternion from a look direction (negative-Z forward convention)
1034
- * and an optional up vector (defaults to +Y).
1029
+ * Build a quaternion from a look direction (Z forward) and optional up (default +Y).
1035
1030
  * @param {number[]} out
1036
1031
  * @param {number[]} dir Forward direction [x,y,z].
1037
1032
  * @param {number[]} [up] Up vector [x,y,z].
1038
1033
  * @returns {number[]} out
1039
1034
  */
1040
1035
  const qFromLookDir = (out, dir, up) => {
1041
- let fx = dir[0], fy = dir[1], fz = dir[2];
1042
- const fLen = Math.sqrt(fx*fx + fy*fy + fz*fz) || 1;
1043
- fx /= fLen; fy /= fLen; fz /= fLen;
1044
- let ux = up ? up[0] : 0, uy = up ? up[1] : 1, uz = up ? up[2] : 0;
1045
- let rx = uy*fz - uz*fy, ry = uz*fx - ux*fz, rz = ux*fy - uy*fx;
1046
- const rLen = Math.sqrt(rx*rx + ry*ry + rz*rz) || 1;
1047
- rx /= rLen; ry /= rLen; rz /= rLen;
1048
- ux = fy*rz - fz*ry; uy = fz*rx - fx*rz; uz = fx*ry - fy*rx;
1049
- 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);
1050
1045
  };
1051
1046
 
1052
1047
  /**
1053
- * 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).
1054
1049
  * @returns {number[]} out (normalised)
1055
1050
  */
1056
- const qFromRotMat3x3 = (out, m00, m01, m02, m10, m11, m12, m20, m21, m22) => {
1057
- const tr = m00 + m11 + m22;
1051
+ const qFromRotMat3x3 = (out, m00,m01,m02, m10,m11,m12, m20,m21,m22) => {
1052
+ const tr = m00+m11+m22;
1058
1053
  if (tr > 0) {
1059
- const s = 0.5 / Math.sqrt(tr + 1);
1060
- out[3] = 0.25 / s;
1061
- out[0] = (m21 - m12) * s;
1062
- out[1] = (m02 - m20) * s;
1063
- out[2] = (m10 - m01) * s;
1064
- } else if (m00 > m11 && m00 > m22) {
1065
- const s = 2 * Math.sqrt(1 + m00 - m11 - m22);
1066
- out[3] = (m21 - m12) / s;
1067
- out[0] = 0.25 * s;
1068
- out[1] = (m01 + m10) / s;
1069
- out[2] = (m02 + m20) / s;
1070
- } else if (m11 > m22) {
1071
- const s = 2 * Math.sqrt(1 + m11 - m00 - m22);
1072
- out[3] = (m02 - m20) / s;
1073
- out[0] = (m01 + m10) / s;
1074
- out[1] = 0.25 * s;
1075
- 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;
1076
1062
  } else {
1077
- const s = 2 * Math.sqrt(1 + m22 - m00 - m11);
1078
- out[3] = (m10 - m01) / s;
1079
- out[0] = (m02 + m20) / s;
1080
- out[1] = (m12 + m21) / s;
1081
- 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;
1082
1065
  }
1083
1066
  return qNormalize(out);
1084
1067
  };
1085
1068
 
1086
1069
  /**
1087
- * 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.
1088
1071
  * @param {number[]} out
1089
1072
  * @param {Float32Array|number[]} m Column-major mat4.
1090
1073
  * @returns {number[]} out
1091
1074
  */
1092
1075
  const qFromMat4 = (out, m) =>
1093
- 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]);
1094
1077
 
1095
1078
  /**
1096
- * Write a quaternion into a column-major mat4 (rotation block only;
1097
- * 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.
1098
1081
  * @param {Float32Array|number[]} out 16-element array.
1099
1082
  * @param {number[]} q [x,y,z,w].
1100
1083
  * @returns {Float32Array|number[]} out
1101
1084
  */
1102
1085
  const qToMat4 = (out, q) => {
1103
- const x = q[0], y = q[1], z = q[2], w = q[3];
1104
- const x2 = x+x, y2 = y+y, z2 = z+z;
1105
- const xx = x*x2, xy = x*y2, xz = x*z2;
1106
- const yy = y*y2, yz = y*z2, zz = z*z2;
1107
- const wx = w*x2, wy = w*y2, wz = w*z2;
1108
- out[0] = 1-(yy+zz); out[1] = xy+wz; out[2] = xz-wy; out[3] = 0;
1109
- out[4] = xy-wz; out[5] = 1-(xx+zz); out[6] = yz+wx; out[7] = 0;
1110
- out[8] = xz+wy; out[9] = yz-wx; out[10] = 1-(xx+yy); out[11] = 0;
1111
- 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;
1112
1093
  return out;
1113
1094
  };
1114
1095
 
@@ -1120,11 +1101,11 @@ const qToMat4 = (out, q) => {
1120
1101
  */
1121
1102
  const quatToAxisAngle = (q, out) => {
1122
1103
  out = out || {};
1123
- const x = q[0], y = q[1], z = q[2], w = q[3];
1124
- const sinHalf = Math.sqrt(x*x + y*y + z*z);
1125
- if (sinHalf < 1e-8) { out.axis = [0, 1, 0]; out.angle = 0; return out; }
1126
- out.angle = 2 * Math.atan2(sinHalf, w);
1127
- 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];
1128
1109
  return out;
1129
1110
  };
1130
1111
 
@@ -1133,35 +1114,34 @@ const quatToAxisAngle = (q, out) => {
1133
1114
  // =========================================================================
1134
1115
 
1135
1116
  function _dist3(a, b) {
1136
- const dx = a[0]-b[0], dy = a[1]-b[1], dz = a[2]-b[2];
1137
- 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);
1138
1119
  }
1139
1120
 
1140
1121
  /**
1141
- * Centripetal Catmull-Rom interpolation (alpha = 0.5, Barry-Goldman).
1142
- * out = interp(p0, p1, p2, p3, t) where t in [0,1] maps p1→p2.
1143
- * Boundary: when p0===p1 or p2===p3 the chord is reused (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 p1→p2.
1124
+ * Boundary: p0===p1 or p2===p3 clamps the end tangent.
1144
1125
  * @param {number[]} out 3-element result.
1145
- * @param {number[]} p0 Control point before p1.
1146
- * @param {number[]} p1 Segment start.
1147
- * @param {number[]} p2 Segment end.
1148
- * @param {number[]} p3 Control point after p2.
1149
- * @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].
1150
1131
  * @returns {number[]} out
1151
1132
  */
1152
1133
  const catmullRomVec3 = (out, p0, p1, p2, p3, t) => {
1153
1134
  const alpha = 0.5;
1154
- const dt0 = Math.pow(_dist3(p0, p1), alpha) || 1;
1155
- const dt1 = Math.pow(_dist3(p1, p2), alpha) || 1;
1156
- 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;
1157
1138
  for (let i = 0; i < 3; i++) {
1158
1139
  const t1_0 = (p1[i]-p0[i])/dt0 - (p2[i]-p0[i])/(dt0+dt1) + (p2[i]-p1[i])/dt1;
1159
1140
  const t2_0 = (p2[i]-p1[i])/dt1 - (p3[i]-p1[i])/(dt1+dt2) + (p3[i]-p2[i])/dt2;
1160
- const m1 = t1_0 * dt1;
1161
- const m2 = t2_0 * dt1;
1162
- const a = 2*p1[i] - 2*p2[i] + m1 + m2;
1163
- const b = -3*p1[i] + 3*p2[i] - 2*m1 - m2;
1164
- 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];
1165
1145
  }
1166
1146
  return out;
1167
1147
  };
@@ -1175,9 +1155,9 @@ const catmullRomVec3 = (out, p0, p1, p2, p3, t) => {
1175
1155
  * @returns {number[]} out
1176
1156
  */
1177
1157
  const lerpVec3 = (out, a, b, t) => {
1178
- out[0] = a[0] + t*(b[0]-a[0]);
1179
- out[1] = a[1] + t*(b[1]-a[1]);
1180
- 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]);
1181
1161
  return out;
1182
1162
  };
1183
1163
 
@@ -1187,45 +1167,42 @@ const lerpVec3 = (out, a, b, t) => {
1187
1167
 
1188
1168
  /**
1189
1169
  * Write a TRS transform into a column-major mat4.
1190
- * Rotation is encoded as a quaternion; scale is baked into rotation columns.
1191
1170
  * @param {Float32Array|number[]} out 16-element column-major mat4.
1192
1171
  * @param {{ pos:number[], rot:number[], scl:number[] }} xform
1193
1172
  * @returns {Float32Array|number[]} out
1194
1173
  */
1195
1174
  const transformToMat4 = (out, xform) => {
1196
1175
  qToMat4(out, xform.rot);
1197
- const sx = xform.scl[0], sy = xform.scl[1], sz = xform.scl[2];
1198
- out[0] *= sx; out[1] *= sx; out[2] *= sx;
1199
- out[4] *= sy; out[5] *= sy; out[6] *= sy;
1200
- out[8] *= sz; out[9] *= sz; out[10] *= sz;
1201
- out[12] = xform.pos[0];
1202
- out[13] = xform.pos[1];
1203
- 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];
1204
1181
  return out;
1205
1182
  };
1206
1183
 
1207
1184
  /**
1208
1185
  * Decompose a column-major mat4 into a TRS transform.
1209
- * Assumes no shear. Scale is extracted from column lengths.
1186
+ * Assumes no shear. Scale extracted from column lengths.
1210
1187
  * @param {{ pos:number[], rot:number[], scl:number[] }} out
1211
1188
  * @param {Float32Array|number[]} m Column-major mat4.
1212
1189
  * @returns {{ pos:number[], rot:number[], scl:number[] }} out
1213
1190
  */
1214
1191
  const mat4ToTransform = (out, m) => {
1215
- out.pos[0] = m[12]; out.pos[1] = m[13]; out.pos[2] = m[14];
1216
- const sx = Math.sqrt(m[0]*m[0] + m[1]*m[1] + m[2]*m[2]);
1217
- const sy = Math.sqrt(m[4]*m[4] + m[5]*m[5] + m[6]*m[6]);
1218
- const sz = Math.sqrt(m[8]*m[8] + m[9]*m[9] + m[10]*m[10]);
1219
- 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;
1220
1197
  qFromRotMat3x3(out.rot,
1221
- m[0]/sx, m[4]/sy, m[8]/sz,
1222
- m[1]/sx, m[5]/sy, m[9]/sz,
1223
- 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);
1224
1201
  return out;
1225
1202
  };
1226
1203
 
1227
1204
  // =========================================================================
1228
- // S4 Spec parser (keyframe input normalisation)
1205
+ // S4a Spec parser PoseTrack
1229
1206
  // =========================================================================
1230
1207
 
1231
1208
  const _isNum = (x) => typeof x === 'number' && Number.isFinite(x);
@@ -1234,198 +1211,175 @@ const _clampS = (x, lo, hi) => x < lo ? lo : (x > hi ? hi : x);
1234
1211
 
1235
1212
  function _parseVec3(v) {
1236
1213
  if (!v) return null;
1237
- if (Array.isArray(v) && v.length >= 3 && v.every(n => typeof n === 'number')) return [v[0], v[1], v[2]];
1238
- 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];
1239
1216
  return null;
1240
1217
  }
1241
1218
 
1242
1219
  function _parseQuat(v) {
1243
1220
  if (!v) return null;
1244
- 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]];
1245
1222
  if (v.axis && typeof v.angle === 'number') {
1246
- const a = Array.isArray(v.axis) ? v.axis : [v.axis.x || 0, v.axis.y || 0, v.axis.z || 0];
1247
- 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);
1248
1225
  }
1249
1226
  if (v.dir) {
1250
- const d = Array.isArray(v.dir) ? v.dir : [v.dir.x || 0, v.dir.y || 0, v.dir.z || 0];
1251
- const u = v.up ? (Array.isArray(v.up) ? v.up : [v.up.x || 0, v.up.y || 0, v.up.z || 0]) : null;
1252
- 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
+ }
1253
1248
  }
1254
1249
  return null;
1255
1250
  }
1256
1251
 
1257
1252
  function _parseSpec(spec) {
1258
1253
  if (!spec || typeof spec !== 'object') return null;
1259
- const pos = _parseVec3(spec.pos) || [0, 0, 0];
1260
- const rot = _parseQuat(spec.rot) || [0, 0, 0, 1];
1261
- 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];
1262
1257
  return { pos, rot, scl };
1263
1258
  }
1264
1259
 
1265
1260
  function _sameTransform(a, b) {
1266
- for (let i = 0; i < 3; i++) if (a.pos[i] !== b.pos[i] || a.scl[i] !== b.scl[i]) return false;
1267
- 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;
1268
1263
  return true;
1269
1264
  }
1270
1265
 
1271
1266
  // =========================================================================
1272
- // S5 PoseTrack
1267
+ // S4b Spec parser — CameraTrack
1273
1268
  // =========================================================================
1274
1269
 
1275
1270
  /**
1276
- * Renderer-agnostic keyframe animation track.
1277
- *
1278
- * Keyframes are TRS pose objects: { pos:[x,y,z], rot:[x,y,z,w], scl:[x,y,z] }.
1279
- * The track maintains a scalar cursor (seg, f) that advances each tick().
1280
- *
1281
- * Position uses centripetal Catmull-Rom spline by default (posInterp = 'catmullrom');
1282
- * set posInterp = 'linear' to switch to lerp. Rotation uses SLERP. Scale uses LERP.
1271
+ * Parse a camera keyframe spec into internal { eye, center, up } form.
1283
1272
  *
1284
- * Rate semantics:
1285
- * rate > 0 forward
1286
- * rate < 0 backward
1287
- * rate === 0 frozen: tick() is a no-op; playing is NOT changed
1273
+ * Accepted forms:
1288
1274
  *
1289
- * Assigning rate never starts or stops playback.
1290
- * Only play() sets playing = true. Only stop() / reset() set it to false.
1275
+ * { eye, center, up? }
1276
+ * Explicit lookat. up defaults to [0,1,0] and is normalised on storage.
1291
1277
  *
1292
- * One-keyframe behaviour:
1293
- * play() with exactly one keyframe snaps eval() to that keyframe
1294
- * without setting playing = true and without firing hooks.
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 }.
1295
1286
  *
1296
- * @example
1297
- * const track = new PoseTrack()
1298
- * track.add({ pos:[0,0,0], rot:[0,0,0,1], scl:[1,1,1] })
1299
- * track.add({ pos:[0,100,0], rot:[0,0,0,1], scl:[1,1,1] })
1300
- * track.play({ loop: true, onStop: t => console.log('stopped at', t.time()) })
1287
+ * @param {Object} spec
1288
+ * @returns {{ eye:number[], center:number[], up:number[] }|null}
1301
1289
  */
1302
- 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 {
1303
1337
  constructor() {
1304
- /** @type {Array<{pos:number[],rot:number[],scl:number[]}>} */
1338
+ /** @type {Array} Keyframe array — shape depends on subclass. */
1305
1339
  this.keyframes = [];
1306
1340
  /** Whether playback is active. @type {boolean} */
1307
1341
  this.playing = false;
1308
- /** Loop flag (overridden by pingPong). @type {boolean} */
1342
+ /** Loop at boundaries. @type {boolean} */
1309
1343
  this.loop = false;
1310
- /** Ping-pong bounce mode (takes precedence over loop). @type {boolean} */
1344
+ /** Ping-pong bounce (takes precedence over loop). @type {boolean} */
1311
1345
  this.pingPong = false;
1312
- /** Frames per segment (>=1). @type {number} */
1346
+ /** Frames per segment (1). @type {number} */
1313
1347
  this.duration = 30;
1314
1348
  /** Current segment index. @type {number} */
1315
1349
  this.seg = 0;
1316
- /** Frame offset within current segment (can be fractional). @type {number} */
1350
+ /** Frame offset within segment (can be fractional). @type {number} */
1317
1351
  this.f = 0;
1318
- /**
1319
- * Position interpolation mode.
1320
- * @type {'catmullrom'|'linear'}
1321
- */
1322
- this.posInterp = 'catmullrom';
1323
1352
 
1324
- // Scratch arrays reused by eval() / toMatrix() — avoids hot-path allocations
1325
- this._pos = [0, 0, 0];
1326
- this._rot = [0, 0, 0, 1];
1327
- this._scl = [1, 1, 1];
1328
-
1329
- // Internal rate — assigning never touches playing
1353
+ // Internal rate never directly starts/stops playback
1330
1354
  this._rate = 1;
1331
1355
 
1332
- // User-space hooks — fired on playback state transitions
1333
- /** Fires when play() starts a false→true transition. @type {Function|null} */
1334
- this.onPlay = null;
1335
- /** Fires in tick() when cursor hits a natural boundary (once mode only). @type {Function|null} */
1336
- this.onEnd = null;
1337
- /** Fires on explicit stop() or reset(). Mutually exclusive with onEnd per event. @type {Function|null} */
1338
- this.onStop = null;
1339
-
1340
- // Lib-space hooks (set by host layer — e.g. p5 bridge)
1341
- /** @type {Function|null} */
1342
- this._onActivate = null;
1343
- /** @type {Function|null} */
1344
- this._onDeactivate = null;
1345
- }
1356
+ // User-space hooks
1357
+ /** @type {Function|null} */ this.onPlay = null;
1358
+ /** @type {Function|null} */ this.onEnd = null;
1359
+ /** @type {Function|null} */ this.onStop = null;
1346
1360
 
1347
- // ── rate ────────────────────────────────────────────────────────────────
1348
- // Getter/setter so future consumers get the right value from track.rate,
1349
- // 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
+ }
1350
1365
 
1351
- /** Playback rate. 0 = frozen (playing flag unchanged). @type {number} */
1366
+ /** Playback rate. Assigning never starts/stops playback. @type {number} */
1352
1367
  get rate() { return this._rate; }
1353
- set rate(v) {
1354
- this._rate = (typeof v === 'number' && Number.isFinite(v)) ? v : 1;
1355
- // Intentionally does NOT start or stop playback.
1356
- }
1368
+ set rate(v) { this._rate = (_isNum(v)) ? v : 1; }
1357
1369
 
1358
- /** Number of interpolatable segments (keyframes.length - 1, min 0). @type {number} */
1370
+ /** Number of interpolatable segments (keyframes.length 1, min 0). @type {number} */
1359
1371
  get segments() { return Math.max(0, this.keyframes.length - 1); }
1360
1372
 
1361
- // ── Keyframe management ──────────────────────────────────────────────────
1362
-
1363
- /**
1364
- * Append a keyframe. Adjacent duplicates are skipped by default.
1365
- * @param {{ pos?, rot?, scl? }} spec pos/rot/scl arrays, {x,y,z}, axis-angle, look-dir.
1366
- * @param {{ deduplicate?: boolean }} [opts]
1367
- */
1368
- add(spec, opts) {
1369
- const kf = _parseSpec(spec);
1370
- if (!kf) return;
1371
- const dedup = !opts || opts.deduplicate !== false;
1372
- if (dedup && this.keyframes.length > 0) {
1373
- if (_sameTransform(this.keyframes[this.keyframes.length - 1], kf)) return;
1374
- }
1375
- this.keyframes.push(kf);
1376
- }
1377
-
1378
- /**
1379
- * Replace (or append at end) the keyframe at index.
1380
- * @param {number} index Existing index or keyframes.length to append.
1381
- * @param {{ pos?, rot?, scl? }} spec
1382
- * @returns {boolean}
1383
- */
1384
- set(index, spec) {
1385
- if (!_isNum(index)) return false;
1386
- const i = index | 0;
1387
- const kf = _parseSpec(spec);
1388
- if (!kf || i < 0 || i > this.keyframes.length) return false;
1389
- if (i === this.keyframes.length) { this.keyframes.push(kf); }
1390
- else { this.keyframes[i] = kf; }
1391
- return true;
1392
- }
1393
-
1394
- /**
1395
- * Remove the keyframe at index. Adjusts cursor if needed.
1396
- * @param {number} index
1397
- * @returns {boolean}
1398
- */
1399
- remove(index) {
1400
- if (!_isNum(index)) return false;
1401
- const i = index | 0;
1402
- if (i < 0 || i >= this.keyframes.length) return false;
1403
- this.keyframes.splice(i, 1);
1404
- const nSeg = this.segments;
1405
- if (nSeg === 0) { this.seg = 0; this.f = 0; }
1406
- else if (this.seg >= nSeg) { this.seg = nSeg - 1; }
1407
- return true;
1408
- }
1409
-
1410
- // ── Transport ────────────────────────────────────────────────────────────
1411
-
1412
1373
  /**
1413
1374
  * Start or update playback.
1414
- * Accepts a numeric rate or an options object:
1415
- * { rate, duration, loop, pingPong, onPlay, onEnd, onStop }.
1416
- *
1417
- * Zero keyframes: no-op.
1418
- * One keyframe: snaps cursor (seg=0, f=0); no playing=true, no hooks.
1419
- * Already playing: updates params in place; hooks are not re-fired.
1420
- * rate=0 is valid: track will be playing but frozen until rate changes.
1421
- *
1422
- * @param {number|Object} [rateOrOpts]
1423
- * @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
1424
1378
  */
1425
1379
  play(rateOrOpts) {
1426
1380
  if (this.keyframes.length === 0) return this;
1427
1381
 
1428
- // One keyframe: snap only, no animation, no hooks
1382
+ // One keyframe: snap cursor, no animation
1429
1383
  if (this.keyframes.length === 1) {
1430
1384
  this.seg = 0; this.f = 0;
1431
1385
  return this;
@@ -1444,9 +1398,7 @@ class PoseTrack {
1444
1398
  if (_isNum(o.rate)) this._rate = o.rate;
1445
1399
  }
1446
1400
 
1447
- // Clamp cursor into valid range
1448
- const nSeg = this.segments;
1449
- const dur = Math.max(1, this.duration | 0);
1401
+ const nSeg = this.segments, dur = Math.max(1, this.duration | 0);
1450
1402
  if (this.seg < 0) this.seg = 0;
1451
1403
  if (this.seg >= nSeg) this.seg = nSeg - 1;
1452
1404
  if (this.f < 0) this.f = 0;
@@ -1454,7 +1406,6 @@ class PoseTrack {
1454
1406
 
1455
1407
  const wasPlaying = this.playing;
1456
1408
  this.playing = true;
1457
-
1458
1409
  if (!wasPlaying) {
1459
1410
  if (typeof this.onPlay === 'function') { try { this.onPlay(this); } catch (_) {} }
1460
1411
  this._onActivate?.();
@@ -1463,11 +1414,9 @@ class PoseTrack {
1463
1414
  }
1464
1415
 
1465
1416
  /**
1466
- * Stop playback. No-op if already stopped.
1467
- * Fires `onStop` `_onDeactivate`, then optionally seeks to the
1468
- * logical start (rate > 0 → t=0, rate < 0 → t=1).
1469
- * @param {boolean} [rewind=false] Seek to playback origin after stopping.
1470
- * @returns {PoseTrack} this
1417
+ * Stop playback.
1418
+ * @param {boolean} [rewind=false] Seek to origin after stopping.
1419
+ * @returns {Track} this
1471
1420
  */
1472
1421
  stop(rewind) {
1473
1422
  const wasPlaying = this.playing;
@@ -1482,8 +1431,7 @@ class PoseTrack {
1482
1431
 
1483
1432
  /**
1484
1433
  * Clear all keyframes and stop.
1485
- * Fires `onStop` then `_onDeactivate` if was playing.
1486
- * @returns {PoseTrack} this
1434
+ * @returns {Track} this
1487
1435
  */
1488
1436
  reset() {
1489
1437
  const wasPlaying = this.playing;
@@ -1497,13 +1445,27 @@ class PoseTrack {
1497
1445
  return this;
1498
1446
  }
1499
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
+
1500
1464
  /**
1501
1465
  * Seek to a normalised position [0,1] across the full path.
1502
- * Can optionally target a specific segment (t is then local to that segment).
1503
- * Does not change the playing flag.
1504
1466
  * @param {number} t Normalised time [0, 1].
1505
1467
  * @param {number} [segIndex] Optional segment override.
1506
- * @returns {PoseTrack} this
1468
+ * @returns {Track} this
1507
1469
  */
1508
1470
  seek(t, segIndex) {
1509
1471
  const nSeg = this.segments;
@@ -1519,8 +1481,7 @@ class PoseTrack {
1519
1481
  }
1520
1482
 
1521
1483
  /**
1522
- * Normalised playback time across the full path [0, 1].
1523
- * Returns 0 when fewer than 2 keyframes exist.
1484
+ * Normalised playback position [0,1].
1524
1485
  * @returns {number}
1525
1486
  */
1526
1487
  time() {
@@ -1531,10 +1492,8 @@ class PoseTrack {
1531
1492
  }
1532
1493
 
1533
1494
  /**
1534
- * Snapshot of the current transport state.
1535
- * @returns {{ keyframes:number, segments:number, seg:number, f:number,
1536
- * time:number, playing:boolean, loop:boolean, pingPong:boolean,
1537
- * rate:number, duration:number }}
1495
+ * Snapshot of transport state.
1496
+ * @returns {Object}
1538
1497
  */
1539
1498
  info() {
1540
1499
  return {
@@ -1552,12 +1511,8 @@ class PoseTrack {
1552
1511
  }
1553
1512
 
1554
1513
  /**
1555
- * Advance the cursor by rate frames.
1556
- *
1557
- * rate === 0: frozen — returns this.playing without moving (no-op).
1558
- * Returns false and fires onEnd → _onDeactivate when a once-mode boundary is hit.
1559
- * Returns true while playing and continuing.
1560
- *
1514
+ * Advance cursor by rate frames.
1515
+ * Returns true while playing, false when stopping.
1561
1516
  * @returns {boolean}
1562
1517
  */
1563
1518
  tick() {
@@ -1566,8 +1521,6 @@ class PoseTrack {
1566
1521
  if (nSeg === 0) {
1567
1522
  this.playing = false; this._onDeactivate?.(); return false;
1568
1523
  }
1569
-
1570
- // Frozen: position does not advance, playing stays true
1571
1524
  if (this._rate === 0) return true;
1572
1525
 
1573
1526
  const dur = Math.max(1, this.duration | 0);
@@ -1575,25 +1528,22 @@ class PoseTrack {
1575
1528
  const s = _clampS(this.seg * dur + this.f, 0, total);
1576
1529
  const next = s + this._rate;
1577
1530
 
1578
- // ── pingPong ──
1579
1531
  if (this.pingPong) {
1580
1532
  let pos = next, flips = 0;
1581
1533
  while (pos < 0 || pos > total) {
1582
- if (pos < 0) { pos = -pos; flips++; }
1583
- else { pos = 2 * total - pos; flips++; }
1534
+ if (pos < 0) { pos = -pos; flips++; }
1535
+ else { pos = 2 * total - pos; flips++; }
1584
1536
  }
1585
1537
  if (flips & 1) this._rate = -this._rate;
1586
1538
  this._setCursorFromScalar(pos);
1587
1539
  return true;
1588
1540
  }
1589
1541
 
1590
- // ── loop ──
1591
1542
  if (this.loop) {
1592
1543
  this._setCursorFromScalar(((next % total) + total) % total);
1593
1544
  return true;
1594
1545
  }
1595
1546
 
1596
- // ── once — boundary check ──
1597
1547
  if (next <= 0) {
1598
1548
  this._setCursorFromScalar(0);
1599
1549
  this.playing = false;
@@ -1613,15 +1563,112 @@ class PoseTrack {
1613
1563
  return true;
1614
1564
  }
1615
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
+
1616
1665
  /**
1617
- * Evaluate the interpolated pose at the current cursor into out.
1618
- * If out is omitted a new object is allocated (avoid in hot paths).
1619
- * Uses centripetal Catmull-Rom for position (posInterp === 'catmullrom') or lerp.
1666
+ * Evaluate interpolated TRS pose at current cursor.
1620
1667
  * @param {{ pos:number[], rot:number[], scl:number[] }} [out]
1621
1668
  * @returns {{ pos:number[], rot:number[], scl:number[] }} out
1622
1669
  */
1623
1670
  eval(out) {
1624
- 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] };
1625
1672
  const n = this.keyframes.length;
1626
1673
  if (n === 0) return out;
1627
1674
 
@@ -1640,27 +1687,30 @@ class PoseTrack {
1640
1687
  const k0 = this.keyframes[seg];
1641
1688
  const k1 = this.keyframes[seg + 1];
1642
1689
 
1643
- // Position
1690
+ // pos — Catmull-Rom or lerp
1644
1691
  if (this.posInterp === 'catmullrom') {
1645
- const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1692
+ const p0 = seg > 0 ? this.keyframes[seg - 1].pos : k0.pos;
1646
1693
  const p3 = seg + 2 < n ? this.keyframes[seg + 2].pos : k1.pos;
1647
1694
  catmullRomVec3(out.pos, p0, k0.pos, k1.pos, p3, t);
1648
1695
  } else {
1649
1696
  lerpVec3(out.pos, k0.pos, k1.pos, t);
1650
1697
  }
1651
1698
 
1652
- // RotationSLERP
1653
- 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
+ }
1654
1705
 
1655
- // ScaleLERP
1706
+ // scllerp
1656
1707
  lerpVec3(out.scl, k0.scl, k1.scl, t);
1657
1708
 
1658
1709
  return out;
1659
1710
  }
1660
1711
 
1661
1712
  /**
1662
- * Evaluate the current cursor into an existing column-major mat4.
1663
- * Reuses internal scratch arrays — no allocation per call.
1713
+ * Evaluate into an existing column-major mat4.
1664
1714
  * @param {Float32Array|number[]} outMat4 16-element array.
1665
1715
  * @returns {Float32Array|number[]} outMat4
1666
1716
  */
@@ -1668,17 +1718,161 @@ class PoseTrack {
1668
1718
  const xf = this.eval({ pos: this._pos, rot: this._rot, scl: this._scl });
1669
1719
  return transformToMat4(outMat4, xf);
1670
1720
  }
1721
+ }
1671
1722
 
1672
- // ── Private ──────────────────────────────────────────────────────────────
1723
+ // =========================================================================
1724
+ // S7 CameraTrack
1725
+ // =========================================================================
1673
1726
 
1674
- /** @private */
1675
- _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;
1676
1844
  const dur = Math.max(1, this.duration | 0);
1677
- const nSeg = this.segments;
1678
- this.seg = Math.floor(s / dur);
1679
- this.f = s - this.seg * dur;
1680
- if (this.seg >= nSeg) { this.seg = nSeg - 1; this.f = dur; }
1681
- 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;
1682
1876
  }
1683
1877
  }
1684
1878
 
@@ -1839,5 +2033,5 @@ function boxVisibility(planes, x0, y0, z0, x1, y1, z1) {
1839
2033
  return allIn ? VISIBLE : SEMIVISIBLE;
1840
2034
  }
1841
2035
 
1842
- 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 };
1843
2037
  //# sourceMappingURL=index.js.map