rig-parts 1.65.0 → 1.66.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/README.md CHANGED
@@ -272,7 +272,7 @@ Use the graph's node classes and the host's `/object_info` when checking the ada
272
272
  ```sh
273
273
  rig-parts inputs --source painting.png --config config.json --out inputs # st_input_full.png; the config needs assemble.rig_scale
274
274
  # See-through on st_input_full.png (external, or `rig-parts comfy seethrough`) -> layers/full
275
- rig-parts layers layers/full # every layer: box, opaque px, depth, plausibility figures; WARN lines
275
+ rig-parts layers layers/full # drawn(a>8)/opaque(a>=250) boxes, CORE body-group height in input-canvas px, WARNs
276
276
  rig-parts propose --head-box --full layers/full --canvas 1664x2432
277
277
  # -> seethrough.head_box into config.json
278
278
  rig-parts inputs --source painting.png --config config.json --out inputs # now st_input_head.png too
@@ -291,7 +291,8 @@ rig-parts propose --parts work/rig --source painting.png --out work
291
291
  # (limbs only with --keypoints, constraints only with --cloth, turn only with --full/--head;
292
292
  # a limbs block left out is refused next)
293
293
  rig-parts propose --parts work/rig --source painting.png --out work --from-config config.json
294
- # -> LINT lines; exit 1 while any remain
294
+ # -> LINT lines; exit 1 while any remain (takes --parts, --source, --out, --from-config and optional --keypoints [--person] only;
295
+ # proposes nothing, so --full, --head, --cloth, --write-config, --wrist-snap, --grid-rule, --only-missing and --compare are refused)
295
296
  rig-parts build --config config.json --source painting.png --full layers/full --head layers/head --out out --loop
296
297
  # -> read out/check/check.json; every FAIL line names what has to change
297
298
  ```
@@ -333,7 +334,7 @@ agent skill.
333
334
  | `inputs --source <png> --config <json> --out <dir>` | the two images See-through is fed: the painting on a white square, and the head box's crop once the config has one |
334
335
  | `comfy paint --config --out [--host]` | optional: generate the painting on a ComfyUI box from the config's `generation` block |
335
336
  | `comfy seethrough --image --out [--host]` | optional: run the ComfyUI See-through wrapper on one image and write the form `layers` reads |
336
- | `layers <dir \| layers.json \| file.psd>` | print every layer of a decomposition: draw order, name, tag group, box, size, opaque pixels, depth, and its translucent, background and area figures; a `WARN` line for a layer `--propose-plan` will leave out |
337
+ | `layers <dir \| layers.json \| file.psd>` | keep the existing columns (`opaque_px` still means alpha >8) and append source-alpha boxes `drawn(a>8)` / `opaque(a>=250)` with exclusive right/bottom; name absent boxes, report the existing body-group union height at **CORE_ALPHA (>=250)** in **input-canvas px**, and show its >8 union separately; no anatomical height, ownership/erosion or painting registration is inferred ([units and rig-scale guidance](docs/AUTHORING.md#4-the-command-order)) |
337
338
  | `sheet --source <png> --layers <path>… --out <png>` | a labelled contact sheet of the painting and every layer or part |
338
339
  | `assemble --propose-plan … [--write-config <config>]` | propose `assemble.plan` and `extend_below_crop` from the two runs, leaving out an implausible layer with a note naming the rule, naming in a note any full-run body no planned part covers, and naming every layer no row takes with why (empty, taken from the other run only, a plausibility rule; `nose`, never planned, with the row that lets `propose` read the head's pitch) |
339
340
  | `assemble --source --full --head --config --out [--seam] [--project]` | merge the two runs into rig-space parts, `parts.json`, the recomposite and its error map, and list the uncovered holes |
@@ -341,7 +342,7 @@ agent skill.
341
342
  | `propose --parts --source --out [--compare <config>] [--write-config <config> [--only-missing]]` | propose bones, meshes, regions and an idle; draw the overlay; print, per bone, which check read it or that none did and why (its role, its kind of segment), and write `basis.json` beside the proposal — what each bone rests on (a joint, a part's mask, a ratio, other bones) and, with `--compare`, whether the config's bone differs |
342
343
  | `propose … --keypoints <file> [--person <id>]` | read a posed figure's joints from one explicit file (space, image size, people, each joint observed, occluded or missing, its source): a joint with a position places the neck, hip, chest or a sleeve chain as given, a note says how every joint was used or which rule stood in, and LINT reads the torso and sleeves along the joints instead of by screen y ([AUTHORING §3](docs/AUTHORING.md)) |
343
344
  | `propose … --grid-rule figure\|part` | how each drafted lattice's spacing is chosen: `figure` (the default) one spacing for the figure, round(H / 37) rig px, a part already coarser keeping its own, with a note on its calibration and cost; `part` the reference's rule, a tenth of the part's short side (the oracle examples are proposed under it) |
344
- | `propose … --from-config <config>` | draw and LINT the config's current bones (by the joints too, given `--keypoints`) |
345
+ | `propose --parts --source --out --from-config <config> [--keypoints <file> [--person <id>]]` | draw and LINT the config's current bones (by the joints too, given `--keypoints`); proposes nothing, so takes only these flags (not `--full`, `--head`, `--cloth`, `--write-config`, `--wrist-snap`, `--grid-rule`, `--only-missing` or `--compare`) |
345
346
  | `compare --left <file> --right <file> [--map <bonemap.json>]` | compare two skeletons — a config, a proposal or a `rig.json` — bone by bone: origin, parent, tip, length and direction per pair, the bones neither side pairs, the required ones missing, and each side's roles read off the spec; no frame is guessed, and nothing pairs by resemblance (`propose --compare` reads origins only) |
346
347
  | `rig --config --parts --out [--idle-keys ctl\|direct]` | author `rig.json` + `motion.json`, written only after rig-c's gate is green; each mesh is in the mode its config entry names — `grid` (the lattice, what `propose` writes), `contour` (the traced outline with declared interior points) or `auto` (the contour mesh at alpha 1 and above, reduced and locally refined by rig-c's `reduceMesh` under the author's declared bounds, written only when its motion against the unreduced source on the idle passes the author's `motion` bounds through rig-c's `compareMeshesInMotion`; [AUTHORING §3](docs/AUTHORING.md)); every chain link is turned along its chain and carries its `length`, so a physics constraint added later has a lever, with every offset under it in the turned frame so nothing moves; `--idle-keys` says whether the idle's keys on mesh-driving bones go through `<bone>_ctl` parents (default) or stay on the bones with `invariants.idleDrivesMeshes` declared; the config's `constraints` go to rigc as written, and a two-bone ik over keyed links needs `--idle-keys direct` (on `rig` or `build`) |
347
348
  | `check --rig --out [--parts] [--source] [--requirements]` | build packed, gated under `rig-play`, render the idle, measure seam, loop and the six judgement lines (mesh texture stretch among them, from the idle's `render --geometry`), and report the recomposite's holes from `parts.json`; on a rig spec with no `parts.json` or no `idle` (a merged rig, issue #77) the gate still runs and every line that needs the missing input says SKIP by name; `--source <painting.png>` adds `SETUP_POSE_VS_SOURCE`, the setup pose against the painting by assemble's recomposite figures; `--requirements <scene.json>` measures what a scene declares of the motion — a contact, a follow fraction, an aim, a joint range, a mesh's stretch, the seam between two parts over the motion — each against the author's own bar, from rig-c's posed frames, one line each, PASS, FAIL or NOT MEASURABLE (AUTHORING §7, *Declared requirements*) |
package/cli.ts CHANGED
@@ -28,7 +28,7 @@ import { GUIDE_DOC, type GuideAnswer, guideRule, guideSection } from './src/guid
28
28
  import { proposeHeadBox } from './src/headbox.ts';
29
29
  import { makeInputs } from './src/inputs.ts';
30
30
  import { idleKeysOf, isIdleKeys } from './src/rig.ts';
31
- import { depthSkipLine, exportLayers, figuresPhrase, hiddenLayerLine, implausibleRules, layerFigures, type LayerSet, layerSetDiff, pct, readLayers, readWrapperLayers, ruleSummary, templateLayerNames, templatePsdBytes, times } from './src/layers.ts';
31
+ import { bodyCoreBox, bodyOpaqueBox, CORE_ALPHA, depthSkipLine, exportLayers, figuresPhrase, hiddenLayerLine, implausibleRules, layerCoreBox, layerFigures, layerOpaqueBox, type LayerSet, layerSetDiff, type OpaqueBox, OPAQUE_ALPHA_ABOVE, pct, readLayers, readWrapperLayers, ruleSummary, templateLayerNames, templatePsdBytes, times } from './src/layers.ts';
32
32
  import { checkImageSize, choosePerson, KEYPOINTS_SPEC, loadKeypoints, type RigJoints, toRigJoints } from './src/keypoints.ts';
33
33
  import { basisLines, branchSqueezeLine, checkProposal, compare, compareLines, DEFAULT_GRID_RULE, draftTurn, drawLandmarks, FIGURE_GRID_DIVISOR, FRINGE_STEP_PX, GRID_RULES, type GridRule, HIP_MIN_FRACTION, type DraftLintFinding, lint, lintLine, type PartSet, PROPOSAL_CONFIG_SECTIONS, proposalAsConfig, proposeWithBasis, readPartSet, serializeProposal, withTurn, WRIST_ON_PART_PX } from './src/propose.ts';
34
34
  import { BASIS_FILE, BASIS_SPEC, basisFile, basisLine, coverageLines, legChainChoice, serializeBasis } from './src/diagnostics.ts';
@@ -79,6 +79,11 @@ usage:
79
79
  where something covers it: listed apart, never drawn, never a part. An
80
80
  input with no depth (a PSD, or depth_median null on every layer, whose
81
81
  manifest order is then the draw order) prints one SKIP depth line.
82
+ Trailing boxes name both source-alpha cuts: drawn(a>8), and opaque
83
+ (a>=250, CORE_ALPHA). The legacy opaque_px still counts alpha above 8.
84
+ Right/bottom are exclusive, in INPUT-CANVAS px; absent boxes are named.
85
+ The body-group union height uses alpha >=250, with the >8 union shown
86
+ separately: no cleanup, ownership, anatomical height or painting map.
82
87
  Refuses, by name, a missing file, an unknown tag, a PNG whose
83
88
  size is not its box, and anything else outside the input contract —
84
89
  never a WARN.
@@ -268,9 +273,10 @@ usage:
268
273
 
269
274
  rig-parts propose --parts <dir> --source <painting.png> --out <dir> --from-config <config.json>
270
275
  Draw the config's CURRENT bones instead (<out>/render/landmarks_config.png
271
- and _head) and LINT them (by the joints too, given --keypoints), with the
272
- coverage lines after them; no ${BASIS_FILE}, as nothing is proposed.
273
- Exits 1 when any LINT line is printed.
276
+ and _head) and LINT them (by the joints too, given --keypoints [--person]), with the
277
+ coverage lines after them; no ${BASIS_FILE}, as nothing is proposed (takes
278
+ --parts, --source, --out, --from-config and optional --keypoints [--person]
279
+ only). Exits 1 when any LINT line is printed.
274
280
 
275
281
  rig-parts propose … --write-config <config.json>
276
282
  Without --cloth, update an existing config with the whole proposal's
@@ -416,7 +422,8 @@ usage:
416
422
  rig-parts mesh-stats --config <config.json> --parts <dir>
417
423
  Print the vertices and triangles of every mesh the config states, as the
418
424
  rig stage would make them from the parts in --parts (the directory
419
- holding parts.json and parts/<name>.png), and their total, in seconds:
425
+ holding parts.json and parts/ — work/rig from assemble --out work, or
426
+ out from build --out out), and their total, in seconds:
420
427
  the rig stage's own mesh construction, without rigc, without assemble
421
428
  and without check (issue #566). One line per mesh in parts.json's order,
422
429
  "mesh-stats: <part> — <mode> <V> vertices, <T> triangles" (mode lattice,
@@ -993,10 +1000,29 @@ function printLayerTable(set: LayerSet): void {
993
1000
  const head = ['order', 'name', 'group', 'left,top', 'right,bottom', 'size', 'opaque_px', 'depth', 'translucent', 'background', 'area'];
994
1001
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
995
1002
  const line = (cells: string[]): string => ` ${cells.map((c, i) => c.padEnd(widths[i])).join(' ')}`.trimEnd();
996
- console.log(line(head));
997
- for (const r of rows) console.log(line(r));
1003
+ // Keep every legacy line byte-for-byte as a prefix; size the trailing fields separately.
1004
+ const legacy = [line(head), ...rows.map(line)];
1005
+ const legacyWidth = Math.max(...legacy.map(l => l.length));
1006
+ const drawn = `drawn(a>${OPAQUE_ALPHA_ABOVE})`;
1007
+ const opaque = `opaque(a>=${CORE_ALPHA})`;
1008
+ const boxes = set.layers.map(l => [layerOpaqueBox(l), layerCoreBox(l)] as const);
1009
+ const opaqueHead = [drawn, opaque].flatMap(label => [`${label}_left,top`, `${label}_right,bottom`, `${label}_size`]);
1010
+ const opaqueRows = boxes.map(pair => pair.flatMap(b => b === null ? ['none', 'none', 'none'] : [`${b.left},${b.top}`, `${b.right},${b.bottom}`, `${b.right - b.left}x${b.bottom - b.top}`]));
1011
+ const opaqueWidths = opaqueHead.map((h, i) => Math.max(h.length, ...opaqueRows.map(r => r[i].length)));
1012
+ for (const [i, cells] of [opaqueHead, ...opaqueRows].entries()) {
1013
+ console.log(`${legacy[i].padEnd(legacyWidth)} ${cells.map((c, j) => c.padEnd(opaqueWidths[j])).join(' ')}`.trimEnd());
1014
+ }
998
1015
  const painted = set.layers.filter((l) => l.opaquePx > 0).length;
999
1016
  console.log(` ${set.layers.length} layer(s): ${painted} with opaque pixels, ${set.layers.length - painted} with none`);
1017
+ console.log(` source-alpha boxes: ${drawn}, ${opaque}; legacy opaque_px remains alpha > ${OPAQUE_ALPHA_ABOVE}; INPUT-CANVAS px; right/bottom exclusive`);
1018
+ for (const [i, [drawnBox, coreBox]] of boxes.entries()) {
1019
+ if (drawnBox === null) console.log(` layer "${set.layers[i].name}": no ${drawn} box (no pixels with alpha > ${OPAQUE_ALPHA_ABOVE})`);
1020
+ if (coreBox === null) console.log(` layer "${set.layers[i].name}": no ${opaque} box (no pixels with alpha >= ${CORE_ALPHA})${drawnBox === null ? '' : `; ${drawn} box is present`}`);
1021
+ }
1022
+ const bodyBounds = (body: OpaqueBox | null): string => body === null ? 'none; height unavailable INPUT-CANVAS px' : `${body.left},${body.top} to ${body.right},${body.bottom}; size ${body.right - body.left}x${body.bottom - body.top}; height ${body.bottom - body.top} INPUT-CANVAS px`;
1023
+ console.log(` body-group union [${opaque}]: ${bodyBounds(bodyCoreBox(set))}; source alpha >= ${CORE_ALPHA} only, not an owned/eroded core or anatomical height`);
1024
+ console.log(` body-group union [${drawn}]: ${bodyBounds(bodyOpaqueBox(set))}; source alpha > ${OPAQUE_ALPHA_ABOVE} only; not an anatomical height`);
1025
+ console.log(' body-group population: drawn group=body (includes hair/head/wings/objects; excludes face/headwear, author and hidden art); before cleanup, resampling or painting registration');
1000
1026
  // Issue #256: a layer the author made is said to be one, so it never reads as See-through's. None, no line.
1001
1027
  for (const l of set.layers) if (l.tag.group === 'author') console.log(` ${authorLayerLine(l.name)}`);
1002
1028
  // Issue #359: a hidden-art layer is listed apart, never in the drawn table. None, no line.
@@ -1351,11 +1377,12 @@ function cmdPropose(args: string[]): number {
1351
1377
  }
1352
1378
  // Issue #671: --only-missing adds entries to an existing config for the parts it attaches by neither section; it
1353
1379
  // belongs to the ordinary proposal's --write-config alone.
1380
+ const fromConfigTakes = '(takes --parts, --source, --out, --from-config and optional --keypoints [--person] only)';
1354
1381
  if (onlyMissing) {
1355
1382
  const mode = 'propose --parts … --write-config <config.json> --only-missing';
1356
1383
  if (headBox) return usage(`--only-missing belongs to ${mode}; --head-box writes seethrough.head_box only`);
1357
1384
  if (!flags.has('--parts')) return usage(`--only-missing belongs to ${mode}; give --parts <dir>, the parts the entries are drafted from`);
1358
- if (flags.has('--from-config')) return usage(`--only-missing belongs to ${mode}; --from-config proposes nothing`);
1385
+ if (flags.has('--from-config')) return usage(`--only-missing belongs to ${mode}; --from-config proposes nothing ${fromConfigTakes}`);
1359
1386
  if (flags.has('--cloth')) return usage(`--only-missing belongs to ${mode}; --cloth writes a new config with the recipe's entries spliced in`);
1360
1387
  if (!flags.has('--write-config')) return usage(`--only-missing belongs to ${mode}; give --write-config <config.json>, the existing config the entries are added to`);
1361
1388
  }
@@ -1400,17 +1427,17 @@ function cmdPropose(args: string[]): number {
1400
1427
  if (partsArg === undefined) return usage('propose needs --parts <dir>, the directory holding parts.json and parts/');
1401
1428
  if (source === undefined) return usage('propose needs --source <painting.png>, drawn under the overlay');
1402
1429
  if (out === undefined) return usage('propose needs --out <dir>');
1403
- if (flags.has('--from-config') && flags.has('--compare')) return usage('--from-config and --compare are two modes; give one');
1430
+ if (flags.has('--from-config') && flags.has('--compare')) return usage(`--from-config and --compare are two modes; give one (--from-config ${fromConfigTakes.slice(1, -1)})`);
1404
1431
  if (flags.has('--person') && !flags.has('--keypoints')) return usage('--person names a person in the --keypoints file; give --keypoints <file>');
1405
1432
  // Issue #251: the See-through runs the turn control's depths are read from; without them nothing is drafted.
1406
- if (flags.has('--from-config') && (flags.has('--full') || flags.has('--head'))) return usage('--full and --head draft the turn control while proposing; --from-config proposes nothing');
1433
+ if (flags.has('--from-config') && (flags.has('--full') || flags.has('--head'))) return usage(`--full and --head draft the turn control while proposing; --from-config proposes nothing ${fromConfigTakes}`);
1407
1434
  // Issue #199: a declared tolerance for a wrist on no handwear part. Whole px above the built-in one: the distance is
1408
1435
  // counted in whole px, so a fraction would bind what its floor binds, and a value at or under the built-in binds nothing.
1409
1436
  const snapArg = flags.get('--wrist-snap');
1410
1437
  let wristSnap: number | null = null;
1411
1438
  if (snapArg !== undefined) {
1412
1439
  if (!flags.has('--keypoints')) return usage('--wrist-snap binds a wrist joint to a handwear part; give --keypoints <file>');
1413
- if (flags.has('--from-config')) return usage('--wrist-snap binds a wrist while proposing; --from-config proposes nothing');
1440
+ if (flags.has('--from-config')) return usage(`--wrist-snap binds a wrist while proposing; --from-config proposes nothing ${fromConfigTakes}`);
1414
1441
  if (!/^[0-9]+$/.test(snapArg) || Number(snapArg) <= WRIST_ON_PART_PX) {
1415
1442
  return usage(`--wrist-snap ${snapArg} is not an integer above ${WRIST_ON_PART_PX}, the px within which a wrist already holds a part`);
1416
1443
  }
@@ -1418,15 +1445,15 @@ function cmdPropose(args: string[]): number {
1418
1445
  }
1419
1446
  // Issue #265: the drafted lattice's spacing; the figure's unless the reference's per-part rule is asked for.
1420
1447
  const ruleArg = flags.get('--grid-rule');
1421
- if (ruleArg !== undefined && flags.has('--from-config')) return usage('--grid-rule chooses the drafted spacing; --from-config proposes nothing');
1448
+ if (ruleArg !== undefined && flags.has('--from-config')) return usage(`--grid-rule chooses the drafted spacing; --from-config proposes nothing ${fromConfigTakes}`);
1422
1449
  if (ruleArg !== undefined && !(GRID_RULES as readonly string[]).includes(ruleArg)) return usage(`--grid-rule ${ruleArg}; one of ${GRID_RULES.join(', ')} is required`);
1423
1450
  const gridRule = (ruleArg ?? DEFAULT_GRID_RULE) as GridRule;
1424
1451
  // Issue #364: the cloth and hair recipe, read from a config's cloth block; without it nothing changes.
1425
1452
  const clothArg = flags.get('--cloth');
1426
- if (clothArg !== undefined && flags.has('--from-config')) return usage('--cloth drafts trees while proposing; --from-config proposes nothing');
1453
+ if (clothArg !== undefined && flags.has('--from-config')) return usage(`--cloth drafts trees while proposing; --from-config proposes nothing ${fromConfigTakes}`);
1427
1454
  // Issue #537: the --cloth config's own text with what its recipe drafts spliced in, beside proposal.json.
1428
1455
  const writeArg = flags.get('--write-config');
1429
- if (writeArg !== undefined && flags.has('--from-config')) return usage('--write-config writes what the recipe drafts into its config; --from-config proposes nothing');
1456
+ if (writeArg !== undefined && flags.has('--from-config')) return usage(`--write-config writes what the recipe drafts into its config; --from-config proposes nothing ${fromConfigTakes}`);
1430
1457
  // Issue #571: the config the recipe is changed from; an entry equal to its recipe's draft is the recipe's, not a hand edit.
1431
1458
  const fromArg = flags.get('--from');
1432
1459
  if (fromArg !== undefined && writeArg === undefined) return usage('--from names the config the recipe is changed from, for --write-config; give --write-config <out.json>');
@@ -1434,7 +1461,7 @@ function cmdPropose(args: string[]): number {
1434
1461
  if (writeArg !== undefined && clothArg !== undefined) {
1435
1462
  const ours = ['proposal.json', BASIS_FILE, 'render/landmarks.png', 'render/landmarks_head.png'].map((f) => resolve(out, f));
1436
1463
  if (existsSync(writeArg) || ours.includes(resolve(writeArg))) {
1437
- throw new PartsError([{ code: 'PROPOSE_WRITE_CONFIG_FRESH', object: writeArg, detail: existsSync(writeArg) ? 'exists; the config is written to a new file, never over the --cloth config or an earlier one' : `is a file this run writes under ${out}; name another file` }]);
1464
+ throw new PartsError([{ code: 'PROPOSE_WRITE_CONFIG_FRESH', object: writeArg, detail: existsSync(writeArg) ? 'exists; the config is written to a new file, never over the --cloth config or an earlier one (unlike assemble --propose-plan and propose without --cloth, which update an existing --config in place)' : `is a file this run writes under ${out}; name another file` }]);
1438
1465
  }
1439
1466
  separateConfigWriteTarget({ path: resolve(writeArg) }, ours, 'PROPOSE_WRITE_CONFIG_FRESH');
1440
1467
  }
@@ -1642,7 +1669,7 @@ function cmdAssemble(args: string[]): number {
1642
1669
  const [source, full, config] = ['--source', '--full', '--config'].map((f) => flags.get(f) as string);
1643
1670
  const head = flags.get('--head') ?? null;
1644
1671
  const writePath = flags.get('--write-config');
1645
- if (writePath !== undefined && resolve(writePath) !== resolve(config)) return usage('--write-config must name the same file as --config for assemble --propose-plan');
1672
+ if (writePath !== undefined && resolve(writePath) !== resolve(config)) return usage('--write-config must name the same file as --config for assemble --propose-plan (assemble --propose-plan updates --config in place; only propose --cloth --write-config writes to a new file)');
1646
1673
  let staged: StagedConfigWrite | null = null;
1647
1674
  try {
1648
1675
  if (propose) {
package/docs/AUTHORING.md CHANGED
@@ -282,8 +282,14 @@ costs nothing a bar reads (`demo`, tips 371 px apart: 1.050 for the pair, as for
282
282
  alone), and on one mass with the two chains close it squeezes the lattice between them
283
283
  (issue #314: tips 39 px apart, an edge at 0.065 of its rest length). Whether the hair is one
284
284
  mass is not something the proposer can read off the opaque shape — both chains always hang
285
- on the one `back hair` part — so the draft is unchanged and the squeeze is measured instead,
286
- by the lint above.
285
+ on the one `back hair` part — but their lattice and motion are readable. A fresh draft now
286
+ uses the squeeze reading above (issue #751): when an opposed pair fails and reversing the
287
+ second chain's signs passes the unchanged bar, it drafts that passing sign and adds a
288
+ `shared-mesh chain signs` note with both figures and the reason. Magnitudes and timing
289
+ stay unchanged. Every shared pair is re-read; an alternative introducing a new or worse
290
+ finding is not adopted. When no measured alternative passes, the draft and LINT remain.
291
+ Already-clean and unread pairs are unchanged. `propose --from-config` only observes the
292
+ supplied config: it still prints the original LINT and never rewrites its amplitudes.
287
293
 
288
294
  **A posed figure: `propose --keypoints`.** Without joints the proposer reads the
289
295
  torso by screen y: the hip 0.14 face heights below `bottomwear`'s top edge (or below
@@ -1834,8 +1840,8 @@ that crosses the region with no point of it inside to split at — is still stop
1834
1840
 
1835
1841
  The package proposes no expression and keys nothing but the idle; what it gives is a rig whose
1836
1842
  face is bones (`eye_r`, `eye_l`, `brow_r`, `brow_l`, `mouth` on `reference-sample`) and one door for
1837
- animations you write over them, `motion.animations_from` (the row above, issues #183 and #569).
1838
- Name a file in rig-c's motion-spec shape, its path relative to the directory `config.json` sits
1843
+ animations you write over them, `motion.animations_from` (the `motion.animations_from` row in the §3 field table above, issues #183 and #569).
1844
+ Name a file in rig-c's motion-spec shape whose top-level keys are `"spec"` (`"rigc-motion/1"`) and `"animations"` (plus optional `"note"` / `"<name>_note"` strings and `"x-<name>"` records; any other top-level key — rig-c's `easings`, `groups`, `setup`, `physics`, `mix`, `archetype` and `cut` included — is refused by `CONFIG_KEY_KNOWN`), its path relative to the directory `config.json` sits
1839
1845
  in — not the working directory:
1840
1846
 
1841
1847
  ```jsonc
@@ -1867,8 +1873,8 @@ same shape over the face bones, and `examples/demo/animations.json` holds seven
1867
1873
  defines `curve` as four numbers per value channel: absolute control-point
1868
1874
  `[time, value, time, value]`, not normalized graph handles. The first chest key above
1869
1875
  shapes its span to the next key. A key carries `ease` or `curve`, never both, and
1870
- the final key carries neither. Top-level motion fields are a separate contract:
1871
- `animations_from` supplies `spec` and `animations`, not a second setup or easing table.
1876
+ the final key carries neither. Top-level motion fields are a separate contract (the `motion.animations_from` row in the §3 field table):
1877
+ `animations_from` accepts only `spec` and `animations` at the top level (plus `note` / `<name>_note` and `x-<name>`), refusing rig-c's `easings`, `groups`, `setup`, `physics`, `mix`, `archetype` and `cut` with `CONFIG_KEY_KNOWN` (`known: spec, animations`); tracks inside `animations` may use the easings `shut` and `open` and the groups `eyes` and `brows` that `motion.json` already holds.
1872
1878
  - **Key times strictly increase within a track, and the last is at `duration`** — rig-c's rule
1873
1879
  (its AUTHORING §4.5, "`t` is in seconds and must strictly increase"; R7: no key past the
1874
1880
  declared duration, and a duration more than a frame longer than the last key is refused).
@@ -2091,7 +2097,7 @@ fragment below, cut to the bust:
2091
2097
 
2092
2098
  A region adds vertices: its `spacing` is finer than the mesh's, and every one of them is a
2093
2099
  triangle more for the runtime to skin. Before the first build, count them:
2094
- `rig-parts mesh-stats --config config.json --parts work/rig` (issue #566; `work` is step 7's `assemble --out`, §4) runs the rig stage's own
2100
+ `rig-parts mesh-stats --config config.json --parts work/rig` (issue #566; `--parts` is the directory holding `parts.json` and `parts/`: `work/rig` from step 7's `assemble --out work`, or `out` from `build --out out`, not `work` itself — §4) runs the rig stage's own
2095
2101
  mesh construction over the assembled parts, without rigc, without `check`, and prints one line
2096
2102
  per mesh — `mesh-stats: topwear — contour 320 vertices, 568 triangles` on `reference-sample` —
2097
2103
  and the total, `mesh-stats: total 1908 triangles over 6 mesh(es)`, the counts the build's
@@ -2311,8 +2317,8 @@ chain, a constraint or a track added by hand survives a `--write-config`. An ent
2311
2317
  recipe re-drafts or removes that the author changed by hand (it equals neither draft) is
2312
2318
  not dropped: the run is refused, `PROPOSE_WRITE_CONFIG_CONFLICT`, naming the entry
2313
2319
  (`config.constraints[40] "skirt0a_0_physics"` in CL207), the first field the hand edit holds and
2314
- what the draft writes there, every conflict at once and nothing written; take the draft
2315
- by removing the hand edit, or leave the recipe out for that part. **A changed recipe
2320
+ what the draft writes there, every conflict at once and nothing written; to take the recipe's draft,
2321
+ remove the hand-edited track, bone or constraint from `config.<section>` (or revert the hand edit) and run `propose --cloth` again, or leave the recipe out for that part. **A changed recipe
2316
2322
  (issue #571).** The draft of the recipe as it stood before a change is neither of the
2317
2323
  two drafts, so a config holding that draft untouched would read as hand-edited at every
2318
2324
  entry the change moves (29 refusals on the public `wind` example with
@@ -2780,6 +2786,66 @@ full loader before its assemble stage writes anything.
2780
2786
  files, not the art (§6, *Reading the inputs*). A `WARN` line is about the art: a
2781
2787
  layer See-through made that is not plausibly part of the figure, which step 6
2782
2788
  leaves out (§6, *Plausibility*). It refuses nothing.
2789
+ The trailing `drawn(a>8)_left,top`, `drawn(a>8)_right,bottom`, `drawn(a>8)_size`
2790
+ and `opaque(a>=250)_left,top`, `opaque(a>=250)_right,bottom`,
2791
+ `opaque(a>=250)_size` columns give two boxes of each drawn layer's original
2792
+ source alpha, in **input-canvas px**, beside the unchanged manifest/layer box.
2793
+ All original columns stay as read: legacy `opaque_px` still counts **alpha >8**.
2794
+ The second box uses the existing **alpha >=CORE_ALPHA (250)** cut, shared with
2795
+ assemble's opaque ownership, projection and seam logic. Only that alpha cut is
2796
+ applied here: no owner clipping, projection-core erosion, ghost cleanup or
2797
+ rig resampling. Alpha 249 is drawn but not CORE; 250 and 251 meet both cuts.
2798
+ Add the reader's `left,top` to a PNG-local pixel; right and bottom are exclusive,
2799
+ so one painted pixel has a 1x1 box. `none` and a line naming the layer say that
2800
+ it has no box at that threshold. A fully transparent layer has neither box;
2801
+ a layer with drawn support but no CORE support is explicitly named as such.
2802
+ Hidden art remains listed apart and is never drawn.
2803
+
2804
+ **Body-group union (issue #757).** The summary unions exactly the drawn layers
2805
+ whose reader tag has `group === 'body'`: the existing body tags include front
2806
+ and back hair, head, neck/neckwear, topwear, handwear (including its sides),
2807
+ bottomwear, legwear, footwear, tail, wings and objects, plus the derived
2808
+ `hand-r/l` and `drape-r/l`. Face, eyes, headwear and the other head-group tags,
2809
+ author layers and hidden art do not contribute. A tag classifies a layer; it
2810
+ does not prove anatomical identity. The main height line uses **alpha >=250**;
2811
+ the raw **alpha >8** union is printed separately and can span the whole input
2812
+ canvas because of boundary support. Each union's height is `bottom - top`,
2813
+ never a sum of layer heights, and an empty union has height unavailable.
2814
+ This is a per-layer thresholded-mask union (OR), not RGBA-over compositing:
2815
+ overlapping source alpha below the applicable cut cannot accumulate into it:
2816
+ two alpha-8 layers do not become drawn support, and two alpha-249 layers do not
2817
+ become CORE support. Neither union is an anatomical height or the height of
2818
+ the final assembled parts. A head-run
2819
+ reading is still only that input's body-group union.
2820
+
2821
+ **Units before choosing `assemble.rig_scale`.** `layers` reads one input and
2822
+ knows no painting size or full/head role or registration. For scale guidance,
2823
+ run `layers` on the **full run** and use its **alpha >=250** body-group extent
2824
+ as the source reference, with the mapping below when it is known. At the
2825
+ [public task-01 input pin](https://github.com/rig-c/exam/tree/131e80f794830238520aae8709db7155719813db/tasks/01-build/inputs),
2826
+ the full-run source CORE union is 911 input-canvas px tall; the head-run 964
2827
+ is a crop extent and is not the full scale reference. These input roles are
2828
+ supplied by that fixture's contract, not inferred by this CLI from a file name.
2829
+ The CLI prints generic body-group unions, without a figure-height or rig-scale
2830
+ advice line. Only with a known full-run
2831
+ mapping from `inputs` can its coordinates be converted: for painting `w x h`,
2832
+ square side `L = max(w,h)`, run resolution `R`, and padding
2833
+ `padX = floor((L-w)/2)`, `padY = floor((L-h)/2)`, a run point maps to painting
2834
+ `(x*L/R-padX, y*L/R-padY)`. This is `checkGeometry`'s `fullK = L/R` and the
2835
+ full `runMap` before rig scale; the mapped vertical extent is `height*L/R`.
2836
+ It is an affine extent, not a newly resampled or clipped painting-pixel mask.
2837
+ For an artist's single run at one file pixel per painting pixel, the raw
2838
+ file's height needs no scale conversion; assemble's internal square padding
2839
+ only translates it. A separately generated head crop has its own crop map
2840
+ and cannot supply a full-body height. Contact-sheet tiles resize independently
2841
+ and preserve no common placement, so their pixels are not measurement inputs.
2842
+ The 1,100–1,200 rig-px advice in §3 uses the union of assembled parts' boxes,
2843
+ the `H` of `propose`'s `mesh spacing:` note. Ghost cleanup, the run-to-rig
2844
+ resampling and the chosen parts can change the bounds and threshold coverage:
2845
+ the analytic conversion above does not measure that final `H`. Instead,
2846
+ use a known painting-space extent for scale guidance and inspect the parts
2847
+ after assembly. Do not substitute this tag-group union for anatomy or assume
2848
+ its input pixels are painting pixels. `layers` performs no registration.
2783
2849
  4. `rig-parts propose --head-box --full inputs/layers/full --canvas <W>x<H> --write-config config.json`
2784
2850
  saves `seethrough.head_box` in the existing config. `<W>x<H>` is the original
2785
2851
  painting's size, not the padded square's. The existing optional `--no-back-hair`
@@ -2864,11 +2930,14 @@ full loader before its assemble stage writes anything.
2864
2930
  For a posed figure (seated, reclining), add `--keypoints keypoints.json` (and
2865
2931
  `--person <id>` when the file holds more than one person): the joints it gives
2866
2932
  place the neck, hip, chest and sleeves (§3, *A posed figure*).
2867
- 8. Correct, then `rig-parts propose … --from-config config.json` to redraw the
2933
+ 8. Correct, then `rig-parts propose --parts work/rig --source inputs/painting.png --out work --from-config config.json` to redraw the
2868
2934
  config's own bones and LINT them — every chain link against its mesh's art, and
2869
2935
  the single bones `hip` and `chest` against each other and the figure (a config
2870
2936
  without them prints a `note:` that those lines did not run); repeat until it
2871
- prints no LINT line (exit 0). Add the same `--keypoints` to lint by the joints.
2937
+ prints no LINT line (exit 0). Add the same `--keypoints` (and `--person`) to lint by the joints;
2938
+ `--from-config` proposes nothing and takes `--parts`, `--source`, `--out`, `--from-config` and
2939
+ optional `--keypoints` (`--person`) only (not `--full`, `--head`, `--cloth`, `--write-config`,
2940
+ `--wrist-snap`, `--grid-rule`, `--only-missing` or `--compare`).
2872
2941
  Config from here on: the `full` row.
2873
2942
  `rig-parts compare --left work/proposal.json --right config.json` says what the
2874
2943
  correction changed, bone by bone: each origin, parent, tip, length and direction
@@ -2986,9 +3055,11 @@ a saved draft still needs validation.
2986
3055
 
2987
3056
  ### Counting the triangles before a build (`mesh-stats`, issue #566)
2988
3057
 
2989
- `rig-parts mesh-stats --config config.json --parts work/rig` prints the vertices and triangles of
2990
- every mesh the config states and their total, against the parts `assemble` wrote (step 7's
2991
- `work/rig`, or a `build --out` directory, which holds `parts.json` and `parts/` itself), in seconds.
3058
+ `rig-parts mesh-stats --config config.json --parts work/rig` (after `assemble --out work`) or
3059
+ `rig-parts mesh-stats --config config.json --parts out` (after `build --out out`) prints the
3060
+ vertices and triangles of every mesh the config states and their total, against the parts
3061
+ `assemble` wrote (`--parts` is the directory holding `parts.json` and `parts/`: step 7's
3062
+ `work/rig` from `assemble --out work`, or `out` from `build --out out` — not `work` itself), in seconds.
2992
3063
  It is the rig stage's mesh construction itself — the same function the rig stage writes
2993
3064
  `mesh_report.json` from — run without rigc, without `check` and without writing a file, so a
2994
3065
  config can be tuned to a triangle budget before the first build and between builds:
@@ -3140,7 +3211,7 @@ one of them occluded, so nothing shows it at rest; paint it there if the face wi
3140
3211
  move off it (`AS245`).
3141
3212
 
3142
3213
  **Saving proposed config fields (`--write-config`, issue #356).** The flag is a
3143
- valued destination path. Its modes have different ownership:
3214
+ valued destination path with two destination rules — **in place on an existing regular config** without `--cloth` (and for `assemble --propose-plan`, `--write-config` must name `--config` itself; §4, step 6), versus **a new file** for `propose --cloth --write-config` (`PROPOSE_WRITE_CONFIG_FRESH`, never over the `--cloth` config or an earlier one; §3, *The config written*). Its modes have different ownership:
3144
3215
 
3145
3216
  | proposal mode | destination | fields written |
3146
3217
  | --- | --- | --- |
@@ -3170,14 +3241,12 @@ loader validates the candidate after unresolved entries have been skipped with a
3170
3241
  naming the part, reference and reason; a skipped entry gets no plan row, and a part of any
3171
3242
  other origin gets no plan row; nothing missing keeps the bytes and mtime; stderr names each added row and entry. It is a usage error with `--head-box`, `--cloth`
3172
3243
  or `--from-config`, or without `--parts` or `--write-config`.
3173
- All four modes use one file-writing adapter. Fresh cloth output keeps its
3174
- already-checked splice text and intentional partial-config contract; the full
3175
- loader is not imposed on it. Publication links the complete same-directory
3244
+ All four modes use one file-writing adapter, split between the two destination
3245
+ rules above: without `--cloth`, `--write-config` updates an existing regular config in place (`CONFIG_FILE_PRESENT` when absent) so the stage's loader (§4, *What each step requires of the config*) validates a same-directory candidate in place before replacement, and for `assemble --propose-plan` `--write-config` must name `--config` itself (a different `--write-config` destination for `assemble --propose-plan` is a usage error, §4 step 6); with `--cloth`, `propose --cloth <input.json> --write-config <out.json>` writes to a new file (`PROPOSE_WRITE_CONFIG_FRESH` when the path exists) because it splices the recipe's drafted entries into `--cloth`'s own text the way `mesh-draft` writes its config, keeping its already-checked splice text and intentional partial-config contract without imposing the full loader on it (§3, *The config written*). Publication links the complete same-directory
3176
3246
  staged file into a destination that must still be absent, so an intervening
3177
3247
  creator is refused without overwrite. Removing the staged temporary happens
3178
3248
  after publication: if that cleanup fails, `CONFIG_WRITE_FAILED` may accompany
3179
- an already-published complete config. Inspect both named paths before retrying. A different
3180
- `--write-config` destination for `assemble --propose-plan` is a usage error.
3249
+ an already-published complete config. Inspect both named paths before retrying.
3181
3250
  For ordinary and cloth proposals, the destination must be separate from all four
3182
3251
  artifacts under `--out`: `proposal.json`, `basis.json`, `render/landmarks.png` and
3183
3252
  `render/landmarks_head.png`. Matching paths, directory-symlink aliases and
@@ -3190,7 +3259,7 @@ existing hardlinks are refused before artifact output; the ordinary mode names
3190
3259
  | --- | --- | --- | --- |
3191
3260
  | `layers` | the table, then one `author-made: layer "author:<label>" — …` line per layer the author made (group `author` in the table; §5, *A layer the author made*), 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*) |
3192
3261
  | `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 |
3193
- | `assemble` | one line per part — its counts (`op=` opaque, `vis=` visible, `src=` taken / candidates, `unproj=` visible but not projected, `drift=<n> (<p>% of core)`, `merged=`, `seam=`; *parts.json*, below). `drift=` is `refused_drift_px` in rig px and its share of the part's candidates, `projected_core_px`, which it is a subset of (issue #314); no bar reads either. On the four public examples the share runs to 94.5 % (`scarf` `hair_back`, 479 of 507) and the count to 26,472 (`scarf` `topwear`, 22.3 %), and the seam override repaints refused top-most pixels afterwards, so a large count says where See-through's colour was not taken, not that the part is unlike the painting: the `drift:` line below is the one that names a body of them another part holds. Then 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, …)`, then one `uncovered: <n> of the <N> uncovered error px lie on the full run's "<tag>", in a body of <px> px at x,y wxh; <the rule that left it out>` line per full-run layer the plan does not take from the full run whose uncovered error holds a body (5x5-opened, 8-connected, at least 150 full-run px in rig px), then one `drift: "<part>" refuses <n> px of <run>:<tag> (its colour more than 90 off the painting, max channel), in a body of <px> px at x,y wxh (…); by the plan's draw order "<holder>" holds <px> px of it (…), "<holder>" drawn in front of "<part>"` line per part whose drift refusals hold a body (the same opening and bar) that another part holds in the draw order — that layer is not what the painting shows there (none on the five public paintings), then, per subset alternate set, one `LINT set "<set>" <part>: <layer part> holds <n> islands (8-connected, alpha above 8)[, and <s> speck(s) under 1 % of the largest (<f> px), each with the island nearest it], kept the one under <joints> (<k> px), dropped <m> (<d> px)` line per limb part cut from a layer of more than one island when the set names one limb side of it (issue #588; issue #608: a joint taken to the nearest island reads `<bone> [x, y] (<e> px off it, within its cap's radius <r>)`, and a part that lost no pixel `dropped none`), then one `LINT set "<set>" <part>: <layer part> holds <limb> and <other limb> in one island (8-connected, alpha above 8), split by the nearest segment line at the set's bones: kept <k> px (<share>), gave <m> px (<share>) to <other limb>; <b> px in the overlap band (nearer one line than the other by 0.5 px or less) went to the limb drawn in front, <bk> kept and <bg> given` line per limb part cut from a layer of one island that a limb the set does not carry also takes (issue #609; k, m and b count the part's pixels above alpha 8, each share of the part's pixels before the split, 3 places) and one `LINT set "<set>" <part>: <n> px under <base part> excluded` line per subset alternate set's limb part and base part drawn over it that the set does not replace which took a pixel (issue #587, `assemble.alternates` above; none on the public examples, which declare no alternate set); and look at `render/recomposite_error_rig.png`. Between the part lines and the totals, one `author-made: part "<part>" (<run>:author:<label>) — …` line per part from a layer the author made (below, *A layer the author made*), then 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 — with `config.limbs` (issue #349) — one `limb cut: "<part>" opaque <n> = "<segment part>" <px> + …` line per part a limb takes and one `limb cap:` line per joint, its cap's pixels from the cap image, from the layer and missing, and its box (under `cap_shape: "ball"`, issue #579, then the lower half's pixels written, left as painted and missing), followed on a short cap by one `LINT "<part>" cap at [x, y] … short_px left <n>, right <m>: no layer gives them …` line (issue #557) — from the upper segment where it gave a pixel (under `cap_shape: "disc"` always), under `edge: "feather"` the pixels faded, under `cut_band` the line the cut was placed on and the stroke pixels it and the joint line cross (issue #360) (§3, *Limbs: cut at their joints*), 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: `reference-sample` 0.83 / 98.0 % / 4,359 / 1,185, 259 holes, the largest 94 px; `reference-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 (reference-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) |
3262
+ | `assemble` | one line per part — its counts (`op=` opaque, `vis=` visible, `src=` taken / candidates, `unproj=` visible but not projected, `drift=<n> (<p>% of core)`, `merged=`, `seam=`; *parts.json*, below). `drift=` is `refused_drift_px` in rig px and its share of the part's candidates, `projected_core_px`, which it is a subset of (issue #314); no bar reads either. On the four public examples the share runs to 94.5 % (`scarf` `hair_back`, 479 of 507) and the count to 26,472 (`scarf` `topwear`, 22.3 %), and the seam override repaints refused top-most pixels afterwards, so a large count says where See-through's colour was not taken, not that the part is unlike the painting: the `drift:` line below is the one that names a body of them another part holds. Then 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, …)`, then one `uncovered: <n> of the <N> uncovered error px lie on the full run's "<tag>", in a body of <px> px at x,y wxh; <the rule that left it out>` line per full-run layer the plan does not take from the full run whose uncovered error holds a body (5x5-opened, 8-connected, at least 150 full-run px in rig px), then one `drift: "<part>" refuses <n> px of <run>:<tag> (its colour more than 90 off the painting, max channel), in a body of <px> px at x,y wxh (…); by the plan's draw order "<holder>" holds <px> px of it (…)[, "<holder>" drawn in front of "<part>"][; the painting is within 90 of "<behind>" (<px> px at alpha >= 250, drawn behind "<part>")]; informational: no gate reads this. Swapping the plan order of the parts named here can clear it (when only the front one differs from the painting) or move it to the other part (when both do): swap, rebuild and read this line again; if it moves, removing it needs different input layers. At rest, the seam pass repaints the holder's pixels that pass its rule from the painting` line per part whose drift refusals hold a body (the same opening and bar) that another part holds in the draw order or that a part drawn behind it matches within 90 of the painting (issues #255, #351, #758) — that layer is not what the painting shows there; no gate reads this line, swapping the plan order of the parts named here can clear it (when only the front one differs from the painting) or move it to the other part (when both do) — swap, rebuild and read this line again, and if it moves, removing it needs different input layers — and at rest the seam pass repaints the holder's pixels that pass its rule from the painting (none on the five public paintings), then, per subset alternate set, one `LINT set "<set>" <part>: <layer part> holds <n> islands (8-connected, alpha above 8)[, and <s> speck(s) under 1 % of the largest (<f> px), each with the island nearest it], kept the one under <joints> (<k> px), dropped <m> (<d> px)` line per limb part cut from a layer of more than one island when the set names one limb side of it (issue #588; issue #608: a joint taken to the nearest island reads `<bone> [x, y] (<e> px off it, within its cap's radius <r>)`, and a part that lost no pixel `dropped none`), then one `LINT set "<set>" <part>: <layer part> holds <limb> and <other limb> in one island (8-connected, alpha above 8), split by the nearest segment line at the set's bones: kept <k> px (<share>), gave <m> px (<share>) to <other limb>; <b> px in the overlap band (nearer one line than the other by 0.5 px or less) went to the limb drawn in front, <bk> kept and <bg> given` line per limb part cut from a layer of one island that a limb the set does not carry also takes (issue #609; k, m and b count the part's pixels above alpha 8, each share of the part's pixels before the split, 3 places) and one `LINT set "<set>" <part>: <n> px under <base part> excluded` line per subset alternate set's limb part and base part drawn over it that the set does not replace which took a pixel (issue #587, `assemble.alternates` above; none on the public examples, which declare no alternate set); and look at `render/recomposite_error_rig.png`. Between the part lines and the totals, one `author-made: part "<part>" (<run>:author:<label>) — …` line per part from a layer the author made (below, *A layer the author made*), then 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 — with `config.limbs` (issue #349) — one `limb cut: "<part>" opaque <n> = "<segment part>" <px> + …` line per part a limb takes and one `limb cap:` line per joint, its cap's pixels from the cap image, from the layer and missing, and its box (under `cap_shape: "ball"`, issue #579, then the lower half's pixels written, left as painted and missing), followed on a short cap by one `LINT "<part>" cap at [x, y] … short_px left <n>, right <m>: no layer gives them …` line (issue #557) — from the upper segment where it gave a pixel (under `cap_shape: "disc"` always), under `edge: "feather"` the pixels faded, under `cut_band` the line the cut was placed on and the stroke pixels it and the joint line cross (issue #360) (§3, *Limbs: cut at their joints*), 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: `reference-sample` 0.83 / 98.0 % / 4,359 / 1,185, 259 holes, the largest 94 px; `reference-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 (reference-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) |
3194
3263
  | `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 <link> at … runs to … N degrees off the head's down axis …` — a fringe link that does not lean with the head (§3, the fringe in the head's frame): move it along the lock it drives; `LINT <bone> under <parent>: amplitude <a> below the link above's <b> in the keyed chain …` — under `--cloth`, a keyed sibling the recipe left the only keyed child of its parent, which `CHAIN_LAG` would refuse (issue #572, §3 *A keyed sibling left behind*): raise the field it names to `<b>` or more in size; `LINT hairback_… on mesh '<part>' (side <r\|l>): <N> tree(s) for <M> visible hair mass(es) (mass 0 columns x=<minX>..<maxX>, y=<minY>..<maxY>; …)` — back-hair cloth trees whose count does not match the side's visible hair masses (`max(1, floor(w / (4 * grid)) + 1)` per mass) or whose link or tip leaves its mass's column range (issues #445, #461): omit `chains` on `cloth."back hair"` or draft trees per visible hair mass inside its columns; `LINT <a_limb> and <b_limb> on <part> (<from>) cross at [x, y] with no per-limb split: …` — two limbs sharing one layer cross in an X without a per-limb split (issue #446): declare `config.limbs.<covering>.split` (`{ "line": [[x, y], …] }` or `{ "edge": "painting" }`, §3 *Which limb*); `LINT foot_<s> at [x, y], the <s>_ankle joint and leg_<s>1's tip, lies on none of leg_<s>'s art within 1 px along its joint line (the nearest of it on that line is the pixel [x, y], D px from the joint): …` — an ankle keypoint off its leg's art on the line `assemble` sizes the ankle's cap by (issue #517): `propose` drafts every leg without its ankle cut, the shoe layer stays one part, and nothing is moved onto the art; move that ankle keypoint onto the leg's or the shoe's art and re-run `propose` (`(the line crosses none of it)` when no art lies on that line at all); `LINT stop on <chain> (<firstLink> at [x0, y0]) against <against> (at [ax, ay]) places <chain>_touch at [tx, ty], above <firstLink>'s root (rest R deg, \|rest\| + outward B deg): …` — a stop's contact point `<chain>_touch` lies above `links[0]`'s root in rig px or pushes `\|rest\| + outward` past `180°` (issue #447, §2 `stops`): stop against the descending limb bone below `firstLink`, set `links` to start at a link below `<chain>_touch`, or omit the stop on a rising limb; `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 `reference-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 (for a wrist on no part, the nearest part and the `--wrist-snap` that would bind it); a `wrist snap: …` note is a wrist bound past 15 px by the flag — check that chain's tip on `landmarks.png`; a `one handwear part holds both wrists` note is two chains on one mesh — cut the part with `assemble.cuts` to separate them; `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 |
3195
3264
  | `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 |
3196
3265
  | `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` |
@@ -3843,8 +3912,8 @@ says why each requirement exists and what breaks without it.
3843
3912
  | <a id="PROPOSE_FIGURE_LAYER"></a>`PROPOSE_FIGURE_LAYER` | with `--keypoints` (issue #577): a limb is drafted from the figure layer (no handwear or legwear part over 500 px for it), and two or more `topwear` parts each hold every joint and the trunk bones' origins within 15 px with the same opaque px in the 31x31 squares about them, so nothing says which is the figure; or the only `topwear` parts that hold them are named by a cloth recipe (`config.cloth.<tag>.part` or `.parts`, issue #631), whose mesh a limb would take (§3, *A figure of one material*) | leave the others out of `assemble.plan`, or give the arms and legs their own handwear and legwear layers; for a recipe's part, take it out of the recipe |
3844
3913
  | <a id="PROPOSE_CLOTH_DRAFTED"></a>`PROPOSE_CLOTH_DRAFTED` | with `--cloth` (issue #364): the config's `cloth` block holds a recipe for a tag the proposer drafts no chain on — no part has that tag, or every `back hair` part ends above the shoulders (a bun bone, no chain). A `bottomwear` recipe with no `bottomwear` part names every `topwear` part shaped like a skirt (issue #402) | leave that recipe out, or assemble the layer it is for, or declare the named part in `cloth.bottomwear.part` |
3845
3914
  | <a id="PROPOSE_CLOTH_PART"></a>`PROPOSE_CLOTH_PART` | with `--cloth` (issues #402, #437): `cloth.bottomwear.part` (a string) names no part, a part or cut piece whose source is not `topwear` (a `bottomwear` part takes the recipe by its tag already), or a target whose box ends above the hip + 0.25 face heights, the topwear rule's waist line; or `cloth.<tag>.parts` (or `part` as a list, issue #437) names a part not in `parts.json`, a part no link of the drafted tree crosses (`spanCrosses == 0`), or parts out of root-to-tip depth order along the chain | name the `topwear` part or its cut skirt piece that holds the skirt (or leave `part` out), or list only parts in `parts.json` that the drafted tree links cross in root-to-tip order |
3846
- | <a id="PROPOSE_WRITE_CONFIG_FRESH"></a>`PROPOSE_WRITE_CONFIG_FRESH` | with `--cloth … --write-config` (issues #537, #356): the out path exists or collides with one of the four artifacts under `--out` — `proposal.json`, `basis.json`, `render/landmarks.png`, `render/landmarks_head.png` — including directory-symlink and hardlink aliases; the config is written to a new file, never over the `--cloth` config or an earlier one | name a file that does not exist yet, beside the `--cloth` config and separate from all four artifact paths and their aliases |
3847
- | <a id="PROPOSE_WRITE_CONFIG_CONFLICT"></a>`PROPOSE_WRITE_CONFIG_CONFLICT` | with `--write-config` (issue #552): an entry of `bones`, `constraints` or `motion.tracks` the recipe re-drafts or removes was changed by hand in the `--cloth` config — it equals neither the draft with the recipe nor the draft without it — so the write would drop the hand edit; the object is the entry (`config.constraints[40] "skirt0a_0_physics"` in CL207), the detail names the first field the hand edit holds and what the draft writes there. With `--from <previous config>` (issue #571) an entry equal to the previous recipe's draft is the recipe's too, and the conflict is an entry that equals neither the draft, the draft without the recipe, nor the previous recipe's draft: the detail says so and reads the first differing field against the previous recipe's draft. Also a name the config states twice where the recipe drafts it. Every conflict is named at once and nothing is written | take the draft by removing the hand edit (or the entry), or leave the recipe out for that part (its `cloth` block); state a name once |
3915
+ | <a id="PROPOSE_WRITE_CONFIG_FRESH"></a>`PROPOSE_WRITE_CONFIG_FRESH` | with `--cloth … --write-config` (issues #537, #356): the out path exists or collides with one of the four artifacts under `--out` — `proposal.json`, `basis.json`, `render/landmarks.png`, `render/landmarks_head.png` — including directory-symlink and hardlink aliases; the config is written to a new file, never over the `--cloth` config or an earlier one (unlike `assemble --propose-plan` and `propose` without `--cloth`, which update an existing `--config` in place; §4, *Saving proposed config fields*) | name a file that does not exist yet, beside the `--cloth` config and separate from all four artifact paths and their aliases |
3916
+ | <a id="PROPOSE_WRITE_CONFIG_CONFLICT"></a>`PROPOSE_WRITE_CONFIG_CONFLICT` | with `--write-config` (issue #552): an entry of `bones`, `constraints` or `motion.tracks` the recipe re-drafts or removes was changed by hand in the `--cloth` config — it equals neither the draft with the recipe nor the draft without it — so the write would drop the hand edit; the object is the entry (`config.constraints[40] "skirt0a_0_physics"` in CL207), the detail names the first field the hand edit holds and what the draft writes there. With `--from <previous config>` (issue #571) an entry equal to the previous recipe's draft is the recipe's too, and the conflict is an entry that equals neither the draft, the draft without the recipe, nor the previous recipe's draft: the detail says so and reads the first differing field against the previous recipe's draft. Also a name the config states twice where the recipe drafts it. Every conflict is named at once and nothing is written | to take the recipe's draft, remove the hand-edited track, bone or constraint from `config.<section>` (or revert the hand edit) and run `propose --cloth` again, or leave the recipe out for that part (its `cloth` block); state a name once |
3848
3917
  | <a id="PROPOSE_WRITE_CONFIG_SPLICE"></a>`PROPOSE_WRITE_CONFIG_SPLICE` | with `--write-config` (issue #537): the `--cloth` config's text with the drafted sections spliced in does not parse to that config with them set, and the message names the first key that moved — the text states a key twice, so the splice lands on the first and the parse keeps the last | state each key once in the `--cloth` config |
3849
3918
  | <a id="PROPOSE_CLOTH_BACK_HAIR_CHAINS"></a>`PROPOSE_CLOTH_BACK_HAIR_CHAINS` | with `--cloth` (issues #390, #445, #461): `cloth."back hair".chains` exceeds what the narrower side's opaque width at the eye-line root row holds at the part's mesh grid (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`), or places a tree root outside the 15 px dilated art mask of the `back hair` part (e.g. an inner twin-tail root over the face where the hair has no pixel); the message names the part, each side's root-row width, the grid spacing, and any off-art root with its pixel alpha | lower `cloth."back hair".chains` to at most the max fit named in the message, or omit `chains` so `propose --cloth` derives the tree count per visible hair mass on each side from its root-row width |
3850
3919
  | <a id="PROPOSE_ACCESSORY_BODY"></a>`PROPOSE_ACCESSORY_BODY` | an accessory has nothing above its pendant rows to hang its bone on | that part's plan entry, or author its bones by hand |
@@ -3855,7 +3924,7 @@ says why each requirement exists and what breaks without it.
3855
3924
  | <a id="KEYPOINTS_JOINT_KNOWN"></a><a id="KEYPOINTS_JOINT_STATE"></a><a id="KEYPOINTS_POSITION_STATED"></a><a id="KEYPOINTS_POSITION_INSIDE"></a>`KEYPOINTS_JOINT_KNOWN`, `KEYPOINTS_JOINT_STATE`, `KEYPOINTS_POSITION_STATED`, `KEYPOINTS_POSITION_INSIDE` | a joint name outside body-18, a state outside observed / occluded / missing, an observed joint with no `at` or a missing one with one, or a position outside the `width`x`height` image | that joint |
3856
3925
  | <a id="KEYPOINTS_PERSON_UNIQUE"></a><a id="KEYPOINTS_PERSON_CHOSEN"></a>`KEYPOINTS_PERSON_UNIQUE`, `KEYPOINTS_PERSON_CHOSEN` | two people share an id; or the file holds more than one person and no `--person` names one, or `--person` names none of them (the message lists the ids) | `--person <id>` |
3857
3926
  | <a id="KEYPOINTS_IMAGE_SIZE"></a>`KEYPOINTS_IMAGE_SIZE` | the file's `width`x`height` is not the `--source` painting's | a file measured on this painting |
3858
- | <a id="PARTS_FILE_PRESENT"></a><a id="PARTS_IS_JSON"></a><a id="PARTS_KEY_KNOWN"></a><a id="PARTS_FIELD_PRESENT"></a><a id="PARTS_FIELD_TYPE"></a><a id="PARTS_NAME_UNIQUE"></a><a id="PARTS_FROM_KNOWN"></a><a id="PARTS_CUT_FROM_KNOWN"></a><a id="PARTS_BOX_INSIDE_RIG"></a><a id="PARTS_COUNTS_ADD_UP"></a><a id="PARTS_HOLES_LARGEST_FIRST"></a><a id="PARTS_HOLE_PART_KNOWN"></a><a id="PARTS_SINGLE_RUN_PAD"></a>`PARTS_*` (`PARTS_FILE_PRESENT`, `PARTS_IS_JSON`, `PARTS_KEY_KNOWN`, `PARTS_FIELD_PRESENT`, `PARTS_FIELD_TYPE`, `PARTS_NAME_UNIQUE`, `PARTS_FROM_KNOWN`, `PARTS_CUT_FROM_KNOWN`, `PARTS_BOX_INSIDE_RIG`, `PARTS_COUNTS_ADD_UP`, `PARTS_HOLES_LARGEST_FIRST`, `PARTS_HOLE_PART_KNOWN`, `PARTS_SINGLE_RUN_PAD`) | the `parts.json` read is not assemble's contract (`PARTS_COUNTS_ADD_UP`: `visible_px + occluded_px` is not `opaque_px`, `visible_not_projected_px` is above `visible_px`, or the `recomposite` block's figures disagree — more uncovered than error pixels, a hole larger than its box, the wrong number of holes listed, listed areas that do not sum to `uncovered_error_px`; the last two codes are the block's order and its border names; `PARTS_SINGLE_RUN_PAD`: the `single_run_pad` block's margins are not the centring, its file is not smaller than its square, or `rig_size` is not the file at the scale, issue #366 — §5) | re-run assemble; do not edit `parts.json` |
3927
+ | <a id="PARTS_FILE_PRESENT"></a><a id="PARTS_IS_JSON"></a><a id="PARTS_KEY_KNOWN"></a><a id="PARTS_FIELD_PRESENT"></a><a id="PARTS_FIELD_TYPE"></a><a id="PARTS_NAME_UNIQUE"></a><a id="PARTS_FROM_KNOWN"></a><a id="PARTS_CUT_FROM_KNOWN"></a><a id="PARTS_BOX_INSIDE_RIG"></a><a id="PARTS_COUNTS_ADD_UP"></a><a id="PARTS_HOLES_LARGEST_FIRST"></a><a id="PARTS_HOLE_PART_KNOWN"></a><a id="PARTS_SINGLE_RUN_PAD"></a>`PARTS_*` (`PARTS_FILE_PRESENT`, `PARTS_IS_JSON`, `PARTS_KEY_KNOWN`, `PARTS_FIELD_PRESENT`, `PARTS_FIELD_TYPE`, `PARTS_NAME_UNIQUE`, `PARTS_FROM_KNOWN`, `PARTS_CUT_FROM_KNOWN`, `PARTS_BOX_INSIDE_RIG`, `PARTS_COUNTS_ADD_UP`, `PARTS_HOLES_LARGEST_FIRST`, `PARTS_HOLE_PART_KNOWN`, `PARTS_SINGLE_RUN_PAD`) | the `parts.json` read is not assemble's contract (`PARTS_FILE_PRESENT`: no `parts.json` under `--parts`, which names the directory holding `parts.json` and `parts/` — `work/rig` from `assemble --out work`, or `out` from `build --out out`; `PARTS_COUNTS_ADD_UP`: `visible_px + occluded_px` is not `opaque_px`, `visible_not_projected_px` is above `visible_px`, or the `recomposite` block's figures disagree — more uncovered than error pixels, a hole larger than its box, the wrong number of holes listed, listed areas that do not sum to `uncovered_error_px`; the last two codes are the block's order and its border names; `PARTS_SINGLE_RUN_PAD`: the `single_run_pad` block's margins are not the centring, its file is not smaller than its square, or `rig_size` is not the file at the scale, issue #366 — §5) | pass the directory holding `parts.json` and `parts/` (`work/rig` or `out`), or re-run assemble; do not edit `parts.json` |
3859
3928
 
3860
3929
  ### compare
3861
3930
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.65.0",
3
+ "version": "1.66.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": {
@@ -125,7 +125,7 @@ under that.
125
125
  6. Correct against `render/landmarks.png` and `note:` lines; a
126
126
  `coverage … not checked` line is a bone no check read (the line says why),
127
127
  and `basis.json` beside the proposal says what each bone rests on — check
128
- the `ratio` bones and any with a `fallback` first. `propose …
128
+ the `ratio` bones and any with a `fallback` first. `rig-parts propose --parts work/rig --source <painting> --out work
129
129
  --from-config config.json` (with the same `--keypoints`) until it prints no
130
130
  `LINT` line. `rig-parts compare
131
131
  --left work/proposal.json --right config.json` lists what the correction changed,
package/src/assemble.ts CHANGED
@@ -102,7 +102,7 @@
102
102
  import { sourceLimbTarget, nearestTargetPart } from './limbownership.ts';
103
103
  import { type ArmSplit, type BoneEntry, type CharacterConfig, type ControlSkeleton, controlSkeletonOf, type Cut, type EarlyConfig, type Extend, type Limb, type Patch, type PlanEntry, type Run, type SeeThrough } from './config.ts';
104
104
  import { type Problem, refuseIfAny } from './errors.ts';
105
- import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
105
+ import { CORE_ALPHA, figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
106
106
  import { padToSquare, squarePad } from './inputs.ts';
107
107
  import { type CapRecord, type LimbRecord, PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord, type SingleRunPad } from './parts.ts';
108
108
  import { assignCrossedLimbs, assignOverlappingHands, type BonePlace, bonePlaces, capBox, coveredRadius, type CutPlacement, featherWeight, halfWidthAbout, inDisc, jointLine, type JointLine, jointOffsets, limbPolyline, limbRun, luma601, nearestLimb, nearestOnLine, offLineFigure, pastJoint, placeCut, polylineDist2, segmentOf } from './limbs.ts';
@@ -133,8 +133,6 @@ const f32 = Math.fround;
133
133
  // the reference's constants, each named once
134
134
  // ---------------------------------------------------------------------------
135
135
 
136
- /** Alpha at or above this is "opaque" for ownership, projection cores and the seam override. `>= 250`, not `== 255`: See-through leaves alpha-254 speckle. */
137
- export const CORE_ALPHA = 250;
138
136
  /** A ghost component is kept when its area is at least this many pixels … */
139
137
  export const GHOST_MIN_AREA = 40;
140
138
  /** … and at least this fraction of the layer's largest component. */
@@ -160,7 +158,7 @@ export const CROP_PROBE_ALPHA = 128;
160
158
  /** Alpha at or above this in a later plan part keeps `growRim`'s ring off the pixel. */
161
159
  export const FRONT_ALPHA = 128;
162
160
  /** A painting pixel whose min channel is above this is "near-white" (the faithful rim and seam guard). Defined beside the layer reader, which applies the same test to a layer's colour (`layerFigures`). */
163
- export { NEAR_WHITE_MIN };
161
+ export { CORE_ALPHA, NEAR_WHITE_MIN };
164
162
  /** The seam override acts where the flat composite differs from the painting by more than this, max channel. */
165
163
  export const SEAM_LIMIT = 60;
166
164
  /** `--propose-plan` drops a tag with fewer run-space opaque pixels than this after ghost clean-up. */
@@ -5214,7 +5212,13 @@ export function driftBodies(part: string, from: string, refused: Mask, minPx: nu
5214
5212
  * `drift: "topwear" refuses 196 px of full:topwear (its colour more than 90
5215
5213
  * off the painting, max channel), in a body of 196 px at 16,12 14x14 (at
5216
5214
  * least 150 px past a 5x5 opening); by the plan's draw order "face" holds 196
5217
- * px of it (the top-most part at alpha >= 250), drawn in front of "topwear"`.
5215
+ * px of it (the top-most part at alpha >= 250), "face" drawn in front of
5216
+ * "topwear"; informational: no gate reads this. Swapping the plan order of the
5217
+ * parts named here can clear it (when only the front one differs from the
5218
+ * painting) or move it to the other part (when both do): swap, rebuild and read
5219
+ * this line again; if it moves, removing it needs different input layers. At
5220
+ * rest, the seam pass repaints the holder's pixels that pass its rule from the
5221
+ * painting`.
5218
5222
  * When `behind` is non-empty (issue #351), names the part(s) drawn behind
5219
5223
  * `part` whose colour is within `DRIFT_LIMIT` of the painting.
5220
5224
  */
@@ -5230,7 +5234,8 @@ export function driftLine(d: DriftBodies): string {
5230
5234
  : '';
5231
5235
  return (
5232
5236
  `drift: "${d.part}" refuses ${d.refusedPx} px of ${d.from} (its colour more than ${DRIFT_LIMIT} off the painting, max channel), in ${held} (at least ${fmtPx(d.minPx)} px past a ${BODY_KERNEL}x${BODY_KERNEL} opening); ` +
5233
- `by the plan's draw order ${d.holders.map((h) => `"${h.part}" holds ${h.px} px`).join(', ')} of it (the top-most part at alpha >= ${CORE_ALPHA})${frontClause}${behindClause}`
5237
+ `by the plan's draw order ${d.holders.map((h) => `"${h.part}" holds ${h.px} px`).join(', ')} of it (the top-most part at alpha >= ${CORE_ALPHA})${frontClause}${behindClause}; ` +
5238
+ "informational: no gate reads this. Swapping the plan order of the parts named here can clear it (when only the front one differs from the painting) or move it to the other part (when both do): swap, rebuild and read this line again; if it moves, removing it needs different input layers. At rest, the seam pass repaints the holder's pixels that pass its rule from the painting"
5234
5239
  );
5235
5240
  }
5236
5241
 
package/src/check.ts CHANGED
@@ -7168,6 +7168,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
7168
7168
  const carry = breathCarrier(inp.rig, inp.motion, p.name, geometryPath, frames, { art, box: p, stage: inp.stage, shown, toward: rises ? 'up' : 'down', root: t.root, tip: t.tip });
7169
7169
  if (carry !== null) rows[rows.length - 1] = { ...rows[rows.length - 1], carried_by: carry.bone, breath_rig_px: round3(carry.breath), root_without_breath_rig_px: round3(carry.root), tip_without_breath_rig_px: round3(carry.tip) };
7170
7170
  const offChain = carry === null && round3(t.tip) > 0 ? offChainClause(inp.rig, inp.motion, p.name) : null;
7171
+ const misplaced = rises || carry !== null || offChain !== null ? null : chainRootsBelowArtClause(inp.rig, inp.motion, p, inp.stage, readGeometry(geometryPath, frames)?.setupBones);
7171
7172
  const why =
7172
7173
  carry === null
7173
7174
  ? `the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain${offChain === null ? '' : `; ${offChain}`}`
@@ -7177,7 +7178,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
7177
7178
  problems.push({
7178
7179
  code: 'CHECK_TIP_OVER_ROOT',
7179
7180
  object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
7180
- detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${why}`,
7181
+ detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${misplaced ?? why}`,
7181
7182
  });
7182
7183
  }
7183
7184
  }
@@ -7236,6 +7237,24 @@ function offChainClause(rig: Record<string, unknown>, motion: Record<string, unk
7236
7237
  return `it is weighted off the chain to ${list} — drop the segment on ${drop} in \`meshes.${part}.segments\`, or grow the chain's amplitudes toward the tip`;
7237
7238
  }
7238
7239
 
7240
+ /** #752: a failing hanging part whose carrying pivots all sit below its art needs their placement named, not larger angles. */
7241
+ export function chainRootsBelowArtClause(
7242
+ rig: Record<string, unknown>, motion: Record<string, unknown>, part: Pick<PartRecord, 'name' | 'y' | 'h'>,
7243
+ stage: StageBox, setupBones: unknown,
7244
+ ): string | null {
7245
+ const { found } = chainsCarrying(rig, motion, part.name);
7246
+ if (found.length === 0 || !Array.isArray(setupBones)) return null;
7247
+ const origins = new Map(setupBones.filter(isRecord).filter((b) => typeof b.name === 'string' && typeof b.worldX === 'number' && typeof b.worldY === 'number').map((b) => [String(b.name), { x: b.worldX as number, y: b.worldY as number }]));
7248
+ const roots = found.map((chain) => {
7249
+ const at = origins.get(chain[0]);
7250
+ return at === undefined ? null : { chain, x: at.x - stage.x, y: cropToSpineY(at.y - stage.y, stage.height) };
7251
+ });
7252
+ if (roots.some((r) => r === null || !Number.isFinite(r.x) || !Number.isFinite(r.y) || r.y <= part.y + part.h)) return null;
7253
+ const placed = roots.filter((r): r is NonNullable<typeof r> => r !== null);
7254
+ const list = placed.map((r) => `${r.chain.join(' > ')} at crop [${round3(r.x)}, ${round3(r.y)}]`).join('; ');
7255
+ return `the carrying chains' roots are below the art: ${list}; the part's crop y range is ${part.y}..${part.y + part.h} — re-place these chains' \`bones\` \`points\` / \`tip\` across this art, rooting them in its upper half (y ${part.y}..${part.y + part.h / 2}) and ending in its lower half (y ${part.y + part.h / 2}..${part.y + part.h}); \`propose\` on these assembled parts drafts those positions; increasing amplitudes leaves the root positions unchanged`;
7256
+ }
7257
+
7239
7258
  /**
7240
7259
  * The bones a part's attachments are bound to in the default skin (issue #231): a mesh's weights, or its slot's
7241
7260
  * bone for an attachment that carries no weights.
@@ -157,10 +157,11 @@ function entryEdits(
157
157
  V === null
158
158
  ? `it holds ${held} where ${draftSays}`
159
159
  : `it equals neither the draft, the draft without the recipe, nor the previous recipe's draft: it holds ${held} where ${prev === undefined ? 'the previous recipe\'s draft has no such entry' : `the previous recipe's draft writes ${field === null ? shown(prev) : fieldOf(prev, field)}`} and ${draftSays}`;
160
+ const kind = section === 'motion.tracks' ? 'track' : section === 'bones' ? 'bone' : 'constraint';
160
161
  conflicts.push({
161
162
  code: 'PROPOSE_WRITE_CONFIG_CONFLICT',
162
163
  object: `config.${section}[${i}] "${k}"`,
163
- detail: `${section} entry "${k}" is the recipe's, and it was changed by hand: ${says}, so the write would drop the hand edit; take the draft by removing the hand edit (or the entry), or leave the recipe out for that part (its config.cloth block)`,
164
+ detail: `${section} entry "${k}" is the recipe's, and it was changed by hand: ${says}, so the write would drop the hand edit; to take the recipe's draft, remove the hand-edited ${kind} "${k}" from config.${section} (or revert the hand edit) and run propose --cloth again, or leave the recipe out for that part (its config.cloth block)`,
164
165
  });
165
166
  };
166
167
  for (const k of [...owned, ...removed]) {
@@ -288,7 +289,7 @@ export function clothConfigEdits(drafted: Proposal, plain: Proposal, input: unkn
288
289
  conflicts.push({
289
290
  code: 'PROPOSE_WRITE_CONFIG_CONFLICT',
290
291
  object: where,
291
- detail: `${path.join('.')} is the previous recipe's, the recipe now leaves it to the draft without the recipe, and it was changed by hand: it equals neither the draft nor the previous recipe's draft: it holds ${field === null ? shown(mine) : at(mine, field)} where the previous recipe's draft writes ${c === undefined ? 'no such entry' : field === null ? shown(c) : at(c, field)} and the draft writes ${b === undefined ? 'no such entry' : field === null ? shown(b) : at(b, field)}, so the write would drop the hand edit; take the draft by removing the hand edit (or the entry), or keep the recipe for that part (its config.cloth block)`,
292
+ detail: `${path.join('.')} is the previous recipe's, the recipe now leaves it to the draft without the recipe, and it was changed by hand: it equals neither the draft nor the previous recipe's draft: it holds ${field === null ? shown(mine) : at(mine, field)} where the previous recipe's draft writes ${c === undefined ? 'no such entry' : field === null ? shown(c) : at(c, field)} and the draft writes ${b === undefined ? 'no such entry' : field === null ? shown(b) : at(b, field)}, so the write would drop the hand edit; to take the draft, remove the hand-edited ${path.join('.')} (or revert the hand edit) and run propose --cloth again, or keep the recipe for that part (its config.cloth block)`,
292
293
  });
293
294
  };
294
295
  const prevKeys = (section: string, have: readonly string[]): string[] => {
package/src/layers.ts CHANGED
@@ -41,6 +41,8 @@ import { BODY_TAGS, HEAD_TAGS, HIDDEN_NAME_RULE, type LayerTagReading, layerTagR
41
41
 
42
42
  /** Alpha strictly above this counts as painted. The reference uses the same cut for its ghost clean-up and its sheets. */
43
43
  export const OPAQUE_ALPHA_ABOVE = 8;
44
+ /** Alpha at or above this is "opaque" for ownership, projection cores and the seam override. `>= 250`, not `== 255`: See-through leaves alpha-254 speckle. */
45
+ export const CORE_ALPHA = 250;
44
46
  /**
45
47
  * A pixel whose min channel is above this is "near-white". The assemble stage's
46
48
  * rim and seam guard apply it to the painting (`src/assemble.ts` re-exports it
@@ -100,6 +102,66 @@ export interface LayerSet {
100
102
  hidden: HiddenLayer[];
101
103
  }
102
104
 
105
+ /** Bounds in input-canvas pixels, with exclusive right/bottom boundaries. */
106
+ export interface OpaqueBox {
107
+ left: number;
108
+ top: number;
109
+ right: number;
110
+ bottom: number;
111
+ }
112
+
113
+ function alphaBox(layer: Pick<Layer, 'pixels' | 'left' | 'top'>, includes: (alpha: number) => boolean): OpaqueBox | null {
114
+ const p = layer.pixels;
115
+ let left = p.width;
116
+ let top = p.height;
117
+ let right = 0;
118
+ let bottom = 0;
119
+ for (let y = 0; y < p.height; y++) {
120
+ for (let x = 0; x < p.width; x++) {
121
+ if (!includes(p.data[(y * p.width + x) * 4 + 3])) continue;
122
+ left = Math.min(left, x);
123
+ top = Math.min(top, y);
124
+ right = Math.max(right, x + 1);
125
+ bottom = Math.max(bottom, y + 1);
126
+ }
127
+ }
128
+ return right === 0 ? null : { left: layer.left + left, top: layer.top + top, right: layer.left + right, bottom: layer.top + bottom };
129
+ }
130
+
131
+ /** Original alpha above the legacy painted-pixel cut (>8), before cleanup or resampling. */
132
+ export function layerOpaqueBox(layer: Pick<Layer, 'pixels' | 'left' | 'top'>): OpaqueBox | null {
133
+ return alphaBox(layer, alpha => alpha > OPAQUE_ALPHA_ABOVE);
134
+ }
135
+
136
+ /** Original alpha at or above CORE_ALPHA; not an owned, eroded or resampled projection core. */
137
+ export function layerCoreBox(layer: Pick<Layer, 'pixels' | 'left' | 'top'>): OpaqueBox | null {
138
+ return alphaBox(layer, alpha => alpha >= CORE_ALPHA);
139
+ }
140
+
141
+ function bodyBox(set: LayerSet, readBox: (layer: Layer) => OpaqueBox | null): OpaqueBox | null {
142
+ let union: OpaqueBox | null = null;
143
+ for (const layer of set.layers) {
144
+ if (layer.tag.group !== 'body') continue;
145
+ const box = readBox(layer);
146
+ if (box === null) continue;
147
+ union = union === null ? box : {
148
+ left: Math.min(union.left, box.left), top: Math.min(union.top, box.top),
149
+ right: Math.max(union.right, box.right), bottom: Math.max(union.bottom, box.bottom),
150
+ };
151
+ }
152
+ return union;
153
+ }
154
+
155
+ /** Source >8 union of the reader's existing drawn body group, not an anatomical silhouette. */
156
+ export function bodyOpaqueBox(set: LayerSet): OpaqueBox | null {
157
+ return bodyBox(set, layerOpaqueBox);
158
+ }
159
+
160
+ /** Source >=CORE_ALPHA union of the same body group; no ownership, erosion or cleanup. */
161
+ export function bodyCoreBox(set: LayerSet): OpaqueBox | null {
162
+ return bodyBox(set, layerCoreBox);
163
+ }
164
+
103
165
  function opaque(r: Raster): number {
104
166
  let n = 0;
105
167
  for (let i = 3; i < r.data.length; i += 4) if (r.data[i] > OPAQUE_ALPHA_ABOVE) n++;
package/src/parts.ts CHANGED
@@ -515,7 +515,7 @@ export function writeParts(path: string, file: PartsFile): void {
515
515
  }
516
516
 
517
517
  export function readParts(path: string): PartsFile {
518
- if (!existsSync(path)) refuseIfAny([{ code: 'PARTS_FILE_PRESENT', object: path, detail: 'no such file' }]);
518
+ if (!existsSync(path)) refuseIfAny([{ code: 'PARTS_FILE_PRESENT', object: path, detail: 'no such file; --parts names the directory holding parts.json and parts/ (work/rig from assemble --out work, or out from build --out out)' }]);
519
519
  let raw: unknown;
520
520
  try {
521
521
  raw = JSON.parse(readFileSync(path, 'utf8'));
package/src/propose.ts CHANGED
@@ -3494,12 +3494,46 @@ export function proposeWithBasis(
3494
3494
  }
3495
3495
  const constrained = clothConstraints.length === 0 ? {} : { constraints: clothConstraints };
3496
3496
  return {
3497
- proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, proposed_rig_scale: P.rigScale, notes },
3497
+ proposal: draftPassingChainSigns(P, { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, proposed_rig_scale: P.rigScale, notes }),
3498
3498
  basis: { frame, bones: records },
3499
3499
  draftLint: [...jointsOffArt, ...radii],
3500
3500
  };
3501
3501
  }
3502
3502
 
3503
+ /**
3504
+ * Issue #751: a fresh draft need not leave a measured, passing sign choice to its author.
3505
+ * The supplied-config lint never calls this. The reader and bar are unchanged; an
3506
+ * unread or still-failing alternative stays a LINT, not a guessed correction.
3507
+ */
3508
+ export function draftPassingChainSigns(P: PartSet, proposal: Proposal): Proposal {
3509
+ const images = new Map(P.recs.filter((p) => P.images.has(p.name)).map((p) => [p.name, { x: p.x, y: p.y, image: P.images.get(p.name) as Raster }]));
3510
+ const key = (f: { mesh: string; a: { chain: string }; b: { chain: string } }): string => JSON.stringify([f.mesh, f.a.chain, f.b.chain]);
3511
+ let current = proposal;
3512
+ let reading = chainSqueezes(current, images);
3513
+ const initial = reading.findings;
3514
+ for (const original of initial) {
3515
+ const finding = reading.findings.find((f) => key(f) === key(original));
3516
+ if (finding === undefined || finding.turned === null || !Number.isFinite(finding.turned) || finding.turned > TEXTURE_STRETCH_CEILING) continue;
3517
+ const candidate: Proposal = {
3518
+ ...current,
3519
+ motion: { ...current.motion, tracks: current.motion.tracks.map((t) => 'chain' in t && t.chain === finding.b.chain ? { ...t, amps: t.amps.map((a) => -a) } : t) },
3520
+ };
3521
+ // A track can be shared by other pairs/meshes. Accept no new or worsened
3522
+ // finding, and do not turn a previously readable pair into an unread one.
3523
+ const after = chainSqueezes(candidate, images);
3524
+ if (JSON.stringify(after.unread) !== JSON.stringify(reading.unread) || after.findings.some((f) => {
3525
+ const before = reading.findings.find((old) => key(old) === key(f));
3526
+ return key(f) === key(finding) || before === undefined || f.both.severity > before.both.severity;
3527
+ })) continue;
3528
+ current = {
3529
+ ...candidate,
3530
+ notes: [...current.notes, `shared-mesh chain signs: ${finding.a.chain} and ${finding.b.chain} on mesh ${pyRepr(finding.mesh)} were opposed; drafted one sign by reversing ${finding.b.chain}'s amplitudes, keeping their magnitudes and timing: squeeze ${pyFixed(finding.both.severity, 3)} -> ${pyFixed(finding.turned, 3)} (<= ${TEXTURE_STRETCH_CEILING}, check's TEXTURE_STRETCH bar); all shared pairs re-read without a new or worsened finding`],
3531
+ };
3532
+ reading = after;
3533
+ }
3534
+ return current;
3535
+ }
3536
+
3503
3537
  /**
3504
3538
  * The note the `figure` grid rule writes (issue #265): the spacing and where it came from, per lattice part its own
3505
3539
  * spacing and the one drafted, what it costs and what it was calibrated on.
package/src/rig.ts CHANGED
@@ -648,7 +648,8 @@ export interface TurnRow {
648
648
  * the control {@link TURN_BONE} under `root` at its origin, nothing keying it;
649
649
  * for each part `config.turn.depths` lists, in parts.json order, a leaf twin of
650
650
  * every bone the part's slots bind (its region's bone, a blink.still piece's
651
- * bone, a mesh's slot bone and every bone its weights name) — an identity child,
651
+ * bone, a mesh's slot bone and every bone its weights name), in `bones`
652
+ * declaration order (issue #746) — an identity child,
652
653
  * x 0, y 0, no rotation, so its world transform is its bone's exactly and every
653
654
  * idle key, physics step or constraint on the bone reaches it — the part's
654
655
  * slots and weights moved onto the twins, and one transform constraint per part
@@ -672,7 +673,7 @@ export interface TurnRow {
672
673
  * constraint. A name the turn needs that is taken is refused by name
673
674
  * (`RIG_TURN_NAME_FREE`).
674
675
  */
675
- function applyTurn(
676
+ export function applyTurn(
676
677
  turn: TurnSpec,
677
678
  order: readonly string[],
678
679
  bones: RigBone[],
@@ -681,8 +682,21 @@ function applyTurn(
681
682
  constraints: readonly ConfigConstraint[],
682
683
  frontSlots: ReadonlySet<string> = new Set(),
683
684
  slotFamilies: ReadonlyMap<string, readonly string[]> = new Map(),
685
+ stops: ReadonlyArray<{ links: readonly string[] }> | null = null,
686
+ orderByDeclaration: boolean = true,
684
687
  ): { constraints: Array<Record<string, unknown>>; detached: DetachedRule[]; rows: TurnRow[] } {
685
688
  const problems: Problem[] = [];
689
+ const undrawn = new Map<string, string>();
690
+ for (const s of stops ?? []) for (const l of s.links) undrawn.set(`${l}_d`, l);
691
+ const boneOrder = new Map(bones.map((b, i) => [b.name, i * 2]));
692
+ const orderOf = (b: string): number => {
693
+ const direct = boneOrder.get(b);
694
+ if (direct !== undefined) return direct;
695
+ const base = undrawn.get(b);
696
+ const baseIdx = base === undefined ? undefined : boneOrder.get(base);
697
+ if (baseIdx === undefined) throw new Error(`applyTurn: bound bone "${b}" is not in bones`);
698
+ return baseIdx + 1;
699
+ };
686
700
  const taken = new Set(bones.map((b) => b.name));
687
701
  const transforms = new Set(constraints.filter((c) => c.type === 'transform').map((c) => c.name));
688
702
  if (taken.has(TURN_BONE)) problems.push({ code: 'RIG_TURN_NAME_FREE', object: `bone "${TURN_BONE}"`, detail: 'is the control the rig stage adds for config.turn, and the rig already has a bone of that name; rename it' });
@@ -702,6 +716,8 @@ function applyTurn(
702
716
  bind(sl.bone);
703
717
  for (const att of Object.values(skin[sl.name] ?? {})) if ('type' in att) for (const v of att.weights) for (const w of v) bind(w.bone);
704
718
  }
719
+ for (const b of bound) orderOf(b);
720
+ if (orderByDeclaration) bound.sort((a, b) => orderOf(a) - orderOf(b));
705
721
  for (const b of bound) {
706
722
  const twin = turnTwin(f.part, b);
707
723
  if (taken.has(twin)) problems.push({ code: 'RIG_TURN_NAME_FREE', object: `bone "${twin}"`, detail: `is the twin that carries part "${f.part}" on "${b}" under config.turn, and the rig already has a bone of that name; rename it` });
@@ -1259,6 +1275,7 @@ export function buildRig(
1259
1275
  maxLoopPasses: number = ONE_LOOP_PASSES,
1260
1276
  idleKeys: IdleKeys = DEFAULT_IDLE_KEYS,
1261
1277
  reduce: Reducer = runReduction,
1278
+ turnOrderByDeclaration: boolean = true,
1262
1279
  ): RigOutput {
1263
1280
  // Generated bands have a fixed one-pixel lattice, with their axial weights
1264
1281
  // below; config never supplies a second mesh policy for the same source.
@@ -2835,7 +2852,7 @@ export function buildRig(
2835
2852
  // ---- the turn control (issue #251) -------------------------------------
2836
2853
  // Only when the config has `turn`: every other build adds nothing here, so no byte moves.
2837
2854
  if (cfg.turn !== undefined) refuseIfAny(turnRiderProblems(cfg.turn, cfg, parts.parts));
2838
- const turned = cfg.turn === undefined ? null : applyTurn(cfg.turn, parts.parts.map((p) => p.name), bones, rebound, skin, cfg.constraints ?? [], declaredFront, slotFamilies);
2855
+ const turned = cfg.turn === undefined ? null : applyTurn(cfg.turn, parts.parts.map((p) => p.name), bones, rebound, skin, cfg.constraints ?? [], declaredFront, slotFamilies, stops, turnOrderByDeclaration);
2839
2856
  for (const row of turned?.rows ?? []) for (const t of row.twins) layoutAlias(explicitLayout.plan, t.bone, t.twin);
2840
2857
  // issue #536: every row names the bones the emitted attachment binds, read once the turn and the stops have
2841
2858
  // rebound the weights; a rig with neither rebinds nothing, so this reads back the names each row already holds.
@@ -45,7 +45,7 @@ export function readConfigWriteTarget(path: string): ConfigWriteTarget {
45
45
  const target = resolve(path);
46
46
  let stat: ReturnType<typeof lstatSync>;
47
47
  try { stat = lstatSync(target); }
48
- catch (error) { return refusal('CONFIG_FILE_PRESENT', target, `cannot read the existing config: ${error instanceof Error ? error.message : String(error)}`); }
48
+ catch (error) { return refusal('CONFIG_FILE_PRESENT', target, `cannot read the existing config: ${error instanceof Error ? error.message : String(error)}; without --cloth, --write-config updates an existing config in place (only propose --cloth --write-config writes to a new file)`); }
49
49
  if (!stat.isFile()) return refusal('CONFIG_FIELD_TYPE', target, 'is not a regular file (symlinks are not replaced); --write-config updates an existing config file in place');
50
50
  let original: string;
51
51
  try { original = readFileSync(target, 'utf8'); }
@@ -133,7 +133,7 @@ export function stageConfigWrite(target: ConfigWriteTarget | FreshConfigWrite, p
133
133
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return;
134
134
  return ioFailure(path, 'checking the fresh destination failed', error);
135
135
  }
136
- refusal('PROPOSE_WRITE_CONFIG_FRESH', path, 'exists; the config is written to a new file, never over the --cloth config or an earlier one');
136
+ refusal('PROPOSE_WRITE_CONFIG_FRESH', path, 'exists; the config is written to a new file, never over the --cloth config or an earlier one (unlike assemble --propose-plan and propose without --cloth, which update an existing --config in place)');
137
137
  };
138
138
  if (fresh) freshAbsent();
139
139
  const temp = join(dirname(path), `.${basename(path)}.rig-parts-${randomBytes(8).toString('hex')}.tmp`);
@@ -182,7 +182,7 @@ export function stageConfigWrite(target: ConfigWriteTarget | FreshConfigWrite, p
182
182
  // refuses an intervening creator atomically; rename would overwrite it.
183
183
  try { (files.publish ?? linkSync)(temp, path); }
184
184
  catch (error) {
185
- if (error instanceof Error && 'code' in error && error.code === 'EEXIST') refusal('PROPOSE_WRITE_CONFIG_FRESH', path, 'exists; the config is written to a new file, never over the --cloth config or an earlier one');
185
+ if (error instanceof Error && 'code' in error && error.code === 'EEXIST') refusal('PROPOSE_WRITE_CONFIG_FRESH', path, 'exists; the config is written to a new file, never over the --cloth config or an earlier one (unlike assemble --propose-plan and propose without --cloth, which update an existing --config in place)');
186
186
  throw error;
187
187
  }
188
188
  cancel();