rig-parts 1.3.0 → 1.4.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
@@ -205,7 +205,7 @@ usage:
205
205
  rounding, and so is the per-frame mesh work (AUTHORING §5).
206
206
  rig-parts check --rig <dir> --out <dir> [--parts <dir>] [--source <painting.png>]
207
207
  [--page-edges pot|free] [--pack-shape rect|polygon]
208
- [--requirements <file.json>]
208
+ [--requirements <file.json>] [--seam-pairs]
209
209
  Build, gate, render and measure a rig through rig-c's CLI (the rigc at
210
210
  node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json:
211
211
  a rig spec, which rigc build compiles, not a compiled skeleton.json.
@@ -222,7 +222,11 @@ usage:
222
222
  entry that gated the build, and pack_mode, the two pack flags). PASS needs every gate summary
223
223
  "0 failed", the seam (setup pose vs the flat composite of parts/) at mean
224
224
  |d| <= ${SEAM_MEAN_BAR.toFixed(1)} with <= ${SEAM_PX_BAR} px over ${SEAM_PX_LEVEL}, and the loop (idle frame 0 vs the
225
- frame at t = duration) at max |d| 0. Then six judgement lines, each in
225
+ frame at t = duration) at max |d| 0. A failing seam, or any seam under
226
+ --seam-pairs, is split by the pair of parts each counted pixel lies
227
+ between: one "seam pair" line each, worst first, with its count, frame
228
+ and rig boxes and a picture under seam/, and check.json's seam_pairs
229
+ (AUTHORING §7). Then six judgement lines, each in
226
230
  check.json and on the console as NAME: PASS|FAIL|SKIP with its figures and
227
231
  bars (AUTHORING §7): BREATH_VISIBLE (the topwear moves, the footwear does
228
232
  not, each rendered alone), BLINK_NO_HOLE (the setup pose with the blink
@@ -425,9 +429,14 @@ usage:
425
429
  Bind several finished characters into one rig. The scene file (spec
426
430
  "${SCENE_SPEC}") states the canvas {width, height}, an optional plate
427
431
  {image, provenance: ${PROVENANCES.join(' | ')}, note?}, the characters
428
- [{id, build, offset: [x, y]}] — each build the --out of a green
432
+ [{id, build, offset: [x, y], scale?}] — each build the --out of a green
429
433
  \`rig-parts build\` (its check/check.json PASS), each offset a
430
- translation from its rig px to canvas px — and order: the draw order,
434
+ translation from its rig px to canvas px, each scale (a positive
435
+ number, 1 when absent) the character's size about its stage's top-left
436
+ corner: every length it carries is scaled as Spine's loader scales a
437
+ skeleton (bones, regions, weight binds, constraint lengths, translate
438
+ keys), and a physics wind or gravity, which no per-character value
439
+ can scale, is refused (SCENE_SCALE_EXACT) — and order: the draw order,
431
440
  back to front, of character ids (that character's remaining slots, in
432
441
  its own order) and "<id>:<slot>" entries, every slot exactly once.
433
442
  Paths are relative to the scene file. Every bone, slot, attachment,
@@ -453,7 +462,7 @@ usage:
453
462
  rig-c as rig gates its own (build --profile spine-html --pack, the
454
463
  compile and the packed pages on disk); --out receives rig/ (rig.json,
455
464
  motion.json, images/) and ${SCENE_REPORT_FILE} (spec "${SCENE_REPORT_SPEC}":
456
- every setting, each character's offset, bounds and shift (and what it
465
+ every setting, each character's offset, scale, bounds and shift (and what it
457
466
  moved),
458
467
  the plate's provenance, the declared and the composed order) only when
459
468
  it is green; then check runs over rig/ into check/, as on a rig it did
@@ -688,7 +697,15 @@ function flags(args: string[], known: readonly string[], command: string, option
688
697
  }
689
698
 
690
699
  function cmdCheck(args: string[]): number {
691
- const f = flags(args, ['--rig', '--out'], 'check', ['--parts', '--source', '--page-edges', '--pack-shape', '--requirements']);
700
+ // The one switch check takes (issue #202): it carries no value, so it is taken out before the valued flags are read.
701
+ const seamPairs = args.filter((a) => a === '--seam-pairs').length;
702
+ if (seamPairs > 1) return usage('--seam-pairs is given twice');
703
+ const f = flags(
704
+ args.filter((a) => a !== '--seam-pairs'),
705
+ ['--rig', '--out'],
706
+ 'check',
707
+ ['--parts', '--source', '--page-edges', '--pack-shape', '--requirements'],
708
+ );
692
709
  if (typeof f === 'string') return usage(f);
693
710
  const rig = f.get('--rig') as string;
694
711
  const out = f.get('--out') as string;
@@ -698,7 +715,12 @@ function cmdCheck(args: string[]): number {
698
715
  if (!isPackShape(shape)) return usage(shape);
699
716
  try {
700
717
  const bin = findRigc(import.meta.dir, process.env.PATH ?? '');
701
- const r = checkStage({ rig, parts: f.get('--parts'), source: f.get('--source'), out, pageEdges: edges, packShape: shape, requirements: f.get('--requirements') }, rigcRunner(bin), bin, console.log);
718
+ const r = checkStage(
719
+ { rig, parts: f.get('--parts'), source: f.get('--source'), out, pageEdges: edges, packShape: shape, requirements: f.get('--requirements'), seamPairs: seamPairs === 1 },
720
+ rigcRunner(bin),
721
+ bin,
722
+ console.log,
723
+ );
702
724
  return r.figures.PASS ? EXIT_OK : EXIT_REFUSED;
703
725
  } catch (err) {
704
726
  return printRefusal(err);
package/docs/AUTHORING.md CHANGED
@@ -82,7 +82,7 @@ read by no CPU stage).
82
82
  | `assemble.plan` | assemble; also the list every `meshes`/`regions` key must come from | **proposed** by `assemble --propose-plan`. `[part name, "full" \| "head", See-through tag]`, in draw order back to front. Names are free; roles come from the tag |
83
83
  | `assemble.extend_below_crop` | assemble | **proposed** with the plan: `{part, run, tag}` — a head-run part that reaches the bottom of the head crop is continued from a full-run layer, whole connected components |
84
84
  | `assemble.patches` | assemble; `meshes`/`regions` keys may name them too | **authored**, optional: `[{name, box, alpha, draw}]` — an extra part cut from the **painting** itself, for figure no See-through layer holds (a hem both runs dropped). `box` is `[x0, y0, x1, y1]` in **rig** pixels, `x1`/`y1` exclusive — the space of `parts.json` and `recomposite_rig.png`, where the hole is found; `alpha` is `"silhouette"` (the painting's figure silhouette inside the box, the one `--seam silhouette` uses) or `"box"` (the whole box); `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`. A patch is always a region: its bone is `regions.<name>`, in the one place every region's bone lives, and a `meshes` entry for it is refused. See §5 for how to place one |
85
- | `assemble.cuts` | assemble; `meshes`/`regions` keys may name the new parts too, and must (each is a part like any other) | **authored**, optional (issue #170): `[{from, into, polygon, draw, overlap}]` — a plan part's layer cut by a polygon the author draws: the pixels of plan part `from` whose **centre** lies inside `polygon` (even-odd; a centre exactly on an edge is outside) leave `from` and make the new part `into`, colour and alpha, so every pixel of the layer ends in exactly one of the two. `polygon` is `[[x, y], …]`, at least three points, non-negative integer **rig** pixels — the space of `parts.json` and of the patches' boxes. The file that shows the layer in those coordinates is `assemble`'s own `rig/parts/<from>.png`, placed at its `parts.json` `x`, `y` (the PNG's pixel `(i, j)` is rig pixel `(x + i, y + j)`), or `render/recomposite_rig.png`, the whole rig 1:1; the contact sheets (`sheet`) are scaled and carry no coordinates. `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`, as for a patch (to keep the setup pose, put it beside `from`: `{"before": "<from>"}`, or before the plan part after it). `from` must be a plan part (`CONFIG_NAME_RESOLVES`; a patch or another cut's piece is refused), `into` a new name (`CONFIG_PART_UNIQUE`), the polygon three or more integer points (`CONFIG_FIELD_TYPE`). The piece's `parts.json` record carries `from`'s `<run>:<tag>` and its own counts, each split from the layer's by where the pixel lies; it is a mesh or a region under its own name, and the coverage rule holds it like a plan part. What the config cannot know the stage refuses: a polygon point past the rig (`ASSEMBLE_CUT_INSIDE`), two cuts of one part sharing a pixel (`ASSEMBLE_CUT_OVERLAP`), a cut that takes none of the layer's opaque pixels or leaves `from` none (`ASSEMBLE_CUT_PIXELS`). `overlap` is required, a non-negative integer of rig pixels with no default (`CONFIG_FIELD_TYPE`): the **band** — the polygon's pixels within `overlap` px of its edge by Chebyshev distance (the polygon's mask less its erosion by a `2 * overlap + 1` square) — stays in `from` as well as going to the piece, so the piece's resampled edge has art under it; the counts stay a strict partition and the band is the base's extra copy, printed on the `cut:` line. At `0` the two abut and `check`'s seam bar judges the edge between them (`CHECK_SEAM_WITHIN_BAR`, §5). A band only covers the edge at rest: a piece that swings well clear of where it was painted shows the hole it leaves, and a thin piece's band shows a ghost of the piece — an authored fill for what lies under a piece is issue #193 |
85
+ | `assemble.cuts` | assemble; `meshes`/`regions` keys may name the new parts too, and must (each is a part like any other) | **authored**, optional (issue #170): `[{from, into, polygon, draw, overlap, under}]`, `under` optional — a plan part's layer cut by a polygon the author draws: the pixels of plan part `from` whose **centre** lies inside `polygon` (even-odd; a centre exactly on an edge is outside) leave `from` and make the new part `into`, colour and alpha, so every pixel of the layer ends in exactly one of the two. `polygon` is `[[x, y], …]`, at least three points, non-negative integer **rig** pixels — the space of `parts.json` and of the patches' boxes. The file that shows the layer in those coordinates is `assemble`'s own `rig/parts/<from>.png`, placed at its `parts.json` `x`, `y` (the PNG's pixel `(i, j)` is rig pixel `(x + i, y + j)`), or `render/recomposite_rig.png`, the whole rig 1:1; the contact sheets (`sheet`) are scaled and carry no coordinates. `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`, as for a patch (to keep the setup pose, draw the piece immediately in front of `from` — `{"before": "<the plan part after from>"}`, or `"front"` when `from` is the last plan part; `{"before": "<from>"}` draws it behind `from`, which `under` refuses as `ASSEMBLE_UNDER_DRAW` because the fill would hide it). `from` must be a plan part (`CONFIG_NAME_RESOLVES`; a patch or another cut's piece is refused), `into` a new name (`CONFIG_PART_UNIQUE`), the polygon three or more integer points (`CONFIG_FIELD_TYPE`). The piece's `parts.json` record carries `from`'s `<run>:<tag>` and its own counts, each split from the layer's by where the pixel lies; it is a mesh or a region under its own name, and the coverage rule holds it like a plan part. What the config cannot know the stage refuses: a polygon point past the rig (`ASSEMBLE_CUT_INSIDE`), two cuts of one part sharing a pixel (`ASSEMBLE_CUT_OVERLAP`), a cut that takes none of the layer's opaque pixels or leaves `from` none (`ASSEMBLE_CUT_PIXELS`). `overlap` is required, a non-negative integer of rig pixels with no default (`CONFIG_FIELD_TYPE`): the **band** — the polygon's pixels within `overlap` px of its edge by Chebyshev distance (the polygon's mask less its erosion by a `2 * overlap + 1` square) — stays in `from` as well as going to the piece, so the piece's resampled edge has art under it; the counts stay a strict partition and the band is the base's extra copy, printed on the `cut:` line. At `0` the two abut and `check`'s seam bar judges the edge between them (`CHECK_SEAM_WITHIN_BAR`, §5). A band only covers the edge at rest: a piece that swings well clear of where it was painted shows the hole it leaves, and a thin piece's band shows a ghost of the piece. `under` (issue #193), optional, is the author's image of what lies under the piece: a PNG path relative to the config's directory (`CONFIG_FIELD_TYPE` unless a non-empty string), in rig pixels over the polygon's **bounding box** — the least and greatest polygon x and y, so the image is `max x − min x` by `max y − min y` and its pixel `(i, j)` is rig pixel `(min x + i, min y + j)`. Where the cut removed pixels from `from` — the piece's pixels (alpha above 0) less the band — `from` takes the image's pixel, colour and alpha wherever the image's alpha is at least the alpha the cut removed there (**filled**); elsewhere `from` stays empty there (**left empty**): a transparent pixel is the author's statement that nothing lies under it (the background, or a part drawn behind), and a less opaque one is not half-filled. The image is read nowhere else, so its pixels over the band, over the box outside the polygon or where the layer had no art never change `from`. The `cut:` line then ends `; under <n> px filled, <m> px left empty` (`n + m` is every removed pixel), so an unintended hole is a number in the log, not a guess; the filled pixels count in `from`'s `opaque_px` and `occluded_px` (the piece is drawn over them), never as visible or projected. The stage refuses: a file that is not there (`ASSEMBLE_UNDER_PRESENT`) or not a PNG (`ASSEMBLE_UNDER_DECODES`), an image not exactly the box's size (`ASSEMBLE_UNDER_SIZE`, both sizes given), an image that fills none of the removed pixels — all transparent there, or another file (`ASSEMBLE_UNDER_EMPTY`; to leave the whole hole, drop `under`), and a piece drawn behind `from` (`ASSEMBLE_UNDER_DRAW`: the fill would hide it; draw it `"front"` or `{"before": "<the plan part after from>"}`). Producing the image is the author's, by whatever means; absent, `from` keeps only its band |
86
86
  | `bones` | rig; `propose --from-config` | **proposed** by `propose`, then corrected. A single bone `{name, parent, at, tip?}` or a chain `{chain, parent, points, tip}` whose links are named `<chain>0 … <chain>n`. Parents come before children. In `rig.json` each link is **turned along its chain** — `rotation` is the direction from its origin to the next link's (the last link's to `tip`) and `length` that distance (issue #73), so a physics constraint added downstream finds a lever; under `--idle-keys ctl` the link's `<link>_ctl` carries the turn and the length and the link sits at local rotation 0 beneath it. A single bone is never turned; one whose parent is a link is turned back upright |
87
87
  | `meshes.<part>` | rig | **proposed**, then corrected. Exactly one of `grid` (the lattice: cell size, px — what `propose` writes), `contour` (the outline mode, authored; the next row and "A contour mesh" below) or `auto` (the automatic mode, authored; "An automatic mesh" below) — two, or none, is `CONFIG_MESH_MODE`. In every mode: `r` (added to every distance before weighting: `w = 1/(d + r)²`), `segments` (a chain name, a bone name with a `tip`, or `[bone, [x0,y0], [x1,y1]]`), and, **authored**, optional, `exponent` (issue #161): a number above 0 that replaces the 2 in that rule, `w = 1/(d + r)^exponent` — a larger one hands each vertex more fully to its nearest bone; absent is 2 and changes no byte, a value that is not a number above 0 is `CONFIG_FIELD_TYPE` at `meshes.<part>.exponent`, and a declared one is echoed as `exponent` in the mesh's `mesh_report.json` row; the exponent is the distance rule's, so declared beside `rule: "heat"` it is refused (`CONFIG_FIELD_TYPE` at `meshes.<part>.exponent`). The segment list is the one authored decision about a layer: which bones may pull it. The slot's bone is the first segment's. Optional `rule` (issue #161): `"distance"` — the rule above, and what an absent `rule` means (every byte is the same) — or `"heat"`, bone heat over the part's own silhouette, in every mode: the lattice's art (alpha above 8) with holes filled, or for `contour` and `auto` the silhouette their outline is traced from (that mode's art — alpha above 8 for `contour`, alpha 1 and above for `auto` — less any island a declared `stray` left out, holes filled, grown by its `margin`); each bound bone's sources are the silhouette pixels whose centre lies within 0.75 px of its segment (a pixel two bones reach goes to the nearer, a tie to the first listed), held at 1 for that bone and 0 for the others; the field is solved by row-major Gauss–Seidel over-relaxation to a sweep change under 1e-7 within 20000 sweeps; a vertex reads the nearest silhouette pixel and takes the same cap and floor (and `regions`) as the distance rule; `r` is not read. The mesh's `mesh_report.json` row gains `heat` (the constants, each bone's sources and sweeps, the residual). Any other value is `CONFIG_FIELD_TYPE`; a bound bone with no source pixel is `RIG_HEAT_BONE_SOURCE`, an island no bone has a source in `RIG_HEAT_ISLAND_SOURCE`, a solve past its sweep bound `RIG_HEAT_CONVERGED` — none is weighted 0 |
88
88
  | `meshes.<part>.contour` | rig | **authored**, optional (issue #84): `{tolerance, margin, spacing, budget?, stray?, regions?}`. `tolerance` — rig-c's Douglas–Peucker tolerance on the traced outline, px, 0 or more; `margin` — how far the silhouette grows before it is traced, px: 0, or 1 or more (a pixel joins when its centre lies within `margin` of an art pixel's centre; a value above 0 and below 1 adds nothing and is refused, `CONFIG_FIELD_TYPE`); `spacing` — the background interior spacing, px, above 0; `budget` — the most vertices the mesh may have (refused above it, nothing thinned); `stray` — the largest island, in art pixels, that may be left out of the mesh (absent: none is); `regions` — local deformation regions, each `{name, shape, bone, spacing, band}` with `shape` `"circle"` (`cx, cy, r`) or `"polygon"` (`points`, 3 or more): `bone` is the region's control bone (any bone `bones` declares), `spacing` the interior spacing inside the region and its band, `band` the width the bone's weight falls across. Every length and position is **rig px**, like every other in the config; the part image is the rig less `(x − 4, y − 4)` (its box less the pad), a translation, so a length is the same number in both. A region's `cx`, `cy`, `r`, `band` and `points` are multiples of 1/256 px (`CONFIG_FIELD_TYPE` otherwise); region names are unique per mesh (`CONFIG_REGION_NAME_UNIQUE`). The alpha threshold is not a field: art is alpha above 8, the lattice's and `check`'s |
@@ -1133,13 +1133,13 @@ proposal in as the steps above say (`RL01`); every step must exit 0.
1133
1133
  | --- | --- | --- | --- |
1134
1134
  | `layers` | the table, then the `WARN` lines | every tag the plan will need has opaque pixels; one `face` in the head run; `0 WARN line(s)` | a run whose layer PNG is not its box's size (`LAYERS_PNG_MATCHES_BBOX`); a `WARN PLAN_LAYER_…` line (§6, *Plausibility*) |
1135
1135
  | `sheet` of both runs | the tile list (and the sheet, if you can see) | eyes, irises, lashes and brows as left/right pairs in the head run | a head box that cut off an ornament: move `head_box`, re-run the head crop |
1136
- | `assemble` | one line per part, the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`; and look at `render/recomposite_error_rig.png`. Between the part lines and the totals, one `cut: "<part>" opaque <n> = "<part>" <kept> + "<piece>" <taken> …` line per part an `assemble.cuts` entry cut (below), then one `fringe pushed back: "<part>" <px> px, where the painting shows "<holder>" <px> px, …` line per part whose fringe was cleared (below) | on the examples: `sample` 0.83 / 98.0 % / 4,359 / 1,185, 259 holes, the largest 94 px; `demo` 2.34 / 95.8 % / 10,340 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (the demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. When neither run holds it at all, an `assemble.patches` entry cuts it from the painting (below) |
1136
+ | `assemble` | one line per part, the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`; and look at `render/recomposite_error_rig.png`. Between the part lines and the totals, one `cut: "<part>" opaque <n> = "<part>" <kept> + "<piece>" <taken> …` line per part an `assemble.cuts` entry cut (below; it ends `; under <n> px filled, <m> px left empty` when a cut of that part has `under`), then one `fringe pushed back: "<part>" <px> px, where the painting shows "<holder>" <px> px, …` line per part whose fringe was cleared (below) | on the examples: `sample` 0.83 / 98.0 % / 4,359 / 1,185, 259 holes, the largest 94 px; `demo` 2.34 / 95.8 % / 10,340 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (the demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. When neither run holds it at all, an `assemble.patches` entry cuts it from the painting (below) |
1137
1137
  | `propose` | `note:` lines, `LINT` lines, the `coverage` lines, `landmarks.png`, `basis.json` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter; the `coverage:` summary says how many bones that verdict covers — a `not checked` bone was read by no check (its line says why), and a `ratio` or `fallback` bone in `basis.json` is a guess | a link off the art (a bone on the background) — move it onto the layer; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head`; a `no face part: face box derived from …` note (always the first) — `head`, `neck` and every face-height scale come from a box guessed off the hair and neck (§3): check `head` and `neck` on `landmarks_head.png` and move them; when it adds `the blink shuts the eyes over no face part`, read `BLINK_NO_HOLE` after `check` — an eye with no face under it can open a hole (the `sample` with only its face removed: 587 px). Under `--keypoints`: the `lint rule set:` line first (joints, or screen and why), then the joint notes — `missing: the rule "…" placed bone X instead` is a bone still guessed; `not used — …` names why an arm's joints did not place its sleeve; `LINT chest at … is not between the neck joint …`, `LINT hip at … is not past chest …`, `LINT hip at … is nearer the neck joint …` and `LINT <chain><i> at … does not advance from <s>_shoulder toward <s>_wrist` are the joint-frame lines: move that bone between, past or along the joints they name |
1138
1138
  | `compare` | one line per pair (origin, parent, tip, length, direction — or SKIP and why), then the per-row summary, the unmapped bones of each side, `required:`, each side's roles and the `controls:` line | proposal against the config you corrected: the rows you changed and no others. Config against the `rig.json` `build` writes for it: measured on both examples, every origin within 6.19e-7 px (the rig's offsets are written to 6 places) and every chain link's tip and length within 4.99e-4 px (`length` is written to 3), printed to three places as `0.000` (a signed figure as `+0.000` or `-0.000`); `parent … is the right's ancestor at depth 2, <bone>_ctl between` for every keyed mesh bone, no `DIFFERENT` and no `NOT MAPPED` | `parent DIFFERENT: left a, right b` — a bone hangs from another parent than the other side's; `frames not related` — a rig with no stage (`skeleton.width`/`height`), two configs at different `rig_scale`: the distance rows SKIP, so declare the frame in a `--map` file if the author knows it; `FAIL STRUCTURE_REQUIRED_PRESENT` — a bone the map requires is missing through its pairs |
1139
1139
  | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`); in `rig.json`, each chain link's (or its `_ctl`'s) `length` and `rotation` | `cover 1.00000` on every mesh, both gate summaries `0 failed` (the compile and the packed pages); every link's `length` the distance to the next link and `rotation` its direction (Spine degrees, counter-clockwise, y up, local to the parent), every offset under it — a child bone's `x, y`, a weight's bind `x, y`, a region's `x, y` — in that turned frame, and a region on a link carrying `rotation` that turns it back upright. Nothing moved: `flattenRig` (`src/rig.ts`) turns every offset back and gives the unturned numbers | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
1140
1140
  | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:` (and `loop physics:` when the rig declares a physics constraint, §7), `seam:`, the six judgement lines, `RECOMPOSITE_HOLES` and, under `--source`, `SETUP_POSE_VS_SOURCE` (§7), `check.json`; the last line says how many of the nine bars measured and names the ones that said SKIP | `check: PASS; 9 of 9 bar(s) measured, 0 skipped` on the examples, and a judgement line SKIP only where the character lacks what it reads; on a rig spec with no `parts.json` or no `idle` (a merged rig, §7 *Measuring a rig rig-parts did not assemble*), PASS with the skipped bars named | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
1141
1141
  | `check --requirements` (and `build --requirements`) | after the lines above, one line per declared requirement, `NAME: PASS`, `FAIL` or `NOT MEASURABLE — …` with its figures, the bar as declared and the worst frame; then `requirements: N declared — M measured (P PASS, F FAIL), K NOT MEASURABLE; not declared: <kinds>`; `check.json`'s `requirements` block; the renders under `requirements/` (§7, *Declared requirements*) | every line PASS and `0 NOT MEASURABLE`; a follow's `released_copy` says whether rigc's consumer-driven door was taken on the throwaway copy | `CHECK_REQUIREMENT_MET` (a figure past its bar; the line names the frame) or `CHECK_REQUIREMENT_MEASURABLE` (the quantity is undefined in the frames: a tip or an axis on a bone of length 0, an aim with no line, a follow no frame of which reaches its least drive); the last line then reads `check: FAIL — B bar(s) not met, R declared requirement(s) not PASS` |
1142
- | `compose` | the `[compose]` lines: one per character (its offset, its placed bounds, the shift and how many first-level bones, regions on `root` and weights bound to `root` it moved, its bone, slot, track and constraint counts, its `rig_scale` and idle), the declared order and the slot count it expands to, rigc's gate lines; then the `[check]` lines as for `check`, and `scene.json` | both gate summaries `0 failed`, `check: PASS` with the bars that measured and the ones that said SKIP named — on a composed rig `4 of 9`: the gate, the loop, `CHAIN_LAG` and `TEXTURE_STRETCH` (there is no `parts.json`; each character's own `build` measured all nine). `scene.json`'s `order.slots` is the order the compiled skeleton draws | a `SCENE_*` refusal (§6, *compose*) — the scene file, a build, or the order; `COMPOSE_RIGC_GREEN` — rigc's own line, quoted |
1142
+ | `compose` | the `[compose]` lines: one per character (its offset, its scale, its placed bounds, the shift and how many first-level bones, regions on `root` and weights bound to `root` it moved, its bone, slot, track and constraint counts, its `rig_scale` and idle), the declared order and the slot count it expands to, rigc's gate lines; then the `[check]` lines as for `check`, and `scene.json` | both gate summaries `0 failed`, `check: PASS` with the bars that measured and the ones that said SKIP named — on a composed rig `4 of 9`: the gate, the loop, `CHAIN_LAG` and `TEXTURE_STRETCH` (there is no `parts.json`; each character's own `build` measured all nine). `scene.json`'s `order.slots` is the order the compiled skeleton draws | a `SCENE_*` refusal (§6, *compose*) — the scene file, a build, or the order; `COMPOSE_RIGC_GREEN` — rigc's own line, quoted |
1143
1143
  | `loop` (inside `build --loop`, or `loop --frames … --out …`) | the dropped-duplicate line, each file's line, then `loop: idle.png N B (lossless); idle-indexed.png N B (max …, mean …); idle.gif N B (max …, mean …)` | `f0048.png equals f0000.png byte for byte, so it is dropped` | `LOOP_ENCODE` (§6) |
1144
1144
 
1145
1145
  `loop` writes three files from one frame set, and they are not interchangeable.
@@ -1353,7 +1353,31 @@ one by 101.5 px [observed, `docs/evidence/scarf-cut.png`]. Where the cut runs th
1353
1353
  opaque art, `overlap` decides the seam: at 0 the pieces abut, each edge is resampled
1354
1354
  against transparency, and on that cut the seam bar read 170 px over 40 against 50
1355
1355
  allowed (`CHECK_SEAM_WITHIN_BAR`); at 1 it read 8, at 2 it read 3, at 4 it read 2
1356
- (the uncut build reads 2) [observed].
1356
+ (the uncut build reads 2) [observed]. Those four readings drew the piece behind
1357
+ `topwear` (`{"before": "topwear"}`); drawn in front of it (`{"before": "brow_r"}`,
1358
+ the plan part after `topwear`), as the setup pose asks, the same cut reads 26 px over
1359
+ 40 at `overlap` 1, 8 and 16 alike, 24 of them along the cut line, which a wider band
1360
+ does not reach (§7, *seam*) [observed, issue #202].
1361
+
1362
+ A band covers the edge at rest; when the piece swings clear, the layer has nothing
1363
+ where it was. `under` (§3) is the author's image of what lies there, in rig pixels
1364
+ over the polygon's bounding box: `from` takes it where the cut removed its pixels and
1365
+ the image is at least as opaque as the layer was, a transparent pixel leaves its pixel
1366
+ empty (nothing lies under it), the piece must be drawn in front of `from`
1367
+ (`ASSEMBLE_UNDER_DRAW`), and the `cut:` line counts both, so an unintended hole is a
1368
+ number in the log:
1369
+
1370
+ cut: "topwear" opaque 134452 = "topwear" 118973 + "scarf" 15479; band 593 px held by both; under 3991 px filled, 11079 px left empty
1371
+
1372
+ That is the scarf cut (overlap 1, the piece drawn `{"before": "brow_r"}`) with an
1373
+ 80x540 image of each row's nearest `topwear` pixel outside the polygon, down to row
1374
+ 681 and transparent below it; `check` passes with the seam bar at 26 px over 40, as
1375
+ without the image [observed, `docs/evidence/scarf-under.png`]. Whether the sweater
1376
+ continues under that scarf end at all is the author's call: continued as a line from
1377
+ its edge just above the polygon (x 306) to its edge at its hem (x 316, row 681), its
1378
+ outline lies right of every removed pixel, and an image of only that silhouette fills
1379
+ none of the 15,070 — `ASSEMBLE_UNDER_EMPTY` [observed]. Producing the image is the
1380
+ author's.
1357
1381
 
1358
1382
  `parts.json` holds one record per part, in draw order: the plan's, with each patch
1359
1383
  and each cut's piece where its `draw` puts it (`"back"` patches first in their own
@@ -1545,6 +1569,10 @@ See-through with another seed.
1545
1569
  | `ASSEMBLE_CUT_INSIDE` | a cut's polygon has a point past the rig (the detail gives the points and the size) | that cut's `polygon` — it is in rig pixels, not painting pixels |
1546
1570
  | `ASSEMBLE_CUT_OVERLAP` | two cuts of one part share a rig pixel (the detail gives the count and the first, `x,y`) | one of the two polygons: a pixel goes to one part |
1547
1571
  | `ASSEMBLE_CUT_PIXELS` | a cut takes none of its part's opaque pixels (alpha above 8), or a part's cuts leave it none | draw the polygon over the art; to move the whole layer, rename the plan part instead |
1572
+ | `ASSEMBLE_UNDER_PRESENT`, `ASSEMBLE_UNDER_DECODES` | a cut's `under` names no file (relative to the config's directory), or a file that is not a PNG; every cut's is named in one refusal | that cut's `under` |
1573
+ | `ASSEMBLE_UNDER_SIZE` | a cut's `under` image is not the size of its polygon's bounding box (the detail gives both, and the box's corner) | the image: `max x − min x` by `max y − min y` of the polygon, in rig pixels |
1574
+ | `ASSEMBLE_UNDER_EMPTY` | a cut's `under` image fills none of the pixels the cut removed: its alpha is below the alpha removed at every one (all transparent there, or another file) | the image or its path; to leave the whole hole, drop `under` |
1575
+ | `ASSEMBLE_UNDER_DRAW` | a cut with `under` draws its piece behind its part, which the fill would hide it under | that cut's `draw`: `"front"`, or `{"before": …}` the plan part after its `from` |
1548
1576
  | `ASSEMBLE_COUNTS_ADD_UP` | a part's visible and occluded counts do not add up to its opaque pixels, or a projected pixel is not visible, or a cut part's pieces do not add up to it — an assembler bug, not an input problem | report it with the part named; nothing was written |
1549
1577
 
1550
1578
  ### propose
@@ -1592,7 +1620,7 @@ See-through with another seed.
1592
1620
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
1593
1621
  | `CHECK_LOOP_LAST_FRAME_AT_DURATION` | the idle's last frame does not sit at `duration` | `motion.duration` — a whole number of 1/12 s |
1594
1622
  | `CHECK_LOOP_CLOSES` | frame 0 and the frame at `duration` differ (max and first pixel quoted); with a physics constraint, the frames of the rig with every physics constraint left out, and the object says so (§7, *loop*) | a track whose last key is not its first |
1595
- | `CHECK_SEAM_WITHIN_BAR` | the setup pose does not reproduce the flat stack of parts | usually a region or mesh placed off its part; compare with `recomposite_rig.png` |
1623
+ | `CHECK_SEAM_WITHIN_BAR` | the setup pose does not reproduce the flat stack of parts | read the `seam pair` lines and `check/seam/` pictures (§7): the pair, its boxes and the pixels counted. Usually a region or mesh placed off its part; compare with `recomposite_rig.png`. A pair of one layer cut in two with `differing_copies_px` is the cut line: move the polygon's edge, not `overlap` |
1596
1624
  | `CHECK_BREATH_VISIBLE` | the torso (`topwear`), rendered alone, barely moves over the idle — or the feet (`footwear`), rendered alone, move at all | the chest's breath tracks (`motion.tracks` on `chest`), or the torso mesh's `segments`; for the feet, the bone their region rides (`regions.<part>`, `root` in both examples) |
1597
1625
  | `CHECK_BLINK_NO_HOLE` | with the blink held shut, the eyewhite box shows the page where the open eye had art | the layer under the eye: the `face` part has no art there. Take the face from the other run, or add a part under the eye; `motion.blink.squash` only hides the hole less |
1598
1626
  | `CHECK_CHAIN_LAG` | a rotate track leads (or does not lag) the keyed bone above it, or a chain link swings less than the link above | that chain track's `phase`/`lag` (a positive `lag`, a child `phase` above its parent's) or its `amps` (non-decreasing toward the tip) |
@@ -1624,13 +1652,14 @@ and stay absent).
1624
1652
 
1625
1653
  | rule | means | change |
1626
1654
  | --- | --- | --- |
1627
- | `SCENE_FILE`, `SCENE_SPEC`, `SCENE_FIELD_PRESENT`, `SCENE_FIELD_TYPE`, `SCENE_KEY_KNOWN` | the scene file is missing, not a JSON object, not `spine-parts-scene/1`, or a field is missing, of the wrong type or not one the format reads (`canvas` two positive integers; `characters` at least one, each `id`, `build`, `offset` — an offset is never assumed (0, 0); `order` a list of strings). `note`, `*_note` (strings) and `x-…` (any value) are read by nothing | the field |
1655
+ | `SCENE_FILE`, `SCENE_SPEC`, `SCENE_FIELD_PRESENT`, `SCENE_FIELD_TYPE`, `SCENE_KEY_KNOWN` | the scene file is missing, not a JSON object, not `spine-parts-scene/1`, or a field is missing, of the wrong type or not one the format reads (`canvas` two positive integers; `characters` at least one, each `id`, `build`, `offset` — an offset is never assumed (0, 0) — and an optional `scale`, a positive finite number: 0, a negative number or a non-number is refused at `characters[i].scale`; `order` a list of strings). `note`, `*_note` (strings) and `x-…` (any value) are read by nothing | the field |
1628
1656
  | `SCENE_ID_UNIQUE`, `SCENE_ID_FORM` | an `id` repeated, empty, or holding `:` — the separator of a prefixed name — or `/`, `\` or a leading `.`, because an id begins every image file of its character | another id |
1629
1657
  | `SCENE_PLATE_PROVENANCE`, `SCENE_PLATE_PRESENT`, `SCENE_PLATE_SIZE` | the plate's `provenance` is not `observed`, `generated` or `unknown`; its image is missing or not a PNG; it is not the canvas's size (it is drawn at canvas (0, 0) and never scaled) | the plate |
1630
1658
  | `SCENE_BUILD_PRESENT` | a build directory, or a file `build` writes in it (`parts.json`, `rig/rig.json`, `rig/motion.json` with an `idle`, an image its skin names, `check/check.json`), is missing | run `build` for that character |
1631
1659
  | `SCENE_BUILD_GREEN` | the build's own `check/check.json` does not say `PASS` and `gate_spine_html_green` | finish that character first: compose binds finished characters |
1632
1660
  | `SCENE_BUILD_FIELD_KNOWN` | a build's `rig.json` or `motion.json` carries a key compose does not know how to prefix or place, or a root with a transform of its own | a rig `build` wrote; a rig written by something else is `check`'s to measure (§7, *Measuring a rig rig-parts did not assemble*), not compose's to merge |
1633
- | `SCENE_RIG_SCALE_AGREES` | the characters' `parts.json` `scale_rig_per_source` differ (each named) | one `assemble.rig_scale` for every character; scaling a character in composition is not offered |
1661
+ | `SCENE_RIG_SCALE_AGREES` | the characters' `parts.json` `scale_rig_per_source` differ (each named) | one `assemble.rig_scale` for every character; `characters[].scale` sizes a placed character, it does not make two `rig_scale`s one |
1662
+ | `SCENE_SCALE_EXACT` | a character at a `scale` other than 1 carries something no per-character value can scale: a physics constraint with a nonzero `wind` or `gravity` (Spine applies both through the skeleton's `referenceScale`, which the composed skeleton holds once for every character), a track whose `property` compose does not know to be a length or not, or a translate key whose `v` is neither a list of numbers nor a map of them | `scale` 1 for that character, or rebuild it at another `assemble.rig_scale` |
1634
1663
  | `SCENE_DURATION_AGREES` | the characters' idles are of different durations (each named) | one `motion.duration` |
1635
1664
  | `SCENE_CHARACTER_INSIDE_CANVAS` | a character's part boxes, placed at its offset, leave the canvas (the boxes and the canvas px quoted) | the offset, or the canvas |
1636
1665
  | `SCENE_ORDER_RESOLVES` | an order entry names no character, or `<id>:<slot>` names a slot that character does not have (its slots listed) | the entry |
@@ -1658,7 +1687,39 @@ assemble*, at the end of this section):
1658
1687
  `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` on the packed pass, 16 not in `spine`; `CH09`
1659
1688
  holds it on every fetched example);
1660
1689
  - **seam**: the setup-pose render against the flat composite of `parts/`: mean
1661
- max-channel |d| ≤ 1.0 of 255, and at most 50 pixels over 40;
1690
+ max-channel |d| ≤ 1.0 of 255, and at most 50 pixels over 40. The bar reads one
1691
+ frame, the setup-pose still, never the idle. When it fails (or when `check
1692
+ --seam-pairs` asks, pass or fail), the pixels over 40 are split by the pair of
1693
+ parts that answers for each (issue #202): the two parts drawn last among those
1694
+ whose art (alpha above 0) lies in the 2x2 rig pixels the composite's bilinear
1695
+ sample reads for that frame pixel, lower first, `(no art)` standing in where
1696
+ fewer than two do. Each pair is a console line `seam pair <i> of <n>: "<a>" /
1697
+ "<b>" at the setup pose: <count> px over 40 (<count> over 80), frame box x
1698
+ <x0>..<x1>, y <y0>..<y1>, rig box …, picture seam/<i>_<a>_<b>.png`, worst first,
1699
+ and a row of `check.json`'s `seam_pairs` (`parts`, `px_over_40`, `px_over_80`,
1700
+ `frame_box` in frame pixels, `rig_box` in the rig pixels of `parts.json` and
1701
+ `cuts[].polygon`, `picture`); `CHECK_SEAM_WITHIN_BAR`'s line quotes the worst.
1702
+ The picture, under `check/seam/`, is the setup-pose frame with that pair's
1703
+ counted pixels painted magenta (255, 0, 255) and a cyan box one pixel outside
1704
+ them. A passing run not asked writes neither the key nor the directory. A pair
1705
+ of one layer cut in two (both parts' `from` the same, `assemble.cuts`) carries
1706
+ `cut`: the layer and `differing_copies_px`, how many of its pixels read a pixel
1707
+ both parts hold in different colours. The band a cut declares is **not**
1708
+ excluded from the count, because the two copies are not one drawing: the piece
1709
+ takes the painting where it is seen and the base keeps the layer's colour under
1710
+ it. Along the cut line the render draws the upper part's resampled edge over
1711
+ the lower part's own resampled copy, where the flat stack is resampled once, so
1712
+ the two part where the copies differ and where the art changes colour across the
1713
+ line; only the band's outermost pixel and the one across the line are read, so
1714
+ the band's width does not enter. On the public `scarf` cut (piece drawn in front
1715
+ of `topwear`) the count read 26 px over 40 at `overlap` 1, 8 and 16 alike, the
1716
+ band 593, 4,886 and 9,508 px (the `cut:` line): 24 of the 26 are `"topwear" /
1717
+ "scarf"` along the cut line, 16 of them reading a band pixel where `topwear`
1718
+ holds the layer's colour and `scarf` the painting's, the other 8 where the cut
1719
+ line runs along the scarf's red edge [observed, issue #202]. A wider band moves
1720
+ neither; the polygon's edge does — run it through art of one colour on both
1721
+ sides, in both copies, which `rig/parts/<from>.png` and `rig/parts/<into>.png`
1722
+ show pixel for pixel;
1662
1723
  - **loop**: idle frame 0 against the frame at `t = duration`: max |d| exactly 0. The bar
1663
1724
  measures the keys (issue #183): a rig that declares a physics constraint — a `rig.json`
1664
1725
  `constraints` entry of `type` `"physics"`, or an entry of `motion.json`'s `physics`
@@ -1997,6 +2058,7 @@ single-character path (§4, steps 0–9) on its own, already isolated input to a
1997
2058
  | `characters[].id` | the prefix: every bone, slot, attachment (skin entry), constraint (its name, and every bone and path slot it names), `invariants.detached` bone, track, group and easing of that character is renamed `<id>:<name>`. `root` is the one shared root. A name resolves to exactly one character, and nothing resolves across characters without its prefix |
1998
2059
  | `characters[].build` | a `build --out` directory (relative to the scene file) whose own `check/check.json` is green |
1999
2060
  | `characters[].offset` | `[x, y]`, a translation from the build's rig px to canvas px. Every character is built at one `assemble.rig_scale`, so its rig px are canvas px at scale 1 |
2061
+ | `characters[].scale` | optional, a positive number; absent is 1, and 1 composes to the bytes a scene without the key writes. The character's size on the canvas, about its stage's top-left corner: a rig px `p` lands at canvas px `scale * p + offset` (issue #197). A character scaled up past its art is drawn soft — the art is not resampled; rebuild it at a higher `assemble.rig_scale` if that matters |
2000
2062
  | `order` | the draw order, back to front: a character id (its slots not named elsewhere, in its own order) or one `<id>:<slot>`. Every slot of every character is drawn exactly once. Interleaving is written by naming slots: `["a", "b:arm_l", "b"]` draws `b`'s left arm between `a` and the rest of `b` |
2001
2063
 
2002
2064
  What is composed:
@@ -2018,6 +2080,23 @@ What is composed:
2018
2080
  character, with what it moved. The root is shared, so a character whose idle keys
2019
2081
  it, or whose constraint names it, is refused (`SCENE_ROOT_SHARED`): it would move
2020
2082
  every character.
2083
+ - **The scale** (issue #197) is applied with the offset: every length the character
2084
+ carries is multiplied by it exactly where Spine's own loader multiplies by its
2085
+ `scale` (`SkeletonJson.js`, spine-core 4.3) — each bone's `x`, `y` and `length`;
2086
+ each region's `x`, `y`, and its drawn size through `scaleX`/`scaleY` (rig-c reads
2087
+ `width`/`height` off the PNG); each weight's bind `x`, `y`; an ik's `softness`; a
2088
+ transform's `x`, `y` and, in its `properties`, the `offset` of an `x`/`y` source and
2089
+ the `offset` and `max` of an `x`/`y` target, its `scale` by the target's unit over
2090
+ the source's; a path's `position` under `positionMode` Fixed and its `spacing` under
2091
+ `spacingMode` Length or Fixed; a physics `limit`; a slider driven by a bone's `x` or
2092
+ `y`, its `from` scaled and its `scale` divided; and the translate keys (`translate`,
2093
+ `translatex`, `translatey`), their curves' values with them. An absent field whose
2094
+ Spine default is not 0 (a physics `limit`, 5000; a transform target's `max`, 1; a
2095
+ slider's `scale`, 1) is written as that default scaled. Degrees, mixes, ratios,
2096
+ physics rates and a mesh's `width`/`height` (its image's, nonessential) are not
2097
+ lengths and stay. Then what sits in `root`'s own frame takes the shift, so the
2098
+ character's stage corner lands at `offset`. `scene.json` writes `scale` beside the
2099
+ offset only when it is not 1, and the `[compose]` line always prints it.
2021
2100
  - **The plate** is a region on the bone `plate` under `root`, the first slot, its
2022
2101
  image at canvas (0, 0).
2023
2102
  - **One `idle`** holds every character's tracks; the characters' idles must be of one
@@ -2038,11 +2117,16 @@ What is composed:
2038
2117
  build: without a `parts.json` the seam, `BREATH_VISIBLE`, `BLINK_NO_HOLE`,
2039
2118
  `TIP_OVER_ROOT`, `STILL_REGIONS_DARK` and `RECOMPOSITE_HOLES` say SKIP and why,
2040
2119
  and the gate, the loop, `CHAIN_LAG` and `TEXTURE_STRETCH` measure.
2041
- `--requirements` is forwarded, and its names are `<id>:<name>`.
2120
+ `--requirements` is forwarded, and its names are `<id>:<name>`. Every bar and
2121
+ requirement measures the composed rig in canvas px, after each character's
2122
+ `scale`: a ratio (`TEXTURE_STRETCH`'s edge against its rest length) or an angle
2123
+ (`CHAIN_LAG`, a `range` in degrees) does not move with it, while a requirement's
2124
+ px figures and its px bar are canvas px — a character composed at 0.5 shows half
2125
+ the px its own `build` measured.
2042
2126
  - **`scene.json`** (`spine-parts-scene-report/1`): the scene file's path as given,
2043
2127
  the canvas, the rig's name (the ids joined by `+`), the pack flags, the plate (its
2044
2128
  image, provenance, note, bone, slot, size, `judged_by: "nothing"`), per character
2045
- its build, offset, `rig_size`, `rig_scale`, placed bounds (the union of its part
2129
+ its build, offset, `scale` (only when it is not 1), `rig_size`, `rig_scale`, placed bounds (the union of its part
2046
2130
  boxes, canvas px), shift and what it moved (`shifted`: the first-level bones, the
2047
2131
  regions on `root`, the count of weights bound to `root`), and its counts, and the
2048
2132
  order —
@@ -2056,8 +2140,9 @@ every idle frame are byte-identical once `<id>:` and `<id>.` are taken off, and
2056
2140
  and the model's `spine.sha256`, which on each side is the sha256 of its own
2057
2141
  `skeleton.json` and so changes with the names (selftest `SC33`).
2058
2142
 
2059
- **Not offered.** Scaling a character in composition (one `rig_scale` is required);
2060
- any order, overlap or occlusion inferred from an image — the order is the author's,
2143
+ **Not offered.** Reconciling two `rig_scale`s (one is required; `scale` sizes a
2144
+ character, it does not resample its art); a non-uniform scale, a flip or a rotation
2145
+ of a character; any order, overlap or occlusion inferred from an image — the order is the author's,
2061
2146
  and a per-pixel vote that holds for a still pose would not hold once the bodies
2062
2147
  move; person detection, segmentation, isolation or occlusion completion (each
2063
2148
  character's input arrives isolated); a composed `parts.json`, so the bars that
package/docs/STABILITY.md CHANGED
@@ -55,7 +55,7 @@ defaults are the help's too, and none is guessed from the art.
55
55
  | `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` (with `--keypoints`) |
56
56
  | `compare` | `--left`, `--right`; optional `--map` |
57
57
  | `rig` | `--config`, `--parts`, `--out`; optional `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
58
- | `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements` |
58
+ | `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements`, `--seam-pairs` |
59
59
  | `loop` | `--frames`, `--out`; optional `--palette` |
60
60
  | `build` | `--config`, `--source`, `--full`, `--head`, `--out`; optional `--seam`, `--project`, `--page-edges`, `--pack-shape`, `--loop`, `--requirements`, `--idle-keys` |
61
61
  | `compose` | `--scene`, `--out`; optional `--requirements` |
@@ -109,7 +109,7 @@ part name, `[i]` an entry of a list):
109
109
  - **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
110
110
  - **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
111
111
  - **assemble.patches[i].draw**, when an object — required: `before`
112
- - **assemble.cuts[i]** — required: `from`, `into`, `polygon`, `draw`, `overlap`
112
+ - **assemble.cuts[i]** — required: `from`, `into`, `polygon`, `draw`, `overlap`; optional: `under`
113
113
  - **assemble.cuts[i].draw**, when an object — required: `before`
114
114
  - **bones[i]**, a single bone — required: `name`, `parent`, `at`; optional: `tip`
115
115
  - **bones[i]**, a chain — required: `chain`, `parent`, `points`, `tip`
@@ -156,7 +156,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
156
156
  | `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from`, the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px`, `seam_override_px`, and `fringe_pushed_back` when not empty); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite` |
157
157
  | `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts |
158
158
  | `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions`; or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`); `ribs` when a contour or auto mesh declares them (after `exponent`, or after `mode`), and then `contour.ribs` (`source.contour.ribs` in the automatic mode) |
159
- | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `RECOMPOSITE_HOLES`, `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
159
+ | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `RECOMPOSITE_HOLES`, `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
160
160
  | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections and `notes` |
161
161
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
162
162
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.3.0",
3
+ "version": "1.4.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": {
package/src/assemble.ts CHANGED
@@ -73,6 +73,10 @@
73
73
  * recorded with the same `<run>:<tag>` (its pixels came from that layer).
74
74
  * The cut is made after step 4, so the new part takes the merged pixels and
75
75
  * every later step treats it as a part of its own.
76
+ * - **What lies under a piece** (`cuts[].under`, `fillUnder`, issue #193) is
77
+ * this port's: an authored image the base takes where the cut removed its
78
+ * pixels, so a piece that swings away leaves no hole. The stage reads that
79
+ * image only there and never invents a pixel.
76
80
  *
77
81
  * Pure: no clock, no randomness, no file access. The CLI reads and writes.
78
82
  */
@@ -213,6 +217,13 @@ export interface AssembleInput {
213
217
  patches: Patch[];
214
218
  /** `assemble.cuts`: plan parts' pixels taken into new parts by polygon (`cutPart`), placed per their `draw`. */
215
219
  cuts: Cut[];
220
+ /**
221
+ * `cuts[i].under`'s image, decoded by the caller (the stage reads no file),
222
+ * at the cut's index: null for a cut without `under`. Absent = every cut
223
+ * without one. A cut that names `under` with no image here is refused
224
+ * (`ASSEMBLE_UNDER_PRESENT`), never assembled as if it named none.
225
+ */
226
+ under?: ReadonlyArray<Raster | null>;
216
227
  seamRule: SeamRule;
217
228
  projectRule: ProjectRule;
218
229
  }
@@ -1490,6 +1501,10 @@ export interface CutCount {
1490
1501
  taken: number;
1491
1502
  /** Of `taken`, the band the from part keeps as well (`cutBand`); counted in neither `kept` nor `before` twice. */
1492
1503
  band: number;
1504
+ /** With `under` only (issue #193): the base pixels filled from the under image (`fillUnder`); absent without it. */
1505
+ under?: number;
1506
+ /** With `under` only: the removed pixels the image left empty (its alpha below the alpha removed); `under + underEmpty` is the removed count. */
1507
+ underEmpty?: number;
1493
1508
  }
1494
1509
 
1495
1510
  /**
@@ -1502,10 +1517,58 @@ export function cutLines(cuts: readonly CutCount[]): string[] {
1502
1517
  const byFrom = new Map<string, CutCount[]>();
1503
1518
  for (const c of cuts) byFrom.set(c.from, [...(byFrom.get(c.from) ?? []), c]);
1504
1519
  return [...byFrom.entries()].map(
1505
- ([from, cs]) => ` cut: "${from}" opaque ${cs[0].before} = "${from}" ${cs[0].kept} + ${cs.map((c) => `"${c.into}" ${c.taken}`).join(' + ')}; band ${cs.reduce((a, c) => a + c.band, 0)} px held by both`,
1520
+ ([from, cs]) =>
1521
+ ` cut: "${from}" opaque ${cs[0].before} = "${from}" ${cs[0].kept} + ${cs.map((c) => `"${c.into}" ${c.taken}`).join(' + ')}; band ${cs.reduce((a, c) => a + c.band, 0)} px held by both` +
1522
+ (cs.some((c) => c.under !== undefined) ? `; under ${cs.reduce((a, c) => a + (c.under ?? 0), 0)} px filled, ${cs.reduce((a, c) => a + (c.underEmpty ?? 0), 0)} px left empty` : ''),
1506
1523
  );
1507
1524
  }
1508
1525
 
1526
+ /**
1527
+ * The box a cut's `under` image covers (issue #193): the polygon's bounding
1528
+ * box in rig pixels, its corners the polygon's least and greatest x and y —
1529
+ * the image is `x1 - x0` by `y1 - y0` and its pixel `(i, j)` is rig pixel
1530
+ * `(x0 + i, y0 + j)`. Every pixel the polygon holds lies in it: a pixel is
1531
+ * held by its centre, and the centres inside lie strictly between the
1532
+ * extreme vertices.
1533
+ */
1534
+ export function underBox(polygon: ReadonlyArray<readonly [number, number]>): { x0: number; y0: number; w: number; h: number } {
1535
+ const xs = polygon.map((q) => q[0]);
1536
+ const ys = polygon.map((q) => q[1]);
1537
+ const x0 = Math.min(...xs);
1538
+ const y0 = Math.min(...ys);
1539
+ return { x0, y0, w: Math.max(...xs) - x0, h: Math.max(...ys) - y0 };
1540
+ }
1541
+
1542
+ /**
1543
+ * Fill the base where a cut removed its pixels (issue #193). The removed
1544
+ * pixels are those the piece holds (alpha above 0) and the band does not —
1545
+ * the pixels the cut cleared in the base. At each, when the under image's
1546
+ * alpha is at least the alpha the cut removed there, the base takes the
1547
+ * image's pixel, colour and alpha (filled); otherwise the base stays cleared
1548
+ * there (left empty): a transparent pixel is the author's "nothing lies under
1549
+ * here", and a pixel less opaque than the layer was is not half-filled. That
1550
+ * is the only place the image is read: its pixels over the band, over pixels
1551
+ * the layer never had, and over the box outside the polygon change nothing.
1552
+ * `filled + empty` is the removed count.
1553
+ */
1554
+ export function fillUnder(base: Raster, piece: Raster, band: Mask, under: Raster, box: { x0: number; y0: number }): { filled: number; empty: number } {
1555
+ const W = base.width;
1556
+ let filled = 0;
1557
+ let empty = 0;
1558
+ for (let p = 0; p < W * base.height; p++) {
1559
+ const a = piece.data[p * 4 + 3];
1560
+ if (a === 0 || band.data[p] === 1) continue;
1561
+ const u = ((Math.floor(p / W) - box.y0) * under.width + (p % W) - box.x0) * 4;
1562
+ if (under.data[u + 3] < a) {
1563
+ empty++;
1564
+ continue;
1565
+ }
1566
+ base.data.set(under.data.subarray(u, u + 4), p * 4);
1567
+ filled++;
1568
+ }
1569
+ return { filled, empty };
1570
+ }
1571
+
1509
1572
  /**
1510
1573
  * A cut's band (issue #170, `overlap`): the polygon's pixels within `overlap`
1511
1574
  * rig px of its edge, distance read as Chebyshev distance — a pixel of the
@@ -1581,6 +1644,7 @@ export function checkPlanAgainstRuns(plan: PlanEntry[], extend: Extend[], full:
1581
1644
  */
1582
1645
  export function assemble(input: AssembleInput): AssembleResult {
1583
1646
  const { source, full, head, plan, extend, patches, cuts, seamRule, projectRule } = input;
1647
+ const unders = cuts.map((q, i) => input.under?.[i] ?? null);
1584
1648
  const frame = checkGeometry(
1585
1649
  { sourceW: source.width, sourceH: source.height, resolution: input.resolution, headBox: input.headBox, rigScale: input.rigScale },
1586
1650
  { full, head },
@@ -1647,6 +1711,41 @@ export function assemble(input: AssembleInput): AssembleResult {
1647
1711
  }
1648
1712
  }
1649
1713
  });
1714
+ // A cut's under image (issue #193), before any pixel work too: it must have
1715
+ // been read, be exactly the polygon's bounding box, and the piece must be
1716
+ // drawn in front of the part it fills — drawn behind, the fill would hide
1717
+ // the piece where it was painted.
1718
+ const order0 = drawOrder(
1719
+ plan.map((e) => e[0]),
1720
+ patches,
1721
+ cuts,
1722
+ );
1723
+ cuts.forEach((q, i) => {
1724
+ if (q.under === undefined) return;
1725
+ const object = `config.assemble.cuts[${i}].under (part "${q.into}")`;
1726
+ const u = unders[i];
1727
+ if (u === null) {
1728
+ patchProblems.push({ code: 'ASSEMBLE_UNDER_PRESENT', object, detail: `names ${q.under}, and no image was handed to the stage for it; the decoded PNG is required (a caller of assemble() reads the file)` });
1729
+ return;
1730
+ }
1731
+ const box = underBox(q.polygon);
1732
+ if (u.width !== box.w || u.height !== box.h) {
1733
+ patchProblems.push({
1734
+ code: 'ASSEMBLE_UNDER_SIZE',
1735
+ object,
1736
+ detail: `${q.under} is ${u.width}x${u.height}; the polygon's bounding box is ${box.w}x${box.h} at ${box.x0},${box.y0} (least and greatest polygon x and y, in rig pixels), and an image of exactly that size is required — its pixel (i, j) is rig pixel (${box.x0} + i, ${box.y0} + j)`,
1737
+ });
1738
+ }
1739
+ const at = order0.findIndex((d) => 'cut' in d && d.cut === i);
1740
+ const base = order0.findIndex((d) => 'plan' in d && plan[d.plan][0] === q.from);
1741
+ if (at < base) {
1742
+ patchProblems.push({
1743
+ code: 'ASSEMBLE_UNDER_DRAW',
1744
+ object: `config.assemble.cuts[${i}].draw (part "${q.into}")`,
1745
+ detail: `is ${JSON.stringify(q.draw)}, which draws "${q.into}" behind "${q.from}"; with under, "${q.from}" is filled where the piece was painted and would hide it, so a draw in front of "${q.from}" is required — "front", or {"before": "<the plan part after ${q.from}>"} to keep the setup pose`,
1746
+ });
1747
+ }
1748
+ });
1650
1749
  refuseIfAny(patchProblems);
1651
1750
 
1652
1751
  interface Built {
@@ -1810,16 +1909,38 @@ export function assemble(input: AssembleInput): AssembleResult {
1810
1909
  }
1811
1910
  b.region = own;
1812
1911
  b.merged = mergedIn(b, own);
1813
- mine.forEach((i, k) => cutCounts.push({ from: name, into: cuts[i].into, before, kept, taken: taken[k], band: bands[k] }));
1912
+ // Issue #193: the base takes the under image where the cut removed its
1913
+ // pixels, after the counts above were taken from the cut alone. A filled
1914
+ // pixel lies outside the base's region, so it is counted occluded (the
1915
+ // piece is drawn over it) and never projected.
1916
+ const filled = mine.map((i) => {
1917
+ const u = unders[i];
1918
+ if (u === null) return undefined;
1919
+ // Filled where the image is at least as opaque as the layer was; left empty elsewhere (`fillUnder`).
1920
+ const f = fillUnder(b.r, (pieces[i] as Built).r, cutBand(cutMasks[i], cuts[i].overlap), u, underBox(cuts[i].polygon));
1921
+ if (f.filled === 0) {
1922
+ empty.push({
1923
+ code: 'ASSEMBLE_UNDER_EMPTY',
1924
+ object: `config.assemble.cuts[${i}].under (part "${cuts[i].into}")`,
1925
+ detail: `${cuts[i].under} fills 0 of the ${f.empty} pixel(s) the cut removed from "${name}": at every one its alpha is below the alpha removed (an all-transparent image, or another file); an image that fills at least one is required — to leave the whole hole, drop under`,
1926
+ });
1927
+ }
1928
+ return f;
1929
+ });
1930
+ mine.forEach((i, k) => {
1931
+ const c: CutCount = { from: name, into: cuts[i].into, before, kept, taken: taken[k], band: bands[k] };
1932
+ const f = filled[k];
1933
+ if (f !== undefined) {
1934
+ c.under = f.filled;
1935
+ c.underEmpty = f.empty;
1936
+ }
1937
+ cutCounts.push(c);
1938
+ });
1814
1939
  });
1815
1940
  refuseIfAny(empty);
1816
1941
  // In cut order, as the config lists them.
1817
1942
  cutCounts.sort((a, b) => cuts.findIndex((q) => q.into === a.into) - cuts.findIndex((q) => q.into === b.into));
1818
- const order = drawOrder(
1819
- plan.map((e) => e[0]),
1820
- patches,
1821
- cuts,
1822
- );
1943
+ const order = order0;
1823
1944
  const pieceAt = (d: { plan: number } | { cut: number }): { name: string; b: Built } =>
1824
1945
  'plan' in d ? { name: plan[d.plan][0], b: built[d.plan] as Built } : { name: cuts[d.cut].into, b: pieces[d.cut] as Built };
1825
1946
 
@@ -1857,7 +1978,8 @@ export function assemble(input: AssembleInput): AssembleResult {
1857
1978
  for (let p = 0; p < W * H; p++) {
1858
1979
  const e = trace.from[p];
1859
1980
  const own = e < 0 ? st : (stats.get(extendKeys[e]) as ProjectionStats);
1860
- if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE && own.clear.data[p] === 1) visible.data[p] = 1;
1981
+ // A pixel outside the piece's region is a base pixel filled from a cut's under image (issue #193): occluded, never visible.
1982
+ if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE && own.clear.data[p] === 1 && (region === null || region.data[p] === 1)) visible.data[p] = 1;
1861
1983
  if (trace.ring.data[p] === 0 && own.takenMask.data[p] === 1 && (region === null || region.data[p] === 1)) projected.data[p] = 1;
1862
1984
  }
1863
1985
  const { x0, y0, x1, y1, opaque } = measureBox(r);