Skip to content

Commit c9d3076

Browse files
Fix live angle previews and improve LLM guide (#117)
Signed-off-by: a-baran-orhan <a.baranorhan@gmail.com>
1 parent c955377 commit c9d3076

7 files changed

Lines changed: 390 additions & 7 deletions

File tree

playground/public/llm-guide.html

Lines changed: 75 additions & 0 deletions
Large diffs are not rendered by default.

playground/src/direct-manipulation.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ export interface AngleRange {
2727
max: number;
2828
}
2929

30+
interface SourceLineRange {
31+
from: number;
32+
to: number;
33+
}
34+
35+
interface PreviewSegment {
36+
start: number;
37+
end: number;
38+
}
39+
3040
// Keep this deliberately stricter than syntax highlighting. Only complete,
3141
// parser-valid joint target lines become controls; comments, turn/travel
3242
// numbers, and half-written source remain ordinary editable text.
@@ -107,3 +117,24 @@ export function normalizeAngle(value: number, range: AngleRange): string {
107117
const clamped = Math.min(range.max, Math.max(range.min, value));
108118
return String(Math.round(clamped * 10) / 10);
109119
}
120+
121+
/**
122+
* Resolve a directly edited source line to the key pose it controls. Joint
123+
* targets in a start-pose override preview at time zero; targets in a step
124+
* preview just inside that phase's endpoint so the looping sampler cannot wrap.
125+
*/
126+
export function previewTimeForLine(
127+
line: number,
128+
phaseRanges: readonly SourceLineRange[],
129+
segments: readonly PreviewSegment[],
130+
): number | null {
131+
const firstPhase = phaseRanges[0];
132+
if (firstPhase && line < firstPhase.from) return 0;
133+
134+
const phaseIndex = phaseRanges.findIndex(
135+
(range) => line >= range.from && line <= range.to,
136+
);
137+
const segment = phaseIndex < 0 ? undefined : segments[phaseIndex];
138+
if (!segment) return null;
139+
return Math.max(segment.start, segment.end - 1e-3);
140+
}

playground/src/editor.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,11 @@ export interface PosecodeEditor {
607607

608608
export interface PosecodeEditorOptions {
609609
doc: string;
610-
onChange: (value: string, userInitiated: boolean) => void;
610+
onChange: (
611+
value: string,
612+
userInitiated: boolean,
613+
context?: { previewLine: number },
614+
) => void;
611615
onJointSelect?: (joint: string | null, boneIds: readonly string[]) => void;
612616
}
613617

@@ -697,7 +701,25 @@ export function createPosecodeEditor(
697701
(transaction) =>
698702
transaction.annotation(Transaction.userEvent) !== undefined,
699703
);
700-
opts.onChange(u.state.doc.toString(), userInitiated);
704+
// Spinner edits are different from ordinary source typing: the
705+
// author is manipulating one key pose and expects to see that pose
706+
// immediately. Pass its resulting source line to the playground;
707+
// main.ts will seek there after rebuilding the timeline.
708+
const directAngleEdit = u.transactions.some((transaction) =>
709+
transaction.effects.some(
710+
(effect) => effect.is(setActiveAngle) && effect.value !== null,
711+
),
712+
);
713+
const activeAngle = directAngleEdit
714+
? u.state.field(activeAngleField)
715+
: null;
716+
opts.onChange(
717+
u.state.doc.toString(),
718+
userInitiated,
719+
activeAngle
720+
? { previewLine: u.state.doc.lineAt(activeAngle.angleFrom).number }
721+
: undefined,
722+
);
701723
}
702724
}),
703725
],

playground/src/main.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type { PosecodeEditor } from "./editor.js";
2828
import { ANIMATION_PROGRESS_MESSAGE, PRESETS } from "./presets.js";
2929
import { prioritizeFeaturedMovement } from "./library-order.js";
3030
import { SHOWCASE_CLIPS } from "./clips.js";
31+
import { previewTimeForLine } from "./direct-manipulation.js";
3132

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

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

297299
let debounce = 0;
298-
function scheduleRecompile(): void {
300+
function scheduleRecompile(previewLine?: number): void {
299301
window.clearTimeout(debounce);
302+
pendingPreviewLine = previewLine ?? null;
300303
debounce = window.setTimeout(recompile, 250);
301304
}
302305

303306
/** Keep the address bar and library label in sync with editor changes. */
304-
function handleEditorChange(source: string, userInitiated: boolean): void {
307+
function handleEditorChange(
308+
source: string,
309+
userInitiated: boolean,
310+
context?: { previewLine: number },
311+
): void {
305312
const editedDocumentKind = documentKind();
306313
const preset = PRESETS.find((p) => p.source === source);
307314
currentPresetId = preset?.id ?? null;
@@ -316,7 +323,7 @@ function handleEditorChange(source: string, userInitiated: boolean): void {
316323
source.trim() ? "Custom movement" : "New movement",
317324
);
318325
history.replaceState(null, "", buildNicePlayPath(source));
319-
scheduleRecompile();
326+
scheduleRecompile(context?.previewLine);
320327
}
321328

322329
function recompile(): void {
@@ -352,8 +359,6 @@ function recompile(): void {
352359
);
353360
viewer.setLoop(loop.checked);
354361
viewer.setSpeed(Number(speed.value));
355-
viewer.play();
356-
setPlaying(true);
357362
const tl = viewer.getTimeline();
358363
repeat = tl?.repeat ?? 1;
359364
rep = 1;
@@ -364,6 +369,26 @@ function recompile(): void {
364369
tl?.segments.length ?? 0,
365370
);
366371
ed.highlightPhase(null); // next onPhase paints the active block
372+
373+
const previewTime =
374+
pendingPreviewLine !== null && tl
375+
? previewTimeForLine(pendingPreviewLine, phaseRanges, tl.segments)
376+
: null;
377+
pendingPreviewLine = null;
378+
if (previewTime !== null && tl) {
379+
// Direct manipulation is a pose inspection workflow: hold the affected
380+
// keyframe so even a fast phase visibly responds to a one-degree edit.
381+
viewer.seek(previewTime);
382+
viewer.pause();
383+
setPlaying(false);
384+
scrub.value = String(Math.round((previewTime / (tl.duration || 1)) * 1000));
385+
paintScrub();
386+
clock.textContent = `${previewTime.toFixed(1)}s`;
387+
scheduleScrubDiagnosticsRefresh();
388+
} else {
389+
viewer.play();
390+
setPlaying(true);
391+
}
367392
}
368393
}
369394

playground/test/direct-manipulation.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
angleTargetAt,
55
findAngleTargets,
66
normalizeAngle,
7+
previewTimeForLine,
78
} from "../src/direct-manipulation.js";
89

910
describe("direct angle manipulation", () => {
@@ -51,4 +52,20 @@ describe("direct angle manipulation", () => {
5152
expect(normalizeAngle(80.06, range)).toBe("80.1");
5253
expect(normalizeAngle(999, range)).toBe("154");
5354
});
55+
56+
it("previews the endpoint of the phase containing a direct angle edit", () => {
57+
const ranges = [
58+
{ from: 5, to: 9 },
59+
{ from: 11, to: 15 },
60+
];
61+
const segments = [
62+
{ start: 0, end: 0.5 },
63+
{ start: 0.5, end: 0.85 },
64+
];
65+
66+
expect(previewTimeForLine(7, ranges, segments)).toBeCloseTo(0.499);
67+
expect(previewTimeForLine(13, ranges, segments)).toBeCloseTo(0.849);
68+
expect(previewTimeForLine(3, ranges, segments)).toBe(0);
69+
expect(previewTimeForLine(20, ranges, segments)).toBeNull();
70+
});
5471
});

scripts/documentation-contract.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
PROP_TYPES,
99
RIG_NAMES,
1010
START_POSE_NAMES,
11+
parse,
1112
} from "../packages/posecode-parser/src/index.js";
1213

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

2829
describe("authoring documentation contract", () => {
30+
it("keeps every Posecode example in the LLM guide parseable and warning-free", () => {
31+
const fences = [...authoringGuide.matchAll(/^([ \t]*)```posecode[ \t]*\n([\s\S]*?)^\1```[ \t]*$/gm)];
32+
expect(fences.length).toBeGreaterThan(0);
33+
34+
for (const [index, fence] of fences.entries()) {
35+
const indent = fence[1] ?? "";
36+
const source = (fence[2] ?? "")
37+
.split("\n")
38+
.map((line) => line.startsWith(indent) ? line.slice(indent.length) : line)
39+
.join("\n");
40+
const documentSource = source.trimStart().startsWith("posecode ")
41+
? source
42+
: [
43+
'posecode posture "Guide snippet"',
44+
" rig humanoid",
45+
" pose start = standing",
46+
"",
47+
...source.split("\n").map((line) => ` ${line}`),
48+
"",
49+
" repeat 1",
50+
].join("\n");
51+
const { ir, errors, warnings } = parse(documentSource);
52+
expect({ example: index + 1, errors }).toEqual({ example: index + 1, errors: [] });
53+
expect({ example: index + 1, warnings }).toEqual({ example: index + 1, warnings: [] });
54+
expect(ir).not.toBeNull();
55+
}
56+
});
57+
2958
it.each([
3059
["the normative specification", specification],
3160
["the pasteable LLM guide", authoringGuide],

0 commit comments

Comments
 (0)