diff --git a/packages/posecode-eval/src/probe.ts b/packages/posecode-eval/src/probe.ts index e3cb029..904b58c 100644 --- a/packages/posecode-eval/src/probe.ts +++ b/packages/posecode-eval/src/probe.ts @@ -68,6 +68,16 @@ export function probeMovement(source: string): ProbeResult { const baseRootPos = m.root.position.clone(); const baseRootQuat = m.root.quaternion.clone(); + // Mirror Viewer.captureGroundTargets(): the grounded base-pose effector + // positions are the anchors horizontal foot planting holds feet to. + const groundTargets = new Map(); + for (const ids of Object.values(m.effectors)) { + for (const id of ids) { + const node = m.bones.get(id); + if (node) groundTargets.set(id, node.getWorldPosition(new THREE.Vector3())); + } + } + // Sample the end of each phase, applying the viewer's per-frame root // pipeline: base root → yaw/travel → ground-lock → floor safety clamp. const yawQ = new THREE.Quaternion(); @@ -82,7 +92,19 @@ export function probeMovement(source: string): ProbeResult { m.root.position.x += info.rootOffset.x; m.root.position.z += info.rootOffset.z; m.root.updateMatrixWorld(true); - applyGroundLock(m, info.groundLock); + // Mirror the viewer's per-frame anchors: captured targets carried along + // by this phase's yaw/travel so planting composes with choreography. + const anchors = new Map(); + for (const [id, captured] of groundTargets) { + const v = captured.clone(); + if (info.rootYaw !== 0) { + v.sub(baseRootPos).applyAxisAngle(WORLD_Y, info.rootYaw).add(baseRootPos); + } + v.x += info.rootOffset.x; + v.z += info.rootOffset.z; + anchors.set(id, v); + } + applyGroundLock(m, info.groundLock, anchors); // Viewer safety net: never leave the lowest mesh point below the floor. m.root.updateMatrixWorld(true); const box = new THREE.Box3().setFromObject(m.root); diff --git a/packages/posecode-render/src/groundlock.ts b/packages/posecode-render/src/groundlock.ts index bbad3d9..5551de6 100644 --- a/packages/posecode-render/src/groundlock.ts +++ b/packages/posecode-render/src/groundlock.ts @@ -15,6 +15,14 @@ * - **Feet only (squat / hinge / roll-down):** drop the body vertically so the * feet stay planted while the legs keep their authored FK bend: the pelvis * lowers. Legs are never CCD-solved (that would overwrite the pose). + * With `anchors`, grounded feet are also held HORIZONTALLY: FK leg motion + * (hip/knee) displaces the feet relative to the root, and without the + * correction the feet skate across the floor while the pelvis stays put — + * backwards from real movement, where planted feet stay fixed and the + * pelvis travels (a squat sits the hips back, a hinge shifts them behind + * the heels). Only feet near the floor anchor (a swing leg in a curl or + * march must stay free), and only the average delta is corrected so + * symmetric spreads (jumping jacks) don't fight the lock. * * Both paths ground the visible MESH (bounding boxes), not just bone origins: * an ankle bone sits ~0.04m above the sole, so anchoring bones alone left the @@ -71,8 +79,19 @@ function rotateRootAboutPivot(m: Mannequin, pivot: THREE.Vector3, angle: number) m.root.updateMatrixWorld(true); } -/** Apply ground-lock for the phase's active effector groups (see module doc). */ -export function applyGroundLock(m: Mannequin, active: string[]): void { +/** A foot whose mesh bottom is within this height counts as planted. */ +const PLANTED_MAX_Y = 0.05; + +/** + * Apply ground-lock for the phase's active effector groups (see module doc). + * `anchors` (optional) maps effector bone ids to the world position each + * planted foot should hold, already transformed by the phase's yaw/travel. + */ +export function applyGroundLock( + m: Mannequin, + active: string[], + anchors?: ReadonlyMap, +): void { if (active.length === 0) return; const ids = activeEffectorIds(m, active); const hands = ids.filter((id) => id.startsWith("wrist")); @@ -120,5 +139,38 @@ export function applyGroundLock(m: Mannequin, active: string[]): void { m.root.position.y -= minY; m.root.updateMatrixWorld(true); } + if (anchors) plantFeetHorizontally(m, feet, anchors); + } +} + +/** + * Translate the root in X/Z so grounded feet return to their anchors (see + * module doc). Runs after vertical grounding so "near the floor" is judged in + * the final vertical placement. + */ +function plantFeetHorizontally( + m: Mannequin, + feet: string[], + anchors: ReadonlyMap, +): void { + const p = new THREE.Vector3(); + let dx = 0; + let dz = 0; + let n = 0; + for (const id of feet) { + const anchor = anchors.get(id); + const node = m.bones.get(id); + if (!anchor || !node) continue; + const box = new THREE.Box3().setFromObject(node); + if (!Number.isFinite(box.min.y) || box.min.y > PLANTED_MAX_Y) continue; // swing foot + node.getWorldPosition(p); + dx += anchor.x - p.x; + dz += anchor.z - p.z; + n++; + } + if (n > 0) { + m.root.position.x += dx / n; + m.root.position.z += dz / n; + m.root.updateMatrixWorld(true); } } diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index 5716142..e5f64a2 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -145,30 +145,31 @@ export function createViewer( scene.add(mannequin.root); // --- Life layer: breathing + blinking so the figure reads as alive even - // when the movement is paused. Breathing is a tiny additive sagittal - // rotation layered onto the sampled pose each frame; it can never drift - // because timeline.sample() rewrites those quaternions every frame. + // when the movement is paused. Both are MESH-only effects. Breathing must + // never rotate skeleton bones: an earlier version breathed via tiny + // chest/spine rotations, but those ran before ground-lock/pin solving, + // which translated the whole figure to re-plant the displaced hands/feet, + // so every movement visibly swayed and the head bobbed. Swelling the + // ribcage mesh cannot disturb any joint, so authored poses stay exact. const BREATH_PERIOD = 3.8; // seconds per breath cycle const BLINK_DURATION = 0.13; - const LIFE_AXIS = new THREE.Vector3(1, 0, 0); - const LIFE_Q = new THREE.Quaternion(); const eyes = ["eye_left", "eye_right"] .map((n) => mannequin.root.getObjectByName(n)) .filter((o): o is THREE.Object3D => Boolean(o)); + const ribcage = mannequin.root.getObjectByName("ribcage"); + const ribcageRestScale = ribcage ? ribcage.scale.clone() : null; let nextBlink = performance.now() / 1000 + 2; - function breatheBone(boneId: string, angle: number): void { - const bone = mannequin.bones.get(boneId); - if (!bone) return; - LIFE_Q.setFromAxisAngle(LIFE_AXIS, angle); - bone.quaternion.multiply(LIFE_Q); - } - function applyLife(nowSec: number): void { - const breath = Math.sin((nowSec * Math.PI * 2) / BREATH_PERIOD); - breatheBone("chest", breath * 0.022); - breatheBone("spine", breath * 0.012); - breatheBone("neck", breath * -0.014); // counter-rotate: head stays level + if (ribcage && ribcageRestScale) { + // 0..1 inhale fraction; the chest swells mostly front-to-back. + const breath = 0.5 + 0.5 * Math.sin((nowSec * Math.PI * 2) / BREATH_PERIOD); + ribcage.scale.set( + ribcageRestScale.x * (1 + breath * 0.015), + ribcageRestScale.y * (1 + breath * 0.01), + ribcageRestScale.z * (1 + breath * 0.05), + ); + } if (nowSec >= nextBlink + BLINK_DURATION) { nextBlink = nowSec + 2.5 + Math.random() * 3; } @@ -224,6 +225,7 @@ export function createViewer( // "Ground-lock" means HOLD the effector where the grounded base pose placed // it, not drag it to y=0. groundFigure() already set the floor contact. groundTargets = new Map(); + frameAnchorMap.clear(); // drop anchors for effectors no longer captured for (const ids of Object.values(mannequin.effectors)) { for (const id of ids) { const node = mannequin.bones.get(id); @@ -236,6 +238,30 @@ export function createViewer( const WORLD_Y = new THREE.Vector3(0, 1, 0); const YAW_Q = new THREE.Quaternion(); + // Per-frame ground anchors: the captured load-time effector positions, + // carried along by the phase's yaw/travel so horizontal foot planting + // composes with choreography instead of fighting it. Values are mutated in + // place each frame; the map is rebuilt on load (captureGroundTargets). + const frameAnchorMap = new Map(); + function frameAnchors(rootYaw: number, rootOffset: { x: number; z: number }): Map { + for (const [id, captured] of groundTargets) { + let v = frameAnchorMap.get(id); + if (!v) { + v = new THREE.Vector3(); + frameAnchorMap.set(id, v); + } + v.copy(captured); + if (rootYaw !== 0) { + // Yaw spins the body about the vertical axis through the root, so the + // anchors must pivot with it (a quarter-turn carries the feet around). + v.sub(baseRootPos).applyAxisAngle(WORLD_Y, rootYaw).add(baseRootPos); + } + v.x += rootOffset.x; + v.z += rootOffset.z; + } + return frameAnchorMap; + } + // Friendly DSL effector aliases → the distal bone whose world position is // driven to the reach target. const EFFECTOR_BONE: Record = { @@ -406,7 +432,7 @@ export function createViewer( mannequin.root.position.x += info.rootOffset.x; mannequin.root.position.z += info.rootOffset.z; mannequin.root.updateMatrixWorld(true); - applyGroundLockTo(mannequin, info.groundLock); + applyGroundLockTo(mannequin, info.groundLock, frameAnchors(info.rootYaw, info.rootOffset)); applyPins(info.pins); // Safety net: nothing above ever intentionally pushes part of the body // below the floor, so clamp the root up whenever the lowest point dips diff --git a/packages/posecode-render/src/mannequin.ts b/packages/posecode-render/src/mannequin.ts index 9a3df80..ef8efd5 100644 --- a/packages/posecode-render/src/mannequin.ts +++ b/packages/posecode-render/src/mannequin.ts @@ -256,8 +256,11 @@ function addEllipsoid( function addTorso(bones: Map, mats: FigureMaterials): void { // Hips: wide, slightly flattened, dressed in shorts. addEllipsoid(bones.get("pelvis")!, 0.09, [1.4, 1.0, 1.05], [0, -0.02, 0], mats.shorts); - // Ribcage: broad across the shoulders, shallow front-to-back. - addEllipsoid(bones.get("chest")!, 0.1, [1.5, 1.22, 0.82], [0, 0.03, 0], mats.top); + // Ribcage: broad across the shoulders, shallow front-to-back. Named so the + // viewer's life layer can swell it for breathing (a mesh-only effect that + // can never disturb the skeleton or the solved pose). + const ribcage = addEllipsoid(bones.get("chest")!, 0.1, [1.5, 1.22, 0.82], [0, 0.03, 0], mats.top); + ribcage.name = "ribcage"; // Deltoids round off the shoulder line. addEllipsoid(bones.get("shoulder_left")!, 0.057, [1.02, 1.12, 1.02], [-0.006, -0.012, 0], mats.top); addEllipsoid(bones.get("shoulder_right")!, 0.057, [1.02, 1.12, 1.02], [0.006, -0.012, 0], mats.top); diff --git a/playground/src/main.ts b/playground/src/main.ts index 982b39a..e2664fb 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -507,7 +507,10 @@ void import("./editor.js").then(({ createPosecodeEditor }) => { // Renderer: keep Three.js off the critical path, mirroring the landing page. void import("posecode-render").then(({ createViewer }) => { - viewer = createViewer(canvas); + // No idle camera orbit in the playground: the point here is judging the + // movement itself, and a permanently rotating scene reads as the figure + // swaying. The landing-page hero keeps its showcase orbit. + viewer = createViewer(canvas, { autoRotate: false }); // Exposed for capture/e2e tooling (frame capture drives README GIFs). (window as unknown as Record).__posecodeViewer = viewer; wireViewer(viewer);