@rig-c/review 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/docs/DESIGN.md +2 -1
- package/docs/REVIEW_PACKAGE.md +16 -6
- package/package.json +1 -1
- package/src/applied-context.ts +35 -7
- package/src/cli.ts +1 -1
- package/src/cloth-recipe.ts +199 -0
- package/src/environment-check.ts +149 -0
- package/src/environment-schedule.ts +183 -0
- package/src/feedback-export.ts +8 -1
- package/src/front.ts +4 -1
- package/src/handoff.ts +20 -13
- package/src/open.ts +1 -1
- package/src/readings-run.ts +2 -1
- package/src/readings.ts +12 -6
- package/src/rebuild.ts +185 -32
- package/src/retune-check.ts +100 -0
- package/src/server.ts +11 -1
- package/src/session.ts +3 -0
- package/web/cloth-panel.ts +168 -0
- package/web/environment-world.ts +42 -0
- package/web/index.html +1 -1
- package/web/main.ts +112 -9
- package/web/readings-panel.ts +1 -1
- package/web/rebuild-client.ts +13 -8
- package/web/rig-overlays.ts +2 -1
- package/web/styles.css +2 -0
package/README.md
CHANGED
|
@@ -156,11 +156,11 @@ rig-review feedback /absolute/output --reference /absolute/original.mp4
|
|
|
156
156
|
rig-review open /absolute/a --compare /absolute/b --reference /absolute/original.mp4 --animation dance
|
|
157
157
|
rig-review feedback /absolute/a --compare /absolute/b --reference /absolute/original.mp4 --animation dance
|
|
158
158
|
# Producer handoff (single review only): the config, parts and inspected rig an Apply rebuilds from
|
|
159
|
-
rig-review open /absolute/output --config /absolute/output/rig/config.json [--parts /absolute/output/rig/out] [--source <png> --full <dir> [--head <dir>]]
|
|
159
|
+
rig-review open /absolute/output --config /absolute/output/rig/config.json [--parts /absolute/output/rig/out] [--source <png> --full <dir> [--head <dir>]] [--keypoints <file>]
|
|
160
160
|
rig-review feedback /absolute/output --config /absolute/output/rig/config.json
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
-
`--config`, `--parts`, `--source`, `--full` and `--
|
|
163
|
+
`--config`, `--parts`, `--source`, `--full`, `--head` and `--keypoints` declare the producer
|
|
164
164
|
handoff (see `docs/REVIEW_PACKAGE.md`, *Producer handoff*). They win over the
|
|
165
165
|
review.json `producer` block, which wins over discovery (`config.json`, then
|
|
166
166
|
`rig/config.json` inside the opened folder, then `../config.json` beside an opened
|
|
@@ -168,8 +168,10 @@ review.json `producer` block, which wins over discovery (`config.json`, then
|
|
|
168
168
|
package root; a path outside it or a symlink that leaves it is refused by name.
|
|
169
169
|
The handoff files are hashed into the reviewed identity, so `feedback` and
|
|
170
170
|
`control` take the same flags to retrieve the same session, and changing the
|
|
171
|
-
config bytes starts a new pending review. `--
|
|
172
|
-
|
|
171
|
+
config bytes starts a new pending review. `--keypoints` (#111) is the body-18 keypoint
|
|
172
|
+
file a cloth recipe Apply hands to `rig-parts propose`; it is hashed into the identity
|
|
173
|
+
only when declared, so a handoff without it keeps its identity. `--parts`,
|
|
174
|
+
`--source`/`--full`/`--head` and `--keypoints` need `--config`; `--source` and `--full` go together; compare takes none of them.
|
|
173
175
|
|
|
174
176
|
`open` chooses an explicit `--animation` first, then the normalized report's
|
|
175
177
|
`animation` after validating actual model membership, then a unique actual clip
|
|
@@ -492,6 +494,21 @@ controls are applied, their current wind and gravity go along as a one-step
|
|
|
492
494
|
the flag). A reading is not a gate; nothing is written into the package. The
|
|
493
495
|
pairs of the last run are recorded in feedback as `context.readings`.
|
|
494
496
|
|
|
497
|
+
In the Environment block, **Use for readings** (issue #112) turns the controls
|
|
498
|
+
into a `rigc-environment/1` schedule from the clip's first frame — one step when
|
|
499
|
+
the wind is steady, else one step per 12 fps frame carrying the breeze and the
|
|
500
|
+
repeating gust (equal to the panel's wind at every frame time; between frames
|
|
501
|
+
rigc holds the frame's value, and the panel states how far that can be from the
|
|
502
|
+
live wind) — and sends it with readings and with Apply, whose check then runs
|
|
503
|
+
`rig-parts check --environment`. The wind clock restarts with the clip, so what
|
|
504
|
+
you watch is the world the readings and bars are taken in. **Export schedule**
|
|
505
|
+
downloads the same bytes; **Check under this world** runs `rig-parts check
|
|
506
|
+
--environment` on the build on the stage (needs `@rig-c/parts` 1.48.0) and shows
|
|
507
|
+
its `ANIMATIONS_DO_NOT_FOLD` and `TEXTURE_STRETCH` lines beside the same build's
|
|
508
|
+
default-world check. A refused schedule (`CHECK_ENVIRONMENT`) is shown verbatim.
|
|
509
|
+
Feedback records the schedule's bytes and sha256 as `context.environmentSchedule`;
|
|
510
|
+
nothing is authored into the rig.
|
|
511
|
+
|
|
495
512
|
Below Readings, the **Requirements** block (`#requirements`) declares what the
|
|
496
513
|
scene asks of the motion (issue #113): pick a kind (contact, follow, aim,
|
|
497
514
|
range, stretch, seam), an animation and the subject — the slot picked on the
|
package/docs/DESIGN.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
The delivery unit is an existing output folder or an optional curated review package handed from an agent to a person. This reusable app owns inspection, reference comparison, annotations and feedback export. Upstream compiler, splitter and runtime remain their respective tools. The existing producing agent needs no change to open its output here.
|
|
6
6
|
|
|
7
|
-
The person uses one inspection workspace with **Setup** and **Animate** sections and a review dialog for feedback (the compare page keeps **View**, **Rig**, **Playback** and **Feedback** sections). Setup covers setup geometry, source art, pivot/joint placement, meshes, weights and draw order; Animate covers angle coverage, frame intervals, reference video/poses, contact/deformation, audio inspection and the inspection environment (wind, gravity and impulses through the published player's `environment`/`moved`; its state is feedback context, never a model or motion edit) and the physics tuning panel (#103: constraint values edited as config patch ops and applied through the producer, never written into the model by review), and the readings block (#108: the compiler's own `rigc render` readings of the drawn animation, run on the inspected build and on the latest scratch build, shown as timeline ticks and worst-frame lines), and the requirements block (#113: the scene's declared requirements written as a `spine-parts-requirements/1` file and measured by the installed producer's `rig-parts check --requirements`, one worst-frame line each). Motion is strictly **view, annotate and export**: time/angle controls navigate inspection. This app does not author animation keys, retime motion or write `motion.json`. Future modeling touch-up remains in scope as a design goal; it is not implemented by this starter.
|
|
7
|
+
The person uses one inspection workspace with **Setup** and **Animate** sections and a review dialog for feedback (the compare page keeps **View**, **Rig**, **Playback** and **Feedback** sections). Setup covers setup geometry, source art, pivot/joint placement, meshes, weights and draw order; Animate covers angle coverage, frame intervals, reference video/poses, contact/deformation, audio inspection and the inspection environment (wind, gravity and impulses through the published player's `environment`/`moved`; its state is feedback context, never a model or motion edit) and the physics tuning panel (#103: constraint values edited as config patch ops and applied through the producer, never written into the model by review), and the readings block (#108: the compiler's own `rigc render` readings of the drawn animation, run on the inspected build and on the latest scratch build, shown as timeline ticks and worst-frame lines), the Environment block's *Use for readings* (#112: the controls exported as a `rigc-environment/1` schedule that readings, Apply's check and *Check under this world* — the installed producer's `rig-parts check --environment` — read, so the bars and readings are taken under the wind the person watched; the schedule is feedback context and is never authored into the rig), and the requirements block (#113: the scene's declared requirements written as a `spine-parts-requirements/1` file and measured by the installed producer's `rig-parts check --requirements`, one worst-frame line each). Motion is strictly **view, annotate and export**: time/angle controls navigate inspection. This app does not author animation keys, retime motion or write `motion.json`. Future modeling touch-up remains in scope as a design goal; it is not implemented by this starter.
|
|
8
8
|
|
|
9
9
|
## Implemented foundation
|
|
10
10
|
|
|
@@ -65,6 +65,7 @@ The single review page is one inspection workspace: a left column, the stage, th
|
|
|
65
65
|
- **Weights.** In Setup, after the Mesh panel: `#weights` (#107), a watch layer. **Show weights** (`#weights-show`, `aria-pressed`) draws the selected slot's setup-pose mesh on `#rig-overlay` with each vertex coloured by the picked bone's stored weight (`#weights-bone`, influencing bones first) on the ramp tokens `--rv-weight-0` / `--rv-weight-half` / `--rv-weight-1`, or by its dominant bone (`#weights-mode` Max influence, `--rv-weight-bone-1…6`); `#weights-legend` and the hover readout `#weights-hover` sit under it. Weights are read from the compiled model document (`src/weights.ts` over the shared reader `src/setup-mesh.ts`); positions come from a never-played `RigPlayer` over the same assets (`web/setup-pose.ts`, shared with Edit Mesh), since the published `posedFrame()` is empty before `play`. A package without the document refuses `WEIGHTS_MODEL_UNAVAILABLE`. No edit, Apply or record; the Mesh edit preview wins when both are on.
|
|
66
66
|
- **Bones.** In Setup, after Weights: `#bones-panel` (#109). **Edit bones on stage** (`#bones-edit`) draws the handoff config's bone anchors — `at`, chain `points`, `tip` — on `#rig-overlay` through the crop → world mapping (tokens `--rv-bone-line`, `-origin`, `-point`, `-tip`, `-pending`, `-selected`, `-ink`, `-label-bg`; the stage stays dark in both schemes, so both blocks carry the same values). A drag moves an anchor by whole rig px; each move is a `replace` op at its JSON Pointer naming the bone (`src/bone-anchors.ts`, `web/bones-panel.ts`). Check (`POST /api/bones-check`, `src/bones-check.ts`) runs `propose --from-config` and `compare` on a scratch copy of the patched config and shows their lines as printed; a LINT line blocks nothing. Apply is the generic patch runner's whole-rig `rig` op behind the reproduced baseline. Adding, removing or re-parenting bones is out of scope.
|
|
67
67
|
- **Rig fields.** In Setup, after Bones: `#rig-fields` (#110). The producer config's small fields as patch ops at their own pointers (`src/rig-fields.ts`, `web/rig-fields-panel.ts`): a rigid part's bone (`/regions/<part>`, a select), a lash's blink still row (`/motion/blink/still/<part>`, a dashed line across the part dragged in whole rig px), a stop's inward / outward bounds (`/stops/<i>`, two filled arcs at the pivot around a dashed rest aim, their ends dragged in whole degrees), an inherit mode per bone (`/bones/<i>/inherit`, a chain's `link_inherit`) and `turn`'s pivot and per-part depth (`/turn/pivot`, `/turn/depths/<part>`, sliders). Stage handles live in `#rig-fields-stage` over the rig viewport (tokens `--rv-field-still`, `-inward`, `-inward-line`, `-outward`, `-outward-line`, `-rest`, `-handle-bg`, `-ink`; the stage stays dark, so both blocks carry the same values). The turn dial holds the drawn build's `turn` bone at x through play's `adjust`, composed with the idle preview, and shows `#rig-fields-dial-label` (*preview — Apply decides*); it is never recorded. Apply is the generic runner's whole-rig `rig` op behind the reproduced baseline; the readout quotes `BLINK_NO_HOLE` and `FEET_STAY_LEVEL` before and after, and **Angle range** runs one #108 reading. Adding a stop and `turn.deform` are out of scope.
|
|
68
|
+
- **Cloth.** In Setup, after Cuts: `#cloth-panel` (#111). One form per cloth tag the installed reader accepts (`bottomwear`, `back hair`, `front hair` — its own `CLOTH_TARGETS`), holding the recipe's fields as the config states them; each change is a patch op at `/cloth/<tag>/…` (`src/cloth-recipe.ts`, `web/cloth-panel.ts`). **Show links** draws, in rig px of the setup pose, every chain the tag's parts are meshed on from the config's `points` and `tip`, and after an Apply the chains of the config the producer wrote (one label per set). Apply runs `rig-parts propose --cloth --write-config` on the declared parts, painting and keypoints, then builds the written config (`build` when the config declares `limbs`, else `rig --config`) and reloads it under the stage's Environment. Review never merges the producer's draft: it reads only the written config, lists where it differs from its input and refuses one that differs outside the drafted sections. Hand-editing the drafted constraints is the physics block's, after the draft.
|
|
68
69
|
- **Mesh edit.** In Setup only, the view bar's **Edit mesh** toggle (`#mesh-edit`, `aria-pressed`) opens `#mesh-editor-bar`: sub-modes Modify / Create / Delete / Edges (keys `V` / `C` / `X` / `E`, one pressed), Undo, Redo, Reset, Download JSON and **Apply · Rebuild** (#135: `rig-parts remesh --delta` through the shared runner, the revision loaded in place, the producer's refusal verbatim; while a delta revision is on the stage Reset returns to the inspected build). A package without the compiled model document refuses `MESH_EDIT_MODEL_UNAVAILABLE` and does not open; whole rig refuses `MESH_EDIT_SELECT_SLOT`; a non-mesh or linked-mesh slot `MESH_EDIT_NO_MESH <slot>`. It edits in setup space (#100): the attachment's entry in the model document, placed through the setup-pose bones of a never-played player (`src/setup-mesh.ts` + `web/setup-pose.ts`, shared with Weights) and drawn over the paused stage with the stage camera. It records a typed delta (`src/mesh-edit.ts`, `basis: "setup"`, the shape `rig-parts remesh --delta` reads) and previews it over the original triangulation on the overlay; Delete removes only constraint edges the delta added (the triangulation is the producer's); it is not a mesher and never retriangulates, writes the package or runs a producer. The chest editor and mesh edit are exclusive: turning one on turns the other off.
|
|
69
70
|
- **Inspector.** Two sections and no tab row: **Setup** is the mesh-review composition (selected part, part presets, the modeling-review proposals for mesh density, contour curvature and chest position) and holds no control that seeks or plays; **Animate** has the transport with the Repeat idle toggle, frame navigation, the motion-review proposals and angle inspection with its axis panel, sound cues and report events. Their element ids and saved section names (`rig`, `playback`) are unchanged. Each section opens with a sticky block row (`.block-row`, `web/inspector-blocks.ts`): Setup — Part · Modeling · Chest · Mesh · Weights · Bones · Fields; Animate — Transport (with frame navigation, motion tools and the angle panel) · Sound · Environment · Physics · Idle tracks · Readings · Report, in document order. A button scrolls its block to just under the row inside the section and marks it `aria-current`; the active block then follows scrolling (IntersectionObserver; at the end of the section the last visible block). The row is one line that scrolls sideways when narrow. The active block per section is the only thing the row adds to the inspection draft (`ui.inspectorBlocks`); the scroll offset stays with the Inspector scroll memory, and the control protocol and saved-view link are unchanged. A saved `view` or `feedback` section from older links and drafts restores without selecting a section or opening the dialog.
|
|
70
71
|
- **Review dialog.** A native modal `<dialog id="review-dialog">` holds the former Feedback section unchanged (review status, reviewed source with the read-only source-file list, selected checkpoint, structured feedback, frame-strip export; every element id kept) and the **Submit review** button (`#save`). **Write review**, a part's or a proposal tool's Add feedback opens it with `showModal()` and pauses playback; Escape or Close dismisses it and focus returns to **Write review**. Its inputs stay in the document while it is closed, so the draft mechanism (autosave, recovery, conflict handling) is unchanged and closing loses nothing. The modal makes the rest of the page inert, so the dialog repeats the footer's message and draft state in its own status line; the footer keeps reporting both.
|
package/docs/REVIEW_PACKAGE.md
CHANGED
|
@@ -123,14 +123,14 @@ The active compiled model supplies rig identity. Without one, a known root or un
|
|
|
123
123
|
|
|
124
124
|
An Apply rebuilds through the installed producer from the files the inspected rig was built from: the producer config, its parts directory (`parts.json`, `parts/`) and the inspected rig directory `<parts>/rig/`. Review finds them in this order, first match wins, never by searching:
|
|
125
125
|
|
|
126
|
-
1. **Declared on the command line**: `rig-review open <dir> --config <path> [--parts <dir>] [--source <png> --full <dir> [--head <dir>]]`. `feedback` and `control` take the same flags so retrieval binds the same identity. Paths are resolved from the working directory and canonicalized; the worker and every reload read exactly those files.
|
|
126
|
+
1. **Declared on the command line**: `rig-review open <dir> --config <path> [--parts <dir>] [--source <png> --full <dir> [--head <dir>]] [--keypoints <file>]`. `feedback` and `control` take the same flags so retrieval binds the same identity. Paths are resolved from the working directory and canonicalized; the worker and every reload read exactly those files.
|
|
127
127
|
2. **Declared in review.json**: the `producer` block, package-relative literal portable paths (no `..`, no absolute path, no encoding).
|
|
128
128
|
3. **Discovered inside the opened folder**: `config.json`, then `rig/config.json`.
|
|
129
129
|
4. **Discovered beside an opened `out/`** (the last step): `../config.json`. It is outside the opened folder, so the record says `"how": "discovered", "outside": true` and the editor bar says *discovered outside the opened folder*, with its path and SHA-256. This step runs only when the opened folder is named `out`, and only for a regular file there (a symlink is refused).
|
|
130
130
|
|
|
131
131
|
Without `--parts` the parts directory is `out/` beside the config (for step 4, the opened `out/` itself). A declared or discovered path that is missing, outside its root (the package root; for step 4 the folder holding `out/`), or a symlink that leaves it is refused by name: `HANDOFF_PATH_OUTSIDE_ROOT`, `HANDOFF_SYMLINK_ESCAPE`, `HANDOFF_INPUT_MISSING`, `HANDOFF_INPUT_INVALID`, `HANDOFF_PATH_INVALID` (`open` prefixes `OPEN_HANDOFF_INVALID <flag or path>`). Nothing found is not a load error: `/api/rebuild` reports `MODELING_INPUT_UNAVAILABLE: config.json — looked at config.json, rig/config.json, ../config.json (beside an opened out/); declare it with rig-review open <dir> --config <path> [--parts <dir>] or the review.json "producer" block`, and Apply refuses `MODELING_HANDOFF_MISSING`.
|
|
132
132
|
|
|
133
|
-
**Hashed files.** The config; the files the config names that the producer resolves against the config's directory (`motion.animations_from`, `generation.control.skeleton`; each must stay inside the root, a missing one is listed in `missing`); `parts.json` and every regular file directly in `parts/`; every regular file directly in the inspected `rig/` (`rig.json`, `motion.json`, `mesh_report.json`, `sets.json`, …) and `rig/images/`; with `--source`/`--full`/`--head`, that PNG and the regular files directly in those directories.
|
|
133
|
+
**Hashed files.** The config; the files the config names that the producer resolves against the config's directory (`motion.animations_from`, `generation.control.skeleton`; each must stay inside the root, a missing one is listed in `missing`); `parts.json` and every regular file directly in `parts/`; every regular file directly in the inspected `rig/` (`rig.json`, `motion.json`, `mesh_report.json`, `sets.json`, …) and `rig/images/`; with `--source`/`--full`/`--head`, that PNG and the regular files directly in those directories; with `--keypoints` (#111; the review.json block's `keypoints`), that file — the same path rules as `--source` (literal, inside the root, no symlink escape).
|
|
134
134
|
|
|
135
135
|
**Record and identity.** `reviewedSource.handoff` (absent when there is no handoff, so earlier identities are unchanged):
|
|
136
136
|
|
|
@@ -140,7 +140,7 @@ Without `--parts` the parts directory is `out/` beside the config (for step 4, t
|
|
|
140
140
|
"files":[{"path":"rig/config.json","sha256":"…"},{"path":"rig/out/parts.json","sha256":"…"}],"missing":[]}
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
`how` is `declared` or `discovered`, `via` is `open --config`, `review.json producer` or `discovery`, and paths are relative to the package root (`../` only for step 4). The record is part of `reviewedSource`, so it reaches `/api/review`, the session, the feedback export and the session file name: changing any handoff file's bytes starts a new pending session while `sourceHash` (the package bytes) is unchanged, and the open-managed key changes with it. The same inputs give the same record, key and URL.
|
|
143
|
+
`keypoints` (#111) is `{path, sha256}` and is present only when the keypoint file is declared: a handoff without it has no `keypoints` key and hashes no keypoint file, so its record and identity are what they were before. `how` is `declared` or `discovered`, `via` is `open --config`, `review.json producer` or `discovery`, and paths are relative to the package root (`../` only for step 4). The record is part of `reviewedSource`, so it reaches `/api/review`, the session, the feedback export and the session file name: changing any handoff file's bytes starts a new pending session while `sourceHash` (the package bytes) is unchanged, and the open-managed key changes with it. The same inputs give the same record, key and URL.
|
|
144
144
|
|
|
145
145
|
**Baseline.** See README, *Chest regions*: once per session `rig-parts rig --config --parts --out <state>/baseline/<key>/baseline`, compared file by file with the inspected rig; `/api/rebuild` returns `baseline` (`status` `running`, `reproduced`, `not-reproduced`, `refused` or `failed`, `firstDifference`, both rig.json and tree hashes, `wallMs`). Nothing is written into the package. Once the baseline reproduces, `rig-parts check --rig baseline --out inspected-check --parts <parts> --page-edges free --pack-shape polygon` runs in the background in the same directory, and `/api/rebuild` returns `inspectedCheck` (`{schema:"rig-review-inspected-check/1", key, status:"running"|"checked"|"refused"|"failed", lines[], check (the check summary, or null), line ("check running…", check's `check: …` line, or its named refusal verbatim), exitCode, wallMs}`, else `null`). The finished record is stored as `<state>/baseline/<key>/inspected-check.json`, so a reopen reads it instead of re-running; it is the physics readout's BEFORE for the inspected build.
|
|
146
146
|
|
|
@@ -160,16 +160,24 @@ Every edit card states its edit as one config patch; review applies it to a scra
|
|
|
160
160
|
- **Refusals before anything runs.** Malformed (HTTP 400): `EDIT_PATCH_INVALID`, `EDIT_PATCH_OP_UNKNOWN`, `EDIT_PATCH_POINTER_INVALID`, `EDIT_PATCH_CMD_UNKNOWN`, `EDIT_PATCH_ARG_UNKNOWN`. Outcomes (HTTP 200, `{status:"refused", error, producerLine:null}`): `EDIT_PATCH_STALE` when `config_sha256` is not the handoff config's hash; `EDIT_PATCH_PATH_MISSING` when a pointer does not resolve in that config; `EDIT_PATCH_NAME_MISMATCH` when an op's `name` is not the name its index holds there; `EDIT_PATCH_PATH_HELD` when the patch changes a file name the config resolves against its directory (`motion.animations_from`, `generation.control.skeleton` — handoff files); `EDIT_PATCH_OUT_OF_SCOPE` when a `remesh` patch edits outside `/meshes/<part>`; `EDIT_PRODUCER_MISMATCH` when the installed producer is not the patch's; for a `rig` patch the runner runs as the whole-rig `rig` (*Routing*, below), `EDIT_BASELINE_MISMATCH: <first differing path>` unless the session's baseline reproduced the inspected rig (*Producer handoff*, Baseline); and the producer-resolution refusals of an Apply.
|
|
161
161
|
- **Routing (#136).** A `rig` patch runs `rig-parts retune` instead of the whole-rig `rig` when the installed producer is `@rig-c/parts` 1.44.0 or later (`RETUNE_SINCE`, the CHANGELOG's first release with retune) and **every** op's pointer lies at or under one of `/constraints`, `/motion/duration`, `/motion/tracks`, `/motion/blink/t`, `/motion/blink/squash`, `/motion/blink/brow_drop` (`src/retune-route.ts`). The scope is retune's own help: it splices the config's constraints (rig.json `constraints` and `invariants.detached`) and idle (motion.json `animations.idle`) onto the gated inspected rig and holds stops, bones, slots, skeleton, images, every other invariant and every other byte of motion.json (`RETUNE_REQUEST_HELD`). So `/stops`, `/bones`, `/regions`, `/turn`, `/meshes`, `/motion/animations_from`, the blink's `eyes` and `brows` (its motion.json groups) and `still` (a `<part>_still` slot), and a patch mixing any of them with retune's pointers, keep the whole-rig `rig` behind the baseline guard; so does every patch on an older producer. Retune needs no baseline: it holds the rest of the inspected rig itself, repairs included, so it Applies on a build whose baseline did not reproduce. What it judges per value (a track that newly keys a mesh-bound bone, which gains a `<bone>_ctl` under `--idle-keys ctl`) comes back as its own `RETUNE_REQUEST_HELD` line verbatim, as do `RETUNE_CONFIG_REFUSED` and its other refusals.
|
|
162
162
|
- **Build (#115).** A `rig` patch with any op at or under `/assemble` or `/limbs` — or under `/bones` on a config that declares `limbs`, whose joints are their bones' origins — runs `rig-parts build` (op `build`): those sections are read by the producer's assemble stage, which `rig --config --parts` never runs (rig-parts AUTHORING §3, the `assemble.cuts`, `assemble.patches` and `limbs` rows). It needs the handoff's painting and full run (`--source <png> --full <dir> [--head <dir>]`, or the review.json block); without them it refuses before anything runs: `MODELING_INPUT_UNAVAILABLE: source painting and See-through runs — rig-parts build reassembles the parts from the painting (--source) and the runs (--full, --head) before the rig, and the handoff declares none; declare them with …`. It is a whole-rig op behind the reproduced baseline (`EDIT_BASELINE_MISMATCH`). The files assemble reads by the config's own naming (each cut's `under`, each limb segment's `cap`, each alternate's `source`, `full`, `head` and `skeleton`) are resolved against the handoff config's directory, must stay inside the package root (`BUILD_INPUT_OUTSIDE_ROOT`, `BUILD_INPUT_MISSING`) and are hashed into the build id; a patch may not name a new one (`EDIT_PATCH_PATH_HELD` — it may keep, move or drop one the config names). `config.json` is the patched config with those paths and the two above made absolute, then `rig-parts build --config config.json --source <handoff source> --full <handoff full> [--head <handoff head>] --out out --seam-pairs [args]`; the rig spec is `out/rig`, and the shared tail diffs and compiles it (no second check: build's own check ran on the parts it assembled). A stage that refuses leaves no scratch, and its FAIL lines come back verbatim (the stage prefix `[assemble]`, `[rig]`, `[check]` stripped); a build that stopped at check with bars not met is kept, check's FAIL lines beside it. The job adds `assembled: {inputs:{source, full, head, named:[{field, path, sha256|null}]}, refusals[], checkFails[], lines[] (the cut:, limb cut:, limb cap: and uncovered-hole lines), seam[], seamPairs[] (the base set's `seam pair` lines), recomposite:{line, before, after, errorMap:{url, sha256}|null}, parts:{changed[], unchanged, partsJson:{count, paths}}|null}` (`before` is the handoff's `parts.json` recomposite block, `after` the scratch one; `errorMap` serves `out/recomposite_error_rig.png` read-only at `/rebuild/<id>/recomposite/recomposite_error_rig.png`), and `wallMs.op` is the build's own wall clock (assemble → rig → check).
|
|
163
|
+
- **Cloth (#111).** A `rig` patch whose ops all lie under `/cloth` (the Setup · Cloth block: one recipe per tag the installed reader accepts — read from its own `CLOTH_TARGETS` — with the reader's fields) runs op `cloth`; a patch mixing `/cloth` ops with others is refused before anything runs (`CLOTH_PATCH_MIXED`). Refused before anything runs: `CLOTH_KEYPOINTS_UNDECLARED` when the handoff declares no `--keypoints` (without them the draft moves the body bones the config states — Decision 1), `MODELING_PRODUCER_TOO_OLD <found> 1.49.1` (the first `@rig-c/parts` whose `--write-config` splices `bones`, `constraints` and `motion.tracks` entry by entry, parts#552), `MODELING_INPUT_UNAVAILABLE: source painting …` without `--source`, and, as a whole-rig op, `EDIT_BASELINE_MISMATCH`. `cloth.json` is the patched config written as `rig` writes its scratch config, with the assemble-read paths made absolute as for `build`; then `rig-parts propose --parts <handoff parts> --source <handoff source> --keypoints <handoff keypoints> --out propose --cloth cloth.json --write-config config.json` (no `--wrist-snap` or `--grid-rule`: the handoff states neither). Its refusals come back verbatim, every FAIL line in `cloth.refusals` — the reader's `CONFIG_CLOTH_*`, `PROPOSE_CLOTH_DRAFTED` (a recipe that drafts nothing), `PROPOSE_CLOTH_PART`, and the write's `PROPOSE_WRITE_CONFIG_FRESH`, `PROPOSE_WRITE_CONFIG_CONFLICT` (an entry the recipe re-drafts or removes that equals neither the draft with the recipe nor the draft without it, rig-parts AUTHORING *The config written*) and `PROPOSE_WRITE_CONFIG_SPLICE`. Review reads only the written `config.json`, never the producer's draft file: it lists every JSON Pointer at which the written config differs from `cloth.json` and refuses to build one that differs outside the sections propose splices (`bones`, `constraints`, `meshes`, `regions`, `limbs`, `motion/tracks`, `turn`, `proposed_rig_scale`): `CLOTH_WRITTEN_OUTSIDE_DRAFTED`. The written config is then built (Decision 2) as `rig-parts build --config config.json …` exactly as a `build` patch when the config declares `limbs` (the job adds `assembled`), else `rig-parts rig --config config.json --parts <handoff parts> --out rig`, and goes through the shared tail. The id adds `cloth`, the keypoint file's hash and (for the build route) the build inputs' hash. The job adds `cloth: {route:"build"|"rig", inputs:{source, keypoints:{path, sha256}}, refusals[], line (propose's `wrote config.json: …` line), written:{count, paths[≤200], outside[≤200], sections[]}|null, links[] (per tag: its parts and the written config's chains, the link preview), wallMs:{propose, build}}`. `GET /api/rebuild` adds `cloth: {targets, targetsRead, recipes, tags[] (per tag: its parts by See-through tag or by the recipe's `part`/`parts`, and the chains their `meshes.<part>.segments` name with `points` and `tip`), keypoints, route, reason}`.
|
|
163
164
|
- **Run.** Scratch is `<state>/rebuild/<package source hash>/<id>/`, `id = sha256([rig-review-rebuild/1, "patch", source hash, config SHA-256, the patch's canonical bytes, producer@version, compiler@version])` — no clock; the same patch again reuses its build. `rig`: `config.json` there is the patched config written as the producer's own scratch configs are (2-space JSON, `motion.animations_from` and `generation.control.skeleton` made absolute when they name a file beside the handoff config), then `rig-parts rig --config config.json --parts <handoff parts> --out rig [args]`, or for a retune-routed patch `rig-parts retune --rig <handoff rig> --config config.json --parts <handoff parts> --out rig [args]` (the same config; retune reads the inspected rig and writes only `rig/`, with `retune.json`). `remesh`: `mesh.json` is the patched `meshes.<part>` entry and `rig-parts remesh --rig <handoff rig> --config <handoff config> --parts <handoff parts> --part <part> --mesh mesh.json --out rig [args]` (remesh reads the config as written). Then `rigc build --rig rig/rig.json --motion rig/motion.json --out build --profile rig-play --pack --page-edges … --pack-shape …` (the args' values, else `free` / `polygon`; for remesh, its `remesh.json` request; for retune, its `retune.json` request) and `rig-parts check --rig rig --out check --parts <handoff parts>` with the same pack flags. A producer or compiler refusal is the first named line verbatim and leaves no scratch; a check `FAIL` is check's judgment, kept beside the build.
|
|
164
|
-
- **
|
|
165
|
+
- **A retune's check (#139).** A retune-routed Apply does not run `rig-parts check` a second time: retune already gated and checked the revision it wrote (`checkStage` over that revision, the same `--parts` and pack flags review passes), and from `@rig-c/parts` 1.49.0 (`RETUNE_CHECK_SINCE`, parts#541) `retune.json`'s `check.revision` carries `line` — check's own last line, byte for byte — and `status` — every block's status as that tree's `check.json` records it, in the file's order. Review first binds it to the files: every `revision` hash row's file under `rig/` must hash to its row, none missing, and `rig/` holds no file but those rows and `retune.json`; otherwise the Apply is refused `RETUNE_CHECK_STALE: <file> sha256 <in retune.json> … <on disk>; retune's check is not this revision's, so no build is recorded` (`absent` on the side without the file) and leaves no scratch. Bound, the side becomes the same check summary a separate check gives (below): `pass` ← `pass`; `exitCode` ← 0 when it passes, else 1 (check's own exit); `line` ← `line`; `bars` ← every upper-case `status` entry (`gate`, `loop` and `seam` are read off figures and are not check.json bars); `gate` ← `status.gate` is `PASS`; plus `source:"retune"`. The job's `wallMs.check` is `null` and its lines say `review: check not run again — retune.json carries retune's check of this revision, bound to it by N revision hash row(s): <line>`. Check still runs on the revision, as before, when `retune.json` carries no `line` / `status` (an older producer; the line says so — not a refusal) or when the Apply checks under an exported world (#112; retune's check read the default one). Whole-rig `rig`, `remesh`, `build` and cloth keep running check.
|
|
166
|
+
- **Request and job.** `POST /api/rebuild` takes exactly `{patch}` (or #99's `{part, regions, configHash}`, or #105's `{part, bust, configHash}` — see *Chest landmarks* below, or #135's `{part, delta, configHash}` — see *Mesh edit export*), same-origin, 64 KiB. The job (`GET /api/rebuild/<id>`): `{schema:"rig-review-rebuild/1", kind:"patch", id, status:"running"|"built"|"refused"|"failed", reused, request:{patch, patchSha256, configHash}, producer, compiler, lines[], producerLine, exitCode, wallMs:{op, compile, check}, check, diff:{count, paths[≤200]}, build:{model, modelUrl, modelHash, files}|null}`. `diff` lists every JSON Pointer at which the scratch `rig.json` differs from the inspected one (a changed leaf or type, a key or index only one side has). `check` is `{schema:"rig-review-check-summary/1", pass (check.json's PASS, or null without one), exitCode, line (check's last `check: …` line, or its first named refusal), bars:{NAME: status}, gate}`, plus `source:"retune"` when the summary is retune's carried check (#139; absent: check's own output, `check.json` and its stdout).
|
|
165
167
|
|
|
166
168
|
**`context.applied`.** Feedback written while a scratch build is on the stage carries the applied state; otherwise `null`. Absent and `null` stay valid, and a present value must be exactly one of:
|
|
167
169
|
|
|
168
170
|
- `rig-review-applied/1` (#99 regions): `{schema, buildId, part, regions, configHash, entrySha256, producer, compiler, line, modelHash}`; a Landmarks Apply (#105) adds `bust` (the `contour.bust` entry as sent) and its `regions` are review's derivation of the two circles.
|
|
169
|
-
- `rig-review-applied/2` (patch): `{schema, buildId, patch, patchSha256, configHash, producer:{package,version}, compiler:{package,version}, line, check, diff, modelHash}` — the exact patch (whose `config_sha256` equals `configHash`), its hash, the producer's last line, check's summary and the rig.json diff. Since #136 the record adds `op`, the producer op that ran: `"rig"` or `"retune"` for a `rig` patch, `"remesh"` for a remesh patch (anything else is refused); the job carries the same `op` and its lines name the command. Records without `op` stay valid. Since #115 `op` may be `"build"` for a `rig` patch, and exactly then the record adds `assembled: {wallMs, recomposite:{before, after}, partsChanged[], seamPairs[≤64]}` — the build's wall clock for that Apply, the recomposite figures, the parts whose PNG changed against the handoff's and the `seam pair` lines; the issue export's `Applied:` line names the wall clock (`· build 118.0 s · parts changed: bottomwear, ribbon_r · recomposite error px 8194 → 8194`). A remesh patch from the Mesh panel (#106) adds `mesh: {part, before:{vertices, triangles, hull}, after:{vertices, triangles, hull}}` — the revision's `remesh.json` counts; `part` must be the part the patch's `remesh --part` names. Since #125 the record adds `files: {documents:[{file, count, paths}], images:{changed, unchanged, names}}` (or `null`) — the scratch rig against the inspected rig, every document the producer writes: `documents` in the order `rig.json`, `motion.json`, `sets.json`, `mesh_report.json` (each `file` at most once; a document neither side has is left out), `count` the number of RFC 6901 pointers at which it differs and `paths` the first ≤ 200 of them (`""` — the whole document — when only one side has it or a side is not JSON, unless the bytes are equal); `images` compares `images/*` by SHA-256 only: `changed` (including an image only one side has, its `images/<name>` in `names`, ≤ 200) and `unchanged`. `diff` stays rig.json's entry as before. Records without `files` (written before #125) stay valid. The list of documents is `PRODUCER_DOCUMENTS` in `src/producer-files.ts`, the same list the baseline compares. The Apply status line and the export state it per file: `motion.json differs at 2 path(s) · rig.json, sets.json, mesh_report.json unchanged · 31 images unchanged`.
|
|
171
|
+
- `rig-review-applied/2` (patch): `{schema, buildId, patch, patchSha256, configHash, producer:{package,version}, compiler:{package,version}, line, check, diff, modelHash}` — the exact patch (whose `config_sha256` equals `configHash`), its hash, the producer's last line, check's summary and the rig.json diff. Since #136 the record adds `op`, the producer op that ran: `"rig"` or `"retune"` for a `rig` patch, `"remesh"` for a remesh patch (anything else is refused); the job carries the same `op` and its lines name the command. Records without `op` stay valid. Since #111 `op` may be `"cloth"` for a `rig` patch whose ops all lie under `/cloth`, and exactly then the record adds `cloth: {route:"build"|"rig", keypoints:{path, sha256}, written:{count, paths[≤200]}}` — how the config propose wrote was built, the keypoint file it read and where the written config differs from the patched one — and, when the route is `build`, `assembled` as below; the export's `Applied:` line names the written diff and the route. Since #115 `op` may be `"build"` for a `rig` patch, and exactly then the record adds `assembled: {wallMs, recomposite:{before, after}, partsChanged[], seamPairs[≤64]}` — the build's wall clock for that Apply, the recomposite figures, the parts whose PNG changed against the handoff's and the `seam pair` lines; the issue export's `Applied:` line names the wall clock (`· build 118.0 s · parts changed: bottomwear, ribbon_r · recomposite error px 8194 → 8194`). A remesh patch from the Mesh panel (#106) adds `mesh: {part, before:{vertices, triangles, hull}, after:{vertices, triangles, hull}}` — the revision's `remesh.json` counts; `part` must be the part the patch's `remesh --part` names. Since #125 the record adds `files: {documents:[{file, count, paths}], images:{changed, unchanged, names}}` (or `null`) — the scratch rig against the inspected rig, every document the producer writes: `documents` in the order `rig.json`, `motion.json`, `sets.json`, `mesh_report.json` (each `file` at most once; a document neither side has is left out), `count` the number of RFC 6901 pointers at which it differs and `paths` the first ≤ 200 of them (`""` — the whole document — when only one side has it or a side is not JSON, unless the bytes are equal); `images` compares `images/*` by SHA-256 only: `changed` (including an image only one side has, its `images/<name>` in `names`, ≤ 200) and `unchanged`. `diff` stays rig.json's entry as before. Records without `files` (written before #125) stay valid. The list of documents is `PRODUCER_DOCUMENTS` in `src/producer-files.ts`, the same list the baseline compares. The Apply status line and the export state it per file: `motion.json differs at 2 path(s) · rig.json, sets.json, mesh_report.json unchanged · 31 images unchanged`.
|
|
170
172
|
- `rig-review-applied/3` (#135, Edit Mesh's delta): `{schema, buildId, part, delta, deltaSha256, configHash, producer, compiler, line, op, files, mesh?, modelHash}` — the `rig-review-mesh-edit/1` document as sent (basis `"setup"`, `slot` the part, at most 300 ops), the SHA-256 of its bytes, the producer's last line, `op` always `"remesh"`, the per-file diff as above (or `null`) and the part's `remesh.json` counts (`mesh.part` must be `part`). No `check`: `rig-parts remesh` holds check on the revision itself (`REMESH_CHECK_HELD`). See *Mesh edit export*.
|
|
171
173
|
|
|
172
|
-
**Readings (#108).** `POST /api/readings` (single review; same-origin, capability,
|
|
174
|
+
**Readings (#108).** `POST /api/readings` (single review; same-origin, capability, 64 KiB) takes exactly `{animation, readings:[1–8], environment}`: each reading is `{kind:"folds"}`, `{kind:"overlaps"}`, `{kind:"pass-through", cloth, leg}`, `{kind:"seam", a, b}` or `{kind:"angle-range", bone, relative}`, every name a slot or bone name without `:` or `,` (whether the rig has it is rigc's to say), no reading twice; `environment` is `null`, `{wind:[x,y], gravity:[x,y]}` (x right, y down) or, with *Use for readings* on (#112), the exported `rigc-environment/1` schedule as data. Malformed: HTTP 400 `READINGS_REQUEST_INVALID`. Outcomes (HTTP 200 `{status:"refused", error}`): `READINGS_COMPILER_NOT_INSTALLED` (rig-c beside `@rig-c/parts`, else beside the package or review), `READINGS_COMPILER_TOO_OLD <found> 2.63.0`, `READINGS_NO_MODEL`, `READINGS_STATE_INSIDE_PACKAGE`, `READINGS_BUSY`. The job (`GET /api/readings/<id>`, the id hashed from the package, the request, the inspected model's bytes, the scratch build id, the compiler and whether the schedule was passed): `{schema:"rig-review-readings/1", id, status:"running"|"done"|"failed", error, request, environment:{requested, flag, passed}, compiler, runs:[{build:"inspected"|"scratch", id, modelHash, results[]}]}`. Each result is `{reading, label, status:"read"|"refused"|"failed", fps, frames[] (every frame the reading's JSON lists: folds' meshes' frames, pass-through's frames, seam's opens, overlaps' moving pairs' runs; angle-range's lowest and highest frame from its printed line), source:"json"|"line", worst:{frame, figure, line}|null, items[] (per mesh or pair), lines[] (rigc's printed rows for the reading), refusal (rigc's `rigc: …` line verbatim), exitCode, wallMs}`. The inspected build is `--candidate <the directory of the manifest's model>`; the scratch build is the session's latest built Apply. Each reading runs as `rigc render --candidate <build> --animation <animation> [--environment environment.json] <flag> --out render` in `<state>/readings/<package hash>/<inspected-<model sha256>|scratch-<build id>>/<key>/`, keeps `result.json`, the reading's JSON and `frames.json`, and removes the rendered frames. The schedule `{spec:"rigc-environment/1", steps:[{t:0, wind, gravity}]}` is passed only when `rigc render --help` lists `--environment`. A schedule sent in the request is written as sent (re-serialized, one-space JSON and a newline).
|
|
175
|
+
|
|
176
|
+
**Environment schedule (#112).** The Animate Environment block exports its controls as a `rigc-environment/1` schedule (`src/environment-schedule.ts`): `{spec:"rigc-environment/1", steps:[{t, wind?:[x,y], gravity?:[x,y]}]}`, x right, y down, t = 0 at the clip's first frame, written as one-space JSON and a newline (the bytes downloaded, sent and hashed). With neither breeze nor a repeating gust it is the step form, one step `{t:0, wind:[base,0], gravity:[0,g]}` (#108's standing schedule) — exact. With breeze or repeat on it is the sampled form: one step per frame at 12 fps (rigc render's default and check's IDLE_FPS) from 0 to the clip's last frame (`round(duration·12)`; rigc refuses a step after it), each the panel's wind at that time from a fresh clock (a repeating gust starts at 0, every `max(6, duration + 0.6)` s, turned when the base is negative), a step equal to the one before left out, gravity on the first step. rigc sets a step before the physics step that ends on its frame, as the page sets the wind before the player's step, so the schedule equals the panel's wind at every frame time; between frames rigc holds the frame's value, within `max|dw/dt| / 12` of the panel's (`(breeze ? 8.48 : 0) + (repeat ? peak·π/duration : 0)`, over 12). A one-off Gust (Repeat off) is not in the schedule. Refused by name: `ENVIRONMENT_NOT_APPLIED` (the controls are not engaged; check and render without `--environment` already read the runtime's world), `ENVIRONMENT_SCHEDULE_NO_CLIP`, `ENVIRONMENT_SCHEDULE_TOO_LONG` (more than 600 steps). *Use for readings* sends the drawn clip's schedule with readings and the checked clip's (`idle` when the rig has one) with Apply, and restarts the wind clock with the clip (and at each idle loop) so the person watches the world rigc reads. *Export schedule* downloads `rigc-environment.json`.
|
|
177
|
+
|
|
178
|
+
**Check under this world (#112).** `POST /api/environment-check` (single review; same-origin, capability, 64 KiB) takes exactly `{build:"inspected"|"scratch", schedule}`, the schedule as data (exact keys, ascending t from 0, a half stated per step, at most 600 steps); malformed: HTTP 400 `ENVIRONMENT_CHECK_REQUEST_INVALID`. Outcomes (HTTP 200 `{status:"refused", error}`): `MODELING_HANDOFF_MISSING`, `ENVIRONMENT_CHECK_NO_SCRATCH`, `MODELING_PRODUCER_NOT_INSTALLED`, `MODELING_PRODUCER_TOO_OLD <found> 1.48.0: rig-parts check --environment needs @rig-c/parts 1.48.0 or later — …`, `ENVIRONMENT_CHECK_STATE_INSIDE_PACKAGE`, `ENVIRONMENT_CHECK_BUSY`. Review writes the schedule as `environment.json` in `<state>/environment-check/<package hash>/<id>/` (the id hashes the package, the schedule's bytes, the build, the scratch build id, the handoff config and the producer; no clock) and runs `rig-parts check --rig <the handoff rig, or the latest scratch build's rig spec> --out out --parts <handoff parts> --page-edges free --pack-shape polygon --environment environment.json` there — the inspected build's own background check's flags, so the default-world line beside it differs only by the world. The check output is removed; `result.json` is kept, and the same schedule on the same build is read back. The job (`GET /api/environment-check/<id>`): `{schema:"rig-review-environment-check/1", id, status:"running"|"checked"|"refused"|"failed", build, buildId, schedule:{sha256, size, steps}, producer, exitCode, wallMs, check (the check summary), environment (check.json's `{path, sha256, steps}`; a sha256 that is not the schedule's fails the job, `ENVIRONMENT_CHECK_UNRECORDED`), environmentLine (check's `environment: …` line), bars:[{name, line}] (ANIMATIONS_DO_NOT_FOLD and TEXTURE_STRETCH as check printed them), error (a `FAIL CHECK_ENVIRONMENT: …` refusal verbatim), lines[]}`. A bar that check judges FAIL is check's judgment (`status:"checked"`), not a refusal.
|
|
179
|
+
|
|
180
|
+
**Apply under the world (#112).** `POST /api/rebuild` also takes `{patch, environment}`: the schedule check reads in the shared tail (`--environment environment.json`, written into the scratch directory first; a `build`-routed patch forwards it to `rig-parts build`, whose check stage reads it). It needs `@rig-c/parts` 1.48.0 (`MODELING_PRODUCER_TOO_OLD <found> 1.48.0: rig-parts check --environment …`, nothing run); a malformed schedule is HTTP 400 `REBUILD_REQUEST_INVALID`. The patch id adds the schedule's sha256, so a default-world build is never reused for it. The job and `rig-review-applied/2` add `environment:{sha256, steps}`; without a schedule neither carries the key and nothing in the tail moves.
|
|
173
181
|
|
|
174
182
|
**`context.readings`.** Feedback written after a readings run carries the pairs of the last finished run: `{schema:"rig-review-readings/1", animation, requests[] (the readings as sent), environment, environmentPassed, builds:{inspected:<model sha256>, scratch:<build id>|null}, compiler:{package, version}, ticks:{inspected, scratch|null}}`; `null` when none ran. Absent and `null` stay valid; a present value must have this shape. The results themselves are not recorded: the AI receiving the review reruns the same `rigc render` readings on the named builds.
|
|
175
183
|
|
|
@@ -179,6 +187,8 @@ Every edit card states its edit as one config patch; review applies it to a scra
|
|
|
179
187
|
|
|
180
188
|
**Retarget (#114).** `GET /api/rebuild` adds `retarget: {available, reason, producer, compiler, rig, sequences:[{id, label, path, cleaned, frames, people[]}], bones[], animations[]}`: the installed `@rig-c/pose` (found as `@rig-c/parts` is: the nearest `node_modules` from the package, then from review's working directory; `RETARGET_PRODUCER_NOT_INSTALLED` when absent, never installed) and the `rig-c` beside it (`RETARGET_COMPILER_NOT_INSTALLED`), the package's declared `rig-pose-sequence/1` pose references, and the handoff rig's bone and animation names (`RETARGET_SEQUENCE_UNAVAILABLE` when the package declares no sequence). `POST /api/rebuild` with exactly `{retarget: {sequence, map, flags, configHash}}` (same-origin, capability, 64 KiB): `sequence` a declared pose reference id; `map` a `rig-pose-map/1` document of at most 32 KiB whose chains each hold a name and 1–32 bones and joints (everything else is the producer's to judge); `flags` exactly `{animation, person, clean, minScore, flip, short, bends, bendTolerance, legScale, plantFeet, stance, raisedByHead, carryIdle}`, `clean` null or exactly `{loop, legs, rest, hold, hipsInferred}` (windows 1, or odd and 5–61). Malformed: HTTP 400 `RETARGET_REQUEST_INVALID`. Refused before anything runs: `REBUILD_CONFIG_STALE`, `RETARGET_SEQUENCE_UNKNOWN`, `RETARGET_SEQUENCE_UNSUPPORTED` (a static pose), the handoff and state-directory refusals, `REBUILD_BUSY`. The server maps the flags to argv (`op.args` for retarget, `op.clean` for clean, only what the flags state) and runs, in `<state>/rebuild/<package hash>/<retarget id>/`, `rig-pose clean --pose pose.json --out pose.clean.json <op.clean>` (unless `clean` is null) and `rig-pose retarget --rig <handoff rig> --map map.json --sequence <pose.clean.json | pose.json> --out motion.json <op.args>`, copies the handoff rig's regular files (motion.json excepted) and `images/` to `rig/` with `rig/motion.json` the retarget's, then the shared tail: the per-file diff and `rigc build --rig rig/rig.json --motion rig/motion.json --out build --profile rig-play --pack --page-edges free --pack-shape polygon`. The retarget id hashes the package, the handoff config, the record's bytes and both tool versions (no clock); an identical retarget reuses its build. The job (`GET /api/rebuild/<id>`): `{schema:"rig-review-rebuild/1", kind:"retarget", op:"retarget", id, status, reused, request:{record, recordSha256, configHash}, producer, compiler, lines[], producerLine, refusals[] (every FAIL line of the step that refused, verbatim), exitCode, wallMs:{clean, retarget, compile}, files, motion:{animation, animations[], kept, changed[], animationSha256, motionSha256}, readings:[{code, line, frame, time}], build}`. `readings` are the retarget's FEET, STANCE, FLIP, SHORT, END-ON, LEG-SCALE and REACH lines (at most 200), each with the first frame it names and that frame's key time from the run's report `frameTimes` (else the line's `t=`). The record, `rig-review-retarget/1`: `{schema, config_sha256, rig:{path, sha256 (rig.json)}, sequence:{reference, path, sha256}, map, map_sha256 (the bytes of map.json as written: one-space JSON and a newline), flags, producer:{name, version}, op:{cmd:"retarget", args, clean}}`; a reader checks that `op` is its flags' own argv. The applied context of a built retarget is `rig-review-applied/4`: `{schema, buildId, retarget, recordSha256, configHash, producer, compiler, line, motion, files, readings, modelHash}`.
|
|
181
189
|
|
|
190
|
+
**`context.environmentSchedule`.** Feedback written while the Environment controls are applied carries the drawn clip's exported schedule by hash and content: `{schema:"rig-review-environment-schedule/1", sha256, size, text, usedForReadings}` — `text` is exactly the exported bytes (a `rigc-environment/1` schedule), `sha256` and `size` are recomputed over it on save, `usedForReadings` says whether readings and Apply read it. Absent and `null` stay valid. The issue export adds an `Environment schedule:` line.
|
|
191
|
+
|
|
182
192
|
**`context.requirements`.** Feedback written while requirements are declared carries the file by hash and content: `{schema:"rig-review-requirements-context/1", sha256, size, text, checked:{build, producer, summary, refusals[], rows:[{name, status, worst}]}|null}` — `text` is exactly the bytes the producer reads, `sha256` and `size` are recomputed over it on save, and `checked` is the last check of those same bytes. Absent and `null` stay valid.
|
|
183
193
|
|
|
184
194
|
A `rig-review-applied/2` record whose `check.pass` is `false` is refused at submission (`PUT /api/session`) as `EDIT_SUBMIT_UNCHECKED: applied build failed check — <check's "check: FAIL …" line, else the first bar check.json marks FAIL>`; the build may still be loaded and looked at. `GET /api/rebuild` also returns the handoff config's `constraints` list as written (or `null`), which the physics tuning panel maps against the inspected model.
|
package/package.json
CHANGED
package/src/applied-context.ts
CHANGED
|
@@ -41,6 +41,12 @@ export interface CheckSummary {
|
|
|
41
41
|
/** Every bar check.json carries as `{status}`, by name. */
|
|
42
42
|
bars: Record<string, string>;
|
|
43
43
|
gate: boolean | null;
|
|
44
|
+
/**
|
|
45
|
+
* #139: where the summary came from. Absent: review read check's own output (`check.json` and its stdout). `"retune"`:
|
|
46
|
+
* the check `rig-parts retune` ran on the revision it wrote, carried in `retune.json`'s `check.revision` (parts 1.49.0+)
|
|
47
|
+
* and bound to the revision's files by its hash rows; no second check ran.
|
|
48
|
+
*/
|
|
49
|
+
source?: 'retune' | 'check';
|
|
44
50
|
}
|
|
45
51
|
export interface Tool { package: string; version: string }
|
|
46
52
|
export interface AppliedPatch {
|
|
@@ -60,9 +66,24 @@ export interface AppliedPatch {
|
|
|
60
66
|
/** #125: every producer document's diff, per file, and the images by hash. Absent in older records. */
|
|
61
67
|
files?: FilesDiff | null;
|
|
62
68
|
/** #136: the producer op that ran — `retune`, `rig` or (#115) `build` for a `rig` patch, `remesh` for a remesh patch. Absent in older records. */
|
|
63
|
-
op?: 'rig' | 'retune' | 'remesh' | 'build';
|
|
69
|
+
op?: 'rig' | 'retune' | 'remesh' | 'build' | 'cloth';
|
|
70
|
+
/** #111, a `cloth` op only: how the config propose wrote was built, the keypoints it read and where the written config differs from its input. */
|
|
71
|
+
cloth?: AppliedCloth;
|
|
64
72
|
/** #115, a `build` op only: the build's wall clock, the recomposite figures before and after, the parts it changed and its `seam pair` lines. */
|
|
65
73
|
assembled?: AppliedAssembled;
|
|
74
|
+
/** #112: the `rigc-environment/1` schedule the Apply's check read (`--environment`), by hash and step count. Absent: the default world. */
|
|
75
|
+
environment?: { sha256: string; steps: number };
|
|
76
|
+
}
|
|
77
|
+
/** #111: a cloth recipe Apply — the route the written config took, the keypoint file propose read and the written diff. */
|
|
78
|
+
export interface AppliedCloth { route: 'build' | 'rig'; keypoints: { path: string; sha256: string }; written: { count: number; paths: string[] } }
|
|
79
|
+
function parseCloth(v: unknown, field: string): AppliedCloth {
|
|
80
|
+
const c = exact(v, 'keypoints,route,written', field);
|
|
81
|
+
if (c.route !== 'build' && c.route !== 'rig') throw new Error(`${field}.route: "build" or "rig"`);
|
|
82
|
+
const k = exact(c.keypoints, 'path,sha256', `${field}.keypoints`);
|
|
83
|
+
if (typeof k.path !== 'string' || !k.path || !hex(k.sha256)) throw new Error(`${field}.keypoints: {path, sha256 (SHA-256 hex)}`);
|
|
84
|
+
const w = exact(c.written, 'count,paths', `${field}.written`);
|
|
85
|
+
if (!count(w.count) || !Array.isArray(w.paths) || w.paths.length > MAX_DIFF_PATHS || w.paths.length > (w.count as number) || !w.paths.every(p => typeof p === 'string')) throw new Error(`${field}.written: {count, paths ≤ ${MAX_DIFF_PATHS}}`);
|
|
86
|
+
return c as unknown as AppliedCloth;
|
|
66
87
|
}
|
|
67
88
|
/** #115: what a `rig-parts build` Apply measured, recorded per Apply. `wallMs` is the build's own wall clock (assemble → rig → check). */
|
|
68
89
|
export interface AppliedAssembled { wallMs: number | null; recomposite: { before: Record<string, number> | null; after: Record<string, number> | null }; partsChanged: string[]; seamPairs: string[] }
|
|
@@ -164,7 +185,8 @@ export function checkSummary(checkJson: unknown, exitCode: number, lines: readon
|
|
|
164
185
|
|
|
165
186
|
function parseCheck(v: unknown, field: string): void {
|
|
166
187
|
if (v === null) return;
|
|
167
|
-
const c = exact(v, 'bars,exitCode,gate,line,pass,schema', field);
|
|
188
|
+
const c = exact(v, isObject(v) && Object.hasOwn(v, 'source') ? 'bars,exitCode,gate,line,pass,schema,source' : 'bars,exitCode,gate,line,pass,schema', field);
|
|
189
|
+
if (Object.hasOwn(c, 'source') && c.source !== 'retune' && c.source !== 'check') throw new Error(`${field}.source: "retune" or "check"`);
|
|
168
190
|
if (c.schema !== CHECK_SUMMARY_SCHEMA) throw new Error(`${field}.schema: expected ${CHECK_SUMMARY_SCHEMA}`);
|
|
169
191
|
if (c.pass !== null && typeof c.pass !== 'boolean') throw new Error(`${field}.pass: boolean or null`);
|
|
170
192
|
if (c.gate !== null && typeof c.gate !== 'boolean') throw new Error(`${field}.gate: boolean or null`);
|
|
@@ -188,7 +210,7 @@ export function parseAppliedContext(value: unknown): void {
|
|
|
188
210
|
return;
|
|
189
211
|
}
|
|
190
212
|
if (value.schema === APPLIED_PATCH_SCHEMA) {
|
|
191
|
-
const v = exact(value, `${Object.hasOwn(value, 'assembled') ? 'assembled,' : ''}buildId,check,compiler,configHash,diff,${Object.hasOwn(value, 'files') ? 'files,' : ''}line,${Object.hasOwn(value, 'mesh') ? 'mesh,' : ''}modelHash,${Object.hasOwn(value, 'op') ? 'op,' : ''}patch,patchSha256,producer,schema`, field);
|
|
213
|
+
const v = exact(value, `${Object.hasOwn(value, 'assembled') ? 'assembled,' : ''}buildId,check,${Object.hasOwn(value, 'cloth') ? 'cloth,' : ''}compiler,configHash,diff,${Object.hasOwn(value, 'environment') ? 'environment,' : ''}${Object.hasOwn(value, 'files') ? 'files,' : ''}line,${Object.hasOwn(value, 'mesh') ? 'mesh,' : ''}modelHash,${Object.hasOwn(value, 'op') ? 'op,' : ''}patch,patchSha256,producer,schema`, field);
|
|
192
214
|
if (![v.buildId, v.configHash, v.patchSha256, v.modelHash].every(hex)) throw new Error(`${field}: buildId, configHash, patchSha256 and modelHash are SHA-256 hex`);
|
|
193
215
|
let patch: ConfigPatch;
|
|
194
216
|
try { patch = parseConfigPatch(v.patch); } catch (e) { throw new Error(`${field}.patch: ${e instanceof Error ? e.message : String(e)}`); }
|
|
@@ -201,10 +223,15 @@ export function parseAppliedContext(value: unknown): void {
|
|
|
201
223
|
meshCounts(m.before, `${field}.mesh.before`); meshCounts(m.after, `${field}.mesh.after`);
|
|
202
224
|
}
|
|
203
225
|
if (Object.hasOwn(v, 'files')) parseFilesDiff(v.files, `${field}.files`);
|
|
226
|
+
if (Object.hasOwn(v, 'environment')) { const e = exact(v.environment, 'sha256,steps', `${field}.environment`); if (!hex(e.sha256) || !Number.isSafeInteger(e.steps) || (e.steps as number) < 1) throw new Error(`${field}.environment: {sha256 (SHA-256 hex), steps ≥ 1}`); }
|
|
204
227
|
// #136: a remesh patch ran remesh; a rig patch ran rig, retune or (#115) build.
|
|
205
|
-
if (Object.hasOwn(v, 'op') && !(patch.op.cmd === 'remesh' ? ['remesh'] : ['rig', 'retune', 'build']).includes(v.op as string)) throw new Error(`${field}.op: ${patch.op.cmd === 'remesh' ? '"remesh" for a remesh patch' : '"rig", "retune" or "
|
|
206
|
-
// #
|
|
207
|
-
if (Object.hasOwn(v, '
|
|
228
|
+
if (Object.hasOwn(v, 'op') && !(patch.op.cmd === 'remesh' ? ['remesh'] : ['rig', 'retune', 'build', 'cloth']).includes(v.op as string)) throw new Error(`${field}.op: ${patch.op.cmd === 'remesh' ? '"remesh" for a remesh patch' : '"rig", "retune", "build" or "cloth" for a rig patch'}`);
|
|
229
|
+
// #111: a cloth op carries its record, and only a cloth op; its ops are all under /cloth.
|
|
230
|
+
if (Object.hasOwn(v, 'cloth') !== (v.op === 'cloth')) throw new Error(`${field}.cloth: present exactly when op is "cloth"`);
|
|
231
|
+
const cloth = Object.hasOwn(v, 'cloth') ? parseCloth(v.cloth, `${field}.cloth`) : null;
|
|
232
|
+
if (cloth && !patch.ops.every(o => o.path.split('/')[1] === 'cloth')) throw new Error(`${field}.patch: a cloth op's patch edits /cloth only`);
|
|
233
|
+
// #115: the build's measurements ride with a build op only (and #111, a cloth op built as build), and such an op carries them.
|
|
234
|
+
if (Object.hasOwn(v, 'assembled') !== (v.op === 'build' || cloth?.route === 'build')) throw new Error(`${field}.assembled: present exactly when op is "build" (or "cloth" built as build)`);
|
|
208
235
|
if (Object.hasOwn(v, 'assembled')) parseAssembled(v.assembled, `${field}.assembled`);
|
|
209
236
|
if (v.diff !== null) {
|
|
210
237
|
const d = exact(v.diff, 'count,paths', `${field}.diff`);
|
|
@@ -268,7 +295,8 @@ export function appliedSummary(value: unknown): string | null {
|
|
|
268
295
|
const diff = a.files ? ` · ${filesDiffText(a.files)}` : a.diff ? ` · rig.json differs at ${a.diff.count} path(s)` : '';
|
|
269
296
|
const mesh = a.mesh ? ` · ${a.mesh.part} v ${a.mesh.before.vertices} t ${a.mesh.before.triangles} → v ${a.mesh.after.vertices} t ${a.mesh.after.triangles}` : '';
|
|
270
297
|
const assembled = a.assembled ? ` · build ${a.assembled.wallMs === null ? '—' : `${(a.assembled.wallMs / 1000).toFixed(1)} s`}${a.assembled.partsChanged.length ? ` · parts changed: ${a.assembled.partsChanged.slice(0, 6).join(', ')}${a.assembled.partsChanged.length > 6 ? ', …' : ''}` : ''}${a.assembled.recomposite.before && a.assembled.recomposite.after ? ` · recomposite error px ${a.assembled.recomposite.before.error_px} → ${a.assembled.recomposite.after.error_px}` : ''}` : '';
|
|
271
|
-
|
|
298
|
+
const cloth = a.cloth ? ` · propose --cloth wrote a config differing at ${a.cloth.written.count} path(s), built by ${a.cloth.route === 'build' ? 'build' : 'rig --config'} · keypoints ${a.cloth.keypoints.path} (${a.cloth.keypoints.sha256.slice(0, 12)}…)` : '';
|
|
299
|
+
return `patch ${a.patchSha256.slice(0, 12)}… (${a.op ?? a.patch.op.cmd}; ${paths.slice(0, 3).join(', ')}${paths.length > 3 ? `, +${paths.length - 3}` : ''}) with ${producer.package} ${producer.version} · ${check}${diff}${mesh}${cloth}${assembled} · model ${model}…`;
|
|
272
300
|
}
|
|
273
301
|
|
|
274
302
|
/**
|
package/src/cli.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { readSession, type Feedback } from './session.ts';
|
|
|
16
16
|
import { readCompareSession } from './compare-session.ts';
|
|
17
17
|
import { exportReviewStrip, exportCompareStrip, stripJson, writeStripOutput, validateStripOutput } from './strip-export.ts';
|
|
18
18
|
import type { StripInterval, StripRequest } from './strip.ts';
|
|
19
|
-
const handoffUsage='[--config <path> [--parts <dir>] [--source <png> --full <dir> [--head <dir>]]]';
|
|
19
|
+
const handoffUsage='[--config <path> [--parts <dir>] [--source <png> --full <dir> [--head <dir>]] [--keypoints <file>]]';
|
|
20
20
|
const usage='Usage: rig-review open <dir> [--reference <video>] [--compare <dir>] [--animation <name>] [--control] [--direct] '+handoffUsage+'\n --config declares the producer handoff (wins over the review.json "producer" block and discovery of config.json, rig/config.json, ../config.json beside an opened out/); feedback and control take the same flags.\n open is managed by default: one loopback front at 127.0.0.1:4318 (RIG_REVIEW_FRONT_PORT overrides) serves /instances/<key>/; --direct serves the worker on its own port.\n rig-review control <dir> [--reference <video>] [--compare <dir>] [--animation <name>] '+handoffUsage+' [--page <id> --expected-revision <n> --command <JSON> --wait-ms <n>]\n rig-review feedback <dir> [--reference <video>] [--compare <dir>] [--animation <name>] '+handoffUsage+' [--format json|issue]\n Usage: bun run review -- <review.json|output-directory> [--port 4317] [--checkpoint ID]\n bun run check -- <review.json|output-directory>\n bun run feedback -- <review.json|output-directory>\n bun run review -- compare <a> <b> --reference <video> --animation <name> [--port 4317]\n bun run check -- compare <a> <b> --reference <video> --animation <name>\n bun run feedback -- compare <a> <b> --reference <video> --animation <name>';
|
|
21
21
|
async function stripCommand(args:string[],options:{stateDir?:string}){
|
|
22
22
|
const compare=args[0]==='compare';if(compare)args.shift();const a=args.shift(),b=compare?args.shift():undefined;if(!a||compare&&!b)throw new Error('strip: expected package input(s)');
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloth recipe form (#111): the producer config's `cloth` block — one recipe per tag (`bottomwear`, `back hair`,
|
|
3
|
+
* `front hair`, whatever the installed reader accepts) — edited as config patch ops at `/cloth/<tag>/…`. An Apply runs
|
|
4
|
+
* the installed producer's `rig-parts propose --cloth <patched config> --keypoints <handoff keypoints> --write-config
|
|
5
|
+
* <scratch>/config.json`, which writes the config's own text with what the recipe drafts spliced in (rig-parts
|
|
6
|
+
* AUTHORING, *The config written*, issues #537 and #552), then builds that written config through the shared tail.
|
|
7
|
+
* Review never reads the producer's draft file and never merges a draft itself: the written config is the producer's
|
|
8
|
+
* one output review consumes. Pure: no IO.
|
|
9
|
+
*/
|
|
10
|
+
import { formatPointer, jsonDiff, parsePointer, type JsonValue, type PatchOp } from './config-patch.ts';
|
|
11
|
+
|
|
12
|
+
/** The first `@rig-c/parts` release whose `propose --cloth --write-config` splices bones, constraints and tracks entry by entry (its CHANGELOG: 1.49.1, parts#552). */
|
|
13
|
+
export const CLOTH_WRITE_SINCE = '1.49.1';
|
|
14
|
+
/** The config section a cloth patch edits. */
|
|
15
|
+
export const CLOTH_PATH = 'cloth';
|
|
16
|
+
/** Decision 1 (#111): without the declared keypoints the draft moves the body bones, so a cloth Apply is refused before anything runs. */
|
|
17
|
+
export const CLOTH_KEYPOINTS_UNDECLARED = 'CLOTH_KEYPOINTS_UNDECLARED: a cloth recipe Apply runs rig-parts propose, which places the body bones from the figure\'s keypoints; without them the draft moves the body bones the config states — declare the keypoint file with rig-review open <dir> --config <path> --keypoints <file> or the review.json "producer" block\'s "keypoints"';
|
|
18
|
+
/** A cloth patch holds only `/cloth` ops: what else the patch edits would be routed and gated differently. */
|
|
19
|
+
export const CLOTH_PATCH_MIXED = 'CLOTH_PATCH_MIXED: a cloth recipe patch holds /cloth ops only; apply the other edits separately';
|
|
20
|
+
/** propose reads the painting the parts were assembled from (`--source`). */
|
|
21
|
+
export const CLOTH_SOURCE_UNAVAILABLE = 'MODELING_INPUT_UNAVAILABLE: source painting — rig-parts propose reads the painting the parts were assembled from (--source), and the handoff declares none; declare it with rig-review open <dir> --config <path> --source <png> --full <dir> [--head <dir>] or the review.json "producer" block';
|
|
22
|
+
|
|
23
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
24
|
+
const under = (tokens: readonly string[], prefix: readonly string[]) => prefix.length <= tokens.length && prefix.every((t, i) => tokens[i] === t);
|
|
25
|
+
|
|
26
|
+
/** True when the op's pointer lies under `/cloth` (the whole block included). */
|
|
27
|
+
export const isClothOp = (op: Pick<PatchOp, 'path'>) => parsePointer(op.path)[0] === CLOTH_PATH;
|
|
28
|
+
/** `cloth` when every op is a cloth op, `mixed` when some are, else `none`. */
|
|
29
|
+
export function clothPatchKind(ops: readonly Pick<PatchOp, 'path'>[]): 'cloth' | 'mixed' | 'none' {
|
|
30
|
+
const n = ops.filter(isClothOp).length;
|
|
31
|
+
return n === 0 ? 'none' : n === ops.length ? 'cloth' : 'mixed';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The tags the installed reader accepts, read from its own source text (`export const CLOTH_TARGETS = [...]`), or null
|
|
36
|
+
* when the text does not state them in that form.
|
|
37
|
+
*/
|
|
38
|
+
export function readClothTargets(sourceText: string): string[] | null {
|
|
39
|
+
const m = /export const CLOTH_TARGETS\s*=\s*\[([^\]]*)\]/.exec(sourceText);
|
|
40
|
+
if (!m) return null;
|
|
41
|
+
const tags = [...m[1]!.matchAll(/'([^']+)'|"([^"]+)"/g)].map(x => x[1] ?? x[2]!);
|
|
42
|
+
return tags.length ? tags : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One recipe field the form edits: its pointer below `/cloth/<tag>` and how it is typed. The producer judges every value. */
|
|
46
|
+
export interface RecipeField { key: string; path: readonly string[]; kind: 'int' | 'number' | 'bool' | 'pair' | 'list' | 'names' | 'shape'; label: string; required?: boolean }
|
|
47
|
+
/** The reader's fields (rig-parts `checkCloth`, AUTHORING §3 `cloth` row), in its order. */
|
|
48
|
+
export const RECIPE_FIELDS: readonly RecipeField[] = [
|
|
49
|
+
{ key: 'chains', path: ['chains'], kind: 'int', label: 'chains' },
|
|
50
|
+
{ key: 'links', path: ['links'], kind: 'int', label: 'links', required: true },
|
|
51
|
+
{ key: 'split.at', path: ['split', 'at'], kind: 'int', label: 'split at' },
|
|
52
|
+
{ key: 'split.into', path: ['split', 'into'], kind: 'int', label: 'split into' },
|
|
53
|
+
{ key: 'split.hold_tip', path: ['split', 'hold_tip'], kind: 'bool', label: 'hold tip' },
|
|
54
|
+
{ key: 'length_ratio', path: ['length_ratio'], kind: 'number', label: 'length ratio' },
|
|
55
|
+
{ key: 'strength.trunk', path: ['strength', 'trunk'], kind: 'number', label: 'strength trunk' },
|
|
56
|
+
{ key: 'strength.branch', path: ['strength', 'branch'], kind: 'number', label: 'strength branch' },
|
|
57
|
+
{ key: 'strength.edge', path: ['strength', 'edge'], kind: 'number', label: 'strength edge' },
|
|
58
|
+
{ key: 'damping', path: ['damping'], kind: 'number', label: 'damping' },
|
|
59
|
+
{ key: 'alternate_damping', path: ['alternate_damping'], kind: 'number', label: 'alternate damping' },
|
|
60
|
+
{ key: 'wind', path: ['wind'], kind: 'pair', label: 'wind [root, tip]', required: true },
|
|
61
|
+
{ key: 'gravity', path: ['gravity'], kind: 'pair', label: 'gravity [root, tip]', required: true },
|
|
62
|
+
{ key: 'translate', path: ['translate'], kind: 'number', label: 'translate' },
|
|
63
|
+
{ key: 'swing.scales', path: ['swing', 'scales'], kind: 'list', label: 'swing scales' },
|
|
64
|
+
{ key: 'part', path: ['part'], kind: 'names', label: 'part' },
|
|
65
|
+
{ key: 'parts', path: ['parts'], kind: 'names', label: 'parts' },
|
|
66
|
+
{ key: 'influence.across', path: ['influence', 'across'], kind: 'bool', label: 'influence across' },
|
|
67
|
+
{ key: 'influence.max', path: ['influence', 'max'], kind: 'int', label: 'influence max' },
|
|
68
|
+
{ key: 'influence.shape', path: ['influence', 'shape'], kind: 'shape', label: 'influence shape' },
|
|
69
|
+
{ key: 'influence.radius', path: ['influence', 'radius'], kind: 'number', label: 'influence radius' },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export class ClothFieldError extends Error { constructor(readonly field: string, detail: string) { super(`CLOTH_FIELD_INVALID ${field}: ${detail}`); } }
|
|
73
|
+
|
|
74
|
+
/** A field's text as the form shows it, from the recipe as written ('' when absent). */
|
|
75
|
+
export function fieldText(recipe: unknown, field: RecipeField): string {
|
|
76
|
+
let v: unknown = recipe;
|
|
77
|
+
for (const k of field.path) v = isObject(v) ? v[k] : undefined;
|
|
78
|
+
if (v === undefined) return '';
|
|
79
|
+
if (field.kind === 'pair' || field.kind === 'list') return Array.isArray(v) ? v.join(', ') : JSON.stringify(v);
|
|
80
|
+
if (field.kind === 'names') return Array.isArray(v) ? v.join(', ') : String(v);
|
|
81
|
+
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A field's typed value from the form's text; `undefined` for an empty field. Only the type is checked here. */
|
|
85
|
+
export function fieldValue(field: RecipeField, text: string): JsonValue | undefined {
|
|
86
|
+
const raw = text.trim();
|
|
87
|
+
if (!raw) return undefined;
|
|
88
|
+
const num = (s: string) => { const n = Number(s.trim()); if (!s.trim() || !Number.isFinite(n)) throw new ClothFieldError(field.key, `${JSON.stringify(s.trim())} is not a number`); return n; };
|
|
89
|
+
switch (field.kind) {
|
|
90
|
+
case 'int': { const n = num(raw); if (!Number.isInteger(n)) throw new ClothFieldError(field.key, `${raw} is not a whole number`); return n; }
|
|
91
|
+
case 'number': return num(raw);
|
|
92
|
+
case 'bool': if (raw === 'true' || raw === 'false') return raw === 'true'; throw new ClothFieldError(field.key, 'true or false');
|
|
93
|
+
case 'shape': return raw;
|
|
94
|
+
case 'pair': { const parts = raw.split(',').map(num); if (parts.length !== 2) throw new ClothFieldError(field.key, 'two numbers, root and tip'); return parts; }
|
|
95
|
+
case 'list': return raw.split(',').map(num);
|
|
96
|
+
case 'names': { const names = raw.split(',').map(s => s.trim()).filter(Boolean); return field.key === 'part' && names.length === 1 && !raw.includes(',') ? names[0]! : names; }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The recipe object the form states: every non-empty field set at its path; `{}` when every field is empty. */
|
|
101
|
+
export function recipeFromFields(values: Readonly<Record<string, string>>): Record<string, JsonValue> {
|
|
102
|
+
const out: Record<string, JsonValue> = {};
|
|
103
|
+
for (const f of RECIPE_FIELDS) {
|
|
104
|
+
const v = fieldValue(f, values[f.key] ?? '');
|
|
105
|
+
if (v === undefined) continue;
|
|
106
|
+
let holder = out;
|
|
107
|
+
for (const k of f.path.slice(0, -1)) { if (!isObject(holder[k])) holder[k] = {}; holder = holder[k] as Record<string, JsonValue>; }
|
|
108
|
+
holder[f.path.at(-1)!] = v;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
114
|
+
/**
|
|
115
|
+
* The ops that turn the config's recipe for `tag` into `next` (null: the tag's recipe removed). Keys the form does not
|
|
116
|
+
* know (an annotation, a record) are kept: only form keys are replaced, added or removed. A tag the config does not
|
|
117
|
+
* state is added whole; a config without a `cloth` block gets one.
|
|
118
|
+
*/
|
|
119
|
+
export function recipeOps(config: unknown, tag: string, next: Record<string, JsonValue> | null): PatchOp[] {
|
|
120
|
+
const cloth = isObject(config) ? config.cloth : undefined;
|
|
121
|
+
const current = isObject(cloth) ? cloth[tag] : undefined;
|
|
122
|
+
const at = (...tokens: string[]) => formatPointer([CLOTH_PATH, tag, ...tokens]);
|
|
123
|
+
if (next === null) return current === undefined ? [] : [{ op: 'remove', path: at() }];
|
|
124
|
+
if (!isObject(cloth)) return [{ op: 'add', path: formatPointer([CLOTH_PATH]), value: { [tag]: next } }];
|
|
125
|
+
if (!isObject(current)) return [{ op: current === undefined ? 'add' : 'replace', path: at(), value: next }];
|
|
126
|
+
const known = new Set(RECIPE_FIELDS.map(f => f.path[0]!));
|
|
127
|
+
const ops: PatchOp[] = [];
|
|
128
|
+
for (const key of [...new Set([...Object.keys(current), ...Object.keys(next)])]) {
|
|
129
|
+
if (!known.has(key)) continue;
|
|
130
|
+
const was = current[key], now = next[key];
|
|
131
|
+
if (same(was, now)) continue;
|
|
132
|
+
if (now === undefined) { ops.push({ op: 'remove', path: at(key) }); continue; }
|
|
133
|
+
if (was === undefined) { ops.push({ op: 'add', path: at(key), value: now }); continue; }
|
|
134
|
+
// An object field (split, strength, swing, influence) is edited key by key, so its other keys keep their place.
|
|
135
|
+
if (isObject(was) && isObject(now)) {
|
|
136
|
+
for (const sub of [...new Set([...Object.keys(was), ...Object.keys(now)])]) {
|
|
137
|
+
if (same(was[sub], now[sub])) continue;
|
|
138
|
+
const known2 = RECIPE_FIELDS.some(f => f.path[0] === key && f.path[1] === sub);
|
|
139
|
+
if (!known2) continue;
|
|
140
|
+
if (now[sub] === undefined) ops.push({ op: 'remove', path: at(key, sub) });
|
|
141
|
+
else ops.push({ op: was[sub] === undefined ? 'add' : 'replace', path: at(key, sub), value: now[sub] as JsonValue });
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
ops.push({ op: 'replace', path: at(key), value: now });
|
|
146
|
+
}
|
|
147
|
+
return ops;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One chain of links as the config states it: its name and its points then its tip, in rig px. */
|
|
151
|
+
export interface ClothChain { chain: string; points: [number, number][] }
|
|
152
|
+
/** Per tag: the parts the recipe drafts on (by See-through tag, plus any part the recipe names) and their chains' links. */
|
|
153
|
+
export interface ClothTagView { tag: string; parts: string[]; chains: ClothChain[] }
|
|
154
|
+
const point = (p: unknown): [number, number] | null => Array.isArray(p) && p.length === 2 && p.every(n => typeof n === 'number' && Number.isFinite(n)) ? [p[0] as number, p[1] as number] : null;
|
|
155
|
+
/**
|
|
156
|
+
* The link preview's data (#111): for each tag, the parts tagged with it in `parts.json` (`from` "<run>:<tag>") and the
|
|
157
|
+
* parts its recipe names, and every chain those parts' `meshes.<part>.segments` name, with the chain's `points` and
|
|
158
|
+
* `tip` from `bones` as written. Read off whatever config is given — the inspected one, or the config the producer wrote.
|
|
159
|
+
*/
|
|
160
|
+
export function clothLinks(config: unknown, partsJson: unknown, tags: readonly string[]): ClothTagView[] {
|
|
161
|
+
const cfg = isObject(config) ? config : {};
|
|
162
|
+
const parts = isObject(partsJson) && Array.isArray(partsJson.parts) ? partsJson.parts : [];
|
|
163
|
+
const recipes = isObject(cfg.cloth) ? cfg.cloth : {};
|
|
164
|
+
const meshes = isObject(cfg.meshes) ? cfg.meshes : {};
|
|
165
|
+
const bones = Array.isArray(cfg.bones) ? cfg.bones : [];
|
|
166
|
+
return tags.map(tag => {
|
|
167
|
+
const names = new Set<string>();
|
|
168
|
+
for (const p of parts) if (isObject(p) && typeof p.name === 'string' && typeof p.from === 'string' && p.from.slice(p.from.indexOf(':') + 1) === tag) names.add(p.name);
|
|
169
|
+
const recipe = recipes[tag];
|
|
170
|
+
if (isObject(recipe)) for (const key of ['part', 'parts']) { const v = recipe[key]; for (const n of Array.isArray(v) ? v : [v]) if (typeof n === 'string' && n) names.add(n); }
|
|
171
|
+
const chains: ClothChain[] = [], seen = new Set<string>();
|
|
172
|
+
for (const part of names) {
|
|
173
|
+
const entry = meshes[part], segments = isObject(entry) && Array.isArray(entry.segments) ? entry.segments : [];
|
|
174
|
+
for (const s of segments) {
|
|
175
|
+
if (typeof s !== 'string' || seen.has(s)) continue;
|
|
176
|
+
const bone = bones.find(b => isObject(b) && b.chain === s);
|
|
177
|
+
if (!isObject(bone) || !Array.isArray(bone.points)) continue;
|
|
178
|
+
const pts = [...bone.points.map(point), point(bone.tip)].filter((x): x is [number, number] => !!x);
|
|
179
|
+
if (pts.length >= 2) { chains.push({ chain: s, points: pts }); seen.add(s); }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { tag, parts: [...names].sort(), chains };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The sections `propose --write-config` splices (rig-parts `PROPOSAL_CONFIG_SECTIONS`, and of `motion` only `tracks`,
|
|
188
|
+
* AUTHORING *The config written*): a written config may differ from its input only at or under these.
|
|
189
|
+
*/
|
|
190
|
+
export const DRAFTED_PREFIXES: readonly (readonly string[])[] = [['bones'], ['constraints'], ['meshes'], ['regions'], ['limbs'], ['motion', 'tracks'], ['turn'], ['proposed_rig_scale']];
|
|
191
|
+
export const MAX_WRITTEN_PATHS = 200;
|
|
192
|
+
/** Bar 1: every JSON Pointer at which the written config differs from its input, and those outside the drafted sections. */
|
|
193
|
+
export interface WrittenDiff { count: number; paths: string[]; outside: string[]; sections: string[] }
|
|
194
|
+
export function writtenDiff(input: unknown, written: unknown): WrittenDiff {
|
|
195
|
+
const paths = jsonDiff(input, written);
|
|
196
|
+
const outside = paths.filter(p => !DRAFTED_PREFIXES.some(prefix => under(parsePointer(p), prefix)));
|
|
197
|
+
const sections = [...new Set(paths.map(p => { const t = parsePointer(p); return t[0] === 'motion' ? t.slice(0, 2).join('.') : t[0] === 'meshes' || t[0] === 'regions' ? t.slice(0, 2).join('.') : t[0] ?? ''; }))];
|
|
198
|
+
return { count: paths.length, paths: paths.slice(0, MAX_WRITTEN_PATHS), outside: outside.slice(0, MAX_WRITTEN_PATHS), sections };
|
|
199
|
+
}
|