@@ -219,17 +236,22 @@ 4. Configured range-of-motion limits
5. Rendering model
- Forward kinematics: each phase sets joint angles; the renderer uses C1-continuous quaternion splines between keyframes, shaped by the destination phase's timing mode.
- 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.
- 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. - 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. - 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). - 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. - 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. - 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. - 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