rig-parts 1.84.0 → 1.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.ts CHANGED
@@ -723,6 +723,8 @@ usage:
723
723
  rig-parts assemble --source <painting.png> --full <dir|psd> [--head <dir|psd>]
724
724
  --config <config.json> --out <dir> [--seam near-white|silhouette]
725
725
  [--project core|visible] [--alt <name>=<source>:<full>[:<head>]]
726
+ [--requirements <file.json>]
727
+ With cut_material declared, records original source edges bound to emitted parts for check.
726
728
  Merge the full-body and head-crop See-through runs into rig-space parts:
727
729
  <out>/rig/parts/<name>.png (each cropped to its alpha box), <out>/rig/parts.json,
728
730
  <out>/render/recomposite_rig.png and <out>/render/${ERROR_MAP_FILE} (the error
@@ -1082,7 +1084,7 @@ const TAKES = {
1082
1084
  compare: { valued: ['--left', '--right', '--map'], switches: [] },
1083
1085
  check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements', '--set', '--environment', '--wind-band'], switches: ['--seam-pairs', '--keep-stills', '--all-sets', '--no-extremes'] },
1084
1086
  loop: { valued: ['--frames', '--out'], switches: ['--palette'] },
1085
- assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project', '--alt', '--write-config'], switches: ['--propose-plan'] },
1087
+ assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project', '--alt', '--write-config', '--requirements'], switches: ['--propose-plan'] },
1086
1088
  'comfy seethrough': { valued: ['--image', '--out', '--resolution', '--steps', '--seed', '--prefix', '--host', '--wait', '--timeout', '--poll', '--config', '--depth-run'], switches: ['--offload', '--no-offload', '--lama', '--nf4'] },
1087
1089
  'comfy paint': { valued: ['--config', '--out', '--seeds', '--seed0', '--host', '--wait', '--timeout', '--poll'], switches: [] },
1088
1090
  inputs: { valued: ['--source', '--config', '--out'], switches: [] },
@@ -1707,6 +1709,7 @@ function cmdAssemble(args: string[]): number {
1707
1709
  }
1708
1710
  // --head is optional (issue #359): without it the full run is the one run, and it must hold the face.
1709
1711
  for (const f of ['--source', '--full', '--config']) if (!flags.has(f)) return usage(`assemble needs ${f}`);
1712
+ if (propose && flags.has('--requirements')) return usage('--requirements belongs to assemble output, not --propose-plan');
1710
1713
  if (propose && (flags.has('--out') || flags.has('--seam') || flags.has('--project') || alts.length > 0)) return usage('--propose-plan prints to the console; it takes none of --out, --seam, --project, --alt');
1711
1714
  if (!propose && flags.has('--write-config')) return usage('--write-config belongs to assemble --propose-plan');
1712
1715
  if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
@@ -1739,7 +1742,7 @@ function cmdAssemble(args: string[]): number {
1739
1742
  }
1740
1743
  const out = flags.get('--out') as string;
1741
1744
  assembleStage(
1742
- { source, full, head, config, seam: seam as SeamRule, project: project as ProjectRule, ...(alts.length === 0 ? {} : { alts }) },
1745
+ { source, full, head, config, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), seam: seam as SeamRule, project: project as ProjectRule, ...(alts.length === 0 ? {} : { alts }) },
1743
1746
  { partsJson: join(out, 'rig', 'parts.json'), partsDir: join(out, 'rig', 'parts'), recomposite: join(out, 'render', 'recomposite_rig.png'), errorMap: join(out, 'render', ERROR_MAP_FILE) },
1744
1747
  console.log,
1745
1748
  );
package/docs/AUTHORING.md CHANGED
@@ -4067,6 +4067,93 @@ consumer acceptance work; assembly provenance does not establish motion quality.
4067
4067
 
4068
4068
  <a id="source-before-scissors-issue-819"></a>
4069
4069
 
4070
+ A hand polygon ending at a joint supplies no material for that joint's turn
4071
+ (issue #888). An `overlap` keeps pixels on the **parent**; it does not grow
4072
+ the cut piece. No source for material past the joint means stop and name the
4073
+ tool gap, following the source ladder below; do not hide a tear with crops or
4074
+ under-pieces. With an explicit `cut_material` declaration, `assemble` prints
4075
+ each original polygon edge's length through
4076
+ opaque source art (`alpha > 8`) and contiguous material past it on the piece.
4077
+ The same reading covers a limb's original structural joint line and the
4078
+ lower segment together with its generated hybrid band, when present.
4079
+ Assembly readings are SKIP-class observations; only check judges the declared bend.
4080
+ Without a declaration the API reading is SKIP and the CLI keeps its existing output.
4081
+ Limb source intersections use the final segmentation ownership map separately
4082
+ for each adjacent source; another limb's island is excluded before capture.
4083
+
4084
+ To enforce a bend, add `cut_material` to the existing
4085
+ `spine-parts-requirements/1` file (the other fields remain required):
4086
+
4087
+ ```json
4088
+ {
4089
+ "spec": "spine-parts-requirements/1", "fps": 12, "requirements": [],
4090
+ "cut_material": [{
4091
+ "part": "hand", "joint": "wrist", "at": [8, 12], "axis": [0, 1],
4092
+ "reach_px": 3, "bend_degrees": [-30, 30]
4093
+ }]
4094
+ }
4095
+ ```
4096
+
4097
+ These are hand-computable fixture coordinates, not recommended rig values.
4098
+ `at` is an independently supplied joint centre in stage pixels (y down);
4099
+ `axis` identifies an original opaque cut inward within reach, checked against
4100
+ the source evidence. Every polygon edge retains its own canonical inward
4101
+ normal; a declaration matching none of the eligible edges is refused.
4102
+ Correspondence accepts exact perpendicularity to the original edge direction
4103
+ (zero dot product), or exact collinearity with its stored canonical inward
4104
+ (zero cross product); the inward cross-product sign must also agree.
4105
+ Positive scalar multiples such as `[1,1]` and `[2,2]` are equivalent; rounded
4106
+ unit components are never compared. Orientation arithmetic must be finite and
4107
+ the direction magnitude representable, otherwise the declaration is refused.
4108
+ `reach_px` is the author's explicit joint reach, and `bend_degrees` is the
4109
+ continuous interval in stage coordinates, positive clockwise, inside
4110
+ `[-180, 180]`. Nothing is inferred from a name or an animation. A limb's joint
4111
+ name and centre must match its recorded lower segment; segment0 and a
4112
+ standalone generated band have no lower structural cut to read.
4113
+
4114
+ Run `assemble --requirements <file>` (or `build --requirements <file>`)
4115
+ first, then `check --requirements <file>` against that assembly. Only an
4116
+ explicit `cut_material` declaration writes `cut_material_sources.json`
4117
+ beside `parts.json`: original edge endpoints, inward direction, opaque
4118
+ source intervals split at pixel-cell boundaries (ordered, nonoverlapping
4119
+ `opaque_t` spans inside `[0,1]` on their actual edge), source labels and
4120
+ transformed RGBA digests including placement and size, bound to the emitted `parts.json` and every base PNG by SHA-256.
4121
+ `check` also verifies the complete source payload digest, the interval census
4122
+ and canonical perpendiculars. Listed source edges must contain positive
4123
+ opaque intervals; an empty array cannot hide an edge beside another one.
4124
+ `check` never reconstructs discarded source pixels or polygons from siblings.
4125
+ Missing or stale evidence is unavailable, not zero support. Assemble again to
4126
+ refresh it. Alternate-set source evidence is not interchangeable with the base
4127
+ assembly; this instrument currently reads the base parts only.
4128
+
4129
+ The gate clips every original opaque edge interval to the declared reach.
4130
+ Let its tangent be `(-ny, nx)` for inward normal `n`. A point relative to the
4131
+ joint has **both** coordinates `(t, d)`, where `d` is the actual cut plane's
4132
+ normal offset. Inverse rotation gives `(t*cos(theta) - d*sin(theta),
4133
+ t*sin(theta) + d*cos(theta))`. Required outward material, measured from the
4134
+ actual cut plane, is `max(0, d*(1-cos(theta)) - t*sin(theta))`; the normal offset
4135
+ is never dropped or projected onto the joint plane.
4136
+
4137
+ The entire actual line segment is tested against every relevant transparent
4138
+ pixel cell's outward normal shadow. Segment/polygon incidence can change only
4139
+ when a rotating endpoint crosses an edge, the rotating source line crosses a
4140
+ vertex, or edges become parallel. All critical angles are solved analytically;
4141
+ interval interiors and boundary angles cover the whole declared continuous
4142
+ range, including a fixed angle. A strict intersection supplies a witness and
4143
+ its contiguous depth, ending at the first transparent cell. Midpoint rays are
4144
+ additional diagnostics, never the coverage proof. No angular sampling rate or
4145
+ calibrated tolerance is used. Controls plant a between-ray hole and translated
4146
+ and oblique cut edges whose inverse rotations are computed by hand.
4147
+
4148
+ `CHECK_CUT_JOINT_MATERIAL` names part, joint, original edge length, length
4149
+ within reach and found/required depth on the same deficient ray. The interval
4150
+ and pixel-cell geometry are measured; subpixel texture filtering, another
4151
+ motion, material outside the declared reach and consumer compositing remain
4152
+ outside this gate. An absent source crossing or unrepresentable arithmetic is
4153
+ `CHECK_CUT_MATERIAL_MEASURED`, never PASS. Without a declaration the API reading
4154
+ is SKIP; the CLI preserves its existing stdout and adds no sidecar, `parts.json` field,
4155
+ `check.json` field or counted bar to an undeclared build.
4156
+
4070
4157
  **Scissors are for structure, never for repair.** `cuts`, `patches` and
4071
4158
  `author:<label>` layers separate something that must move independently and
4072
4159
  that See-through has no layer for: a joint, an accessory, or a rear piece that
@@ -4934,6 +5021,9 @@ documents do not ship). Each is an input this package built; none is an author's
4934
5021
  | <a id="CHECK_INPUT_PRESENT"></a><a id="CHECK_INPUT_IS_JSON"></a><a id="CHECK_PART_PNG_PRESENT"></a><a id="CHECK_PART_PNG_MATCHES_BOX"></a><a id="CHECK_PART_SLOT_PRESENT"></a><a id="CHECK_RIG_STAGE_PRESENT"></a><a id="CHECK_RIG_STAGE_IS_THE_CANVAS"></a><a id="CHECK_RIG_ROOT_BONE"></a><a id="CHECK_IDLE_PRESENT"></a>`CHECK_INPUT_PRESENT`, `CHECK_INPUT_IS_JSON`, `CHECK_PART_PNG_PRESENT`, `CHECK_PART_PNG_MATCHES_BOX`, `CHECK_PART_SLOT_PRESENT`, `CHECK_RIG_STAGE_PRESENT`, `CHECK_RIG_STAGE_IS_THE_CANVAS`, `CHECK_RIG_ROOT_BONE`, `CHECK_IDLE_PRESENT` | the rig directory is incomplete or disagrees with `parts.json` (`CHECK_PART_SLOT_PRESENT`: a part with no slot of its own name, which the judgement lines render it by; `CHECK_INPUT_PRESENT` on `--set` / `--all-sets`: no `parts.json`, no `alternates`, or the named set is not in `alternates`). `motion.json` is required even with no animation in it, because `rigc build` takes `--motion` (a spec with `"animations": {}` builds green); `CHECK_IDLE_PRESENT` is an `idle` that is there with no positive `duration` — an absent `idle` is measured around (§7). `CHECK_INPUT_PRESENT` on `…/parts.json`: `--parts` named a directory without `parts.json` — most often `parts/` itself — or, without `--parts`, the rig directory has none while the directory above it does (build's layout, `<out>/rig` under `<out>/parts.json`). The detail says it: "--parts names the directory holding parts.json and parts/, not parts/ itself (after build, that is build's --out, whose rig is <out>/rig); without --parts it is --rig", and names the parent when the parent holds `parts.json`. With neither (no `--parts`, and no `parts.json` beside `rig.json` or above it), the rig is measured without parts and nothing is refused (§7) | re-run rig (`build` does both); for `…/parts.json`, `--parts` — the directory above `parts/` |
4935
5022
  | <a id="CHECK_SOURCE_SIZE"></a>`CHECK_SOURCE_SIZE` | the painting `--source` names is not this rig's: no single `rig_scale` takes its width and height to the stage's (assemble makes the canvas `trunc(w × rig_scale)` x `trunc(h × rig_scale)`), or, with `parts.json` read, its `scale_rig_per_source` does not; or the stage is not whole pixels. Refused before anything is built | the painting the rig was made from — the stage's own size, or the painting `assemble` was given |
4936
5023
  | <a id="CHECK_SOURCE_PNG"></a>`CHECK_SOURCE_PNG` | `--source` names a file that does not read as a PNG | a PNG |
5024
+ | <a id="CHECK_SETUP_MATCHES_PAINTING"></a>`CHECK_SETUP_MATCHES_PAINTING` | region, slot, failing-pixel box, slot pixelcount, complete region pixelcount and declared limit | compare the source painting beside the actual setup; follow [Source before scissors](#source-before-scissors), then rerun with the same authored limit |
5025
+ | <a id="CHECK_SETUP_PAINTING_SOURCE"></a>`CHECK_SETUP_PAINTING_SOURCE` | a declaration without a source painting | supply `--source`; the assembled composite is not an independent reference |
5026
+ | <a id="CHECK_SETUP_PAINTING_MEASURED"></a>`CHECK_SETUP_PAINTING_MEASURED` | missing native setup/ownership or unsupported clipping | supply observable textured setup geometry; an unavailable instrument never passes |
4937
5027
  | <a id="CHECK_SOURCE_GRID"></a>`CHECK_SOURCE_GRID` | the setup-pose still and its black-tinted twin (the coverage `SETUP_POSE_VS_SOURCE` reads) did not come back on one grid over one opaque background | a rigc problem; report it |
4938
5028
  | <a id="CHECK_RIGC_VERSION"></a>`CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on rig-c ^2.20.4), or put a newer `rigc` first on `PATH` |
4939
5029
  | <a id="CHECK_RIGC_ENTRY"></a><a id="CHECK_RIGC_ENTRY_READS"></a>`CHECK_RIGC_ENTRY`, `CHECK_RIGC_ENTRY_READS` | `rigc --version` names no entry (a rigc below 2.0.0), or names one in neither launcher form (`entry: cli.ts — @esotericsoftware/spine-core <v> present`, `entry: cli_core.ts — @esotericsoftware/spine-core absent — …`); `check.json`'s `rigc_entry` records the one that gated the build | `bun install` |
@@ -4987,6 +5077,8 @@ documents do not ship). Each is an input this package built; none is an author's
4987
5077
  | <a id="REQUIREMENTS_TARGET"></a>`REQUIREMENTS_TARGET` | a scene target places one bone twice for one animation, places it for an animation no requirement measures, has a key outside the animation's `[0, duration]`, or sits in a group the animation translates | one placement per bone and animation, inside it, for an animation a requirement reads |
4988
5078
  | <a id="REQUIREMENTS_SELF_TARGET"></a>`REQUIREMENTS_SELF_TARGET` | a `contact` whose `target` is its own `bone` and `point` (0 apart by construction), or an `aim` whose `target` is a point of the aimed bone itself — its tip lies on its own axis (0 degrees by construction), its origin gives no line at all (issue #540): a PASS would say nothing. Refused before anything is built, beside the file's other refusals; a bone's tip against its own origin is a contact like any other (it reads the bone's length), and a stage target is never one | a different bone (or a stage point, for an aim), or for a contact the bone's other point |
4989
5079
  | <a id="CHECK_REQUIREMENTS_FRAMES"></a>`CHECK_REQUIREMENTS_FRAMES` | a requirement render did not write every frame rigc sampled, or the as-declared, released and full renders of a follow sampled different frames | a rigc problem; report it |
5080
+ | <a id="CHECK_CUT_JOINT_MATERIAL"></a>`CHECK_CUT_JOINT_MATERIAL` | A declared `cut_material` source ray within reach needs more depth during its continuous bend interval than its final piece supplies; the message names part, joint, original opaque edge length, found and required material | follow [Source before scissors](#source-before-scissors); obtain source material past the joint, or explicitly declare the consumer's smaller required bend; no crop or under-piece repair |
5081
+ | <a id="CHECK_CUT_MATERIAL_MEASURED"></a>`CHECK_CUT_MATERIAL_MEASURED` | Original source evidence is missing, malformed or stale, a part/joint correspondence is unresolved, the inward axis disagrees, or no opaque crossing lies within reach | rerun `assemble --requirements <file>` or `build --requirements <file>` to bind original source readings to the actual emitted parts; correct an independently declared correspondence/reach; never infer original art from emitted siblings |
4990
5082
  | <a id="CHECK_REQUIREMENT_MET"></a>`CHECK_REQUIREMENT_MET` | a declared requirement measured outside its bar; the figure, its frame and the bar are quoted | the rig or its motion (§7, *Declared requirements*) — or the bar, if the scene asks less |
4991
5083
  | <a id="CHECK_REQUIREMENT_MEASURABLE"></a>`CHECK_REQUIREMENT_MEASURABLE` | a declared requirement is NOT MEASURABLE: what it asks is undefined in the frames, and the reason is quoted. Not a pass | the declaration (a bone with a length, a target off the bone's origin, a least drive the constraint reaches) or the rig |
4992
5084
  | <a id="REQUIREMENTS_MAY_FOLD"></a>`REQUIREMENTS_MAY_FOLD` | a `may_fold` entry names a slot with no mesh attachment — a region is one quad and cannot fold (issue #320). Refused before anything is built. Before issue #333 an entry naming the idle was refused here too; the bar reads the idle now, and `idle` is an animation name like any other | a mesh slot |
@@ -5028,6 +5120,28 @@ wind (1, 0), then (40, 0) from 0.5 s, reads `TEXTURE_STRETCH` 32.181 (FAIL, `hai
5028
5120
  triangle 258), two meshes folding under `ANIMATIONS_DO_NOT_FOLD` (FAIL; none without the
5029
5121
  flag) and 8 `CLOTH_SETTLES` warnings for 9 (selftest `CK515`).
5030
5122
 
5123
+ **Joint material contact** (issue #894). Each played `SEAM_OVER_MOTION.rows[]` adds
5124
+ `joint_contact`: `status` (`REPORTED` or `SKIP`), `joints` (declared limb joint
5125
+ labels or a proven body root's bone), and, when observed, the frame counts
5126
+ `joined`, `separated` and `unavailable`, plus `reasons` with each unavailable
5127
+ reason and its observed frame indices. `JOINT_CONTACT` prints one informational
5128
+ line per pair and animation; it is never a bar and changes no PASS, warning or
5129
+ exit status. It counts core 2.84.0's `jointContact.frames` from the same seam
5130
+ render. Core reads edge-connected half-opacity material: initially overlapping
5131
+ single-component pieces are joined when their posed union connects by an edge
5132
+ or overlap, separated when it does not. A diagonal contact is separated. Missing
5133
+ setup overlap, absent drawings, disconnected material or multiple fragments are
5134
+ unavailable with core's reason. A pair without a declared named joint is
5135
+ unavailable even if its arbitrary slot drawings connect. An absent core contact
5136
+ field is unavailable when the frame count is known; absent frame counts or zero
5137
+ samples are SKIP with a reason, never invented joined frames. Unavailable is
5138
+ never added to joined. No pair or animation is a named SKIP in the output.
5139
+ Contact reports material connectivity at core's texel grid, not anatomical
5140
+ intent, sub-cell contact or the absence of local holes. Raw gap, ghost, floor
5141
+ and body-root verdicts keep their existing producer readings. Core 2.83.0's
5142
+ visible-ghost and local-support gap definitions are inherited by the 2.84.0 pin;
5143
+ contact counts do not replace or reinterpret them.
5144
+
5031
5145
  **Physics seam environments** (issue #591). Default: wind [1, 0], gravity [0, 1]. Still: wind [0, 0], gravity [0, 1]. In rig-play, environment(null) returns to Default; still air requires an explicit wind [0, 0]. For a physics rig with readable declared pairs and animations, SEAM_OVER_MOTION adds optional `worlds.default` and `worlds.still` readings. They are informational raw gap/ghost cell counts (including cells below the warning floor), never new bars or warnings; neither changes PASS, exit status or the bar count. Each world records its vectors, status and rows; each row names its pair, animation, drawn/banded frames and gap/ghost maximum and worst frame, cell size and world box. Per-frame hits are not embedded in check.json. A rendered world's `dir` is relative to check.json (`seam_motion/default` or `seam_motion/still`): it retains the complete rigc render, including frames.json, frame images and each animation's seam.json with all raw hits. The directories are published with check.json only after the readings are validated; scratch is removed, and an unrendered SKIP has no directory reference. The original reading and any custom `--environment` schedule keep their existing meaning; the two reference worlds are independent of that schedule. A rig with no wind response reports Still as SKIP with its reason; no band is SKIP, never a measured zero. With no readable declared pair or animation no worlds block is written. The same declared pair list is used for both reference worlds: neighbouring limb segments, explicit seam requirements and the proven body-root declarations described below. Thigh/hem pairs are not inferred. Adding reference worlds to a nonphysics reading stays byte-identical, with no extra key, line or render; issue #686 separately adds body-root declarations and their active-reading bar. Reference renders start physics at frame 0 at 12 fps; they do not certify a composed player scene or another sampling rate. Malformed sidecars or renderer failures remain named refusals.
5032
5146
 
5033
5147
  **Steady-wind seam band** (`--wind-band`, issue #590). `check --wind-band <spec.json>` (or `build --wind-band <spec.json>`, forwarded) sweeps steady horizontal wind across a caller-declared grid and reports the contiguous `[x_lo, x_hi]` band in which each declared seam pair holds `gap <= max_gap` and `ghost <= max_ghost` across every animation in `motion.json`. Every field in `{ "spec": "rig-parts-wind-band/1", "min", "max", "step", "fps", "max_gap", "max_ghost", "pairs" }` is required — nothing falls back to `SEAM_OVER_MOTION`'s pairs, one-cell floor, or fps. At each swept step `x = min + k * step`, `check` runs `rigc render --seam <pairs> --fps <fps> --max 16 --environment` with a single-step steady schedule `{ "spec": "rigc-environment/1", "steps": [{ "t": 0, "wind": [x, 0], "gravity": [0, 1] }] }` (`seam.json` cells are in each pair's coarser drawing texels, independent of `--max`). **What it does not measure:** the sweep is steady wind (`wind: [x, 0], gravity: [0, 1]` at `t = 0`), not a time-varying gust (`x ± a` at 0.2 Hz); transient overshoot from a changing wind is not read by a steady sweep, and the band is documented and reported as the steady-wind band (`mode: "steady"`, `gravity: [0, 1]`). It is a reported line (`status: "REPORTED"`), never a bar: it prints one `WIND_BAND:` summary line plus one `WIND_BAND:` line per pair that breaks inside `[min, max]`, and writes `WIND_BAND` in `check.json` before `PASS`.
@@ -5549,6 +5663,7 @@ Under `--source <painting.png>`, a second reported line, also never a FAIL
5549
5663
  | line | reports | bar | `reference-demo` | `reference-sample` | SKIP when |
5550
5664
  | --- | --- | --- | --- | --- | --- |
5551
5665
  | <a id="SETUP_POSE_VS_SOURCE"></a>`SETUP_POSE_VS_SOURCE` | the setup pose against the painting, by `assemble`'s own `measureRecomposite` over the stage's pixels — `mean_abs` and `within_share` (mean channel, within 8), `error_px` (max channel over 40), `uncovered_error_px` (of those, where the pose has alpha 128 or less), `hole_count` and the largest hole's box — with `painting_px`, `stage_px`, `render_px` and `render_scale` beside them. The painting is resampled onto the stage as assemble resamples it (lanczos3, alpha dropped). rigc renders onto an opaque grey, so the pose over white and its alpha are read off a second still of the same pose with every slot tinted black; both are carried onto the stage with the seam's map turned round (bilinear) | none — status `REPORTED`; assemble applies no bar to the figures it measures, so none is derived here | 3.146 / 93.43 % / 12,399 / 1,732, 518 holes, the largest 61 px at 351,157 6x20 (assemble's recomposite: 2.391 / 95.81 % / 11,050 / 1,564, 453) | 1.095 / 97.30 % / 5,348 / 1,239, 313 holes, the largest 88 px at 479,416 6x29 (assemble's: 0.835 / 97.95 % / 4,512 / 1,185, 259) | not written without `--source` |
5666
+ | <a id="SETUP_MATCHES_PAINTING"></a>`SETUP_MATCHES_PAINTING` | declared region count, native box, limit and actual top contributor rows | PASS/FAIL against authored limits; no limit is SKIP; undeclared regions are not judged |
5552
5667
 
5553
5668
  The figures are assemble's by construction, not a second definition beside them;
5554
5669
  what differs is what they are taken of — the pose rigc draws, not the flat stack —
@@ -6084,8 +6199,69 @@ Unknown fields are refused.
6084
6199
 
6085
6200
  | line | measures | bar | SKIP |
6086
6201
  | --- | --- | --- | --- |
6202
+ | <a id="CUT_JOINT_MATERIAL"></a>`CUT_JOINT_MATERIAL` | Original source edge intervals versus final opaque pixel-cell material over each explicit `cut_material` joint reach and continuous bend interval; full two-coordinate swept-segment coverage plus contiguous normal support | every required source interval has material through its inverse-rotation sweep; a hole between diagnostic rays also fails | no declaration: API SKIP, unchanged CLI stdout, no JSON key or counted bar; missing/stale source evidence or no crossing within reach: named unavailable refusal |
6087
6203
  | <a id="ART_JOINT_DISTANCE"></a>`ART_JOINT_DISTANCE` | Each independently measured art joint versus its explicitly named bone's renderer setup origin, in stage pixels; printed and recorded per joint | distance <= the declared `fixed_skeleton.within_px`, with no rounding slack | no limit declared: every row and the block retain their distances as SKIP, never a pass; no declaration: no line |
6088
6204
 
6205
+ ### Setup against the painting
6206
+
6207
+ `check --source <painting> --requirements <file>` can declare
6208
+ `setup_painting` in the existing `spine-parts-requirements/1` file:
6209
+
6210
+ ```json
6211
+ {
6212
+ "spec": "spine-parts-requirements/1",
6213
+ "fps": 12,
6214
+ "requirements": [],
6215
+ "setup_painting": {
6216
+ "rgb_tolerance": 60,
6217
+ "regions": [
6218
+ { "name": "torso", "box": [10, 20, 40, 60], "max_pixels": 0 }
6219
+ ]
6220
+ }
6221
+ }
6222
+ ```
6223
+
6224
+ These are authored limits, not defaults or calibrated figures. A region's
6225
+ `box` is `[x, y, width, height]` in the declared stage's native pixel grid,
6226
+ x right and y down, with exclusive far edges. Regions must fit the stage.
6227
+ `rgb_tolerance` is an integer from 0 through 255. Count a pixel once when its
6228
+ maximum absolute RGB-channel difference is **strictly greater** than that
6229
+ value. The region passes when its total count is at most its authored
6230
+ nonnegative integer `max_pixels`. Without that limit the region reports
6231
+ **SKIP**, including a zero-count region. An undeclared region has no verdict.
6232
+ Without `setup_painting`, existing output bytes and rendering remain unchanged.
6233
+
6234
+ The observation is the actual renderer's setup pose, without animation or
6235
+ physics advancement, from the already gated setup candidate. Its viewport is
6236
+ the declared stage, scale 1; no fitted alignment or flat part composite is
6237
+ substituted. Source PNG readers preserve straight RGBA. This optional check
6238
+ first composites the painting over opaque white at its **original source
6239
+ resolution**, rounding each RGB channel to RGBA8, then passes that opaque
6240
+ image through the existing `sourceInRig` Lanczos3 resampling onto the stage.
6241
+ The rendered setup is composited over white on the native stage grid. This
6242
+ ordering excludes hidden RGB from both resampling taps and figure detection;
6243
+ the global `sourceInRig` and legacy checks retain their existing RGB semantics.
6244
+ The figure domain is the union of rendered
6245
+ alpha above zero and the painting's existing `figureSilhouette`: border-median
6246
+ background difference, morphological opening and hole filling. This includes
6247
+ missing output over source figure and extra rendered support. It is not an
6248
+ independent segmentation oracle for arbitrary backgrounds.
6249
+
6250
+ `SETUP_MATCHES_PAINTING` reports each region's box, pixel count and limit.
6251
+ Diagnostic slot rows partition that count and report their failing-pixel box.
6252
+ The slot is the **last actual textured drawing with sampled alpha above zero**
6253
+ at that pixel, measured by rig-c's rasterizer, or explicit `(uncovered)`.
6254
+ For translucent stacks this names the top contributor, not the exclusive
6255
+ material owner or the cause of the difference. Overlapping region declarations
6256
+ are measured independently. Clipped drawings whose ownership cannot be observed
6257
+ are refused rather than attributed by a part's bounding box.
6258
+
6259
+ Invalid limits, repeated names, unknown fields and out-of-stage boxes use the existing `REQUIREMENTS_FIELD` refusal; no value is guessed.
6260
+
6261
+ This optional declaration is independent of motion requirements and
6262
+ `fixed_skeleton`; it does not replace their schemas or validation. The named
6263
+ refusals are in the check/build rule table and the observation in the line table.
6264
+
6089
6265
  ## 8. What one character costs
6090
6266
 
6091
6267
  | step | reference-sample | reference-demo | source |
package/docs/STABILITY.md CHANGED
@@ -54,7 +54,7 @@ defaults are the help's too, and none is guessed from the art.
54
54
  | `inputs` | `--source`, `--config`, `--out` |
55
55
  | `comfy` | `paint`: `--config`, `--out`; optional `--seeds`, `--seed0`, `--host`, `--wait`, `--timeout`, `--poll` |
56
56
  | `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) |
57
- | `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) |
57
+ | `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) |
58
58
  | `propose` | `--head-box` with `--full`, `--canvas`; optional `--no-back-hair`, `--write-config` (existing config, issue #356) |
59
59
  | `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) |
60
60
  | `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) |
@@ -501,3 +501,27 @@ no role or depth and changes no emitted file bytes.
501
501
  The unit-depth precedence and its primary-surface sampling are documented in
502
502
  [AUTHORING](AUTHORING.md#unit-depth-precedence).
503
503
  The separate ordering scalar is unchanged. No-image outputs retain legacy bytes.
504
+
505
+ ### Declared setup-painting regions
506
+
507
+ The optional `setup_painting` key in `spine-parts-requirements/1` adds a
508
+ `SETUP_MATCHES_PAINTING` observation only when declared. It requires authored
509
+ `rgb_tolerance` and named native-stage boxes. Each optional `max_pixels` is
510
+ that whole region's limit; omission is SKIP, never PASS. Existing files without
511
+ the key retain their output bytes and renderer calls. Other requirement fields
512
+ remain independent. The [authoring contract](AUTHORING.md#setup-against-the-painting)
513
+ defines white compositing, the figure domain, strict RGB comparison, top
514
+ contributor attribution and named measurement refusals. The check observes the
515
+ actual renderer setup at scale 1; it neither fits the art nor duplicates core
516
+ validation. Only this declared check composites source RGBA over white at its
517
+ original resolution **before** existing Lanczos3 stage resampling. Setup RGBA
518
+ is composited on the native stage grid. Source readers preserve alpha; the
519
+ global RGB-only source helper and checks without this declaration are unchanged.
520
+
521
+ The optional `cut_material` block in `--requirements` declares a part, joint,
522
+ independent stage-pixel centre, inward axis, reach and continuous bend interval.
523
+ `assemble --requirements` and `build --requirements` write source-edge evidence
524
+ only for that explicit block; `check --requirements` refuses unavailable or
525
+ stale evidence rather than reconstructing original art from emitted siblings.
526
+ Without the block the API reading is SKIP; CLI stdout and emitted bytes remain unchanged.
527
+ See [Source before scissors](AUTHORING.md#source-before-scissors).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.84.0",
3
+ "version": "1.86.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": {
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "ag-psd": "^31.0.2",
70
- "@rig-c/core": "2.82.0"
70
+ "@rig-c/core": "2.84.0"
71
71
  },
72
72
  "devDependencies": {
73
73
  "@esotericsoftware/spine-core": "4.3.13",
package/src/assemble.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { edgeLength } from './length.ts';
2
+ import { materialSource, type MaterialSource, materialRows, polygonMaterialEdges, type MaterialRow, type MaterialEdge, type PlacedArt } from './cutmaterial.ts';
1
3
  import { unionTakenSources } from './takesource.ts';
2
4
  import { authoredUnitDepths, type AuthoredUnitDepth } from './authoredunit.ts';
3
5
  import { paintingExtensionMasks, paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
@@ -3934,10 +3936,10 @@ function cutLimbs(
3934
3936
  readHidden: (layer: string, by: string) => void = () => undefined,
3935
3937
  tips?: Record<string, [number, number]>,
3936
3938
  sourceGroups: ReadonlyMap<string, string> = new Map(),
3937
- ): { parts: LimbPart[]; pieces: Built[]; counts: LimbCount[]; capFrom: Array<CapFrom | null> } {
3939
+ ): { parts: LimbPart[]; pieces: Built[]; counts: LimbCount[]; capFrom: Array<CapFrom | null>; material: MaterialRow[]; sources: MaterialSource[] } {
3938
3940
  const logical = (source: string): string => sourceGroups.get(source) ?? source;
3939
3941
  const names = Object.keys(limbs);
3940
- if (names.length === 0) return { parts: [], pieces: [], counts: [], capFrom: [] };
3942
+ if (names.length === 0) return { parts: [], pieces: [], counts: [], capFrom: [], material: [], sources: [] };
3941
3943
  const problems: Problem[] = [];
3942
3944
  const places = bonePlaces(bones, tips);
3943
3945
  const parts: LimbPart[] = [];
@@ -4413,7 +4415,30 @@ function cutLimbs(
4413
4415
  });
4414
4416
  }
4415
4417
  refuseIfAny(problems);
4416
- return { parts, pieces: pieces as Built[], counts, capFrom };
4418
+ const material: MaterialRow[] = [];
4419
+ const sourceReadings: MaterialSource[] = [];
4420
+ for (const l of names) limbs[l].segments.forEach((sg, k) => {
4421
+ if (k === 0) return;
4422
+ const line = (lines.get(l) as Array<JointLine | null>)[k];
4423
+ if (line === null) return;
4424
+ const lo = parts.findIndex(p => p.part === sg.part);
4425
+ const source = [...new Set([limbs[l].segments[k - 1].from, sg.from])].flatMap(name => {
4426
+ const b = builtOf(name);
4427
+ if (b === null) return [];
4428
+ // Match segmentation and cap source ownership, after crossing/hand reassignment.
4429
+ // Evidence must never borrow another limb's island or mutate its source raster.
4430
+ const image = { ...b.r, data: b.r.data.slice() };
4431
+ const owned = owners.get(name), li = names.indexOf(l);
4432
+ for (let p = 0; p < W * H; p++) if (owned?.[p] !== li) image.data.fill(0, p * 4, p * 4 + 4);
4433
+ return [{ x: 0, y: 0, image }];
4434
+ });
4435
+ const own = [lo, ...(sg.hybrid === undefined ? [] : [parts.findIndex(p => p.part === sg.hybrid?.part)])].filter(i => i >= 0).map(i => ({ x: 0, y: 0, image: (pieces[i] as Built).r }));
4436
+ const t: [number, number] = [-line.axis[1], line.axis[0]], r = edgeLength(W, H);
4437
+ const edge: MaterialEdge = { a: [line.at[0] - r * t[0], line.at[1] - r * t[1]], b: [line.at[0] + r * t[0], line.at[1] + r * t[1]], inward: line.axis };
4438
+ sourceReadings.push(materialSource(sg.part, [...new Set([limbs[l].segments[k - 1].from, sg.from])], [edge], source));
4439
+ material.push(...materialRows(sg.part, undefined, [edge], source, own).map(row => ({ ...row, joint: String(sg.joint) })));
4440
+ });
4441
+ return { parts, pieces: pieces as Built[], counts, capFrom, material, sources: sourceReadings };
4417
4442
  }
4418
4443
 
4419
4444
  // ---------------------------------------------------------------------------
@@ -4470,6 +4495,9 @@ export interface AssembleResult {
4470
4495
  figures: RecompositeFigures;
4471
4496
  /** What each `assemble.cuts` entry took, counted when it was made (`cutLines`); empty without cuts. */
4472
4497
  cuts: CutCount[];
4498
+ /** Console-only source-edge support (#888); never serialized into parts.json. */
4499
+ cutMaterial: MaterialRow[];
4500
+ materialSources: MaterialSource[];
4473
4501
  /** What each `config.limbs` limb cut, per part it took and per joint (`limbLines`); empty without limbs (issue #349). */
4474
4502
  limbs: LimbCount[];
4475
4503
  /** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
@@ -5016,11 +5044,14 @@ export function assemble(input: AssembleInput): AssembleResult {
5016
5044
  const mergedIn = (b: Built, region: Mask): number => count(b.trace.lastSel ?? null, region) + count(b.trace.lastRing ?? null, region);
5017
5045
  const pieces: Array<Built | null> = cuts.map(() => null);
5018
5046
  const cutCounts: CutCount[] = [];
5047
+ const cutMaterial: MaterialRow[] = [];
5048
+ const materialSources: MaterialSource[] = [];
5019
5049
  plan.forEach(([name], pi) => {
5020
5050
  const mine = cuts.map((q, i) => i).filter((i) => cuts[i].from === name);
5021
5051
  const b = built[pi];
5022
5052
  if (mine.length === 0 || b === null) return;
5023
5053
  const before = opaqueOf(b.r);
5054
+ const sourceBeforeCuts: PlacedArt = { x: 0, y: 0, image: { ...b.r, data: b.r.data.slice() } };
5024
5055
  const rest = newMask(W, H);
5025
5056
  rest.data.fill(1);
5026
5057
  // The base's own pixels: `rest`, and every cut's band, which it keeps as well as the piece.
@@ -5120,6 +5151,8 @@ export function assemble(input: AssembleInput): AssembleResult {
5120
5151
  if (cuts[i].under_fill === 'colour') c.underFill = 'colour';
5121
5152
  }
5122
5153
  cutCounts.push(c);
5154
+ const piece = pieces[i];
5155
+ if (piece !== null) { materialSources.push(materialSource(cuts[i].into, [b.key], polygonMaterialEdges(cuts[i].polygon), [sourceBeforeCuts])); cutMaterial.push(...materialRows(cuts[i].into, undefined, polygonMaterialEdges(cuts[i].polygon), [sourceBeforeCuts], [{ x: 0, y: 0, image: piece.r }])); }
5123
5156
  });
5124
5157
  });
5125
5158
  // Parent partition counts and under use the removed parent art above. Only
@@ -5131,6 +5164,10 @@ export function assemble(input: AssembleInput): AssembleResult {
5131
5164
  if (opaqueOf(r) === 0) empty.push({ code: 'ASSEMBLE_CUT_PIXELS', object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`, detail: `takes 0 opaque pixels from selected ${source.key}; draw its polygon over that layer's art` });
5132
5165
  const writers = (source.writers as Int32Array).slice();
5133
5166
  pieces[i] = { ...source, r, writers, region: m, merged: mergedIn(source, m) };
5167
+ for (let j = materialSources.length - 1; j >= 0; j--) if (materialSources[j].part === cuts[i].into) materialSources.splice(j, 1);
5168
+ materialSources.push(materialSource(cuts[i].into, [source.key], polygonMaterialEdges(cuts[i].polygon), [{ x: 0, y: 0, image: source.r }]));
5169
+ for (let j = cutMaterial.length - 1; j >= 0; j--) if (cutMaterial[j].part === cuts[i].into) cutMaterial.splice(j, 1);
5170
+ cutMaterial.push(...materialRows(cuts[i].into, undefined, polygonMaterialEdges(cuts[i].polygon), [{ x: 0, y: 0, image: source.r }], [{ x: 0, y: 0, image: r }]));
5134
5171
  });
5135
5172
  refuseIfAny(empty);
5136
5173
  // In cut order, as the config lists them.
@@ -5638,6 +5675,8 @@ export function assemble(input: AssembleInput): AssembleResult {
5638
5675
  figure: inside === null ? null : figureImage(inside, W, H),
5639
5676
  figures,
5640
5677
  cuts: cutCounts,
5678
+ cutMaterial: [...cutMaterial, ...lim.material],
5679
+ materialSources: [...materialSources, ...lim.sources],
5641
5680
  limbs: lim.counts,
5642
5681
  uncovered,
5643
5682
  holeKinds: kinds,
package/src/build.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { SETUP_PAINTING_LINE, setupPaintingLines } from './setuppainting.ts';
2
+ import { writeMaterialSources } from './cutmaterialio.ts';
3
+ import { CUT_MATERIAL_LINE, CUT_MATERIAL_SOURCES, materialLines } from './cutmaterial.ts';
1
4
  import { ART_JOINT_LINE, artJointLines } from './artjoints.ts';
2
5
  import { applyTakenLayers } from './takelayers.ts';
3
6
  import { ownershipLines, provenanceLines } from './provenance.ts';
@@ -78,7 +81,7 @@ import {
78
81
  splitSchedule,
79
82
  untestedIntervals,
80
83
  } from './autoreplay.ts';
81
- import { besideIdleLine, checkSummaryLine, inputFileRecord, type InputFileRecord, CLOTH_LINE, type ClothLine, clothWarnText, frameRuns, MOTION_SEAM_LINE, type MotionSeamLine, type MotionSeamPlant, motionSeamWarnText, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, environmentLine, 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, readCheckEnvironment, readWindBand, 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, WIND_BAND_LINE, windBandLines } from './check.ts';
84
+ import { besideIdleLine, checkSummaryLine, inputFileRecord, type InputFileRecord, CLOTH_LINE, type ClothLine, clothWarnText, frameRuns, MOTION_SEAM_LINE, type MotionSeamLine, type MotionSeamPlant, motionSeamWarnText, motionJointContactLines, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, environmentLine, 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, readCheckEnvironment, readWindBand, 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, WIND_BAND_LINE, windBandLines } from './check.ts';
82
85
  import { type Cut, type Extend, type Limb, loadConfigAndAnimations, loadConfigWithBytes, loadEarlyConfig, type Patch, type PlanEntry } from './config.ts';
83
86
  import { coreCompileGuideLine, failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
84
87
  import type { MeshQualityReport, MotionSchedule } from '@rig-c/core/mesh';
@@ -154,6 +157,8 @@ export interface AlternateCliInput {
154
157
  }
155
158
 
156
159
  export interface AssembleStageInput {
160
+ /** Write source-edge evidence only when cut_material is explicitly declared. */
161
+ requirements?: string;
157
162
  source: string;
158
163
  full: string;
159
164
  /** The head run, or null for one run that holds the face (issue #359). */
@@ -318,6 +323,7 @@ export function frontOrdered<T extends { record: { name: string } }>(items: read
318
323
 
319
324
  /** Read, assemble, and write only after every refusal has had its chance. Throws a PartsError on a refusal, having written nothing. */
320
325
  export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs, log: Log): AssembleResult {
326
+ const materialAsked = input.requirements === undefined ? false : readRequirements(input.requirements).cut_material !== undefined;
321
327
  // The early door: assemble runs before propose has drafted bones, meshes,
322
328
  // regions and motion, so it requires only what it reads (issue #20).
323
329
  const cfg = loadEarlyConfig(input.config, 'assemble');
@@ -772,6 +778,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
772
778
  for (const b of altBuilt) {
773
779
  for (const { name, image } of b.images) writePng(join(outs.partsDir, `${name}.png`), image);
774
780
  }
781
+ if (materialAsked) writeMaterialSources(outs.partsJson, outs.partsDir, result.parts, result.materialSources);
782
+ else rmSync(join(dirname(outs.partsJson), CUT_MATERIAL_SOURCES), { force: true });
775
783
  writePng(outs.recomposite, result.recomposite);
776
784
  writePng(outs.errorMap, result.errorMap);
777
785
  // Issue #242: under config.scene, the figure the recomposite was read inside, beside the recomposite.
@@ -799,6 +807,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
799
807
  for (const l of authorPartLines(result.parts.parts)) log(l);
800
808
  // One line per plan part an assemble.cuts entry cut, before the fringe lines: the cut is made first. None without cuts.
801
809
  for (const l of cutLines(result.cuts)) log(l);
810
+ if (materialAsked) for (const line of materialLines({ status: 'SKIP', rows: result.cutMaterial, reason: 'assemble source readings only; check --requirements judges declared joint material' })) log(` ${line}`);
802
811
  // Issue #349: one line per part a limb took, and one per joint with its cap's pixels. None without limbs.
803
812
  for (const l of limbLines(result.limbs)) log(l);
804
813
  for (const l of fringeLines(result.parts.parts)) log(l);
@@ -1815,7 +1824,7 @@ export function sweepCheckLines(line: SweepLine, steps: ReadonlyMap<string, { le
1815
1824
  export function motionSeamLines(line: MotionSeamLine, plant: MotionSeamPlant = {}): string[] {
1816
1825
  const unmeasured = (line.roots?.unmeasured ?? []).map(root => `root pair for ${root.limb} (${root.part}, bone "${root.bone}", parent "${root.parent}", candidates ${root.candidates.join('/')}): SKIP — ${root.reason}`);
1817
1826
  const rootStatus = line.roots === undefined ? '' : '; body-root gap-open+under-ghost gate ' + line.roots.status + ' (' + line.roots.pairs.length + ' pair(s)); ghost ownership from resolved draw order' + (line.roots.reason === undefined ? '' : ' — ' + line.roots.reason);
1818
- if (line.worlds === undefined && line.status === 'SKIP' && line.rows.length === 0 && line.pairs.every((p) => p.unread === undefined)) return [`${MOTION_SEAM_LINE}: SKIP — ${String(line.reason)}${unmeasured.length === 0 ? '' : rootStatus}`, ...unmeasured];
1827
+ if (line.worlds === undefined && line.status === 'SKIP' && line.rows.length === 0 && line.pairs.every((p) => p.unread === undefined)) return [`${MOTION_SEAM_LINE}: SKIP — ${String(line.reason)}${unmeasured.length === 0 ? '' : rootStatus}`, ...unmeasured, ...motionJointContactLines(line)];
1819
1828
  const read = `${line.pairs.length} declared pair(s) in ${line.animations.length} animation(s) (${line.animations.join(', ')}), read by rigc render --seam at ${line.fps} fps on the build as played`;
1820
1829
  const head =
1821
1830
  line.status === 'SKIP'
@@ -1823,6 +1832,7 @@ export function motionSeamLines(line: MotionSeamLine, plant: MotionSeamPlant = {
1823
1832
  : `${MOTION_SEAM_LINE}: ${line.status} — ${read}: ${line.warnings} warning(s), never a bar${rootStatus}; ${line.floor}`;
1824
1833
  return [
1825
1834
  head,
1835
+ ...motionJointContactLines(line),
1826
1836
  ...unmeasured,
1827
1837
  ...line.pairs.filter((p) => p.unread !== undefined).map((p) => `seam "${p.pair}" (${p.declared.join('; ')}): not read — ${String(p.unread)}`),
1828
1838
  ...line.rows.filter((r) => r.status === 'skip').map((r) => `seam "${r.pair}" in "${r.animation}": SKIP — rigc read no band on any of the ${r.drawn} frame(s) both slots draw, so there is no seam to read`),
@@ -1942,8 +1952,12 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1942
1952
  if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
1943
1953
  const windBand = fig[WIND_BAND_LINE];
1944
1954
  if (windBand !== undefined) for (const l of windBandLines(windBand)) log(` ${l}`);
1955
+ const cutMaterial = fig[CUT_MATERIAL_LINE];
1956
+ if (cutMaterial !== undefined) for (const line of materialLines(cutMaterial)) log(` ${line}`);
1945
1957
  const artJoints = fig[ART_JOINT_LINE];
1946
1958
  if (artJoints !== undefined) for (const line of artJointLines(artJoints)) log(` ${line}`);
1959
+ const setupPainting = fig[SETUP_PAINTING_LINE];
1960
+ if (setupPainting !== undefined) for (const line of setupPaintingLines(setupPainting)) log(` ${line}`);
1947
1961
  if (r.requirements !== null) {
1948
1962
  for (const [name, line] of Object.entries(r.requirements.lines)) log(` ${requirementText(name, line)}`);
1949
1963
  log(` ${summaryText(r.requirements.summary)}`);
@@ -2388,6 +2402,8 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
2388
2402
  const out = input.out;
2389
2403
  mkdirSync(out, { recursive: true });
2390
2404
  for (const p of BUILD_OWNS) rmSync(join(out, p), { recursive: true, force: true });
2405
+ // Optional source evidence is cleared separately; BUILD_OWNS is the legacy complete output tree.
2406
+ rmSync(join(out, CUT_MATERIAL_SOURCES), { force: true });
2391
2407
  // Issue #242: the figure mask is written only under config.scene; one left by an earlier scene build is not this build's.
2392
2408
  rmSync(join(out, FIGURE_MASK_FILE), { force: true });
2393
2409
  // 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.
@@ -2413,7 +2429,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
2413
2429
  // And the steady-wind band spec (issue #590), as check reads it.
2414
2430
  if (input.windBand !== undefined) readWindBand(input.windBand);
2415
2431
  stages.assemble(
2416
- { source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project, ...(input.alts === undefined ? {} : { alts: input.alts }) },
2432
+ { source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project, ...(req === null ? {} : { requirements: req.read }), ...(input.alts === undefined ? {} : { alts: input.alts }) },
2417
2433
  { partsJson: join(out, 'parts.json'), partsDir: join(out, 'parts'), recomposite: join(out, 'recomposite_rig.png'), errorMap: join(out, ERROR_MAP_FILE) },
2418
2434
  prefixed('assemble'),
2419
2435
  );