rig-parts 1.66.0 → 1.67.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/README.md +1 -1
- package/docs/AUTHORING.md +32 -13
- package/docs/MIGRATION.md +30 -1
- package/docs/STABILITY.md +50 -1
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +1 -1
- package/src/assemble.ts +6 -1
- package/src/check.ts +104 -2
- package/src/config.ts +14 -4
- package/src/motionseampose.ts +117 -0
- package/src/motionseams.ts +91 -0
- package/src/parts.ts +10 -2
- package/src/propose.ts +9 -3
package/README.md
CHANGED
|
@@ -287,7 +287,7 @@ rig-parts assemble --source painting.png --full layers/full --head layers/head -
|
|
|
287
287
|
# -> a large red hole neither run holds? add an assemble.patches entry (cut from the painting) and assemble again
|
|
288
288
|
rig-parts propose --parts work/rig --source painting.png --out work
|
|
289
289
|
# -> proposal.json, basis.json (what each bone rests on) and render/landmarks.png; per-bone coverage lines;
|
|
290
|
-
# correct it, copy bones/meshes/regions/limbs/motion/constraints/turn/proposed_rig_scale into config.json, each it wrote
|
|
290
|
+
# correct it, copy bones/meshes/regions/limbs/motion/constraints/turn/motion_seams/proposed_rig_scale into config.json, each it wrote
|
|
291
291
|
# (limbs only with --keypoints, constraints only with --cloth, turn only with --full/--head;
|
|
292
292
|
# a limbs block left out is refused next)
|
|
293
293
|
rig-parts propose --parts work/rig --source painting.png --out work --from-config config.json
|
package/docs/AUTHORING.md
CHANGED
|
@@ -140,6 +140,7 @@ read by no CPU stage).
|
|
|
140
140
|
| `scene` | assemble (the recomposite, its holes and the figure's mask); check, through `parts.json`'s `scene` block (`RECOMPOSITE_HOLES`, the seam) | **authored**, optional; `propose` writes none (issue #242). `{}` declares the painting a scene: the figure and its surroundings in one image, See-through's full run holding only the figure. With it, `assemble` reads the recomposite figures, the hole list and the `uncovered:` lines inside the figure — the rig pixels where any full-run layer's alpha is above 8 (`OPAQUE_ALPHA_ABOVE`, the cut every layer's painted pixels are read by) — each line starting `inside the figure (config.scene: …, <n> of <m> rig px)`; writes that figure as `figure_rig.png` (white on black) beside the recomposite, and a `scene` block in `parts.json`; flags error pixels on the error map inside it only (outside, the painting grey); and `check` leaves out of the seam every frame pixel whose composite sample reaches the painting's outermost row or column, saying how many (`seam_border` in `check.json`, and on the seam line). It takes no field: the figure and its threshold are derived. A key in it other than a record or an annotation is `CONFIG_KEY_KNOWN`. Absent, no byte of any output moves |
|
|
141
141
|
| `cloth` | `propose --cloth` only; no other stage reads it, so it moves no byte of any build | **authored**, optional (issue #364): one recipe per tag, `{"bottomwear": {…}, "back hair": {…}, "front hair": {…}}`, each `{chains, links, wind, gravity, split?, length_ratio?, strength?, damping?, alternate_damping?, translate?, swing?, part?, influence?}`. `influence` (issue #518) — `{across, max, shape, radius?}`: the parts this recipe meshes are drafted in the influence mode (`meshes.<part>.influence` above) at their lattice's cell, with `across`, `max` and `shape` as declared (each required — the reading behind the radius did not vary them) and `radius` as declared or, absent, drafted as the farthest vertex's distance plus 3 cells (the `influence.radius` row); without it the parts keep their lattice. `chains` — trees across the piece (for `back hair`, per side of the eye axis), a whole number, 1 or more; `links` — links from root to tip on every path, 1 or more; `split` — `{at, into}`: the first `at` links are the trunk, which forks into `into` (2 or more) branches; `at` below `links` (`CONFIG_CLOTH_SPLIT_BEFORE_TIP`); `length_ratio` — a path's last link over its first, above 0, default 0.5; `strength` — `{trunk, branch, edge}`, each above 0 and below the spring's stability bound (`CONFIG_CLOTH_SPRING_STABLE`), default 100 / 100 / 50; `damping` — in [0, 1], absent: rig-c's default; `alternate_damping` — the odd trees' damping, in [0, 1], beside `damping` and different from it (`CONFIG_CLOTH_ALTERNATE_DIFFERS`); `wind`, `gravity` — `[root, tip]`, each 0 or more, tip at or above root (`CONFIG_CLOTH_RESPONSE_GROWS_TO_TIP`); `translate` — the x and y mix of a dangling end's flutter, above 0 and at most 1; `swing` — `bottomwear` only, `{scales}`, one positive scale per tree (`CONFIG_CLOTH_SWING_SCALE_PER_TREE`); `part` — `bottomwear` only (`CONFIG_CLOTH_PART_ON_BOTTOMWEAR`), a non-empty part or cut-piece name: the `topwear` part that holds a skirt See-through returned inside it (a dress), or its skirt piece cut by `assemble.cuts`, which takes the skirt's trees (issue #402, *A skirt inside topwear* below; the part is checked by `propose`, `PROPOSE_CLOTH_PART`). The two defaults are the recipe's own, measured on its synthetic fixture (the recipe below). The recipe below says what each field drafts. The config written: `propose --cloth <config.json> --write-config <out.json>` (issue #537) writes this config's own text back with the sections the recipe drafts spliced in — `meshes.<part>` and `regions.<part>`, and `bones`, `constraints` and `motion.tracks` entry by entry by name (issue #552) — and every other byte as read; `--from <previous config>` (issue #571) takes an entry equal to the previous recipe's draft as the recipe's (*The config written*, under the recipe below) |
|
|
142
142
|
| `proposed_rig_scale` | assemble, rig, build, `propose --from-config`/`--compare` | **proposed** by `propose` (issue #354): `parts.json`'s `scale_rig_per_source`, the `assemble.rig_scale` the rig sections (`bones`, `meshes`, `limbs`, `motion`) were drafted at. Held equal to `assemble.rig_scale` when present (`CONFIG_PROPOSED_RIG_SCALE`; 0, negative or not a number: `CONFIG_FIELD_TYPE`). Absent on a hand-authored config, where no byte of any output moves |
|
|
143
|
+
| `motion_seams` | propose, assemble, check | Optional `[{part, neighbour}]`, using output part names. Each unordered pair is declared once; self-pairs, unknown names, wrong shapes and unknown fields refuse (`CONFIG_MOTION_SEAM`, `PARTS_MOTION_SEAM`, `CHECK_MOTION_SEAM_DECLARATION`). Empty adds no pair. Assemble copies the declaration into `parts.json`; check merges it with existing pairs and never regenerates a removed declaration. It uses the existing non-root WARN floor and changes no bar or exit status. See *Explicit motion seam observations* below. |
|
|
143
144
|
| `requirements` | build (read in place of `--requirements` when the flag is not given), and so its check | **authored**, optional, never proposed (issue #556): a path, relative to the config file's directory, to the `spine-parts-requirements/1` file the build's check reads (§7, *Declared requirements*) — so a rig that needs one (a `not_swinging` part, a `may_fold`) rebuilds from its config alone. `build` reads it before assemble, as it reads the flag's file, and `check.json`'s `requirements.file` records it with `path` as declared. Given beside `--requirements`, the two must name the same file once resolved, or a file of the same bytes, and the declaration is read and recorded (a build given both writes what the config alone writes); otherwise `BUILD_REQUIREMENTS_CONFLICT`, naming both. Not a non-empty string: `CONFIG_FIELD_TYPE`; no such file, where the config is read from a path: `CONFIG_FILE_PRESENT`. `check`, `compose`, `mesh-draft`, `remesh` and `retune` do not read it: their `--requirements` is the only door. Absent, nothing is read and no byte of any output moves |
|
|
144
145
|
|
|
145
146
|
A skeleton alternate may declare `anchor: {neck: "head"}` (issue #543):
|
|
@@ -1280,7 +1281,7 @@ silhouette holds besides the art, each pixel counted once: `filledHolePixels` (t
|
|
|
1280
1281
|
holes), `grownPixels` (added by the margin), `pinchFilledPixels` (added by the pinch fill) and
|
|
1281
1282
|
`grownHolePixels` (transparent pixels the grown silhouette encloses — a notch whose mouth grew
|
|
1282
1283
|
shut))
|
|
1283
|
-
and, per region, the vertices its bone reaches (`g > 0`) and holds alone (`g
|
|
1284
|
+
and, per region (`regions: [{ name, bone, reached, whole }, ...]`, `src/rig.ts`), the vertices its bone reaches (`reached`, where the region owns a nonzero contribution, `g > 0`) and holds alone (`whole`, where `g >= 1`) — static setup-weight vertex counts, not a rendered frame-to-frame or `> 0.5 px` counterfactual-motion count.
|
|
1284
1285
|
Across contour, automatic and influence reports, `regions[].bone` and `reach.nearest`
|
|
1285
1286
|
name the authored template bones used to calculate the regions and reach. Stops and turn
|
|
1286
1287
|
can rebind the emitted attachment after those calculations; these diagnostic names are
|
|
@@ -2108,8 +2109,8 @@ it runs in under a second; the build it saves runs the render and `check` as wel
|
|
|
2108
2109
|
roughly as area / spacing², including the transition band: for a circle that area
|
|
2109
2110
|
extends to `r + band`, not just `r`. The outline, background grid and sampled region
|
|
2110
2111
|
rings also contribute, so multiplying the *whole* triangle count by that ratio
|
|
2111
|
-
incorrectly scales the fixed part. Read the part's `mesh_report.json` row first, and use
|
|
2112
|
-
`mesh-stats` for the candidate rather than waiting for a rendered build.
|
|
2112
|
+
incorrectly scales the fixed part. Read the part's `mesh_report.json` row first (`regions[].{name, bone, reached, whole}` and `contour` in `rig/mesh_report.json` or `out/rig/mesh_report.json` — see *A contour mesh*, *What it reports*, above, which `mesh-stats` does not print), and use
|
|
2113
|
+
`mesh-stats` for the candidate's `{part, mode, vertices, triangles}` dry count rather than waiting for a rendered build.
|
|
2113
2114
|
|
|
2114
2115
|
A bounded calibration on the public [family office config](https://github.com/rig-c/examples/blob/d5a1768ce4a2d1e3f5726d1739cd0da4a63b0e13/family/roa/config.json)
|
|
2115
2116
|
uses both `topwear` bust regions at spacing 14. Its pinned
|
|
@@ -2252,15 +2253,15 @@ the wind — is the consumer's, and the recipe drafts no key and no stimulus.
|
|
|
2252
2253
|
```
|
|
2253
2254
|
|
|
2254
2255
|
(The selftest's fixture recipe, `fixtures/cloth.ts`: values chosen to exercise every
|
|
2255
|
-
rule, not advice. It leaves `length_ratio` and `strength` out, so the recipe's own
|
|
2256
|
+
rule, not advice — in particular, `"back hair": { "chains": 2, … }` exercises explicit multi-tree splitting and is not a recommendation to declare two trees on a one-mass side. It leaves `length_ratio` and `strength` out, so the recipe's own
|
|
2256
2257
|
defaults are what is built and measured below.)
|
|
2257
2258
|
|
|
2258
2259
|
What each rule drafts (`src/cloth.ts` holds them):
|
|
2259
2260
|
|
|
2260
2261
|
| rule | drafts | from |
|
|
2261
2262
|
| --- | --- | --- |
|
|
2262
|
-
| where | `bottomwear`: from `hip`, the root row 20 px below hip's y (the keyed chains' row), the tip row 4 px above the cloth's own bottom under each column (4 px above the part box's bottom when every column reaches the bottom band, or 4 px above where the cloth's alpha ends under that column on a floor-spread skirt whose middle hem ends above bare thighs; a column with no cloth below the root row gets no tree drafted there and emits a note saying so, issue #438). `back hair` that reaches below the shoulders: from `head`, per side of the eye axis (twin tails are a side each), the eye line to 4 px above its bottom. `front hair`: each strand that hangs below the chin (the lock rule's), from `head`, 0.22 face heights above the eye line to 5 px below the strand
|
|
2263
|
-
| trees | tree k of `chains` owns the cell k/N .. (k+1)/N of the art's width in every row, and its links sit at the cell's centre in the 24-row band there, so a piece that widens fans its trees. On `back hair`, `chains` is optional (issues #390, #445, #461): when omitted, `propose --cloth` derives the tree count on each side from the side's **visible hair masses** — connected components of the `back hair` part's alpha below the eye-line root row (after skipping any undivided crown rows that bridge continuously across the eye axis), merged when they touch within one mesh grid cell — placing `max(1, floor(w / (4 * grid)) + 1)` trees inside each mass across its root-row opaque width `w` (one tree per `4 * grid` of horizontal reach, where a tree's segment weight `1 / (d + r)^2` with `r ≈ 0.42 * grid` drops to `MIN_WEIGHT = 0.03` at `2 * grid` on each side of the trunk), rooted at that mass's own top row and with every link inside that mass's column range; `propose`'s `LINT` names the masses it found and their columns when a side's tree count or link leaves them. An explicit `chains` that exceeds what the narrower side's opaque width at the eye-line root row holds (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`) or places a root off the 15 px dilated art is refused (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`). With `split`, the trunk's tip is the fork, and branch b of `into` follows the centre of its share of the cell. Chains are named `<prefix><k>_` (trunk) and `<prefix><k><a, b, …>_` (branches): `skirt`, `hairback_r`/`hairback_l`, `lockt`. With `split.hold_tip: true` (issues #403, #392), sibling branches keep `strength.branch` across the fork, the mesh's `r` is raised to `grid`, and the rig stage blends each fork's sibling branch weight shares across four passes of 1-ring mesh-edge averaging (self weight 0.5, floor 0.005, cap 8 influences) — every share alike, trunk, branch and fixed bones, none held in place — holding the triangles where two branches' cells meet; when the recipe also declares `influence`, the drafted influence mesh carries `hold_tip: true` and `max` at least 8, and the rig stage smooths it by the same passes capped at that `max` (issue #615, the `meshes.<part>.influence` row) | `chains`, `links`, `split` |
|
|
2263
|
+
| where | `bottomwear`: from `hip`, the root row 20 px below hip's y (the keyed chains' row), the tip row 4 px above the cloth's own bottom under each column (4 px above the part box's bottom when every column reaches the bottom band, or 4 px above where the cloth's alpha ends under that column on a floor-spread skirt whose middle hem ends above bare thighs; a column with no cloth below the root row gets no tree drafted there and emits a note saying so, issue #438). `back hair` that reaches below the shoulders: from `head`, per side of the eye axis (twin tails are a side each), the eye line to 4 px above its bottom. `front hair`: each qualifying strand that hangs below the chin (the lock rule's: each connected component below `chin + 4` of height at least `0.3` face heights, `src/propose.ts`), from `head`, 0.22 face heights above the eye line to 5 px below the strand — the recipe's `chains`, `links` and `split` are applied separately to each qualifying hanging strand and do not replace the fringe's fixed three two-link columns (`bang_r`, `bang_c`, `bang_l` keep their two links and their places and take physics on both links) | the keyed chains' own rows, which these replace |
|
|
2264
|
+
| trees | tree k of `chains` (`N`) owns the cell k/N .. (k+1)/N of the art's width in every row, and its links sit at the cell's centre in the 24-row band there, so a piece that widens fans its trees. **Count unit of `chains`:** on `back hair`, an explicit `chains: N` is passed to **each emitted side of the eye axis** (`N` trees on `r` and `N` on `l` when both sides emit, not a combined total across both sides; `src/cloth.ts`, `src/propose.ts`); on `front hair`, `chains: N` is applied **separately to each qualifying hanging strand** and does not replace the fringe's fixed two-link columns (`src/propose.ts`). On `back hair`, `chains` is optional (issues #390, #445, #461): when omitted, `propose --cloth` derives the tree count on each side from the side's **visible hair masses** and root-row reach — it is **not** a universal default of 1 or 2 trees for every painting (`RECIPE_BACK_HAIR_CHAINS = 2` in `src/cloth.ts` is only the fallback when `draftTrees` is called with neither `masses` nor `chains`) — connected components of the `back hair` part's alpha below the eye-line root row (after skipping any undivided crown rows that bridge continuously across the eye axis), merged when they touch within one mesh grid cell — placing `max(1, floor(w / (4 * grid)) + 1)` trees inside each mass across its root-row opaque width `w` (one tree per `4 * grid` of horizontal reach, where a tree's segment weight `1 / (d + r)^2` with `r ≈ 0.42 * grid` drops to `MIN_WEIGHT = 0.03` at `2 * grid` on each side of the trunk), rooted at that mass's own top row and with every link inside that mass's column range; `propose`'s `LINT` names the masses it found and their columns when a side's tree count or link leaves them. An explicit `chains` that exceeds what the narrower side's opaque width at the eye-line root row holds (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`) or places a root off the 15 px dilated art is refused (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`). With `split`, the trunk's tip is the fork, and branch b of `into` follows the centre of its share of the cell. Chains are named `<prefix><k>_` (trunk) and `<prefix><k><a, b, …>_` (branches): `skirt`, `hairback_r`/`hairback_l`, `lockt`. With `split.hold_tip: true` (issues #403, #392), sibling branches keep `strength.branch` across the fork, the mesh's `r` is raised to `grid`, and the rig stage blends each fork's sibling branch weight shares across four passes of 1-ring mesh-edge averaging (self weight 0.5, floor 0.005, cap 8 influences) — every share alike, trunk, branch and fixed bones, none held in place — holding the triangles where two branches' cells meet; when the recipe also declares `influence`, the drafted influence mesh carries `hold_tip: true` and `max` at least 8, and the rig stage smooths it by the same passes capped at that `max` (issue #615, the `meshes.<part>.influence` row) | `chains`, `links`, `split` |
|
|
2264
2265
|
| lengths | link j's length is the first's times q^j, q = `length_ratio`^(1/(links − 1)), the whole spanning root row to tip row: the links shorten toward the tip | `length_ratio` (default 0.5, below) |
|
|
2265
2266
|
| stiffness | a trunk link takes `strength.trunk`, a branch link `strength.branch`, and the edge — the outermost branch on each outer side of the piece (both sides of a skirt or a strand; the outer side of each back-hair side; or, under `split.hold_tip: true` with `strength` left out, kept at `strength.branch` so a fork's sibling branches do not pull apart at the default 2:1 stiffness), or without `split` the outermost tree — `strength.edge`; a fringe column is a trunk, its outer two the edge. **Strength does not fall along a chain**: a tip reads soft because each link turns on top of every link above it, so the motion accumulates down the chain at one strength. Only the edge is declared softer | `strength` (default trunk 100, branch 100, edge 50, below) |
|
|
2266
2267
|
| response | link j of an n-link path takes `wind` and `gravity` at j/(n − 1) of the way from the root value to the tip value; a single-link path takes the tip value. Each is a scalar **multiplier** of the environment the consumer sets (spine-core's skeleton `windX, windY`, `gravityX, gravityY`; rig-c 2.52.0's live track `setEnvironment`, rig-play 1.10.0's `environment()`): no direction is drafted | `wind`, `gravity` |
|
|
@@ -2891,7 +2892,7 @@ full loader before its assemble stage writes anything.
|
|
|
2891
2892
|
no later stage can see — then
|
|
2892
2893
|
`rig-parts propose --parts work/rig --source inputs/painting.png --out work --write-config config.json` →
|
|
2893
2894
|
`work/proposal.json` and `work/render/landmarks.png` (+ `_head`). The flag saves
|
|
2894
|
-
each of `bones`, `meshes`, `regions`, `limbs`, `motion`, `constraints`, `turn` and `proposed_rig_scale` it
|
|
2895
|
+
each of `bones`, `meshes`, `regions`, `limbs`, `motion`, `constraints`, `turn`, `motion_seams` and `proposed_rig_scale` it
|
|
2895
2896
|
wrote into the config: `limbs` is written only with `--keypoints`, `constraints`
|
|
2896
2897
|
only with `--cloth` and `turn` only with `--full`/`--head` (`PROPOSAL_CONFIG_SECTIONS` in `src/propose.ts` is the list,
|
|
2897
2898
|
and the selftest's `TY18` holds this sentence to it). A `limbs` block left out
|
|
@@ -3048,7 +3049,7 @@ full loader before its assemble stage writes anything.
|
|
|
3048
3049
|
The command-order selftest runs these stages on each fetched example from a
|
|
3049
3050
|
config holding only `key`, `seethrough` and `assemble.rig_scale` (`RL01`). For
|
|
3050
3051
|
the proposal step, copy `bones`, `meshes`, `regions`, `limbs`, `motion`,
|
|
3051
|
-
`constraints`, `turn` and `proposed_rig_scale` into the config, each section the
|
|
3052
|
+
`constraints`, `turn`, `motion_seams` and `proposed_rig_scale` into the config, each section the
|
|
3052
3053
|
proposal wrote. `RL01` exercises that manual route and does not execute
|
|
3053
3054
|
`--write-config`. The save forms above write the fields owned by their mode;
|
|
3054
3055
|
a saved draft still needs validation.
|
|
@@ -3071,14 +3072,14 @@ mesh-stats: total 1816 triangles over 9 mesh(es)
|
|
|
3071
3072
|
```
|
|
3072
3073
|
|
|
3073
3074
|
(`demo` as tracked, nine lattices.) The package holds no triangle budget: a budget is the
|
|
3074
|
-
consumer's, and the command prints no line against one.
|
|
3075
|
+
consumer's, and the command prints no line against one. `mesh-stats` runs `buildRig` (`src/meshstats.ts`) and projects its `meshReport` via `meshStatsRows` to `{part, mode, vertices, triangles}` and the total (`meshStatsLines`) — one line per mesh, in parts.json's
|
|
3075
3076
|
order, the row order of `mesh_report.json`; the mode is `lattice` for a `grid` entry,
|
|
3076
|
-
`contour`, `auto` or `
|
|
3077
|
+
`contour`, `auto`, `influence` or `explicit`. It writes no `mesh_report.json` or skeleton data and runs no `rigc` or `check`. For per-region vertex counts on a contour mesh (`regions: [{ name, bone, reached, whole }, ...]`, where `reached` counts vertices with a nonzero region contribution, `g > 0`, and `whole` counts vertices where `g >= 1`; `src/rig.ts`), read the built rig's `mesh_report.json` (`rig/mesh_report.json` from `rig`, or `out/rig/mesh_report.json` from `build --out out` — see §3, *A contour mesh*, *What it reports*, and *Recipe: a soft region driven by physics*): those fields are written by `rig` / `build` and are not printed by `mesh-stats`. Neither `mesh-stats`'s dry totals nor `mesh_report.json`'s static `regions[].{reached, whole}` setup-weight counts are a rendered `> 0.5 px` counterfactual-motion count or a claim that the config passed the compiler, coverage, motion or visual `check` gates (§3, *Recipe: a soft region driven by physics*). An `auto` part's count is its reduction's result: when rigc's
|
|
3077
3078
|
motion gate refuses that result, the rig stage replays an earlier accepted step, which needs
|
|
3078
3079
|
rigc, and the line says so. A problem the rig stage would refuse — a part with no PNG, an
|
|
3079
3080
|
outline that does not close, a name `bones` does not declare — is the rig stage's own refusal,
|
|
3080
|
-
by the same name, exit 1; it takes no `--requirements`, because the rig stage's meshes read
|
|
3081
|
-
none, and no `--idle-keys`, because where the idle's keys go moves no vertex.
|
|
3081
|
+
by the same name, exit 1; it takes `--config` and `--parts` alone (no `--candidate`, `--regions`, `--report`, `--requirements`, because the rig stage's meshes read
|
|
3082
|
+
none, and no `--idle-keys`, because where the idle's keys go moves no vertex).
|
|
3082
3083
|
|
|
3083
3084
|
### For artists: your own layered file (issue #359)
|
|
3084
3085
|
|
|
@@ -3217,7 +3218,7 @@ valued destination path with two destination rules — **in place on an existing
|
|
|
3217
3218
|
| --- | --- | --- |
|
|
3218
3219
|
| `propose --head-box` | existing regular config | `seethrough.head_box` |
|
|
3219
3220
|
| `assemble --propose-plan` | existing regular config; must name `--config` itself | `assemble.plan`, `assemble.extend_below_crop` |
|
|
3220
|
-
| ordinary `propose --parts` without `--cloth` | existing regular config | each present `PROPOSAL_CONFIG_SECTIONS` value: `bones`, `meshes`, `regions`, `limbs`, `motion`, `constraints`, `turn`, `proposed_rig_scale` |
|
|
3221
|
+
| ordinary `propose --parts` without `--cloth` | existing regular config | each present `PROPOSAL_CONFIG_SECTIONS` value: `bones`, `meshes`, `regions`, `limbs`, `motion`, `constraints`, `turn`, `motion_seams`, `proposed_rig_scale` |
|
|
3221
3222
|
| ordinary `propose --parts … --only-missing` (issues #671, #679) | existing regular config | only resolvable `meshes.<part>` or `regions.<part>` for an unattached part: attached covers existing `meshes` and `regions`, source parts consumed by the config's own `limbs.<name>.segments[].from`, and `assemble.patches` (always regions), with or without `--keypoints`. Entries are checked against the config's bones and chains, including mesh and `auto` bone/region references; an unresolved entry is never written. Each skipped part gets a note naming its attachment or the unresolved reference and why. An accepted split part from `full:drape-<s>` / `full:hand-<s>` also gains its missing `assemble.plan` row (drape before the full run's `handwear-<s>` row, hand after it); added row by row and entry by entry, every existing byte as read |
|
|
3222
3223
|
| `propose --cloth <input.json>` | new file | the recipe's edits spliced into the cloth config; §3, *The config written*, gives entry ownership, hand-edit conflicts and `--from <previous.json>` |
|
|
3223
3224
|
|
|
@@ -3779,6 +3780,9 @@ See-through with another seed.
|
|
|
3779
3780
|
| <a id="CONFIG_TAG_KNOWN"></a><a id="CONFIG_PART_NAME"></a><a id="CONFIG_PART_UNIQUE"></a>`CONFIG_TAG_KNOWN`, `CONFIG_PART_NAME`, `CONFIG_PART_UNIQUE` | a plan or extend entry names neither a v3 tag nor `author:<label>` (an `author:` tag with a malformed label says what the label must be), a part name that is not a file name, or a part or layer twice — a patch named like a plan part or another patch, and a cut's `into` named like a plan part, a patch or another cut's, included | `assemble.plan`, `assemble.patches`, `assemble.cuts` |
|
|
3780
3781
|
| <a id="CONFIG_SET_FOCUS"></a>`CONFIG_SET_FOCUS` | optional `assemble.alternates[i].focus` (issue #719) must be exactly two finite painting-pixel coordinates `[x, y]`, x right/y down, inside that alternate painting (edges included); the loader checks the tuple and `assemble` checks the actual canvas before writing. Only with focus, `parts.json` carries the original point and `focus_source_size`; `sets.json` carries the point in setup-world coordinates through the accepted rig-canvas resize and `src/coords.ts`. Absent means no focus field or inferred hand position | author a point inside the painting or omit `focus`; after changing or removing it, re-run `assemble` and `rig`. Scene placement belongs to the consumer |
|
|
3781
3782
|
| <a id="CONFIG_SET_LAYERS"></a>`CONFIG_SET_LAYERS` | `assemble.alternates[i].layers` names an unknown, uncovered or hidden part, or a painting patch with no run-layer source | name a covered layer-backed output part (a limb segment name, not its consumed source); use `author:<label>` for author art |
|
|
3783
|
+
| <a id="CONFIG_MOTION_SEAM"></a>`CONFIG_MOTION_SEAM` | motion_seams has a malformed entry, an unknown output part, a self-pair, duplicate/reversed pair or unknown entry field | declare each unordered pair once with two resolved output part names |
|
|
3784
|
+
| <a id="PARTS_MOTION_SEAM"></a>`PARTS_MOTION_SEAM` | parts.json motion_seams violates the same declaration shape/name/uniqueness contract | reassemble with the declared output part names |
|
|
3785
|
+
| <a id="CHECK_MOTION_SEAM_DECLARATION"></a>`CHECK_MOTION_SEAM_DECLARATION` | a directly supplied parts record has an invalid explicit motion seam declaration | supply a valid parts.json declaration; check never infers a replacement |
|
|
3782
3786
|
| <a id="CONFIG_NAME_RESOLVES"></a>`CONFIG_NAME_RESOLVES` | a parent, segment, region, track, blink member, blink still part or bone, extend part, patch or cut `draw.before`, or cut `from` names nothing declared above it (a cut's `from` must be a plan part); or a constraint's `bones`, `target`, `source` or `bone` names a bone `bones` does not declare (the detail names the constraint and lists the bones that exist); in `meshes.<part>.auto`, a bone or region name nothing declares; or `bones[i].link_inherit` names a link outside its own chain (the detail lists that chain's expanded links) | the named reference, or the declaration it needs |
|
|
3783
3787
|
| <a id="CONFIG_BONE_UNIQUE"></a>`CONFIG_BONE_UNIQUE` | a bone or chain declared twice, or `root` declared | `bones` |
|
|
3784
3788
|
| <a id="CONFIG_BONE_INHERIT"></a>`CONFIG_BONE_INHERIT` | a single bone's `inherit`, or a chain's `link_inherit.<link>`, is a string that is not one of rig-c's five mode names as its table spells them (issue #348); the line lists all five. `"NoScale"` is refused too: the runtime would fold the first letter, and this loader folds and maps nothing | `bones[i].inherit` or `bones[i].link_inherit.<link>` — `normal`, `onlyTranslation`, `noRotationOrReflection`, `noScale` or `noScaleOrReflection` (§3, `bones`) |
|
|
@@ -4287,6 +4291,14 @@ flag) and 8 `CLOTH_SETTLES` warnings for 9 (selftest `CK515`).
|
|
|
4287
4291
|
**Steady-wind seam band** (`--wind-band`, issue #590). `check --wind-band <spec.json>` (or `build --wind-band <spec.json>`, forwarded) sweeps steady horizontal wind across a caller-declared grid and reports the contiguous `[x_lo, x_hi]` band in which each declared seam pair holds `gap <= max_gap` and `ghost <= max_ghost` across every animation in `motion.json`. Every field in `{ "spec": "rig-parts-wind-band/1", "min", "max", "step", "fps", "max_gap", "max_ghost", "pairs" }` is required — nothing falls back to `SEAM_OVER_MOTION`'s pairs, one-cell floor, or fps. At each swept step `x = min + k * step`, `check` runs `rigc render --seam <pairs> --fps <fps> --max 16 --environment` with a single-step steady schedule `{ "spec": "rigc-environment/1", "steps": [{ "t": 0, "wind": [x, 0], "gravity": [0, 1] }] }` (`seam.json` cells are in each pair's coarser drawing texels, independent of `--max`). **What it does not measure:** the sweep is steady wind (`wind: [x, 0], gravity: [0, 1]` at `t = 0`), not a time-varying gust (`x ± a` at 0.2 Hz); transient overshoot from a changing wind is not read by a steady sweep, and the band is documented and reported as the steady-wind band (`mode: "steady"`, `gravity: [0, 1]`). It is a reported line (`status: "REPORTED"`), never a bar: it prints one `WIND_BAND:` summary line plus one `WIND_BAND:` line per pair that breaks inside `[min, max]`, and writes `WIND_BAND` in `check.json` before `PASS`.
|
|
4288
4292
|
|
|
4289
4293
|
|
|
4294
|
+
**Explicit motion seam observations** (issue #750). `motion_seams` is optional in a proposal, config and `parts.json`. The pure `draftMotionSeams(config, parts)` generator reads source provenance and the final declared carrier hierarchy: a non-limb `topwear` mesh on exactly two single-bone carriers which are a named parent and child, and non-limb `handwear-r`/`handwear-l` whose carriers are strict descendants with the same nearest trunk carrier, either that parent or that child. A sleeve may branch from the parent as a sibling of the child; it binds neither trunk carrier itself, and carriers with different nearest trunks do not qualify. This establishes declared structure, not positive compiled vertex weights or setup alpha overlap; the stock seam reader observes the band. Other structures add nothing. No part spelling, motion keys, distance heuristic or ancestor in common supplies identity. Proposal copy/write-config and turn preserve this field. For an older authored config, retain its original and use the same generator on its frozen structure/source parts, adding only this declaration in a derived config with a provenance receipt; regenerating its other rig or motion sections changes the experiment. Check reads the declaration, never calls this generator, and never restores a removed pair. A duplicate with an existing limb/requirement/root pair retains each source; an independently proven body-root pair keeps its existing FAIL policy.
|
|
4295
|
+
|
|
4296
|
+
Every played non-root WARN, including existing limb pairs, additionally records the actual above-floor `worst` hit (`kind`, `index`, `px`, `cell`, `box`; maximum count, earliest frame on a tie, gap before ghost on a remaining tie) and `pose_at_worst` (`index`, actual raw `time`, `joints`). Each joint records `bone`, `parent`, `world_x_axis_degrees`, `parent_relative_setup_axis_delta_degrees_mod360`, `static_range`, `comparison` and, when needed, `reason`. Carrier paths come from the drawings actually shown at that frame and their positive weights or rigid binding, followed through the declared hierarchy. The SAME seam render supplies `--geometry`; animation, viewport, frame index and raw hit time are bound before its actual `setup.bones` and posed matrices are read. Animation frame 0 can already be posed and is never substituted for setup. Missing/mismatched geometry or matrices is a named refusal, never a zero angle or an estimate from source keys or frame/fps.
|
|
4297
|
+
|
|
4298
|
+
Parent-relative axes use the full inverse parent matrix; parent-only rotation cancels. Angles are CCW in world y-up, and the setup-relative delta is explicitly modulo 360 in [-180,180). A static range is copied unchanged from its limb record, or null when absent. `comparison: "axis-modulo-360"` describes normal, unsheared, nondegenerate axes and a range within that branch; it does not certify unwrapped winding or impose an angle bar. Special inheritance, shear/reflection, singular or degenerate axes, absent ranges and ambiguous winding report `comparison: "unavailable"` with a reason. These fields are observation evidence only. Existing `opens`, `px`, `max_px`, under-floor counts, root partitions and FAIL ownership keep their definitions.
|
|
4299
|
+
|
|
4300
|
+
Absent declaration retains existing output bytes except optional new evidence on existing WARN rows. With a declared no-chest control, compare standalone checks at identical paths: exclude only the newly selected seam line's selection, rows, derived status and reason when the baseline already has that container. The historical absent-container bar required seven keys, CLEAR and zero warnings in every animation; its measured raw exit 1 remains red because the original idle keys the chest even though the added greeting keys only the head, and optional `worlds` is valid source data. [HQ's post-verdict restatement](https://github.com/rig-c/parts/issues/750#issuecomment-6398631279) instead requires the complete source-derived `MotionSeamLine` schema, including optional reference worlds, exact metadata and generated selection, and independent raw-row audits. All added greeting rows must be clean; any WARN must have an actual keyed pair-joint witness from the original motion, compiled tracks and carrier ancestry. STABILITY's *Explicit motion seam observations* section defines this audit and lossless removal of the new container: every outside byte stays identical. Keep the historical red beside the separate re-evaluation. No input, threshold, angle gate, exit behavior or animation selector is changed. Whether an open armhole should eventually fail is an open severity decision outside this WARN contract.
|
|
4301
|
+
|
|
4290
4302
|
**Body-root seam identity** (issue #686). A limb must have exactly one segment0 part. Its declared bone must have one named immediate parent; the root slot must bind that bone (or its declared turn child). Non-limb parts with `topwear` or `bottomwear` source provenance, a slot on that parent (directly or through its verified generated turn child) and a named default-skin drawing are candidates. The body-side child must match `applyTurn`'s exact generated name, unique identity bone and parent, control and per-part transform binding; a similarly named bone alone is not proof (issue #708). The original carrier and pair slot names do not change under turn; stock setup pose `render --seam` selects all matching drawings with `banded > 0` (its existing alpha-overlap band, without dilation), so one selects one pair and two select both pairs. When eligible candidates all return valid setup banded 0, that limb has no selected root pair: `unmeasuredRoots` retains its limb, bone, parent, segment0 part, candidates and named reason, reported as SKIP, not measured PASS. Part spelling, nearest geometry and shared mesh influences select nothing. On `wind` the pairs are `topwear|upperarm_r`, `topwear|upperarm_l`, `bottomwear|thigh_r` and `bottomwear|thigh_l`; “trunk” is not a part name. Existing pairs keep their order; a reversed explicit declaration deduplicates with the root pair and retains both declarations. An ambiguous/missing named binding, no eligible candidate, or unreadable/undrawn setup data is `CHECK_MOTION_SEAM_ROOT`, naming the part and candidates. Valid zero-band metadata is carried separately as optional `roots.unmeasured`, never as a fabricated measured pair. The all-zero root block remains present and its bar is skipped; measured FAIL takes precedence over SKIP, while measured PASS plus any unmeasured root stays SKIP. No body provenance leaves legacy pair selection unchanged and supplies no body-root coverage.
|
|
4291
4303
|
|
|
4292
4304
|
Renderer metadata and exact raster reads use the promised `rig-c/render-shared` entry (rig-c >=2.69.0 <3.0.0). It registers no Spine runtime; where that runtime resolves from rig-c's own package root, the existing `rig-c/render` entry registers the official Spine side explicitly. An install without Spine keeps rig-c's own candidate chooser and gate; candidate or loader failures never select a guessed fallback.
|
|
@@ -5307,3 +5319,10 @@ missing a path, given twice, or combined with `--shard`):
|
|
|
5307
5319
|
enter `holes`; check names or indented detail lines containing `HOLE` or `SKIP` (such as
|
|
5308
5320
|
`RG82_BLINK_NO_HOLE_…` or `SO21_ZERO_SETUP_BANDS_REMAIN_NAMED_SKIPS_…`) are counted as `PASS` or
|
|
5309
5321
|
`FAIL` controls and never as `skips` or `holes` (`RT16`, `RT17`).
|
|
5322
|
+
|
|
5323
|
+
The WARN pose reader also requires the published stock coordinate header and
|
|
5324
|
+
the required source bone/parent roster. It binds the complete geometry frame
|
|
5325
|
+
cohort to the actual PNG frame set through `readFrameSet` and `boneTrackOf`,
|
|
5326
|
+
including unselected frames, before reading the worst pose. Identical static
|
|
5327
|
+
ranges on a bone deduplicate; conflicting ranges are explicitly ambiguous,
|
|
5328
|
+
never described as no declared range.
|
package/docs/MIGRATION.md
CHANGED
|
@@ -301,7 +301,7 @@ either (the examples' `expected/` and every existing control are unchanged).
|
|
|
301
301
|
|
|
302
302
|
What to do: nothing. To draft a skirt or hair that the consumer's wind and gravity move,
|
|
303
303
|
add a `cloth` block and propose with `--cloth`; copy each of `bones`, `meshes`, `regions`,
|
|
304
|
-
`limbs`, `motion`, `constraints`, `turn` and `proposed_rig_scale` the proposal wrote into the config, as before
|
|
304
|
+
`limbs`, `motion`, `constraints`, `turn`, `motion_seams` and `proposed_rig_scale` the proposal wrote into the config, as before
|
|
305
305
|
(`constraints` is the one `--cloth` adds).
|
|
306
306
|
|
|
307
307
|
## After 1.20.0: the seam over a joint's range
|
|
@@ -443,3 +443,32 @@ bands* for the source, name, width and overlap refusals. Existing configs need
|
|
|
443
443
|
no change. The parts reader gains `limb.hybrid` and `limb.band` records only
|
|
444
444
|
for this mode, and the recorded lower range is measured once. The normal
|
|
445
445
|
one-bone rule and existing range thresholds remain unchanged.
|
|
446
|
+
|
|
447
|
+
## Explicit motion seam declarations and posed WARN evidence (issue #750)
|
|
448
|
+
|
|
449
|
+
New proposals can include optional `motion_seams: [{part, neighbour}]` alongside
|
|
450
|
+
the other proposed config sections. Carry it through assemble into parts.json.
|
|
451
|
+
Each pair names two existing output parts, once regardless of direction; the
|
|
452
|
+
strict readers name malformed, self, duplicate and unresolved declarations.
|
|
453
|
+
Check observes declared pairs in every supplied animation with the existing
|
|
454
|
+
non-root WARN floor; bars and exit status are unchanged. Existing body-root
|
|
455
|
+
FAIL semantics remain in force for independently selected root pairs.
|
|
456
|
+
|
|
457
|
+
Older authored configs acquire no declaration automatically. Keep the originals
|
|
458
|
+
and use exported `draftMotionSeams(config, parts)` on the frozen authored
|
|
459
|
+
structure and source-provenance parts, preserving every other config value in
|
|
460
|
+
a derived input and recording the added declaration. Check does not run that
|
|
461
|
+
generator; removing a declaration silences its independently declared pair.
|
|
462
|
+
|
|
463
|
+
Existing played limb WARNs as well as newly declared WARNs gain same-render
|
|
464
|
+
worst-frame pose evidence. Read relative angles as setup-axis deltas modulo
|
|
465
|
+
360, alongside unchanged static ranges; unsupported comparisons carry an
|
|
466
|
+
unavailable reason. The geometry setup, actual raw frame/time and parent
|
|
467
|
+
matrices replace no source motion keys and introduce no angle threshold.
|
|
468
|
+
|
|
469
|
+
The WARN pose reader also requires the published stock coordinate header and
|
|
470
|
+
the required source bone/parent roster. It binds the complete geometry frame
|
|
471
|
+
cohort to the actual PNG frame set through `readFrameSet` and `boneTrackOf`,
|
|
472
|
+
including unselected frames, before reading the worst pose. Identical static
|
|
473
|
+
ranges on a bone deduplicate; conflicting ranges are explicitly ambiguous,
|
|
474
|
+
never described as no declared range.
|
package/docs/STABILITY.md
CHANGED
|
@@ -118,7 +118,7 @@ The fields, object by object (a bold path is where the object sits; `<part>` is
|
|
|
118
118
|
part name, `[i]` an entry of a list):
|
|
119
119
|
|
|
120
120
|
<!-- stable:config -->
|
|
121
|
-
- **config** — required by the full loader, in front of rig and build: `key`, `assemble`, `bones`, `meshes`, `regions`, `motion`; optional: `generation`, `seethrough`, `limbs`, `constraints`, `stops`, `turn`, `scene`, `cloth`, `proposed_rig_scale`, `requirements` (issue #556, additive: a path beside the config that build reads in place of its requirements flag, whose behaviour is unchanged)
|
|
121
|
+
- **config** — required by the full loader, in front of rig and build: `key`, `assemble`, `bones`, `meshes`, `regions`, `motion`; optional: `generation`, `seethrough`, `limbs`, `constraints`, `stops`, `turn`, `scene`, `cloth`, `proposed_rig_scale`, `requirements` (issue #556, additive: a path beside the config that build reads in place of its requirements flag, whose behaviour is unchanged), `motion_seams`
|
|
122
122
|
- **generation** — required: `checkpoint`, `loras`, `trigger`, `identity`, `sampler`, `costume`, `negative_extra`, `style`, `negative_pose`, `latent`, `seed`; optional: `pose`, `control`
|
|
123
123
|
- **generation.loras[i]** — required: `name`, `strength`; optional: `strength_clip`
|
|
124
124
|
- **generation.sampler** — required: `steps`, `cfg`, `sampler`, `scheduler`
|
|
@@ -381,3 +381,52 @@ carrier and measured geometry must agree; the lower rigid piece alone carries
|
|
|
381
381
|
existing `LIMB_ONE_BONE` line checks ordinary segments as before; the band
|
|
382
382
|
requires exactly its two adjacent carriers. `SEAM_OVER_RANGE` uses the stored
|
|
383
383
|
radius and existing rest-art comparison, without changing its bar.
|
|
384
|
+
|
|
385
|
+
## Explicit motion seam observations (issue #750)
|
|
386
|
+
|
|
387
|
+
`motion_seams: [{part, neighbour}]` is optional in proposal/config/parts.json.
|
|
388
|
+
Absent leaves the existing declaration path unchanged. Readers refuse unknown
|
|
389
|
+
parts, self-pairs, unordered duplicates, malformed entries and unknown entry
|
|
390
|
+
fields by name. Empty declares no additional pair. Proposal turn/copy/write and
|
|
391
|
+
assemble preserve it; check never infers a removed declaration. A declared pair
|
|
392
|
+
uses the existing non-root WARN floor and unchanged exits, unless independently
|
|
393
|
+
selected by the existing proven body-root FAIL contract.
|
|
394
|
+
|
|
395
|
+
All played non-root WARN rows may add `worst` and `pose_at_worst`, including
|
|
396
|
+
existing limb rows. Worst is the maximum above-floor raw hit, earliest frame
|
|
397
|
+
on a tie, gap before ghost on a remaining tie. Pose evidence binds the same
|
|
398
|
+
seam geometry's actual setup, frame index, animation, viewport and raw time.
|
|
399
|
+
It records carrier-path world axes, parent-relative setup-axis deltas modulo
|
|
400
|
+
360, unchanged static ranges and explicit unavailable comparison reasons;
|
|
401
|
+
frame0 and source key endpoints are not setup or measured pose substitutes.
|
|
402
|
+
No angle bar or new threshold is added. Missing/mismatched data refuses.
|
|
403
|
+
Without a declaration only optional WARN evidence changes existing check bytes.
|
|
404
|
+
For the declared no-chest control at identical paths, the exact byte projection
|
|
405
|
+
of an existing seam container excludes only its selection, rows, derived status
|
|
406
|
+
and reason. The historical optional-container bar required exactly seven keys,
|
|
407
|
+
`CLEAR` and zero warnings across every animation. Its measured raw exit 1 stays
|
|
408
|
+
red: the added greeting keys only the head, but the original idle also keys the
|
|
409
|
+
chest and reports an above-floor opening at frames 8–12. The source type also
|
|
410
|
+
permits informational `worlds`; neither finding changes the product contract.
|
|
411
|
+
|
|
412
|
+
[HQ's post-verdict restatement](https://github.com/rig-c/parts/issues/750#issuecomment-6398631279)
|
|
413
|
+
corrects that premise. When the baseline omits `SEAM_OVER_MOTION` entirely, audit
|
|
414
|
+
the new container against the complete `MotionSeamLine` source type, including
|
|
415
|
+
its required and optional fields and referenced types, rather than a hand-kept
|
|
416
|
+
key list. Independently verify fps 12, the exact source floor, compiled animation
|
|
417
|
+
roster, generated selection and every row against the raw readings. All added
|
|
418
|
+
greeting rows must be readable and clean. Any WARN must name an animation that
|
|
419
|
+
actually keys a joint on the pair's carrier ancestry, witnessed by the original
|
|
420
|
+
authored motion, compiled tracks and actual drawing carriers. The original idle
|
|
421
|
+
WARN remains reported with the unchanged floor and exit behavior. Only after
|
|
422
|
+
those audits may lossless removal of the new container establish exact equality
|
|
423
|
+
of every outside byte. An existing baseline container keeps the narrower field
|
|
424
|
+
exclusions above. The original red and the separate post-verdict re-evaluation
|
|
425
|
+
are retained together; no input, animation selector or threshold is changed.
|
|
426
|
+
|
|
427
|
+
The WARN pose reader also requires the published stock coordinate header and
|
|
428
|
+
the required source bone/parent roster. It binds the complete geometry frame
|
|
429
|
+
cohort to the actual PNG frame set through `readFrameSet` and `boneTrackOf`,
|
|
430
|
+
including unselected frames, before reading the worst pose. Identical static
|
|
431
|
+
ranges on a bone deduplicate; conflicting ranges are explicitly ambiguous,
|
|
432
|
+
never described as no declared range.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.67.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": {
|
|
@@ -85,7 +85,7 @@ under that.
|
|
|
85
85
|
gap See-through left (a skirt split into two legs), fixed in the plan or the layers,
|
|
86
86
|
not in the rig. Then `rig-parts propose --parts work/rig
|
|
87
87
|
--source <painting> --out work` → copy `bones`, `meshes`, `regions`, `limbs`,
|
|
88
|
-
`motion`, `constraints`, `turn`, `proposed_rig_scale` from `proposal.json` into the config, each it
|
|
88
|
+
`motion`, `constraints`, `turn`, `motion_seams`, `proposed_rig_scale` from `proposal.json` into the config, each it
|
|
89
89
|
wrote (`limbs` only with `--keypoints`, `constraints` only with `--cloth`, `turn`
|
|
90
90
|
only with `--full`/`--head`; a `limbs` block left out is refused at the next step). `assemble` needs the plan and none of
|
|
91
91
|
those sections; do not stub them to get past it (AUTHORING §4 says what each step
|
package/src/assemble.ts
CHANGED
|
@@ -104,6 +104,7 @@ import { type ArmSplit, type BoneEntry, type CharacterConfig, type ControlSkelet
|
|
|
104
104
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
105
105
|
import { CORE_ALPHA, figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
|
|
106
106
|
import { padToSquare, squarePad } from './inputs.ts';
|
|
107
|
+
import type { MotionSeamDeclaration } from './motionseams.ts';
|
|
107
108
|
import { type CapRecord, type LimbRecord, PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord, type SingleRunPad } from './parts.ts';
|
|
108
109
|
import { assignCrossedLimbs, assignOverlappingHands, type BonePlace, bonePlaces, capBox, coveredRadius, type CutPlacement, featherWeight, halfWidthAbout, inDisc, jointLine, type JointLine, jointOffsets, limbPolyline, limbRun, luma601, nearestLimb, nearestOnLine, offLineFigure, pastJoint, placeCut, polylineDist2, segmentOf } from './limbs.ts';
|
|
109
110
|
import {
|
|
@@ -320,6 +321,7 @@ export interface AssembleInput {
|
|
|
320
321
|
patches: Patch[];
|
|
321
322
|
/** `assemble.cuts`: plan parts' pixels taken into new parts by polygon (`cutPart`), placed per their `draw`. */
|
|
322
323
|
cuts: Cut[];
|
|
324
|
+
motion_seams?: MotionSeamDeclaration[];
|
|
323
325
|
/** Internal alternate selection: piece layer by cut output; polygon subtraction and under stay on from. */
|
|
324
326
|
cutLayers?: ReadonlyMap<string, string>;
|
|
325
327
|
/**
|
|
@@ -5105,6 +5107,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5105
5107
|
parts: {
|
|
5106
5108
|
rig_size: [W, H],
|
|
5107
5109
|
scale_rig_per_source: frame.S,
|
|
5110
|
+
...(input.motion_seams === undefined ? {} : { motion_seams: input.motion_seams.map(({ part, neighbour }) => ({ part, neighbour })) }),
|
|
5108
5111
|
parts: placed.map((p) => p.record),
|
|
5109
5112
|
ghost_px: ghost,
|
|
5110
5113
|
recomposite: recompositeRecord(figures),
|
|
@@ -5875,7 +5878,7 @@ function unplannedNotes(
|
|
|
5875
5878
|
* full-loader config, where it is optional.
|
|
5876
5879
|
*/
|
|
5877
5880
|
export function stageFields(
|
|
5878
|
-
cfg: Pick<CharacterConfig, 'seethrough' | 'assemble' | 'scene'> & { limbs?: Record<string, Limb>; bones?: BoneEntry[] },
|
|
5881
|
+
cfg: Pick<CharacterConfig, 'seethrough' | 'assemble' | 'scene' | 'motion_seams'> & { limbs?: Record<string, Limb>; bones?: BoneEntry[] },
|
|
5879
5882
|
single: { w: number; h: number } | null = null,
|
|
5880
5883
|
): {
|
|
5881
5884
|
resolution: number;
|
|
@@ -5885,6 +5888,7 @@ export function stageFields(
|
|
|
5885
5888
|
extend: Extend[];
|
|
5886
5889
|
patches: Patch[];
|
|
5887
5890
|
cuts: Cut[];
|
|
5891
|
+
motion_seams?: MotionSeamDeclaration[];
|
|
5888
5892
|
scene?: boolean;
|
|
5889
5893
|
limbs?: Record<string, Limb>;
|
|
5890
5894
|
bones?: BoneEntry[];
|
|
@@ -5915,6 +5919,7 @@ export function stageFields(
|
|
|
5915
5919
|
extend: cfg.assemble.extend_below_crop ?? [],
|
|
5916
5920
|
patches: cfg.assemble.patches ?? [],
|
|
5917
5921
|
cuts: cfg.assemble.cuts ?? [],
|
|
5922
|
+
...(cfg.motion_seams === undefined ? {} : { motion_seams: cfg.motion_seams }),
|
|
5918
5923
|
...(cfg.scene === undefined ? {} : { scene: true }),
|
|
5919
5924
|
...(cfg.limbs === undefined ? {} : { limbs: cfg.limbs, bones: cfg.bones ?? [] }),
|
|
5920
5925
|
};
|
package/src/check.ts
CHANGED
|
@@ -85,6 +85,8 @@ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep
|
|
|
85
85
|
import { measureRecomposite, type RecompositeFigures, recompositeRecord, scenePhrase, sourceInRig } from './assemble.ts';
|
|
86
86
|
import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal } from './coords.ts';
|
|
87
87
|
import { capBox, inDisc } from './limbs.ts';
|
|
88
|
+
import { motionSeamProblems } from './motionseams.ts';
|
|
89
|
+
import { seamPoseFrame, seamStaticRange, type SeamPoseAtWorst } from './motionseampose.ts';
|
|
88
90
|
import { coreCompileGuideLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
89
91
|
import { framesInside, IDLE_FPS, TIME_SLACK } from './motion.ts';
|
|
90
92
|
import {
|
|
@@ -4305,6 +4307,8 @@ export interface MotionSeamRow {
|
|
|
4305
4307
|
worst?: MotionSeamHit & { kind: 'gap' | 'ghost' | 'gapOpen' | 'underGhost' | 'gapOpen+underGhost' };
|
|
4306
4308
|
/** Exact stock partitions and the resolved UNDER owner, retained even below the floor. */
|
|
4307
4309
|
cells?: MotionRootCells[];
|
|
4310
|
+
/** Same-render posed evidence on every played non-root WARN. */
|
|
4311
|
+
pose_at_worst?: SeamPoseAtWorst;
|
|
4308
4312
|
}
|
|
4309
4313
|
|
|
4310
4314
|
/** Existing pairs warn; new body roots alone can FAIL. Unavailable readings are named SKIP, never measured zero. */
|
|
@@ -4353,6 +4357,10 @@ export function motionSeamSelection(parts: PartsFile, seams: ReadonlyArray<{ nam
|
|
|
4353
4357
|
}
|
|
4354
4358
|
}
|
|
4355
4359
|
for (const r of seams) add(r.part, r.neighbour, `requirement "${r.name}" (seam)`);
|
|
4360
|
+
if (parts.motion_seams !== undefined) {
|
|
4361
|
+
refuseIfAny(motionSeamProblems(parts.motion_seams, new Set(parts.parts.map(p => p.name)), 'parts.json motion_seams', 'CHECK_MOTION_SEAM_DECLARATION'));
|
|
4362
|
+
parts.motion_seams.forEach((r, i) => add(r.part, r.neighbour, `motion_seams[${i}]`));
|
|
4363
|
+
}
|
|
4356
4364
|
const candidates = rig === undefined ? [] : motionRootCandidates(parts, rig);
|
|
4357
4365
|
const selected: MotionSeamPair[] = [];
|
|
4358
4366
|
if (candidates.length > 0) {
|
|
@@ -4750,6 +4758,7 @@ function readSeamRows(dir: string, animations: readonly string[], fps: number, p
|
|
|
4750
4758
|
}
|
|
4751
4759
|
const over = new Map<number, number>();
|
|
4752
4760
|
let rootWorst: MotionSeamRow['worst'];
|
|
4761
|
+
let warnWorst: MotionSeamRow['worst'];
|
|
4753
4762
|
const rootFrames = new Set<number>();
|
|
4754
4763
|
const cells: MotionRootCells[] = [];
|
|
4755
4764
|
const kinds: Record<'gap' | 'ghost', MotionSeamWorldKind> = { gap: { max_px: 0, worst: null }, ghost: { max_px: 0, worst: null } };
|
|
@@ -4767,6 +4776,7 @@ function readSeamRows(dir: string, animations: readonly string[], fps: number, p
|
|
|
4767
4776
|
// Existing internal pairs retain their original maximum-kind warning and floor.
|
|
4768
4777
|
for (const h of hits) if (want.root === undefined && (plant.noFloor === true || openingOverFloor(h))) {
|
|
4769
4778
|
over.set(h.index, Math.max(over.get(h.index) ?? 0, h.px));
|
|
4779
|
+
if (warnWorst === undefined || h.px > warnWorst.px || (h.px === warnWorst.px && h.index < warnWorst.index)) warnWorst = { ...h, kind };
|
|
4770
4780
|
}
|
|
4771
4781
|
}
|
|
4772
4782
|
if (want.root !== undefined && !setup) {
|
|
@@ -4842,6 +4852,7 @@ function readSeamRows(dir: string, animations: readonly string[], fps: number, p
|
|
|
4842
4852
|
max_px: opens.reduce((m, f) => Math.max(m, over.get(f) ?? 0), 0),
|
|
4843
4853
|
under_floor: (want.root === undefined ? p.opens : [...rootFrames].filter(f => cells.some(c => c.index === f && c.counted > 0))).filter((f) => !over.has(f)),
|
|
4844
4854
|
...(rootWorst === undefined ? {} : { worst: rootWorst }),
|
|
4855
|
+
...(warnWorst === undefined || setup ? {} : { worst: warnWorst }),
|
|
4845
4856
|
...(want.root === undefined ? {} : { cells }),
|
|
4846
4857
|
});
|
|
4847
4858
|
});
|
|
@@ -4898,9 +4909,99 @@ export function motionSeamLine(pairs: MotionSeamPair[], animations: string[], fp
|
|
|
4898
4909
|
};
|
|
4899
4910
|
}
|
|
4900
4911
|
|
|
4912
|
+
/** Enrich active non-root WARNs only, using the SAME seam render and raw hit time. */
|
|
4913
|
+
export function enrichMotionSeamPoses(dir: string, rig: Record<string, unknown>, parts: PartsFile | null, pairs: readonly MotionSeamPair[], rows: MotionSeamRow[]): void {
|
|
4914
|
+
const wanted = rows.filter(row => row.opens.length > 0 && pairs.some(pair => pair.pair === row.pair && pair.root === undefined));
|
|
4915
|
+
if (wanted.length === 0) return;
|
|
4916
|
+
const problems: Problem[] = [];
|
|
4917
|
+
const sidecar = readJson(join(dir, 'frames.json'), problems);
|
|
4918
|
+
refuseIfAny(problems);
|
|
4919
|
+
const sets = Array.isArray(sidecar?.sets) ? sidecar.sets.filter(isRecord) : [];
|
|
4920
|
+
// Slots/weights already bind drawn stop links; resolve their ancestry in the same graph rig-c compiles.
|
|
4921
|
+
const expanded = Array.isArray(rig.stops) ? (expandStops(rig as unknown as RigcRigSpec, 'rig.json') as unknown as Record<string, unknown>) : rig;
|
|
4922
|
+
const bones = (Array.isArray(expanded.bones) ? expanded.bones : []).filter(isRecord);
|
|
4923
|
+
const skin = isRecord(rig.skins) && isRecord(rig.skins.default) ? rig.skins.default : {};
|
|
4924
|
+
const cache = new Map<string, { geometry: Record<string, unknown>; seam: Record<string, unknown>; cohort: FrameSet; parsed: IdleGeometry }>();
|
|
4925
|
+
for (const row of wanted) {
|
|
4926
|
+
const h = row.worst;
|
|
4927
|
+
if (h === undefined) geometryProblem(row.pair, 'above-floor WARN has no worst raw hit');
|
|
4928
|
+
const matches = sets.filter(set => set.animation === row.animation);
|
|
4929
|
+
const set = matches[0];
|
|
4930
|
+
if (matches.length !== 1 || typeof set.dir !== 'string' || isAbsolute(set.dir) || set.dir.split(/[\\/]/).includes('..')) geometryProblem(row.animation, 'one local same-animation frame set required for WARN pose');
|
|
4931
|
+
let data = cache.get(row.animation);
|
|
4932
|
+
if (data === undefined) {
|
|
4933
|
+
const geometry = readJson(join(dir, String(set.dir), GEOMETRY_FILE), problems);
|
|
4934
|
+
const seam = readJson(join(dir, String(set.dir), SEAM_READING_FILE), problems);
|
|
4935
|
+
refuseIfAny(problems);
|
|
4936
|
+
if (geometry === null || seam === null || !Array.isArray(geometry.frames) || geometry.frames.length !== set.written) geometryProblem(row.animation, 'stock seam geometry frame count differs from frames.json');
|
|
4937
|
+
const cohort = readFrameSet(join(dir, String(set.dir)));
|
|
4938
|
+
const parsed = readGeometry(join(dir, String(set.dir), GEOMETRY_FILE), cohort.written);
|
|
4939
|
+
if (parsed === null) geometryProblem(row.animation, 'same-render geometry is missing');
|
|
4940
|
+
data = { geometry, seam, cohort, parsed };
|
|
4941
|
+
cache.set(row.animation, data);
|
|
4942
|
+
}
|
|
4943
|
+
const rawPairs = Array.isArray(data.seam.pairs) ? data.seam.pairs.filter(isRecord) : [];
|
|
4944
|
+
const pair = pairs.find(p => p.pair === row.pair);
|
|
4945
|
+
const raw = rawPairs.find(p => p.a === pair?.a && p.b === pair?.b);
|
|
4946
|
+
const kind = raw?.[h.kind];
|
|
4947
|
+
const hits = isRecord(kind) && Array.isArray(kind.frames) ? kind.frames.filter(isRecord) : [];
|
|
4948
|
+
const hitsAt = hits.filter(hit => hit.index === h.index && hit.px === h.px && hit.cell === h.cell && JSON.stringify(hit.box) === JSON.stringify(h.box));
|
|
4949
|
+
const time = hitsAt[0]?.time;
|
|
4950
|
+
if (hitsAt.length !== 1 || typeof time !== 'number' || !Number.isFinite(time)) geometryProblem(row.pair, 'one matching worst raw hit with actual finite time required');
|
|
4951
|
+
const rawFrames = (data.geometry.frames as unknown[]).filter(isRecord);
|
|
4952
|
+
const frame = rawFrames.find(f => f.index === h.index);
|
|
4953
|
+
const shown = Array.isArray(frame?.attachments) ? frame.attachments.filter(isRecord) : [];
|
|
4954
|
+
const carriers = new Set<string>();
|
|
4955
|
+
for (const name of [pair?.a, pair?.b]) {
|
|
4956
|
+
const active = shown.filter(a => a.slot === name);
|
|
4957
|
+
const onValue = typeof name === 'string' ? skin[name] : undefined;
|
|
4958
|
+
const on = isRecord(onValue) ? onValue : {};
|
|
4959
|
+
const attachment = active.length === 1 && typeof active[0].attachment === 'string' ? on[active[0].attachment] : undefined;
|
|
4960
|
+
if (!isRecord(attachment)) geometryProblem(row.pair, `slot "${String(name)}": active default-skin drawing unavailable at worst frame`);
|
|
4961
|
+
if (Array.isArray(attachment.weights)) {
|
|
4962
|
+
const weights = attachment.weights.flatMap(v => Array.isArray(v) ? v.filter(isRecord) : []);
|
|
4963
|
+
for (const w of weights) if (typeof w.bone === 'string' && typeof w.weight === 'number' && w.weight > 0) carriers.add(w.bone);
|
|
4964
|
+
} else {
|
|
4965
|
+
const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find(sl => sl.name === name);
|
|
4966
|
+
if (typeof slot?.bone !== 'string') geometryProblem(row.pair, `slot "${String(name)}": no declared bone`);
|
|
4967
|
+
carriers.add(slot.bone);
|
|
4968
|
+
}
|
|
4969
|
+
}
|
|
4970
|
+
if (carriers.size === 0) geometryProblem(row.pair, 'no positive-weight or rigid carrier at worst frame');
|
|
4971
|
+
const ancestry = new Set<string>();
|
|
4972
|
+
for (const carrier of carriers) {
|
|
4973
|
+
const seen = new Set<string>();
|
|
4974
|
+
let next: string | undefined = carrier;
|
|
4975
|
+
while (next !== undefined) {
|
|
4976
|
+
if (seen.has(next)) geometryProblem(row.pair, `cycle in carrier path at "${next}"`);
|
|
4977
|
+
seen.add(next);
|
|
4978
|
+
ancestry.add(next);
|
|
4979
|
+
const named = bones.filter(b => b.name === next);
|
|
4980
|
+
if (named.length !== 1) geometryProblem(row.pair, `carrier "${next}": one declared bone required`);
|
|
4981
|
+
next = typeof named[0].parent === 'string' ? named[0].parent : undefined;
|
|
4982
|
+
}
|
|
4983
|
+
}
|
|
4984
|
+
const joints = bones.filter(b => typeof b.name === 'string' && ancestry.has(b.name)).map(b => {
|
|
4985
|
+
const ranges = parts?.parts.flatMap(p => {
|
|
4986
|
+
const limb = p.limb;
|
|
4987
|
+
return limb !== undefined && limb.bone === b.name && limb.range !== undefined ? [limb.range] : [];
|
|
4988
|
+
}) ?? [];
|
|
4989
|
+
return { bone: String(b.name), parent: typeof b.parent === 'string' ? b.parent : null,
|
|
4990
|
+
inherit: typeof b.inherit === 'string' ? b.inherit : 'normal', ...seamStaticRange(ranges) };
|
|
4991
|
+
});
|
|
4992
|
+
for (const joint of joints) {
|
|
4993
|
+
const track = boneTrackOf(data.parsed, join(dir, String(set.dir), GEOMETRY_FILE), data.cohort, joint.bone);
|
|
4994
|
+
if (typeof track === 'string') geometryProblem(row.pair, track);
|
|
4995
|
+
}
|
|
4996
|
+
row.pose_at_worst = seamPoseFrame(data.geometry, row.animation, h.index, time, data.cohort, joints);
|
|
4997
|
+
}
|
|
4998
|
+
}
|
|
4999
|
+
|
|
4901
5000
|
/** One warning as the console prints it (issue #493's form, the floor's clause after it). */
|
|
4902
5001
|
export function motionSeamWarnText(r: MotionSeamRow): string {
|
|
4903
|
-
|
|
5002
|
+
const pose = r.pose_at_worst;
|
|
5003
|
+
const evidence = pose === undefined ? '' : `; worst frame ${pose.index} (t = ${pose.time}s), ${pose.joints.map(j => `bone "${j.bone}" parent ${JSON.stringify(j.parent)} world ${j.world_x_axis_degrees === null ? 'unavailable' : round3(j.world_x_axis_degrees) + 'deg'}; relative setup axis ${j.parent_relative_setup_axis_delta_degrees_mod360 === null ? 'unavailable' : round3(j.parent_relative_setup_axis_delta_degrees_mod360) + 'deg modulo360'}, static range ${JSON.stringify(j.static_range)} (${j.comparison}${j.reason === undefined ? '' : ': ' + j.reason})`).join(' | ')}`;
|
|
5004
|
+
return `WARN seam "${r.pair}" opens on ${r.opens.length} frame(s) ${frameRuns(r.opens)} max ${r.max_px} px in "${r.animation}" — over the one-cell floor${r.under_floor.length === 0 ? '' : `, ${r.under_floor.length} more frame(s) one cell thick not reported`}${evidence}`;
|
|
4904
5005
|
}
|
|
4905
5006
|
|
|
4906
5007
|
// ---------------------------------------------------------------------------
|
|
@@ -6136,10 +6237,11 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
6136
6237
|
const activeDest = stripped === null ? seamMotionDir : join(seamMotionDir, env === null ? 'default' : 'active');
|
|
6137
6238
|
const renderedWorlds = new Set<'default' | 'still'>();
|
|
6138
6239
|
const hasRoots = read.some(p => p.root !== undefined);
|
|
6139
|
-
const r = measured(['render', '--candidate', buildDir, '--seam', list, '--fps', String(fps), '--max', String(FOLD_RENDER_MAX),
|
|
6240
|
+
const r = measured(['render', '--candidate', buildDir, '--seam', list, '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--geometry', '--out', activeDest]);
|
|
6140
6241
|
if (r.status !== 0) refuseIfAny(rigcFailed(`render --seam ${list} --fps ${fps}`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
6141
6242
|
const activeWorldRows: MotionSeamWorldRow[] = [];
|
|
6142
6243
|
motionSeam = motionSeamLine(pairs, animationNames, fps, readMotionSeams(activeDest, animationNames, fps, read, {}, stripped !== null && env === null ? activeWorldRows : undefined, hasRoots ? motionRootDrawOrderReader(activeDest) : undefined), undefined, motionRootProblems, unmeasuredRoots);
|
|
6244
|
+
enrichMotionSeamPoses(activeDest, inp.rig, inp.parts, pairs, motionSeam.rows);
|
|
6143
6245
|
if (stripped !== null) {
|
|
6144
6246
|
const response = environmentResponse(readFileSync(join(buildDir, 'skeleton.json'), 'utf8'));
|
|
6145
6247
|
motionSeam.worlds = motionSeamWorlds(true, response.wind.length > 0, world => {
|
package/src/config.ts
CHANGED
|
@@ -88,6 +88,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
|
88
88
|
import { dirname, resolve } from 'node:path';
|
|
89
89
|
import { RIG_BONE_INHERIT, RIG_SKIN_CONSTRAINT_KEYS, type RigBoneInherit, type RigConstraint, type RigSkinConstraintKey, type RigStop } from 'rig-c/src/rig.ts';
|
|
90
90
|
import { pyRound } from './round.ts';
|
|
91
|
+
import { motionSeamProblems, type MotionSeamDeclaration } from './motionseams.ts';
|
|
91
92
|
import { REQUIREMENTS_SPEC } from './requirements.ts';
|
|
92
93
|
import { CLOTH_TARGETS, type ClothRecipes, RECIPE_STRENGTH, RIGC_DEFAULT_DAMPING, springStable } from './cloth.ts';
|
|
93
94
|
import { GRID, MAX_SIDE } from './contour.ts';
|
|
@@ -1069,6 +1070,8 @@ export interface CharacterConfig {
|
|
|
1069
1070
|
* is read and no byte of any output moves.
|
|
1070
1071
|
*/
|
|
1071
1072
|
requirements?: string;
|
|
1073
|
+
/** Explicit motion observations, propagated to parts.json; absent means no added pair. */
|
|
1074
|
+
motion_seams?: MotionSeamDeclaration[];
|
|
1072
1075
|
}
|
|
1073
1076
|
|
|
1074
1077
|
/** `config.scene` (issue #242): the declaration that the painting is a scene. It holds no field today. */
|
|
@@ -1389,11 +1392,12 @@ export interface AssembleConfig {
|
|
|
1389
1392
|
/** Issue #349: read with `bones` (the joints are the bones' origins) when present. */
|
|
1390
1393
|
limbs?: Record<string, Limb>;
|
|
1391
1394
|
bones?: BoneEntry[];
|
|
1395
|
+
motion_seams?: MotionSeamDeclaration[];
|
|
1392
1396
|
/** Issue #354: the `rig_scale` `propose` drafted the rig sections at, held equal to `assemble.rig_scale` when present. */
|
|
1393
1397
|
proposed_rig_scale?: number;
|
|
1394
1398
|
}
|
|
1395
1399
|
|
|
1396
|
-
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'stops', 'turn', 'scene', 'cloth', 'proposed_rig_scale', 'requirements'] as const;
|
|
1400
|
+
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'stops', 'turn', 'scene', 'cloth', 'proposed_rig_scale', 'requirements', 'motion_seams'] as const;
|
|
1397
1401
|
|
|
1398
1402
|
/**
|
|
1399
1403
|
* The one partial entry point. It validates exactly what its door requires —
|
|
@@ -1464,6 +1468,10 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1464
1468
|
if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
|
|
1465
1469
|
checkAlternates(c, t.assemble.alternates, made, limbs, names?.bones ?? null, names?.chains ?? null, t.regions, t.meshes);
|
|
1466
1470
|
}
|
|
1471
|
+
if ('motion_seams' in t) {
|
|
1472
|
+
const output = new Set([...made.parts.filter(n => !limbs?.consumed.has(n)), ...made.patches, ...made.cuts, ...(limbs?.parts.keys() ?? [])]);
|
|
1473
|
+
c.problems.push(...motionSeamProblems(t.motion_seams, output, 'config.motion_seams', 'CONFIG_MOTION_SEAM'));
|
|
1474
|
+
}
|
|
1467
1475
|
}
|
|
1468
1476
|
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
1469
1477
|
const pose = base === null ? null : poseBeside(c, t.generation, base);
|
|
@@ -1660,6 +1668,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1660
1668
|
if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
|
|
1661
1669
|
if ('stops' in t) checkStops(c, t.stops, names.bones, names.chains);
|
|
1662
1670
|
const attached = attachedParts(t.meshes, t.regions);
|
|
1671
|
+
if ('motion_seams' in t) c.problems.push(...motionSeamProblems(t.motion_seams, attached, 'config.motion_seams', 'CONFIG_MOTION_SEAM'));
|
|
1663
1672
|
const hasAltFace = hasAlternateFaceSet(t.assemble, [...parts, ...cuts]);
|
|
1664
1673
|
if ('turn' in t) checkTurn(c, t.turn, attached, names.bones, hasAltFace);
|
|
1665
1674
|
if ('scene' in t) checkScene(c, t.scene);
|
|
@@ -1890,9 +1899,9 @@ export interface SkeletonSections {
|
|
|
1890
1899
|
* both before writing (`checkProposal`). A key the loader would not take is
|
|
1891
1900
|
* refused by name, as in a config. Refusals name the fields `proposal.…`.
|
|
1892
1901
|
*/
|
|
1893
|
-
export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[]; proposed_rig_scale?: number } {
|
|
1902
|
+
export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[]; proposed_rig_scale?: number; motion_seams?: MotionSeamDeclaration[] } {
|
|
1894
1903
|
const c = new Check();
|
|
1895
|
-
const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints', 'stops', 'proposed_rig_scale']);
|
|
1904
|
+
const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints', 'stops', 'proposed_rig_scale', 'motion_seams']);
|
|
1896
1905
|
if (top !== null) {
|
|
1897
1906
|
const names = 'bones' in top ? checkBones(c, top.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
|
|
1898
1907
|
if ('limbs' in top) {
|
|
@@ -1903,6 +1912,7 @@ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: R
|
|
|
1903
1912
|
}
|
|
1904
1913
|
if ('meshes' in top) checkMeshes(c, top.meshes, names.bones, names.chains, top.stops);
|
|
1905
1914
|
if ('regions' in top) checkRegions(c, top.regions, names.bones);
|
|
1915
|
+
if ('motion_seams' in top) c.problems.push(...motionSeamProblems(top.motion_seams, attachedParts(top.meshes, top.regions), 'config.motion_seams', 'CONFIG_MOTION_SEAM'));
|
|
1906
1916
|
if ('motion' in top) checkMotion(c, top.motion, names.bones, names.chains, 'regions' in top ? top.regions : undefined);
|
|
1907
1917
|
if ('constraints' in top) checkConstraints(c, top.constraints, names.bones, names.parents);
|
|
1908
1918
|
if ('stops' in top) checkStops(c, top.stops, names.bones, names.chains);
|
|
@@ -1910,7 +1920,7 @@ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: R
|
|
|
1910
1920
|
if ('notes' in top && c.array('config.notes', top.notes, false)) (top.notes as Json[]).forEach((n, i) => c.string(`config.notes[${i}]`, n, false));
|
|
1911
1921
|
}
|
|
1912
1922
|
refuseIfAny(c.problems.map((p) => ({ ...p, object: p.object.replace(/^config\b/, 'proposal') })));
|
|
1913
|
-
return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[]; proposed_rig_scale?: number };
|
|
1923
|
+
return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[]; proposed_rig_scale?: number; motion_seams?: MotionSeamDeclaration[] };
|
|
1914
1924
|
}
|
|
1915
1925
|
|
|
1916
1926
|
/**
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Posed-axis evidence from one stock seam render, never sampled source keys. */
|
|
2
|
+
import type { GeometryFile, GeometryFrame } from 'rig-c/render';
|
|
3
|
+
import type { FrameSet } from './check.ts';
|
|
4
|
+
import { PartsError } from './errors.ts';
|
|
5
|
+
|
|
6
|
+
// Stock rig-c 2.69 geometry header, also exported by rig-c/render. render-shared
|
|
7
|
+
// does not expose its constants; avoid loading the optional Spine side for a header read.
|
|
8
|
+
export const SEAM_GEOMETRY_COORDINATES = 'spine world, y up, world units';
|
|
9
|
+
const SEAM_GEOMETRY_SPEC: GeometryFile['spec'] = 'rigc-geometry/1';
|
|
10
|
+
|
|
11
|
+
export interface SeamMatrix {
|
|
12
|
+
a: number; b: number; c: number; d: number;
|
|
13
|
+
worldX: number; worldY: number;
|
|
14
|
+
}
|
|
15
|
+
export interface SeamJointPose {
|
|
16
|
+
bone: string;
|
|
17
|
+
parent: string | null;
|
|
18
|
+
world_x_axis_degrees: number | null;
|
|
19
|
+
parent_relative_setup_axis_delta_degrees_mod360: number | null;
|
|
20
|
+
static_range: [number, number] | null;
|
|
21
|
+
comparison: 'axis-modulo-360' | 'unavailable';
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface SeamPoseAtWorst {
|
|
25
|
+
index: number;
|
|
26
|
+
time: number;
|
|
27
|
+
joints: SeamJointPose[];
|
|
28
|
+
}
|
|
29
|
+
const record = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
30
|
+
const fail = (detail: string): never => { throw new PartsError([{ code: 'CHECK_GEOMETRY_FILE', object: 'motion seam pose', detail }]); };
|
|
31
|
+
const wrap = (degrees: number): number => ((degrees + 180) % 360 + 360) % 360 - 180;
|
|
32
|
+
const angle = (a: number, c: number): number => Math.atan2(c, a) * 180 / Math.PI;
|
|
33
|
+
const inverseProduct = (p: SeamMatrix, c: SeamMatrix): SeamMatrix | null => {
|
|
34
|
+
const det = p.a * p.d - p.b * p.c;
|
|
35
|
+
if (det === 0 || !Number.isFinite(det)) return null;
|
|
36
|
+
const a = (p.d * c.a - p.b * c.c) / det, b = (p.d * c.b - p.b * c.d) / det;
|
|
37
|
+
const x = (-p.c * c.a + p.a * c.c) / det, d = (-p.c * c.b + p.a * c.d) / det;
|
|
38
|
+
return { a, b, c: x, d, worldX: 0, worldY: 0 };
|
|
39
|
+
};
|
|
40
|
+
const regular = (m: SeamMatrix): boolean => {
|
|
41
|
+
const x2 = m.a * m.a + m.c * m.c, y2 = m.b * m.b + m.d * m.d;
|
|
42
|
+
// Floating-point arithmetic slack only, relative to the two axis lengths.
|
|
43
|
+
return x2 > 0 && y2 > 0 && m.a * m.d - m.b * m.c > 0 &&
|
|
44
|
+
Math.abs(m.a * m.b + m.c * m.d) <= 64 * Number.EPSILON * Math.sqrt(x2 * y2);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** World axes remain evidence even when local-range equivalence is unavailable. */
|
|
48
|
+
export function seamJointPose(bone: string, parent: string | null, inherit: string, setup: SeamMatrix, posed: SeamMatrix,
|
|
49
|
+
parentSetup: SeamMatrix | null, parentPosed: SeamMatrix | null, range: [number, number] | null, rangeReason?: string): SeamJointPose {
|
|
50
|
+
const out: SeamJointPose = { bone, parent, world_x_axis_degrees: posed.a === 0 && posed.c === 0 ? null : angle(posed.a, posed.c),
|
|
51
|
+
parent_relative_setup_axis_delta_degrees_mod360: null, static_range: range, comparison: 'unavailable' };
|
|
52
|
+
if (inherit !== 'normal') return { ...out, reason: `inherit ${inherit}: parent-relative local-range comparison unavailable` };
|
|
53
|
+
if (parent === null || parentSetup === null || parentPosed === null) return { ...out, reason: 'no parent frame for a local-range comparison' };
|
|
54
|
+
const rest = inverseProduct(parentSetup, setup), frame = inverseProduct(parentPosed, posed);
|
|
55
|
+
if (rest === null || frame === null || !regular(rest) || !regular(frame) || !regular(parentSetup) || !regular(parentPosed)) return { ...out, reason: 'singular, reflected, sheared or degenerate axes: local-range comparison unavailable' };
|
|
56
|
+
const delta = wrap(angle(frame.a, frame.c) - angle(rest.a, rest.c));
|
|
57
|
+
out.parent_relative_setup_axis_delta_degrees_mod360 = delta;
|
|
58
|
+
if (range === null) return { ...out, reason: rangeReason ?? 'no declared static range; relative axis delta is modulo 360' };
|
|
59
|
+
if (range[0] <= -180 || range[1] >= 180 || Math.abs(delta) === 180) return { ...out, reason: 'ambiguous winding against static range; relative axis delta is modulo 360' };
|
|
60
|
+
// This is explicitly an axis comparison modulo 360, never an unwrapped local key or a new gate.
|
|
61
|
+
return { ...out, comparison: 'axis-modulo-360', reason: 'normal unsheared axes; modulo 360 only, winding not certified' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Identical declarations share one range; conflicting declarations remain explicitly ambiguous. */
|
|
65
|
+
export function seamStaticRange(ranges: readonly [number, number][]): { range: [number, number] | null; rangeReason?: string } {
|
|
66
|
+
const unique = [...new Map(ranges.map(range => [JSON.stringify(range), range])).values()];
|
|
67
|
+
return unique.length <= 1 ? { range: unique[0] ?? null }
|
|
68
|
+
: { range: null, rangeReason: `ambiguous declared static ranges ${unique.map(range => JSON.stringify(range)).join(', ')}; no local-range comparison` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type SeamFrameCohort = Pick<FrameSet, 'animation' | 'fps' | 'viewport'> & { frames: readonly Pick<GeometryFrame, 'index'>[] };
|
|
72
|
+
export interface SeamPoseJoint {
|
|
73
|
+
bone: string;
|
|
74
|
+
parent: string | null;
|
|
75
|
+
inherit: string;
|
|
76
|
+
range: [number, number] | null;
|
|
77
|
+
rangeReason?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Bind stock coordinates, source parent identities and the complete real PNG frame cohort. */
|
|
81
|
+
export function seamPoseFrame(geometry: unknown, animation: string, index: number, time: number, cohort: SeamFrameCohort,
|
|
82
|
+
joints: readonly SeamPoseJoint[]): SeamPoseAtWorst {
|
|
83
|
+
if (!record(geometry) || geometry.spec !== SEAM_GEOMETRY_SPEC || geometry.animation !== animation || cohort.animation !== animation ||
|
|
84
|
+
geometry.fps !== cohort.fps || !record(geometry.viewport) || !Array.isArray(geometry.frames) || !record(geometry.setup)) return fail('same-animation stock geometry with matching fps, viewport, setup and frames required');
|
|
85
|
+
if (geometry.coordinates !== SEAM_GEOMETRY_COORDINATES) return fail(`coordinates must be ${JSON.stringify(SEAM_GEOMETRY_COORDINATES)}; found ${JSON.stringify(geometry.coordinates)}`);
|
|
86
|
+
const viewport = cohort.viewport;
|
|
87
|
+
for (const key of ['x', 'y', 'width', 'height', 'scale', 'pixelWidth', 'pixelHeight'] as const) {
|
|
88
|
+
if (typeof viewport[key] !== 'number' || !Number.isFinite(viewport[key]) || geometry.viewport[key] !== viewport[key]) return fail(`viewport ${key} differs from frames.json or is not finite`);
|
|
89
|
+
}
|
|
90
|
+
const roster = Array.isArray(geometry.bones) ? geometry.bones.filter(record) : [];
|
|
91
|
+
if (!Array.isArray(geometry.bones) || roster.length !== geometry.bones.length || roster.some(b => typeof b.name !== 'string' || (b.parent !== null && typeof b.parent !== 'string')) ||
|
|
92
|
+
new Set(roster.map(b => b.name)).size !== roster.length) return fail('bone/parent roster requires unique names and explicit string/null parents');
|
|
93
|
+
const declared = new Map<string, GeometryFile['bones'][number]>(roster.map(b => [String(b.name), { name: String(b.name), parent: b.parent as string | null }]));
|
|
94
|
+
for (const b of declared.values()) if (b.parent !== null && !declared.has(b.parent)) return fail(`bone/parent roster: parent "${b.parent}" of "${b.name}" is missing`);
|
|
95
|
+
for (const j of joints) {
|
|
96
|
+
if (!declared.has(j.bone) || declared.get(j.bone)?.parent !== j.parent || (j.parent !== null && !declared.has(j.parent))) return fail(`bone/parent roster for "${j.bone}" disagrees with source parent ${JSON.stringify(j.parent)}`);
|
|
97
|
+
}
|
|
98
|
+
const frames = geometry.frames.filter(record);
|
|
99
|
+
if (frames.length !== geometry.frames.length || frames.some(f => !Number.isSafeInteger(f.index) || Number(f.index) < 0 || typeof f.time !== 'number' || !Number.isFinite(f.time) || f.time < 0) || new Set(frames.map(f => f.index)).size !== frames.length) return fail('frame cohort requires unique integer indices and finite actual times');
|
|
100
|
+
if (frames.length !== cohort.frames.length || frames.some((f, i) => f.index !== cohort.frames[i].index)) return fail('frame cohort differs from the actual PNG frame set (including unselected frames)');
|
|
101
|
+
const frame = frames.find(f => f.index === index);
|
|
102
|
+
if (!Number.isSafeInteger(index) || !Number.isFinite(time) || frame === undefined || frame.time !== time) return fail(`frame ${index}: raw seam and geometry frame/time differ`);
|
|
103
|
+
const matrix = (rows: unknown, name: string): SeamMatrix => {
|
|
104
|
+
const matches = Array.isArray(rows) ? rows.filter(r => record(r) && r.name === name) : [];
|
|
105
|
+
const row = matches[0];
|
|
106
|
+
if (matches.length !== 1 || !record(row) || ['a','b','c','d','worldX','worldY'].some(k => typeof row[k] !== 'number' || !Number.isFinite(row[k]))) return fail(`bone "${name}": one matrix with six finite numbers required`);
|
|
107
|
+
return { a: Number(row.a), b: Number(row.b), c: Number(row.c), d: Number(row.d), worldX: Number(row.worldX), worldY: Number(row.worldY) };
|
|
108
|
+
};
|
|
109
|
+
const required = new Set(joints.flatMap(j => j.parent === null ? [j.bone] : [j.bone, j.parent]));
|
|
110
|
+
for (const name of required) {
|
|
111
|
+
matrix(geometry.setup.bones, name);
|
|
112
|
+
for (const f of frames) matrix(f.bones, name);
|
|
113
|
+
}
|
|
114
|
+
const setupBones = geometry.setup.bones;
|
|
115
|
+
return { index, time, joints: joints.map(j => seamJointPose(j.bone, j.parent, j.inherit, matrix(setupBones, j.bone), matrix(frame.bones, j.bone),
|
|
116
|
+
j.parent === null ? null : matrix(setupBones, j.parent), j.parent === null ? null : matrix(frame.bones, j.parent), j.range, j.rangeReason)) };
|
|
117
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Explicit motion observations; no automatic declaration in check. */
|
|
2
|
+
import type { BoneEntry, CharacterConfig, Segment } from './config.ts';
|
|
3
|
+
import type { Problem } from './errors.ts';
|
|
4
|
+
import { tagOf, type PartRecord } from './parts.ts';
|
|
5
|
+
|
|
6
|
+
export interface MotionSeamDeclaration {
|
|
7
|
+
part: string;
|
|
8
|
+
neighbour: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Both readers share shape, name, self-pair and unordered uniqueness rules. */
|
|
12
|
+
export function motionSeamProblems(value: unknown, names: ReadonlySet<string>, at: string, code: string): Problem[] {
|
|
13
|
+
const problems: Problem[] = [];
|
|
14
|
+
const bad = (object: string, detail: string): void => { problems.push({ code, object, detail }); };
|
|
15
|
+
if (!Array.isArray(value)) {
|
|
16
|
+
bad(at, 'requires an array of {part, neighbour} declarations');
|
|
17
|
+
return problems;
|
|
18
|
+
}
|
|
19
|
+
const seen = new Set<string>();
|
|
20
|
+
value.forEach((row: unknown, i) => {
|
|
21
|
+
const object = `${at}[${i}]`;
|
|
22
|
+
if (typeof row !== 'object' || row === null || Array.isArray(row)) { bad(object, 'requires {part, neighbour}'); return; }
|
|
23
|
+
const r = row as Record<string, unknown>;
|
|
24
|
+
for (const key of Object.keys(r)) if (key !== 'part' && key !== 'neighbour') bad(`${object}.${key}`, 'is unknown; only part and neighbour are read');
|
|
25
|
+
for (const key of ['part', 'neighbour']) {
|
|
26
|
+
if (typeof r[key] !== 'string' || r[key] === '') bad(`${object}.${key}`, 'requires a nonempty output part name');
|
|
27
|
+
else if (!names.has(r[key])) bad(`${object}.${key}`, `names "${r[key]}", which is no output part; known: ${[...names].join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
if (typeof r.part !== 'string' || typeof r.neighbour !== 'string') return;
|
|
30
|
+
if (r.part === r.neighbour) bad(object, `names "${r.part}" on both sides; two different parts are required`);
|
|
31
|
+
const key = JSON.stringify([r.part, r.neighbour].sort());
|
|
32
|
+
if (seen.has(key)) bad(object, 'duplicates an earlier pair, including reversed order; declare each unordered pair once');
|
|
33
|
+
seen.add(key);
|
|
34
|
+
});
|
|
35
|
+
return problems;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Expand only declared links; a miss has no inferred parent. */
|
|
39
|
+
export function motionSeamParents(bones: readonly BoneEntry[]): Map<string, string> {
|
|
40
|
+
const parents = new Map<string, string>();
|
|
41
|
+
for (const bone of bones) {
|
|
42
|
+
if ('name' in bone) parents.set(bone.name, bone.parent);
|
|
43
|
+
else bone.points.forEach((_, i) => parents.set(`${bone.chain}${i}`, i === 0 ? bone.parent : `${bone.chain}${i - 1}`));
|
|
44
|
+
}
|
|
45
|
+
return parents;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Source-bound declaration generator, also callable on a frozen handed config.
|
|
50
|
+
* A non-limb topwear mesh has exactly two single-bone carriers, a named parent
|
|
51
|
+
* and child. Every handwear carrier is strictly below the same nearest member
|
|
52
|
+
* of that pair, either parent or child, and none is a trunk carrier itself.
|
|
53
|
+
* This proves the declared binding structure, not positive
|
|
54
|
+
* compiled vertex weights or alpha overlap (the stock seam reader observes that).
|
|
55
|
+
* No motion keys, thresholds, part spelling or nearest geometry select a pair.
|
|
56
|
+
*/
|
|
57
|
+
export function draftMotionSeams(config: Pick<CharacterConfig, 'bones' | 'meshes' | 'regions' | 'limbs'>, parts: readonly Pick<PartRecord, 'name' | 'from' | 'limb'>[]): MotionSeamDeclaration[] {
|
|
58
|
+
const parents = motionSeamParents(config.bones);
|
|
59
|
+
const singles = new Set(config.bones.flatMap(b => 'name' in b ? [b.name] : []));
|
|
60
|
+
const chains = new Map(config.bones.flatMap(b => 'chain' in b ? [[b.chain, b.points.map((_, i) => `${b.chain}${i}`)] as const] : []));
|
|
61
|
+
const bound = (part: string): string[] => {
|
|
62
|
+
const mesh = config.meshes[part];
|
|
63
|
+
if (mesh !== undefined && 'segments' in mesh) return [...new Set(mesh.segments.flatMap((s: Segment) => typeof s === 'string' ? chains.get(s) ?? [s] : [s[0]]))];
|
|
64
|
+
const region = config.regions[part];
|
|
65
|
+
return region === undefined ? [] : [typeof region === 'string' ? region : region.bone];
|
|
66
|
+
};
|
|
67
|
+
const consumed = new Set(Object.values(config.limbs ?? {}).flatMap(l => l.segments.map(s => s.from)));
|
|
68
|
+
const out: MotionSeamDeclaration[] = [];
|
|
69
|
+
for (const body of parts) {
|
|
70
|
+
if (body.limb !== undefined || consumed.has(body.name) || tagOf(body.from) !== 'topwear' || config.meshes[body.name] === undefined) continue;
|
|
71
|
+
const trunk = bound(body.name);
|
|
72
|
+
if (trunk.length !== 2 || !trunk.every(b => singles.has(b))) continue;
|
|
73
|
+
const child = trunk.find(b => parents.get(b) === trunk.find(other => other !== b));
|
|
74
|
+
if (child === undefined) continue;
|
|
75
|
+
for (const sleeve of parts) {
|
|
76
|
+
if (sleeve.limb !== undefined || consumed.has(sleeve.name) || !['handwear-r', 'handwear-l'].includes(tagOf(sleeve.from) ?? '')) continue;
|
|
77
|
+
const carriers = bound(sleeve.name);
|
|
78
|
+
const nearestTrunk = (bone: string): string | undefined => {
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
for (let b: string | undefined = parents.get(bone); b !== undefined && !seen.has(b); b = parents.get(b)) {
|
|
81
|
+
if (trunk.includes(b)) return b;
|
|
82
|
+
seen.add(b);
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
};
|
|
86
|
+
const carrierTrunk = carriers.length === 0 ? undefined : nearestTrunk(carriers[0]);
|
|
87
|
+
if (carrierTrunk !== undefined && carriers.every(b => parents.has(b) && !trunk.includes(b) && nearestTrunk(b) === carrierTrunk)) out.push({ part: body.name, neighbour: sleeve.name });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
package/src/parts.ts
CHANGED
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
* a later stage. The writer's key order is fixed, so two runs over the same
|
|
86
86
|
* inputs write the same bytes.
|
|
87
87
|
*/
|
|
88
|
+
import { motionSeamProblems, type MotionSeamDeclaration } from './motionseams.ts';
|
|
88
89
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
89
90
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
90
91
|
import { KEYPOINT_NAMES, type PosePoints } from './skeleton.ts';
|
|
@@ -355,6 +356,8 @@ export interface PartsFile {
|
|
|
355
356
|
scale_rig_per_source: number;
|
|
356
357
|
parts: PartRecord[];
|
|
357
358
|
ghost_px: Record<string, number>;
|
|
359
|
+
/** Explicit observations only; check never regenerates a removed declaration. */
|
|
360
|
+
motion_seams?: MotionSeamDeclaration[];
|
|
358
361
|
/** Absent in a `parts.json` the reference wrote; `assemble` always writes it. */
|
|
359
362
|
recomposite?: RecompositeRecord;
|
|
360
363
|
/** Written only under `config.scene` (issue #242): the recomposite was read inside this figure; absent otherwise. */
|
|
@@ -397,8 +400,8 @@ export interface SceneRecord {
|
|
|
397
400
|
figure_mask: string;
|
|
398
401
|
}
|
|
399
402
|
|
|
400
|
-
const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad', 'alternates'] as const;
|
|
401
|
-
const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad' && k !== 'alternates');
|
|
403
|
+
const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad', 'alternates', 'motion_seams'] as const;
|
|
404
|
+
const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad' && k !== 'alternates' && k !== 'motion_seams');
|
|
402
405
|
export const SINGLE_RUN_PAD_KEYS = ['file_size', 'side', 'left', 'top'] as const;
|
|
403
406
|
export const SCENE_KEYS = ['figure_alpha_above', 'figure_px', 'figure_mask'] as const;
|
|
404
407
|
export const RECOMPOSITE_KEYS = ['mean_abs', 'within_limit', 'within_share', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed', 'holes'] as const;
|
|
@@ -475,6 +478,7 @@ export function serializeParts(file: PartsFile): string {
|
|
|
475
478
|
rig_size: file.rig_size,
|
|
476
479
|
scale_rig_per_source: file.scale_rig_per_source,
|
|
477
480
|
parts: serializePartList(file.parts),
|
|
481
|
+
...(file.motion_seams === undefined ? {} : { motion_seams: file.motion_seams.map(({ part, neighbour }) => ({ part, neighbour })) }),
|
|
478
482
|
ghost_px: Object.fromEntries(Object.keys(file.ghost_px).sort().map((k) => [k, file.ghost_px[k]])),
|
|
479
483
|
...(file.recomposite === undefined ? {} : { recomposite: serializeRecomposite(file.recomposite) }),
|
|
480
484
|
...(file.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (file.scene as SceneRecord)[k]])) }),
|
|
@@ -663,6 +667,10 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
663
667
|
}
|
|
664
668
|
}
|
|
665
669
|
if (Array.isArray(o.parts)) checkLimbRecords(o.parts, '', fail);
|
|
670
|
+
if ('motion_seams' in o) {
|
|
671
|
+
const names = new Set((Array.isArray(o.parts) ? o.parts : []).flatMap(p => typeof p === 'object' && p !== null && typeof p.name === 'string' ? [p.name] : []));
|
|
672
|
+
problems.push(...motionSeamProblems(o.motion_seams, names, `${path} motion_seams`, 'PARTS_MOTION_SEAM'));
|
|
673
|
+
}
|
|
666
674
|
if ('scene' in o) {
|
|
667
675
|
const sc = o.scene;
|
|
668
676
|
if (typeof sc !== 'object' || sc === null || Array.isArray(sc)) fail('PARTS_FIELD_TYPE', `${path} field "scene"`, `is ${show(sc)}; an object is required`);
|
package/src/propose.ts
CHANGED
|
@@ -147,6 +147,8 @@ import { type Mask, newMask, newRaster, type Raster } from './raster/types.ts';
|
|
|
147
147
|
import { npMean, npMedian, npPercentile, pyFixed, pyInt, pyIntList, pyRepr, pyRound, pyStrList } from './pyfmt.ts';
|
|
148
148
|
import type { JointState, RigJoint, RigJoints } from './keypoints.ts';
|
|
149
149
|
import { IDLE_FPS } from './motion.ts';
|
|
150
|
+
import { draftMotionSeams, type MotionSeamDeclaration } from './motionseams.ts';
|
|
151
|
+
export { draftMotionSeams } from './motionseams.ts';
|
|
150
152
|
import { FORK_SMOOTH_MAX_INFLUENCES, influenceFarthest } from './rig.ts';
|
|
151
153
|
import { type Segment as WeightSegment } from './weights.ts';
|
|
152
154
|
import { pyRound as roundTo } from './round.ts';
|
|
@@ -278,6 +280,8 @@ export interface Proposal {
|
|
|
278
280
|
regions: Record<string, string>;
|
|
279
281
|
/** Only under `--keypoints`, where an arm or a leg is drafted from its joints (issue #349): absent otherwise, so a proposal without one is the one written before it. */
|
|
280
282
|
limbs?: Record<string, Limb>;
|
|
283
|
+
/** Source-bound torso/sleeve observations, written only when the structure resolves. */
|
|
284
|
+
motion_seams?: MotionSeamDeclaration[];
|
|
281
285
|
motion: {
|
|
282
286
|
duration: number;
|
|
283
287
|
tracks: Array<ProposedSingleTrack | ProposedChainTrack>;
|
|
@@ -304,7 +308,7 @@ export interface Proposal {
|
|
|
304
308
|
* {@link Proposal} key it does not hold, and the selftest's `TY18` holds every
|
|
305
309
|
* paste sentence in the documents to it.
|
|
306
310
|
*/
|
|
307
|
-
export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn', 'proposed_rig_scale'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
|
|
311
|
+
export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn', 'motion_seams', 'proposed_rig_scale'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
|
|
308
312
|
|
|
309
313
|
/** A {@link Proposal} key that is neither `notes` nor in {@link PROPOSAL_CONFIG_SECTIONS}; `never` while the list is whole. */
|
|
310
314
|
type UnlistedProposalSection = Exclude<keyof Proposal, 'notes' | (typeof PROPOSAL_CONFIG_SECTIONS)[number]>;
|
|
@@ -661,7 +665,7 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
|
|
|
661
665
|
}
|
|
662
666
|
turn = { pivot: turn.pivot, depths };
|
|
663
667
|
}
|
|
664
|
-
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), proposed_rig_scale: p.proposed_rig_scale, notes: [...p.notes, ...draft.notes] };
|
|
668
|
+
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), ...(p.motion_seams === undefined ? {} : { motion_seams: p.motion_seams }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), proposed_rig_scale: p.proposed_rig_scale, notes: [...p.notes, ...draft.notes] };
|
|
665
669
|
}
|
|
666
670
|
|
|
667
671
|
// ---------------------------------------------------------------------------
|
|
@@ -3493,8 +3497,10 @@ export function proposeWithBasis(
|
|
|
3493
3497
|
meshes[name] = { influence: { cell, radius, across: decl.across, max, shape: decl.shape, ...(held ? { hold_tip: true as const } : {}) }, segments };
|
|
3494
3498
|
}
|
|
3495
3499
|
const constrained = clothConstraints.length === 0 ? {} : { constraints: clothConstraints };
|
|
3500
|
+
const motionSeams = draftMotionSeams({ bones, meshes, regions, ...drafted }, P.recs);
|
|
3501
|
+
const observed = motionSeams.length === 0 ? {} : { motion_seams: motionSeams };
|
|
3496
3502
|
return {
|
|
3497
|
-
proposal: draftPassingChainSigns(P, { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, proposed_rig_scale: P.rigScale, notes }),
|
|
3503
|
+
proposal: draftPassingChainSigns(P, { bones, meshes, regions, ...drafted, ...observed, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, proposed_rig_scale: P.rigScale, notes }),
|
|
3498
3504
|
basis: { frame, bones: records },
|
|
3499
3505
|
draftLint: [...jointsOffArt, ...radii],
|
|
3500
3506
|
};
|