rig-parts 1.57.1 → 1.59.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/NOTICE.md CHANGED
@@ -122,6 +122,28 @@ redistributed.
122
122
  The [parts-examples](https://github.com/rig-c/parts-examples) README records
123
123
  both, beside each character's generation record.
124
124
 
125
+ ### Models named by the optional painting graph
126
+
127
+ **[observed 2026-10-22]** In `src/graphs.ts`, the intended OpenPose ControlNet is
128
+ [xinsir/controlnet-openpose-sdxl-1.0](https://huggingface.co/xinsir/controlnet-openpose-sdxl-1.0/blob/23f966cd5cfdd3f7729c903e243d87152162d2b7/README.md).
129
+ That revision's card declares `license: apache-2.0`. Its
130
+ [`diffusion_pytorch_model.safetensors`](https://huggingface.co/xinsir/controlnet-openpose-sdxl-1.0/blob/23f966cd5cfdd3f7729c903e243d87152162d2b7/diffusion_pytorch_model.safetensors)
131
+ is the source for the host-local name `controlnet-openpose-sdxl-1.0.safetensors`.
132
+ The graph uses that name only when `generation.control` is configured and
133
+ `generation.control.model` is absent; a configured model filename replaces it.
134
+ The local filename alone does not identify or verify the installed weights.
135
+
136
+ **[observed 2026-10-22]** The graph always names `4x-AnimeSharp.pth` for model
137
+ upscaling, followed by Lanczos resizing. The source is
138
+ [Kim2091/AnimeSharp at `7696d95`](https://huggingface.co/Kim2091/AnimeSharp/blob/7696d95ced82b0c1f2a41f6ac73336133f0a90e1/4x-AnimeSharp.pth),
139
+ and the [creator's card at that revision](https://huggingface.co/Kim2091/AnimeSharp/blob/7696d95ced82b0c1f2a41f6ac73336133f0a90e1/README.md)
140
+ declares CC BY-NC-SA 4.0, including its NonCommercial condition. rig-parts ships
141
+ neither model and grants no additional rights to their weights. These observations
142
+ record the models' declared terms; they do not assign a blanket licence to output
143
+ images. The existing upscaler selection is unchanged. Whether to require explicit
144
+ selection is the separate, deferred owner decision in
145
+ [#428](https://github.com/rig-c/parts/issues/428).
146
+
125
147
  ## 4. Behaviour reimplemented, not code copied
126
148
 
127
149
  The raster operations in `src/raster/` reproduce the documented behaviour of
package/README.md CHANGED
@@ -1,7 +1,10 @@
1
1
  # rig-parts
2
2
 
3
3
  **AI-authored 2D character rigs from one anime painting, verified before they
4
- are written.** rig-parts takes a single character painting and the layers
4
+ are written.** rig-parts generates no images. The painting and See-through layers
5
+ come from models the user runs on their GPU or a web demo; this package ships no
6
+ weights. The optional `comfy` adapter drives the user's own ComfyUI host and models.
7
+ rig-parts takes a single character painting and the layers
5
8
  [See-through](https://github.com/shitagaki-lab/see-through) decomposed it into,
6
9
  merges them into measured rig-space parts, authors weighted meshes, bone chains
7
10
  and a looping idle over them in [rig-c](https://github.com/rig-c/core)'s
@@ -168,11 +171,19 @@ rig-parts at what it wrote:
168
171
 
169
172
  | Route | Where | Output rig-parts reads |
170
173
  | --- | --- | --- |
171
- | Hugging Face Space | [24yearsold/see-through-demo](https://huggingface.co/spaces/24yearsold/see-through-demo) (ZeroGPU; upstream states 1-2 extractions a day for a registered user) | the `.psd` it produces |
174
+ | Hugging Face Space | [24yearsold/see-through-demo](https://huggingface.co/spaces/24yearsold/see-through-demo) (ZeroGPU) | the `.psd` it produces; upload `st_input_full.png`, the white-padded square from `inputs`, for the full run, and `st_input_head.png` for the separate head run |
172
175
  | ModelScope demo | [ljsabc/See-Through](https://modelscope.cn/studios/ljsabc/See-Through), linked from the upstream README | the `.psd` it produces |
173
176
  | Upstream CLI | `python inference/scripts/inference_psd.py --srcp <image> --save_to_psd` in a checkout of [shitagaki-lab/see-through](https://github.com/shitagaki-lab/see-through) | the `.psd` in `workspace/layerdiff_output/` |
174
177
  | ComfyUI wrapper | [jtydhr88/ComfyUI-See-through](https://github.com/jtydhr88/ComfyUI-See-through) on your own ComfyUI box; the optional `comfy` adapter (`rig-parts comfy seethrough`) drives it | the `layers.json` + PNGs it writes |
175
178
 
179
+ Use the square `inputs` outputs on the Space too. Its [app calls `apply_layerdiff`](https://huggingface.co/spaces/24yearsold/see-through-demo/blob/049a1ab07908e404aad03f73431dd1a98f88202c/app.py#L110-L118),
180
+ which [calls `center_square_pad_resize`](https://huggingface.co/spaces/24yearsold/see-through-demo/blob/049a1ab07908e404aad03f73431dd1a98f88202c/common/utils/inference_utils.py#L78-L83).
181
+ That [preprocessing function](https://huggingface.co/spaces/24yearsold/see-through-demo/blob/049a1ab07908e404aad03f73431dd1a98f88202c/common/utils/cv.py#L254-L274)
182
+ pads a non-square upload before resizing; the centre-crop reported in
183
+ [#356](https://github.com/rig-c/parts/issues/356) is not the current source path.
184
+ `st_input_full.png` supplies the explicit white padding that rig-parts maps back
185
+ to the original painting. This is a source inspection, not a new demo run.
186
+
176
187
  **The head run is needed on every route that was read.** See-through's model
177
188
  already separates the head in a second stage of its own: it crops the head the
178
189
  first stage found, runs the face tags on that crop at the same `resolution`, and
@@ -217,10 +228,10 @@ commands drive it; nothing else in the pipeline needs one:
217
228
 
218
229
  ```sh
219
230
  rig-parts comfy paint --config config.json --out <dir> --host http://<box>:8188
220
- rig-parts comfy seethrough --image st_input_full.png --out layers/full --host http://<box>:8188 --offload
231
+ rig-parts comfy seethrough --image st_input_full.png --out layers/full --host http://<box>:8188
221
232
  ```
222
233
 
223
- `comfy paint` generates `painting_<seed>.png` from the config's inline
234
+ `comfy paint` asks the user's models to generate `painting_<seed>.png` from the config's inline
224
235
  `generation` block — checkpoint, LoRAs, sampler, and an optional OpenPose
225
236
  control skeleton it draws itself — and records the prompts verbatim beside it.
226
237
  `comfy seethrough` runs the [ComfyUI-See-through](https://github.com/jtydhr88/ComfyUI-See-through)
@@ -230,6 +241,32 @@ the graph is checked against the box's `/object_info`, so a missing node class
230
241
  or model is refused by name, and the adapter waits for an empty queue rather
231
242
  than queueing behind someone else's job.
232
243
 
244
+ The painting graph uses `4x-AnimeSharp.pth`, then Lanczos resizing. The
245
+ [creator's card](https://huggingface.co/Kim2091/AnimeSharp/blob/7696d95ced82b0c1f2a41f6ac73336133f0a90e1/README.md)
246
+ declares CC BY-NC-SA 4.0. When `generation.control` is set, the intended OpenPose
247
+ ControlNet is [xinsir/controlnet-openpose-sdxl-1.0](https://huggingface.co/xinsir/controlnet-openpose-sdxl-1.0/blob/23f966cd5cfdd3f7729c903e243d87152162d2b7/README.md),
248
+ whose card declares Apache-2.0. Install its `diffusion_pytorch_model.safetensors`
249
+ on the host as `controlnet-openpose-sdxl-1.0.safetensors`, or select its installed
250
+ filename with `generation.control.model`. A filename alone does not verify the
251
+ weights. [NOTICE](NOTICE.md#models-named-by-the-optional-painting-graph) pins the
252
+ sources and records their declared terms; those declarations do not assign a
253
+ blanket licence to output images. The graph's automatic upscaler selection is
254
+ unchanged; the owner decision is tracked separately in [#428](https://github.com/rig-c/parts/issues/428).
255
+
256
+ `--offload` selects the wrapper's CUDA group-offload path on the **ComfyUI host**:
257
+ its [LayerDiff and depth loaders](https://github.com/jtydhr88/ComfyUI-See-through/blob/98d754bf04f668647919ab750eccb0e0640faa81/nodes.py#L395-L475)
258
+ call `enable_group_offload('cuda', ...)`. Omit it for a local Mac/MPS host. A Mac
259
+ running this CLI may pass it to a remote CUDA host. This describes that offload
260
+ path; it is not a claim that the remaining model stack has been verified on MPS.
261
+
262
+ The [wrapper README at `98d754b`](https://github.com/jtydhr88/ComfyUI-See-through/blob/98d754bf04f668647919ab750eccb0e0640faa81/README.md#L22-L28)
263
+ describes a combined Decompose node and names `24yearsold/seethroughv0.0.1_marigold`
264
+ for depth. The shipped `workflows/seethrough.json` instead sends
265
+ `SeeThrough_GenerateLayers`, `SeeThrough_GenerateDepth` and `SeeThrough_PostProcess`,
266
+ and selects `layerdifforg/seethroughv0.0.1_marigold`, matching the wrapper's
267
+ [implemented depth default](https://github.com/jtydhr88/ComfyUI-See-through/blob/98d754bf04f668647919ab750eccb0e0640faa81/nodes.py#L82-L85).
268
+ Use the graph's node classes and the host's `/object_info` when checking the adapter.
269
+
233
270
  ## The loop, for an agent
234
271
 
235
272
  ```sh
@@ -261,8 +298,10 @@ rig-parts build --config config.json --source painting.png --full layers/full --
261
298
 
262
299
  At each step the config holds only what that step reads; the one table of what
263
300
  that is, step by step, is [docs/AUTHORING.md §4](docs/AUTHORING.md#4-the-command-order).
264
- The selftest runs this block in order, command by command, on each fetched example,
265
- from a config holding only what the first step reads (`RL01`). On `demo` the
301
+ The command-order controls exercise the hand-copy route above on each fetched
302
+ example from a config holding only what the first step reads (`RL01`). The optional
303
+ `--write-config` forms below save the proposed fields; `RL01` does not exercise
304
+ that flag. On `demo` the
266
305
  proposal pasted uncorrected stops at the last step, naming its `bottomwear` on
267
306
  `CHECK_TIP_OVER_ROOT` — the correction its tracked config records there, the two outer
268
307
  skirt chains moved (issue #227, [its README](examples/demo/README.md)); on `scarf`,
@@ -296,10 +335,10 @@ agent skill.
296
335
  | `comfy seethrough --image --out [--host]` | optional: run the ComfyUI See-through wrapper on one image and write the form `layers` reads |
297
336
  | `layers <dir \| layers.json \| file.psd>` | print every layer of a decomposition: draw order, name, tag group, box, size, opaque pixels, depth, and its translucent, background and area figures; a `WARN` line for a layer `--propose-plan` will leave out |
298
337
  | `sheet --source <png> --layers <path>… --out <png>` | a labelled contact sheet of the painting and every layer or part |
299
- | `assemble --propose-plan …` | propose `assemble.plan` and `extend_below_crop` from the two runs, leaving out an implausible layer with a note naming the rule, naming in a note any full-run body no planned part covers, and naming every layer no row takes with why (empty, taken from the other run only, a plausibility rule; `nose`, never planned, with the row that lets `propose` read the head's pitch) |
338
+ | `assemble --propose-plan … [--write-config <config>]` | propose `assemble.plan` and `extend_below_crop` from the two runs, leaving out an implausible layer with a note naming the rule, naming in a note any full-run body no planned part covers, and naming every layer no row takes with why (empty, taken from the other run only, a plausibility rule; `nose`, never planned, with the row that lets `propose` read the head's pitch) |
300
339
  | `assemble --source --full --head --config --out [--seam] [--project]` | merge the two runs into rig-space parts, `parts.json`, the recomposite and its error map, and list the uncovered holes |
301
- | `propose --head-box --full <run> --canvas WxH` | propose `seethrough.head_box` from the full run, held inside the painting |
302
- | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay; print, per bone, which check read it or that none did and why (its role, its kind of segment), and write `basis.json` beside the proposal — what each bone rests on (a joint, a part's mask, a ratio, other bones) and, with `--compare`, whether the config's bone differs |
340
+ | `propose --head-box --full <run> --canvas WxH [--no-back-hair] [--write-config <config>]` | propose `seethrough.head_box` from the full run, held inside the painting; `--no-back-hair` leaves the back-hair layer out of that proposal |
341
+ | `propose --parts --source --out [--compare <config>] [--write-config <config>]` | propose bones, meshes, regions and an idle; draw the overlay; print, per bone, which check read it or that none did and why (its role, its kind of segment), and write `basis.json` beside the proposal — what each bone rests on (a joint, a part's mask, a ratio, other bones) and, with `--compare`, whether the config's bone differs |
303
342
  | `propose … --keypoints <file> [--person <id>]` | read a posed figure's joints from one explicit file (space, image size, people, each joint observed, occluded or missing, its source): a joint with a position places the neck, hip, chest or a sleeve chain as given, a note says how every joint was used or which rule stood in, and LINT reads the torso and sleeves along the joints instead of by screen y ([AUTHORING §3](docs/AUTHORING.md)) |
304
343
  | `propose … --grid-rule figure\|part` | how each drafted lattice's spacing is chosen: `figure` (the default) one spacing for the figure, round(H / 37) rig px, a part already coarser keeping its own, with a note on its calibration and cost; `part` the reference's rule, a tenth of the part's short side (the oracle examples are proposed under it) |
305
344
  | `propose … --from-config <config>` | draw and LINT the config's current bones (by the joints too, given `--keypoints`) |
@@ -322,10 +361,40 @@ fields, the files a build writes and their spec ids, the environment, and what t
322
361
  package relies on in rig-c — and what is not, is [docs/STABILITY.md](docs/STABILITY.md).
323
362
  Coming from 0.16.0: [docs/MIGRATION.md](docs/MIGRATION.md).
324
363
 
364
+ `--write-config <config.json>` saves the three proposals above into an existing
365
+ regular config file: `seethrough.head_box`, `assemble.plan` plus
366
+ `assemble.extend_below_crop`, or the present config sections of an ordinary
367
+ proposal. For `assemble --propose-plan`, the destination must name `--config`
368
+ itself; a different destination is a usage error. Other fields and absent optional
369
+ sections remain; proposal notes are not copied. Changed whole values are spliced
370
+ into the original text, preserving untouched bytes. Stage loaders validate the candidate, including `proposed_rig_scale`,
371
+ before atomic replacement. Relative paths keep the config's directory as their
372
+ base. Changes are listed on stderr; the head-box and plan JSON on stdout remain.
373
+ An identical update leaves bytes and mtime untouched. Without the flag no config
374
+ is changed. This commits the config file, not a transaction across the proposal
375
+ artifacts.
376
+
377
+ With `--cloth <config.json>`, the flag requires a **new** destination and applies
378
+ the recipe's entry ownership: recipe-owned entries are spliced into the cloth
379
+ config while hand entries and unrelated text survive; conflicts are refused.
380
+ The destination must also be separate from all four artifacts under `--out`:
381
+ `proposal.json`, `basis.json`, `render/landmarks.png`, `render/landmarks_head.png`.
382
+ Aliases, including directory symlinks and hardlinks, count as collisions too.
383
+ Fresh output is published without overwriting a file created while the command
384
+ runs. Its already-checked splice text may be a partial config; it is not passed
385
+ through the full-config loader. If removing the staged temporary fails after
386
+ publication, the error may accompany an already-published complete config.
387
+ `--from <previous.json>` retains the previous-recipe comparison.
388
+ `--from-config` proposes nothing and cannot be combined with `--write-config`.
389
+ [AUTHORING §4](docs/AUTHORING.md#4-the-command-order) describes all four modes.
390
+
325
391
  ## What it does not do
326
392
 
327
393
  These are limits of the approach, stated so nobody reads more into a green run:
328
394
 
395
+ - **rig-parts generates no images.** The painting and See-through layers are made
396
+ by models the user runs on their GPU or a web demo. No weights ship here; the
397
+ optional `comfy` adapter drives the user's own ComfyUI host and models.
329
398
  - **One depth value per layer.** See-through gives each layer a single depth, so a
330
399
  layer that is in front of another in one place and behind it in another is drawn
331
400
  right in the still (the painting's own pixels are projected onto it) and wrong once
@@ -354,7 +423,10 @@ These are limits of the approach, stated so nobody reads more into a green run:
354
423
  file of joints comes from any pose
355
424
  estimator or a hand; this package does not estimate a pose itself, and its
356
425
  keypoint support has been measured on generated figures and on files read off the
357
- examples' own configs, never on an estimator's output.
426
+ examples' own configs, and on one reported estimator output: RTMPose whole-body
427
+ through rtmlib on one painting built green with the estimated file unedited
428
+ ([#356](https://github.com/rig-c/parts/issues/356)). That is one measurement,
429
+ not an estimator success rate; it has not been re-run here.
358
430
  - **The proposer reads tags, not pictures.** A swinging element painted inside another
359
431
  layer (a sash tail in the skirt), hair that is none of the shapes it knows, and
360
432
  whether an accessory swings are the corrector's to add. The one accessory shape it
package/cli.ts CHANGED
@@ -20,6 +20,7 @@ import { type AlternateCliInput, assembleStage, build, checkStage, ERROR_MAP_FIL
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, rigcRanClause, 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';
23
+ import { configWriteLines, readConfigWriteTarget, separateConfigWriteTarget, stageConfigWrite, type StagedConfigWrite } from './tools/configwrite.ts';
23
24
  import { failLine, guidePointer, PartsError } from './src/errors.ts';
24
25
  import { GUIDE_DOC, type GuideAnswer, guideRule, guideSection } from './src/guide.ts';
25
26
  import { proposeHeadBox } from './src/headbox.ts';
@@ -27,8 +28,8 @@ import { makeInputs } from './src/inputs.ts';
27
28
  import { idleKeysOf, isIdleKeys } from './src/rig.ts';
28
29
  import { depthSkipLine, exportLayers, figuresPhrase, hiddenLayerLine, implausibleRules, layerFigures, type LayerSet, layerSetDiff, pct, readLayers, readWrapperLayers, ruleSummary, templateLayerNames, templatePsdBytes, times } from './src/layers.ts';
29
30
  import { checkImageSize, choosePerson, KEYPOINTS_SPEC, loadKeypoints, type RigJoints, toRigJoints } from './src/keypoints.ts';
30
- import { basisLines, branchSqueezeLine, checkProposal, compare, compareLines, DEFAULT_GRID_RULE, draftTurn, drawLandmarks, FIGURE_GRID_DIVISOR, FRINGE_STEP_PX, GRID_RULES, type GridRule, HIP_MIN_FRACTION, type DraftLintFinding, lint, lintLine, type PartSet, proposalAsConfig, proposeWithBasis, readPartSet, serializeProposal, withTurn, WRIST_ON_PART_PX } from './src/propose.ts';
31
- import { BASIS_FILE, BASIS_SPEC, basisFile, basisLine, coverageLines, serializeBasis } from './src/diagnostics.ts';
31
+ import { basisLines, branchSqueezeLine, checkProposal, compare, compareLines, DEFAULT_GRID_RULE, draftTurn, drawLandmarks, FIGURE_GRID_DIVISOR, FRINGE_STEP_PX, GRID_RULES, type GridRule, HIP_MIN_FRACTION, type DraftLintFinding, lint, lintLine, type PartSet, PROPOSAL_CONFIG_SECTIONS, proposalAsConfig, proposeWithBasis, readPartSet, serializeProposal, withTurn, WRIST_ON_PART_PX } from './src/propose.ts';
32
+ import { BASIS_FILE, BASIS_SPEC, basisFile, basisLine, coverageLines, legChainChoice, serializeBasis } from './src/diagnostics.ts';
32
33
  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, relativeConfigPaths, TOLERANCE_FIGURE_DIVISOR } from './src/meshdraft.ts';
33
34
  import { meshStatsStage } from './src/meshstats.ts';
34
35
  import { chainsLeftBehind, clothConfigEdits, editPaths, plainCount, previousCount, writtenClothConfig } from './src/clothconfig.ts';
@@ -269,13 +270,24 @@ usage:
269
270
  coverage lines after them; no ${BASIS_FILE}, as nothing is proposed.
270
271
  Exits 1 when any LINT line is printed.
271
272
 
273
+ rig-parts propose … --write-config <config.json>
274
+ Without --cloth, update an existing config with the whole proposal's
275
+ present config sections, including proposed_rig_scale; notes stay in the
276
+ proposal. Absent optional sections and all other fields are kept. The
277
+ full config loader validates a same-directory candidate before atomic
278
+ replacement; relative paths resolve beside the config. An equal update
279
+ keeps the original bytes and mtime. A changed source, an artifact alias
280
+ or a failed write is refused by name. Change lines go to stderr.
281
+
272
282
  rig-parts propose --head-box --full <dir | layers.json | file.psd> --canvas <W>x<H> [--no-back-hair]
283
+ [--write-config <config.json>]
273
284
  Propose seethrough.head_box (source px, square) from the full run's
274
285
  layers for a painting of WxH px (portrait or landscape: the full run is
275
286
  mapped back through the pad inputs added), held inside the painting; a
276
287
  shift is printed when one was needed. When "back hair" contributes, prints
277
288
  the box without it and how much of the side and area it took;
278
- --no-back-hair leaves "back hair" out of the box.
289
+ --no-back-hair leaves "back hair" out of the box. --write-config updates
290
+ only seethrough.head_box in an existing config through the layers loader.
279
291
 
280
292
  rig-parts compare --left <file> --right <file> [--map <bonemap.json>]
281
293
  Compare two skeletons bone by bone. Each file is a config, a proposal.json
@@ -697,7 +709,7 @@ usage:
697
709
  ${DEFAULT_PROJECT_RULE}.
698
710
 
699
711
  rig-parts assemble --propose-plan --source <painting.png> --full <dir|psd>
700
- [--head <dir|psd>] --config <config.json>
712
+ [--head <dir|psd>] --config <config.json> [--write-config <config.json>]
701
713
  Print {plan, extend_below_crop, notes} for config.assemble, from the two
702
714
  runs. A layer \`layers\` WARNs about (PLAN_LAYER_TRANSLUCENT,
703
715
  PLAN_LAYER_BACKGROUND, PLAN_LAYER_OVERSIZED) is not proposed, and a note
@@ -709,7 +721,8 @@ usage:
709
721
  config.seethrough.resolution and config.assemble.rig_scale — the rest of
710
722
  the config need not exist yet. With no --head, the one run is held as
711
723
  assemble holds it and every row is written from it, and the first note
712
- says so.
724
+ says so. --write-config must name --config itself; it updates the whole
725
+ assemble.plan and assemble.extend_below_crop through the assemble loader.
713
726
 
714
727
  rig-parts inputs --source <painting.png> --config <config.json> --out <dir>
715
728
  Cut the two images See-through is fed: <out>/st_input_full.png (the
@@ -975,7 +988,7 @@ const TAKES = {
975
988
  compare: { valued: ['--left', '--right', '--map'], switches: [] },
976
989
  check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements', '--set', '--environment'], switches: ['--seam-pairs', '--keep-stills', '--all-sets'] },
977
990
  loop: { valued: ['--frames', '--out'], switches: ['--palette'] },
978
- assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project', '--alt'], switches: ['--propose-plan'] },
991
+ assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project', '--alt', '--write-config'], switches: ['--propose-plan'] },
979
992
  'comfy seethrough': { valued: ['--image', '--out', '--resolution', '--steps', '--seed', '--prefix', '--host', '--wait', '--timeout', '--poll'], switches: ['--offload', '--lama', '--nf4'] },
980
993
  'comfy paint': { valued: ['--config', '--out', '--seeds', '--seed0', '--host', '--wait', '--timeout', '--poll'], switches: [] },
981
994
  inputs: { valued: ['--source', '--config', '--out'], switches: [] },
@@ -1267,7 +1280,7 @@ function parseCanvas(v: string): { w: number; h: number } | null {
1267
1280
  return m === null ? null : { w: Number(m[1]), h: Number(m[2]) };
1268
1281
  }
1269
1282
 
1270
- function printLint(P: PartSet, spec: { bones: CharacterConfig['bones']; meshes: CharacterConfig['meshes']; regions: CharacterConfig['regions']; motion: CharacterConfig['motion']; constraints?: CharacterConfig['constraints'] }, joints?: RigJoints, drafted: readonly DraftLintFinding[] = []): number {
1283
+ function printLint(P: PartSet, spec: { bones: CharacterConfig['bones']; meshes: CharacterConfig['meshes']; regions: CharacterConfig['regions']; motion: CharacterConfig['motion']; constraints?: CharacterConfig['constraints']; limbs?: CharacterConfig['limbs']; notes?: readonly string[] }, joints?: RigJoints, drafted: readonly DraftLintFinding[] = []): number {
1271
1284
  const res = lint(P, spec, joints, drafted);
1272
1285
  // Under --keypoints only: which rule set read the torso and the chains, and on what basis.
1273
1286
  if (res.basis !== undefined) for (const l of basisLines(res.basis)) console.log(l);
@@ -1285,7 +1298,7 @@ function printLint(P: PartSet, spec: { bones: CharacterConfig['bones']; meshes:
1285
1298
  }
1286
1299
  console.log(`${res.findings.length} LINT line(s) over ${Object.keys(spec.meshes).length - res.unknownMeshes.length} mesh(es) and the hip`);
1287
1300
  // Issue #86: after every line above, which check read each bone, or why none did.
1288
- for (const l of coverageLines(res.coverage)) console.log(l);
1301
+ for (const l of coverageLines(res.coverage, legChainChoice(P, spec, joints))) console.log(l);
1289
1302
  return res.findings.length;
1290
1303
  }
1291
1304
 
@@ -1311,17 +1324,24 @@ function cmdPropose(args: string[]): number {
1311
1324
  flags.set(flag, value);
1312
1325
  i++;
1313
1326
  }
1327
+ let staged: StagedConfigWrite | null = null;
1314
1328
  try {
1315
1329
  if (headBox) {
1316
- const stray = [...flags.keys()].filter((f) => f !== '--full' && f !== '--canvas');
1317
- if (stray.length > 0) return usage(`--head-box takes --full and --canvas only; got ${stray.join(', ')}`);
1330
+ const stray = [...flags.keys()].filter((f) => f !== '--full' && f !== '--canvas' && f !== '--write-config');
1331
+ if (stray.length > 0) return usage(`--head-box takes --full, --canvas and optional --write-config only; got ${stray.join(', ')}`);
1318
1332
  const full = flags.get('--full');
1319
1333
  const canvasArg = flags.get('--canvas');
1320
1334
  if (full === undefined) return usage('--head-box needs --full <dir | layers.json | file.psd>, the full run\'s layers');
1321
1335
  if (canvasArg === undefined) return usage('--head-box needs --canvas <W>x<H>, the painting\'s size in px');
1322
1336
  const canvas = parseCanvas(canvasArg);
1323
1337
  if (canvas === null) return usage(`--canvas ${canvasArg} is not <W>x<H> in integer px`);
1338
+ const target = flags.has('--write-config') ? readConfigWriteTarget(flags.get('--write-config') as string) : null;
1324
1339
  const r = proposeHeadBox(readLayers(full), canvas, { backHair: !noBackHair });
1340
+ if (target !== null) {
1341
+ staged = stageConfigWrite(target, { kind: 'head-box', head_box: r.head_box });
1342
+ staged.commit();
1343
+ for (const line of configWriteLines(target, staged.changes)) console.error(line);
1344
+ }
1325
1345
  console.log(`rig-parts propose --head-box: ${full}`);
1326
1346
  console.log(` painting ${canvas.w}x${canvas.h}; head layers ${r.layers.map(([n, c]) => `${n} ${c} px`).join(', ')}`);
1327
1347
  if (r.shift[0] !== 0 || r.shift[1] !== 0) {
@@ -1372,16 +1392,21 @@ function cmdPropose(args: string[]): number {
1372
1392
  // Issue #537: the --cloth config's own text with what its recipe drafts spliced in, beside proposal.json.
1373
1393
  const writeArg = flags.get('--write-config');
1374
1394
  if (writeArg !== undefined && flags.has('--from-config')) return usage('--write-config writes what the recipe drafts into its config; --from-config proposes nothing');
1375
- if (writeArg !== undefined && clothArg === undefined) return usage('--write-config writes the --cloth config with what its recipe drafts spliced in; give --cloth <config.json>');
1376
1395
  // Issue #571: the config the recipe is changed from; an entry equal to its recipe's draft is the recipe's, not a hand edit.
1377
1396
  const fromArg = flags.get('--from');
1378
1397
  if (fromArg !== undefined && writeArg === undefined) return usage('--from names the config the recipe is changed from, for --write-config; give --write-config <out.json>');
1379
- if (writeArg !== undefined) {
1380
- const ours = ['proposal.json', BASIS_FILE].map((f) => resolve(out, f));
1398
+ if (fromArg !== undefined && clothArg === undefined) return usage('--from names a previous cloth recipe; give --cloth <config.json>');
1399
+ if (writeArg !== undefined && clothArg !== undefined) {
1400
+ const ours = ['proposal.json', BASIS_FILE, 'render/landmarks.png', 'render/landmarks_head.png'].map((f) => resolve(out, f));
1381
1401
  if (existsSync(writeArg) || ours.includes(resolve(writeArg))) {
1382
1402
  throw new PartsError([{ code: 'PROPOSE_WRITE_CONFIG_FRESH', object: writeArg, detail: existsSync(writeArg) ? 'exists; the config is written to a new file, never over the --cloth config or an earlier one' : `is a file this run writes under ${out}; name another file` }]);
1383
1403
  }
1404
+ separateConfigWriteTarget({ path: resolve(writeArg) }, ours, 'PROPOSE_WRITE_CONFIG_FRESH');
1384
1405
  }
1406
+ // The existing cloth writer keeps its fresh-file, per-entry splice contract.
1407
+ // Without --cloth, #356 updates only an existing config through the stage loader.
1408
+ const target = writeArg === undefined || clothArg !== undefined ? null : readConfigWriteTarget(writeArg);
1409
+ if (target !== null) separateConfigWriteTarget(target, [join(out, 'proposal.json'), join(out, BASIS_FILE), join(out, 'render', 'landmarks.png'), join(out, 'render', 'landmarks_head.png')]);
1385
1410
  const cloth = clothArg === undefined ? null : loadClothConfig(clothArg).cloth;
1386
1411
  const previousCloth = fromArg === undefined ? null : loadClothConfig(fromArg).cloth;
1387
1412
  const partsDir = existsSync(partsArg) && statSync(partsArg).isFile() ? dirname(partsArg) : partsArg;
@@ -1455,14 +1480,16 @@ function cmdPropose(args: string[]): number {
1455
1480
  })();
1456
1481
  const bfile = basisFile(P, prop, basis, cfg === null || cmpPath === undefined ? null : { where: cmpPath, config: cfg });
1457
1482
  const img = drawLandmarks(P, painting, prop.bones, 'PROPOSAL - CORRECT ME');
1483
+ if (target !== null) staged = stageConfigWrite(target, { kind: 'proposal', proposal: prop, sections: PROPOSAL_CONFIG_SECTIONS });
1484
+ else if (written !== null) staged = stageConfigWrite({ kind: 'fresh', path: written.at, text: written.text });
1458
1485
  mkdirSync(render, { recursive: true });
1459
1486
  writeFileSync(join(out, 'proposal.json'), serializeProposal(prop));
1460
1487
  writeFileSync(join(out, BASIS_FILE), serializeBasis(bfile));
1461
1488
  writePng(join(render, 'landmarks.png'), img.full);
1462
1489
  if (img.head !== null) writePng(join(render, 'landmarks_head.png'), img.head);
1463
- if (written !== null) {
1464
- mkdirSync(dirname(written.at), { recursive: true });
1465
- writeFileSync(written.at, written.text);
1490
+ if (staged !== null) {
1491
+ staged.commit();
1492
+ if (target !== null) for (const line of configWriteLines(target, staged.changes)) console.error(line);
1466
1493
  }
1467
1494
  console.log(`wrote proposal.json (${prop.bones.length} bone entries, ${Object.keys(prop.meshes).length} meshes) and render/landmarks.png (+_head) under ${out}`);
1468
1495
  for (const n of prop.notes) console.log(`note: ${n}`);
@@ -1484,7 +1511,12 @@ function cmdPropose(args: string[]): number {
1484
1511
  if (cfg !== null) for (const l of compareLines(compare(prop.bones, cfg.bones))) console.log(l);
1485
1512
  return EXIT_OK;
1486
1513
  } catch (err) {
1514
+ if (staged !== null) {
1515
+ try { staged.abort(err); } catch (combined) { return printRefusal(combined); }
1516
+ }
1487
1517
  return printRefusal(err);
1518
+ } finally {
1519
+ staged?.cancel();
1488
1520
  }
1489
1521
  }
1490
1522
 
@@ -1566,6 +1598,7 @@ function cmdAssemble(args: string[]): number {
1566
1598
  // --head is optional (issue #359): without it the full run is the one run, and it must hold the face.
1567
1599
  for (const f of ['--source', '--full', '--config']) if (!flags.has(f)) return usage(`assemble needs ${f}`);
1568
1600
  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');
1601
+ if (!propose && flags.has('--write-config')) return usage('--write-config belongs to assemble --propose-plan');
1569
1602
  if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
1570
1603
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1571
1604
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
@@ -1573,13 +1606,22 @@ function cmdAssemble(args: string[]): number {
1573
1606
  if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
1574
1607
  const [source, full, config] = ['--source', '--full', '--config'].map((f) => flags.get(f) as string);
1575
1608
  const head = flags.get('--head') ?? null;
1609
+ const writePath = flags.get('--write-config');
1610
+ if (writePath !== undefined && resolve(writePath) !== resolve(config)) return usage('--write-config must name the same file as --config for assemble --propose-plan');
1611
+ let staged: StagedConfigWrite | null = null;
1576
1612
  try {
1577
1613
  if (propose) {
1614
+ const target = writePath === undefined ? null : readConfigWriteTarget(writePath);
1578
1615
  const src = readSource(source);
1579
1616
  const runs = readRuns(full, head);
1580
1617
  const g = proposeFields(loadEarlyConfig(config, 'layers'), runs.single ? { w: src.width, h: src.height } : null);
1581
1618
  const geometry = { sourceW: src.width, sourceH: src.height, ...g };
1582
1619
  const proposal = runs.single ? proposeSinglePlan(runs.full, geometry) : proposePlan(runs.full, runs.head, geometry);
1620
+ if (target !== null) {
1621
+ staged = stageConfigWrite(target, { kind: 'assemble-plan', plan: proposal.plan, extend_below_crop: proposal.extend_below_crop });
1622
+ staged.commit();
1623
+ for (const line of configWriteLines(target, staged.changes)) console.error(line);
1624
+ }
1583
1625
  console.log(JSON.stringify(proposal, null, 2));
1584
1626
  return EXIT_OK;
1585
1627
  }
@@ -1591,7 +1633,12 @@ function cmdAssemble(args: string[]): number {
1591
1633
  );
1592
1634
  return EXIT_OK;
1593
1635
  } catch (err) {
1636
+ if (staged !== null) {
1637
+ try { staged.abort(err); } catch (combined) { return printRefusal(combined); }
1638
+ }
1594
1639
  return printRefusal(err);
1640
+ } finally {
1641
+ staged?.cancel();
1595
1642
  }
1596
1643
  }
1597
1644