@rig-c/parts 1.16.0 → 1.18.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
@@ -75,8 +75,13 @@ this port to.
75
75
 
76
76
  ## What it takes in
77
77
 
78
- - **One painting** — a PNG of one character, front-facing, full body, taller than
79
- wide.
78
+ - **One painting** — a PNG of one character that a rig can be cut from: front view,
79
+ arms clear of the body, hands apart and open, legs apart with a gap, on a plain
80
+ light background. [docs/PAINTING.md](docs/PAINTING.md) says what each requirement
81
+ is for and what breaks without it, gives one generation recipe, and describes
82
+ `rig-parts painting`, which checks a painting against its body-18 joints before
83
+ See-through runs. To set up the machine that makes the painting and the layers,
84
+ see [rig-c/getting-started](https://github.com/rig-c/getting-started).
80
85
  - **Its See-through layers, twice**: one run on the whole figure (the painting padded
81
86
  white to a square) and one on a square crop around the head, because the eyes of a
82
87
  full-figure run are too small to separate. Either form See-through reaches you in
@@ -436,7 +441,7 @@ third-party term this package touches, See-through's included.
436
441
  bun install
437
442
  bun run typecheck # tsc --noEmit, strict
438
443
  bun run lint # one rule: no explicit any
439
- bun run fetch-examples # the public examples' paintings and layers, into examples/*/inputs (needs a network)
444
+ bun run fetch-examples # the public examples' paintings and layers, into examples/*/inputs (needs a network); each file held to its blob id at the pinned commit, a stale one replaced by name
440
445
  bun run selftest # every gate's controls; with the examples fetched, build on each of them against its expected/
441
446
  bun run smoke # pack, install into an empty directory, run from the install (needs a network)
442
447
  ```
package/cli.ts CHANGED
@@ -15,7 +15,7 @@ import { spawnSync } from 'node:child_process';
15
15
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { basename, dirname, extname, join } from 'node:path';
18
- import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
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
19
  import { 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';
@@ -25,11 +25,12 @@ import { GUIDE_DOC, type GuideAnswer, guideRule, guideSection } from './src/guid
25
25
  import { proposeHeadBox } from './src/headbox.ts';
26
26
  import { makeInputs } from './src/inputs.ts';
27
27
  import { idleKeysOf, isIdleKeys } from './src/rig.ts';
28
- import { figuresPhrase, implausibleRules, layerFigures, type LayerSet, pct, readLayers, ruleSummary, times } from './src/layers.ts';
28
+ import { depthSkipLine, exportLayers, figuresPhrase, hiddenLayerLine, implausibleRules, layerFigures, type LayerSet, layerSetDiff, pct, readLayers, readWrapperLayers, ruleSummary, templateLayerNames, templatePsdBytes, times } from './src/layers.ts';
29
29
  import { checkImageSize, choosePerson, KEYPOINTS_SPEC, loadKeypoints, type RigJoints, toRigJoints } from './src/keypoints.ts';
30
30
  import { basisLines, checkProposal, compare, compareLines, DEFAULT_GRID_RULE, draftTurn, drawLandmarks, FIGURE_GRID_DIVISOR, FRINGE_STEP_PX, GRID_RULES, type GridRule, HIP_MIN_FRACTION, lint, lintLine, type PartSet, proposeWithBasis, readPartSet, serializeProposal, withTurn, WRIST_ON_PART_PX } from './src/propose.ts';
31
31
  import { BASIS_FILE, BASIS_SPEC, basisFile, basisLine, coverageLines, serializeBasis } from './src/diagnostics.ts';
32
32
  import { BEND_DEGREES, BEND_WORST_SHARE, DUST_MAX_PX, DUST_MAX_SHARE, IDLE_BOUND_PX, MESH_DRAFT_REPORT, MESH_DRAFT_SPEC, meshDraftStage, MORE_TRIANGLES_FLAG, TOLERANCE_FIGURE_DIVISOR } from './src/meshdraft.ts';
33
+ import { checkPainting } from './src/paintcheck.ts';
33
34
  import { readPng, writePng } from './src/raster/png.ts';
34
35
  import { REQUIREMENTS_SENTENCE } from './src/requirements.ts';
35
36
  import { composeStage, IMAGE_SEP, PLATE, PROVENANCES, SCENE_REPORT_FILE, SCENE_REPORT_SPEC, SCENE_SPEC } from './src/scene.ts';
@@ -67,10 +68,32 @@ usage:
67
68
  crosses a plausibility rule gets a WARN line naming the rule and the bar;
68
69
  --propose-plan leaves it out. A layer named author:<label> is one the
69
70
  author made by hand: its group is author, and a line says it has no
70
- role. Refuses, by name, a missing file, an unknown tag, a PNG whose
71
+ role. A layer named <layer>:hidden (issue #359) is that layer's art
72
+ where something covers it: listed apart, never drawn, never a part. An
73
+ input with no depth (a PSD, or depth_median null on every layer, whose
74
+ manifest order is then the draw order) prints one SKIP depth line.
75
+ Refuses, by name, a missing file, an unknown tag, a PNG whose
71
76
  size is not its box, and anything else outside the input contract —
72
77
  never a WARN.
73
78
 
79
+ rig-parts layers --export <dir> <dir | layers.json | file.psd>
80
+ Print the table, then write the input as the wrapper form into <dir>
81
+ (absent or empty, LAYERS_EXPORT_DIR_EMPTY): layers.json and one RGBA PNG
82
+ per layer, <index>_<name>.png, drawn layers in draw order then the
83
+ hidden-art layers, depth_median as read (null for a PSD). The export is
84
+ read back at once and must hold the same layers, boxes, pixels and order
85
+ (LAYERS_EXPORT_ROUND_TRIP). Correct the parse there with any image
86
+ library and hand <dir> to any command that reads layers
87
+ (docs/AUTHORING.md, the correction loop).
88
+
89
+ rig-parts layers --template <out.psd> --canvas <W>x<H>
90
+ Write a PSD for an artist: one empty layer per tag name the rig reads,
91
+ bottom first (back hair, the body, the face, front hair; the six split
92
+ tags as their -r and -l halves; no head). The same canvas writes the
93
+ same bytes. Read back, every layer is refused as empty
94
+ (PSD_LAYER_HAS_PIXELS) until painted: paint the ones the figure has,
95
+ delete the rest.
96
+
74
97
  rig-parts sheet --source <painting.png> --layers <path> [--layers <path> ...]
75
98
  --out <sheet.png> [--cell <px>] [--cols <n>]
76
99
  A contact sheet: the painting, then every layer or part, each on a
@@ -137,7 +160,11 @@ usage:
137
160
  hip (the midpoint of r_hip and l_hip), the chest half way along neck ->
138
161
  hip, and each sleeve chain shoulder -> elbow with its tip at the wrist
139
162
  when every handwear part holds a wrist; a missing joint leaves its bone to
140
- the rule. One note per joint says which. LINT then reads the torso along
163
+ the rule. One note per joint says which. Each arm and leg so placed is a
164
+ limb (issue #349): proposal.json's "limbs" cuts its layer at the elbow or
165
+ the knee into parts rigid on its links (and, when every ankle lies on a
166
+ footwear part, a foot on a bone of its own at the ankle that keeps its
167
+ setup angle); assemble with the block makes the cut. LINT then reads the torso along
141
168
  the neck -> hips line and each sleeve chain along shoulder -> wrist
142
169
  instead of by screen y, and prints the rule set it used first. Without
143
170
  --keypoints nothing changes. A wrist holds a part when it lies within
@@ -351,9 +378,13 @@ usage:
351
378
  holds an animation, ANIMATIONS_DO_NOT_FOLD reads each, the idle included,
352
379
  with rigc render --folds, physics constraints left out: a mesh triangle turned
353
380
  over against the setup pose fails, unless the --requirements file's
354
- may_fold declares that slot and animation with a why. The last line says
355
- how many of the bars measured — nine, ten with the fold bar — and names
356
- the skipped ones.
381
+ may_fold declares that slot and animation with a why. Where a footwear
382
+ part rides a bone that hangs from another, FEET_STAY_LEVEL reads the
383
+ same render's geometry: while a bone above that bone is turned, the
384
+ part's drawing must stay within ±10 degrees of its setup pose (HQ's
385
+ line); config.bones' "inherit" on the foot is what keeps it there. The
386
+ last line says how many of the bars measured — nine, ten with the fold
387
+ bar, eleven with the feet-level bar — and names the skipped ones.
357
388
  --source adds SETUP_POSE_VS_SOURCE: REPORTED, never a FAIL — the setup
358
389
  pose against the painting by assemble's own recomposite figures (mean |d|,
359
390
  % within 8, error px over 40, uncovered error px where the pose has alpha
@@ -417,7 +448,7 @@ usage:
417
448
  onto frame 0 itself. Animated WebP is not written — it needs a VP8/VP8L
418
449
  encoder, out of scope.
419
450
 
420
- rig-parts assemble --source <painting.png> --full <dir|psd> --head <dir|psd>
451
+ rig-parts assemble --source <painting.png> --full <dir|psd> [--head <dir|psd>]
421
452
  --config <config.json> --out <dir> [--seam near-white|silhouette]
422
453
  [--project core|visible]
423
454
  Merge the full-body and head-crop See-through runs into rig-space parts:
@@ -431,11 +462,16 @@ usage:
431
462
  drawn "back", "front" or {"before": <plan part>}, recorded in parts.json
432
463
  as from "painting:<name>" and counted 100 % source — and .cuts — a plan
433
464
  part's pixels inside a rig-pixel polygon taken into a new part, drawn the
434
- same way, recorded with the plan part's from — and no rig section:
465
+ same way, recorded with the plan part's from — and .limbs (issue #349),
466
+ with the bones they name: each limb's layer cut at its joints into one
467
+ part per segment, each lower part carrying a cap past its joint, the
468
+ upper drawn over the lower — and no other rig section:
435
469
  bones, meshes, regions and motion need not exist yet, because propose
436
470
  drafts them from these parts (AUTHORING §4, the table).
437
471
  Prints one line per part, one \`cut:\` line per part a cut split (its
438
- opaque pixels = what it kept + what each cut took), the seam override
472
+ opaque pixels = what it kept + what each cut took), one \`limb cut:\`
473
+ line per part a limb took and one \`limb cap:\` line per joint (the cap's
474
+ pixels from the layer and missing, and the box to paint them into), the seam override
439
475
  counts, the \`pixels:\` totals
440
476
  (opaque = visible + occluded; taken from the painting; visible but not
441
477
  projected), and \`recomposite vs source\` (mean |d| and % within 8 over the
@@ -446,7 +482,15 @@ usage:
446
482
  and one \`uncovered:\` line per full-run layer the plan does not take from
447
483
  the full run whose uncovered error holds a body (5x5-opened, 8-connected,
448
484
  at least 150 full-run px in rig px), naming the layer, its pixels and the
449
- rule that left it out.
485
+ rule that left it out. One \`hidden art:\` line per hidden-art layer
486
+ (<layer>:hidden, issue #359) names what read it: the under of a cut of
487
+ its layer's part, or the cap of a limb segment below a joint that takes
488
+ that part, when neither names a file (both: ASSEMBLE_HIDDEN_ONE_SOURCE).
489
+ With no --head, the full run is the one run (an artist's own file): it
490
+ must hold face, eyewhite-r and eyewhite-l, painted
491
+ (ASSEMBLE_SINGLE_RUN_FACE), every plan row takes "full" and none
492
+ extends below a crop (ASSEMBLE_SINGLE_RUN_PLAN), and the config sets no
493
+ seethrough.head_box (ASSEMBLE_SINGLE_RUN_HEAD_BOX).
450
494
  Writes nothing unless every check passed.
451
495
  --seam defaults to ${DEFAULT_SEAM_RULE}. --project says where a layer takes
452
496
  the painting's pixel: core (the reference's) erodes every layer's top-most
@@ -455,7 +499,7 @@ usage:
455
499
  ${DEFAULT_PROJECT_RULE}.
456
500
 
457
501
  rig-parts assemble --propose-plan --source <painting.png> --full <dir|psd>
458
- --head <dir|psd> --config <config.json>
502
+ [--head <dir|psd>] --config <config.json>
459
503
  Print {plan, extend_below_crop, notes} for config.assemble, from the two
460
504
  runs. A layer \`layers\` WARNs about (PLAN_LAYER_TRANSLUCENT,
461
505
  PLAN_LAYER_BACKGROUND, PLAN_LAYER_OVERSIZED) is not proposed, and a note
@@ -465,7 +509,9 @@ usage:
465
509
  rule that skipped it and the continuation; the plan itself is not
466
510
  changed. Reads only config.seethrough.head_box,
467
511
  config.seethrough.resolution and config.assemble.rig_scale — the rest of
468
- the config need not exist yet.
512
+ the config need not exist yet. With no --head, the one run is held as
513
+ assemble holds it and every row is written from it, and the first note
514
+ says so.
469
515
 
470
516
  rig-parts inputs --source <painting.png> --config <config.json> --out <dir>
471
517
  Cut the two images See-through is fed: <out>/st_input_full.png (the
@@ -498,7 +544,7 @@ usage:
498
544
  The config needs only key and generation here; the rest comes later.
499
545
 
500
546
  rig-parts build --config <config.json> --source <painting.png> --full <dir|psd>
501
- --head <dir|psd> --out <dir> [--seam near-white|silhouette]
547
+ [--head <dir|psd>] --out <dir> [--seam near-white|silhouette]
502
548
  [--project core|visible] [--page-edges pot|free]
503
549
  [--pack-shape rect|polygon] [--loop] [--requirements <file.json>]
504
550
  [--idle-keys ctl|direct] [--lead-in <periods>] [--seam-pairs] [--keep-stills]
@@ -591,6 +637,21 @@ usage:
591
637
  line and ${SCENE_REPORT_FILE} name what was accepted, and the run exits 1.
592
638
  Exit 0 when the check is PASS and nothing was accepted, 1 otherwise.
593
639
 
640
+ rig-parts painting --keypoints <keypoints.json> [--person <id>] <painting.png>
641
+ Before See-through: read the painting against its body-18 joints (the
642
+ file propose --keypoints reads, measured on this painting) and refuse,
643
+ by name, what a rig cannot be cut from. Background is near-white
644
+ (every channel above 235) or unpainted (alpha 8 or less) ground
645
+ connected to the edge; the figure is the rest; a gap is one background
646
+ pixel on the line between two joints. PAINTING_BACKGROUND_PLAIN (a
647
+ border pixel that is not ground), PAINTING_JOINTS_STATED (a wrist, hip,
648
+ knee or ankle not observed), PAINTING_JOINT_ON_FIGURE (an observed
649
+ joint on the background), PAINTING_ARM_CLEAR (no gap from a wrist to
650
+ its hip), PAINTING_HANDS_APART (none between the wrists),
651
+ PAINTING_LEGS_APART (none between the knees, or the ankles). Prints the
652
+ figure, every joint and every gap with its count; writes nothing.
653
+ docs/PAINTING.md says why each one matters. Exit 0 PASS, 1 refused.
654
+
594
655
  rig-parts guide <RULE | §<n> | §<n>.<m> | §<n>.<m>.<k>>
595
656
  Print a slice of ${GUIDE_DOC}, the guide this package ships, read
596
657
  from the installed file at the moment of the call; nothing is kept
@@ -676,6 +737,10 @@ function printLayerTable(set: LayerSet): void {
676
737
  console.log(` ${set.layers.length} layer(s): ${painted} with opaque pixels, ${set.layers.length - painted} with none`);
677
738
  // Issue #256: a layer the author made is said to be one, so it never reads as See-through's. None, no line.
678
739
  for (const l of set.layers) if (l.tag.group === 'author') console.log(` ${authorLayerLine(l.name)}`);
740
+ // Issue #359: a hidden-art layer is listed apart, never in the drawn table. None, no line.
741
+ for (const h of set.hidden) console.log(` ${hiddenLayerLine(h)}`);
742
+ // Issue #359: an input with no depth is said once, with what reads depth and that it is skipped, never passed.
743
+ if (set.layers.length > 0 && set.layers.every((l) => l.depth === null)) console.log(` ${depthSkipLine(set)}`);
679
744
  // A reader refuses nothing on plausibility: it says what --propose-plan will leave out, and why.
680
745
  let warned = 0;
681
746
  for (const f of figures) {
@@ -690,10 +755,11 @@ function printLayerTable(set: LayerSet): void {
690
755
  /**
691
756
  * Every flag each command takes: `valued` flags carry a value, `switches` are bare. Each parser reads its row to
692
757
  * decide what it accepts, and {@link notTaken} prints the same row in the unknown-flag refusal, so the refusal
693
- * lists exactly the flags the parser accepts (CL26). `layers` takes one positional path and `guide` one positional
694
- * argument, and neither takes a flag, so neither has a row.
758
+ * lists exactly the flags the parser accepts (CL26). `guide` takes one positional argument and no flag, so it has
759
+ * no row; `layers` takes one positional path beside its flags (issue #359).
695
760
  */
696
761
  const TAKES = {
762
+ layers: { valued: ['--export', '--template', '--canvas'], switches: [] },
697
763
  sheet: { valued: ['--source', '--layers', '--out', '--cell', '--cols'], switches: [] },
698
764
  rig: { valued: ['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
699
765
  propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--head', '--canvas', '--keypoints', '--person', '--wrist-snap', '--grid-rule'], switches: ['--head-box'] },
@@ -707,6 +773,7 @@ const TAKES = {
707
773
  build: { valued: ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges', '--pack-shape', '--requirements', '--idle-keys', '--lead-in'], switches: ['--loop', '--seam-pairs', '--keep-stills'] },
708
774
  compose: { valued: ['--scene', '--out', '--requirements', '--accept'], switches: [] },
709
775
  'mesh-draft': { valued: ['--config', '--parts', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: ['--more-triangles'] },
776
+ painting: { valued: ['--keypoints', '--person'], switches: [] },
710
777
  } as const satisfies Record<string, { valued: readonly string[]; switches: readonly string[] }>;
711
778
 
712
779
  type Command = keyof typeof TAKES;
@@ -722,10 +789,58 @@ function notTaken(command: Command, flag: string): string {
722
789
  return `${command} does not take "${flag}"; it takes ${[...row.valued, ...row.switches].join(', ')}`;
723
790
  }
724
791
 
792
+ /**
793
+ * `layers <path>` prints the table; `layers --export <dir> <path>` writes the
794
+ * set as the wrapper form and reads it back (issue #359); `layers --template
795
+ * <out.psd> --canvas <W>x<H>` writes the artist's template. One positional
796
+ * path, except with --template, which reads nothing.
797
+ */
725
798
  function cmdLayers(args: string[]): number {
726
- if (args.length !== 1) return usage(`layers takes one path; got ${args.length}`);
799
+ const flags = new Map<string, string>();
800
+ const paths: string[] = [];
801
+ for (let i = 0; i < args.length; i++) {
802
+ const a = args[i];
803
+ if (!a.startsWith('--')) {
804
+ paths.push(a);
805
+ continue;
806
+ }
807
+ if (!takesValued('layers', a)) return usage(notTaken('layers', a));
808
+ const value = args[i + 1];
809
+ if (value === undefined) return usage(`${a} needs a value`);
810
+ if (flags.has(a)) return usage(`${a} is given twice`);
811
+ flags.set(a, value);
812
+ i++;
813
+ }
814
+ const template = flags.get('--template');
815
+ if (template !== undefined) {
816
+ if (flags.has('--export') || paths.length > 0) return usage(`--template writes a PSD and reads nothing; it takes --canvas only, and got ${[...[...flags.keys()].filter((f) => f !== '--template' && f !== '--canvas'), ...paths].join(', ')}`);
817
+ const canvasArg = flags.get('--canvas');
818
+ if (canvasArg === undefined) return usage('--template needs --canvas <W>x<H>, the canvas size in px (with one run, a square of seethrough.resolution)');
819
+ const canvas = parseCanvas(canvasArg);
820
+ if (canvas === null) return usage(`--canvas ${canvasArg} is not <W>x<H> in integer px`);
821
+ if (extname(template).toLowerCase() !== '.psd') return usage(`--template ${template} is not a .psd path`);
822
+ const names = templateLayerNames();
823
+ mkdirSync(dirname(template), { recursive: true });
824
+ writeFileSync(template, templatePsdBytes(canvas.w, canvas.h));
825
+ console.log(`rig-parts layers --template: wrote ${template}, ${canvas.w}x${canvas.h}, ${names.length} empty layer(s), bottom first: ${names.join(', ')}`);
826
+ console.log(' paint into the layers the figure has and delete the rest: an empty layer is refused by name (PSD_LAYER_HAS_PIXELS)');
827
+ return EXIT_OK;
828
+ }
829
+ if (flags.has('--canvas')) return usage('--canvas belongs to --template');
830
+ if (paths.length !== 1) return usage(`layers takes one path; got ${paths.length}`);
727
831
  try {
728
- printLayerTable(readLayers(args[0]));
832
+ const set = readLayers(paths[0]);
833
+ printLayerTable(set);
834
+ const dir = flags.get('--export');
835
+ if (dir !== undefined) {
836
+ const w = exportLayers(set, dir);
837
+ // The round trip, read now: what was written must read back as what was read.
838
+ const back = readWrapperLayers(dir);
839
+ const diff = layerSetDiff(set, back);
840
+ if (diff !== null) throw new PartsError([{ code: 'LAYERS_EXPORT_ROUND_TRIP', object: w.manifest, detail: `reads back unlike ${paths[0]}: ${diff}; the same layers, boxes, pixels and order are required (a bug in the exporter, not in the input)` }]);
841
+ console.log(`rig-parts layers --export: wrote ${w.manifest} and ${w.files.length} PNG(s) in ${dir}; read back: ${back.layers.length} drawn and ${back.hidden.length} hidden-art layer(s), boxes, pixels and order as read`);
842
+ console.log(` correct it there — move pixels between PNGs, change a box in layers.json with its PNG — and hand ${dir} to any command that reads layers (docs/AUTHORING.md, *The correction loop*)`);
843
+ }
729
844
  return EXIT_OK;
730
845
  } catch (err) {
731
846
  return printRefusal(err);
@@ -1128,20 +1243,23 @@ function cmdAssemble(args: string[]): number {
1128
1243
  flags.set(flag, value);
1129
1244
  i++;
1130
1245
  }
1131
- for (const f of ['--source', '--full', '--head', '--config']) if (!flags.has(f)) return usage(`assemble needs ${f}`);
1246
+ // --head is optional (issue #359): without it the full run is the one run, and it must hold the face.
1247
+ for (const f of ['--source', '--full', '--config']) if (!flags.has(f)) return usage(`assemble needs ${f}`);
1132
1248
  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');
1133
1249
  if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
1134
1250
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1135
1251
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1136
1252
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
1137
1253
  if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
1138
- const [source, full, head, config] = ['--source', '--full', '--head', '--config'].map((f) => flags.get(f) as string);
1254
+ const [source, full, config] = ['--source', '--full', '--config'].map((f) => flags.get(f) as string);
1255
+ const head = flags.get('--head') ?? null;
1139
1256
  try {
1140
1257
  if (propose) {
1141
1258
  const src = readSource(source);
1142
1259
  const runs = readRuns(full, head);
1143
- const g = proposeFields(loadEarlyConfig(config, 'layers'));
1144
- const proposal = proposePlan(runs.full, runs.head, { sourceW: src.width, sourceH: src.height, ...g });
1260
+ const g = proposeFields(loadEarlyConfig(config, 'layers'), runs.single ? { w: src.width, h: src.height } : null);
1261
+ const geometry = { sourceW: src.width, sourceH: src.height, ...g };
1262
+ const proposal = runs.single ? proposeSinglePlan(runs.full, geometry) : proposePlan(runs.full, runs.head, geometry);
1145
1263
  console.log(JSON.stringify(proposal, null, 2));
1146
1264
  return EXIT_OK;
1147
1265
  }
@@ -1312,7 +1430,8 @@ function cmdBuild(args: string[]): number {
1312
1430
  flags.set(flag, value);
1313
1431
  i++;
1314
1432
  }
1315
- for (const f of ['--config', '--source', '--full', '--head', '--out']) if (!flags.has(f)) return usage(`build needs ${f}`);
1433
+ // --head is optional (issue #359), as for assemble.
1434
+ for (const f of ['--config', '--source', '--full', '--out']) if (!flags.has(f)) return usage(`build needs ${f}`);
1316
1435
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1317
1436
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1318
1437
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
@@ -1327,7 +1446,8 @@ function cmdBuild(args: string[]): number {
1327
1446
  const leadText = flags.get('--lead-in');
1328
1447
  if (leadText !== undefined && !/^[1-9][0-9]*$/.test(leadText)) return usage(`--lead-in ${leadText}; a whole number of idle periods, 1 or more, is required`);
1329
1448
  if (leadText !== undefined && !loop) return usage('--lead-in needs --loop; it says which frames the loop is encoded from');
1330
- const [config, source, full, head, out] = ['--config', '--source', '--full', '--head', '--out'].map((f) => flags.get(f) as string);
1449
+ const [config, source, full, out] = ['--config', '--source', '--full', '--out'].map((f) => flags.get(f) as string);
1450
+ const head = flags.get('--head') ?? null;
1331
1451
  let bin: string;
1332
1452
  try {
1333
1453
  bin = findRigc(import.meta.dir, process.env.PATH ?? '');
@@ -1411,6 +1531,52 @@ function cmdMeshDraft(args: string[]): number {
1411
1531
  }
1412
1532
  }
1413
1533
 
1534
+ /**
1535
+ * `painting --keypoints <k.json> [--person <id>] <painting.png>` (issue #350): what the painting must be before
1536
+ * See-through runs on it, read off its pixels and the body-18 joints `propose --keypoints` reads. Writes nothing.
1537
+ */
1538
+ function cmdPainting(args: string[]): number {
1539
+ const flags = new Map<string, string>();
1540
+ const paths: string[] = [];
1541
+ for (let i = 0; i < args.length; i++) {
1542
+ const a = args[i];
1543
+ if (!a.startsWith('--')) {
1544
+ paths.push(a);
1545
+ continue;
1546
+ }
1547
+ if (!takesValued('painting', a)) return usage(notTaken('painting', a));
1548
+ const value = args[i + 1];
1549
+ if (value === undefined) return usage(`${a} needs a value`);
1550
+ if (flags.has(a)) return usage(`${a} is given twice`);
1551
+ flags.set(a, value);
1552
+ i++;
1553
+ }
1554
+ const kpPath = flags.get('--keypoints');
1555
+ if (kpPath === undefined) return usage('painting needs --keypoints <file>, the body-18 joints of the figure in painting px (the file propose --keypoints reads)');
1556
+ if (paths.length !== 1) return usage(`painting takes one painting path; got ${paths.length}`);
1557
+ const source = paths[0];
1558
+ try {
1559
+ if (!existsSync(source)) throw new PartsError([{ code: 'PAINTING_SOURCE_PRESENT', object: source, detail: 'no such file; the painting to check is required' }]);
1560
+ let painting: ReturnType<typeof readPng>;
1561
+ try {
1562
+ painting = readPng(source);
1563
+ } catch (err) {
1564
+ throw new PartsError([{ code: 'PAINTING_SOURCE_DECODES', object: source, detail: `${(err as Error).message}; a PNG is required` }]);
1565
+ }
1566
+ const kp = loadKeypoints(kpPath);
1567
+ checkImageSize(kp, painting.width, painting.height, kpPath, source);
1568
+ const person = choosePerson(kp, flags.get('--person'), kpPath);
1569
+ console.log(`rig-parts painting: ${source}, joints from ${kpPath} person "${person.id}" (source: ${kp.source})`);
1570
+ const r = checkPainting(painting, person, source, kpPath);
1571
+ for (const l of r.lines) console.log(l);
1572
+ if (r.problems.length > 0) throw new PartsError(r.problems);
1573
+ console.log(' PASS the painting holds every requirement this check reads (docs/PAINTING.md); what only an eye reads is listed there');
1574
+ return EXIT_OK;
1575
+ } catch (err) {
1576
+ return printRefusal(err);
1577
+ }
1578
+ }
1579
+
1414
1580
  function main(argv: string[]): number | Promise<number> {
1415
1581
  const [command, ...rest] = argv;
1416
1582
  if (command === undefined || command === '--help' || command === '-h' || command === 'help') {
@@ -1434,6 +1600,7 @@ function main(argv: string[]): number | Promise<number> {
1434
1600
  if (command === 'build') return cmdBuild(rest);
1435
1601
  if (command === 'compose') return cmdCompose(rest);
1436
1602
  if (command === 'mesh-draft') return cmdMeshDraft(rest);
1603
+ if (command === 'painting') return cmdPainting(rest);
1437
1604
  if (command === 'guide') return cmdGuide(rest);
1438
1605
  const later = LATER.find(([name]) => name === command);
1439
1606
  if (later !== undefined) {