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 +4 -1
- package/docs/AUTHORING.md +14 -3
- package/docs/STABILITY.md +3 -2
- package/package.json +1 -1
- package/src/build.ts +9 -5
- package/src/check.ts +131 -4
- package/src/config.ts +202 -5
- package/src/motion.ts +12 -0
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.
|
|
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
|
|
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.<part>.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.<part>.auto.boundaryRuns** — required: `maxVertices`
|
|
129
129
|
- **regions.<part>** — 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.<part>** — 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.
|
|
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 =
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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
|
-
/**
|
|
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);
|