Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions playground/public/llm-guide.html

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions playground/src/direct-manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ export interface AngleRange {
max: number;
}

interface SourceLineRange {
from: number;
to: number;
}

interface PreviewSegment {
start: number;
end: number;
}

// Keep this deliberately stricter than syntax highlighting. Only complete,
// parser-valid joint target lines become controls; comments, turn/travel
// numbers, and half-written source remain ordinary editable text.
Expand Down Expand Up @@ -107,3 +117,24 @@ export function normalizeAngle(value: number, range: AngleRange): string {
const clamped = Math.min(range.max, Math.max(range.min, value));
return String(Math.round(clamped * 10) / 10);
}

/**
* Resolve a directly edited source line to the key pose it controls. Joint
* targets in a start-pose override preview at time zero; targets in a step
* preview just inside that phase's endpoint so the looping sampler cannot wrap.
*/
export function previewTimeForLine(
line: number,
phaseRanges: readonly SourceLineRange[],
segments: readonly PreviewSegment[],
): number | null {
const firstPhase = phaseRanges[0];
if (firstPhase && line < firstPhase.from) return 0;

const phaseIndex = phaseRanges.findIndex(
(range) => line >= range.from && line <= range.to,
);
const segment = phaseIndex < 0 ? undefined : segments[phaseIndex];
if (!segment) return null;
return Math.max(segment.start, segment.end - 1e-3);
}
26 changes: 24 additions & 2 deletions playground/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,11 @@ export interface PosecodeEditor {

export interface PosecodeEditorOptions {
doc: string;
onChange: (value: string, userInitiated: boolean) => void;
onChange: (
value: string,
userInitiated: boolean,
context?: { previewLine: number },
) => void;
onJointSelect?: (joint: string | null, boneIds: readonly string[]) => void;
}

Expand Down Expand Up @@ -697,7 +701,25 @@ export function createPosecodeEditor(
(transaction) =>
transaction.annotation(Transaction.userEvent) !== undefined,
);
opts.onChange(u.state.doc.toString(), userInitiated);
// Spinner edits are different from ordinary source typing: the
// author is manipulating one key pose and expects to see that pose
// immediately. Pass its resulting source line to the playground;
// main.ts will seek there after rebuilding the timeline.
const directAngleEdit = u.transactions.some((transaction) =>
transaction.effects.some(
(effect) => effect.is(setActiveAngle) && effect.value !== null,
),
);
const activeAngle = directAngleEdit
? u.state.field(activeAngleField)
: null;
opts.onChange(
u.state.doc.toString(),
userInitiated,
activeAngle
? { previewLine: u.state.doc.lineAt(activeAngle.angleFrom).number }
: undefined,
);
}
}),
],
Expand Down
35 changes: 30 additions & 5 deletions playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { PosecodeEditor } from "./editor.js";
import { ANIMATION_PROGRESS_MESSAGE, PRESETS } from "./presets.js";
import { prioritizeFeaturedMovement } from "./library-order.js";
import { SHOWCASE_CLIPS } from "./clips.js";
import { previewTimeForLine } from "./direct-manipulation.js";

// During source-only typechecks the playground resolves posecode-render's last
// built declaration bundle. Keep the local extension explicit until the normal
Expand Down Expand Up @@ -98,6 +99,7 @@ let scrubDiagnosticsRefresh = 0;
let documentRevision = 1;
let pendingRenderTrigger: RenderTrigger = "initial";
let selectedBoneIds: readonly string[] = [];
let pendingPreviewLine: number | null = null;

/** Keep the source selection and its live 3D joint markers in sync. */
function handleJointSelect(
Expand Down Expand Up @@ -295,13 +297,18 @@ function computePhaseRanges(
}

let debounce = 0;
function scheduleRecompile(): void {
function scheduleRecompile(previewLine?: number): void {
window.clearTimeout(debounce);
pendingPreviewLine = previewLine ?? null;
debounce = window.setTimeout(recompile, 250);
}

/** Keep the address bar and library label in sync with editor changes. */
function handleEditorChange(source: string, userInitiated: boolean): void {
function handleEditorChange(
source: string,
userInitiated: boolean,
context?: { previewLine: number },
): void {
const editedDocumentKind = documentKind();
const preset = PRESETS.find((p) => p.source === source);
currentPresetId = preset?.id ?? null;
Expand All @@ -316,7 +323,7 @@ function handleEditorChange(source: string, userInitiated: boolean): void {
source.trim() ? "Custom movement" : "New movement",
);
history.replaceState(null, "", buildNicePlayPath(source));
scheduleRecompile();
scheduleRecompile(context?.previewLine);
}

function recompile(): void {
Expand Down Expand Up @@ -352,8 +359,6 @@ function recompile(): void {
);
viewer.setLoop(loop.checked);
viewer.setSpeed(Number(speed.value));
viewer.play();
setPlaying(true);
const tl = viewer.getTimeline();
repeat = tl?.repeat ?? 1;
rep = 1;
Expand All @@ -364,6 +369,26 @@ function recompile(): void {
tl?.segments.length ?? 0,
);
ed.highlightPhase(null); // next onPhase paints the active block

const previewTime =
pendingPreviewLine !== null && tl
? previewTimeForLine(pendingPreviewLine, phaseRanges, tl.segments)
: null;
pendingPreviewLine = null;
if (previewTime !== null && tl) {
// Direct manipulation is a pose inspection workflow: hold the affected
// keyframe so even a fast phase visibly responds to a one-degree edit.
viewer.seek(previewTime);
viewer.pause();
setPlaying(false);
scrub.value = String(Math.round((previewTime / (tl.duration || 1)) * 1000));
paintScrub();
clock.textContent = `${previewTime.toFixed(1)}s`;
scheduleScrubDiagnosticsRefresh();
} else {
viewer.play();
setPlaying(true);
}
}
}

Expand Down
17 changes: 17 additions & 0 deletions playground/test/direct-manipulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
angleTargetAt,
findAngleTargets,
normalizeAngle,
previewTimeForLine,
} from "../src/direct-manipulation.js";

describe("direct angle manipulation", () => {
Expand Down Expand Up @@ -51,4 +52,20 @@ describe("direct angle manipulation", () => {
expect(normalizeAngle(80.06, range)).toBe("80.1");
expect(normalizeAngle(999, range)).toBe("154");
});

it("previews the endpoint of the phase containing a direct angle edit", () => {
const ranges = [
{ from: 5, to: 9 },
{ from: 11, to: 15 },
];
const segments = [
{ start: 0, end: 0.5 },
{ start: 0.5, end: 0.85 },
];

expect(previewTimeForLine(7, ranges, segments)).toBeCloseTo(0.499);
expect(previewTimeForLine(13, ranges, segments)).toBeCloseTo(0.849);
expect(previewTimeForLine(3, ranges, segments)).toBe(0);
expect(previewTimeForLine(20, ranges, segments)).toBeNull();
});
});
29 changes: 29 additions & 0 deletions scripts/documentation-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
PROP_TYPES,
RIG_NAMES,
START_POSE_NAMES,
parse,
} from "../packages/posecode-parser/src/index.js";

const specification = readFileSync(resolve(import.meta.dirname, "../spec/SPEC.md"), "utf8");
Expand All @@ -26,6 +27,34 @@ const closedVocabulary = [
];

describe("authoring documentation contract", () => {
it("keeps every Posecode example in the LLM guide parseable and warning-free", () => {
const fences = [...authoringGuide.matchAll(/^([ \t]*)```posecode[ \t]*\n([\s\S]*?)^\1```[ \t]*$/gm)];
expect(fences.length).toBeGreaterThan(0);

for (const [index, fence] of fences.entries()) {
const indent = fence[1] ?? "";
const source = (fence[2] ?? "")
.split("\n")
.map((line) => line.startsWith(indent) ? line.slice(indent.length) : line)
.join("\n");
const documentSource = source.trimStart().startsWith("posecode ")
? source
: [
'posecode posture "Guide snippet"',
" rig humanoid",
" pose start = standing",
"",
...source.split("\n").map((line) => ` ${line}`),
"",
" repeat 1",
].join("\n");
const { ir, errors, warnings } = parse(documentSource);
expect({ example: index + 1, errors }).toEqual({ example: index + 1, errors: [] });
expect({ example: index + 1, warnings }).toEqual({ example: index + 1, warnings: [] });
expect(ir).not.toBeNull();
}
});

it.each([
["the normative specification", specification],
["the pasteable LLM guide", authoringGuide],
Expand Down
Loading