diff --git a/.changeset/heroic-owls-land.md b/.changeset/heroic-owls-land.md index 4ac6c01..bf71c75 100644 --- a/.changeset/heroic-owls-land.md +++ b/.changeset/heroic-owls-land.md @@ -4,4 +4,7 @@ "posecode-mcp": patch --- -Strengthen Posecode motion authoring and playback with strict contact validation, grounded multi-contact solving, continuous sparse transitions, and more lifelike canonical movement guidance. +Strengthen Posecode motion authoring and playback with strict contact validation, +grounded multi-contact solving, continuous sparse transitions, explicit forearm +roll guidance, natural relaxed hands, a mobile-safe phase rail, and more lifelike +canonical movements. diff --git a/packages/posecode-eval/src/checks.ts b/packages/posecode-eval/src/checks.ts index 081fced..55f65bd 100644 --- a/packages/posecode-eval/src/checks.ts +++ b/packages/posecode-eval/src/checks.ts @@ -21,6 +21,9 @@ import { kneeFlexionDeg, lowestPoint, palmFloorAngleDeg, + palmForwardAngleDeg, + palmInwardAngleDeg, + palmUpAngleDeg, phaseMaxLandmarkSpeed, propPenetrationDepth, segmentTiltDeg, @@ -535,6 +538,81 @@ export const MOVEMENT_CHECKS: MovementChecks[] = [ (v) => v > 1.0, "wrist > 1.0m", ), + phaseCheck( + "left-palm-up", + "Curl", + (p) => palmUpAngleDeg(p, "left"), + (v) => v < 55, + "< 55° from palm-up at peak flexion", + ), + phaseCheck( + "right-palm-up", + "Curl", + (p) => palmUpAngleDeg(p, "right"), + (v) => v < 55, + "< 55° from palm-up at peak flexion", + ), + phaseCheck( + "left-palm-forward-at-bottom", + "Lower", + (p) => palmForwardAngleDeg(p, "left"), + (v) => v < 25, + "< 25° from forward", + ), + phaseCheck( + "right-palm-forward-at-bottom", + "Lower", + (p) => palmForwardAngleDeg(p, "right"), + (v) => v < 25, + "< 25° from forward", + ), + ], + }, + { + movement: "jab-cross", + checks: [ + phaseCheck( + "jab-palm-down", + "Jab", + (p) => palmFloorAngleDeg(p, "left"), + (v) => v < 35, + "< 35° from palm-down", + ), + phaseCheck( + "cross-palm-down", + "Cross", + (p) => palmFloorAngleDeg(p, "right"), + (v) => v < 35, + "< 35° from palm-down", + ), + phaseCheck( + "lead-guard-palm-in", + "Recoil cross", + (p) => palmInwardAngleDeg(p, "left"), + (v) => v < 20, + "< 20° from inward", + ), + phaseCheck( + "rear-guard-palm-in", + "Recoil cross", + (p) => palmInwardAngleDeg(p, "right"), + (v) => v < 20, + "< 20° from inward", + ), + phaseCheck( + "lead-guard-high", + "Recoil cross", + (p) => heightOf(p, "wrist_left"), + (v) => v > 1.45, + "> 1.45m", + ), + phaseCheck( + "rear-guard-high", + "Recoil cross", + (p) => heightOf(p, "wrist_right"), + (v) => v > 1.45, + "> 1.45m", + ), ], }, { diff --git a/packages/posecode-eval/src/index.ts b/packages/posecode-eval/src/index.ts index 2cc99fc..cb6af49 100644 --- a/packages/posecode-eval/src/index.ts +++ b/packages/posecode-eval/src/index.ts @@ -29,6 +29,9 @@ export { kneeFlexionDeg, lowestPoint, palmFloorAngleDeg, + palmForwardAngleDeg, + palmInwardAngleDeg, + palmUpAngleDeg, phaseMaxLandmarkSpeed, segmentTiltDeg, soleUpAngleDeg, diff --git a/packages/posecode-eval/src/metrics.ts b/packages/posecode-eval/src/metrics.ts index f6b1ed8..3976c81 100644 --- a/packages/posecode-eval/src/metrics.ts +++ b/packages/posecode-eval/src/metrics.ts @@ -107,6 +107,31 @@ export function palmFloorAngleDeg(pose: PhasePose, side: "left" | "right"): numb return angleBetweenDeg(rotateByQuat([0, 0, 1], q), [0, -1, 0]); } +/** Angle between the palm face normal and world-up (0 = palm facing up). */ +export function palmUpAngleDeg(pose: PhasePose, side: "left" | "right"): number { + const q = pose.boneQuaternions.get(`wrist_${side}`); + if (!q) return 180; + return angleBetweenDeg(rotateByQuat([0, 0, 1], q), [0, 1, 0]); +} + +/** Angle between the palm face normal and character-forward (+Z at zero yaw). */ +export function palmForwardAngleDeg(pose: PhasePose, side: "left" | "right"): number { + const q = pose.boneQuaternions.get(`wrist_${side}`); + if (!q) return 180; + const forward: Vec3 = [Math.sin(pose.rootYaw), 0, Math.cos(pose.rootYaw)]; + return angleBetweenDeg(rotateByQuat([0, 0, 1], q), forward); +} + +/** Angle between a palm and the body's lateral midline direction. */ +export function palmInwardAngleDeg(pose: PhasePose, side: "left" | "right"): number { + const q = pose.boneQuaternions.get(`wrist_${side}`); + if (!q) return 180; + const inward = side === "left" + ? sub(bone(pose, "shoulder_right"), bone(pose, "shoulder_left")) + : sub(bone(pose, "shoulder_left"), bone(pose, "shoulder_right")); + return angleBetweenDeg(rotateByQuat([0, 0, 1], q), inward); +} + /** Angle between the semantic fist's knuckle direction and floor-down. */ export function fistFloorAngleDeg(pose: PhasePose, side: "left" | "right"): number { const q = pose.boneQuaternions.get(`wrist_${side}`); diff --git a/packages/posecode-language/src/hover.ts b/packages/posecode-language/src/hover.ts index bbeffaa..8e58cba 100644 --- a/packages/posecode-language/src/hover.ts +++ b/packages/posecode-language/src/hover.ts @@ -57,12 +57,13 @@ export function getHover( if (bone && token !== "hold") { const rom = romFor(bone, token); if (rom) { + const detail = KEYWORD_DOCS[token]; return md( - `**${boneType(bone)} · ${token}**: configured range **${rom.min}–${rom.max}°**. Angles beyond this are clamped with a diagnostic.`, + `**${boneType(bone)} · ${token}**: configured range **${rom.min}–${rom.max}°**. Angles beyond this are clamped with a diagnostic.${detail ? ` ${detail}` : ""}`, ); } } - return md(`Action **${token}**.`); + return md(`Action **${token}**.${KEYWORD_DOCS[token] ? ` ${KEYWORD_DOCS[token]}` : ""}`); } if (JOINT_NAMES.includes(token)) { diff --git a/packages/posecode-language/src/vocab.ts b/packages/posecode-language/src/vocab.ts index 49b350a..4362bcf 100644 --- a/packages/posecode-language/src/vocab.ts +++ b/packages/posecode-language/src/vocab.ts @@ -74,4 +74,6 @@ export const KEYWORD_DOCS: Record = { travel: "Moves the figure across the floor: `travel: 0.4 0` (world x z metres from the start spot). Absolute, carried across phases. Standing poses only.", cue: "A short coaching cue shown while this phase plays.", hold: "Reset every rotation channel on this joint to its neutral / rest angle: `: hold neutral`.", + pronate: "Rolls the forearm toward palm-down. With upright arms at the sides, about 80° faces the palm inward toward the thigh; final world facing also depends on the arm pose.", + supinate: "Rolls the forearm in the palm-up direction; final world facing also depends on the shoulder and elbow pose.", }; diff --git a/packages/posecode-language/test/language.test.ts b/packages/posecode-language/test/language.test.ts index 3f732f2..3c2f61d 100644 --- a/packages/posecode-language/test/language.test.ts +++ b/packages/posecode-language/test/language.test.ts @@ -59,7 +59,7 @@ describe("getCompletions", () => { it("suggests joints (and child keywords) at the start of an indented line", () => { const l = onLine(" ", 4); - expect(l).toEqual(expect.arrayContaining(["knees", "elbows"])); + expect(l).toEqual(expect.arrayContaining(["knees", "elbows", "forearms"])); expect(l).toContain("cue"); }); @@ -87,6 +87,15 @@ describe("getCompletions", () => { expect(chestActions).not.toContain("rotate-out"); }); + it("offers and explains anatomical forearm rotation", () => { + expect(onLine(" forearms: ", 14)).toEqual( + expect.arrayContaining(["pronate", "supinate"]), + ); + const line = " forearms: pronate 80"; + const hover = getHover(line, 0, line.indexOf("pronate") + 1); + expect(hover?.contents).toContain("thigh"); + }); + it("suggests timing modes inside a step header", () => { expect(onLine(' step "y" 2s ', 14)).toEqual( expect.arrayContaining(["flow", "settle", "linear"]), diff --git a/packages/posecode-mcp/src/guide.ts b/packages/posecode-mcp/src/guide.ts index cc2fcb7..315ac86 100644 --- a/packages/posecode-mcp/src/guide.ts +++ b/packages/posecode-mcp/src/guide.ts @@ -60,9 +60,13 @@ posecode "" # kind = exercise | stretch | posture \`\`\` Joints: neck head spine chest pelvis, and (singular or plural) shoulders elbows -wrists hips knees ankles. Actions (degrees are absolute targets): flex/extend, +forearms wrists hips knees ankles. \`forearms\` aliases the elbow bones for palm +roll. Actions (degrees are absolute targets): flex/extend, abduct/adduct, rotate-in/rotate-out (shoulder/hip), twist-left/twist-right -(axial joints), dorsiflex/plantarflex, hold neutral, and hinge (pelvis only). +(axial joints), supinate/pronate (forearm roll), dorsiflex/plantarflex, hold +neutral, and hinge (pelvis only). With upright arms at the sides, +\`forearms: pronate 80\` faces the palms inward toward the thighs. +At zero degrees, \`pronate 0\` and \`supinate 0\` are the same absolute target. Use only joint/action pairs and declared prop anchors accepted by the validator. Author the gross pose before reach; a parsed reach is not proof of contact. Keep cues, sides, and declared contacts consistent through every phase. Floor diff --git a/packages/posecode-parser/src/joints.ts b/packages/posecode-parser/src/joints.ts index 74b1101..c87c88d 100644 --- a/packages/posecode-parser/src/joints.ts +++ b/packages/posecode-parser/src/joints.ts @@ -70,6 +70,9 @@ const FINGERS_RIGHT: BoneId[] = [ const GROUPS: Record = { shoulders: ["shoulder_left", "shoulder_right"], elbows: ["elbow_left", "elbow_right"], + // Anatomical authoring alias: forearm axial rotation lives on the elbow + // bone in the rig, but `forearms: pronate 80` is clearer to authors. + forearms: ["elbow_left", "elbow_right"], wrists: ["wrist_left", "wrist_right"], hips: ["hip_left", "hip_right"], knees: ["knee_left", "knee_right"], @@ -196,8 +199,10 @@ const ACTIONS: Record = { // toward the person's left (+X), and -Y turns it right (-X). "twist-left": { axis: "y", sign: 1 }, "twist-right": { axis: "y", sign: -1 }, - supinate: { axis: "y", sign: 1 }, - pronate: { axis: "y", sign: -1 }, + // From the palm-forward driver rest, pronation turns each palm toward its + // own thigh. The left-side mirror in clamp.ts supplies the opposite sign. + supinate: { axis: "y", sign: -1 }, + pronate: { axis: "y", sign: 1 }, // The foot points FORWARD (+Z): lifting the toes toward the shin // (dorsiflexion) is a -X rotation, pointing them is +X. dorsiflex: { axis: "x", sign: -1 }, diff --git a/packages/posecode-parser/src/types.ts b/packages/posecode-parser/src/types.ts index df10e34..48a4b3b 100644 --- a/packages/posecode-parser/src/types.ts +++ b/packages/posecode-parser/src/types.ts @@ -47,9 +47,10 @@ export interface ReachTarget { /** * A contact pin: translate the whole figure so `effector` sits on a fixed - * `anchor` (a prop anchor, a landmark, or `floor`). Unlike a reach (which moves - * the limb to a target) a pin moves the BODY, so the figure can hang from a bar, - * rise onto a box, or lower into a dip while the contact stays put. + * world `anchor` (a declared prop anchor or `floor`). Unlike a reach (which + * moves the limb to a target) a pin moves the BODY, so a body landmark cannot + * serve as its anchor: that landmark would move with the same root. Pins let the + * figure hang from a bar, rise onto a box, or keep one floor support fixed. */ export interface PinTarget { effector: string; diff --git a/packages/posecode-parser/test/parse.test.ts b/packages/posecode-parser/test/parse.test.ts index 91dd60e..2be6a53 100644 --- a/packages/posecode-parser/test/parse.test.ts +++ b/packages/posecode-parser/test/parse.test.ts @@ -54,6 +54,25 @@ describe("parse", () => { expect(elbowL.euler.x).toBe(-90); }); + it("accepts `forearms` as the anatomical alias for palm rotation", () => { + const { ir, errors } = parse([ + 'posecode posture "Palms inward"', + " rig humanoid", + ' step "Turn palms" 1s settle:', + " forearms: pronate 80", + ].join("\n")); + expect(errors).toEqual([]); + const targets = ir!.phases[0]!.targets; + expect(targets.find((target) => target.boneId === "elbow_left")).toMatchObject({ + euler: { y: -80 }, + axes: ["y"], + }); + expect(targets.find((target) => target.boneId === "elbow_right")).toMatchObject({ + euler: { y: 80 }, + axes: ["y"], + }); + }); + it("clamps out-of-range angles and records a warning", () => { const src = [ 'posecode exercise "Bad knee"', diff --git a/packages/posecode-parser/test/rom.test.ts b/packages/posecode-parser/test/rom.test.ts index 55286df..7707629 100644 --- a/packages/posecode-parser/test/rom.test.ts +++ b/packages/posecode-parser/test/rom.test.ts @@ -64,8 +64,8 @@ describe("euler ROM boxes (eulerRomFor)", () => { const right = eulerRomFor("elbow_right")!; const left = eulerRomFor("elbow_left")!; // supinate 92 / pronate 84 flip sides under the mirror. - expect(right.y).toEqual({ min: -84, max: 92 }); - expect(left.y).toEqual({ min: -92, max: 84 }); + expect(right.y).toEqual({ min: -92, max: 84 }); + expect(left.y).toEqual({ min: -84, max: 92 }); // X (sagittal) is never mirrored. expect(left.x).toEqual(right.x); }); diff --git a/packages/posecode-parser/test/strict-validation.test.ts b/packages/posecode-parser/test/strict-validation.test.ts index b3b8060..d1445ff 100644 --- a/packages/posecode-parser/test/strict-validation.test.ts +++ b/packages/posecode-parser/test/strict-validation.test.ts @@ -179,6 +179,22 @@ describe("contact namespace", () => { expect(parse(crossed).errors[0]?.message).toMatch(/does not match/i); }); + it("expands a valid grouped grip onto distinct declared side anchors", () => { + const source = [ + 'posecode exercise "Grip"', + " rig humanoid", + " prop bar", + ' step "Hold" 1s settle:', + " grip: hands bar", + ].join("\n"); + const result = parse(source); + expect(result.errors).toEqual([]); + expect(result.ir?.phases[0]?.grips).toEqual([ + { effector: "hand_left", anchor: "bar_left" }, + { effector: "hand_right", anchor: "bar_right" }, + ]); + }); + it("rejects competing whole-root contact solvers in one step", () => { const result = parse(doc("pin: knee_left floor", "ground-lock: foot_right")); expect(result.ir).toBeNull(); diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts index 8735939..d7b89f3 100644 --- a/packages/posecode-render/src/contacts.ts +++ b/packages/posecode-render/src/contacts.ts @@ -5,7 +5,12 @@ import type { Mannequin } from "./mannequin.js"; import { effectorBoneId } from "./reach.js"; const DOWN = new THREE.Vector3(0, -1, 0); +const FOREARM_AXIS = new THREE.Vector3(0, 1, 0); const DEG = Math.PI / 180; +/** Strong palm-down cone with 10° margin inside eval's 55° contact limit. */ +const PALM_DOWN_TARGET_DOT = Math.cos(45 * DEG); +/** Natural outward travel as an arm straightens toward an unconstrained floor. */ +const HAND_FLOOR_OUTSET = 0.04; const CONTACT_EULER = new THREE.Euler(); export type HandSide = "left" | "right"; @@ -78,6 +83,147 @@ function alignWristNormal( return true; } +function quaternionIsInRom(q: THREE.Quaternion, boneId: string): boolean { + const rom = eulerRomFor(boneId); + if (!rom) return false; + const euler = new THREE.Euler().setFromQuaternion(q, "XYZ"); + // CCD and world/local quaternion round-trips can leave locked axes a few + // ten-thousandths of a degree off zero. Treat that as numerical noise, not + // an authored ROM violation that disables the whole contact solver. + const epsilon = 1e-4; + return ( + euler.x >= rom.x.min * DEG - epsilon && euler.x <= rom.x.max * DEG + epsilon && + euler.y >= rom.y.min * DEG - epsilon && euler.y <= rom.y.max * DEG + epsilon && + euler.z >= rom.z.min * DEG - epsilon && euler.z <= rom.z.max * DEG + epsilon + ); +} + +/** + * Find the legal forearm-axis twist that gives the wrist the best attainable + * palm-down frame. A post-multiplied local-Y twist leaves the elbow→wrist + * offset ([0,-length,0]) unchanged, so this can re-orient a planted palm + * without pulling its solved contact point away from the floor target. + */ +function bestPalmForearmTwist(m: Mannequin, side: HandSide): number | null { + const elbowId = `elbow_${side}`; + const wristId = `wrist_${side}`; + const elbow = m.bones.get(elbowId); + const wrist = m.bones.get(wristId); + const wristRom = eulerRomFor(wristId); + if (!elbow?.parent || wrist?.parent !== elbow || !wristRom) return null; + + m.root.updateMatrixWorld(true); + const elbowParentWorld = elbow.parent.getWorldQuaternion(new THREE.Quaternion()); + const authoredElbow = elbow.quaternion.clone(); + const authoredWrist = wrist.quaternion.clone(); + const twist = new THREE.Quaternion(); + const candidateElbow = new THREE.Quaternion(); + const elbowWorld = new THREE.Quaternion(); + const wristWorld = new THREE.Quaternion(); + const correction = new THREE.Quaternion(); + const desiredWorld = new THREE.Quaternion(); + const desiredLocal = new THREE.Quaternion(); + const clampedWrist = new THREE.Quaternion(); + const finalWorld = new THREE.Quaternion(); + const wristEuler = new THREE.Euler(); + const candidateEuler = new THREE.Euler(); + const currentNormal = new THREE.Vector3(); + const finalNormal = new THREE.Vector3(); + const authoredEuler = new THREE.Euler().setFromQuaternion(authoredElbow, "XYZ"); + const desiredSupinationY = (side === "left" ? 1 : -1) * Math.abs(authoredEuler.y); + + const score = (radians: number): number | null => { + twist.setFromAxisAngle(FOREARM_AXIS, radians); + candidateElbow.copy(authoredElbow).multiply(twist); + if (!quaternionIsInRom(candidateElbow, elbowId)) return null; + + elbowWorld.copy(elbowParentWorld).multiply(candidateElbow); + wristWorld.copy(elbowWorld).multiply(authoredWrist); + currentNormal.set(...PALM_LOCAL_NORMAL).applyQuaternion(wristWorld).normalize(); + correction.setFromUnitVectors(currentNormal, DOWN); + desiredWorld.copy(correction).multiply(wristWorld); + desiredLocal.copy(elbowWorld).invert().multiply(desiredWorld); + + // Simulate the exact wrist correction and hard ROM clamp used below. The + // search therefore prefers a forearm twist the real wrist can finish. + wristEuler.setFromQuaternion(desiredLocal, "XYZ"); + wristEuler.set( + THREE.MathUtils.clamp(wristEuler.x, wristRom.x.min * DEG, wristRom.x.max * DEG), + THREE.MathUtils.clamp(wristEuler.y, wristRom.y.min * DEG, wristRom.y.max * DEG), + THREE.MathUtils.clamp(wristEuler.z, wristRom.z.min * DEG, wristRom.z.max * DEG), + "XYZ", + ); + clampedWrist.setFromEuler(wristEuler); + finalWorld.copy(elbowWorld).multiply(clampedWrist); + finalNormal.set(...PALM_LOCAL_NORMAL).applyQuaternion(finalWorld).normalize(); + return finalNormal.dot(DOWN); + }; + + let bestRadians = 0; + let bestScore = score(0) ?? -Infinity; + // Most palms need only the cheap wrist correction. In particular this keeps + // plank/mountain-climber geometry unchanged and avoids a search per frame. + if (bestScore >= PALM_DOWN_TARGET_DOT) return 0; + let targetRadians: number | null = null; + let targetYDistance = Infinity; + const consider = (radians: number): void => { + const candidateScore = score(radians); + if (candidateScore === null) return; + const scoreGain = candidateScore - bestScore; + if (scoreGain > 1e-9 || (Math.abs(scoreGain) <= 1e-9 && Math.abs(radians) < Math.abs(bestRadians))) { + bestScore = candidateScore; + bestRadians = radians; + } + // A declared palm-floor contact overrides incompatible pronation with the + // corresponding anatomical supination frame. Prefer that semantic mirror + // over a mathematical maximum at the extreme edge of the ROM box. + candidateEuler.setFromQuaternion(candidateElbow, "XYZ"); + const isSupinationHalf = side === "left" ? candidateEuler.y >= -1e-6 : candidateEuler.y <= 1e-6; + const yDistance = Math.abs(candidateEuler.y - desiredSupinationY); + if (candidateScore >= PALM_DOWN_TARGET_DOT && isSupinationHalf && yDistance < targetYDistance) { + targetYDistance = yDistance; + targetRadians = radians; + } + }; + + // Coarse global search handles authored full pronation (±80°), whose + // palm-down solution can lie roughly 160° away at the opposite ROM edge. + const coarseStep = 5 * DEG; + for (let radians = -Math.PI; radians <= Math.PI + 1e-8; radians += coarseStep) { + consider(Math.min(Math.PI, radians)); + } + // Refine locally so the semantic supination mirror is not quantized to the + // coarse search step. + const coarseBest = targetRadians ?? bestRadians; + const fineStep = 0.25 * DEG; + for (let radians = coarseBest - coarseStep; radians <= coarseBest + coarseStep + 1e-8; radians += fineStep) { + consider(THREE.MathUtils.clamp(radians, -Math.PI, Math.PI)); + } + return Number.isFinite(bestScore) ? targetRadians ?? bestRadians : null; +} + +function alignPalmNormal(m: Mannequin, side: HandSide, weight: number): boolean { + const elbowId = `elbow_${side}`; + const elbow = m.bones.get(elbowId); + const bestTwist = bestPalmForearmTwist(m, side); + if (!elbow || bestTwist === null) { + return alignWristNormal(m, side, PALM_LOCAL_NORMAL, weight); + } + + const safeWeight = THREE.MathUtils.clamp(weight, 0, 1); + const weightedTwist = new THREE.Quaternion().setFromAxisAngle( + FOREARM_AXIS, + bestTwist * safeWeight, + ); + const candidate = elbow.quaternion.clone().multiply(weightedTwist); + // Both endpoints of the interpolation are legal in normal operation. Keep a + // defensive fallback for unusual imported rigs/Euler singularities without + // ever clamping the elbow in a way that could move the wrist endpoint. + if (quaternionIsInRom(candidate, elbowId)) elbow.quaternion.copy(candidate); + m.root.updateMatrixWorld(true); + return alignWristNormal(m, side, PALM_LOCAL_NORMAL, weight); +} + function collectFloorHandContacts( reaches: readonly (ReachTarget & { weight?: number })[], pins: readonly PinTarget[], @@ -114,7 +260,9 @@ function collectFloorHandContacts( /** * Orient floor contacts by their real geometry: a palm presents its flattened * +Z face, while a fist presents the wrist→knuckle (-Y) direction. The two are - * intentionally distinct, and both corrections are strict wrist-ROM-clamped. + * intentionally distinct. Palm contacts may also redistribute orientation + * into a legal forearm-axis twist; fists remain wrist-only so knuckle and grip + * semantics are unaffected. Every wrist correction is strict-ROM-clamped. */ export function alignFloorContacts( m: Mannequin, @@ -125,8 +273,9 @@ export function alignFloorContacts( const contacts = collectFloorHandContacts(reaches, pins, groundLock); let changed = false; for (const [side, contact] of contacts) { - const normal = contact.kind === "fist" ? FIST_LOCAL_NORMAL : PALM_LOCAL_NORMAL; - changed = alignWristNormal(m, side, normal, contact.weight) || changed; + changed = contact.kind === "palm" + ? alignPalmNormal(m, side, contact.weight) || changed + : alignWristNormal(m, side, FIST_LOCAL_NORMAL, contact.weight) || changed; } if (changed) m.root.updateMatrixWorld(true); } @@ -175,6 +324,25 @@ export function floorTargetForEffector( p.y = m.collision.arm; return p; } + // Fists are fixed knuckle contacts (e.g. superhero landing), not sliding + // open palms, so they deliberately retain their authored X/Z target. + const handMatch = /^(?:hand|wrist)_(left|right)$/.exec(effectorName); + if (handMatch) { + // A floor is an infinite contact plane, not a fixed X/Z landmark. Lowering + // a nearly straight arm naturally carries the hand a few centimetres away + // from the shoulder; targeting the wrist's old vertical projection makes + // safe elbow ROM miss Cobra's otherwise reachable landing point. + const shoulder = m.bones.get(`shoulder_${handMatch[1]}`); + if (shoulder) { + const shoulderPosition = shoulder.getWorldPosition(new THREE.Vector3()); + const outward = new THREE.Vector3( + p.x - shoulderPosition.x, + 0, + p.z - shoulderPosition.z, + ); + if (outward.lengthSq() > 1e-8) p.add(outward.normalize().multiplyScalar(HAND_FLOOR_OUTSET)); + } + } const box = new THREE.Box3().setFromObject(effector); p.y = Number.isFinite(box.min.y) ? Math.max(0, p.y - box.min.y) : 0; return p; @@ -424,12 +592,12 @@ export function wrapGrip(m: Mannequin, grips: readonly GripTarget[]): void { /** * Relaxed resting finger curl (radians) for an idle hand in the air. A truly - * relaxed hand is not flat: the fingers settle into a soft inward hook (~30°), + * relaxed hand is not flat: the fingers settle into a soft inward hook (~18°), * which reads as a natural cupped hand instead of a stiff splayed palm. */ -export const REST_CURL = -0.55; +export const REST_CURL = -0.32; /** Gentle thumb opposition for a relaxed hand, within adduction ROM. */ -export const REST_THUMB_OPPOSE = 0.4; +export const REST_THUMB_OPPOSE = 0.26; /** Finger bones are intentionally single-DOF; their authored rest offsets * provide natural spacing without inventing an out-of-ROM lateral rotation. */ export const REST_ADDUCT = 0; diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index 8aa747e..6ab530e 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -60,6 +60,8 @@ import { solveCCD } from "./ik.js"; const DEG = Math.PI / 180; export interface ViewerPhaseInfo { + /** Zero-based real phase index, or -1 while blending through loop reset. */ + phaseIndex: number; phaseName: string; cue?: string; } @@ -361,7 +363,7 @@ export function createViewer( let phaseCb: (info: ViewerPhaseInfo) => void = () => {}; let tickCb: (time: number, duration: number) => void = () => {}; let loopCb: () => void = () => {}; - let lastPhaseName = ""; + let lastPhaseIndex = -2; let activeSegIndex = 0; // `load()` briefly solves each real phase endpoint to seed any floor pin // introduced by the following phase from the *fully solved* prior pose. @@ -681,15 +683,7 @@ export function createViewer( if (timeline) { const info = timeline.sample(time, mannequin.bones); solvedInfo = info; - activeSegIndex = 0; - const tt = timeline.duration > 0 ? ((time % timeline.duration) + timeline.duration) % timeline.duration : 0; - for (let k = 0; k < timeline.segments.length; k++) { - const seg = timeline.segments[k]!; - if (tt >= seg.start && tt <= seg.end) { - activeSegIndex = k; - break; - } - } + activeSegIndex = info.phaseIndex >= 0 ? info.phaseIndex : 0; // Life layer rides on wall-clock time (not timeline time) so the figure // keeps breathing and blinking while paused or scrubbing. if (!precomputingAnchors) applyLife(performance.now() / 1000); @@ -812,9 +806,13 @@ export function createViewer( mannequin.root.updateMatrixWorld(true); } refreshReachResiduals(); - if (!precomputingAnchors && info.phaseName !== lastPhaseName) { - lastPhaseName = info.phaseName; - phaseCb({ phaseName: info.phaseName, ...(info.cue ? { cue: info.cue } : {}) }); + if (!precomputingAnchors && info.phaseIndex !== lastPhaseIndex) { + lastPhaseIndex = info.phaseIndex; + phaseCb({ + phaseIndex: info.phaseIndex, + phaseName: info.phaseName, + ...(info.cue ? { cue: info.cue } : {}), + }); } } if (precomputingAnchors) return; @@ -883,7 +881,7 @@ export function createViewer( lastIR = ir; timeline = buildTimeline(ir); time = 0; - lastPhaseName = ""; + lastPhaseIndex = -2; reachResiduals = []; reachResidualTargets = []; authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId)); diff --git a/packages/posecode-render/src/poses.ts b/packages/posecode-render/src/poses.ts index 3af5a87..4251494 100644 --- a/packages/posecode-render/src/poses.ts +++ b/packages/posecode-render/src/poses.ts @@ -19,6 +19,12 @@ const NEUTRAL: PoseSpec = { joints: {}, }; +/** Half-pronated forearms: palms face the thighs instead of away from them. */ +const RELAXED_FOREARMS: NonNullable = { + elbow_left: [0, -80, 0], + elbow_right: [0, 80, 0], +}; + // Face-down support position: torso horizontal, arms reaching to the floor. // Ground-lock IK plants hands and feet; this just gets the gross posture right. const PLANK: PoseSpec = { @@ -43,10 +49,7 @@ const PLANK: PoseSpec = { // Left/right Y signs mirror in the rig, matching `elbows: pronate 80`. const STANDING: PoseSpec = { root: { position: [0, 0, 0], rotationDeg: [0, 0, 0] }, - joints: { - elbow_left: [0, 80, 0], - elbow_right: [0, -80, 0], - }, + joints: { ...RELAXED_FOREARMS }, }; // Lying face-up. Rotating the standing figure -90° about X lays it on its back: @@ -69,6 +72,7 @@ const PRONE: PoseSpec = { const SEATED: PoseSpec = { root: { position: [0, 0.5, 0], rotationDeg: [0, 0, 0] }, joints: { + ...RELAXED_FOREARMS, hip_left: [-90, 0, 0], hip_right: [-90, 0, 0], }, diff --git a/packages/posecode-render/src/timeline.ts b/packages/posecode-render/src/timeline.ts index ed66850..cb70c7c 100644 --- a/packages/posecode-render/src/timeline.ts +++ b/packages/posecode-render/src/timeline.ts @@ -72,6 +72,8 @@ export interface BuiltTimeline { t: number, bones: Map, ): { + /** Zero-based real phase index, or -1 while blending through loop reset. */ + phaseIndex: number; phaseName: string; cue?: string; groundLock: string[]; @@ -390,6 +392,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline { z: hermite(a.pos.z, b.pos.z, zA, zB, span, eased), }; return { + phaseIndex: i < segments.length ? i : -1, phaseName: b.name, ...(b.cue ? { cue: b.cue } : {}), groundLock: b.groundLock, diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts index 67192fe..fb6e707 100644 --- a/packages/posecode-render/test/contacts.test.ts +++ b/packages/posecode-render/test/contacts.test.ts @@ -110,11 +110,58 @@ describe("contact geometry", () => { } }); + it("plants authored-pronated palms by twisting forearms without moving wrist targets", () => { + const m = buildMannequin(); + const wristPositions = new Map(); + for (const side of ["left", "right"] as const) { + // Cobra-like arms: flexed/externally-rotated shoulders, bent elbows, and + // full semantic pronation (mirrored in the two local rig frames). + m.bones.get(`shoulder_${side}`)!.rotation.set( + -100 * DEG, + (side === "left" ? 60 : -60) * DEG, + 0, + ); + m.bones.get(`elbow_${side}`)!.rotation.set( + -40 * DEG, + (side === "left" ? -80 : 80) * DEG, + 0, + ); + } + m.root.updateMatrixWorld(true); + for (const side of ["left", "right"] as const) { + wristPositions.set( + side, + m.bones.get(`wrist_${side}`)!.getWorldPosition(new THREE.Vector3()), + ); + } + + alignFloorContacts(m, [], [{ effector: "hands", anchor: "floor" }], []); + for (const side of ["left", "right"] as const) { + const elbow = m.bones.get(`elbow_${side}`)!; + const wrist = m.bones.get(`wrist_${side}`)!; + const normal = new THREE.Vector3(...PALM_LOCAL_NORMAL) + .applyQuaternion(wrist.getWorldQuaternion(new THREE.Quaternion())) + .normalize(); + const local = new THREE.Euler().setFromQuaternion(elbow.quaternion, "XYZ"); + // The solver uses the matching anatomical supination frame to enter a + // strong palm-down cone while preserving the solved wrist endpoint. + expect(normal.dot(down)).toBeGreaterThan(Math.cos(45.5 * DEG)); + expect(wrist.getWorldPosition(new THREE.Vector3()).distanceTo(wristPositions.get(side)!)) + .toBeLessThan(1e-7); + expect(local.x).toBeGreaterThanOrEqual(-154 * DEG - 1e-6); + expect(local.x).toBeLessThanOrEqual(10 * DEG + 1e-6); + expect(local.y).toBeGreaterThanOrEqual((side === "left" ? -84 : -92) * DEG - 1e-6); + expect(local.y).toBeLessThanOrEqual((side === "left" ? 92 : 84) * DEG + 1e-6); + expect(Math.abs(local.z)).toBeLessThan(1e-6); + } + }); + it("plants a fist on its knuckles, distinctly from a palm, without losing curl", () => { const m = buildMannequin(); m.bones.get("wrist_left")!.rotation.x = -30 * DEG; formFists(m, new Set(["left"])); const curlBefore = m.bones.get("index_left")!.quaternion.clone(); + const elbowBefore = m.bones.get("elbow_left")!.quaternion.clone(); alignFloorContacts(m, [{ effector: "fist_left", target: "floor", weight: 1 }], [], []); const wristWorld = m.bones.get("wrist_left")!.getWorldQuaternion(new THREE.Quaternion()); @@ -123,6 +170,7 @@ describe("contact geometry", () => { expect(knuckles.dot(down)).toBeGreaterThan(0.995); expect(Math.abs(palm.dot(down))).toBeLessThan(0.1); expect(m.bones.get("index_left")!.quaternion.angleTo(curlBefore)).toBeLessThan(1e-8); + expect(m.bones.get("elbow_left")!.quaternion.angleTo(elbowBefore)).toBeLessThan(1e-8); expect(m.bones.get("index_left")!.rotation.x).toBeLessThan(-1); }); @@ -209,6 +257,7 @@ describe("relaxHands (L4.1)", () => { relaxHands(m, new Set(), new Set(), new Set()); const freeCurl = m.bones.get("index_left")!.rotation.x; expect(freeCurl).toBeLessThan(-0.3); + expect(freeCurl).toBeGreaterThan(-0.4); // relaxed, not a near-fist // ...but a hand pressed to the floor (plank/push-up) lies extended. relaxHands(m, new Set(), new Set(), new Set(["left"])); expect(m.bones.get("index_left")!.rotation.x).toBeLessThan(0.1); // flat diff --git a/packages/posecode-render/test/render.test.ts b/packages/posecode-render/test/render.test.ts index aad7e5f..653011e 100644 --- a/packages/posecode-render/test/render.test.ts +++ b/packages/posecode-render/test/render.test.ts @@ -6,7 +6,7 @@ import { solveCCD } from "../src/ik.js"; import { poseFor } from "../src/poses.js"; import { buildProps } from "../src/props.js"; import { applyGroundLock, groundFigure } from "../src/groundlock.js"; -import { levelPlantedFeet, wrapGrip } from "../src/contacts.js"; +import { PALM_LOCAL_NORMAL, formFists, levelPlantedFeet, wrapGrip } from "../src/contacts.js"; import { effectorBoneId, missingReachTarget, solveReachToPoint } from "../src/reach.js"; import { parse, eulerRomFor } from "posecode-parser"; @@ -48,8 +48,31 @@ describe("mannequin", () => { describe("timeline", () => { it("starts standing poses with relaxed palms facing the thighs", () => { const joints = poseFor("standing").joints!; - expect(joints.elbow_left).toEqual([0, 80, 0]); - expect(joints.elbow_right).toEqual([0, -80, 0]); + expect(joints.elbow_left).toEqual([0, -80, 0]); + expect(joints.elbow_right).toEqual([0, 80, 0]); + + const m = buildMannequin(); + for (const [boneId, rotation] of Object.entries(joints)) { + const [x, y, z] = rotation; + m.bones.get(boneId)!.rotation.set(x * DEG, y * DEG, z * DEG); + } + m.root.updateMatrixWorld(true); + const pelvis = m.bones.get("pelvis")!.getWorldPosition(new THREE.Vector3()); + const expectPalmsInward = (): void => { + for (const side of ["left", "right"] as const) { + const wrist = m.bones.get(`wrist_${side}`)!; + const palm = new THREE.Vector3(...PALM_LOCAL_NORMAL) + .applyQuaternion(wrist.getWorldQuaternion(new THREE.Quaternion())) + .normalize(); + const towardBody = pelvis.clone() + .sub(wrist.getWorldPosition(new THREE.Vector3())) + .normalize(); + expect(palm.dot(towardBody)).toBeGreaterThan(0.8); + } + }; + expectPalmsInward(); + formFists(m, new Set(["left", "right"])); + expectPalmsInward(); // finger curl never changes palm facing }); const PUSHUP = [ diff --git a/packages/posecode-render/test/timeline-sparse.test.ts b/packages/posecode-render/test/timeline-sparse.test.ts index 587b244..7118b24 100644 --- a/packages/posecode-render/test/timeline-sparse.test.ts +++ b/packages/posecode-render/test/timeline-sparse.test.ts @@ -11,6 +11,28 @@ function eulerDegrees(node: THREE.Object3D): THREE.Euler { } describe("sparse timeline targets", () => { + it("keeps duplicate phase names distinct by timeline index", () => { + const result = parse([ + 'posecode posture "Repeated label"', + " rig humanoid", + ' step "Hold" 1s linear:', + " elbows: flex 20", + ' step "Hold" 1s linear:', + " elbows: flex 40", + ].join("\n")); + const timeline = buildTimeline(result.ir!); + const mannequin = buildMannequin(); + + expect(timeline.sample(0.5, mannequin.bones)).toMatchObject({ + phaseIndex: 0, + phaseName: "Hold", + }); + expect(timeline.sample(1.5, mannequin.bones)).toMatchObject({ + phaseIndex: 1, + phaseName: "Hold", + }); + }); + it("preserves an unauthored standing-pose forearm rotation", () => { const result = parse([ 'posecode posture "Curl"', @@ -24,7 +46,7 @@ describe("sparse timeline targets", () => { timeline.sample(1, mannequin.bones); const euler = eulerDegrees(mannequin.bones.get("elbow_left")!); expect(euler.x * DEG).toBeCloseTo(-30, 4); - expect(euler.y * DEG).toBeCloseTo(80, 4); + expect(euler.y * DEG).toBeCloseTo(-80, 4); }); it("carries flexion when a later phase authors only axial rotation", () => { @@ -41,7 +63,7 @@ describe("sparse timeline targets", () => { timeline.sample(2, mannequin.bones); const euler = eulerDegrees(mannequin.bones.get("elbow_right")!); expect(euler.x * DEG).toBeCloseTo(-60, 4); - expect(euler.y * DEG).toBeCloseTo(-35, 4); + expect(euler.y * DEG).toBeCloseTo(35, 4); }); it("defensively clamps coupled hips in manually constructed legacy IR", () => { diff --git a/playground/public/llm-guide.html b/playground/public/llm-guide.html index 7c637d9..1112abc 100644 --- a/playground/public/llm-guide.html +++ b/playground/public/llm-guide.html @@ -141,10 +141,10 @@

Grammar

cue "<short coaching cue>" repeat <count>

Joints

-

neck head spine chest pelvis and (singular or plural) shoulders elbows wrists hips knees ankles. Plural names move both sides symmetrically; use elbow_left etc. for one side. Fingers: fingers (or fingers_left / fingers_right), and individually thumb_* index_* middle_* ring_* pinky_*.

+

neck head spine chest pelvis and (singular or plural) shoulders elbows forearms wrists hips knees ankles. forearms is an anatomical alias for the two elbow bones when authoring palm roll. Plural names move both sides symmetrically; use elbow_left etc. for one side. Fingers: fingers (or fingers_left / fingers_right), and individually thumb_* index_* middle_* ring_* pinky_*.

Actions (degrees are absolute targets)

-
  • flex / extend: bend / straighten (sagittal)
  • abduct / adduct: away from / toward midline (frontal)
  • rotate-in / rotate-out: internal / external rotation of a shoulder or hip
  • twist-left / twist-right: turn an axial joint (spine, chest, neck, or head) toward the named side
  • supinate / pronate: forearm turn (palm up / down)
  • dorsiflex / plantarflex: ankle up / down
  • hinge: hip hinge (on pelvis only): tip the torso forward over the hips with a flat back, legs staying planted. Use this, not spinal flex, for a deadlift, bent-over row, good-morning, or a bow.
  • hold neutral: set every channel on that joint to its rest value (no angle)
-

Use only anatomically compatible pairs. Examples: knees take flex/extend, ankles take dorsiflex/plantarflex, elbows take flex/extend/pronate/supinate, wrists take flex/extend/abduct/adduct, and hinge belongs only to the pelvis. The validator rejects globally-known actions on the wrong joint; never use the absence of a warning as permission to invent a pairing.

+
  • flex / extend: bend / straighten (sagittal)
  • abduct / adduct: away from / toward midline (frontal)
  • rotate-in / rotate-out: internal / external rotation of a shoulder or hip
  • twist-left / twist-right: turn an axial joint (spine, chest, neck, or head) toward the named side
  • supinate / pronate: forearm roll toward palm-up / palm-down. With upright arms at the sides, forearms: pronate 80 faces the palms inward toward the thighs and pronate 0 faces them forward. Shoulder/elbow pose still affects the final world-facing direction. Since targets are absolute, pronate 0 and supinate 0 resolve to the same zero-angle reference.
  • dorsiflex / plantarflex: ankle up / down
  • hinge: hip hinge (on pelvis only): tip the torso forward over the hips with a flat back, legs staying planted. Use this, not spinal flex, for a deadlift, bent-over row, good-morning, or a bow.
  • hold neutral: set every channel on that joint to its rest value (no angle)
+

Use only anatomically compatible pairs. Examples: knees take flex/extend, ankles take dorsiflex/plantarflex, elbows/forearms take flex/extend/pronate/supinate, wrists take flex/extend/abduct/adduct, and hinge belongs only to the pelvis. The validator rejects globally-known actions on the wrong joint; never use the absence of a warning as permission to invent a pairing.

Rules

  1. Break the movement into 2–6 concurrent phases; each step is the time taken to arrive at one key pose. A duration is not a dwell.
  2. Set only the channels that change. Unset channels hold their previous value; hold neutral deliberately resets the whole joint.
  3. Stay within Posecode's configured range-of-motion bounds (for example, knee flex ≤ 144°, elbow flex ≤ 154°, shoulder flex ≤ 180°). These bounds constrain the rig; they do not certify a movement as safe. Do not stack a pelvis hinge and hip flexion past the hip's combined limit.
  4. Choose one lead/trail convention and keep it across joint targets, contacts, phase names, and cues. Mirror all four together when switching sides.
  5. Declare every load-bearing contact in every phase where it remains active. Contacts do not inherit. Use side-specific support for lunges, kneeling, and single-leg work; ground-lock: feet is for two genuinely planted feet. Ground contacts are a closed vocabulary: use ground-lock: hands, feet in a high plank, ground-lock: forearms, feet in a forearm plank, and ground-lock: back for supine floor work. Do not invent contact names.
  6. Use ground-lock only for floor supports already meant to stay planted. Use reach to move a limb to a target, pin when a single contact must move the whole body, and grip for a bar or rails. Never combine ground-lock, pin, or grip root solvers in one step; use one primary support plus per-limb reach constraints for the remaining contacts.
  7. Author the gross body position before adding reach. A hand cannot reach a floor that the torso/legs leave outside the arm's reachable workspace.
  8. Derive each cue from the actual commands. Remove phrases such as “foot forward,” “knee down,” “fist planted,” or “arm overhead” unless that exact side and constraint are encoded.
  9. Use linear only for an unchanged dwell or intentionally mechanical motion. A moving phase that arrives at a landing or hold should normally settle.
  10. Add an explicit unchanged step for a visible hold, repeating its active contacts. Then author a controlled recovery when the movement should loop.
  11. Declare a prop before using its anchor; never invent target names.
  12. Set repeat to the requested repetition count.

Before returning the document, privately run this final check:

@@ -208,6 +208,7 @@

Reaching, props, lying poses & hands

reach: hand_right ankle_right ground-lock: feet cue "Hinge and reach toward the ankles" +

A hand or fist sent to the floor is also oriented onto its palm or knuckles. That explicit surface contact can adjust forearm/wrist roll within ROM, so do not fight it with a contradictory palm-facing cue.

  • Props: prop chair | wall | bar | box | dip-bars (top level). The chair sits behind the figure (sit-to-stand, box squat), the wall behind that (wall sit), the bar overhead, the box in front (step-ups), and the dip bars either side at hip-press height (grip: hands bars + elbow flex = triceps dips).
  • Pins: pin: <effector> <anchor> moves the whole BODY so the effector sits on the anchor (vs reach, which moves just the limb). Use one primary pin for body translation: pin: foot_right box can support a step-up, and pin: pelvis floor can keep the pelvis on the mat. Use grip, not several simultaneous hand pins, for a bar or rails.
  • Grips: grip: hands bar or grip: hands bars is the dedicated two-hand prop contact. It assigns separate left/right anchors, solves both arms, and closes the fingers. Declare prop bar or prop dip-bars first. Prefer this to multiple pins for a pull-up, hang, or dip.
  • Lying / seated: pose start = supine | prone | seated for floor and mat work (glute bridge, dead bug, cobra, seated forward fold). In a supine exercise whose torso stays down, add ground-lock: back to each phase.
  • Hands: fingers: flex 80 makes a fist; curl individual fingers for shapes (index_right: flex 95). Single-DOF per finger, good for grip and rough gesture, not exact sign language.

Three-point superhero landing

A landing is defined by its contacts, not by a dramatic cue. Keep the existing front-foot support planted while reach constraints blend the rear knee and same-side fist down. Once the knee has arrived, hand the whole-body anchor to that knee and solve the foot and fist independently. Repeat the three contacts through the hold; never combine a whole-root pin with ground-lock in one step.

@@ -259,7 +260,7 @@

Three-point superhero landing

neck: hold neutral chest: hold neutral shoulders: hold neutral - elbows: hold neutral + elbows: flex 0 fingers_left: hold neutral ground-lock: feet cue "Press through both feet and return to standing" diff --git a/playground/public/moves/bent-over-row.html b/playground/public/moves/bent-over-row.html index aecef0a..ba07378 100644 --- a/playground/public/moves/bent-over-row.html +++ b/playground/public/moves/bent-over-row.html @@ -191,7 +191,7 @@

The .posecode source

ankles: dorsiflex 0 shoulders: flex 0 elbows: flex 0 - elbows: pronate 0 + elbows: pronate 80 neck: extend 0 ground-lock: feet cue "Stand up tall between sets" diff --git a/playground/public/moves/jab-cross.html b/playground/public/moves/jab-cross.html index 9bd318a..752ce7c 100644 --- a/playground/public/moves/jab-cross.html +++ b/playground/public/moves/jab-cross.html @@ -137,19 +137,19 @@

Movement phases

  1. Jab0.4s · settle - Snap the lead (left) hand straight out, rotating slightly into it + Snap the lead fist straight out with the palm down and the rear fist guarding the face
  2. Recoil jab0.4s · drive - Bring the hand back to guard + Bring the lead fist back high with both palms facing inward
  3. Cross0.45s · settle - Drive the rear (right) hand across, rotating the trunk + Drive the rear fist across with the palm down as the trunk turns
  4. Recoil cross0.45s · drive - Return to guard, hands high + Return to a high guard with both palms facing inward
@@ -162,39 +162,46 @@

The .posecode source

step "Jab" 0.4s settle: shoulder_left: flex 85 + shoulder_left: rotate-in 70 elbow_left: flex 15 - elbow_left: pronate 80 + elbow_left: pronate 84 + shoulder_right: flex 60 + elbow_right: flex 130 + elbow_right: pronate 80 spine: twist-right 10 fingers: flex 80 ground-lock: feet - cue "Snap the lead (left) hand straight out, rotating slightly into it" + cue "Snap the lead fist straight out with the palm down and the rear fist guarding the face" step "Recoil jab" 0.4s drive: - shoulder_left: flex 0 - elbow_left: flex 90 - elbow_left: pronate 0 + shoulder_left: flex 60 + shoulder_left: rotate-in 0 + elbow_left: flex 130 + elbow_left: pronate 80 spine: twist-right 0 fingers: flex 80 ground-lock: feet - cue "Bring the hand back to guard" + cue "Bring the lead fist back high with both palms facing inward" step "Cross" 0.45s settle: shoulder_right: flex 90 + shoulder_right: rotate-in 70 elbow_right: flex 10 - elbow_right: pronate 80 + elbow_right: pronate 84 spine: twist-left 35 fingers: flex 80 ground-lock: feet - cue "Drive the rear (right) hand across, rotating the trunk" + cue "Drive the rear fist across with the palm down as the trunk turns" step "Recoil cross" 0.45s drive: - shoulder_right: flex 0 - elbow_right: flex 90 - elbow_right: pronate 0 + shoulder_right: flex 60 + shoulder_right: rotate-in 0 + elbow_right: flex 130 + elbow_right: pronate 80 spine: twist-left 0 fingers: flex 80 ground-lock: feet - cue "Return to guard, hands high" + cue "Return to a high guard with both palms facing inward" repeat 4 diff --git a/playground/public/moves/quad-stretch.html b/playground/public/moves/quad-stretch.html index 1d54993..66e7198 100644 --- a/playground/public/moves/quad-stretch.html +++ b/playground/public/moves/quad-stretch.html @@ -159,6 +159,7 @@

The .posecode source

hip_right: extend 20 shoulder_right: extend 35 elbow_right: flex 20 + elbow_right: supinate 80 reach: hand_right ankle_right pin: foot_left floor cue "Turn slightly right, draw the heel toward the seat, and catch the ankle" @@ -170,6 +171,7 @@

The .posecode source

hip_right: extend 0 shoulder_right: extend 0 elbow_right: flex 0 + elbow_right: pronate 80 pin: foot_left floor cue "Release the foot and return to standing" diff --git a/playground/public/moves/superhero-landing.html b/playground/public/moves/superhero-landing.html index 3fb1a0d..0a5affa 100644 --- a/playground/public/moves/superhero-landing.html +++ b/playground/public/moves/superhero-landing.html @@ -173,6 +173,7 @@

The .posecode source

shoulder_left: flex 98 shoulder_left: abduct 2 elbow_left: flex 8 + elbow_left: supinate 80 fingers_left: flex 80 shoulder_right: extend 24 shoulder_right: abduct 22 @@ -207,10 +208,11 @@

The .posecode source

neck: hold neutral chest: hold neutral shoulder_left: hold neutral - elbow_left: hold neutral + elbow_left: flex 0 fingers_left: hold neutral shoulder_right: hold neutral - elbow_right: hold neutral + elbow_right: flex 0 + forearms: pronate 80 ground-lock: feet cue "Press through both feet and return to standing" diff --git a/playground/public/spec.html b/playground/public/spec.html index 7a23679..f42c78c 100644 --- a/playground/public/spec.html +++ b/playground/public/spec.html @@ -155,6 +155,7 @@

1. Grammar

DURATION = NUMBER "s" ; (* e.g. 2s, 1.5s *)

A step is one phase of the movement. Phases run in sequence; within a phase, all joint targets apply concurrently.

The header kind, rig, props, start poses, joints, actions, effectors, targets, and timing modes are closed vocabularies. Unknown values are errors; the parser does not accept a plausible-looking word and leave it for the renderer to ignore.

+

Contact target vocabularies are capability-specific. reach accepts floor, a rig body landmark, or an anchor from a declared prop. pin accepts only fixed world anchors (floor or a declared prop anchor), because translating the root cannot pin one body landmark to another landmark that moves with that same root. grip accepts only anchors supplied by a declared bar or dip-bars prop. Grouped grip: hands ... uses the bare bar / bars anchor and expands to separate left/right anchors; an explicitly sided grip anchor is valid only with the matching single-hand effector.

Ground locks accept the groups hands, forearms, and feet, the axial surface contact back, plus the single-side forms hand_left|hand_right, elbow_left|elbow_right, and foot_left|foot_right. Human-readable left foot, right foot, left hand, and related forms normalize to the canonical side-specific names.

Timing modes describe how motion crosses the phase boundary:

@@ -170,6 +171,7 @@

2. Joints

ModeUse
+ @@ -186,7 +188,7 @@

3. Actions

- + @@ -194,6 +196,7 @@

3. Actions

hinge is a hip hinge: applied to the pelvis, it pivots the torso forward over the hip line while the legs stay planted and vertical (the renderer counter-rotates the hips). Use it, not spinal flex, for a flat-back forward bend: deadlift, bent-over row, good-morning, or a bow.

rotate-in / rotate-out describe internal and external rotation only on a shoulder or hip. Use twist-left / twist-right for axial bones; older axial uses of rotate-in/out remain readable during the compatibility window but emit an authoring hint.

Each authored action changes one Euler channel. Other channels on that joint carry forward from the prior phase. hold neutral is the deliberate exception: it resets all three channels, so it is suitable for an explicit recovery.

+

Forearm roll is authored on elbows or its anatomical alias forearms because the wrist itself does not pronate. With upright arms at the sides, forearms: pronate 80 faces the palms inward toward the thighs; pronate 0 faces them forward. The final world-facing direction also depends on shoulder and elbow pose, so use supinate / pronate as anatomical rotation rather than as an absolute world-space palm constraint. Because targets are absolute, pronate 0 and supinate 0 name the same zero-angle reference; the action name selects the direction only when the authored magnitude is greater than zero.

Coordinate convention: rest pose is standing, arms at sides, facing +Z. The renderer's mannequin is built in this same convention so the parser's resolved Euler angles apply directly.


4. Configured range-of-motion limits

@@ -214,7 +217,7 @@

4. Configured range-of-motion limits

A pelvis hinge counter-rotates the hips in the renderer. The validator therefore also checks the combined hinge + carried hip-flexion result and clamps the newly authored channel when their composed local hip angle would exceed 135°.


5. Rendering model

-
  1. Forward kinematics: each phase sets joint angles; the renderer uses C1-continuous quaternion splines between keyframes, shaped by the destination phase's timing mode.
  2. Grounding: the figure is dropped so its lowest point rests on the floor (a bounding-box drop), which grounds standing, plank, and the lying/seated poses alike.
  3. Ground-lock: contacts listed in ground-lock (hands, forearms, feet, or the per-side aliases hand_left|hand_right, elbow_left|elbow_right, foot_left|foot_right) stay planted while the body moves. back holds the pelvis-to-ribcage surface on the floor for supine work such as dead bugs. Unsupported contact names are line-anchored validation errors.
  4. Reach-IK: a reach: line drives an effector (hand_*, fist_*, elbow_*, knee_*, or foot_*, plus their supported groups) to a world target via Cyclic Coordinate Descent (CCD) over the arm/leg chain. A target is a body landmark bone (e.g. ankle_left), the keyword floor, or a prop anchor (bar, seat, wall). The solve is ROM-constrained: each iteration clamps every chain joint into its §4 configured range-of-motion limits (expressed as a per-axis box in the bone's local Euler frame), so a reach toward an unreachable target settles on the closest pose available within that configured joint-angle box; solved angles obey the same limits as authored ones. The viewer records a post-solve residual for every active reach. A reach target is not reported as reached merely because its syntax parsed: missing, unsupported, and geometrically unreachable reach targets remain explicit diagnostics.
  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, or grip targets. Selected prop surfaces declare sampled blocking faces (the wall's surface, the chair's backrest and seat edge, the box's near face). A bounded contact pass reduces penetration, 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: <effector> <anchor> translates the whole figure so one primary effector sits on the anchor. Where ground-lock preserves an already planted floor support and reach moves a limb to a target, a pin moves the body. Typical uses include pin: knee_left floor, pin: foot_right box, and pin: pelvis floor. A phase accepts one pin because each pin translates the same floating root; express additional simultaneous contacts with independent reach constraints. Use grip instead of hand pins for a two-handed bar or rail contact.
  7. Grips: grip: hands bar|bars is the dedicated two-hand contact for an overhead bar or dip rails; side-specific hand_left / hand_right forms are also available. A grip resolves independent left/right anchors, uses arm IK for each hand, orients the terminal contact, and closes the fingers. The matching prop must be declared. Use grips, rather than hand pins, for hangs, pull-ups, and dips.
  8. Spatial choreography: turn: <deg> rotates the figure's facing (yaw about vertical) and travel: <x> <z> moves it across the floor (world metres from the load spot). Both are absolute targets carried across phases (like joint angles) and both return home on the loop wrap, so a box-step traces a square back to start and a pirouette spins a full turn. They layer under grounding (feet still rest on the floor) and power pirouettes, grapevines, traveling combos, and walk cycles. Standing poses only: combining with lying/seated bases (whose root is already tilted) is out of scope.
  9. Looping: the timeline loops base → phases → base; repeat is the rep count surfaced to the UI.
+
  1. Forward kinematics: each phase sets joint angles; the renderer uses C1-continuous quaternion splines between keyframes, shaped by the destination phase's timing mode.
  2. Grounding: the figure is dropped so its lowest point rests on the floor (a bounding-box drop), which grounds standing, plank, and the lying/seated poses alike.
  3. Ground-lock: contacts listed in ground-lock (hands, forearms, feet, or the per-side aliases hand_left|hand_right, elbow_left|elbow_right, foot_left|foot_right) stay planted while the body moves. back holds the pelvis-to-ribcage surface on the floor for supine work such as dead bugs. Unsupported contact names are line-anchored validation errors.
  4. Reach-IK: a reach: line drives an effector (hand_*, fist_*, elbow_*, knee_*, or foot_*, plus their supported groups) to a world target via Cyclic Coordinate Descent (CCD) over the arm/leg chain. A target is a body landmark bone (e.g. ankle_left), the keyword floor, or a prop anchor (bar, seat, wall). The solve is ROM-constrained: each iteration clamps every chain joint into its §4 configured range-of-motion limits (expressed as a per-axis box in the bone's local Euler frame), so a reach toward an unreachable target settles on the closest pose available within that configured joint-angle box; solved angles obey the same limits as authored ones. The viewer records a post-solve residual for every active reach. A reach target is not reported as reached merely because its syntax parsed: missing, unsupported, and geometrically unreachable reach targets remain explicit diagnostics. A palm or fist declared against the floor also presents its matching contact surface to the floor. For a palm, the solver may redistribute incompatible authored roll into a legal forearm/wrist frame; the explicit floor contact takes priority, and every adjusted joint remains inside the same configured ROM.
  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, or grip targets. Selected prop surfaces declare sampled blocking faces (the wall's surface, the chair's backrest and seat edge, the box's near face). A bounded contact pass reduces penetration, 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: <effector> <anchor> translates the whole figure so one primary effector sits on the anchor. Where ground-lock preserves an already planted floor support and reach moves a limb to a target, a pin moves the body. Typical uses include pin: knee_left floor, pin: foot_right box, and pin: pelvis floor. A phase accepts one pin because each pin translates the same floating root; express additional simultaneous contacts with independent reach constraints. Use grip instead of hand pins for a two-handed bar or rail contact.
  7. Grips: grip: hands bar|bars is the dedicated two-hand contact for an overhead bar or dip rails; side-specific hand_left / hand_right forms are also available. A grip resolves independent left/right anchors, uses arm IK for each hand, orients the terminal contact, and closes the fingers. The matching prop must be declared. Use grips, rather than hand pins, for hangs, pull-ups, and dips.
  8. Spatial choreography: turn: <deg> rotates the figure's facing (yaw about vertical) and travel: <x> <z> moves it across the floor (world metres from the load spot). Both are absolute targets carried across phases (like joint angles) and both return home on the loop wrap, so a box-step traces a square back to start and a pirouette spins a full turn. They layer under grounding (feet still rest on the floor) and power pirouettes, grapevines, traveling combos, and walk cycles. Standing poses only: combining with lying/seated bases (whose root is already tilted) is out of scope.
  9. Looping: the timeline loops base → phases → base; repeat is the rep count surfaced to the UI.

When a mocap clip is active, the renderer selects the take containing the most actual bone motion (rather than blindly choosing the longest embedded take), retargets and blends it, then restores solved terminal contacts on the visible character. Mocap therefore cannot overwrite a planted sole or active grip.

Start poses: neutral, standing, plank, supine (face-up), prone (face-down), seated (long-sit on the floor).

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. Selected limb-vs-body and body-vs-prop penetrations are reduced with sampled, bounded correction passes; this is not comprehensive collision detection or a physics simulation. Two-person/dual-IK and figure-vs-figure collision remain deferred (research §5.2, §6.2).

diff --git a/playground/src/main.ts b/playground/src/main.ts index 52eba58..2e0a2f4 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -60,9 +60,9 @@ let viewer: Viewer | null = null; let scrubbing = false; let repeat = 1; let rep = 1; -// Maps each phase name → the 1-based line range of its `step` block, so the +// Maps each phase index → the 1-based line range of its `step` block, so the // editor can highlight the lines driving the currently-animating phase. -let phaseRanges = new Map(); +let phaseRanges: Array<{ from: number; to: number }> = []; let lastParseErrors: ParseError[] = []; let lastRomWarnings: Warning[] = []; let lastContactSignature = ""; @@ -101,12 +101,12 @@ function paintScrub(): void { /** Wire the viewer's playback callbacks. Runs once, after the renderer loads. */ function wireViewer(v: Viewer): void { - v.onPhase(({ phaseName, cue }) => { + v.onPhase(({ phaseIndex, phaseName, cue }) => { phaseEl.textContent = phaseName === "reset" ? "" : phaseName; cueEl.textContent = cue ?? ""; - highlightChip(phaseName); + highlightChip(phaseIndex); // Light up the step block driving this phase (cleared between loops / on reset). - const range = phaseName === "reset" ? undefined : phaseRanges.get(phaseName); + const range = phaseIndex < 0 ? undefined : phaseRanges[phaseIndex]; editorApi?.highlightPhase(range ? range.from : null, range?.to); refreshContactDiagnostics(true); }); @@ -137,11 +137,12 @@ function buildRibbonAndMarkers(): void { ribbon.innerHTML = ""; markers.innerHTML = ""; if (!tl) return; - for (const seg of tl.segments) { + for (const [index, seg] of tl.segments.entries()) { const chip = document.createElement("button"); + chip.type = "button"; chip.className = "chip"; chip.textContent = seg.name; - chip.dataset.name = seg.name; + chip.dataset.index = String(index); chip.title = seg.cue ?? ""; chip.addEventListener("click", () => viewer?.seek(seg.start + 1e-3)); ribbon.append(chip); @@ -155,9 +156,29 @@ function buildRibbonAndMarkers(): void { } } -function highlightChip(name: string): void { +function highlightChip(index: number): void { + let active: HTMLElement | null = null; for (const el of ribbon.querySelectorAll(".chip")) { - el.classList.toggle("active", el.dataset.name === name); + const selected = Number(el.dataset.index) === index; + el.classList.toggle("active", selected); + if (selected) el.setAttribute("aria-current", "step"); + else el.removeAttribute("aria-current"); + if (selected) active = el; + } + if (active && ribbon.scrollWidth > ribbon.clientWidth) { + const gutter = 12; + const visibleStart = ribbon.scrollLeft + gutter; + const visibleEnd = ribbon.scrollLeft + ribbon.clientWidth - gutter; + const chipStart = active.offsetLeft; + const chipEnd = chipStart + active.offsetWidth; + let left: number | null = null; + if (chipStart < visibleStart) left = chipStart - gutter; + else if (chipEnd > visibleEnd) left = chipEnd - ribbon.clientWidth + gutter; + if (left === null) return; + ribbon.scrollTo({ + left: Math.max(0, left), + behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", + }); } } @@ -168,8 +189,8 @@ function highlightChip(name: string): void { */ function computePhaseRanges( text: string, - segmentNames: string[], -): Map { + segmentCount: number, +): Array<{ from: number; to: number }> { const lines = text.split(/\r?\n/); const stepLines: number[] = []; const boundaries: number[] = []; @@ -178,13 +199,13 @@ function computePhaseRanges( if (isStep) stepLines.push(i + 1); if (isStep || /^\s*repeat\b/.test(lines[i]!)) boundaries.push(i + 1); } - const ranges = new Map(); - for (let s = 0; s < stepLines.length && s < segmentNames.length; s++) { + const ranges: Array<{ from: number; to: number }> = []; + for (let s = 0; s < stepLines.length && s < segmentCount; s++) { const from = stepLines[s]!; const next = boundaries.find((b) => b > from); let to = next ? next - 1 : lines.length; while (to > from && lines[to - 1]!.trim() === "") to--; - ranges.set(segmentNames[s]!, { from, to }); + ranges.push({ from, to }); } return ranges; } @@ -242,7 +263,7 @@ function recompile(): void { buildRibbonAndMarkers(); phaseRanges = computePhaseRanges( ed.getValue(), - (tl?.segments ?? []).map((s) => s.name), + tl?.segments.length ?? 0, ); ed.highlightPhase(null); // next onPhase paints the active block } diff --git a/playground/src/style.css b/playground/src/style.css index c43ceea..8782f4b 100644 --- a/playground/src/style.css +++ b/playground/src/style.css @@ -559,15 +559,17 @@ select:hover { /* --- Viewer pane ---------------------------------------------------------- */ .viewer-pane { position: relative; - display: flex; - flex-direction: column; + display: grid; + grid-template-rows: minmax(0, 1fr) auto auto; min-height: 0; + overflow: hidden; background: var(--bg-viewer); } #canvas { - flex: 1; width: 100%; + height: 100%; display: block; + min-width: 0; min-height: 0; } /* Top vignette so HUD text stays legible over bright poses */ @@ -635,12 +637,24 @@ select:hover { /* --- Phase ribbon --------------------------------------------------------- */ .ribbon { + position: relative; display: flex; gap: 7px; padding: 12px 16px 4px; - flex-wrap: wrap; + flex: 0 0 auto; + flex-wrap: nowrap; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} +.ribbon::-webkit-scrollbar { + display: none; } .ribbon .chip { + flex: 0 0 auto; font-family: var(--sans); font-size: 12px; font-weight: 600; @@ -653,6 +667,7 @@ select:hover { display: inline-flex; align-items: center; gap: 7px; + white-space: nowrap; transition: color 0.15s var(--ease), border-color 0.15s var(--ease), @@ -1269,6 +1284,10 @@ select:hover { /* --- Responsive ----------------------------------------------------------- */ @media (max-width: 860px) { + body { + height: 100dvh; + } + /* Topbar: brand on its own line; below it one row with the Movement button plus the two icon actions, then the primary CTA. Two dropdown-free rows instead of the old three rows of selects. */ @@ -1416,6 +1435,7 @@ select:hover { .ribbon { gap: 0; padding: 0 16px; border-top: 1px solid var(--border); } .ribbon .chip { border: 0; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 10px 14px; } .ribbon .chip:first-child { border-left: 1px solid var(--border); } +.ribbon .chip:focus-visible { outline-offset: -3px; } .ribbon .chip::before { border-radius: 0; } .ribbon .chip:hover { background: var(--panel-2); } .ribbon .chip.active { background: var(--text); color: var(--bg); border-color: var(--border); box-shadow: none; } diff --git a/playground/test/mobile-viewer-layout.test.ts b/playground/test/mobile-viewer-layout.test.ts new file mode 100644 index 0000000..80ca4b9 --- /dev/null +++ b/playground/test/mobile-viewer-layout.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const css = readFileSync( + resolve(import.meta.dirname, "../src/style.css"), + "utf8", +); + +function declarations(selector: string): string[] { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return [...css.matchAll(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`, "g"))] + .map((match) => match[1] ?? ""); +} + +describe("mobile viewer layout", () => { + it("keeps phase count from consuming the canvas height", () => { + const viewerRules = declarations(".viewer-pane").join("\n"); + const ribbonRules = declarations(".ribbon").join("\n"); + const chipRules = declarations(".ribbon .chip").join("\n"); + + expect(viewerRules).toMatch(/display:\s*grid/); + expect(viewerRules).toMatch(/grid-template-rows:\s*minmax\(0,\s*1fr\)\s+auto\s+auto/); + expect(ribbonRules).toMatch(/flex-wrap:\s*nowrap/); + expect(ribbonRules).toMatch(/overflow-x:\s*auto/); + expect(ribbonRules).toMatch(/scrollbar-width:\s*none/); + expect(ribbonRules).not.toMatch(/flex-wrap:\s*wrap(?:\s|;|$)/); + expect(chipRules).toMatch(/flex:\s*0\s+0\s+auto/); + }); +}); diff --git a/spec/SPEC.md b/spec/SPEC.md index 78cd9e4..54ccba8 100644 --- a/spec/SPEC.md +++ b/spec/SPEC.md @@ -57,6 +57,15 @@ The header kind, rig, props, start poses, joints, actions, effectors, targets, and timing modes are closed vocabularies. Unknown values are errors; the parser does not accept a plausible-looking word and leave it for the renderer to ignore. +Contact target vocabularies are capability-specific. `reach` accepts `floor`, a +rig body landmark, or an anchor from a declared prop. `pin` accepts only fixed +world anchors (`floor` or a declared prop anchor), because translating the root +cannot pin one body landmark to another landmark that moves with that same root. +`grip` accepts only anchors supplied by a declared `bar` or `dip-bars` prop. +Grouped `grip: hands ...` uses the bare `bar` / `bars` anchor and expands to +separate left/right anchors; an explicitly sided grip anchor is valid only with +the matching single-hand effector. + Ground locks accept the groups `hands`, `forearms`, and `feet`, the axial surface contact `back`, plus the single-side forms `hand_left|hand_right`, `elbow_left|elbow_right`, and `foot_left|foot_right`. Human-readable `left foot`, @@ -85,6 +94,7 @@ New documents should use the canonical v0.2 names. | --- | --- | --- | | `shoulders` | `shoulder_left`, `shoulder_right` | yes | | `elbows` | `elbow_left`, `elbow_right` | yes | +| `forearms` (alias) | `elbow_left`, `elbow_right` | use `elbow_left` / `elbow_right` | | `wrists` | `wrist_left`, `wrist_right` | yes | | `hips` | `hip_left`, `hip_right` | yes | | `knees` | `knee_left`, `knee_right` | yes | @@ -110,7 +120,7 @@ where the previous phase left it). | `abduct` / `adduct` | Z (frontal) | away from / toward midline | | `rotate-in` / `rotate-out` | Y (longitudinal) | internal / external rotation | | `twist-left` / `twist-right` | Y (longitudinal) | unambiguous axial turn for spine, chest, neck, or head | -| `supinate` / `pronate` | Y | forearm turn | +| `supinate` / `pronate` | Y | forearm roll toward palm-up / palm-down | | `dorsiflex` / `plantarflex` | X | ankle up / down | | `hinge` | X | tip the torso forward over the hips (`pelvis` only) | | `hold neutral` | all | reset every channel on the named joint to rest | @@ -129,6 +139,15 @@ Each authored action changes one Euler channel. Other channels on that joint carry forward from the prior phase. `hold neutral` is the deliberate exception: it resets all three channels, so it is suitable for an explicit recovery. +Forearm roll is authored on `elbows` or its anatomical alias `forearms` because +the wrist itself does not pronate. With upright arms at the sides, +`forearms: pronate 80` faces the palms inward toward the thighs; `pronate 0` +faces them forward. The final world-facing direction also depends on shoulder +and elbow pose, so use `supinate` / `pronate` as anatomical rotation rather than +as an absolute world-space palm constraint. Because targets are absolute, +`pronate 0` and `supinate 0` name the same zero-angle reference; the action name +selects the direction only when the authored magnitude is greater than zero. + **Coordinate convention:** rest pose is standing, arms at sides, facing +Z. The renderer's mannequin is built in this same convention so the parser's resolved Euler angles apply directly. @@ -192,7 +211,11 @@ newly authored channel when their composed local hip angle would exceed 135°. authored ones. The viewer records a post-solve residual for every active reach. A reach target is not reported as reached merely because its syntax parsed: missing, unsupported, and geometrically unreachable reach targets - remain explicit diagnostics. + remain explicit diagnostics. A palm or fist declared against the floor also + presents its matching contact surface to the floor. For a palm, the solver + may redistribute incompatible authored roll into a legal forearm/wrist frame; + the explicit floor contact takes priority, and every adjusted joint remains + inside the same configured ROM. 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`, diff --git a/spec/examples/bent-over-row.posecode b/spec/examples/bent-over-row.posecode index ca9a04f..63fcdc5 100644 --- a/spec/examples/bent-over-row.posecode +++ b/spec/examples/bent-over-row.posecode @@ -33,7 +33,7 @@ posecode exercise "Bent-over row" ankles: dorsiflex 0 shoulders: flex 0 elbows: flex 0 - elbows: pronate 0 + elbows: pronate 80 neck: extend 0 ground-lock: feet cue "Stand up tall between sets" diff --git a/spec/examples/biceps-curl.posecode b/spec/examples/biceps-curl.posecode index 1d86ab2..76461d5 100644 --- a/spec/examples/biceps-curl.posecode +++ b/spec/examples/biceps-curl.posecode @@ -4,12 +4,12 @@ posecode exercise "Biceps curl" step "Curl" 1.1s settle: elbows: flex 135 - elbows: supinate 80 + forearms: pronate 0 cue "Curl up, keep the elbows tucked at your sides" step "Lower" 1.4s settle: elbows: flex 15 - elbows: supinate 80 + forearms: pronate 0 cue "Lower under control: don't swing" repeat 10 diff --git a/spec/examples/cobra.posecode b/spec/examples/cobra.posecode index c406b29..c73125f 100644 --- a/spec/examples/cobra.posecode +++ b/spec/examples/cobra.posecode @@ -9,7 +9,7 @@ posecode stretch "Cobra" shoulders: flex 100 shoulders: rotate-out 60 elbows: flex 40 - elbows: pronate 80 + forearms: pronate 80 pin: pelvis floor reach: feet floor reach: hands floor @@ -22,7 +22,7 @@ posecode stretch "Cobra" shoulders: flex 0 shoulders: rotate-out 0 elbows: flex 0 - elbows: pronate 80 + forearms: pronate 80 pin: pelvis floor reach: feet floor cue "Lower the chest back to the floor with control" diff --git a/spec/examples/dead-hang.posecode b/spec/examples/dead-hang.posecode index 31992dc..03d2543 100644 --- a/spec/examples/dead-hang.posecode +++ b/spec/examples/dead-hang.posecode @@ -20,7 +20,7 @@ posecode exercise "Dead hang" step "Down" 1.5s settle: shoulders: flex 0 elbows: flex 0 - elbows: pronate 0 + elbows: pronate 80 fingers: flex 0 ground-lock: feet cue "Drop down off the bar and shake out the arms" diff --git a/spec/examples/elbow-flexion-pronation.posecode b/spec/examples/elbow-flexion-pronation.posecode index 2a47192..f47af13 100644 --- a/spec/examples/elbow-flexion-pronation.posecode +++ b/spec/examples/elbow-flexion-pronation.posecode @@ -2,14 +2,14 @@ posecode stretch "Elbow flexion & forearm rotation (ROM demo)" rig humanoid pose start = standing - step "Flex & supinate" 2.5s flow: + step "Flex with palms up" 2.5s flow: elbows: flex 140 - elbows: supinate 80 - cue "Bend the elbows and turn the palms up: flexion with supination" + forearms: pronate 0 + cue "Bend the elbows and reduce pronation until the palms face up" step "Extend & pronate" 2.5s settle: elbows: flex 10 elbows: pronate 80 - cue "Straighten and turn the palms down: extension with pronation" + cue "Straighten and turn the palms inward toward the thighs with pronation" repeat 4 diff --git a/spec/examples/hanging-knee-raise.posecode b/spec/examples/hanging-knee-raise.posecode index 9d41cf5..1475250 100644 --- a/spec/examples/hanging-knee-raise.posecode +++ b/spec/examples/hanging-knee-raise.posecode @@ -41,7 +41,7 @@ posecode exercise "Hanging knee raise" step "Release" 0.8s settle: shoulders: flex 0 elbows: flex 0 - elbows: pronate 0 + elbows: pronate 80 fingers: flex 0 ground-lock: feet cue "Drop off the bar and rest" diff --git a/spec/examples/jab-cross.posecode b/spec/examples/jab-cross.posecode index 9c506ab..3ca00b3 100644 --- a/spec/examples/jab-cross.posecode +++ b/spec/examples/jab-cross.posecode @@ -4,38 +4,45 @@ posecode exercise "Jab-cross" step "Jab" 0.4s settle: shoulder_left: flex 85 + shoulder_left: rotate-in 70 elbow_left: flex 15 - elbow_left: pronate 80 + elbow_left: pronate 84 + shoulder_right: flex 60 + elbow_right: flex 130 + elbow_right: pronate 80 spine: twist-right 10 fingers: flex 80 ground-lock: feet - cue "Snap the lead (left) hand straight out, rotating slightly into it" + cue "Snap the lead fist straight out with the palm down and the rear fist guarding the face" step "Recoil jab" 0.4s drive: - shoulder_left: flex 0 - elbow_left: flex 90 - elbow_left: pronate 0 + shoulder_left: flex 60 + shoulder_left: rotate-in 0 + elbow_left: flex 130 + elbow_left: pronate 80 spine: twist-right 0 fingers: flex 80 ground-lock: feet - cue "Bring the hand back to guard" + cue "Bring the lead fist back high with both palms facing inward" step "Cross" 0.45s settle: shoulder_right: flex 90 + shoulder_right: rotate-in 70 elbow_right: flex 10 - elbow_right: pronate 80 + elbow_right: pronate 84 spine: twist-left 35 fingers: flex 80 ground-lock: feet - cue "Drive the rear (right) hand across, rotating the trunk" + cue "Drive the rear fist across with the palm down as the trunk turns" step "Recoil cross" 0.45s drive: - shoulder_right: flex 0 - elbow_right: flex 90 - elbow_right: pronate 0 + shoulder_right: flex 60 + shoulder_right: rotate-in 0 + elbow_right: flex 130 + elbow_right: pronate 80 spine: twist-left 0 fingers: flex 80 ground-lock: feet - cue "Return to guard, hands high" + cue "Return to a high guard with both palms facing inward" repeat 4 diff --git a/spec/examples/pull-up.posecode b/spec/examples/pull-up.posecode index 0131cd2..77f8d80 100644 --- a/spec/examples/pull-up.posecode +++ b/spec/examples/pull-up.posecode @@ -40,7 +40,7 @@ posecode exercise "Pull-up" step "Release" 0.8s settle: shoulders: flex 0 elbows: flex 0 - elbows: pronate 0 + elbows: pronate 80 fingers: flex 0 ground-lock: feet cue "Drop off the bar and rest" diff --git a/spec/examples/standing-quad-stretch.posecode b/spec/examples/standing-quad-stretch.posecode index d45f3c3..d242383 100644 --- a/spec/examples/standing-quad-stretch.posecode +++ b/spec/examples/standing-quad-stretch.posecode @@ -9,6 +9,7 @@ posecode stretch "Standing quad stretch" hip_right: extend 20 shoulder_right: extend 35 elbow_right: flex 20 + elbow_right: supinate 80 reach: hand_right ankle_right pin: foot_left floor cue "Turn slightly right, draw the heel toward the seat, and catch the ankle" @@ -20,6 +21,7 @@ posecode stretch "Standing quad stretch" hip_right: extend 0 shoulder_right: extend 0 elbow_right: flex 0 + elbow_right: pronate 80 pin: foot_left floor cue "Release the foot and return to standing" diff --git a/spec/examples/superhero-landing.posecode b/spec/examples/superhero-landing.posecode index f185364..8a930b2 100644 --- a/spec/examples/superhero-landing.posecode +++ b/spec/examples/superhero-landing.posecode @@ -15,6 +15,7 @@ posecode posture "Superhero Three-Point Landing" shoulder_left: flex 98 shoulder_left: abduct 2 elbow_left: flex 8 + elbow_left: supinate 80 fingers_left: flex 80 shoulder_right: extend 24 shoulder_right: abduct 22 @@ -49,10 +50,11 @@ posecode posture "Superhero Three-Point Landing" neck: hold neutral chest: hold neutral shoulder_left: hold neutral - elbow_left: hold neutral + elbow_left: flex 0 fingers_left: hold neutral shoulder_right: hold neutral - elbow_right: hold neutral + elbow_right: flex 0 + forearms: pronate 80 ground-lock: feet cue "Press through both feet and return to standing" diff --git a/spec/examples/triceps-dips.posecode b/spec/examples/triceps-dips.posecode index 282fc78..02f5f19 100644 --- a/spec/examples/triceps-dips.posecode +++ b/spec/examples/triceps-dips.posecode @@ -38,7 +38,7 @@ posecode exercise "Triceps dips" step "Dismount" 0.8s settle: shoulders: abduct 0 elbows: flex 0 - elbows: pronate 0 + elbows: pronate 80 fingers: flex 0 knees: flex 0 ankles: plantarflex 0 diff --git a/spec/llm-authoring.md b/spec/llm-authoring.md index 9544b41..4ae555b 100644 --- a/spec/llm-authoring.md +++ b/spec/llm-authoring.md @@ -43,7 +43,8 @@ posecode "" # kind = exercise | stretch | posture ## Joints `neck head spine chest pelvis` and (singular or plural) `shoulders elbows -wrists hips knees ankles`. Plural names move both sides symmetrically; use +forearms wrists hips knees ankles`. `forearms` is an anatomical alias for the +two elbow bones when authoring palm roll. Plural names move both sides symmetrically; use `elbow_left` etc. for one side. Fingers: `fingers` (or `fingers_left` / `fingers_right`), and individually `thumb_* index_* middle_* ring_* pinky_*`. @@ -54,7 +55,11 @@ wrists hips knees ankles`. Plural names move both sides symmetrically; use - `rotate-in` / `rotate-out`: internal / external rotation of a shoulder or hip - `twist-left` / `twist-right`: turn an axial joint (spine, chest, neck, or head) toward the named side -- `supinate` / `pronate`: forearm turn (palm up / down) +- `supinate` / `pronate`: forearm roll toward palm-up / palm-down. With upright + arms at the sides, `forearms: pronate 80` faces the palms inward toward the + thighs and `pronate 0` faces them forward. Shoulder/elbow pose still affects + the final world-facing direction. Since targets are absolute, `pronate 0` + and `supinate 0` resolve to the same zero-angle reference. - `dorsiflex` / `plantarflex`: ankle up / down - `hinge`: **hip hinge** (on `pelvis` only): tip the torso forward over the hips with a flat back, legs staying planted. Use this, not spinal `flex`, @@ -62,7 +67,7 @@ wrists hips knees ankles`. Plural names move both sides symmetrically; use - `hold neutral`: set every channel on that joint to its rest value (no angle) Use only anatomically compatible pairs. Examples: knees take flex/extend, ankles -take dorsiflex/plantarflex, elbows take flex/extend/pronate/supinate, wrists take +take dorsiflex/plantarflex, elbows/forearms take flex/extend/pronate/supinate, wrists take flex/extend/abduct/adduct, and `hinge` belongs only to the pelvis. The validator rejects globally-known actions on the wrong joint; never use the absence of a warning as permission to invent a pairing. @@ -207,6 +212,10 @@ posecode exercise "Body-weight hip hinge" cue "Hinge and reach toward the ankles" ``` + A hand or fist sent to the floor is also oriented onto its palm or knuckles. + That explicit surface contact can adjust forearm/wrist roll within ROM, so do + not fight it with a contradictory palm-facing cue. + - **Props**: `prop chair | wall | bar | box | dip-bars` (top level). The chair sits behind the figure (sit-to-stand, box squat), the wall behind that (wall sit), the bar overhead, the box in front (step-ups), and the dip bars either @@ -284,7 +293,7 @@ posecode posture "Superhero Three-Point Landing" neck: hold neutral chest: hold neutral shoulders: hold neutral - elbows: hold neutral + elbows: flex 0 fingers_left: hold neutral ground-lock: feet cue "Press through both feet and return to standing"
Group (plural)BonesSingular forms
shouldersshoulder_left, shoulder_rightyes
elbowselbow_left, elbow_rightyes
forearms (alias)elbow_left, elbow_rightuse elbow_left / elbow_right
wristswrist_left, wrist_rightyes
hipship_left, hip_rightyes
kneesknee_left, knee_rightyes
abduct / adductZ (frontal)away from / toward midline
rotate-in / rotate-outY (longitudinal)internal / external rotation
twist-left / twist-rightY (longitudinal)unambiguous axial turn for spine, chest, neck, or head
supinate / pronateYforearm turn
supinate / pronateYforearm roll toward palm-up / palm-down
dorsiflex / plantarflexXankle up / down
hingeXtip the torso forward over the hips (pelvis only)
hold neutralallreset every channel on the named joint to rest