diff --git a/CHANGELOG.md b/CHANGELOG.md index a226f51..a6a9c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Realistic human figure**: the playground, landing hero, and `` embeds now render a fully rigged, textured human character (hands with articulated fingers, sneakers, face) instead of the procedural capsule mannequin. All solving (FK, ground-lock, pins, reach-IK) still runs on the driver skeleton, rebuilt to the character's exact proportions and retargeted bone-for-bone every frame; the procedural figure remains as an automatic fallback (and via `?figure=classic` / `character="off"`). +- **Self-collision resolution**: a capsule-based de-penetration pass keeps forearms/hands out of the torso, head, and legs (and shins out of each other), clamped to healthy ROM, so limbs no longer pass through the body mid-movement. +- `viewer.characterActive`, `createViewer({ characterUrl })`, and the embed `character` attribute. +- `scripts/capture-gifs.mjs` (`npm run gifs`): reproducible headless regeneration of the README movement GIFs from the live renderer. + +### Fixed + +- Deadlift arms now hang toward the bar during the hinge (were authored as shoulder extension, flying up behind the back). +- Crunch keeps the feet planted with bent knees (shins previously folded through the floor and jacked the body up). +- Touch-toes folds like a human (hinge depth and knee bend were over-authored, collapsing the figure). + ## [0.1.0] - 2026-07-08 ### Added diff --git a/README.md b/README.md index 03d035e..e124f51 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ Two safety layers ship with the language: The protocol and both libraries are **MIT-licensed**: the open core. See [`spec/SPEC.md`](spec/SPEC.md) for the full language and [`spec/llm-authoring.md`](spec/llm-authoring.md) for the authoring prompt. For where Posecode spreads fastest and the per-domain go-to-market plan, see [`docs/market-research.md`](docs/market-research.md); for the engine roadmap, [`ROADMAP.md`](ROADMAP.md). +The 3D figure is the [Adobe Mixamo](https://www.mixamo.com) anatomical mannequin character, exported from Mixamo and used under the Mixamo license (royalty-free in projects). The renderer also ships a zero-asset procedural figure, used automatically wherever the character can't load — and it accepts any Mixamo-rigged GLB via `characterUrl`. + --- ## Scope (v0.1) diff --git a/docs/media/deadlift.gif b/docs/media/deadlift.gif index b33db55..849df5f 100644 Binary files a/docs/media/deadlift.gif and b/docs/media/deadlift.gif differ diff --git a/docs/media/jumping-jacks.gif b/docs/media/jumping-jacks.gif index e13dc52..5932842 100644 Binary files a/docs/media/jumping-jacks.gif and b/docs/media/jumping-jacks.gif differ diff --git a/docs/media/lateral-raise.gif b/docs/media/lateral-raise.gif index d10d45d..d33088f 100644 Binary files a/docs/media/lateral-raise.gif and b/docs/media/lateral-raise.gif differ diff --git a/docs/media/squat.gif b/docs/media/squat.gif index 6a37be0..0a064cc 100644 Binary files a/docs/media/squat.gif and b/docs/media/squat.gif differ diff --git a/package-lock.json b/package-lock.json index 3c57984..b44259b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ ], "devDependencies": { "@vitest/coverage-v8": "^2.1.8", + "gifenc": "^1.0.3", + "playwright-core": "^1.61.1", "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^2.1.8" @@ -2344,6 +2346,13 @@ "node": ">= 0.4" } }, + "node_modules/gifenc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz", + "integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "dev": true, @@ -2824,6 +2833,19 @@ "node": ">=16.20.0" } }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/posecode-embed": { "resolved": "packages/posecode-embed", "link": true diff --git a/package.json b/package.json index 118f281..fdfe5d8 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,13 @@ "dev": "npm run dev -w playground", "build": "npm run build -w playground", "eval": "tsx packages/posecode-eval/src/cli.ts", - "typecheck": "for p in packages/*/tsconfig.json playground/tsconfig.json editors/*/tsconfig.json; do tsc --noEmit -p \"$p\" || exit 1; done" + "typecheck": "for p in packages/*/tsconfig.json playground/tsconfig.json editors/*/tsconfig.json; do tsc --noEmit -p \"$p\" || exit 1; done", + "gifs": "node scripts/capture-gifs.mjs" }, "devDependencies": { "@vitest/coverage-v8": "^2.1.8", + "gifenc": "^1.0.3", + "playwright-core": "^1.61.1", "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^2.1.8" diff --git a/packages/posecode-embed/README.md b/packages/posecode-embed/README.md index 064e14f..bfccf62 100644 --- a/packages/posecode-embed/README.md +++ b/packages/posecode-embed/README.md @@ -64,6 +64,7 @@ definePosecodePlayer(); // idempotent | `controls` | `true` | Show the play/pause bar. | | `autorotate` | `true` | Slowly orbit the camera when idle. | | `speed` | `1` | Playback multiplier (`0.1`–`4`). | +| `character` | *(hosted default)* | Realistic figure: a GLB URL (Mixamo rig), or `off` for the procedural mannequin. Load failures fall back to the mannequin. | | `playground` | `https://posecode.org/play` | Base URL for the "Edit ↗" link. | Boolean attributes accept `false` / `0` / `no` / `off` to turn them off, so diff --git a/packages/posecode-embed/src/element.ts b/packages/posecode-embed/src/element.ts index 929cfd3..037869e 100644 --- a/packages/posecode-embed/src/element.ts +++ b/packages/posecode-embed/src/element.ts @@ -105,6 +105,7 @@ export class PosecodePlayerElement extends HTMLElement { controls: this.getAttribute("controls"), autorotate: this.getAttribute("autorotate"), speed: this.getAttribute("speed"), + character: this.getAttribute("character"), }); } @@ -178,6 +179,7 @@ export class PosecodePlayerElement extends HTMLElement { const { createViewer } = await import("posecode-render"); const viewer = createViewer(this.#canvas, { autoRotate: opts.autoRotate && !reduceMotion, + ...(opts.characterUrl ? { characterUrl: opts.characterUrl } : {}), }); this.#viewer = viewer; viewer.onPhase(({ phaseName }) => { diff --git a/packages/posecode-embed/src/options.ts b/packages/posecode-embed/src/options.ts index 7439de5..82a6e0d 100644 --- a/packages/posecode-embed/src/options.ts +++ b/packages/posecode-embed/src/options.ts @@ -18,14 +18,25 @@ export interface PlayerOptions { autoRotate: boolean; /** Playback speed multiplier (0.1–4). */ speed: number; + /** + * Realistic skinned figure: a GLB URL, the default hosted character when + * absent, or `""` (attribute `character="off"`) for the procedural figure. + * Load failures fall back to the procedural figure, so an offline page + * degrades instead of blanking. + */ + characterUrl: string; } +/** The character the hosted playground uses, served from the same origin. */ +export const DEFAULT_CHARACTER_URL = "https://posecode.org/models/character.glb"; + export const DEFAULT_OPTIONS: PlayerOptions = { autoplay: true, loop: true, controls: true, autoRotate: true, speed: 1, + characterUrl: DEFAULT_CHARACTER_URL, }; const SPEED_MIN = 0.1; @@ -38,6 +49,7 @@ export interface RawAttributes { controls?: string | null; autorotate?: string | null; speed?: string | null; + character?: string | null; } const FALSEY = new Set(["false", "0", "no", "off"]); @@ -54,6 +66,15 @@ function clamp(n: number, lo: number, hi: number): number { export function parseOptions(attrs: RawAttributes): PlayerOptions { const speedRaw = attrs.speed != null ? Number(attrs.speed) : NaN; + // `character` accepts a GLB URL, a falsey word to opt out, or absent for + // the hosted default. + const characterRaw = attrs.character?.trim(); + const characterUrl = + characterRaw === undefined || characterRaw === null + ? DEFAULT_OPTIONS.characterUrl + : FALSEY.has(characterRaw.toLowerCase()) + ? "" + : characterRaw; return { autoplay: boolAttr(attrs.autoplay, DEFAULT_OPTIONS.autoplay), loop: boolAttr(attrs.loop, DEFAULT_OPTIONS.loop), @@ -62,5 +83,6 @@ export function parseOptions(attrs: RawAttributes): PlayerOptions { speed: Number.isFinite(speedRaw) ? clamp(speedRaw, SPEED_MIN, SPEED_MAX) : DEFAULT_OPTIONS.speed, + characterUrl, }; } diff --git a/packages/posecode-eval/src/probe.ts b/packages/posecode-eval/src/probe.ts index 904b58c..4da8cf1 100644 --- a/packages/posecode-eval/src/probe.ts +++ b/packages/posecode-eval/src/probe.ts @@ -18,6 +18,7 @@ import { applyGroundLock, buildMannequin, buildTimeline, + depenetrate, groundFigure, } from "posecode-render"; @@ -64,6 +65,7 @@ export function probeMovement(source: string): ProbeResult { m.root.rotation.set(rx * DEG, ry * DEG, rz * DEG); tl.sample(0, m.bones); m.root.updateMatrixWorld(true); + depenetrate(m); groundFigure(m); const baseRootPos = m.root.position.clone(); const baseRootQuat = m.root.quaternion.clone(); @@ -92,6 +94,8 @@ export function probeMovement(source: string): ProbeResult { m.root.position.x += info.rootOffset.x; m.root.position.z += info.rootOffset.z; m.root.updateMatrixWorld(true); + // Self-collision resolution, then contact solving (same order as the viewer). + depenetrate(m); // 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(); diff --git a/packages/posecode-render/README.md b/packages/posecode-render/README.md index 426a264..73c5ac0 100644 --- a/packages/posecode-render/README.md +++ b/packages/posecode-render/README.md @@ -1,8 +1,12 @@ # posecode-render Renders a [`posecode-parser`](https://www.npmjs.com/package/posecode-parser) IR -as an animated low-poly mannequin with [Three.js](https://threejs.org): +as an animated 3D human figure with [Three.js](https://threejs.org): forward kinematics plus ground-lock CCD IK, live in the browser at 60fps. +Pass `characterUrl` to show a realistic skinned character (any Mixamo-rigged +GLB); without it — or while it loads, or if it fails — a procedural athletic +figure renders instead, so the scene is never blank. Either way, a capsule +self-collision pass keeps limbs from passing through the body. Part of [Posecode](https://posecode.org): a kinematic-motion protocol LLMs can write, rendered as text-to-motion 3D animation. @@ -20,7 +24,12 @@ import { parse } from "posecode-parser"; import { createViewer } from "posecode-render"; const canvas = document.querySelector("canvas")!; -const viewer = createViewer(canvas, { autoRotate: false }); +const viewer = createViewer(canvas, { + autoRotate: false, + // Optional: realistic skinned character (Mixamo bone naming). Omit for the + // zero-asset procedural figure. + characterUrl: "https://posecode.org/models/character.glb", +}); const { ir } = parse(myPosecodeSource); if (ir) { diff --git a/packages/posecode-render/package.json b/packages/posecode-render/package.json index 43d8759..21efd27 100644 --- a/packages/posecode-render/package.json +++ b/packages/posecode-render/package.json @@ -1,7 +1,7 @@ { "name": "posecode-render", "version": "0.1.0", - "description": "Render a Posecode IR as an animated low-poly mannequin with Three.js.", + "description": "Render a Posecode IR as an animated 3D human figure (skinned character or procedural mannequin) with Three.js.", "license": "MIT", "type": "module", "main": "./src/index.ts", diff --git a/packages/posecode-render/src/character.ts b/packages/posecode-render/src/character.ts new file mode 100644 index 0000000..e77cb5c --- /dev/null +++ b/packages/posecode-render/src/character.ts @@ -0,0 +1,421 @@ +/** + * Skinned character layer: a realistic rigged human (Mixamo-convention GLB) + * driven by the invisible procedural driver skeleton. + * + * The driver rig stays the single source of truth for ALL solving (FK poses, + * ground-lock, pins, reach-IK, self-collision): its joint offsets are rebuilt + * from the character's own joint positions so both skeletons are exactly + * congruent, then every frame the character copies the driver's world-space + * rotation deltas bone-for-bone (fingers included). Because the driver's rest + * pose has identity rotations on every bone, the retarget reduces to: + * + * charBone.world = driverBone.world * charBone.calibratedRestWorld + * + * Calibration happens once at load: the GLB ships in a T-pose, so the arms are + * rotated down (palms forward, matching the driver's anatomical rest) and each + * limb segment is aimed exactly along the driver's rest direction. After that + * the two skeletons agree joint-for-joint in every pose, so floor contact and + * prop pins solved on the driver are exact on the character too. + */ + +import * as THREE from "three"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import type { Mannequin, Proportions } from "./mannequin.js"; + +/** Driver bone id → Mixamo bone name (without the "mixamorig" prefix). */ +const BONE_MAP: Record = { + pelvis: "Hips", + spine: "Spine", + chest: "Spine2", + neck: "Neck", + head: "Head", + shoulder_left: "LeftArm", + elbow_left: "LeftForeArm", + wrist_left: "LeftHand", + shoulder_right: "RightArm", + elbow_right: "RightForeArm", + wrist_right: "RightHand", + hip_left: "LeftUpLeg", + knee_left: "LeftLeg", + ankle_left: "LeftFoot", + hip_right: "RightUpLeg", + knee_right: "RightLeg", + ankle_right: "RightFoot", + thumb_left: "LeftHandThumb1", + index_left: "LeftHandIndex1", + middle_left: "LeftHandMiddle1", + ring_left: "LeftHandRing1", + pinky_left: "LeftHandPinky1", + thumb_right: "RightHandThumb1", + index_right: "RightHandIndex1", + middle_right: "RightHandMiddle1", + ring_right: "RightHandRing1", + pinky_right: "RightHandPinky1", +}; + +/** Distal phalanges that mirror a driver finger's curl (bone → curl factor). */ +const PHALANX_FOLLOW: [suffix: string, factor: number][] = [ + ["2", 0.9], + ["3", 0.7], +]; + +/** Driver rest height the character is scaled to (matches the procedural rig). */ +const DRIVER_HEIGHT = 1.75; +/** Fraction of the wrist→fingertip span where the driver knuckle sits. */ +const KNUCKLE_T = 0.55; + +export interface Character { + /** Scene-level wrapper (scaled). Add this next to the mannequin root. */ + group: THREE.Group; + /** Driver-skeleton overrides making the driver congruent with this mesh. */ + proportions: Proportions; + /** Copy the driver's current pose onto the character skeleton. */ + sync(driver: Mannequin): void; + /** Free GPU resources. */ + dispose(): void; +} + +const Y_UP = new THREE.Vector3(0, 1, 0); +const Y_DOWN = new THREE.Vector3(0, -1, 0); +const Z_FWD = new THREE.Vector3(0, 0, 1); + +/** + * Strip the mixamo namespace: "mixamorig:LeftArm", "mixamorigLeftArm", and + * numbered re-exports like "mixamorig1:LeftArm" all → "LeftArm". (Colons are + * already removed by GLTFLoader's name sanitizer at runtime.) + */ +function plainName(name: string): string { + return name.replace(/^mixamorig\d*:?/i, ""); +} + +interface MappedBone { + driverId: string; + node: THREE.Object3D; + /** Bone world quaternion at the calibrated rest, wrapper at identity. */ + restWorld: THREE.Quaternion; + /** Bone local quaternion at the calibrated rest. */ + restLocal: THREE.Quaternion; +} + +/** + * Load a character GLB. Resolves once geometry + textures are ready; rejects on + * network/parse failure (callers fall back to the procedural figure). + */ +export async function loadCharacter(url: string): Promise { + const gltf = await new GLTFLoader().loadAsync(url); + return rigCharacter(gltf.scene); +} + +/** + * Calibrate and wrap an already-loaded character scene. Exposed separately from + * `loadCharacter` so the retarget math is testable without GLTF parsing. + */ +export function rigCharacter(charScene: THREE.Object3D): Character { + const group = new THREE.Group(); + group.name = "posecode-character"; + group.add(charScene); + + // Index the skeleton by plain mixamo name. + const byName = new Map(); + charScene.traverse((n) => { + if ((n as THREE.Bone).isBone) byName.set(plainName(n.name), n); + }); + + const bone = (driverId: string): THREE.Object3D => { + const n = byName.get(BONE_MAP[driverId]!); + if (!n) throw new Error(`character: missing bone ${BONE_MAP[driverId]} (${driverId})`); + return n; + }; + // Every driver bone must exist before we touch anything. + for (const id of Object.keys(BONE_MAP)) bone(id); + + charScene.updateMatrixWorld(true); + + // ---- Calibration: pose the T-pose rig into the driver's rest pose. ---- + // Aim constraints per bone: rotate (in world space) so the direction to the + // named child joint matches the driver rest direction, and a roll reference + // vector maps to the driver's forward. Torso/legs aim straight up/down with + // forward staying +Z; arms aim straight down with the T-pose palm (world -Y) + // turned to face forward (+Z), the driver's anatomical rest. + const aim = ( + node: THREE.Object3D, + childWorld: THREE.Vector3, + aimTo: THREE.Vector3, + rollFrom: THREE.Vector3, + rollTo: THREE.Vector3, + ): void => { + node.updateWorldMatrix(true, false); + const nodeWorld = node.getWorldPosition(new THREE.Vector3()); + const curAim = childWorld.clone().sub(nodeWorld).normalize(); + const rot = twoAxisRotation(curAim, rollFrom, aimTo, rollTo); + // Apply the world-space rotation on the bone's local quaternion. + const parentWorldQ = node.parent!.getWorldQuaternion(new THREE.Quaternion()); + node.quaternion.copy( + parentWorldQ.clone().invert().multiply(rot).multiply(parentWorldQ).multiply(node.quaternion), + ); + node.updateMatrixWorld(true); + }; + + const worldPos = (n: THREE.Object3D): THREE.Vector3 => { + n.updateWorldMatrix(true, false); + return n.getWorldPosition(new THREE.Vector3()); + }; + + // The T-pose palm normal (world -Y) becomes the roll reference for arm + // chains; torso/leg chains keep facing forward. + const armRollFrom = Y_DOWN; + + // Torso chain: hips→spine→…→head aim +Y, forward stays +Z. + const torsoAims: [string, string][] = [ + ["pelvis", "Spine"], + ["spine", "Spine1"], + ["chest", "Neck"], + ["neck", "Head"], + ["head", "HeadTop_End"], + ]; + for (const [driverId, aimChild] of torsoAims) { + const child = byName.get(aimChild); + if (!child) continue; // HeadTop_End is optional in some rigs + aim(bone(driverId), worldPos(child), Y_UP, Z_FWD, Z_FWD); + } + + for (const side of ["left", "right"] as const) { + const S = side === "left" ? "Left" : "Right"; + // Arms: T-pose (out along ±X, palm down) → straight down, palm forward. + aim(bone(`shoulder_${side}`), worldPos(bone(`elbow_${side}`)), Y_DOWN, armRollFrom, Z_FWD); + aim(bone(`elbow_${side}`), worldPos(bone(`wrist_${side}`)), Y_DOWN, armRollFrom, Z_FWD); + aim(bone(`wrist_${side}`), worldPos(byName.get(`${S}HandMiddle1`)!), Y_DOWN, armRollFrom, Z_FWD); + // Fingers: aim each first phalanx along its own knuckle direction so the + // driver's digit pivot matches, but leave the distal phalanges at their + // designed rest curl: fully straightened fingers read as spider hands. + const wristPos = worldPos(bone(`wrist_${side}`)); + for (const fing of ["thumb", "index", "middle", "ring", "pinky"]) { + const f1 = bone(`${fing}_${side}`); + const dir = worldPos(f1).sub(wristPos).normalize(); + const next = f1.children.find((c) => (c as THREE.Bone).isBone); + if (next) aim(f1, worldPos(next), dir, armRollFrom, Z_FWD); + } + // Legs: straight down, forward stays +Z. The foot then returns to its own + // designed stance (heel down, toes forward) below. + const footRest = bone(`ankle_${side}`).getWorldQuaternion(new THREE.Quaternion()); + aim(bone(`hip_${side}`), worldPos(bone(`knee_${side}`)), Y_DOWN, Z_FWD, Z_FWD); + aim(bone(`knee_${side}`), worldPos(bone(`ankle_${side}`)), Y_DOWN, Z_FWD, Z_FWD); + // Restore the foot's original world orientation (leg straightening tilted it). + const ankle = bone(`ankle_${side}`); + const parentQ = ankle.parent!.getWorldQuaternion(new THREE.Quaternion()); + ankle.quaternion.copy(parentQ.invert().multiply(footRest)); + } + charScene.updateMatrixWorld(true); + + // ---- Measure the calibrated rest: scale, offsets, rest quaternions. ---- + // Mesh bounding box when there is one; bare skeletons (tests) fall back to + // joint extents with a nominal head/sole allowance. + const bbox = new THREE.Box3().setFromObject(charScene); + const rawTop = byName.get("HeadTop_End") ?? bone("head"); + const minY = Number.isFinite(bbox.min.y) ? bbox.min.y : 0; + const maxY = Number.isFinite(bbox.max.y) + ? bbox.max.y + : worldPos(rawTop).y + 0.12; + const scale = DRIVER_HEIGHT / Math.max(0.5, maxY - minY); + group.scale.setScalar(scale); + + // Joint positions AFTER the wrapper scale (worldPos sees the scaled tree). + const jointPos = new Map(); + for (const id of Object.keys(BONE_MAP)) { + jointPos.set(id, worldPos(bone(id))); + } + + const offsets: Record = {}; + const offsetOf = (id: string, parentId: string | null): void => { + const p = jointPos.get(id)!; + const base = parentId ? jointPos.get(parentId)! : new THREE.Vector3(); + offsets[id] = [p.x - base.x, p.y - base.y, p.z - base.z]; + }; + offsetOf("pelvis", null); + offsetOf("spine", "pelvis"); + offsetOf("chest", "spine"); + offsetOf("neck", "chest"); + offsetOf("head", "neck"); + for (const side of ["left", "right"] as const) { + offsetOf(`shoulder_${side}`, "chest"); + offsetOf(`elbow_${side}`, `shoulder_${side}`); + offsetOf(`wrist_${side}`, `elbow_${side}`); + offsetOf(`hip_${side}`, "pelvis"); + offsetOf(`knee_${side}`, `hip_${side}`); + offsetOf(`ankle_${side}`, `knee_${side}`); + // Driver finger offsets name the FINGERTIP; the bone sits at KNUCKLE_T of + // that span. Place the fingertip so the knuckle lands exactly on the + // character's first phalanx joint. + for (const fing of ["thumb", "index", "middle", "ring", "pinky"]) { + const id = `${fing}_${side}`; + const knuckle = jointPos.get(id)!.clone().sub(jointPos.get(`wrist_${side}`)!); + const tip = knuckle.multiplyScalar(1 / KNUCKLE_T); + offsets[id] = [tip.x, tip.y, tip.z]; + } + } + + // Vertical extent of the visible foot below the ankle joint: the driver's + // shoe geometry is rebuilt to bottom out exactly where this mesh's soles do, + // so bounding-box grounding rests the character's feet on the floor. + const soleDrop = jointPos.get("ankle_left")!.y - minY * scale; + // Skull height so supine/prone grounding accounts for the real head extent. + const headTop = byName.get("HeadTop_End"); + const headLength = headTop + ? worldPos(headTop).y - jointPos.get("head")!.y + : 0.12; + + const proportions: Proportions = { + offsets, + soleDrop, + headLength, + // Self-collision radii tuned to a slim realistic mesh rather than the + // chunkier procedural figure. + collision: { torso: 0.105, head: 0.1, thigh: 0.068, shin: 0.05, arm: 0.034 }, + }; + + // ---- Capture rest state for the per-frame retarget. ---- + const mapped: MappedBone[] = []; + const mappedByNode = new Map(); + for (const [driverId] of Object.entries(BONE_MAP)) { + const node = bone(driverId); + const mb: MappedBone = { + driverId, + node, + restWorld: node.getWorldQuaternion(new THREE.Quaternion()), + restLocal: node.quaternion.clone(), + }; + mapped.push(mb); + mappedByNode.set(node, mb); + } + // Distal phalanges: capture rest locals + the curl axis expressed in each + // phalanx's rest-local frame (the driver curls fingers as a single bone; the + // character folds all three knuckles for a natural fist). + interface Phalanx { + node: THREE.Object3D; + restLocal: THREE.Quaternion; + invRestWorld: THREE.Quaternion; + factor: number; + finger: string; // driver finger id + } + const phalanges: Phalanx[] = []; + for (const [driverId, mixamo] of Object.entries(BONE_MAP)) { + if (!/^(thumb|index|middle|ring|pinky)_/.test(driverId)) continue; + for (const [suffix, factor] of PHALANX_FOLLOW) { + const seg = byName.get(mixamo.replace(/1$/, suffix)); + if (!seg) continue; + phalanges.push({ + node: seg, + restLocal: seg.quaternion.clone(), + invRestWorld: seg.getWorldQuaternion(new THREE.Quaternion()).invert(), + factor, + finger: driverId, + }); + } + } + + // Skinned meshes deform far beyond their bind-pose bounds; never cull them. + group.traverse((o) => { + const mesh = o as THREE.Mesh; + if (mesh.isMesh) { + mesh.frustumCulled = false; + mesh.castShadow = true; + mesh.receiveShadow = true; + } + }); + + // ---- Per-frame retarget (see module doc for the math). ---- + const TMP_Q = new THREE.Quaternion(); + const TMP_Q2 = new THREE.Quaternion(); + const TMP_AXIS = new THREE.Vector3(); + + function sync(driver: Mannequin): void { + group.position.copy(driver.root.position); + group.quaternion.copy(driver.root.quaternion); + + // Walk the character tree accumulating world quaternions, assigning mapped + // bones from the driver as we descend (parents are final before children). + const recurse = (node: THREE.Object3D, parentWorldQ: THREE.Quaternion): void => { + const mb = mappedByNode.get(node); + if (mb) { + const driverBone = driver.bones.get(mb.driverId); + if (driverBone) { + driverBone.getWorldQuaternion(TMP_Q); // includes driver root + // local = parentWorld⁻¹ · driverWorld · restWorld + node.quaternion.copy(TMP_Q2.copy(parentWorldQ).invert().multiply(TMP_Q).multiply(mb.restWorld)); + } + } + const worldQ = parentWorldQ.clone().multiply(node.quaternion); + for (const child of node.children) recurse(child, worldQ); + }; + recurse(charScene, group.quaternion); + + // Fold the distal phalanges by the driver finger's curl angle. + for (const ph of phalanges) { + const driverFinger = driver.bones.get(ph.finger); + if (!driverFinger) continue; + // Driver finger locals are pure rotations in the wrist frame (== the + // driver rest world frame): extract signed axis/angle directly. + const q = driverFinger.quaternion; + const angle = 2 * Math.acos(THREE.MathUtils.clamp(q.w, -1, 1)); + if (angle < 1e-4) { + ph.node.quaternion.copy(ph.restLocal); + continue; + } + const s = Math.sqrt(Math.max(1e-12, 1 - q.w * q.w)); + TMP_AXIS.set(q.x / s, q.y / s, q.z / s); + // Express the curl axis in this phalanx's rest-local frame. + TMP_AXIS.applyQuaternion(ph.invRestWorld); + TMP_Q.setFromAxisAngle(TMP_AXIS, angle * ph.factor); + ph.node.quaternion.copy(ph.restLocal).multiply(TMP_Q); + } + + group.updateMatrixWorld(true); + } + + return { + group, + proportions, + sync, + dispose() { + group.traverse((o) => { + const mesh = o as THREE.Mesh; + if (mesh.isMesh) { + mesh.geometry?.dispose(); + const mat = mesh.material; + if (Array.isArray(mat)) mat.forEach((m) => m.dispose()); + else mat?.dispose(); + } + }); + }, + }; +} + +/** + * The world-space rotation that maps direction `a1`→`b1` while turning the + * roll reference `a2`→`b2` (both pairs orthonormalized against the aim). + */ +function twoAxisRotation( + a1: THREE.Vector3, + a2: THREE.Vector3, + b1: THREE.Vector3, + b2: THREE.Vector3, +): THREE.Quaternion { + const fromM = frameOf(a1, a2); + const toM = frameOf(b1, b2); + const qFrom = new THREE.Quaternion().setFromRotationMatrix(fromM); + const qTo = new THREE.Quaternion().setFromRotationMatrix(toM); + return qTo.multiply(qFrom.invert()); +} + +/** Right-handed orthonormal frame with X = aim and Y ≈ ref (Gram-Schmidt). */ +function frameOf(aim: THREE.Vector3, ref: THREE.Vector3): THREE.Matrix4 { + const x = aim.clone().normalize(); + let y = ref.clone().sub(x.clone().multiplyScalar(ref.dot(x))); + if (y.lengthSq() < 1e-8) { + // Degenerate roll reference (parallel to aim): pick any perpendicular. + y = Math.abs(x.y) < 0.9 ? new THREE.Vector3(0, 1, 0).cross(x) : new THREE.Vector3(1, 0, 0).cross(x); + } + y.normalize(); + const z = x.clone().cross(y); + return new THREE.Matrix4().makeBasis(x, y, z); +} diff --git a/packages/posecode-render/src/depenetrate.ts b/packages/posecode-render/src/depenetrate.ts new file mode 100644 index 0000000..742b897 --- /dev/null +++ b/packages/posecode-render/src/depenetrate.ts @@ -0,0 +1,232 @@ +/** + * Self-collision resolution: stop limbs from passing through the body. + * + * Authored poses are pure per-joint rotations, so nothing prevents a biceps + * curl from dragging the forearm through the thighs, or a cross-body reach + * from sweeping the hand through the chest. This pass approximates the body + * with capsules (torso, head, thighs, shins), samples points along each + * forearm/hand and each lower leg, and when a sample sits inside an obstacle + * it rotates the limb's proximal joint (shoulder / hip) just enough to clear. + * + * Principles: + * - **Minimal**: corrections only remove actual overlap, so intentional + * contact poses ("hands to temples") end up touching the surface instead of + * inside it. A pose with no overlap is untouched. + * - **Deterministic**: corrections are a pure function of the pose, so looping + * animations stay smooth (no frame-to-frame jitter). + * - **Safe**: each adjusted joint is clamped back into its healthy ROM + * (widened to admit the authored angle), the same guarantee reach-IK gives. + * + * Runs on the driver skeleton right after FK sampling, before ground-lock, in + * both the viewer's frame loop and its load-time anchor capture, so ground + * anchors and per-frame poses see the same corrected skeleton. + */ + +import * as THREE from "three"; +import { eulerRomFor } from "posecode-parser"; +import type { Mannequin } from "./mannequin.js"; + +const DEG = Math.PI / 180; + +/** Max corrective rotation applied per joint per frame (radians). */ +const MAX_CORRECTION = 30 * DEG; +/** Per-iteration step cap (radians): several small steps converge smoothly. */ +const MAX_STEP = 6 * DEG; +const ITERATIONS = 8; + +interface Capsule { + a: THREE.Vector3; + b: THREE.Vector3; + r: number; +} + +const TMP_AB = new THREE.Vector3(); +const TMP_AP = new THREE.Vector3(); +const TMP_CLOSEST = new THREE.Vector3(); +const TMP_PUSH = new THREE.Vector3(); +const TMP_LEVER = new THREE.Vector3(); +const TMP_AXIS = new THREE.Vector3(); +const TMP_Q = new THREE.Quaternion(); +const TMP_PARENT_Q = new THREE.Quaternion(); +const TMP_EULER = new THREE.Euler(); + +/** Closest point on segment ab to p, written into `out`. */ +function closestOnSegment(p: THREE.Vector3, cap: Capsule, out: THREE.Vector3): THREE.Vector3 { + TMP_AB.subVectors(cap.b, cap.a); + TMP_AP.subVectors(p, cap.a); + const len2 = TMP_AB.lengthSq(); + const t = len2 > 1e-10 ? THREE.MathUtils.clamp(TMP_AP.dot(TMP_AB) / len2, 0, 1) : 0; + return out.copy(cap.a).addScaledVector(TMP_AB, t); +} + +interface Hit { + depth: number; + point: THREE.Vector3; + push: THREE.Vector3; +} + +/** Deepest penetration of sample point p (radius r) against the obstacles. */ +function deepestHit(p: THREE.Vector3, r: number, obstacles: Capsule[], best: Hit | null): Hit | null { + for (const cap of obstacles) { + closestOnSegment(p, cap, TMP_CLOSEST); + TMP_PUSH.subVectors(p, TMP_CLOSEST); + const dist = TMP_PUSH.length(); + const depth = cap.r + r - dist; + if (depth <= 0 || depth <= (best?.depth ?? 0)) continue; + // Degenerate: sample exactly on the axis. Push sideways, away from midline. + const dir = dist > 1e-6 ? TMP_PUSH.clone().multiplyScalar(1 / dist) : new THREE.Vector3(Math.sign(p.x) || 1, 0, 0); + best = { depth, point: p.clone(), push: dir }; + } + return best; +} + +/** World position helper (assumes matrices are current). */ +function wp(m: Mannequin, id: string, out = new THREE.Vector3()): THREE.Vector3 { + return m.bones.get(id)!.getWorldPosition(out); +} + +/** + * Rotate `joint` (world-space axis/angle) and clamp it back into `limits`. + * Mirrors the CCD solver's joint update so corrections obey the same ROM. + */ +function rotateJoint( + joint: THREE.Object3D, + axis: THREE.Vector3, + angle: number, + limits: { x: [number, number]; y: [number, number]; z: [number, number] } | null, +): void { + joint.parent?.getWorldQuaternion(TMP_PARENT_Q); + const localAxis = TMP_AXIS.copy(axis).applyQuaternion(TMP_PARENT_Q.invert()); + TMP_Q.setFromAxisAngle(localAxis, angle); + joint.quaternion.premultiply(TMP_Q); + if (limits) { + TMP_EULER.setFromQuaternion(joint.quaternion, "XYZ"); + const x = THREE.MathUtils.clamp(TMP_EULER.x, limits.x[0], limits.x[1]); + const y = THREE.MathUtils.clamp(TMP_EULER.y, limits.y[0], limits.y[1]); + const z = THREE.MathUtils.clamp(TMP_EULER.z, limits.z[0], limits.z[1]); + if (x !== TMP_EULER.x || y !== TMP_EULER.y || z !== TMP_EULER.z) { + TMP_EULER.set(x, y, z, "XYZ"); + joint.quaternion.setFromEuler(TMP_EULER); + } + } + joint.updateMatrixWorld(true); +} + +/** The joint's ROM (radians), widened to admit its current authored pose. */ +function widenedLimits( + boneId: string, + joint: THREE.Object3D, +): { x: [number, number]; y: [number, number]; z: [number, number] } | null { + const rom = eulerRomFor(boneId); + if (!rom) return null; + TMP_EULER.setFromQuaternion(joint.quaternion, "XYZ"); + const widen = (min: number, max: number, cur: number): [number, number] => [ + Math.min(min * DEG, cur), + Math.max(max * DEG, cur), + ]; + return { + x: widen(rom.x.min, rom.x.max, TMP_EULER.x), + y: widen(rom.y.min, rom.y.max, TMP_EULER.y), + z: widen(rom.z.min, rom.z.max, TMP_EULER.z), + }; +} + +/** + * Resolve self-collisions on the driver skeleton in place. Call with the + * root's matrix world current; leaves matrices current. + */ +export function depenetrate(m: Mannequin): void { + const R = m.collision; + + // Sample points along a limb, proximal → tip. `tipOverhang` extends past the + // last joint to cover the hand/foot mesh beyond its bone. + const samples = (aId: string, bId: string, tipOverhang: number): THREE.Vector3[] => { + const a = wp(m, aId); + const b = wp(m, bId); + const dir = b.clone().sub(a); + const pts: THREE.Vector3[] = []; + for (const t of [0.15, 0.45, 0.75, 1.0]) pts.push(a.clone().addScaledVector(dir, t)); + if (tipOverhang > 0) { + const n = dir.clone().normalize(); + pts.push(b.clone().addScaledVector(n, tipOverhang)); + } + return pts; + }; + + // Rebuilt each iteration: obstacles move as corrections are applied. + const bodyObstacles = (): { torsoHead: Capsule[]; leg: Record<"left" | "right", Capsule[]> } => { + const pelvis = wp(m, "pelvis"); + const neck = wp(m, "neck"); + const head = wp(m, "head"); + // Extend the torso capsule a little below the pelvis joint (hip mass) and + // centre the head sphere in the skull rather than at the neck end. + const torso: Capsule = { + a: pelvis.clone().addScaledVector(neck.clone().sub(pelvis).normalize(), -0.08), + b: neck, + r: R.torso, + }; + const headCap: Capsule = { + a: head.clone().addScaledVector(head.clone().sub(neck).normalize(), 0.05), + b: head, + r: R.head, + }; + const legCaps = (side: "left" | "right"): Capsule[] => [ + { a: wp(m, `hip_${side}`), b: wp(m, `knee_${side}`), r: R.thigh }, + { a: wp(m, `knee_${side}`), b: wp(m, `ankle_${side}`), r: R.shin }, + ]; + return { + torsoHead: [torso, headCap], + leg: { left: legCaps("left"), right: legCaps("right") }, + }; + }; + + for (const side of ["left", "right"] as const) { + // --- Arm: forearm + hand vs torso, head, and both legs. --- + const shoulder = m.bones.get(`shoulder_${side}`)!; + const shoulderLimits = widenedLimits(`shoulder_${side}`, shoulder); + let applied = 0; + for (let i = 0; i < ITERATIONS && applied < MAX_CORRECTION; i++) { + const obs = bodyObstacles(); + const obstacles = [...obs.torsoHead, ...obs.leg.left, ...obs.leg.right]; + let hit: Hit | null = null; + for (const p of samples(`elbow_${side}`, `wrist_${side}`, 0.09)) { + hit = deepestHit(p, R.arm, obstacles, hit); + } + if (!hit) break; + const pivot = wp(m, `shoulder_${side}`); + TMP_LEVER.subVectors(hit.point, pivot); + const lever = TMP_LEVER.length(); + if (lever < 0.05) break; + const axis = TMP_LEVER.clone().cross(hit.push); + if (axis.lengthSq() < 1e-8) break; + axis.normalize(); + const step = Math.min(hit.depth / lever, MAX_STEP, MAX_CORRECTION - applied); + rotateJoint(shoulder, axis, step, shoulderLimits); + applied += step; + } + + // --- Leg: knee→foot vs the OTHER leg (crossing steps, curtsies). --- + const hip = m.bones.get(`hip_${side}`)!; + const hipLimits = widenedLimits(`hip_${side}`, hip); + const other = side === "left" ? "right" : "left"; + applied = 0; + for (let i = 0; i < ITERATIONS && applied < 10 * DEG; i++) { + const obs = bodyObstacles(); + let hit: Hit | null = null; + for (const p of samples(`knee_${side}`, `ankle_${side}`, 0.06)) { + hit = deepestHit(p, R.shin, obs.leg[other], hit); + } + if (!hit) break; + const pivot = wp(m, `hip_${side}`); + TMP_LEVER.subVectors(hit.point, pivot); + const lever = TMP_LEVER.length(); + if (lever < 0.05) break; + const axis = TMP_LEVER.clone().cross(hit.push); + if (axis.lengthSq() < 1e-8) break; + axis.normalize(); + const step = Math.min(hit.depth / lever, MAX_STEP, 10 * DEG - applied); + rotateJoint(hip, axis, step, hipLimits); + applied += step; + } + } +} diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index e5f64a2..8fe07fb 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -18,6 +18,8 @@ import { applyGroundLock as applyGroundLockTo, groundFigure as groundFigureOf } import { buildTimeline, type BuiltTimeline, type PhaseSegment } from "./timeline.js"; import { solveCCD, type JointLimits } from "./ik.js"; import { buildProps, type PropScene } from "./props.js"; +import { loadCharacter, type Character } from "./character.js"; +import { depenetrate } from "./depenetrate.js"; const DEG = Math.PI / 180; @@ -43,6 +45,8 @@ export interface Viewer { get playing(): boolean; get duration(): number; get time(): number; + /** True once the skinned character (characterUrl) is loaded and visible. */ + get characterActive(): boolean; getTimeline(): TimelineInfo | null; /** * Render the current time synchronously and return the frame as a PNG data @@ -60,6 +64,14 @@ export interface Viewer { export interface ViewerOptions { /** Slowly orbit the camera when idle. Defaults to true. */ autoRotate?: boolean; + /** + * URL of a rigged human character GLB (Mixamo bone naming) to render instead + * of the procedural figure. Loaded asynchronously; until it resolves — and if + * it fails — the viewer shows the procedural figure, so a missing or slow + * asset can never blank the scene. All solving still runs on the driver + * skeleton, rebuilt to the character's exact proportions (see character.ts). + */ + characterUrl?: string; } export function createViewer( @@ -144,6 +156,12 @@ export function createViewer( enableShadows(mannequin.root); scene.add(mannequin.root); + // Skinned character layer (optional). While loading (and on failure) the + // procedural figure stays; once ready, the driver skeleton is rebuilt with + // the character's proportions, its meshes are hidden (they keep feeding the + // bounding-box grounding), and the character mirrors it every frame. + let character: Character | null = null; + // --- Life layer: breathing + blinking so the figure reads as alive even // when the movement is paused. Both are MESH-only effects. Breathing must // never rotate skeleton bones: an earlier version breathed via tiny @@ -153,11 +171,11 @@ export function createViewer( // 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 eyes = ["eye_left", "eye_right"] + let 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 ribcage = mannequin.root.getObjectByName("ribcage"); + let ribcageRestScale = ribcage ? ribcage.scale.clone() : null; let nextBlink = performance.now() / 1000 + 2; function applyLife(nowSec: number): void { @@ -178,6 +196,9 @@ export function createViewer( } let timeline: BuiltTimeline | null = null; + // The last loaded document, kept so the viewer can re-solve base pose and + // ground anchors when the character (with its own proportions) arrives. + let lastIR: PosecodeIR | null = null; let groundTargets = new Map(); // World-space anchor points contributed by scene props (chair seat, bar grip, // wall surface). Populated when a doc declares props; empty otherwise. @@ -432,6 +453,9 @@ export function createViewer( mannequin.root.position.x += info.rootOffset.x; mannequin.root.position.z += info.rootOffset.z; mannequin.root.updateMatrixWorld(true); + // Self-collision: nudge limbs out of the body BEFORE contact solving so + // ground-lock and pins see the corrected pose (same order as load()). + depenetrate(mannequin); applyGroundLockTo(mannequin, info.groundLock, frameAnchors(info.rootYaw, info.rootOffset)); applyPins(info.pins); // Safety net: nothing above ever intentionally pushes part of the body @@ -455,6 +479,8 @@ export function createViewer( phaseCb({ phaseName: info.phaseName, ...(info.cue ? { cue: info.cue } : {}) }); } } + // Mirror the fully-solved driver pose onto the skinned character. + character?.sync(mannequin); if (easeCamera) { controls.target.lerp(desiredTarget, 0.07); camera.position.lerp(desiredPos, 0.07); @@ -490,6 +516,7 @@ export function createViewer( const api: Viewer = { load(ir: PosecodeIR) { + lastIR = ir; timeline = buildTimeline(ir); time = 0; lastPhaseName = ""; @@ -512,6 +539,7 @@ export function createViewer( applyBaseRoot(); timeline.sample(0, mannequin.bones); mannequin.root.updateMatrixWorld(true); + depenetrate(mannequin); groundFigureOf(mannequin); captureGroundTargets(); baseRootPos.copy(mannequin.root.position); @@ -549,6 +577,9 @@ export function createViewer( get time() { return time; }, + get characterActive() { + return character !== null; + }, getTimeline() { if (!timeline) return null; return { @@ -573,9 +604,41 @@ export function createViewer( dispose() { cancelAnimationFrame(raf); controls.dispose(); + character?.dispose(); renderer.dispose(); }, }; + + // Kick off the character load (if requested). On success, swap the driver + // skeleton for one congruent with the character, hide the procedural meshes + // (still feeding the bounding-box grounding), and re-solve the current + // document against the new proportions. On failure, the procedural figure + // simply remains: the scene is never blank. + if (opts.characterUrl) { + void loadCharacter(opts.characterUrl) + .then((char) => { + scene.remove(mannequin.root); + disposeTree(mannequin.root); + mannequin = buildMannequin(undefined, char.proportions); + mannequin.root.traverse((obj) => { + if ((obj as THREE.Mesh).isMesh) obj.visible = false; + }); + scene.add(mannequin.root); + scene.add(char.group); + character = char; + // The life layer's mesh handles died with the procedural figure. + eyes = []; + ribcage = undefined; + ribcageRestScale = null; + if (lastIR) api.load(lastIR); + else char.sync(mannequin); + }) + .catch(() => { + // Keep the procedural figure. Deliberately silent: an offline embed + // or a blocked CDN should degrade, not error. + }); + } + return api; } @@ -603,8 +666,10 @@ function disposeTree(root: THREE.Object3D): void { export { buildMannequin } from "./mannequin.js"; export { applyGroundLock, groundFigure } from "./groundlock.js"; -export type { Mannequin } from "./mannequin.js"; +export type { Mannequin, Proportions, CollisionRadii } from "./mannequin.js"; export { buildTimeline } from "./timeline.js"; export { solveCCD, type IkChain, type JointLimits } from "./ik.js"; export { buildProps, type PropScene } from "./props.js"; +export { loadCharacter, rigCharacter, type Character } from "./character.js"; +export { depenetrate } from "./depenetrate.js"; export type { PhaseSegment } from "./timeline.js"; diff --git a/packages/posecode-render/src/mannequin.ts b/packages/posecode-render/src/mannequin.ts index ef8efd5..505f1d6 100644 --- a/packages/posecode-render/src/mannequin.ts +++ b/packages/posecode-render/src/mannequin.ts @@ -22,8 +22,47 @@ export interface Mannequin { bones: Map; /** Effector group name → the distal joint nodes used for ground-lock. */ effectors: Record; + /** Body-part radii for the self-collision pass (metres). */ + collision: CollisionRadii; } +/** Capsule/sphere radii approximating the visible body for self-collision. */ +export interface CollisionRadii { + torso: number; + head: number; + thigh: number; + shin: number; + arm: number; +} + +/** + * Overrides that rebuild the driver skeleton congruent with a loaded skinned + * character: joint offsets measured from the character's calibrated rest pose, + * plus the mesh extents that bounding-box grounding depends on. + */ +export interface Proportions { + /** boneId → offset from parent joint (metres, parent rest frame). */ + offsets: Record; + /** Vertical extent of the visible foot below the ankle joint. */ + soleDrop?: number; + /** Vertical extent of the head above the head joint (skull + hair). */ + headLength?: number; + /** Collision radii matching the character's mesh. */ + collision?: CollisionRadii; +} + +/** Radii for the chunky procedural figure (capsule segments + ellipsoids). */ +const DEFAULT_COLLISION: CollisionRadii = { + torso: 0.13, + head: 0.105, + thigh: 0.075, + shin: 0.055, + arm: 0.038, +}; + +/** Foot-mesh depth below the ankle bone in the default shoe (see addShoe). */ +const DEFAULT_SOLE_DROP = 0.042; + interface BoneSpec { id: string; parent: string | null; @@ -123,8 +162,13 @@ function segmentMaterial(id: string, mats: FigureMaterials): THREE.Material { return mats.skin; } -/** Build the figure. `material` overrides the whole palette (embed theming). */ -export function buildMannequin(material?: THREE.Material): Mannequin { +/** + * Build the figure. `material` overrides the whole palette (embed theming). + * `proportions` rebuilds the skeleton congruent with a loaded skinned + * character (see character.ts); the procedural meshes are then hidden but keep + * feeding the bounding-box grounding, so contact solving matches the mesh. + */ +export function buildMannequin(material?: THREE.Material, proportions?: Proportions): Mannequin { const mats = material ? { skin: material, @@ -146,7 +190,7 @@ export function buildMannequin(material?: THREE.Material): Mannequin { const bone = new THREE.Object3D(); bone.name = spec.id; // Finger bones sit at the knuckle; the offset names the fingertip. - const offset = new THREE.Vector3(...spec.offset); + const offset = new THREE.Vector3(...(proportions?.offsets[spec.id] ?? spec.offset)); if (isFinger(spec.id)) offset.multiplyScalar(KNUCKLE_T); bone.position.copy(offset); @@ -169,7 +213,7 @@ export function buildMannequin(material?: THREE.Material): Mannequin { // finger curl visibly folds at the knuckle. for (const spec of SKELETON) { if (!isFinger(spec.id) || !spec.radius) continue; - const full = new THREE.Vector3(...spec.offset); + const full = new THREE.Vector3(...(proportions?.offsets[spec.id] ?? spec.offset)); const span = full.clone().multiplyScalar(1 - KNUCKLE_T); const digit = makeSegment(span.length(), spec.radius * 0.92, mats.skin); orientSegment(digit, span); @@ -177,11 +221,11 @@ export function buildMannequin(material?: THREE.Material): Mannequin { } addTorso(bones, mats); - addHead(bones.get("head")!, mats); + addHead(bones.get("head")!, mats, proportions?.headLength); addPalm(bones.get("wrist_left")!, mats.skin); addPalm(bones.get("wrist_right")!, mats.skin); - addShoe(bones.get("ankle_left")!, mats); - addShoe(bones.get("ankle_right")!, mats); + addShoe(bones.get("ankle_left")!, mats, proportions?.soleDrop); + addShoe(bones.get("ankle_right")!, mats, proportions?.soleDrop); return { root, @@ -190,6 +234,7 @@ export function buildMannequin(material?: THREE.Material): Mannequin { hands: ["wrist_left", "wrist_right"], feet: ["ankle_left", "ankle_right"], }, + collision: proportions?.collision ?? DEFAULT_COLLISION, }; } @@ -271,9 +316,12 @@ function addTorso(bones: Map, mats: FigureMaterials): vo * on the front (+Z). The face keeps head yaw/turns readable from any angle; * the hair breaks the "billiard ball" look and marks up-vs-down in inversions. */ -function addHead(head: THREE.Object3D, mats: FigureMaterials): void { +function addHead(head: THREE.Object3D, mats: FigureMaterials, headLength?: number): void { const skull = new THREE.Mesh(new THREE.SphereGeometry(0.062, 20, 16), mats.skin); - skull.scale.set(0.92, 1.12, 0.98); + // With `headLength`, stretch the skull so its top matches a character's real + // head extent: supine/prone grounding then rests the visible head correctly. + const scaleY = headLength ? Math.max(1.12, (headLength - 0.01) / 0.062) : 1.12; + skull.scale.set(0.92, scaleY, 0.98); skull.position.y = 0.01; head.add(skull); @@ -320,11 +368,15 @@ function addPalm(wrist: THREE.Object3D, mat: THREE.Material): void { /** * A sneaker-shaped foot: rounded upper + thin sole. Sole depth matches the - * old foot box (bottom ≈ -0.04) so ground contact height is unchanged. + * old foot box (bottom ≈ -0.04) so ground contact height is unchanged. With + * `soleDrop`, the whole shoe shifts down so its bottom sits that far below the + * ankle joint: characters carry their ankle higher above the floor, and the + * bounding-box grounding must plant THEIR sole, not the default one. */ -function addShoe(ankle: THREE.Object3D, mats: FigureMaterials): void { - addEllipsoid(ankle, 0.05, [0.75, 0.55, 1.9], [0, -0.012, 0.05], mats.shoes); +function addShoe(ankle: THREE.Object3D, mats: FigureMaterials, soleDrop?: number): void { + const dy = soleDrop !== undefined ? -(soleDrop - DEFAULT_SOLE_DROP) : 0; + addEllipsoid(ankle, 0.05, [0.75, 0.55, 1.9], [0, -0.012 + dy, 0.05], mats.shoes); const sole = new THREE.Mesh(new THREE.BoxGeometry(0.075, 0.012, 0.185), mats.face); - sole.position.set(0, -0.036, 0.05); + sole.position.set(0, -0.036 + dy, 0.05); ankle.add(sole); } diff --git a/packages/posecode-render/test/character.test.ts b/packages/posecode-render/test/character.test.ts new file mode 100644 index 0000000..c81ce98 --- /dev/null +++ b/packages/posecode-render/test/character.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import * as THREE from "three"; +import { rigCharacter } from "../src/character.js"; +import { buildMannequin } from "../src/mannequin.js"; + +const DEG = Math.PI / 180; + +/** + * A minimal Mixamo-convention skeleton in a T-pose (arms straight out along + * ±X, palms notionally down, legs straight down, facing +Z). World positions + * below; locals are the diffs since every rest rotation is identity. + */ +function makeTposeSkeleton(): THREE.Object3D { + const world = new Map([ + ["Hips", [0, 1.0, 0]], + ["Spine", [0, 1.1, 0]], + ["Spine1", [0, 1.2, 0]], + ["Spine2", [0, 1.3, 0]], + ["Neck", [0, 1.45, 0]], + ["Head", [0, 1.5, 0]], + ["HeadTop_End", [0, 1.65, 0]], + ]); + const parents = new Map([ + ["Spine", "Hips"], + ["Spine1", "Spine"], + ["Spine2", "Spine1"], + ["Neck", "Spine2"], + ["Head", "Neck"], + ["HeadTop_End", "Head"], + ]); + for (const [S, sx] of [ + ["Left", 1], + ["Right", -1], + ] as const) { + const put = (name: string, parent: string, p: [number, number, number]): void => { + world.set(`${S}${name}`, [sx * p[0], p[1], p[2]]); + parents.set(`${S}${name}`, /^(Shoulder|UpLeg)$/.test(name) ? parent : `${S}${parent}`); + }; + put("Shoulder", "Spine2", [0.05, 1.4, 0]); + put("Arm", "Shoulder", [0.15, 1.4, 0]); + put("ForeArm", "Arm", [0.4, 1.4, 0]); + put("Hand", "ForeArm", [0.65, 1.4, 0]); + for (const [fing, y, z] of [ + ["Thumb", 1.37, 0.03], + ["Index", 1.4, 0.025], + ["Middle", 1.4, 0.008], + ["Ring", 1.4, -0.008], + ["Pinky", 1.4, -0.025], + ] as const) { + put(`Hand${fing}1`, "Hand", [0.72, y, z]); + put(`Hand${fing}2`, `Hand${fing}1`, [0.75, y, z]); + put(`Hand${fing}3`, `Hand${fing}2`, [0.78, y, z]); + } + put("UpLeg", "Hips", [0.1, 0.95, 0]); + put("Leg", "UpLeg", [0.1, 0.5, 0]); + put("Foot", "Leg", [0.1, 0.1, 0]); + put("ToeBase", "Foot", [0.1, 0.02, 0.12]); + } + + const scene = new THREE.Group(); + const nodes = new Map(); + for (const [name, pos] of world) { + const b = new THREE.Bone(); + b.name = `mixamorig${name}`; + const parent = parents.get(name); + const base = parent ? world.get(parent)! : [0, 0, 0]; + b.position.set(pos[0] - base[0], pos[1] - base[1], pos[2] - base[2]); + (parent ? nodes.get(parent)! : scene).add(b); + nodes.set(name, b); + } + scene.updateMatrixWorld(true); + return scene; +} + +/** World-position distance between a driver bone and its character bone. */ +function jointGap( + driver: ReturnType, + char: ReturnType, + driverId: string, + mixamoName: string, +): number { + const d = driver.bones.get(driverId)!.getWorldPosition(new THREE.Vector3()); + const c = char.group + .getObjectByName(`mixamorig${mixamoName}`)! + .getWorldPosition(new THREE.Vector3()); + return d.distanceTo(c); +} + +const CHECKS: [string, string][] = [ + ["pelvis", "Hips"], + ["chest", "Spine2"], + ["head", "Head"], + ["shoulder_left", "LeftArm"], + ["elbow_left", "LeftForeArm"], + ["wrist_left", "LeftHand"], + ["elbow_right", "RightForeArm"], + ["wrist_right", "RightHand"], + ["hip_left", "LeftUpLeg"], + ["knee_right", "RightLeg"], + ["ankle_left", "LeftFoot"], + ["ankle_right", "RightFoot"], +]; + +describe("character retargeting", () => { + it("calibrates a T-pose rig into the driver rest pose (arms down)", () => { + const char = rigCharacter(makeTposeSkeleton()); + const driver = buildMannequin(undefined, char.proportions); + driver.root.updateMatrixWorld(true); + char.sync(driver); + for (const [driverId, mixamo] of CHECKS) { + expect(jointGap(driver, char, driverId, mixamo), `${driverId}↔${mixamo}`).toBeLessThan(1e-3); + } + // Arms really came down: the character hand hangs below its elbow. + const hand = char.group + .getObjectByName("mixamorigLeftHand")! + .getWorldPosition(new THREE.Vector3()); + const elbow = char.group + .getObjectByName("mixamorigLeftForeArm")! + .getWorldPosition(new THREE.Vector3()); + expect(hand.y).toBeLessThan(elbow.y - 0.1); + }); + + it("keeps the skeletons congruent under an arbitrary posed frame", () => { + const char = rigCharacter(makeTposeSkeleton()); + const driver = buildMannequin(undefined, char.proportions); + + // A messy asymmetric pose incl. root motion (as ground-lock would apply). + driver.root.position.set(0.3, -0.12, 0.5); + driver.root.rotation.set(20 * DEG, 45 * DEG, 0); + const set = (id: string, x: number, y: number, z: number): void => { + driver.bones.get(id)!.rotation.set(x * DEG, y * DEG, z * DEG); + }; + set("pelvis", -30, 0, 0); + set("chest", 10, 15, 0); + set("shoulder_left", -120, 0, 20); + set("elbow_left", -90, 0, 0); + set("shoulder_right", 0, 0, -80); + set("hip_left", -85, 0, 8); + set("knee_left", 70, 0, 0); + set("ankle_right", -20, 0, 0); + driver.root.updateMatrixWorld(true); + + char.sync(driver); + for (const [driverId, mixamo] of CHECKS) { + expect(jointGap(driver, char, driverId, mixamo), `${driverId}↔${mixamo}`).toBeLessThan(2e-3); + } + }); + + it("derives driver proportions from the character (sole depth, scale)", () => { + const char = rigCharacter(makeTposeSkeleton()); + const p = char.proportions; + // Skeleton is ~1.77m raw (1.65 head-top + 0.12 allowance): scale ≈ 1. + expect(p.offsets["pelvis"]![1]).toBeGreaterThan(0.9); + expect(p.offsets["pelvis"]![1]).toBeLessThan(1.1); + // Limb segments are straight down after calibration. + expect(p.offsets["knee_left"]![0]).toBeCloseTo(0, 5); + expect(p.offsets["elbow_left"]![0]).toBeCloseTo(0, 5); + expect(p.offsets["elbow_left"]![1]).toBeLessThan(-0.2); + // The ankle rides well above the sole (character feet, not the default shoe). + expect(p.soleDrop).toBeGreaterThan(0.05); + }); +}); diff --git a/packages/posecode-render/test/depenetrate.test.ts b/packages/posecode-render/test/depenetrate.test.ts new file mode 100644 index 0000000..c971352 --- /dev/null +++ b/packages/posecode-render/test/depenetrate.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import * as THREE from "three"; +import { buildMannequin } from "../src/mannequin.js"; +import { depenetrate } from "../src/depenetrate.js"; + +const DEG = Math.PI / 180; + +/** Distance from a point to the pelvis→neck torso axis segment. */ +function distToTorso(m: ReturnType, p: THREE.Vector3): number { + const a = m.bones.get("pelvis")!.getWorldPosition(new THREE.Vector3()); + const b = m.bones.get("neck")!.getWorldPosition(new THREE.Vector3()); + const ab = b.clone().sub(a); + const t = THREE.MathUtils.clamp(p.clone().sub(a).dot(ab) / ab.lengthSq(), 0, 1); + return p.distanceTo(a.addScaledVector(ab, t)); +} + +describe("self-collision de-penetration", () => { + /** Min distance from any point along the forearm (elbow→wrist) to the torso axis. */ + function forearmClearance(m: ReturnType): number { + const e = m.bones.get("elbow_left")!.getWorldPosition(new THREE.Vector3()); + const w = m.bones.get("wrist_left")!.getWorldPosition(new THREE.Vector3()); + let min = Infinity; + for (let t = 0; t <= 1; t += 0.1) { + min = Math.min(min, distToTorso(m, e.clone().lerp(w, t))); + } + return min; + } + + it("pushes a forearm swung across the chest back out to the surface", () => { + const m = buildMannequin(); + // Swing the straight arm across the body in the frontal plane: the forearm + // slices through the torso capsule. Probe both lateral signs so the test + // doesn't depend on the adduction sign convention. + let before = Infinity; + for (const sz of [80, -80]) { + m.bones.get("shoulder_left")!.rotation.set(0, 0, sz * DEG); + m.bones.get("elbow_left")!.rotation.set(-25 * DEG, 0, 0); + m.root.updateMatrixWorld(true); + before = forearmClearance(m); + if (before < m.collision.torso) break; + } + expect(before).toBeLessThan(m.collision.torso); // sanity: really penetrating + + depenetrate(m); + m.root.updateMatrixWorld(true); + expect(forearmClearance(m)).toBeGreaterThan(before + 0.02); // pushed outward + // Elbow flexion is untouched: only the shoulder re-aims the arm. + const elbow = new THREE.Euler().setFromQuaternion(m.bones.get("elbow_left")!.quaternion, "XYZ"); + expect(elbow.x).toBeCloseTo(-25 * DEG, 5); + }); + + it("leaves a clean pose untouched", () => { + const m = buildMannequin(); + m.bones.get("shoulder_left")!.rotation.set(-90 * DEG, 0, 0); // arm straight forward + m.root.updateMatrixWorld(true); + const before = m.bones.get("shoulder_left")!.quaternion.clone(); + depenetrate(m); + expect(m.bones.get("shoulder_left")!.quaternion.angleTo(before)).toBeLessThan(1e-6); + }); + + it("separates crossing shins", () => { + const m = buildMannequin(); + // Swing the left leg across the right: ankles/shins overlap. + m.bones.get("hip_left")!.rotation.set(-20 * DEG, 0, -35 * DEG); + m.root.updateMatrixWorld(true); + const gap = (): number => { + const l = m.bones.get("ankle_left")!.getWorldPosition(new THREE.Vector3()); + const a = m.bones.get("knee_right")!.getWorldPosition(new THREE.Vector3()); + const b = m.bones.get("ankle_right")!.getWorldPosition(new THREE.Vector3()); + const ab = b.clone().sub(a); + const t = THREE.MathUtils.clamp(l.clone().sub(a).dot(ab) / ab.lengthSq(), 0, 1); + return l.distanceTo(a.addScaledVector(ab, t)); + }; + const before = gap(); + depenetrate(m); + m.root.updateMatrixWorld(true); + expect(gap()).toBeGreaterThanOrEqual(before); + }); +}); diff --git a/playground/public/models/character.glb b/playground/public/models/character.glb new file mode 100644 index 0000000..a14bae1 Binary files /dev/null and b/playground/public/models/character.glb differ diff --git a/playground/src/landing.ts b/playground/src/landing.ts index 743a07b..17dbc43 100644 --- a/playground/src/landing.ts +++ b/playground/src/landing.ts @@ -28,7 +28,11 @@ function initHero(): void { const heroCanvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null; if (!heroCanvas) return; void import("posecode-render").then(({ createViewer }) => { - const viewer = createViewer(heroCanvas, { autoRotate: !prefersReducedMotion }); + const viewer = createViewer(heroCanvas, { + autoRotate: !prefersReducedMotion, + // Realistic skinned figure; the procedural one covers until it loads. + characterUrl: "/models/character.glb", + }); const phaseEl = document.getElementById("hero-phase"); viewer.onPhase(({ phaseName }) => { if (phaseEl) phaseEl.textContent = phaseName === "reset" ? "" : phaseName; diff --git a/playground/src/main.ts b/playground/src/main.ts index e2664fb..1d9defe 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -510,7 +510,14 @@ void import("posecode-render").then(({ createViewer }) => { // 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 }); + // Realistic skinned figure by default; `?figure=classic` keeps the + // procedural mannequin (debugging aid, and a fallback link for slow pages). + const classicFigure = + new URLSearchParams(location.search).get("figure") === "classic"; + viewer = createViewer(canvas, { + autoRotate: false, + ...(classicFigure ? {} : { characterUrl: "/models/character.glb" }), + }); // Exposed for capture/e2e tooling (frame capture drives README GIFs). (window as unknown as Record).__posecodeViewer = viewer; wireViewer(viewer); diff --git a/scripts/capture-gifs.mjs b/scripts/capture-gifs.mjs new file mode 100644 index 0000000..a699b74 --- /dev/null +++ b/scripts/capture-gifs.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * Regenerates the README movement GIFs from the real renderer. + * + * Boots the playground with Vite, drives the live viewer headlessly with + * Playwright (seek → captureFrame per animation frame), and encodes looping + * GIFs with gifenc. Frames are composed to the target size inside the page + * (cover-crop of the viewer canvas), so no image tooling is needed in node. + * + * Not wired into `npm run build`: run it manually when the figure or a + * showcased movement changes, and commit the output. + * + * Usage: + * node scripts/capture-gifs.mjs # all README gifs + * node scripts/capture-gifs.mjs squat # just one + * + * The Chromium binary is resolved from PLAYWRIGHT_BROWSERS_PATH/chromium or + * POSECODE_CHROMIUM. + */ +import { createServer } from "vite"; +import { chromium } from "playwright-core"; +import gifencPkg from "gifenc"; // CJS: no named ESM exports +const { GIFEncoder, quantize, applyPalette } = gifencPkg; +import { writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, ".."); + +/** README media set. `size` matches the committed GIFs' dimensions. */ +const TARGETS = [ + { id: "jumping-jacks", size: [480, 534], fps: 14 }, + { id: "squat", size: [420, 582], fps: 14 }, + { id: "deadlift", size: [420, 582], fps: 14 }, + { id: "lateral", out: "lateral-raise", size: [420, 582], fps: 14 }, +]; + +function chromiumPath() { + if (process.env.POSECODE_CHROMIUM) return process.env.POSECODE_CHROMIUM; + const base = process.env.PLAYWRIGHT_BROWSERS_PATH; + if (base && existsSync(`${base}/chromium`)) return `${base}/chromium`; + return chromium.executablePath(); +} + +const only = process.argv[2]; +const targets = TARGETS.filter((t) => !only || t.id === only || t.out === only); +if (targets.length === 0) { + console.error(`no such target: ${only}`); + process.exit(1); +} + +const server = await createServer({ + configFile: resolve(repoRoot, "playground/vite.config.ts"), + server: { port: 0 }, + logLevel: "error", +}); +await server.listen(); +const port = server.config.server.port ?? server.httpServer.address().port; +const origin = `http://127.0.0.1:${port}`; + +const browser = await chromium.launch({ executablePath: chromiumPath() }); +const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); +page.on("pageerror", (e) => console.error("[page]", e.message)); + +for (const t of targets) { + const [w, h] = t.size; + await page.goto(`${origin}/play.html#doc=${t.id}`, { waitUntil: "load" }); + await page.reload({ waitUntil: "load" }); + await page.waitForFunction(() => window.__posecodeViewer?.duration > 0, null, { + timeout: 60000, + }); + await page.waitForFunction(() => window.__posecodeViewer.characterActive === true, null, { + timeout: 60000, + }); + await page.waitForTimeout(1600); // let the auto-framing camera settle + + const duration = await page.evaluate(() => { + const v = window.__posecodeViewer; + v.pause(); + return v.duration; + }); + const frameCount = Math.round(duration * t.fps); + const delayMs = Math.round(1000 / t.fps); + + const gif = GIFEncoder(); + let palette = null; + for (let i = 0; i < frameCount; i++) { + const time = (i / t.fps) % duration; + const b64 = await page.evaluate( + ({ time, w, h }) => { + const v = window.__posecodeViewer; + v.seek(time); + v.captureFrame(); + const src = document.getElementById("canvas"); + // Cover-crop the viewer canvas into the target frame. + const scale = Math.max(w / src.width, h / src.height); + const sw = w / scale; + const sh = h / scale; + const sx = (src.width - sw) / 2; + const sy = (src.height - sh) / 2; + const out = new OffscreenCanvas(w, h); + const ctx = out.getContext("2d"); + ctx.drawImage(src, sx, sy, sw, sh, 0, 0, w, h); + const data = ctx.getImageData(0, 0, w, h).data; + let bin = ""; + for (let j = 0; j < data.length; j += 8192) { + bin += String.fromCharCode.apply(null, data.subarray(j, j + 8192)); + } + return btoa(bin); + }, + { time, w, h }, + ); + const rgba = Uint8Array.from(Buffer.from(b64, "base64")); + // One palette for the whole clip keeps the loop flicker-free (the scene + // lighting is static; only the figure moves). + if (!palette) palette = quantize(rgba, 256); + const indexed = applyPalette(rgba, palette); + gif.writeFrame(indexed, w, h, { palette: i === 0 ? palette : undefined, delay: delayMs }); + } + gif.finish(); + + const outName = `${t.out ?? t.id}.gif`; + const outPath = resolve(repoRoot, "docs/media", outName); + await writeFile(outPath, gif.bytes()); + console.log(`wrote docs/media/${outName} (${frameCount} frames @ ${t.fps}fps, ${w}x${h})`); +} + +await browser.close(); +await server.close(); diff --git a/spec/examples/crunch.posecode b/spec/examples/crunch.posecode index 72777e4..6d7d484 100644 --- a/spec/examples/crunch.posecode +++ b/spec/examples/crunch.posecode @@ -3,6 +3,7 @@ posecode exercise "Crunch" pose start = supine step "Curl up" 1s ease-out: + hips: flex 45 knees: flex 90 spine: flex 30 chest: flex 20 @@ -12,7 +13,8 @@ posecode exercise "Crunch" cue "Curl the shoulders off the floor, ribs toward the hips" step "Lower" 1.2s ease-in: - knees: flex 0 + hips: flex 45 + knees: flex 90 spine: flex 0 chest: flex 0 neck: flex 0 diff --git a/spec/examples/deadlift.posecode b/spec/examples/deadlift.posecode index acf43e2..66cd371 100644 --- a/spec/examples/deadlift.posecode +++ b/spec/examples/deadlift.posecode @@ -5,7 +5,7 @@ posecode exercise "Deadlift" step "Lower" 1.8s ease-in-out: pelvis: hinge 75 knees: flex 25 - shoulders: extend 60 + shoulders: flex 70 neck: extend 12 ground-lock: feet cue "Push the hips back and hinge with a flat back: let the arms hang to the bar" @@ -13,7 +13,7 @@ posecode exercise "Deadlift" step "Lift" 1.4s ease-out: pelvis: hinge 0 knees: flex 0 - shoulders: extend 0 + shoulders: flex 0 neck: extend 0 ground-lock: feet cue "Drive the hips forward to stand tall, bar close to the body" diff --git a/spec/examples/touch-toes.posecode b/spec/examples/touch-toes.posecode index 6229b05..1fa0611 100644 --- a/spec/examples/touch-toes.posecode +++ b/spec/examples/touch-toes.posecode @@ -3,8 +3,8 @@ posecode stretch "Touch your toes" pose start = standing step "Fold" 2.5s ease-in-out: - pelvis: hinge 120 - knees: flex 60 + pelvis: hinge 95 + knees: flex 20 neck: flex 15 reach: hand_left ankle_left reach: hand_right ankle_right