Skip to content

Commit c9aac4e

Browse files
Merge pull request #51 from posecode-dev/fix/animation-overshoot-and-floor-contacts
fix(render): stop spline overshoot and ground floor-planted hands/feet
2 parents 67491e8 + 052aa9f commit c9aac4e

6 files changed

Lines changed: 110 additions & 21 deletions

File tree

packages/posecode-eval/src/probe.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ export function probeMovement(source: string): ProbeResult {
260260
...info.reaches.map((r) => ({ effector: r.effector, anchor: r.target })),
261261
]));
262262
const propPush: Vec3 = [m.root.position.x - prePush.x, 0, m.root.position.z - prePush.z];
263-
alignFloorPalms(m, info.reaches, info.pins);
263+
alignFloorPalms(m, info.reaches, info.pins, info.groundLock);
264264
// Plantigrade correction (viewer parity): flatten planted soles. This lifts
265265
// the foot mesh a little, so it must run BEFORE the floor clamp reconciles.
266266
levelPlantedFeet(m, info.groundLock);

packages/posecode-render/src/contacts.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,18 @@ import type { Mannequin } from "./mannequin.js";
66
const DOWN = new THREE.Vector3(0, -1, 0);
77
const DEG = Math.PI / 180;
88

9-
/** Rotate contacting wrists so the palm face normal points into the floor. */
9+
/**
10+
* Rotate contacting wrists so the palm face normal points into the floor.
11+
* Applies to hands pressed to the floor via `reach`/`pin: hands floor` AND via
12+
* `ground-lock: hands` (a high plank / push-up / mountain-climber): those bear
13+
* weight flat on the ground and must be palm-down, not left in their raw FK
14+
* (pronated) orientation.
15+
*/
1016
export function alignFloorPalms(
1117
m: Mannequin,
1218
reaches: readonly ReachTarget[],
1319
pins: readonly PinTarget[],
20+
groundLock: readonly string[] = [],
1421
): void {
1522
const sides = new Set<"left" | "right">();
1623
const collect = (effector: string, target: string) => {
@@ -20,6 +27,10 @@ export function alignFloorPalms(
2027
};
2128
reaches.forEach((r) => collect(r.effector, r.target));
2229
pins.forEach((p) => collect(p.effector, p.anchor));
30+
if (groundLock.includes("hands")) {
31+
sides.add("left");
32+
sides.add("right");
33+
}
2334

2435
for (const side of sides) {
2536
const wrist = m.bones.get(`wrist_${side}`);

packages/posecode-render/src/groundlock.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ export function applyGroundLock(
100100
const upperSupports = forearms.length > 0 ? forearms : hands;
101101

102102
if (upperSupports.length > 0 && feet.length > 0) {
103+
// Plant the feet FIRST: drop the body so the foot mesh rests on the floor,
104+
// so the pivot the body then rotates about is itself at floor level. The
105+
// rotation keeps the pivot fixed, so grounding the feet up front is what
106+
// lets BOTH ends land — without it the pivot sits at whatever height the
107+
// toes happened to reach and only the forearms plant while the feet float
108+
// (the "plank feet off the ground" bug). A straight, correctly-authored
109+
// plank has its forearms/toes near-coplanar, so the follow-up rotation is
110+
// small; a piked pose still ends with the toes planted.
111+
dropFeetToFloor(m, feet);
103112
const pivot = avgWorld(m, feet);
104113
// Newton iterations: rotate about the toes until the authored upper-body
105114
// support reaches the floor (palms for high plank, elbows for forearm
@@ -132,21 +141,31 @@ export function applyGroundLock(
132141
// Ground the FOOT MESH's lowest point, not the ankle bone's origin: the
133142
// bone sits ~0.04m above the sole (foot box + capsule radius), so
134143
// anchoring the bone itself left the visible foot sunk into the floor.
135-
let minY = Infinity;
136-
for (const id of feet) {
137-
const node = m.bones.get(id);
138-
if (!node) continue;
139-
const box = new THREE.Box3().setFromObject(node);
140-
if (Number.isFinite(box.min.y)) minY = Math.min(minY, box.min.y);
141-
}
142-
if (Number.isFinite(minY)) {
143-
m.root.position.y -= minY;
144-
m.root.updateMatrixWorld(true);
145-
}
144+
dropFeetToFloor(m, feet);
146145
if (anchors) plantFeetHorizontally(m, feet, anchors);
147146
}
148147
}
149148

149+
/**
150+
* Drop the whole body vertically so the lowest FOOT-mesh point rests on the
151+
* floor. Grounds the visible sole (bounding box), not the ankle bone, which
152+
* sits ~0.04m above it. Shared by the feet-only path and the plank/push-up path
153+
* (which grounds the feet before pivoting the body onto its hands).
154+
*/
155+
function dropFeetToFloor(m: Mannequin, feet: string[]): void {
156+
let minY = Infinity;
157+
for (const id of feet) {
158+
const node = m.bones.get(id);
159+
if (!node) continue;
160+
const box = new THREE.Box3().setFromObject(node);
161+
if (Number.isFinite(box.min.y)) minY = Math.min(minY, box.min.y);
162+
}
163+
if (Number.isFinite(minY)) {
164+
m.root.position.y -= minY;
165+
m.root.updateMatrixWorld(true);
166+
}
167+
}
168+
150169
/**
151170
* Translate the root in X/Z so grounded feet return to their anchors (see
152171
* module doc). Runs after vertical grounding so "near the floor" is judged in

packages/posecode-render/src/index.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,7 @@ export function createViewer(
698698
// bug. Ground-lock and pins have already fixed the root placement that
699699
// floor/landmark targets resolve against.
700700
applyReaches(info.reaches);
701-
alignFloorPalms(mannequin, info.reaches, info.pins);
701+
alignFloorPalms(mannequin, info.reaches, info.pins, info.groundLock);
702702
// Plantigrade correction: keep planted soles flat to the floor so grounded
703703
// lower-body poses (squat, lunge, deadlift) don't balance on the toes.
704704
// Runs before the floor clamp so the leveled sole is what rests on y=0.
@@ -710,7 +710,7 @@ export function createViewer(
710710
mannequin,
711711
gripSidesOf(info.grips),
712712
authoredFingers,
713-
floorHandSidesOf(info.reaches, info.pins),
713+
floorHandSidesOf(info.reaches, info.pins, info.groundLock),
714714
);
715715
// L4.3 aliveness: turn the head toward the active contact (bar / floor reach).
716716
applyLookAt(info);
@@ -843,7 +843,7 @@ export function createViewer(
843843
mannequin,
844844
gripSidesOf(ir.phases[0]?.grips ?? []),
845845
authoredFingers,
846-
floorHandSidesOf(ir.phases[0]?.reaches ?? [], ir.phases[0]?.pins ?? []),
846+
floorHandSidesOf(ir.phases[0]?.reaches ?? [], ir.phases[0]?.pins ?? [], ir.phases[0]?.groundLock ?? []),
847847
);
848848
applyLookAt({ grips: ir.phases[0]?.grips ?? [], reaches: ir.phases[0]?.reaches ?? [] });
849849
captureGroundTargets();
@@ -1080,13 +1080,17 @@ function gripSidesOf(grips: readonly { effector: string }[]): Set<"left" | "righ
10801080
}
10811081

10821082
/**
1083-
* Hand sides pressed onto the floor this phase (a `reach`/`pin: hands floor`).
1084-
* Their fingers rest flat instead of taking the idle inward hook, so a plank or
1085-
* push-up hand lies on the ground rather than clawing into it.
1083+
* Hand sides pressed onto the floor this phase — via `reach`/`pin: hands floor`
1084+
* OR `ground-lock: hands` (a high plank / push-up / mountain-climber, where the
1085+
* hands bear weight flat on the ground). Their fingers rest flat instead of
1086+
* taking the idle inward hook, so the palm lies on the floor rather than
1087+
* clawing into it. Ground-locked hands never carry a `floor` reach/pin, so
1088+
* without the ground-lock check they were mis-read as free and hooked up.
10861089
*/
10871090
function floorHandSidesOf(
10881091
reaches: readonly { effector: string; target: string }[],
10891092
pins: readonly { effector: string; anchor: string }[],
1093+
groundLock: readonly string[] = [],
10901094
): Set<"left" | "right"> {
10911095
const sides = new Set<"left" | "right">();
10921096
const add = (effector: string): void => {
@@ -1095,6 +1099,7 @@ function floorHandSidesOf(
10951099
};
10961100
for (const r of reaches) if (r.target === "floor") add(r.effector);
10971101
for (const p of pins) if (p.anchor === "floor") add(p.effector);
1102+
if (groundLock.includes("hands")) add("hands");
10981103
return sides;
10991104
}
11001105

packages/posecode-render/src/timeline.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ interface Keyframe {
2121
name: string;
2222
cue?: string;
2323
easing: TimingMode;
24+
/**
25+
* The figure is at rest here (zero boundary velocity), so the spline uses this
26+
* keyframe's own value as its control (no velocity carried across it). True for
27+
* settle/snap phases AND for the two structural anchors — the start pose and
28+
* the loop-reset — which represent the figure standing still at the base pose.
29+
* Those anchors have no real predecessor/successor, so deriving a squad tangent
30+
* from a clamped neighbor yields a backward-biased control that overshoots
31+
* (the "snap to fully-curled" biceps bug); a rest tangent slerps cleanly.
32+
*/
33+
rest: boolean;
2434
quats: Map<string, THREE.Quaternion>;
2535
groundLock: string[];
2636
reaches: ReachTarget[];
@@ -115,6 +125,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
115125
time: 0,
116126
name: ir.startPose ?? "start",
117127
easing: "flow",
128+
rest: true,
118129
quats: snapshot(curr),
119130
groundLock: [],
120131
reaches: [],
@@ -138,6 +149,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
138149
name: phase.name,
139150
...(phase.cue ? { cue: phase.cue } : {}),
140151
easing: phase.easing,
152+
rest: REST_MODE[phase.easing],
141153
quats: snapshot(curr),
142154
groundLock: phase.groundLock,
143155
reaches: phase.reaches,
@@ -160,6 +172,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
160172
time: t,
161173
name: "reset",
162174
easing: "flow",
175+
rest: true,
163176
quats: snapshot(new Map(baseJoints)),
164177
groundLock: [],
165178
reaches: [],
@@ -227,10 +240,10 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
227240
// A rest-point keyframe uses its own value as the control (zero tangent
228241
// → the spline comes to / leaves from rest there); otherwise the
229242
// Shoemake control from the neighboring keyframe carries velocity.
230-
const s0 = REST_MODE[a.easing]
243+
const s0 = a.rest
231244
? q0.clone()
232245
: squadControl(kPrev.quats.get(bone)!, q0, q1);
233-
const s1 = REST_MODE[b.easing]
246+
const s1 = b.rest
234247
? q1.clone()
235248
: squadControl(q0, q1, kNext.quats.get(bone)!);
236249
squad(q0, s0, s1, q1, eased, node.quaternion);

packages/posecode-render/test/render.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,47 @@ describe("timeline", () => {
7474

7575
expect(start.angleTo(lowered)).toBeGreaterThan(1.0); // ~90deg in radians
7676
});
77+
78+
// Regression: a large rest-to-rest move (biceps curl: elbow flex 135 +
79+
// supinate 80, near-antipodal endpoints) must sweep MONOTONICALLY. The squad
80+
// spline used to derive a backward-biased control at the clamped start
81+
// keyframe, flinging the forearm past +50deg then snapping ~135deg in a single
82+
// step — the "curl happens suddenly" bug. The start/reset anchors are now rest
83+
// points, so their segments slerp cleanly.
84+
it("curls the forearm monotonically from rest (no squad overshoot snap)", () => {
85+
const CURL = [
86+
'posecode exercise "Curl"',
87+
" rig humanoid",
88+
" pose start = standing",
89+
' step "Curl" 1.1s settle:',
90+
" elbows: flex 135",
91+
" elbows: supinate 80",
92+
' step "Lower" 1.4s drive:',
93+
" elbows: flex 15",
94+
" elbows: supinate 80",
95+
" repeat 10",
96+
].join("\n");
97+
const { ir } = parse(CURL);
98+
const tl = buildTimeline(ir!);
99+
const m = buildMannequin();
100+
const forearm = new THREE.Vector3(0, -1, 0); // wrist sits at elbow-local -Y
101+
const wq = new THREE.Quaternion();
102+
const dir = new THREE.Vector3();
103+
let prev: THREE.Vector3 | null = null;
104+
// Walk the 1.1s Curl segment in even steps. The forearm swings ~135deg
105+
// total, so at this resolution each step is small; the overshoot bug instead
106+
// parked the forearm near rest then jumped ~135deg in a single step. Assert
107+
// no adjacent step exceeds 0.9rad (~50deg): the fix keeps every step under
108+
// ~0.5rad, while the snap produced a ~2.3rad jump.
109+
for (let t = 0; t <= 1.1 + 1e-9; t += 1.1 / 22) {
110+
tl.sample(t, m.bones);
111+
m.root.updateMatrixWorld(true);
112+
m.bones.get("elbow_left")!.getWorldQuaternion(wq);
113+
dir.copy(forearm).applyQuaternion(wq).normalize();
114+
if (prev) expect(dir.angleTo(prev)).toBeLessThan(0.9);
115+
prev = dir.clone();
116+
}
117+
});
77118
});
78119

79120
describe("hip-hinge coupling", () => {

0 commit comments

Comments
 (0)