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 +22 -0
- package/README.md +82 -10
- package/cli.ts +63 -16
- package/docs/AUTHORING.md +88 -17
- package/docs/STABILITY.md +9 -5
- package/package.json +2 -1
- package/src/build.ts +3 -2
- package/src/check.ts +4 -3
- package/src/configwrite.ts +117 -0
- package/src/diagnostics.ts +62 -1
- package/src/errors.ts +12 -0
- package/src/propose.ts +8 -0
- package/tools/configwrite.ts +203 -0
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
|
|
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;
|
|
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
|
|
231
|
+
rig-parts comfy seethrough --image st_input_full.png --out layers/full --host http://<box>:8188
|
|
221
232
|
```
|
|
222
233
|
|
|
223
|
-
`comfy paint`
|
|
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
|
|
265
|
-
from a config holding only what the first step reads (`RL01`).
|
|
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
|
|
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,
|
|
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 --
|
|
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 (
|
|
1380
|
-
|
|
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 (
|
|
1464
|
-
|
|
1465
|
-
|
|
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.
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
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
|
|
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
|
-
|
|
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`).
|
|
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
|
|
2641
|
-
|
|
2642
|
-
|
|
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` (
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/diagnostics.ts
CHANGED
|
@@ -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
|
+
}
|