Skip to content

Commit f1e8795

Browse files
Merge pull request #50 from posecode-dev/claude/wall-sit-clipping-fix-tsdjl2
2 parents 0b1fda1 + c9d9cc9 commit f1e8795

11 files changed

Lines changed: 589 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **Realistic human figure**: the playground, landing hero, and `<posecode-player>` embeds now render a fully rigged, textured human character (hands with articulated fingers, sneakers, face) instead of the procedural capsule mannequin. All solving (FK, ground-lock, pins, reach-IK) still runs on the driver skeleton, rebuilt to the character's exact proportions and retargeted bone-for-bone every frame; the procedural figure remains as an automatic fallback (and via `?figure=classic` / `character="off"`).
1313
- **Self-collision resolution**: a capsule-based de-penetration pass keeps forearms/hands out of the torso, head, and legs (and shins out of each other), clamped to healthy ROM, so limbs no longer pass through the body mid-movement.
14+
- **Solid props**: props now declare blocking faces (the wall's surface, the chair's backrest and seat edge, the box's near face) and a contact pass keeps the body out of them — translating the whole figure along the face normal, or bending the offending leg's hip clear (ROM-clamped). Limbs pinned/gripped/reached to a prop anchor stay exempt as declared support. A new `solid-props` eval invariant (independent geometry re-derivation) guards every prop movement against this bug class.
1415
- `viewer.characterActive`, `createViewer({ characterUrl })`, and the embed `character` attribute.
1516
- `scripts/capture-gifs.mjs` (`npm run gifs`): reproducible headless regeneration of the README movement GIFs from the live renderer.
1617

1718
### Fixed
1819

20+
- Wall sit no longer clips through the wall: the body now translates forward until the back rests on the wall's surface (feet walking out, thighs parallel), the physically correct wall-sit geometry. Sit-to-stand and box-squat land against the chair's backrest instead of sinking into it, a standing figure's calves clear the seat edge, and a step-up's trailing shin bends over the box edge instead of sweeping through it.
1921
- Deadlift arms now hang toward the bar during the hinge (were authored as shoulder extension, flying up behind the back).
2022
- Crunch keeps the feet planted with bent knees (shins previously folded through the floor and jacked the body up).
2123
- Touch-toes folds like a human (hinge depth and knee bend were over-authored, collapsing the figure).

ROADMAP.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ These are the unlocks, roughly in order of leverage:
4141
Powers `sit-to-stand`, `box-squat`, `wall-sit`, `dead-hang`, `hanging-knee-raise`.
4242
Bar and dip-bar contacts now resolve to independent left/right anchors with
4343
terminal wrist orientation; mocap is contact-corrected after blending.
44+
Props are now **solid**: declared blocking faces (wall surface, chair
45+
backrest + seat edge, box edge) physically stop the body — a wall-sit
46+
slides down the wall instead of through it, a sit lands against the
47+
backrest, a swing leg steps over the box edge — guarded by a `solid-props`
48+
eval invariant on every prop movement.
4449
Next: more props (bench, rings, bands), load cues, arbitrary surface shapes.
4550
4. ~~**Lying & seated base poses**~~: **shipped.** `supine | prone | seated`
4651
start poses (grounded by a bounding-box drop). Powers `glute-bridge`,
@@ -84,7 +89,9 @@ Each prop is a small scene object + an anchor type; movements then reference it
8489
- A **starter** prop set (chair / wall / bar / box / dip bars): no bench,
8590
rings, bands, or loaded implements yet, and props sit at fixed default
8691
placements.
87-
- Props are visual + reach anchors (no physical sit/lean solve).
92+
- Prop solidity is face-based: each built-in prop declares its blocking
93+
surfaces (wall face, backrest, seat edge, box edge). Arbitrary-shape
94+
collision and load/pressure simulation are future.
8895
- Fingers are **single-DOF** curls, good for grip and rough gesture, not exact
8996
sign language. The head has no facial articulation.
9097

packages/posecode-eval/src/checks.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
lowestPoint,
1919
palmFloorAngleDeg,
2020
phaseMaxLandmarkSpeed,
21+
propPenetrationDepth,
2122
segmentTiltDeg,
2223
spineCurlDeg,
2324
torsoPitchDeg,
@@ -137,6 +138,19 @@ export function genericChecks(result: ProbeResult): CheckOutcome[] {
137138
detail: `${clearance.toFixed(3)}m clearance (want > -0.01m)`,
138139
});
139140
}
141+
142+
// Props are solid: no body capsule may sit inside a prop's blocking face
143+
// (the wall-sit-through-the-wall class of bug). Independent re-derivation
144+
// of the face geometry, so it fails loudly if resolvePropContacts or a
145+
// prop's collider declaration regresses.
146+
const penetration = propPenetrationDepth(result, p);
147+
if (Number.isFinite(penetration)) {
148+
out.push({
149+
id: `solid-props:${p.name}`,
150+
pass: penetration < 0.03,
151+
detail: `${penetration.toFixed(3)}m into a solid prop face (want < 0.030)`,
152+
});
153+
}
140154
}
141155

142156
for (let i = 1; i < result.phases.length; i++) {

packages/posecode-eval/src/metrics.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,90 @@ export function headPropClearance(result: ProbeResult, pose: PhasePose): number
196196
return clearance;
197197
}
198198

199+
interface SolidFace {
200+
point: Vec3;
201+
normal: Vec3;
202+
tangentU: Vec3;
203+
halfU: number;
204+
tangentV: Vec3;
205+
halfV: number;
206+
captureDepth: number;
207+
blocks: readonly string[];
208+
}
209+
210+
/** The solid prop faces, re-derived from the prop geometry independently of
211+
* the renderer's collider declarations so a regression in either is caught. */
212+
function solidFaces(propTypes: readonly string[]): SolidFace[] {
213+
const out: SolidFace[] = [];
214+
const all = ["torso", "head", "thigh", "shin", "arm"];
215+
if (propTypes.includes("wall")) {
216+
out.push({ point: [0, 1.3, -0.29], normal: [0, 0, 1], tangentU: [1, 0, 0], halfU: 1.1, tangentV: [0, 1, 0], halfV: 1.3, captureDepth: 0.8, blocks: all });
217+
}
218+
if (propTypes.includes("chair")) {
219+
out.push(
220+
{ point: [0, 0.78, -0.31], normal: [0, 0, 1], tangentU: [1, 0, 0], halfU: 0.21, tangentV: [0, 1, 0], halfV: 0.25, captureDepth: 0.4, blocks: ["torso", "head"] },
221+
{ point: [0, 0.47, 0.05], normal: [0, 0, 1], tangentU: [1, 0, 0], halfU: 0.21, tangentV: [0, 1, 0], halfV: 0.03, captureDepth: 0.42, blocks: ["shin"] },
222+
);
223+
}
224+
if (propTypes.includes("box")) {
225+
out.push({ point: [0, 0.15, 0.11], normal: [0, 0, -1], tangentU: [1, 0, 0], halfU: 0.25, tangentV: [0, 1, 0], halfV: 0.15, captureDepth: 0.42, blocks: ["shin"] });
226+
}
227+
return out;
228+
}
229+
230+
/** Body capsule radii matching the render mannequin (see mannequin.ts). */
231+
const PART_RADII = { torso: 0.13, head: 0.105, thigh: 0.075, shin: 0.055, arm: 0.038 } as const;
232+
233+
/**
234+
* Worst body penetration into a solid prop face (metres, ≤0 when clear), or
235+
* -Infinity when the document declares no solid-faced prop. Limbs pinned or
236+
* reached to a non-floor anchor are that phase's declared prop support and
237+
* don't count (a foot standing ON the box is not "in" the box).
238+
*/
239+
export function propPenetrationDepth(result: ProbeResult, pose: PhasePose): number {
240+
const faces = solidFaces(result.propTypes);
241+
if (faces.length === 0) return -Infinity;
242+
const exemptLegs = new Set<string>();
243+
const contacts = [
244+
...pose.pins,
245+
...pose.reaches.map((r) => ({ effector: r.effector, anchor: r.target })),
246+
];
247+
for (const c of contacts) {
248+
if (c.anchor === "floor") continue;
249+
if (c.effector === "feet" || c.effector === "foot_left") exemptLegs.add("left");
250+
if (c.effector === "feet" || c.effector === "foot_right") exemptLegs.add("right");
251+
}
252+
const segments: [string, string, keyof typeof PART_RADII][] = [
253+
["pelvis", "neck", "torso"],
254+
["neck", "head", "head"],
255+
];
256+
for (const side of ["left", "right"]) {
257+
segments.push([`shoulder_${side}`, `elbow_${side}`, "arm"], [`elbow_${side}`, `wrist_${side}`, "arm"]);
258+
if (exemptLegs.has(side)) continue;
259+
segments.push([`hip_${side}`, `knee_${side}`, "thigh"], [`knee_${side}`, `ankle_${side}`, "shin"]);
260+
}
261+
let worst = -Infinity;
262+
for (const [aId, bId, part] of segments) {
263+
const a = pose.bones.get(aId);
264+
const b = pose.bones.get(bId);
265+
if (!a || !b) continue;
266+
const r = PART_RADII[part];
267+
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
268+
const p: Vec3 = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
269+
for (const f of faces) {
270+
if (!f.blocks.includes(part)) continue;
271+
const rel = sub(p, f.point);
272+
const d = dot(rel, f.normal);
273+
if (d < -f.captureDepth) continue;
274+
if (Math.abs(dot(rel, f.tangentU)) > f.halfU + r) continue;
275+
if (Math.abs(dot(rel, f.tangentV)) > f.halfV + r) continue;
276+
worst = Math.max(worst, r - d);
277+
}
278+
}
279+
}
280+
return worst;
281+
}
282+
199283
/** Fastest landmark's average speed from the previous endpoint into this phase. */
200284
export function phaseMaxLandmarkSpeed(previous: PhasePose | null, pose: PhasePose): number {
201285
if (!previous || pose.durationSec <= 0) return 0;
@@ -209,8 +293,11 @@ export function phaseMaxLandmarkSpeed(previous: PhasePose | null, pose: PhasePos
209293

210294
export function footSkateDistance(previous: PhasePose, pose: PhasePose, side: "left" | "right"): number {
211295
const id = `ankle_${side}`;
296+
// Authored travel AND the solid-prop contact push both translate the whole
297+
// body deliberately, feet included; skate is what's left after removing them.
212298
const local = (p: Vec3, phase: PhasePose): readonly [number, number] => {
213-
const x = p[0] - phase.rootOffset[0], z = p[2] - phase.rootOffset[2];
299+
const x = p[0] - phase.rootOffset[0] - phase.propPush[0];
300+
const z = p[2] - phase.rootOffset[2] - phase.propPush[2];
214301
const c = Math.cos(-phase.rootYaw), s = Math.sin(-phase.rootYaw);
215302
return [x * c - z * s, x * s + z * c];
216303
};
@@ -224,7 +311,8 @@ export function feetCenterSkateDistance(previous: PhasePose, pose: PhasePose): n
224311
const id = `ankle_${side}`;
225312
const a = bone(previous, id), b = bone(pose, id);
226313
const unyaw = (p: Vec3, phase: PhasePose) => {
227-
const x = p[0] - phase.rootOffset[0], z = p[2] - phase.rootOffset[2];
314+
const x = p[0] - phase.rootOffset[0] - phase.propPush[0];
315+
const z = p[2] - phase.rootOffset[2] - phase.propPush[2];
228316
const c = Math.cos(-phase.rootYaw), s = Math.sin(-phase.rootYaw);
229317
return [x * c - z * s, x * s + z * c] as const;
230318
};

packages/posecode-eval/src/probe.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
depenetrate,
2424
groundFigure,
2525
levelPlantedFeet,
26+
propContactExemptions,
27+
resolvePropContacts,
2628
} from "posecode-render";
2729

2830
export type Vec3 = readonly [x: number, y: number, z: number];
@@ -39,6 +41,14 @@ export interface PhasePose {
3941
reaches: readonly ReachTarget[];
4042
rootOffset: Vec3;
4143
rootYaw: number;
44+
/**
45+
* Horizontal body translation applied by the solid-prop contact solve
46+
* (resolvePropContacts): the feet legitimately glide by this much while the
47+
* body is pressed out of a prop (a wall-sit walks the feet forward as the
48+
* back slides down the wall), so skate metrics compensate for it like they
49+
* do for authored travel.
50+
*/
51+
propPush: Vec3;
4252
/** True when the phase relies on pins/reach-IK the probe cannot solve. */
4353
usesSceneIk: boolean;
4454
/** Whether the phase should rest on the floor (no elevated prop/grip support). */
@@ -89,6 +99,11 @@ export function probeMovement(source: string): ProbeResult {
8999
m.root.updateMatrixWorld(true);
90100
depenetrate(m);
91101
groundFigure(m);
102+
resolvePropContacts(m, propScene.colliders, propContactExemptions([
103+
...(ir.phases[0]?.pins ?? []),
104+
...(ir.phases[0]?.grips ?? []),
105+
...(ir.phases[0]?.reaches ?? []).map((r) => ({ effector: r.effector, anchor: r.target })),
106+
]));
92107
const baseRootPos = m.root.position.clone();
93108
const baseRootQuat = m.root.quaternion.clone();
94109

@@ -168,6 +183,16 @@ export function probeMovement(source: string): ProbeResult {
168183
m.root.updateMatrixWorld(true);
169184
}
170185
}
186+
// Props are solid (viewer parity): after the root solvers place the body,
187+
// push it back out of any prop face it crossed and bend swing legs clear.
188+
// Limbs pinned/gripped to a prop anchor are declared support, exempt.
189+
const prePush = m.root.position.clone();
190+
resolvePropContacts(m, propScene.colliders, propContactExemptions([
191+
...info.pins,
192+
...info.grips,
193+
...info.reaches.map((r) => ({ effector: r.effector, anchor: r.target })),
194+
]));
195+
const propPush: Vec3 = [m.root.position.x - prePush.x, 0, m.root.position.z - prePush.z];
171196
alignFloorPalms(m, info.reaches, info.pins);
172197
// Plantigrade correction (viewer parity): flatten planted soles. This lifts
173198
// the foot mesh a little, so it must run BEFORE the floor clamp reconciles.
@@ -192,6 +217,7 @@ export function probeMovement(source: string): ProbeResult {
192217
reaches: [...info.reaches],
193218
rootOffset: [info.rootOffset.x, 0, info.rootOffset.z],
194219
rootYaw: info.rootYaw,
220+
propPush,
195221
usesSceneIk: info.pins.length > 0 || info.reaches.length > 0 || info.grips.length > 0,
196222
floorBound,
197223
meshMinY: Number.isFinite(finalBox.min.y) ? finalBox.min.y : 0,

packages/posecode-render/src/depenetrate.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,9 @@ function wp(m: Mannequin, id: string, out = new THREE.Vector3()): THREE.Vector3
8888
/**
8989
* Rotate `joint` (world-space axis/angle) and clamp it back into `limits`.
9090
* Mirrors the CCD solver's joint update so corrections obey the same ROM.
91+
* Shared with the prop-contact pass (propcontact.ts).
9192
*/
92-
function rotateJoint(
93+
export function rotateJoint(
9394
joint: THREE.Object3D,
9495
axis: THREE.Vector3,
9596
angle: number,
@@ -113,7 +114,7 @@ function rotateJoint(
113114
}
114115

115116
/** The joint's ROM (radians), widened to admit its current authored pose. */
116-
function widenedLimits(
117+
export function widenedLimits(
117118
boneId: string,
118119
joint: THREE.Object3D,
119120
): { x: [number, number]; y: [number, number]; z: [number, number] } | null {

packages/posecode-render/src/index.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
type ClipSource,
2828
} from "./clips.js";
2929
import { depenetrate } from "./depenetrate.js";
30+
import { resolvePropContacts, propContactExemptions } from "./propcontact.js";
3031
import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms, aimHead } from "./contacts.js";
3132

3233
const DEG = Math.PI / 180;
@@ -585,6 +586,19 @@ export function createViewer(
585586
aimHead(mannequin, focus.multiplyScalar(1 / pts.length));
586587
}
587588

589+
/** Prop-contact exemptions for a phase: limbs pinned/gripped/reached to props. */
590+
function contactExemptionsOf(info: {
591+
pins?: readonly PinTarget[];
592+
grips?: readonly GripTarget[];
593+
reaches?: readonly ReachTarget[];
594+
}): ReturnType<typeof propContactExemptions> {
595+
return propContactExemptions([
596+
...(info.pins ?? []),
597+
...(info.grips ?? []),
598+
...(info.reaches ?? []).map((r) => ({ effector: r.effector, anchor: r.target })),
599+
]);
600+
}
601+
588602
function frameCamera(): void {
589603
// Auto-frame the figure: fit its bounding box, keep a pleasant angle.
590604
// Include any scene prop too: a pull-up bar sits well above the figure's
@@ -641,6 +655,15 @@ export function createViewer(
641655
applyGroundLockTo(mannequin, info.groundLock, frameAnchors(info.rootYaw, info.rootOffset));
642656
applyPins(info.pins);
643657
applyGrips(info.grips);
658+
// Props are solid: after the root solvers place the body, push it back
659+
// out of any prop face it crossed (wall-sit slides down the wall's
660+
// surface, not through it) and bend swing legs clear of box edges.
661+
// Before reach-IK so a later root push can't drag reached hands off
662+
// their world targets. Limbs pinned/gripped to a prop anchor are that
663+
// phase's declared support, exempt from clearing.
664+
if (propScene) {
665+
resolvePropContacts(mannequin, propScene.colliders, contactExemptionsOf(info));
666+
}
644667
// Reach-IK BEFORE the floor safety clamp. When authored FK pushes a
645668
// reaching limb through the floor (cobra: prone + shoulders flex 50),
646669
// the limb must bend to meet the floor. Running reaches after the clamp
@@ -782,6 +805,9 @@ export function createViewer(
782805
mannequin.root.updateMatrixWorld(true);
783806
depenetrate(mannequin);
784807
groundFigureOf(mannequin);
808+
if (propScene) {
809+
resolvePropContacts(mannequin, propScene.colliders, contactExemptionsOf(ir.phases[0] ?? {}));
810+
}
785811
levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []);
786812
authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId));
787813
authoredShoulders = new Set(timeline.bonesUsed.filter((id) => id.startsWith("shoulder_")));
@@ -1012,7 +1038,8 @@ export { applyGroundLock, groundFigure } from "./groundlock.js";
10121038
export type { Mannequin, Proportions, CollisionRadii } from "./mannequin.js";
10131039
export { buildTimeline } from "./timeline.js";
10141040
export { solveCCD, type IkChain, type JointLimits } from "./ik.js";
1015-
export { buildProps, type PropScene } from "./props.js";
1041+
export { buildProps, type PropScene, type FaceCollider, type BlockedPart } from "./props.js";
1042+
export { resolvePropContacts, propContactExemptions, type PropContactExemptions } from "./propcontact.js";
10161043
export { loadCharacter, rigCharacter, type Character } from "./character.js";
10171044
export {
10181045
loadClipSource,

0 commit comments

Comments
 (0)