rig-parts 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.ts CHANGED
@@ -186,7 +186,10 @@ usage:
186
186
  states no motion.blink, and a blink group that names no bone, or one
187
187
  bone twice, is refused by the loader, CONFIG_BLINK_GROUP_MEMBERS or
188
188
  CONFIG_BLINK_GROUP_UNIQUE, before rigc starts, as are two tracks on one
189
- bone property, CONFIG_BONE_PROPERTY_KEYED_ONCE. --parts is the
189
+ bone property, CONFIG_BONE_PROPERTY_KEYED_ONCE. Beside the idle,
190
+ every animation of the file config.motion.animations_from names is
191
+ written into motion.json as read, for rigc's gate to judge (AUTHORING
192
+ §3). --parts is the
190
193
  directory holding parts.json and parts/<name>.png. The result is built
191
194
  through rig-c (profile spine-html, packed with --page-edges and
192
195
  --pack-shape as for check; rigc's build gates the compile and the packed pages on disk) in a
package/docs/AUTHORING.md CHANGED
@@ -90,6 +90,7 @@ read by no CPU stage).
90
90
  | `motion.tracks` | rig | **proposed**, then tuned. Single `{bone, prop, amp, period, phase, base?}` or chain `{chain, amps, period, phase, lag}` — one amplitude per link, link `i` at phase `phase + lag·i`. Every `period` must divide `duration` (`CONFIG_PERIOD_DIVIDES_DURATION`). A bone property is keyed by one track (`CONFIG_BONE_PROPERTY_KEYED_ONCE`): a chain keys `rotate` on every link, the blink's `eyes` group `scaley` and its `brows` group `translatey` on every member, so a single track on any of those is a second track on it — rigc would refuse it one stage later, `animation "idle" has two tracks on eye.scaley`. A chain link is turned along its chain (the `bones` row), so a `translatex`/`translatey` key on a bone whose parent is a turned link, or a `scale`/`shear` key on a turned link, would move or stretch along the link and not along the picture's axis: the rig stage refuses it (`RIG_KEY_FRAME_UNTURNED`); `rotate` is keyed on any bone |
91
91
  | `motion.blink` | rig | **proposed**: `{t, eyes, brows, squash, brow_drop}`; the whole blink, `t` to `t + 0.364` s (`t + 0.314` s without brows: the brows' keys are the ones that end last), must fit inside the idle (`RIG_BLINK_INSIDE_IDLE`). The `eyes` group's `scaley` squashes every part on the eye bones about the bone's origin, the eyewhite's centre. Optional: a figure with no `eyewhite-r`/`-l` part has no eye bone, so `propose` writes no `blink` and notes `no blink: no eyewhite part (looked for: eyewhite-r, eyewhite-l), …`; with eyes and no `eyebrow-r`/`-l` part it writes the blink without `brows` and `brow_drop` and notes `blink without brows: …`. `brows` and `brow_drop` are stated together or not at all (`CONFIG_BLINK_BROWS_PAIRED`), and a group that names no bone is refused (`CONFIG_BLINK_GROUP_MEMBERS`) — rigc would refuse it one stage later, `group "eyes" declares no members` — as is a group that names one bone twice (`CONFIG_BLINK_GROUP_UNIQUE`; rigc: `group "eyes" names member "eye" twice`). An `irides-*` or `eyelash-*` part on a side with no eyewhite has no eye bone to ride: `propose` places it on `head` as a region, which the blink does not move, and notes `no eyewhite part for eye_r (looked for: eyewhite-r): iris (head:irides-r) is placed on head as a region, …`. The timing is fixed, not a field (table below) |
92
92
  | `motion.blink.still` | rig | **proposed** only for a lash `propose` notes (below), then checked: `{<part>: {row, bone}}` — the rows of that region part above `row` (rig px, y down) are drawn by a second slot `<part>_still` on `bone` and do not blink; rows from `row` down keep the part's slot and blink. The part must be a region on a bone `eyes` names, `bone` one it does not (`CONFIG_STILL_OFF_THE_BLINK`), and `row` a row of the part with no art across its whole width (`RIG_STILL_ROW_CLEAR`) |
93
+ | `motion.animations_from` | rig; check (named, not measured) | optional, never proposed (issue #183): a path, relative to the config file's directory, to a file in rig-c's motion-spec shape, `{"spec": "rigc-motion/1", "animations": {"<name>": {"duration": …, "tracks": [...]}, …}}` — an expression, a gesture, anything beside the idle. On every `rig` and `build` each of its animations is written into `rig/motion.json` after `idle`, as read: nothing added, re-keyed or judged here, and each is rigc's to accept or refuse at the rig stage's gate, in rigc's words. It may name what `motion.json` already holds — the bones `bones` declares, the easings `shut` and `open`, the groups `eyes` and `brows` when the blink writes them — and nothing else from the file reaches it. The loader refuses, every problem at once: no such file or one that cannot be read (`CONFIG_FILE_PRESENT`), text that is not JSON (`CONFIG_IS_JSON`), a file that is not an object, a `spec` other than `"rigc-motion/1"`, an `animations` that is not a table of objects (`CONFIG_FIELD_TYPE`), any other key — rig-c's `easings`, `groups`, `setup`, `physics`, `mix`, `archetype` and `cut` included, which would be read and not written (`CONFIG_KEY_KNOWN`), an animation named `idle` (`CONFIG_ANIMATION_NAME_FREE`), and a key written twice in one object of the file, which JSON would fold into its last value without a word (`CONFIG_KEY_UNIQUE`). `check` measures the idle alone and prints one line naming the others (`beside the idle: …`). Absent, `motion.json` holds the idle alone, byte for byte as before |
93
94
  | `constraints` | rig (handed to rig-c as `rig.json`'s `constraints`); `compare` and the coverage lines (roles) | **authored**, optional; `propose` writes none. A list of constraints in **rig-c's own rig-spec shape** (`RigConstraint`: `ik`, `transform`, `path`, `physics`, `slider` — rig-c's `src/rig.ts` documents every field), in the order rigc applies them. The loader checks only what this package owns: each entry is an object with a `type` rigc names (`CONFIG_CONSTRAINT_TYPE_KNOWN`) and a non-empty `name`, unique within its kind as rigc finds constraints (`CONFIG_CONSTRAINT_NAME_UNIQUE`; an ik and a transform may share one); every bone it names — `bones`, an ik's `target`, a transform's `source`, a physics or slider constraint's `bone` — is a bone `bones` declares, a chain link as `<chain><i>` (`CONFIG_NAME_RESOLVES`); the bone a constraint follows does not sit under a bone it drives (`CONFIG_CONSTRAINT_TARGET_DETACHED`). Every other field (`mix`, `softness`, `properties`, a path's `slot`, a slider's `animation`) and every value is rigc's to accept or refuse at the rig stage's gate, in rigc's words (`RIG_RIGC_GREEN`) — an ik's `bones` as a shape too: from rig-c 2.15.0 rigc refuses an ik over more than two bones, or over a pair whose second bone is not the first's child (issue #103; this loader refused both itself until then). A note and an `x-` record may ride on an entry; the rig stage leaves them out of what it hands rigc. Under the default `--idle-keys ctl` a two-bone ik whose child link the idle keys is split by that link's control, and rigc refuses the pair: run `rig --idle-keys direct` or `build --idle-keys direct` (the rig stage adds that sentence, naming the flag on the command that ran, to rigc's line; `build` forwards it to its rig stage, issue #95). Each bone a constraint follows is declared to rigc as `invariants.detached` from every bone it drives, which rigc's gate rule `A25` checks |
94
95
 
95
96
  **A scene target is an ordinary bone.** A target the scene places — a point a hand
@@ -981,7 +982,7 @@ proposal in as the steps above say (`RL01`); every step must exit 0.
981
982
  | `propose` | `note:` lines, `LINT` lines, the `coverage` lines, `landmarks.png`, `basis.json` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter; the `coverage:` summary says how many bones that verdict covers — a `not checked` bone was read by no check (its line says why), and a `ratio` or `fallback` bone in `basis.json` is a guess | a link off the art (a bone on the background) — move it onto the layer; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head`; a `no face part: face box derived from …` note (always the first) — `head`, `neck` and every face-height scale come from a box guessed off the hair and neck (§3): check `head` and `neck` on `landmarks_head.png` and move them; when it adds `the blink shuts the eyes over no face part`, read `BLINK_NO_HOLE` after `check` — an eye with no face under it can open a hole (the `sample` with only its face removed: 587 px). Under `--keypoints`: the `lint rule set:` line first (joints, or screen and why), then the joint notes — `missing: the rule "…" placed bone X instead` is a bone still guessed; `not used — …` names why an arm's joints did not place its sleeve; `LINT chest at … is not between the neck joint …`, `LINT hip at … is not past chest …`, `LINT hip at … is nearer the neck joint …` and `LINT <chain><i> at … does not advance from <s>_shoulder toward <s>_wrist` are the joint-frame lines: move that bone between, past or along the joints they name |
982
983
  | `compare` | one line per pair (origin, parent, tip, length, direction — or SKIP and why), then the per-row summary, the unmapped bones of each side, `required:`, each side's roles and the `controls:` line | proposal against the config you corrected: the rows you changed and no others. Config against the `rig.json` `build` writes for it: measured on both examples, every origin within 6.19e-7 px (the rig's offsets are written to 6 places) and every chain link's tip and length within 4.99e-4 px (`length` is written to 3), printed to three places as `0.000` (a signed figure as `+0.000` or `-0.000`); `parent … is the right's ancestor at depth 2, <bone>_ctl between` for every keyed mesh bone, no `DIFFERENT` and no `NOT MAPPED` | `parent DIFFERENT: left a, right b` — a bone hangs from another parent than the other side's; `frames not related` — a rig with no stage (`skeleton.width`/`height`), two configs at different `rig_scale`: the distance rows SKIP, so declare the frame in a `--map` file if the author knows it; `FAIL STRUCTURE_REQUIRED_PRESENT` — a bone the map requires is missing through its pairs |
983
984
  | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`); in `rig.json`, each chain link's (or its `_ctl`'s) `length` and `rotation` | `cover 1.00000` on every mesh, both gate summaries `0 failed` (the compile and the packed pages); every link's `length` the distance to the next link and `rotation` its direction (Spine degrees, counter-clockwise, y up, local to the parent), every offset under it — a child bone's `x, y`, a weight's bind `x, y`, a region's `x, y` — in that turned frame, and a region on a link carrying `rotation` that turns it back upright. Nothing moved: `flattenRig` (`src/rig.ts`) turns every offset back and gives the unturned numbers | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
984
- | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:`, `seam:`, the six judgement lines, `RECOMPOSITE_HOLES` and, under `--source`, `SETUP_POSE_VS_SOURCE` (§7), `check.json`; the last line says how many of the nine bars measured and names the ones that said SKIP | `check: PASS; 9 of 9 bar(s) measured, 0 skipped` on the examples, and a judgement line SKIP only where the character lacks what it reads; on a rig spec with no `parts.json` or no `idle` (a merged rig, §7 *Measuring a rig rig-parts did not assemble*), PASS with the skipped bars named | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
985
+ | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:` (and `loop physics:` when the rig declares a physics constraint, §7), `seam:`, the six judgement lines, `RECOMPOSITE_HOLES` and, under `--source`, `SETUP_POSE_VS_SOURCE` (§7), `check.json`; the last line says how many of the nine bars measured and names the ones that said SKIP | `check: PASS; 9 of 9 bar(s) measured, 0 skipped` on the examples, and a judgement line SKIP only where the character lacks what it reads; on a rig spec with no `parts.json` or no `idle` (a merged rig, §7 *Measuring a rig rig-parts did not assemble*), PASS with the skipped bars named | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
985
986
  | `check --requirements` (and `build --requirements`) | after the lines above, one line per declared requirement, `NAME: PASS`, `FAIL` or `NOT MEASURABLE — …` with its figures, the bar as declared and the worst frame; then `requirements: N declared — M measured (P PASS, F FAIL), K NOT MEASURABLE; not declared: <kinds>`; `check.json`'s `requirements` block; the renders under `requirements/` (§7, *Declared requirements*) | every line PASS and `0 NOT MEASURABLE`; a follow's `released_copy` says whether rigc's consumer-driven door was taken on the throwaway copy | `CHECK_REQUIREMENT_MET` (a figure past its bar; the line names the frame) or `CHECK_REQUIREMENT_MEASURABLE` (the quantity is undefined in the frames: a tip or an axis on a bone of length 0, an aim with no line, a follow no frame of which reaches its least drive); the last line then reads `check: FAIL — B bar(s) not met, R declared requirement(s) not PASS` |
986
987
  | `compose` | the `[compose]` lines: one per character (its offset, its placed bounds, the shift and how many first-level bones, regions on `root` and weights bound to `root` it moved, its bone, slot, track and constraint counts, its `rig_scale` and idle), the declared order and the slot count it expands to, rigc's gate lines; then the `[check]` lines as for `check`, and `scene.json` | both gate summaries `0 failed`, `check: PASS` with the bars that measured and the ones that said SKIP named — on a composed rig `4 of 9`: the gate, the loop, `CHAIN_LAG` and `TEXTURE_STRETCH` (there is no `parts.json`; each character's own `build` measured all nine). `scene.json`'s `order.slots` is the order the compiled skeleton draws | a `SCENE_*` refusal (§6, *compose*) — the scene file, a build, or the order; `COMPOSE_RIGC_GREEN` — rigc's own line, quoted |
987
988
  | `loop` (inside `build --loop`, or `loop --frames … --out …`) | the dropped-duplicate line, each file's line, then `loop: idle.png N B (lossless); idle-indexed.png N B (max …, mean …); idle.gif N B (max …, mean …)` | `f0048.png equals f0000.png byte for byte, so it is dropped` | `LOOP_ENCODE` (§6) |
@@ -1408,7 +1409,7 @@ See-through with another seed.
1408
1409
  | `CHECK_RIGC_ENTRY`, `CHECK_RIGC_ENTRY_READS` | `rigc --version` names no entry (a rigc below 2.0.0), or names one in neither launcher form (`entry: cli.ts — @esotericsoftware/spine-core <v> present`, `entry: cli_core.ts — @esotericsoftware/spine-core absent — …`); `check.json`'s `rigc_entry` records the one that gated the build | `bun install` |
1409
1410
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
1410
1411
  | `CHECK_LOOP_LAST_FRAME_AT_DURATION` | the idle's last frame does not sit at `duration` | `motion.duration` — a whole number of 1/12 s |
1411
- | `CHECK_LOOP_CLOSES` | frame 0 and the frame at `duration` differ (max and first pixel quoted) | a track whose last key is not its first |
1412
+ | `CHECK_LOOP_CLOSES` | frame 0 and the frame at `duration` differ (max and first pixel quoted); with a physics constraint, the frames of the rig with every physics constraint left out, and the object says so (§7, *loop*) | a track whose last key is not its first |
1412
1413
  | `CHECK_SEAM_WITHIN_BAR` | the setup pose does not reproduce the flat stack of parts | usually a region or mesh placed off its part; compare with `recomposite_rig.png` |
1413
1414
  | `CHECK_BREATH_VISIBLE` | the torso (`topwear`), rendered alone, barely moves over the idle — or the feet (`footwear`), rendered alone, move at all | the chest's breath tracks (`motion.tracks` on `chest`), or the torso mesh's `segments`; for the feet, the bone their region rides (`regions.<part>`, `root` in both examples) |
1414
1415
  | `CHECK_BLINK_NO_HOLE` | with the blink held shut, the eyewhite box shows the page where the open eye had art | the layer under the eye: the `face` part has no art there. Take the face from the other run, or add a part under the eye; `motion.blink.squash` only hides the hole less |
@@ -1476,7 +1477,17 @@ assemble*, at the end of this section):
1476
1477
  holds it on every fetched example);
1477
1478
  - **seam**: the setup-pose render against the flat composite of `parts/`: mean
1478
1479
  max-channel |d| ≤ 1.0 of 255, and at most 50 pixels over 40;
1479
- - **loop**: idle frame 0 against the frame at `t = duration`: max |d| exactly 0.
1480
+ - **loop**: idle frame 0 against the frame at `t = duration`: max |d| exactly 0. The bar
1481
+ measures the keys (issue #183): a rig that declares a physics constraint — a `rig.json`
1482
+ `constraints` entry of `type` `"physics"`, or an entry of `motion.json`'s `physics`
1483
+ table — has the two frames read from the same rig and motion with every one left out
1484
+ (with a skin's `physics` list and any track keying one), built and rendered as the idle
1485
+ is, because rig-c's render resets physics at frame 0 and steps it, so the last frame
1486
+ differs from the first by what the simulation is still doing. The constraints left out
1487
+ are printed on one line of their own, `loop physics: … "<name>" on bone "<bone>"
1488
+ (rig.json|motion.json)`, and written as `check.json`'s `loop_physics` after
1489
+ `loop_max_diff`; they are reported, not judged. A rig with none is measured from the
1490
+ idle render itself, exactly as before, and writes no such line or key.
1480
1491
 
1481
1492
  On the examples: `sample` 23/23, seam 0.207 with 0 pixels over 40, loop 0;
1482
1493
  `demo` (default rule) 23/23, seam 0.326 with 2 pixels over 40, loop 0.
package/docs/STABILITY.md CHANGED
@@ -127,11 +127,12 @@ part name, `[i]` an entry of a list):
127
127
  - **meshes.&lt;part&gt;.auto.regions[i]** — required: `name`, `shape`, `maxEdgeLength`, `transition`, `grade`, `minArtSamples`, and `cx`, `cy`, `r` for a circle or `points` for a polygon; optional: `bone`, `band` (both or neither; with neither the region is density only)
128
128
  - **meshes.&lt;part&gt;.auto.boundaryRuns** — required: `maxVertices`
129
129
  - **regions.&lt;part&gt;** — a bone name
130
- - **motion** — required: `duration`, `tracks`; optional: `blink`
130
+ - **motion** — required: `duration`, `tracks`; optional: `blink`, `animations_from`
131
131
  - **motion.tracks[i]**, a single track — required: `bone`, `prop`, `amp`, `period`, `phase`; optional: `base`
132
132
  - **motion.tracks[i]**, a chain track — required: `chain`, `amps`, `period`, `phase`, `lag`
133
133
  - **motion.blink** — required: `t`, `eyes`, `squash`; optional: `brows`, `brow_drop`, `still`
134
134
  - **motion.blink.still.&lt;part&gt;** — required: `row`, `bone`
135
+ - **motion.animations_from**, the file it names — required: `spec`, `animations`
135
136
  - **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `motion` under the rules above, and `notes`
136
137
  <!-- /stable:config -->
137
138
 
@@ -152,7 +153,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
152
153
  | `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from`, the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px`, `seam_override_px`, and `fringe_pushed_back` when not empty); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite` |
153
154
  | `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts |
154
155
  | `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions`; or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`) |
155
- | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `RECOMPOSITE_HOLES`, `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
156
+ | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `RECOMPOSITE_HOLES`, `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
156
157
  | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections and `notes` |
157
158
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
158
159
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/build.ts CHANGED
@@ -70,12 +70,12 @@ import {
70
70
  splitSchedule,
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
- import { BARS, causeLines, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_MEAN_BAR, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK } from './check.ts';
74
- import { loadConfig, loadEarlyConfig } from './config.ts';
73
+ import { BARS, besideIdleLine, causeLines, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_MEAN_BAR, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK } from './check.ts';
74
+ import { loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
75
  import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
77
77
  import { encodeGif } from './gif.ts';
78
- import { CONTROL_SUFFIX } from './motion.ts';
78
+ import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
79
79
  import type { PaletteError } from './palette.ts';
80
80
  import { type LayerSet, readLayers } from './layers.ts';
81
81
  import { type PartRecord, readParts, writeParts } from './parts.ts';
@@ -637,7 +637,7 @@ function multiIntervalRun(
637
637
  * accepted it (issue #126 item 3; {@link motionGates}).
638
638
  */
639
639
  export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string, log: Log): RigOutput {
640
- const cfg = loadConfig(input.config);
640
+ const { config: cfg, animations: from } = loadConfigAndAnimations(input.config);
641
641
  const parts = readParts(join(input.parts, 'parts.json'));
642
642
  const images = new Map<string, Raster>();
643
643
  for (const p of parts.parts) {
@@ -647,7 +647,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
647
647
  const first = buildRig(cfg, parts, images, undefined, input.idleKeys ?? DEFAULT_IDLE_KEYS, input.reduce);
648
648
  const textsOf = (r: RigOutput): Array<[string, string]> => [
649
649
  ['rig.json', rigJsonText(r.rig)],
650
- ['motion.json', rigJsonText(r.motion)],
650
+ ['motion.json', rigJsonText(withAnimations(r.motion, from === null ? null : from.table))],
651
651
  ['mesh_report.json', rigJsonText(r.meshReport)],
652
652
  ];
653
653
  const firstTexts = textsOf(first);
@@ -735,6 +735,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
735
735
  log(
736
736
  ` bones ${rig.rig.bones.length} (${rig.controls.length} control) slots ${rig.rig.slots.length} meshes ${rig.meshReport.length} regions ${regions} vertices ${vertices}; idle ${rig.motion.animations.idle.duration} s, ${tracks.length} track(s), ${keys} key(s)`,
737
737
  );
738
+ if (from !== null) log(` animations beside the idle, from ${from.file} as written: ${Object.keys(from.table).join(', ') || 'none'}`);
738
739
  log(
739
740
  rig.idleKeys === 'ctl'
740
741
  ? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
@@ -875,6 +876,9 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
875
876
  const fig = r.figures;
876
877
  if (r.idle === null) log(` loop: SKIP — ${fig.skipped?.loop ?? ''}`);
877
878
  else log(` loop: idle ${r.idle.frames} frame(s) at ${r.idle.fps} fps, f0000 vs f${String(r.idle.lastIndex).padStart(4, '0')} (t = ${r.idle.duration}s): max |d| ${fig.loop_max_diff} (0 required)`);
879
+ if (r.loopPhysics !== null) log(` ${r.loopPhysics}`);
880
+ const beside = besideIdleLine(r.besideIdle);
881
+ if (beside !== null) log(` ${beside}`);
878
882
  if (r.seamViewport === null || fig.seam_mean === null) log(` seam: SKIP — ${fig.skipped?.seam ?? ''}`);
879
883
  else {
880
884
  log(
package/src/check.ts CHANGED
@@ -919,6 +919,70 @@ export function readFrameSet(path: string): FrameSet {
919
919
  };
920
920
  }
921
921
 
922
+ // ---------------------------------------------------------------------------
923
+ // the keyed loop: physics left out (issue #183)
924
+ // ---------------------------------------------------------------------------
925
+
926
+ /** A physics constraint the loop's frames leave out, as the `loop:` physics line names it. */
927
+ export interface LoopPhysics {
928
+ name: string;
929
+ bone: string;
930
+ /** Where it was declared: rig.json's `constraints`, or motion.json's `physics` table. */
931
+ in: 'rig.json' | 'motion.json';
932
+ }
933
+
934
+ /**
935
+ * The rig spec and motion spec with every physics constraint left out, and
936
+ * the constraints it left out; null when there is none, so a rig without one
937
+ * is measured from the very build and render it always was.
938
+ *
939
+ * rig-c's render resets physics at frame 0 and steps it by 1/fps (its
940
+ * `spinePoser`), so a constraint still moving at the idle's duration makes
941
+ * the last frame differ from the first by what the simulation is doing, not by
942
+ * what the keys say. `CHECK_LOOP_CLOSES` measures the keys: a rig-c
943
+ * `constraints` entry of `type` `"physics"` and every entry of motion.json's
944
+ * `physics` table are dropped, with what names them only to activate or key
945
+ * them — a skin's `physics` list, and an animation track whose target is a
946
+ * `physics` constraint. Nothing else is touched, and nothing here judges the
947
+ * constraints: the line that names them reports.
948
+ */
949
+ export function withoutPhysics(rig: Record<string, unknown>, motion: Record<string, unknown>): { rig: Record<string, unknown>; motion: Record<string, unknown>; physics: LoopPhysics[] } | null {
950
+ const physics: LoopPhysics[] = [];
951
+ const constraints = Array.isArray(rig.constraints) ? rig.constraints : null;
952
+ if (constraints !== null) {
953
+ for (const c of constraints) if (isRecord(c) && c.type === 'physics') physics.push({ name: String(c.name), bone: String(c.bone), in: 'rig.json' });
954
+ }
955
+ const table = isRecord(motion.physics) ? motion.physics : null;
956
+ if (table !== null) for (const [name, e] of Object.entries(table)) physics.push({ name, bone: isRecord(e) ? String(e.bone) : '', in: 'motion.json' });
957
+ if (physics.length === 0) return null;
958
+ const r: Record<string, unknown> = { ...rig };
959
+ if (constraints !== null) {
960
+ const kept = constraints.filter((c) => !(isRecord(c) && c.type === 'physics'));
961
+ if (kept.length > 0) r.constraints = kept;
962
+ else delete r.constraints;
963
+ }
964
+ if (isRecord(rig.skins)) {
965
+ r.skins = Object.fromEntries(Object.entries(rig.skins).map(([skin, entry]) => {
966
+ if (!isRecord(entry) || !('physics' in entry)) return [skin, entry];
967
+ return [skin, Object.fromEntries(Object.entries(entry).filter(([k]) => k !== 'physics'))];
968
+ }));
969
+ }
970
+ const m: Record<string, unknown> = { ...motion };
971
+ delete m.physics;
972
+ if (isRecord(motion.animations)) {
973
+ m.animations = Object.fromEntries(Object.entries(motion.animations).map(([name, a]) => {
974
+ if (!isRecord(a) || !Array.isArray(a.tracks)) return [name, a];
975
+ return [name, { ...a, tracks: a.tracks.filter((t) => !(isRecord(t) && 'physics' in t)) }];
976
+ }));
977
+ }
978
+ return { rig: r, motion: m, physics };
979
+ }
980
+
981
+ /** The one line naming what {@link withoutPhysics} left out of the loop's frames: reported, not judged. */
982
+ export function loopPhysicsLine(physics: readonly LoopPhysics[]): string {
983
+ return `loop physics: ${physics.length} physics constraint(s) left out of the loop's frames, reported, not judged — ${physics.map((p) => `"${p.name}" on bone "${p.bone}" (${p.in})`).join(', ')}`;
984
+ }
985
+
922
986
  // ---------------------------------------------------------------------------
923
987
  // pixel measurements
924
988
  // ---------------------------------------------------------------------------
@@ -1188,6 +1252,12 @@ export interface CheckFigures {
1188
1252
  pack_mode: { page_edges: PageEdges; pack_shape: PackShape };
1189
1253
  /** Null when the rig has no idle (issue #77); `skipped.loop` then says why. */
1190
1254
  loop_max_diff: number | null;
1255
+ /**
1256
+ * Written only when the rig declares a physics constraint (issue #183):
1257
+ * each one {@link withoutPhysics} left out of the frames `loop_max_diff` was
1258
+ * read from. Reported, not judged; a rig without one writes the keys it always wrote.
1259
+ */
1260
+ loop_physics?: LoopPhysics[];
1191
1261
  /** The three seam figures: null when there is no parts.json (issue #77); `skipped.seam` then says why. */
1192
1262
  seam_mean: number | null;
1193
1263
  seam_px_over_40: number | null;
@@ -1232,6 +1302,8 @@ export interface CheckReport {
1232
1302
  packOpaque: Array<{ page: string; share: number }>;
1233
1303
  /** Null when the rig has no idle. */
1234
1304
  idle: { frames: number; fps: number; duration: number; lastIndex: number; loopAt: { x: number; y: number } } | null;
1305
+ /** The physics line ({@link loopPhysicsLine}); null when the rig declares no physics constraint or has no idle. */
1306
+ loopPhysics: string | null;
1235
1307
  /** The setup-pose still's grid; null when nothing read the still (no parts.json and no `--source`). */
1236
1308
  seamViewport: Viewport | null;
1237
1309
  /** Every bar in {@link BARS} that measured, and every one that did not with its reason. */
@@ -1241,6 +1313,22 @@ export interface CheckReport {
1241
1313
  written: string[];
1242
1314
  /** Under `--requirements` only (issue #93): what `check.json`'s `requirements` block holds; null without the flag. */
1243
1315
  requirements: RequirementsBlock | null;
1316
+ /**
1317
+ * The animations `motion.json` holds beside the idle (issue #183), in its
1318
+ * order: built and gated with the rig, measured by nothing here — every bar
1319
+ * reads the idle. Empty when the idle is alone.
1320
+ */
1321
+ besideIdle: string[];
1322
+ }
1323
+
1324
+ /**
1325
+ * The one line `check` prints when `motion.json` holds animations beside the
1326
+ * idle (issue #183), or null when it holds none, so a rig of the idle alone
1327
+ * prints what it printed before.
1328
+ */
1329
+ export function besideIdleLine(names: readonly string[]): string | null {
1330
+ if (names.length === 0) return null;
1331
+ return `beside the idle: ${names.length} animation(s), ${names.join(', ')} — built and gated by rigc with the rig, not measured: every bar here reads the idle`;
1244
1332
  }
1245
1333
 
1246
1334
  /**
@@ -1323,7 +1411,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1323
1411
  const idleDir = join(out, 'idle_frames');
1324
1412
  const stillDir = join(out, '_still');
1325
1413
  const isoDir = join(out, '_isolated');
1326
- for (const d of [buildDir, idleDir, stillDir, isoDir]) rmSync(d, { recursive: true, force: true });
1414
+ const loopDir = join(out, '_loop');
1415
+ for (const d of [buildDir, idleDir, stillDir, isoDir, loopDir]) rmSync(d, { recursive: true, force: true });
1327
1416
  const written: string[] = [];
1328
1417
  const write = (name: string, data: string): void => {
1329
1418
  writeFileSync(join(out, name), data);
@@ -1352,6 +1441,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1352
1441
  // 3. the idle, the loop, the heat — only when there is an idle
1353
1442
  let idle: FrameSet | null = null;
1354
1443
  let loop: { max: number; x: number; y: number } | null = null;
1444
+ let loopSet: FrameSet | null = null;
1445
+ let loopPhysics: LoopPhysics[] | null = null;
1355
1446
  if (inp.idleDuration !== null) {
1356
1447
  const render = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--geometry', '--out', idleDir]);
1357
1448
  if (render.status !== 0) refuseIfAny(rigcFailed('render --animation idle --geometry', render, render.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
@@ -1372,6 +1463,39 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1372
1463
  written.push('contact.png');
1373
1464
  }
1374
1465
  loop = maxRgbDiff(idle.frames[0].image, last.image);
1466
+ loopSet = idle;
1467
+ // The keyed loop (issue #183): with a physics constraint, the same rig and motion with every one left out, built
1468
+ // the same way and rendered the same way, is what frame 0 and the frame at the duration are read from.
1469
+ const stripped = withoutPhysics(inp.rig, inp.motion);
1470
+ if (stripped !== null) {
1471
+ loopPhysics = stripped.physics;
1472
+ try {
1473
+ mkdirSync(loopDir, { recursive: true });
1474
+ const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
1475
+ writeFileSync(join(loopDir, 'rig.json'), JSON.stringify({ ...stripped.rig, images }));
1476
+ writeFileSync(join(loopDir, 'motion.json'), JSON.stringify(stripped.motion));
1477
+ const what = 'the idle with its physics constraint(s) left out';
1478
+ const lb = rigc(['build', '--rig', join(loopDir, 'rig.json'), '--motion', join(loopDir, 'motion.json'), '--out', join(loopDir, 'build'), ...packedBuildArgs(mode)]);
1479
+ if (lb.status !== 0) refuseIfAny(rigcFailed(`${packedBuildLabel(mode)} (${what})`, lb, buildGateLines(lb.out)));
1480
+ const lr = rigc(['render', '--candidate', join(loopDir, 'build'), '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--out', join(loopDir, 'frames')]);
1481
+ if (lr.status !== 0) refuseIfAny(rigcFailed(`render --animation idle (${what})`, lr, lr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
1482
+ const kept = readFrameSet(join(loopDir, 'frames'));
1483
+ const keptLast = kept.frames[kept.frames.length - 1];
1484
+ if (kept.stride !== 1 || kept.written !== kept.sampled || keptLast.index !== last.index) {
1485
+ refuseIfAny([
1486
+ {
1487
+ code: 'CHECK_LOOP_LAST_FRAME_AT_DURATION',
1488
+ object: `idle frame ${keptLast.name} (${what})`,
1489
+ detail: `rigc sampled ${kept.sampled} frame(s), wrote ${kept.written} at stride ${kept.stride}; the idle as written ends at ${last.name}, and the loop compares frame 0 with that frame`,
1490
+ },
1491
+ ]);
1492
+ }
1493
+ loop = maxRgbDiff(kept.frames[0].image, keptLast.image);
1494
+ loopSet = kept;
1495
+ } finally {
1496
+ rmSync(loopDir, { recursive: true, force: true });
1497
+ }
1498
+ }
1375
1499
  const idleImages = idle.frames.map((f) => f.image);
1376
1500
  writePng(join(out, 'motion_heat.png'), motionHeat(idleImages));
1377
1501
  written.push('motion_heat.png');
@@ -1491,6 +1615,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1491
1615
  rigc_entry: rigcEntry,
1492
1616
  pack_mode: { page_edges: mode.pageEdges, pack_shape: mode.packShape },
1493
1617
  loop_max_diff: loop === null ? null : loop.max,
1618
+ ...(loopPhysics === null ? {} : { loop_physics: loopPhysics }),
1494
1619
  seam_mean: seam === null ? null : round3(seam.mean),
1495
1620
  seam_px_over_40: seam === null ? null : seam.over40,
1496
1621
  seam_px_over_80: seam === null ? null : seam.over80,
@@ -1508,12 +1633,12 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1508
1633
  };
1509
1634
  const barProblems: Problem[] = [];
1510
1635
  if (!htmlGreen) barProblems.push(...rigcFailed(packedBuildLabel(mode), build, gateHtml));
1511
- if (loop !== null && idle !== null && loop.max !== LOOP_MAX_BAR) {
1636
+ if (loop !== null && idle !== null && loopSet !== null && loop.max !== LOOP_MAX_BAR) {
1512
1637
  const last = idle.frames[idle.frames.length - 1];
1513
1638
  barProblems.push({
1514
1639
  code: 'CHECK_LOOP_CLOSES',
1515
- object: `idle frame f0000.png vs ${last.name} (t = ${idle.duration}s)`,
1516
- detail: `max |d| ${loop.max}/255, first at pixel ${loop.x},${loop.y} of ${idle.viewport.pixelWidth}x${idle.viewport.pixelHeight}; ${LOOP_MAX_BAR} is required — the idle's last key must equal its first`,
1640
+ object: `idle frame f0000.png vs ${last.name} (t = ${idle.duration}s)${loopPhysics === null ? '' : ', its physics constraint(s) left out'}`,
1641
+ detail: `max |d| ${loop.max}/255, first at pixel ${loop.x},${loop.y} of ${loopSet.viewport.pixelWidth}x${loopSet.viewport.pixelHeight}; ${LOOP_MAX_BAR} is required — the idle's last key must equal its first`,
1517
1642
  });
1518
1643
  }
1519
1644
  if (figures.seam_mean !== null && figures.seam_px_over_40 !== null && (figures.seam_mean > SEAM_MEAN_BAR || figures.seam_px_over_40 > SEAM_PX_BAR)) {
@@ -1546,11 +1671,13 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1546
1671
  pack,
1547
1672
  packOpaque,
1548
1673
  idle: idle === null || loop === null ? null : { frames: idle.frames.length, fps: idle.fps, duration: idle.duration, lastIndex: idle.frames[idle.frames.length - 1].index, loopAt: { x: loop.x, y: loop.y } },
1674
+ loopPhysics: loopPhysics === null ? null : loopPhysicsLine(loopPhysics),
1549
1675
  seamViewport,
1550
1676
  bars,
1551
1677
  problems,
1552
1678
  written,
1553
1679
  requirements: reqBlock,
1680
+ besideIdle: isRecord(inp.motion.animations) ? Object.keys(inp.motion.animations).filter((n) => n !== 'idle') : [],
1554
1681
  };
1555
1682
  }
1556
1683
 
package/src/config.ts CHANGED
@@ -54,8 +54,22 @@
54
54
  * (`x-…`) and an annotation (`note`, `…_note`) ride on a constraint as on any
55
55
  * object the loader vouches for, and the rig stage leaves them out of what it
56
56
  * hands rigc ({@link constraintForRig}).
57
+ *
58
+ * 🔗 **`motion.animations_from` is rig-c's too, verbatim (issue #183).** An
59
+ * optional path, relative to the config file's directory, to a file in
60
+ * rig-c's motion-spec shape whose `animations` are written into `motion.json`
61
+ * beside the idle on every `rig` and `build`, untouched. The loader owns what
62
+ * the merge needs and nothing else ({@link readAnimationsFrom}): the file
63
+ * exists and parses; it is an object holding `spec` ("rigc-motion/1") and an
64
+ * `animations` table of objects, nothing else that would be dropped; no
65
+ * animation is named `idle`; and no key in it is written twice. What an
66
+ * animation holds is rigc's to accept or refuse, at the rig stage's gate. The
67
+ * file is read where the config is read from a path ({@link loadConfig});
68
+ * {@link parseConfig}, which has no path to resolve against, checks only that
69
+ * the field is a non-empty string.
57
70
  */
58
- import { existsSync, readFileSync } from 'node:fs';
71
+ import { existsSync, readFileSync, statSync } from 'node:fs';
72
+ import { dirname, resolve } from 'node:path';
59
73
  import { RIG_SKIN_CONSTRAINT_KEYS, type RigConstraint, type RigSkinConstraintKey } from 'rig-c/src/rig.ts';
60
74
  import { GRID, MAX_SIDE } from './contour.ts';
61
75
  import { type Problem, refuseIfAny } from './errors.ts';
@@ -473,6 +487,28 @@ export interface Motion {
473
487
  duration: number;
474
488
  tracks: Track[];
475
489
  blink?: Blink;
490
+ /**
491
+ * A file in rig-c's motion-spec shape whose `animations` are written beside
492
+ * the idle (issue #183), its path relative to the config file's directory.
493
+ * Absent, `motion.json` holds the idle alone.
494
+ */
495
+ animations_from?: string;
496
+ }
497
+
498
+ /** The motion-spec version a file `motion.animations_from` names must state: rig-c's, the one `motion.json` states. */
499
+ export const ANIMATIONS_FROM_SPEC = 'rigc-motion/1';
500
+
501
+ /**
502
+ * The animations `motion.animations_from` names, as read: `table` is the file's
503
+ * `animations` object itself, each animation untouched, in the order
504
+ * `JSON.parse` gives its keys (the file's order, except that a name that is an
505
+ * array index, `"0"` to `"4294967294"`, comes first, in numeric order — the
506
+ * JavaScript object's own key order, which no writer here can change).
507
+ */
508
+ export interface AnimationsFrom {
509
+ /** The file, resolved against the config's directory. */
510
+ file: string;
511
+ table: Record<string, unknown>;
476
512
  }
477
513
 
478
514
  /**
@@ -687,7 +723,16 @@ function readConfigFile(path: string): Json {
687
723
  }
688
724
 
689
725
  export function loadConfig(path: string): CharacterConfig {
690
- return parseConfig(readConfigFile(path));
726
+ return loadConfigAndAnimations(path).config;
727
+ }
728
+
729
+ /**
730
+ * The full loader with the file `motion.animations_from` names read beside it,
731
+ * relative to the config's directory: every problem in either is one refusal.
732
+ * `animations` is null when the config names no file.
733
+ */
734
+ export function loadConfigAndAnimations(path: string): { config: CharacterConfig; animations: AnimationsFrom | null } {
735
+ return parseConfigFrom(readConfigFile(path), dirname(resolve(path)));
691
736
  }
692
737
 
693
738
  // ---------------------------------------------------------------------------
@@ -830,8 +875,16 @@ export function loadEarlyConfig(path: string, door: EarlyDoor): PaintConfig | Ea
830
875
  return parseEarlyConfig(raw, 'assemble');
831
876
  }
832
877
 
833
- /** Validate a parsed config. Every problem found is thrown at once, as one `PartsError`. */
878
+ /**
879
+ * Validate a parsed config. Every problem found is thrown at once, as one
880
+ * `PartsError`. With no path to resolve against, `motion.animations_from` is
881
+ * checked as a non-empty string and its file is not read ({@link loadConfig} reads it).
882
+ */
834
883
  export function parseConfig(raw: Json): CharacterConfig {
884
+ return parseConfigFrom(raw, null).config;
885
+ }
886
+
887
+ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterConfig; animations: AnimationsFrom | null } {
835
888
  const c = new Check();
836
889
  const required = topRequired('full');
837
890
  const top = c.object('config', raw, required, TOP_KEYS.filter((k) => !required.includes(k)));
@@ -847,8 +900,151 @@ export function parseConfig(raw: Json): CharacterConfig {
847
900
  if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, parts, patches, t.meshes, t.regions);
848
901
  if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
849
902
  if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
903
+ const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
904
+ const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
850
905
  refuseIfAny(c.problems);
851
- return raw as CharacterConfig;
906
+ return { config: raw as CharacterConfig, animations };
907
+ }
908
+
909
+ function isPlainObject(v: Json): v is Record<string, Json> {
910
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
911
+ }
912
+
913
+ /**
914
+ * Read the file `motion.animations_from` names (issue #183) and vouch for what
915
+ * merging its animations beside the idle needs, every problem collected in `c`:
916
+ *
917
+ * - `CONFIG_FILE_PRESENT`: no such file, or a file that cannot be read.
918
+ * - `CONFIG_IS_JSON`: the text does not parse.
919
+ * - `CONFIG_FIELD_TYPE`: the file is not an object, its `spec` is not
920
+ * "rigc-motion/1", its `animations` is not an object, or an animation in it
921
+ * is not one. `CONFIG_KEY_KNOWN` / `CONFIG_FIELD_PRESENT`: a key besides
922
+ * `spec` and `animations` (and the annotation and record doors), or one of
923
+ * the two absent. rig-c's `easings`, `groups`, `setup`, `physics` and `mix`
924
+ * are refused rather than dropped: `motion.json`'s own are this package's,
925
+ * and a table read and not written would be a value lost without a word.
926
+ * - `CONFIG_ANIMATION_NAME_FREE`: an animation named `idle`, the one the
927
+ * config's own `motion` writes.
928
+ * - `CONFIG_KEY_UNIQUE`: a key written twice in one object of the file.
929
+ * `JSON.parse` keeps the last and drops the others silently, so the file's
930
+ * text is read for it ({@link repeatedKeys}).
931
+ *
932
+ * Returns the animations when this file added no problem, else null.
933
+ */
934
+ function readAnimationsFrom(c: Check, file: string): AnimationsFrom | null {
935
+ const field = 'config.motion.animations_from';
936
+ const shape = `a file in rig-c's motion-spec shape, {"spec": "${ANIMATIONS_FROM_SPEC}", "animations": {"<name>": {"duration": …, "tracks": [...]}}}, its path relative to the config's directory`;
937
+ if (!existsSync(file) || !statSync(file).isFile()) {
938
+ c.fail('CONFIG_FILE_PRESENT', field, `names ${file}, which is no such file; ${shape} is required`);
939
+ return null;
940
+ }
941
+ let text: string;
942
+ try {
943
+ text = readFileSync(file, 'utf8');
944
+ } catch (err) {
945
+ c.fail('CONFIG_FILE_PRESENT', field, `names ${file}, which cannot be read: ${(err as Error).message}; ${shape} is required`);
946
+ return null;
947
+ }
948
+ let raw: Json;
949
+ try {
950
+ raw = JSON.parse(text);
951
+ } catch (err) {
952
+ c.fail('CONFIG_IS_JSON', `${field} (${file})`, `does not parse as JSON: ${(err as Error).message}`);
953
+ return null;
954
+ }
955
+ const before = c.problems.length;
956
+ const at = `${field} (${file})`;
957
+ if (!isPlainObject(raw)) {
958
+ c.fail('CONFIG_FIELD_TYPE', at, `is ${show(raw)}; ${shape} is required`);
959
+ return null;
960
+ }
961
+ for (const r of repeatedKeys(text)) {
962
+ c.fail(
963
+ 'CONFIG_KEY_UNIQUE',
964
+ `${at}${r.at === '' ? '' : `.${r.at}`}`,
965
+ `names "${r.key}" ${r.count === 2 ? 'twice' : `${r.count} times`}; each key once is required — JSON keeps only the last, so ${r.at === 'animations' ? 'every animation of that name but the last' : 'every value but the last'} would be dropped without a word`,
966
+ );
967
+ }
968
+ const o = c.object(at, raw, ['spec', 'animations'], []);
969
+ if (o !== null && 'spec' in o && o.spec !== ANIMATIONS_FROM_SPEC) {
970
+ c.fail('CONFIG_FIELD_TYPE', `${at}.spec`, `is ${show(o.spec)}; "${ANIMATIONS_FROM_SPEC}" is required, the motion-spec version motion.json states`);
971
+ }
972
+ if (o !== null && 'animations' in o) {
973
+ if (!isPlainObject(o.animations)) {
974
+ c.fail('CONFIG_FIELD_TYPE', `${at}.animations`, `is ${show(o.animations)}; a table keyed by animation name is required, {"<name>": {"duration": …, "tracks": [...]}}`);
975
+ } else {
976
+ for (const [name, anim] of Object.entries(o.animations)) {
977
+ if (name === 'idle') {
978
+ c.fail('CONFIG_ANIMATION_NAME_FREE', `${at}.animations.idle`, 'is the idle, which config.motion writes; an animation beside it needs another name');
979
+ } else if (!isPlainObject(anim)) {
980
+ c.fail('CONFIG_FIELD_TYPE', `${at}.animations.${name}`, `is ${show(anim)}; an animation object in rig-c's motion-spec shape is required ({"duration": …, "tracks": [...]}, and what else rigc reads)`);
981
+ }
982
+ }
983
+ }
984
+ }
985
+ if (c.problems.length > before || o === null) return null;
986
+ return { file, table: o.animations as Record<string, unknown> };
987
+ }
988
+
989
+ /**
990
+ * Every key an object in the JSON `text` names more than once, with the
991
+ * object's dotted path (`""` for the top level, `animations.smile.tracks[0]`
992
+ * below it) and how many times — what `JSON.parse` folds into its last value
993
+ * without a word. `text` must already parse; the walk is a reader of valid
994
+ * JSON only, and keys are compared after their escapes are decoded.
995
+ */
996
+ export function repeatedKeys(text: string): Array<{ at: string; key: string; count: number }> {
997
+ const out: Array<{ at: string; key: string; count: number }> = [];
998
+ let i = 0;
999
+ const ws = (): void => {
1000
+ while (i < text.length && ' \t\n\r'.includes(text[i])) i++;
1001
+ };
1002
+ const str = (): string => {
1003
+ const start = i;
1004
+ i++;
1005
+ while (text[i] !== '"') i += text[i] === '\\' ? 2 : 1;
1006
+ i++;
1007
+ return JSON.parse(text.slice(start, i)) as string;
1008
+ };
1009
+ const value = (at: string): void => {
1010
+ ws();
1011
+ const ch = text[i];
1012
+ if (ch === '{') {
1013
+ i++;
1014
+ const seen = new Map<string, number>();
1015
+ ws();
1016
+ if (text[i] === '}') {
1017
+ i++;
1018
+ return;
1019
+ }
1020
+ for (;;) {
1021
+ ws();
1022
+ const key = str();
1023
+ seen.set(key, (seen.get(key) ?? 0) + 1);
1024
+ ws();
1025
+ i++; // the colon
1026
+ value(at === '' ? key : `${at}.${key}`);
1027
+ ws();
1028
+ if (text[i++] === '}') break;
1029
+ }
1030
+ for (const [key, count] of seen) if (count > 1) out.push({ at, key, count });
1031
+ } else if (ch === '[') {
1032
+ i++;
1033
+ ws();
1034
+ if (text[i] === ']') {
1035
+ i++;
1036
+ return;
1037
+ }
1038
+ for (let k = 0; ; k++) {
1039
+ value(`${at}[${k}]`);
1040
+ ws();
1041
+ if (text[i++] === ']') break;
1042
+ }
1043
+ } else if (ch === '"') str();
1044
+ else while (i < text.length && !',]} \t\n\r'.includes(text[i])) i++;
1045
+ };
1046
+ value('');
1047
+ return out;
852
1048
  }
853
1049
 
854
1050
  /** The four sections `propose` drafts and a config carries: what a skeleton comparison reads of either. */
@@ -1499,9 +1695,10 @@ const BLINK_GROUP_EMPTY: Readonly<Record<'eyes' | 'brows', string>> = {
1499
1695
  };
1500
1696
 
1501
1697
  function checkMotion(c: Check, v: Json, bones: Set<string>, chains: Map<string, number>, regions: Json): void {
1502
- const m = c.object('config.motion', v, ['duration', 'tracks'], ['blink']);
1698
+ const m = c.object('config.motion', v, ['duration', 'tracks'], ['blink', 'animations_from']);
1503
1699
  if (m === null) return;
1504
1700
  const p = 'config.motion';
1701
+ if ('animations_from' in m) c.string(`${p}.animations_from`, m.animations_from);
1505
1702
  // Every bone property the idle will key, and the tracks that key it, in the
1506
1703
  // order idleMotion writes them: single and chain tracks, then the eyes
1507
1704
  // group, then the brows group.
package/src/motion.ts CHANGED
@@ -130,6 +130,18 @@ export interface MotionSpec {
130
130
  animations: { idle: { duration: number; loop: true; note: string; tracks: MotionTrack[] } };
131
131
  }
132
132
 
133
+ /**
134
+ * The motion `motion.json` holds: the idle, and after it every animation the
135
+ * file `config.motion.animations_from` names, as read (issue #183) — nothing
136
+ * added, nothing re-keyed, nothing judged; each is rigc's to accept at the
137
+ * gate. With none, `motion` itself, so a config that names no file writes the
138
+ * bytes it wrote before the field existed.
139
+ */
140
+ export function withAnimations(motion: MotionSpec, extra: Readonly<Record<string, unknown>> | null): MotionSpec | Record<string, unknown> {
141
+ if (extra === null) return motion;
142
+ return { ...motion, animations: { ...motion.animations, ...extra } };
143
+ }
144
+
133
145
  /** One sine channel over an idle of `duration` seconds. */
134
146
  export function sineTrack(bone: string, property: string, amp: number, period: number, phase: number, base: number, duration: number): MotionTrack {
135
147
  const n = Math.round((duration / period) * KEYS_PER_PERIOD);