diff --git a/README.md b/README.md index c5ab363..f763faf 100644 --- a/README.md +++ b/README.md @@ -192,16 +192,49 @@ You should see `73 frame(s), 3 s at 24 fps`, `GATE … green`, and failure: where a mesh does fold, it names the part and the frames to look at. `wave-build/` holds the rig with the new animation. +### 9b. Say hello + +Step 9 turned joint positions into a motion. A motion can also be written as keys on the +bones and handed to the build, which checks it with the rig. +[`scripts/hello.ts`](scripts/hello.ts) writes a four-second hello that way, reading the bones +from the rig step 8 built; the config names the file in `motion.animations_from`, and the +build runs again. + +```sh +bun ../getting-started/scripts/hello.ts out/rig/rig.json +echo '{"animations_from": "hello.anim.json"}' > work/hello-from.json +bun ../getting-started/scripts/take.ts work/hello-from.json config.json motion.animations_from=animations_from +bunx rig-parts build --config config.json --source inputs/painting.png --full inputs/layers/full --head inputs/layers/head --out out --loop +``` + +About a minute and a half. You should see `hello.ts: 4 beats (hm?, heh, wave, settle), 4 s at +24 fps, 16 tracks of 97 keys; sleeve_r0 at most 4.0 deg; hip shift 6.0 px`, then from the +build `animations beside the idle, from …/hello.anim.json as written: hello`, +`ANIMATIONS_DO_NOT_FOLD: PASS — 2 animation(s) read by rigc render --folds at 12 fps (idle, +hello)`, `check: PASS; 11 of 12 bar(s) measured, 1 skipped (STRESS_OFF_CHAIN); 1 warning(s)`, +and `build: PASS`. The warning is `SEAM_OVER_MOTION`, never a bar: where the forearm turns +at the elbow, a sliver of it shows past the sleeve's cuff. `out/check/build/` now holds `idle` +and `hello`. + +The wave here is the forearm's, with the upper arm kept within 4 degrees of rest. On this +painting the upper arm and its puff sleeve are one image under a sleeveless top, so +swinging it out, as step 9's wave does, opens a gap at the armhole. The comment at the top +of `hello.ts` says why each limit is where it is. + ### 10. Play it in your browser ```sh bun ../getting-started/scripts/serve.ts ``` -Open . +Open . -You should see the character raise her right arm and wave, over and over; the menu switches -to `idle`. Ctrl+C stops the server. That's the whole chain. The page is +You should see her notice you — "hm?", her head straightening — then smile with narrowed +eyes and two little shrugs, wave with her forearm beside her shoulder, and settle her weight +before it starts again. `&under=idle` keeps the idle playing beneath, so she breathes +through it. Step 9's wave is at +; the menu switches +animations. Ctrl+C stops the server. That's the whole chain. The page is [`scripts/play.html`](scripts/play.html), about sixty lines around `@rig-c/play`; [`scripts/serve.ts`](scripts/serve.ts) serves it on your own machine only. @@ -238,7 +271,7 @@ npm install @rig-c/pixi pixi.js @rig-c/play ``` Put this in your app's JavaScript entry point, serve `wave-build/` beside the page, -and you have the wave from step 10 inside a Pixi canvas: +and you have step 9's wave inside a Pixi canvas: ```js import { Application } from 'pixi.js'; diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 7140afc..21b55da 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -10,7 +10,7 @@ quoted from. Three words recur: ## Tested with -Recorded 2026-10-13; *Try it first* re-run 2026-10-20. Everything marked *measured* on this +Recorded 2026-10-13; *Try it first* re-run twice on 2026-10-20, the second time with step 9b. Everything marked *measured* on this page was run on this machine with these versions. | | | @@ -20,10 +20,10 @@ page was run on this machine with these versions. | Bun | 1.4.2 | | uv | 0.12.17, with the Python 3.12.14 it chose | | Browser | Chromium 152 (the built-in browser of the Claude desktop app), meshes drawn through WebGL (2026-10-13) | -| `@rig-c/parts` | 1.41.1 (2026-10-20; 1.16.0 on 2026-10-13) | -| `rig-c` | 2.61.1 (2026-10-20; 2.48.0 on 2026-10-13) | -| `@rig-c/pose` | 0.35.0 (2026-10-20; 0.1.1 on 2026-10-13) | -| `@rig-c/play` | 1.12.0 (2026-10-20; 1.9.1 on 2026-10-13) | +| `@rig-c/parts` | 1.48.0 (2026-10-20, with step 9b; 1.41.1 earlier that day; 1.16.0 on 2026-10-13) | +| `rig-c` | 2.64.1 (2026-10-20, with step 9b; 2.61.1 earlier that day; 2.48.0 on 2026-10-13) | +| `@rig-c/pose` | 0.37.0 (2026-10-20, with step 9b; 0.35.0 earlier that day; 0.1.1 on 2026-10-13) | +| `@rig-c/play` | 1.12.0 (2026-10-20, both runs; 1.9.1 on 2026-10-13) | | Spine editor | Spine 4.3.26 Professional (launcher 4.3.08), driven from the command line only; see *Use what you made* | The README's `bun add` line pins nothing, so you get the current versions. If an output of @@ -42,6 +42,18 @@ times. Step 10 was checked by fetching the page and the model from `serve.ts` (b 200), not by opening a browser. The README's expected lines come from that run. The earlier runs are kept below. +Later on 2026-10-20, with step 9b added, *Try it first* was run twice more on the current +npm versions (parts 1.48.0, rig-c 2.64.1, pose 0.37.0, play 1.12.0): once with the published +guide's steps 1–10 and then 9b by hand (run 4), and once as a smoke of the finished page, +every `sh` block extracted and run verbatim in order from an empty folder (run 5). Bun's, +uv's and the pose models' caches were warm for both. In run 5, `git clone` was replaced by a +copy of the checkout, because `scripts/hello.ts` was not yet published. Every expected line +on the README held in both, step 9's included, and run 5's outputs were byte-identical to +run 4's (`hello.anim.json`, `config.json`, `wave.motion.json`, the build's `skeleton.json` +and `skeleton.model.json`). Step 10 was checked by fetching the page and both models from +`serve.ts` (HTTP 200), and the `&under=idle` address was opened once in headless Chromium: +`idle` on track 0, `hello` on track 1, both looping, the page's message line empty. + *Try it first* (the CPU-only half) was run end to end twice on the machine above: once command by command while drafting, and once as a smoke, every `sh` block of the finished page extracted and run verbatim in order from an empty folder with an empty Bun cache. The @@ -66,22 +78,25 @@ game engine was run. Two runs; where they differ, both are given. The README's step numbers are the left column. -| README step | Commands | Run 1 (2026-10-13) | Run 2 (smoke, 2026-10-13) | Run 3 (clean, 2026-10-20) | -| --- | --- | --- | --- | --- | -| 1 install | `bun add` the four packages | 2.05 s (empty cache) | 3.1 s incl. mkdir and copy | 1.9 s incl. `git clone` | -| 2 example inputs | curl the exam tarball at `01436ee`, extract task 01 inputs | 4.0 s | 4.6 s | 3.6 s | -| 3 config | `rig-parts inputs` | 3.2 s | 4.2 s | 1.5 s | -| 4 head box | `layers`, `propose --head-box`, `take.ts`, `inputs` | 1.5 + 2.0 + 3.5 s | 8.0 s | 2.0 s | -| 5 assemble | `assemble --propose-plan`, `take.ts`, `assemble` | 19.3 + 35.2 s | 67.3 s | 28.3 s | -| 6 joints | `uv run … keypoints.py` | 3.6 s (models already downloaded) | 3.9 s (models already downloaded) | 42.5 s (models and packages downloaded) | -| 7 draft bones | `propose --keypoints`, `take.ts` | 6.9 s | 8.8 s | 6.3 s | -| 8 build | `rig-parts build --loop` | 71.6 s | 67.2 s | 71.9 s | -| — look | `rigc inspect`, `rigc render` (under *Where to go next*) | 1.9 + 2.3 s | 2.8 s | not run | -| 9 wave | `wave.ts`, `clean`, map heredoc, `rig-pose retarget --build` | 0.09 + 6.0 s | 5.7 s | 5.2 s | -| 10 play | `serve.ts` | played in Chromium 152; meshes on WebGL | played; 37 requests, last at 242 ms | page and model HTTP 200 | - -Run 2 total: **176 s**. Run 3 total: **166 s** wall clock, first-time downloads included. The -download in step 2 is 41 MB. +| README step | Commands | Run 1 (2026-10-13) | Run 2 (smoke, 2026-10-13) | Run 3 (clean, 2026-10-20) | Run 4 (warm, 2026-10-20) | +| --- | --- | --- | --- | --- | --- | +| 1 install | `bun add` the four packages | 2.05 s (empty cache) | 3.1 s incl. mkdir and copy | 1.9 s incl. `git clone` | 1.6 s | +| 2 example inputs | curl the exam tarball at `01436ee`, extract task 01 inputs | 4.0 s | 4.6 s | 3.6 s | 3.4 s | +| 3 config | `rig-parts inputs` | 3.2 s | 4.2 s | 1.5 s | 2.6 s | +| 4 head box | `layers`, `propose --head-box`, `take.ts`, `inputs` | 1.5 + 2.0 + 3.5 s | 8.0 s | 2.0 s | 4.0 s | +| 5 assemble | `assemble --propose-plan`, `take.ts`, `assemble` | 19.3 + 35.2 s | 67.3 s | 28.3 s | 40.2 s | +| 6 joints | `uv run … keypoints.py` | 3.6 s (models already downloaded) | 3.9 s (models already downloaded) | 42.5 s (models and packages downloaded) | 4.4 s (cached) | +| 7 draft bones | `propose --keypoints`, `take.ts` | 6.9 s | 8.8 s | 6.3 s | 6.2 s | +| 8 build | `rig-parts build --loop` | 71.6 s | 67.2 s | 71.9 s | 60.6 s | +| — look | `rigc inspect`, `rigc render` (under *Where to go next*) | 1.9 + 2.3 s | 2.8 s | not run | not run | +| 9 wave | `wave.ts`, `clean`, map heredoc, `rig-pose retarget --build` | 0.09 + 6.0 s | 5.7 s | 5.2 s | 6.1 s | +| 9b hello | `hello.ts`, the `animations_from` line, `take.ts`, `rig-parts build --loop` | — | — | — | 80.6 s | +| 10 play | `serve.ts` | played in Chromium 152; meshes on WebGL | played; 37 requests, last at 242 ms | page and model HTTP 200 | page and both models HTTP 200 | + +Run 2 total: **176 s**. Run 3 total: **166 s** wall clock, first-time downloads included. Run +4 total: **214 s** (133 s for steps 1–10 and 81 s for 9b), with the caches warm. The run 5 +smoke took 580 s, but the machine's load average was 75–87 while it ran (other jobs), so its +times are not given. The download in step 2 is 41 MB. Disk after run 2: `my-rig` 79 MB (`node_modules` 37, `inputs` 19, `work` 4.3, `out` 16, `wave-build` 1.6) and Bun's download cache 38 MB — about 120 MB in all. Bun reported @@ -99,6 +114,18 @@ page edges free, shape polygon`; retarget `GATE` green, every `LAND` within 0.00 `check` and `build: PASS`. Disk after run 3: `my-rig` 79 MB, Bun's cache 40 MB, the pose models in `~/.cache/rtmlib` 328 MB. +Results, runs 4 and 5 (parts 1.48.0, rig-c 2.64.1, pose 0.37.0): steps 3–9 printed what +run 3 printed, line for line where the README quotes it; `check` adds `0 warning(s) +(SEAM_OVER_MOTION, not a bar)` to step 8's line. Step 9b: `hello.ts` printing `4 beats (hm?, +heh, wave, settle), 4 s at 24 fps, 16 tracks of 97 keys; sleeve_r0 at most 4.0 deg; hip shift +6.0 px (legs r +2.05/-3.42, l +1.82/-2.90 deg, ankles held)`; the build printing `animations +beside the idle, … as written: hello`, `ANIMATIONS_DO_NOT_FOLD: PASS — 2 animation(s) … +(idle, hello): 0 folding mesh(es)`, one `WARN seam "upperarm_r|forearm_r" opens on 6 +frame(s) [26, 29, 31, 33, 35, 39] max 23 px in "hello"`, `check: PASS; 11 of 12 bar(s) +measured, 1 skipped (STRESS_OFF_CHAIN); 1 warning(s) (SEAM_OVER_MOTION, not a bar)`, `build: +PASS`; `out/check/build/skeleton.model.json` holding `idle` and `hello`. The hello's own +readings are under *Step 9b, the hello* below. + Results, runs 1 and 2 (parts 1.16.0, rig-c 2.48.0): `check: PASS; 10 of 10 bar(s) measured, 0 skipped`; `build: PASS`; the packed atlas `skeleton.png 507x1472, 22 region(s), 90.7% covered, padding 2, page edges free, shape polygon`; `assemble` reporting `recomposite vs source: mean |d|=1.13, within 8: 97.8%, @@ -113,6 +140,64 @@ round trip and the commands that need it are not available here`. It is correct: published package checks every build with rigc's own validator, and the [rig-c README](https://github.com/rig-c/core#install) explains the two. +### Step 9b, the hello + +`scripts/hello.ts` writes keys, not joint positions: the face is outside the 18 body joints +rig-pose reads, and the arm's limit is an angle, which a key states exactly. It reads the +bones from `out/rig/rig.json` — which bones exist, and the legs' lengths and rest angles, +which a two-bone solve uses so both ankles stay where they were painted while the hip moves. +The keypoints are not read. The file goes in through the config's `motion.animations_from`, +"a path, relative to the config file's directory, to a file in rig-c's motion-spec shape" +(stated by the parts [AUTHORING][authoring], the `motion.animations_from` row), so the build +gates it with the rig and `check` reads it for folds. + +**The limits, and where they come from.** Measured on this painting with `rigc render +--seam` in a probe before the step was written (parts 1.42.0, rig-c 2.64.1): the upper-arm image is the puff sleeve and +the arm in one piece under a sleeveless top, and turning it out from rest opened 0 px of +background at the armhole at 3 degrees and 50 px at 6; step 9's wave turns it about 105 +degrees and opens 3,772 px. The forearm turned anywhere from −160 to +60 degrees at the +elbow with a gap of 8 px at most and a ghost of 28. The thigh art starts at the skirt's hem, so a hip +move opens background there: 6 px of hip travel (0.5 % of the figure) with the skirt's first +links following passed, 12 px did not. The shoes are one image that no leg bone moves, so no +foot lifts. + +**Bars, written down before measuring:** on each seam pair, gap ≤ 25 px and ghost ≤ 50 px +on the worst frame; 0 frames of pass-through; `ANIMATIONS_DO_NOT_FOLD` PASS; `sleeve_r0` +within ±4 degrees of rest; hip travel ≤ 6 px with the skirt links following. + +**Readings**, on run 4's build, `rigc render --candidate out/check/build --animation +--fps 24 --seam … --pass-through bottomwear:thigh_r,bottomwear:thigh_l --angle-range +sleeve_r0:chest,sleeve_r1:sleeve_r0` (gap / ghost, px, worst frame): + +| pair | `idle` | `hello` | +| --- | --- | --- | +| upperarm_r \| topwear | 0 / 2 | 8 / 8 | +| forearm_r \| upperarm_r | 0 / 2 | 1 / 25 | +| upperarm_l \| topwear | 0 / 0 | 0 / 1 | +| forearm_l \| upperarm_l | 0 / 1 | 0 / 0 | +| neck \| topwear | 0 / 0 | 0 / 5 | +| face \| hair_front | 2 / 0 | 0 / 0 | +| face \| neck | 0 / 0 | 0 / 1 | +| hair_back \| face | 0 / 1 | 0 / 0 | +| topwear \| bottomwear | 0 / 3 | 1 / 9 | +| thigh_r \| bottomwear | 1 / 0 | 0 / 0 | +| thigh_l \| bottomwear | 0 / 1 | 3 / 1 | +| shin_r \| thigh_r | 0 / 0 | 0 / 9 | +| shin_l \| thigh_l | 0 / 0 | 0 / 5 | +| shoes \| shin_r | 0 / 0 | 0 / 0 | +| shoes \| shin_l | SKIP | SKIP | + +`SKIP` is rigc's: the two drawings share no band at the setup pose. Pass-through: 0 of 97 +frames for both thighs (0 of 81 under the idle). `sleeve_r0` against `chest`: −103.03 to +−99.03 degrees under the hello, rest −99.03, so 4.00 degrees out at most (the idle moves it +0.39). `sleeve_r1` against `sleeve_r0`: −151.23 to −11.32 degrees. Hip: world x from 31.00 to +25.04, 5.96 px on the frames read; both ankles (`leg_r1`'s and `leg_l1`'s tips) moved at most +0.0007 px (`render --geometry`). Every reading is inside its bar. + +**Not measured:** the seams with the hello played over the idle on two tracks, as step 10's +`&under=idle` plays it — `rigc render` reads one animation at a time. The idle adds its +breath on `chest_ctl` and its sway on the left sleeve beneath the hello's own keys. + ### The joint script, `scripts/keypoints.py` - **Downloads, first run**: two model zips, 94,223,081 and 212,739,469 bytes (sizes from the @@ -481,3 +566,4 @@ changes is the packages' decision. | The Spine editor import of the guide's build, as written | the packages writing each weighted vertex's bones in ascending bone-index order (the one edit that imported), then the round trip again on a fresh build | | The editor's import through its window (`Import Data`), the `MISSING` images step | a licensed editor opened by a person; only the command line was run | | `out/check/build/` or `wave-build/` loading in a game engine's Spine 4.3 runtime | one engine, one load, one frame compared against `rigc render` | +| The seams of step 10's hello over the idle (`&under=idle`, two tracks) | a reading of the two tracks mixed, frame by frame, as the player poses them | diff --git a/scripts/check-adapters.ts b/scripts/check-adapters.ts index f735b39..058fec4 100644 --- a/scripts/check-adapters.ts +++ b/scripts/check-adapters.ts @@ -30,7 +30,7 @@ function run(command: string[], cwd: string) { function blocks(source: string, language: string): string[] { return [...source.matchAll(new RegExp('```' + language + '\\n([\\s\\S]*?)```', 'g'))].map(m => m[1].trim()); } -function step(number: number) { +function step(number: number | string) { const match = readme.match(new RegExp('### ' + number + '\\. [^\\n]+\\n([\\s\\S]*?)(?=\\n### |\\n## )')); assert(match, `GUIDE_STEP: missing step ${number}`); return match[1]; @@ -59,12 +59,19 @@ function anchorExists(source: string, anchor: string) { return (n ? `${base}-${n}` : base) === decodeURIComponent(anchor); }); } +// Step 10's two addresses: the hello from step 9b over the idle, and step 9's wave. +const tutorialModels: Record = { + 'out/check/build/skeleton.model.json': { anim: 'hello', under: 'idle' }, + 'wave-build/skeleton.model.json': { anim: 'wave', under: null }, +}; function tutorialUrl(link: string) { const url = new URL(link); assert(tutorialOrigin, 'TUTORIAL_SERVER: owned step-10 server is not running'); assert(url.protocol === 'http:' && url.hostname === 'localhost' && url.port === '3000' && url.pathname === '/', `TUTORIAL_URL: unexpected tutorial endpoint ${link}`); - assert.equal(url.searchParams.get('model'), 'wave-build/skeleton.model.json', 'TUTORIAL_MODEL: expected the step-10 build'); - assert.equal(url.searchParams.get('anim'), 'wave', 'TUTORIAL_ANIMATION: expected wave'); + const expected = tutorialModels[url.searchParams.get('model') ?? '']; + assert(expected, `TUTORIAL_MODEL: expected one of ${Object.keys(tutorialModels).join(', ')}`); + assert.equal(url.searchParams.get('anim'), expected.anim, `TUTORIAL_ANIMATION: expected ${expected.anim}`); + assert.equal(url.searchParams.get('under'), expected.under, `TUTORIAL_UNDER: expected ${expected.under}`); return tutorialOrigin + url.pathname + url.search; } const branchCache = new Map>(); @@ -184,6 +191,11 @@ try { reports.cleanControl = { exitCode: uncleaned.exitCode, rule: 'POSE_NOT_CLEANED' }; console.log('[bar:guide-clean] cleaned sequence retarget exit 0; exact original raw-sequence command refused POSE_NOT_CLEANED PASS'); reports.modelSha256 = createHash('sha256').update(readFileSync(join(consumer, 'wave-build/skeleton.model.json'))).digest('hex'); + // Step 9b: the hello, named in the config and gated by the build. + run(['bash', '-eu', '-o', 'pipefail', '-c', blocks(step('9b'), 'sh')[0]], consumer); + const built = JSON.parse(readFileSync(join(consumer, 'out/check/build/skeleton.model.json'), 'utf8')) as { animations: { name: string }[] }; + assert.deepEqual(built.animations.map(a => a.name), ['idle', 'hello'], 'GUIDE_HELLO: the build must hold idle and hello'); + console.log('[bar:guide-hello] step 9b build exit 0; out/check/build holds idle, hello PASS'); // Verification tools are separate from each empty consumer installation. const tools = join(temp, 'tools'); mkdirSync(tools); writeFileSync(join(tools, 'package.json'), '{"private":true,"type":"module"}'); run(['npm', 'install', '--no-audit', '--no-fund', 'playwright@1.58.2'], tools); @@ -245,16 +257,21 @@ window.exampleCheck = async () => { const browser = await chromium.launch({ args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }).catch((error: Error) => { server.stop(true); throw error; }); try { const tutorialLinks = urls(readme).filter(link => link.startsWith('http://localhost:')); - assert.equal(tutorialLinks.length, 1, 'TUTORIAL_LINK: expected one step-10 endpoint'); - const tutorialPage = await browser.newPage(); - const tutorialErrors: string[] = []; - tutorialPage.on('pageerror', (error: Error) => tutorialErrors.push(String(error))); - await tutorialPage.goto(tutorialUrl(tutorialLinks[0])); - await tutorialPage.waitForFunction('window.rigPlayer && document.querySelector("#anim").value === "wave"', null, { timeout: 60000 }); - assert(await tutorialPage.evaluate('window.rigPlayer.posedFrame().order.length > 0'), 'TUTORIAL_FRAME: model has no drawn slots'); - assert.deepEqual(tutorialErrors, [], 'TUTORIAL_BROWSER: step-10 page failed'); - await tutorialPage.close(); - console.log('[bar:tutorial] owned documented server loaded requested wave-build model and wave animation PASS'); + assert.deepEqual(tutorialLinks.map(link => new URL(link).searchParams.get('model')).sort(), Object.keys(tutorialModels).sort(), 'TUTORIAL_LINK: expected the two step-10 endpoints'); + for (const link of tutorialLinks) { + const expected = tutorialModels[new URL(link).searchParams.get('model') ?? '']; + const tutorialPage = await browser.newPage(); + const tutorialErrors: string[] = []; + tutorialPage.on('pageerror', (error: Error) => tutorialErrors.push(String(error))); + await tutorialPage.goto(tutorialUrl(link)); + await tutorialPage.waitForFunction(`window.rigPlayer && document.querySelector("#anim").value === ${JSON.stringify(expected.anim)}`, null, { timeout: 60000 }); + assert(await tutorialPage.evaluate('window.rigPlayer.posedFrame().order.length > 0'), 'TUTORIAL_FRAME: model has no drawn slots'); + const tracks = await tutorialPage.evaluate('[window.rigPlayer.entry(0)?.animation ?? null, window.rigPlayer.entry(1)?.animation ?? null]'); + assert.deepEqual(tracks, expected.under ? [expected.under, expected.anim] : [expected.anim, null], 'TUTORIAL_TRACKS: unexpected tracks'); + assert.deepEqual(tutorialErrors, [], 'TUTORIAL_BROWSER: step-10 page failed'); + await tutorialPage.close(); + console.log(`[bar:tutorial] owned documented server loaded ${new URL(link).search} with tracks ${JSON.stringify(tracks)} PASS`); + } const page = await browser.newPage(); const errors: string[] = []; page.on('pageerror', (error: Error) => errors.push(String(error))); page.on('requestfailed', (request: { url(): string; failure(): { errorText: string } | null }) => errors.push(`${request.url()}: ${request.failure()?.errorText}`)); diff --git a/scripts/hello.ts b/scripts/hello.ts new file mode 100644 index 0000000..a368f77 --- /dev/null +++ b/scripts/hello.ts @@ -0,0 +1,193 @@ +// Make a four-second "hello" as one rig-c motion spec, for the config's +// motion.animations_from. +// +// bun hello.ts [--seconds 4] [--fps 24] [--out hello.anim.json] +// +// is the rig a build wrote, out/rig/rig.json: the bones the hello +// keys are read from it, and the legs' lengths and rest angles for the weight +// shift. The output is {"spec": "rigc-motion/1", "animations": {"hello": …}}, +// the shape rig-parts reads beside the idle; `rig-parts build` then gates it +// with the rig and reads it for folds. +// +// Four beats, every track back at rest by the end, so it loops: +// hm? the head straightens toward the viewer, a small lean, brows up; +// heh the eyes narrow and the mouth widens, two shoulder bobs; +// wave the forearm comes up beside the shoulder and waves twice; +// settle she shifts her weight onto her right foot and back, to rest. +// Nothing here comes from a video or a dataset: it is an authored test motion, +// keyed on the bones, sampled once per frame. +// +// Two limits come from the example painting, measured with `rigc render --seam`: +// - The upper arm stays within 4 degrees of rest. Its image is the puff sleeve +// and the arm in one piece, under a sleeveless top, so swinging it out shows +// background at the armhole (50 px at 6 degrees). The forearm tucks under +// the cuff and is free at the elbow, so the wave is the forearm's. +// - The weight shift is 6 px (0.5 % of the figure). The thigh art starts at the +// skirt's hem, so a larger hip move opens a gap there; the skirt's first +// links follow the hip, and both ankles stay where they were painted (the +// shoes are one image that no leg bone moves). +import { readFileSync, writeFileSync } from 'node:fs'; + +type Bone = { name: string; parent?: string; x?: number; y?: number; rotation?: number; length?: number }; + +const args = process.argv.slice(2); +const opt = (name: string, dflt: number): number => { + const i = args.indexOf(name); + if (i < 0) return dflt; + const v = Number(args[i + 1]); + if (!Number.isFinite(v) || v <= 0) { + console.error(`hello.ts: ${name} is ${args[i + 1]}; a positive number is required`); + process.exit(2); + } + args.splice(i, 2); + return v; +}; +const seconds = opt('--seconds', 4); +const fps = opt('--fps', 24); +const oi = args.indexOf('--out'); +const outPath = oi >= 0 ? args.splice(oi, 2)[1] : 'hello.anim.json'; +const [rigPath] = args; +if (!rigPath || !outPath) { + console.error('usage: bun hello.ts [--seconds 4] [--fps 24] [--out hello.anim.json]'); + process.exit(2); +} + +const rig = JSON.parse(readFileSync(rigPath, 'utf8')); +const bones: Record = {}; +for (const b of rig.bones ?? []) bones[b.name] = b; +const need = ['hip', 'chest', 'head', 'mouth', 'sleeve_r0', 'sleeve_r1', 'leg_r0', 'leg_r1', 'leg_l0', 'leg_l1', + 'skirt_r0', 'skirt_c0', 'skirt_l0']; +const lacking = need.filter((n) => !bones[n]); +if (lacking.length > 0) { + console.error(`hello.ts: ${rigPath} has no bone ${lacking.join(', ')}; the hello keys all of ${need.join(', ')}`); + process.exit(1); +} +for (const leg of ['leg_r', 'leg_l']) { + if (bones[`${leg}0`].parent !== 'hip' || bones[`${leg}1`].parent !== `${leg}0`) { + console.error(`hello.ts: ${leg}0 must hang from hip and ${leg}1 from ${leg}0 for the weight shift`); + process.exit(1); + } +} + +// The beat sheet is written for 4 s; another --seconds stretches it evenly. +const k = seconds / 4; +const smooth = (a: number, b: number, t: number) => { + const x = Math.min(1, Math.max(0, (t - a) / (b - a))); + return x * x * (3 - 2 * x); +}; +// Through (time, value) points, smoothstep between each pair; times in beat-sheet seconds. +const ramp = (t: number, pts: [number, number][]) => { + let v = pts[0][1]; + for (let i = 0; i + 1 < pts.length; i++) { + const [t0, v0] = pts[i]; + const [t1, v1] = pts[i + 1]; + if (t >= t0) v = v0 + (v1 - v0) * smooth(t0, t1, t); + } + return v; +}; +// Two lifts between a and b: sin² over each half. +const twoLifts = (t: number, a: number, b: number) => + t < a || t > b ? 0 : Math.sin((Math.PI * (t - a)) / ((b - a) / 2)) ** 2; + +const UPPER_ARM = -4; // degrees: sleeve_r0, out from her side; the limit above +const FOREARM = -125; // degrees: sleeve_r1, the forearm up beside the shoulder +const WAVE = 15; // degrees each way, twice a second +const SHIFT = -6; // px: hip x, toward the image's left, onto her right foot; the limit above +const SKIRT = 1.66; // degrees: skirt_*0 following the hip + +// Settle: out over 3.30–3.65 s, back over 3.65–4.00 s. +const settle = (t: number) => smooth(3.3, 3.65, t) - smooth(3.65, 4.0, t); + +// Two-bone leg IK in the hip's frame (the hip has no rotation): the hip moves +// by dx, the ankle stays put, the knee keeps the side it was painted on. +const rad = Math.PI / 180; +const legIK = (leg: string, dx: number): [number, number] => { + const b0 = bones[`${leg}0`]; + const b1 = bones[`${leg}1`]; + const L0 = b0.length ?? 0; + const L1 = b1.length ?? 0; + const r0 = (b0.rotation ?? 0) * rad; + const r1 = (b1.rotation ?? 0) * rad; + const R: [number, number] = [b0.x ?? 0, b0.y ?? 0]; + const K: [number, number] = [R[0] + L0 * Math.cos(r0), R[1] + L0 * Math.sin(r0)]; + const A: [number, number] = [K[0] + L1 * Math.cos(r0 + r1), K[1] + L1 * Math.sin(r0 + r1)]; + // The ankle, seen from the moved hip. + const Ax = A[0] - dx; + const Ay = A[1]; + const d = Math.hypot(Ax - R[0], Ay - R[1]); + if (d > L0 + L1) { + console.error(`hello.ts: ${leg} cannot reach its ankle after a ${dx} px hip shift (${d.toFixed(1)} > ${(L0 + L1).toFixed(1)} px)`); + process.exit(1); + } + const base = Math.atan2(Ay - R[1], Ax - R[0]); + const inner = Math.acos(Math.min(1, Math.max(-1, (L0 * L0 + d * d - L1 * L1) / (2 * L0 * d)))); + const knee = Math.acos(Math.min(1, Math.max(-1, (L0 * L0 + L1 * L1 - d * d) / (2 * L0 * L1)))); + const side = Math.sign(r1) || 1; // the knee bends the way the rest pose bends it + const n0 = base - side * inner; + const n1 = side * (Math.PI - knee); + const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a)) / rad; + return [wrap(n0 - r0), wrap(n1 - r1)]; +}; + +type Curve = (t: number) => number | number[]; +const curves: [string, string, Curve, boolean?][] = [ + ['head', 'rotate', (t) => ramp(t, [[0, 0], [0.35, 6], [0.85, 6], [1.05, 4], [3.4, 3], [3.95, 0]]) + 1.2 * twoLifts(t, 0.95, 1.75)], + ['chest', 'rotate', (t) => ramp(t, [[0, 0], [0.4, 1], [1.9, 1], [2.3, 0]]) + 1.5 * twoLifts(t, 0.95, 1.75) - 0.5 * settle(t)], + ['chest', 'translatey', (t) => 4 * twoLifts(t, 0.95, 1.75)], + ['brows', 'translatey', (t) => ramp(t, [[0, 0], [0.3, 3], [0.85, 3], [1.05, 1], [3.4, 1], [3.8, 0]]), true], + ['eyes', 'scaley', (t) => ramp(t, [[0, 1], [0.85, 1], [1.05, 0.7], [1.7, 0.7], [1.95, 1]]), true], + ['mouth', 'scale', (t) => [ + ramp(t, [[0, 1], [0.85, 1], [1.05, 1.12], [1.7, 1.12], [1.95, 1]]), + ramp(t, [[0, 1], [0.85, 1], [1.05, 0.9], [1.7, 0.9], [1.95, 1]]), + ]], + ['sleeve_r0', 'rotate', (t) => ramp(t, [[0, 0], [1.9, 0], [2.15, UPPER_ARM], [3.15, UPPER_ARM], [3.4, 0]])], + ['sleeve_r1', 'rotate', (t) => + ramp(t, [[0, 0], [1.9, 0], [2.2, FOREARM], [3.1, FOREARM], [3.4, 0]]) + + (t >= 2.2 && t <= 3.1 ? WAVE * Math.sin(2 * Math.PI * 2 * (t - 2.2)) * smooth(2.2, 2.3, t) * (1 - smooth(3.0, 3.1, t)) : 0)], + ['hip', 'translate', (t) => [SHIFT * settle(t), 0]], + ['leg_r0', 'rotate', (t) => legIK('leg_r', SHIFT * settle(t))[0]], + ['leg_r1', 'rotate', (t) => legIK('leg_r', SHIFT * settle(t))[1]], + ['leg_l0', 'rotate', (t) => legIK('leg_l', SHIFT * settle(t))[0]], + ['leg_l1', 'rotate', (t) => legIK('leg_l', SHIFT * settle(t))[1]], + ['skirt_r0', 'rotate', (t) => SKIRT * settle(t)], + ['skirt_c0', 'rotate', (t) => SKIRT * settle(t)], + ['skirt_l0', 'rotate', (t) => SKIRT * settle(t)], +]; + +const n = Math.round(seconds * fps); +const r4 = (x: number) => Math.round(x * 1e4) / 1e4 || 0; +let maxUpper = 0; +let maxShift = 0; +const tracks = curves.map(([target, property, f, group]) => { + const keys = []; + for (let i = 0; i <= n; i++) { + const t = i / fps; + const raw = f(t / k); + const v = (Array.isArray(raw) ? raw : [raw]).map(r4); + if (target === 'sleeve_r0') maxUpper = Math.max(maxUpper, Math.abs(v[0])); + if (target === 'hip') maxShift = Math.max(maxShift, Math.abs(v[0])); + keys.push({ t: Math.round(t * 1e5) / 1e5, v }); + } + return { [group ? 'group' : 'bone']: target, property, keys }; +}); + +const doc = { + spec: 'rigc-motion/1', + animations: { + hello: { + duration: seconds, + loop: true, + note: `scripts/hello.ts over ${rigPath}: hm? (head straightens, brows up), heh (eyes narrowed, mouth wide, two shoulder bobs), an elbow-down wave, a weight-shift settle; ${seconds} s at ${fps} fps`, + tracks, + }, + }, +}; +writeFileSync(outPath, JSON.stringify(doc) + '\n'); +const [r0, r1] = legIK('leg_r', SHIFT); +const [l0, l1] = legIK('leg_l', SHIFT); +const f2 = (x: number) => (x >= 0 ? '+' : '') + x.toFixed(2); +console.log( + `hello.ts: 4 beats (hm?, heh, wave, settle), ${seconds} s at ${fps} fps, ${tracks.length} tracks of ${n + 1} keys; ` + + `sleeve_r0 at most ${maxUpper.toFixed(1)} deg; hip shift ${maxShift.toFixed(1)} px ` + + `(legs r ${f2(r0)}/${f2(r1)}, l ${f2(l0)}/${f2(l1)} deg, ankles held) -> ${outPath}`, +); diff --git a/scripts/play.html b/scripts/play.html index 134b253..1201b41 100644 --- a/scripts/play.html +++ b/scripts/play.html @@ -23,6 +23,8 @@ // Plays one rigc build in the page. The build is named in the address: // http://localhost:3000/?model=&anim= // Both paths are relative to the directory serve.ts was started in. +// &under= plays that one on track 0 and over it on +// track 1, so a gesture keeps the idle's breathing where it keys other bones. import { loadModel, RigPlayer } from '/node_modules/@rig-c/play/dist/index.js'; const q = new URLSearchParams(location.search); @@ -39,8 +41,11 @@ const first = q.get('anim') || assets.animations[0]?.name; if (!first) throw new Error(`${modelUrl} holds no animation`); pick.value = first; - player.play(first); - pick.onchange = () => player.play(pick.value); + const under = q.get('under'); + const top = under && under !== first ? 1 : 0; + if (top) player.play(under); + player.play(first, { track: top }); + pick.onchange = () => player.play(pick.value, { track: top }); // Fit the rig to the stage. The root element is the rig's world origin and // the rig grows upward from it (y up), so it is placed at the bottom-left of