@rig-c/parts 1.19.0 → 1.21.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/docs/STABILITY.md CHANGED
@@ -53,7 +53,7 @@ defaults are the help's too, and none is guessed from the art.
53
53
  | `comfy` | `seethrough`: `--image`, `--out`; optional `--resolution`, `--steps`, `--seed`, `--prefix`, `--offload`, `--lama`, `--nf4`, `--host`, `--wait`, `--timeout`, `--poll` |
54
54
  | `assemble` | `--propose-plan` with `--source`, `--full`, `--config`, optional `--head`; or `--source`, `--full`, `--config`, `--out`, optional `--head` (without it, one run: issue #359), `--seam` (near-white, silhouette), `--project` (core, visible) |
55
55
  | `propose` | `--head-box` with `--full`, `--canvas` |
56
- | `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`), `--full`, `--head` (the turn control's depths; not with `--from-config`), `--grid-rule` (`figure` or `part`, the drafted lattice's spacing; not with `--from-config`) |
56
+ | `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`), `--full`, `--head` (the turn control's depths; not with `--from-config`), `--grid-rule` (`figure` or `part`, the drafted lattice's spacing; not with `--from-config`), `--cloth` (a config whose `cloth` block is the recipe, issue #364; not with `--from-config`) |
57
57
  | `compare` | `--left`, `--right`; optional `--map` |
58
58
  | `rig` | `--config`, `--parts`, `--out`; optional `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
59
59
  | `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements`, `--seam-pairs`, `--keep-stills` |
@@ -112,7 +112,7 @@ The fields, object by object (a bold path is where the object sits; `<part>` is
112
112
  part name, `[i]` an entry of a list):
113
113
 
114
114
  <!-- stable:config -->
115
- - **config** — required by the full loader, in front of rig and build: `key`, `assemble`, `bones`, `meshes`, `regions`, `motion`; optional: `generation`, `seethrough`, `limbs`, `constraints`, `turn`, `scene`
115
+ - **config** — required by the full loader, in front of rig and build: `key`, `assemble`, `bones`, `meshes`, `regions`, `motion`; optional: `generation`, `seethrough`, `limbs`, `constraints`, `turn`, `scene`, `cloth`
116
116
  - **generation** — required: `checkpoint`, `loras`, `trigger`, `identity`, `sampler`, `costume`, `negative_extra`, `style`, `negative_pose`, `latent`, `seed`; optional: `pose`, `control`
117
117
  - **generation.loras[i]** — required: `name`, `strength`; optional: `strength_clip`
118
118
  - **generation.sampler** — required: `steps`, `cfg`, `sampler`, `scheduler`
@@ -123,10 +123,10 @@ part name, `[i]` an entry of a list):
123
123
  - **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
124
124
  - **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
125
125
  - **assemble.patches[i].draw**, when an object — required: `before`
126
- - **assemble.cuts[i]** — required: `from`, `into`, `polygon`, `draw`, `overlap`; optional: `under`
126
+ - **assemble.cuts[i]** — required: `from`, `into`, `polygon`, `draw`, `overlap`; optional: `under`, `under_fill`
127
127
  - **assemble.cuts[i].draw**, when an object — required: `before`
128
128
  - **limbs.&lt;limb&gt;** — required: `segments` (issue #349)
129
- - **limbs.&lt;limb&gt;.segments[i]** — required: `part`, `from`, `bone`; required below a joint (i > 0): `joint`; optional below a joint: `range`, `cap`
129
+ - **limbs.&lt;limb&gt;.segments[i]** — required: `part`, `from`, `bone`; required below a joint (i > 0): `joint`; optional below a joint: `range`, `cap`, `edge`, `cap_shape`
130
130
  - **bones[i]**, a single bone — required: `name`, `parent`, `at`; optional: `tip`, `inherit`
131
131
  - **bones[i]**, a chain — required: `chain`, `parent`, `points`, `tip`
132
132
  - **meshes.&lt;part&gt;** — exactly one of `grid`, `contour`, `auto`; required in every mode: `r`, `segments`; optional in every mode: `exponent`, `rule`; optional with `contour` or `auto`: `ribs`; optional with `grid` or `contour`: `influences` (issue #284; beside `auto` it is refused, CONFIG_INFLUENCES_MODE); optional with `grid`: `stray` (issue #303; beside `contour` or `auto` it is refused, CONFIG_STRAY_MODE)
@@ -147,6 +147,10 @@ part name, `[i]` an entry of a list):
147
147
  - **meshes.&lt;part&gt;.auto.regions[i]** — required: `name`, `shape`, `maxEdgeLength`, `transition`, `grade`, `minArtSamples`, and `cx`, `cy`, `r` for a circle or `points` for a polygon; optional: `bone`, `band` (both or neither; with neither the region is density only)
148
148
  - **meshes.&lt;part&gt;.auto.boundaryRuns** — required: `maxVertices`
149
149
  - **meshes.&lt;part&gt;.auto.stimulus**, or each entry of it when a list — required: `chain`, `degrees`
150
+ - **cloth.&lt;tag&gt;**, the tag bottomwear, back hair or front hair (issue #364; read by propose --cloth only) — required: `chains`, `links`, `wind`, `gravity`; optional: `split`, `length_ratio`, `strength`, `damping`, `alternate_damping`, `translate`, `swing` (on bottomwear only)
151
+ - **cloth.&lt;tag&gt;.split** — required: `at`, `into`
152
+ - **cloth.&lt;tag&gt;.strength** — required: `trunk`, `branch`, `edge`
153
+ - **cloth.&lt;tag&gt;.swing** — required: `scales`
150
154
  - **regions.&lt;part&gt;** — a bone name
151
155
  - **motion** — required: `duration`, `tracks`; optional: `blink`, `animations_from`
152
156
  - **motion.tracks[i]**, a single track — required: `bone`, `prop`, `amp`, `period`, `phase`; optional: `base`
@@ -174,13 +178,13 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
174
178
 
175
179
  | file | written by | what is stable |
176
180
  | --- | --- | --- |
177
- | `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from` (`<run>:<tag>` with a See-through v3 tag, `<run>:author:<label>` for a layer the author made — issue #256, read by no role rule — or `painting:<name>` for a patch), `cut_from` on a cut's piece only (the plan part it was cut from, issue #230), `limb` on a limb segment's part only (issue #349: `name`, `segment`, `of`, `bone`, and below a joint `joint` and `cap` — `at`, `width`, `radius`, `box`, `layer_px`, `image_px` with a cap image, `image_layer` after it when that image was a hidden-art layer — issue #359, `<run>:<layer>:hidden` — `missing_px`), the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px`, `seam_override_px`, and `fringe_pushed_back` when not empty); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite`, and `scene` (`figure_alpha_above`, `figure_px`, `figure_mask`) under `config.scene` only, with the figure's mask `figure_rig.png` beside the recomposite (issue #242); `single_run_pad` (`file_size`, `side`, `left`, `top`) last, only for one run whose file is not the `seethrough.resolution` square (issue #366) |
181
+ | `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from` (`<run>:<tag>` with a See-through v3 tag, `<run>:author:<label>` for a layer the author made — issue #256, read by no role rule — or `painting:<name>` for a patch), `cut_from` on a cut's piece only (the plan part it was cut from, issue #230), `limb` on a limb segment's part only (issue #349: `name`, `segment`, `of`, `bone`, and below a joint `joint`, `range` when the config segment declares one (issue #360), and `cap` — `at`, `width`, `radius`, `box`, `layer_px`, `image_px` with a cap image, `image_layer` after it when that image was a hidden-art layer — issue #359, `<run>:<layer>:hidden` — `missing_px`, then `shape` and `upper_px` under the segment's `cap_shape`, and `edge`, `feather_radius` and `feathered_px` under its `edge`, issue #360), the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px`, `seam_override_px`, and `fringe_pushed_back` when not empty); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite`, and `scene` (`figure_alpha_above`, `figure_px`, `figure_mask`) under `config.scene` only, with the figure's mask `figure_rig.png` beside the recomposite (issue #242); `single_run_pad` (`file_size`, `side`, `left`, `top`) last, only for one run whose file is not the `seethrough.resolution` square (issue #366) |
178
182
  | `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts; a bone carries `inherit` only where config.bones declares one, rig-c's mode name as written (issue #348) |
179
183
  | `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions` (`params.maxEdge` and `contour.outlineCut` only when the mesh declares `contour.maxEdge`; `params.bridges` and `contour.bridges` — `islands`, `units`, `joins`, and `faint` only when an island was left out as faint — only when it declares `contour.bridges`); or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`); `influences`, as declared, when a lattice or contour mesh declares it (after `exponent`, or where `exponent` would be); `stray` and `stray_cleared` when a lattice mesh declares `stray` (after `influences`, or where `influences` would be); `ribs` when a contour or auto mesh declares them (after `exponent`, or after `mode`), and then `contour.ribs` (`source.contour.ribs` in the automatic mode) |
180
- | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_border` after the seam figures when `parts.json` declares a scene (issue #242), `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `ANIMATIONS_DO_NOT_FOLD` after them when `motion.json` holds an animation, the idle included — on every rig with an idle since issue #333 (issue #320: `status`, `animations`, `fps`, `physics_left_out`, `folding`, `folding_undeclared`, `declared_not_folding`, `bar`), `FEET_STAY_LEVEL` after it when a `footwear` part rides a bone that hangs from another and `motion.json` holds an animation (issue #348: `status`, `reason` on a SKIP only, `animations`, `fps`, `physics_left_out`, `bar_degrees`, `bar`, `feet` — each `part`, `bone`, `hangs_from`, `status`, `frames_posed`, `worst`), `LIMB_ONE_BONE` after it (and after `STRESS_OFF_CHAIN` where that is written) when `parts.json` holds a limb segment's part (issue #349: `status`, `parts` — each `part`, `limb`, `bone`, `status`, `others`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
184
+ | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_border` after the seam figures when `parts.json` declares a scene (issue #242), `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `ANIMATIONS_DO_NOT_FOLD` after them when `motion.json` holds an animation, the idle included — on every rig with an idle since issue #333 (issue #320: `status`, `animations`, `fps`, `physics_left_out`, `folding`, `folding_undeclared`, `declared_not_folding`, `bar`), `FEET_STAY_LEVEL` after it when a `footwear` part rides a bone that hangs from another and `motion.json` holds an animation (issue #348: `status`, `reason` on a SKIP only, `animations`, `fps`, `physics_left_out`, `bar_degrees`, `bar`, `feet` — each `part`, `bone`, `hangs_from`, `status`, `frames_posed`, `worst`), `LIMB_ONE_BONE` after it (and after `STRESS_OFF_CHAIN` where that is written) when `parts.json` holds a limb segment's part (issue #349: `status`, `parts` — each `part`, `limb`, `bone`, `status`, `others`), `SEAM_OVER_RANGE` after it when a limb record carries a `range` (issue #360: `status`, `frame_scale`, `physics_left_out`, `bar` — `mean`, `rig_px_over_40`, `level` — and `joints`, each `part`, `limb`, `segment`, `joint`, `bone`, `range`, `at`, `radius`, `under_px`, `status`, `poses` — each `degrees`, `status`, `mean`, `px_over_40`, `px_over_80`, `rig_px_over_40` — and `unread` on a joint not read), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
181
185
  | `layers.json`, `<index>_<name>.png` | `layers --export` | the wrapper form `layers` reads (issue #359): `width`, `height`, then `layers` — each `name`, `filename`, `left`, `top`, `right`, `bottom`, `depth_median` (the input's own; `null` for a PSD, the manifest's order then being the draw order) — drawn layers in draw order, then the hidden-art layers; a PNG's name is its index and its layer's name, and the manifest, not the file name, says which layer it is |
182
186
  | the template `.psd` | `layers --template` | one empty layer per name `templateLayerNames` (`src/layers.ts`) holds, bottom first; the same canvas writes the same bytes |
183
- | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `turn` with `--full`/`--head`, and `notes` |
187
+ | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `constraints` with `--cloth` (issue #364: a physics constraint per tree link named `<bone>_physics`, a dangling end's `<bone>_translate`, and a skirt swing's transforms `<swing>_<tree>`), `turn` with `--full`/`--head`, and `notes` |
184
188
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
185
189
  | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate` (with `bridges` — `units`, `joins` of `span` and `area_px2`, `area_px2`, `overdraw`, and `faint` only when an island was left out as faint — only when the candidate is a bridged one, issue #326), `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
186
190
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.19.0",
3
+ "version": "1.21.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": {
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "ag-psd": "^31.0.2",
64
- "rig-c": "^2.47.1"
64
+ "rig-c": "^2.54.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@esotericsoftware/spine-core": "4.3.13",
@@ -84,9 +84,11 @@ under that.
84
84
  `work/render/recomposite_error_rig.png`; a large red hole between two parts is a
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
- --source <painting> --out work` → copy `bones`, `meshes`, `regions`, `motion`
88
- from `proposal.json` into the config. `assemble` needs the plan and none of
89
- those four; do not stub them to get past it (AUTHORING §4 says what each step
87
+ --source <painting> --out work` → copy `bones`, `meshes`, `regions`, `limbs`,
88
+ `motion`, `constraints`, `turn` from `proposal.json` into the config, each it
89
+ wrote (`limbs` only with `--keypoints`, `constraints` only with `--cloth`, `turn`
90
+ only with `--full`/`--head`; a `limbs` block left out is refused at the next step). `assemble` needs the plan and none of
91
+ those sections; do not stub them to get past it (AUTHORING §4 says what each step
90
92
  requires). If an `uncovered hole` stays large with the plan right — a piece
91
93
  of the figure neither run holds, red in `recomposite_error_rig.png` — add an
92
94
  `assemble.patches` entry (a rig-pixel box, `alpha: "silhouette"`, drawn
@@ -108,6 +110,18 @@ under that.
108
110
  again, and read each `limb cap:` line for the pixels a cap is missing. A clean
109
111
  LINT does not show a seated hip is right; the overlay does. Never write a joint you did not get from an
110
112
  estimator or the painting: say `missing` and let the rule stand in.
113
+ A skirt or hair that must answer the scene (wind, the body moving) rather than
114
+ loop a keyed swing: add a `cloth` block to the config — one recipe per tag,
115
+ `bottomwear`, `back hair`, `front hair`: `chains`, `links`, `wind` and `gravity`
116
+ as `[root, tip]` (multipliers of the consumer's environment, tip >= root), and
117
+ optionally `split`, `strength`, `damping`/`alternate_damping`, `translate`,
118
+ and on the skirt `swing.scales` (AUTHORING §3, *Recipe: cloth and hair*) —
119
+ and propose with `--cloth config.json`; copy its `constraints` too. Every
120
+ number is yours but `length_ratio` and `strength`, whose defaults the note
121
+ names. Weights across neighbouring trees are not drafted (issue #378). No
122
+ stop at the legs is drafted: on the recipe's fixture the environment alone
123
+ pushed no hem through a thigh, but the consumer's hip motion can
124
+ (rig-c/core#1413).
111
125
  6. Correct against `render/landmarks.png` and `note:` lines; a
112
126
  `coverage … not checked` line is a bone no check read (the line says why),
113
127
  and `basis.json` beside the proposal says what each bone rests on — check
package/src/assemble.ts CHANGED
@@ -93,7 +93,9 @@
93
93
  * - **What lies under a piece** (`cuts[].under`, `fillUnder`, issue #193) is
94
94
  * this port's: an authored image the base takes where the cut removed its
95
95
  * pixels, so a piece that swings away leaves no hole. The stage reads that
96
- * image only there and never invents a pixel.
96
+ * image only there and never invents a pixel. With `under_fill: "colour"`
97
+ * (issue #360) the base takes the image's colour only and keeps the alpha
98
+ * the cut removed, so the part's silhouette is the uncut layer's.
97
99
  *
98
100
  * Pure: no clock, no randomness, no file access. The CLI reads and writes.
99
101
  */
@@ -102,7 +104,7 @@ import { type Problem, refuseIfAny } from './errors.ts';
102
104
  import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
103
105
  import { padToSquare, squarePad } from './inputs.ts';
104
106
  import { type CapRecord, type LimbRecord, PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord, type SingleRunPad } from './parts.ts';
105
- import { type BonePlace, bonePlaces, capBox, inDisc, type JointLine, jointLine, limbPolyline, limbWidth, nearestLimb, pastJoint, segmentOf } from './limbs.ts';
107
+ import { type BonePlace, bonePlaces, capBox, coveredRadius, featherWeight, halfWidthAbout, inDisc, jointLine, type JointLine, jointOffsets, limbPolyline, limbRun, nearestLimb, pastJoint, segmentOf } from './limbs.ts';
106
108
  import {
107
109
  alphaComposite,
108
110
  connectedComponents,
@@ -1726,22 +1728,28 @@ export interface CutCount {
1726
1728
  under?: number;
1727
1729
  /** With `under` only: the removed pixels the image left empty (its alpha below the alpha removed); `under + underEmpty` is the removed count. */
1728
1730
  underEmpty?: number;
1731
+ /** With `under_fill: "colour"` only (issue #360): the `under` pixels took the image's colour at the alpha the cut removed; absent without it. */
1732
+ underFill?: 'colour';
1729
1733
  }
1730
1734
 
1731
1735
  /**
1732
1736
  * The cut lines the assemble stage prints, one per plan part with cuts, in
1733
1737
  * the `pixels:` line's shape: ` cut: "<from>" opaque <before> = "<from>"
1734
1738
  * <kept> + "<into>" <taken> …`. Empty without cuts, so a config with none
1735
- * prints what it printed before.
1739
+ * prints what it printed before. With a cut of `under_fill: "colour"`
1740
+ * (issue #360) the filled count says how many of its pixels took the colour
1741
+ * only; without one the line is issue #193's.
1736
1742
  */
1737
1743
  export function cutLines(cuts: readonly CutCount[]): string[] {
1738
1744
  const byFrom = new Map<string, CutCount[]>();
1739
1745
  for (const c of cuts) byFrom.set(c.from, [...(byFrom.get(c.from) ?? []), c]);
1740
- return [...byFrom.entries()].map(
1741
- ([from, cs]) =>
1746
+ return [...byFrom.entries()].map(([from, cs]) => {
1747
+ const colour = cs.some((c) => c.underFill === 'colour') ? ` (${cs.reduce((a, c) => a + (c.underFill === 'colour' ? (c.under ?? 0) : 0), 0)} colour only, at the alpha the cut removed)` : '';
1748
+ return (
1742
1749
  ` cut: "${from}" opaque ${cs[0].before} = "${from}" ${cs[0].kept} + ${cs.map((c) => `"${c.into}" ${c.taken}`).join(' + ')}; band ${cs.reduce((a, c) => a + c.band, 0)} px held by both` +
1743
- (cs.some((c) => c.under !== undefined) ? `; under ${cs.reduce((a, c) => a + (c.under ?? 0), 0)} px filled, ${cs.reduce((a, c) => a + (c.underEmpty ?? 0), 0)} px left empty` : ''),
1744
- );
1750
+ (cs.some((c) => c.under !== undefined) ? `; under ${cs.reduce((a, c) => a + (c.under ?? 0), 0)} px filled${colour}, ${cs.reduce((a, c) => a + (c.underEmpty ?? 0), 0)} px left empty` : '')
1751
+ );
1752
+ });
1745
1753
  }
1746
1754
 
1747
1755
  /**
@@ -1771,8 +1779,14 @@ export function underBox(polygon: ReadonlyArray<readonly [number, number]>): { x
1771
1779
  * is the only place the image is read: its pixels over the band, over pixels
1772
1780
  * the layer never had, and over the box outside the polygon change nothing.
1773
1781
  * `filled + empty` is the removed count.
1782
+ *
1783
+ * With `fill` `"colour"` (issue #360, `cuts[].under_fill`) the same pixels are
1784
+ * filled and left empty, and a filled pixel takes the image's colour at the
1785
+ * alpha the cut removed there instead of the image's alpha: the part's alpha
1786
+ * is the uncut layer's wherever the image fills, so its silhouette does not
1787
+ * grow over a soft edge an opaque image covers.
1774
1788
  */
1775
- export function fillUnder(base: Raster, piece: Raster, band: Mask, under: Raster, box: { x0: number; y0: number }): { filled: number; empty: number } {
1789
+ export function fillUnder(base: Raster, piece: Raster, band: Mask, under: Raster, box: { x0: number; y0: number }, fill?: 'colour'): { filled: number; empty: number } {
1776
1790
  const W = base.width;
1777
1791
  let filled = 0;
1778
1792
  let empty = 0;
@@ -1784,7 +1798,10 @@ export function fillUnder(base: Raster, piece: Raster, band: Mask, under: Raster
1784
1798
  empty++;
1785
1799
  continue;
1786
1800
  }
1787
- base.data.set(under.data.subarray(u, u + 4), p * 4);
1801
+ if (fill === 'colour') {
1802
+ base.data.set(under.data.subarray(u, u + 3), p * 4);
1803
+ base.data[p * 4 + 3] = a;
1804
+ } else base.data.set(under.data.subarray(u, u + 4), p * 4);
1788
1805
  filled++;
1789
1806
  }
1790
1807
  return { filled, empty };
@@ -1839,7 +1856,7 @@ export interface LimbCount {
1839
1856
  before: number;
1840
1857
  shares: Array<{ part: string; px: number }>;
1841
1858
  /** The joints whose cap this part's segments carry, in `config.limbs` order. */
1842
- joints: Array<{ limb: string; joint: string; part: string; cap: CapRecord }>;
1859
+ joints: Array<{ limb: string; joint: string; part: string; upper: string; cap: CapRecord }>;
1843
1860
  }
1844
1861
 
1845
1862
  /**
@@ -1853,13 +1870,22 @@ export function limbLines(counts: readonly LimbCount[]): string[] {
1853
1870
  out.push(` limb cut: "${c.of}" opaque ${c.before} = ${c.shares.map((q) => `"${q.part}" ${q.px}`).join(' + ')}`);
1854
1871
  for (const j of c.joints) {
1855
1872
  const k = j.cap;
1856
- const from = k.image_px === undefined ? `${k.layer_px} px from the layer` : `${k.image_px} px from ${k.image_layer === undefined ? 'the cap image' : `the hidden-art layer ${k.image_layer}`}, ${k.layer_px} px from the layer`;
1873
+ const base = k.image_px === undefined ? `${k.layer_px} px from the layer` : `${k.image_px} px from ${k.image_layer === undefined ? 'the cap image' : `the hidden-art layer ${k.image_layer}`}, ${k.layer_px} px from the layer`;
1874
+ // Issue #360: what cap_shape "disc" and edge "feather" did, said only when declared, so a limb without them prints what it did.
1875
+ const from = k.shape === undefined ? base : `${base}, ${k.upper_px ?? 0} px from "${j.upper}"`;
1876
+ const radius =
1877
+ k.shape === undefined
1878
+ ? `a disc of radius ${k.radius} px (the limb ${k.width} px across the joint line)`
1879
+ : `a disc of radius ${k.radius} px (cap_shape "disc": the joint's distance to the nearer side of the limb, ${k.width} px across the joint line)`;
1880
+ const shaped = [
1881
+ ...(k.edge === undefined ? [] : [`edge "feather": ${k.feathered_px ?? 0} px of "${j.upper}" faded across the ${k.feather_radius ?? 0} px about the joint that "${j.part}" covers at every turn`]),
1882
+ ];
1857
1883
  const miss =
1858
1884
  k.missing_px === 0
1859
1885
  ? 'none missing'
1860
1886
  : `${k.missing_px} px missing — the layer has no pixel there (hidden in the painting, or past the limb's outline), and the tool invents none: paint them into a ${k.box[2]}x${k.box[3]} image whose pixel (i, j) is rig pixel (${k.box[0]} + i, ${k.box[1]} + j), and name it in the segment's "cap"`;
1861
1887
  out.push(
1862
- ` limb cap: "${j.limb}" joint "${j.joint}" at [${k.at[0]}, ${k.at[1]}], on "${j.part}": a disc of radius ${k.radius} px (the limb ${k.width} px across the joint line), box [${k.box.join(', ')}] — on the upper side of the line ${from}, ${miss}`,
1888
+ ` limb cap: "${j.limb}" joint "${j.joint}" at [${k.at[0]}, ${k.at[1]}], on "${j.part}": ${radius}, box [${k.box.join(', ')}] — on the upper side of the line ${from}, ${miss}${shaped.map((q) => `; ${q}`).join('')}`,
1863
1889
  );
1864
1890
  }
1865
1891
  }
@@ -1926,7 +1952,7 @@ function cutLimbs(
1926
1952
  const parts: LimbPart[] = [];
1927
1953
  for (const l of names) {
1928
1954
  limbs[l].segments.forEach((sg, k) =>
1929
- parts.push({ limb: l, segment: k, part: sg.part, of: sg.from, record: { name: l, segment: k, of: sg.from, bone: sg.bone, ...(k === 0 ? {} : { joint: sg.joint as string }) } }),
1955
+ parts.push({ limb: l, segment: k, part: sg.part, of: sg.from, record: { name: l, segment: k, of: sg.from, bone: sg.bone, ...(k === 0 ? {} : { joint: sg.joint as string, ...(sg.range === undefined ? {} : { range: [sg.range[0], sg.range[1]] as [number, number] }) }) } }),
1930
1956
  );
1931
1957
  }
1932
1958
  const polylines = new Map(names.map((l) => [l, limbPolyline(limbs[l], places)]));
@@ -2028,8 +2054,8 @@ function cutLimbs(
2028
2054
  return false;
2029
2055
  };
2030
2056
  const object = `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}", part "${sg.part}")`;
2031
- const width = limbWidth(line, art, [0, 0, W - 1, H - 1]);
2032
- if (width === null) {
2057
+ const run = limbRun(line, art, [0, 0, W - 1, H - 1]);
2058
+ if (run === null) {
2033
2059
  problems.push({
2034
2060
  code: 'ASSEMBLE_LIMB_JOINT_ON_ART',
2035
2061
  object,
@@ -2038,10 +2064,23 @@ function cutLimbs(
2038
2064
  return;
2039
2065
  }
2040
2066
  // The width to 3 places (src/round.ts): a measured length, and a radius the line and the record print as read.
2041
- const w3 = pyRound(width, 3);
2042
- const radius = w3 / 2;
2067
+ const w3 = pyRound(run[1] - run[0] + 1, 3);
2068
+ // Issue #360: under cap_shape "disc" the radius is the half-width about the joint (src/limbs.ts), to 3 places as well.
2069
+ const disc = sg.cap_shape === 'disc';
2070
+ const half = halfWidthAbout(run);
2071
+ if (disc && half <= 0) {
2072
+ problems.push({
2073
+ code: 'ASSEMBLE_LIMB_JOINT_ON_ART',
2074
+ object,
2075
+ detail: `the joint, "${sg.bone}"'s origin at [${line.at.join(', ')}], lies on the edge of the limb's art along its joint line — the run of art reaches ${pyRound(run[1] + 0.5, 3)} px from it one way and ${pyRound(0.5 - run[0], 3)} px the other — so cap_shape "disc" has no half-width about it; a bone origin inside the limb's art is required — move the bone onto the joint`,
2076
+ });
2077
+ return;
2078
+ }
2079
+ const radius = disc ? pyRound(half, 3) : w3 / 2;
2043
2080
  const box = capBox(line.at, radius);
2044
2081
  const at = parts.findIndex((q) => q.limb === l && q.segment === k);
2082
+ const upAt = parts.findIndex((q) => q.limb === l && q.segment === k - 1);
2083
+ const upper = rasters[upAt];
2045
2084
  // Issue #359: the lower segment's part's hidden-art layer is its cap when the segment names no cap image; with one named, a refusal.
2046
2085
  const hidden = hiddenOf(sg.from);
2047
2086
  if (hidden !== null && sg.cap !== undefined) {
@@ -2066,6 +2105,7 @@ function cutLimbs(
2066
2105
  const ownLower = owners.get(sg.from) as Int32Array;
2067
2106
  let layerPx = 0;
2068
2107
  let imagePx = 0;
2108
+ let upperPx = 0;
2069
2109
  let missing = 0;
2070
2110
  for (let y = Math.max(0, box[1]); y < Math.min(H, box[1] + box[3]); y++) {
2071
2111
  for (let x = Math.max(0, box[0]); x < Math.min(W, box[0] + box[2]); x++) {
@@ -2078,6 +2118,10 @@ function cutLimbs(
2078
2118
  } else if (ownLower[p] === li && lowerSrc.r.data[p * 4 + 3] > 0) {
2079
2119
  lower.data.set(lowerSrc.r.data.subarray(p * 4, p * 4 + 4), p * 4);
2080
2120
  layerPx++;
2121
+ } else if (disc && upper.data[p * 4 + 3] > 0) {
2122
+ // Issue #360: the disc is filled from the art — the upper segment's, where the lower's layer has none.
2123
+ lower.data.set(upper.data.subarray(p * 4, p * 4 + 4), p * 4);
2124
+ upperPx++;
2081
2125
  } else missing++;
2082
2126
  }
2083
2127
  }
@@ -2085,11 +2129,43 @@ function cutLimbs(
2085
2129
  problems.push({ code: 'ASSEMBLE_LIMB_CAP_EMPTY', object: `${object}.cap`, detail: `${String(sg.cap)} is transparent over every one of the cap's pixels; an image that gives at least one is required — to keep the layer's cap, drop cap` });
2086
2130
  return;
2087
2131
  }
2088
- const cap: CapRecord = { at: [line.at[0], line.at[1]], width: w3, radius, box, layer_px: layerPx, ...(img === null ? {} : { image_px: imagePx }), ...(hidden === null ? {} : { image_layer: hidden.layer }), missing_px: missing };
2132
+ // Issue #360: under edge "feather", the upper piece fades out across the cap, over the lower's opaque pixels only.
2133
+ let featheredPx = 0;
2134
+ let band = 0;
2135
+ if (sg.edge === 'feather') {
2136
+ band = pyRound(Math.max(0, coveredRadius(line.at, radius, (x, y) => x >= 0 && y >= 0 && x < W && y < H && lower.data[(y * W + x) * 4 + 3] === 255)), 3);
2137
+ const fb = capBox(line.at, band);
2138
+ for (let y = Math.max(0, fb[1]); y < Math.min(H, fb[1] + fb[3]); y++) {
2139
+ for (let x = Math.max(0, fb[0]); x < Math.min(W, fb[0] + fb[2]); x++) {
2140
+ if (band === 0 || !inDisc(line.at, band, x, y) || pastJoint(line, x, y)) continue;
2141
+ const p = y * W + x;
2142
+ const ua = upper.data[p * 4 + 3];
2143
+ if (ua === 0) continue;
2144
+ const o = jointOffsets(line, x, y);
2145
+ const na = Math.floor(ua * featherWeight(band, o.along, o.across) + 0.5);
2146
+ if (na === ua) continue;
2147
+ if (na === 0) upper.data.fill(0, p * 4, p * 4 + 4);
2148
+ else upper.data[p * 4 + 3] = na;
2149
+ featheredPx++;
2150
+ }
2151
+ }
2152
+ }
2153
+ const cap: CapRecord = {
2154
+ at: [line.at[0], line.at[1]],
2155
+ width: w3,
2156
+ radius,
2157
+ box,
2158
+ layer_px: layerPx,
2159
+ ...(img === null ? {} : { image_px: imagePx }),
2160
+ ...(hidden === null ? {} : { image_layer: hidden.layer }),
2161
+ missing_px: missing,
2162
+ ...(disc ? { shape: 'disc' as const, upper_px: upperPx } : {}),
2163
+ ...(sg.edge === 'feather' ? { edge: 'feather' as const, feather_radius: band, feathered_px: featheredPx } : {}),
2164
+ };
2089
2165
  if (hidden !== null) readHidden(hidden.layer, `config.limbs.${l}.segments[${k}].cap (part "${sg.part}")`);
2090
2166
  parts[at].record.cap = cap;
2091
2167
  const c = counts.find((q) => q.of === sg.from);
2092
- if (c !== undefined) c.joints.push({ limb: l, joint: String(sg.joint), part: sg.part, cap });
2168
+ if (c !== undefined) c.joints.push({ limb: l, joint: String(sg.joint), part: sg.part, upper: segs[k - 1].part, cap });
2093
2169
  });
2094
2170
  }
2095
2171
  refuseIfAny(problems);
@@ -2377,7 +2453,17 @@ export function assemble(input: AssembleInput): AssembleResult {
2377
2453
  cuts,
2378
2454
  );
2379
2455
  cuts.forEach((q, i) => {
2380
- if (q.under === undefined) return;
2456
+ if (q.under === undefined) {
2457
+ // Issue #360: under_fill says how an under fills; a cut with no under (no file, no hidden-art layer) has nothing it could say it of.
2458
+ if (q.under_fill !== undefined) {
2459
+ patchProblems.push({
2460
+ code: 'ASSEMBLE_UNDER_FILL_SOURCE',
2461
+ object: `config.assemble.cuts[${i}].under_fill (part "${q.into}")`,
2462
+ detail: `is "${q.under_fill}", and the cut has no under: it names no under file and "${q.from}" has no hidden-art layer; under_fill says how an under fills the pixels the cut removed, so one is required — name under, or drop under_fill`,
2463
+ });
2464
+ }
2465
+ return;
2466
+ }
2381
2467
  const object = `config.assemble.cuts[${i}].under (part "${q.into}")`;
2382
2468
  const u = unders[i];
2383
2469
  if (u === null) {
@@ -2563,7 +2649,8 @@ export function assemble(input: AssembleInput): AssembleResult {
2563
2649
  const u = unders[i];
2564
2650
  if (u === null) return undefined;
2565
2651
  // Filled where the image is at least as opaque as the layer was; left empty elsewhere (`fillUnder`).
2566
- const f = fillUnder(b.r, (pieces[i] as Built).r, cutBand(cutMasks[i], cuts[i].overlap), u, underBox(cuts[i].polygon));
2652
+ // With under_fill "colour" (issue #360) a filled pixel keeps the alpha the cut removed and takes the image's colour only.
2653
+ const f = fillUnder(b.r, (pieces[i] as Built).r, cutBand(cutMasks[i], cuts[i].overlap), u, underBox(cuts[i].polygon), cuts[i].under_fill);
2567
2654
  // A hidden-art layer (issue #359) is the whole layer's covered art, read by every cut of its part: a cut it holds nothing
2568
2655
  // under fills 0, which the cut line counts, and is not refused as a file made for this one cut would be.
2569
2656
  if (f.filled === 0 && input.cuts[i].under !== undefined) {
@@ -2581,6 +2668,7 @@ export function assemble(input: AssembleInput): AssembleResult {
2581
2668
  if (f !== undefined) {
2582
2669
  c.under = f.filled;
2583
2670
  c.underEmpty = f.empty;
2671
+ if (cuts[i].under_fill === 'colour') c.underFill = 'colour';
2584
2672
  }
2585
2673
  cutCounts.push(c);
2586
2674
  });
package/src/build.ts CHANGED
@@ -70,7 +70,7 @@ import {
70
70
  splitSchedule,
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
- import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
73
+ import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
74
  import { type Cut, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
75
  import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
@@ -365,7 +365,7 @@ function gateRun(label: string, rigc: RigcRunner, args: string[]): GateRun {
365
365
  * written anywhere, so packing them was work nobody read. No render is asked
366
366
  * of rigc by either build.
367
367
  */
368
- export const REFERENCE_BUILD_ARGS: readonly string[] = ['--profile', 'spine-html'];
368
+ export const REFERENCE_BUILD_ARGS: readonly string[] = ['--profile', GATE_PROFILE];
369
369
 
370
370
  /** The reference build's label, as the rig stage prints it. */
371
371
  export const REFERENCE_BUILD_LABEL = `build ${REFERENCE_BUILD_ARGS.join(' ')}`;
@@ -1135,6 +1135,22 @@ export function limbCheckLines(line: LimbLine): string[] {
1135
1135
  ];
1136
1136
  }
1137
1137
 
1138
+ /**
1139
+ * The seam over the range (issue #360) as the console prints it: the status, what was posed and the bar, then each
1140
+ * joint at each angle on a line of its own — its status and the seam figures there — or, not read, why.
1141
+ */
1142
+ export function sweepCheckLines(line: SweepLine): string[] {
1143
+ const s = line.frame_scale;
1144
+ return [
1145
+ `${SWEEP_LINE}: ${line.status} — ${line.joints.length} joint(s) bent to their declared range's ends and middle${line.physics_left_out === 0 ? '' : `, ${line.physics_left_out} physics constraint(s) left out`}; at each, the pose against the same pose with the joint's own art drawn under the limb, on the rest-pose seam's bar (mean |d| <= ${line.bar.mean.toFixed(1)}, <= ${line.bar.rig_px_over_40} rig px over ${line.bar.level})${s === null ? '' : ` at frame scale ${s}`}`,
1146
+ ...line.joints.flatMap((j) => {
1147
+ const head = `joint "${j.joint}" (${j.limb}, "${j.part}" on "${j.bone}", range [${j.range.join(', ')}]${j.shape === 'disc' ? `, cap_shape "disc": no pixel over ${line.bar.level} in the disc` : ''})`;
1148
+ if (j.unread !== undefined) return [`${head}: FAIL — not read: ${j.unread}`];
1149
+ return j.poses.map((p) => `${head} at ${p.degrees}°: ${p.status} — mean |d| ${p.mean}/255, ${p.px_over_40} frame px over ${line.bar.level} (${p.px_over_80} over ${SEAM_PX_LEVEL_HIGH}), ${p.rig_px_over_40.toFixed(2)} rig px`);
1150
+ }),
1151
+ ];
1152
+ }
1153
+
1138
1154
  /** A requirement's line as the console prints it: its name and status, the reason first when NOT MEASURABLE, then its figures as check.json holds them. */
1139
1155
  export function requirementText(name: string, line: RequirementLine): string {
1140
1156
  const rest = Object.entries(line).filter(([k]) => k !== 'status' && k !== 'reason');
@@ -1181,6 +1197,8 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1181
1197
  if (stress !== undefined) log(` ${judgementLine(STRESS_LINE, stress)}`);
1182
1198
  const limbs = fig[LIMB_LINE];
1183
1199
  if (limbs !== undefined) for (const [i, l] of limbCheckLines(limbs).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1200
+ const sweep = fig[SWEEP_LINE];
1201
+ if (sweep !== undefined) for (const [i, l] of sweepCheckLines(sweep).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1184
1202
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
1185
1203
  const vsSource = fig[SOURCE_LINE];
1186
1204
  if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);