rig-parts 2.8.1 → 2.9.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/MIGRATION.md CHANGED
@@ -382,7 +382,7 @@ What to do, keeping every entry you tuned — one command, after `assemble` has
382
382
  the split parts:
383
383
 
384
384
  ```
385
- rig-parts propose --parts <work/rig> --source <painting> --out <dir> --write-config config.json --only-missing
385
+ rig-parts propose --parts <work/rig> --bust-landmarks bust-landmarks.json --source <painting> --out <dir> --write-config config.json --only-missing
386
386
  ```
387
387
 
388
388
  For each part the config attaches by neither `meshes` nor `regions`, it adds what is
@@ -481,3 +481,22 @@ cohort to the actual PNG frame set through `readFrameSet` and `boneTrackOf`,
481
481
  including unselected frames, before reading the worst pose. Identical static
482
482
  ranges on a bone deduplicate; conflicting ranges are explicitly ambiguous,
483
483
  never described as no declared range.
484
+
485
+ ## Contour torso drafts (#986)
486
+
487
+ Fresh `propose` runs draft the chest-holding topwear as a contour mesh, including
488
+ `trunk_chest` when the figure layer is split into limbs. The drafted `proposal.json`
489
+ changes that mesh's mode and its explanatory notes. A torso on chains also declares
490
+ uncapped continuous influences (#991). Non-torso meshes retain their existing modes.
491
+
492
+ `--bust-landmarks <file>` declares `apex_r`, `apex_l` and optional `cleavage` in rig
493
+ pixels. With these landmarks, `proposal.json` also adds `contour.bust` and the two
494
+ chest-parented bones, `basis.json` records those bones, and the landmark overlays
495
+ show them. A chest-holding torso now refuses with `PROPOSE_BUST_INPUT` when neither
496
+ `--bust-landmarks` nor `--no-bust` is supplied. Runs that previously succeeded with
497
+ a missing-landmarks note must now declare the landmarks or give an explicit
498
+ `--no-bust "<reason>"`. Blank omission reasons are refused.
499
+
500
+ A config receiving these newly drafted sections, and builds made from it, have
501
+ changed mesh bytes. `--from-config` continues to read authored meshes unchanged.
502
+ See [AUTHORING.md](AUTHORING.md#drafting-the-chest-carrier) for the input contract.
package/docs/STABILITY.md CHANGED
@@ -49,6 +49,7 @@ defaults are the help's too, and none is guessed from the art.
49
49
  <!-- stable:commands -->
50
50
  | command | flags |
51
51
  | --- | --- |
52
+ | `diff-components` | `--stack`, `--painting`, `--parts`, `--floor`; optional `--threshold`, `--figure`; exactly one of `--out`, `--verify` (issue #1005; exhaustive native error inventory, with byte-bound reader) |
52
53
  | `depth-prepare` | `--parts`, `--config`, `--out` (issue #897; separate numeric-depth recipe) |
53
54
  | `depth-apply` | `--parts`, `--manifest`, `--config`, `--rig-config`, `--out` (issue #897; original character config is read-only) |
54
55
  | `layers` | one positional path: a wrapper directory, a `layers.json` or a `.psd`; with it, optional `--export` (a directory to write the wrapper form into, issue #359); or `--template` with `--canvas` and no path; or a positional head run with `--full`, `--source`, `--config` together for `PLAN_HEAD_GARMENT_OVERLAP` (issue #826) |
@@ -58,7 +59,7 @@ defaults are the help's too, and none is guessed from the art.
58
59
  | `comfy` | `seethrough`: `--image`, `--out`; optional `--resolution`, `--steps`, `--seed`, `--prefix`, `--offload`, `--no-offload`, `--lama`, `--nf4`, `--host`, `--wait`, `--timeout`, `--poll`, `--config` (See-through settings and optional depth block), `--depth-run` (`full` or `head`, required with the block) |
59
60
  | `assemble` | `--propose-plan` with `--source`, `--full`, `--config`, optional `--head`, `--write-config` (must name `--config` itself, issue #356); or `--source`, `--full`, `--config`, `--out`, optional `--head` (without it, one run: issue #359), `--seam` (near-white, silhouette), `--project` (core, visible), `--alt` (repeatable `<name>=<source>:<full>[:<head>]`, issue #454), `--requirements` (source-bound cut-material evidence only when the file declares `cut_material`, issue #888) |
60
61
  | `propose` | `--head-box` with `--full`, `--canvas`; optional `--no-back-hair`, `--write-config` (existing config, issue #356) |
61
- | `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`), `--write-config` (existing config for an ordinary proposal, issue #356; a new file with `--cloth`, issue #537; not with `--from-config` — additive: without it nothing `propose` writes changes), `--from` (the config the recipe is changed from, only with `--write-config`, issue #571 — additive: without it nothing `--write-config` writes changes), `--only-missing` (only with `--parts` and `--write-config`, not with `--cloth` or `--from-config`, issue #671 — additive: without it nothing `--write-config` writes changes) |
62
+ | `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`), `--write-config` (existing config for an ordinary proposal, issue #356; a new file with `--cloth`, issue #537; not with `--from-config` — additive: without it nothing `propose` writes changes), `--from` (the config the recipe is changed from, only with `--write-config`, issue #571 — additive: without it nothing `--write-config` writes changes), `--only-missing` (only with `--parts` and `--write-config`, not with `--cloth` or `--from-config`, issue #671 — additive: without it nothing `--write-config` writes changes) `--bust-landmarks` (rig-pixel apex_r/apex_l and optional cleavage), or `--no-bust` (non-blank omission reason), one required when drafting a chest-holding torso, both incompatible with `--from-config` |
62
63
  | `propose` | `--parts`, `--source`, `--out`, `--full`, `--head`, `--write-config` together: the [neck draft](AUTHORING.md#a-neck-that-moves-on-its-own) for an existing config with `turn.body` and one neck plan row; reassembles under `<out>/rig`, preserves the sampled turn and drafts a rigid neck unit (issue #826) |
63
64
  | `compare` | `--left`, `--right`; optional `--map` |
64
65
  | `rig` | `--config`, `--parts`, `--out`; optional `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
@@ -86,6 +87,10 @@ The [command reference](AUTHORING.md#command-reference) describes each mode;
86
87
  [writing proposed config fields](AUTHORING.md#writing-proposed-config-fields)
87
88
  records the existing-file and fresh-file destinations and byte-preservation rules.
88
89
 
90
+ `assemble --project visible` adds optional `edge_residuals.json` beside
91
+ `parts.json` (issue #999), a report-only front/back residual candidate sidecar.
92
+ Its observations add no acceptance bar to the existing outputs.
93
+
89
94
  ## The config contract
90
95
 
91
96
  `src/config.ts` is the schema and the loader, and
@@ -256,6 +261,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
256
261
  | `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) |
257
262
  | `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones` (the sorted names the attachment's weights bind in the written `rig.json`, in every mode — since 1.49.1 (issue #536) a rig with `turn` or `stops` names the twins and `<link>_d` bones there, where the row named the segment bones they replace: a fix of a wrong name, not a format change; a rig with neither writes the bytes it wrote), `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); or `mode` "explicit" with `image_sha256` and `region_sha256` (the verified guards; counts and coverage are measured from the supplied mesh, no legacy generator report is adopted); `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` "influence" with `influence` (as declared), `reach` (`farthest`, `at`, `nearest`, `capped`, `widest`) and `lattice` (rig-c's `SegmentsLatticeReport`: `cols`, `rows`, `artCells`, `keptCells`, `passes`, `islands`) (issue #378); 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) |
258
263
  | `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`, `environment` after it under `--environment` only (issue #538, additive: `path` as given, `sha256`, `steps`), `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 `step_px` (`left`, `right`) only on a joint whose cap record carries `short_px` (issue #557, additive) — and `unread` on a joint not read), `SEAM_OVER_MOTION` after it when `parts.json` holds a limb segment or `--requirements` a `seam` (issue #493 retains existing pair warnings; issue #686 gates GAP on proven body-root pairs only (gap-only; mixed ghost remains INFO until rig-c/core#1555 supplies owners): `status` — `WARN`, `CLEAR`, `SKIP` or `FAIL` — `reason` on a SKIP only, `fps`, `floor`, `pairs` — each `pair`, `a`, `b`, `declared`, optional `root` (`limb`, `bone`, `parent`) on a proven body-root pair, and `unread` on a pair not read — `animations`, `warnings`, `rows` — each `pair`, `animation`, `status`, `drawn`, `banded`, `opens`, `px`, `max_px`, `under_floor`, and only on new-root rows stock `gapOpen` plus UNDER-owned `ghostA`/`ghostB` cells selected by resolved per-frame draw order gate their summed count against the unchanged strict one-cell floor on the original raw kind boxes (their union when both kinds contribute), never narrowed partition boxes, while `gapCovered` and OVER-owned ghost cells are excluded (issue #711); `cells` retains partition counts, UNDER owner and counted total, `worst` retains frame/count/cell/box, and `roots` retains status, mode `gap-open+under-ghost`, ghost_owner `resolved-draw-order`, pairs, failed, reason and optional unmeasured bindings; eligible candidates whose valid setup bands are all zero retain a named unmeasured root and SKIP bar, including when no pair is selected; measured root FAIL wins over SKIP, while measured PASS plus an unmeasured root remains SKIP; every eligible body drawing with stock setup `banded > 0` is selected, including both when a parent is shared; issue #708 resolves a body slot on its exact generated turn child to the same named carrier only after unique identity-bone/control/per-part-transform proof, without changing pair identity, setup selection or the direct-carrier path; setup-only observation preserves the authored motion and default drawing, root-free inputs add no setup compile/render, and no animation remains SKIP rather than a measured zero; existing internal pair warnings and their exits are unchanged; issue #941 adds optional `required` only under the requirements file's `seam_over_motion.required: true` — `status` (`PASS` or `FAIL`), `mode` `gap-open`, `bar_px` 0, `pairs` (each `pair`, `class` — `body-root`, `internal-joint` or `explicit` — `frames`, `background_frames`, `max_px`) and `failed` — and with it the key is written even when no pair is declared; issue #591 adds optional `worlds` on physics rigs with readable declared pairs and animations: `default` and `still`, each `wind`, `gravity`, `status` (`REPORTED` or `SKIP`), `reason` on SKIP, `dir` only when rendered (relative to check.json: `seam_motion/default` or `seam_motion/still`, the complete retained render with frames.json and each animation's raw seam.json), and `rows` — pair, animation, status, drawn, banded, `gap` and `ghost`, each `max_px` and `worst` only; worst carries `index`, `px`, `cell`, `box`. Per-frame hits stay in the raw sidecars. These raw reference readings add no bar or warning and leave the custom environment reading unchanged), `CLOTH_SETTLES` after it when the rig declares a physics constraint and has an idle (issue #501, a warning and not a bar: `status` — `WARN` or `CLEAR` — `fps`, `frames`, `duration`, `floor_rig_px_per_s`, `floor`, `levers`, `warnings`, `rows` — each `tip`, `root`, `links`, `point`, `settles_at`, `max_rig_px_per_s`, `min_rig_px_per_s`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `WIND_BAND` after it under `--wind-band` only (issue #590, additive: `status` `"REPORTED"`, `spec` `"rig-parts-wind-band/1"`, `path` as given, `sha256`, `steady_wind_only` `true`, `gravity` `[0, 1]`, `min`, `max`, `step`, `steps`, `fps`, `animations`, `max_gap`, `max_ghost`, `holds_from`, `holds_to`, `pairs_holding_all`, and `pairs` — each `pair`, `a`, `b`, `holds_all`, `holds_from`, `holds_to`, `first_break_below`, `first_break_above`, `worst_gap`, `worst_ghost`, `steps`), `skipped`, `config` under `build` only (issue #607, additive: `path` as `--config` was typed, `sha256` of the bytes the build read; `check` and `compose` read no config and write none), a `requirements` block under `--requirements` (or `build`'s `config.requirements`) — `file` first from issue #556, additive: `path` as typed or declared, `sha256`, `keys` — and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes. Missing stock partitions or resolved owner are named refusals, never zero. No whole-box exemption or consumer compositor remains; internal warnings and reference raw readings are unchanged. |
264
+ | `part_support_domains.json` | `assemble`, `build` | optional `rig-parts-support-domains/1` independent cut masks and unavailable reasons, with per-domain config-entry provenance and parts/PNG/payload digests; additive beside parts.json, whose bytes and PNGs are unchanged. Legacy output may omit it. `PART_SUPPORT_REPORT` reads pairs in the assembly placement frame and Unowned in actual setup, never as an acceptance gate. |
259
265
  | `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 |
260
266
  | the template `.psd` | `layers --template` | one empty layer per name `templateLayerNames` (`src/layers.ts`) holds, bottom first; the same canvas writes the same bytes |
261
267
  | `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` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "2.8.1",
3
+ "version": "2.9.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": {
@@ -86,7 +86,7 @@ under that.
86
86
  5. `rig-parts assemble … --out work` — read the `uncovered hole` lines and look at
87
87
  `work/render/recomposite_error_rig.png`; a large red hole between two parts is a
88
88
  gap See-through left (a skirt split into two legs), fixed in the plan or the layers,
89
- not in the rig. Then `rig-parts propose --parts work/rig
89
+ not in the rig. Then `rig-parts propose --parts work/rig --bust-landmarks bust-landmarks.json
90
90
  --source <painting> --out work` → copy `bones`, `meshes`, `regions`, `limbs`,
91
91
  `motion`, `constraints`, `turn`, `motion_seams`, `proposed_rig_scale` from `proposal.json` into the config, each it
92
92
  wrote (`limbs` only with `--keypoints`, `constraints` only with `--cloth`, `turn`
package/src/assemble.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { edgePairResiduals, type EdgeResidualReport } from './edgeresidual.ts';
2
+ import type { SupportDomain } from './coincide.ts';
1
3
  import { type SeamRule } from './seamrule.ts';
2
4
  import { DEFAULT_GROUND, DEFAULT_PROJECT_RULE, type Ground, type ProjectRule } from './projectrule.ts';
3
5
  import { paintingEdges, type PaintingEdgeSkips } from './paintingedges.ts';
@@ -2582,10 +2584,10 @@ export function visibilityCounts(object: string, part: Raster, visible: Mask, pr
2582
2584
  *
2583
2585
  * This is NOT in the reference. It is the input of `seamRule: 'silhouette'`.
2584
2586
  */
2585
- export function figureSilhouette(srcr: Raster): Mask {
2587
+ export function figureSilhouette(srcr: Raster, painted = paintedFigure(srcr)): Mask {
2586
2588
  const { width: W, height: H } = srcr;
2587
2589
  const fig = newMask(W, H);
2588
- fig.data.set(paintedFigure(srcr));
2590
+ fig.data.set(painted);
2589
2591
  const opened = dilate(dilate(erode(erode(fig, 3), 3), 3), 3);
2590
2592
  return fillHoles(opened);
2591
2593
  }
@@ -2628,9 +2630,8 @@ export function paintedFigure(srcr: Raster): Uint8Array {
2628
2630
  * ground reads near 0; a ground of many colours, or a figure crossing the
2629
2631
  * border, reads more.
2630
2632
  */
2631
- export function groundRingShare(srcr: Raster): number {
2633
+ export function groundRingShare(srcr: Raster, painted = paintedFigure(srcr)): number {
2632
2634
  const { width: W, height: H } = srcr;
2633
- const painted = paintedFigure(srcr);
2634
2635
  let ring = 0;
2635
2636
  let figure = 0;
2636
2637
  for (let y = 0; y < H; y++) {
@@ -3066,7 +3067,7 @@ export function recomposite(parts: PlacedPart[], W: number, H: number): Raster {
3066
3067
  }
3067
3068
 
3068
3069
  /** Per rig pixel: 1 where some part has alpha above `COVERED_ALPHA`. */
3069
- function coveredMask(parts: PlacedPart[], W: number, H: number): Uint8Array {
3070
+ export function coveredMask(parts: PlacedPart[], W: number, H: number): Uint8Array {
3070
3071
  const covered = new Uint8Array(W * H);
3071
3072
  for (const { record: p, image } of parts) {
3072
3073
  for (let y = 0; y < p.h; y++) for (let x = 0; x < p.w; x++) if (image.data[(y * p.w + x) * 4 + 3] > COVERED_ALPHA) covered[(p.y + y) * W + p.x + x] = 1;
@@ -3075,14 +3076,14 @@ function coveredMask(parts: PlacedPart[], W: number, H: number): Uint8Array {
3075
3076
  }
3076
3077
 
3077
3078
  /** Per rig pixel: 1 where the recomposite's max-channel difference from the painting is above `ERROR_LIMIT` — and, with `inside` (a scene, issue #242), the pixel is the figure's. */
3078
- function errorMask(can: Raster, srcr: Raster, inside: Uint8Array | null = null): Uint8Array {
3079
+ export function errorMask(can: Raster, srcr: Raster, inside: Uint8Array | null = null, threshold = ERROR_LIMIT): Uint8Array {
3079
3080
  const n = srcr.width * srcr.height;
3080
3081
  const err = new Uint8Array(n);
3081
3082
  for (let p = 0; p < n; p++) {
3082
3083
  if (inside !== null && inside[p] === 0) continue;
3083
3084
  let m = 0;
3084
3085
  for (let c = 0; c < 3; c++) m = Math.max(m, Math.abs(can.data[p * 4 + c] - srcr.data[p * 4 + c]));
3085
- if (m > ERROR_LIMIT) err[p] = 1;
3086
+ if (m > threshold) err[p] = 1;
3086
3087
  }
3087
3088
  return err;
3088
3089
  }
@@ -3127,7 +3128,7 @@ export function uncoveredHoles(can: Raster, srcr: Raster, parts: PlacedPart[], i
3127
3128
  * parts hold counts for both). `uncoveredHoles` reads it at `COVERED_ALPHA`,
3128
3129
  * `measurePinholes` (issue #944) at 0.
3129
3130
  */
3130
- function holeBorders(parts: readonly PlacedPart[], cc: Components, s: Components['stats'][number], W: number, H: number, above: number): HoleBorder[] {
3131
+ export function holeBorders(parts: readonly PlacedPart[], cc: Components, s: Components['stats'][number], W: number, H: number, above: number): HoleBorder[] {
3131
3132
  const borders: HoleBorder[] = [];
3132
3133
  parts.forEach(({ record: p, image }) => {
3133
3134
  let n = 0;
@@ -4597,6 +4598,8 @@ export function softOcclusionLine(r: SoftOcclusionReading): string {
4597
4598
  }
4598
4599
 
4599
4600
  export interface AssembleResult {
4601
+ /** Report-only residual candidates, present after the visible edge pass. */
4602
+ edgeResiduals?: EdgeResidualReport;
4600
4603
  /** Report-only source re-take under actual soft front support (#825). */
4601
4604
  softOcclusion?: SoftOcclusionReading[];
4602
4605
  parts: PartsFile;
@@ -4613,6 +4616,10 @@ export interface AssembleResult {
4613
4616
  /** Console-only source-edge support (#888); never serialized into parts.json. */
4614
4617
  cutMaterial: MaterialRow[];
4615
4618
  materialSources: MaterialSource[];
4619
+ /** Independent authored masks frozen before any art-dependent partition. */
4620
+ supportDomains: SupportDomain[];
4621
+ /** Transient native source observation reused by build, never serialized. */
4622
+ supportFigure?: Mask;
4616
4623
  /** What each `config.limbs` limb cut, per part it took and per joint (`limbLines`); empty without limbs (issue #349). */
4617
4624
  limbs: LimbCount[];
4618
4625
  /** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
@@ -4846,7 +4853,8 @@ export function assemble(input: AssembleInput): AssembleResult {
4846
4853
  checkPlanAgainstRuns(sourcePlan, extend, full, head);
4847
4854
  const { W, H } = frame;
4848
4855
  const srcr = sourceInRig(source, W, H);
4849
- const groundShare = groundRingShare(srcr);
4856
+ const supportPainted = paintedFigure(srcr);
4857
+ const groundShare = groundRingShare(srcr, supportPainted);
4850
4858
  // Issue #359: a part's hidden-art layer is the under of a cut that names none — and, with one named, a refusal, since
4851
4859
  // reading either would be a guess. Its uses are counted for `hiddenLines`.
4852
4860
  const hiddenArt = hiddenArtByPart(plan, input.cuts, { full, head }, frame, input.resolution);
@@ -5002,6 +5010,9 @@ export function assemble(input: AssembleInput): AssembleResult {
5002
5010
  // most likely), and two cuts of one part that share a pixel would give it
5003
5011
  // to both. Every pair is named.
5004
5012
  const cutMasks = cuts.map((q) => pixelsInPolygon(q.polygon, W, H));
5013
+ const supportDomains: SupportDomain[] = cuts.map((cut, i) => ({ part: cut.into,
5014
+ mask: { width: W, height: H, data: cutMasks[i].data.slice() },
5015
+ provenance: { kind: 'cut-polygon', entry: `config.assemble.cuts[${i}].polygon` } }));
5005
5016
  cuts.forEach((q, i) => {
5006
5017
  const out = q.polygon.filter(([x, y]) => x > W || y > H);
5007
5018
  if (out.length > 0) {
@@ -5139,7 +5150,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5139
5150
  // Every Built owns its writer slice. No source-map lookup follows this
5140
5151
  // last cut-layer build; drop originals before cut/limb working sets grow.
5141
5152
  layerWriters.clear();
5142
- const silhouette = seamRule === 'silhouette' || patches.some((q) => q.alpha === 'silhouette') ? figureSilhouette(srcr) : null;
5153
+ const silhouette = seamRule === 'silhouette' || patches.some((q) => q.alpha === 'silhouette') ? figureSilhouette(srcr, supportPainted) : null;
5143
5154
  const cut = patches.map((q, i) => {
5144
5155
  const c = cutPatch(q, srcr, silhouette);
5145
5156
  if (c === null) {
@@ -5653,7 +5664,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5653
5664
  return whole;
5654
5665
  };
5655
5666
  const e = paintingEdges(stack.map(onRig), srcr, {
5656
- painted: paintedFigure(srcr),
5667
+ painted: supportPainted,
5657
5668
  ...(input.ground === undefined ? {} : { ground: input.ground }),
5658
5669
  editable: order.map((d) => !('patch' in d)),
5659
5670
  keep: keepInOrder,
@@ -5835,6 +5846,13 @@ export function assemble(input: AssembleInput): AssembleResult {
5835
5846
  frame.headBottom,
5836
5847
  bodyMinPx(frame),
5837
5848
  );
5849
+ // This observation belongs to the source painting. Reuse the mask already
5850
+ // read for ground coverage without changing the assembly silhouette.
5851
+ let opaqueSource = true;
5852
+ for (let p = 3; p < source.data.length; p += 4) if (source.data[p] !== 255) { opaqueSource = false; break; }
5853
+ const supportSilhouette = opaqueSource ? silhouette ?? figureSilhouette(srcr, supportPainted) : null;
5854
+ const supportFigure = supportSilhouette === null ? undefined : newMask(W, H);
5855
+ if (supportFigure !== undefined && supportSilhouette !== null) for (let p = 0; p < supportPainted.length; p++) supportFigure.data[p] = supportSilhouette.data[p] & supportPainted[p];
5838
5856
  return {
5839
5857
  ...(softOcclusion.length === 0 ? {} : { softOcclusion }),
5840
5858
  parts: {
@@ -5851,6 +5869,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5851
5869
  ...(padRecord === null ? {} : { single_run_pad: padRecord }),
5852
5870
  },
5853
5871
  images: placed,
5872
+ ...(projectRule === 'visible' ? { edgeResiduals: edgePairResiduals(placed.map(p => ({ name: p.record.name, x: p.record.x, y: p.record.y, image: p.image })), srcr, can, ERROR_LIMIT) } : {}),
5854
5873
  ...(registeredDepth.size === 0 ? {} : { depthReadings: ([['full', depthFullInput], ...(single ? [] : [['head', depthHeadInput]])] as Array<['full' | 'head', LayerSet]>).flatMap(([run, set]) => set.layers.flatMap(layer => layer.depthSheet === undefined ? [] : [{ from: `${run}:${layer.name}`, reading: layer.depthSheet.reading }])) }),
5855
5874
  ...(depthClamps.length === 0 ? {} : { depthClamps }),
5856
5875
  recomposite: can,
@@ -5860,6 +5879,8 @@ export function assemble(input: AssembleInput): AssembleResult {
5860
5879
  cuts: cutCounts,
5861
5880
  cutMaterial: [...cutMaterial, ...lim.material],
5862
5881
  materialSources: [...materialSources, ...lim.sources],
5882
+ supportDomains,
5883
+ ...(supportFigure === undefined ? {} : { supportFigure }),
5863
5884
  limbs: lim.counts,
5864
5885
  uncovered,
5865
5886
  holeKinds: kinds,
package/src/build.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { EDGE_RESIDUAL_FILE, edgeResidualLines } from './edgeresidual.ts';
2
+ import { SUPPORT_DOMAINS_FILE, writeSupportDomains } from './supportdomains.ts';
3
+ import { COINCIDE_LINE, supportLines } from './coincide.ts';
1
4
  import { DEFAULT_SEAM_RULE } from './seamrule.ts';
2
5
  import { DEFAULT_PROJECT_RULE } from './projectrule.ts';
3
6
  import { EDGE_CONTRAST } from './paintingedges.ts';
@@ -338,6 +341,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
338
341
  // The early door: assemble runs before propose has drafted bones, meshes,
339
342
  // regions and motion, so it requires only what it reads (issue #20).
340
343
  const cfg = loadEarlyConfig(input.config, 'assemble');
344
+ const supportDeclarations = JSON.stringify(cfg.assemble.cuts ?? []);
341
345
  const seam = input.seam ?? cfg.assemble.seam ?? DEFAULT_SEAM_RULE;
342
346
  const project = input.project ?? cfg.assemble.project ?? DEFAULT_PROJECT_RULE;
343
347
  const src = readSource(input.source);
@@ -793,6 +797,9 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
793
797
  mkdirSync(dirname(outs.recomposite), { recursive: true });
794
798
  mkdirSync(dirname(outs.errorMap), { recursive: true });
795
799
  writeParts(outs.partsJson, result.parts);
800
+ const residualPath = join(dirname(outs.partsJson), EDGE_RESIDUAL_FILE);
801
+ if (result.edgeResiduals === undefined) rmSync(residualPath, { force: true });
802
+ else writeFileSync(residualPath, JSON.stringify(result.edgeResiduals, null, 2) + '\n');
796
803
  for (const { record, image, depthImage } of result.images) {
797
804
  writePng(join(outs.partsDir, `${record.name}.png`), image);
798
805
  if (depthImage !== undefined && record.depth !== undefined) writePng(join(outs.partsDir, record.depth.image), depthImage);
@@ -805,6 +812,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
805
812
  }
806
813
  if (materialAsked) writeMaterialSources(outs.partsJson, outs.partsDir, result.parts, result.materialSources);
807
814
  else rmSync(join(dirname(outs.partsJson), CUT_MATERIAL_SOURCES), { force: true });
815
+ writeSupportDomains(outs.partsJson, outs.partsDir, result.parts, result.supportDomains, supportDeclarations);
808
816
  writePng(outs.recomposite, result.recomposite);
809
817
  writePng(outs.errorMap, result.errorMap);
810
818
  // Issue #242: under config.scene, the figure the recomposite was read inside, beside the recomposite.
@@ -822,6 +830,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
822
830
  // Issue #996: under a declared busy ground, what the two ground rules left.
823
831
  if (fields.ground === 'busy') log(` busy ground (assemble.ground): ${e.skipped.mix} behind-side px left that are not a mix of the two reference colours, ${e.skipped.support} part px not raised outside the part's own support`);
824
832
  }
833
+ if (result.edgeResiduals !== undefined) for (const line of edgeResidualLines(result.edgeResiduals)) log(line);
825
834
  // Issue #359: one run, said, so a reader of the lines knows no head run was placed.
826
835
  if (runs.single) log(` one run: ${input.full} holds the face (${SINGLE_RUN_FACE_TAGS.join(', ')}), so no head run is read; every part comes from the full run`);
827
836
  // Issue #366: a file that is not the resolution square was padded to it; said, with where parts.json records it. None for a square file.
@@ -1665,6 +1674,10 @@ export interface CheckStageInput {
1665
1674
  out: string;
1666
1675
  /** The painting `--source` names (issue #77): adds the setup pose against it. */
1667
1676
  source?: string;
1677
+ /** Internal build painting for the report only; does not enable source gates. */
1678
+ supportSource?: string;
1679
+ /** Transient source observation passed by assembly, never a public output. */
1680
+ supportFigure?: Mask;
1668
1681
  /** The packed build's `--page-edges`; {@link DEFAULT_PAGE_EDGES} when absent. */
1669
1682
  pageEdges?: PageEdges;
1670
1683
  /** The packed build's `--pack-shape`; {@link DEFAULT_PACK_SHAPE} when absent. */
@@ -1826,9 +1839,12 @@ export function levelLines(line: LevelLine): string[] {
1826
1839
  * own — its limb, its bone, and on a FAIL every other bone it is bound to.
1827
1840
  */
1828
1841
  export function limbCheckLines(line: LimbLine): string[] {
1842
+ const carriers = line.parts.some(row => row.region_bones !== undefined)
1843
+ ? 'its segment carrier or its declared contour region bones'
1844
+ : 'its one bone with weight 1 (a region on it, or a mesh weighted to it alone)';
1829
1845
  return [
1830
- `${LIMB_LINE}: ${line.status} — ${line.parts.length} limb part(s), each bound in rig.json to its one bone with weight 1 (a region on it, or a mesh weighted to it alone)`,
1831
- ...line.parts.map((r) => `limb "${r.part}" (${r.limb}) on "${r.bone}": ${r.status}${r.others.length === 0 ? '' : `, also weighted to ${r.others.map((b) => `"${b}"`).join(', ')}`}`),
1846
+ `${LIMB_LINE}: ${line.status} — ${line.parts.length} limb part(s), each bound in rig.json to ${carriers}`,
1847
+ ...line.parts.map((r) => `limb "${r.part}" (${r.limb}) on "${r.bone}": ${r.status}${r.region_bones === undefined ? '' : `, declared contour region bones [${r.region_bones.join(', ')}]`}${r.others.length === 0 ? '' : `, also weighted to ${r.others.map((b) => `"${b}"`).join(', ')}`}`),
1832
1848
  ];
1833
1849
  }
1834
1850
 
@@ -1928,7 +1944,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1928
1944
  const entryLine = versionLines.find((l) => l.startsWith('entry:'));
1929
1945
  log(` ${rigcIdentity(bin, v)}${entryLine === undefined ? '' : `; ${entryLine}`}`);
1930
1946
  const mode: PackMode = { pageEdges: input.pageEdges ?? DEFAULT_PAGE_EDGES, packShape: input.packShape ?? DEFAULT_PACK_SHAPE };
1931
- const r = runCheck(input.rig, input.out, rigc, input.parts, mode, input.source, input.requirements, input.seamPairs === true, input.keepStills === true, input.set, input.allSets === true, input.environment, input.requirementsAs, input.config, input.windBand, input.noExtremes === true);
1947
+ const r = runCheck(input.rig, input.out, rigc, input.parts, mode, input.source, input.requirements, input.seamPairs === true, input.keepStills === true, input.set, input.allSets === true, input.environment, input.requirementsAs, input.config, input.windBand, input.noExtremes === true, input.supportSource, input.supportFigure);
1932
1948
  if (r.figures.environment !== undefined) log(` ${environmentLine(r.figures.environment)}`);
1933
1949
  log(` gate rig-play (rigc ${packedBuildLabel(mode)}), verbatim:`);
1934
1950
  for (const l of r.gateHtml) log(l);
@@ -1994,6 +2010,8 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1994
2010
  if (cutMaterial !== undefined) for (const line of materialLines(cutMaterial)) log(` ${line}`);
1995
2011
  const artJoints = fig[ART_JOINT_LINE];
1996
2012
  if (artJoints !== undefined) for (const line of artJointLines(artJoints)) log(` ${line}`);
2013
+ const partSupport = fig[COINCIDE_LINE];
2014
+ if (partSupport !== undefined) for (const line of supportLines(partSupport)) log(` ${line}`);
1997
2015
  const setupPainting = fig[SETUP_PAINTING_LINE];
1998
2016
  if (setupPainting !== undefined) for (const line of setupPaintingLines(setupPainting)) log(` ${line}`);
1999
2017
  for (const line of boundaryColourLines(r.boundaryColour)) log(` ${line}`);
@@ -2368,9 +2386,9 @@ export function suppliedCheckFlag(flag: string): string | null {
2368
2386
  * rig-c's launcher answers for; they are two fields so a caller can tell
2369
2387
  * the stages' calls apart (the selftest counts them).
2370
2388
  */
2371
- /** Stage implementations may run outside this process; only check has a returned value the driver reads. */
2389
+ /** Stage implementations may run outside this process; assembly may return its transient source observation; check returns its report. */
2372
2390
  export interface BuildStages {
2373
- assemble: (input: AssembleStageInput, outputs: AssembleOutputs, log: Log) => void;
2391
+ assemble: (input: AssembleStageInput, outputs: AssembleOutputs, log: Log) => { supportFigure?: Mask } | void;
2374
2392
  rig: (input: RigStageInput, rigc: RigcRunner, scratch: string, log: Log) => void;
2375
2393
  check: typeof checkStage;
2376
2394
  }
@@ -2445,6 +2463,8 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
2445
2463
  for (const p of BUILD_OWNS) rmSync(join(out, p), { recursive: true, force: true });
2446
2464
  // Optional source evidence is cleared separately; BUILD_OWNS is the legacy complete output tree.
2447
2465
  rmSync(join(out, CUT_MATERIAL_SOURCES), { force: true });
2466
+ rmSync(join(out, EDGE_RESIDUAL_FILE), { force: true });
2467
+ rmSync(join(out, SUPPORT_DOMAINS_FILE), { force: true });
2448
2468
  // Issue #242: the figure mask is written only under config.scene; one left by an earlier scene build is not this build's.
2449
2469
  rmSync(join(out, FIGURE_MASK_FILE), { force: true });
2450
2470
  // The idle-keys value is named only when it is not the default, so a build without the flag, or with the default, prints the line it printed before issue #95.
@@ -2455,6 +2475,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
2455
2475
  // Issue #978: the painting is handed to the check only when the requirements file declares setup_painting, which
2456
2476
  // cannot be measured without it; a build without that declaration hands it nothing, as before, and writes the same bytes.
2457
2477
  let paintingForCheck: string | null = null;
2478
+ let supportFigure: Mask | undefined;
2458
2479
  try {
2459
2480
  // build runs rig next, which needs the whole config, so the full loader is
2460
2481
  // asked first: a config rig would refuse is refused before assemble writes
@@ -2474,11 +2495,12 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
2474
2495
  if (input.environment !== undefined) readCheckEnvironment(input.environment);
2475
2496
  // And the steady-wind band spec (issue #590), as check reads it.
2476
2497
  if (input.windBand !== undefined) readWindBand(input.windBand);
2477
- stages.assemble(
2498
+ const assembled = stages.assemble(
2478
2499
  { source: input.source, full: input.full, head: input.head, config: input.config, seam, project, ...(req === null ? {} : { requirements: req.read }), ...(input.alts === undefined ? {} : { alts: input.alts }) },
2479
2500
  { partsJson: join(out, 'parts.json'), partsDir: join(out, 'parts'), recomposite: join(out, 'recomposite_rig.png'), errorMap: join(out, ERROR_MAP_FILE) },
2480
2501
  prefixed('assemble'),
2481
2502
  );
2503
+ supportFigure = assembled?.supportFigure;
2482
2504
  } catch (err) {
2483
2505
  return refused('assemble', err);
2484
2506
  }
@@ -2491,7 +2513,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
2491
2513
 
2492
2514
  let report: CheckReport;
2493
2515
  try {
2494
- report = stages.check({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges, packShape: input.packShape, ...(req === null ? {} : { requirements: req.read, requirementsAs: req.recorded }), ...(paintingForCheck === null ? {} : { source: paintingForCheck }), seamPairs: input.seamPairs === true, keepStills: input.keepStills === true, noExtremes: input.noExtremes === true, ...(input.set === undefined ? {} : { set: input.set }), allSets: input.allSets === true, ...(input.environment === undefined ? {} : { environment: input.environment }), ...(input.windBand === undefined ? {} : { windBand: input.windBand }), ...(configRecord === null ? {} : { config: configRecord }) }, run.check, run.checkBin, prefixed('check'));
2516
+ report = stages.check({ rig: join(out, 'rig'), parts: out, supportSource: input.source, ...(supportFigure === undefined ? {} : { supportFigure }), out: join(out, 'check'), pageEdges: input.pageEdges, packShape: input.packShape, ...(req === null ? {} : { requirements: req.read, requirementsAs: req.recorded }), ...(paintingForCheck === null ? {} : { source: paintingForCheck }), seamPairs: input.seamPairs === true, keepStills: input.keepStills === true, noExtremes: input.noExtremes === true, ...(input.set === undefined ? {} : { set: input.set }), allSets: input.allSets === true, ...(input.environment === undefined ? {} : { environment: input.environment }), ...(input.windBand === undefined ? {} : { windBand: input.windBand }), ...(configRecord === null ? {} : { config: configRecord }) }, run.check, run.checkBin, prefixed('check'));
2495
2517
  } catch (err) {
2496
2518
  return refused('check', err);
2497
2519
  }