@rig-c/parts 1.17.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
@@ -425,7 +448,7 @@ usage:
425
448
  onto frame 0 itself. Animated WebP is not written — it needs a VP8/VP8L
426
449
  encoder, out of scope.
427
450
 
428
- 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>]
429
452
  --config <config.json> --out <dir> [--seam near-white|silhouette]
430
453
  [--project core|visible]
431
454
  Merge the full-body and head-crop See-through runs into rig-space parts:
@@ -459,7 +482,15 @@ usage:
459
482
  and one \`uncovered:\` line per full-run layer the plan does not take from
460
483
  the full run whose uncovered error holds a body (5x5-opened, 8-connected,
461
484
  at least 150 full-run px in rig px), naming the layer, its pixels and the
462
- 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).
463
494
  Writes nothing unless every check passed.
464
495
  --seam defaults to ${DEFAULT_SEAM_RULE}. --project says where a layer takes
465
496
  the painting's pixel: core (the reference's) erodes every layer's top-most
@@ -468,7 +499,7 @@ usage:
468
499
  ${DEFAULT_PROJECT_RULE}.
469
500
 
470
501
  rig-parts assemble --propose-plan --source <painting.png> --full <dir|psd>
471
- --head <dir|psd> --config <config.json>
502
+ [--head <dir|psd>] --config <config.json>
472
503
  Print {plan, extend_below_crop, notes} for config.assemble, from the two
473
504
  runs. A layer \`layers\` WARNs about (PLAN_LAYER_TRANSLUCENT,
474
505
  PLAN_LAYER_BACKGROUND, PLAN_LAYER_OVERSIZED) is not proposed, and a note
@@ -478,7 +509,9 @@ usage:
478
509
  rule that skipped it and the continuation; the plan itself is not
479
510
  changed. Reads only config.seethrough.head_box,
480
511
  config.seethrough.resolution and config.assemble.rig_scale — the rest of
481
- 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.
482
515
 
483
516
  rig-parts inputs --source <painting.png> --config <config.json> --out <dir>
484
517
  Cut the two images See-through is fed: <out>/st_input_full.png (the
@@ -511,7 +544,7 @@ usage:
511
544
  The config needs only key and generation here; the rest comes later.
512
545
 
513
546
  rig-parts build --config <config.json> --source <painting.png> --full <dir|psd>
514
- --head <dir|psd> --out <dir> [--seam near-white|silhouette]
547
+ [--head <dir|psd>] --out <dir> [--seam near-white|silhouette]
515
548
  [--project core|visible] [--page-edges pot|free]
516
549
  [--pack-shape rect|polygon] [--loop] [--requirements <file.json>]
517
550
  [--idle-keys ctl|direct] [--lead-in <periods>] [--seam-pairs] [--keep-stills]
@@ -604,6 +637,21 @@ usage:
604
637
  line and ${SCENE_REPORT_FILE} name what was accepted, and the run exits 1.
605
638
  Exit 0 when the check is PASS and nothing was accepted, 1 otherwise.
606
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
+
607
655
  rig-parts guide <RULE | §<n> | §<n>.<m> | §<n>.<m>.<k>>
608
656
  Print a slice of ${GUIDE_DOC}, the guide this package ships, read
609
657
  from the installed file at the moment of the call; nothing is kept
@@ -689,6 +737,10 @@ function printLayerTable(set: LayerSet): void {
689
737
  console.log(` ${set.layers.length} layer(s): ${painted} with opaque pixels, ${set.layers.length - painted} with none`);
690
738
  // Issue #256: a layer the author made is said to be one, so it never reads as See-through's. None, no line.
691
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)}`);
692
744
  // A reader refuses nothing on plausibility: it says what --propose-plan will leave out, and why.
693
745
  let warned = 0;
694
746
  for (const f of figures) {
@@ -703,10 +755,11 @@ function printLayerTable(set: LayerSet): void {
703
755
  /**
704
756
  * Every flag each command takes: `valued` flags carry a value, `switches` are bare. Each parser reads its row to
705
757
  * decide what it accepts, and {@link notTaken} prints the same row in the unknown-flag refusal, so the refusal
706
- * lists exactly the flags the parser accepts (CL26). `layers` takes one positional path and `guide` one positional
707
- * 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).
708
760
  */
709
761
  const TAKES = {
762
+ layers: { valued: ['--export', '--template', '--canvas'], switches: [] },
710
763
  sheet: { valued: ['--source', '--layers', '--out', '--cell', '--cols'], switches: [] },
711
764
  rig: { valued: ['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
712
765
  propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--head', '--canvas', '--keypoints', '--person', '--wrist-snap', '--grid-rule'], switches: ['--head-box'] },
@@ -720,6 +773,7 @@ const TAKES = {
720
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'] },
721
774
  compose: { valued: ['--scene', '--out', '--requirements', '--accept'], switches: [] },
722
775
  'mesh-draft': { valued: ['--config', '--parts', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: ['--more-triangles'] },
776
+ painting: { valued: ['--keypoints', '--person'], switches: [] },
723
777
  } as const satisfies Record<string, { valued: readonly string[]; switches: readonly string[] }>;
724
778
 
725
779
  type Command = keyof typeof TAKES;
@@ -735,10 +789,58 @@ function notTaken(command: Command, flag: string): string {
735
789
  return `${command} does not take "${flag}"; it takes ${[...row.valued, ...row.switches].join(', ')}`;
736
790
  }
737
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
+ */
738
798
  function cmdLayers(args: string[]): number {
739
- 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}`);
740
831
  try {
741
- 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
+ }
742
844
  return EXIT_OK;
743
845
  } catch (err) {
744
846
  return printRefusal(err);
@@ -1141,20 +1243,23 @@ function cmdAssemble(args: string[]): number {
1141
1243
  flags.set(flag, value);
1142
1244
  i++;
1143
1245
  }
1144
- 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}`);
1145
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');
1146
1249
  if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
1147
1250
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1148
1251
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1149
1252
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
1150
1253
  if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
1151
- 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;
1152
1256
  try {
1153
1257
  if (propose) {
1154
1258
  const src = readSource(source);
1155
1259
  const runs = readRuns(full, head);
1156
- const g = proposeFields(loadEarlyConfig(config, 'layers'));
1157
- 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);
1158
1263
  console.log(JSON.stringify(proposal, null, 2));
1159
1264
  return EXIT_OK;
1160
1265
  }
@@ -1325,7 +1430,8 @@ function cmdBuild(args: string[]): number {
1325
1430
  flags.set(flag, value);
1326
1431
  i++;
1327
1432
  }
1328
- 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}`);
1329
1435
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1330
1436
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1331
1437
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
@@ -1340,7 +1446,8 @@ function cmdBuild(args: string[]): number {
1340
1446
  const leadText = flags.get('--lead-in');
1341
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`);
1342
1448
  if (leadText !== undefined && !loop) return usage('--lead-in needs --loop; it says which frames the loop is encoded from');
1343
- 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;
1344
1451
  let bin: string;
1345
1452
  try {
1346
1453
  bin = findRigc(import.meta.dir, process.env.PATH ?? '');
@@ -1424,6 +1531,52 @@ function cmdMeshDraft(args: string[]): number {
1424
1531
  }
1425
1532
  }
1426
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
+
1427
1580
  function main(argv: string[]): number | Promise<number> {
1428
1581
  const [command, ...rest] = argv;
1429
1582
  if (command === undefined || command === '--help' || command === '-h' || command === 'help') {
@@ -1447,6 +1600,7 @@ function main(argv: string[]): number | Promise<number> {
1447
1600
  if (command === 'build') return cmdBuild(rest);
1448
1601
  if (command === 'compose') return cmdCompose(rest);
1449
1602
  if (command === 'mesh-draft') return cmdMeshDraft(rest);
1603
+ if (command === 'painting') return cmdPainting(rest);
1450
1604
  if (command === 'guide') return cmdGuide(rest);
1451
1605
  const later = LATER.find(([name]) => name === command);
1452
1606
  if (later !== undefined) {