diff --git a/.changeset/bright-dancers-guide.md b/.changeset/bright-dancers-guide.md new file mode 100644 index 0000000..2c5c0ad --- /dev/null +++ b/.changeset/bright-dancers-guide.md @@ -0,0 +1,14 @@ +--- +"posecode-parser": minor +"posecode-render": minor +"posecode-embed": minor +"posecode-mcp": patch +--- + +Add scoped, ROM-checked custom start-pose overrides with deterministic loop resets, Posecode language/IR v0.3 metadata, and updated authoring guidance. + +Expose solved-frame grounding and residual self-collision diagnostics, plus a metric floor guide with facing, authored travel, and loop-reset paths. + +Keep the renderer peer range compatible with the parser's new start-pose IR. + +Keep the MCP initialization identity synchronized with its published package version. diff --git a/ROADMAP.md b/ROADMAP.md index 481e65d..e623f5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,7 +18,7 @@ domain needs, so contributions land where they unlock the most. | **Fitness / strength** | Body-weight and free-form movement coaching | Core today (squat, curl, raise). Barbell/dumbbell/machine work needs props + grip. | | **Functional / elderly care** | Sit-to-stand, balance, gentle ROM, fall-prevention drills | Partial: sit-to-stand works; reaching/balance need reach-IK + props. | | **Sports technique** | Golf swing, tennis serve, throwing, kicking | Partial: needs trunk rotation fidelity, weight shift, and implements (club/racket/ball). | -| **Dance / choreography** | Notating sequences, port de bras, phrases that turn & travel | Phrases, port de bras, pirouettes, and traveling combos (box-step, grapevine, chassé) work via `turn`/`travel`; partner work is future. | +| **Dance / choreography** | Notating sequences, port de bras, phrases that turn & travel | `turn` / `travel` and several experimental examples exist. Ballet terminology, technique fidelity, spotting, and floor-pattern readability still need expert review; partner work is future. | | **Martial arts** | Stances, strikes, basic forms | Stances/strikes partly work; contact and weapons are future. | | **Sign language / gesture** | Finger-spelling, signs, expressive gesture | Partial: single-DOF finger curls render visibly (fist, pinch, wave, rough finger-spelling); exact sign language needs multi-joint fingers + wrist orientation. | @@ -65,6 +65,33 @@ These are the unlocks, roughly in order of leverage: 7. **Two-person + collision**: partner stretches, assisted rehab, contact sports (still deferred in the spec). +## Feedback-driven implementation order (July 2026) + +This sequence turns the first external-user review into small, testable slices. It +starts with correctness and observability, then builds one shared motion-export +foundation before adding formats. The detailed dance/UAT list remains in +[issue #91](https://github.com/posecode-dev/posecode/issues/91), glTF in +[issue #90](https://github.com/posecode-dev/posecode/issues/90), and BVH in +[issue #63](https://github.com/posecode-dev/posecode/issues/63). + +| Order | Slice | Completion evidence | +| --- | --- | --- | +| 0 | **Clarify the language contract.** Keep the public spec canonical, distinguish it from the LLM guide, define `ground-lock` / `reach` / `pin` / `grip`, and state that cues are display-only. | Implemented in the feedback branch with documentation-contract, playground, and renderer regression tests. | +| 1 | **Make current solver failures visible.** Add heel/toe height, sole-angle, foot-drift, and residual-collision diagnostics over whole clips. Reproduce the deadlift, demi-plié, and arms-lowering reports as fixtures. | **Implemented in the feedback branch.** Live viewer warnings and 12Hz clip diagnostics now name the grounding/ROM conflict or residual collision; strict known failures remain non-gating until their solver fixes land. | +| 2 | **Add small authoring controls.** Support a built-in start pose plus sparse joint overrides; improve floor origin, facing, and metre-scale markers. | **Implemented in the feedback branch.** Parser, LSP, share-link, loop-reset, floor-guide, travel/reset-path, accessibility, and responsive UI tests cover the new behavior. | +| 3 | **Separate shape from motion limits.** Introduce explicit, named ROM profiles and contact-aware ankle limits. Do not infer movement limits from a `male` / `female` label; body proportions, rig topology, and an individual's mobility are separate inputs. | General and expert-reviewed dance profiles produce deterministic clamp diagnostics; old documents retain today's default. | +| 4 | **Run expert dance UAT.** Mark unreviewed ballet examples experimental; record the school/convention and reviewer; then fix demi-plié, rise terminology, pirouette/spotting, and chassé mechanics. | Each promoted example has a reviewer/reference and pose/contact/orientation regression checks. | +| 5 | **Extract a solved-motion sampler.** Move the final post-grounding, post-IK, post-collision pose sampling out of the viewer so evaluation and exporters consume identical transforms. | Configurable-FPS samples round-trip through the viewer with matching root and local joint transforms. | +| 6 | **Ship glTF/GLB export.** First export one compatible skinned rig plus one baked animation clip; then add multiple clips on the same rig and minimal editable materials. | Three.js `GLTFLoader` round-trip plus a Godot smoke test; no retargeting required for the first version. | +| 7 | **Ship BVH export from the same sampler.** Specify hierarchy, axes, units, Euler order, frame time, and end sites before serializing. | `BVHLoader` round-trip plus documented Blender import/export validation. | +| 8 | **Broaden the pipeline.** Add configurable rig adapters/retargeting, then a visual authoring layer and optional natural-language front end. Explore Labanotation only as a bounded translator with an explicit unsupported-feature report. | Additional rigs pass adapter fixtures; non-code edits remain deterministic and export the same motion as text-authored documents. | + +Natural-language animation is best treated as an input surface, not the competing +core. Posecode's role is the deterministic, inspectable, editable constraint and +interchange layer underneath a prompt UI, visual editor, cache, or generated clip. +The comparison should be measured on repeatability, targeted edits, contact +correctness, diagnostics, and export—not only first-draft generation speed. + ## Prop / equipment library (future) Each prop is a small scene object + an anchor type; movements then reference it @@ -86,7 +113,16 @@ Each prop is a small scene object + an anchor type; movements then reference it ## Current limitations (honest) -- One figure only; partner work and collision are still deferred. +- One figure only; partner work and inter-person collision are still deferred. +- The character adapter currently expects a Mixamo-compatible bone set. Body + proportions can vary, but arbitrary naming/topology and distributing motion + across extra spine or shoulder bones are not supported yet. +- Range-of-motion uses one general profile. It is not individualized by body, + training background, task, or weight-bearing context. +- Self-collision is a bounded corrective pass over selected body pairs, not a + comprehensive physics system. It exposes residuals for those sampled pairs, + but does not detect every possible body-body collision. +- There is no glTF/GLB or BVH motion export yet. - A **starter** prop set (chair / wall / bar / box / dip bars): no bench, rings, bands, or loaded implements yet, and props sit at fixed default placements. diff --git a/package-lock.json b/package-lock.json index 65309a3..e8f7aef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7130,7 +7130,7 @@ "@types/three": "^0.171.0" }, "peerDependencies": { - "posecode-parser": ">=0.2.2 <0.4.0" + "posecode-parser": ">=0.2.2 <0.5.0" } }, "packages/posecode-share": { diff --git a/packages/posecode-embed/README.md b/packages/posecode-embed/README.md index de5de6d..2c181dd 100644 --- a/packages/posecode-embed/README.md +++ b/packages/posecode-embed/README.md @@ -9,7 +9,7 @@ the movement as an animated 3D figure, right where a share link would have gone. ## Quick start (CDN, no build step) ```html - + @@ -35,9 +35,9 @@ posecode exercise "Lateral raise" The script auto-registers the element and boots each player when it scrolls into view. That's it. -Pin a package version in production, as above, so a deployment always uses a -known parser/render pair. A `src` URL can be relative or absolute; cross-origin -movement files must be served with CORS permission. +Replace `@latest` with an exact package version in production so a deployment +always uses a known parser/render pair. A `src` URL can be relative or absolute; +cross-origin movement files must be served with CORS permission. ## With a bundler @@ -116,7 +116,7 @@ console.log(Posecode.version, Posecode.languageVersion, result.errors); For a movement library in CI, run: ```bash -npx posecode-parser@0.2.0 validate --strict ./movements +npx posecode-parser@latest validate --strict ./movements ``` AGPL-3.0-only, part of [Posecode](https://github.com/posecode-dev/posecode). A [separate commercial license](https://github.com/posecode-dev/posecode/blob/main/docs/legal/COMMERCIAL-LICENSE.md) is available for closed-source product use. diff --git a/packages/posecode-embed/test/compat.test.ts b/packages/posecode-embed/test/compat.test.ts index eceda0b..82198c9 100644 --- a/packages/posecode-embed/test/compat.test.ts +++ b/packages/posecode-embed/test/compat.test.ts @@ -28,6 +28,6 @@ describe("embed compatibility contract", () => { readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8"), ) as { version: string }; expect(version).toBe(pkg.version); - expect(languageVersion).toBe("0.2"); + expect(languageVersion).toBe("0.3"); }); }); diff --git a/packages/posecode-eval/src/diagnostics.ts b/packages/posecode-eval/src/diagnostics.ts new file mode 100644 index 0000000..27f06f6 --- /dev/null +++ b/packages/posecode-eval/src/diagnostics.ts @@ -0,0 +1,371 @@ +/** Clip-wide aggregation of renderer constraint diagnostics. */ +import { + measureFootContact, + measureSelfCollisions, + floorContactHeight, + FOOT_CONTACT_HEIGHT_MAX, + PLANTIGRADE_SOLE_ANGLE_MAX, + SELF_COLLISION_DEPTH_MAX, + isGroundLockFootPlanted, + type Mannequin, + type SelfCollisionKind, +} from "posecode-render"; +import type { PinTarget } from "posecode-parser"; +import type { Vec3 } from "./probe.js"; + +export const DEFAULT_DIAGNOSTIC_SAMPLE_RATE_HZ = 12; +/** Warning threshold for one continuously supported foot's horizontal drift. */ +export const PLANTED_FOOT_DRIFT_MAX = 0.03; +/** Small proxy/solver allowance while a raised heel pivots on its toe edge. */ +export const TIPTOE_FOOT_DRIFT_MAX = 0.04; + +export interface DiagnosticLocation { + timeSec: number; + phaseName: string; +} + +export interface FootClipDiagnostics { + side: "left" | "right"; + supportedSamples: number; + plantigradeSamples: number; + minHeelHeightMeters: number | null; + maxHeelHeightMeters: number | null; + minToeHeightMeters: number | null; + maxToeHeightMeters: number | null; + maxSoleAngleDeg: number | null; + /** Maximum X/Z movement from the start of one continuous support interval. */ + maxDriftMeters: number; + maxPlantigradeDriftMeters: number; + maxTiptoeDriftMeters: number; + worstHeel: DiagnosticLocation | null; + /** Whether the worst heel lift coincided with the configured dorsiflexion bound. */ + worstHeelAtDorsiflexionLimit: boolean; + /** Local ankle +X angle at the worst heel sample (negative is dorsiflexion). */ + worstHeelAnkleXDeg: number | null; + worstToe: DiagnosticLocation | null; + worstSoleAngle: DiagnosticLocation | null; + worstDrift: DiagnosticLocation | null; + worstPlantigradeDrift: DiagnosticLocation | null; + worstTiptoeDrift: DiagnosticLocation | null; +} + +export interface SelfCollisionClipDiagnostics extends DiagnosticLocation { + id: string; + kind: SelfCollisionKind; + side: "left" | "right"; + maxDepthMeters: number; +} + +export type ClipDiagnosticWarningKind = + | "heel-height" + | "toe-height" + | "sole-angle" + | "foot-drift" + | "grounding-rom-conflict" + | "self-collision"; + +/** Strict, named constraint breach surfaced without changing the CI gate. */ +export interface ClipDiagnosticWarning extends DiagnosticLocation { + id: string; + kind: ClipDiagnosticWarningKind; + value: number; + limit: number; + unit: "m" | "deg"; + detail: string; +} + +/** Diagnostics aggregated from regularly sampled, fully solved clip frames. */ +export interface ClipDiagnostics { + sampleRateHz: number; + sampleCount: number; + feet: Readonly>; + selfCollisions: readonly SelfCollisionClipDiagnostics[]; + /** Known constraint breaches; intentionally non-gating until solver fixes land. */ + warnings: readonly ClipDiagnosticWarning[]; +} + +export interface DiagnosticFrame { + timeSec: number; + phaseName: string; + groundLock: readonly string[]; + pins: readonly PinTarget[]; + rootOffset: Vec3; + rootYaw: number; + propPush: Vec3; +} + +interface MutableFoot extends FootClipDiagnostics { + supportKind: "ground-lock" | "pin" | null; + supportAnchorMode: "sole" | "toe" | null; + supportAnchor: readonly [x: number, z: number] | null; + worstHeelAbs: number; + worstToeAbs: number; +} + +const footState = (side: "left" | "right"): MutableFoot => ({ + side, + supportedSamples: 0, + plantigradeSamples: 0, + minHeelHeightMeters: null, + maxHeelHeightMeters: null, + minToeHeightMeters: null, + maxToeHeightMeters: null, + maxSoleAngleDeg: null, + maxDriftMeters: 0, + maxPlantigradeDriftMeters: 0, + maxTiptoeDriftMeters: 0, + worstHeel: null, + worstHeelAtDorsiflexionLimit: false, + worstHeelAnkleXDeg: null, + worstToe: null, + worstSoleAngle: null, + worstDrift: null, + worstPlantigradeDrift: null, + worstTiptoeDrift: null, + supportKind: null, + supportAnchorMode: null, + supportAnchor: null, + worstHeelAbs: -Infinity, + worstToeAbs: -Infinity, +}); + +function supportKind( + frame: DiagnosticFrame, + side: "left" | "right", +): "ground-lock" | "pin" | null { + if (frame.pins.some((pin) => + pin.anchor === "floor" && (pin.effector === "feet" || pin.effector === `foot_${side}`))) { + return "pin"; + } + return frame.groundLock.includes("feet") || frame.groundLock.includes(`foot_${side}`) + ? "ground-lock" + : null; +} + +function choreographyRelative( + point: readonly [number, number, number], + frame: DiagnosticFrame, +): readonly [number, number] { + const x = point[0] - frame.rootOffset[0] - frame.propPush[0]; + const z = point[2] - frame.rootOffset[2] - frame.propPush[2]; + const c = Math.cos(-frame.rootYaw); + const s = Math.sin(-frame.rootYaw); + return [x * c - z * s, x * s + z * c]; +} + +export interface ClipDiagnosticsCollector { + record(m: Mannequin, frame: DiagnosticFrame): void; + finish(): ClipDiagnostics; +} + +export function createClipDiagnosticsCollector(sampleRateHz: number): ClipDiagnosticsCollector { + const rate = Math.max( + 1, + Math.min(120, Number.isFinite(sampleRateHz) ? sampleRateHz : DEFAULT_DIAGNOSTIC_SAMPLE_RATE_HZ), + ); + let sampleCount = 0; + const feet = { left: footState("left"), right: footState("right") }; + const collisions = new Map(); + + const record = (m: Mannequin, frame: DiagnosticFrame): void => { + sampleCount++; + for (const side of ["left", "right"] as const) { + const state = feet[side]; + const foot = measureFootContact(m, side); + let kind = supportKind(frame, side); + // The generic `feet` group also contains a deliberately lifted swing + // foot. Match ground-lock's own near-floor selection so that swing height + // is not mislabeled as a failed planted contact; an explicit foot lock or + // floor pin is always evaluated. + if ( + kind === "ground-lock" + && frame.groundLock.includes("feet") + && !frame.groundLock.includes(`foot_${side}`) + && foot + && !isGroundLockFootPlanted(floorContactHeight(m, `foot_${side}`) ?? NaN) + ) kind = null; + if (!kind || !foot) { + state.supportKind = null; + state.supportAnchorMode = null; + state.supportAnchor = null; + continue; + } + state.supportedSamples++; + const location = { timeSec: frame.timeSec, phaseName: frame.phaseName }; + state.minToeHeightMeters = Math.min(state.minToeHeightMeters ?? Infinity, foot.toeHeight); + state.maxToeHeightMeters = Math.max(state.maxToeHeightMeters ?? -Infinity, foot.toeHeight); + if (Math.abs(foot.toeHeight) > state.worstToeAbs) { + state.worstToeAbs = Math.abs(foot.toeHeight); + state.worstToe = location; + } + if (foot.plantigrade) { + state.plantigradeSamples++; + state.minHeelHeightMeters = Math.min(state.minHeelHeightMeters ?? Infinity, foot.heelHeight); + state.maxHeelHeightMeters = Math.max(state.maxHeelHeightMeters ?? -Infinity, foot.heelHeight); + state.maxSoleAngleDeg = Math.max(state.maxSoleAngleDeg ?? -Infinity, foot.soleAngleDeg); + if (Math.abs(foot.heelHeight) > state.worstHeelAbs) { + state.worstHeelAbs = Math.abs(foot.heelHeight); + state.worstHeel = location; + state.worstHeelAtDorsiflexionLimit = foot.atDorsiflexionLimit; + state.worstHeelAnkleXDeg = foot.plantarflexDeg; + } + if (foot.soleAngleDeg >= (state.maxSoleAngleDeg ?? -Infinity)) state.worstSoleAngle = location; + } + + // A plantigrade foot is supported by its full sole, while a deliberate + // rise pivots about the toe/ball edge. Measuring the sole centre during + // plantarflexion fabricates drift from normal foot rotation. + const anchorMode = foot.plantigrade ? "sole" : "toe"; + const contactPoint = anchorMode === "sole" ? foot.center : foot.toeCenter; + const position = kind === "pin" + ? [contactPoint[0], contactPoint[2]] as const + : choreographyRelative(contactPoint, frame); + if ( + state.supportKind !== kind + || state.supportAnchorMode !== anchorMode + || !state.supportAnchor + ) { + state.supportKind = kind; + state.supportAnchorMode = anchorMode; + state.supportAnchor = position; + } else { + const drift = Math.hypot( + position[0] - state.supportAnchor[0], + position[1] - state.supportAnchor[1], + ); + if (drift > state.maxDriftMeters) { + state.maxDriftMeters = drift; + state.worstDrift = location; + } + if (anchorMode === "sole" && drift > state.maxPlantigradeDriftMeters) { + state.maxPlantigradeDriftMeters = drift; + state.worstPlantigradeDrift = location; + } + if (anchorMode === "toe" && drift > state.maxTiptoeDriftMeters) { + state.maxTiptoeDriftMeters = drift; + state.worstTiptoeDrift = location; + } + } + } + + for (const residual of measureSelfCollisions(m)) { + const previous = collisions.get(residual.id); + if (!previous || residual.depth > previous.maxDepthMeters) { + collisions.set(residual.id, { + id: residual.id, + kind: residual.kind, + side: residual.side, + maxDepthMeters: residual.depth, + timeSec: frame.timeSec, + phaseName: frame.phaseName, + }); + } + } + }; + + const finish = (): ClipDiagnostics => { + const publicFoot = (state: MutableFoot): FootClipDiagnostics => ({ + side: state.side, + supportedSamples: state.supportedSamples, + plantigradeSamples: state.plantigradeSamples, + minHeelHeightMeters: state.minHeelHeightMeters, + maxHeelHeightMeters: state.maxHeelHeightMeters, + minToeHeightMeters: state.minToeHeightMeters, + maxToeHeightMeters: state.maxToeHeightMeters, + maxSoleAngleDeg: state.maxSoleAngleDeg, + maxDriftMeters: state.maxDriftMeters, + maxPlantigradeDriftMeters: state.maxPlantigradeDriftMeters, + maxTiptoeDriftMeters: state.maxTiptoeDriftMeters, + worstHeel: state.worstHeel, + worstHeelAtDorsiflexionLimit: state.worstHeelAtDorsiflexionLimit, + worstHeelAnkleXDeg: state.worstHeelAnkleXDeg, + worstToe: state.worstToe, + worstSoleAngle: state.worstSoleAngle, + worstDrift: state.worstDrift, + worstPlantigradeDrift: state.worstPlantigradeDrift, + worstTiptoeDrift: state.worstTiptoeDrift, + }); + const publicFeet = { left: publicFoot(feet.left), right: publicFoot(feet.right) }; + const publicCollisions = [...collisions.values()]; + const warnings: ClipDiagnosticWarning[] = []; + const pushFootWarning = ( + side: "left" | "right", + kind: Exclude, + value: number, + limit: number, + unit: "m" | "deg", + location: DiagnosticLocation | null, + label: string, + ): void => { + if (value <= limit || !location) return; + warnings.push({ + id: `clip-${kind}:foot_${side}`, + kind, + value, + limit, + unit, + timeSec: location.timeSec, + phaseName: location.phaseName, + detail: `${label} ${value.toFixed(unit === "m" ? 3 : 1)}${unit} at ${location.phaseName} ${location.timeSec.toFixed(2)}s (want ≤ ${limit.toFixed(unit === "m" ? 3 : 1)}${unit})`, + }); + }; + for (const side of ["left", "right"] as const) { + const foot = publicFeet[side]; + const heel = Math.max( + Math.abs(foot.minHeelHeightMeters ?? 0), + Math.abs(foot.maxHeelHeightMeters ?? 0), + ); + const toe = Math.max( + Math.abs(foot.minToeHeightMeters ?? 0), + Math.abs(foot.maxToeHeightMeters ?? 0), + ); + pushFootWarning(side, "heel-height", heel, FOOT_CONTACT_HEIGHT_MAX, "m", foot.worstHeel, `foot_${side} heel floor offset`); + if ( + heel > FOOT_CONTACT_HEIGHT_MAX + && foot.worstHeelAtDorsiflexionLimit + && foot.worstHeel + ) { + warnings.push({ + id: `clip-grounding-rom-conflict:foot_${side}`, + kind: "grounding-rom-conflict", + value: heel, + limit: FOOT_CONTACT_HEIGHT_MAX, + unit: "m", + timeSec: foot.worstHeel.timeSec, + phaseName: foot.worstHeel.phaseName, + detail: `foot_${side} heel is ${heel.toFixed(3)}m off floor while ankle is at ${foot.worstHeelAnkleXDeg?.toFixed(1)}° (configured dorsiflexion limit)`, + }); + } + pushFootWarning(side, "toe-height", toe, FOOT_CONTACT_HEIGHT_MAX, "m", foot.worstToe, `foot_${side} toe floor offset`); + pushFootWarning(side, "sole-angle", foot.maxSoleAngleDeg ?? 0, PLANTIGRADE_SOLE_ANGLE_MAX, "deg", foot.worstSoleAngle, `foot_${side} sole tilt`); + const plantigradeRatio = foot.maxPlantigradeDriftMeters / PLANTED_FOOT_DRIFT_MAX; + const tiptoeRatio = foot.maxTiptoeDriftMeters / TIPTOE_FOOT_DRIFT_MAX; + if (plantigradeRatio >= tiptoeRatio) { + pushFootWarning(side, "foot-drift", foot.maxPlantigradeDriftMeters, PLANTED_FOOT_DRIFT_MAX, "m", foot.worstPlantigradeDrift, `foot_${side} planted drift`); + } else { + pushFootWarning(side, "foot-drift", foot.maxTiptoeDriftMeters, TIPTOE_FOOT_DRIFT_MAX, "m", foot.worstTiptoeDrift, `foot_${side} toe-anchor drift`); + } + } + for (const collision of publicCollisions) { + if (collision.maxDepthMeters <= SELF_COLLISION_DEPTH_MAX) continue; + warnings.push({ + id: collision.id, + kind: "self-collision", + value: collision.maxDepthMeters, + limit: SELF_COLLISION_DEPTH_MAX, + unit: "m", + timeSec: collision.timeSec, + phaseName: collision.phaseName, + detail: `${collision.id} residual ${collision.maxDepthMeters.toFixed(3)}m at ${collision.phaseName} ${collision.timeSec.toFixed(2)}s (want ≤ ${SELF_COLLISION_DEPTH_MAX.toFixed(3)}m)`, + }); + } + return { + sampleRateHz: rate, + sampleCount, + feet: publicFeet, + selfCollisions: publicCollisions, + warnings, + }; + }; + return { record, finish }; +} diff --git a/packages/posecode-eval/src/index.ts b/packages/posecode-eval/src/index.ts index cb6af49..c3e148d 100644 --- a/packages/posecode-eval/src/index.ts +++ b/packages/posecode-eval/src/index.ts @@ -5,11 +5,28 @@ export type { ContactKind, ContactResidual, ContactStatus, + ProbeOptions, ProbeResult, PhasePose, Quat, Vec3, } from "./probe.js"; +export { + DEFAULT_DIAGNOSTIC_SAMPLE_RATE_HZ, + PLANTED_FOOT_DRIFT_MAX, + TIPTOE_FOOT_DRIFT_MAX, + createClipDiagnosticsCollector, +} from "./diagnostics.js"; +export type { + ClipDiagnostics, + ClipDiagnosticsCollector, + ClipDiagnosticWarning, + ClipDiagnosticWarningKind, + DiagnosticFrame, + DiagnosticLocation, + FootClipDiagnostics, + SelfCollisionClipDiagnostics, +} from "./diagnostics.js"; export { angleBetweenDeg, balanceOverflow, diff --git a/packages/posecode-eval/src/probe.ts b/packages/posecode-eval/src/probe.ts index 1eb2a4a..0ed3b26 100644 --- a/packages/posecode-eval/src/probe.ts +++ b/packages/posecode-eval/src/probe.ts @@ -37,6 +37,9 @@ import { floorContactHeight, floorTargetForEffector, formFists, + relaxHands, + swingArms, + aimHead, groundFigure, isDipBarGrip, levelPlantedFeet, @@ -50,6 +53,11 @@ import { type Character, type Proportions, } from "posecode-render"; +import { + DEFAULT_DIAGNOSTIC_SAMPLE_RATE_HZ, + createClipDiagnosticsCollector, + type ClipDiagnostics, +} from "./diagnostics.js"; export type Vec3 = readonly [x: number, y: number, z: number]; export type Quat = readonly [x: number, y: number, z: number, w: number]; @@ -119,6 +127,13 @@ export interface ProbeResult { propTypes: readonly string[]; /** Flattened contact residuals for scorecards/consumers that do not walk phases. */ contactResiduals: readonly ContactResidual[]; + /** Constraint residuals aggregated over regularly sampled solved clip frames. */ + diagnostics: ClipDiagnostics; +} + +export interface ProbeOptions { + /** Sampling rate for clip-wide grounding/collision diagnostics. Defaults to 12Hz. */ + diagnosticSampleRateHz?: number; } const DEG = Math.PI / 180; @@ -156,15 +171,85 @@ function fistSidesOf( return sides; } +function gripSidesOf(grips: readonly { effector: string }[]): Set<"left" | "right"> { + const sides = new Set<"left" | "right">(); + for (const grip of grips) { + if (grip.effector.endsWith("_left") || grip.effector === "hands") sides.add("left"); + if (grip.effector.endsWith("_right") || grip.effector === "hands") sides.add("right"); + } + return sides; +} + +function contactHandSidesOf( + reaches: readonly { effector: string }[], + pins: readonly { effector: string }[], + grips: readonly { effector: string }[], + groundLock: readonly string[], +): Set<"left" | "right"> { + const sides = gripSidesOf(grips); + const add = (effector: string): void => { + if (/^(?:hand|fist|elbow)_left$/.test(effector) + || effector === "hands" || effector === "fists" || effector === "forearms") sides.add("left"); + if (/^(?:hand|fist|elbow)_right$/.test(effector) + || effector === "hands" || effector === "fists" || effector === "forearms") sides.add("right"); + }; + reaches.forEach((contact) => add(contact.effector)); + pins.forEach((contact) => add(contact.effector)); + groundLock.forEach(add); + return sides; +} + +function floorHandSidesOf( + reaches: readonly { effector: string; target: string }[], + pins: readonly { effector: string; anchor: string }[], + groundLock: readonly string[], +): Set<"left" | "right"> { + const sides = new Set<"left" | "right">(); + const add = (effector: string): void => { + if (effector === "hands" || effector === "hand_left") sides.add("left"); + if (effector === "hands" || effector === "hand_right") sides.add("right"); + }; + reaches.filter((reach) => reach.target === "floor").forEach((reach) => add(reach.effector)); + pins.filter((pin) => pin.anchor === "floor").forEach((pin) => add(pin.effector)); + groundLock.forEach(add); + return sides; +} + +function unionHandSides( + a: ReadonlySet<"left" | "right">, + b: ReadonlySet<"left" | "right">, +): Set<"left" | "right"> { + return new Set([...a, ...b]); +} + /** Probe a movement: FK + root solving at each phase end, viewer-faithful. */ export function probeMovement( source: string, proportions?: Proportions, character?: Character, + options: ProbeOptions = {}, ): ProbeResult { const { ir, errors, warnings } = parse(source); + const requestedDiagnosticSampleRate = options.diagnosticSampleRateHz; + const diagnosticSampleRateHz = Math.max( + 1, + Math.min( + 120, + requestedDiagnosticSampleRate !== undefined && Number.isFinite(requestedDiagnosticSampleRate) + ? requestedDiagnosticSampleRate + : DEFAULT_DIAGNOSTIC_SAMPLE_RATE_HZ, + ), + ); if (!ir || errors.length > 0) { - return { ok: false, errors, warnings, phases: [], propTypes: [], contactResiduals: [] }; + return { + ok: false, + errors, + warnings, + phases: [], + propTypes: [], + contactResiduals: [], + diagnostics: createClipDiagnosticsCollector(diagnosticSampleRateHz).finish(), + }; } const m = buildMannequin(undefined, proportions); @@ -173,6 +258,8 @@ export function probeMovement( const authoredFingers = new Set(tl.bonesUsed.filter((id) => /^(thumb|index|middle|ring|pinky)_(left|right)$/.test(id), )); + const authoredShoulders = new Set(tl.bonesUsed.filter((id) => id.startsWith("shoulder_"))); + const authoredHead = tl.bonesUsed.some((id) => id === "head" || id === "neck"); // Mirror Viewer.load(): reset bones, apply the base-pose root, pose at t=0, // then drop the figure onto the floor and remember the grounded base root. @@ -183,14 +270,29 @@ export function probeMovement( m.root.rotation.set(rx * DEG, ry * DEG, rz * DEG); tl.sample(0, m.bones); m.root.updateMatrixWorld(true); - formFists( + const initialPhase = ir.phases[0]; + const initialFistSides = fistSidesOf( + initialPhase?.reaches ?? [], + initialPhase?.pins ?? [], + initialPhase?.groundLock ?? [], + ); + const initialGripSides = gripSidesOf(initialPhase?.grips ?? []); + const initialConstrainedHandSides = contactHandSidesOf( + initialPhase?.reaches ?? [], + initialPhase?.pins ?? [], + initialPhase?.grips ?? [], + initialPhase?.groundLock ?? [], + ); + formFists(m, initialFistSides, authoredFingers); + relaxHands( m, - fistSidesOf( - ir.phases[0]?.reaches ?? [], - ir.phases[0]?.pins ?? [], - ir.phases[0]?.groundLock ?? [], - ), + unionHandSides(initialGripSides, initialFistSides), authoredFingers, + floorHandSidesOf( + initialPhase?.reaches ?? [], + initialPhase?.pins ?? [], + initialPhase?.groundLock ?? [], + ), ); alignFloorContacts( m, @@ -205,6 +307,9 @@ export function probeMovement( ...(ir.phases[0]?.grips ?? []), ...(ir.phases[0]?.reaches ?? []).map((r) => ({ effector: r.effector, anchor: r.target })), ])); + levelPlantedFeet(m, initialPhase?.groundLock ?? []); + swingArms(m, authoredShoulders, initialConstrainedHandSides); + enforceContactRom(m); const baseRootPos = m.root.position.clone(); const baseRootQuat = m.root.quaternion.clone(); @@ -297,6 +402,25 @@ export function probeMovement( return null; }; + const applyLookAt = (info: { + grips: readonly GripTarget[]; + reaches: readonly ReachTarget[]; + }): void => { + if (authoredHead) return; + const points: THREE.Vector3[] = []; + const collect = (effector: string, target: string): void => { + const resolved = resolveTarget(target, effector) + ?? resolveTarget(target.replace(/_(left|right)$/, ""), effector); + if (resolved) points.push(resolved.point); + }; + info.grips.forEach((grip) => collect(grip.effector, grip.anchor)); + info.reaches.forEach((reach) => collect(reach.effector, reach.target)); + if (points.length === 0) return; + const focus = new THREE.Vector3(); + points.forEach((point) => focus.add(point)); + aimHead(m, focus.multiplyScalar(1 / points.length)); + }; + const unsupported = ( kind: ContactKind, effector: string, @@ -688,6 +812,107 @@ export function probeMovement( }; }); + // Endpoint probes above power semantic movement checks. Separately sample + // the solved clip between endpoints so a heel lift or collision that appears + // only mid-transition cannot hide behind two valid terminal poses. + const diagnosticsCollector = createClipDiagnosticsCollector(diagnosticSampleRateHz); + for (let phaseIndex = 0; phaseIndex < tl.segments.length; phaseIndex++) { + const seg = tl.segments[phaseIndex]!; + const steps = Math.max(1, Math.ceil((seg.end - seg.start) * diagnosticSampleRateHz)); + const firstStep = phaseIndex === 0 ? 0 : 1; + for (let step = firstStep; step <= steps; step++) { + const fraction = step / steps; + const rawTime = seg.start + (seg.end - seg.start) * fraction; + const sampleTime = Math.min(rawTime, seg.end - EPS); + for (const bone of m.bones.values()) bone.quaternion.identity(); + const info = tl.sample(sampleTime, m.bones); + m.root.position.copy(baseRootPos); + m.root.quaternion.copy(baseRootQuat); + if (info.rootYaw !== 0) { + yawQ.setFromAxisAngle(WORLD_Y, info.rootYaw); + m.root.quaternion.premultiply(yawQ); + } + m.root.position.x += info.rootOffset.x; + m.root.position.z += info.rootOffset.z; + m.root.updateMatrixWorld(true); + const fistSides = fistSidesOf(info.reaches, info.pins, info.groundLock); + const gripSides = gripSidesOf(info.grips); + const constrainedHandSides = contactHandSidesOf( + info.reaches, + info.pins, + info.grips, + info.groundLock, + ); + formFists(m, fistSides, authoredFingers); + relaxHands( + m, + unionHandSides(gripSides, fistSides), + authoredFingers, + floorHandSidesOf(info.reaches, info.pins, info.groundLock), + ); + alignFloorContacts(m, info.reaches, info.pins, info.groundLock); + depenetrate(m); + + const anchors = new Map(); + for (const [id, captured] of groundTargets) { + const point = captured.clone(); + if (info.rootYaw !== 0) { + point.sub(baseRootPos).applyAxisAngle(WORLD_Y, info.rootYaw).add(baseRootPos); + } + point.x += info.rootOffset.x; + point.z += info.rootOffset.z; + anchors.set(id, point); + } + applyGroundLock(m, info.groundLock, anchors); + applyPins(info.pins, phaseIndex); + applyGrips(info.grips); + + const prePush = m.root.position.clone(); + resolvePropContacts(m, propScene.colliders, propContactExemptions([ + ...info.pins, + ...info.grips, + ...info.reaches.map((reach) => ({ effector: reach.effector, anchor: reach.target })), + ])); + const propPush: Vec3 = [ + m.root.position.x - prePush.x, + 0, + m.root.position.z - prePush.z, + ]; + applyReaches(info.reaches); + alignFloorContacts(m, info.reaches, info.pins, info.groundLock); + levelPlantedFeet(m, info.groundLock); + swingArms(m, authoredShoulders, constrainedHandSides); + applyLookAt(info); + enforceContactRom(m); + if (info.groundLock.length > 0 && info.reaches.length > 0) { + for (let refinement = 0; refinement < 3; refinement++) { + applyGroundLock(m, info.groundLock, anchors); + applyReaches(info.reaches); + alignFloorContacts(m, info.reaches, info.pins, info.groundLock); + enforceContactRom(m); + } + } + m.root.updateMatrixWorld(true); + const box = new THREE.Box3().setFromObject(m.root); + const floorBound = info.grips.length === 0 + && !info.pins.some((pin) => pin.anchor !== "floor"); + if (box.min.y < 0 || (floorBound && box.min.y > 0)) { + m.root.position.y -= box.min.y; + m.root.updateMatrixWorld(true); + } + diagnosticsCollector.record(m, { + timeSec: sampleTime, + phaseName: seg.name, + groundLock: info.groundLock, + pins: info.pins, + rootOffset: [info.rootOffset.x, 0, info.rootOffset.z], + rootYaw: info.rootYaw, + propPush, + }); + } + } + const diagnostics = diagnosticsCollector.finish(); + return { ok: true, errors, @@ -695,6 +920,7 @@ export function probeMovement( phases, propTypes: [...ir.props], contactResiduals: phases.flatMap((phase) => [...phase.contactResiduals]), + diagnostics, }; } diff --git a/packages/posecode-eval/src/report.ts b/packages/posecode-eval/src/report.ts index 95647d9..69ef51d 100644 --- a/packages/posecode-eval/src/report.ts +++ b/packages/posecode-eval/src/report.ts @@ -6,6 +6,7 @@ import { probeMovement } from "./probe.js"; import { genericChecks, MOVEMENT_CHECKS, type CheckOutcome } from "./checks.js"; import type { Character, Proportions } from "posecode-render"; +import type { ClipDiagnostics } from "./diagnostics.js"; export interface MovementSource { /** Identifier matched against MOVEMENT_CHECKS (e.g. file stem "deadlift"). */ @@ -18,6 +19,8 @@ export interface MovementReport { parseOk: boolean; clampWarnings: number; checks: CheckOutcome[]; + /** Strict clip-wide constraint diagnostics; warnings do not change CI pass counts. */ + diagnostics: ClipDiagnostics; passed: number; total: number; } @@ -30,6 +33,7 @@ export interface EvalReport { clampWarnings: number; checksPassed: number; checksTotal: number; + constraintWarnings: number; }; } @@ -38,6 +42,8 @@ export interface EvalOptions { proportions?: Proportions; /** Optional retargeted visible character sampled after each solved phase. */ character?: Character; + /** Sampling rate for clip-wide grounding/collision diagnostics. Defaults to 12Hz. */ + diagnosticSampleRateHz?: number; } export function runEval( @@ -53,6 +59,7 @@ export function runEval( clampWarnings: movements.reduce((n, m) => n + m.clampWarnings, 0), checksPassed: movements.reduce((n, m) => n + m.passed, 0), checksTotal: movements.reduce((n, m) => n + m.total, 0), + constraintWarnings: movements.reduce((n, m) => n + m.diagnostics.warnings.length, 0), }, }; } @@ -61,7 +68,12 @@ function evalMovement( { movement, source }: MovementSource, options: EvalOptions, ): MovementReport { - const result = probeMovement(source, options.proportions, options.character); + const result = probeMovement( + source, + options.proportions, + options.character, + { diagnosticSampleRateHz: options.diagnosticSampleRateHz }, + ); const specific = MOVEMENT_CHECKS.find((m) => m.movement === movement); const checks = [ ...genericChecks(result), @@ -72,6 +84,7 @@ function evalMovement( parseOk: result.ok, clampWarnings: result.warnings.length, checks, + diagnostics: result.diagnostics, passed: checks.filter((c) => c.pass).length, total: checks.length, }; @@ -86,12 +99,16 @@ export function renderReport(report: EvalReport): string { for (const c of m.checks.filter((c) => !c.pass)) { lines.push(` ✗ ${c.id}: ${c.detail}`); } + for (const warning of m.diagnostics.warnings) { + lines.push(` ⚠ ${warning.id}: ${warning.detail}`); + } } const s = report.summary; lines.push(""); lines.push( `${s.checksPassed}/${s.checksTotal} checks · ${s.movements} movements · ` + - `${s.parseFailures} parse failures · ${s.clampWarnings} clamp warnings`, + `${s.parseFailures} parse failures · ${s.clampWarnings} clamp warnings · ` + + `${s.constraintWarnings} constraint warnings`, ); return lines.join("\n"); } diff --git a/packages/posecode-eval/test/eval.test.ts b/packages/posecode-eval/test/eval.test.ts index 51a22a3..42d9eaf 100644 --- a/packages/posecode-eval/test/eval.test.ts +++ b/packages/posecode-eval/test/eval.test.ts @@ -2,9 +2,11 @@ import { describe, it, expect } from "vitest"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import type { Proportions } from "posecode-render"; +import { loadXbotCharacter } from "../src/xbot.js"; import { loadFixtures, probeMovement, + renderReport, runEval, torsoPitchDeg, kneeFlexionDeg, @@ -109,6 +111,109 @@ describe("probe", () => { }); }); +describe("clip-wide constraint diagnostics", () => { + const fixtures = loadFixtures(examplesDir); + const source = (name: string) => fixtures.find((fixture) => fixture.movement === name)!.source; + + it("keeps the deadlift heels planted through the hinge and still flags the demi-plié lift", () => { + const deadlift = probeMovement(source("deadlift")); + const plie = probeMovement(source("demi-plie")); + + expect(deadlift.diagnostics.sampleCount).toBeGreaterThan(deadlift.phases.length); + // The hip hinge keeps soft knees (18°) inside the ankle's dorsiflexion ROM + // (20°), so both soles stay flat on the floor across the whole clip. + expect(deadlift.diagnostics.feet.left.maxHeelHeightMeters).toBeLessThan(0.005); + expect(deadlift.diagnostics.warnings).toHaveLength(0); + + // The demi-plié still drives the ankle past its dorsiflexion ROM, so it + // remains the exemplar of a reported heel-lift / grounding conflict. + expect(plie.diagnostics.feet.left.maxHeelHeightMeters).toBeGreaterThan(0.02); + expect(plie.diagnostics.warnings).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "clip-heel-height:foot_left", phaseName: "Plié" }), + expect.objectContaining({ + id: "clip-grounding-rom-conflict:foot_left", + phaseName: "Plié", + kind: "grounding-rom-conflict", + }), + ])); + }); + + it("detects the residual arm collision while touch-toes lowers its arms", () => { + const result = probeMovement(source("touch-toes")); + const collision = result.diagnostics.warnings.find((warning) => + warning.id === "self-collision:arm_left:body", + ); + + expect(collision).toEqual(expect.objectContaining({ + kind: "self-collision", + phaseName: "Rise", + })); + expect(collision!.value).toBeGreaterThan(0.08); + expect(collision!.timeSec).toBeGreaterThan(2.5); + expect(collision!.timeSec).toBeLessThan(4.5); + }); + + it("keeps strict diagnostics visible but non-gating in EvalReport and CLI text", () => { + const report = runEval([{ movement: "demi-plie", source: source("demi-plie") }]); + const movement = report.movements[0]!; + const text = renderReport(report); + + expect(movement.passed).toBe(movement.total); + expect(movement.diagnostics.warnings.length).toBeGreaterThan(0); + expect(report.summary.constraintWarnings).toBe(movement.diagnostics.warnings.length); + expect(text).toContain("⚠ clip-heel-height:foot_left"); + expect(text).toContain("constraint warnings"); + }); + + it("accepts a bounded diagnostic sampling rate", () => { + const low = probeMovement(source("deadlift"), undefined, undefined, { + diagnosticSampleRateHz: 4, + }); + const high = probeMovement(source("deadlift"), undefined, undefined, { + diagnosticSampleRateHz: 24, + }); + expect(low.diagnostics.sampleRateHz).toBe(4); + expect(high.diagnostics.sampleRateHz).toBe(24); + expect(high.diagnostics.sampleCount).toBeGreaterThan(low.diagnostics.sampleCount); + }); + + it("anchors intentional rises at the toe while retaining real planted drift", () => { + for (const movement of ["heel-raises", "releve"]) { + const result = probeMovement(source(movement)); + expect(result.diagnostics.warnings.some((warning) => + warning.kind === "foot-drift", + )).toBe(false); + expect(result.diagnostics.feet.left.maxTiptoeDriftMeters).toBeGreaterThan(0); + } + + const translated = probeMovement(source("jumping-jacks")); + expect(translated.diagnostics.warnings).toContainEqual(expect.objectContaining({ + id: "clip-foot-drift:foot_left", + kind: "foot-drift", + phaseName: "Out", + })); + expect(translated.diagnostics.feet.left.maxPlantigradeDriftMeters).toBeGreaterThan(0.4); + }); + + it("keeps XBot heel raises and relevé below the toe-anchor drift warning", async () => { + const xbot = await loadXbotCharacter(new URL( + "../../../playground/public/models/xbot.glb", + import.meta.url, + )); + try { + for (const movement of ["heel-raises", "releve"]) { + const result = probeMovement(source(movement), xbot.proportions, xbot); + expect(result.diagnostics.warnings.some((warning) => + warning.kind === "foot-drift", + )).toBe(false); + expect(result.diagnostics.feet.left.maxTiptoeDriftMeters).toBeLessThan(0.04); + } + } finally { + xbot.dispose(); + } + }); +}); + describe("metrics", () => { const hinged = probeMovement( ['posecode exercise "t"', " rig humanoid", ' step "go" 1s linear:', " pelvis: hinge 70"].join("\n"), @@ -196,9 +301,17 @@ describe("metrics", () => { describe("fixture scorecard", () => { it("scores every declared canonical contact and keeps the fallback catalog green", () => { const fixtures = loadFixtures(examplesDir); - const report = runEval(fixtures); + // This test validates the scorecard/contact catalog, not diagnostic peak + // sampling. Keep one clip-wide sample per second so the full fixture pass + // remains stable on slower shared CI runners. + const report = runEval(fixtures, { diagnosticSampleRateHz: 1 }); const declaredContacts = fixtures.reduce( - (count, fixture) => count + probeMovement(fixture.source).contactResiduals.length, + (count, fixture) => count + probeMovement( + fixture.source, + undefined, + undefined, + { diagnosticSampleRateHz: 1 }, + ).contactResiduals.length, 0, ); const contactChecks = report.movements.flatMap((movement) => @@ -210,7 +323,7 @@ describe("fixture scorecard", () => { expect(report.summary.parseFailures).toBe(0); expect(report.summary.clampWarnings).toBe(0); expect(report.summary.checksPassed).toBe(report.summary.checksTotal); - }); + }, 20_000); it("does not give the supplied bad superhero landing a perfect score", () => { const source = `posecode posture "Superhero Three-Point Landing" diff --git a/packages/posecode-language/src/completion.ts b/packages/posecode-language/src/completion.ts index a59f463..5c96d77 100644 --- a/packages/posecode-language/src/completion.ts +++ b/packages/posecode-language/src/completion.ts @@ -46,28 +46,84 @@ type Context = | "grip-effector" | "action" | "joint" + | "start-joint" | "top" | "none"; -function contextFor(prefix: string, line: number): Context { +interface EnclosingBlock { + kind: "start-pose" | "step"; + indent: number; +} + +function contextFor( + prefix: string, + line: number, + enclosingBlock: EnclosingBlock | null, + documentIndent: number | null, +): Context { if (line === 0) { return /^\s*posecode\s+[\w-]*$/.test(prefix) ? "kind" : "none"; } const indent = prefix.length - prefix.trimStart().length; - if (indent === 2 && /^\s*pose\s+start\s*=\s*[\w-]*$/.test(prefix)) return "pose"; - if (indent === 2 && /^\s*step\s+"[^"]*"\s+[0-9.]+s\s+[\w-]*$/.test(prefix)) return "easing"; - if (indent >= 4 && /^\s*ground-lock\s*:\s*[\w,\s-]*$/.test(prefix)) return "effector"; - if (indent >= 4 && /^\s*reach\s*:\s*[\w-]*$/.test(prefix)) return "reach-effector"; - if (indent >= 4 && /^\s*pin\s*:\s*[\w-]*$/.test(prefix)) return "pin-effector"; - if (indent >= 4 && /^\s*grip\s*:\s*[\w-]*$/.test(prefix)) return "grip-effector"; - if (indent >= 4 && /^\s*[\w-]+\s*:\s*[\w-]*$/.test(prefix)) return "action"; - + const atDocumentIndent = + enclosingBlock === null && indent > 0 && (documentIndent === null || indent === documentIndent); + if (atDocumentIndent && /^\s*pose\s+start\s*=\s*[\w-]*$/.test(prefix)) return "pose"; + if (atDocumentIndent && /^\s*step\s+"[^"]*"\s+[0-9.]+s\s+[\w-]*$/.test(prefix)) return "easing"; + const isActualChild = enclosingBlock !== null && indent > enclosingBlock.indent; + if (isActualChild && enclosingBlock.kind === "start-pose") { + if (/^\s*[\w-]+\s*:\s*[\w-]*$/.test(prefix)) return "action"; + return /^\s*[\w-]*$/.test(prefix) ? "start-joint" : "none"; + } + const isStepChild = isActualChild && enclosingBlock.kind === "step"; + const isConventionalChild = indent >= 4; + if ((isStepChild || isConventionalChild) && /^\s*ground-lock\s*:\s*[\w,\s-]*$/.test(prefix)) return "effector"; + if ((isStepChild || isConventionalChild) && /^\s*reach\s*:\s*[\w-]*$/.test(prefix)) return "reach-effector"; + if ((isStepChild || isConventionalChild) && /^\s*pin\s*:\s*[\w-]*$/.test(prefix)) return "pin-effector"; + if ((isStepChild || isConventionalChild) && /^\s*grip\s*:\s*[\w-]*$/.test(prefix)) return "grip-effector"; + if ((isStepChild || isConventionalChild) && /^\s*[\w-]+\s*:\s*[\w-]*$/.test(prefix)) return "action"; if (/^\s*[\w-]*$/.test(prefix)) { - return indent >= 4 ? "joint" : indent >= 2 ? "top" : "none"; + return isStepChild || isConventionalChild ? "joint" : indent > 0 ? "top" : "none"; } return "none"; } +/** Find the closest less-indented scoped header that owns the cursor line. */ +function enclosingBlockAt( + lines: readonly string[], + line: number, + currentIndent: number, +): EnclosingBlock | null { + for (let i = line - 1; i >= 0; i--) { + const candidate = lines[i]!; + const trimmed = candidate.trim(); + if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("//")) continue; + const indent = candidate.length - candidate.trimStart().length; + if (indent >= currentIndent) continue; + if (/^pose\s+start\s*=\s*[\w-]+\s*:\s*(?:#.*|\/\/.*)?$/.test(trimmed)) { + return { kind: "start-pose", indent }; + } + if (/^step\s+"[^"]*"\s+[0-9.]+s\s+[\w-]+\s*:\s*(?:#.*|\/\/.*)?$/.test(trimmed)) { + return { kind: "step", indent }; + } + // Any other less-indented line is a structural boundary: do not scan + // through a later directive and accidentally re-enter an older block. + return null; + } + return null; +} + +/** Indentation established by the first document directive before the cursor. */ +function documentIndentBefore(lines: readonly string[], line: number): number | null { + for (let i = 1; i < line; i++) { + const candidate = lines[i]!; + const trimmed = candidate.trim(); + if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("//")) continue; + if (!/^(?:rig|prop|pose|clip|step|repeat)\b/.test(trimmed)) continue; + return candidate.length - candidate.trimStart().length; + } + return null; +} + function item(label: string, kind: CompletionKind): CompletionItem { const detail = KEYWORD_DOCS[label]; return detail ? { label, kind, detail } : { label, kind }; @@ -80,8 +136,11 @@ export function getCompletions( ): CompletionItem[] { const lineText = text.split(/\r?\n/)[line] ?? ""; const prefix = lineText.slice(0, character); + const lines = text.split(/\r?\n/); + const indent = prefix.length - prefix.trimStart().length; + const enclosingBlock = enclosingBlockAt(lines, line, indent); - switch (contextFor(prefix, line)) { + switch (contextFor(prefix, line, enclosingBlock, documentIndentBefore(lines, line))) { case "kind": return KINDS.map((k) => item(k, "kind")); case "pose": @@ -107,6 +166,8 @@ export function getCompletions( ...JOINT_NAMES.map((j) => item(j, "joint")), ...CHILD_KEYWORDS.map((k) => item(k, "keyword")), ]; + case "start-joint": + return JOINT_NAMES.map((j) => item(j, "joint")); case "top": return TOP_KEYWORDS.map((k) => item(k, "keyword")); default: diff --git a/packages/posecode-language/src/vocab.ts b/packages/posecode-language/src/vocab.ts index d20e38a..4742f81 100644 --- a/packages/posecode-language/src/vocab.ts +++ b/packages/posecode-language/src/vocab.ts @@ -55,8 +55,8 @@ export const KEYWORD_DOCS: Record = { posecode: 'Document header: `posecode ""`.', rig: "Selects the rig (currently `humanoid`).", prop: "Adds a scene object: `prop chair | wall | bar | box | dip-bars`. Supplies declared reach, pin, and grip anchors.", - pose: "Sets the starting pose: `pose start = standing | first-position | neutral | plank | supine | prone | seated`.", - start: "Used in `pose start = `.", + pose: "Sets the starting pose. Add a trailing `:` and indented joint targets to sparsely override a built-in pose.", + start: "Used in `pose start = ` or the custom form `pose start = :` followed by joint overrides.", clip: 'Optional mocap clip: `clip "walk"`. A renderer with a matching retargeted animation plays it crossfaded over the procedural pose; others ignore it.', step: 'A movement phase: `step "" :` where mode is flow | settle | drive | snap | linear.', flow: "Timing mode: pass through this pose with continuous velocity (flowing motion).", diff --git a/packages/posecode-language/test/language.test.ts b/packages/posecode-language/test/language.test.ts index 3c2f61d..c14717b 100644 --- a/packages/posecode-language/test/language.test.ts +++ b/packages/posecode-language/test/language.test.ts @@ -106,6 +106,89 @@ describe("getCompletions", () => { expect(onLine(" pose start = ", 15)).toContain("standing"); }); + it("offers only joint targets inside a scoped start-pose override", () => { + const text = [ + 'posecode posture "Custom"', + " pose start = standing:", + " ", + ].join("\n"); + const labels = getCompletions(text, 2, 4).map((item) => item.label); + expect(labels).toEqual(expect.arrayContaining(["shoulders", "elbow_left", "hips"])); + expect(labels).not.toEqual(expect.arrayContaining(["cue", "ground-lock", "reach"])); + }); + + it("offers joint-specific actions inside a scoped start-pose override", () => { + const text = [ + 'posecode posture "Custom"', + " pose start = standing:", + " knees: ", + ].join("\n"); + const labels = getCompletions(text, 2, 11).map((item) => item.label); + expect(labels).toEqual(expect.arrayContaining(["flex", "extend", "hold"])); + expect(labels).not.toContain("abduct"); + }); + + it("uses the actual scoped-header indentation for start-pose completions", () => { + const jointText = [ + 'posecode posture "Compact custom"', + " pose start = standing:", + " ", + ].join("\n"); + const joints = getCompletions(jointText, 2, 2).map((item) => item.label); + expect(joints).toEqual(expect.arrayContaining(["shoulders", "elbow_left", "hips"])); + expect(joints).not.toContain("ground-lock"); + + const actionText = [ + 'posecode posture "Compact custom"', + " pose start = standing:", + " knees: ", + ].join("\n"); + const actions = getCompletions(actionText, 2, 9).map((item) => item.label); + expect(actions).toEqual(expect.arrayContaining(["flex", "extend", "hold"])); + expect(actions).not.toContain("abduct"); + }); + + it("does not scan through a later block into an earlier start-pose block", () => { + const text = [ + 'posecode posture "Two scopes"', + " pose start = standing:", + " shoulders: flex 20", + ' step "Move" 1s linear:', + " ", + ].join("\n"); + const labels = getCompletions(text, 4, 2).map((item) => item.label); + expect(labels).toEqual(expect.arrayContaining(["knees", "ground-lock", "cue"])); + }); + + it("does not complete document directives when they are nested inside a step", () => { + const nestedPose = [ + 'posecode posture "Nested"', + ' step "Outer" 1s linear:', + " pose start = ", + ].join("\n"); + expect(getCompletions(nestedPose, 2, 15).map((item) => item.label)).not.toContain("standing"); + + const nestedStep = [ + 'posecode posture "Nested"', + ' step "Outer" 1s linear:', + ' step "Inner" 1s ', + ].join("\n"); + expect(getCompletions(nestedStep, 2, 19).map((item) => item.label)).not.toContain("flow"); + }); + + it("reports ROM clamps in scoped start-pose overrides", () => { + const text = [ + 'posecode posture "Custom"', + " pose start = standing:", + " knees: flex 200", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n"); + expect(getDiagnostics(text)).toContainEqual( + expect.objectContaining({ line: 3, severity: "warning", message: expect.stringContaining("144") }), + ); + }); + it("suggests effectors after `ground-lock: `", () => { expect(onLine(" ground-lock: ", 17)).toEqual( expect.arrayContaining([ diff --git a/packages/posecode-lsp/test/convert.test.ts b/packages/posecode-lsp/test/convert.test.ts index 07626fa..32633f5 100644 --- a/packages/posecode-lsp/test/convert.test.ts +++ b/packages/posecode-lsp/test/convert.test.ts @@ -40,6 +40,15 @@ describe("toCompletions", () => { ); expect(items[0]?.kind).toBe(CompletionItemKind.Function); }); + + it("returns joint completions inside a scoped start-pose override", () => { + const text = 'posecode posture "x"\n pose start = standing:\n '; + const items = toCompletions(text, 2, 4); + expect(items.map((item) => item.label)).toEqual( + expect.arrayContaining(["shoulders", "elbow_left", "hips"]), + ); + expect(items.map((item) => item.label)).not.toContain("ground-lock"); + }); }); describe("toHover", () => { diff --git a/packages/posecode-mcp/src/guide.ts b/packages/posecode-mcp/src/guide.ts index 9b34d32..b5c60cf 100644 --- a/packages/posecode-mcp/src/guide.ts +++ b/packages/posecode-mcp/src/guide.ts @@ -49,6 +49,7 @@ posecode "" # kind = exercise | stretch | posture rig humanoid prop # optional: chair | wall | bar | box | dip-bars pose start = # neutral | standing | first-position | plank | supine | prone | seated + # Or use \`pose start = :\` with indented, sparse joint overrides. step "" : # mode = flow | settle | drive | snap | linear : ground-lock: # repeat feet/hands/forearms/back or side-specific supports @@ -67,6 +68,9 @@ abduct/adduct, rotate-in/rotate-out (shoulder/hip), twist-left/twist-right 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. +To customize the opening shape, append \`:\` to \`pose start = \` and +indent joint targets beneath it. These ROM-clamped targets sparsely overlay the +built-in pose, consume no time, and become the deterministic loop-reset pose. 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-mcp/src/server.ts b/packages/posecode-mcp/src/server.ts index 349c922..df1fffb 100644 --- a/packages/posecode-mcp/src/server.ts +++ b/packages/posecode-mcp/src/server.ts @@ -20,9 +20,12 @@ export interface PosecodeServerOptions { baseUrl?: string; } +/** Package/server identity kept in sync by `version-packages`. */ +export const POSECODE_MCP_VERSION = "0.3.0"; + export function createPosecodeServer(opts: PosecodeServerOptions = {}): McpServer { const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL; - const server = new McpServer({ name: "posecode", version: "0.2.0" }); + const server = new McpServer({ name: "posecode", version: POSECODE_MCP_VERSION }); const sourceSchema = { source: z.string().describe("The full .posecode document text"), diff --git a/packages/posecode-mcp/test/posecode-mcp.test.ts b/packages/posecode-mcp/test/posecode-mcp.test.ts index b91f09b..fbe991d 100644 --- a/packages/posecode-mcp/test/posecode-mcp.test.ts +++ b/packages/posecode-mcp/test/posecode-mcp.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, it, expect } from "vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; @@ -64,6 +66,17 @@ describe("Posecode MCP server", () => { return client; } + it("advertises the package version during initialization", async () => { + const pkg = JSON.parse( + readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8"), + ) as { version: string }; + const client = await connect(); + expect(client.getServerVersion()).toMatchObject({ + name: "posecode", + version: pkg.version, + }); + }); + it("exposes validate, render, and authoring-guide tools", async () => { const client = await connect(); const { tools } = await client.listTools(); @@ -117,5 +130,7 @@ describe("Posecode MCP server", () => { const text = (res.content as { type: string; text: string }[])[0]!.text; expect(text).toContain("Posecode"); expect(text.toLowerCase()).toContain("grammar"); + expect(text).toContain("pose start = :"); + expect(text).toContain("restored when the animation loops"); }); }); diff --git a/packages/posecode-parser/README.md b/packages/posecode-parser/README.md index c5ed8cd..6a0af8a 100644 --- a/packages/posecode-parser/README.md +++ b/packages/posecode-parser/README.md @@ -36,13 +36,26 @@ if (errors.length === 0 && ir) { } ``` +Append `:` to a built-in start pose to customize it with sparse, ROM-clamped +joint targets. The composed pose is used both at load and when the animation +loops: + +```posecode + pose start = standing: + shoulders: flex 20 + elbow_left: flex 35 +``` + +A document may contain only one `pose start` declaration; duplicates are +reported as errors rather than replacing an earlier built-in pose or block. + ## Validate a movement library -Version 0.2 includes a zero-config validator for local files and directories: +The parser includes a zero-config validator for local files and directories: ```bash -npx posecode-parser@0.2.0 validate ./movements -npx posecode-parser@0.2.0 validate --strict ./movements +npx posecode-parser@latest validate ./movements +npx posecode-parser@latest validate --strict ./movements ``` The command recurses through directories, prints file-and-line diagnostics, and diff --git a/packages/posecode-parser/src/clamp.ts b/packages/posecode-parser/src/clamp.ts index fcb318f..4519fb8 100644 --- a/packages/posecode-parser/src/clamp.ts +++ b/packages/posecode-parser/src/clamp.ts @@ -54,6 +54,30 @@ export function resolve(ast: AstDoc): ResolveResult { const errors: ParseError[] = []; const declaredAnchors = anchorsForProps(ast.props); const semanticState = initialSemanticState(ast.startPose); + const startOverrideStep: AstStep = { + name: "start pose", + durationSec: 0, + easing: "linear", + targets: ast.startPoseOverrides, + groundLock: [], + reaches: [], + pins: [], + grips: [], + line: ast.startPoseOverrides[0]?.line ?? 1, + }; + const startOverridePhase = resolveStep( + startOverrideStep, + declaredAnchors, + warnings, + errors, + ); + enforceHipHingeLimit( + startOverrideStep, + startOverridePhase, + semanticState, + warnings, + ); + applyPhaseToState(semanticState, startOverridePhase); const phases: Phase[] = []; // Resolve in document order so cross-joint mechanics can be checked against @@ -72,6 +96,9 @@ export function resolve(ast: AstDoc): ResolveResult { name: ast.name, rig: ast.rig, ...(ast.startPose ? { startPose: ast.startPose } : {}), + ...(startOverridePhase.targets.length > 0 + ? { startPoseOverrides: startOverridePhase.targets } + : {}), props: ast.props, ...(ast.clip ? { clip: ast.clip } : {}), repeat: ast.repeat, diff --git a/packages/posecode-parser/src/parser.ts b/packages/posecode-parser/src/parser.ts index 8778c3d..fdab0e9 100644 --- a/packages/posecode-parser/src/parser.ts +++ b/packages/posecode-parser/src/parser.ts @@ -67,6 +67,8 @@ export interface AstDoc { name: string; rig: string; startPose?: string; + /** Sparse joint targets layered over the selected built-in start pose. */ + startPoseOverrides: AstJointTarget[]; props: string[]; /** Optional mocap clip name (`clip ""`), resolved to an asset by hosts. */ clip?: string; @@ -134,25 +136,28 @@ export function parseToAst(source: string): ParseAstResult { kind: ht[1].value, name: ht[2].value, rig: "humanoid", + startPoseOverrides: [], props: [], repeat: 1, steps: [], }; let current: AstStep | null = null; - let invalidStepIndent: number | null = null; + let invalidBlockIndent: number | null = null; let topLevelIndent: number | null = null; let currentStepIndent: number | null = null; let currentChildIndent: number | null = null; + let startPoseBlockIndent: number | null = null; + let startPoseChildIndent: number | null = null; for (let i = 1; i < lines.length; i++) { const ln = lines[i]!; - // A malformed step header already explains why the phase cannot be parsed. - // Ignore its indented children so authors get one actionable diagnostic - // instead of a cascade of misleading "outside of a step" errors. - if (invalidStepIndent !== null) { - if (ln.indent > invalidStepIndent) continue; - invalidStepIndent = null; + // A malformed scoped header already explains why its children cannot be + // parsed. Ignore them so authors get one actionable diagnostic instead of + // a cascade of misleading "outside of a step" errors. + if (invalidBlockIndent !== null) { + if (ln.indent > invalidBlockIndent) continue; + invalidBlockIndent = null; } const head = word(ln.tokens[0]); const t = ln.tokens; @@ -168,7 +173,9 @@ export function parseToAst(source: string): ParseAstResult { current = null; currentStepIndent = null; currentChildIndent = null; - if (head === "step") invalidStepIndent = ln.indent; + startPoseBlockIndent = null; + startPoseChildIndent = null; + if (head === "step") invalidBlockIndent = ln.indent; continue; } if (topLevelIndent === null) topLevelIndent = ln.indent; @@ -177,29 +184,46 @@ export function parseToAst(source: string): ParseAstResult { current = null; currentStepIndent = null; currentChildIndent = null; - if (head === "step") invalidStepIndent = ln.indent; + startPoseBlockIndent = null; + startPoseChildIndent = null; + if (head === "step") invalidBlockIndent = ln.indent; continue; } + // Any recognized document directive closes the preceding scoped start- + // pose block. A `pose ...:` case below opens a fresh block explicitly. + startPoseBlockIndent = null; + startPoseChildIndent = null; if (head !== "step") { current = null; currentStepIndent = null; currentChildIndent = null; } } else { - if (!current || currentStepIndent === null) { + if (current && currentStepIndent !== null) { + if (ln.indent <= currentStepIndent) { + errors.push({ line: ln.line, message: "step children must be indented beneath their `step` header" }); + continue; + } + if (currentChildIndent === null) currentChildIndent = ln.indent; + if (ln.indent !== currentChildIndent) { + errors.push({ line: ln.line, message: `step children must use one indentation level (${currentChildIndent} spaces)` }); + continue; + } + } else if (startPoseBlockIndent !== null) { + if (ln.indent <= startPoseBlockIndent) { + errors.push({ line: ln.line, message: "start-pose overrides must be indented beneath their `pose start` header" }); + continue; + } + if (startPoseChildIndent === null) startPoseChildIndent = ln.indent; + if (ln.indent !== startPoseChildIndent) { + errors.push({ line: ln.line, message: `start-pose overrides must use one indentation level (${startPoseChildIndent} spaces)` }); + continue; + } + } else { const err = parseStepChild(ln, null); if (err) errors.push(err); continue; } - if (ln.indent <= currentStepIndent) { - errors.push({ line: ln.line, message: "step children must be indented beneath their `step` header" }); - continue; - } - if (currentChildIndent === null) currentChildIndent = ln.indent; - if (ln.indent !== currentChildIndent) { - errors.push({ line: ln.line, message: `step children must use one indentation level (${currentChildIndent} spaces)` }); - continue; - } } switch (head) { @@ -238,19 +262,36 @@ export function parseToAst(source: string): ParseAstResult { break; } case "pose": { - // `pose start = ` + // `pose start = ` keeps the compact legacy form. A trailing + // colon opens a scoped list of sparse joint overrides: + // `pose start = standing:` followed by `: `. const name = word(t[3]); - if (t.length === 4 && word(t[1]) === "start" && t[2]?.type === "eq" && name) { + const block = t.length === 5 && t[4]?.type === "colon"; + if ((t.length === 4 || block) && word(t[1]) === "start" && t[2]?.type === "eq" && name) { if (!isStartPoseName(name)) { errors.push({ line: ln.line, message: `unknown start pose "${name}"; expected one of ${START_POSE_NAMES.join(", ")}`, }); + if (block) invalidBlockIndent = ln.indent; + break; + } + if (doc.startPose !== undefined) { + errors.push({ + line: ln.line, + message: "duplicate `pose start` declaration; a document may define exactly one start pose", + }); + if (block) invalidBlockIndent = ln.indent; break; } doc.startPose = name; + if (block) { + startPoseBlockIndent = ln.indent; + startPoseChildIndent = null; + } } else { - errors.push({ line: ln.line, message: "expected `pose start = `" }); + errors.push({ line: ln.line, message: "expected `pose start = ` or `pose start = :`" }); + if (t.at(-1)?.type === "colon") invalidBlockIndent = ln.indent; } break; } @@ -286,7 +327,7 @@ export function parseToAst(source: string): ParseAstResult { current = null; currentStepIndent = null; currentChildIndent = null; - invalidStepIndent = ln.indent; + invalidBlockIndent = ln.indent; break; } current = { @@ -306,8 +347,11 @@ export function parseToAst(source: string): ParseAstResult { break; } default: { - // A step-child line (joint target, cue, or ground-lock). - const err = parseStepChild(ln, current); + // A scoped start-pose override is deliberately limited to joint + // targets, so it cannot be mistaken for motion or a phase constraint. + const err = startPoseBlockIndent !== null + ? parseStartPoseOverride(ln, doc) + : parseStepChild(ln, current); if (err) errors.push(err); } } @@ -316,6 +360,13 @@ export function parseToAst(source: string): ParseAstResult { return { ast: doc, errors }; } +function parseStartPoseOverride(ln: Line, doc: AstDoc): ParseError | null { + const parsed = parseJointTarget(ln); + if (parsed.error) return parsed.error; + doc.startPoseOverrides.push(parsed.target!); + return null; +} + function parseDuration(value: string): number { return parseFloat(value.replace(/s$/, "")); } @@ -439,29 +490,53 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null { if (!current) { return { line: ln.line, message: "joint target outside of a step" }; } + const parsed = parseJointTarget(ln); + if (parsed.error) return parsed.error; + current.targets.push(parsed.target!); + return null; +} + +function parseJointTarget( + ln: Line, +): { target: AstJointTarget | null; error: ParseError | null } { + const t = ln.tokens; + const head = word(t[0]); if (head === null || t[1]?.type !== "colon" || t[2]?.type !== "word") { - return { line: ln.line, message: "expected `: `" }; + return { + target: null, + error: { line: ln.line, message: "expected `: `" }, + }; } const action = t[2].value; if (action === "hold") { if (t.length !== 4 || word(t[3]) !== "neutral") { return { - line: ln.line, - message: "expected `: hold neutral` (no trailing angle)", + target: null, + error: { + line: ln.line, + message: "expected `: hold neutral` (no trailing angle)", + }, }; } - current.targets.push({ joint: head, action, degrees: null, line: ln.line }); - return null; + return { + target: { joint: head, action, degrees: null, line: ln.line }, + error: null, + }; } const degTok = t[3]; if (t.length !== 4 || degTok?.type !== "num") { - return { line: ln.line, message: "expected `: `" }; + return { + target: null, + error: { line: ln.line, message: "expected `: `" }, + }; } - current.targets.push({ - joint: head, - action, - degrees: Number(degTok.value), - line: ln.line, - }); - return null; + return { + target: { + joint: head, + action, + degrees: Number(degTok.value), + line: ln.line, + }, + error: null, + }; } diff --git a/packages/posecode-parser/src/schema.ts b/packages/posecode-parser/src/schema.ts index f9cef04..1d31215 100644 --- a/packages/posecode-parser/src/schema.ts +++ b/packages/posecode-parser/src/schema.ts @@ -81,6 +81,7 @@ const docSchema = z.object({ name: z.string().min(1), rig: z.enum(RIG_NAMES), startPose: z.enum(START_POSE_NAMES).optional(), + startPoseOverrides: z.array(jointTargetSchema), props: z.array(z.enum(PROP_TYPES)), repeat: z.number().int().positive(), steps: z.array(stepSchema).min(1, "a Posecode document requires at least one step"), @@ -101,6 +102,9 @@ export function validateAst(ast: AstDoc): ParseError[] { } function lineForIssue(ast: AstDoc, path: PropertyKey[]): number { + if (path[0] === "startPoseOverrides" && typeof path[1] === "number") { + return ast.startPoseOverrides[path[1]]?.line ?? 1; + } // path like ["steps", 0, "easing"] → that step's source line. if (path[0] === "steps" && typeof path[1] === "number") { return ast.steps[path[1]]?.line ?? 1; diff --git a/packages/posecode-parser/src/types.ts b/packages/posecode-parser/src/types.ts index 48a4b3b..947e1fc 100644 --- a/packages/posecode-parser/src/types.ts +++ b/packages/posecode-parser/src/types.ts @@ -8,7 +8,7 @@ */ /** Version of the parsed Posecode language/IR contract. */ -export const POSECODE_VERSION = "0.2"; +export const POSECODE_VERSION = "0.3"; export type Axis = "x" | "y" | "z"; @@ -92,6 +92,7 @@ export interface Phase { * end of this phase, absolute, carried forward. Powers travel / locomotion. */ travel?: { x: number; z: number }; + /** Display-only coaching text; never changes validation or motion solving. */ cue?: string; } @@ -103,6 +104,8 @@ export interface PosecodeIR { name: string; rig: string; startPose?: string; + /** Sparse, ROM-clamped joint channels layered over the built-in start pose. */ + startPoseOverrides?: JointTarget[]; /** Scene props declared with `prop `, e.g. ["chair", "bar"]. */ props: string[]; /** diff --git a/packages/posecode-parser/test/parse.test.ts b/packages/posecode-parser/test/parse.test.ts index 2be6a53..5055b35 100644 --- a/packages/posecode-parser/test/parse.test.ts +++ b/packages/posecode-parser/test/parse.test.ts @@ -103,6 +103,147 @@ describe("parse", () => { expect(ir!.kind).toBe("posture"); }); + it("layers scoped, sparse overrides over a built-in start pose", () => { + const result = parse([ + 'posecode posture "Custom opening"', + " rig humanoid", + " pose start = standing:", + " shoulders: flex 20", + " elbow_left: pronate 35", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(result.ir?.startPose).toBe("standing"); + expect(result.ir?.startPoseOverrides).toEqual([ + { boneId: "shoulder_left", euler: { x: -20, y: 0, z: 0 }, axes: ["x"] }, + { boneId: "shoulder_right", euler: { x: -20, y: 0, z: 0 }, axes: ["x"] }, + { boneId: "elbow_left", euler: { x: 0, y: -35, z: 0 }, axes: ["y"] }, + ]); + }); + + it("ROM-clamps start-pose overrides with their source line", () => { + const result = parse([ + 'posecode posture "Custom opening"', + " rig humanoid", + " pose start = standing:", + " knees: flex 200", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + + expect(result.errors).toEqual([]); + expect(result.warnings).toHaveLength(2); + expect(result.warnings[0]).toMatchObject({ + line: 4, + phase: "start pose", + requested: 200, + clamped: 144, + }); + expect(result.ir?.startPoseOverrides?.[0]?.euler.x).toBe(144); + }); + + it("rejects step-only directives inside a start-pose block", () => { + const result = parse([ + 'posecode posture "Invalid opening"', + " rig humanoid", + " pose start = standing:", + " ground-lock: feet", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + + expect(result.ir).toBeNull(); + expect(result.errors).toEqual([ + expect.objectContaining({ line: 4, message: expect.stringContaining("") }), + ]); + }); + + it.each([ + ["moon: flex 20", /unknown joint.*moon/i], + ["knees: teleport 20", /unknown action.*teleport/i], + ])("validates the closed joint/action vocabulary in start overrides: %s", (override, message) => { + const result = parse([ + 'posecode posture "Invalid override"', + " rig humanoid", + " pose start = standing:", + ` ${override}`, + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + expect(result.ir).toBeNull(); + expect(result.errors).toContainEqual( + expect.objectContaining({ line: 4, message: expect.stringMatching(message) }), + ); + }); + + it("rejects duplicate pose declarations in either scoped ordering", () => { + const blockThenOneLine = parse([ + 'posecode posture "Duplicate custom"', + " rig humanoid", + " pose start = standing:", + " shoulders: flex 20", + " pose start = prone", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + expect(blockThenOneLine.ir).toBeNull(); + expect(blockThenOneLine.errors).toEqual([ + expect.objectContaining({ line: 5, message: expect.stringMatching(/duplicate.*pose start/i) }), + ]); + + const oneLineThenBlock = parse([ + 'posecode posture "Duplicate built-in"', + " rig humanoid", + " pose start = prone", + " pose start = standing:", + " shoulder_left: flex 25", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + expect(oneLineThenBlock.ir).toBeNull(); + expect(oneLineThenBlock.errors).toEqual([ + expect.objectContaining({ line: 4, message: expect.stringMatching(/duplicate.*pose start/i) }), + ]); + }); + + it.each([ + ["moon: flex 20"], + ["knees: teleport 20"], + ])("cannot hide an invalid earlier override behind a second pose: %s", (override) => { + const result = parse([ + 'posecode posture "No superseding"', + " rig humanoid", + " pose start = standing:", + ` ${override}`, + " pose start = prone", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + expect(result.ir).toBeNull(); + expect(result.errors).toContainEqual( + expect.objectContaining({ line: 5, message: expect.stringMatching(/duplicate.*pose start/i) }), + ); + }); + + it("suppresses child cascades after an unknown scoped pose header", () => { + const result = parse([ + 'posecode posture "Bad scoped pose"', + " rig humanoid", + " pose start = crouching:", + " shoulders: flex 20", + " elbows: flex 30", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + expect(result.ir).toBeNull(); + expect(result.errors).toEqual([ + expect.objectContaining({ line: 3, message: expect.stringMatching(/unknown start pose/i) }), + ]); + }); + it("reports a structured error for an unknown joint", () => { const src = [ 'posecode exercise "Typo"', diff --git a/packages/posecode-parser/test/strict-validation.test.ts b/packages/posecode-parser/test/strict-validation.test.ts index d1445ff..407a02b 100644 --- a/packages/posecode-parser/test/strict-validation.test.ts +++ b/packages/posecode-parser/test/strict-validation.test.ts @@ -82,6 +82,20 @@ describe("indentation grammar", () => { expect(result.ir).toBeNull(); expect(result.errors[0]?.message).toMatch(/one indentation level/i); }); + + it("requires scoped start-pose overrides to be indented consistently", () => { + const result = parse([ + 'posecode posture "Indent"', + " rig humanoid", + " pose start = standing:", + " shoulders: flex 20", + " elbows: flex 30", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + expect(result.ir).toBeNull(); + expect(result.errors[0]?.message).toMatch(/start-pose overrides.*one indentation level/i); + }); }); describe("joint/action compatibility", () => { diff --git a/packages/posecode-render/README.md b/packages/posecode-render/README.md index 94b78ef..35e04b5 100644 --- a/packages/posecode-render/README.md +++ b/packages/posecode-render/README.md @@ -26,6 +26,9 @@ import { createViewer } from "posecode-render"; const canvas = document.querySelector("canvas")!; const viewer = createViewer(canvas, { autoRotate: false, + // Metric grid, load origin, live +Z facing arrow, and authored travel path. + // Enabled by default; disable it for a clean presentation-only embed. + floorGuide: true, // Optional: realistic skinned character (Mixamo bone naming). Omit for the // zero-asset procedural figure. characterUrl: "https://posecode.org/models/xbot.glb", @@ -41,8 +44,25 @@ if (ir) { viewer.onPhase(({ phaseName, cue }) => { console.log(phaseName, cue); }); + +const floor = viewer.getFloorGuideInfo(); +console.log(floor?.gridStepMetres, floor?.hasTravel, floor?.waypoints); + +const solverWarnings = viewer + .getConstraintDiagnostics() + .filter((diagnostic) => !diagnostic.pass); +console.log(solverWarnings); ``` +`cue` is optional display-only coaching text. It never changes the pose, +timing, range checks, contacts, or collision solving. + +Constraint diagnostics name visible heel/toe contact errors, sole tilt, +grounding-versus-ankle-ROM conflicts, and residual overlaps from the renderer's +bounded self-collision pairs. They measure the post-solver procedural driver, +before optional skinned-character or mocap surface reconciliation, and report +outcomes without changing the authored motion. + No GPU, no diffusion model: generation is a fraction of a cent of text, and rendering is plain forward kinematics. diff --git a/packages/posecode-render/package.json b/packages/posecode-render/package.json index b6b390e..3838ed7 100644 --- a/packages/posecode-render/package.json +++ b/packages/posecode-render/package.json @@ -17,7 +17,7 @@ "three": "^0.171.0" }, "peerDependencies": { - "posecode-parser": ">=0.2.2 <0.4.0" + "posecode-parser": ">=0.2.2 <0.5.0" }, "devDependencies": { "@types/three": "^0.171.0" diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts index d7b89f3..a3c57d2 100644 --- a/packages/posecode-render/src/contacts.ts +++ b/packages/posecode-render/src/contacts.ts @@ -13,7 +13,8 @@ const PALM_DOWN_TARGET_DOT = Math.cos(45 * DEG); const HAND_FLOOR_OUTSET = 0.04; const CONTACT_EULER = new THREE.Euler(); -export type HandSide = "left" | "right"; +export type BodySide = "left" | "right"; +export type HandSide = BodySide; export type HandContactKind = "palm" | "fist"; /** @@ -368,6 +369,83 @@ export function floorContactHeight(m: Mannequin, effectorName: string): number | return Number.isFinite(minY) ? minY : null; } +export interface FootContactMeasurement { + side: BodySide; + /** Lowest heel-edge point of the visible sole above the world floor (metres). */ + heelHeight: number; + /** Lowest toe-edge point of the visible sole above the world floor (metres). */ + toeHeight: number; + /** World-space centre of the sampled sole footprint. */ + center: readonly [x: number, y: number, z: number]; + /** World-space midpoint of the heel edge. */ + heelCenter: readonly [x: number, y: number, z: number]; + /** World-space midpoint of the toe/ball edge used as a tiptoe contact anchor. */ + toeCenter: readonly [x: number, y: number, z: number]; + /** Angle between the visible sole normal and world-up; 0 means flat. */ + soleAngleDeg: number; + /** Current local ankle plantarflexion (+X) in degrees. */ + plantarflexDeg: number; + /** True when the local ankle has reached its configured dorsiflexion bound. */ + atDorsiflexionLimit: boolean; + /** False when explicit plantarflexion opts the foot into intentional tiptoe contact. */ + plantigrade: boolean; +} + +/** + * Measure the real procedural sole at its heel and toe edges. Unlike ankle-bone + * height, these samples distinguish a flat planted foot from a foot balanced on + * its ball while the ankle origin remains close to the floor. + */ +export function measureFootContact( + m: Mannequin, + side: BodySide, +): FootContactMeasurement | null { + const ankle = m.bones.get(`ankle_${side}`); + const sole = m.contactSurfaces[`foot_${side}`]?.[0] as THREE.Mesh | undefined; + const geometry = sole?.geometry; + if (!ankle || !sole || !geometry) return null; + if (!geometry.boundingBox) geometry.computeBoundingBox(); + const bounds = geometry.boundingBox; + if (!bounds) return null; + + m.root.updateMatrixWorld(true); + const points = (z: number): THREE.Vector3[] => [bounds.min.x, bounds.max.x].map((x) => + sole.localToWorld(new THREE.Vector3(x, bounds.min.y, z)), + ); + const heel = points(bounds.min.z); + const toe = points(bounds.max.z); + const midpoint = (edge: readonly THREE.Vector3[]): THREE.Vector3 => + edge.reduce((sum, point) => sum.add(point), new THREE.Vector3()) + .multiplyScalar(1 / edge.length); + const heelCenter = midpoint(heel); + const toeCenter = midpoint(toe); + const footprint = [...heel, ...toe]; + const center = new THREE.Vector3(); + for (const point of footprint) center.add(point); + center.multiplyScalar(1 / footprint.length); + + const world = sole.getWorldQuaternion(new THREE.Quaternion()); + const normal = new THREE.Vector3(0, 1, 0).applyQuaternion(world).normalize(); + const soleAngleDeg = Math.acos(THREE.MathUtils.clamp(normal.dot(new THREE.Vector3(0, 1, 0)), -1, 1)) / DEG; + TMP_EULER.setFromQuaternion(ankle.quaternion, "XYZ"); + const plantarflexDeg = TMP_EULER.x / DEG; + const ankleRom = eulerRomFor(`ankle_${side}`); + return { + side, + heelHeight: Math.min(...heel.map((point) => point.y)), + toeHeight: Math.min(...toe.map((point) => point.y)), + center: [center.x, center.y, center.z], + heelCenter: [heelCenter.x, heelCenter.y, heelCenter.z], + toeCenter: [toeCenter.x, toeCenter.y, toeCenter.z], + soleAngleDeg, + plantarflexDeg, + atDorsiflexionLimit: ankleRom + ? Math.abs(TMP_EULER.x - ankleRom.x.min * DEG) <= 0.25 * DEG + : false, + plantigrade: TMP_EULER.x <= PLANTARFLEX_SKIP + 1e-6, + }; +} + function jointSurfaceDrop( m: Mannequin, boneId: string, diff --git a/packages/posecode-render/src/depenetrate.ts b/packages/posecode-render/src/depenetrate.ts index f6ef964..6af49e0 100644 --- a/packages/posecode-render/src/depenetrate.ts +++ b/packages/posecode-render/src/depenetrate.ts @@ -66,6 +66,18 @@ interface Hit { push: THREE.Vector3; } +export type SelfCollisionKind = "arm-body" | "lower-leg-other-leg"; + +/** Residual overlap remaining after the bounded self-collision pass. */ +export interface SelfCollisionResidual { + /** Stable diagnostic identity suitable for UI warnings and evaluation output. */ + id: string; + kind: SelfCollisionKind; + side: "left" | "right"; + /** Deepest sampled overlap in metres; 0 means no sampled penetration. */ + depth: number; +} + /** 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) { @@ -232,3 +244,75 @@ export function depenetrate(m: Mannequin): void { } } } + +/** + * Re-measure the exact capsule/sample pairs handled by `depenetrate`. This is a + * residual diagnostic, not a claim of comprehensive physics: a positive depth + * means the bounded pass left (or a later contact solve reintroduced) overlap. + */ +export function measureSelfCollisions(m: Mannequin): SelfCollisionResidual[] { + const R = m.collision; + m.root.updateMatrixWorld(true); + + 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 points = [0.15, 0.45, 0.75, 1.0].map((t) => a.clone().addScaledVector(dir, t)); + if (tipOverhang > 0) points.push(b.clone().addScaledVector(dir.clone().normalize(), tipOverhang)); + return points; + }; + const obstacles = (): { torsoHead: Capsule[]; leg: Record<"left" | "right", Capsule[]> } => { + const pelvis = wp(m, "pelvis"); + const neck = wp(m, "neck"); + const head = wp(m, "head"); + 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 leg = (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: leg("left"), right: leg("right") } }; + }; + + const residuals: SelfCollisionResidual[] = []; + for (const side of ["left", "right"] as const) { + const current = obstacles(); + let armHit: Hit | null = null; + for (const point of samples(`elbow_${side}`, `wrist_${side}`, 0.09)) { + armHit = deepestHit( + point, + R.arm, + [...current.torsoHead, ...current.leg.left, ...current.leg.right], + armHit, + ); + } + residuals.push({ + id: `self-collision:arm_${side}:body`, + kind: "arm-body", + side, + depth: armHit?.depth ?? 0, + }); + + const other = side === "left" ? "right" : "left"; + let legHit: Hit | null = null; + for (const point of samples(`knee_${side}`, `ankle_${side}`, 0.06)) { + legHit = deepestHit(point, R.shin, current.leg[other], legHit); + } + residuals.push({ + id: `self-collision:lower_leg_${side}:leg_${other}`, + kind: "lower-leg-other-leg", + side, + depth: legHit?.depth ?? 0, + }); + } + return residuals; +} diff --git a/packages/posecode-render/src/diagnostics.ts b/packages/posecode-render/src/diagnostics.ts new file mode 100644 index 0000000..24808b4 --- /dev/null +++ b/packages/posecode-render/src/diagnostics.ts @@ -0,0 +1,123 @@ +/** Non-mutating renderer constraint diagnostics shared by live UI and eval. */ +import type { Mannequin } from "./mannequin.js"; +import { floorContactHeight, measureFootContact, type BodySide } from "./contacts.js"; +import { measureSelfCollisions } from "./depenetrate.js"; +import { isGroundLockFootPlanted } from "./groundlock.js"; + +/** Maximum absolute heel/toe surface offset accepted as floor contact. */ +export const FOOT_CONTACT_HEIGHT_MAX = 0.02; +/** Maximum sole tilt accepted for an explicitly plantigrade ground-lock. */ +export const PLANTIGRADE_SOLE_ANGLE_MAX = 12; +/** Sampled capsule overlap tolerated as solver/numeric margin. */ +export const SELF_COLLISION_DEPTH_MAX = 0.01; + +export type ConstraintDiagnosticKind = + | "heel-height" + | "toe-height" + | "sole-angle" + | "grounding-rom-conflict" + | "self-collision"; + +/** One named outcome measured on the post-solver procedural-driver frame. */ +export interface ConstraintDiagnostic { + id: string; + kind: ConstraintDiagnosticKind; + pass: boolean; + value: number; + limit: number; + unit: "m" | "deg"; + detail: string; +} + +export interface DiagnosticPin { + effector: string; + anchor: string; +} + +function groundedFootSides( + activeGroundLock: readonly string[], + activePins: readonly DiagnosticPin[], +): BodySide[] { + return (["left", "right"] as const).filter((side) => + activeGroundLock.includes("feet") + || activeGroundLock.includes(`foot_${side}`) + || activePins.some((pin) => + pin.anchor === "floor" + && (pin.effector === "feet" || pin.effector === `foot_${side}`)), + ); +} + +/** + * Measure constraints after the procedural driver's frame solvers have run. + * This precedes optional skinned-character and mocap surface reconciliation. + * Intentional tiptoe poses require toe contact without demanding a flat sole. + */ +export function measureConstraintDiagnostics( + m: Mannequin, + activeGroundLock: readonly string[], + activePins: readonly DiagnosticPin[] = [], +): ConstraintDiagnostic[] { + const out: ConstraintDiagnostic[] = []; + for (const side of groundedFootSides(activeGroundLock, activePins)) { + const foot = measureFootContact(m, side); + if (!foot) continue; + const explicitlyPinned = activePins.some((pin) => + pin.anchor === "floor" + && (pin.effector === "feet" || pin.effector === `foot_${side}`)); + if ( + !explicitlyPinned + && activeGroundLock.includes("feet") + && !activeGroundLock.includes(`foot_${side}`) + && !isGroundLockFootPlanted(floorContactHeight(m, `foot_${side}`) ?? NaN) + ) continue; + const height = (edge: "heel" | "toe", value: number): ConstraintDiagnostic => ({ + id: `grounding:foot_${side}:${edge}-height`, + kind: `${edge}-height`, + pass: Math.abs(value) <= FOOT_CONTACT_HEIGHT_MAX, + value, + limit: FOOT_CONTACT_HEIGHT_MAX, + unit: "m", + detail: `foot_${side} ${edge} ${value.toFixed(3)}m from floor (want ±${FOOT_CONTACT_HEIGHT_MAX.toFixed(3)}m)`, + }); + // Every grounded foot needs a toe/ball contact. A plantigrade declaration + // additionally promises that the heel and full sole remain down. + out.push(height("toe", foot.toeHeight)); + if (foot.plantigrade) { + const heel = height("heel", foot.heelHeight); + out.push(heel); + if (!heel.pass && foot.atDorsiflexionLimit) { + out.push({ + id: `grounding-rom-conflict:foot_${side}`, + kind: "grounding-rom-conflict", + pass: false, + value: Math.abs(foot.heelHeight), + limit: FOOT_CONTACT_HEIGHT_MAX, + unit: "m", + detail: `foot_${side} heel is ${Math.abs(foot.heelHeight).toFixed(3)}m off floor while ankle is at its dorsiflexion ROM limit`, + }); + } + out.push({ + id: `grounding:foot_${side}:sole-angle`, + kind: "sole-angle", + pass: foot.soleAngleDeg <= PLANTIGRADE_SOLE_ANGLE_MAX, + value: foot.soleAngleDeg, + limit: PLANTIGRADE_SOLE_ANGLE_MAX, + unit: "deg", + detail: `foot_${side} sole ${foot.soleAngleDeg.toFixed(1)}° from flat (want ≤ ${PLANTIGRADE_SOLE_ANGLE_MAX}°)`, + }); + } + } + + for (const collision of measureSelfCollisions(m)) { + out.push({ + id: collision.id, + kind: "self-collision", + pass: collision.depth <= SELF_COLLISION_DEPTH_MAX, + value: collision.depth, + limit: SELF_COLLISION_DEPTH_MAX, + unit: "m", + detail: `${collision.id} residual ${collision.depth.toFixed(3)}m (want ≤ ${SELF_COLLISION_DEPTH_MAX.toFixed(3)}m)`, + }); + } + return out; +} diff --git a/packages/posecode-render/src/floor-guide.ts b/packages/posecode-render/src/floor-guide.ts new file mode 100644 index 0000000..18e86c0 --- /dev/null +++ b/packages/posecode-render/src/floor-guide.ts @@ -0,0 +1,381 @@ +/** + * Floor-orientation overlay for the viewer. + * + * The guide turns otherwise implicit root choreography into scene geometry: + * a half-metre grid, a one-metre ruler, the document load origin, the figure's + * live facing direction, and (only when authored) its travel trajectory. + */ + +import * as THREE from "three"; +import type { PosecodeIR } from "posecode-parser"; +import type { BuiltTimeline } from "./timeline.js"; + +export const FLOOR_GRID_STEP_METRES = 0.5; +export const FLOOR_SCALE_BAR_METRES = 1; + +export interface FloorGuidePoint { + x: number; + z: number; +} + +/** Read-only metadata hosts can use to describe the rendered floor guide. */ +export interface FloorGuideInfo { + /** Whether the guide was enabled when the viewer was created. */ + visible: boolean; + /** Distance between the fine grid lines. */ + gridStepMetres: number; + /** Length of the brighter ruler drawn beside the origin. */ + scaleBarMetres: number; + /** True when the source contains a non-zero root travel path. */ + hasTravel: boolean; + /** True when the implicit loop reset translates the root back to the origin. */ + hasLoopReset: boolean; + /** Exact authored travel destinations, beginning at the load origin. */ + waypoints: readonly FloorGuidePoint[]; +} + +export interface FloorGuideData extends Omit { + /** Timeline-sampled authored path used for the solid curve. */ + path: readonly FloorGuidePoint[]; + /** Timeline-sampled implicit return to origin, rendered separately as dashes. */ + resetPath: readonly FloorGuidePoint[]; + /** Half-width of the square grid, in metres. */ + gridRadiusMetres: number; +} + +export interface FloorGuideScene { + group: THREE.Group; + getInfo(visible: boolean): FloorGuideInfo; + /** Place the guide's load origin in world space. */ + setOrigin(x: number, z: number): void; + /** Move and turn the live facing marker relative to the load origin. */ + updateRoot(offset: FloorGuidePoint, yawRadians: number): void; + dispose(): void; +} + +const EPSILON = 1e-5; +const GUIDE_Y = 0.006; + +/** + * Synchronize the live marker to the renderer's final world-space root. + * Contact and prop solvers may translate X/Z beyond the authored trajectory, + * so callers should invoke this only after those solver passes finish. + */ +export function syncFloorGuideToSolvedRoot( + guide: Pick, + solvedRoot: FloorGuidePoint, + loadOrigin: FloorGuidePoint, + yawRadians: number, +): void { + guide.updateRoot( + { + x: solvedRoot.x - loadOrigin.x, + z: solvedRoot.z - loadOrigin.z, + }, + yawRadians, + ); +} + +/** + * Build deterministic guide data from the authored destinations and the same + * root interpolation the figure uses. Sampling the built timeline means a + * curved `flow` corner is displayed as the actual trajectory, not a misleading + * straight waypoint polyline. + */ +export function buildFloorGuideData( + ir: PosecodeIR, + timeline: BuiltTimeline, +): FloorGuideData { + const waypoints: FloorGuidePoint[] = [{ x: 0, z: 0 }]; + let position = { x: 0, z: 0 }; + for (const phase of ir.phases) { + if (!phase.travel) continue; + position = { x: phase.travel.x, z: phase.travel.z }; + const previous = waypoints.at(-1)!; + if (Math.hypot(position.x - previous.x, position.z - previous.z) > EPSILON) { + waypoints.push({ ...position }); + } + } + + const hasTravel = waypoints.some((point) => Math.hypot(point.x, point.z) > EPSILON); + const path: FloorGuidePoint[] = []; + const resetPath: FloorGuidePoint[] = []; + const motionEnd = timeline.segments.at(-1)?.end ?? 0; + if (hasTravel) { + path.push(...sampleRootPath(timeline, 0, motionEnd, 16, 192)); + // Sampling at `duration` wraps to t=0. Preserve the exact final authored + // destination for a clip that ends at home as well as one with a reset. + const final = waypoints.at(-1)!; + const last = path.at(-1); + if (!last || Math.hypot(final.x - last.x, final.z - last.z) > 0.002) { + path.push({ ...final }); + } + } + + const final = waypoints.at(-1)!; + const hasLoopReset = + timeline.duration - motionEnd > EPSILON && Math.hypot(final.x, final.z) > EPSILON; + if (hasLoopReset) { + resetPath.push(...sampleRootPath(timeline, motionEnd, timeline.duration, 8, 96)); + // Keep the semantic boundary exact: solid reaches the final authored + // waypoint; dashed begins there and ends at the load origin. + resetPath[0] = { ...final }; + resetPath[resetPath.length - 1] = { x: 0, z: 0 }; + } + + const maxCoordinate = Math.max( + 0, + ...[...waypoints, ...path, ...resetPath] + .map((point) => Math.max(Math.abs(point.x), Math.abs(point.z))), + ); + // Preserve the existing 12m floor for normal movements, expanding in whole + // metres only when choreography would otherwise leave the grid. + const gridRadiusMetres = Math.max(6, Math.ceil(maxCoordinate + 1.5)); + return { + gridStepMetres: FLOOR_GRID_STEP_METRES, + scaleBarMetres: FLOOR_SCALE_BAR_METRES, + hasTravel, + hasLoopReset, + waypoints, + path, + resetPath, + gridRadiusMetres, + }; +} + +function sampleRootPath( + timeline: BuiltTimeline, + start: number, + end: number, + minimumSamples: number, + maximumSamples: number, +): FloorGuidePoint[] { + if (end - start <= EPSILON) return []; + // 16 Hz is smooth at floor scale; cap density for unusually long clips. + const samples = THREE.MathUtils.clamp( + Math.ceil((end - start) * 16), + minimumSamples, + maximumSamples, + ); + const path: FloorGuidePoint[] = []; + const noBones = new Map(); + for (let i = 0; i <= samples; i++) { + const time = start + (end - start) * (i / samples); + const point = timeline.sample(time, noBones).rootOffset; + const previous = path.at(-1); + if (!previous || Math.hypot(point.x - previous.x, point.z - previous.z) > 0.002) { + path.push({ ...point }); + } + } + return path; +} + +/** Build disposable Three.js geometry for one loaded movement. */ +export function createFloorGuide(data: FloorGuideData): FloorGuideScene { + const group = new THREE.Group(); + group.name = "posecode-floor-guide"; + + const size = data.gridRadiusMetres * 2; + const minorGrid = new THREE.GridHelper( + size, + Math.round(size / data.gridStepMetres), + 0x343b45, + 0x20262e, + ); + minorGrid.name = "floor-grid-half-metre"; + configureLineMaterial(minorGrid.material, 0.42); + minorGrid.position.y = 0.001; + group.add(minorGrid); + + const majorGrid = new THREE.GridHelper(size, Math.round(size), 0x46515e, 0x303844); + majorGrid.name = "floor-grid-metre"; + configureLineMaterial(majorGrid.material, 0.34); + majorGrid.position.y = 0.002; + group.add(majorGrid); + + const originMaterial = new THREE.MeshBasicMaterial({ + color: 0xf2f1eb, + transparent: true, + opacity: 0.9, + depthWrite: false, + side: THREE.DoubleSide, + }); + const origin = new THREE.Mesh(new THREE.RingGeometry(0.055, 0.078, 32), originMaterial); + origin.name = "floor-origin"; + origin.rotation.x = -Math.PI / 2; + origin.position.y = GUIDE_Y; + origin.renderOrder = 2; + group.add(origin); + + const rulerMaterial = new THREE.LineBasicMaterial({ + color: 0xf2f1eb, + transparent: true, + opacity: 0.78, + depthWrite: false, + }); + const rulerZ = -0.38; + const rulerHalf = data.scaleBarMetres / 2; + const tick = 0.055; + const ruler = new THREE.LineSegments( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(-rulerHalf, GUIDE_Y, rulerZ), + new THREE.Vector3(rulerHalf, GUIDE_Y, rulerZ), + new THREE.Vector3(-rulerHalf, GUIDE_Y, rulerZ - tick), + new THREE.Vector3(-rulerHalf, GUIDE_Y, rulerZ + tick), + new THREE.Vector3(rulerHalf, GUIDE_Y, rulerZ - tick), + new THREE.Vector3(rulerHalf, GUIDE_Y, rulerZ + tick), + ]), + rulerMaterial, + ); + ruler.name = "floor-scale-one-metre"; + ruler.renderOrder = 2; + group.add(ruler); + + if (data.hasTravel && data.path.length > 1) { + const curve = new THREE.CurvePath(); + for (let i = 1; i < data.path.length; i++) { + const from = data.path[i - 1]!; + const to = data.path[i]!; + curve.add( + new THREE.LineCurve3( + new THREE.Vector3(from.x, GUIDE_Y, from.z), + new THREE.Vector3(to.x, GUIDE_Y, to.z), + ), + ); + } + const path = new THREE.Mesh( + new THREE.TubeGeometry(curve, Math.max(12, data.path.length * 2), 0.009, 5, false), + new THREE.MeshBasicMaterial({ + color: 0xd4ff3f, + transparent: true, + opacity: 0.62, + depthWrite: false, + }), + ); + path.name = "floor-travel-path"; + path.renderOrder = 2; + group.add(path); + + const waypointMaterial = new THREE.MeshBasicMaterial({ + color: 0xd4ff3f, + transparent: true, + opacity: 0.8, + depthWrite: false, + side: THREE.DoubleSide, + }); + for (const [index, point] of data.waypoints.entries()) { + // The white origin marker already marks both departure and return-home. + if (index === 0 || Math.hypot(point.x, point.z) <= EPSILON) continue; + const marker = new THREE.Mesh(new THREE.RingGeometry(0.025, 0.043, 20), waypointMaterial); + marker.name = `floor-travel-waypoint-${index}`; + marker.rotation.x = -Math.PI / 2; + marker.position.set(point.x, GUIDE_Y + 0.002, point.z); + marker.renderOrder = 3; + group.add(marker); + } + } + + if (data.hasLoopReset && data.resetPath.length > 1) { + const reset = new THREE.Line( + new THREE.BufferGeometry().setFromPoints( + data.resetPath.map((point) => new THREE.Vector3(point.x, GUIDE_Y + 0.003, point.z)), + ), + new THREE.LineDashedMaterial({ + color: 0xb8c2cc, + dashSize: 0.09, + gapSize: 0.055, + transparent: true, + opacity: 0.68, + depthWrite: false, + }), + ); + reset.name = "floor-loop-reset-path"; + reset.renderOrder = 2; + reset.computeLineDistances(); + group.add(reset); + } + + const currentMaterial = new THREE.MeshBasicMaterial({ + color: 0xd4ff3f, + transparent: true, + opacity: 0.96, + depthWrite: false, + side: THREE.DoubleSide, + }); + const current = new THREE.Mesh(new THREE.RingGeometry(0.033, 0.052, 24), currentMaterial); + current.name = "floor-current-position"; + current.rotation.x = -Math.PI / 2; + current.position.y = GUIDE_Y + 0.004; + current.renderOrder = 4; + group.add(current); + + // The mannequin's anatomical front is local +Z. Keep this arrow attached to + // the root trajectory so turns are readable even when the camera orbits. + const facing = new THREE.ArrowHelper( + new THREE.Vector3(0, 0, 1), + new THREE.Vector3(0, GUIDE_Y + 0.006, 0), + 0.46, + 0xd4ff3f, + 0.12, + 0.075, + ); + facing.name = "floor-facing-direction"; + facing.line.renderOrder = 4; + facing.cone.renderOrder = 4; + configureLineMaterial(facing.line.material, 0.95); + const coneMaterial = facing.cone.material; + if (Array.isArray(coneMaterial)) { + for (const material of coneMaterial) material.depthWrite = false; + } else { + coneMaterial.depthWrite = false; + } + group.add(facing); + + return { + group, + getInfo(visible) { + return { + visible, + gridStepMetres: data.gridStepMetres, + scaleBarMetres: data.scaleBarMetres, + hasTravel: data.hasTravel, + hasLoopReset: data.hasLoopReset, + waypoints: data.waypoints.map((point) => ({ ...point })), + }; + }, + setOrigin(x, z) { + group.position.x = x; + group.position.z = z; + }, + updateRoot(offset, yawRadians) { + current.position.x = offset.x; + current.position.z = offset.z; + facing.position.x = offset.x; + facing.position.z = offset.z; + facing.setDirection( + new THREE.Vector3(Math.sin(yawRadians), 0, Math.cos(yawRadians)).normalize(), + ); + }, + dispose() { + group.traverse((object) => { + const drawable = object as THREE.Mesh | THREE.Line; + drawable.geometry?.dispose(); + const material = drawable.material; + if (Array.isArray(material)) material.forEach((item) => item.dispose()); + else material?.dispose(); + }); + }, + }; +} + +function configureLineMaterial( + material: THREE.Material | THREE.Material[], + opacity: number, +): void { + const materials = Array.isArray(material) ? material : [material]; + for (const item of materials) { + item.transparent = true; + item.opacity = opacity; + item.depthWrite = false; + } +} diff --git a/packages/posecode-render/src/groundlock.ts b/packages/posecode-render/src/groundlock.ts index e9c0829..486cdc9 100644 --- a/packages/posecode-render/src/groundlock.ts +++ b/packages/posecode-render/src/groundlock.ts @@ -82,8 +82,13 @@ function rotateRootAboutPivot(m: Mannequin, pivot: THREE.Vector3, angle: number) m.root.updateMatrixWorld(true); } -/** A foot whose mesh bottom is within this height counts as planted. */ -const PLANTED_MAX_Y = 0.05; +/** A foot whose visible surface is within this height counts as planted. */ +export const GROUND_LOCK_PLANTED_MAX_Y = 0.05; + +/** Shared swing-foot predicate for ground-lock and its diagnostics. */ +export function isGroundLockFootPlanted(surfaceMinY: number): boolean { + return Number.isFinite(surfaceMinY) && surfaceMinY <= GROUND_LOCK_PLANTED_MAX_Y; +} /** * Apply ground-lock for the phase's active effector groups (see module doc). @@ -238,7 +243,7 @@ function plantFeetHorizontally( const node = m.bones.get(id); if (!anchor || !node) continue; const box = new THREE.Box3().setFromObject(node); - if (!Number.isFinite(box.min.y) || box.min.y > PLANTED_MAX_Y) continue; // swing foot + if (!isGroundLockFootPlanted(box.min.y)) continue; // swing foot node.getWorldPosition(p); dx += anchor.x - p.x; dz += anchor.z - p.z; diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index 6ab530e..bdb88f3 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -21,6 +21,14 @@ import { type PhaseSegment, type WeightedReachTarget, } from "./timeline.js"; +import { + buildFloorGuideData, + createFloorGuide, + syncFloorGuideToSolvedRoot, + type FloorGuideData, + type FloorGuideInfo, + type FloorGuideScene, +} from "./floor-guide.js"; import { buildProps, type PropScene } from "./props.js"; import { loadCharacter, type Character } from "./character.js"; import { @@ -31,6 +39,10 @@ import { type ClipSource, } from "./clips.js"; import { depenetrate } from "./depenetrate.js"; +import { + measureConstraintDiagnostics, + type ConstraintDiagnostic, +} from "./diagnostics.js"; import { resolvePropContacts, propContactExemptions } from "./propcontact.js"; import { alignFloorContacts, @@ -58,11 +70,14 @@ import { import { solveCCD } from "./ik.js"; const DEG = Math.PI / 180; +/** Live diagnostics match the playground warning refresh cadence (~5Hz). */ +const CONSTRAINT_DIAGNOSTIC_INTERVAL_MS = 200; export interface ViewerPhaseInfo { /** Zero-based real phase index, or -1 while blending through loop reset. */ phaseIndex: number; phaseName: string; + /** Display-only coaching text from the phase; it never drives the animation. */ cue?: string; } @@ -88,8 +103,12 @@ export interface Viewer { /** True while a retargeted mocap clip is driving (or fading over) the pose. */ get clipActive(): boolean; getTimeline(): TimelineInfo | null; + /** Floor scale/orientation and authored root-path metadata for the loaded clip. */ + getFloorGuideInfo(): FloorGuideInfo | null; /** Diagnostics for every active reach, including missing/unreachable targets. */ getReachResiduals(): readonly ReachResidual[]; + /** Procedural-driver grounding/collision outcomes, before optional skin/mocap reconciliation. */ + getConstraintDiagnostics(): readonly ConstraintDiagnostic[]; /** Precise visible world bounds; intended for audits and deterministic export. */ getVisibleBounds(): THREE.Box3; getMannequin(): any; @@ -110,6 +129,11 @@ export interface Viewer { export interface ViewerOptions { /** Slowly orbit the camera when idle. Defaults to true. */ autoRotate?: boolean; + /** + * Show the metric floor, load origin, live facing arrow, and authored travel + * path. Defaults to true; set false for a completely clean embed. + */ + floorGuide?: 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 @@ -211,12 +235,6 @@ export function createViewer( ground.receiveShadow = true; scene.add(ground); - const grid = new THREE.GridHelper(12, 24, 0x2b323d, 0x1b2027); - (grid.material as THREE.Material).transparent = true; - (grid.material as THREE.Material).opacity = 0.5; - grid.position.y = 0.001; - scene.add(grid); - let mannequin: Mannequin = buildMannequin(); enableShadows(mannequin.root); scene.add(mannequin.root); @@ -327,6 +345,9 @@ export function createViewer( } let timeline: BuiltTimeline | null = null; + const floorGuideEnabled = opts.floorGuide ?? true; + let floorGuideData: FloorGuideData | null = null; + let floorGuide: FloorGuideScene | null = null; // Finger bones the loaded document explicitly poses (make-a-fist, finger-spell, // hand-wave): the L4.1 resting-hand curl leaves these alone. let authoredFingers = new Set(); @@ -340,6 +361,9 @@ export function createViewer( // Rebuilt on every solved frame. Missing target names and unreachable // effectors remain visible here instead of disappearing behind `continue`. let reachResiduals: ReachResidual[] = []; + let constraintDiagnostics: ConstraintDiagnostic[] = []; + let constraintDiagnosticsDirty = true; + let lastConstraintDiagnosticsAt = -Infinity; type ReachResidualTarget = | { kind: "fixed"; point: THREE.Vector3 } | { kind: "floor"; point: THREE.Vector3 } @@ -371,6 +395,23 @@ export function createViewer( // the canvas; they are deterministic anchor preparation, not visible frames. let precomputingAnchors = false; + function refreshConstraintDiagnostics( + info: ReturnType["sample"]>, + ): void { + if (precomputingAnchors) return; + const now = performance.now(); + if (!constraintDiagnosticsDirty) { + if (!playing || now - lastConstraintDiagnosticsAt < CONSTRAINT_DIAGNOSTIC_INTERVAL_MS) return; + } + constraintDiagnostics = measureConstraintDiagnostics( + mannequin, + info.groundLock, + info.pins, + ); + constraintDiagnosticsDirty = false; + lastConstraintDiagnosticsAt = now; + } + // Camera easing targets. const desiredTarget = new THREE.Vector3(0, 0.9, 0); const desiredPos = camera.position.clone(); @@ -806,6 +847,18 @@ export function createViewer( mannequin.root.updateMatrixWorld(true); } refreshReachResiduals(); + refreshConstraintDiagnostics(info); + // Root/contact solvers can translate X/Z beyond the authored travel + // offset (pins, grips, and solid-prop correction). Keep the live floor + // marker on the final rendered root rather than the pre-solve target. + if (!precomputingAnchors && floorGuide) { + syncFloorGuideToSolvedRoot( + floorGuide, + mannequin.root.position, + baseRootPos, + info.rootYaw, + ); + } if (!precomputingAnchors && info.phaseIndex !== lastPhaseIndex) { lastPhaseIndex = info.phaseIndex; phaseCb({ @@ -867,6 +920,7 @@ export function createViewer( } else { time = timeline.duration; playing = false; + constraintDiagnosticsDirty = true; } } tickCb(time, timeline.duration); @@ -880,9 +934,22 @@ export function createViewer( load(ir: PosecodeIR) { lastIR = ir; timeline = buildTimeline(ir); + floorGuideData = buildFloorGuideData(ir, timeline); + if (floorGuide) { + scene.remove(floorGuide.group); + floorGuide.dispose(); + floorGuide = null; + } + if (floorGuideEnabled) { + floorGuide = createFloorGuide(floorGuideData); + scene.add(floorGuide.group); + } time = 0; lastPhaseIndex = -2; reachResiduals = []; + constraintDiagnostics = []; + constraintDiagnosticsDirty = true; + lastConstraintDiagnosticsAt = -Infinity; reachResidualTargets = []; authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId)); authoredShoulders = new Set(timeline.bonesUsed.filter((id) => id.startsWith("shoulder_"))); @@ -949,6 +1016,8 @@ export function createViewer( captureGroundTargets(); baseRootPos.copy(mannequin.root.position); baseRootQuat.copy(mannequin.root.quaternion); + floorGuide?.setOrigin(baseRootPos.x, baseRootPos.z); + floorGuide?.updateRoot({ x: 0, z: 0 }, 0); // Precompute world positions of all effectors at start of each segment segmentStartEffectors.length = 0; @@ -1063,25 +1132,33 @@ export function createViewer( levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []); enforceContactRom(mannequin); } + // Anchor precomputation samples every phase endpoint; restore the visual + // root marker alongside the figure before the first visible frame. + floorGuide?.updateRoot({ x: 0, z: 0 }, 0); requestClip(ir); frameCamera(); }, play() { playing = true; + constraintDiagnosticsDirty = true; lastT = performance.now(); }, pause() { playing = false; + constraintDiagnosticsDirty = true; }, toggle() { playing = !playing; + constraintDiagnosticsDirty = true; lastT = performance.now(); return playing; }, seek(seconds: number) { if (!timeline) return; time = THREE.MathUtils.clamp(seconds, 0, timeline.duration); + constraintDiagnostics = []; + constraintDiagnosticsDirty = true; }, setSpeed(multiplier: number) { speed = Math.max(0.1, multiplier); @@ -1112,9 +1189,25 @@ export function createViewer( segments: timeline.segments, }; }, + getFloorGuideInfo() { + if (!floorGuideData) return null; + return floorGuide + ? floorGuide.getInfo(true) + : { + visible: false, + gridStepMetres: floorGuideData.gridStepMetres, + scaleBarMetres: floorGuideData.scaleBarMetres, + hasTravel: floorGuideData.hasTravel, + hasLoopReset: floorGuideData.hasLoopReset, + waypoints: floorGuideData.waypoints.map((point) => ({ ...point })), + }; + }, getReachResiduals() { return reachResiduals.map((residual) => ({ ...residual })); }, + getConstraintDiagnostics() { + return constraintDiagnostics.map((diagnostic) => ({ ...diagnostic })); + }, getVisibleBounds() { return character?.getBounds() ?? new THREE.Box3().setFromObject(mannequin.root); }, @@ -1142,6 +1235,7 @@ export function createViewer( controls.dispose(); clipLayer?.dispose(); character?.dispose(); + floorGuide?.dispose(); renderer.dispose(); }, }; @@ -1342,9 +1436,25 @@ function disposeTree(root: THREE.Object3D): void { } export { buildMannequin } from "./mannequin.js"; -export { applyGroundLock, groundFigure } from "./groundlock.js"; +export { + applyGroundLock, + groundFigure, + GROUND_LOCK_PLANTED_MAX_Y, + isGroundLockFootPlanted, +} from "./groundlock.js"; export type { Mannequin, Proportions, CollisionRadii } from "./mannequin.js"; export { buildTimeline } from "./timeline.js"; +export { + FLOOR_GRID_STEP_METRES, + FLOOR_SCALE_BAR_METRES, + buildFloorGuideData, + createFloorGuide, + syncFloorGuideToSolvedRoot, + type FloorGuideData, + type FloorGuideInfo, + type FloorGuidePoint, + type FloorGuideScene, +} from "./floor-guide.js"; export { solveCCD, type IkChain, type JointLimits } from "./ik.js"; export { EFFECTOR_BONE, @@ -1366,10 +1476,26 @@ export { type ClipLayer, type ClipSource, } from "./clips.js"; -export { depenetrate } from "./depenetrate.js"; +export { + depenetrate, + measureSelfCollisions, + type SelfCollisionKind, + type SelfCollisionResidual, +} from "./depenetrate.js"; +export { + FOOT_CONTACT_HEIGHT_MAX, + PLANTIGRADE_SOLE_ANGLE_MAX, + SELF_COLLISION_DEPTH_MAX, + measureConstraintDiagnostics, + type ConstraintDiagnostic, + type ConstraintDiagnosticKind, + type DiagnosticPin, +} from "./diagnostics.js"; export { PALM_LOCAL_NORMAL, FIST_LOCAL_NORMAL, + PLANT_FADE, + PLANTARFLEX_SKIP, alignFloorContacts, alignFloorPalms, prepareGripFrames, @@ -1377,9 +1503,15 @@ export { isDipBarGrip, floorTargetForEffector, floorContactHeight, + measureFootContact, enforceContactRom, formFists, levelPlantedFeet, + relaxHands, + swingArms, + aimHead, wrapGrip, + type BodySide, + type FootContactMeasurement, } from "./contacts.js"; export type { PhaseSegment } from "./timeline.js"; diff --git a/packages/posecode-render/src/mannequin.ts b/packages/posecode-render/src/mannequin.ts index dcaaf5c..317bd91 100644 --- a/packages/posecode-render/src/mannequin.ts +++ b/packages/posecode-render/src/mannequin.ts @@ -26,6 +26,10 @@ export interface Mannequin { collision: CollisionRadii; /** Visible meshes that form semantic local support surfaces. */ contactSurfaces: { + /** Thin sole mesh used for heel/toe grounding diagnostics. */ + foot_left: THREE.Object3D[]; + /** Thin sole mesh used for heel/toe grounding diagnostics. */ + foot_right: THREE.Object3D[]; /** Trunk/mat surface used by `pin: pelvis floor`. */ pelvis: THREE.Object3D[]; /** Rounded thigh/shin caps that meet at each supporting knee. */ @@ -249,8 +253,8 @@ export function buildMannequin(material?: THREE.Material, proportions?: Proporti 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, proportions?.soleDrop); - addShoe(bones.get("ankle_right")!, mats, proportions?.soleDrop); + const leftSole = addShoe(bones.get("ankle_left")!, mats, proportions?.soleDrop); + const rightSole = addShoe(bones.get("ankle_right")!, mats, proportions?.soleDrop); // A pelvis floor pin represents the trunk's mat contact, whose thickness // changes with orientation (supine/prone) and spinal pose. A fixed proxy @@ -293,6 +297,8 @@ export function buildMannequin(material?: THREE.Material, proportions?: Proporti }, collision: proportions?.collision ?? DEFAULT_COLLISION, contactSurfaces: { + foot_left: [leftSole], + foot_right: [rightSole], pelvis: pelvisSurface, knee_left: jointCaps.get("knee_left") ?? [], knee_right: jointCaps.get("knee_right") ?? [], @@ -443,10 +449,11 @@ function addPalm(wrist: THREE.Object3D, mat: THREE.Material): void { * 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, soleDrop?: number): void { +function addShoe(ankle: THREE.Object3D, mats: FigureMaterials, soleDrop?: number): THREE.Mesh { 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 + dy, 0.05); ankle.add(sole); + return sole; } diff --git a/packages/posecode-render/src/timeline.ts b/packages/posecode-render/src/timeline.ts index cb70c7c..7b66cb0 100644 --- a/packages/posecode-render/src/timeline.ts +++ b/packages/posecode-render/src/timeline.ts @@ -208,10 +208,42 @@ function blendReaches( } export function buildTimeline(ir: PosecodeIR): BuiltTimeline { - const basePose = poseFor(ir.startPose); + const namedBasePose = poseFor(ir.startPose); const baseJoints = new Map( - Object.entries(basePose.joints ?? {}), + Object.entries(namedBasePose.joints ?? {}).map(([boneId, euler]) => [boneId, [...euler]]), ); + // A custom start pose is a sparse overlay on a named built-in pose. Merge + // only authored channels, exactly like phase targets, so (for example) a + // standing elbow-flex override preserves the built-in relaxed forearm roll. + // This composed map seeds both the t=0 anchor and the structural loop reset. + for (const target of ir.startPoseOverrides ?? []) { + const next = [...(baseJoints.get(target.boneId) ?? [0, 0, 0])] as EulerDegTuple; + for (const axis of target.axes ?? ALL_AXES) { + next[AXIS_INDEX[axis]] = target.euler[axis]; + } + baseJoints.set(target.boneId, next); + } + // Expose the same composed pose that sampling uses. Consumers use basePose + // to initialize/ground the root, and must not observe a stale named preset + // while sample(0) applies a different customized joint map. + const sampledBaseJoints = snapshot(baseJoints); + const basePose: PoseSpec = { + ...(namedBasePose.root + ? { + root: { + ...(namedBasePose.root.position + ? { position: [...namedBasePose.root.position] as [number, number, number] } + : {}), + ...(namedBasePose.root.rotationDeg + ? { rotationDeg: [...namedBasePose.root.rotationDeg] as [number, number, number] } + : {}), + }, + } + : {}), + joints: Object.fromEntries( + [...sampledBaseJoints].map(([boneId, euler]) => [boneId, [...euler] as EulerDegTuple]), + ), + }; // Accumulating current joint angles (degrees). const curr = new Map(baseJoints); diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts index fb6e707..bda8006 100644 --- a/packages/posecode-render/test/contacts.test.ts +++ b/packages/posecode-render/test/contacts.test.ts @@ -10,6 +10,7 @@ import { floorTargetForEffector, formFists, levelPlantedFeet, + measureFootContact, prepareGripFrames, relaxHands, swingArms, @@ -76,6 +77,25 @@ describe("levelPlantedFeet", () => { expect(m.bones.get("ankle_left")!.quaternion.angleTo(leftBefore)).toBeGreaterThan(1e-3); expect(m.bones.get("ankle_right")!.quaternion.angleTo(rightBefore)).toBeLessThan(1e-6); }); + + it("reports visible heel/toe height and sole angle, not ankle-bone height", () => { + const m = buildMannequin(); + groundFigure(m); + const flat = measureFootContact(m, "left")!; + expect(flat.heelHeight).toBeCloseTo(0, 5); + expect(flat.toeHeight).toBeCloseTo(0, 5); + expect(flat.soleAngleDeg).toBeCloseTo(0, 5); + + m.bones.get("knee_left")!.rotation.x = 25 * DEG; + m.root.updateMatrixWorld(true); + groundFigure(m); + levelPlantedFeet(m, ["foot_left"]); + groundFigure(m); + const limited = measureFootContact(m, "left")!; + expect(Math.min(limited.heelHeight, limited.toeHeight)).toBeLessThan(0.02); + expect(limited.heelHeight).toBeGreaterThan(0.02); + expect(limited.soleAngleDeg).toBeGreaterThan(5); + }); }); describe("contact geometry", () => { diff --git a/packages/posecode-render/test/depenetrate.test.ts b/packages/posecode-render/test/depenetrate.test.ts index c971352..6cded14 100644 --- a/packages/posecode-render/test/depenetrate.test.ts +++ b/packages/posecode-render/test/depenetrate.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import * as THREE from "three"; import { buildMannequin } from "../src/mannequin.js"; -import { depenetrate } from "../src/depenetrate.js"; +import { depenetrate, measureSelfCollisions } from "../src/depenetrate.js"; const DEG = Math.PI / 180; @@ -49,6 +49,22 @@ describe("self-collision de-penetration", () => { expect(elbow.x).toBeCloseTo(-25 * DEG, 5); }); + it("exposes stable named residuals before and after correction", () => { + const m = buildMannequin(); + m.bones.get("shoulder_left")!.rotation.set(0, 0, -80 * DEG); + m.bones.get("elbow_left")!.rotation.set(-25 * DEG, 0, 0); + m.root.updateMatrixWorld(true); + const before = measureSelfCollisions(m).find((item) => + item.id === "self-collision:arm_left:body", + )!; + + depenetrate(m); + const after = measureSelfCollisions(m).find((item) => item.id === before.id)!; + expect(before.depth).toBeGreaterThan(0); + expect(after.depth).toBeLessThan(before.depth); + expect(after.kind).toBe("arm-body"); + }); + it("leaves a clean pose untouched", () => { const m = buildMannequin(); m.bones.get("shoulder_left")!.rotation.set(-90 * DEG, 0, 0); // arm straight forward diff --git a/packages/posecode-render/test/diagnostics.test.ts b/packages/posecode-render/test/diagnostics.test.ts new file mode 100644 index 0000000..619ac7f --- /dev/null +++ b/packages/posecode-render/test/diagnostics.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { buildMannequin } from "../src/mannequin.js"; +import { floorContactHeight } from "../src/contacts.js"; +import { + GROUND_LOCK_PLANTED_MAX_Y, + groundFigure, + isGroundLockFootPlanted, +} from "../src/groundlock.js"; +import { measureConstraintDiagnostics } from "../src/diagnostics.js"; + +describe("live constraint diagnostics", () => { + it("treats a floor-pinned foot as an active support", () => { + const m = buildMannequin(); + groundFigure(m); + + const diagnostics = measureConstraintDiagnostics( + m, + [], + [{ effector: "foot_left", anchor: "floor" }], + ); + + expect(diagnostics.map((item) => item.id)).toEqual(expect.arrayContaining([ + "grounding:foot_left:toe-height", + "grounding:foot_left:heel-height", + "grounding:foot_left:sole-angle", + ])); + expect(diagnostics.some((item) => item.id.includes("foot_right"))).toBe(false); + }); + + it("uses ground-lock's exact planted predicate for a grouped swing foot", () => { + const m = buildMannequin(); + groundFigure(m); + const ankle = m.bones.get("ankle_left")!; + + ankle.position.y += GROUND_LOCK_PLANTED_MAX_Y + 0.005; + m.root.updateMatrixWorld(true); + expect(isGroundLockFootPlanted(floorContactHeight(m, "foot_left") ?? NaN)).toBe(false); + expect(measureConstraintDiagnostics(m, ["feet"]) + .some((item) => item.id.includes("foot_left"))).toBe(false); + expect(measureConstraintDiagnostics(m, ["foot_left"]) + .some((item) => item.id.includes("foot_left"))).toBe(true); + expect(measureConstraintDiagnostics( + m, + [], + [{ effector: "foot_left", anchor: "floor" }], + ).some((item) => item.id.includes("foot_left"))).toBe(true); + + ankle.position.y -= 0.01; + m.root.updateMatrixWorld(true); + expect(isGroundLockFootPlanted(floorContactHeight(m, "foot_left") ?? NaN)).toBe(true); + expect(measureConstraintDiagnostics(m, ["feet"]) + .some((item) => item.id === "grounding:foot_left:toe-height")).toBe(true); + }); + + it("defines the planted cutoff as an inclusive finite boundary", () => { + expect(isGroundLockFootPlanted(GROUND_LOCK_PLANTED_MAX_Y)).toBe(true); + expect(isGroundLockFootPlanted(GROUND_LOCK_PLANTED_MAX_Y + Number.EPSILON)).toBe(false); + expect(isGroundLockFootPlanted(Number.NaN)).toBe(false); + expect(isGroundLockFootPlanted(Infinity)).toBe(false); + }); +}); diff --git a/packages/posecode-render/test/floor-guide.test.ts b/packages/posecode-render/test/floor-guide.test.ts new file mode 100644 index 0000000..fcccff0 --- /dev/null +++ b/packages/posecode-render/test/floor-guide.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import * as THREE from "three"; +import { parse } from "posecode-parser"; +import { buildTimeline } from "../src/timeline.js"; +import { + buildFloorGuideData, + createFloorGuide, + syncFloorGuideToSolvedRoot, +} from "../src/floor-guide.js"; + +function parseTimeline(source: string) { + const parsed = parse(source); + expect(parsed.errors).toEqual([]); + expect(parsed.ir).toBeDefined(); + return { ir: parsed.ir!, timeline: buildTimeline(parsed.ir!) }; +} + +describe("floor guide", () => { + it("keeps static clips quiet while preserving metric orientation metadata", () => { + const { ir, timeline } = parseTimeline([ + 'posecode exercise "Curl"', + " rig humanoid", + " pose start = standing", + ' step "Curl" 1s linear:', + " elbows: flex 90", + " repeat 1", + ].join("\n")); + + const data = buildFloorGuideData(ir, timeline); + expect(data.gridStepMetres).toBe(0.5); + expect(data.scaleBarMetres).toBe(1); + expect(data.hasTravel).toBe(false); + expect(data.hasLoopReset).toBe(false); + expect(data.waypoints).toEqual([{ x: 0, z: 0 }]); + expect(data.path).toEqual([]); + expect(data.resetPath).toEqual([]); + + const guide = createFloorGuide(data); + expect(guide.group.getObjectByName("floor-origin")).toBeDefined(); + expect(guide.group.getObjectByName("floor-facing-direction")).toBeDefined(); + expect(guide.group.getObjectByName("floor-scale-one-metre")).toBeDefined(); + expect(guide.group.getObjectByName("floor-travel-path")).toBeUndefined(); + expect(guide.group.getObjectByName("floor-loop-reset-path")).toBeUndefined(); + guide.dispose(); + }); + + it("uses timeline interpolation for the visible path and retains authored waypoints", () => { + const { ir, timeline } = parseTimeline([ + 'posecode exercise "Corner"', + " rig humanoid", + " pose start = standing", + ' step "Across" 1s flow:', + " travel: 1 0", + ' step "Forward" 1s flow:', + " travel: 1 1", + " repeat 1", + ].join("\n")); + + const data = buildFloorGuideData(ir, timeline); + expect(data.hasTravel).toBe(true); + expect(data.waypoints).toEqual([ + { x: 0, z: 0 }, + { x: 1, z: 0 }, + { x: 1, z: 1 }, + ]); + expect(data.path[0]).toEqual({ x: 0, z: 0 }); + expect(data.path.some((point) => Math.hypot(point.x - 1, point.z) < 1e-6)).toBe(true); + expect(data.path.at(-1)).toEqual({ x: 1, z: 1 }); + // The solid authored path stops at the final waypoint. The implicit + // Hermite/flow return is separate, never presented as authored travel. + expect(data.hasLoopReset).toBe(true); + expect(data.resetPath[0]).toEqual({ x: 1, z: 1 }); + expect(data.resetPath.at(-1)).toEqual({ x: 0, z: 0 }); + const resetMidTime = (timeline.segments.at(-1)!.end + timeline.duration) / 2; + const sampledResetMidpoint = timeline.sample(resetMidTime, new Map()).rootOffset; + expect(data.resetPath.some((point) => + Math.hypot( + point.x - sampledResetMidpoint.x, + point.z - sampledResetMidpoint.z, + ) < 1e-6, + )).toBe(true); + const authoredBeforeEnd = data.path.at(-2)!; + const resetAfterStart = data.resetPath[1]!; + const authoredVector = { + x: 1 - authoredBeforeEnd.x, + z: 1 - authoredBeforeEnd.z, + }; + const resetVector = { + x: resetAfterStart.x - 1, + z: resetAfterStart.z - 1, + }; + // The final authored leg arrives from a different direction than the + // automatic return; the dashed reset must still be present as its own path. + expect(Math.abs(authoredVector.x * resetVector.z - authoredVector.z * resetVector.x)) + .toBeGreaterThan(1e-4); + + const guide = createFloorGuide(data); + expect(guide.group.getObjectByName("floor-travel-path")).toBeDefined(); + expect(guide.group.getObjectByName("floor-travel-waypoint-2")).toBeDefined(); + const reset = guide.group.getObjectByName("floor-loop-reset-path") as THREE.Line; + expect(reset).toBeDefined(); + expect(reset.material).toBeInstanceOf(THREE.LineDashedMaterial); + expect(reset.geometry.getAttribute("lineDistance")).toBeDefined(); + guide.dispose(); + }); + + it("moves the live marker and rotates +Z facing with root choreography", () => { + const { ir, timeline } = parseTimeline([ + 'posecode exercise "Quarter turn"', + " rig humanoid", + ' step "Turn" 1s linear:', + " turn: 90", + " travel: 0.5 -0.25", + " repeat 1", + ].join("\n")); + const guide = createFloorGuide(buildFloorGuideData(ir, timeline)); + + guide.setOrigin(0.2, -0.4); + guide.updateRoot({ x: 0.5, z: -0.25 }, Math.PI / 2); + expect(guide.group.position.x).toBeCloseTo(0.2); + expect(guide.group.position.z).toBeCloseTo(-0.4); + + const marker = guide.group.getObjectByName("floor-current-position")!; + expect(marker.position.x).toBeCloseTo(0.5); + expect(marker.position.z).toBeCloseTo(-0.25); + + const arrow = guide.group.getObjectByName("floor-facing-direction")!; + const direction = new THREE.Vector3(0, 1, 0).applyQuaternion(arrow.quaternion); + expect(direction.x).toBeCloseTo(1, 5); + expect(direction.z).toBeCloseTo(0, 5); + guide.dispose(); + }); + + it("tracks the final solver-adjusted root relative to the load origin", () => { + const { ir, timeline } = parseTimeline([ + 'posecode exercise "Squat"', + " rig humanoid", + ' step "Lower" 1s settle:', + " knees: flex 90", + " ground-lock: feet", + " repeat 1", + ].join("\n")); + const guide = createFloorGuide(buildFloorGuideData(ir, timeline)); + + // Simulate a contact solver moving the final root 17.5cm behind the + // authored zero-travel target, from a non-zero world-space load origin. + syncFloorGuideToSolvedRoot( + guide, + { x: 0.4, z: -0.375 }, + { x: 0.4, z: -0.2 }, + 0, + ); + + const marker = guide.group.getObjectByName("floor-current-position")!; + expect(marker.position.x).toBeCloseTo(0); + expect(marker.position.z).toBeCloseTo(-0.175); + guide.dispose(); + }); +}); diff --git a/packages/posecode-render/test/timeline-sparse.test.ts b/packages/posecode-render/test/timeline-sparse.test.ts index 7118b24..739abe2 100644 --- a/packages/posecode-render/test/timeline-sparse.test.ts +++ b/packages/posecode-render/test/timeline-sparse.test.ts @@ -11,6 +11,113 @@ function eulerDegrees(node: THREE.Object3D): THREE.Euler { } describe("sparse timeline targets", () => { + it("composes custom start overrides and restores them at the loop boundary", () => { + const result = parse([ + 'posecode posture "Custom opening"', + " rig humanoid", + " pose start = standing:", + " elbow_left: flex 35", + " hip_left: flex 20", + ' step "Move" 1s settle:', + " elbow_left: flex 100", + ].join("\n")); + expect(result.errors).toEqual([]); + const timeline = buildTimeline(result.ir!); + const mannequin = buildMannequin(); + + expect(timeline.basePose.root).toEqual({ + position: [0, 0, 0], + rotationDeg: [0, 0, 0], + }); + expect(timeline.basePose.joints?.elbow_left).toEqual([-35, -80, 0]); + expect(timeline.basePose.joints?.hip_left).toEqual([-20, 0, 0]); + + timeline.sample(0, mannequin.bones); + let elbow = eulerDegrees(mannequin.bones.get("elbow_left")!); + let hip = eulerDegrees(mannequin.bones.get("hip_left")!); + expect(elbow.x * DEG).toBeCloseTo(-35, 4); + expect(elbow.y * DEG).toBeCloseTo(-80, 4); // inherited from standing + expect(hip.x * DEG).toBeCloseTo(-20, 4); + + expect(timeline.duration).toBeCloseTo(2, 5); // movement + reset segment + timeline.sample(timeline.duration - 1e-6, mannequin.bones); + elbow = eulerDegrees(mannequin.bones.get("elbow_left")!); + hip = eulerDegrees(mannequin.bones.get("hip_left")!); + expect(elbow.x * DEG).toBeCloseTo(-35, 3); + expect(elbow.y * DEG).toBeCloseTo(-80, 3); + expect(hip.x * DEG).toBeCloseTo(-20, 3); + }); + + it("lets a start override explicitly neutralize built-in channels", () => { + const result = parse([ + 'posecode posture "Neutral forearm"', + " rig humanoid", + " pose start = standing:", + " elbow_left: hold neutral", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + const timeline = buildTimeline(result.ir!); + const mannequin = buildMannequin(); + timeline.sample(0, mannequin.bones); + const elbow = eulerDegrees(mannequin.bones.get("elbow_left")!); + expect(elbow.x * DEG).toBeCloseTo(0, 4); + expect(elbow.y * DEG).toBeCloseTo(0, 4); + expect(elbow.z * DEG).toBeCloseTo(0, 4); + }); + + it("returns a basePose with the same hip-hinge coupling as sample(0)", () => { + const result = parse([ + 'posecode posture "Hinged opening"', + " rig humanoid", + " pose start = standing:", + " pelvis: hinge 50", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + expect(result.errors).toEqual([]); + const timeline = buildTimeline(result.ir!); + expect(timeline.basePose.joints?.pelvis).toEqual([50, 0, 0]); + expect(timeline.basePose.joints?.hip_left).toEqual([-50, 0, 0]); + expect(timeline.basePose.joints?.hip_right).toEqual([-50, 0, 0]); + + const mannequin = buildMannequin(); + timeline.sample(0, mannequin.bones); + for (const boneId of ["pelvis", "hip_left", "hip_right"] as const) { + const [x, y, z] = timeline.basePose.joints![boneId]!; + const expected = new THREE.Quaternion().setFromEuler( + new THREE.Euler(x / DEG, y / DEG, z / DEG, "XYZ"), + ); + expect(mannequin.bones.get(boneId)!.quaternion.angleTo(expected)).toBeLessThan(1e-7); + } + }); + + it("keeps cues as display-only metadata", () => { + const source = (withCue: boolean) => [ + 'posecode posture "Cue contract"', + " rig humanoid", + " pose start = standing", + ' step "Reach" 1s settle:', + " shoulder_left: flex 45", + " ground-lock: feet", + ...(withCue ? [' cue "Lift the left arm"'] : []), + ].join("\n"); + + const withCue = buildTimeline(parse(source(true)).ir!); + const withoutCue = buildTimeline(parse(source(false)).ir!); + const cuedMannequin = buildMannequin(); + const plainMannequin = buildMannequin(); + const cuedInfo = withCue.sample(0.5, cuedMannequin.bones); + const plainInfo = withoutCue.sample(0.5, plainMannequin.bones); + + expect(cuedInfo.cue).toBe("Lift the left arm"); + expect({ ...cuedInfo, cue: undefined }).toEqual({ ...plainInfo, cue: undefined }); + for (const [id, cuedBone] of cuedMannequin.bones) { + const plainBone = plainMannequin.bones.get(id)!; + expect(cuedBone.quaternion.angleTo(plainBone.quaternion), id).toBeLessThan(1e-7); + } + }); + it("keeps duplicate phase names distinct by timeline index", () => { const result = parse([ 'posecode posture "Repeated label"', diff --git a/packages/posecode-share/test/share.test.ts b/packages/posecode-share/test/share.test.ts index 9e6ce2f..d548547 100644 --- a/packages/posecode-share/test/share.test.ts +++ b/packages/posecode-share/test/share.test.ts @@ -30,6 +30,19 @@ describe("posecode-share codec", () => { expect(decodePosecode(encodePosecode(unicode))).toBe(unicode); }); + it("round-trips a scoped custom start pose verbatim", () => { + const source = [ + 'posecode posture "Custom opening"', + " rig humanoid", + " pose start = standing:", + " shoulder_left: flex 25", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n"); + expect(decodePosecode(encodePosecode(source))).toBe(source); + expect(readShareHash(buildShareHash(source))).toBe(source); + }); + it("produces a URL-safe token (no +, /, =, or whitespace)", () => { const token = encodePosecode(SAMPLE); expect(token).toMatch(/^[A-Za-z0-9_-]+$/); diff --git a/playground/play.html b/playground/play.html index 398d4e8..0d04184 100644 --- a/playground/play.html +++ b/playground/play.html @@ -90,6 +90,15 @@ + + Spec +
-
+
+ +
@@ -226,6 +253,22 @@

How to use Posecode

+
+

Authoring reference

+

+ The language specification is the normative + Posecode contract. The LLM guide is a + self-contained authoring aid built from that contract. +

+

+ ground-lock preserves an existing floor support; + reach moves a limb; pin moves the whole body + around one anchor; and grip solves hands onto a bar or + rails. A cue is display-only coaching text and never + changes the animation. +

+
+
The prompt you’ll paste into the LLM diff --git a/playground/public/llm-guide.html b/playground/public/llm-guide.html index 482e627..42facc1 100644 --- a/playground/public/llm-guide.html +++ b/playground/public/llm-guide.html @@ -121,6 +121,7 @@

For LLMs

Authoring .posecode with an LLM

Paste the prompt below into ChatGPT, Claude, or any capable model. Then ask for a movement ("write a squat", "show a hamstring stretch") and paste the reply into the Posecode playground.

+

This is the task-oriented, pasteable authoring guide. The published Posecode Protocol Specification is the normative language and IR contract. This guide stays self-contained so a model does not need to follow a link, but the specification wins if the two ever disagree.


You write Posecode, a small text language that describes one person's movement so a 3D figure can animate it.

When the request is representable, output ONLY the raw .posecode document: no Markdown fence and no prose. Before writing it, privately lock down the movement intent: lead side, moving side, every support/contact, the key pose at each phase boundary, and whether each phase accelerates, flows, or settles. Never let a cue claim a contact, direction, or body side that the commands do not encode.

@@ -130,6 +131,9 @@

Grammar

rig humanoid prop <type> # optional: chair | wall | bar | box | dip-bars (repeatable) pose start = <pose> # neutral | standing | first-position | plank | supine | prone | seated + # Or append `:` and indent sparse joint overrides beneath it: + pose start = <pose>: + <joint>: <action> <degrees> step "<Phase name>" <Ns> <mode>: # mode = flow | settle | drive | snap | linear <joint>: <action> <degrees> reach: <effector> <target> # limb IK to a landmark, floor, or declared prop anchor @@ -138,8 +142,18 @@

Grammar

ground-lock: <contacts> # planted supports; groups/back or side-specific names turn: <degrees> # optional: face this yaw by phase end (standing only) travel: <x> <z> # optional: move to this x z (metres) by phase end - cue "<short coaching cue>" + cue "<short coaching cue>" # display-only text; never changes the motion repeat <count> +

Contact mechanisms

+
+ + + + +
DirectiveWhat movesUse it for
ground-lockThe solver preserves an existing floor support while the body moves.A foot, hand, forearm, or the back is already planted.
reachA limb endpoint moves toward a target through IK; the body root does not translate.An additional floor, body-landmark, or declared-prop contact.
pinThe whole body translates around one primary fixed anchor.A knee on the floor, a foot on a box, or another single body-moving support.
gripThe body translates, each arm solves to a bar/rail, and the fingers close.One- or two-hand support on a declared bar or dip-bars prop.
+

Use only one of the root-solving families (ground-lock, pin, or grip) in a step. Add compatible secondary contacts with reach. For example, ground-lock: foot_right plus reach: knee_left floor is valid; adding pin: knee_left floor to that same step is a conflict and the parser rejects it.

+

cue is display-only coaching text. It may appear next to the current phase in a viewer, but it does not change joint targets, contacts, timing, validation, or solver behavior. Never rely on a cue to create motion.

+

Use a start-pose override block only when none of the built-ins is the exact opening shape. Its joint targets are ROM-clamped and layered sparsely over the built-in pose; they do not consume time or create a phase. Omitted channels keep the built-in value, and the composed pose is restored when the animation loops. Do not put contacts, cues, turn, or travel inside a start-pose block. Write exactly one pose start declaration; duplicate declarations are errors.

Joints

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)

diff --git a/playground/public/moves/bent-over-row.html b/playground/public/moves/bent-over-row.html index 002784c..1fb75ea 100644 --- a/playground/public/moves/bent-over-row.html +++ b/playground/public/moves/bent-over-row.html @@ -134,6 +134,8 @@

Bent-over row

▶ Open Bent-over row in the playground →

Movement phases

+

Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

  1. Set the hinge1.6s · flow diff --git a/playground/public/moves/bow.html b/playground/public/moves/bow.html index ea776e0..d59bd69 100644 --- a/playground/public/moves/bow.html +++ b/playground/public/moves/bow.html @@ -134,6 +134,8 @@

    Standing bow

    ▶ Open Standing bow in the playground →

    Movement phases

    +

    Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

    1. Bow2s · flow diff --git a/playground/public/moves/box-squat.html b/playground/public/moves/box-squat.html index f559ca8..7220b3b 100644 --- a/playground/public/moves/box-squat.html +++ b/playground/public/moves/box-squat.html @@ -134,6 +134,8 @@

      Box squat

      ▶ Open Box squat in the playground →

      Movement phases

      +

      Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

      1. Sit back1.6s · flow diff --git a/playground/public/moves/box-step-taps.html b/playground/public/moves/box-step-taps.html index da95897..3ea8e86 100644 --- a/playground/public/moves/box-step-taps.html +++ b/playground/public/moves/box-step-taps.html @@ -134,6 +134,8 @@

        Box step taps

        ▶ Open Box step taps in the playground →

        Movement phases

        +

        Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

        1. Right tap0.6s · settle diff --git a/playground/public/moves/box-step.html b/playground/public/moves/box-step.html index 3895be1..1e92566 100644 --- a/playground/public/moves/box-step.html +++ b/playground/public/moves/box-step.html @@ -134,6 +134,8 @@

          Box step

          ▶ Open Box step in the playground →

          Movement phases

          +

          Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

          1. Right foot forward0.9s · flow diff --git a/playground/public/moves/calf-raise.html b/playground/public/moves/calf-raise.html index f2c15a4..9deb0a1 100644 --- a/playground/public/moves/calf-raise.html +++ b/playground/public/moves/calf-raise.html @@ -134,6 +134,8 @@

            Single-leg calf raise

            ▶ Open Single-leg calf raise in the playground →

            Movement phases

            +

            Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

            1. Rise1.1s · settle diff --git a/playground/public/moves/chair.html b/playground/public/moves/chair.html index 9bb5d23..ccd0cf4 100644 --- a/playground/public/moves/chair.html +++ b/playground/public/moves/chair.html @@ -134,6 +134,8 @@

              Chair pose

              ▶ Open Chair pose in the playground →

              Movement phases

              +

              Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

              1. Sink3s · flow diff --git a/playground/public/moves/chasse.html b/playground/public/moves/chasse.html index 7654264..27cbc8c 100644 --- a/playground/public/moves/chasse.html +++ b/playground/public/moves/chasse.html @@ -134,6 +134,8 @@

                Chassé

                ▶ Open Chassé in the playground →

                Movement phases

                +

                Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                1. Right foot reaches0.65s · flow diff --git a/playground/public/moves/chest-opener.html b/playground/public/moves/chest-opener.html index a50ad22..3060efd 100644 --- a/playground/public/moves/chest-opener.html +++ b/playground/public/moves/chest-opener.html @@ -134,6 +134,8 @@

                  Chest opener

                  ▶ Open Chest opener in the playground →

                  Movement phases

                  +

                  Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                  1. Open3s · flow diff --git a/playground/public/moves/crunch.html b/playground/public/moves/crunch.html index fba6978..2a2ed58 100644 --- a/playground/public/moves/crunch.html +++ b/playground/public/moves/crunch.html @@ -134,6 +134,8 @@

                    Crunch

                    ▶ Open Crunch in the playground →

                    Movement phases

                    +

                    Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                    1. Set up0.5s · flow diff --git a/playground/public/moves/dead-bug.html b/playground/public/moves/dead-bug.html index 56272ef..dbaaf0a 100644 --- a/playground/public/moves/dead-bug.html +++ b/playground/public/moves/dead-bug.html @@ -134,6 +134,8 @@

                      Dead bug

                      ▶ Open Dead bug in the playground →

                      Movement phases

                      +

                      Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                      1. Set1s · flow diff --git a/playground/public/moves/deadlift.html b/playground/public/moves/deadlift.html index d79b199..358a3f8 100644 --- a/playground/public/moves/deadlift.html +++ b/playground/public/moves/deadlift.html @@ -134,6 +134,8 @@

                        Deadlift

                        ▶ Open Deadlift in the playground →

                        Movement phases

                        +

                        Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                        1. Lower1.8s · flow @@ -154,7 +156,7 @@

                          The .posecode source

                          step "Lower" 1.8s flow: pelvis: hinge 75 - knees: flex 25 + knees: flex 14 ankles: plantarflex 0 shoulders: flex 70 elbows: pronate 80 diff --git a/playground/public/moves/demi-plie.html b/playground/public/moves/demi-plie.html index 0768dad..7fed46e 100644 --- a/playground/public/moves/demi-plie.html +++ b/playground/public/moves/demi-plie.html @@ -134,6 +134,8 @@

                          Demi-plié

                          ▶ Open Demi-plié in the playground →

                          Movement phases

                          +

                          Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                          1. Plié2.2s · settle diff --git a/playground/public/moves/fold.html b/playground/public/moves/fold.html index b09223c..f9a8b48 100644 --- a/playground/public/moves/fold.html +++ b/playground/public/moves/fold.html @@ -134,6 +134,8 @@

                            Standing roll-down

                            ▶ Open Standing roll-down in the playground →

                            Movement phases

                            +

                            Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                            1. Roll down3s · flow diff --git a/playground/public/moves/forward-lunge.html b/playground/public/moves/forward-lunge.html index d5bcaff..021842f 100644 --- a/playground/public/moves/forward-lunge.html +++ b/playground/public/moves/forward-lunge.html @@ -134,6 +134,8 @@

                              Forward lunge

                              ▶ Open Forward lunge in the playground →

                              Movement phases

                              +

                              Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                              1. Lunge1.6s · flow diff --git a/playground/public/moves/front-kick.html b/playground/public/moves/front-kick.html index b2eed50..6f0d0af 100644 --- a/playground/public/moves/front-kick.html +++ b/playground/public/moves/front-kick.html @@ -134,6 +134,8 @@

                                Front kick

                                ▶ Open Front kick in the playground →

                                Movement phases

                                +

                                Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                1. Chamber0.5s · drive diff --git a/playground/public/moves/good-morning.html b/playground/public/moves/good-morning.html index 7f0d57a..7b845e0 100644 --- a/playground/public/moves/good-morning.html +++ b/playground/public/moves/good-morning.html @@ -134,6 +134,8 @@

                                  Good morning

                                  ▶ Open Good morning in the playground →

                                  Movement phases

                                  +

                                  Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                  1. Hinge2s · flow diff --git a/playground/public/moves/grapevine.html b/playground/public/moves/grapevine.html index 10ac428..bc2cfbd 100644 --- a/playground/public/moves/grapevine.html +++ b/playground/public/moves/grapevine.html @@ -134,6 +134,8 @@

                                    Grapevine

                                    ▶ Open Grapevine in the playground →

                                    Movement phases

                                    +

                                    Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                    1. Left step side0.65s · flow diff --git a/playground/public/moves/hamstring-curl.html b/playground/public/moves/hamstring-curl.html index f2be603..50abb0d 100644 --- a/playground/public/moves/hamstring-curl.html +++ b/playground/public/moves/hamstring-curl.html @@ -134,6 +134,8 @@

                                      Standing hamstring curl

                                      ▶ Open Standing hamstring curl in the playground →

                                      Movement phases

                                      +

                                      Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                      1. Curl1.4s · settle diff --git a/playground/public/moves/heel-raises.html b/playground/public/moves/heel-raises.html index decedfe..fa4ba8f 100644 --- a/playground/public/moves/heel-raises.html +++ b/playground/public/moves/heel-raises.html @@ -134,6 +134,8 @@

                                        Heel raises

                                        ▶ Open Heel raises in the playground →

                                        Movement phases

                                        +

                                        Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                        1. Rise1.3s · settle diff --git a/playground/public/moves/high-knee-march.html b/playground/public/moves/high-knee-march.html index 6fed351..900cec3 100644 --- a/playground/public/moves/high-knee-march.html +++ b/playground/public/moves/high-knee-march.html @@ -134,6 +134,8 @@

                                          High-knee march

                                          ▶ Open High-knee march in the playground →

                                          Movement phases

                                          +

                                          Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                          1. Right knee up0.7s · flow diff --git a/playground/public/moves/hip-abduction.html b/playground/public/moves/hip-abduction.html index be1d55c..d1d65fd 100644 --- a/playground/public/moves/hip-abduction.html +++ b/playground/public/moves/hip-abduction.html @@ -134,6 +134,8 @@

                                            Standing hip abduction

                                            ▶ Open Standing hip abduction in the playground →

                                            Movement phases

                                            +

                                            Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                            1. Lift1.6s · flow diff --git a/playground/public/moves/hip-flexion.html b/playground/public/moves/hip-flexion.html index 5d9fbbd..ee299ac 100644 --- a/playground/public/moves/hip-flexion.html +++ b/playground/public/moves/hip-flexion.html @@ -134,6 +134,8 @@

                                              Hip flexion

                                              ▶ Open Hip flexion in the playground →

                                              Movement phases

                                              +

                                              Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                              1. Lift2.5s · flow diff --git a/playground/public/moves/horse-stance.html b/playground/public/moves/horse-stance.html index be06bdd..2540c61 100644 --- a/playground/public/moves/horse-stance.html +++ b/playground/public/moves/horse-stance.html @@ -134,6 +134,8 @@

                                                Horse stance

                                                ▶ Open Horse stance in the playground →

                                                Movement phases

                                                +

                                                Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                1. Sink2s · flow diff --git a/playground/public/moves/jab-cross.html b/playground/public/moves/jab-cross.html index dff4f34..cfb1eab 100644 --- a/playground/public/moves/jab-cross.html +++ b/playground/public/moves/jab-cross.html @@ -134,6 +134,8 @@

                                                  Jab-cross

                                                  ▶ Open Jab-cross in the playground →

                                                  Movement phases

                                                  +

                                                  Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                  1. Jab0.4s · settle diff --git a/playground/public/moves/jumping-jacks.html b/playground/public/moves/jumping-jacks.html index badb762..62486ab 100644 --- a/playground/public/moves/jumping-jacks.html +++ b/playground/public/moves/jumping-jacks.html @@ -134,6 +134,8 @@

                                                    Jumping jacks

                                                    ▶ Open Jumping jacks in the playground →

                                                    Movement phases

                                                    +

                                                    Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                    1. Out0.5s · settle diff --git a/playground/public/moves/lateral.html b/playground/public/moves/lateral.html index 92156ef..0cfef56 100644 --- a/playground/public/moves/lateral.html +++ b/playground/public/moves/lateral.html @@ -134,6 +134,8 @@

                                                      Lateral raise

                                                      ▶ Open Lateral raise in the playground →

                                                      Movement phases

                                                      +

                                                      Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                      1. Raise1.4s · settle diff --git a/playground/public/moves/neck-side-stretch.html b/playground/public/moves/neck-side-stretch.html index e10ef1e..20a1e19 100644 --- a/playground/public/moves/neck-side-stretch.html +++ b/playground/public/moves/neck-side-stretch.html @@ -134,6 +134,8 @@

                                                        Neck side stretch

                                                        ▶ Open Neck side stretch in the playground →

                                                        Movement phases

                                                        +

                                                        Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                        1. Ear to right3s · flow diff --git a/playground/public/moves/neck.html b/playground/public/moves/neck.html index d4ff1d3..8a789b8 100644 --- a/playground/public/moves/neck.html +++ b/playground/public/moves/neck.html @@ -134,6 +134,8 @@

                                                          Neck rotation

                                                          ▶ Open Neck rotation in the playground →

                                                          Movement phases

                                                          +

                                                          Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                          1. Look right2s · flow diff --git a/playground/public/moves/pirouette.html b/playground/public/moves/pirouette.html index 0c72d8e..4c7d309 100644 --- a/playground/public/moves/pirouette.html +++ b/playground/public/moves/pirouette.html @@ -134,6 +134,8 @@

                                                            Pirouette

                                                            ▶ Open Pirouette in the playground →

                                                            Movement phases

                                                            +

                                                            Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                            1. Prep - plié1.2s · flow diff --git a/playground/public/moves/plank-hold.html b/playground/public/moves/plank-hold.html index b4c8047..df1473f 100644 --- a/playground/public/moves/plank-hold.html +++ b/playground/public/moves/plank-hold.html @@ -134,6 +134,8 @@

                                                              Plank hold

                                                              ▶ Open Plank hold in the playground →

                                                              Movement phases

                                                              +

                                                              Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                              1. Brace0.5s · flow diff --git a/playground/public/moves/posture.html b/playground/public/moves/posture.html index c602d6e..6b69f79 100644 --- a/playground/public/moves/posture.html +++ b/playground/public/moves/posture.html @@ -134,6 +134,8 @@

                                                                Desk posture reset

                                                                ▶ Open Desk posture reset in the playground →

                                                                Movement phases

                                                                +

                                                                Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                1. Slouch2.5s · flow diff --git a/playground/public/moves/quad-stretch.html b/playground/public/moves/quad-stretch.html index 30121c5..3014621 100644 --- a/playground/public/moves/quad-stretch.html +++ b/playground/public/moves/quad-stretch.html @@ -134,6 +134,8 @@

                                                                  Standing quad stretch

                                                                  ▶ Open Standing quad stretch in the playground →

                                                                  Movement phases

                                                                  +

                                                                  Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                  1. Catch the foot3s · flow diff --git a/playground/public/moves/quarter-turns.html b/playground/public/moves/quarter-turns.html index 0601fb2..d12dc5a 100644 --- a/playground/public/moves/quarter-turns.html +++ b/playground/public/moves/quarter-turns.html @@ -134,6 +134,8 @@

                                                                    Quarter turns

                                                                    ▶ Open Quarter turns in the playground →

                                                                    Movement phases

                                                                    +

                                                                    Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                    1. Face right1s · flow diff --git a/playground/public/moves/releve.html b/playground/public/moves/releve.html index 2622e77..24d1973 100644 --- a/playground/public/moves/releve.html +++ b/playground/public/moves/releve.html @@ -134,6 +134,8 @@

                                                                      Relevé

                                                                      ▶ Open Relevé in the playground →

                                                                      Movement phases

                                                                      +

                                                                      Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                      1. Rise1.8s · settle diff --git a/playground/public/moves/seated-forward-fold.html b/playground/public/moves/seated-forward-fold.html index 78ef0af..7b870a8 100644 --- a/playground/public/moves/seated-forward-fold.html +++ b/playground/public/moves/seated-forward-fold.html @@ -134,6 +134,8 @@

                                                                        Seated forward fold

                                                                        ▶ Open Seated forward fold in the playground →

                                                                        Movement phases

                                                                        +

                                                                        Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                        1. Fold3s · flow diff --git a/playground/public/moves/sidebend.html b/playground/public/moves/sidebend.html index 4fd62c2..3e675f7 100644 --- a/playground/public/moves/sidebend.html +++ b/playground/public/moves/sidebend.html @@ -134,6 +134,8 @@

                                                                          Standing side bend

                                                                          ▶ Open Standing side bend in the playground →

                                                                          Movement phases

                                                                          +

                                                                          Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                          1. Reach over3s · flow diff --git a/playground/public/moves/sit-to-stand.html b/playground/public/moves/sit-to-stand.html index c2cec7d..fce8897 100644 --- a/playground/public/moves/sit-to-stand.html +++ b/playground/public/moves/sit-to-stand.html @@ -134,6 +134,8 @@

                                                                            Sit to stand

                                                                            ▶ Open Sit to stand in the playground →

                                                                            Movement phases

                                                                            +

                                                                            Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                            1. Sit2s · flow diff --git a/playground/public/moves/spine-rotation.html b/playground/public/moves/spine-rotation.html index a7bccd0..37fda72 100644 --- a/playground/public/moves/spine-rotation.html +++ b/playground/public/moves/spine-rotation.html @@ -134,6 +134,8 @@

                                                                              Spine rotation

                                                                              ▶ Open Spine rotation in the playground →

                                                                              Movement phases

                                                                              +

                                                                              Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                              1. Rotate right2.5s · flow diff --git a/playground/public/moves/squat.html b/playground/public/moves/squat.html index accd138..081d664 100644 --- a/playground/public/moves/squat.html +++ b/playground/public/moves/squat.html @@ -134,6 +134,8 @@

                                                                                Body-weight squat

                                                                                ▶ Open Body-weight squat in the playground →

                                                                                Movement phases

                                                                                +

                                                                                Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                                1. Descend1.6s · settle diff --git a/playground/public/moves/step-up.html b/playground/public/moves/step-up.html index be5fc18..89d50d0 100644 --- a/playground/public/moves/step-up.html +++ b/playground/public/moves/step-up.html @@ -134,6 +134,8 @@

                                                                                  Step-up

                                                                                  ▶ Open Step-up in the playground →

                                                                                  Movement phases

                                                                                  +

                                                                                  Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                                  1. Plant the foot1s · flow diff --git a/playground/public/moves/superhero-landing.html b/playground/public/moves/superhero-landing.html index b566713..d18cbba 100644 --- a/playground/public/moves/superhero-landing.html +++ b/playground/public/moves/superhero-landing.html @@ -134,6 +134,8 @@

                                                                                    Superhero three-point landing

                                                                                    ▶ Open Superhero three-point landing in the playground →

                                                                                    Movement phases

                                                                                    +

                                                                                    Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                                    1. Drop into the landing0.55s · flow diff --git a/playground/public/moves/supine-leg-raise.html b/playground/public/moves/supine-leg-raise.html index 6e1baa9..99f3742 100644 --- a/playground/public/moves/supine-leg-raise.html +++ b/playground/public/moves/supine-leg-raise.html @@ -134,6 +134,8 @@

                                                                                      Lying leg raise

                                                                                      ▶ Open Lying leg raise in the playground →

                                                                                      Movement phases

                                                                                      +

                                                                                      Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                                      1. Raise1.2s · settle diff --git a/playground/public/moves/tendu.html b/playground/public/moves/tendu.html index 3ca730b..72f67ef 100644 --- a/playground/public/moves/tendu.html +++ b/playground/public/moves/tendu.html @@ -134,6 +134,8 @@

                                                                                        Tendu

                                                                                        ▶ Open Tendu in the playground →

                                                                                        Movement phases

                                                                                        +

                                                                                        Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                                        1. Brush and point1.8s · settle diff --git a/playground/public/moves/twist.html b/playground/public/moves/twist.html index 4852486..ed2a475 100644 --- a/playground/public/moves/twist.html +++ b/playground/public/moves/twist.html @@ -134,6 +134,8 @@

                                                                                          Standing spinal twist

                                                                                          ▶ Open Standing spinal twist in the playground →

                                                                                          Movement phases

                                                                                          +

                                                                                          Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                                          1. Twist right2.5s · flow diff --git a/playground/public/moves/walk-cycle.html b/playground/public/moves/walk-cycle.html index 2b3a7ae..b03381e 100644 --- a/playground/public/moves/walk-cycle.html +++ b/playground/public/moves/walk-cycle.html @@ -134,6 +134,8 @@

                                                                                            Walk & turn

                                                                                            ▶ Open Walk & turn in the playground →

                                                                                            Movement phases

                                                                                            +

                                                                                            Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                                            1. Step right0.7s · flow diff --git a/playground/public/moves/wall-sit.html b/playground/public/moves/wall-sit.html index a43a2cb..8a39533 100644 --- a/playground/public/moves/wall-sit.html +++ b/playground/public/moves/wall-sit.html @@ -134,6 +134,8 @@

                                                                                              Wall sit

                                                                                              ▶ Open Wall sit in the playground →

                                                                                              Movement phases

                                                                                              +

                                                                                              Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                                              1. Slide down2.5s · flow diff --git a/playground/public/moves/waltz-box.html b/playground/public/moves/waltz-box.html index 1f5bf07..d8a2cd2 100644 --- a/playground/public/moves/waltz-box.html +++ b/playground/public/moves/waltz-box.html @@ -134,6 +134,8 @@

                                                                                                Waltz box step

                                                                                                ▶ Open Waltz box step in the playground →

                                                                                                Movement phases

                                                                                                +

                                                                                                Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                                                1. 1 - right foot forward0.95s · flow diff --git a/playground/public/sitemap.xml b/playground/public/sitemap.xml index 6f9b6c1..c44a4ae 100644 --- a/playground/public/sitemap.xml +++ b/playground/public/sitemap.xml @@ -2,319 +2,319 @@ https://www.posecode.org/ - 2026-07-17 + 2026-07-19 weekly 1.0 https://www.posecode.org/play - 2026-07-17 + 2026-07-19 weekly 0.9 https://www.posecode.org/moves/ - 2026-07-17 + 2026-07-19 weekly 0.8 https://www.posecode.org/spec.html - 2026-07-17 + 2026-07-19 weekly 0.6 https://www.posecode.org/llm-guide.html - 2026-07-17 + 2026-07-19 weekly 0.6 https://www.posecode.org/moves/squat.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/superhero-landing.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/demi-plie.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/deadlift.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/front-kick.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/good-morning.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/chest-opener.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/plank-hold.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/crunch.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/supine-leg-raise.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/forward-lunge.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/calf-raise.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/jumping-jacks.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/box-step-taps.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/step-up.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/quad-stretch.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/hip-flexion.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/spine-rotation.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/heel-raises.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/hamstring-curl.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/hip-abduction.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/neck.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/posture.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/twist.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/neck-side-stretch.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/jab-cross.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/horse-stance.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/bow.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/high-knee-march.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/releve.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/tendu.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/bent-over-row.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/lateral.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/fold.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/chair.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/sidebend.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/dead-bug.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/seated-forward-fold.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/sit-to-stand.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/box-squat.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/wall-sit.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/pirouette.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/box-step.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/grapevine.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/waltz-box.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/chasse.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/walk-cycle.html - 2026-07-17 + 2026-07-19 weekly 0.5 https://www.posecode.org/moves/quarter-turns.html - 2026-07-17 + 2026-07-19 weekly 0.5 diff --git a/playground/public/spec.html b/playground/public/spec.html index 1f4d438..aedc18c 100644 --- a/playground/public/spec.html +++ b/playground/public/spec.html @@ -6,11 +6,11 @@ Posecode Language Specification: The .posecode Kinematic Motion DSL - + - + @@ -119,10 +119,11 @@

                                                                                                  Reference

                                                                                                  -

                                                                                                  Posecode Protocol Specification v0.2

                                                                                                  +

                                                                                                  Posecode Protocol Specification v0.3

                                                                                                  Posecode is a small text language for describing a single person's kinematic movement so it can be rendered as an animated 3D figure in a web browser.

                                                                                                  +

                                                                                                  This document is the normative language and IR contract. The LLM authoring guide is task-oriented and pasteable; it is intentionally self-contained, but it must not define syntax or behavior that differs from this specification.

                                                                                                  It is to human movement what Mermaid is to diagrams: an LLM (or a human) writes a compact, readable document; a client-side parser + renderer turns it into a moving mannequin. The model never produces 3D matrices: it expresses the *semantic phases* of a movement, which it already understands.

                                                                                                  -
                                                                                                  • Version keyword: documents declare nothing; this is posecode 0.2.
                                                                                                  • Compatibility: v0.2 parsers continue to accept the v0.1 easing aliases.
                                                                                                  • File extension: .posecode
                                                                                                  • Compute model: generation is pure text (server-cheap); all 3D math runs on the client (Three.js). See the project research §6.
                                                                                                  +
                                                                                                  • Version keyword: documents declare nothing; this is posecode 0.3.
                                                                                                  • Compatibility: v0.3 parsers continue to accept v0.2 documents and the v0.1 easing aliases.
                                                                                                  • File extension: .posecode
                                                                                                  • Compute model: generation is pure text (server-cheap); all 3D math runs on the client (Three.js). See the project research §6.

                                                                                                  1. Grammar

                                                                                                  Posecode is line- and indentation-oriented. Comments start with # or //.

                                                                                                  @@ -132,7 +133,8 @@

                                                                                                  1. Grammar

                                                                                                  directive = rig | prop | pose | clip | step | repeat ; rig = "rig" "humanoid" ; prop = "prop" ("chair"|"wall"|"bar"|"box"|"dip-bars") ; -pose = "pose" "start" "=" startPose ; +pose = "pose" "start" "=" startPose [ ":" { startOverride } ] ; +startOverride = jointTarget ; (* indented; sparse overlay, not a phase *) startPose = "neutral"|"standing"|"first-position"|"plank"|"supine"|"prone"|"seated" ; clip = "clip" STRING ; (* optional mocap clip; renderer may retarget & blend *) repeat = "repeat" NUMBER ; @@ -151,11 +153,26 @@

                                                                                                  1. Grammar

                                                                                                  gripAnchor = "bar" | "bars" ; turn = "turn" ":" NUMBER ; (* face this yaw (deg) by phase end *) travel = "travel" ":" NUMBER NUMBER ; (* move to this x z (metres) by phase end *) -cue = "cue" STRING ; +cue = "cue" STRING ; (* display-only coaching text *) 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.

                                                                                                  +

                                                                                                  A built-in start pose can be customized with an indented override block:

                                                                                                  +
                                                                                                    pose start = standing:
                                                                                                  +    shoulders: flex 20
                                                                                                  +    elbow_left: flex 35
                                                                                                  +

                                                                                                  The existing one-line form remains valid. Start-pose targets use the same joint/action vocabulary, channel mirroring, compatibility checks, and ROM clamping as phase targets, but they do not create a phase or consume time. They sparsely overlay the selected built-in pose: omitted channels keep the built-in value, while hold neutral resets all three channels on that joint. The composed start pose is the deterministic animation start and loop-reset pose. Contacts, root travel/turn, cues, and other step-only directives are not valid inside this block.

                                                                                                  +

                                                                                                  A document may contain at most one pose start declaration. A second declaration is an error rather than a replacement, so an earlier scoped block cannot be bypassed before its joint/action vocabulary is validated.

                                                                                                  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.

                                                                                                  +

                                                                                                  Contact mechanisms

                                                                                                  +
                                                                                                  + + + + +
                                                                                                  DirectiveWhat the solver doesUse it when
                                                                                                  ground-lockPreserves an existing floor support while the rest of the body moves.A foot, hand, forearm, or the back is already planted.
                                                                                                  reachMoves a limb endpoint toward a target through ROM-constrained IK; it does not translate the body root.An additional hand, fist, elbow, knee, or foot must meet a floor, landmark, or prop target.
                                                                                                  pinTranslates the whole body so one primary effector stays on a fixed anchor.One contact should carry or reposition the body, such as a knee on the floor or foot on a box.
                                                                                                  gripTranslates the body, solves each gripping arm to a bar or rail, and closes the fingers.One or both hands support the body on a declared bar or dip-bars prop.
                                                                                                  +

                                                                                                  ground-lock, pin, and grip are mutually exclusive root-solving families within one phase. Choose one primary support family, then express compatible additional contacts with independent reach constraints. The parser rejects conflicting combinations instead of silently choosing a solver order.

                                                                                                  +

                                                                                                  cue is display-only coaching metadata. The parser stores it in the IR and a viewer may show it alongside the current phase, but it does not alter joint targets, contacts, timing, range validation, or rendering solves. A cue should describe only motion that the phase's executable directives actually encode.

                                                                                                  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:

                                                                                                  @@ -219,17 +236,22 @@

                                                                                                  4. Configured range-of-motion limits

                                                                                                  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. 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, first-position (ballet turnout), plank, supine (face-up), prone (face-down), seated (long-sit on the floor).

                                                                                                  +

                                                                                                  Start poses: neutral, standing, first-position (ballet turnout), plank, supine (face-up), prone (face-down), seated (long-sit on the floor). Append : and indented joint targets to define a custom start pose as a sparse overlay on any of these built-ins.

                                                                                                  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).


                                                                                                  6. Intermediate Representation (IR)

                                                                                                  parse(source) returns { ir, warnings, errors }. The IR is renderer-agnostic; angles are in degrees.

                                                                                                  interface PosecodeIR {
                                                                                                  -  version: string;          // "0.2"
                                                                                                  +  version: string;          // "0.3"
                                                                                                     kind: string;             // "exercise" | "stretch" | "posture"
                                                                                                     name: string;
                                                                                                     rig: string;              // "humanoid"
                                                                                                     startPose?: string;       // "plank" | "standing" | ...
                                                                                                  +  startPoseOverrides?: {    // sparse, ROM-clamped overlay on startPose
                                                                                                  +    boneId: string;
                                                                                                  +    euler: { x: number; y: number; z: number };
                                                                                                  +    axes?: ("x" | "y" | "z")[];
                                                                                                  +  }[];
                                                                                                     repeat: number;
                                                                                                     phases: {
                                                                                                       name: string;
                                                                                                  diff --git a/playground/src/main.ts b/playground/src/main.ts
                                                                                                  index e4e81ab..b20f39e 100644
                                                                                                  --- a/playground/src/main.ts
                                                                                                  +++ b/playground/src/main.ts
                                                                                                  @@ -9,7 +9,7 @@
                                                                                                   
                                                                                                   import { parse, type ParseError, type Warning } from "posecode-parser";
                                                                                                   import { inject } from "@vercel/analytics";
                                                                                                  -import type { Viewer } from "posecode-render";
                                                                                                  +import type { ConstraintDiagnostic, Viewer } from "posecode-render";
                                                                                                   import {
                                                                                                     buildNicePlayPath,
                                                                                                     buildNiceShareHash,
                                                                                                  @@ -48,6 +48,9 @@ const loop = $("loop");
                                                                                                   const speed = $("speed");
                                                                                                   const phaseEl = $("phase");
                                                                                                   const cueEl = $("cue");
                                                                                                  +const floorGuideKey = $("floor-guide-key");
                                                                                                  +const floorGuideTravel = $("floor-guide-travel");
                                                                                                  +const floorGuideReset = $("floor-guide-reset");
                                                                                                   const copyBtn = $("copy-prompt");
                                                                                                   const shareBtn = $("share");
                                                                                                   const tabEditor = $("tab-editor");
                                                                                                  @@ -68,23 +71,40 @@ let lastParseErrors: ParseError[] = [];
                                                                                                   let lastRomWarnings: Warning[] = [];
                                                                                                   let lastContactSignature = "";
                                                                                                   let lastContactRefresh = 0;
                                                                                                  +let scrubDiagnosticsRefresh = 0;
                                                                                                   
                                                                                                  -/** Merge live IK residuals with source diagnostics without repainting each frame. */
                                                                                                  +/** Merge live solver residuals with source diagnostics without repainting each frame. */
                                                                                                   function refreshContactDiagnostics(force = false): void {
                                                                                                     if (!viewer) return;
                                                                                                     const contacts = viewer.getReachResiduals();
                                                                                                  -  const signature = contacts
                                                                                                  +  const constraints = viewer.getConstraintDiagnostics();
                                                                                                  +  const contactSignature = contacts
                                                                                                       .filter((contact) => contact.weight >= 0.98 && !contact.reached)
                                                                                                       .map((contact) =>
                                                                                                         `${contact.effector}|${contact.target}|${contact.reason ?? ""}|${
                                                                                                           contact.distance === null ? "null" : Math.round(contact.distance * 1000)
                                                                                                         }`,
                                                                                                       )
                                                                                                  -    .sort()
                                                                                                  -    .join(";");
                                                                                                  +    .sort();
                                                                                                  +  const constraintSignature = constraints
                                                                                                  +    .filter((diagnostic) => !diagnostic.pass)
                                                                                                  +    .map((diagnostic: ConstraintDiagnostic) =>
                                                                                                  +      `${diagnostic.id}|${Math.round(diagnostic.value * (diagnostic.unit === "m" ? 1000 : 10))}`,
                                                                                                  +    )
                                                                                                  +    .sort();
                                                                                                  +  const signature = [...contactSignature, ...constraintSignature].join(";");
                                                                                                     if (!force && signature === lastContactSignature) return;
                                                                                                     lastContactSignature = signature;
                                                                                                  -  renderWarnings(warnings, lastParseErrors, lastRomWarnings, contacts);
                                                                                                  +  renderWarnings(warnings, lastParseErrors, lastRomWarnings, contacts, constraints);
                                                                                                  +}
                                                                                                  +
                                                                                                  +/** Read the seeked pose after the viewer's continuously scheduled frame solves it. */
                                                                                                  +function scheduleScrubDiagnosticsRefresh(): void {
                                                                                                  +  cancelAnimationFrame(scrubDiagnosticsRefresh);
                                                                                                  +  scrubDiagnosticsRefresh = requestAnimationFrame(() => {
                                                                                                  +    scrubDiagnosticsRefresh = 0;
                                                                                                  +    refreshContactDiagnostics(true);
                                                                                                  +  });
                                                                                                   }
                                                                                                   
                                                                                                   const playLbl = playpause.querySelector(".lbl");
                                                                                                  @@ -133,6 +153,21 @@ function updateReps(): void {
                                                                                                     reps.textContent = repeat > 1 ? `rep ${rep} / ${repeat}` : "";
                                                                                                   }
                                                                                                   
                                                                                                  +/** Keep the compact 2D key in sync with the movement-specific 3D floor guide. */
                                                                                                  +function updateFloorGuideKey(): void {
                                                                                                  +  const info = viewer?.getFloorGuideInfo();
                                                                                                  +  floorGuideKey.hidden = !info?.visible;
                                                                                                  +  floorGuideTravel.hidden = !info?.hasTravel;
                                                                                                  +  floorGuideReset.hidden = !info?.hasLoopReset;
                                                                                                  +  const describedFeatures = ["load origin", "current facing", "one metre scale"];
                                                                                                  +  if (info?.hasTravel) describedFeatures.push("authored travel path");
                                                                                                  +  if (info?.hasLoopReset) describedFeatures.push("dashed loop reset");
                                                                                                  +  floorGuideKey.setAttribute(
                                                                                                  +    "aria-label",
                                                                                                  +    `Floor guide: ${describedFeatures.join(", ")}`,
                                                                                                  +  );
                                                                                                  +}
                                                                                                  +
                                                                                                   function buildRibbonAndMarkers(): void {
                                                                                                     const tl = viewer?.getTimeline();
                                                                                                     ribbon.innerHTML = "";
                                                                                                  @@ -254,6 +289,7 @@ function recompile(): void {
                                                                                                     // the viewer-dependent work re-runs once `boot()` calls recompile() again.
                                                                                                     if (ir && viewer) {
                                                                                                       viewer.load(ir);
                                                                                                  +    updateFloorGuideKey();
                                                                                                       viewer.setLoop(loop.checked);
                                                                                                       viewer.play();
                                                                                                       setPlaying(true);
                                                                                                  @@ -501,6 +537,7 @@ scrub.addEventListener("input", () => {
                                                                                                     scrubbing = true;
                                                                                                     paintScrub();
                                                                                                     viewer.seek((Number(scrub.value) / 1000) * viewer.duration);
                                                                                                  +  scheduleScrubDiagnosticsRefresh();
                                                                                                   });
                                                                                                   scrub.addEventListener("change", () => {
                                                                                                     scrubbing = false;
                                                                                                  diff --git a/playground/src/style.css b/playground/src/style.css
                                                                                                  index 628692e..0c5708d 100644
                                                                                                  --- a/playground/src/style.css
                                                                                                  +++ b/playground/src/style.css
                                                                                                  @@ -156,6 +156,7 @@ body {
                                                                                                   .btn .ico-link,
                                                                                                   .btn .ico-help,
                                                                                                   .btn .ico-plus,
                                                                                                  +.btn .ico-spec,
                                                                                                   .btn .ico-github {
                                                                                                     width: 14px;
                                                                                                     height: 14px;
                                                                                                  @@ -167,6 +168,7 @@ body {
                                                                                                   .btn:hover .ico-link,
                                                                                                   .btn:hover .ico-help,
                                                                                                   .btn:hover .ico-plus,
                                                                                                  +.btn:hover .ico-spec,
                                                                                                   .btn:hover .ico-github {
                                                                                                     opacity: 1;
                                                                                                   }
                                                                                                  @@ -179,6 +181,9 @@ body {
                                                                                                   .btn .ico-help {
                                                                                                     --btn-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3'/%3E%3Cline x1='12' y1='17' x2='12.01' y2='17'/%3E%3C/svg%3E");
                                                                                                   }
                                                                                                  +.btn .ico-spec {
                                                                                                  +  --btn-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z'/%3E%3Cpath d='M14 2v6h6'/%3E%3Cpath d='M8 13h8M8 17h6'/%3E%3C/svg%3E");
                                                                                                  +}
                                                                                                   .btn .ico-github {
                                                                                                     --btn-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.57.1.78-.25.78-.55v-2.13c-3.2.7-3.88-1.36-3.88-1.36-.52-1.34-1.28-1.7-1.28-1.7-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.23-1.28-5.23-5.69 0-1.26.45-2.29 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.18 1.18a11.1 11.1 0 0 1 5.8 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.11 3.05.74.8 1.19 1.83 1.19 3.09 0 4.42-2.69 5.39-5.25 5.68.42.36.78 1.06.78 2.15v3.18c0 .3.21.65.79.54A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z'/%3E%3C/svg%3E");
                                                                                                   }
                                                                                                  @@ -537,7 +542,7 @@ select:hover {
                                                                                                     background: var(--panel);
                                                                                                   }
                                                                                                   .warnings:empty::before {
                                                                                                  -  content: "✓  source valid; no active reach misses";
                                                                                                  +  content: "✓  source valid; no active constraint warnings";
                                                                                                     color: var(--accent);
                                                                                                     opacity: 0.85;
                                                                                                   }
                                                                                                  @@ -635,6 +640,76 @@ select:hover {
                                                                                                     display: none;
                                                                                                   }
                                                                                                   
                                                                                                  +/* Compact legend for the scene-native metric/orientation floor overlay. */
                                                                                                  +.floor-guide-key {
                                                                                                  +  position: absolute;
                                                                                                  +  top: 54px;
                                                                                                  +  right: 20px;
                                                                                                  +  z-index: 2;
                                                                                                  +  display: flex;
                                                                                                  +  align-items: center;
                                                                                                  +  gap: 10px;
                                                                                                  +  padding: 6px 9px;
                                                                                                  +  color: var(--text-2);
                                                                                                  +  background: rgba(10, 13, 18, 0.58);
                                                                                                  +  border: 1px solid var(--border-2);
                                                                                                  +  border-radius: 3px;
                                                                                                  +  font-family: var(--mono);
                                                                                                  +  font-size: 9px;
                                                                                                  +  letter-spacing: 0.35px;
                                                                                                  +  line-height: 1;
                                                                                                  +  text-transform: uppercase;
                                                                                                  +  pointer-events: none;
                                                                                                  +}
                                                                                                  +.floor-guide-key[hidden],
                                                                                                  +.floor-guide-key [hidden] {
                                                                                                  +  display: none;
                                                                                                  +}
                                                                                                  +.floor-guide-key > span {
                                                                                                  +  display: inline-flex;
                                                                                                  +  align-items: center;
                                                                                                  +  gap: 4px;
                                                                                                  +  white-space: nowrap;
                                                                                                  +}
                                                                                                  +.floor-guide-key i {
                                                                                                  +  display: inline-block;
                                                                                                  +  flex: 0 0 auto;
                                                                                                  +  color: var(--accent);
                                                                                                  +  font-style: normal;
                                                                                                  +}
                                                                                                  +.floor-key-origin {
                                                                                                  +  width: 7px;
                                                                                                  +  height: 7px;
                                                                                                  +  border: 1px solid var(--text);
                                                                                                  +  border-radius: 50%;
                                                                                                  +}
                                                                                                  +.floor-key-facing {
                                                                                                  +  width: 8px;
                                                                                                  +  font-size: 12px;
                                                                                                  +  line-height: 8px;
                                                                                                  +  transform: translateY(-1px);
                                                                                                  +}
                                                                                                  +.floor-key-scale,
                                                                                                  +.floor-key-travel,
                                                                                                  +.floor-key-reset {
                                                                                                  +  width: 13px;
                                                                                                  +}
                                                                                                  +.floor-key-scale {
                                                                                                  +  height: 5px;
                                                                                                  +  border-right: 1px solid var(--text-2);
                                                                                                  +  border-bottom: 1px solid var(--text-2);
                                                                                                  +  border-left: 1px solid var(--text-2);
                                                                                                  +}
                                                                                                  +.floor-key-travel {
                                                                                                  +  height: 0;
                                                                                                  +  border-top: 2px solid var(--accent);
                                                                                                  +  border-bottom: 0;
                                                                                                  +}
                                                                                                  +.floor-key-reset {
                                                                                                  +  height: 0;
                                                                                                  +  border-top: 1px dashed var(--text-2);
                                                                                                  +}
                                                                                                  +
                                                                                                   /* --- Phase ribbon --------------------------------------------------------- */
                                                                                                   .ribbon {
                                                                                                     position: relative;
                                                                                                  @@ -1018,6 +1093,35 @@ select:hover {
                                                                                                     border-radius: 5px;
                                                                                                     color: var(--text);
                                                                                                   }
                                                                                                  +.authoring-reference {
                                                                                                  +  margin: 0 0 24px;
                                                                                                  +  padding: 16px;
                                                                                                  +  border: 1px solid var(--border-2);
                                                                                                  +  border-radius: var(--radius);
                                                                                                  +  background: var(--panel-2);
                                                                                                  +}
                                                                                                  +.authoring-reference h3 {
                                                                                                  +  margin: 0 0 8px;
                                                                                                  +  color: var(--text);
                                                                                                  +  font-size: 14px;
                                                                                                  +}
                                                                                                  +.authoring-reference p {
                                                                                                  +  margin: 0 0 9px;
                                                                                                  +  color: var(--text-2);
                                                                                                  +  font-size: 13px;
                                                                                                  +  line-height: 1.55;
                                                                                                  +}
                                                                                                  +.authoring-reference p:last-child {
                                                                                                  +  margin-bottom: 0;
                                                                                                  +}
                                                                                                  +.authoring-reference a {
                                                                                                  +  color: var(--accent);
                                                                                                  +  font-weight: 700;
                                                                                                  +}
                                                                                                  +.authoring-reference code {
                                                                                                  +  font-family: var(--mono);
                                                                                                  +  color: var(--text);
                                                                                                  +}
                                                                                                   .prompt-block {
                                                                                                     border: 1px solid var(--border-2);
                                                                                                     border-radius: var(--radius);
                                                                                                  @@ -1320,17 +1424,20 @@ select:hover {
                                                                                                     }
                                                                                                     /* Compact "How to use" + "Share" to icon-only so the controls fit one row. */
                                                                                                     #how-to,
                                                                                                  +  #spec-link,
                                                                                                     #feedback,
                                                                                                     #share {
                                                                                                       flex: 0 0 auto;
                                                                                                       padding: 9px 13px;
                                                                                                     }
                                                                                                     #how-to .lbl,
                                                                                                  +  #spec-link .lbl,
                                                                                                     #feedback .lbl,
                                                                                                     #share .lbl {
                                                                                                       display: none;
                                                                                                     }
                                                                                                     #how-to .ico-help,
                                                                                                  +  #spec-link .ico-spec,
                                                                                                     #feedback .ico-github,
                                                                                                     #share .ico-link {
                                                                                                       width: 17px;
                                                                                                  @@ -1459,22 +1566,22 @@ select:hover {
                                                                                                     .topbar { padding: 12px; }
                                                                                                     .topbar-actions {
                                                                                                       display: grid;
                                                                                                  -    grid-template-columns: minmax(0, 1fr) auto auto;
                                                                                                  +    grid-template-columns: minmax(0, 1fr) auto auto auto;
                                                                                                       align-items: stretch;
                                                                                                       gap: 6px;
                                                                                                     }
                                                                                                     .lib-btn { min-height: 42px; }
                                                                                                  -  #new-doc, #how-to, #feedback, #share {
                                                                                                  +  #new-doc, #how-to, #spec-link, #feedback, #share {
                                                                                                       min-height: 42px;
                                                                                                     }
                                                                                                  -  #feedback, #share {
                                                                                                  +  #spec-link, #feedback, #share {
                                                                                                       justify-content: center;
                                                                                                     }
                                                                                                     #share {
                                                                                                       display: none;
                                                                                                     }
                                                                                                     #feedback {
                                                                                                  -    grid-column: 3;
                                                                                                  +    grid-column: 4;
                                                                                                       grid-row: 2;
                                                                                                     }
                                                                                                     #share .lbl {
                                                                                                  @@ -1488,12 +1595,21 @@ select:hover {
                                                                                                       font-size: 13px;
                                                                                                     }
                                                                                                     #copy-prompt {
                                                                                                  -    grid-column: 1 / 3;
                                                                                                  +    grid-column: 1 / 4;
                                                                                                       grid-row: 2;
                                                                                                       min-height: 42px;
                                                                                                     }
                                                                                                     .intro { padding-left: 12px; padding-right: 12px; }
                                                                                                     .layout { grid-template-columns: 1fr; }
                                                                                                  +  .floor-guide-key {
                                                                                                  +    /* Clear the phase plus a two-line coaching cue instead of overlaying it. */
                                                                                                  +    top: 96px;
                                                                                                  +    right: 12px;
                                                                                                  +    gap: 7px;
                                                                                                  +    max-width: calc(100% - 24px);
                                                                                                  +    padding: 6px 7px;
                                                                                                  +    background: rgba(10, 13, 18, 0.72);
                                                                                                  +  }
                                                                                                     .transport { gap: 8px; padding-left: 12px; padding-right: 12px; }
                                                                                                     .speed > span, .loop > span { display: none; }
                                                                                                     .clock { padding-left: 8px; min-width: 40px; }
                                                                                                  diff --git a/playground/src/warnings.ts b/playground/src/warnings.ts
                                                                                                  index 6c21e08..7e9d800 100644
                                                                                                  --- a/playground/src/warnings.ts
                                                                                                  +++ b/playground/src/warnings.ts
                                                                                                  @@ -1,12 +1,13 @@
                                                                                                   /** Render source validation plus live contact-solve diagnostics. */
                                                                                                   import type { ParseError, Warning } from "posecode-parser";
                                                                                                  -import type { ReachResidual } from "posecode-render";
                                                                                                  +import type { ConstraintDiagnostic, ReachResidual } from "posecode-render";
                                                                                                   
                                                                                                   export function renderWarnings(
                                                                                                     el: HTMLElement,
                                                                                                     errors: ParseError[],
                                                                                                     warnings: Warning[],
                                                                                                     contacts: readonly ReachResidual[] = [],
                                                                                                  +  constraints: readonly ConstraintDiagnostic[] = [],
                                                                                                   ): void {
                                                                                                     const rows: string[] = [];
                                                                                                   
                                                                                                  @@ -44,6 +45,29 @@ export function renderWarnings(
                                                                                                       }
                                                                                                     }
                                                                                                   
                                                                                                  +  const romConflictFeet = new Set(
                                                                                                  +    constraints
                                                                                                  +      .filter((diagnostic) => !diagnostic.pass && diagnostic.kind === "grounding-rom-conflict")
                                                                                                  +      .map((diagnostic) => diagnostic.id.split(":").at(-1)),
                                                                                                  +  );
                                                                                                  +  for (const diagnostic of constraints) {
                                                                                                  +    if (diagnostic.pass) continue;
                                                                                                  +    // The specific ROM-conflict row explains the same lifted heel more
                                                                                                  +    // usefully than a second generic height row.
                                                                                                  +    if (
                                                                                                  +      diagnostic.kind === "heel-height"
                                                                                                  +      && [...romConflictFeet].some((foot) => foot && diagnostic.id.includes(`:${foot}:`))
                                                                                                  +    ) continue;
                                                                                                  +    const label = diagnostic.kind === "self-collision"
                                                                                                  +      ? "residual collision"
                                                                                                  +      : diagnostic.kind === "grounding-rom-conflict"
                                                                                                  +        ? "grounding vs ROM"
                                                                                                  +        : "constraint";
                                                                                                  +    rows.push(
                                                                                                  +      `
                                                                                                  ⚠ ${label} · ${escape(diagnostic.detail)}
                                                                                                  `, + ); + } + el.innerHTML = rows.join(""); } diff --git a/playground/test/authoring-help.test.ts b/playground/test/authoring-help.test.ts new file mode 100644 index 0000000..b18ec95 --- /dev/null +++ b/playground/test/authoring-help.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const html = readFileSync(resolve(import.meta.dirname, "../play.html"), "utf8"); + +describe("playground authoring help", () => { + it("links directly to the normative language specification", () => { + expect(html).toMatch(/id="spec-link"[\s\S]*?href="\/spec\.html"/); + expect(html).toContain("The language specification is the normative"); + }); + + it("labels cues as display-only coaching text", () => { + expect(html).toContain('aria-label="Display-only coaching cue"'); + expect(html).toContain("A cue is display-only coaching text"); + }); +}); diff --git a/playground/test/floor-guide.test.ts b/playground/test/floor-guide.test.ts new file mode 100644 index 0000000..35ad27a --- /dev/null +++ b/playground/test/floor-guide.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const html = readFileSync(resolve(import.meta.dirname, "../play.html"), "utf8"); +const main = readFileSync(resolve(import.meta.dirname, "../src/main.ts"), "utf8"); +const css = readFileSync(resolve(import.meta.dirname, "../src/style.css"), "utf8"); + +describe("playground floor guide", () => { + it("explains origin, facing, metric scale, and optional travel without covering the canvas", () => { + expect(html).toContain('id="floor-guide-key"'); + expect(html).toMatch(/id="floor-guide-key"[\s\S]*?role="img"[\s\S]*?aria-label=/); + expect(html).toContain('id="floor-guide-travel" hidden'); + expect(html).toContain('id="floor-guide-reset" hidden'); + expect(html).toContain("origin"); + expect(html).toContain("facing"); + expect(html).toContain("1 m"); + expect(css).toMatch(/\.floor-guide-key\s*\{[\s\S]*?pointer-events:\s*none/); + expect(html.match(/class="floor-key-[^"]+" aria-hidden="true"/g)).toHaveLength(5); + }); + + it("shows the travel key only when the loaded viewer reports authored travel", () => { + expect(main).toContain("viewer?.getFloorGuideInfo()"); + expect(main).toContain("floorGuideTravel.hidden = !info?.hasTravel"); + expect(main).toContain("floorGuideReset.hidden = !info?.hasLoopReset"); + expect(main).toContain('describedFeatures.push("dashed loop reset")'); + expect(main).toMatch(/viewer\.load\(ir\);\s*updateFloorGuideKey\(\);/); + }); + + it("keeps the key compact at the mobile viewer breakpoint", () => { + expect(css).toMatch( + /@media \(max-width: 860px\)[\s\S]*?\.floor-guide-key\s*\{[\s\S]*?max-width:\s*calc\(100% - 24px\)/, + ); + expect(css).toMatch( + /@media \(max-width: 860px\)[\s\S]*?\.floor-guide-key\s*\{[\s\S]*?top:\s*96px/, + ); + }); +}); diff --git a/playground/test/scrub-diagnostics.test.ts b/playground/test/scrub-diagnostics.test.ts new file mode 100644 index 0000000..461d8ee --- /dev/null +++ b/playground/test/scrub-diagnostics.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const main = readFileSync(resolve(import.meta.dirname, "../src/main.ts"), "utf8"); + +describe("paused scrub diagnostics", () => { + it("refreshes warnings after the seeked viewer frame has been solved", () => { + expect(main).toMatch( + /function scheduleScrubDiagnosticsRefresh\(\)[\s\S]*?requestAnimationFrame\(\(\) => \{[\s\S]*?refreshContactDiagnostics\(true\)/, + ); + expect(main).toMatch( + /scrub\.addEventListener\("input",[\s\S]*?viewer\.seek\([\s\S]*?scheduleScrubDiagnosticsRefresh\(\)/, + ); + }); +}); diff --git a/playground/test/warnings.test.ts b/playground/test/warnings.test.ts new file mode 100644 index 0000000..68a7c8c --- /dev/null +++ b/playground/test/warnings.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { ConstraintDiagnostic } from "posecode-render"; +import { renderWarnings } from "../src/warnings.js"; + +function target(): HTMLElement { + return { innerHTML: "" } as HTMLElement; +} + +function diagnostic( + kind: ConstraintDiagnostic["kind"], + id: string, + detail: string, +): ConstraintDiagnostic { + return { id, kind, detail, pass: false, value: 0.04, limit: 0.02, unit: "m" }; +} + +describe("playground solver diagnostics", () => { + it("surfaces grounding/ROM conflicts without duplicating the heel warning", () => { + const el = target(); + renderWarnings(el, [], [], [], [ + diagnostic("heel-height", "grounding:foot_left:heel-height", "heel is high"), + diagnostic( + "grounding-rom-conflict", + "grounding-rom-conflict:foot_left", + "foot_left heel is off floor while ankle is at its ROM limit", + ), + ]); + + expect(el.innerHTML).toContain("grounding vs ROM"); + expect(el.innerHTML).toContain("ankle is at its ROM limit"); + expect(el.innerHTML).not.toContain("heel is high"); + }); + + it("surfaces residual self-collision and escapes diagnostic text", () => { + const el = target(); + renderWarnings(el, [], [], [], [ + diagnostic( + "self-collision", + "self-collision:arm_left:body", + "arm_left < body overlap", + ), + ]); + + expect(el.innerHTML).toContain("residual collision"); + expect(el.innerHTML).toContain("arm_left < body overlap"); + }); + + it("omits passing constraint outcomes", () => { + const el = target(); + renderWarnings(el, [], [], [], [{ + ...diagnostic("sole-angle", "grounding:foot_left:sole-angle", "flat"), + pass: true, + value: 0, + }]); + expect(el.innerHTML).toBe(""); + }); +}); diff --git a/scripts/documentation-contract.test.ts b/scripts/documentation-contract.test.ts new file mode 100644 index 0000000..5089058 --- /dev/null +++ b/scripts/documentation-contract.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ACTION_NAMES, + MODES, + MOVEMENT_KINDS, + PROP_TYPES, + RIG_NAMES, + START_POSE_NAMES, +} from "../packages/posecode-parser/src/index.js"; + +const specification = readFileSync(resolve(import.meta.dirname, "../spec/SPEC.md"), "utf8"); +const authoringGuide = readFileSync( + resolve(import.meta.dirname, "../spec/llm-authoring.md"), + "utf8", +); + +const closedVocabulary = [ + ...MOVEMENT_KINDS, + ...RIG_NAMES, + ...START_POSE_NAMES, + ...PROP_TYPES, + ...MODES, + ...ACTION_NAMES, +]; + +describe("authoring documentation contract", () => { + it.each([ + ["the normative specification", specification], + ["the pasteable LLM guide", authoringGuide], + ])("keeps the parser's core closed vocabulary in %s", (_label, document) => { + for (const token of closedVocabulary) { + expect(document, `missing parser token: ${token}`).toContain(token); + } + }); + + it.each([ + ["the normative specification", specification], + ["the pasteable LLM guide", authoringGuide], + ])("defines contact behavior and display-only cues in %s", (_label, document) => { + for (const directive of ["ground-lock", "reach", "pin", "grip"]) { + expect(document, `missing contact directive: ${directive}`).toContain(`\`${directive}\``); + } + expect(document).toMatch(/cue[^\n]*(display-only|display only)/i); + }); + + it.each([ + ["the normative specification", specification], + ["the pasteable LLM guide", authoringGuide], + ])("documents scoped, sparse custom start poses in %s", (_label, document) => { + expect(document).toMatch(/pose start = (?:standing|):/); + expect(document).toMatch(/sparse[^\n]*(overlay|joint)/i); + expect(document).toMatch(/loop-reset|loops/i); + }); +}); diff --git a/scripts/generate-content-pages.mjs b/scripts/generate-content-pages.mjs index 577fbf7..1d78bc8 100644 --- a/scripts/generate-content-pages.mjs +++ b/scripts/generate-content-pages.mjs @@ -124,6 +124,8 @@ async function main() { ▶ Open ${esc(name)} in the playground →

                                                                                                  Movement phases

                                                                                                  +

                                                                                                  Phase cues are display-only coaching text. The executable joint and + contact directives in the source below determine the animation.

                                                                                                    ${stepsHtml}
                                                                                                  @@ -192,7 +194,7 @@ ${stepsHtml} const specHtml = pageShell({ title: "Posecode Language Specification: The .posecode Kinematic Motion DSL", description: - "The full Posecode v0.2 grammar, timing modes, joints, actions, and configured range-of-motion tables for a text language capable LLMs can use to describe human movement.", + "The full Posecode v0.3 grammar, timing modes, joints, actions, and configured range-of-motion tables for a text language capable LLMs can use to describe human movement.", canonicalPath: "/spec.html", bodyHtml: `

                                                                                                  Reference

                                                                                                  \n${renderMarkdown(specMd)}`, }); diff --git a/scripts/generate-content-pages.test.ts b/scripts/generate-content-pages.test.ts index c308eb7..44fc978 100644 --- a/scripts/generate-content-pages.test.ts +++ b/scripts/generate-content-pages.test.ts @@ -21,4 +21,21 @@ describe("generated content pages", () => { `
                                                                                                  ${esc(source)}
                                                                                                  `, ); }); + + it("publishes the canonical authoring contract and cue semantics", () => { + const spec = readFileSync(resolve(root, "playground/public/spec.html"), "utf8"); + const guide = readFileSync(resolve(root, "playground/public/llm-guide.html"), "utf8"); + const movement = readFileSync( + resolve(root, "playground/public/moves/superhero-landing.html"), + "utf8", + ); + + expect(spec).toContain("normative language and IR contract"); + expect(guide).toContain("normative language and IR contract"); + expect(spec).toContain("display-only coaching metadata"); + expect(guide).toContain("display-only coaching text"); + expect(spec).toContain("pose start = standing:"); + expect(guide).toContain("pose start = <pose>:"); + expect(movement).toContain("Phase cues are display-only coaching text"); + }); }); diff --git a/scripts/sync-mcp-version.mjs b/scripts/sync-mcp-version.mjs index ea29eb5..54a6327 100644 --- a/scripts/sync-mcp-version.mjs +++ b/scripts/sync-mcp-version.mjs @@ -2,11 +2,13 @@ import { readFileSync, writeFileSync } from "node:fs"; const mcpPackagePath = new URL("../packages/posecode-mcp/package.json", import.meta.url); const serverPath = new URL("../packages/posecode-mcp/server.json", import.meta.url); +const mcpServerSourcePath = new URL("../packages/posecode-mcp/src/server.ts", import.meta.url); const embedPackagePath = new URL("../packages/posecode-embed/package.json", import.meta.url); const embedSourcePath = new URL("../packages/posecode-embed/src/compat.ts", import.meta.url); const packageJson = JSON.parse(readFileSync(mcpPackagePath, "utf8")); const serverJson = JSON.parse(readFileSync(serverPath, "utf8")); +const mcpServerSource = readFileSync(mcpServerSourcePath, "utf8"); const embedPackageJson = JSON.parse(readFileSync(embedPackagePath, "utf8")); const embedSource = readFileSync(embedSourcePath, "utf8"); @@ -19,6 +21,18 @@ for (const pkg of serverJson.packages ?? []) { writeFileSync(serverPath, `${JSON.stringify(serverJson, null, 2)}\n`); +const mcpVersionDeclaration = /export const POSECODE_MCP_VERSION = "[^"]+";/; +if (!mcpVersionDeclaration.test(mcpServerSource)) { + throw new Error("Could not find the Posecode MCP server version declaration."); +} +writeFileSync( + mcpServerSourcePath, + mcpServerSource.replace( + mcpVersionDeclaration, + `export const POSECODE_MCP_VERSION = ${JSON.stringify(packageJson.version)};`, + ), +); + const versionDeclaration = /export const version = "[^"]+";/; if (!versionDeclaration.test(embedSource)) { throw new Error("Could not find the exported posecode-embed version declaration."); diff --git a/spec/SPEC.md b/spec/SPEC.md index e3e5f9e..0aae3b8 100644 --- a/spec/SPEC.md +++ b/spec/SPEC.md @@ -1,15 +1,21 @@ -# Posecode Protocol Specification v0.2 +# Posecode Protocol Specification v0.3 Posecode is a small text language for describing a single person's **kinematic movement** so it can be rendered as an animated 3D figure in a web browser. +This document is the **normative language and IR contract**. The +[LLM authoring guide](https://posecode.org/llm-guide.html) is task-oriented and +pasteable; it is intentionally self-contained, but it must not define syntax or +behavior that differs from this specification. + It is to human movement what Mermaid is to diagrams: an LLM (or a human) writes a compact, readable document; a client-side parser + renderer turns it into a moving mannequin. The model never produces 3D matrices: it expresses the *semantic phases* of a movement, which it already understands. -- **Version keyword:** documents declare nothing; this is `posecode 0.2`. -- **Compatibility:** v0.2 parsers continue to accept the v0.1 easing aliases. +- **Version keyword:** documents declare nothing; this is `posecode 0.3`. +- **Compatibility:** v0.3 parsers continue to accept v0.2 documents and the + v0.1 easing aliases. - **File extension:** `.posecode` - **Compute model:** generation is pure text (server-cheap); all 3D math runs on the client (Three.js). See the project research §6. @@ -27,7 +33,8 @@ kind = "exercise" | "stretch" | "posture" ; directive = rig | prop | pose | clip | step | repeat ; rig = "rig" "humanoid" ; prop = "prop" ("chair"|"wall"|"bar"|"box"|"dip-bars") ; -pose = "pose" "start" "=" startPose ; +pose = "pose" "start" "=" startPose [ ":" { startOverride } ] ; +startOverride = jointTarget ; (* indented; sparse overlay, not a phase *) startPose = "neutral"|"standing"|"first-position"|"plank"|"supine"|"prone"|"seated" ; clip = "clip" STRING ; (* optional mocap clip; renderer may retarget & blend *) repeat = "repeat" NUMBER ; @@ -46,13 +53,34 @@ handEffector = "hands" | "hand_left" | "hand_right" ; gripAnchor = "bar" | "bars" ; turn = "turn" ":" NUMBER ; (* face this yaw (deg) by phase end *) travel = "travel" ":" NUMBER NUMBER ; (* move to this x z (metres) by phase end *) -cue = "cue" STRING ; +cue = "cue" STRING ; (* display-only coaching text *) 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. +A built-in start pose can be customized with an indented override block: + +```posecode + pose start = standing: + shoulders: flex 20 + elbow_left: flex 35 +``` + +The existing one-line form remains valid. Start-pose targets use the same +joint/action vocabulary, channel mirroring, compatibility checks, and ROM +clamping as phase targets, but they do not create a phase or consume time. +They sparsely overlay the selected built-in pose: omitted channels keep the +built-in value, while `hold neutral` resets all three channels on that joint. +The composed start pose is the deterministic animation start and loop-reset +pose. Contacts, root travel/turn, cues, and other step-only directives are not +valid inside this block. + +A document may contain at most one `pose start` declaration. A second +declaration is an error rather than a replacement, so an earlier scoped block +cannot be bypassed before its joint/action vocabulary is validated. + 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. @@ -66,6 +94,25 @@ 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. +### Contact mechanisms + +| Directive | What the solver does | Use it when | +| --- | --- | --- | +| `ground-lock` | Preserves an existing floor support while the rest of the body moves. | A foot, hand, forearm, or the back is already planted. | +| `reach` | Moves a limb endpoint toward a target through ROM-constrained IK; it does not translate the body root. | An additional hand, fist, elbow, knee, or foot must meet a floor, landmark, or prop target. | +| `pin` | Translates the whole body so one primary effector stays on a fixed anchor. | One contact should carry or reposition the body, such as a knee on the floor or foot on a box. | +| `grip` | Translates the body, solves each gripping arm to a bar or rail, and closes the fingers. | One or both hands support the body on a declared `bar` or `dip-bars` prop. | + +`ground-lock`, `pin`, and `grip` are mutually exclusive root-solving families +within one phase. Choose one primary support family, then express compatible +additional contacts with independent `reach` constraints. The parser rejects +conflicting combinations instead of silently choosing a solver order. + +`cue` is display-only coaching metadata. The parser stores it in the IR and a +viewer may show it alongside the current phase, but it does not alter joint +targets, contacts, timing, range validation, or rendering solves. A cue should +describe only motion that the phase's executable directives actually encode. + 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`, @@ -260,7 +307,8 @@ character. Mocap therefore cannot overwrite a planted sole or active grip. **Start poses:** `neutral`, `standing`, `first-position` (ballet turnout), `plank`, `supine` (face-up), `prone` (face-down), `seated` (long-sit on the -floor). +floor). Append `:` and indented joint targets to define a custom start pose as +a sparse overlay on any of these built-ins. **IK note:** Three.js's bundled `CCDIKSolver` targets `SkinnedMesh`; the Posecode mannequin is rigid capsule segments, so Posecode implements CCD directly over the @@ -279,11 +327,16 @@ angles are in **degrees**. ```ts interface PosecodeIR { - version: string; // "0.2" + version: string; // "0.3" kind: string; // "exercise" | "stretch" | "posture" name: string; rig: string; // "humanoid" startPose?: string; // "plank" | "standing" | ... + startPoseOverrides?: { // sparse, ROM-clamped overlay on startPose + boneId: string; + euler: { x: number; y: number; z: number }; + axes?: ("x" | "y" | "z")[]; + }[]; repeat: number; phases: { name: string; diff --git a/spec/examples/deadlift.posecode b/spec/examples/deadlift.posecode index 49c68f1..9534fd9 100644 --- a/spec/examples/deadlift.posecode +++ b/spec/examples/deadlift.posecode @@ -4,7 +4,7 @@ posecode exercise "Deadlift" step "Lower" 1.8s flow: pelvis: hinge 75 - knees: flex 25 + knees: flex 14 ankles: plantarflex 0 shoulders: flex 70 elbows: pronate 80 diff --git a/spec/llm-authoring.md b/spec/llm-authoring.md index 4f4e02e..263aca1 100644 --- a/spec/llm-authoring.md +++ b/spec/llm-authoring.md @@ -4,6 +4,11 @@ Paste the prompt below into ChatGPT, Claude, or any capable model. Then ask for a movement ("write a squat", "show a hamstring stretch") and paste the reply into the Posecode playground. +This is the task-oriented, pasteable authoring guide. The +[published Posecode Protocol Specification](https://posecode.org/spec.html) is the normative language and IR +contract. This guide stays self-contained so a model does not need to follow a +link, but the specification wins if the two ever disagree. + --- You write **Posecode**, a small text language that describes one person's @@ -28,6 +33,9 @@ posecode "" # kind = exercise | stretch | posture rig humanoid prop # optional: chair | wall | bar | box | dip-bars (repeatable) pose start = # neutral | standing | first-position | plank | supine | prone | seated + # Or append `:` and indent sparse joint overrides beneath it: + pose start = : + : step "" : # mode = flow | settle | drive | snap | linear : reach: # limb IK to a landmark, floor, or declared prop anchor @@ -36,10 +44,36 @@ posecode "" # kind = exercise | stretch | posture ground-lock: # planted supports; groups/back or side-specific names turn: # optional: face this yaw by phase end (standing only) travel: # optional: move to this x z (metres) by phase end - cue "" + cue "" # display-only text; never changes the motion repeat ``` +## Contact mechanisms + +| Directive | What moves | Use it for | +| --- | --- | --- | +| `ground-lock` | The solver preserves an existing floor support while the body moves. | A foot, hand, forearm, or the back is already planted. | +| `reach` | A limb endpoint moves toward a target through IK; the body root does not translate. | An additional floor, body-landmark, or declared-prop contact. | +| `pin` | The whole body translates around one primary fixed anchor. | A knee on the floor, a foot on a box, or another single body-moving support. | +| `grip` | The body translates, each arm solves to a bar/rail, and the fingers close. | One- or two-hand support on a declared `bar` or `dip-bars` prop. | + +Use only one of the root-solving families (`ground-lock`, `pin`, or `grip`) in +a step. Add compatible secondary contacts with `reach`. For example, +`ground-lock: foot_right` plus `reach: knee_left floor` is valid; adding +`pin: knee_left floor` to that same step is a conflict and the parser rejects +it. + +`cue` is display-only coaching text. It may appear next to the current phase in +a viewer, but it does not change joint targets, contacts, timing, validation, +or solver behavior. Never rely on a cue to create motion. + +Use a start-pose override block only when none of the built-ins is the exact +opening shape. Its joint targets are ROM-clamped and layered sparsely over the +built-in pose; they do not consume time or create a phase. Omitted channels keep +the built-in value, and the composed pose is restored when the animation loops. +Do not put contacts, cues, turn, or travel inside a start-pose block. +Write exactly one `pose start` declaration; duplicate declarations are errors. + ## Joints `neck head spine chest pelvis` and (singular or plural) `shoulders elbows
                                                                                                  ModeUse