rig-parts 1.87.0 → 1.88.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
@@ -555,7 +555,8 @@ usage:
555
555
  [--page-edges pot|free] [--pack-shape rect|polygon]
556
556
  [--requirements <file.json>] [--seam-pairs] [--keep-stills]
557
557
  [--set <name> | --all-sets] [--environment <schedule.json>]
558
- [--wind-band <spec.json>]
558
+ [--wind-band <spec.json>] [--json]
559
+ --json prints the completed check.json object only, with unchanged exit codes.
559
560
  Build, gate, render and measure a rig through rig-c's CLI (the rigc at
560
561
  node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json:
561
562
  a rig spec, which rigc build compiles, not a compiled skeleton.json.
@@ -777,7 +778,7 @@ usage:
777
778
  extends below a crop (ASSEMBLE_SINGLE_RUN_PLAN), and the config sets no
778
779
  seethrough.head_box (ASSEMBLE_SINGLE_RUN_HEAD_BOX).
779
780
  Writes nothing unless every check passed.
780
- --seam defaults to ${DEFAULT_SEAM_RULE}. --project says where a layer takes
781
+ --seam overrides config.assemble.seam, otherwise ${DEFAULT_SEAM_RULE}. --project says where a layer takes
781
782
  the painting's pixel: core (the reference's) erodes every layer's top-most
782
783
  opaque area by 5x5 first, so a part a few pixels wide takes none; visible
783
784
  erodes only along a rim where a later layer is in front. It defaults to
@@ -861,7 +862,7 @@ usage:
861
862
  green build ends with the pack line beside the spineboy yardstick and the
862
863
  three artifact paths — skeleton .json, .atlas and the packed page: the
863
864
  packed atlas is the result, the loose parts are the intermediate it was
864
- made from. --seam defaults to ${DEFAULT_SEAM_RULE}, --project to
865
+ made from. --seam overrides config.assemble.seam, otherwise ${DEFAULT_SEAM_RULE}; --project to
865
866
  ${DEFAULT_PROJECT_RULE} (both as for assemble), --page-edges to ${DEFAULT_PAGE_EDGES} and
866
867
  --pack-shape to ${DEFAULT_PACK_SHAPE} (as for check; both packed builds, rig's gate
867
868
  and check's artifact, take them). --requirements is forwarded to check
@@ -1095,7 +1096,7 @@ const TAKES = {
1095
1096
  rig: { valued: ['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
1096
1097
  propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--head', '--canvas', '--keypoints', '--person', '--wrist-snap', '--grid-rule', '--cloth', '--write-config', '--from'], switches: ['--head-box', '--no-back-hair', '--only-missing'] },
1097
1098
  compare: { valued: ['--left', '--right', '--map'], switches: [] },
1098
- check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements', '--set', '--environment', '--wind-band'], switches: ['--seam-pairs', '--keep-stills', '--all-sets', '--no-extremes'] },
1099
+ check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements', '--set', '--environment', '--wind-band'], switches: ['--seam-pairs', '--keep-stills', '--all-sets', '--no-extremes', '--json'] },
1099
1100
  loop: { valued: ['--frames', '--out'], switches: ['--palette'] },
1100
1101
  assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project', '--alt', '--write-config', '--requirements'], switches: ['--propose-plan'] },
1101
1102
  'comfy seethrough': { valued: ['--image', '--out', '--resolution', '--steps', '--seed', '--prefix', '--host', '--wait', '--timeout', '--poll', '--config', '--depth-run'], switches: ['--offload', '--no-offload', '--lama', '--nf4'] },
@@ -1338,6 +1339,8 @@ function flags(args: string[], command: Command, required: readonly string[]): M
1338
1339
 
1339
1340
  function cmdCheck(args: string[]): number {
1340
1341
  // The switches check takes (issues #202, #209, #454): they carry no value, so they are taken out before the valued flags are read.
1342
+ const json = args.filter((a) => a === '--json').length;
1343
+ if (json > 1) return usage('--json is given twice');
1341
1344
  const seamPairs = args.filter((a) => a === '--seam-pairs').length;
1342
1345
  if (seamPairs > 1) return usage('--seam-pairs is given twice');
1343
1346
  const keepStills = args.filter((a) => a === '--keep-stills').length;
@@ -1347,7 +1350,7 @@ function cmdCheck(args: string[]): number {
1347
1350
  const allSets = args.filter((a) => a === '--all-sets').length;
1348
1351
  if (allSets > 1) return usage('--all-sets is given twice');
1349
1352
  const f = flags(
1350
- args.filter((a) => a !== '--seam-pairs' && a !== '--keep-stills' && a !== '--all-sets' && a !== '--no-extremes'),
1353
+ args.filter((a) => a !== '--seam-pairs' && a !== '--keep-stills' && a !== '--all-sets' && a !== '--no-extremes' && a !== '--json'),
1351
1354
  'check',
1352
1355
  ['--rig', '--out'],
1353
1356
  );
@@ -1365,8 +1368,9 @@ function cmdCheck(args: string[]): number {
1365
1368
  { rig, parts: f.get('--parts'), source: f.get('--source'), out, pageEdges: edges, packShape: shape, requirements: f.get('--requirements'), seamPairs: seamPairs === 1, keepStills: keepStills === 1, noExtremes: noExtremes === 1, ...(f.has('--set') ? { set: f.get('--set') as string } : {}), allSets: allSets === 1, ...(f.has('--environment') ? { environment: f.get('--environment') as string } : {}), ...(f.has('--wind-band') ? { windBand: f.get('--wind-band') as string } : {}) },
1366
1369
  rigcRunner(bin),
1367
1370
  bin,
1368
- console.log,
1371
+ json === 1 ? () => {} : console.log,
1369
1372
  );
1373
+ if (json === 1) process.stdout.write(`${JSON.stringify(r.figures, null, 1)}\n`);
1370
1374
  return r.figures.PASS ? EXIT_OK : EXIT_REFUSED;
1371
1375
  } catch (err) {
1372
1376
  return printRefusal(err);
@@ -1726,8 +1730,8 @@ function cmdAssemble(args: string[]): number {
1726
1730
  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');
1727
1731
  if (!propose && flags.has('--write-config')) return usage('--write-config belongs to assemble --propose-plan');
1728
1732
  if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
1729
- const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1730
- if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1733
+ const seam = flags.get('--seam');
1734
+ if (seam !== undefined && !(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1731
1735
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
1732
1736
  if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
1733
1737
  const [source, full, config] = ['--source', '--full', '--config'].map((f) => flags.get(f) as string);
@@ -1755,7 +1759,7 @@ function cmdAssemble(args: string[]): number {
1755
1759
  }
1756
1760
  const out = flags.get('--out') as string;
1757
1761
  assembleStage(
1758
- { 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 }) },
1762
+ { source, full, head, config, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), ...(seam === undefined ? {} : { seam: seam as SeamRule }), project: project as ProjectRule, ...(alts.length === 0 ? {} : { alts }) },
1759
1763
  { 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) },
1760
1764
  console.log,
1761
1765
  );
@@ -1946,8 +1950,8 @@ function cmdBuild(args: string[]): number {
1946
1950
  // --head is optional (issue #359), as for assemble.
1947
1951
  for (const f of ['--config', '--source', '--full', '--out']) if (!flags.has(f)) return usage(`build needs ${f}`);
1948
1952
  if (on.has('--all-sets') && flags.has('--set')) return usage('--set and --all-sets are two modes; give one');
1949
- const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1950
- if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1953
+ const seam = flags.get('--seam');
1954
+ if (seam !== undefined && !(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1951
1955
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
1952
1956
  if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
1953
1957
  const edges = pageEdgesOf(flags.get('--page-edges'));
@@ -1971,7 +1975,7 @@ function cmdBuild(args: string[]): number {
1971
1975
  const scratch = mkdtempSync(join(tmpdir(), 'rig-parts-build-'));
1972
1976
  try {
1973
1977
  const r = build(
1974
- { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges, packShape: shape, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), idleKeys: keys, ...(leadText === undefined ? {} : { leadIn: Number(leadText) }), seamPairs: on.has('--seam-pairs'), keepStills: on.has('--keep-stills'), noExtremes: on.has('--no-extremes'), ...(alts.length === 0 ? {} : { alts }), ...(flags.has('--set') ? { set: flags.get('--set') as string } : {}), allSets: on.has('--all-sets'), ...(flags.has('--environment') ? { environment: flags.get('--environment') as string } : {}), ...(flags.has('--wind-band') ? { windBand: flags.get('--wind-band') as string } : {}) },
1978
+ { config, source, full, head, out, ...(seam === undefined ? {} : { seam: seam as SeamRule }), project: project as ProjectRule, loop, pageEdges: edges, packShape: shape, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), idleKeys: keys, ...(leadText === undefined ? {} : { leadIn: Number(leadText) }), seamPairs: on.has('--seam-pairs'), keepStills: on.has('--keep-stills'), noExtremes: on.has('--no-extremes'), ...(alts.length === 0 ? {} : { alts }), ...(flags.has('--set') ? { set: flags.get('--set') as string } : {}), allSets: on.has('--all-sets'), ...(flags.has('--environment') ? { environment: flags.get('--environment') as string } : {}), ...(flags.has('--wind-band') ? { windBand: flags.get('--wind-band') as string } : {}) },
1975
1979
  { rig: rigcRunner(bin), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate'), stages: {
1976
1980
  assemble: (input, outputs) => runBuildProcess({ stage: 'assemble', bin, input, outputs }, join(scratch, 'workers')),
1977
1981
  rig: (input, _rigc, gateScratch) => runBuildProcess({ stage: 'rig', bin, input, scratch: gateScratch }, join(scratch, 'workers')),
package/docs/AUTHORING.md CHANGED
@@ -147,6 +147,7 @@ read by no CPU stage).
147
147
  | `seethrough.steps`, `.seed`, `.offload` | `comfy seethrough --config` | run inputs when `--config <config.json>` is given; explicit CLI flags override them, including `--no-offload` for false |
148
148
  | `seethrough.head_box` | inputs (cuts the head image there; optional at the first call); assemble — where the head run sits on the painting | **proposed** by `propose --head-box` from the full run, square, source pixels, shifted inside the painting when needed; the head run must then be fed exactly that crop. With one run (no `--head`, issue #359: an artist's own file, §4 *For artists*) it is left out — there is no crop to place — and a config that sets it is refused (`ASSEMBLE_SINGLE_RUN_HEAD_BOX`) |
149
149
  | `seethrough.arm_split` | assemble, `assemble --propose-plan` — separates a single full-run `handwear-r` or `handwear-l` layer that holds both overlapping arms into both arm tags | **authored**, optional (issue #439): `{x?: number, line?: [[x, y], …], order: ["handwear-r", "handwear-l"] \| ["handwear-l", "handwear-r"]}` in `seethrough.resolution` full-run pixels. Exactly one of `x` (image column, `0 <= x <= resolution`) or `line` (a polyline of two or more `[x, y]` points in `[0, resolution]`, interpolated in x along y and clamped above/below its endpoints) is required (`CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE`), and `order` is `[back_tag, front_tag]`, the draw order of the two separated arm layers (`CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE`). Pixels at `< splitX(y)` go to `handwear-r` (image left, the subject's right arm) and pixels at `>= splitX(y)` go to `handwear-l` (image right, the subject's left arm). A split line that cuts a horizontal opaque run of the arm layer into more than two segments is refused (`ASSEMBLE_ARM_SPLIT_CUTS_ARM`). Without the declaration, when one arm layer's 8-connected component reaches both wrists under `propose --keypoints`, `propose` emits an `overlapping-arms` `LINT` naming the part, both wrists and `seethrough.arm_split`; absent, no byte of any output moves |
150
+ | `assemble.seam` | assemble, build | **authored**, optional `"near-white"` or `"silhouette"` (issue #905). An explicit `--seam` overrides the config; otherwise this field overrides the existing `near-white` default. An invalid value is `CONFIG_FIELD_TYPE` at `config.assemble.seam`, including when a CLI override is given. Absent, existing build bytes are unchanged. |
150
151
  | `assemble.rig_scale` | assemble — the rig is `trunc(w × rig_scale)` x `trunc(h × rig_scale)`, and every rig-px value after it (`bones`, `meshes`, `motion`, `patches`, `cuts`) is in that rig's pixels; `check --source` (`CHECK_SOURCE_SIZE`) and `compose` (`SCENE_RIG_SCALE_AGREES`) read it back off `parts.json` | authored; rig pixels per source pixel. 0.5 in both examples (a 1664x2432 painting makes an 832x1216 rig). **Keep the figure about 1,100 to 1,200 rig px tall** — the `H` of `propose`'s `mesh spacing:` note, the height of the parts' boxes together — so for another painting, about 1,150 over the figure's height in painting px: that is the size the proposer's spacing divisor was calibrated at (H 1106–1185, the `mesh spacing` note says so) and the size of the two examples `check`'s bars are derived from, and the proposer and `check` also hold fixed rig-px values that do not scale with it (the fringe's 10 px step, the trunk segments' 18, 20, 30 and 100 px, the 15 px wrist tolerance, the 10-row pendant floor, the seam bar's 50 rig px over 40). What else it trades, measured on `demo` with the README loop from a fresh config at 0.25, 0.5 and 1 (issue #345, rig-c 2.47.1): the same plan and the same ten chains; every bar but the one the uncorrected `demo` always fails (`bottomwear` `TIP_OVER_ROOT`, the README loop's `RL01`) passing at all three; the atlas page 418x606, 832x1205 and 1825x2047; the figure's mesh spacing 16, 32 and 64, so 830, 868 and 932 triangles; the breath 2.5 % of a torso that scales with it; and the proposed bones, scaled to 0.5, off the 0.5 proposal's by up to 38 px at 0.25 and 22.5 px at 1 (the hairpin's origin; the skirt roots 20 and 10.5) — the fixed-px values above. `propose` records it on the proposal as `proposed_rig_scale` (issue #354), and changing `assemble.rig_scale` after the rig sections are written is refused (`CONFIG_PROPOSED_RIG_SCALE`): without that refusal `demo`'s tracked config built at 0.25 gated green and `check` failed only `TIP_OVER_ROOT` on both sleeves (`propose --from-config` named 54 `LINT` lines, links off the art) — re-run `assemble` and `propose` at the new scale. Absent: `CONFIG_FIELD_PRESENT`; 0, negative or not a number: `CONFIG_FIELD_TYPE`; a rig under 1x1: `ASSEMBLE_RIG_SIZE` |
151
152
  | `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. A layer the author made and added to a run takes `author:<label>` in the tag's place, and has no role (§5, *A layer the author made*). When `generation.control.skeleton` is present and a full-run `handwear-r` or `handwear-l` layer merges sleeve, bare hand and overgarment drape into one layer — a skin-coloured hand cluster (`>= 40` run px, `GHOST_MIN_AREA`, measured `516..4,787` run px across the 9-standee corpus) lies within `0.35` forearm lengths of `<s>_wrist` and non-skin pixels reach more than `1.0x` the forearm length (`<s>_elbow -> <s>_wrist`) below `<s>_wrist` (issue #490; measured `-0.15x..0.45x` on the 6 clean standees vs `1.25x..2.02x` on the 3 merged standees, placing `1.0x` inside the `0.45x..1.25x` gap) — the layer is split into `drape-<s>`, `handwear-<s>` and `hand-<s>` (back to front) when the Euclidean RGB distance between the sleeve median colour above `<s>_wrist` and the drape median colour below `<s>_wrist` is at least `80` (`ARM_DRAPE_COLOR_DISTANCE`, measured `103.6..236.0` on the two distinct-colour merged standees vs `20.8..28.2` on the same-colour merged standee, placing `80` between `28.2` and `103.6`), or refused with `SEETHROUGH_ARM_RUN_MERGED` when the distance is under `80` |
152
153
  | `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 |
@@ -5233,6 +5234,7 @@ and stay absent).
5233
5234
  | <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 |
5234
5235
  | <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 |
5235
5236
  | <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 |
5237
+ | <a id="RIG_TURN_HEAD_ROLL"></a>`RIG_TURN_HEAD_ROLL` | **Warning:** a declared head turn has an absolute authored-eye-origin roll above the empirical limit displayed as 18.485385 degrees; names the face/eye parts, both eye bones, signed roll and limit | inspect the neck at both extremes; this proxy does not detect the painting independently, correct tilt or change emitted bytes; see [Head roll before a declared turn](#head-roll-before-a-declared-turn) |
5236
5238
  | <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` |
5237
5239
  | <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 |
5238
5240
  | <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` |
@@ -5663,7 +5665,7 @@ Under `--source <painting.png>`, a second reported line, also never a FAIL
5663
5665
  | line | reports | bar | `reference-demo` | `reference-sample` | SKIP when |
5664
5666
  | --- | --- | --- | --- | --- | --- |
5665
5667
  | <a id="SETUP_POSE_VS_SOURCE"></a>`SETUP_POSE_VS_SOURCE` | the setup pose against the painting, by `assemble`'s own `measureRecomposite` over the stage's pixels — `mean_abs` and `within_share` (mean channel, within 8), `error_px` (max channel over 40), `uncovered_error_px` (of those, where the pose has alpha 128 or less), `hole_count` and the largest hole's box — with `painting_px`, `stage_px`, `render_px` and `render_scale` beside them. The painting is resampled onto the stage as assemble resamples it (lanczos3, alpha dropped). rigc renders onto an opaque grey, so the pose over white and its alpha are read off a second still of the same pose with every slot tinted black; both are carried onto the stage with the seam's map turned round (bilinear) | none — status `REPORTED`; assemble applies no bar to the figures it measures, so none is derived here | 3.146 / 93.43 % / 12,399 / 1,732, 518 holes, the largest 61 px at 351,157 6x20 (assemble's recomposite: 2.391 / 95.81 % / 11,050 / 1,564, 453) | 1.095 / 97.30 % / 5,348 / 1,239, 313 holes, the largest 88 px at 479,416 6x29 (assemble's: 0.835 / 97.95 % / 4,512 / 1,185, 259) | not written without `--source` |
5666
- | <a id="SETUP_MATCHES_PAINTING"></a>`SETUP_MATCHES_PAINTING` | declared region count, native box, limit and actual top contributor rows | PASS/FAIL against authored limits; no limit is SKIP; undeclared regions are not judged |
5668
+ | <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; with `--source`, no declaration is SKIP with its reason and declaration remedy; undeclared regions are not judged |
5667
5669
 
5668
5670
  The figures are assemble's by construction, not a second definition beside them;
5669
5671
  what differs is what they are taken of — the pose rigc draws, not the flat stack —
@@ -6229,7 +6231,22 @@ maximum absolute RGB-channel difference is **strictly greater** than that
6229
6231
  value. The region passes when its total count is at most its authored
6230
6232
  nonnegative integer `max_pixels`. Without that limit the region reports
6231
6233
  **SKIP**, including a zero-count region. An undeclared region has no verdict.
6232
- Without `setup_painting`, existing output bytes and rendering remain unchanged.
6234
+ With `--source` but no `setup_painting` declaration, `SETUP_MATCHES_PAINTING`
6235
+ is explicitly **SKIP** in the console, `check.json`, the report's skipped-bar
6236
+ list and `check --json`. Its reason says the comparison was not measured and
6237
+ asks for `--requirements <file>` declaring `setup_painting.rgb_tolerance` and
6238
+ named `regions` with `box` and authored `max_pixels` limits. The SKIP changes
6239
+ no exit code and invents no metric, region or limit. This applies both without
6240
+ a requirements file and with one that omits the key. Without `--source` and
6241
+ without the declaration, the line stays absent. Declared observations retain
6242
+ their output bytes and renderer calls.
6243
+
6244
+ `check --json` prints only the completed `check.json` object to stdout, with
6245
+ the same bytes as the file and the same exit code as ordinary `check` (0 or 1).
6246
+ Early input refusals and usage errors retain their named text and exits (1 or 2);
6247
+ they have no completed check object. `build --json` is refused by name: run
6248
+ `check --rig <out>/rig --parts <out> --out <dir> --json` after the build to read
6249
+ the completed check object.
6233
6250
 
6234
6251
  The observation is the actual renderer's setup pose, without animation or
6235
6252
  physics advancement, from the already gated setup candidate. Its viewport is
@@ -6566,6 +6583,47 @@ art. An authored `parts.json` depth reference remains an active standalone
6566
6583
  guards still apply. With binding inactive there is no added reference, file or
6567
6584
  legacy-byte change. #773 adds no image generation, geometry or motion model.
6568
6585
 
6586
+ ### Head roll before a declared turn
6587
+
6588
+ `rig` and `build` report `WARN RIG_TURN_HEAD_ROLL` when a declared head
6589
+ turn (`turn.depths` or `turn.roles`, including the depth-sampled producer)
6590
+ has an absolute setup eye-line roll above 18.485385 degrees (six-place display).
6591
+ The line names the face part (or the eye parts without a face), the signed
6592
+ roll, its absolute value, the limit, and both eye bones and parts. It reads
6593
+ the existing `eyeLineDegrees` on the authored `eye_r` and `eye_l` origins,
6594
+ image-left to image-right in Spine degrees, counter-clockwise; it does not
6595
+ detect the painting's tilt independently of those authored origins, estimate
6596
+ tilt from a bounding box, or invent a reading without both eye parts
6597
+ and bones or when their origins share a column.
6598
+
6599
+ The limit is the midpoint between the maximum absolute roll of the measured
6600
+ public controls, 4.864514 degrees, and the tilted `three-quarter`
6601
+ input from issue #900, 32.106256 degrees. The comparison uses the unrounded
6602
+ midpoint. The controls are the five
6603
+ parts examples plus the published dancer config and head-turn demo's compiled
6604
+ setup eye origins. The named quiet controls read 3.239700 degrees
6605
+ (`reference-demo`), -1.941486 degrees (`dancer`), and
6606
+ 0.520856 degrees (head-turn demo). This is an empirical separation
6607
+ of these inputs, not an anatomical limit or a visual-quality verdict.
6608
+
6609
+ The parts configs were read at
6610
+ [`38e5f574`](https://github.com/rig-c/parts/tree/38e5f574a88b2e6753a982b0ee0952dbf273a12e/examples),
6611
+ the [dancer config](https://github.com/rig-c/examples/blob/564285414fccf7b47df32501c0b3c2968db77691/dancer/config.json)
6612
+ at `56428541`, and the
6613
+ [head-turn model](https://github.com/rig-c/rig-c.github.io/blob/989cebbd6733a58142923cec96fe2333da08194a/demos/head-turn/bundle/skeleton.model.json)
6614
+ at `989cebbd`. The [issue #900](https://github.com/rig-c/parts/issues/900)
6615
+ bootstrap used public `three-quarter` inputs at examples commit
6616
+ `ba99e403d4058fc1cfe40699bdcde8aaf00a90c0`; its authored crop-pixel eye origins
6617
+ are `[236, 136]` and `[287, 104]`. The compiled demo is a setup metric control,
6618
+ not a rebuild of its unavailable parts config.
6619
+
6620
+ An already rolled head is turned about upright yaw/pitch axes; inspect its
6621
+ neck at both extremes before accepting that motion. This warning does not
6622
+ refuse the build, correct the tilt, add a roll axis, or change any pose or
6623
+ emitted file. With no head turn, including a body-only turn, it adds no warning.
6624
+ With an unreadable eye line the existing face-axis report retains its reason;
6625
+ silence is not a measured zero. The warning lives only in the stage report.
6626
+
6569
6627
  ### Missing body-turn coverage
6570
6628
 
6571
6629
  `RIG_TURN_BODY_UNCOVERED` warns when a shown part without a
@@ -6682,6 +6740,26 @@ The trial's role relief/width ratios (face 0.1264803, front hair 0.1018772,
6682
6740
  back hair 0.0857722, ornament 0.0375868, garment 0.0990473) came from one
6683
6741
  head-turn demo's p5..p95 depth and part widths. They are measured starting
6684
6742
  points for declared proportions, not anatomical laws or default calibration.
6743
+ For a head-referenced calibration, declare
6744
+ `relief = referenceRoleRelief * targetFaceWidth / referenceFaceWidth`, using
6745
+ the assembled target face width and the same declared reference head measure
6746
+ for every role. This keeps head relief on one scale when a hair silhouette
6747
+ is much wider than the face. In contrast, `roleRatio * targetPartWidth`
6748
+ increases relief with that silhouette while the group's `zScale` stays fixed.
6749
+ An ornament narrower than its reference can gain relief under the head rule;
6750
+ inspect it as well as the widest part. The command derives neither rule:
6751
+ write the resulting rig-pixel `relief` explicitly in each recipe entry.
6752
+
6753
+ Compare calibrations on identical prepared inputs and numeric maps, with the
6754
+ same medians, group pivot and `zScale`; only the declared relief changes.
6755
+ Read each named report row's `relief`, `zScale` and `clipFraction` before
6756
+ judging its render. The clipping denominator is every original part pixel
6757
+ with alpha >=128, including boundary support, never the padded input area.
6758
+ A low clipped fraction does not establish shape quality. Inspect setup,
6759
+ both yaw endpoints and their difference at native scale, then enlarge the
6760
+ same pixels with nearest-neighbour sampling. Neither clipping nor agreement
6761
+ with a reference map substitutes for that inspection.
6762
+
6685
6763
  The owner's preference for per-part volume is subjective provenance, not an
6686
6764
  automated quality conclusion. Model licence posture remains the owner's choice.
6687
6765
 
package/docs/STABILITY.md CHANGED
@@ -62,7 +62,7 @@ defaults are the help's too, and none is guessed from the art.
62
62
  | `propose` | `--parts`, `--source`, `--out`, `--full`, `--head`, `--write-config` together: the [neck draft](AUTHORING.md#a-neck-that-moves-on-its-own) for an existing config with `turn.body` and one neck plan row; reassembles under `<out>/rig`, preserves the sampled turn and drafts a rigid neck unit (issue #826) |
63
63
  | `compare` | `--left`, `--right`; optional `--map` |
64
64
  | `rig` | `--config`, `--parts`, `--out`; optional `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
65
- | `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements`, `--seam-pairs`, `--keep-stills`, `--no-extremes` (issue #829), `--set` or `--all-sets` (issue #454), `--environment` (issue #538, additive), `--wind-band` (issue #590, additive) |
65
+ | `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements`, `--seam-pairs`, `--keep-stills`, `--no-extremes` (issue #829), `--set` or `--all-sets` (issue #454), `--environment` (issue #538, additive), `--wind-band` (issue #590, additive), `--json` (issue #904) |
66
66
  | `loop` | `--frames`, `--out`; optional `--palette` |
67
67
  | `build` | `--config`, `--source`, `--full`, `--out`; optional `--head` (as for `assemble`), `--seam`, `--project`, `--page-edges`, `--pack-shape`, `--loop`, `--lead-in`, `--requirements`, `--idle-keys`, `--seam-pairs`, `--keep-stills`, `--no-extremes` (issue #829), `--alt` (as for `assemble`, issue #454), `--set` or `--all-sets` (as for `check`, issue #454), `--environment` (as for `check`, issue #538, additive), `--wind-band` (as for `check`, issue #590, additive) |
68
68
  | `compose` | `--scene`, `--out`; optional `--requirements`, `--accept` |
@@ -116,6 +116,18 @@ rules that make the contract:
116
116
  - **`turn`** (issue #251) adds a control bone `turn` under `root` that nothing
117
117
  keys, and carries each part in `depths` by `(pivot − depth) / (pivot − nearest
118
118
  depth)` of its x. Without the field no byte of any output moves.
119
+ A declared head turn (`depths` or `roles`) reports `RIG_TURN_HEAD_ROLL`
120
+ when the absolute setup eye-line roll exceeds the limit displayed as
121
+ 18.485385 degrees
122
+ (issue #903). This empirical limit is the midpoint of the measured public
123
+ control maximum, 4.864514 degrees, and `three-quarter`,
124
+ 32.106256 degrees (the comparison uses the unrounded midpoint). The warning
125
+ names the face/eye parts, the eye
126
+ bones, signed roll and limit. It is report-only: no refusal, field, pose or
127
+ emitted-byte change. No head turn, a body-only turn, or an unreadable eye
128
+ line adds no warning; unreadable retains the existing face-axis reason.
129
+ See [Head roll before a declared turn](AUTHORING.md#head-roll-before-a-declared-turn)
130
+ for the measurement scope and its limits.
119
131
  - **`scene`** (issue #242) declares the painting a scene — the figure and what
120
132
  is round it — and takes no field: `{}`. With it `assemble` reads the
121
133
  recomposite inside the figure (the union of the full run's layers at alpha
@@ -135,7 +147,7 @@ part name, `[i]` an entry of a list):
135
147
  - **generation.control.skeleton**, the pose file it names when it is no built-in — required: `width`, `height`, `points`
136
148
  - **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
137
149
  - **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
138
- - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`, `take`
150
+ - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `seam`, `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`, `take`
139
151
  - **assemble.alternates[i]** — required: `name`; optional: `source`, `full`, `head`, `parts`, `hides`, `head_box`, `cuts`, `skeleton`, `anchor`, `base_anchor`, `drawn`, `tips`, `front`, `layers`, `focus` (issues #454, #465, #543, #652, #664, #666, #719, #780). `focus` is an authored finite `[x, y]` in painting pixels, within its canvas (edges included), refused as CONFIG_SET_FOCUS before assemble output when invalid; only when present, `parts.json` records it with focus_source_size and `sets.json` emits it in setup-world coordinates through the actual rig resize and `src/coords.ts`. Absent focus emits no field or inferred value; changing/removing it requires reassembly
140
152
  - **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
141
153
  - **assemble.extend_painting[i]** — required: `part`, `band`, `max_distance`; optional: `min_held_alpha`, `order` (issues #848, #854, #855; explicit final layer-derived part, half-open rig band and positive four-neighbour path bound; base painting only). The optional integer floor 1–255 admits exact original alpha-over coverage strictly below it; absent or 1 blocks every nonzero holder. Only selected target samples below the floor may take painting RGBA; target samples at/above the floor and every non-target holder remain exact. By default overlapping reachable claims refuse. With `order` on every entry (unique nonnegative safe integers), lower values claim first and their gains block later candidates and paths; array order never breaks ties or supplies missing precedence
@@ -206,6 +218,11 @@ part name, `[i]` an entry of a list):
206
218
  - **waist** — required: `top`, `bottom`, `upper`, `lower`, `bone`, `constraint` (#650; absent keeps the ordinary bindings and graph)
207
219
  <!-- /stable:config -->
208
220
 
221
+ `assemble.seam` accepts `near-white` or `silhouette` (issue #905). CLI
222
+ `--seam` overrides config, then the existing `near-white` default applies.
223
+ Invalid values refuse as `CONFIG_FIELD_TYPE` at `config.assemble.seam`;
224
+ an absent field preserves existing output bytes.
225
+
209
226
  `TY14` holds the set of field names in backticks above to the set of names the
210
227
  loader's object checks spell (every `c.object(…)` in `src/config.ts`, with the lists
211
228
  they name), in both directions, and refuses an object check whose key list it cannot
@@ -507,10 +524,17 @@ The separate ordering scalar is unchanged. No-image outputs retain legacy bytes.
507
524
  ### Declared setup-painting regions
508
525
 
509
526
  The optional `setup_painting` key in `spine-parts-requirements/1` adds a
510
- `SETUP_MATCHES_PAINTING` observation only when declared. It requires authored
527
+ `SETUP_MATCHES_PAINTING` measurement when declared. It requires authored
511
528
  `rgb_tolerance` and named native-stage boxes. Each optional `max_pixels` is
512
- that whole region's limit; omission is SKIP, never PASS. Existing files without
513
- the key retain their output bytes and renderer calls. Other requirement fields
529
+ that whole region's limit; omission is SKIP, never PASS. With `--source` but
530
+ no declaration, the console, `check.json` and the report's skipped-bar list now
531
+ say SKIP with the reason and the `--requirements` declaration remedy; exit
532
+ codes stay unchanged. No metric, region or limit is invented. Without source
533
+ and without the key, the line stays absent. Declared output bytes and renderer
534
+ calls remain unchanged. `check --json` prints the completed `check.json` bytes
535
+ to stdout and keeps the ordinary check's exit code; early input refusals and
536
+ usage errors keep their existing named text and exits. `build --json` is refused
537
+ with a `check --json` remedy; build keeps its multi-stage text report. Other requirement fields
514
538
  remain independent. The [authoring contract](AUTHORING.md#setup-against-the-painting)
515
539
  defines white compositing, the figure domain, strict RGB comparison, top
516
540
  contributor attribution and named measurement refusals. The check observes the
@@ -540,3 +564,9 @@ encoding, convexity sign and refusal remedies are in
540
564
  calls these stages; no-config builds retain their existing behavior and bytes.
541
565
  Group numbers and unit depths are explicit; no provider or absolute scale is
542
566
  inferred. A new depth stage never replaces Core's rig gate.
567
+ `relief` is an explicit rig-pixel value, not an implicit width multiplier.
568
+ Part-width and head-referenced calibrations are authored recipe choices;
569
+ changing a calibration requires preparing its recipe again. Compare the same
570
+ provider maps and preserve their provenance. Each report row names the part,
571
+ declared relief, group `zScale` and clipping over original alpha>=128 support;
572
+ it remains a measurement rather than a shape-quality gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.87.0",
3
+ "version": "1.88.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
@@ -1,3 +1,4 @@
1
+ import { type SeamRule } from './seamrule.ts';
1
2
  import { edgeLength } from './length.ts';
2
3
  import { materialSource, type MaterialSource, materialRows, polygonMaterialEdges, type MaterialRow, type MaterialEdge, type PlacedArt } from './cutmaterial.ts';
3
4
  import { unionTakenSources } from './takesource.ts';
@@ -188,16 +189,7 @@ export const SILHOUETTE_BORDER = 8;
188
189
  /** `figureSilhouette`: a pixel is figure where it differs from that median by more than this, max channel. */
189
190
  export const SILHOUETTE_LIMIT = 40;
190
191
 
191
- export type SeamRule = 'near-white' | 'silhouette';
192
- export const SEAM_RULES: readonly SeamRule[] = ['near-white', 'silhouette'];
193
-
194
- /**
195
- * The seam rule a run uses when none is named. Set by measurement, not taste:
196
- * the silhouette rule ships as the default only if it lowers the error pixel
197
- * count on every oracle character without raising mean |d| on any
198
- * (ORACLE_assemble.md, *faithful vs silhouette*).
199
- */
200
- export const DEFAULT_SEAM_RULE: SeamRule = 'near-white';
192
+ export { DEFAULT_SEAM_RULE, SEAM_RULES, type SeamRule } from './seamrule.ts';
201
193
 
202
194
  /**
203
195
  * Where a layer may take the painting's pixel (`projectSource`). `core` is the
package/src/build.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_SEAM_RULE } from './seamrule.ts';
1
2
  import { SETUP_PAINTING_LINE, setupPaintingLines } from './setuppainting.ts';
2
3
  import { writeMaterialSources } from './cutmaterialio.ts';
3
4
  import { CUT_MATERIAL_LINE, CUT_MATERIAL_SOURCES, materialLines } from './cutmaterial.ts';
@@ -164,7 +165,7 @@ export interface AssembleStageInput {
164
165
  /** The head run, or null for one run that holds the face (issue #359). */
165
166
  head: string | null;
166
167
  config: string;
167
- seam: SeamRule;
168
+ seam?: SeamRule;
168
169
  project: ProjectRule;
169
170
  /** `--alt` entries, merged with `config.assemble.alternates` by set name (issue #454). */
170
171
  alts?: readonly AlternateCliInput[];
@@ -327,13 +328,14 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
327
328
  // The early door: assemble runs before propose has drafted bones, meshes,
328
329
  // regions and motion, so it requires only what it reads (issue #20).
329
330
  const cfg = loadEarlyConfig(input.config, 'assemble');
331
+ const seam = input.seam ?? cfg.assemble.seam ?? DEFAULT_SEAM_RULE;
330
332
  const src = readSource(input.source);
331
333
  const runs = applyTakenLayers(readRuns(input.full, input.head), src, cfg, input.config);
332
334
  for (const note of runs.notes) log(`note: ${note}`);
333
335
  const fields = stageFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
334
336
  const under = readUnders(input.config, fields.cuts);
335
337
  const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
336
- const result = assemble({ source: src, full: runs.full, head: runs.head, depth: cfg.depth, depthBase: dirname(resolve(input.config)), ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: input.seam, projectRule: input.project });
338
+ const result = assemble({ source: src, full: runs.full, head: runs.head, depth: cfg.depth, depthBase: dirname(resolve(input.config)), ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: seam, projectRule: input.project });
337
339
 
338
340
  // Issue #454: alternate painting sets, assembled before any file is written.
339
341
  const configDir = dirname(resolve(input.config));
@@ -673,7 +675,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
673
675
  ...(fields.yieldRefused === undefined ? {} : { yieldRefused: fields.yieldRefused }),
674
676
  ...(fields.turnMovingPair === undefined ? {} : { turnMovingPair: fields.turnMovingPair }),
675
677
  ...(fields.turn === undefined ? {} : { turn: fields.turn, regions: fields.regions, meshes: fields.meshes, motion: fields.motion }),
676
- seamRule: input.seam,
678
+ seamRule: seam,
677
679
  projectRule: input.project,
678
680
  ...(altExclude.length === 0 ? {} : { exclude: altExclude }),
679
681
  ...(altIslands.length === 0 ? {} : { limbIslands: altIslands }),
@@ -2218,7 +2220,7 @@ export interface BuildInput {
2218
2220
  /** The head run, or null for one run that holds the face (issue #359). */
2219
2221
  head: string | null;
2220
2222
  out: string;
2221
- seam: SeamRule;
2223
+ seam?: SeamRule;
2222
2224
  project: ProjectRule;
2223
2225
  loop: boolean;
2224
2226
  /** `--page-edges` for both packed builds, the rig stage's gate and the check's artifact. */
@@ -2253,7 +2255,7 @@ export interface BuildInput {
2253
2255
  windBand?: string;
2254
2256
  }
2255
2257
 
2256
- /** What `build` does with one flag `check` takes: hands it to its check stage, takes it as its own, or refuses it because build supplies the value itself. */
2258
+ /** What `build` does with one flag `check` takes: hands it to its check stage, takes it as its own, or refuses it because build supplies the value or report itself. */
2257
2259
  export type CheckFlagInBuild =
2258
2260
  | { as: 'forwarded' }
2259
2261
  | { as: 'own'; why: string }
@@ -2280,6 +2282,7 @@ export const CHECK_FLAGS_IN_BUILD: Readonly<Record<string, CheckFlagInBuild>> =
2280
2282
  '--no-extremes': { as: 'forwarded' },
2281
2283
  '--environment': { as: 'forwarded' },
2282
2284
  '--wind-band': { as: 'forwarded' },
2285
+ '--json': { as: 'supplied', why: 'build supplies its multi-stage text report; run check --rig <out>/rig --parts <out> --out <dir> --json for the completed check object' },
2283
2286
  };
2284
2287
 
2285
2288
  /** The requirements file build's check reads (issue #556): `read` is where it is opened, `recorded` what `check.json` names it by. */
@@ -2315,7 +2318,7 @@ export function buildRequirements(flag: string | undefined, declared: string | u
2315
2318
  ]);
2316
2319
  }
2317
2320
 
2318
- /** The `build` refusal of a `check` flag build supplies itself ({@link CHECK_FLAGS_IN_BUILD}), or null for any other flag. */
2321
+ /** The `build` refusal of a `check` flag whose value or report build supplies itself ({@link CHECK_FLAGS_IN_BUILD}), or null for any other flag. */
2319
2322
  export function suppliedCheckFlag(flag: string): string | null {
2320
2323
  const row = Object.hasOwn(CHECK_FLAGS_IN_BUILD, flag) ? CHECK_FLAGS_IN_BUILD[flag] : undefined;
2321
2324
  return row?.as === 'supplied' ? `build does not take "${flag}"; ${row.why}` : null;
@@ -2408,7 +2411,6 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
2408
2411
  rmSync(join(out, FIGURE_MASK_FILE), { force: true });
2409
2412
  // The idle-keys value is named only when it is not the default, so a build without the flag, or with the default, prints the line it printed before issue #95.
2410
2413
  const idleKeys = input.idleKeys ?? DEFAULT_IDLE_KEYS;
2411
- log(`rig-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ? `, with the idle loop${input.leadIn === undefined ? '' : ` after a lead-in of ${input.leadIn} period(s)`}` : ''}`);
2412
2414
 
2413
2415
  let req: BuildRequirements | null = null;
2414
2416
  let configRecord: InputFileRecord | null = null;
@@ -2420,6 +2422,8 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
2420
2422
  // Issue #556: the flag, or the config's declaration when there is none; both naming other bytes is refused here.
2421
2423
  // Issue #607: the bytes this read parsed are the ones check.json's config record hashes, under the path as typed.
2422
2424
  const loaded = loadConfigWithBytes(input.config);
2425
+ const seam = input.seam ?? loaded.config.assemble.seam ?? DEFAULT_SEAM_RULE;
2426
+ log(`rig-parts build: ${input.config} -> ${out}, seam rule ${seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ? `, with the idle loop${input.leadIn === undefined ? '' : ` after a lead-in of ${input.leadIn} period(s)`}` : ''}`);
2423
2427
  configRecord = inputFileRecord(loaded.bytes, input.config);
2424
2428
  req = buildRequirements(input.requirements, loaded.config.requirements, input.config);
2425
2429
  // The same for the requirements file's own shape; its names resolve against the rig, in the check, before it builds.
@@ -2429,7 +2433,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
2429
2433
  // And the steady-wind band spec (issue #590), as check reads it.
2430
2434
  if (input.windBand !== undefined) readWindBand(input.windBand);
2431
2435
  stages.assemble(
2432
- { source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project, ...(req === null ? {} : { requirements: req.read }), ...(input.alts === undefined ? {} : { alts: input.alts }) },
2436
+ { source: input.source, full: input.full, head: input.head, config: input.config, seam, project: input.project, ...(req === null ? {} : { requirements: req.read }), ...(input.alts === undefined ? {} : { alts: input.alts }) },
2433
2437
  { partsJson: join(out, 'parts.json'), partsDir: join(out, 'parts'), recomposite: join(out, 'recomposite_rig.png'), errorMap: join(out, ERROR_MAP_FILE) },
2434
2438
  prefixed('assemble'),
2435
2439
  );
package/src/check.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SETUP_PAINTING_LINE, setupPaintingBounds, setupPaintingLine, type SetupPainting, type SetupPaintingLine } from './setuppainting.ts';
1
+ import { SETUP_PAINTING_LINE, UNDECLARED_SETUP_PAINTING_REASON, setupPaintingBounds, setupPaintingLine, type SetupPainting, type SetupPaintingLine, type SetupPaintingObservation } from './setuppainting.ts';
2
2
  import { readMaterialSources } from './cutmaterialio.ts';
3
3
  import { CUT_MATERIAL_LINE, checkJointMaterial, type MaterialLine } from './cutmaterial.ts';
4
4
  import { ART_JOINT_LINE, artJointLine, type ArtJointLine } from './artjoints.ts';
@@ -2730,8 +2730,8 @@ export interface CheckFigures {
2730
2730
  requirements?: RequirementsBlock;
2731
2731
  /** Issue #880: only when requirements declares fixed_skeleton. */
2732
2732
  [ART_JOINT_LINE]?: ArtJointLine;
2733
- /** Only when setup_painting is explicitly declared. */
2734
- [SETUP_PAINTING_LINE]?: SetupPaintingLine;
2733
+ /** Under --source: the declared observation, or an explicit undeclared SKIP. */
2734
+ [SETUP_PAINTING_LINE]?: SetupPaintingObservation;
2735
2735
  [CUT_MATERIAL_LINE]?: MaterialLine;
2736
2736
  PASS: boolean;
2737
2737
  }
@@ -6476,7 +6476,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
6476
6476
  const setHints = new Map<string, string>();
6477
6477
  const fixedSkeleton = reqFile?.fixed_skeleton;
6478
6478
  let artJoints: ArtJointLine | null = null;
6479
- let setupPainting: SetupPaintingLine | null = null;
6479
+ let setupPainting: SetupPaintingObservation | null = inp.source !== null && reqFile?.setup_painting === undefined
6480
+ ? { status: 'SKIP', reason: UNDECLARED_SETUP_PAINTING_REASON } : null;
6480
6481
  if (inp.parts !== null || inp.source !== null || fixedSkeleton !== undefined) {
6481
6482
  try {
6482
6483
  mkdirSync(stillDir, { recursive: true });
package/src/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { SEAM_RULES, type SeamRule } from './seamrule.ts';
1
2
  import type { TakeLayers } from './takelayers.ts';
2
3
  import { paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
3
4
  import { baseAnchorProblems, type BaseAnchor } from './baseanchor.ts';
@@ -300,6 +301,8 @@ export interface AlternateSetConfig {
300
301
  }
301
302
 
302
303
  export interface Assemble {
304
+ /** Optional per-character seam rule; an explicit CLI flag wins. */
305
+ seam?: SeamRule;
303
306
  /** Verified named replacements from another See-through run (#866). */
304
307
  take?: TakeLayers[];
305
308
  /** Rig pixels per source pixel. */
@@ -1622,7 +1625,7 @@ export interface EarlyConfig {
1622
1625
  depth?: DepthConfig;
1623
1626
  key: string;
1624
1627
  seethrough: SeeThrough;
1625
- assemble: { rig_scale: number; take?: TakeLayers[]; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
1628
+ assemble: { rig_scale: number; seam?: SeamRule; take?: TakeLayers[]; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
1626
1629
  }
1627
1630
 
1628
1631
  /** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
@@ -1691,8 +1694,9 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1691
1694
  if (door === 'layers') {
1692
1695
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1693
1696
  if ('assemble' in t) {
1694
- const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take']);
1697
+ const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam']);
1695
1698
  if (a !== null) {
1699
+ checkSeam(c, a);
1696
1700
  if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
1697
1701
  if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
1698
1702
  c.fail('CONFIG_FIELD_TYPE', 'config.assemble.head_face_underfill', `is ${show(a.head_face_underfill)}; true or false is required`);
@@ -2457,10 +2461,17 @@ function checkTakeLayers(c: Check, value: Json): void {
2457
2461
  });
2458
2462
  }
2459
2463
 
2464
+ function checkSeam(c: Check, a: Record<string, Json>): void {
2465
+ if ('seam' in a && !(SEAM_RULES as readonly unknown[]).includes(a.seam)) {
2466
+ c.fail('CONFIG_FIELD_TYPE', 'config.assemble.seam', `is ${show(a.seam)}; one of ${SEAM_RULES.join(', ')} is required`);
2467
+ }
2468
+ }
2469
+
2460
2470
  function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
2461
2471
  const required = sectionRequired(door, 'assemble');
2462
- const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take']);
2472
+ const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam']);
2463
2473
  if (a === null) return { parts: [], patches: [], cuts: [] };
2474
+ checkSeam(c, a);
2464
2475
  if (required.includes('plan') && !('plan' in a)) {
2465
2476
  c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
2466
2477
  }
package/src/rig.ts CHANGED
@@ -309,6 +309,14 @@ function lineGap(a: number, b: number): number {
309
309
  /** The bones the face's line runs through: rigc's A51 draws its line through their origins, and the face bones are turned to that same line. */
310
310
  export const EYE_BONES: readonly [string, string] = ['eye_r', 'eye_l'];
311
311
 
312
+ /**
313
+ * Issue #903: midpoint of absolute setup eye-line rolls measured on the public
314
+ * controls (maximum 4.864514437760526 degrees across the five parts examples,
315
+ * dancer and the head-turn demo) and three-quarter (32.106255955117845 degrees).
316
+ * This separates those inputs; it is not an anatomical or visual-quality bar.
317
+ */
318
+ const HEAD_TURN_ROLL_LIMIT = (4.864514437760526 + 32.106255955117845) / 2;
319
+
312
320
  /**
313
321
  * The eye line (issue #239). It exists when parts.json holds an eye on each
314
322
  * side — the first part tagged `eyewhite-r` and the first tagged `eyewhite-l`
@@ -3656,6 +3664,15 @@ export function buildWaistPairSource(
3656
3664
  boneLandmarks.set(bName, [bEntry.x, bEntry.y]);
3657
3665
  }
3658
3666
  const turnWarnings: Problem[] = [];
3667
+ if ((cfg.turn?.depths !== undefined || cfg.turn?.roles !== undefined) && line.degrees !== null && Math.abs(line.degrees) > HEAD_TURN_ROLL_LIMIT) {
3668
+ const faces = parts.parts.filter(p => tagOf(p.from) === 'face' && p.cut_from === undefined).map(p => p.name);
3669
+ const names = faces.length === 0 ? line.parts : faces;
3670
+ turnWarnings.push({
3671
+ code: 'RIG_TURN_HEAD_ROLL',
3672
+ object: `${names.length === 1 ? 'part' : 'parts'} ${names.map(n => `"${n}"`).join(', ')}`,
3673
+ detail: `setup eye-line roll ${pyRound(line.degrees, 6)} degrees (Spine, counter-clockwise), absolute ${pyRound(Math.abs(line.degrees), 6)} exceeds limit ${pyRound(HEAD_TURN_ROLL_LIMIT, 6)} degrees; read through authored ${EYE_BONES.join(' and ')} origins on ${line.parts.map(n => `"${n}"`).join(' and ')} (an eye-origin proxy, not painting detection); the declared head turn uses upright yaw/pitch axes on an already rolled head; inspect the neck at both turn extremes; warning only, no pose or output changed`,
3674
+ });
3675
+ }
3659
3676
  if (cfg.turn?.roles !== undefined || cfg.turn?.body !== undefined) {
3660
3677
  buildDepth25DTurn(rig, motion, {
3661
3678
  warnings: turnWarnings,
@@ -0,0 +1,11 @@
1
+ /** Shared seam choices for the config reader and assembly. */
2
+ export type SeamRule = 'near-white' | 'silhouette';
3
+ export const SEAM_RULES: readonly SeamRule[] = ['near-white', 'silhouette'];
4
+
5
+ /**
6
+ * The seam rule a run uses when none is named. Set by measurement, not taste:
7
+ * the silhouette rule ships as the default only if it lowers the error pixel
8
+ * count on every oracle character without raising mean |d| on any
9
+ * (ORACLE_assemble.md, *faithful vs silhouette*).
10
+ */
11
+ export const DEFAULT_SEAM_RULE: SeamRule = 'near-white';
@@ -74,6 +74,10 @@ export interface SetupPaintingLine {
74
74
  reason?: string;
75
75
  }
76
76
 
77
+ /** An undeclared observation has no measured pixels, inferred regions or limit. */
78
+ export type SetupPaintingObservation = SetupPaintingLine | { status: 'SKIP'; reason: string };
79
+ export const UNDECLARED_SETUP_PAINTING_REASON = 'setup_painting is undeclared; setup-to-painting was not measured. To measure it, give --requirements <file> declaring setup_painting.rgb_tolerance and setup_painting.regions with name, box and authored max_pixels limits';
80
+
77
81
  /** Pixel metric only. Caller supplies the actual renderer's textured setup and
78
82
  * its per-pixel drawn-piece owners on precisely the source grid. RGB under
79
83
  * zero alpha is ignored: both observations are source-over white first.
@@ -119,7 +123,8 @@ export function setupPaintingLine(
119
123
  ...(noLimit ? { reason: 'a region has no declared max_pixels; it is measured as SKIP, never PASS' } : {}) };
120
124
  }
121
125
 
122
- export function setupPaintingLines(line: SetupPaintingLine): string[] {
126
+ export function setupPaintingLines(line: SetupPaintingObservation): string[] {
127
+ if (!('regions' in line)) return [`${SETUP_PAINTING_LINE}: SKIP — ${line.reason}`];
123
128
  return [`${SETUP_PAINTING_LINE}: ${line.status}${line.reason === undefined ? '' : ` — ${line.reason}`}; ${line.registration}; ${line.compositing}`,
124
129
  ...line.regions.flatMap(r => [`${SETUP_PAINTING_LINE}: region "${r.name}" ${r.status} — box [${r.box.join(', ')}], pixelcount ${r.pixel_count}, limit ${r.limit ?? 'undeclared (SKIP)'}, RGB tolerance ${line.rgb_tolerance}`,
125
130
  ...r.slots.map(s => `${SETUP_PAINTING_LINE}: slot "${s.slot}" region "${r.name}" — box [${s.box.join(', ')}], pixelcount ${s.pixel_count}; region total ${r.pixel_count}, limit ${r.limit ?? 'undeclared (SKIP)'}`)])];