cozyclay 1.2.0 → 1.3.0

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +37 -1
  4. package/bin/cozyclay.mjs +51 -2
  5. package/dist/ai-camera-control/index.html +406 -0
  6. package/dist/app/index.html +5 -5
  7. package/dist/assets/app-B3U5aut1.js +4811 -0
  8. package/dist/assets/app-BrRF0wso.css +1 -0
  9. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  10. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  11. package/dist/fonts/Inter-OFL.txt +92 -0
  12. package/dist/fonts/README.md +15 -0
  13. package/dist/index.html +60 -16
  14. package/dist/sitemap.xml +7 -1
  15. package/mcp/LIVE-PROTOCOL.md +63 -0
  16. package/mcp/README.md +142 -0
  17. package/mcp/ardy-prompts.mjs +170 -0
  18. package/mcp/live-hub.mjs +105 -0
  19. package/mcp/package.json +24 -0
  20. package/mcp/server.mjs +1394 -0
  21. package/package.json +122 -90
  22. package/src/App.jsx +2957 -514
  23. package/src/ardy/cskel27.js +7 -2
  24. package/src/ardy/ik.js +25 -15
  25. package/src/ardy/npz.js +64 -3
  26. package/src/ardy/playback.js +31 -1
  27. package/src/ardy/prompt-clips.js +7 -2
  28. package/src/ardy/retime.js +211 -0
  29. package/src/ardy/timeline-coordinates.js +13 -0
  30. package/src/ardy/timeline.jsx +124 -5
  31. package/src/ardy/to-cskel27.js +34 -12
  32. package/src/ardy/trim.js +33 -0
  33. package/src/asset-pane.jsx +36 -0
  34. package/src/dualview.jsx +14 -8
  35. package/src/hierarchy-model.js +95 -13
  36. package/src/hierarchy-panel.jsx +139 -6
  37. package/src/live-control.js +122 -0
  38. package/src/matte-editor.js +543 -0
  39. package/src/matte.js +503 -0
  40. package/src/multimodel-ingest.js +344 -0
  41. package/src/object-gizmo.jsx +43 -15
  42. package/src/planview.jsx +43 -32
  43. package/src/pose-extract/detector.js +75 -0
  44. package/src/pose-extract/index.js +3 -0
  45. package/src/pose-extract/take.js +87 -0
  46. package/src/pose-extract/video-frames.js +91 -0
  47. package/src/pose-thumbs.js +152 -0
  48. package/src/posestudio.jsx +361 -11
  49. package/src/project-browser.jsx +135 -0
  50. package/src/project.js +289 -0
  51. package/src/props.jsx +69 -3
  52. package/src/room.jsx +14 -35
  53. package/src/scene-asset-cache.js +125 -0
  54. package/src/scene-assets.js +288 -0
  55. package/src/scene-objects.js +245 -7
  56. package/src/scenes.js +207 -26
  57. package/src/shot-authoring.js +55 -13
  58. package/src/styles.css +1296 -129
  59. package/tools/ardy/BRIDGE.md +3 -2
  60. package/tools/ardy/README.md +9 -5
  61. package/tools/ardy/bridge.mjs +57 -1
  62. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  63. package/tools/ardy/cclay_constrained_generate.py +123 -11
  64. package/tools/ardy/cclay_sequence_generate.py +49 -0
  65. package/tools/ardy/extract.mjs +367 -0
  66. package/tools/ardy/footage.mjs +462 -0
  67. package/tools/ardy/npz.mjs +74 -9
  68. package/tools/ardy/run-on-box.sh +25 -0
  69. package/tools/ardy/run-sequence-on-box.sh +15 -0
  70. package/tools/ardy/runners/remote.mjs +8 -2
  71. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  72. package/dist/assets/app-DgZvaAE1.css +0 -1
@@ -0,0 +1,1209 @@
1
+ /**
2
+ * bvh-cskel27.mjs — SAM-3D-Body's Mixamo BVH → the exact motion arrays the
3
+ * npz decoder consumes ({ frames, fps, rotMats, rootPos, posedJoints }).
4
+ *
5
+ * Both skeletons rest in the same T-pose convention (Y-up, spine +Y, left
6
+ * arm +X — verified against CSKEL27_NEUTRAL and the BVH template generator),
7
+ * so a BVH world rotation IS the cskel27 global rotation for the matching
8
+ * joint, and each cskel27 local is recovered as parentGlobalᵀ · global.
9
+ * Joints cskel27 has and the BVH does not (Spine3, the Hand ends) borrow the
10
+ * nearest BVH ancestor's world rotation, which makes their local rotation
11
+ * identity — the same "not authored" rule poseToCskel27 applies.
12
+ *
13
+ * Joint positions are re-grown with FK over the CANONICAL cskel27 skeleton:
14
+ * the take must move CozyClay's body, not drag SAM's per-clip bone lengths
15
+ * into the scene. The root trajectory is scaled by the leg-length ratio and
16
+ * floor-shifted so the clip's lowest foot sample touches Y=0.
17
+ */
18
+
19
+ import { CSKEL27_JOINTS, CSKEL27_PARENTS } from "../../src/ardy/cskel27.js";
20
+ import { deriveBoneOffsets, forwardKinematics, globalRotations, matMul, matToQuat, matTranspose, quatToMat } from "../../src/ardy/convert.js";
21
+ import { canonicalCskel27Reference } from "../../src/ardy/to-cskel27.js";
22
+ import { slerpQuat } from "../../src/ardy/retime.js";
23
+
24
+ const CM_TO_M = 0.01;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // EVERY temporal constant in this file is a DURATION, resolved to frames
28
+ // against the clip's own fps at runtime. It used to be a mix, and the frame
29
+ // counts were a measured bug: SAM extractions arrive at whatever rate the
30
+ // footage ran at — the two fixtures are 30 fps, the user's real capture is
31
+ // 60 — and a constant written as N FRAMES covers half the time at 60 fps, so
32
+ // it filters half as hard exactly where the per-frame input noise is twice as
33
+ // dense (foot wobble measured 3.4 cm on the 60 fps take against 0.6 cm on the
34
+ // 30 fps fixtures). Before the conversion, feeding this module a 60 fps
35
+ // rendering of the SAME motion produced 1.8x the time-normalized ankle jitter
36
+ // and 1.7x the knee jerk of the 30 fps one, and dropped grounding 86.6 → 81.4 %.
37
+ // Every duration below is written as <30 fps frames>/30 so that 30 fps
38
+ // reproduces exactly the numbers each comment was measured at.
39
+ // ---------------------------------------------------------------------------
40
+
41
+ // Stabilization, ported from the proven Blender retarget script the old
42
+ // ingest pipeline used (sam3d-retarget-shadow.py):
43
+ // - SPIN: SAM's BVH occasionally winds the root nearly 360° in a few frames
44
+ // when yaw crosses ±180; detected by root angular SPEED (deg/s — as a
45
+ // per-frame threshold the same physical spin reads half as fast at 60 fps
46
+ // and the repair silently stops firing) and bridged with one shortest-path
47
+ // slerp, corrected on EVERY joint so local articulation survives.
48
+ // - TORSO: mild zero-lag three-frame slerp on the trunk only; hands and
49
+ // arms stay untouched so punch timing and impact speed survive.
50
+ // - LEGS: the same slerp, harder, on both leg chains — see LEG_BLEND.
51
+ const SPIN_DEG_PER_S = 25 * 30; // = 25°/frame at 30 fps
52
+ const TORSO_BLEND = 0.2;
53
+ const TORSO_JOINTS = ["Hips", "Spine", "Spine1", "Spine2", "Neck", "Head"].map((n) => `mixamorig:${n}`);
54
+ // The leg chains get the SAME zero-lag 3-frame slerp, at double strength. This
55
+ // is the single largest measured lever on foot tremble: SAM estimates the legs
56
+ // frame-independently and they are the joints a fixed camera sees worst
57
+ // (self-occlusion behind the torso on every guard-up stance), so their
58
+ // per-frame rotation noise is what the ankle — and then the whole grounding
59
+ // stack downstream — has to fight. On its own it takes ankle accel RMS
60
+ // 2.32 → 1.71 cm and toe 2.53 → 1.83 on boxing-offline (2.29 → 1.65 / 2.50 →
61
+ // 1.78 on shadow17); take it back out of the finished stack and the ankle
62
+ // returns to 2.35. It costs almost nothing in motion: the 95th percentile
63
+ // ankle speed falls 318 → 303 cm/s, so the footwork is smoothed, not killed.
64
+ // Arms are still deliberately NOT smoothed: a punch is a two-frame
65
+ // acceleration into an impact, and blending it with its neighbours is exactly
66
+ // what softens the landing the animation exists to show. A foot has no such
67
+ // event — it plants and it swings — so the same filter costs it nothing.
68
+ const LEG_BLEND = 0.4;
69
+ const LEG_JOINTS = ["LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase", "RightUpLeg", "RightLeg", "RightFoot", "RightToeBase"]
70
+ .map((n) => `mixamorig:${n}`);
71
+ // Neighbour span of that slerp: ±1 frame at 30 fps. Widening the span instead
72
+ // of repeating the pass would be wrong — a stride-2 three-tap filter has unity
73
+ // gain at Nyquist, so at 60 fps it would leave the every-other-frame noise
74
+ // (the dominant term) completely untouched. Repeating a ±1-frame pass keeps
75
+ // the stopband and matches the 30 fps kernel in TIME (see smoothingPasses).
76
+ const TORSO_SMOOTH_HALF_S = 1 / 30;
77
+ const ROOT_XZ_SMOOTH_HALF_S = 2 / 30; // root translation jitter is camera noise, not gait (5-frame window at 30 fps)
78
+ // Grounding: the ground line PINS the SUPPORT sole to zero (the fixed-camera
79
+ // scope makes "wherever the planted sole lands" the floor by definition),
80
+ // with a jump permit so fast+tall+brief excursions of BOTH feet keep their
81
+ // measured air (see 4c). The version this replaces read the floor off the
82
+ // merely LOWEST foot, which is the SWING leg most of the time — measured on
83
+ // both fixtures that foot is horizontally still on 6–13 % of frames and runs
84
+ // 0.7 m/s median — so its per-frame estimation error went straight into body
85
+ // height and the hip popped at each of the ~3 support changes per second.
86
+ // On top of the line: per-foot contact RUNS with hysteresis, then a 2-bone
87
+ // leg IK that pins each planted ankle in XZ. Occlusion errors (a forward bend
88
+ // hides the legs and SAM floats or sinks them by ±25 cm) are bridged by XZ
89
+ // consistency: a foot that leaves "contact" but reappears at the same spot
90
+ // never actually moved, so the gap is pinned too — while a real jump either
91
+ // travels or lifts past the bridge ceiling and keeps its air. Whatever still
92
+ // ends up under the floor is corrected in the LEG rather than in the body
93
+ // (4f), because that is where the error is.
94
+ // SUPPORT — is this foot on the floor at all? Deliberately generous: a foot
95
+ // that pivots, drags or rocks while it carries weight is still the floor
96
+ // witness, and the boxer's slower ankle never drops below 0.43 m/s anyway.
97
+ // The three together put at least one foot in support on 82 % of frames; a
98
+ // strict reading leaves the ground line nothing to stand on for half the clip.
99
+ const SUPPORT_BAND_M = 0.16; // ankle within this of its own rolling low...
100
+ const SUPPORT_RISE_MPS = 1.0; // ...not climbing or dropping like a swing leg...
101
+ const SUPPORT_MIN_RUN_S = 4 / 30; // ...and holding for at least this long (0.13 s)
102
+ // Baseline of the vertical-speed central difference the two gates above read.
103
+ // A ±2-FRAME difference measures 0.13 s at 30 fps but 0.07 s at 60, where
104
+ // SAM's per-frame estimation noise (ankle accel RMS ~2.4 cm) has not yet
105
+ // averaged out — the gate would then be reading noise rather than movement and
106
+ // reject planted feet. Per-frame deltas are mostly that noise; this baseline is
107
+ // long enough to read the actual movement underneath it.
108
+ const SPEED_DIFF_HALF_S = 2 / 30;
109
+ // The hand-over. REF_MARGIN_M is what makes the reference sticky: it takes a
110
+ // real weight shift to move the floor onto the other foot, not the 3.6 cm
111
+ // median disagreement between the two feet's soles. Reference changes fall
112
+ // from 3.0/s (reading whichever foot is lowest) to 1.2/s.
113
+ const REF_MARGIN_M = 0.05; // the other foot must be in support AND this much lower to take over
114
+ const TRANSFER_BLEND_HALF_S = 3 / 30; // hand-over cross-fade (7-frame window at 30 fps), box-smoothed twice → an S-ramp with no corner
115
+ const FLOOR_SAG_M = 0.02; // how far the stance line may sit above the lowest sole
116
+ // CONTACT — is this foot ALSO nailed to one spot? This gate feeds the XZ foot
117
+ // LOCK, which teleports the ankle onto a single point, so it must stay strict:
118
+ // widening it to cover footwork (a 14 cm band / 12 cm wander patch) made the
119
+ // lock fire on 41 % of frames and drag ankles up to 18 cm — legs visibly
120
+ // flying. The ground line does NOT depend on this track (it reads `support`
121
+ // above), so strictness here costs nothing but a little residual foot slide.
122
+ // Stillness is still judged over the RUN rather than per frame: 1 cm of SAM's
123
+ // per-frame XZ noise already reads as 0.3 m/s.
124
+ const CONTACT_BAND_M = 0.06; // ankle within this of its own rolling low (two-sided: a below-floor dip is an ERROR, not a contact)
125
+ const CONTACT_SETTLE_MPS = 0.6; // ...not dropping or rising like a swing leg...
126
+ const CONTACT_WANDER_M = 0.03; // ...and never leaving one patch of floor for the whole run
127
+ const CONTACT_MIN_RUN_S = 3 / 30; // shortest accepted contact / bridged flicker gap (0.1 s)
128
+ const BRIDGE_MAX_GAP_S = 0.8; // occlusion gaps longer than this are believed instead
129
+ const BRIDGE_MAX_XZ_M = 0.12; // the foot must reappear this close to where it left
130
+ const BRIDGE_MAX_LIFT_M = 0.25; // ankle lift above this reads as a real jump, never bridged
131
+ const LOCK_BLEND_S = 4 / 30; // IK ramp at each contact-run edge (0.13 s)
132
+ // A plant that would have to be dragged further than this was never a plant —
133
+ // the run passed the wander gate but the lock point still lands far from where
134
+ // this frame's ankle actually is (occlusion bridging can extend a run across
135
+ // exactly such a stretch). Snapping the leg there is worse than the slide the
136
+ // lock exists to remove, so the correction is skipped instead of ramped.
137
+ const LOCK_MAX_PULL_M = 0.04;
138
+ // Jump permit — the only measured air the foot-pinned ground line believes.
139
+ // SAM's depth-drift floats are SLOW (the worst on the boxing clip climb
140
+ // ~20 cm over a second-plus); a real hop is FAST and BRIEF. An excursion of
141
+ // the lowest sole must clear all three gates to keep its air.
142
+ const AIR_EPS_M = 0.05; // candidate air: lowest sole this far above its local floor
143
+ const JUMP_MIN_PEAK_M = 0.15; // must peak at least this high...
144
+ const JUMP_MIN_RISE_MPS = 0.7; // ...climbing at least this fast...
145
+ const JUMP_MAX_AIR_S = 0.9; // ...and land within ballistic time, or it is drift
146
+ // Ground-line smoothing: sole jitter must not shake the root. Short on
147
+ // purpose — every centimetre the smoothed line drifts off the measured sole is
148
+ // a centimetre 4f has to take back out of a leg, and past a few frames that
149
+ // correction costs more than the smoothing saves (hip jerk RMS 0.58 cm at a
150
+ // 5-frame window, 0.90 at 13). Re-swept: 5 frames was still too long. Roughly
151
+ // HALF the floor penetration the 4f guard has to repair was manufactured here
152
+ // — on exactly the frames the guard fires, the smoothed line sat +0.61 cm
153
+ // above the measured sole — and a 3-frame window beats a 5-frame one on knee
154
+ // jerk (3.44 vs 3.68°), grounding (87.2 vs 86.6 %) and hover (1.64 vs 1.86 cm)
155
+ // while the hip cost the longer window was bought for is 0.01 cm. In the
156
+ // finished stack it matters more than that: with the shaped guard in front of
157
+ // it, putting the window back to 5 frames costs 2.1 points of grounding
158
+ // (86.2 → 84.1 %) for 0.004 cm of hip.
159
+ const PIN_SMOOTH_HALF_S = 1 / 30; // 3-frame window at 30 fps
160
+ // The 4f penetration guard's correction is SHAPED over this half-window before
161
+ // it is applied. Raw, the guard fired on 228/514 frames (44 %), switching on
162
+ // and off 6.8 times a second in bursts of median length 3 frames, and the
163
+ // correction's own second difference was 1.10 cm RMS — it fixed the sole and
164
+ // injected a per-frame tremble into the leg doing it. ±2 frames at 30 fps is
165
+ // the shortest window that spans that median burst.
166
+ const GUARD_SHAPE_HALF_S = 2 / 30;
167
+ // "Near the floor" is always measured against a LOCAL low, never a global
168
+ // constant, because SAM's vertical drift moves the apparent ground by tens of
169
+ // cm across a clip. Half a second is long enough to contain a whole step and
170
+ // short enough that the drift inside it is small. Already a duration before
171
+ // this file's constants were converted; named here so the block is complete.
172
+ const LOCAL_LOW_HALF_S = 0.5;
173
+
174
+ /** Frames covering `seconds` at `fps`, never fewer than `min` — a run length,
175
+ * a blend ramp or a central-difference baseline of zero frames is not an
176
+ * algorithm, so a slow clip degrades to the tightest meaningful setting
177
+ * rather than to a no-op. */
178
+ function framesFor(seconds, fps, min = 1) {
179
+ return Math.max(min, Math.round(seconds * fps));
180
+ }
181
+
182
+ /** Odd, centred moving-average window covering ±`halfSeconds` — the smoothers
183
+ * below all read `Math.floor(window / 2)` on each side, so the window has to
184
+ * be built from the half-width to stay symmetric at any rate. */
185
+ function windowFor(halfSeconds, fps) {
186
+ return 2 * framesFor(halfSeconds, fps) + 1;
187
+ }
188
+
189
+ /** How many passes of the three-frame slerp reproduce ONE pass at 30 fps.
190
+ * A three-tap smoother's spread grows as sqrt(passes) FRAMES, i.e.
191
+ * sqrt(passes)/fps seconds, so holding the duration fixed costs
192
+ * (halfSeconds * fps)² passes — 1 at 30 fps, 4 at 60. */
193
+ function smoothingPasses(halfSeconds, fps) {
194
+ return Math.max(1, Math.round((halfSeconds * fps) ** 2));
195
+ }
196
+
197
+ // cskel27 joint -> BVH joint (mixamorig: prefix added at lookup). Identity
198
+ // mapping except the joints the BVH does not carry.
199
+ const BVH_SOURCE = Object.fromEntries(CSKEL27_JOINTS.map((name) => [name, name]));
200
+ BVH_SOURCE.Spine3 = "Spine2";
201
+ BVH_SOURCE.LeftHandEnd = "LeftHand";
202
+ BVH_SOURCE.RightHandEnd = "RightHand";
203
+
204
+ /** Minimal BVH reader: hierarchy (names, parents, offsets, channels) and the
205
+ * motion table. Throws with a named reason on anything malformed. */
206
+ export function parseBvh(text) {
207
+ if (typeof text !== "string" || !text.includes("HIERARCHY")) throw new Error("bvh-malformed");
208
+ const tokens = text.slice(text.indexOf("HIERARCHY")).split(/\s+/);
209
+ let cursor = 0;
210
+ const next = () => tokens[cursor++];
211
+ const peek = () => tokens[cursor];
212
+ const joints = [];
213
+ const stack = [];
214
+
215
+ if (next() !== "HIERARCHY") throw new Error("bvh-malformed");
216
+ while (cursor < tokens.length) {
217
+ const token = next();
218
+ if (token === "ROOT" || token === "JOINT") {
219
+ const name = next();
220
+ const joint = { name, parent: stack.length ? stack[stack.length - 1] : -1, offset: [0, 0, 0], channels: [] };
221
+ joints.push(joint);
222
+ if (next() !== "{") throw new Error("bvh-malformed");
223
+ stack.push(joints.length - 1);
224
+ } else if (token === "OFFSET") {
225
+ const target = stack[stack.length - 1];
226
+ const offset = [Number(next()), Number(next()), Number(next())];
227
+ if (!offset.every(Number.isFinite)) throw new Error("bvh-malformed");
228
+ if (target !== undefined && joints[target].endPending) joints[target].end = offset;
229
+ else if (target !== undefined) joints[target].offset = offset;
230
+ } else if (token === "CHANNELS") {
231
+ const count = Number(next());
232
+ const channels = [];
233
+ for (let i = 0; i < count; i += 1) channels.push(next());
234
+ joints[stack[stack.length - 1]].channels = channels;
235
+ } else if (token === "End") {
236
+ next(); // "Site"
237
+ if (next() !== "{") throw new Error("bvh-malformed");
238
+ joints[stack[stack.length - 1]].endPending = true;
239
+ } else if (token === "}") {
240
+ const top = stack[stack.length - 1];
241
+ if (top !== undefined && joints[top].endPending) joints[top].endPending = false;
242
+ else stack.pop();
243
+ if (stack.length === 0 && peek() === "MOTION") break;
244
+ } else if (token === "MOTION") {
245
+ break;
246
+ }
247
+ }
248
+ if (next() !== "MOTION" && tokens[cursor - 1] !== "MOTION") {
249
+ // the while-loop may have consumed MOTION already via peek/break
250
+ }
251
+ // Align cursor to just after "MOTION".
252
+ while (tokens[cursor - 1] !== "MOTION") {
253
+ if (cursor >= tokens.length) throw new Error("bvh-malformed");
254
+ cursor += 1;
255
+ }
256
+ if (next() !== "Frames:") throw new Error("bvh-malformed");
257
+ const frames = Number(next());
258
+ if (next() !== "Frame" || next() !== "Time:") throw new Error("bvh-malformed");
259
+ const frameTimeS = Number(next());
260
+ if (!Number.isInteger(frames) || frames < 1 || !(frameTimeS > 0)) throw new Error("bvh-malformed");
261
+ const channelTotal = joints.reduce((sum, joint) => sum + joint.channels.length, 0);
262
+ const values = new Float64Array(frames * channelTotal);
263
+ for (let i = 0; i < values.length; i += 1) {
264
+ const value = Number(next());
265
+ if (!Number.isFinite(value)) throw new Error("bvh-motion-truncated");
266
+ values[i] = value;
267
+ }
268
+ return { joints, frames, frameTimeS, channelTotal, values };
269
+ }
270
+
271
+ function rotX(deg) {
272
+ const r = (deg * Math.PI) / 180;
273
+ const c = Math.cos(r);
274
+ const s = Math.sin(r);
275
+ return [[1, 0, 0], [0, c, -s], [0, s, c]];
276
+ }
277
+ function rotY(deg) {
278
+ const r = (deg * Math.PI) / 180;
279
+ const c = Math.cos(r);
280
+ const s = Math.sin(r);
281
+ return [[c, 0, s], [0, 1, 0], [-s, 0, c]];
282
+ }
283
+ function rotZ(deg) {
284
+ const r = (deg * Math.PI) / 180;
285
+ const c = Math.cos(r);
286
+ const s = Math.sin(r);
287
+ return [[c, -s, 0], [s, c, 0], [0, 0, 1]];
288
+ }
289
+ const ROTATORS = { Xrotation: rotX, Yrotation: rotY, Zrotation: rotZ };
290
+
291
+ /** Convert a parsed Mixamo BVH into cskel27 motion arrays. */
292
+ export function bvhToCskel27Motion(bvh) {
293
+ const { joints, frames, frameTimeS, channelTotal, values } = bvh;
294
+ const bvhIndex = new Map(joints.map((joint, index) => [joint.name, index]));
295
+ const sourceIndex = CSKEL27_JOINTS.map((name) => {
296
+ const source = `mixamorig:${BVH_SOURCE[name]}`;
297
+ if (!bvhIndex.has(source)) throw new Error(`bvh-missing-joint:${source}`);
298
+ return bvhIndex.get(source);
299
+ });
300
+ const channelStart = [];
301
+ let running = 0;
302
+ for (const joint of joints) {
303
+ channelStart.push(running);
304
+ running += joint.channels.length;
305
+ }
306
+
307
+ const jointCount = CSKEL27_JOINTS.length;
308
+ const fps = Math.round(1 / frameTimeS);
309
+ // Every duration in the constants block, resolved against THIS clip's rate.
310
+ // Nothing below this line may be written as a frame count.
311
+ const spinDegPerFrame = SPIN_DEG_PER_S / fps;
312
+ const torsoPasses = smoothingPasses(TORSO_SMOOTH_HALF_S, fps);
313
+ const rootXzWindow = windowFor(ROOT_XZ_SMOOTH_HALF_S, fps);
314
+ const supportMinRun = framesFor(SUPPORT_MIN_RUN_S, fps);
315
+ const contactMinRun = framesFor(CONTACT_MIN_RUN_S, fps);
316
+ const speedDiffHalf = framesFor(SPEED_DIFF_HALF_S, fps);
317
+ const transferBlendWindow = windowFor(TRANSFER_BLEND_HALF_S, fps);
318
+ const pinSmoothWindow = windowFor(PIN_SMOOTH_HALF_S, fps);
319
+ const lockBlendFrames = framesFor(LOCK_BLEND_S, fps);
320
+ const guardShapeHalf = framesFor(GUARD_SHAPE_HALF_S, fps);
321
+ const localLowHalf = framesFor(LOCAL_LOW_HALF_S, fps);
322
+ const rotMats = new Float32Array(frames * jointCount * 9);
323
+ const rootPos = new Float32Array(frames * 3);
324
+ const posedJoints = new Float32Array(frames * jointCount * 3);
325
+
326
+ const skeleton = canonicalCskel27Reference();
327
+ const offsets = deriveBoneOffsets(skeleton.posed_joints, skeleton.local_rot_mats);
328
+
329
+ // Scale the root trajectory to CozyClay's body: SAM writes per-clip bone
330
+ // lengths, and a 92 cm-legged skeleton's hip path on a 88 cm-legged body
331
+ // floats or sinks. Ratio of hip-to-ankle chain lengths, both skeletons.
332
+ const canonicalLeg = chainLength(skeleton.posed_joints, ["LeftUpLeg", "LeftLeg", "LeftFoot"]);
333
+ const bvhLeg =
334
+ (magnitude(joints[bvhIndex.get("mixamorig:LeftLeg")].offset) +
335
+ magnitude(joints[bvhIndex.get("mixamorig:LeftFoot")].offset)) * CM_TO_M;
336
+ const rootScale = bvhLeg > 1e-6 ? canonicalLeg / bvhLeg : 1;
337
+
338
+ const IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
339
+ const rootChannels = joints[0].channels;
340
+
341
+ // Pass 1 — BVH world rotations for every joint on every frame: local
342
+ // eulers composed in listed channel order, chained down the hierarchy.
343
+ const worldsByFrame = new Array(frames);
344
+ for (let f = 0; f < frames; f += 1) {
345
+ const base = f * channelTotal;
346
+ const worlds = new Array(joints.length);
347
+ for (let j = 0; j < joints.length; j += 1) {
348
+ const joint = joints[j];
349
+ let local = IDENTITY;
350
+ const start = base + channelStart[j];
351
+ for (let c = 0; c < joint.channels.length; c += 1) {
352
+ const rotate = ROTATORS[joint.channels[c]];
353
+ if (!rotate) continue; // position channels are read separately
354
+ local = matMul(local, rotate(values[start + c]));
355
+ }
356
+ worlds[j] = joint.parent < 0 ? local : matMul(worlds[joint.parent], local);
357
+ }
358
+ worldsByFrame[f] = worlds;
359
+ // Root position: the position channels, cm → m, scaled.
360
+ const start = base + channelStart[0];
361
+ for (let c = 0; c < rootChannels.length; c += 1) {
362
+ if (rootChannels[c] === "Xposition") rootPos[f * 3] = values[start + c] * CM_TO_M * rootScale;
363
+ else if (rootChannels[c] === "Yposition") rootPos[f * 3 + 1] = values[start + c] * CM_TO_M * rootScale;
364
+ else if (rootChannels[c] === "Zposition") rootPos[f * 3 + 2] = values[start + c] * CM_TO_M * rootScale;
365
+ }
366
+ }
367
+ // Frame-0 root in RAW metres (unscaled, untouched by any later pass):
368
+ // every person extracted from the same clip shares this camera space, so
369
+ // the difference between two takes' rawRootStart is their real-world
370
+ // relative placement in the scene.
371
+ const rawRootStart = [rootPos[0] / rootScale, rootPos[1] / rootScale, rootPos[2] / rootScale];
372
+
373
+ // Pass 2 — repair root spins, then stabilize the torso and the legs (see
374
+ // header). The two joint sets are disjoint and each pass snapshots its own
375
+ // input, so the order between them does not change the result.
376
+ repairRootSpins(worldsByFrame, bvhIndex.get("mixamorig:Hips"), spinDegPerFrame);
377
+ const bvhIndices = (names) => names.map((name) => bvhIndex.get(name)).filter((i) => i !== undefined);
378
+ stabilizeChain(worldsByFrame, bvhIndices(TORSO_JOINTS), TORSO_BLEND, torsoPasses);
379
+ stabilizeChain(worldsByFrame, bvhIndices(LEG_JOINTS), LEG_BLEND, torsoPasses);
380
+
381
+ // The BVH's own sole heights, via FK over ITS skeleton. SAM's offline
382
+ // foot-contact pass already leveled the ground relationship in that
383
+ // space (median lowest sole ≈ 2 cm); re-growing the pose on CozyClay's
384
+ // canonical proportions would shift it (a crouched leg of different
385
+ // thigh/shin ratio lands its foot at a different height), so the raw
386
+ // sole track is carried over as the reference to restore.
387
+ const rawSoleScaled = new Float64Array(frames);
388
+ {
389
+ const soleJoints = ["LeftFoot", "RightFoot", "LeftToeBase", "RightToeBase"]
390
+ .map((name) => bvhIndex.get(`mixamorig:${name}`))
391
+ .filter((index) => index !== undefined);
392
+ const positions = new Array(joints.length);
393
+ for (let f = 0; f < frames; f += 1) {
394
+ const worlds = worldsByFrame[f];
395
+ positions[0] = [rootPos[f * 3] / (CM_TO_M * rootScale), rootPos[f * 3 + 1] / (CM_TO_M * rootScale), rootPos[f * 3 + 2] / (CM_TO_M * rootScale)];
396
+ for (let j = 1; j < joints.length; j += 1) {
397
+ const parent = joints[j].parent;
398
+ const rotated = matVec3(worlds[parent], joints[j].offset);
399
+ positions[j] = [
400
+ positions[parent][0] + rotated[0],
401
+ positions[parent][1] + rotated[1],
402
+ positions[parent][2] + rotated[2],
403
+ ];
404
+ }
405
+ let low = Infinity;
406
+ for (const j of soleJoints) low = Math.min(low, positions[j][1]);
407
+ rawSoleScaled[f] = low * CM_TO_M * rootScale;
408
+ }
409
+ }
410
+
411
+ // Root translation: camera-space jitter is noise, not gait — X/Z ride a
412
+ // short centred average.
413
+ smoothTrack(rootPos, frames, 0, rootXzWindow);
414
+ smoothTrack(rootPos, frames, 2, rootXzWindow);
415
+
416
+ // Pass 3 — cskel27 locals: parentGlobalᵀ · global along the cskel27 chain.
417
+ for (let f = 0; f < frames; f += 1) {
418
+ const worlds = worldsByFrame[f];
419
+ for (let j = 0; j < jointCount; j += 1) {
420
+ const global = worlds[sourceIndex[j]];
421
+ const parent = CSKEL27_PARENTS[j];
422
+ const local = parent === null ? global : matMul(matTranspose(worlds[sourceIndex[parent]]), global);
423
+ const o = (f * jointCount + j) * 9;
424
+ rotMats[o] = local[0][0]; rotMats[o + 1] = local[0][1]; rotMats[o + 2] = local[0][2];
425
+ rotMats[o + 3] = local[1][0]; rotMats[o + 4] = local[1][1]; rotMats[o + 5] = local[1][2];
426
+ rotMats[o + 6] = local[2][0]; rotMats[o + 7] = local[2][1]; rotMats[o + 8] = local[2][2];
427
+ }
428
+ }
429
+
430
+ // Pass 4 — grounding and foot lock (see the constants block), then one
431
+ // global shift so the lowest contact sits exactly on Y=0.
432
+ const readLocals = (f) => {
433
+ const frameLocals = new Array(jointCount);
434
+ for (let j = 0; j < jointCount; j += 1) {
435
+ const o = (f * jointCount + j) * 9;
436
+ frameLocals[j] = [
437
+ [rotMats[o], rotMats[o + 1], rotMats[o + 2]],
438
+ [rotMats[o + 3], rotMats[o + 4], rotMats[o + 5]],
439
+ [rotMats[o + 6], rotMats[o + 7], rotMats[o + 8]],
440
+ ];
441
+ }
442
+ return frameLocals;
443
+ };
444
+ const rootAt = (f) => [rootPos[f * 3], rootPos[f * 3 + 1], rootPos[f * 3 + 2]];
445
+ const writeLocal = (f, j, m) => {
446
+ const o = (f * jointCount + j) * 9;
447
+ rotMats[o] = m[0][0]; rotMats[o + 1] = m[0][1]; rotMats[o + 2] = m[0][2];
448
+ rotMats[o + 3] = m[1][0]; rotMats[o + 4] = m[1][1]; rotMats[o + 5] = m[1][2];
449
+ rotMats[o + 6] = m[2][0]; rotMats[o + 7] = m[2][1]; rotMats[o + 8] = m[2][2];
450
+ };
451
+ const J = (name) => CSKEL27_JOINTS.indexOf(name);
452
+ const feet = ["LeftToeBase", "RightToeBase", "LeftFoot", "RightFoot"].map(J);
453
+ const legs = [
454
+ { hip: J("LeftUpLeg"), knee: J("LeftLeg"), ankle: J("LeftFoot") },
455
+ { hip: J("RightUpLeg"), knee: J("RightLeg"), ankle: J("RightFoot") },
456
+ ];
457
+ const hipsIdx = J("Hips");
458
+
459
+ // 4a) ankle and sole tracks from a first FK sweep — and the proportion
460
+ // correction: with the SAME joint angles, a crouched leg of different
461
+ // thigh/shin ratio lands its sole at a different height, which reads as
462
+ // a constant hover on a fighter who never fully straightens. The root is
463
+ // re-seated per frame so the canonical body's lowest sole reproduces the
464
+ // (scaled) sole height SAM's own leveled skeleton had.
465
+ const toes = [J("LeftToeBase"), J("RightToeBase")];
466
+ const ankle = [new Float64Array(frames * 3), new Float64Array(frames * 3)];
467
+ const kneeY = [new Float64Array(frames), new Float64Array(frames)];
468
+ const lowestSole = new Float64Array(frames * 2);
469
+ for (let f = 0; f < frames; f += 1) {
470
+ let positions = forwardKinematics(readLocals(f), offsets, rootAt(f));
471
+ const canonSole = Math.min(
472
+ positions[legs[0].ankle][1], positions[toes[0]][1],
473
+ positions[legs[1].ankle][1], positions[toes[1]][1]
474
+ );
475
+ const reseat = rawSoleScaled[f] - canonSole;
476
+ if (Math.abs(reseat) > 1e-9) {
477
+ rootPos[f * 3 + 1] += reseat;
478
+ positions = forwardKinematics(readLocals(f), offsets, rootAt(f));
479
+ }
480
+ for (let s = 0; s < 2; s += 1) {
481
+ ankle[s][f * 3] = positions[legs[s].ankle][0];
482
+ ankle[s][f * 3 + 1] = positions[legs[s].ankle][1];
483
+ ankle[s][f * 3 + 2] = positions[legs[s].ankle][2];
484
+ kneeY[s][f] = positions[legs[s].knee][1];
485
+ lowestSole[f * 2 + s] = Math.min(positions[legs[s].ankle][1], positions[toes[s]][1]);
486
+ }
487
+ }
488
+
489
+ // 4b) per-foot stance evidence, in two nested strengths, because the two
490
+ // consumers want different things. Both are measured against that foot's
491
+ // LOCAL low, not a global constant: SAM's vertical drift moves the apparent
492
+ // ground by tens of cm across a clip, so "near the floor" can only mean
493
+ // "near where this foot bottoms out around now".
494
+ // - SUPPORT (4c, the ground line) only has to know which foot is ON the
495
+ // floor. A boxer's lead foot pivots and drags while it carries his
496
+ // weight; it is still the floor witness, so support does NOT gate on
497
+ // horizontal speed — only on being low and vertically settled.
498
+ // - CONTACT (4e, the XZ foot lock) additionally needs the foot to STAY in
499
+ // one place, since the lock nails it there. That is judged over the RUN
500
+ // (see splitByXzWander), not per frame: 1 cm of SAM's per-frame XZ noise
501
+ // already reads as 0.3 m/s over a ±2-frame baseline, so an instantaneous
502
+ // speed gate tight enough to reject a real step also rejects every
503
+ // jittery-but-planted foot — which is exactly how the old single
504
+ // detector ended up firing on 4 % of foot-frames, leaving both the
505
+ // ground line and the lock with nothing to hold on to.
506
+ const support = [new Array(frames).fill(false), new Array(frames).fill(false)];
507
+ const realContact = [new Array(frames).fill(false), new Array(frames).fill(false)];
508
+ const lockContact = [new Array(frames).fill(false), new Array(frames).fill(false)];
509
+ const settleLimit = CONTACT_SETTLE_MPS / fps;
510
+ const riseLimit = SUPPORT_RISE_MPS / fps;
511
+ const lowHalf = localLowHalf;
512
+ for (let s = 0; s < 2; s += 1) {
513
+ for (let f = 0; f < frames; f += 1) {
514
+ // Central difference over SPEED_DIFF_HALF_S (±2 frames at 30 fps):
515
+ // per-frame deltas are mostly estimation noise (ankle accel RMS
516
+ // ~2.4 cm), a 0.13 s baseline reads the actual movement underneath
517
+ // it. The result is a per-FRAME delta, which is what riseLimit and
518
+ // settleLimit are (m/s ÷ fps), so both sides scale together.
519
+ const before = Math.max(0, f - speedDiffHalf);
520
+ const after = Math.min(frames - 1, f + speedDiffHalf);
521
+ const span = Math.max(1, after - before);
522
+ const dy = (ankle[s][after * 3 + 1] - ankle[s][before * 3 + 1]) / span;
523
+ let localLow = Infinity;
524
+ for (let k = Math.max(0, f - lowHalf); k <= Math.min(frames - 1, f + lowHalf); k += 1) {
525
+ localLow = Math.min(localLow, ankle[s][k * 3 + 1]);
526
+ }
527
+ const above = ankle[s][f * 3 + 1] - localLow;
528
+ const belowKnee = ankle[s][f * 3 + 1] < kneeY[s][f] - 0.05;
529
+ support[s][f] = above < SUPPORT_BAND_M && Math.abs(dy) < riseLimit && belowKnee;
530
+ realContact[s][f] = above < CONTACT_BAND_M && Math.abs(dy) < settleLimit && belowKnee;
531
+ }
532
+ // Run cleaning IS the hysteresis: below supportMinRun neither a
533
+ // flicker of evidence starts a stance nor a flicker of noise ends one,
534
+ // so the ground reference cannot chatter between feet frame by frame.
535
+ cleanRuns(support[s], supportMinRun);
536
+ cleanRuns(realContact[s], contactMinRun);
537
+ // ...then the lockable half of the evidence: keep only the stretches
538
+ // the ankle spends inside one CONTACT_WANDER_M patch of floor. This is
539
+ // what separates "planted, estimated noisily" from "stepping".
540
+ splitByXzWander(realContact[s], ankle[s], CONTACT_WANDER_M, contactMinRun);
541
+ }
542
+
543
+ // 4c) the measured ground line: STANCE PINNING with a jump permit. The
544
+ // scope is fixed-camera footage, so wherever a planted sole lands IS the
545
+ // ground — pinning it to zero kills SAM's vertical drift outright. (The
546
+ // rolling-quantile line two revisions back still floated up to 23 cm
547
+ // whenever a window went majority-dip.) What the floor is read FROM is the
548
+ // SUPPORT foot, not whichever foot is lowest: measured on both fixtures
549
+ // the lowest foot is horizontally still on 6–13 % of frames and runs
550
+ // 0.7 m/s median — it is usually the SWING leg, so every swing-leg
551
+ // estimation error went straight into body height and the hip popped ~1 cm
552
+ // (5.5 cm worst) at each of the ~3 support changes per second.
553
+ // The only measured air that survives is a PERMITTED jump: a fast, tall,
554
+ // brief excursion of the lowest sole above its local floor — the three
555
+ // gates that separate a real hop from a slow depth-drift float or a
556
+ // bend-occlusion lift. Over permitted air the ground interpolates takeoff
557
+ // level → landing level, so the arc keeps its measured height.
558
+ const soleAt = new Float64Array(frames); // physical lowest sole: jump gates and the sag clamp
559
+ const soleSide = [new Float64Array(frames), new Float64Array(frames)];
560
+ for (let f = 0; f < frames; f += 1) {
561
+ soleSide[0][f] = lowestSole[f * 2];
562
+ soleSide[1][f] = lowestSole[f * 2 + 1];
563
+ soleAt[f] = Math.min(soleSide[0][f], soleSide[1][f]);
564
+ }
565
+ const airHalf = localLowHalf;
566
+ const localFloor = new Float64Array(frames);
567
+ for (let f = 0; f < frames; f += 1) {
568
+ let low = Infinity;
569
+ for (let k = Math.max(0, f - airHalf); k <= Math.min(frames - 1, f + airHalf); k += 1) low = Math.min(low, soleAt[k]);
570
+ localFloor[f] = low;
571
+ }
572
+ const airborne = new Array(frames).fill(false);
573
+ const candidate = new Array(frames).fill(false);
574
+ for (let f = 0; f < frames; f += 1) candidate[f] = soleAt[f] - localFloor[f] > AIR_EPS_M;
575
+ const maxAirFrames = Math.round(JUMP_MAX_AIR_S * fps);
576
+ const airRuns = [];
577
+ for (const run of contactRuns(candidate)) {
578
+ let peak = 0;
579
+ let rise = 0;
580
+ for (let f = run.start; f <= run.end; f += 1) {
581
+ peak = Math.max(peak, soleAt[f] - localFloor[f]);
582
+ // The takeoff-speed gate reads the same SPEED_DIFF_HALF_S baseline
583
+ // as the stance gates: over ±1 frame at 60 fps a real hop and a
584
+ // noisy plant are indistinguishable.
585
+ const before = Math.max(0, f - speedDiffHalf);
586
+ const after = Math.min(frames - 1, f + speedDiffHalf);
587
+ rise = Math.max(rise, ((soleAt[after] - soleAt[before]) / Math.max(1, after - before)) * fps);
588
+ }
589
+ if (peak >= JUMP_MIN_PEAK_M && rise >= JUMP_MIN_RISE_MPS && run.end - run.start + 1 <= maxAirFrames) {
590
+ for (let f = run.start; f <= run.end; f += 1) airborne[f] = true;
591
+ airRuns.push({ ...run, peak });
592
+ }
593
+ }
594
+ // Which foot the floor is read from, frame by frame — a STICKY choice, so
595
+ // that a stance the detector reports on both feet (double support runs 53 %
596
+ // of this footage) does not hand the reference back and forth on whichever
597
+ // sole the estimator happened to place a millimetre lower this frame. The
598
+ // reference moves only when the foot holding it loses support outright, or
599
+ // when the other foot is both in support and REF_MARGIN_M lower — a real
600
+ // weight shift. With no evidence on either foot (flight, or footwork the
601
+ // detector missed) the last reference is simply held; the jump permit below
602
+ // owns those stretches.
603
+ const refSide = new Int8Array(frames);
604
+ {
605
+ let side = soleSide[0][0] <= soleSide[1][0] ? 0 : 1;
606
+ for (let f = 0; f < frames; f += 1) {
607
+ const other = 1 - side;
608
+ if (support[other][f] && (!support[side][f] || soleSide[other][f] < soleSide[side][f] - REF_MARGIN_M)) side = other;
609
+ refSide[f] = side;
610
+ }
611
+ }
612
+ // A support TRANSFER must never step. The two soles disagree by 3.6 cm
613
+ // median — estimation error, not a floor that moved — so switching source
614
+ // instantly dumps that whole gap into one frame of body height. Cross-fade
615
+ // the two sole tracks instead: the 0/1 side track box-smoothed TWICE, so
616
+ // the hand-over is an S-ramp with no corner at either end of it.
617
+ const rightShare = new Float64Array(frames);
618
+ for (let f = 0; f < frames; f += 1) rightShare[f] = refSide[f];
619
+ smoothArray(rightShare, transferBlendWindow);
620
+ smoothArray(rightShare, transferBlendWindow);
621
+ const ground = new Float64Array(frames);
622
+ for (let f = 0; f < frames; f += 1) {
623
+ ground[f] = soleSide[0][f] * (1 - rightShare[f]) + soleSide[1][f] * rightShare[f];
624
+ }
625
+ // Permitted air: the floor does not move while nobody is standing on it,
626
+ // so the line interpolates takeoff → landing and the hop keeps its arc.
627
+ for (const run of contactRuns(airborne)) {
628
+ const i0 = run.start - 1;
629
+ const i1 = run.end + 1;
630
+ const y0 = i0 >= 0 ? ground[i0] : i1 < frames ? ground[i1] : localFloor[run.start];
631
+ const y1 = i1 < frames ? ground[i1] : y0;
632
+ for (let f = run.start; f <= run.end; f += 1) {
633
+ ground[f] = y0 + ((y1 - y0) * (f - i0)) / (i1 - i0);
634
+ }
635
+ }
636
+ // Sag clamp: the stance line may sit at most FLOOR_SAG_M above the lowest
637
+ // sole. Without it a foot the detector calls "swinging" while it is in
638
+ // fact a few cm below the support foot would be driven under Y=0, and 4f's
639
+ // per-frame penetration guard would hoist that single frame — trading the
640
+ // pop we are removing for an identical one. Above permitted air the clamp
641
+ // is inert (the soles are far above the line by construction).
642
+ for (let f = 0; f < frames; f += 1) ground[f] = Math.min(ground[f], soleAt[f] + FLOOR_SAG_M);
643
+ smoothArray(ground, pinSmoothWindow);
644
+
645
+ if (process.env.BVH_GROUND_DEBUG) {
646
+ const pctOf = (n) => `${((n / frames) * 100).toFixed(0)}%`;
647
+ const trueCount = (track) => track.filter(Boolean).length;
648
+ let either = 0;
649
+ let switches = 0;
650
+ for (let f = 0; f < frames; f += 1) {
651
+ if (support[0][f] || support[1][f]) either += 1;
652
+ if (f > 0 && refSide[f] !== refSide[f - 1]) switches += 1;
653
+ }
654
+ const contacts = trueCount(realContact[0]) + trueCount(realContact[1]);
655
+ const jumps = airRuns.map((r) => `f${r.start}..${r.end}@${(r.peak * 100).toFixed(0)}cm`).join(" ") || "none";
656
+ console.error(`[stance] support L=${pctOf(trueCount(support[0]))} R=${pctOf(trueCount(support[1]))} either=${pctOf(either)}; lockable contact ${contacts}/${frames * 2} (${((contacts / (frames * 2)) * 100).toFixed(0)}%); reference hand-overs ${switches} (${(switches / (frames / fps)).toFixed(1)}/s)`);
657
+ console.error(`[ground] range=[${Math.min(...ground).toFixed(2)}..${Math.max(...ground).toFixed(2)}] jumps: ${jumps}`);
658
+ }
659
+
660
+ // 4d) level the root by subtracting the ground line.
661
+ for (let f = 0; f < frames; f += 1) {
662
+ rootPos[f * 3 + 1] -= ground[f];
663
+ ankle[0][f * 3 + 1] -= ground[f];
664
+ ankle[1][f * 3 + 1] -= ground[f];
665
+ }
666
+ const anchors = realContact[0].filter(Boolean).length + realContact[1].filter(Boolean).length;
667
+
668
+ // XZ bridging runs in LEVELED space, where the planted-ankle level is a
669
+ // constant again (the median over real contacts).
670
+ const leveledPlant = [];
671
+ for (let s = 0; s < 2; s += 1) {
672
+ for (let f = 0; f < frames; f += 1) if (realContact[s][f]) leveledPlant.push(ankle[s][f * 3 + 1]);
673
+ }
674
+ leveledPlant.sort((a, b) => a - b);
675
+ const plantY = leveledPlant[Math.floor(leveledPlant.length / 2)] ?? 0;
676
+ for (let s = 0; s < 2; s += 1) {
677
+ for (let f = 0; f < frames; f += 1) lockContact[s][f] = realContact[s][f];
678
+ bridgeOcclusionGaps(lockContact[s], ankle[s], plantY, fps);
679
+ }
680
+
681
+ // How far the lock actually dragged an ankle sideways, the metric that
682
+ // separates "removed a slide" from "threw the leg across the floor".
683
+ let lockPullMax = 0;
684
+ // 4e) foot lock: hold each contact run's ankle at its median planted spot
685
+ // via 2-bone leg IK, ramped over the run edges. Positions are re-read
686
+ // after leveling so the lock lives in the leveled space.
687
+ if (anchors > 0) {
688
+ for (let f = 0; f < frames; f += 1) {
689
+ const positions = forwardKinematics(readLocals(f), offsets, rootAt(f));
690
+ for (let s = 0; s < 2; s += 1) {
691
+ ankle[s][f * 3] = positions[legs[s].ankle][0];
692
+ ankle[s][f * 3 + 1] = positions[legs[s].ankle][1];
693
+ ankle[s][f * 3 + 2] = positions[legs[s].ankle][2];
694
+ }
695
+ }
696
+ for (let s = 0; s < 2; s += 1) {
697
+ for (const run of contactRuns(lockContact[s])) {
698
+ const lock = runLockPoint(run, realContact[s], ankle[s]);
699
+ if (!lock) continue;
700
+ for (let f = run.start; f <= run.end; f += 1) {
701
+ const edge = Math.min(f - run.start + 1, run.end - f + 1);
702
+ const weight = Math.min(1, edge / (lockBlendFrames + 1));
703
+ const locals = readLocals(f);
704
+ const worlds = globalRotations(locals);
705
+ const positions = forwardKinematics(locals, offsets, rootAt(f));
706
+ const current = positions[legs[s].ankle];
707
+ const pull = Math.hypot(lock[0] - current[0], lock[2] - current[2]);
708
+ if (pull > LOCK_MAX_PULL_M) continue;
709
+ lockPullMax = Math.max(lockPullMax, pull * weight);
710
+ const target = [
711
+ current[0] + (lock[0] - current[0]) * weight,
712
+ current[1] + (lock[1] - current[1]) * weight,
713
+ current[2] + (lock[2] - current[2]) * weight,
714
+ ];
715
+ const solved = solveLegIk(positions, worlds, legs[s], hipsIdx, target);
716
+ if (!solved) continue;
717
+ writeLocal(f, legs[s].hip, solved.hipLocal);
718
+ writeLocal(f, legs[s].knee, solved.kneeLocal);
719
+ writeLocal(f, legs[s].ankle, solved.ankleLocal);
720
+ }
721
+ }
722
+ }
723
+ }
724
+
725
+ // 4f) final FK, then the vertical placement. The reference is a robust
726
+ // touch level (the 10th percentile of per-frame lowest soles) — NOT the
727
+ // clip's global minimum: one residual estimation dip below the floor
728
+ // would otherwise hoist the entire clip by its depth. What still pokes
729
+ // through afterwards is caught by a one-sided guard (lifting a sunken
730
+ // frame never squashes a jump; only chasing feet upward does).
731
+ const lowestAt = new Float64Array(frames);
732
+ for (let f = 0; f < frames; f += 1) {
733
+ const positions = forwardKinematics(readLocals(f), offsets, rootAt(f));
734
+ for (let j = 0; j < jointCount; j += 1) {
735
+ const p = (f * jointCount + j) * 3;
736
+ posedJoints[p] = positions[j][0];
737
+ posedJoints[p + 1] = positions[j][1];
738
+ posedJoints[p + 2] = positions[j][2];
739
+ }
740
+ let low = Infinity;
741
+ for (const j of feet) low = Math.min(low, posedJoints[(f * jointCount + j) * 3 + 1]);
742
+ lowestAt[f] = low;
743
+ }
744
+ // The ground line already brought touch-downs to ≈0; the last shift only
745
+ // centres the touch CLUSTER on zero. A low percentile would land just
746
+ // above the residual error dips instead and hoist the clip by their
747
+ // depth — the exact 7 cm hover this replaced.
748
+ const touchValues = [...lowestAt].filter((value) => value < 0.05).sort((a, b) => a - b);
749
+ const sortedLowest = [...lowestAt].sort((a, b) => a - b);
750
+ const shift = touchValues.length > frames * 0.05
751
+ ? touchValues[Math.floor(touchValues.length / 2)]
752
+ : sortedLowest[Math.floor(sortedLowest.length * 0.1)] ?? 0;
753
+ if (Number.isFinite(shift) && Math.abs(shift) > 1e-6) {
754
+ for (let f = 0; f < frames; f += 1) {
755
+ rootPos[f * 3 + 1] -= shift;
756
+ lowestAt[f] -= shift;
757
+ for (let j = 0; j < jointCount; j += 1) posedJoints[(f * jointCount + j) * 3 + 1] -= shift;
758
+ }
759
+ }
760
+ // A sole under the floor is a LEG error, not a body error: the ground line
761
+ // stands on the SUPPORT foot, so what pokes through is the other leg's
762
+ // per-frame estimate. Hoisting the whole body for it — the only thing this
763
+ // guard used to do — wrote every one of those errors into hip height, on
764
+ // 44 % of frames, 1.8 cm mean and 6 cm worst, and that rectified track was
765
+ // measurably the largest single source of hip jitter in the take (hip jerk
766
+ // RMS fell from 1.17 to 0.80 cm with the guard simply switched off).
767
+ // So: lift the offending FOOT with the same 2-bone leg IK, where the error
768
+ // actually is, and the hips never feel it. The body lift stays underneath
769
+ // as the backstop for whatever the leg cannot reach — a leg already
770
+ // straight has nowhere left to go — so nothing ever ends up below Y=0.
771
+ //
772
+ // The correction is SHAPED in time before it is applied, per leg. Raw, it
773
+ // is a per-frame quantity computed from a per-frame estimate: it fired on
774
+ // 44 % of frames in bursts of median length 3, switching on and off 6.8
775
+ // times a second, and its own second difference was 1.10 cm RMS — so while
776
+ // it fixed the sole it wrote that tremble straight into the knee. Shaping:
777
+ // deficit → running MAX over ±guardShapeHalf → moving average over the
778
+ // same half-window.
779
+ // The DILATION BEFORE THE BLUR is what makes this safe: after the max,
780
+ // every sample the average sees within ±guardShapeHalf of frame f is
781
+ // already ≥ the raw deficit at f, so their mean is too — the smoothed
782
+ // correction is pointwise ≥ the raw deficit everywhere and no floor
783
+ // penetration can be reintroduced by the smoothing, which is the only
784
+ // reason it is allowed to touch this correction at all. (Blurring the raw
785
+ // deficit alone would halve the correction at the tip of every spike,
786
+ // which is exactly where the sole is deepest under the floor.)
787
+ // What this buys is measured in the leg's ANGLES, which is where the raw
788
+ // guard's damage was: knee angle jerk 3.04 → 2.63°/frame² and the worst
789
+ // single-frame knee change 16.2 → 14.5° on boxing-offline. The ankle's own
790
+ // accel barely moves (1.70 → 1.72 cm) — the guard was never displacing the
791
+ // foot much, it was chattering the joints that carry it. The cost is that
792
+ // frames NEAR a penetration are lifted slightly above the floor: grounding
793
+ // 87.2 → 86.2 %, which is most of why PIN_SMOOTH_HALF_S was re-swept.
794
+ const legLift = { frames: 0, sum: 0, max: 0 };
795
+ const bodyLift = { frames: 0, sum: 0, max: 0 };
796
+ const soleOfLeg = (f, s) => Math.min(
797
+ posedJoints[(f * jointCount + legs[s].ankle) * 3 + 1],
798
+ posedJoints[(f * jointCount + toes[s]) * 3 + 1]
799
+ );
800
+ const correction = [0, 1].map((s) => {
801
+ const deficit = new Float64Array(frames);
802
+ for (let f = 0; f < frames; f += 1) deficit[f] = Math.max(0, -soleOfLeg(f, s));
803
+ return shapeFloorCorrection(deficit, guardShapeHalf);
804
+ });
805
+ for (let f = 0; f < frames; f += 1) {
806
+ let lifted = false;
807
+ for (let s = 0; s < 2; s += 1) {
808
+ const lift = correction[s][f];
809
+ if (lift <= 1e-6) continue;
810
+ const locals = readLocals(f);
811
+ const positions = forwardKinematics(locals, offsets, rootAt(f));
812
+ const current = positions[legs[s].ankle];
813
+ const solved = solveLegIk(positions, globalRotations(locals), legs[s], hipsIdx, [current[0], current[1] + lift, current[2]]);
814
+ if (!solved) continue;
815
+ writeLocal(f, legs[s].hip, solved.hipLocal);
816
+ writeLocal(f, legs[s].knee, solved.kneeLocal);
817
+ writeLocal(f, legs[s].ankle, solved.ankleLocal);
818
+ lifted = true;
819
+ legLift.sum += lift;
820
+ legLift.max = Math.max(legLift.max, lift);
821
+ }
822
+ if (lifted) {
823
+ legLift.frames += 1;
824
+ const positions = forwardKinematics(readLocals(f), offsets, rootAt(f));
825
+ let low = Infinity;
826
+ for (let j = 0; j < jointCount; j += 1) {
827
+ const p = (f * jointCount + j) * 3;
828
+ posedJoints[p] = positions[j][0];
829
+ posedJoints[p + 1] = positions[j][1];
830
+ posedJoints[p + 2] = positions[j][2];
831
+ }
832
+ for (const j of feet) low = Math.min(low, posedJoints[(f * jointCount + j) * 3 + 1]);
833
+ lowestAt[f] = low;
834
+ }
835
+ const residual = -lowestAt[f];
836
+ if (residual <= 1e-6) continue;
837
+ bodyLift.frames += 1;
838
+ bodyLift.sum += residual;
839
+ bodyLift.max = Math.max(bodyLift.max, residual);
840
+ rootPos[f * 3 + 1] += residual;
841
+ lowestAt[f] += residual;
842
+ for (let j = 0; j < jointCount; j += 1) posedJoints[(f * jointCount + j) * 3 + 1] += residual;
843
+ }
844
+ if (process.env.BVH_GROUND_DEBUG) {
845
+ const report = (label, lift) =>
846
+ `${label} ${lift.frames}/${frames} (${((lift.frames / frames) * 100).toFixed(0)}%) mean ${((lift.sum / Math.max(1, lift.frames)) * 100).toFixed(2)}cm max ${(lift.max * 100).toFixed(2)}cm`;
847
+ console.error(`[guard] ${report("leg lifts", legLift)}; ${report("body lifts", bodyLift)}`);
848
+ }
849
+
850
+ // How large the FILMED person is relative to CozyClay's canonical body
851
+ // (leg-chain ratio). The take itself stays canonical; the app may scale
852
+ // the rendered character by this so the performer's real stature reads.
853
+ const personScale = rootScale > 1e-6 ? 1 / rootScale : 1;
854
+
855
+ return { frames, fps, rotMats, rootPos, posedJoints, personScale, rawRootStart, lockPullMax };
856
+ }
857
+
858
+ /** Angle in degrees between two rotation matrices, via the quat dot. */
859
+ function angleBetweenDeg(a, b) {
860
+ const qa = matToQuat(a);
861
+ const qb = matToQuat(b);
862
+ const dot = Math.min(1, Math.abs(qa[0] * qb[0] + qa[1] * qb[1] + qa[2] * qb[2] + qa[3] * qb[3]));
863
+ return (2 * Math.acos(dot) * 180) / Math.PI;
864
+ }
865
+
866
+ /** Bridge intervals where the root spins impossibly fast with one shortest
867
+ * path between the sound frames on either side, applying the correction to
868
+ * EVERY joint so local articulation is preserved (the old retarget script's
869
+ * repair, generalized from its hand-measured frame range). */
870
+ function repairRootSpins(worldsByFrame, hipsIndex, degPerFrame) {
871
+ const frames = worldsByFrame.length;
872
+ if (hipsIndex === undefined || frames < 3) return;
873
+ const fast = new Array(frames).fill(false);
874
+ for (let f = 1; f < frames; f += 1) {
875
+ fast[f] = angleBetweenDeg(worldsByFrame[f - 1][hipsIndex], worldsByFrame[f][hipsIndex]) > degPerFrame;
876
+ }
877
+ let f = 1;
878
+ while (f < frames) {
879
+ if (!fast[f]) {
880
+ f += 1;
881
+ continue;
882
+ }
883
+ let end = f;
884
+ while (end + 1 < frames && fast[end + 1]) end += 1;
885
+ const before = f - 1;
886
+ const after = end + 1;
887
+ if (before >= 0 && after < frames) {
888
+ const qa = matToQuat(worldsByFrame[before][hipsIndex]);
889
+ const qb = matToQuat(worldsByFrame[after][hipsIndex]);
890
+ for (let inner = f; inner <= end; inner += 1) {
891
+ const repaired = quatToMat(slerpQuat(qa, qb, (inner - before) / (after - before)));
892
+ const correction = matMul(repaired, matTranspose(worldsByFrame[inner][hipsIndex]));
893
+ const worlds = worldsByFrame[inner];
894
+ for (let j = 0; j < worlds.length; j += 1) worlds[j] = matMul(correction, worlds[j]);
895
+ }
896
+ }
897
+ f = end + 1;
898
+ }
899
+ }
900
+
901
+ /** Zero-lag three-frame stabilization: each world rotation on the chain slides
902
+ * `blend` of the way toward its immediate neighbours' midpoint, `passes`
903
+ * times. Used on the torso (0.2) and both legs (0.4); the ARMS are never
904
+ * passed in — see LEG_BLEND for why the same filter that costs a foot nothing
905
+ * would blunt a punch. The neighbour span stays ±1 frame at every rate and
906
+ * the DURATION is carried by the pass count, because a stride-N three-tap
907
+ * filter passes Nyquist untouched (LEG/TORSO_SMOOTH_HALF_S). */
908
+ function stabilizeChain(worldsByFrame, chainIndices, blend, passes) {
909
+ const frames = worldsByFrame.length;
910
+ if (frames < 3 || chainIndices.length === 0) return;
911
+ for (let pass = 0; pass < passes; pass += 1) {
912
+ for (const j of chainIndices) {
913
+ const original = worldsByFrame.map((worlds) => matToQuat(worlds[j]));
914
+ for (let f = 1; f < frames - 1; f += 1) {
915
+ const mid = slerpQuat(original[f - 1], original[f + 1], 0.5);
916
+ worldsByFrame[f][j] = quatToMat(slerpQuat(original[f], mid, blend));
917
+ }
918
+ }
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Shape a per-frame floor-penetration correction so that applying it does not
924
+ * itself shake the leg: running MAX over ±half (dilation), then a centred
925
+ * moving average over the same ±half. Returns a new track.
926
+ *
927
+ * SAFETY INVARIANT — the result is pointwise >= the input, so a correction
928
+ * shaped this way can never reintroduce penetration the raw one removed. Every
929
+ * sample the average sees at frame f is dilated[k] for some |k - f| <= half,
930
+ * and dilated[k] >= source[f] because f is inside k's own ±half window; a mean
931
+ * of values all >= source[f] is >= source[f]. (Blurring the raw deficit
932
+ * without the dilation would instead halve the correction at the tip of every
933
+ * spike, which is exactly where the sole is deepest under the floor.)
934
+ * Exported so the invariant is tested directly rather than inferred.
935
+ */
936
+ export function shapeFloorCorrection(source, half) {
937
+ const frames = source.length;
938
+ const dilated = new Float64Array(frames);
939
+ for (let f = 0; f < frames; f += 1) {
940
+ let peak = 0;
941
+ for (let k = Math.max(0, f - half); k <= Math.min(frames - 1, f + half); k += 1) peak = Math.max(peak, source[k]);
942
+ dilated[f] = peak;
943
+ }
944
+ const blurred = new Float64Array(frames);
945
+ for (let f = 0; f < frames; f += 1) {
946
+ let sum = 0;
947
+ let count = 0;
948
+ for (let k = Math.max(0, f - half); k <= Math.min(frames - 1, f + half); k += 1) {
949
+ sum += dilated[k];
950
+ count += 1;
951
+ }
952
+ blurred[f] = sum / count;
953
+ }
954
+ return blurred;
955
+ }
956
+
957
+ /** In-place centred moving average of a plain array, boundary-clamped. */
958
+ function smoothArray(values, window) {
959
+ const half = Math.floor(window / 2);
960
+ const source = Float64Array.from(values);
961
+ for (let f = 0; f < values.length; f += 1) {
962
+ let sum = 0;
963
+ let count = 0;
964
+ for (let k = Math.max(0, f - half); k <= Math.min(values.length - 1, f + half); k += 1) {
965
+ sum += source[k];
966
+ count += 1;
967
+ }
968
+ values[f] = sum / count;
969
+ }
970
+ }
971
+
972
+ /** Erode contact runs shorter than minRun, then bridge gaps shorter than
973
+ * minRun — single-frame flicker neither starts nor breaks a plant. */
974
+ function cleanRuns(track, minRun) {
975
+ const frames = track.length;
976
+ for (let f = 0; f < frames; ) {
977
+ if (!track[f]) { f += 1; continue; }
978
+ let g = f;
979
+ while (g < frames && track[g]) g += 1;
980
+ if (g - f < minRun) for (let k = f; k < g; k += 1) track[k] = false;
981
+ f = g;
982
+ }
983
+ for (let f = 0; f < frames; ) {
984
+ if (track[f]) { f += 1; continue; }
985
+ let g = f;
986
+ while (g < frames && !track[g]) g += 1;
987
+ if (f > 0 && g < frames && g - f < minRun) for (let k = f; k < g; k += 1) track[k] = true;
988
+ f = g;
989
+ }
990
+ }
991
+
992
+ /** Cut a candidate contact track down to the stretches during which the ankle
993
+ * never leaves a `maxWander`-wide patch of floor, dropping what is then
994
+ * shorter than minRun. Net wander over a run is immune to the per-frame XZ
995
+ * noise that defeats an instantaneous speed gate, and still rejects a foot
996
+ * that is actually travelling — a step's bounding box blows past the patch
997
+ * within a few frames, so the run is closed exactly where the foot left. */
998
+ function splitByXzWander(track, anklePositions, maxWander, minRun) {
999
+ const kept = new Array(track.length).fill(false);
1000
+ for (const run of contactRuns(track)) {
1001
+ let start = run.start;
1002
+ let minX = Infinity;
1003
+ let maxX = -Infinity;
1004
+ let minZ = Infinity;
1005
+ let maxZ = -Infinity;
1006
+ for (let f = run.start; f <= run.end; f += 1) {
1007
+ const x = anklePositions[f * 3];
1008
+ const z = anklePositions[f * 3 + 2];
1009
+ const spanX = Math.max(maxX, x) - Math.min(minX, x);
1010
+ const spanZ = Math.max(maxZ, z) - Math.min(minZ, z);
1011
+ if (f > start && Math.hypot(spanX, spanZ) > maxWander) {
1012
+ if (f - start >= minRun) for (let k = start; k < f; k += 1) kept[k] = true;
1013
+ start = f;
1014
+ minX = maxX = x;
1015
+ minZ = maxZ = z;
1016
+ continue;
1017
+ }
1018
+ minX = Math.min(minX, x); maxX = Math.max(maxX, x);
1019
+ minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
1020
+ }
1021
+ if (run.end - start + 1 >= minRun) for (let k = start; k <= run.end; k += 1) kept[k] = true;
1022
+ }
1023
+ for (let f = 0; f < track.length; f += 1) track[f] = kept[f];
1024
+ }
1025
+
1026
+ /** Contiguous true runs of a boolean track as { start, end } (inclusive). */
1027
+ function contactRuns(track) {
1028
+ const runs = [];
1029
+ for (let f = 0; f < track.length; ) {
1030
+ if (!track[f]) { f += 1; continue; }
1031
+ let g = f;
1032
+ while (g < track.length && track[g]) g += 1;
1033
+ runs.push({ start: f, end: g - 1 });
1034
+ f = g;
1035
+ }
1036
+ return runs;
1037
+ }
1038
+
1039
+ /** Merge contact runs across short occlusion gaps: the foot left "contact"
1040
+ * but reappears at (nearly) the same spot without ever lifting past the
1041
+ * jump ceiling — so it never actually moved, and the gap is an estimation
1042
+ * error to be pinned, not air to be preserved. */
1043
+ export function bridgeOcclusionGaps(track, anklePositions, floorY, fps) {
1044
+ const maxGap = Math.round(BRIDGE_MAX_GAP_S * fps);
1045
+ const runs = contactRuns(track);
1046
+ for (let i = 0; i + 1 < runs.length; i += 1) {
1047
+ const a = runs[i];
1048
+ const b = runs[i + 1];
1049
+ const gap = b.start - a.end - 1;
1050
+ if (gap <= 0 || gap > maxGap) continue;
1051
+ const dx = anklePositions[b.start * 3] - anklePositions[a.end * 3];
1052
+ const dz = anklePositions[b.start * 3 + 2] - anklePositions[a.end * 3 + 2];
1053
+ if (Math.hypot(dx, dz) > BRIDGE_MAX_XZ_M) continue;
1054
+ let maxLift = -Infinity;
1055
+ for (let f = a.end + 1; f < b.start; f += 1) maxLift = Math.max(maxLift, anklePositions[f * 3 + 1] - floorY);
1056
+ if (maxLift > BRIDGE_MAX_LIFT_M) continue; // a real jump keeps its air
1057
+ for (let f = a.end + 1; f < b.start; f += 1) track[f] = true;
1058
+ }
1059
+ }
1060
+
1061
+ /** Where a contact run pins its ankle: the median planted position over the
1062
+ * run's REAL contact frames (bridged frames are the error being repaired). */
1063
+ function runLockPoint(run, realTrack, anklePositions) {
1064
+ const xs = [];
1065
+ const ys = [];
1066
+ const zs = [];
1067
+ for (let f = run.start; f <= run.end; f += 1) {
1068
+ if (!realTrack[f]) continue;
1069
+ xs.push(anklePositions[f * 3]);
1070
+ ys.push(anklePositions[f * 3 + 1]);
1071
+ zs.push(anklePositions[f * 3 + 2]);
1072
+ }
1073
+ if (xs.length === 0) return null;
1074
+ const median = (values) => values.sort((a, b) => a - b)[Math.floor(values.length / 2)];
1075
+ return [median(xs), median(ys), median(zs)];
1076
+ }
1077
+
1078
+ function v3sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
1079
+ function v3add(a, b) { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; }
1080
+ function v3scale(v, k) { return [v[0] * k, v[1] * k, v[2] * k]; }
1081
+ function v3dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
1082
+ function v3cross(a, b) {
1083
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
1084
+ }
1085
+ function v3len(v) { return Math.hypot(v[0], v[1], v[2]); }
1086
+ function matVec3(m, v) {
1087
+ return [
1088
+ m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
1089
+ m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
1090
+ m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
1091
+ ];
1092
+ }
1093
+
1094
+ /** Shortest rotation matrix taking direction u onto direction v (Rodrigues). */
1095
+ function rotationBetween(u, v) {
1096
+ const lu = v3len(u);
1097
+ const lv = v3len(v);
1098
+ if (lu < 1e-9 || lv < 1e-9) return null;
1099
+ const a = v3scale(u, 1 / lu);
1100
+ const b = v3scale(v, 1 / lv);
1101
+ const c = v3cross(a, b);
1102
+ const d = v3dot(a, b);
1103
+ const s2 = v3dot(c, c);
1104
+ if (s2 < 1e-14) {
1105
+ if (d > 0) return [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
1106
+ // opposite: 180 deg about any axis perpendicular to a
1107
+ let axis = v3cross(a, [1, 0, 0]);
1108
+ if (v3len(axis) < 1e-6) axis = v3cross(a, [0, 1, 0]);
1109
+ const n = v3scale(axis, 1 / v3len(axis));
1110
+ return [
1111
+ [2 * n[0] * n[0] - 1, 2 * n[0] * n[1], 2 * n[0] * n[2]],
1112
+ [2 * n[0] * n[1], 2 * n[1] * n[1] - 1, 2 * n[1] * n[2]],
1113
+ [2 * n[0] * n[2], 2 * n[1] * n[2], 2 * n[2] * n[2] - 1],
1114
+ ];
1115
+ }
1116
+ const K = [
1117
+ [0, -c[2], c[1]],
1118
+ [c[2], 0, -c[0]],
1119
+ [-c[1], c[0], 0],
1120
+ ];
1121
+ const k = (1 - d) / s2;
1122
+ const KK = matMul(K, K);
1123
+ return [
1124
+ [1 + K[0][0] + KK[0][0] * k, K[0][1] + KK[0][1] * k, K[0][2] + KK[0][2] * k],
1125
+ [K[1][0] + KK[1][0] * k, 1 + K[1][1] + KK[1][1] * k, K[1][2] + KK[1][2] * k],
1126
+ [K[2][0] + KK[2][0] * k, K[2][1] + KK[2][1] * k, 1 + K[2][2] + KK[2][2] * k],
1127
+ ];
1128
+ }
1129
+
1130
+ /**
1131
+ * Analytic 2-bone leg IK (research 11 §2: "hold the ankle world position and
1132
+ * re-solve the two-bone leg chain"). Re-aims hip and knee so the ankle
1133
+ * reaches `target`, keeps the knee's current bend plane, and leaves the
1134
+ * foot's WORLD orientation untouched — a planted foot must not roll because
1135
+ * the leg above it moved. Returns the new cskel27 LOCAL matrices, or null
1136
+ * when the chain is degenerate.
1137
+ */
1138
+ function solveLegIk(positions, worlds, leg, hipsIdx, target) {
1139
+ const H = positions[leg.hip];
1140
+ const K = positions[leg.knee];
1141
+ const A = positions[leg.ankle];
1142
+ const L1 = v3len(v3sub(K, H));
1143
+ const L2 = v3len(v3sub(A, K));
1144
+ if (L1 < 1e-4 || L2 < 1e-4) return null;
1145
+ let d = v3len(v3sub(target, H));
1146
+ const eps = 1e-3;
1147
+ d = Math.max(Math.abs(L1 - L2) + eps, Math.min(L1 + L2 - eps, d));
1148
+ if (d < 1e-4) return null;
1149
+ const dir = v3scale(v3sub(target, H), 1 / v3len(v3sub(target, H)));
1150
+ // Preserve the current bend plane: the knee keeps pointing the way it
1151
+ // already points, projected off the new hip→ankle axis.
1152
+ const hk = v3sub(K, H);
1153
+ let bend = v3sub(hk, v3scale(dir, v3dot(hk, dir)));
1154
+ if (v3len(bend) < 1e-6) {
1155
+ bend = v3sub(matVec3(worlds[leg.hip], [0, 0, 1]), v3scale(dir, v3dot(matVec3(worlds[leg.hip], [0, 0, 1]), dir)));
1156
+ if (v3len(bend) < 1e-6) return null;
1157
+ }
1158
+ bend = v3scale(bend, 1 / v3len(bend));
1159
+ const a1 = (L1 * L1 - L2 * L2 + d * d) / (2 * d);
1160
+ const h = Math.sqrt(Math.max(0, L1 * L1 - a1 * a1));
1161
+ const newKnee = v3add(H, v3add(v3scale(dir, a1), v3scale(bend, h)));
1162
+ const clampedTarget = v3add(H, v3scale(dir, d));
1163
+
1164
+ const hipDelta = rotationBetween(v3sub(K, H), v3sub(newKnee, H));
1165
+ if (!hipDelta) return null;
1166
+ const ankleAfterHip = v3add(newKnee, matVec3(hipDelta, v3sub(A, K)));
1167
+ const kneeDelta = rotationBetween(v3sub(ankleAfterHip, newKnee), v3sub(clampedTarget, newKnee));
1168
+ if (!kneeDelta) return null;
1169
+
1170
+ const hipGlobal = matMul(hipDelta, worlds[leg.hip]);
1171
+ const kneeGlobal = matMul(kneeDelta, matMul(hipDelta, worlds[leg.knee]));
1172
+ return {
1173
+ hipLocal: matMul(matTranspose(worlds[hipsIdx]), hipGlobal),
1174
+ kneeLocal: matMul(matTranspose(hipGlobal), kneeGlobal),
1175
+ // the foot keeps its ORIGINAL world orientation under the new knee
1176
+ ankleLocal: matMul(matTranspose(kneeGlobal), worlds[leg.ankle]),
1177
+ };
1178
+ }
1179
+
1180
+ /** Centred moving average over one interleaved component of a stride-3 track. */
1181
+ function smoothTrack(track, frames, component, window) {
1182
+ const half = Math.floor(window / 2);
1183
+ const source = new Float64Array(frames);
1184
+ for (let f = 0; f < frames; f += 1) source[f] = track[f * 3 + component];
1185
+ for (let f = 0; f < frames; f += 1) {
1186
+ let sum = 0;
1187
+ let count = 0;
1188
+ for (let k = Math.max(0, f - half); k <= Math.min(frames - 1, f + half); k += 1) {
1189
+ sum += source[k];
1190
+ count += 1;
1191
+ }
1192
+ track[f * 3 + component] = sum / count;
1193
+ }
1194
+ }
1195
+
1196
+
1197
+ function magnitude(v) {
1198
+ return Math.hypot(v[0], v[1], v[2]);
1199
+ }
1200
+
1201
+ function chainLength(positions, names) {
1202
+ let total = 0;
1203
+ for (let i = 1; i < names.length; i += 1) {
1204
+ const a = positions[CSKEL27_JOINTS.indexOf(names[i - 1])];
1205
+ const b = positions[CSKEL27_JOINTS.indexOf(names[i])];
1206
+ total += Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
1207
+ }
1208
+ return total;
1209
+ }