@rig-c/parts 1.24.0 → 1.26.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
@@ -52,7 +52,7 @@ defaults are the help's too, and none is guessed from the art.
52
52
  | `comfy` | `paint`: `--config`, `--out`; optional `--seeds`, `--seed0`, `--host`, `--wait`, `--timeout`, `--poll` |
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
- | `propose` | `--head-box` with `--full`, `--canvas` |
55
+ | `propose` | `--head-box` with `--full`, `--canvas`; optional `--no-back-hair` |
56
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) |
@@ -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`, `cloth`
115
+ - **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`
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`
@@ -128,7 +128,9 @@ part name, `[i]` an entry of a list):
128
128
  - **limbs.&lt;limb&gt;** — required: `segments` (issue #349)
129
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`, `cut_band`
130
130
  - **bones[i]**, a single bone — required: `name`, `parent`, `at`; optional: `tip`, `inherit`
131
- - **bones[i]**, a chain — required: `chain`, `parent`, `points`, `tip`
131
+ - **bones[i]**, a chain — required: `chain`, `parent`, `points`, `tip`; optional: `link_inherit`
132
+ - **stops[i]** — required: `chain`, `against`, `at`, `inward`, `outward`; optional: `links`, `lower` (issue #404)
133
+ - **stops[i].lower** — required: `inward`, `outward`
132
134
  - **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)
133
135
  - **meshes.&lt;part&gt;.influences** — optional, at least one: `maxInfluences` (a whole number, 1 or more, or null for no cap), `floor` ("cut" or "continuous")
134
136
  - **meshes.&lt;part&gt;.ribs.&lt;chain&gt;** — required: `stations`
@@ -147,8 +149,8 @@ part name, `[i]` an entry of a list):
147
149
  - **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
150
  - **meshes.&lt;part&gt;.auto.boundaryRuns** — required: `maxVertices`
149
151
  - **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), `part` (on bottomwear only)
151
- - **cloth.&lt;tag&gt;.split** — required: `at`, `into`
152
+ - **cloth.&lt;tag&gt;**, the tag bottomwear, back hair or front hair (issue #364; read by propose --cloth only) — required: `chains` (optional on `back hair`, issue #390), `links`, `wind`, `gravity`; optional: `split`, `length_ratio`, `strength`, `damping`, `alternate_damping`, `translate`, `swing` (on bottomwear only), `part` (on bottomwear only)
153
+ - **cloth.&lt;tag&gt;.split** — required: `at`, `into`; optional: `hold_tip`
152
154
  - **cloth.&lt;tag&gt;.strength** — required: `trunk`, `branch`, `edge`
153
155
  - **cloth.&lt;tag&gt;.swing** — required: `scales`
154
156
  - **regions.&lt;part&gt;** — a bone name
@@ -161,7 +163,7 @@ part name, `[i]` an entry of a list):
161
163
  - **turn** — required: `pivot`, `depths`
162
164
  - **turn.depths.&lt;part&gt;** — a See-through depth
163
165
  - **scene** — no field (`{}`; a record or an annotation only)
164
- - **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `limbs` under `--keypoints` where an arm or a leg is drafted from its joints (issue #349), `motion` under the rules above, `turn` with `--full`/`--head`, and `notes`
166
+ - **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `limbs` under `--keypoints` where an arm or a leg is drafted from its joints (issue #349), `stops` when supplied, `motion` under the rules above, `turn` with `--full`/`--head`, `proposed_rig_scale` (issue #354), and `notes`
165
167
  <!-- /stable:config -->
166
168
 
167
169
  `TY14` holds the set of field names in backticks above to the set of names the
@@ -181,10 +183,10 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
181
183
  | `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`, and `cut_band`, `cut_offset`, `cut_at`, `cut_crossed`, `joint_crossed`, `band_px` and `stroke_luma_max` (when the band has a split) under its `cut_band`, 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) |
182
184
  | `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) |
183
185
  | `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) |
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 |
186
+ | `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 (issues #320, #363: `status`, `animations`, `fps`, `physics_left_out`, `folding` — each `animation`, `slot`, `attachment`, `frames`, `worst_frame`, `worst_triangles`, `triangles`, `physics_on`, `physics_off`, `physics_constraints`, `declared`, `line` — `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 |
185
187
  | `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 |
186
188
  | the template `.psd` | `layers --template` | one empty layer per name `templateLayerNames` (`src/layers.ts`) holds, bottom first; the same canvas writes the same bytes |
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` |
189
+ | `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`, `proposed_rig_scale` (issue #354), and `notes` |
188
190
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
189
191
  | `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 |
190
192
  | 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.24.0",
3
+ "version": "1.26.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.54.0"
64
+ "rig-c": "^2.57.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@esotericsoftware/spine-core": "4.3.13",
@@ -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` from `proposal.json` into the config, each it
88
+ `motion`, `constraints`, `turn`, `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,7 +104,7 @@ import { type Problem, refuseIfAny } from './errors.ts';
104
104
  import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
105
105
  import { padToSquare, squarePad } from './inputs.ts';
106
106
  import { type CapRecord, type LimbRecord, PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord, type SingleRunPad } from './parts.ts';
107
- import { type BonePlace, bonePlaces, capBox, coveredRadius, type CutPlacement, featherWeight, halfWidthAbout, inDisc, jointLine, type JointLine, jointOffsets, limbPolyline, limbRun, luma601, nearestLimb, pastJoint, placeCut, segmentOf } from './limbs.ts';
107
+ import { assignOverlappingHands, type BonePlace, bonePlaces, capBox, coveredRadius, type CutPlacement, featherWeight, halfWidthAbout, inDisc, jointLine, type JointLine, jointOffsets, limbPolyline, limbRun, luma601, nearestLimb, pastJoint, placeCut, segmentOf } from './limbs.ts';
108
108
  import {
109
109
  alphaComposite,
110
110
  connectedComponents,
@@ -1561,6 +1561,61 @@ export function holeLines(f: RecompositeFigures, scene: string | null = null): s
1561
1561
  ];
1562
1562
  }
1563
1563
 
1564
+ /** One kind of uncovered error pixel (issue #351): total pixel count, 8-connected component count, and largest component box. */
1565
+ export interface HoleKindSummary {
1566
+ px: number;
1567
+ count: number;
1568
+ largest: { px: number; x: number; y: number; w: number; h: number } | null;
1569
+ }
1570
+
1571
+ /**
1572
+ * The partition of uncovered error pixels into two disjoint causes (issue #351):
1573
+ * - `noLayer`: pixels where no See-through layer in either run has `alpha > OPAQUE_ALPHA_ABOVE` (an `assemble.patches` box from the painting is the only repair).
1574
+ * - `betweenLayers`: pixels where at least one See-through layer has `alpha > OPAQUE_ALPHA_ABOVE`, but no placed part covers the pixel at `alpha > COVERED_ALPHA`.
1575
+ */
1576
+ export interface HoleKinds {
1577
+ noLayer: HoleKindSummary;
1578
+ betweenLayers: HoleKindSummary;
1579
+ }
1580
+
1581
+ function summarizeKindMask(mask: Mask): HoleKindSummary {
1582
+ let px = 0;
1583
+ for (let p = 0; p < mask.data.length; p++) px += mask.data[p];
1584
+ if (px === 0) return { px: 0, count: 0, largest: null };
1585
+ const cc = connectedComponents(mask, 8);
1586
+ const all = cc.stats.slice(1).filter((s) => s.area > 0);
1587
+ all.sort((a, b) => b.area - a.area || a.top - b.top || a.left - b.left);
1588
+ const s = all[0];
1589
+ return { px, count: all.length, largest: s === undefined ? null : { px: s.area, x: s.left, y: s.top, w: s.width, h: s.height } };
1590
+ }
1591
+
1592
+ export function holeKinds(err: Uint8Array, covered: Uint8Array, anyLayer: Uint8Array, W: number, H: number): HoleKinds {
1593
+ const noLayerMask = newMask(W, H);
1594
+ const betweenMask = newMask(W, H);
1595
+ for (let p = 0; p < W * H; p++) {
1596
+ if (err[p] !== 1 || covered[p] !== 0) continue;
1597
+ if (anyLayer[p] === 0) noLayerMask.data[p] = 1;
1598
+ else betweenMask.data[p] = 1;
1599
+ }
1600
+ return { noLayer: summarizeKindMask(noLayerMask), betweenLayers: summarizeKindMask(betweenMask) };
1601
+ }
1602
+
1603
+ /**
1604
+ * The summary line under `holeLines` (issue #351): partitions `uncoveredErrorPx`
1605
+ * into pixels where no See-through layer has `alpha > 8` (`noLayer`, repaired
1606
+ * only by `assemble.patches`) and pixels where a layer has `alpha > 8` (`betweenLayers`).
1607
+ */
1608
+ export function holeKindsLine(k: HoleKinds, scene: string | null = null): string {
1609
+ const fmt = (s: HoleKindSummary): string =>
1610
+ s.px === 0 || s.largest === null
1611
+ ? '0 px'
1612
+ : `${s.px} px in ${s.count} component(s) (largest ${s.largest.px} px at ${s.largest.x},${s.largest.y} ${s.largest.w}x${s.largest.h})`;
1613
+ return (
1614
+ `${scene === null ? '' : `${scene}: `}uncovered error by cause: no layer (alpha <= ${OPAQUE_ALPHA_ABOVE} on every layer; only assemble.patches from the painting can cover): ${fmt(k.noLayer)}; ` +
1615
+ `on a layer (alpha > ${OPAQUE_ALPHA_ABOVE} on a layer, below covered alpha ${COVERED_ALPHA} in the parts): ${fmt(k.betweenLayers)}`
1616
+ );
1617
+ }
1618
+
1564
1619
  /** The `parts.json` `recomposite` block: the four figures, the limits they were measured at, and the listed holes. */
1565
1620
  export function recompositeRecord(f: RecompositeFigures): RecompositeRecord {
1566
1621
  return {
@@ -2054,16 +2109,35 @@ function cutLimbs(
2054
2109
  placements.set(`${l}/${k}`, placed);
2055
2110
  });
2056
2111
  }
2112
+ const segOverride = new Int32Array(W * H).fill(-1);
2113
+ const segAt = (l: string, x: number, y: number): number => {
2114
+ const segs = limbs[l].segments;
2115
+ const first = segs.findIndex((sg) => sg.from === src);
2116
+ let last = first;
2117
+ while (last + 1 < segs.length && segs[last + 1].from === src) last++;
2118
+ return segmentOf(cutLines.get(l) as Array<JointLine | null>, first, last, x, y);
2119
+ };
2120
+ // Issue #400: when two forearm segments share `src` and their hand regions overlap, the overlap goes to the front
2121
+ // piece (the limb later in config.limbs) in one piece, and the back piece ends at its cuff.
2122
+ const forearms = on.flatMap((l) => {
2123
+ const li = names.indexOf(l);
2124
+ return limbs[l].segments.flatMap((sg, k) => {
2125
+ if (k === 0 || sg.joint !== 'elbow' || sg.from !== src) return [];
2126
+ const me = places.get(sg.bone) as BonePlace;
2127
+ return me.end === null ? [] : [{ limb: li, seg: k, elbow: me.at, wrist: me.end, isSeg: (x: number, y: number): boolean => segAt(l, x, y) === k }];
2128
+ });
2129
+ });
2130
+ for (let i = 0; i < forearms.length; i++) {
2131
+ for (let j = i + 1; j < forearms.length; j++) {
2132
+ assignOverlappingHands(forearms[i], forearms[j], W, H, own, segOverride);
2133
+ }
2134
+ }
2057
2135
  for (let y = 0; y < H; y++) {
2058
2136
  for (let x = 0; x < W; x++) {
2059
2137
  const p = y * W + x;
2060
2138
  if (b.r.data[p * 4 + 3] === 0) continue;
2061
2139
  const l = names[own[p]];
2062
- const segs = limbs[l].segments;
2063
- const first = segs.findIndex((sg) => sg.from === src);
2064
- let last = first;
2065
- while (last + 1 < segs.length && segs[last + 1].from === src) last++;
2066
- const k = segmentOf(cutLines.get(l) as Array<JointLine | null>, first, last, x, y);
2140
+ const k = segOverride[p] >= 0 ? segOverride[p] : segAt(l, x, y);
2067
2141
  const at = parts.findIndex((q) => q.limb === l && q.segment === k);
2068
2142
  rasters[at].data.set(b.r.data.subarray(p * 4, p * 4 + 4), p * 4);
2069
2143
  if (b.region === null || b.region.data[p] === 1) regions[at].data[p] = 1;
@@ -2259,6 +2333,8 @@ export interface AssembleResult {
2259
2333
  limbs: LimbCount[];
2260
2334
  /** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
2261
2335
  uncovered: UncoveredErrorLayer[];
2336
+ /** The partition of uncovered error pixels into no-layer and on-a-layer components (`holeKinds`, issue #351). */
2337
+ holeKinds: HoleKinds;
2262
2338
  /** The parts whose `DRIFT_LIMIT` refusals hold a body another part holds in the draw order (`driftBodies`), in draw order; empty otherwise. */
2263
2339
  drift: DriftBodies[];
2264
2340
  /** Every hidden-art layer of the runs read (issue #359) and what read it (`hiddenLines`); empty when the runs hold none. */
@@ -2773,13 +2849,26 @@ export function assemble(input: AssembleInput): AssembleResult {
2773
2849
  });
2774
2850
  const stackNames = stack.map((q) => q.record.name);
2775
2851
  const drift: DriftBodies[] = [];
2776
- order.forEach((d) => {
2852
+ order.forEach((d, orderIdx) => {
2777
2853
  if ('patch' in d) return;
2778
2854
  const { name, b } = pieceAt(d);
2779
2855
  if (b.st.refusedMask === null) return;
2780
2856
  const refused = newMask(W, H);
2781
2857
  for (let p = 0; p < W * H; p++) if (b.st.refusedMask.data[p] === 1 && (b.region === null || b.region.data[p] === 1)) refused.data[p] = 1;
2782
- const found = driftBodies(name, b.key, refused, bodyMinPx(frame), holderAt, stackNames);
2858
+ const behindAt = new Int32Array(W * H).fill(-1);
2859
+ for (let j = 0; j < orderIdx; j++) {
2860
+ const under = stack[j].image.data;
2861
+ for (let p = 0; p < W * H; p++) {
2862
+ if (under[p * 4 + 3] < CORE_ALPHA) continue;
2863
+ const dMax = Math.max(
2864
+ Math.abs(under[p * 4] - srcr.data[p * 4]),
2865
+ Math.abs(under[p * 4 + 1] - srcr.data[p * 4 + 1]),
2866
+ Math.abs(under[p * 4 + 2] - srcr.data[p * 4 + 2]),
2867
+ );
2868
+ if (dMax <= DRIFT_LIMIT) behindAt[p] = j;
2869
+ }
2870
+ }
2871
+ const found = driftBodies(name, b.key, refused, bodyMinPx(frame), holderAt, stackNames, behindAt);
2783
2872
  if (found !== null) drift.push(found);
2784
2873
  });
2785
2874
  const pushedOf: FringePushed[][] = plan.map(() => []);
@@ -2861,11 +2950,16 @@ export function assemble(input: AssembleInput): AssembleResult {
2861
2950
  const inside = input.scene === true ? sceneFigure([...rig.entries()].filter(([k]) => k.startsWith('full:')).map(([, r]) => r), W, H) : null;
2862
2951
  const sceneRecord: SceneRecord | null = inside === null ? null : { figure_alpha_above: OPAQUE_ALPHA_ABOVE, figure_px: inside.reduce((a, b) => a + b, 0), figure_mask: FIGURE_MASK_FILE };
2863
2952
  const figures = measureRecomposite(can, srcr, placed, inside);
2953
+ const covered = coveredMask(placed, W, H);
2954
+ const err = errorMask(can, srcr, inside);
2955
+ const anyLayer = new Uint8Array(W * H);
2956
+ for (const r of rig.values()) for (let p = 0; p < W * H; p++) if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) anyLayer[p] = 1;
2957
+ const kinds = holeKinds(err, covered, anyLayer, W, H);
2864
2958
  const fullRig = fullTags.map((tag) => ({ tag, rig: rig.get(`full:${tag}`) as Raster }));
2865
2959
  const uncovered = uncoveredErrorLayers(
2866
2960
  fullRig,
2867
- coveredMask(placed, W, H),
2868
- errorMask(can, srcr, inside),
2961
+ covered,
2962
+ err,
2869
2963
  figures.uncoveredErrorPx,
2870
2964
  plan,
2871
2965
  extend,
@@ -2892,6 +2986,7 @@ export function assemble(input: AssembleInput): AssembleResult {
2892
2986
  cuts: cutCounts,
2893
2987
  limbs: lim.counts,
2894
2988
  uncovered,
2989
+ holeKinds: kinds,
2895
2990
  drift,
2896
2991
  hidden: [...hiddenUses].map(([layer, readBy]) => ({ layer, readBy })),
2897
2992
  seamRule,
@@ -2920,11 +3015,18 @@ export interface DriftBodies {
2920
3015
  * (`seamCandidates`) — with its pixel count, most first, then by name.
2921
3016
  */
2922
3017
  holders: Array<{ part: string; px: number }>;
3018
+ /**
3019
+ * Over every pixel of the bodies, the top-most part drawn behind `part` at
3020
+ * alpha >= `CORE_ALPHA` whose colour is within `DRIFT_LIMIT` of the painting
3021
+ * (issue #351), with its pixel count, most first, then by name.
3022
+ */
3023
+ behind?: Array<{ part: string; px: number }>;
2923
3024
  }
2924
3025
 
2925
3026
  /**
2926
3027
  * A part's refused bodies that another part holds in the draw order (issue
2927
- * #255).
3028
+ * #255) or that a part drawn behind it matches within `DRIFT_LIMIT` of the
3029
+ * painting (issue #351).
2928
3030
  *
2929
3031
  * ⚖️ Invariant: `refused` is the part's projection core pixels whose layer
2930
3032
  * colour is more than `DRIFT_LIMIT` off the painting (`projectSource`'s
@@ -2934,32 +3036,36 @@ export interface DriftBodies {
2934
3036
  * uncovered layer's, and the stage passes the same bar (`bodyMinPx`).
2935
3037
  * `holderAt` is, per rig pixel, the index into `names` of the top-most part
2936
3038
  * at alpha >= `CORE_ALPHA` in draw order (-1: none), as the seam pass reads a
2937
- * holder. The result is non-null exactly when the bodies hold a pixel whose
2938
- * holder is another part: the plan draws that part over the body, which is
2939
- * the signature of "this layer is not the owner here".
2940
- *
2941
- * Not every body: a body the part holds itself, with nothing drawn over it,
2942
- * is See-through's colour for the part's own pixels disagreeing with the
2943
- * painting, and on four of the five public paintings it holds bodies of
2944
- * hundreds to tens of thousands of pixels that no other part touches (the
2945
- * pull request that added this has the figures); a line there would name no
2946
- * owner, which is what the line exists to name.
2947
- */
2948
- export function driftBodies(part: string, from: string, refused: Mask, minPx: number, holderAt: Int32Array, names: readonly string[]): DriftBodies | null {
3039
+ * holder. `behindAt` (optional) is, per rig pixel, the index into `names` of
3040
+ * the top-most part drawn behind `part` at alpha >= `CORE_ALPHA` whose colour
3041
+ * is within `DRIFT_LIMIT` of the painting (-1: none). The result is non-null
3042
+ * exactly when the bodies hold a pixel whose holder is another part or whose
3043
+ * `behindAt` names a part drawn behind `part` that matches the painting.
3044
+ */
3045
+ export function driftBodies(part: string, from: string, refused: Mask, minPx: number, holderAt: Int32Array, names: readonly string[], behindAt?: Int32Array): DriftBodies | null {
2949
3046
  let refusedPx = 0;
2950
3047
  for (let p = 0; p < refused.data.length; p++) refusedPx += refused.data[p];
2951
3048
  if (refusedPx === 0) return null;
2952
3049
  const { bodies, pixels } = bodiesWithPixels(refused, minPx);
2953
3050
  if (bodies.length === 0) return null;
2954
3051
  const count = new Map<string, number>();
3052
+ const behindCount = new Map<string, number>();
2955
3053
  for (let p = 0; p < pixels.data.length; p++) {
2956
- if (pixels.data[p] === 0 || holderAt[p] < 0) continue;
2957
- const n = names[holderAt[p]];
2958
- count.set(n, (count.get(n) ?? 0) + 1);
3054
+ if (pixels.data[p] === 0) continue;
3055
+ if (holderAt[p] >= 0) {
3056
+ const n = names[holderAt[p]];
3057
+ count.set(n, (count.get(n) ?? 0) + 1);
3058
+ }
3059
+ if (behindAt !== undefined && behindAt[p] >= 0) {
3060
+ const b = names[behindAt[p]];
3061
+ if (b !== part) behindCount.set(b, (behindCount.get(b) ?? 0) + 1);
3062
+ }
2959
3063
  }
2960
- if (![...count.keys()].some((n) => n !== part)) return null;
3064
+ const hasFront = [...count.keys()].some((n) => n !== part);
3065
+ if (!hasFront && behindCount.size === 0) return null;
2961
3066
  const holders = [...count.entries()].map(([q, px]) => ({ part: q, px })).sort((a, b) => b.px - a.px || (a.part < b.part ? -1 : a.part > b.part ? 1 : 0));
2962
- return { part, from, refusedPx, minPx, bodies, holders };
3067
+ const behind = [...behindCount.entries()].map(([q, px]) => ({ part: q, px })).sort((a, b) => b.px - a.px || (a.part < b.part ? -1 : a.part > b.part ? 1 : 0));
3068
+ return { part, from, refusedPx, minPx, bodies, holders, ...(behind.length > 0 ? { behind } : {}) };
2963
3069
  }
2964
3070
 
2965
3071
  /**
@@ -2969,15 +3075,22 @@ export function driftBodies(part: string, from: string, refused: Mask, minPx: nu
2969
3075
  * off the painting, max channel), in a body of 196 px at 16,12 14x14 (at
2970
3076
  * least 150 px past a 5x5 opening); by the plan's draw order "face" holds 196
2971
3077
  * px of it (the top-most part at alpha >= 250), drawn in front of "topwear"`.
3078
+ * When `behind` is non-empty (issue #351), names the part(s) drawn behind
3079
+ * `part` whose colour is within `DRIFT_LIMIT` of the painting.
2972
3080
  */
2973
3081
  export function driftLine(d: DriftBodies): string {
2974
3082
  const total = d.bodies.reduce((a, b) => a + b.px, 0);
2975
3083
  const big = d.bodies[0];
2976
3084
  const held = d.bodies.length > 1 ? `${d.bodies.length} bodies of ${total} px, the largest ${big.px} px at ${bodyBox(big)}` : `a body of ${big.px} px at ${bodyBox(big)}`;
2977
3085
  const others = d.holders.filter((h) => h.part !== d.part).map((h) => `"${h.part}"`);
3086
+ const frontClause = others.length > 0 ? `, ${others.join(', ')} drawn in front of "${d.part}"` : '';
3087
+ const behindClause =
3088
+ d.behind !== undefined && d.behind.length > 0
3089
+ ? `; the painting is within ${DRIFT_LIMIT} of ${d.behind.map((h) => `"${h.part}" (${h.px} px at alpha >= ${CORE_ALPHA}, drawn behind "${d.part}")`).join(', ')}`
3090
+ : '';
2978
3091
  return (
2979
3092
  `drift: "${d.part}" refuses ${d.refusedPx} px of ${d.from} (its colour more than ${DRIFT_LIMIT} off the painting, max channel), in ${held} (at least ${fmtPx(d.minPx)} px past a ${BODY_KERNEL}x${BODY_KERNEL} opening); ` +
2980
- `by the plan's draw order ${d.holders.map((h) => `"${h.part}" holds ${h.px} px`).join(', ')} of it (the top-most part at alpha >= ${CORE_ALPHA}), ${others.join(', ')} drawn in front of "${d.part}"`
3093
+ `by the plan's draw order ${d.holders.map((h) => `"${h.part}" holds ${h.px} px`).join(', ')} of it (the top-most part at alpha >= ${CORE_ALPHA})${frontClause}${behindClause}`
2981
3094
  );
2982
3095
  }
2983
3096
 
package/src/automesh.ts CHANGED
@@ -212,6 +212,7 @@ export function sourceWeights(
212
212
  spec: AutoSpec,
213
213
  exponent: number = DISTANCE_EXPONENT,
214
214
  heat: HeatField | null = null,
215
+ segsAt: (wy: number) => readonly Segment[] = () => segs,
215
216
  ): { weights: Influence[][]; local: LocalInfluence[] } | { overlap: RegionOverlap; vertex: number; at: Point } {
216
217
  const all = spec.regions ?? [];
217
218
  const index: number[] = [];
@@ -225,7 +226,7 @@ export function sourceWeights(
225
226
  const local: LocalInfluence[] = [];
226
227
  for (let v = 0; v < vertices.length; v++) {
227
228
  const at: Point = [vertices[v][0] + ox, vertices[v][1] + oy];
228
- const li = localInfluences(at, segs, r, regions, spec.influences, exponent, heat === null ? undefined : heatInfluences(heat, [vertices[v][0], vertices[v][1]], spec.influences));
229
+ const li = localInfluences(at, segsAt(at[1]), r, regions, spec.influences, exponent, heat === null ? undefined : heatInfluences(heat, [vertices[v][0], vertices[v][1]], spec.influences));
229
230
  if ('first' in li) return { overlap: { ...li, first: index[li.first], second: index[li.second] }, vertex: v, at };
230
231
  weights.push(li.influences.map((e) => ({ bone: e.bone, weight: e.weight })));
231
232
  local.push(li.region < 0 ? li : { ...li, region: index[li.region] });
package/src/build.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -237,11 +237,12 @@ export function readCaps(configPath: string, limbs: Readonly<Record<string, Limb
237
237
 
238
238
  /** Read, assemble, and write only after every refusal has had its chance. Throws a PartsError on a refusal, having written nothing. */
239
239
  export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs, log: Log): AssembleResult {
240
- const src = readSource(input.source);
241
- const runs = readRuns(input.full, input.head);
242
240
  // The early door: assemble runs before propose has drafted bones, meshes,
243
241
  // regions and motion, so it requires only what it reads (issue #20).
244
- const fields = stageFields(loadEarlyConfig(input.config, 'assemble'), runs.single ? { w: src.width, h: src.height } : null);
242
+ const cfg = loadEarlyConfig(input.config, 'assemble');
243
+ const src = readSource(input.source);
244
+ const runs = readRuns(input.full, input.head);
245
+ const fields = stageFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
245
246
  const under = readUnders(input.config, fields.cuts);
246
247
  const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
247
248
  const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: input.seam, projectRule: input.project });
@@ -292,6 +293,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
292
293
  const scene = result.parts.scene === undefined ? null : scenePhrase(result.parts.scene, W * H);
293
294
  log(figuresLine(result.figures, scene));
294
295
  for (const l of holeLines(result.figures, scene)) log(l);
296
+ if (result.figures.uncoveredErrorPx > 0) log(holeKindsLine(result.holeKinds, scene));
295
297
  for (const u of result.uncovered) log(uncoveredLine(u, scene));
296
298
  for (const d of result.drift) log(driftLine(d));
297
299
  log(`wrote ${outs.partsJson}, ${result.images.length} PNG(s) in ${outs.partsDir}, ${outs.recomposite}, ${outs.errorMap} (uncovered error px red, covered error px blue, the painting grey)${figurePath === null ? '' : `, ${figurePath} (the figure, white, the recomposite was read inside)`}`);
@@ -1088,17 +1090,29 @@ export function judgementLine(name: string, line: JudgementLine | ReportedLine):
1088
1090
  }
1089
1091
 
1090
1092
  /**
1091
- * The fold line (issues #320, #333) as the console prints it: the status, what was
1092
- * read and at what rate, and then each folding mesh on a line of its own,
1093
- * rigc's own words, with the declaration's why where `may_fold` takes it.
1093
+ * The fold line (issues #320, #333, #363) as the console prints it: the status,
1094
+ * what was read and at what rate, and then each folding mesh on a line of its
1095
+ * own, with physics on and off side by side (and its physics constraints named
1096
+ * when only physics folds it), rigc's own words, and the declaration's why
1097
+ * where `may_fold` takes it.
1094
1098
  */
1095
1099
  export function foldsLines(line: FoldsLine): string[] {
1096
- const physics = line.physics_left_out === 0 ? '' : `, its ${line.physics_left_out} physics constraint(s) left out`;
1100
+ const physics = line.physics_left_out === 0 ? '' : `, with its ${line.physics_left_out} physics constraint(s) on and off`;
1097
1101
  const head =
1098
1102
  `${FOLD_LINE}: ${line.status} — ${line.animations.length} animation(s) read by rigc render --folds at ${line.fps} fps${physics} (${line.animations.join(', ')}): ` +
1099
1103
  `${line.folding.length} folding mesh(es), ${line.folding_undeclared} not declared in may_fold (<= ${line.bar})` +
1100
1104
  (line.declared_not_folding.length === 0 ? '' : `; may_fold declares ${line.declared_not_folding.map((d) => `slot "${d.slot}" in "${d.animation}"`).join(', ')}, which do(es) not fold`);
1101
- return [head, ...line.folding.map((r) => ` folds ${r.line}${r.declared === null ? '' : ` — declared in may_fold: ${r.declared}`}`)];
1105
+ return [
1106
+ head,
1107
+ ...line.folding.map((r) => {
1108
+ const side =
1109
+ line.physics_left_out === 0
1110
+ ? ''
1111
+ : ` — physics on: ${r.physics_on === null ? '0 frame(s)' : `${r.physics_on.frames} frame(s), worst frame ${r.physics_on.worst_frame} (${r.physics_on.worst_triangles}/${r.triangles} triangle(s))`}; physics off: ${r.physics_off === null ? '0 frame(s)' : `${r.physics_off.frames} frame(s), worst frame ${r.physics_off.worst_frame} (${r.physics_off.worst_triangles}/${r.triangles} triangle(s))`}` +
1112
+ (r.physics_constraints.length === 0 ? '' : `; physics constraint(s): ${r.physics_constraints.join(', ')}`);
1113
+ return ` folds ${r.line}${side}${r.declared === null ? '' : ` — declared in may_fold: ${r.declared}`}`;
1114
+ }),
1115
+ ];
1102
1116
  }
1103
1117
 
1104
1118
  /**