diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a9c76..b515f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Realistic human figure**: the playground, landing hero, and `` 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"`). - **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. +- **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. - `viewer.characterActive`, `createViewer({ characterUrl })`, and the embed `character` attribute. - `scripts/capture-gifs.mjs` (`npm run gifs`): reproducible headless regeneration of the README movement GIFs from the live renderer. ### Fixed +- 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. - Deadlift arms now hang toward the bar during the hinge (were authored as shoulder extension, flying up behind the back). - Crunch keeps the feet planted with bent knees (shins previously folded through the floor and jacked the body up). - Touch-toes folds like a human (hinge depth and knee bend were over-authored, collapsing the figure). diff --git a/ROADMAP.md b/ROADMAP.md index d0a1df3..b861453 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,6 +41,11 @@ These are the unlocks, roughly in order of leverage: Powers `sit-to-stand`, `box-squat`, `wall-sit`, `dead-hang`, `hanging-knee-raise`. Bar and dip-bar contacts now resolve to independent left/right anchors with terminal wrist orientation; mocap is contact-corrected after blending. + Props are now **solid**: declared blocking faces (wall surface, chair + backrest + seat edge, box edge) physically stop the body — a wall-sit + slides down the wall instead of through it, a sit lands against the + backrest, a swing leg steps over the box edge — guarded by a `solid-props` + eval invariant on every prop movement. Next: more props (bench, rings, bands), load cues, arbitrary surface shapes. 4. ~~**Lying & seated base poses**~~: **shipped.** `supine | prone | seated` 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 - A **starter** prop set (chair / wall / bar / box / dip bars): no bench, rings, bands, or loaded implements yet, and props sit at fixed default placements. -- Props are visual + reach anchors (no physical sit/lean solve). +- Prop solidity is face-based: each built-in prop declares its blocking + surfaces (wall face, backrest, seat edge, box edge). Arbitrary-shape + collision and load/pressure simulation are future. - Fingers are **single-DOF** curls, good for grip and rough gesture, not exact sign language. The head has no facial articulation. diff --git a/packages/posecode-eval/src/checks.ts b/packages/posecode-eval/src/checks.ts index da9eeab..fb9fe08 100644 --- a/packages/posecode-eval/src/checks.ts +++ b/packages/posecode-eval/src/checks.ts @@ -18,6 +18,7 @@ import { lowestPoint, palmFloorAngleDeg, phaseMaxLandmarkSpeed, + propPenetrationDepth, segmentTiltDeg, spineCurlDeg, torsoPitchDeg, @@ -137,6 +138,19 @@ export function genericChecks(result: ProbeResult): CheckOutcome[] { detail: `${clearance.toFixed(3)}m clearance (want > -0.01m)`, }); } + + // Props are solid: no body capsule may sit inside a prop's blocking face + // (the wall-sit-through-the-wall class of bug). Independent re-derivation + // of the face geometry, so it fails loudly if resolvePropContacts or a + // prop's collider declaration regresses. + const penetration = propPenetrationDepth(result, p); + if (Number.isFinite(penetration)) { + out.push({ + id: `solid-props:${p.name}`, + pass: penetration < 0.03, + detail: `${penetration.toFixed(3)}m into a solid prop face (want < 0.030)`, + }); + } } for (let i = 1; i < result.phases.length; i++) { diff --git a/packages/posecode-eval/src/metrics.ts b/packages/posecode-eval/src/metrics.ts index 4617db2..1ab1ba9 100644 --- a/packages/posecode-eval/src/metrics.ts +++ b/packages/posecode-eval/src/metrics.ts @@ -196,6 +196,90 @@ export function headPropClearance(result: ProbeResult, pose: PhasePose): number return clearance; } +interface SolidFace { + point: Vec3; + normal: Vec3; + tangentU: Vec3; + halfU: number; + tangentV: Vec3; + halfV: number; + captureDepth: number; + blocks: readonly string[]; +} + +/** The solid prop faces, re-derived from the prop geometry independently of + * the renderer's collider declarations so a regression in either is caught. */ +function solidFaces(propTypes: readonly string[]): SolidFace[] { + const out: SolidFace[] = []; + const all = ["torso", "head", "thigh", "shin", "arm"]; + if (propTypes.includes("wall")) { + 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 }); + } + if (propTypes.includes("chair")) { + out.push( + { 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"] }, + { 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"] }, + ); + } + if (propTypes.includes("box")) { + 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"] }); + } + return out; +} + +/** Body capsule radii matching the render mannequin (see mannequin.ts). */ +const PART_RADII = { torso: 0.13, head: 0.105, thigh: 0.075, shin: 0.055, arm: 0.038 } as const; + +/** + * Worst body penetration into a solid prop face (metres, ≤0 when clear), or + * -Infinity when the document declares no solid-faced prop. Limbs pinned or + * reached to a non-floor anchor are that phase's declared prop support and + * don't count (a foot standing ON the box is not "in" the box). + */ +export function propPenetrationDepth(result: ProbeResult, pose: PhasePose): number { + const faces = solidFaces(result.propTypes); + if (faces.length === 0) return -Infinity; + const exemptLegs = new Set(); + const contacts = [ + ...pose.pins, + ...pose.reaches.map((r) => ({ effector: r.effector, anchor: r.target })), + ]; + for (const c of contacts) { + if (c.anchor === "floor") continue; + if (c.effector === "feet" || c.effector === "foot_left") exemptLegs.add("left"); + if (c.effector === "feet" || c.effector === "foot_right") exemptLegs.add("right"); + } + const segments: [string, string, keyof typeof PART_RADII][] = [ + ["pelvis", "neck", "torso"], + ["neck", "head", "head"], + ]; + for (const side of ["left", "right"]) { + segments.push([`shoulder_${side}`, `elbow_${side}`, "arm"], [`elbow_${side}`, `wrist_${side}`, "arm"]); + if (exemptLegs.has(side)) continue; + segments.push([`hip_${side}`, `knee_${side}`, "thigh"], [`knee_${side}`, `ankle_${side}`, "shin"]); + } + let worst = -Infinity; + for (const [aId, bId, part] of segments) { + const a = pose.bones.get(aId); + const b = pose.bones.get(bId); + if (!a || !b) continue; + const r = PART_RADII[part]; + for (const t of [0, 0.25, 0.5, 0.75, 1]) { + const p: Vec3 = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; + for (const f of faces) { + if (!f.blocks.includes(part)) continue; + const rel = sub(p, f.point); + const d = dot(rel, f.normal); + if (d < -f.captureDepth) continue; + if (Math.abs(dot(rel, f.tangentU)) > f.halfU + r) continue; + if (Math.abs(dot(rel, f.tangentV)) > f.halfV + r) continue; + worst = Math.max(worst, r - d); + } + } + } + return worst; +} + /** Fastest landmark's average speed from the previous endpoint into this phase. */ export function phaseMaxLandmarkSpeed(previous: PhasePose | null, pose: PhasePose): number { if (!previous || pose.durationSec <= 0) return 0; @@ -209,8 +293,11 @@ export function phaseMaxLandmarkSpeed(previous: PhasePose | null, pose: PhasePos export function footSkateDistance(previous: PhasePose, pose: PhasePose, side: "left" | "right"): number { const id = `ankle_${side}`; + // Authored travel AND the solid-prop contact push both translate the whole + // body deliberately, feet included; skate is what's left after removing them. const local = (p: Vec3, phase: PhasePose): readonly [number, number] => { - const x = p[0] - phase.rootOffset[0], z = p[2] - phase.rootOffset[2]; + const x = p[0] - phase.rootOffset[0] - phase.propPush[0]; + const z = p[2] - phase.rootOffset[2] - phase.propPush[2]; const c = Math.cos(-phase.rootYaw), s = Math.sin(-phase.rootYaw); return [x * c - z * s, x * s + z * c]; }; @@ -224,7 +311,8 @@ export function feetCenterSkateDistance(previous: PhasePose, pose: PhasePose): n const id = `ankle_${side}`; const a = bone(previous, id), b = bone(pose, id); const unyaw = (p: Vec3, phase: PhasePose) => { - const x = p[0] - phase.rootOffset[0], z = p[2] - phase.rootOffset[2]; + const x = p[0] - phase.rootOffset[0] - phase.propPush[0]; + const z = p[2] - phase.rootOffset[2] - phase.propPush[2]; const c = Math.cos(-phase.rootYaw), s = Math.sin(-phase.rootYaw); return [x * c - z * s, x * s + z * c] as const; }; diff --git a/packages/posecode-eval/src/probe.ts b/packages/posecode-eval/src/probe.ts index d7e2ba0..53015cc 100644 --- a/packages/posecode-eval/src/probe.ts +++ b/packages/posecode-eval/src/probe.ts @@ -23,6 +23,8 @@ import { depenetrate, groundFigure, levelPlantedFeet, + propContactExemptions, + resolvePropContacts, } from "posecode-render"; export type Vec3 = readonly [x: number, y: number, z: number]; @@ -39,6 +41,14 @@ export interface PhasePose { reaches: readonly ReachTarget[]; rootOffset: Vec3; rootYaw: number; + /** + * Horizontal body translation applied by the solid-prop contact solve + * (resolvePropContacts): the feet legitimately glide by this much while the + * body is pressed out of a prop (a wall-sit walks the feet forward as the + * back slides down the wall), so skate metrics compensate for it like they + * do for authored travel. + */ + propPush: Vec3; /** True when the phase relies on pins/reach-IK the probe cannot solve. */ usesSceneIk: boolean; /** Whether the phase should rest on the floor (no elevated prop/grip support). */ @@ -89,6 +99,11 @@ export function probeMovement(source: string): ProbeResult { m.root.updateMatrixWorld(true); depenetrate(m); groundFigure(m); + resolvePropContacts(m, propScene.colliders, propContactExemptions([ + ...(ir.phases[0]?.pins ?? []), + ...(ir.phases[0]?.grips ?? []), + ...(ir.phases[0]?.reaches ?? []).map((r) => ({ effector: r.effector, anchor: r.target })), + ])); const baseRootPos = m.root.position.clone(); const baseRootQuat = m.root.quaternion.clone(); @@ -168,6 +183,16 @@ export function probeMovement(source: string): ProbeResult { m.root.updateMatrixWorld(true); } } + // Props are solid (viewer parity): after the root solvers place the body, + // push it back out of any prop face it crossed and bend swing legs clear. + // Limbs pinned/gripped to a prop anchor are declared support, exempt. + const prePush = m.root.position.clone(); + resolvePropContacts(m, propScene.colliders, propContactExemptions([ + ...info.pins, + ...info.grips, + ...info.reaches.map((r) => ({ effector: r.effector, anchor: r.target })), + ])); + const propPush: Vec3 = [m.root.position.x - prePush.x, 0, m.root.position.z - prePush.z]; alignFloorPalms(m, info.reaches, info.pins); // Plantigrade correction (viewer parity): flatten planted soles. This lifts // the foot mesh a little, so it must run BEFORE the floor clamp reconciles. @@ -192,6 +217,7 @@ export function probeMovement(source: string): ProbeResult { reaches: [...info.reaches], rootOffset: [info.rootOffset.x, 0, info.rootOffset.z], rootYaw: info.rootYaw, + propPush, usesSceneIk: info.pins.length > 0 || info.reaches.length > 0 || info.grips.length > 0, floorBound, meshMinY: Number.isFinite(finalBox.min.y) ? finalBox.min.y : 0, diff --git a/packages/posecode-render/src/depenetrate.ts b/packages/posecode-render/src/depenetrate.ts index 742b897..b4cea52 100644 --- a/packages/posecode-render/src/depenetrate.ts +++ b/packages/posecode-render/src/depenetrate.ts @@ -88,8 +88,9 @@ function wp(m: Mannequin, id: string, out = new THREE.Vector3()): THREE.Vector3 /** * Rotate `joint` (world-space axis/angle) and clamp it back into `limits`. * Mirrors the CCD solver's joint update so corrections obey the same ROM. + * Shared with the prop-contact pass (propcontact.ts). */ -function rotateJoint( +export function rotateJoint( joint: THREE.Object3D, axis: THREE.Vector3, angle: number, @@ -113,7 +114,7 @@ function rotateJoint( } /** The joint's ROM (radians), widened to admit its current authored pose. */ -function widenedLimits( +export function widenedLimits( boneId: string, joint: THREE.Object3D, ): { x: [number, number]; y: [number, number]; z: [number, number] } | null { diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index 1c462d3..dca4ed5 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -27,6 +27,7 @@ import { type ClipSource, } from "./clips.js"; import { depenetrate } from "./depenetrate.js"; +import { resolvePropContacts, propContactExemptions } from "./propcontact.js"; import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms, aimHead } from "./contacts.js"; const DEG = Math.PI / 180; @@ -585,6 +586,19 @@ export function createViewer( aimHead(mannequin, focus.multiplyScalar(1 / pts.length)); } + /** Prop-contact exemptions for a phase: limbs pinned/gripped/reached to props. */ + function contactExemptionsOf(info: { + pins?: readonly PinTarget[]; + grips?: readonly GripTarget[]; + reaches?: readonly ReachTarget[]; + }): ReturnType { + return propContactExemptions([ + ...(info.pins ?? []), + ...(info.grips ?? []), + ...(info.reaches ?? []).map((r) => ({ effector: r.effector, anchor: r.target })), + ]); + } + function frameCamera(): void { // Auto-frame the figure: fit its bounding box, keep a pleasant angle. // Include any scene prop too: a pull-up bar sits well above the figure's @@ -641,6 +655,15 @@ export function createViewer( applyGroundLockTo(mannequin, info.groundLock, frameAnchors(info.rootYaw, info.rootOffset)); applyPins(info.pins); applyGrips(info.grips); + // Props are solid: after the root solvers place the body, push it back + // out of any prop face it crossed (wall-sit slides down the wall's + // surface, not through it) and bend swing legs clear of box edges. + // Before reach-IK so a later root push can't drag reached hands off + // their world targets. Limbs pinned/gripped to a prop anchor are that + // phase's declared support, exempt from clearing. + if (propScene) { + resolvePropContacts(mannequin, propScene.colliders, contactExemptionsOf(info)); + } // Reach-IK BEFORE the floor safety clamp. When authored FK pushes a // reaching limb through the floor (cobra: prone + shoulders flex 50), // the limb must bend to meet the floor. Running reaches after the clamp @@ -782,6 +805,9 @@ export function createViewer( mannequin.root.updateMatrixWorld(true); depenetrate(mannequin); groundFigureOf(mannequin); + if (propScene) { + resolvePropContacts(mannequin, propScene.colliders, contactExemptionsOf(ir.phases[0] ?? {})); + } levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []); authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId)); authoredShoulders = new Set(timeline.bonesUsed.filter((id) => id.startsWith("shoulder_"))); @@ -1012,7 +1038,8 @@ export { applyGroundLock, groundFigure } from "./groundlock.js"; export type { Mannequin, Proportions, CollisionRadii } from "./mannequin.js"; export { buildTimeline } from "./timeline.js"; export { solveCCD, type IkChain, type JointLimits } from "./ik.js"; -export { buildProps, type PropScene } from "./props.js"; +export { buildProps, type PropScene, type FaceCollider, type BlockedPart } from "./props.js"; +export { resolvePropContacts, propContactExemptions, type PropContactExemptions } from "./propcontact.js"; export { loadCharacter, rigCharacter, type Character } from "./character.js"; export { loadClipSource, diff --git a/packages/posecode-render/src/propcontact.ts b/packages/posecode-render/src/propcontact.ts new file mode 100644 index 0000000..1f7624f --- /dev/null +++ b/packages/posecode-render/src/propcontact.ts @@ -0,0 +1,224 @@ +/** + * Prop contact solving: props are solid, not just visual. + * + * Authored poses are pure joint rotations relative to the root, and the root + * solvers (ground-lock, pins) only know about the floor and named anchors, so + * nothing stopped a wall-sit's pelvis from hinging straight through the wall + * or a sit-to-stand's torso from sinking into the chair's backrest. This pass + * samples the body as capsules (torso, head, thighs, shins, forearms) against + * each prop's declared solid faces (`FaceCollider`, see props.ts) and removes + * any overlap: + * + * - **Body-resolved faces** (wall, backrest, seat edge) translate the WHOLE + * figure along the face normal — the physical resolution of leaning into a + * flat surface is that the body moves, exactly like a real wall-sit where + * the feet walk forward as the back slides down the wall. Running after + * ground-lock, the push composes with planted feet per frame (recomputed + * from the base root, so it never accumulates). + * - **Limb-resolved faces** (box edge) rotate the offending leg's hip just + * enough to clear, ROM-clamped like every other solve, leaving the root — + * and with it any pinned support foot — untouched. + * + * Same principles as the self-collision pass (depenetrate.ts): minimal (a + * pose with no overlap is untouched, contact settles ON the surface), + * deterministic (pure function of the pose), and ROM-safe. + * + * Runs after ground-lock / pins / grips (it must see the final root + * placement) and before reach-IK (reached hands must not be dragged off + * their world targets by a later root translation). + */ + +import * as THREE from "three"; +import type { Mannequin } from "./mannequin.js"; +import type { BlockedPart, FaceCollider } from "./props.js"; +import { rotateJoint, widenedLimits } from "./depenetrate.js"; + +const DEG = Math.PI / 180; + +/** Max corrective hip rotation for a limb-resolved contact (radians). */ +const MAX_LIMB_CORRECTION = 25 * DEG; +/** Per-iteration limb step cap: several small steps converge smoothly. */ +const MAX_LIMB_STEP = 6 * DEG; +const LIMB_ITERATIONS = 8; +/** Passes over the body-resolved faces (pushes can unblock each other). */ +const BODY_PASSES = 2; + +interface BodySample { + part: BlockedPart; + /** Which side a limb sample belongs to; unset for torso/head. */ + side?: "left" | "right"; + point: THREE.Vector3; + radius: number; +} + +const TMP_D = new THREE.Vector3(); + +/** + * Penetration depth of a sample sphere behind a face, or 0 when clear. + * A sample is owned by the face only while it projects onto the patch + * (within the tangent half-extents, widened by its radius) and sits no + * deeper than `captureDepth` behind it. + */ +function faceDepth(c: FaceCollider, p: THREE.Vector3, r: number): number { + TMP_D.subVectors(p, c.point); + const d = TMP_D.dot(c.normal); + if (d - r >= 0 || d < -c.captureDepth) return 0; + if (Math.abs(TMP_D.dot(c.tangentU)) > c.halfU + r) return 0; + if (Math.abs(TMP_D.dot(c.tangentV)) > c.halfV + r) return 0; + return r - d; +} + +/** World position helper (assumes matrices are current). */ +function wp(m: Mannequin, id: string, out = new THREE.Vector3()): THREE.Vector3 { + return m.bones.get(id)!.getWorldPosition(out); +} + +/** Sample points along the segment a→b, plus an optional tip overhang. */ +function segmentSamples( + m: Mannequin, + part: BlockedPart, + aId: string, + bId: string, + radius: number, + tipOverhang: number, + out: BodySample[], + side?: "left" | "right", +): void { + const a = wp(m, aId); + const b = wp(m, bId); + const dir = b.clone().sub(a); + for (const t of [0, 0.33, 0.66, 1]) { + out.push({ part, side, point: a.clone().addScaledVector(dir, t), radius }); + } + if (tipOverhang > 0 && dir.lengthSq() > 1e-10) { + out.push({ part, side, point: b.clone().addScaledVector(dir.normalize(), tipOverhang), radius }); + } +} + +/** All body samples the solid faces test against, at current matrices. */ +function bodySamples(m: Mannequin): BodySample[] { + const R = m.collision; + const out: BodySample[] = []; + segmentSamples(m, "torso", "pelvis", "neck", R.torso, 0, out); + segmentSamples(m, "head", "neck", "head", R.head, 0.05, out); + for (const side of ["left", "right"] as const) { + segmentSamples(m, "thigh", `hip_${side}`, `knee_${side}`, R.thigh, 0, out, side); + // Shin overhang covers the foot mesh beyond the ankle bone. + segmentSamples(m, "shin", `knee_${side}`, `ankle_${side}`, R.shin, 0.06, out, side); + segmentSamples(m, "arm", `shoulder_${side}`, `elbow_${side}`, R.arm, 0, out, side); + segmentSamples(m, "arm", `elbow_${side}`, `wrist_${side}`, R.arm, 0.09, out, side); + } + return out; +} + +/** + * Limbs whose end-effector is pinned / reached / gripped to a NON-floor + * anchor this phase. That contact is intentional prop contact (a foot + * standing on the box top, hands gripping the bar or pressing the seat), so + * the contact pass must not "clear" the limb off its own support. Accepts + * the phase's declarations in any effector spelling (`feet`, `foot_left`, + * `hands`, `wrist_right`, …); pass reach targets as `anchor`. + */ +export function propContactExemptions( + contacts: readonly { effector: string; anchor: string }[], +): PropContactExemptions { + const legs = new Set<"left" | "right">(); + const arms = new Set<"left" | "right">(); + for (const c of contacts) { + if (c.anchor === "floor") continue; + for (const side of ["left", "right"] as const) { + if (c.effector === "feet" || c.effector === `foot_${side}` || c.effector === `ankle_${side}`) legs.add(side); + if (c.effector === "hands" || c.effector === `hand_${side}` || c.effector === `wrist_${side}`) arms.add(side); + } + } + return { legs, arms }; +} + +export interface PropContactExemptions { + legs: ReadonlySet<"left" | "right">; + arms: ReadonlySet<"left" | "right">; +} + +const NO_EXEMPTIONS: PropContactExemptions = { legs: new Set(), arms: new Set() }; + +/** + * Resolve body-vs-prop contact in place (see module doc). Call with the + * root's matrix world current; leaves matrices current. `exempt` lists limbs + * intentionally contacting a prop anchor (see propContactExemptions). + */ +export function resolvePropContacts( + m: Mannequin, + colliders: readonly FaceCollider[], + exempt: PropContactExemptions = NO_EXEMPTIONS, +): void { + if (colliders.length === 0) return; + const bodyFaces = colliders.filter((c) => c.resolve === "body"); + const limbFaces = colliders.filter((c) => c.resolve === "limb"); + + // A sample from an exempt limb never drives a correction: its contact with + // the prop is the movement's declared support. + const exemptSample = (s: BodySample): boolean => + (s.part === "shin" && exempt.legs.has(s.side!)) || + (s.part === "thigh" && exempt.legs.has(s.side!)) || + (s.part === "arm" && exempt.arms.has(s.side!)); + + // --- Whole-body push-out, one face at a time (samples re-read after each + // move so same-direction faces compose instead of double-pushing). --- + for (let pass = 0; pass < BODY_PASSES && bodyFaces.length > 0; pass++) { + let moved = false; + for (const c of bodyFaces) { + let depth = 0; + for (const s of bodySamples(m)) { + if (!c.blocks.includes(s.part) || exemptSample(s)) continue; + depth = Math.max(depth, faceDepth(c, s.point, s.radius)); + } + if (depth <= 1e-4) continue; + m.root.position.addScaledVector(c.normal, depth); + m.root.updateMatrixWorld(true); + moved = true; + } + if (!moved) break; + } + + // --- Per-leg clearing for limb-resolved faces (box edge): rotate the hip + // so the shin/foot lifts over the face, mirroring depenetrate's leg pass. --- + if (limbFaces.length === 0) return; + const TMP_LEVER = new THREE.Vector3(); + for (const side of ["left", "right"] as const) { + if (exempt.legs.has(side)) continue; + const hip = m.bones.get(`hip_${side}`); + if (!hip) continue; + const hipLimits = widenedLimits(`hip_${side}`, hip); + let applied = 0; + for (let i = 0; i < LIMB_ITERATIONS && applied < MAX_LIMB_CORRECTION; i++) { + const R = m.collision; + const samples: BodySample[] = []; + segmentSamples(m, "shin", `knee_${side}`, `ankle_${side}`, R.shin, 0.06, samples); + let deepest = 0; + let point: THREE.Vector3 | null = null; + let push: THREE.Vector3 | null = null; + for (const c of limbFaces) { + if (!c.blocks.includes("shin")) continue; + for (const s of samples) { + const depth = faceDepth(c, s.point, s.radius); + if (depth > deepest) { + deepest = depth; + point = s.point; + push = c.normal; + } + } + } + if (deepest <= 1e-4 || !point || !push) break; + const pivot = wp(m, `hip_${side}`); + TMP_LEVER.subVectors(point, pivot); + const lever = TMP_LEVER.length(); + if (lever < 0.05) break; + const axis = TMP_LEVER.clone().cross(push); + if (axis.lengthSq() < 1e-8) break; + axis.normalize(); + const step = Math.min(deepest / lever, MAX_LIMB_STEP, MAX_LIMB_CORRECTION - applied); + rotateJoint(hip, axis, step, hipLimits); + applied += step; + } + } +} diff --git a/packages/posecode-render/src/props.ts b/packages/posecode-render/src/props.ts index 3ff3463..4fe981e 100644 --- a/packages/posecode-render/src/props.ts +++ b/packages/posecode-render/src/props.ts @@ -13,11 +13,49 @@ import * as THREE from "three"; +/** Body parts a prop face can block (sampled as capsules by the contact pass). */ +export type BlockedPart = "torso" | "head" | "thigh" | "shin" | "arm"; + +/** + * A solid, one-sided face of a prop: a bounded plane the body may not cross. + * Solidity is per-face rather than per-volume because contact intent differs + * by surface: a chair's backrest blocks the torso, but its seat TOP is a + * support the thighs rest on (owned by pins/reaches), so only the surfaces + * that should push back are declared. + */ +export interface FaceCollider { + /** A point on the face (its centre), world space. */ + point: THREE.Vector3; + /** Outward unit normal: the side of the face the body must stay on. */ + normal: THREE.Vector3; + /** Unit tangents spanning the face, with the patch half-extent along each. */ + tangentU: THREE.Vector3; + halfU: number; + tangentV: THREE.Vector3; + halfV: number; + /** + * How far BEHIND the face a sample is still owned by it (metres). Must + * exceed the prop's thickness so a body that fully passed through (the + * wall-sit pelvis) is recaptured and pushed back out the declared side. + */ + captureDepth: number; + /** Which body parts this face blocks. */ + blocks: readonly BlockedPart[]; + /** + * How penetration is resolved: `"body"` translates the whole figure along + * the normal (you step away from a wall); `"limb"` bends the offending + * limb's proximal joint (you lift your leg over a box edge). + */ + resolve: "body" | "limb"; +} + export interface PropScene { /** All prop meshes; add this to the scene. */ group: THREE.Group; /** Anchor name → world-space contact point, merged into reach/ground-lock. */ anchors: Map; + /** Solid faces the body cannot pass through (see resolvePropContacts). */ + colliders: FaceCollider[]; } /** Build the declared props (`chair | wall | bar | box | dip-bars`). Unknown types are ignored. */ @@ -25,6 +63,27 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen const group = new THREE.Group(); group.name = "posecode-props"; const anchors = new Map(); + const colliders: FaceCollider[] = []; + // All built-in props are axis-aligned, so faces are declared by their axis. + const face = ( + cx: number, cy: number, cz: number, + normal: [number, number, number], + tangentU: [number, number, number], halfU: number, + tangentV: [number, number, number], halfV: number, + captureDepth: number, + blocks: readonly BlockedPart[], + resolve: "body" | "limb", + ): FaceCollider => ({ + point: new THREE.Vector3(cx, cy, cz), + normal: new THREE.Vector3(...normal), + tangentU: new THREE.Vector3(...tangentU), + halfU, + tangentV: new THREE.Vector3(...tangentV), + halfV, + captureDepth, + blocks, + resolve, + }); const mat = material ?? new THREE.MeshStandardMaterial({ color: 0x6b7280, roughness: 0.8, metalness: 0.05 }); @@ -38,6 +97,15 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen back.position.set(0, seatH + 0.28, -0.34); group.add(seat, back, leg(mat, 0.18, -0.0), leg(mat, -0.18, -0.0), leg(mat, 0.18, -0.32), leg(mat, -0.18, -0.32)); anchors.set("seat", new THREE.Vector3(0, seatH + 0.03, -0.12)); + // Backrest front face: sitting back is stopped by the backrest instead + // of the torso sinking through it (sit-to-stand, box-squat). + colliders.push( + face(0, seatH + 0.28, -0.31, [0, 0, 1], [1, 0, 0], 0.21, [0, 1, 0], 0.25, 0.4, ["torso", "head"], "body"), + // Seat front edge: a standing figure's calves can't occupy the seat + // slab. Blocks shins only — seated THIGHS legitimately rest across + // this plane on the seat top, which stays a contact surface. + face(0, seatH - 0.03, 0.05, [0, 0, 1], [1, 0, 0], 0.21, [0, 1, 0], 0.03, 0.42, ["shin"], "body"), + ); } else if (type === "bar") { // Above standing reach, so a pinned grip genuinely hangs the body below it. const barH = 2.3; @@ -66,6 +134,13 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen wall.position.set(0, 1.3, -0.34); group.add(wall); anchors.set("wall", new THREE.Vector3(0, 0.9, -0.29)); + // The whole front surface is solid: a wall-sit slides DOWN the wall + // (the body translates forward until the back rests on the plane) + // instead of the torso hinging through it. Deep capture recovers a + // body that FK placed entirely beyond the 0.1m slab. + colliders.push( + face(0, 1.3, -0.29, [0, 0, 1], [1, 0, 0], 1.1, [0, 1, 0], 1.3, 0.8, ["torso", "head", "thigh", "shin", "arm"], "body"), + ); } else if (type === "dip-bars") { // Parallel dip bars either side of the figure, rails running along Z. // Rail height is set so a straight-arm support holds the feet clear of @@ -102,10 +177,16 @@ export function buildProps(types: string[], material?: THREE.Material): PropScen plat.position.set(0, topH / 2, 0.32); group.add(plat); anchors.set("box", new THREE.Vector3(0, topH, 0.3)); + // Near face (toward the figure): a swinging shin clears the box edge by + // bending at the hip (step OVER it) rather than sweeping through it. + // Limb-resolved so the pinned lead foot on the box top is undisturbed. + colliders.push( + face(0, topH / 2, 0.11, [0, 0, -1], [1, 0, 0], 0.25, [0, 1, 0], topH / 2, 0.42, ["shin"], "limb"), + ); } } - return { group, anchors }; + return { group, anchors, colliders }; } function box(w: number, h: number, d: number, mat: THREE.Material): THREE.Mesh { diff --git a/packages/posecode-render/test/propcontact.test.ts b/packages/posecode-render/test/propcontact.test.ts new file mode 100644 index 0000000..c6531e3 --- /dev/null +++ b/packages/posecode-render/test/propcontact.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import * as THREE from "three"; +import { buildMannequin } from "../src/mannequin.js"; +import { buildProps } from "../src/props.js"; +import { groundFigure } from "../src/groundlock.js"; +import { resolvePropContacts, propContactExemptions } from "../src/propcontact.js"; + +const DEG = Math.PI / 180; +const WALL_FACE_Z = -0.29; +const BOX_FACE_Z = 0.11; + +function torsoBackZ(m: ReturnType): number { + const pelvis = m.bones.get("pelvis")!.getWorldPosition(new THREE.Vector3()); + return pelvis.z - m.collision.torso; +} + +describe("solid prop contact", () => { + it("pushes a wall-sit body out of the wall until the back rests on its surface", () => { + const m = buildMannequin(); + const { colliders } = buildProps(["wall"]); + // The wall-sit deep pose: feet planted, hips/knees at 90 carry the pelvis + // ~0.45m backward — historically straight through the wall slab. + for (const side of ["left", "right"]) { + m.bones.get(`hip_${side}`)!.rotation.set(-90 * DEG, 0, 0); + m.bones.get(`knee_${side}`)!.rotation.set(90 * DEG, 0, 0); + } + m.root.updateMatrixWorld(true); + groundFigure(m); + m.root.position.z -= 0.45; // where ground-locked feet leave the pelvis + m.root.updateMatrixWorld(true); + expect(torsoBackZ(m)).toBeLessThan(WALL_FACE_Z - 0.1); // sanity: through the wall + + resolvePropContacts(m, colliders); + // The back now rests ON the wall plane (small tolerance), not inside it. + expect(torsoBackZ(m)).toBeGreaterThan(WALL_FACE_Z - 1e-3); + expect(torsoBackZ(m)).toBeLessThan(WALL_FACE_Z + 0.02); + }); + + it("leaves a clear standing pose untouched", () => { + const m = buildMannequin(); + const { colliders } = buildProps(["wall", "box"]); + groundFigure(m); + const before = m.root.position.clone(); + resolvePropContacts(m, colliders); + expect(m.root.position.distanceTo(before)).toBeLessThan(1e-6); + }); + + it("steps a standing figure's calves clear of the chair's seat edge", () => { + const m = buildMannequin(); + const { colliders } = buildProps(["chair"]); + groundFigure(m); + // Standing at the origin, the default chair placement overlaps the calves + // with the seat slab; the body steps forward until they clear it. + resolvePropContacts(m, colliders); + const ankle = m.bones.get("ankle_left")!.getWorldPosition(new THREE.Vector3()); + expect(ankle.z - m.collision.shin).toBeGreaterThan(0.05 - 1e-3); // seat front edge + }); + + it("bends a swinging shin clear of the box edge without moving the root", () => { + const m = buildMannequin(); + const { colliders } = buildProps(["box"]); + groundFigure(m); + // Swing the right leg forward so the shin sweeps into the box's near face. + m.bones.get("hip_right")!.rotation.set(-40 * DEG, 0, 0); + m.bones.get("knee_right")!.rotation.set(30 * DEG, 0, 0); + m.root.updateMatrixWorld(true); + const ankleZ = () => m.bones.get("ankle_right")!.getWorldPosition(new THREE.Vector3()).z; + expect(ankleZ() + m.collision.shin).toBeGreaterThan(BOX_FACE_Z); // sanity: into the face + const rootBefore = m.root.position.clone(); + + resolvePropContacts(m, colliders); + expect(m.root.position.distanceTo(rootBefore)).toBeLessThan(1e-6); // limb-resolved + // The shin pulled back toward the face instead of sweeping through it. + expect(ankleZ() + m.collision.shin).toBeLessThan(BOX_FACE_Z + 0.02); + }); + + it("exempts a leg pinned to a prop anchor from limb clearing", () => { + const m = buildMannequin(); + const { colliders } = buildProps(["box"]); + groundFigure(m); + m.bones.get("hip_right")!.rotation.set(-40 * DEG, 0, 0); + m.bones.get("knee_right")!.rotation.set(30 * DEG, 0, 0); + m.root.updateMatrixWorld(true); + const before = m.bones.get("ankle_right")!.getWorldPosition(new THREE.Vector3()); + + const exempt = propContactExemptions([{ effector: "foot_right", anchor: "box" }]); + resolvePropContacts(m, colliders, exempt); + const after = m.bones.get("ankle_right")!.getWorldPosition(new THREE.Vector3()); + expect(after.distanceTo(before)).toBeLessThan(1e-6); + }); + + it("maps effector spellings and floor anchors correctly in propContactExemptions", () => { + const e = propContactExemptions([ + { effector: "feet", anchor: "box" }, + { effector: "hand_left", anchor: "bar" }, + { effector: "foot_right", anchor: "floor" }, // floor: not a prop contact + ]); + expect([...e.legs].sort()).toEqual(["left", "right"]); + expect([...e.arms]).toEqual(["left"]); + }); +}); diff --git a/spec/SPEC.md b/spec/SPEC.md index 76910f1..e861f0b 100644 --- a/spec/SPEC.md +++ b/spec/SPEC.md @@ -138,7 +138,15 @@ research §5.1 normative tables. Selected ceilings (degrees): 5. **Props**: `prop chair|wall|bar|box|dip-bars` adds a scene object at a fixed default placement (chair/wall behind, bar overhead, box in front, dip bars either side); its named anchors (`seat`, `wall`, `bar`, `box`, - `bars`) become reach/pin targets. + `bars`) become reach/pin targets. Props are **solid**: each prop declares + blocking faces (the wall's surface, the chair's backrest and seat edge, + the box's near face) and a contact pass removes any body overlap — either + by translating the whole figure out along the face normal (a wall-sit + slides down the wall's *surface*, feet walking forward, instead of the + torso hinging through the slab) or by bending the offending limb's hip + clear, ROM-clamped like every other solve. Limbs pinned, gripped, or + reached to a prop anchor are that phase's declared support and are exempt + (a foot standing on the box top is not "inside" the box). 6. **Pins**: `pin: ` translates the whole figure so the effector sits on the anchor (effectors accept the same `hands`/`feet` groups as reach: `pin: hands bar` pins both). Where ground-lock keeps a foot on @@ -171,7 +179,8 @@ character. Mocap therefore cannot overwrite a planted sole or pinned grip. **IK note:** Three.js's bundled `CCDIKSolver` targets `SkinnedMesh`; the Posecode mannequin is rigid capsule segments, so Posecode implements CCD directly over the Object3D bone hierarchy (`posecode-render/ik.ts`) for both ground-lock and reach. -Two-person/dual-IK and collision detection remain deferred (research §5.2, §6.2). +Self-collision (limb-vs-body) and body-vs-prop contact are solved; two-person/ +dual-IK and figure-vs-figure collision remain deferred (research §5.2, §6.2). ---