@rig-c/parts 1.30.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.ts CHANGED
@@ -16,7 +16,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri
16
16
  import { tmpdir } from 'node:os';
17
17
  import { basename, dirname, extname, join } from 'node:path';
18
18
  import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, proposeSinglePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
19
- import { assembleStage, build, checkStage, ERROR_MAP_FILE, loopStage, readRuns, readSource, rigStage, suppliedCheckFlag } from './src/build.ts';
19
+ import { type AlternateCliInput, assembleStage, build, checkStage, ERROR_MAP_FILE, loopStage, readRuns, readSource, rigStage, suppliedCheckFlag } from './src/build.ts';
20
20
  import { ACCEPTABLE_BARS, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, findRigc, PACK_SHAPES, rigcRunnerAt, type PackShape, PAGE_EDGES, type PageEdges, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, type RigcRunner, NO_PARTS_SENTENCE, SEAM_MEAN_BAR, SOURCE_SENTENCE, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
21
21
  import { ComfyClient, resolveHost, runPainting, runSeeThrough } from './src/comfy/index.ts';
22
22
  import { type CharacterConfig, loadClothConfig, loadConfig, loadEarlyConfig, loadPaintConfig } from './src/config.ts';
@@ -469,7 +469,7 @@ usage:
469
469
 
470
470
  rig-parts assemble --source <painting.png> --full <dir|psd> [--head <dir|psd>]
471
471
  --config <config.json> --out <dir> [--seam near-white|silhouette]
472
- [--project core|visible]
472
+ [--project core|visible] [--alt <name>=<source>:<full>[:<head>]]
473
473
  Merge the full-body and head-crop See-through runs into rig-space parts:
474
474
  <out>/rig/parts/<name>.png (each cropped to its alpha box), <out>/rig/parts.json,
475
475
  <out>/render/recomposite_rig.png and <out>/render/${ERROR_MAP_FILE} (the error
@@ -567,6 +567,7 @@ usage:
567
567
  [--project core|visible] [--page-edges pot|free]
568
568
  [--pack-shape rect|polygon] [--loop] [--requirements <file.json>]
569
569
  [--idle-keys ctl|direct] [--lead-in <periods>] [--seam-pairs] [--keep-stills]
570
+ [--alt <name>=<source>:<full>[:<head>]]
570
571
  assemble, then rig, then check, in one process, each stage's own lines
571
572
  printed under [assemble], [rig] and [check]; the first stage that refuses
572
573
  stops the build with its own FAIL lines. The config must already carry
@@ -786,11 +787,11 @@ const TAKES = {
786
787
  compare: { valued: ['--left', '--right', '--map'], switches: [] },
787
788
  check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements'], switches: ['--seam-pairs', '--keep-stills'] },
788
789
  loop: { valued: ['--frames', '--out'], switches: ['--palette'] },
789
- assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project'], switches: ['--propose-plan'] },
790
+ assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project', '--alt'], switches: ['--propose-plan'] },
790
791
  'comfy seethrough': { valued: ['--image', '--out', '--resolution', '--steps', '--seed', '--prefix', '--host', '--wait', '--timeout', '--poll'], switches: ['--offload', '--lama', '--nf4'] },
791
792
  'comfy paint': { valued: ['--config', '--out', '--seeds', '--seed0', '--host', '--wait', '--timeout', '--poll'], switches: [] },
792
793
  inputs: { valued: ['--source', '--config', '--out'], switches: [] },
793
- build: { valued: ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges', '--pack-shape', '--requirements', '--idle-keys', '--lead-in'], switches: ['--loop', '--seam-pairs', '--keep-stills'] },
794
+ build: { valued: ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges', '--pack-shape', '--requirements', '--idle-keys', '--lead-in', '--alt'], switches: ['--loop', '--seam-pairs', '--keep-stills'] },
794
795
  compose: { valued: ['--scene', '--out', '--requirements', '--accept'], switches: [] },
795
796
  'mesh-draft': { valued: ['--config', '--parts', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: ['--more-triangles'] },
796
797
  painting: { valued: ['--keypoints', '--person'], switches: [] },
@@ -809,6 +810,20 @@ function notTaken(command: Command, flag: string): string {
809
810
  return `${command} does not take "${flag}"; it takes ${[...row.valued, ...row.switches].join(', ')}`;
810
811
  }
811
812
 
813
+ /** Parse `--alt <name>=<source>:<full>[:<head>]` (issue #454), or return a usage message. */
814
+ function parseAltArg(value: string): AlternateCliInput | string {
815
+ const eq = value.indexOf('=');
816
+ if (eq <= 0) return `--alt "${value}" is not <name>=<source>:<full>[:<head>]`;
817
+ const name = value.slice(0, eq);
818
+ if (name.startsWith('.') || name.includes('/') || name.includes('\\')) return `--alt set name "${name}" may not start with "." or hold a slash`;
819
+ const rest = value.slice(eq + 1);
820
+ const parts = rest.split(':');
821
+ if (parts.length < 2 || parts.length > 3 || parts[0].length === 0 || parts[1].length === 0 || (parts.length === 3 && parts[2].length === 0)) {
822
+ return `--alt "${value}" is not <name>=<source>:<full>[:<head>]`;
823
+ }
824
+ return { name, source: parts[0], full: parts[1], ...(parts.length === 3 ? { head: parts[2] } : {}) };
825
+ }
826
+
812
827
  /**
813
828
  * `layers <path>` prints the table; `layers --export <dir> <path>` writes the
814
829
  * set as the wrapper form and reads it back (issue #359); `layers --template
@@ -1267,6 +1282,7 @@ function cmdLoop(args: string[]): number {
1267
1282
 
1268
1283
  function cmdAssemble(args: string[]): number {
1269
1284
  const flags = new Map<string, string>();
1285
+ const alts: AlternateCliInput[] = [];
1270
1286
  let propose = false;
1271
1287
  for (let i = 0; i < args.length; i++) {
1272
1288
  const flag = args[i];
@@ -1277,13 +1293,21 @@ function cmdAssemble(args: string[]): number {
1277
1293
  if (!takesValued('assemble', flag)) return usage(notTaken('assemble', flag));
1278
1294
  const value = args[i + 1];
1279
1295
  if (value === undefined) return usage(`${flag} needs a value`);
1296
+ if (flag === '--alt') {
1297
+ const parsed = parseAltArg(value);
1298
+ if (typeof parsed === 'string') return usage(parsed);
1299
+ if (alts.some((a) => a.name === parsed.name)) return usage(`--alt set "${parsed.name}" is given twice`);
1300
+ alts.push(parsed);
1301
+ i++;
1302
+ continue;
1303
+ }
1280
1304
  if (flags.has(flag)) return usage(`${flag} is given twice`);
1281
1305
  flags.set(flag, value);
1282
1306
  i++;
1283
1307
  }
1284
1308
  // --head is optional (issue #359): without it the full run is the one run, and it must hold the face.
1285
1309
  for (const f of ['--source', '--full', '--config']) if (!flags.has(f)) return usage(`assemble needs ${f}`);
1286
- if (propose && (flags.has('--out') || flags.has('--seam') || flags.has('--project'))) return usage('--propose-plan prints to the console; it takes none of --out, --seam, --project');
1310
+ if (propose && (flags.has('--out') || flags.has('--seam') || flags.has('--project') || alts.length > 0)) return usage('--propose-plan prints to the console; it takes none of --out, --seam, --project, --alt');
1287
1311
  if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
1288
1312
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1289
1313
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
@@ -1303,7 +1327,7 @@ function cmdAssemble(args: string[]): number {
1303
1327
  }
1304
1328
  const out = flags.get('--out') as string;
1305
1329
  assembleStage(
1306
- { source, full, head, config, seam: seam as SeamRule, project: project as ProjectRule },
1330
+ { source, full, head, config, seam: seam as SeamRule, project: project as ProjectRule, ...(alts.length === 0 ? {} : { alts }) },
1307
1331
  { partsJson: join(out, 'rig', 'parts.json'), partsDir: join(out, 'rig', 'parts'), recomposite: join(out, 'render', 'recomposite_rig.png'), errorMap: join(out, 'render', ERROR_MAP_FILE) },
1308
1332
  console.log,
1309
1333
  );
@@ -1448,6 +1472,7 @@ function cmdInputs(args: string[]): number {
1448
1472
 
1449
1473
  function cmdBuild(args: string[]): number {
1450
1474
  const flags = new Map<string, string>();
1475
+ const alts: AlternateCliInput[] = [];
1451
1476
  // The bare switches build takes: --loop its own, --seam-pairs and --keep-stills check's, forwarded (issue #257).
1452
1477
  const on = new Set<string>();
1453
1478
  const switches: readonly string[] = TAKES.build.switches;
@@ -1464,6 +1489,14 @@ function cmdBuild(args: string[]): number {
1464
1489
  if (!takesValued('build', flag)) return usage(notTaken('build', flag));
1465
1490
  const value = args[i + 1];
1466
1491
  if (value === undefined) return usage(`${flag} needs a value`);
1492
+ if (flag === '--alt') {
1493
+ const parsed = parseAltArg(value);
1494
+ if (typeof parsed === 'string') return usage(parsed);
1495
+ if (alts.some((a) => a.name === parsed.name)) return usage(`--alt set "${parsed.name}" is given twice`);
1496
+ alts.push(parsed);
1497
+ i++;
1498
+ continue;
1499
+ }
1467
1500
  if (flags.has(flag)) return usage(`${flag} is given twice`);
1468
1501
  flags.set(flag, value);
1469
1502
  i++;
@@ -1495,7 +1528,7 @@ function cmdBuild(args: string[]): number {
1495
1528
  const scratch = mkdtempSync(join(tmpdir(), 'rig-parts-build-'));
1496
1529
  try {
1497
1530
  const r = build(
1498
- { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges, packShape: shape, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), idleKeys: keys, ...(leadText === undefined ? {} : { leadIn: Number(leadText) }), seamPairs: on.has('--seam-pairs'), keepStills: on.has('--keep-stills') },
1531
+ { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges, packShape: shape, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), idleKeys: keys, ...(leadText === undefined ? {} : { leadIn: Number(leadText) }), seamPairs: on.has('--seam-pairs'), keepStills: on.has('--keep-stills'), ...(alts.length === 0 ? {} : { alts }) },
1499
1532
  { rig: rigcRunner(bin), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
1500
1533
  console.log,
1501
1534
  );
package/docs/AUTHORING.md CHANGED
@@ -1596,7 +1596,7 @@ What each rule drafts (`src/cloth.ts` holds them):
1596
1596
  | rule | drafts | from |
1597
1597
  | --- | --- | --- |
1598
1598
  | where | `bottomwear`: from `hip`, the root row 20 px below hip's y (the keyed chains' row), the tip row 4 px above the cloth's own bottom under each column (4 px above the part box's bottom when every column reaches the bottom band, or 4 px above where the cloth's alpha ends under that column on a floor-spread skirt whose middle hem ends above bare thighs; a column with no cloth below the root row gets no tree drafted there and emits a note saying so, issue #438). `back hair` that reaches below the shoulders: from `head`, per side of the eye axis (twin tails are a side each), the eye line to 4 px above its bottom. `front hair`: each strand that hangs below the chin (the lock rule's), from `head`, 0.22 face heights above the eye line to 5 px below the strand; the fringe's three columns keep their two links and their places | the keyed chains' own rows, which these replace |
1599
- | trees | tree k of `chains` owns the cell k/N .. (k+1)/N of the art's width in every row, and its links sit at the cell's centre in the 24-row band there, so a piece that widens fans its trees. On `back hair`, `chains` is optional (issues #390, #445): when omitted, `propose --cloth` derives the tree count on each side from the side's **visible hair masses** — connected components of the `back hair` part's alpha below the eye-line root row (after skipping any undivided crown rows that bridge continuously across the eye axis), merged when they touch within one mesh grid cell — drafting one tree per mass, rooted at that mass's own top row and with every link inside that mass's column range; `propose`'s `LINT` names the masses it found and their columns when a side's tree count or link leaves them. An explicit `chains` that exceeds what the narrower side's opaque width at the eye-line root row holds (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`) or places a root off the 15 px dilated art is refused (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`). With `split`, the trunk's tip is the fork, and branch b of `into` follows the centre of its share of the cell. Chains are named `<prefix><k>_` (trunk) and `<prefix><k><a, b, …>_` (branches): `skirt`, `hairback_r`/`hairback_l`, `lockt`. With `split.hold_tip: true` (issues #403, #392), sibling branches keep `strength.branch` across the fork, the mesh's `r` is raised to `grid`, and the rig stage blends each fork's sibling branch weight shares across four passes of 1-ring mesh-edge averaging (self weight 0.5, floor 0.005, cap 8 influences) with trunk and fixed shares held in place, holding the triangles where two branches' cells meet | `chains`, `links`, `split` |
1599
+ | trees | tree k of `chains` owns the cell k/N .. (k+1)/N of the art's width in every row, and its links sit at the cell's centre in the 24-row band there, so a piece that widens fans its trees. On `back hair`, `chains` is optional (issues #390, #445, #461): when omitted, `propose --cloth` derives the tree count on each side from the side's **visible hair masses** — connected components of the `back hair` part's alpha below the eye-line root row (after skipping any undivided crown rows that bridge continuously across the eye axis), merged when they touch within one mesh grid cell — placing `max(1, floor(w / (4 * grid)) + 1)` trees inside each mass across its root-row opaque width `w` (one tree per `4 * grid` of horizontal reach, where a tree's segment weight `1 / (d + r)^2` with `r ≈ 0.42 * grid` drops to `MIN_WEIGHT = 0.03` at `2 * grid` on each side of the trunk), rooted at that mass's own top row and with every link inside that mass's column range; `propose`'s `LINT` names the masses it found and their columns when a side's tree count or link leaves them. An explicit `chains` that exceeds what the narrower side's opaque width at the eye-line root row holds (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`) or places a root off the 15 px dilated art is refused (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`). With `split`, the trunk's tip is the fork, and branch b of `into` follows the centre of its share of the cell. Chains are named `<prefix><k>_` (trunk) and `<prefix><k><a, b, …>_` (branches): `skirt`, `hairback_r`/`hairback_l`, `lockt`. With `split.hold_tip: true` (issues #403, #392), sibling branches keep `strength.branch` across the fork, the mesh's `r` is raised to `grid`, and the rig stage blends each fork's sibling branch weight shares across four passes of 1-ring mesh-edge averaging (self weight 0.5, floor 0.005, cap 8 influences) with trunk and fixed shares held in place, holding the triangles where two branches' cells meet | `chains`, `links`, `split` |
1600
1600
  | lengths | link j's length is the first's times q^j, q = `length_ratio`^(1/(links − 1)), the whole spanning root row to tip row: the links shorten toward the tip | `length_ratio` (default 0.5, below) |
1601
1601
  | stiffness | a trunk link takes `strength.trunk`, a branch link `strength.branch`, and the edge — the outermost branch on each outer side of the piece (both sides of a skirt or a strand; the outer side of each back-hair side; or, under `split.hold_tip: true` with `strength` left out, kept at `strength.branch` so a fork's sibling branches do not pull apart at the default 2:1 stiffness), or without `split` the outermost tree — `strength.edge`; a fringe column is a trunk, its outer two the edge. **Strength does not fall along a chain**: a tip reads soft because each link turns on top of every link above it, so the motion accumulates down the chain at one strength. Only the edge is declared softer | `strength` (default trunk 100, branch 100, edge 50, below) |
1602
1602
  | response | link j of an n-link path takes `wind` and `gravity` at j/(n − 1) of the way from the root value to the tip value; a single-link path takes the tip value. Each is a scalar **multiplier** of the environment the consumer sets (spine-core's skeleton `windX, windY`, `gravityX, gravityY`; rig-c 2.52.0's live track `setEnvironment`, rig-play 1.10.0's `environment()`): no direction is drafted | `wind`, `gravity` |
@@ -2140,7 +2140,7 @@ move off it (`AS245`).
2140
2140
  | `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*) |
2141
2141
  | `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 |
2142
2142
  | `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); 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: "disc"` also from the upper segment, 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) |
2143
- | `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 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 or whose link or tip leaves its mass's column range (issue #445): omit `chains` on `cloth."back hair"` or draft one tree 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 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 |
2143
+ | `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 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 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 |
2144
2144
  | `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 |
2145
2145
  | `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` |
2146
2146
  | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:` (and `loop physics:` when the rig declares a physics constraint, §7), `seam:`, the six judgement lines, then — when `motion.json` holds an animation, the idle included — `ANIMATIONS_DO_NOT_FOLD` and one `folds` line per folding mesh, rigc's own (issues #320, #333), then — where a `footwear` part rides a bone that hangs from another — `FEET_STAY_LEVEL` and one `foot` line per such part (issue #348), then — when `parts.json` names a `handwear` part and there is an idle — `STRESS_OFF_CHAIN` (issue #347), then — where `parts.json` holds a limb segment's part — `LIMB_ONE_BONE` and one `limb` line per such part (issue #349), then — where a limb record carries a `range` — `SEAM_OVER_RANGE` and one `joint` line per joint and angle (issue #360), `RECOMPOSITE_HOLES` and, under `--source`, `SETUP_POSE_VS_SOURCE` (§7), `check.json`; the last line says how many of the bars measured — nine, ten with the fold bar, one more each with the feet-level bar, the stress bar, the limb bar and the range bar — and names the ones that said SKIP | `check: PASS; 11 of 11 bar(s) measured, 0 skipped` on `demo` and `scarf`, whose sleeves each ride one chain; `check: PASS; 10 of 11 bar(s) measured, 1 skipped (STRESS_OFF_CHAIN)` on `reference-demo` and `reference-sample`, whose one `sleeves` mesh rides both sleeve chains (issue #347) — no example writes `FEET_STAY_LEVEL`, every shoe riding `root`; a judgement line SKIP only where the character lacks what it reads; on a rig spec with no `parts.json` or no `idle` (a merged rig, §7 *Measuring a rig rig-parts did not assemble*), PASS with the skipped bars named | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6); `CHECK_ANIMATIONS_DO_NOT_FOLD` — a clip turns a mesh over (§6); `CHECK_STRESS_OFF_CHAIN` — a sleeve weighted off its chain (§6); `CHECK_LIMB_ONE_BONE` — a limb part on a second bone (§6); `CHECK_SEAM_OVER_RANGE` — a joint that opens when bent (§6) |
@@ -2727,6 +2727,8 @@ says why each requirement exists and what breaks without it.
2727
2727
  | <a id="ASSEMBLE_LIMB_CUT_BAND"></a>`ASSEMBLE_LIMB_CUT_BAND` | a segment's `cut_band` reaches past the limb: a line of its band, the detail names the one nearest the joint and a point it passes through, crosses none of the limb's art within 1 px of that point, so a cut placed there would cut nothing (issue #360) | `cut_band`: the widest band whose every line crosses the limb, which the detail gives |
2728
2728
  | <a id="ASSEMBLE_LIMB_CAP_PRESENT"></a><a id="ASSEMBLE_LIMB_CAP_DECODES"></a><a id="ASSEMBLE_LIMB_CAP_SIZE"></a><a id="ASSEMBLE_LIMB_CAP_EMPTY"></a>`ASSEMBLE_LIMB_CAP_PRESENT`, `ASSEMBLE_LIMB_CAP_DECODES`, `ASSEMBLE_LIMB_CAP_SIZE`, `ASSEMBLE_LIMB_CAP_EMPTY` | a segment's `cap` names no file (relative to the config's directory) or one that is not a PNG; an image not the size of the cap's box (the detail gives both, and the box's corner); or one transparent over every cap pixel (issue #349) | the image: the box the `limb cap:` line prints, `w`x`h`, its pixel `(i, j)` rig pixel `(x + i, y + j)`; to keep the layer's cap, drop `cap` |
2729
2729
  | <a id="ASSEMBLE_HIDDEN_ONE_SOURCE"></a>`ASSEMBLE_HIDDEN_ONE_SOURCE` | a cut's `under`, or a limb segment's `cap`, names a file, and the part it reads also has a hidden-art layer (`<layer>:hidden`, issue #359): reading either would be a guess | remove the file from the config, or the hidden-art layer from the input |
2730
+ | <a id="ASSEMBLE_ALT_INPUT_PRESENT"></a>`ASSEMBLE_ALT_INPUT_PRESENT` | an alternate painting set (`assemble.alternates` or `--alt`, issue #454) is missing `source` or `full`, names a file or directory that is not on disk, or lacks a covered part's layer in its runs | give `source` and `full` on the alternate set, or list an occluded part in `hides` |
2731
+ | <a id="ASSEMBLE_ALT_CANVAS_SIZE"></a>`ASSEMBLE_ALT_CANVAS_SIZE` | an alternate painting's canvas size differs from the primary painting's (issue #454; the detail gives both sizes) | the alternate painting: the primary painting's canvas size, so its parts land in the same rig frame |
2730
2732
  | <a id="ASSEMBLE_COUNTS_ADD_UP"></a>`ASSEMBLE_COUNTS_ADD_UP` | a part's visible and occluded counts do not add up to its opaque pixels, or a projected pixel is not visible, or a cut part's pieces do not add up to it — an assembler bug, not an input problem | report it with the part named; nothing was written |
2731
2733
 
2732
2734
  ### propose
@@ -2740,7 +2742,7 @@ says why each requirement exists and what breaks without it.
2740
2742
  | <a id="PROPOSE_LIMB_PARTS"></a>`PROPOSE_LIMB_PARTS` | `parts.json` holds a standalone or incomplete limb part `config.limbs` cut at its joints (issues #349, #440) that cannot be reassembled into the source part `of` (a missing segment or `cap` record, or `of` still present beside the limb parts); a complete `assemble` output with `config.limbs` reconstructs its pre-limb parts in the same run | `--parts` a complete `assemble` output (every segment of each limb, with its `cap` record) or a build assembled with `config.limbs` left out; `propose --from-config` reads the config's bones on either build |
2741
2743
  | <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` |
2742
2744
  | <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 |
2743
- | <a id="PROPOSE_CLOTH_BACK_HAIR_CHAINS"></a>`PROPOSE_CLOTH_BACK_HAIR_CHAINS` | with `--cloth` (issues #390, #445): `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 one tree per visible hair mass on each side |
2745
+ | <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 |
2744
2746
  | <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 |
2745
2747
  | <a id="PROPOSE_TURN_RUN_GIVEN"></a><a id="PROPOSE_TURN_LAYER_PRESENT"></a><a id="PROPOSE_TURN_DEPTH_PRESENT"></a>`PROPOSE_TURN_RUN_GIVEN`, `PROPOSE_TURN_LAYER_PRESENT`, `PROPOSE_TURN_DEPTH_PRESENT` | with `--full`/`--head` (issue #251): a part came from a run whose layers were not given, the run given holds no layer of the part's tag, or its layer carries no `depth_median` while another part's layer carries one; a run with no depth on any part's layer (a PSD, or an export of one) is not refused — the turn is skipped, and the note says `turn: SKIP` and names the run (issue #359) | give the run the parts were assembled from, as the wrapper's `layers.json` |
2746
2748
  | <a id="BASIS_RECORDS_EVERY_BONE"></a><a id="BASIS_NAMES_RESOLVE"></a><a id="BASIS_KIND_KNOWN"></a><a id="BASIS_KIND_HOLDS"></a>`BASIS_RECORDS_EVERY_BONE`, `BASIS_NAMES_RESOLVE`, `BASIS_KIND_KNOWN`, `BASIS_KIND_HOLDS` | `propose --keypoints`'s record of how it placed each bone, `basis.json`, does not hold to the proposal it was written with: a proposed bone with no record, one recorded twice or out of the proposal's order; a part, bone or frame quantity it names that is not there; a placement kind outside the known ones; or a placement that does not hold what its kind says (a joint placement with no joint, a non-joint one with a joint, a derived one with no bone, a constant that is not finite). Nothing is written | a proposer bug, not an input problem: report it with the line |
package/docs/STABILITY.md CHANGED
@@ -51,14 +51,14 @@ defaults are the help's too, and none is guessed from the art.
51
51
  | `inputs` | `--source`, `--config`, `--out` |
52
52
  | `comfy` | `paint`: `--config`, `--out`; optional `--seeds`, `--seed0`, `--host`, `--wait`, `--timeout`, `--poll` |
53
53
  | `comfy` | `seethrough`: `--image`, `--out`; optional `--resolution`, `--steps`, `--seed`, `--prefix`, `--offload`, `--lama`, `--nf4`, `--host`, `--wait`, `--timeout`, `--poll` |
54
- | `assemble` | `--propose-plan` with `--source`, `--full`, `--config`, optional `--head`; or `--source`, `--full`, `--config`, `--out`, optional `--head` (without it, one run: issue #359), `--seam` (near-white, silhouette), `--project` (core, visible) |
54
+ | `assemble` | `--propose-plan` with `--source`, `--full`, `--config`, optional `--head`; or `--source`, `--full`, `--config`, `--out`, optional `--head` (without it, one run: issue #359), `--seam` (near-white, silhouette), `--project` (core, visible), `--alt` (repeatable `<name>=<source>:<full>[:<head>]`, issue #454) |
55
55
  | `propose` | `--head-box` with `--full`, `--canvas`; optional `--no-back-hair` |
56
56
  | `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`), `--full`, `--head` (the turn control's depths; not with `--from-config`), `--grid-rule` (`figure` or `part`, the drafted lattice's spacing; not with `--from-config`), `--cloth` (a config whose `cloth` block is the recipe, issue #364; not with `--from-config`) |
57
57
  | `compare` | `--left`, `--right`; optional `--map` |
58
58
  | `rig` | `--config`, `--parts`, `--out`; optional `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
59
59
  | `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements`, `--seam-pairs`, `--keep-stills` |
60
60
  | `loop` | `--frames`, `--out`; optional `--palette` |
61
- | `build` | `--config`, `--source`, `--full`, `--out`; optional `--head` (as for `assemble`), `--seam`, `--project`, `--page-edges`, `--pack-shape`, `--loop`, `--lead-in`, `--requirements`, `--idle-keys`, `--seam-pairs`, `--keep-stills` |
61
+ | `build` | `--config`, `--source`, `--full`, `--out`; optional `--head` (as for `assemble`), `--seam`, `--project`, `--page-edges`, `--pack-shape`, `--loop`, `--lead-in`, `--requirements`, `--idle-keys`, `--seam-pairs`, `--keep-stills`, `--alt` (as for `assemble`, issue #454) |
62
62
  | `compose` | `--scene`, `--out`; optional `--requirements`, `--accept` |
63
63
  | `mesh-draft` | `--config`, `--parts`, `--out`; optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon), `--more-triangles` |
64
64
  | `painting` | one positional path, the painting; `--keypoints`; optional `--person` |
@@ -120,7 +120,8 @@ part name, `[i]` an entry of a list):
120
120
  - **generation.control.skeleton**, the pose file it names when it is no built-in — required: `width`, `height`, `points`
121
121
  - **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
122
122
  - **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
123
- - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`
123
+ - **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`
124
+ - **assemble.alternates[i]** — required: `name`; optional: `source`, `full`, `head`, `parts`, `hides`, `head_box`, `cuts` (issue #454)
124
125
  - **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
125
126
  - **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
126
127
  - **assemble.patches[i].draw**, when an object — required: `before`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/build.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -71,18 +71,18 @@ import {
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
73
  import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
- import { type Cut, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
74
+ import { type Cut, type Extend, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig, type Patch, type PlanEntry } from './config.ts';
75
75
  import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
77
77
  import { encodeGif } from './gif.ts';
78
78
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
79
79
  import type { PaletteError } from './palette.ts';
80
80
  import { type LayerSet, readLayers } from './layers.ts';
81
- import { type PartRecord, readParts, type SingleRunPad, tagOf, writeParts } from './parts.ts';
81
+ import { type AlternatePartsRecord, type PartRecord, readParts, type RecompositeRecord, type SingleRunPad, tagOf, writeParts } from './parts.ts';
82
82
  import { encodePngBytes, readPng, writePng } from './raster/png.ts';
83
83
  import type { Raster } from './raster/types.ts';
84
84
  import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
85
- import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared, type TurnRow } from './rig.ts';
85
+ import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, SETS_FILE, type StrayCleared, type TurnRow } from './rig.ts';
86
86
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
87
87
  import { TURN_BONE } from './turn.ts';
88
88
  import { pyRound } from './round.ts';
@@ -130,6 +130,14 @@ export function readRuns(full: string, head: string | null): { full: LayerSet; h
130
130
  return { full: f as LayerSet, head: h as LayerSet, single: head === null };
131
131
  }
132
132
 
133
+ /** One `--alt <name>=<source>:<full>[:<head>]` argument (issue #454). */
134
+ export interface AlternateCliInput {
135
+ name: string;
136
+ source: string;
137
+ full: string;
138
+ head?: string;
139
+ }
140
+
133
141
  export interface AssembleStageInput {
134
142
  source: string;
135
143
  full: string;
@@ -138,6 +146,8 @@ export interface AssembleStageInput {
138
146
  config: string;
139
147
  seam: SeamRule;
140
148
  project: ProjectRule;
149
+ /** `--alt` entries, merged with `config.assemble.alternates` by set name (issue #454). */
150
+ alts?: readonly AlternateCliInput[];
141
151
  }
142
152
 
143
153
  /** Where the assemble stage writes: `parts.json`, the directory of part PNGs, the recomposite, and its error map. */
@@ -246,6 +256,191 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
246
256
  const under = readUnders(input.config, fields.cuts);
247
257
  const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
248
258
  const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: input.seam, projectRule: input.project });
259
+
260
+ // Issue #454: alternate painting sets, assembled before any file is written.
261
+ const configDir = dirname(resolve(input.config));
262
+ const cliAlts = input.alts ?? [];
263
+ const cfgAlts = cfg.assemble.alternates ?? [];
264
+ const cliByName = new Map<string, AlternateCliInput>();
265
+ for (const a of cliAlts) cliByName.set(a.name, a);
266
+ const mergedAlts: Array<{
267
+ name: string;
268
+ at: string;
269
+ source?: string;
270
+ full?: string;
271
+ head?: string;
272
+ parts?: string[];
273
+ hides?: string[];
274
+ head_box?: [number, number, number, number];
275
+ cuts?: Cut[];
276
+ }> = cfgAlts.map((a, i) => {
277
+ const cli = cliByName.get(a.name);
278
+ if (cli !== undefined) cliByName.delete(a.name);
279
+ return {
280
+ name: a.name,
281
+ at: `config.assemble.alternates[${i}] (set "${a.name}")`,
282
+ source: cli?.source ?? (a.source === undefined ? undefined : resolve(configDir, a.source)),
283
+ full: cli?.full ?? (a.full === undefined ? undefined : resolve(configDir, a.full)),
284
+ head: cli !== undefined ? cli.head : a.head === undefined ? undefined : resolve(configDir, a.head),
285
+ parts: a.parts,
286
+ hides: a.hides,
287
+ head_box: a.head_box,
288
+ cuts: a.cuts,
289
+ };
290
+ });
291
+ for (const cli of cliByName.values()) {
292
+ mergedAlts.push({
293
+ name: cli.name,
294
+ at: `--alt ${cli.name}`,
295
+ source: cli.source,
296
+ full: cli.full,
297
+ head: cli.head,
298
+ });
299
+ }
300
+
301
+ const altProblems: Problem[] = [];
302
+ const altBuilt: Array<{ record: AlternatePartsRecord; images: Array<{ name: string; image: Raster }> }> = [];
303
+ const baseSlots = result.parts.parts.map((p) => p.name);
304
+ for (const alt of mergedAlts) {
305
+ if (alt.source === undefined || alt.full === undefined) {
306
+ altProblems.push({
307
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
308
+ object: alt.at,
309
+ detail: 'is missing source or full; declare source and full on the alternate set in config.assemble.alternates, or supply --alt <name>=<source>:<full>[:<head>]',
310
+ });
311
+ continue;
312
+ }
313
+ if (!existsSync(alt.source)) {
314
+ altProblems.push({
315
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
316
+ object: `${alt.source} (alternate set "${alt.name}")`,
317
+ detail: 'no such file; the alternate painting is required',
318
+ });
319
+ continue;
320
+ }
321
+ if (!existsSync(alt.full)) {
322
+ altProblems.push({
323
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
324
+ object: `${alt.full} (alternate set "${alt.name}")`,
325
+ detail: 'no such file or directory; the alternate full run is required',
326
+ });
327
+ continue;
328
+ }
329
+ if (alt.head !== undefined && !existsSync(alt.head)) {
330
+ altProblems.push({
331
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
332
+ object: `${alt.head} (alternate set "${alt.name}")`,
333
+ detail: 'no such file or directory; the alternate head run is required when named',
334
+ });
335
+ continue;
336
+ }
337
+ const altSrc = readSource(alt.source);
338
+ if (altSrc.width !== src.width || altSrc.height !== src.height) {
339
+ altProblems.push({
340
+ code: 'ASSEMBLE_ALT_CANVAS_SIZE',
341
+ object: `${alt.source} (alternate set "${alt.name}")`,
342
+ detail: `is ${altSrc.width}x${altSrc.height}, while the primary painting ${input.source} is ${src.width}x${src.height}; an alternate painting must share the primary painting's canvas size so its parts land in the same rig frame`,
343
+ });
344
+ continue;
345
+ }
346
+ const altRuns = readRuns(alt.full, alt.head ?? null);
347
+ const hidesSet = new Set(alt.hides ?? []);
348
+ const declaredSlots = alt.parts !== undefined ? alt.parts.filter((p) => !hidesSet.has(p)) : baseSlots.filter((p) => !hidesSet.has(p));
349
+ const coveredSet = new Set(declaredSlots);
350
+ const rawCuts = alt.cuts !== undefined ? alt.cuts : fields.cuts.filter((c) => coveredSet.has(c.into) && coveredSet.has(c.from)).map((c) => ({ ...c, under: undefined }));
351
+ const altLimbs: Record<string, Limb> = {};
352
+ if (fields.limbs !== undefined) {
353
+ for (const [ln, limb] of Object.entries(fields.limbs)) {
354
+ if (limb.segments.every((sg) => coveredSet.has(sg.part))) {
355
+ altLimbs[ln] = { ...limb, segments: limb.segments.map((sg) => ({ ...sg, cap: undefined })) };
356
+ }
357
+ }
358
+ }
359
+ const neededPlan = new Set<string>();
360
+ for (const [name] of fields.plan) {
361
+ if (
362
+ coveredSet.has(name) ||
363
+ rawCuts.some((c) => c.from === name) ||
364
+ Object.values(altLimbs).some((l) => l.segments.some((sg) => sg.from === name))
365
+ ) {
366
+ neededPlan.add(name);
367
+ }
368
+ }
369
+ const altPlan: PlanEntry[] = fields.plan
370
+ .filter(([name]) => neededPlan.has(name))
371
+ .map(([name, run, tag]): PlanEntry => (altRuns.single ? [name, 'full', tag] : [name, run, tag]));
372
+ let missingLayer = false;
373
+ for (const [name, run, tag] of altPlan) {
374
+ const l = altRuns[run].layers.find((x) => x.name === tag);
375
+ if (l === undefined || l.opaquePx === 0) {
376
+ altProblems.push({
377
+ code: 'ASSEMBLE_ALT_INPUT_PRESENT',
378
+ object: `alternate set "${alt.name}" (part "${name}")`,
379
+ detail: `takes ${run}:${tag}, which ${l === undefined ? 'is absent' : 'has 0 opaque pixels'} in ${altRuns[run].source}; every covered part of the alternate set must be present in its runs, or listed in hides`,
380
+ });
381
+ missingLayer = true;
382
+ }
383
+ }
384
+ if (missingLayer) continue;
385
+ const altPlanNames = new Set(altPlan.map((e) => e[0]));
386
+ const remapDraw = (d: Patch['draw']): Patch['draw'] => {
387
+ if (typeof d !== 'object') return d;
388
+ if (altPlanNames.has(d.before)) return d;
389
+ const baseIdx = fields.plan.findIndex((e) => e[0] === d.before);
390
+ for (let i = Math.max(0, baseIdx); i < fields.plan.length; i++) {
391
+ if (altPlanNames.has(fields.plan[i][0])) return { before: fields.plan[i][0] };
392
+ }
393
+ return 'front';
394
+ };
395
+ const altExtend: Extend[] = altRuns.single ? [] : fields.extend.filter((e) => altPlanNames.has(e.part));
396
+ const altPatches: Patch[] = fields.patches.filter((p) => coveredSet.has(p.name)).map((p) => ({ ...p, draw: remapDraw(p.draw) }));
397
+ const altCuts: Cut[] = rawCuts.map((c) => ({ ...c, draw: remapDraw(c.draw) }));
398
+ const altUnder = alt.cuts !== undefined ? readUnders(input.config, altCuts) : altCuts.map(() => null);
399
+ const altHeadBox = alt.head_box ?? (altRuns.single ? singleRunHeadBox(altSrc.width, altSrc.height, fields.resolution) : fields.headBox);
400
+ const altRes = assemble({
401
+ source: altSrc,
402
+ full: altRuns.full,
403
+ head: altRuns.head,
404
+ resolution: fields.resolution,
405
+ headBox: altHeadBox,
406
+ rigScale: fields.rigScale,
407
+ plan: altPlan,
408
+ extend: altExtend,
409
+ patches: altPatches,
410
+ cuts: altCuts,
411
+ under: altUnder,
412
+ ...(Object.keys(altLimbs).length > 0 ? { limbs: altLimbs, bones: fields.bones } : {}),
413
+ ...(fields.scene === undefined ? {} : { scene: fields.scene }),
414
+ ...(fields.armSplit !== undefined && altPlan.some((e) => e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l')) ? { armSplit: fields.armSplit } : {}),
415
+ seamRule: input.seam,
416
+ projectRule: input.project,
417
+ });
418
+ const keptImages = altRes.images
419
+ .filter(({ record }) => coveredSet.has(record.name))
420
+ .map(({ record, image }) => ({ name: `${alt.name}_${record.name}`, image }));
421
+ const keptRecords = altRes.parts.parts.filter((p) => coveredSet.has(p.name));
422
+ const slots: Record<string, string> = {};
423
+ for (const r of keptRecords) slots[r.name] = `${alt.name}_${r.name}`;
424
+ altBuilt.push({
425
+ record: {
426
+ name: alt.name,
427
+ ...(alt.parts === undefined ? {} : { parts: [...alt.parts] }),
428
+ ...(alt.hides === undefined || alt.hides.length === 0 ? {} : { hides: [...alt.hides] }),
429
+ slots,
430
+ records: keptRecords,
431
+ ghost_px: altRes.parts.ghost_px,
432
+ recomposite: altRes.parts.recomposite as RecompositeRecord,
433
+ ...(altRes.parts.scene === undefined ? {} : { scene: altRes.parts.scene }),
434
+ ...(altRes.parts.single_run_pad === undefined ? {} : { single_run_pad: altRes.parts.single_run_pad }),
435
+ },
436
+ images: keptImages,
437
+ });
438
+ }
439
+ refuseIfAny(altProblems);
440
+ if (altBuilt.length > 0) {
441
+ result.parts.alternates = altBuilt.map((b) => b.record);
442
+ }
443
+
249
444
  // Emit only after green: every refusal above has already thrown.
250
445
  mkdirSync(outs.partsDir, { recursive: true });
251
446
  mkdirSync(dirname(outs.partsJson), { recursive: true });
@@ -253,6 +448,9 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
253
448
  mkdirSync(dirname(outs.errorMap), { recursive: true });
254
449
  writeParts(outs.partsJson, result.parts);
255
450
  for (const { record, image } of result.images) writePng(join(outs.partsDir, `${record.name}.png`), image);
451
+ for (const b of altBuilt) {
452
+ for (const { name, image } of b.images) writePng(join(outs.partsDir, `${name}.png`), image);
453
+ }
256
454
  writePng(outs.recomposite, result.recomposite);
257
455
  writePng(outs.errorMap, result.errorMap);
258
456
  // Issue #242: under config.scene, the figure the recomposite was read inside, beside the recomposite.
@@ -820,11 +1018,19 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
820
1018
  const png = join(input.parts, 'parts', `${p.name}.png`);
821
1019
  if (existsSync(png)) images.set(p.name, readPng(png));
822
1020
  }
1021
+ for (const alt of parts.alternates ?? []) {
1022
+ for (const p of alt.records) {
1023
+ const attName = `${alt.name}_${p.name}`;
1024
+ const png = join(input.parts, 'parts', `${attName}.png`);
1025
+ if (existsSync(png)) images.set(attName, readPng(png));
1026
+ }
1027
+ }
823
1028
  const first = buildRig(cfg, parts, images, undefined, input.idleKeys ?? DEFAULT_IDLE_KEYS, input.reduce);
824
1029
  const textsOf = (r: RigOutput): Array<[string, string]> => [
825
1030
  ['rig.json', rigJsonText(r.rig)],
826
1031
  ['motion.json', rigJsonText(withAnimations(r.motion, from === null ? null : from.table))],
827
1032
  ['mesh_report.json', rigJsonText(r.meshReport)],
1033
+ ...(r.sets === undefined ? [] : [[SETS_FILE, rigJsonText(r.sets)] as [string, string]]),
828
1034
  ];
829
1035
  const firstTexts = textsOf(first);
830
1036
  // The gate runs before the lines are printed, and its lines print where they always did: an automatic part's motion
@@ -855,7 +1061,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
855
1061
  const replays = new Map<string, ReplayRow | MultiIntervalRow>();
856
1062
  if (settled) {
857
1063
  const swapped = JSON.parse(rigJsonText(first.rig)) as RigSpec;
858
- for (const k of chosen) swapped.skins.default[k.part] = { [k.part]: k.candidate.attachment };
1064
+ for (const k of chosen) swapped.skins.default[k.part] = { ...swapped.skins.default[k.part], [k.part]: k.candidate.attachment };
859
1065
  rig = { ...first, rig: swapped, meshReport: first.meshReport.map((m) => chosen.find((k) => k.part === m.part)?.candidate.row ?? m) };
860
1066
  texts = textsOf(rig);
861
1067
  let model: string | null = null;
@@ -1499,6 +1705,8 @@ export interface BuildInput {
1499
1705
  seamPairs?: boolean;
1500
1706
  /** `--keep-stills`, forwarded to the check as `check` takes it (issue #257): the setup-pose still under `check/setup_still/`; absent, nothing moves. */
1501
1707
  keepStills?: boolean;
1708
+ /** `--alt`, forwarded to the assemble stage as `assemble` takes it (issue #454). */
1709
+ alts?: readonly AlternateCliInput[];
1502
1710
  }
1503
1711
 
1504
1712
  /** What `build` does with one flag `check` takes: hands it to its check stage, takes it as its own, or refuses it because build supplies the value itself. */
@@ -1617,7 +1825,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1617
1825
  // The same for the requirements file's own shape; its names resolve against the rig, in the check, before it builds.
1618
1826
  if (input.requirements !== undefined) readRequirements(input.requirements);
1619
1827
  assembleStage(
1620
- { source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project },
1828
+ { source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project, ...(input.alts === undefined ? {} : { alts: input.alts }) },
1621
1829
  { partsJson: join(out, 'parts.json'), partsDir: join(out, 'parts'), recomposite: join(out, 'recomposite_rig.png'), errorMap: join(out, ERROR_MAP_FILE) },
1622
1830
  prefixed('assemble'),
1623
1831
  );
package/src/check.ts CHANGED
@@ -2569,7 +2569,7 @@ export function stressPlan(rig: Record<string, unknown>, motion: Record<string,
2569
2569
  continue;
2570
2570
  }
2571
2571
  const onSlot = skin[p.name];
2572
- const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
2572
+ const atts: unknown[] = isRecord(onSlot) ? (isRecord(onSlot[p.name]) ? [onSlot[p.name]] : Object.values(onSlot)) : [];
2573
2573
  const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
2574
2574
  if (weighted.length === 0) {
2575
2575
  notRead.push({ part: p.name, reason: 'it is not a weighted mesh, so it rides one bone whole and cannot stretch' });
@@ -4252,7 +4252,7 @@ function offChainClause(rig: Record<string, unknown>, motion: Record<string, unk
4252
4252
  const skins = isRecord(rig.skins) ? rig.skins : {};
4253
4253
  const skin = isRecord(skins.default) ? skins.default : {};
4254
4254
  const onSlot = skin[part];
4255
- const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
4255
+ const atts: unknown[] = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
4256
4256
  const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
4257
4257
  if (weighted.length === 0) return null;
4258
4258
  const { found } = chainsCarrying(rig, motion, part);
@@ -4295,7 +4295,8 @@ function boundBones(rig: Record<string, unknown>, part: string): Set<string> {
4295
4295
  const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
4296
4296
  const skins = isRecord(rig.skins) ? rig.skins : {};
4297
4297
  const skin = isRecord(skins.default) ? skins.default : {};
4298
- const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
4298
+ const onSlot = skin[part];
4299
+ const atts = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
4299
4300
  const bound = new Set<string>();
4300
4301
  for (const a of atts) {
4301
4302
  if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
@@ -4345,7 +4346,8 @@ function breathCarrier(
4345
4346
  // The one weighted mesh the part draws, its weights in the order of the geometry's vertices.
4346
4347
  const skins = isRecord(rig.skins) ? rig.skins : {};
4347
4348
  const skin = isRecord(skins.default) ? skins.default : {};
4348
- const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
4349
+ const onSlot = skin[part];
4350
+ const atts = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
4349
4351
  const first = m.shown[0];
4350
4352
  if (atts.length !== 1 || first === null || first === undefined) return null;
4351
4353
  const att = atts[0];