rig-parts 1.57.1 → 1.58.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
 
package/docs/AUTHORING.md CHANGED
@@ -47,7 +47,17 @@ with `flattenRig(built)`, not with the built file.
47
47
 
48
48
  The painting is yours to bring: a PNG of one character, front-facing, full body,
49
49
  taller than wide, on a plain light background. How it was made is not read by any
50
- stage (`config.generation` is recorded for the optional adapter only).
50
+ stage (`config.generation` is recorded for the optional adapter only). rig-parts
51
+ ships no weights and generates no images itself. The optional `comfy` adapter
52
+ drives models on the user's own ComfyUI host. The default OpenPose ControlNet
53
+ and upscaler sources, pinned model cards and declared terms are in
54
+ [NOTICE](../NOTICE.md#models-named-by-the-optional-painting-graph).
55
+ `comfy seethrough --offload` selects the wrapper's CUDA group offload on that
56
+ host; omit it for a local Mac/MPS host. A Mac CLI may drive a remote CUDA host.
57
+ This offload limitation does not establish MPS compatibility for the rest of the
58
+ model stack. The wrapper README and the adapter graph use different node names
59
+ and depth-model identifiers; [the adapter notes](../README.md#the-optional-comfyui-adapter)
60
+ name the graph actually sent.
51
61
 
52
62
  ## 2. The folder
53
63
 
@@ -2046,10 +2056,13 @@ moved on the public `wind` example with its keypoints, whose tracked config the
2046
2056
  wrote back byte for byte (measured once, when the flag landed). The text is written as stated: a relative path in it
2047
2057
  (`motion.animations_from`, `generation.control.skeleton`) resolves against the directory
2048
2058
  the written config sits in, so the out path belongs beside the input, and when it is not
2049
- a note names each such path. Refused before anything is written: `--write-config`
2050
- without `--cloth` or beside `--from-config`, and `--from` without `--write-config`
2051
- (`USAGE`), an out path that exists or that
2052
- the run writes itself (`PROPOSE_WRITE_CONFIG_FRESH`), a hand edit to an entry the
2059
+ a note names each such path. These are the cloth mode's rules; the three other
2060
+ `--write-config` modes are described in §4. Refused before anything is written:
2061
+ `--write-config` beside `--from-config`, and `--from` without both `--cloth` and `--write-config`
2062
+ (`USAGE`), an out path that exists or collides with any of the four artifacts
2063
+ under `--out` (`proposal.json`, `basis.json`, `render/landmarks.png`,
2064
+ `render/landmarks_head.png`), including directory-symlink and hardlink aliases
2065
+ (`PROPOSE_WRITE_CONFIG_FRESH`), a hand edit to an entry the
2053
2066
  recipe owns (`PROPOSE_WRITE_CONFIG_CONFLICT`), and a text whose splice does not
2054
2067
  parse to the config with the sections set — a key stated twice
2055
2068
  (`PROPOSE_WRITE_CONFIG_SPLICE`); a recipe that drafts nothing is
@@ -2446,21 +2459,28 @@ full loader before its assemble stage writes anything.
2446
2459
  a painting padded to a square by hand, whose padding becomes part of the rig.
2447
2460
  Config: the `layers` row, `head_box` not yet. A translucent painting is refused
2448
2461
  (`INPUTS_PAINTING_OPAQUE`).
2449
- 2. **See-through, full run** (external).
2462
+ 2. **See-through, full run** (external), on `inputs/st_input_full.png`. Upload
2463
+ that white-padded square to the Hugging Face Space too. Its [current source](https://huggingface.co/spaces/24yearsold/see-through-demo/blob/049a1ab07908e404aad03f73431dd1a98f88202c/common/utils/cv.py#L254-L274)
2464
+ pads non-square images before resizing; the earlier centre-crop report is not
2465
+ the current source path. The explicit white square keeps the input consistent
2466
+ with rig-parts' full-run coordinate map.
2450
2467
  3. `rig-parts layers inputs/layers/full` — read it. A refusal here is about the
2451
2468
  files, not the art (§6, *Reading the inputs*). A `WARN` line is about the art: a
2452
2469
  layer See-through made that is not plausibly part of the figure, which step 6
2453
2470
  leaves out (§6, *Plausibility*). It refuses nothing.
2454
- 4. `rig-parts propose --head-box --full inputs/layers/full --canvas <W>x<H>` →
2455
- `seethrough.head_box`. Run `rig-parts inputs` again: it now also writes
2471
+ 4. `rig-parts propose --head-box --full inputs/layers/full --canvas <W>x<H> --write-config config.json`
2472
+ saves `seethrough.head_box` in the existing config. `<W>x<H>` is the original
2473
+ painting's size, not the padded square's. The existing optional `--no-back-hair`
2474
+ leaves the back-hair layer out of the head-box proposal; without it that layer
2475
+ contributes to the box. Run `rig-parts inputs` again: it now also writes
2456
2476
  `st_input_head.png`, the painting cropped to that box at its exact size.
2457
2477
  5. **See-through, head run** (external), on that crop. See-through's own second,
2458
2478
  head stage does not replace it: that stage's layers are pasted back at the full
2459
2479
  run's scale on every route that was read (README *Getting See-through layers*),
2460
2480
  so the full run's eyes stay at its density — 22×13 px for reference-demo's right eye
2461
2481
  white at `resolution` 1024, against 60×38 px in the head run.
2462
- 6. `rig-parts assemble --propose-plan --source … --full … --head … --config config.json`
2463
- → paste `plan` and `extend_below_crop` into `config.assemble`. Config: the
2482
+ 6. `rig-parts assemble --propose-plan --source … --full … --head … --config config.json --write-config config.json`
2483
+ saves `assemble.plan` and `assemble.extend_below_crop`. Config: the
2464
2484
  `layers` row, with `head_box`.
2465
2485
  Read `notes`: every layer not planned — each tag a layer of either run holds that no
2466
2486
  row takes — is named there once, `<tag>: in no plan row — …`, with why, run by run: the
@@ -2491,8 +2511,8 @@ full loader before its assemble stage writes anything.
2491
2511
  7. `rig-parts assemble … --out work` — read its `uncovered hole` and `uncovered:` lines and look at
2492
2512
  `work/render/recomposite_error_rig.png` (§5): red is painting no part holds, which
2493
2513
  no later stage can see — then
2494
- `rig-parts propose --parts work/rig --source inputs/painting.png --out work` →
2495
- `work/proposal.json` and `work/render/landmarks.png` (+ `_head`). Copy
2514
+ `rig-parts propose --parts work/rig --source inputs/painting.png --out work --write-config config.json` →
2515
+ `work/proposal.json` and `work/render/landmarks.png` (+ `_head`). The flag saves
2496
2516
  each of `bones`, `meshes`, `regions`, `limbs`, `motion`, `constraints`, `turn` and `proposed_rig_scale` it
2497
2517
  wrote into the config: `limbs` is written only with `--keypoints`, `constraints`
2498
2518
  only with `--cloth` and `turn` only with `--full`/`--head` (`PROPOSAL_CONFIG_SECTIONS` in `src/propose.ts` is the list,
@@ -2508,6 +2528,13 @@ full loader before its assemble stage writes anything.
2508
2528
  `[bone, from, to]` segment (the off-art check reads only chain links), a
2509
2529
  region, a mesh that names no part, a torso bone absent, a chain the joints
2510
2530
  declare no line for — and a `coverage:` summary counts the three apart.
2531
+ Before the totals, `coverage leg-chain choice:` reports the `leg_r` and
2532
+ `leg_l` chains declared by the supplied proposal or config, their count and
2533
+ recorded source/part bindings, or why none was selected. A missing source is
2534
+ `not recorded`, a chain with no bound parts says `none bound`; nothing is
2535
+ inferred to fill either. The line is selection only, not a checked verdict.
2536
+ Read it and the per-part notes again when changing keypoint sources: a bone
2537
+ map for two leg chains does not cover a proposal that bound only one.
2511
2538
  A `not checked` bone is one nothing verified: look at it on
2512
2539
  `landmarks.png`. `work/basis.json` (spec `spine-parts-basis/1`) says, per
2513
2540
  proposed bone, what its origin and its stated tip rest on: `joint` (which,
@@ -2637,9 +2664,13 @@ full loader before its assemble stage writes anything.
2637
2664
  relative `motion.animations_from` or `generation.control.skeleton` keeps its
2638
2665
  text, so it resolves against `draft/`; the command says so.
2639
2666
 
2640
- The selftest runs this order, with the README's flags, on each fetched example from
2641
- a config holding only `key`, `seethrough` and `assemble.rig_scale`, pasting each
2642
- proposal in as the steps above say (`RL01`); every step must exit 0.
2667
+ The command-order selftest runs these stages on each fetched example from a
2668
+ config holding only `key`, `seethrough` and `assemble.rig_scale` (`RL01`). For
2669
+ the proposal step, copy `bones`, `meshes`, `regions`, `limbs`, `motion`,
2670
+ `constraints`, `turn` and `proposed_rig_scale` into the config, each section the
2671
+ proposal wrote. `RL01` exercises that manual route and does not execute
2672
+ `--write-config`. The save forms above write the fields owned by their mode;
2673
+ a saved draft still needs validation.
2643
2674
 
2644
2675
  ### Counting the triangles before a build (`mesh-stats`, issue #566)
2645
2676
 
@@ -2796,6 +2827,43 @@ file's figure, and every part is the clean file's but `hair_back`, which keeps a
2796
2827
  one of them occluded, so nothing shows it at rest; paint it there if the face will
2797
2828
  move off it (`AS245`).
2798
2829
 
2830
+ **Saving proposed config fields (`--write-config`, issue #356).** The flag is a
2831
+ valued destination path. Its modes have different ownership:
2832
+
2833
+ | proposal mode | destination | fields written |
2834
+ | --- | --- | --- |
2835
+ | `propose --head-box` | existing regular config | `seethrough.head_box` |
2836
+ | `assemble --propose-plan` | existing regular config; must name `--config` itself | `assemble.plan`, `assemble.extend_below_crop` |
2837
+ | ordinary `propose --parts` without `--cloth` | existing regular config | each present `PROPOSAL_CONFIG_SECTIONS` value: `bones`, `meshes`, `regions`, `limbs`, `motion`, `constraints`, `turn`, `proposed_rig_scale` |
2838
+ | `propose --cloth <input.json>` | new file | the recipe's edits spliced into the cloth config; §3, *The config written*, gives entry ownership, hand-edit conflicts and `--from <previous.json>` |
2839
+
2840
+ For the first three modes, each selected field replaces its whole value; other
2841
+ fields and optional proposal sections not emitted are retained. Changed whole
2842
+ values are spliced into the original text through `splicedEdits`, preserving
2843
+ untouched bytes. `notes` stays in the proposal. The stage's loader validates a same-directory candidate before
2844
+ replacement, so relative file references keep their base and invalid retained
2845
+ references are refused. A scale mismatch retains `CONFIG_PROPOSED_RIG_SCALE`;
2846
+ the writer does not change `assemble.rig_scale` to hide it. An unchanged update
2847
+ keeps the original bytes and mtime. A final source-change check precedes atomic
2848
+ replacement; this is a config-file commit, not a transaction over every artifact
2849
+ of the proposing command. A refusal leaves the target config unchanged. The
2850
+ writer lists changes on stderr, keeping the existing head-box and plan JSON on
2851
+ stdout. Without the flag, no config is changed. `--from-config` proposes nothing
2852
+ and refuses this flag; `--from` belongs to the cloth mode only.
2853
+ All four modes use one file-writing adapter. Fresh cloth output keeps its
2854
+ already-checked splice text and intentional partial-config contract; the full
2855
+ loader is not imposed on it. Publication links the complete same-directory
2856
+ staged file into a destination that must still be absent, so an intervening
2857
+ creator is refused without overwrite. Removing the staged temporary happens
2858
+ after publication: if that cleanup fails, `CONFIG_WRITE_FAILED` may accompany
2859
+ an already-published complete config. Inspect both named paths before retrying. A different
2860
+ `--write-config` destination for `assemble --propose-plan` is a usage error.
2861
+ For ordinary and cloth proposals, the destination must be separate from all four
2862
+ artifacts under `--out`: `proposal.json`, `basis.json`, `render/landmarks.png` and
2863
+ `render/landmarks_head.png`. Matching paths, directory-symlink aliases and
2864
+ existing hardlinks are refused before artifact output; the ordinary mode names
2865
+ `CONFIG_WRITE_TARGET`, and the cloth mode `PROPOSE_WRITE_CONFIG_FRESH`.
2866
+
2799
2867
  ## 5. After each stage: what to read
2800
2868
 
2801
2869
  | stage | read | green looks like | a common red |
@@ -3301,9 +3369,12 @@ See-through with another seed.
3301
3369
 
3302
3370
  | rule | means | change |
3303
3371
  | --- | --- | --- |
3372
+ | <a id="CONFIG_WRITE_TARGET"></a>`CONFIG_WRITE_TARGET` | ordinary `propose … --write-config` would overwrite its config through one of its own four artifact paths under `--out`: `proposal.json`, `basis.json`, `render/landmarks.png`, `render/landmarks_head.png`; the collision check resolves existing path ancestors and compares existing file identity, so directory-symlink aliases and hardlinks count too. Refused before artifact output; the cloth mode names the same collision `PROPOSE_WRITE_CONFIG_FRESH` | choose a config outside all four artifact paths and their aliases |
3373
+ | <a id="CONFIG_WRITE_CHANGED"></a>`CONFIG_WRITE_CHANGED` | the existing destination no longer matches the snapshot read for `--write-config` when the writer is ready to commit: it is absent, no longer a regular file, or its permissions or text differ. The writer refuses to replace the current destination | keep the current config, reconcile any concurrent edit, and rerun against it |
3374
+ | <a id="CONFIG_WRITE_FAILED"></a>`CONFIG_WRITE_FAILED` | a proposed-value splice moved an unintended field, or a config-writing operation failed: reading, checking collisions, staging, writing proposal artifacts, publishing or replacing the destination, or removing the staged temporary. The message names the action and path. Cleanup failures retain the primary failure and name the stranded temporary. Fresh cloth publication precedes temporary cleanup, so a cleanup failure may leave the complete new config present; there is no transaction over every proposal artifact | inspect the named action, destination and temporary; restore access or permissions as needed; remove only the specifically reported stranded temporary, and inspect any published config before retrying |
3304
3375
  | <a id="CONFIG_FILE_PRESENT"></a><a id="CONFIG_IS_JSON"></a>`CONFIG_FILE_PRESENT`, `CONFIG_IS_JSON` | no such file, or not JSON; for a file the config names (`motion.animations_from`, `requirements`), the path is read relative to the config's directory, and the `motion.animations_from` line names the path as typed, that directory and what the two resolved to (issue #569) | `--config`; a file the config names: put it at the resolved path, or write the path from the config's directory |
3305
3376
  | <a id="CONFIG_KEY_KNOWN"></a><a id="CONFIG_KEY_RETIRED"></a><a id="CONFIG_FIELD_PRESENT"></a><a id="CONFIG_FIELD_TYPE"></a>`CONFIG_KEY_KNOWN`, `CONFIG_KEY_RETIRED`, `CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE` | an unknown key (a retired one says what replaces it), a missing one (a `motion.animations_from` file with no `spec` is told to write `"spec": "rigc-motion/1"` and the whole file's shape, issue #569), a wrong type (`bones[i].link_inherit` must be an object whose values are strings); a project's own record under a plain key, or an annotation (`note`, `<name>_note`) holding something other than a string; in `meshes.<part>.auto` (§3, *An automatic mesh*), a missing or out-of-range number, a Stage B opt-in that is not exactly a value rig-c accepts, or a field the mode does not have — a bound on one of the five allocation rows, or an authored `motionAmplitude` (it is derived, never written); under `comfy paint`, `CONFIG_FIELD_PRESENT` on `config.generation`: a config with no `generation` block (the line names the fields it holds) | the named field; a record goes under a key beginning `x-` (any JSON, read by nothing), a remark under `note` or `<name>_note` (a string) — §3; under `comfy paint`, `config.generation` |
3306
- | <a id="CONFIG_KEY_UNIQUE"></a>`CONFIG_KEY_UNIQUE` | a key written twice in one object of a file the config names — the pose file `generation.control.skeleton` names, or the `motion.animations_from` file; JSON keeps only the last value, so every other would be dropped without a word | that file: each key once |
3377
+ | <a id="CONFIG_KEY_UNIQUE"></a>`CONFIG_KEY_UNIQUE` | a key written twice in an existing config updated by `--write-config`, or in one object of a file the config names — the pose file `generation.control.skeleton` names, or the `motion.animations_from` file; JSON keeps only the last value, so every other would be dropped without a word | that file: each key once |
3307
3378
  | <a id="CONFIG_ANIMATION_NAME_FREE"></a>`CONFIG_ANIMATION_NAME_FREE` | the `motion.animations_from` file holds an animation named `idle`, the animation `config.motion` writes | rename that animation |
3308
3379
  | <a id="CONFIG_HEAD_BOX_SQUARE"></a>`CONFIG_HEAD_BOX_SQUARE` | `seethrough.head_box` is not a non-empty square | `head_box` — take `propose --head-box`'s |
3309
3380
  | <a id="CONFIG_TAG_KNOWN"></a><a id="CONFIG_PART_NAME"></a><a id="CONFIG_PART_UNIQUE"></a>`CONFIG_TAG_KNOWN`, `CONFIG_PART_NAME`, `CONFIG_PART_UNIQUE` | a plan or extend entry names neither a v3 tag nor `author:<label>` (an `author:` tag with a malformed label says what the label must be), a part name that is not a file name, or a part or layer twice — a patch named like a plan part or another patch, and a cut's `into` named like a plan part, a patch or another cut's, included | `assemble.plan`, `assemble.patches`, `assemble.cuts` |
@@ -3439,7 +3510,7 @@ says why each requirement exists and what breaks without it.
3439
3510
  | <a id="PROPOSE_FIGURE_LAYER"></a>`PROPOSE_FIGURE_LAYER` | with `--keypoints` (issue #577): a limb is drafted from the figure layer (no handwear or legwear part over 500 px for it), and two or more `topwear` parts each hold every joint and the trunk bones' origins within 15 px with the same opaque px in the 31x31 squares about them, so nothing says which is the figure; or the only `topwear` parts that hold them are named by a cloth recipe (`config.cloth.<tag>.part` or `.parts`, issue #631), whose mesh a limb would take (§3, *A figure of one material*) | leave the others out of `assemble.plan`, or give the arms and legs their own handwear and legwear layers; for a recipe's part, take it out of the recipe |
3440
3511
  | <a id="PROPOSE_CLOTH_DRAFTED"></a>`PROPOSE_CLOTH_DRAFTED` | with `--cloth` (issue #364): the config's `cloth` block holds a recipe for a tag the proposer drafts no chain on — no part has that tag, or every `back hair` part ends above the shoulders (a bun bone, no chain). A `bottomwear` recipe with no `bottomwear` part names every `topwear` part shaped like a skirt (issue #402) | leave that recipe out, or assemble the layer it is for, or declare the named part in `cloth.bottomwear.part` |
3441
3512
  | <a id="PROPOSE_CLOTH_PART"></a>`PROPOSE_CLOTH_PART` | with `--cloth` (issues #402, #437): `cloth.bottomwear.part` (a string) names no part, a part or cut piece whose source is not `topwear` (a `bottomwear` part takes the recipe by its tag already), or a target whose box ends above the hip + 0.25 face heights, the topwear rule's waist line; or `cloth.<tag>.parts` (or `part` as a list, issue #437) names a part not in `parts.json`, a part no link of the drafted tree crosses (`spanCrosses == 0`), or parts out of root-to-tip depth order along the chain | name the `topwear` part or its cut skirt piece that holds the skirt (or leave `part` out), or list only parts in `parts.json` that the drafted tree links cross in root-to-tip order |
3442
- | <a id="PROPOSE_WRITE_CONFIG_FRESH"></a>`PROPOSE_WRITE_CONFIG_FRESH` | with `--write-config` (issue #537): the out path exists, or is a file the run writes itself (`proposal.json`, `basis.json` under `--out`); the config is written to a new file, never over the `--cloth` config or an earlier one | name a file that does not exist yet, beside the `--cloth` config |
3513
+ | <a id="PROPOSE_WRITE_CONFIG_FRESH"></a>`PROPOSE_WRITE_CONFIG_FRESH` | with `--cloth … --write-config` (issues #537, #356): the out path exists or collides with one of the four artifacts under `--out` — `proposal.json`, `basis.json`, `render/landmarks.png`, `render/landmarks_head.png` — including directory-symlink and hardlink aliases; the config is written to a new file, never over the `--cloth` config or an earlier one | name a file that does not exist yet, beside the `--cloth` config and separate from all four artifact paths and their aliases |
3443
3514
  | <a id="PROPOSE_WRITE_CONFIG_CONFLICT"></a>`PROPOSE_WRITE_CONFIG_CONFLICT` | with `--write-config` (issue #552): an entry of `bones`, `constraints` or `motion.tracks` the recipe re-drafts or removes was changed by hand in the `--cloth` config — it equals neither the draft with the recipe nor the draft without it — so the write would drop the hand edit; the object is the entry (`config.constraints[40] "skirt0a_0_physics"` in CL207), the detail names the first field the hand edit holds and what the draft writes there. With `--from <previous config>` (issue #571) an entry equal to the previous recipe's draft is the recipe's too, and the conflict is an entry that equals neither the draft, the draft without the recipe, nor the previous recipe's draft: the detail says so and reads the first differing field against the previous recipe's draft. Also a name the config states twice where the recipe drafts it. Every conflict is named at once and nothing is written | take the draft by removing the hand edit (or the entry), or leave the recipe out for that part (its `cloth` block); state a name once |
3444
3515
  | <a id="PROPOSE_WRITE_CONFIG_SPLICE"></a>`PROPOSE_WRITE_CONFIG_SPLICE` | with `--write-config` (issue #537): the `--cloth` config's text with the drafted sections spliced in does not parse to that config with them set, and the message names the first key that moved — the text states a key twice, so the splice lands on the first and the parse keeps the last | state each key once in the `--cloth` config |
3445
3516
  | <a id="PROPOSE_CLOTH_BACK_HAIR_CHAINS"></a>`PROPOSE_CLOTH_BACK_HAIR_CHAINS` | with `--cloth` (issues #390, #445, #461): `cloth."back hair".chains` exceeds what the narrower side's opaque width at the eye-line root row holds at the part's mesh grid (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`), or places a tree root outside the 15 px dilated art mask of the `back hair` part (e.g. an inner twin-tail root over the face where the hair has no pixel); the message names the part, each side's root-row width, the grid spacing, and any off-art root with its pixel alpha | lower `cloth."back hair".chains` to at most the max fit named in the message, or omit `chains` so `propose --cloth` derives the tree count per visible hair mass on each side from its root-row width |
package/docs/STABILITY.md CHANGED
@@ -37,7 +37,10 @@ this version does not implement. No command is unimplemented at 1.0.0 (`LATER`
37
37
  in `cli.ts` is empty). A refusal line has the shape
38
38
  ` FAIL <RULE>: <object> — <value found>; <value required> (see rig-parts guide <RULE>)`:
39
39
  the rule name is a stable identifier, the pointer at the end is fixed text, and the
40
- detail between them is prose that may be reworded. A command
40
+ detail between them is prose that may be reworded. Existing named or child core
41
+ guide pointers in relayed compile-error text are preserved verbatim. Only a bare
42
+ core compile-error line receives `(see rigc guide §5.1)` inside the enclosing
43
+ rig-parts refusal; no core error code is invented. A command
41
44
  also answers to the alias `spine-parts` as long as the install keeps it
42
45
  ([MIGRATION.md](MIGRATION.md)). `-h`, `help` and `-v` are the short forms of
43
46
  `--help` and `--version`. The flags' meanings are `rig-parts --help`'s; their
@@ -51,9 +54,9 @@ defaults are the help's too, and none is guessed from the art.
51
54
  | `inputs` | `--source`, `--config`, `--out` |
52
55
  | `comfy` | `paint`: `--config`, `--out`; optional `--seeds`, `--seed0`, `--host`, `--wait`, `--timeout`, `--poll` |
53
56
  | `comfy` | `seethrough`: `--image`, `--out`; optional `--resolution`, `--steps`, `--seed`, `--prefix`, `--offload`, `--lama`, `--nf4`, `--host`, `--wait`, `--timeout`, `--poll` |
54
- | `assemble` | `--propose-plan` with `--source`, `--full`, `--config`, optional `--head`; or `--source`, `--full`, `--config`, `--out`, optional `--head` (without it, one run: issue #359), `--seam` (near-white, silhouette), `--project` (core, visible), `--alt` (repeatable `<name>=<source>:<full>[:<head>]`, issue #454) |
55
- | `propose` | `--head-box` with `--full`, `--canvas`; optional `--no-back-hair` |
56
- | `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`), `--full`, `--head` (the turn control's depths; not with `--from-config`), `--grid-rule` (`figure` or `part`, the drafted lattice's spacing; not with `--from-config`), `--cloth` (a config whose `cloth` block is the recipe, issue #364; not with `--from-config`), `--write-config` (a new file, with `--cloth` and not with `--from-config`, issue #537 — additive: without it nothing `propose` writes changes), `--from` (the config the recipe is changed from, only with `--write-config`, issue #571 — additive: without it nothing `--write-config` writes changes) |
57
+ | `assemble` | `--propose-plan` with `--source`, `--full`, `--config`, optional `--head`, `--write-config` (must name `--config` itself, issue #356); or `--source`, `--full`, `--config`, `--out`, optional `--head` (without it, one run: issue #359), `--seam` (near-white, silhouette), `--project` (core, visible), `--alt` (repeatable `<name>=<source>:<full>[:<head>]`, issue #454) |
58
+ | `propose` | `--head-box` with `--full`, `--canvas`; optional `--no-back-hair`, `--write-config` (existing config, issue #356) |
59
+ | `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`), `--full`, `--head` (the turn control's depths; not with `--from-config`), `--grid-rule` (`figure` or `part`, the drafted lattice's spacing; not with `--from-config`), `--cloth` (a config whose `cloth` block is the recipe, issue #364; not with `--from-config`), `--write-config` (existing config for an ordinary proposal, issue #356; a new file with `--cloth`, issue #537; not with `--from-config` — additive: without it nothing `propose` writes changes), `--from` (the config the recipe is changed from, only with `--write-config`, issue #571 — additive: without it nothing `--write-config` writes changes) |
57
60
  | `compare` | `--left`, `--right`; optional `--map` |
58
61
  | `rig` | `--config`, `--parts`, `--out`; optional `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
59
62
  | `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements`, `--seam-pairs`, `--keep-stills`, `--set` or `--all-sets` (issue #454), `--environment` (issue #538, additive) |
@@ -201,7 +204,8 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
201
204
  | the template `.psd` | `layers --template` | one empty layer per name `templateLayerNames` (`src/layers.ts`) holds, bottom first; the same canvas writes the same bytes |
202
205
  | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `constraints` with `--cloth` (issue #364: a physics constraint per tree link named `<bone>_physics`, a dangling end's `<bone>_translate`, and a skirt swing's transforms `<swing>_<tree>`), `turn` with `--full`/`--head`, `proposed_rig_scale` (issue #354), and `notes` |
203
206
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
204
- | the `--write-config` file | `propose --cloth … --write-config` | the `--cloth` config read, byte for byte, but for the entries the recipe drafts (issue #537), measured against the same parts drafted without it: `meshes.<part>` and `regions.<part>` for each part the recipe moves, and `bones`, `constraints` and `motion.tracks` entry by entry by name (issue #552) — an entry the recipe drafts replaced or inserted, one it replaces removed, every other entry kept with its bytes and its place, and a hand edit to an entry the recipe owns refused by name (`PROPOSE_WRITE_CONFIG_CONFLICT`) rather than dropped; a section the input lacks is inserted; with `--from <previous config>` (issue #571) an entry equal to the previous recipe's draft is the recipe's as well |
207
+ | an existing config named by `--write-config` | `propose --head-box`, `assemble --propose-plan`, ordinary `propose` | issue #356, additive: `assemble --propose-plan` must update `--config` itself; only the selected proposed fields are replaced by whole-value text splices, preserving untouched bytes; absent optional proposal sections and other fields remain; the stage loader validates before atomic replacement, relative paths keep their base, an unchanged update preserves bytes and mtime, and stderr lists changes; no multi-artifact transaction is promised |
208
+ | a new `--write-config` file | `propose --cloth … --write-config` | complete checked splice text published without overwrite; an intervening creator is refused, while cleanup failure after publication may leave the complete new file present; intentional partial configs retain their existing contract; the `--cloth` config read, byte for byte, but for the entries the recipe drafts (issue #537), measured against the same parts drafted without it: `meshes.<part>` and `regions.<part>` for each part the recipe moves, and `bones`, `constraints` and `motion.tracks` entry by entry by name (issue #552) — an entry the recipe drafts replaced or inserted, one it replaces removed, every other entry kept with its bytes and its place, and a hand edit to an entry the recipe owns refused by name (`PROPOSE_WRITE_CONFIG_CONFLICT`) rather than dropped; a section the input lacks is inserted; with `--from <previous config>` (issue #571) an entry equal to the previous recipe's draft is the recipe's as well |
205
209
  | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate` (with `bridges` — `units`, `joins` of `span` and `area_px2`, `area_px2`, `overdraw`, and `faint` only when an island was left out as faint — only when the candidate is a bridged one, issue #326), `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
206
210
  | `rig.json`, `motion.json`, `mesh_report.json`, `images/`, `remesh.json` | `remesh` | the input revision's files, `rig.json` and `mesh_report.json` with the part's attachment and row replaced (under `--delta` the row is the input's with its counts, `bones` and influences recomputed and `mode` "delta"), every other file byte for byte; `remesh.json` is spec `rig-parts-remesh/1`: `request` (`part`, `mesh`, `mesh_sha256`, `idle_keys`, `page_edges`, `pack_shape`; under `--delta`, issue #526: `part`, `delta` — the mesh edit as read — `delta_sha256`, `ops`, `applied` — `moved`, `added`, `removed`, `constrained` — `idle_keys`, `page_edges`, `pack_shape`), `versions` (`parts`, `rigc`), `input` (`rig` — `path`, `bytes`, `sha256` per file — `config_sha256`, `parts_json_sha256`), `baseline` (`reproduced`, `not_compared`; under `--delta` `reproduced` holds one sentence: no config is built, the input is the baseline), `target` (`before`, `after`: `vertices`, `triangles`, `hull`), `preserved` (`identical`, `spliced`), `revision` (per file as `input.rig`), `check` (`input`, `revision`: `pass`, `measured`, `not_met`, `skipped`, `physics`, then `line` and `status` — issue #541, additive, the spec stays `rig-parts-remesh/1`: `line` is the last line `check` prints on that tree, byte for byte, and `status` every block's status as that tree's `check.json` records it, by name in the file's order — `gate`, `loop` and `seam` read from their figures, every other block its own `status`, a block the file does not write absent; `scope`) |
207
211
  | `rig.json`, `motion.json`, `mesh_report.json`, `images/`, `retune.json` | `retune` | the input revision's files, `rig.json` with `constraints` and `invariants.detached` replaced and `motion.json` with `animations.idle` replaced by the config's build, every other file byte for byte; `retune.json` is spec `rig-parts-retune/1`: `request` (`idle_keys`, `page_edges`, `pack_shape`), `versions` (`parts`, `rigc`), `input` (`rig` — `path`, `bytes`, `sha256` per file — `config_sha256`, `parts_json_sha256`), `held` (`compared`, `not_compared`), `changed` (`constraints` — each `name`, `type`, `fields` — `added`, `removed`, `idle`: `tracks_changed`, `duration` as before and after), `preserved` (`identical`, `spliced`), `revision` (per file as `input.rig`), `check` (`input`, `revision`: `pass`, `measured`, `not_met`, `skipped`, `physics`, then `line` and `status` — issue #541, additive, the spec stays `rig-parts-retune/1`: `line` is the last line `check` prints on that tree, byte for byte, and `status` every block's status as that tree's `check.json` records it, by name in the file's order — `gate`, `loop` and `seam` read from their figures, every other block its own `status`, a block the file does not write absent; `scope`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.57.1",
3
+ "version": "1.58.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,6 +39,7 @@
39
39
  "files": [
40
40
  "bin/rig-parts.cjs",
41
41
  "cli.ts",
42
+ "tools/configwrite.ts",
42
43
  "src",
43
44
  "workflows",
44
45
  "docs/AUTHORING.md",
package/src/build.ts CHANGED
@@ -73,7 +73,7 @@ import {
73
73
  } from './autoreplay.ts';
74
74
  import { besideIdleLine, checkSummaryLine, inputFileRecord, type InputFileRecord, CLOTH_LINE, type ClothLine, clothWarnText, frameRuns, MOTION_SEAM_LINE, type MotionSeamLine, type MotionSeamPlant, motionSeamWarnText, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, environmentLine, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, readCheckEnvironment, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
75
75
  import { type Cut, type Extend, type Limb, loadConfigAndAnimations, loadConfigWithBytes, loadEarlyConfig, type Patch, type PlanEntry } from './config.ts';
76
- import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
76
+ import { coreCompileGuideLine, failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
77
77
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
78
78
  import { encodeGif } from './gif.ts';
79
79
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
@@ -817,7 +817,8 @@ function gateRun(label: string, rigc: RigcRunner, args: string[]): GateRun {
817
817
  const r = rigc(args);
818
818
  const lines = r.out.split('\n').filter((l) => /^ {2}FAIL {2}/.test(l) || /assertions: \d+ measured/.test(l) || /^rigc compile error/.test(l) || DECLARED_SKIP.test(l) || HERE_LINE.test(l));
819
819
  const failed = lines.some((l) => /^ {2}FAIL {2}/.test(l) || /^rigc compile error/.test(l));
820
- return { label, status: r.status, lines: r.status !== 0 && !failed ? [...lines, ...causeLines(r.out)] : lines, ...(r.ran === undefined ? {} : { ran: r.ran }) };
820
+ const quoted = r.status !== 0 && !failed ? [...lines, ...causeLines(r.out)] : lines;
821
+ return { label, status: r.status, lines: quoted.map(coreCompileGuideLine), ...(r.ran === undefined ? {} : { ran: r.ran }) };
821
822
  }
822
823
 
823
824
  /**
package/src/check.ts CHANGED
@@ -83,7 +83,7 @@ import { basename, delimiter, dirname, join, resolve } from 'node:path';
83
83
  import { measureRecomposite, type RecompositeFigures, recompositeRecord, scenePhrase, sourceInRig } from './assemble.ts';
84
84
  import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal } from './coords.ts';
85
85
  import { capBox, inDisc } from './limbs.ts';
86
- import { PartsError, type Problem, refuseIfAny } from './errors.ts';
86
+ import { coreCompileGuideLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
87
87
  import { framesInside, IDLE_FPS, TIME_SLACK } from './motion.ts';
88
88
  import {
89
89
  blinkFigures,
@@ -818,7 +818,8 @@ const HERE_LINE = /^\.\.\s+here: /;
818
818
  export function buildGateLines(out: string): string[] {
819
819
  return out
820
820
  .split('\n')
821
- .filter((l) => (l.trim().startsWith('..') && (l.includes(' assertions: ') || l.includes('pages=') || l.includes('pack:') || HERE_LINE.test(l.trim()))) || l.includes('FAIL') || l.includes('compile error'));
821
+ .filter((l) => (l.trim().startsWith('..') && (l.includes(' assertions: ') || l.includes('pages=') || l.includes('pack:') || HERE_LINE.test(l.trim()))) || l.includes('FAIL') || l.includes('compile error'))
822
+ .map(coreCompileGuideLine);
822
823
  }
823
824
 
824
825
  /**
@@ -3848,7 +3849,7 @@ export function clothWarnText(r: ClothRow, duration: number): string {
3848
3849
  */
3849
3850
  export function rigcFailed(what: string, call: RigcCall, lines: readonly string[]): Problem[] {
3850
3851
  const named = lines.filter((l) => l.includes('FAIL') || l.includes('compile error'));
3851
- const quoted = named.length > 0 ? named.map((l) => l.trim()) : causeLines(call.out);
3852
+ const quoted = (named.length > 0 ? named.map((l) => l.trim()) : causeLines(call.out)).map(coreCompileGuideLine);
3852
3853
  const object = `\`rigc ${what}\``;
3853
3854
  const ran = rigcRanClause(call);
3854
3855
  if (quoted.length === 0) return [{ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}, and it printed nothing${ran}` }];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The three config updates proposed by the CLI (issue #356). Pure JSON merge:
3
+ * no file reads/writes and no substitute for the stage's existing validator.
4
+ * A present proposal section replaces that whole field; an absent optional
5
+ * section is retained. The caller supplies the proposer's own section tuple,
6
+ * avoiding a second policy list or a runtime import of the proposer graph.
7
+ */
8
+ import type { PlanProposal } from './assemble.ts';
9
+ import type { HeadBoxProposal } from './headbox.ts';
10
+ import type { Proposal, PROPOSAL_CONFIG_SECTIONS } from './propose.ts';
11
+ import { PartsError } from './errors.ts';
12
+ import type { ConfigEdit } from './meshdraft.ts';
13
+
14
+ export type ConfigJson = null | boolean | number | string | ConfigJson[] | ConfigObject;
15
+ export interface ConfigObject { [key: string]: ConfigJson }
16
+ export type ConfigWritePatch =
17
+ | { kind: 'head-box'; head_box: HeadBoxProposal['head_box'] }
18
+ | { kind: 'assemble-plan'; plan: PlanProposal['plan']; extend_below_crop: PlanProposal['extend_below_crop'] }
19
+ | { kind: 'proposal'; proposal: Proposal; sections: typeof PROPOSAL_CONFIG_SECTIONS };
20
+ export interface ConfigWriteChange {
21
+ path: string;
22
+ kind: 'added' | 'replaced' | 'removed';
23
+ }
24
+ export interface ConfigWriteResult {
25
+ config: ConfigObject;
26
+ /** Semantic changes only, in destination key order, then new key order. */
27
+ changes: ConfigWriteChange[];
28
+ /** Whole owned values, applied by the same splicedEdits engine as cloth. */
29
+ edits: ConfigEdit[];
30
+ }
31
+ const own = (o: object, key: string): boolean => Object.prototype.hasOwnProperty.call(o, key);
32
+ const object = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
33
+ function field(parent: string, key: string): string {
34
+ return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) ? `${parent}${parent === '' ? '' : '.'}${key}` : `${parent}[${JSON.stringify(key)}]`;
35
+ }
36
+ function refuse(path: string, detail: string): never {
37
+ throw new PartsError([{ code: 'CONFIG_FIELD_TYPE', object: path, detail }]);
38
+ }
39
+ /** Clone JSON without dropping undefined, accepting non-finite numbers or following cycles. */
40
+ function clone(value: unknown, path: string, ancestors = new Set<object>()): ConfigJson {
41
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
42
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
43
+ if (typeof value !== 'object' || value === null) return refuse(path, `is ${typeof value}; a finite JSON value is required`);
44
+ if (ancestors.has(value)) return refuse(path, 'contains a cycle; JSON values cannot refer to their ancestors');
45
+ const proto = Object.getPrototypeOf(value);
46
+ if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return refuse(path, 'is not a plain JSON object');
47
+ ancestors.add(value);
48
+ let result: ConfigJson;
49
+ if (Array.isArray(value)) {
50
+ result = Array.from(value, (v, i) => clone(v, `${path}[${i}]`, ancestors));
51
+ } else {
52
+ // fromEntries keeps a JSON key named __proto__ as data, never as a setter.
53
+ result = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, clone(v, field(path, k), ancestors)]));
54
+ }
55
+ ancestors.delete(value);
56
+ return result;
57
+ }
58
+ function equal(a: ConfigJson, b: ConfigJson): boolean {
59
+ if (a === b) return true;
60
+ if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => equal(v, b[i]));
61
+ if (!object(a) || !object(b)) return false;
62
+ const ak = Object.keys(a), bk = Object.keys(b);
63
+ return ak.length === bk.length && ak.every((k) => own(b, k) && equal(a[k], b[k]));
64
+ }
65
+ function changes(before: ConfigObject, after: ConfigObject, parent = ''): ConfigWriteChange[] {
66
+ const result: ConfigWriteChange[] = [];
67
+ for (const [key, a] of Object.entries(before)) {
68
+ const path = field(parent, key);
69
+ if (!own(after, key)) result.push({ path, kind: 'removed' });
70
+ else if (!equal(a, after[key])) {
71
+ if (object(a) && object(after[key])) result.push(...changes(a, after[key] as ConfigObject, path));
72
+ else result.push({ path, kind: 'replaced' });
73
+ }
74
+ }
75
+ for (const key of Object.keys(after)) if (!own(before, key)) result.push({ path: field(parent, key), kind: 'added' });
76
+ return result;
77
+ }
78
+
79
+ /** The caller validates the returned candidate through its existing config door before saving it. */
80
+ export function mergeConfigProposal(raw: unknown, patch: ConfigWritePatch): ConfigWriteResult {
81
+ if (!object(raw)) return refuse('config', 'is not an object; an existing config JSON object is required');
82
+ const before = clone(raw, 'config') as ConfigObject;
83
+ const config = clone(before, 'config') as ConfigObject;
84
+ const edits: ConfigEdit[] = [];
85
+ switch (patch.kind) {
86
+ case 'head-box': {
87
+ if (!object(config.seethrough)) return refuse('config.seethrough', 'is absent or not an object; the existing See-through settings are required before writing head_box');
88
+ const previous = config.seethrough.head_box;
89
+ config.seethrough.head_box = clone(patch.head_box, 'proposal.head_box');
90
+ if (!equal(previous, config.seethrough.head_box)) edits.push({ path: ['seethrough', 'head_box'], value: config.seethrough.head_box });
91
+ break;
92
+ }
93
+ case 'assemble-plan': {
94
+ if (!object(config.assemble)) return refuse('config.assemble', 'is absent or not an object; the existing assembly settings are required before writing its plan');
95
+ const previousPlan = config.assemble.plan, previousExtend = config.assemble.extend_below_crop;
96
+ config.assemble.plan = clone(patch.plan, 'proposal.plan');
97
+ config.assemble.extend_below_crop = clone(patch.extend_below_crop, 'proposal.extend_below_crop');
98
+ if (!equal(previousPlan, config.assemble.plan)) edits.push({ path: ['assemble', 'plan'], value: config.assemble.plan });
99
+ if (!equal(previousExtend, config.assemble.extend_below_crop)) edits.push({ path: ['assemble', 'extend_below_crop'], value: config.assemble.extend_below_crop });
100
+ break;
101
+ }
102
+ case 'proposal': {
103
+ for (const key of patch.sections) {
104
+ // This is the caller's checked Proposal, with the proposer's own tuple.
105
+ // Optional absent fields are retained rather than erased by a narrower run.
106
+ if (patch.proposal[key] !== undefined) {
107
+ const previous = config[key];
108
+ config[key] = clone(patch.proposal[key], `proposal.${key}`);
109
+ if (!equal(previous, config[key])) edits.push({ path: [key], value: config[key] });
110
+ }
111
+ }
112
+ break;
113
+ }
114
+ default: return refuse('write-config', 'has no supported operation; head-box, assemble-plan or proposal is required');
115
+ }
116
+ return { config, changes: changes(before, config), edits };
117
+ }
@@ -32,6 +32,8 @@
32
32
  * write the same bytes.
33
33
  */
34
34
  import { type Problem, refuseIfAny } from './errors.ts';
35
+ import type { BoneEntry, Limb } from './config.ts';
36
+ import type { RigJoints } from './keypoints.ts';
35
37
  import { BASIS_KINDS, type BasisKind, type BoneBasis, type BoneCoverage, COVERAGE_OUTCOMES, coverageOutcome, expand, FRAME_KEYS, type FrameBasis, type FrameKey, type PartSet, type Placement, type Proposal, type ProposalBasis } from './propose.ts';
36
38
  import { BONE_ROLES, type BoneRole, compareStructure, readSkeleton } from './structure.ts';
37
39
 
@@ -175,8 +177,66 @@ export function basisLine(f: BasisFile, out: string): string {
175
177
  return `wrote ${BASIS_FILE} (${f.bones.length} bone record(s) by the basis of their origin: ${kinds}${cmp}) under ${out}`;
176
178
  }
177
179
 
180
+ /** The leg choice actually declared by the supplied proposal/config. Placement
181
+ * and coverage are distinct: selecting a chain never claims its links were checked.
182
+ */
183
+ export interface LegChainChoice {
184
+ chains: Array<{ chain: string; sources: string[]; parts: string[] }>;
185
+ /** Present when no chain was selected; null never stands for a guessed cause. */
186
+ none: string | null;
187
+ }
188
+
189
+ export function legChainChoice(P: PartSet, spec: { bones: readonly BoneEntry[]; meshes: Proposal['meshes']; regions?: Proposal['regions']; limbs?: Readonly<Record<string, Limb>>; notes?: readonly string[] }, joints?: RigJoints): LegChainChoice {
190
+ const chains: LegChainChoice['chains'] = [];
191
+ for (const entry of spec.bones) {
192
+ if (!('chain' in entry) || !/^leg_[rl]$/.test(entry.chain)) continue;
193
+ const links = new Set(entry.points.map((_, i) => `${entry.chain}${i}`));
194
+ const sources = new Set<string>();
195
+ const parts = new Set<string>();
196
+ for (const limb of Object.values(spec.limbs ?? {})) {
197
+ for (const segment of limb.segments) if (links.has(segment.bone)) {
198
+ sources.add(segment.from);
199
+ parts.add(segment.part);
200
+ }
201
+ }
202
+ const hasSourceBinding = sources.size > 0;
203
+ const bound = (part: string): void => {
204
+ parts.add(part);
205
+ if (!hasSourceBinding && P.recs.some((p) => p.name === part)) sources.add(part);
206
+ };
207
+ for (const [part, mesh] of Object.entries(spec.meshes)) {
208
+ if (mesh.segments.some((s) => typeof s === 'string' ? s === entry.chain || links.has(s) : links.has(s[0]))) bound(part);
209
+ }
210
+ for (const [part, bone] of Object.entries(spec.regions ?? {})) if (links.has(bone)) bound(part);
211
+ chains.push({ chain: entry.chain, sources: [...sources], parts: [...parts] });
212
+ }
213
+ if (chains.length > 0) return { chains, none: null };
214
+ if (joints === undefined) return { chains, none: 'no keypoints supplied; leg placement requires hip, knee and ankle positions' };
215
+ const recorded = spec.notes?.find((n) => n.includes(': no leg chain — '));
216
+ if (recorded !== undefined) return { chains, none: recorded };
217
+ const gaps: string[] = [];
218
+ for (const side of ['r', 'l'] as const) {
219
+ const missing = ([`${side}_hip`, `${side}_knee`, `${side}_ankle`] as const).filter((key) => joints.joints[key].at === null);
220
+ if (missing.length > 0) gaps.push(`${missing.join(', ')} ${missing.length === 1 ? 'has' : 'have'} no position`);
221
+ else {
222
+ const hip = joints.joints[`${side}_hip`].at as [number, number];
223
+ const knee = joints.joints[`${side}_knee`].at as [number, number];
224
+ if (hip[0] === knee[0] && hip[1] === knee[1]) gaps.push(`${side}_hip and ${side}_knee are one point; the thigh has no direction`);
225
+ }
226
+ }
227
+ if (gaps.length === 2) return { chains, none: gaps.join('; ') };
228
+ if (!P.byTag('legwear', undefined, true).some((p) => p.opaque_px > 500)) return { chains, none: 'no legwear part over 500 opaque px; the proposal declares no leg chain' };
229
+ return { chains, none: 'no leg_r or leg_l chain declared; the supplied spec records no leg-placement refusal' };
230
+ }
231
+
232
+ export function legChainChoiceLine(choice: LegChainChoice): string {
233
+ if (choice.chains.length === 0) return `coverage leg-chain choice: 0 chains — ${choice.none ?? 'no placement reason recorded'}`;
234
+ const named = (values: readonly string[]): string => values.map((v) => JSON.stringify(v)).join(', ');
235
+ return `coverage leg-chain choice: ${choice.chains.length} ${choice.chains.length === 1 ? 'chain' : 'chains'} — ${choice.chains.map((c) => `${c.chain}: source ${c.sources.length === 0 ? 'not recorded' : named(c.sources)}; parts ${c.parts.length === 0 ? 'none bound' : named(c.parts)}`).join(' | ')}; selection only, not a checked verdict`;
236
+ }
237
+
178
238
  /** One coverage line per bone, then the summary. Every line starts `coverage`. */
179
- export function coverageLines(coverage: readonly BoneCoverage[]): string[] {
239
+ export function coverageLines(coverage: readonly BoneCoverage[], legChoice?: LegChainChoice): string[] {
180
240
  const out: string[] = [];
181
241
  for (const c of coverage) {
182
242
  const role = c.role === null ? 'role not read' : c.role.role;
@@ -184,6 +244,7 @@ export function coverageLines(coverage: readonly BoneCoverage[]): string[] {
184
244
  if (outcome === 'not checked') out.push(`coverage ${c.bone} [${role}]: not checked — ${c.why.join('; ')}`);
185
245
  else out.push(`coverage ${c.bone} [${role}]: ${outcome} — read by ${c.read.join(', ')}${c.lint > 0 ? `; named by ${c.lint} LINT line(s)` : ''}`);
186
246
  }
247
+ if (legChoice !== undefined) out.push(legChainChoiceLine(legChoice));
187
248
  const by = (o: (typeof COVERAGE_OUTCOMES)[number]): number => coverage.filter((c) => coverageOutcome(c) === o).length;
188
249
  const roles: Record<BoneRole, number> = { target: 0, deforms: 0, control: 0, unclassified: 0 };
189
250
  let unread = 0;
package/src/errors.ts CHANGED
@@ -34,6 +34,18 @@ export function guidePointer(code: string): string {
34
34
  return ` (see rig-parts guide ${code})`;
35
35
  }
36
36
 
37
+ /**
38
+ * Complete only a bare relayed core compile refusal (issue #356). Current core
39
+ * names a guide rule; older callers may name the compile section or a child.
40
+ * Keep those pointers and all non-compile lines verbatim. The parts rule's
41
+ * outer pointer remains failLine's responsibility.
42
+ */
43
+ export function coreCompileGuideLine(line: string): string {
44
+ if (!/^\s*rigc compile error\b/.test(line) || /\(see rigc guide (?:§5\.1(?:\.\d+)*|[A-Z][A-Z0-9_]*)\)/.test(line)) return line;
45
+ const body = line.trimEnd();
46
+ return `${body} (see rigc guide §5.1)${line.slice(body.length)}`;
47
+ }
48
+
37
49
  /** A refusal as it is printed: ` FAIL RULE: object — detail (see rig-parts guide RULE)`. */
38
50
  export function failLine(p: Problem): string {
39
51
  return ` FAIL ${problemLine(p)}${guidePointer(p.code)}`;
package/src/propose.ts CHANGED
@@ -5486,6 +5486,14 @@ function lintBasis(bones: readonly BoneEntry[], joints: RigJoints): LintBasis {
5486
5486
  if (from[0] === to[0] && from[1] === to[1]) return { chain, joints: [a, b], line: null, why: `${a} and ${b} are one point, ${fmtAt(from)}, so they declare no line` };
5487
5487
  return { chain, joints: [a, b], line: { from, to }, why: null };
5488
5488
  });
5489
+ // Leg joints place a bent chain; projecting all links onto hip -> ankle is
5490
+ // not a rule for that bend. Say why that check does not read these chains.
5491
+ for (const side of ['r', 'l'] as const) {
5492
+ const chain = `leg_${side}`;
5493
+ if (bones.some((e) => 'chain' in e && e.chain === chain)) {
5494
+ chains.push({ chain, joints: [`${side}_hip`, `${side}_ankle`], line: null, why: 'chain-not-advancing reads sleeve chains only; a bent knee need not advance along hip -> ankle' });
5495
+ }
5496
+ }
5489
5497
  return { person: joints.person, torso, chains };
5490
5498
  }
5491
5499
 
@@ -0,0 +1,203 @@
1
+ /** Same-directory staging for every CLI config writer (issue #356).
2
+ * Ownership is pure; this adapter alone owns files. Existing updates use their
3
+ * stage loaders; fresh cloth text retains its intentional partial-config contract.
4
+ */
5
+ import { closeSync, fchmodSync, linkSync, mkdirSync, lstatSync, openSync, readFileSync, realpathSync, statSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { basename, dirname, join, resolve } from 'node:path';
8
+ import { loadConfigAndAnimations, loadEarlyConfig, repeatedKeys } from '../src/config.ts';
9
+ import { mergeConfigProposal, type ConfigWriteChange, type ConfigWritePatch } from '../src/configwrite.ts';
10
+ import { PartsError, type Problem } from '../src/errors.ts';
11
+ import { splicedEdits } from '../src/meshdraft.ts';
12
+
13
+ export interface ConfigWriteTarget { path: string; original: string; mode: number; raw: unknown }
14
+ /** Cloth supplies already checked splice text; a partial config is intentional. */
15
+ export interface FreshConfigWrite { kind: 'fresh'; path: string; text: string }
16
+ export interface ConfigWriteFiles {
17
+ /** Narrow filesystem operations, supplied only by failure controls; CLI uses the real defaults. */
18
+ replace?: (from: string, to: string) => void;
19
+ remove?: (path: string) => void;
20
+ publish?: (from: string, to: string) => void;
21
+ }
22
+ export interface StagedConfigWrite {
23
+ changes: readonly ConfigWriteChange[];
24
+ commit(): void;
25
+ cancel(): void;
26
+ /** Combine a caller artifact failure with cleanup before its catch prints it. */
27
+ abort(error: unknown): never;
28
+ }
29
+ function refusal(code: string, path: string, detail: string): never {
30
+ throw new PartsError([{ code, object: path, detail }]);
31
+ }
32
+ function ioProblem(path: string, action: string, error: unknown): Problem {
33
+ return { code: 'CONFIG_WRITE_FAILED', object: path, detail: `${action}: ${error instanceof Error ? error.message : String(error)}; check the destination directory and permissions` };
34
+ }
35
+ function ioFailure(path: string, action: string, error: unknown): never {
36
+ throw new PartsError([ioProblem(path, action, error)]);
37
+ }
38
+ function failedOperation(path: string, action: string, error: unknown, cancel: () => void): never {
39
+ const problems = error instanceof PartsError ? [...error.problems] : [ioProblem(path, action, error)];
40
+ try { cancel(); }
41
+ catch (cleanup) { problems.push(...(cleanup instanceof PartsError ? cleanup.problems : [ioProblem(path, 'removing the staged config failed', cleanup)])); }
42
+ throw new PartsError(problems);
43
+ }
44
+ export function readConfigWriteTarget(path: string): ConfigWriteTarget {
45
+ const target = resolve(path);
46
+ let stat: ReturnType<typeof lstatSync>;
47
+ try { stat = lstatSync(target); }
48
+ catch (error) { return refusal('CONFIG_FILE_PRESENT', target, `cannot read the existing config: ${error instanceof Error ? error.message : String(error)}`); }
49
+ if (!stat.isFile()) return refusal('CONFIG_FIELD_TYPE', target, 'is not a regular file (symlinks are not replaced); --write-config updates an existing config file in place');
50
+ let original: string;
51
+ try { original = readFileSync(target, 'utf8'); }
52
+ catch (error) { return ioFailure(target, 'reading the destination failed', error); }
53
+ let raw: unknown;
54
+ try { raw = JSON.parse(original); }
55
+ catch (error) { return refusal('CONFIG_IS_JSON', target, `does not parse as JSON: ${error instanceof Error ? error.message : String(error)}`); }
56
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return refusal('CONFIG_FIELD_TYPE', target, 'must hold a config JSON object');
57
+ const duplicates = repeatedKeys(original);
58
+ if (duplicates.length > 0) throw new PartsError(duplicates.map((r) => ({ code: 'CONFIG_KEY_UNIQUE', object: `config${r.at === '' ? '' : `.${r.at}`}`, detail: `names ${JSON.stringify(r.key)} ${r.count} times; each key must occur once before an in-place update` })));
59
+ return { path: target, original, raw, mode: stat.mode & 0o777 };
60
+ }
61
+ /** Resolve existing ancestors too: an output parent may be an alias or not exist yet. */
62
+ function canonical(path: string): string {
63
+ let at = resolve(path);
64
+ const tail: string[] = [];
65
+ for (;;) {
66
+ try { return join(realpathSync(at), ...tail); }
67
+ catch (error) {
68
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error;
69
+ const parent = dirname(at);
70
+ if (parent === at) throw error;
71
+ tail.unshift(basename(at));
72
+ at = parent;
73
+ }
74
+ }
75
+ }
76
+ /** Refuse before ordinary propose writes any artifact that could overwrite its config. */
77
+ export function separateConfigWriteTarget(target: Pick<ConfigWriteTarget, 'path'>, artifacts: readonly string[], code = 'CONFIG_WRITE_TARGET'): void {
78
+ try {
79
+ let identity: ReturnType<typeof statSync> | null = null;
80
+ try { identity = statSync(target.path); }
81
+ catch (error) {
82
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error;
83
+ }
84
+ const targetPath = canonical(target.path);
85
+ for (const artifact of artifacts) {
86
+ let alias = canonical(artifact) === targetPath;
87
+ try {
88
+ const existing = statSync(artifact);
89
+ alias ||= identity !== null && existing.dev === identity.dev && existing.ino === identity.ino;
90
+ } catch (error) {
91
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error;
92
+ }
93
+ if (alias) refusal(code, target.path, `is also the proposal output ${resolve(artifact)}; choose a config outside the command's proposal, basis and landmark artifact paths — nothing was written`);
94
+ }
95
+ } catch (error) {
96
+ if (error instanceof PartsError) throw error;
97
+ ioFailure(target.path, 'checking output collisions failed', error);
98
+ }
99
+ }
100
+
101
+ function unchanged(target: ConfigWriteTarget): void {
102
+ let same = false;
103
+ try {
104
+ const stat = lstatSync(target.path);
105
+ same = stat.isFile() && (stat.mode & 0o777) === target.mode && readFileSync(target.path, 'utf8') === target.original;
106
+ } catch { /* Removal is also a change, never a reason to create a new config. */ }
107
+ if (!same) refusal('CONFIG_WRITE_CHANGED', target.path, 'changed after it was read; nothing was replaced — rerun against the current config');
108
+ }
109
+ /** One file-writing adapter for all --write-config modes. Existing-file ownership
110
+ * produces whole-value edits for splicedEdits, the engine clothConfigEdits uses.
111
+ * Fresh cloth text already passed writtenClothConfig's splice check: it is never
112
+ * sent through a full loader that would reject an intentional partial config.
113
+ */
114
+ export function stageConfigWrite(target: ConfigWriteTarget | FreshConfigWrite, patch?: ConfigWritePatch, files: ConfigWriteFiles = {}): StagedConfigWrite {
115
+ const fresh = 'kind' in target;
116
+ const path = resolve(target.path);
117
+ let text: string;
118
+ let changes: readonly ConfigWriteChange[];
119
+ if (fresh) {
120
+ text = target.text;
121
+ changes = [];
122
+ } else {
123
+ if (patch === undefined) return refusal('CONFIG_FIELD_TYPE', path, 'has no config patch; a supported proposal operation is required');
124
+ const merged = mergeConfigProposal(target.raw, patch);
125
+ const spliced = merged.changes.length === 0 ? { text: target.original, moved: null } : splicedEdits(target.original, merged.edits);
126
+ if (spliced.moved !== null) return refusal('CONFIG_WRITE_FAILED', path, `spliced proposal values moved ${spliced.moved}; the original was not replaced`);
127
+ text = spliced.text;
128
+ changes = merged.changes;
129
+ }
130
+ const freshAbsent = (): void => {
131
+ try { lstatSync(path); }
132
+ catch (error) {
133
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return;
134
+ return ioFailure(path, 'checking the fresh destination failed', error);
135
+ }
136
+ refusal('PROPOSE_WRITE_CONFIG_FRESH', path, 'exists; the config is written to a new file, never over the --cloth config or an earlier one');
137
+ };
138
+ if (fresh) freshAbsent();
139
+ const temp = join(dirname(path), `.${basename(path)}.rig-parts-${randomBytes(8).toString('hex')}.tmp`);
140
+ let owned = false;
141
+ const cancel = (): void => {
142
+ if (!owned) return;
143
+ owned = false;
144
+ try { if (files.remove === undefined) rmSync(temp, { force: true }); else files.remove(temp); }
145
+ catch (error) { ioFailure(temp, 'removing the staged config failed; remove this temporary after restoring directory permissions', error); }
146
+ };
147
+ let fd: number | undefined;
148
+ try {
149
+ if (fresh) mkdirSync(dirname(path), { recursive: true });
150
+ fd = openSync(temp, 'wx', fresh ? 0o666 : target.mode);
151
+ owned = true;
152
+ writeFileSync(fd, text);
153
+ if (!fresh) fchmodSync(fd, target.mode);
154
+ closeSync(fd);
155
+ fd = undefined;
156
+ if (!fresh) {
157
+ if (patch?.kind === 'head-box') loadEarlyConfig(temp, 'layers');
158
+ else if (patch?.kind === 'assemble-plan') loadEarlyConfig(temp, 'assemble');
159
+ else loadConfigAndAnimations(temp);
160
+ }
161
+ } catch (error) {
162
+ if (fd !== undefined) {
163
+ try { closeSync(fd); }
164
+ catch (cleanup) {
165
+ const primary = error instanceof PartsError ? [...error.problems] : [ioProblem(path, 'staging the config failed', error)];
166
+ error = new PartsError([...primary, ioProblem(temp, 'closing the staged config failed', cleanup)]);
167
+ }
168
+ }
169
+ return failedOperation(path, 'staging the config failed; the destination was not written', error, cancel);
170
+ }
171
+ return {
172
+ changes,
173
+ cancel,
174
+ abort(error: unknown): never {
175
+ return failedOperation(path, 'writing proposal artifacts failed', error, cancel);
176
+ },
177
+ commit(): void {
178
+ try {
179
+ if (fresh) {
180
+ freshAbsent();
181
+ // Linking the same-directory staged file publishes complete bytes and
182
+ // refuses an intervening creator atomically; rename would overwrite it.
183
+ try { (files.publish ?? linkSync)(temp, path); }
184
+ catch (error) {
185
+ if (error instanceof Error && 'code' in error && error.code === 'EEXIST') refusal('PROPOSE_WRITE_CONFIG_FRESH', path, 'exists; the config is written to a new file, never over the --cloth config or an earlier one');
186
+ throw error;
187
+ }
188
+ cancel();
189
+ } else {
190
+ unchanged(target);
191
+ if (changes.length > 0) { (files.replace ?? renameSync)(temp, path); owned = false; }
192
+ else cancel();
193
+ }
194
+ } catch (error) {
195
+ failedOperation(path, 'committing the config failed', error, cancel);
196
+ }
197
+ },
198
+ };
199
+ }
200
+ /** Print to stderr so proposal JSON on stdout remains machine-readable. */
201
+ export function configWriteLines(target: ConfigWriteTarget, changes: readonly ConfigWriteChange[]): string[] {
202
+ return changes.length === 0 ? [`config ${target.path}: unchanged`] : changes.map((c) => `config ${target.path}: ${c.kind} ${c.path}`);
203
+ }