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.
- package/CHANGELOG.md +85 -0
- package/README.md +31 -0
- package/THIRD_PARTY_NOTICES.md +37 -1
- package/bin/cozyclay.mjs +51 -2
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +60 -16
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -90
- package/src/App.jsx +2957 -514
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline.jsx +124 -5
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/asset-pane.jsx +36 -0
- package/src/dualview.jsx +14 -8
- package/src/hierarchy-model.js +95 -13
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +122 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +43 -32
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/index.js +3 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/posestudio.jsx +361 -11
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +207 -26
- package/src/shot-authoring.js +55 -13
- package/src/styles.css +1296 -129
- package/tools/ardy/BRIDGE.md +3 -2
- package/tools/ardy/README.md +9 -5
- package/tools/ardy/bridge.mjs +57 -1
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/remote.mjs +8 -2
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- 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
|
+
}
|