@rig-c/parts 1.17.0 → 1.19.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,22 @@ 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,
648
+ knee or ankle not observed, or a hip neither observed nor occluded
649
+ with a position), PAINTING_JOINT_ON_FIGURE (an observed joint, or an
650
+ occluded hip, on the background), PAINTING_ARM_CLEAR (no gap from a
651
+ wrist to its hip), PAINTING_HANDS_APART (none between the wrists),
652
+ PAINTING_LEGS_APART (none between the knees, or the ankles). Prints the
653
+ figure, every joint and every gap with its count; writes nothing.
654
+ docs/PAINTING.md says why each one matters. Exit 0 PASS, 1 refused.
655
+
607
656
  rig-parts guide <RULE | §<n> | §<n>.<m> | §<n>.<m>.<k>>
608
657
  Print a slice of ${GUIDE_DOC}, the guide this package ships, read
609
658
  from the installed file at the moment of the call; nothing is kept
@@ -689,6 +738,10 @@ function printLayerTable(set: LayerSet): void {
689
738
  console.log(` ${set.layers.length} layer(s): ${painted} with opaque pixels, ${set.layers.length - painted} with none`);
690
739
  // Issue #256: a layer the author made is said to be one, so it never reads as See-through's. None, no line.
691
740
  for (const l of set.layers) if (l.tag.group === 'author') console.log(` ${authorLayerLine(l.name)}`);
741
+ // Issue #359: a hidden-art layer is listed apart, never in the drawn table. None, no line.
742
+ for (const h of set.hidden) console.log(` ${hiddenLayerLine(h)}`);
743
+ // Issue #359: an input with no depth is said once, with what reads depth and that it is skipped, never passed.
744
+ if (set.layers.length > 0 && set.layers.every((l) => l.depth === null)) console.log(` ${depthSkipLine(set)}`);
692
745
  // A reader refuses nothing on plausibility: it says what --propose-plan will leave out, and why.
693
746
  let warned = 0;
694
747
  for (const f of figures) {
@@ -703,10 +756,11 @@ function printLayerTable(set: LayerSet): void {
703
756
  /**
704
757
  * Every flag each command takes: `valued` flags carry a value, `switches` are bare. Each parser reads its row to
705
758
  * 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.
759
+ * lists exactly the flags the parser accepts (CL26). `guide` takes one positional argument and no flag, so it has
760
+ * no row; `layers` takes one positional path beside its flags (issue #359).
708
761
  */
709
762
  const TAKES = {
763
+ layers: { valued: ['--export', '--template', '--canvas'], switches: [] },
710
764
  sheet: { valued: ['--source', '--layers', '--out', '--cell', '--cols'], switches: [] },
711
765
  rig: { valued: ['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
712
766
  propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--head', '--canvas', '--keypoints', '--person', '--wrist-snap', '--grid-rule'], switches: ['--head-box'] },
@@ -720,6 +774,7 @@ const TAKES = {
720
774
  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
775
  compose: { valued: ['--scene', '--out', '--requirements', '--accept'], switches: [] },
722
776
  'mesh-draft': { valued: ['--config', '--parts', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: ['--more-triangles'] },
777
+ painting: { valued: ['--keypoints', '--person'], switches: [] },
723
778
  } as const satisfies Record<string, { valued: readonly string[]; switches: readonly string[] }>;
724
779
 
725
780
  type Command = keyof typeof TAKES;
@@ -735,10 +790,58 @@ function notTaken(command: Command, flag: string): string {
735
790
  return `${command} does not take "${flag}"; it takes ${[...row.valued, ...row.switches].join(', ')}`;
736
791
  }
737
792
 
793
+ /**
794
+ * `layers <path>` prints the table; `layers --export <dir> <path>` writes the
795
+ * set as the wrapper form and reads it back (issue #359); `layers --template
796
+ * <out.psd> --canvas <W>x<H>` writes the artist's template. One positional
797
+ * path, except with --template, which reads nothing.
798
+ */
738
799
  function cmdLayers(args: string[]): number {
739
- if (args.length !== 1) return usage(`layers takes one path; got ${args.length}`);
800
+ const flags = new Map<string, string>();
801
+ const paths: string[] = [];
802
+ for (let i = 0; i < args.length; i++) {
803
+ const a = args[i];
804
+ if (!a.startsWith('--')) {
805
+ paths.push(a);
806
+ continue;
807
+ }
808
+ if (!takesValued('layers', a)) return usage(notTaken('layers', a));
809
+ const value = args[i + 1];
810
+ if (value === undefined) return usage(`${a} needs a value`);
811
+ if (flags.has(a)) return usage(`${a} is given twice`);
812
+ flags.set(a, value);
813
+ i++;
814
+ }
815
+ const template = flags.get('--template');
816
+ if (template !== undefined) {
817
+ 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(', ')}`);
818
+ const canvasArg = flags.get('--canvas');
819
+ if (canvasArg === undefined) return usage('--template needs --canvas <W>x<H>, the canvas size in px (with one run, a square of seethrough.resolution)');
820
+ const canvas = parseCanvas(canvasArg);
821
+ if (canvas === null) return usage(`--canvas ${canvasArg} is not <W>x<H> in integer px`);
822
+ if (extname(template).toLowerCase() !== '.psd') return usage(`--template ${template} is not a .psd path`);
823
+ const names = templateLayerNames();
824
+ mkdirSync(dirname(template), { recursive: true });
825
+ writeFileSync(template, templatePsdBytes(canvas.w, canvas.h));
826
+ console.log(`rig-parts layers --template: wrote ${template}, ${canvas.w}x${canvas.h}, ${names.length} empty layer(s), bottom first: ${names.join(', ')}`);
827
+ console.log(' paint into the layers the figure has and delete the rest: an empty layer is refused by name (PSD_LAYER_HAS_PIXELS)');
828
+ return EXIT_OK;
829
+ }
830
+ if (flags.has('--canvas')) return usage('--canvas belongs to --template');
831
+ if (paths.length !== 1) return usage(`layers takes one path; got ${paths.length}`);
740
832
  try {
741
- printLayerTable(readLayers(args[0]));
833
+ const set = readLayers(paths[0]);
834
+ printLayerTable(set);
835
+ const dir = flags.get('--export');
836
+ if (dir !== undefined) {
837
+ const w = exportLayers(set, dir);
838
+ // The round trip, read now: what was written must read back as what was read.
839
+ const back = readWrapperLayers(dir);
840
+ const diff = layerSetDiff(set, back);
841
+ 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)` }]);
842
+ 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`);
843
+ 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*)`);
844
+ }
742
845
  return EXIT_OK;
743
846
  } catch (err) {
744
847
  return printRefusal(err);
@@ -1141,20 +1244,23 @@ function cmdAssemble(args: string[]): number {
1141
1244
  flags.set(flag, value);
1142
1245
  i++;
1143
1246
  }
1144
- for (const f of ['--source', '--full', '--head', '--config']) if (!flags.has(f)) return usage(`assemble needs ${f}`);
1247
+ // --head is optional (issue #359): without it the full run is the one run, and it must hold the face.
1248
+ for (const f of ['--source', '--full', '--config']) if (!flags.has(f)) return usage(`assemble needs ${f}`);
1145
1249
  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
1250
  if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
1147
1251
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1148
1252
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1149
1253
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
1150
1254
  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);
1255
+ const [source, full, config] = ['--source', '--full', '--config'].map((f) => flags.get(f) as string);
1256
+ const head = flags.get('--head') ?? null;
1152
1257
  try {
1153
1258
  if (propose) {
1154
1259
  const src = readSource(source);
1155
1260
  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 });
1261
+ const g = proposeFields(loadEarlyConfig(config, 'layers'), runs.single ? { w: src.width, h: src.height } : null);
1262
+ const geometry = { sourceW: src.width, sourceH: src.height, ...g };
1263
+ const proposal = runs.single ? proposeSinglePlan(runs.full, geometry) : proposePlan(runs.full, runs.head, geometry);
1158
1264
  console.log(JSON.stringify(proposal, null, 2));
1159
1265
  return EXIT_OK;
1160
1266
  }
@@ -1325,7 +1431,8 @@ function cmdBuild(args: string[]): number {
1325
1431
  flags.set(flag, value);
1326
1432
  i++;
1327
1433
  }
1328
- for (const f of ['--config', '--source', '--full', '--head', '--out']) if (!flags.has(f)) return usage(`build needs ${f}`);
1434
+ // --head is optional (issue #359), as for assemble.
1435
+ for (const f of ['--config', '--source', '--full', '--out']) if (!flags.has(f)) return usage(`build needs ${f}`);
1329
1436
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1330
1437
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1331
1438
  const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
@@ -1340,7 +1447,8 @@ function cmdBuild(args: string[]): number {
1340
1447
  const leadText = flags.get('--lead-in');
1341
1448
  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
1449
  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);
1450
+ const [config, source, full, out] = ['--config', '--source', '--full', '--out'].map((f) => flags.get(f) as string);
1451
+ const head = flags.get('--head') ?? null;
1344
1452
  let bin: string;
1345
1453
  try {
1346
1454
  bin = findRigc(import.meta.dir, process.env.PATH ?? '');
@@ -1424,6 +1532,52 @@ function cmdMeshDraft(args: string[]): number {
1424
1532
  }
1425
1533
  }
1426
1534
 
1535
+ /**
1536
+ * `painting --keypoints <k.json> [--person <id>] <painting.png>` (issue #350): what the painting must be before
1537
+ * See-through runs on it, read off its pixels and the body-18 joints `propose --keypoints` reads. Writes nothing.
1538
+ */
1539
+ function cmdPainting(args: string[]): number {
1540
+ const flags = new Map<string, string>();
1541
+ const paths: string[] = [];
1542
+ for (let i = 0; i < args.length; i++) {
1543
+ const a = args[i];
1544
+ if (!a.startsWith('--')) {
1545
+ paths.push(a);
1546
+ continue;
1547
+ }
1548
+ if (!takesValued('painting', a)) return usage(notTaken('painting', a));
1549
+ const value = args[i + 1];
1550
+ if (value === undefined) return usage(`${a} needs a value`);
1551
+ if (flags.has(a)) return usage(`${a} is given twice`);
1552
+ flags.set(a, value);
1553
+ i++;
1554
+ }
1555
+ const kpPath = flags.get('--keypoints');
1556
+ if (kpPath === undefined) return usage('painting needs --keypoints <file>, the body-18 joints of the figure in painting px (the file propose --keypoints reads)');
1557
+ if (paths.length !== 1) return usage(`painting takes one painting path; got ${paths.length}`);
1558
+ const source = paths[0];
1559
+ try {
1560
+ if (!existsSync(source)) throw new PartsError([{ code: 'PAINTING_SOURCE_PRESENT', object: source, detail: 'no such file; the painting to check is required' }]);
1561
+ let painting: ReturnType<typeof readPng>;
1562
+ try {
1563
+ painting = readPng(source);
1564
+ } catch (err) {
1565
+ throw new PartsError([{ code: 'PAINTING_SOURCE_DECODES', object: source, detail: `${(err as Error).message}; a PNG is required` }]);
1566
+ }
1567
+ const kp = loadKeypoints(kpPath);
1568
+ checkImageSize(kp, painting.width, painting.height, kpPath, source);
1569
+ const person = choosePerson(kp, flags.get('--person'), kpPath);
1570
+ console.log(`rig-parts painting: ${source}, joints from ${kpPath} person "${person.id}" (source: ${kp.source})`);
1571
+ const r = checkPainting(painting, person, source, kpPath);
1572
+ for (const l of r.lines) console.log(l);
1573
+ if (r.problems.length > 0) throw new PartsError(r.problems);
1574
+ console.log(' PASS the painting holds every requirement this check reads (docs/PAINTING.md); what only an eye reads is listed there');
1575
+ return EXIT_OK;
1576
+ } catch (err) {
1577
+ return printRefusal(err);
1578
+ }
1579
+ }
1580
+
1427
1581
  function main(argv: string[]): number | Promise<number> {
1428
1582
  const [command, ...rest] = argv;
1429
1583
  if (command === undefined || command === '--help' || command === '-h' || command === 'help') {
@@ -1447,6 +1601,7 @@ function main(argv: string[]): number | Promise<number> {
1447
1601
  if (command === 'build') return cmdBuild(rest);
1448
1602
  if (command === 'compose') return cmdCompose(rest);
1449
1603
  if (command === 'mesh-draft') return cmdMeshDraft(rest);
1604
+ if (command === 'painting') return cmdPainting(rest);
1450
1605
  if (command === 'guide') return cmdGuide(rest);
1451
1606
  const later = LATER.find(([name]) => name === command);
1452
1607
  if (later !== undefined) {