rig-parts 1.83.2 → 1.85.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 |
@@ -5118,6 +5210,7 @@ and stay absent).
5118
5210
  | <a id="RIG_TURN_SURFACE_OVERLAP_DEPTH"></a>`RIG_TURN_SURFACE_OVERLAP_DEPTH` | same-group overlap neighbours demand incompatible emitted offsets at one target setup point | separate the conflicting support or provide coincident neighbour fields; the refusal names the fields and target vertex |
5119
5211
  | <a id="RIG_TURN_SURFACE_MESH_BINDINGS"></a>`RIG_TURN_SURFACE_MESH_BINDINGS` | an authored surface mesh has unresolved/nonclosing weights or shares a frame moved by a unit/rear turn carrier; declared stop draw aliases resolve to their links for setup sampling | provide weighted bindings closing at 1 in the authored frame; `<link>_d` resolves only for links declared in `config.stops`; use independent chains for parts with different roles |
5120
5212
  | <a id="RIG_TURN_SURFACE_MESH_ATTACHMENT"></a>`RIG_TURN_SURFACE_MESH_ATTACHMENT` | a surface mesh has an alternate drawing without its own registered depth sampling frame | use the base attachment; explicit islands retain their own slot, attachment and bindings |
5213
+ | <a id="RIG_TURN_BODY_UNCOVERED"></a>`RIG_TURN_BODY_UNCOVERED` | **Warning:** a shown part without a body role overlaps a moved `turn.body` surface at setup alpha >= 128 and has different emitted endpoint displacement | declare the intended body role and inspect both endpoints; no role or depth is inferred |
5121
5214
  | <a id="RIG_TURN_SURFACE_DEPTH_REQUIRED"></a>`RIG_TURN_SURFACE_DEPTH_REQUIRED` | a part with role `"surface"` in `config.turn.roles` or `config.turn.body.roles` carries no depth sheet in `parts.json` | run `assemble` with `config.depth` or provide `<part>_depth.png` in `parts.json` |
5122
5215
  | <a id="RIG_TURN_GRID_BUDGET"></a>`RIG_TURN_GRID_BUDGET` | a `"surface"` grid exceeds 2,400 triangles on one slot or 4,000 triangles across all `"surface"` slots | lower `config.turn.grid` / `config.turn.body.grid` `[cols, rows]` within the per-slot and total budgets |
5123
5216
  | <a id="RIG_TURN_HEAD_BONE"></a>`RIG_TURN_HEAD_BONE` | `buildRig` found no `"head"` (or body turn root) bone when emitting 2.5D turn carriers | declare `"head"` (or the body turn root bone) in `config.bones` |
@@ -5548,6 +5641,7 @@ Under `--source <painting.png>`, a second reported line, also never a FAIL
5548
5641
  | line | reports | bar | `reference-demo` | `reference-sample` | SKIP when |
5549
5642
  | --- | --- | --- | --- | --- | --- |
5550
5643
  | <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` |
5644
+ | <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 |
5551
5645
 
5552
5646
  The figures are assemble's by construction, not a second definition beside them;
5553
5647
  what differs is what they are taken of — the pose rigc draws, not the flat stack —
@@ -6020,6 +6114,132 @@ on a bone of length 0 are NOT MEASURABLE and the run is not PASS (`CK68`–`CK79
6020
6114
  frame the slide peaks; a seam on a rig with no `parts.json` is refused before anything
6021
6115
  is built (`CK83`–`CK85`, `RQ30`–`RQ35`).
6022
6116
 
6117
+ ### Art joints on a fixed skeleton
6118
+
6119
+ When art is fitted to a skeleton whose setup must stay fixed, declare
6120
+ `fixed_skeleton` in the existing `--requirements` file (issue #880):
6121
+
6122
+ ```json
6123
+ {
6124
+ "spec": "spine-parts-requirements/1",
6125
+ "fps": 12,
6126
+ "requirements": [],
6127
+ "fixed_skeleton": {
6128
+ "source": "independent joint measurements on the fitted art",
6129
+ "within_px": 4,
6130
+ "joints": [
6131
+ { "name": "shoulder", "bone": "upper_arm", "at": [26, 57] }
6132
+ ]
6133
+ }
6134
+ }
6135
+ ```
6136
+
6137
+ The block explicitly identifies this as a fixed-skeleton comparison; a generation
6138
+ control pose does not. `source` is required nonempty provenance text, recorded
6139
+ without interpretation. Each `at` is an independently measured point on the art
6140
+ **as placed on the rig's stage**, in stage pixels from its top-left, x right and y
6141
+ down, the same space as a requirement's `target.stage`. Convert painting/source
6142
+ coordinates through the actual placement before declaring them: raw source pixels,
6143
+ bone-local offsets and normalized coordinates are not stage pixels. Each nonempty
6144
+ `name` is unique and each `bone` explicitly names its matching bone origin in
6145
+ `rig.json`. No anatomical names, hidden joints, correspondences or positions are
6146
+ inferred; only the listed measurements are covered. An unmeasured joint must not
6147
+ be supplied as the bone's own position.
6148
+
6149
+ `check` uses the existing setup-still renderer, requesting `--geometry`, and reads
6150
+ `geometry.json`'s `setup.bones` through its existing geometry reader. It converts
6151
+ the renderer's setup-world origin to stage pixels through the coordinate door;
6152
+ it never substitutes an animation's first frame or a bone's parent-local `x,y`.
6153
+ This also works for an external `rig.json` with no `parts.json` or idle. The
6154
+ Euclidean distance, art point and setup origin are printed **for every declared
6155
+ joint** and written to `check.json`'s `ART_JOINT_DISTANCE`, along with `source`,
6156
+ `instrument` and `within_px`. This is a setup-placement check, not a motion check
6157
+ or automatic detection of joints in an image.
6158
+
6159
+ `within_px` is optional and, when present, must be finite and nonnegative. The
6160
+ example's `4` is an authored demonstration, not a calibrated or default tolerance.
6161
+ Distance **greater than** the declared limit fails; equality passes. Without a
6162
+ limit, the block and every joint are **SKIP**, with the measured distances and
6163
+ `within_px: null`; this line is excluded from measured bars and contributes no
6164
+ PASS. Other independent checks may still pass. With no `fixed_skeleton` block,
6165
+ this measurement adds no output or render call. `fps` and `requirements` remain
6166
+ required by the enclosing file; they do not change this setup measurement.
6167
+
6168
+ | rule | change |
6169
+ | --- | --- |
6170
+ | <a id="CHECK_ART_JOINT_DISTANCE"></a>`CHECK_ART_JOINT_DISTANCE` | The named joint is farther from its setup bone origin than `fixed_skeleton.within_px`; the message gives distance and limit. Correct a wrong art measurement or bone correspondence, or supply art aligned to the fixed skeleton. Choose any changed limit explicitly from the consumer's requirement; bones and art are never moved by this check. |
6171
+ | <a id="CHECK_ART_JOINT_MEASURED"></a>`CHECK_ART_JOINT_MEASURED` | Supply a finite renderer setup origin and a finite measurable distance; missing geometry is never taken as zero. The existing `CHECK_GEOMETRY_FILE` refusal names missing/malformed geometry. |
6172
+
6173
+ `REQUIREMENTS_FIELD` and `REQUIREMENTS_RESOLVES` cover malformed declarations:
6174
+ supply nonempty provenance and a nonempty joint list, unique joint names, finite
6175
+ `[x, y]` points, known bone names and an explicit valid limit or omit the limit.
6176
+ Unknown fields are refused.
6177
+
6178
+ | line | measures | bar | SKIP |
6179
+ | --- | --- | --- | --- |
6180
+ | <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 |
6181
+ | <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 |
6182
+
6183
+ ### Setup against the painting
6184
+
6185
+ `check --source <painting> --requirements <file>` can declare
6186
+ `setup_painting` in the existing `spine-parts-requirements/1` file:
6187
+
6188
+ ```json
6189
+ {
6190
+ "spec": "spine-parts-requirements/1",
6191
+ "fps": 12,
6192
+ "requirements": [],
6193
+ "setup_painting": {
6194
+ "rgb_tolerance": 60,
6195
+ "regions": [
6196
+ { "name": "torso", "box": [10, 20, 40, 60], "max_pixels": 0 }
6197
+ ]
6198
+ }
6199
+ }
6200
+ ```
6201
+
6202
+ These are authored limits, not defaults or calibrated figures. A region's
6203
+ `box` is `[x, y, width, height]` in the declared stage's native pixel grid,
6204
+ x right and y down, with exclusive far edges. Regions must fit the stage.
6205
+ `rgb_tolerance` is an integer from 0 through 255. Count a pixel once when its
6206
+ maximum absolute RGB-channel difference is **strictly greater** than that
6207
+ value. The region passes when its total count is at most its authored
6208
+ nonnegative integer `max_pixels`. Without that limit the region reports
6209
+ **SKIP**, including a zero-count region. An undeclared region has no verdict.
6210
+ Without `setup_painting`, existing output bytes and rendering remain unchanged.
6211
+
6212
+ The observation is the actual renderer's setup pose, without animation or
6213
+ physics advancement, from the already gated setup candidate. Its viewport is
6214
+ the declared stage, scale 1; no fitted alignment or flat part composite is
6215
+ substituted. Source PNG readers preserve straight RGBA. This optional check
6216
+ first composites the painting over opaque white at its **original source
6217
+ resolution**, rounding each RGB channel to RGBA8, then passes that opaque
6218
+ image through the existing `sourceInRig` Lanczos3 resampling onto the stage.
6219
+ The rendered setup is composited over white on the native stage grid. This
6220
+ ordering excludes hidden RGB from both resampling taps and figure detection;
6221
+ the global `sourceInRig` and legacy checks retain their existing RGB semantics.
6222
+ The figure domain is the union of rendered
6223
+ alpha above zero and the painting's existing `figureSilhouette`: border-median
6224
+ background difference, morphological opening and hole filling. This includes
6225
+ missing output over source figure and extra rendered support. It is not an
6226
+ independent segmentation oracle for arbitrary backgrounds.
6227
+
6228
+ `SETUP_MATCHES_PAINTING` reports each region's box, pixel count and limit.
6229
+ Diagnostic slot rows partition that count and report their failing-pixel box.
6230
+ The slot is the **last actual textured drawing with sampled alpha above zero**
6231
+ at that pixel, measured by rig-c's rasterizer, or explicit `(uncovered)`.
6232
+ For translucent stacks this names the top contributor, not the exclusive
6233
+ material owner or the cause of the difference. Overlapping region declarations
6234
+ are measured independently. Clipped drawings whose ownership cannot be observed
6235
+ are refused rather than attributed by a part's bounding box.
6236
+
6237
+ Invalid limits, repeated names, unknown fields and out-of-stage boxes use the existing `REQUIREMENTS_FIELD` refusal; no value is guessed.
6238
+
6239
+ This optional declaration is independent of motion requirements and
6240
+ `fixed_skeleton`; it does not replace their schemas or validation. The named
6241
+ refusals are in the check/build rule table and the observation in the line table.
6242
+
6023
6243
  ## 8. What one character costs
6024
6244
 
6025
6245
  | step | reference-sample | reference-demo | source |
@@ -6324,6 +6544,26 @@ art. An authored `parts.json` depth reference remains an active standalone
6324
6544
  guards still apply. With binding inactive there is no added reference, file or
6325
6545
  legacy-byte change. #773 adds no image generation, geometry or motion model.
6326
6546
 
6547
+ ### Missing body-turn coverage
6548
+
6549
+ `RIG_TURN_BODY_UNCOVERED` warns when a shown part without a
6550
+ `turn.body.roles` entry overlaps a moved body surface. It names both parts,
6551
+ both endpoint times, their actual emitted displacement vectors and the maximum
6552
+ relative displacement at a setup painting-pixel centre. Both drawings require
6553
+ alpha >= 128 and actual covering setup triangles; bounding boxes and transparent
6554
+ pixels alone do not count. Excluded alpha does not count. The diagnostic reads
6555
+ positive bindings in the declared torso subtree, excluding head/neck subtrees.
6556
+ Explicit body roles and bindings wholly inheriting an emitted body-turn carrier
6557
+ count as declared coverage; identical inherited motion needs no second role. Differences no larger than the producer's six-place
6558
+ encoding unit (0.000001 rig px) are ignored. This is a warning about a missing
6559
+ declaration, not an automatic seam verdict or a rule for limb crossings.
6560
+
6561
+ Warnings belong to the stage report only. They add no rig, motion or PNG field
6562
+ and never assign a role, depth, binding or draw order. A part already bound to
6563
+ `chest` does not inherit a weighted topwear surface's per-vertex deform. Declare
6564
+ its actual intended role, then inspect setup, both turn endpoints and their diff;
6565
+ an explicit role alone does not establish that two drawings remain coincident.
6566
+
6327
6567
  ### Unit depth precedence
6328
6568
 
6329
6569
  For `"unit"` roles, a transported part's sheet is recorded and validated but is not
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) |
@@ -491,6 +491,37 @@ inverse-bind deform offsets in motion, without adding a Core attachment depth
491
491
  field. Head yaw/pitch and independent body yaw use their existing turn groups.
492
492
  Blended surfaces retain the documented per-group masks and setup-overlap
493
493
  neighbour field rules in [AUTHORING](AUTHORING.md#per-layer-depth-sheets-773).
494
+ `RIG_TURN_BODY_UNCOVERED` is a report-only warning for a shown torso part
495
+ without a body role that has opaque setup-triangle overlap with a moved body
496
+ surface and different emitted endpoint displacement. It names the part, surface,
497
+ times, vectors and relative travel. Head/neck bindings, transparent/excluded
498
+ alpha, explicit/inherited body roles and identical inherited motion are excluded as
499
+ described in [AUTHORING](AUTHORING.md#missing-body-turn-coverage). It assigns
500
+ no role or depth and changes no emitted file bytes.
494
501
  The unit-depth precedence and its primary-surface sampling are documented in
495
502
  [AUTHORING](AUTHORING.md#unit-depth-precedence).
496
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.83.2",
3
+ "version": "1.85.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": {
@@ -0,0 +1,94 @@
1
+ /** Explicit art-joint measurements on a fixed skeleton (issue #880). */
2
+ import { refuseIfAny, type Problem } from './errors.ts';
3
+ import { cropToSpineY } from './coords.ts';
4
+ import { edgeLength } from './length.ts';
5
+
6
+ export interface ArtJoint {
7
+ name: string;
8
+ bone: string;
9
+ /** Measured art point in stage pixels, x right and y down. */
10
+ at: [number, number];
11
+ }
12
+ export interface FixedSkeleton {
13
+ /** Where the independent art measurements came from; never interpreted. */
14
+ source: string;
15
+ joints: ArtJoint[];
16
+ /** Maximum Euclidean distance in stage pixels. Absent means measurement only. */
17
+ within_px?: number;
18
+ }
19
+ const object = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
20
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
21
+ const free = (k: string, v: unknown): boolean => (k.startsWith('x-') && k.length > 2) || ((k === 'note' || k.endsWith('_note')) && typeof v === 'string');
22
+
23
+ /** Validate without deriving any art point, bone correspondence or limit. */
24
+ export function fixedSkeletonProblems(value: unknown, at: string): Problem[] {
25
+ const problems: Problem[] = [];
26
+ const fail = (where: string, detail: string): void => { problems.push({ code: 'REQUIREMENTS_FIELD', object: where, detail }); };
27
+ const keys = (v: Record<string, unknown>, allowed: string[], where: string): void => {
28
+ for (const k of Object.keys(v)) if (!allowed.includes(k) && !free(k, v[k])) fail(`${where}.${k}`, `is unknown; fields are ${allowed.join(', ')}`);
29
+ };
30
+ if (!object(value)) return [{ code: 'REQUIREMENTS_FIELD', object: at, detail: 'requires {source, joints, within_px?}; omit fixed_skeleton for a rig without this declaration' }];
31
+ keys(value, ['source', 'joints', 'within_px'], at);
32
+ if (typeof value.source !== 'string' || value.source.trim() === '') fail(`${at}.source`, 'requires the provenance of the independent art-joint measurements as nonempty text');
33
+ if ('within_px' in value && (!finite(value.within_px) || value.within_px < 0)) fail(`${at}.within_px`, `is ${JSON.stringify(value.within_px)}; a finite nonnegative stage-pixel limit is required, or omit it for a SKIP-class measurement`);
34
+ if (!Array.isArray(value.joints) || value.joints.length === 0) fail(`${at}.joints`, 'requires a nonempty list of {name, bone, at: [x, y]} measured on the art in stage pixels (x right, y down)');
35
+ else {
36
+ const seen = new Set<string>();
37
+ value.joints.forEach((joint, i) => {
38
+ const where = `${at}.joints[${i}]`;
39
+ if (!object(joint)) { fail(where, 'requires {name, bone, at: [x, y]}'); return; }
40
+ keys(joint, ['name', 'bone', 'at'], where);
41
+ for (const k of ['name', 'bone']) if (typeof joint[k] !== 'string' || joint[k].trim() === '') fail(`${where}.${k}`, 'requires a nonempty name');
42
+ if (typeof joint.name === 'string') {
43
+ if (seen.has(joint.name)) fail(`${where}.name`, `declares joint "${joint.name}" twice; each measured joint is declared once`);
44
+ seen.add(joint.name);
45
+ }
46
+ if (!Array.isArray(joint.at) || joint.at.length !== 2 || !joint.at.every(finite)) fail(`${where}.at`, 'requires a finite [x, y] measured on the art in stage pixels (x right, y down)');
47
+ });
48
+ }
49
+ return problems;
50
+ }
51
+
52
+ export const ART_JOINT_LINE = 'ART_JOINT_DISTANCE';
53
+ export interface ArtJointRow extends ArtJoint {
54
+ /** Bone origin in the same stage-pixel space as at. */
55
+ bone_at: [number, number];
56
+ distance_px: number;
57
+ status: 'PASS' | 'FAIL' | 'SKIP';
58
+ }
59
+ export interface ArtJointLine {
60
+ status: 'PASS' | 'FAIL' | 'SKIP';
61
+ instrument: 'rigc-geometry/1 setup.bones';
62
+ source: string;
63
+ within_px: number | null;
64
+ joints: ArtJointRow[];
65
+ reason?: string;
66
+ }
67
+
68
+ /** Compare independent art points to renderer setup origins, never animation frame zero. */
69
+ export function artJointLine(
70
+ fixed: FixedSkeleton,
71
+ stage: { x: number; y: number; height: number },
72
+ setup: (bone: string) => { worldX: number; worldY: number },
73
+ problems: Problem[],
74
+ ): ArtJointLine {
75
+ refuseIfAny(fixedSkeletonProblems(fixed, 'fixed_skeleton'));
76
+ const limit = fixed.within_px;
77
+ const joints = fixed.joints.map<ArtJointRow>(j => {
78
+ const world = setup(j.bone);
79
+ if (!Number.isFinite(world.worldX) || !Number.isFinite(world.worldY)) refuseIfAny([{ code: 'CHECK_ART_JOINT_MEASURED', object: `joint "${j.name}" (bone "${j.bone}")`, detail: 'has no finite renderer setup origin; a missing position is never a zero distance' }]);
80
+ const boneAt: [number, number] = [world.worldX - stage.x, cropToSpineY(world.worldY - stage.y, stage.height)];
81
+ const distance = edgeLength(j.at[0] - boneAt[0], j.at[1] - boneAt[1]);
82
+ if (!Number.isFinite(distance)) refuseIfAny([{ code: 'CHECK_ART_JOINT_MEASURED', object: `joint "${j.name}" (bone "${j.bone}")`, detail: 'has no finite art-to-setup distance; finite stage-pixel coordinates are required' }]);
83
+ const status = limit === undefined ? 'SKIP' : distance > limit ? 'FAIL' : 'PASS';
84
+ if (status === 'FAIL') problems.push({ code: 'CHECK_ART_JOINT_DISTANCE', object: `joint "${j.name}" (bone "${j.bone}")`, detail: `art at [${j.at.join(', ')}] is ${distance} stage px from setup origin [${boneAt.join(', ')}]; distance <= declared fixed_skeleton.within_px ${limit} px is required — correct the art measurement/correspondence if wrong, or supply art aligned to the fixed skeleton; bones are not moved` });
85
+ return { name: j.name, bone: j.bone, at: [...j.at] as [number, number], bone_at: boneAt, distance_px: distance, status };
86
+ });
87
+ return { status: limit === undefined ? 'SKIP' : joints.some(j => j.status === 'FAIL') ? 'FAIL' : 'PASS', instrument: 'rigc-geometry/1 setup.bones', source: fixed.source, within_px: limit ?? null, joints,
88
+ ...(limit === undefined ? { reason: 'fixed_skeleton.within_px is absent; distances are measured, no limit is invented and no joint passes' } : {}) };
89
+ }
90
+
91
+ export function artJointLines(line: ArtJointLine): string[] {
92
+ return [`${ART_JOINT_LINE}: ${line.status}${line.reason === undefined ? '' : ` — ${line.reason}`}; source ${JSON.stringify(line.source)}; ${line.instrument}`,
93
+ ...line.joints.map(j => `${ART_JOINT_LINE}: joint "${j.name}" (bone "${j.bone}") ${j.status} — art [${j.at.join(', ')}], setup [${j.bone_at.join(', ')}], distance ${j.distance_px} px; ${line.within_px === null ? 'no declared limit (SKIP)' : `limit <= ${line.within_px} px`}`)];
94
+ }
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,