From b711064e33285dd7f10720b68fb5b445efa2a66c Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Wed, 8 Jul 2026 19:37:36 +0300 Subject: [PATCH 1/2] fix: make breathing a mesh-only effect so poses stay exact Breathing via chest/spine bone rotations ran before ground-lock and pin solving, which translated the whole figure every frame to re-plant the displaced hands/feet: every movement visibly swayed and the head bobbed in poses that should hold it still (e.g. a squat). Breathe by swelling the named ribcage mesh (~5% front-to-back) instead. A mesh scale cannot touch any joint, so authored movements render exactly as the timeline solves them. Blinking is unchanged (already mesh-only). Verified: 361/361 eval checks across 73 movements, and a paused squat now holds its pose pixel-steady across breath phases. --- packages/posecode-render/src/index.ts | 33 ++++++++++++----------- packages/posecode-render/src/mannequin.ts | 7 +++-- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index 5716142..55df47e 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; } 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); From 15ba5a1e2a639c538970d019a32af84203a44f69 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Thu, 9 Jul 2026 01:26:20 +0300 Subject: [PATCH 2/2] fix: plant grounded feet horizontally and stop playground camera orbit Feet-only ground-lock corrected only the root's Y, so FK leg motion (hip/knee flexion) slid the feet across the floor while the pelvis stayed put: squats, hinges, and calf raises all skated, the opposite of real mechanics where planted feet stay fixed and the pelvis travels. applyGroundLock now accepts per-frame anchors (the captured base-pose effector positions carried along by the phase's yaw/travel) and translates the root in X/Z so grounded feet return to them. Only feet near the floor anchor, so a swing leg in a curl or march stays free, and only the average delta is corrected so symmetric spreads (jumping jacks) don't fight the lock. The eval probe mirrors the same anchors, keeping the harness viewer-faithful. Also disable the playground's idle camera auto-rotate: a permanently orbiting scene reads as the figure swaying and makes movements hard to judge. The landing-page hero keeps its showcase orbit. Verified: 361/361 eval checks across 73 movements (incl. walk-cycle travel and quarter-turn yaw), 188/188 unit tests, and visual checks of squat (hips sit back over planted feet), single-leg hamstring curl (swing foot free), and walk-and-turn (travel preserved). --- packages/posecode-eval/src/probe.ts | 24 +++++++++- packages/posecode-render/src/groundlock.ts | 56 +++++++++++++++++++++- packages/posecode-render/src/index.ts | 27 ++++++++++- playground/src/main.ts | 5 +- 4 files changed, 107 insertions(+), 5 deletions(-) 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 55df47e..e5f64a2 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -225,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); @@ -237,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 = { @@ -407,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/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);