@rig-c/review 0.10.3 → 0.10.5
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 +3 -3
- package/docs/DESIGN.md +3 -3
- package/docs/LIVE_FIXTURE.md +1 -1
- package/docs/REVIEW_PACKAGE.md +4 -3
- package/package.json +2 -2
- package/src/rebuild.ts +62 -8
- package/src/setup-mesh.ts +5 -40
- package/src/weights.ts +5 -6
- package/web/main.ts +2 -2
- package/web/mesh-editor.ts +11 -10
- package/web/setup-pose.ts +17 -16
- package/web/weights-view.ts +12 -12
package/README.md
CHANGED
|
@@ -587,7 +587,7 @@ The Parts explorer (left column; right on the compare page) selects an actual mo
|
|
|
587
587
|
|
|
588
588
|
Solo preserves the full held pose's resolved clips and filters only drawing. In context, the whole rig remains visible with an amber dashed conservative selection box; this box is geometry/clip bounds, not exact source-alpha coverage. Selection changes no track, skin, set, motion, source asset or review decision. The selected slot's side/mode survives source-bound links and local drafts. The Setup section shows actual posed mesh counts or region corners, page/region, current skin/set context and declared attachment candidates. **Use slot for feedback** explicitly prefills the target; selecting a row alone submits nothing.
|
|
589
589
|
|
|
590
|
-
The pinned published `@rig-c/play`1.
|
|
590
|
+
The pinned published `@rig-c/play`1.13.0 supplies `slotIdentity()` and frame generation. The explorer atomically retains the public identity snapshot with the same-generation held frame: exact attachment key, owning skin, active set/skin, full z order and explicit hidden/no-attachment/timeline-null/set-hide reasons. Hidden/null slots keep null keys with their reported reason; declarations and texture/UV names never replace that public result. An identity-known attachment need not have a visible primitive or positive fit bounds. The current UI follows its selected slot as animation changes, updating the displayed identity to the actual held frame.
|
|
591
591
|
|
|
592
592
|
Source-bound links/drafts and explicit feedback preserve that side/slot plus exact attachment/owning skin/set/active skin/null reason. A restored explicit target is checked after restoring its animation/time/set and drawing; `PART_IDENTITY_MISMATCH` rolls back rather than silently substituting another key. Older slot-only links remain readable and resolve from the current authoritative snapshot. Runtime generation is current observation/feedback evidence, never a persistent identity. Named vertex weights and original source alpha remain unavailable in this inspection; packed pages are not original part images.
|
|
593
593
|
|
|
@@ -640,7 +640,7 @@ In **Setup**, after the Mesh panel, the **Weights** block (`#weights`, #107; **W
|
|
|
640
640
|
- **Bone weight.** Pick a bone (the slot's influencing bones first, then every other bone of the model): each vertex is coloured by that bone's stored weight on the weight ramp, 0 → 1 (`--rv-weight-0`, `--rv-weight-half`, `--rv-weight-1`); a bone that does not influence a vertex is 0 there. The legend shows the ramp and the bone.
|
|
641
641
|
- **Max influence.** Each vertex takes its dominant bone's colour (the largest stored weight; the first stored on a tie), from `--rv-weight-bone-1` … `-6` in the influencing bones' order; the legend names each bone with its colour.
|
|
642
642
|
- **Hover.** The vertex under the pointer (within 10 px) is ringed and labelled with its index, the bone and the weight drawn; the block's readout lists all of its stored influences. Values are printed as stored, never rounded or normalised.
|
|
643
|
-
- **Source.** The vertices and weights are the slot's attachment at the setup pose (`skins[].attachments.<slot>.<name>.vertices` of `rigc-compiled/3`: `{weighted:true, bindings}` — per vertex `{bone, x, y, weight}` — or `{weighted:false, xy}`). An unweighted mesh is its slot's bone at 1.0, named. A linked mesh reads its source mesh.
|
|
643
|
+
- **Source.** The vertices and weights are the slot's attachment at the setup pose (`skins[].attachments.<slot>.<name>.vertices` of `rigc-compiled/3`: `{weighted:true, bindings}` — per vertex `{bone, x, y, weight}` — or `{weighted:false, xy}`). An unweighted mesh is its slot's bone at 1.0, named. A linked mesh reads its source mesh. A never-played player reads only the public setup identity, including any setup-slider attachment switch. Positions, triangles and hull come directly from published Play 1.13.0's `readSetupMesh`, which includes selected setup-slider deform; Review supplies no skinning formula. Its active skin selection (null for a fresh player) stays distinct from the supplying table used for stored metadata. Hidden/null identities keep their public reason and are never guessed from declarations. The stage player is not touched, so the drawing can sit apart from an animated pose on the stage.
|
|
644
644
|
- **Refusals.** A package without a compiled model document refuses `WEIGHTS_MODEL_UNAVAILABLE`; no slot `WEIGHTS_SELECT_SLOT`; a slot without a mesh `WEIGHTS_NO_MESH <slot>`; a malformed attachment `WEIGHTS_MODEL_INVALID`.
|
|
645
645
|
|
|
646
646
|
Nothing here edits, applies or records a weight: the block adds nothing to the draft or to feedback, and weight editing is out of scope. With **Edit mesh** on, the weights layer is not drawn.
|
|
@@ -680,7 +680,7 @@ Review paints no image: a new cut names no `under`, and a patch may only keep, m
|
|
|
680
680
|
|
|
681
681
|
## Mesh edit (typed delta, preview only)
|
|
682
682
|
|
|
683
|
-
In **Setup**, the view bar's **Edit mesh** button (pressed state; hidden in Animate) edits the selected slot's actual mesh attachment **in setup space**: the attachment's entry in the compiled model document (`skeleton.model.json`), its
|
|
683
|
+
In **Setup**, the view bar's **Edit mesh** button (pressed state; hidden in Animate) edits the selected slot's actual mesh attachment **in setup space**: the attachment's entry in the compiled model document (`skeleton.model.json`), its full-double setup world coordinates, triangles and hull from published Play 1.13.0's `readSetupMesh(assets, {slot, attachment, skin})`, shared with Weights. A never-played player reads only the public setup identity, including any setup-slider attachment switch; the named geometry read owns setup-slider deform. Its active skin selection (null for a fresh player) is distinct from the supplying table returned as `skin`. Hidden/null identities remain unavailable with the public reason; Review never guesses a mesh from declarations. A package without the model document (a Spine export alone, or the bundled demo) refuses `MESH_EDIT_MODEL_UNAVAILABLE` and the editor does not open. With **Whole rig** selected it refuses `MESH_EDIT_SELECT_SLOT`; a slot whose setup attachment is not a mesh refuses `MESH_EDIT_NO_MESH <slot>`, and so does a linked mesh (edit its source mesh). Entering pauses playback (the stage keeps the pose it held, under the veil; the mesh is drawn at the setup pose with the stage's own camera), turns the chest-region editor off and opens the mesh editor bar under the view bar. The rig pane dims except the slot's own triangles; the mesh is drawn at full opacity with every vertex as a handle, hull edges, interior triangulation edges and added constraint edges each in their own theme token.
|
|
684
684
|
|
|
685
685
|
The bar has four sub-modes, one pressed at a time, with single keys that avoid the layer keys `I` / `M` / `B`:
|
|
686
686
|
|
package/docs/DESIGN.md
CHANGED
|
@@ -62,11 +62,11 @@ The single review page is one inspection workspace: a left column, the stage, th
|
|
|
62
62
|
- **Panel headers and scrolling.** The left column's header is a fixed row outside its scrolling body. The Inspector heading, the Settings title and each drawer's header (title and close) carry `.sticky-header` and are their panel's first child, so the body scrolls beneath them. Every scrolling region shares one thin scrollbar rule on `*` whose colours are the `--rv-scrollbar-*` tokens. Resize separators are drawn 4px with a 16px transparent hit area (22px on coarse pointers) and use `--rv-separator` / `--rv-separator-hover`, never a scrollbar colour.
|
|
63
63
|
- **Chest-region editor.** In Setup only, the view bar's **Chest regions** toggle (`#chest-edit`, `aria-pressed`) opens `#chest-editor-bar` under the view bar: R / L groups (X, Y, R, Band), Mirror R → L, Download JSON, **Apply · Rebuild** and **Reset**. Apply sends the rows to the installed producer (`rig-parts remesh` on that part, then `rigc build`) through `POST /api/rebuild` (`src/rebuild.ts`, `src/producer.ts`) and loads the compiled scratch build in place; the producer's lines stream into `#chest-editor-status` and `#chest-editor-log`, a refusal shows its first named line verbatim, and Reset returns to the inspected build (README, *Chest regions*). The circles, their centre / core / band handles and labels are drawn on `#rig-overlay` through the crop → world mapping; handle drags (move, r, band) are clamped live with the example rebuild script's non-overlap rule (`CHEST_REGIONS_CLAMPED`) and never move the camera, while other drags and Space-drags pan. The values are the Setup **Chest regions · detail** block's fields, so its draft, export and binding apply unchanged; the block keeps names, bones, semi-axes, clip and the contour. Review builds no mesh: the rebuild is the producer's, into scratch under the state directory, never into the package. A **Circles** / **Landmarks** switch (`#bust-mode`, a hidden input the draft keeps) selects what Apply sends: Landmarks (#105) places the right apex, left apex and cleavage with draggable markers (`#lm-*`), alpha and clearance sliders and optional bone names, draws the producer's derived `bust_r` / `bust_l` live (`src/chest-landmarks.ts`, a transcription of parts' `deriveBustRegions`) and sends `{part, bust, configHash}`; review pre-checks only `CONFIG_BUST_CLEARANCE` and leaves bones and names to the producer.
|
|
64
64
|
- **Mesh panel.** In Setup, after Chest regions · detail: `#mesh-panel` for the selected mesh part — mode-specific sliders with number fields (`#mesh-params`), regions (`#mesh-regions`; **Add circle**, **Draw polygon** and **Drag on stage** with `aria-pressed`), segments (`#mesh-segments`, bones from the loaded model, ribs stations per chain), the pending diff (`#mesh-diff`), the pre-check (`#mesh-precheck`), **Apply** / **Reset** / **Folds reading**, the status (`#mesh-status`), the counts (`#mesh-counts`) and the `TEXTURE_STRETCH` readout (`#mesh-readout`). Region colours are theme tokens (`--rv-mesh-region`, `--rv-mesh-region-fill`, `--rv-mesh-handle`).
|
|
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
|
|
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, triangles and hull come directly from published Play 1.13.0's `readSetupMesh` (`web/setup-pose.ts`, shared with Edit Mesh), including selected setup-slider deform. A never-played `RigPlayer` reads only public setup identity; its active skin selection stays distinct from the supplying table, and null identities are never guessed. 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
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.
|
|
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
|
|
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 stored provenance in the model document (`src/setup-mesh.ts`) and geometry from Play's public `readSetupMesh` (`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.
|
|
70
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.
|
|
71
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.
|
|
72
72
|
- **Footer.** Message (truncated, full text as tooltip) on the left; player, effective layout, renderer, layers, idle, remote control and draft state in the middle; the numeric view region (behind **View region**) and the source hash on the right. The View region body is a `position:fixed` popover above the 40px footer, right-aligned, its width and height clamped to the viewport and its content scrolling inside, so the footer's clipping never hides it and opening it never scrolls the page.
|
|
@@ -86,7 +86,7 @@ The export uses dedicated real canvas players and leaves the live playhead/sessi
|
|
|
86
86
|
|
|
87
87
|
The Parts explorer (the review page's left column; the compare page's right one) provides source-hash-bound slot selection, held-pose clipped Solo, Whole/context, conservative bounds fit, a diagnostic selection box, actual mesh/region counts and reproducible selection context. This is inspection rather than geometry editing. All rows use the public held identity snapshot's exact z order, including hidden/null slots. Current source-bound links/drafts and control observations retain side/slot/mode.
|
|
88
88
|
|
|
89
|
-
Published Play1.
|
|
89
|
+
Published Play1.13.0 supplies a frozen `slotIdentity()` snapshot coherent with `posedFrame().generation`. Review retains copied frame/identity together only after equal-generation and source-slot/order checks, uses the player's default public filter, and never seeks/reposes/changes physics merely to read identity. Exact owning skin/key, active set/skin and null reasons come from that snapshot, not a unique-declaration or texture match. A resolved identity can remain known without visible geometry or fit bounds. Clip declarations do not invent keys when the API reports no drawable attachment.
|
|
90
90
|
|
|
91
91
|
Selecting the selected row again (click, Space or Enter) clears the selection, the same as **Whole rig**; the control value is then `null`.
|
|
92
92
|
|
package/docs/LIVE_FIXTURE.md
CHANGED
|
@@ -89,5 +89,5 @@ baseline: reproduced — rig.json d19892a32cec… = inspected d19892a32cec…
|
|
|
89
89
|
|
|
90
90
|
- **Whole rig.** On a reproduced baseline, a physics patch runs `rig --config` (#136).
|
|
91
91
|
- **Retune.** To exercise retune (#136, #139), change one number in `$P/out/rig/rig.json` (a +0.001 nudge is enough), so the baseline is not reproduced. Then a patch inside retune's scope routes to `rig-parts retune`. Keep the file the writer's text (edit the number in place, or re-serialise with `JSON.stringify(value, null, 1)` and a closing newline): any other text refuses `RETUNE_INPUT_WRITTEN` (#148). A nudge in an image, not in rig.json, refuses every retune `RETUNE_REQUEST_HELD` instead.
|
|
92
|
-
- **Density strip (#173).** Select `bottomwear` (a `grid` 31 entry) in the Mesh panel and build grid 40 → 120 with N 5: grid 120 refuses `REMESH_CHECK_HELD` (TIP_OVER_ROOT), the other four build.
|
|
92
|
+
- **Density strip (#173).** Select `bottomwear` (a `grid` 31 entry) in the Mesh panel and build grid 40 → 120 with N 5: grid 120 refuses `REMESH_CHECK_HELD` (TIP_OVER_ROOT), the other four build. With parts 1.55.0 or later (#179), the first successful remesh records the input check and the remaining candidates reuse it over the same inspected directories; every candidate still runs its own remesh and revision check. At most four candidates run at once, including the seed. Older producers measure the input independently for each candidate. Re-measure the five-value strip against one fresh grid-40 Apply immediately before it, with wall time, CPU and machine load recorded; the acceptance bar is at most 1.3 times that Apply, or a measured blocker report. Sharing the input check alone does not establish that bar.
|
|
93
93
|
- **Build.** A patch under `/assemble`, `/limbs`, or `/bones` on a config with `limbs` runs `rig-parts build` (#115). So does a cloth Apply (#111).
|
package/docs/REVIEW_PACKAGE.md
CHANGED
|
@@ -227,7 +227,8 @@ The issue export adds an `Applied:` line for a valid record (`patch <hash>… (r
|
|
|
227
227
|
**Undo across Applies (#166).** The session's applied records are a stack, in order, each with its build id; the last is on the stage. `GET /api/rebuild/history` (single review) answers `{schema:"rig-review-applied-history/1", depth, records:[{id, kind:"regions"|"patch"|"delta"|"retarget", op, what, modelHash, kept}]}` (`kept`: the record's build is still on disk). `POST /api/rebuild/undo` (same-origin, capability, 1 KiB) takes exactly `{from}`, the build id the page shows on the stage; it removes the last record and returns the stage to its predecessor's build, or to the inspected build when none is left: `{status:"built", undone, job, history}` (the predecessor's job, its model served at `/rebuild/<id>/` again), `{status:"running", undone, job}` (an evicted predecessor re-running; poll `/api/rebuild/<id>`), or `{status:"inspected", undone, history}`. Refusals (HTTP 200 `{status:"refused", error}`): `UNDO_NOTHING_APPLIED`, `UNDO_STALE` (`from` is not the last record), `REBUILD_BUSY`, and `UNDO_PREDECESSOR_CHANGED: <what>`; malformed: HTTP 400 `REBUILD_REQUEST_INVALID`. The scratch root `<state>/rebuild/<package hash>/` still holds only the build on the stage; the other records' builds wait in `<state>/rebuild-kept/<package hash>/<id>/`, at most `MAX_KEPT_BUILDS` (8) builds per package on disk counting the stage's, the oldest record's evicted first, and a build no record names is removed. A kept predecessor is moved back and re-hashed (every file the model names) against both its result.json and its record; an evicted one re-runs its recorded request through the same runner, refused before anything runs when the handoff config's sha256 or the producer or compiler version is not the record's (`UNDO_PREDECESSOR_CHANGED: <config path> sha256 a… → b…`, `UNDO_PREDECESSOR_CHANGED: @rig-c/parts 1.49.1 → @rig-c/parts 1.50.0; …`), or when its build id hashes differently (`UNDO_PREDECESSOR_CHANGED: build id a… → b… — …`), and refused after the run when the build is not byte-identical, by the first differing file (`UNDO_PREDECESSOR_CHANGED: skeleton.model.json — the record's build has sha256 a…, the re-run wrote b…; …`) — never guessed; a refusal leaves the stage and the stack on the last Apply. The undone record's build is removed (no redo). Undo writes nothing into the package. Feedback written after an undo carries the post-undo record as `context.applied` (or `null` on the inspected build), never the reverted one. The Cuts and Cloth blocks read the same record (#172): a `rig-review-applied/2` record with op `build` is the Cuts block's, with op `cloth` the Cloth block's, and a pending op is marked applied only while that record holds it (same op, path, value and name); their readouts show the restored build's job when it is theirs, else nothing. The stack lives in the review process; a restarted review starts with none.
|
|
228
228
|
|
|
229
229
|
**Density strip (#173).** Next to the selected part's density field — `grid` on a lattice entry, `contour.spacing` on a contour entry — the Mesh panel takes "N values from A to B" (2 ≤ N ≤ 5; a lattice's grid is rounded to whole numbers) and **Build strip** sends one Mesh-panel patch per value (the pending entry with that field at that value, `replace /meshes/<part>`, `remesh --part <part>`) to `POST /api/density-strip` (single review; same-origin, capability, 320 KiB = five 64 KiB patches) as exactly `{patches}`. Malformed (HTTP 400): `DENSITY_STRIP_REQUEST_INVALID` (not 2–5 patches, a repeated value, another config hash, producer or flag set), `MESH_PATCH_SCOPE` (a patch that is not the panel's one op) and `DENSITY_STRIP_FIELD` (entries that differ anywhere but the density field, or a mode without one); each patch is then refused as an Apply refuses it (`EDIT_PATCH_STALE`, `EDIT_PATCH_NAME_MISMATCH`, `EDIT_PATCH_PATH_HELD`, `EDIT_PRODUCER_MISMATCH`, the producer-resolution lines), before anything runs. The strip id hashes the package, the config bytes, every patch's bytes in order and both tool versions; each candidate's build id is the patch's own Apply id (*Run*, above).
|
|
230
|
-
- **Runner.** Every candidate runs the patch runner a remesh Apply runs — remesh into `rig/`, the compile with remesh.json's pack flags, #174's carried check (no third check; an older producer runs check, as an Apply does), the build hashed and `result.json` written — into its own folder `<state>/density-strip/<package hash>/<strip id>/<build id>/`, at most `max(1, floor(cores / 2))` at once (the strip's `concurrency
|
|
230
|
+
- **Runner.** Every candidate runs the patch runner a remesh Apply runs — remesh into `rig/`, the compile with remesh.json's pack flags, #174's carried check (no third check; an older producer runs check, as an Apply does), the build hashed and `result.json` written — into its own folder `<state>/density-strip/<package hash>/<strip id>/<build id>/`, at most `min(4, max(1, floor(cores / 2)))` at once (the strip's `concurrency`; explicit limits are also capped at four). A candidate does not land: no applied record, no `latest` build, nothing in the Apply scratch root or `rebuild-kept`, no undo entry (#166's stack is untouched). One producer workload runs at a time per review: a strip is refused while an Apply (or undo's re-run) runs, and an Apply, an undo and another strip are refused `REBUILD_BUSY: density strip <id> is still running` while a strip runs; a single Apply is otherwise unchanged (its records, ids, scratch and lines).
|
|
231
|
+
- **Shared input check (#179).** On parts 1.55.0 or later, the first candidate asks remesh for `--record-input-check`. When remesh succeeds, Review moves its `input-check.json` out of `rig/` into the strip's folder, before the ordinary revision hash binding: the auxiliary record is not a revision file, and the revision binding still rejects every unexpected or changed file. The remaining candidates pass that record as `--input-check`, over the same absolute inspected rig and parts directories, unchanged bytes, tools and pack flags. The producer validates the record and refuses malformed or mismatched input by name; Review never retries without it. A refused seed has no record: its refusal stays on its row and the next candidate tries to record. A successful producer that leaves no available regular record refuses `DENSITY_STRIP_INPUT_CHECK_MISSING`, and the remaining rows refuse without starting a producer. A valid record releases the other slots while the seed finishes compiling; the seed counts toward the four-candidate limit. Older producers receive neither flag and still measure each input. The record stays private to this strip: it is never served, adopted with a candidate, or reused by a later strip; replacement/close removes it. The revision is always checked, and a strip still approves nothing.
|
|
231
232
|
- **Rows.** `GET /api/density-strip/<id>` (or `GET /api/density-strip` → `{strip}`, the one on screen or null) answers `{schema:"rig-review-density-strip/1", id, status:"running"|"done", part, field, concurrency, configHash, producer, compiler, wallMs, rows[], labels:{fewest, widest}}`, one row per patch in the request's order: `{value, id, status, counts:{vertices, triangles}|null (remesh.json target.after), modelTriangles, folds (ANIMATIONS_DO_NOT_FOLD's folding mesh count), stretch:{severity, frame}|null (the part's TEXTURE_STRETCH per-mesh severity and the frame of the side it came from), bars:[{bar, status, key, value, bound, kind:"floor"|"ceiling", ratio, margin}] (every bar line that measures the part — "part <name>," or "slot <name>," not unmeasured — its figure against the line's <key>_floor or <key>_ceiling), verdict ("PASS", check's line when it did not pass, or the refusal verbatim, e.g. "FAIL REMESH_CHECK_HELD: …"), modelUrl, modelHash, applied, entry}`. The figures are the producer's own printed check of the revision (`[check-revision] …` lines; the lines of review's check when it ran one). `GET /api/density-strip/<id>/<build id>` is the candidate's job in full. `labels.fewest` is the passing row with the fewest triangles; `labels.widest`, when passing rows tie on that count, the one whose narrowest margin over its bars is widest (margin = value/floor, or ceiling/value). Labels only: the table has no Apply.
|
|
232
233
|
- **Load is not Apply.** A built row's model is served read-only at `/density-strip/<id>/<build id>/<file>` (its hashed files only; a refused row has none and cannot be loaded). Load puts it on the stage as **pending** through the same in-place reload an Apply uses (camera, animation, time, world kept) and sets the Mesh panel's pending entry to the row's entry; the counts line shows `MESH_COUNTS_AGREE` / `MESH_COUNTS_DIFFER` for the row. Nothing is recorded: while a pending row is on the stage the feedback context's applied state is null (nothing applied is on the stage), Undo is unavailable (the stage is not the last record), and **Back** returns the stage to what it showed. The Mesh panel's Apply then sends the same patch; its build id is the candidate's, so the server moves the candidate's folder into the Apply scratch root and the ordinary reuse re-hashes and lands it — one applied record, exactly as a re-Applied patch, with no producer run. The row then reads `applied` and is no longer served from the strip. Opening, building or loading a strip approves nothing.
|
|
233
234
|
- **Refine between** two adjacent rows builds the next strip with N values strictly inside their interval.
|
|
@@ -247,8 +248,8 @@ Edit Mesh's **Download JSON** (#80, setup space since #100) saves two files; nei
|
|
|
247
248
|
"ops":[{"op":"move","v":4,"from":[5,5],"to":[6.5,4]},{"op":"edge","a":0,"b":2},{"op":"add","at":[5,0],"onEdge":[0,1]},{"op":"remove","v":3}]}
|
|
248
249
|
```
|
|
249
250
|
|
|
250
|
-
- **Space.** Every position is a setup-pose crop pixel of the rig canvas, x right, y down: the attachment's
|
|
251
|
-
- **Source.** `vertexCount`, `triangleCount` and `hullLength` are the stored entry's (`uvs`/`vertices`, `triangles`, `hull`; the first `hull` vertices, in order, are the outline). `hash` is the SHA-256 of `JSON.stringify` of the attachment's entry (`skins[name].attachments.<slot>.<attachment>`) in the hash-verified model document. The producer holds both to the input part's own (`REMESH_DELTA_SOURCE`) and requires `skin` `"default"` with `slot` and `attachment` the part's name. A linked mesh is not editable (edit its source).
|
|
251
|
+
- **Space.** Every position is a setup-pose crop pixel of the rig canvas, x right, y down: the attachment's full-double world coordinates from published Play 1.13.0's `readSetupMesh`, including selected setup-slider deform, mapped by the model stage (`world = (x − width/2, height − y)`, root at the world origin). `basis` is always `"setup"`; the producer refuses any other basis (`REMESH_DELTA_BASIS`). A package without the model document has no setup mesh: Edit Mesh refuses `MESH_EDIT_MODEL_UNAVAILABLE` and exports nothing.
|
|
252
|
+
- **Source.** `vertexCount`, `triangleCount` and `hullLength` are the stored entry's (`uvs`/`vertices`, `triangles`, `hull`; the first `hull` vertices, in order, are the outline). `hash` is the SHA-256 of `JSON.stringify` of the attachment's entry (`skins[name].attachments.<slot>.<attachment>`) in the hash-verified model document. The producer holds both to the input part's own (`REMESH_DELTA_SOURCE`) and requires `skin` `"default"` with `slot` and `attachment` the part's name. A linked mesh is not editable (edit its source). Geometry, triangles and hull come directly from that API. A never-played player reads only the public setup identity (setup sliders may change it); its active skin selection is passed independently of the supplying table used to read stored provenance. A hidden/null setup identity stays unavailable with its public reason, without attachment inference.
|
|
252
253
|
- **Ops**, in order, at most 300: `move {v, from, to}` — `from` is the vertex's exact current position, unrounded (the producer refuses a `from` more than 1e-3 px off), `to` rounded to 0.01 px; `add {at, onEdge?}` — the next id, splitting edge `onEdge` when given; `remove {v}` — the vertex and its triangulation edges, never a vertex that ends a constraint edge; `edge {a, b}` — a constraint edge; `unedge {a, b}` — only a constraint edge this delta added (hull and triangulation edges are the producer's; review refuses `MESH_EDIT_HULL_EDGE`, `MESH_EDIT_TRIANGULATION_EDGE` and `MESH_EDIT_CONSTRAINT_END` before recording). Vertex ids are the attachment's indices; an added vertex takes the next id; a removed id is never reused. Each op carries exactly these keys.
|
|
253
254
|
- `units` and `preview` are text for a reader; nothing reads them.
|
|
254
255
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/review",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local human rig inspection and structured feedback export",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@napi-rs/canvas": "1.0.11",
|
|
20
|
-
"@rig-c/play": "1.
|
|
20
|
+
"@rig-c/play": "1.13.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/bun": "^1.3.0",
|
package/src/rebuild.ts
CHANGED
|
@@ -407,12 +407,18 @@ const flagValue = (args: readonly string[], flag: string, fallback: string) => {
|
|
|
407
407
|
|
|
408
408
|
export interface RebuildOptions {
|
|
409
409
|
stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>; bones: readonly string[] | null;
|
|
410
|
-
/** #
|
|
410
|
+
/** #179: how many strip candidates run at once, at most four including the recording candidate. */
|
|
411
411
|
stripConcurrency?: number;
|
|
412
412
|
}
|
|
413
413
|
/** #173: a density strip's scratch — one folder per package, holding at most the one strip on screen. */
|
|
414
414
|
export const stripRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'density-strip', sourceHash);
|
|
415
|
-
export const defaultStripConcurrency = () => Math.max(1, Math.floor(availableParallelism() / 2));
|
|
415
|
+
export const defaultStripConcurrency = () => Math.min(4, Math.max(1, Math.floor(availableParallelism() / 2)));
|
|
416
|
+
export const boundedStripConcurrency = (value = defaultStripConcurrency()) => Number.isFinite(value) ? Math.min(4, Math.max(1, Math.floor(value))) : defaultStripConcurrency();
|
|
417
|
+
/** parts#617: the input check is reusable only over the same inspected directories, bytes, options and tools. */
|
|
418
|
+
const INPUT_CHECK_SINCE = '1.55.0';
|
|
419
|
+
interface StripInputCheck {
|
|
420
|
+
path: string; record: boolean; recorded?: () => void; problem?: string;
|
|
421
|
+
}
|
|
416
422
|
/** Deterministic: the package, the config bytes, every candidate's patch bytes in order and both tool versions. */
|
|
417
423
|
export function stripId(input: { sourceHash: string; configHash: string; patches: readonly ConfigPatch[]; producer: string; compiler: string }): string {
|
|
418
424
|
return sha256(JSON.stringify([DENSITY_STRIP_SCHEMA, input.sourceHash, input.configHash, input.patches.map(patchText), input.producer, input.compiler]));
|
|
@@ -925,7 +931,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
925
931
|
* #102: the patched config → `rig-parts rig` (whole rig), `retune` (#136: the constraints and the idle spliced onto the
|
|
926
932
|
* inspected rig) or `remesh` (one part's patched entry) → `<dir>/rig`; the tail adds the diff and check.
|
|
927
933
|
*/
|
|
928
|
-
function executePatch(job: PatchJob, dir: string, inputs: ProducerInputs, resolved: Resolved, patched: JsonValue, part: string | null, plan: BuildPlan | null = null, world: ApplyWorld | null = null, cloth: ClothPlan | null = null, candidate = false): Promise<void> {
|
|
934
|
+
function executePatch(job: PatchJob, dir: string, inputs: ProducerInputs, resolved: Resolved, patched: JsonValue, part: string | null, plan: BuildPlan | null = null, world: ApplyWorld | null = null, cloth: ClothPlan | null = null, candidate = false, inputCheck?: StripInputCheck): Promise<void> {
|
|
929
935
|
const args = job.request.patch.op.args, producer = resolved.producer;
|
|
930
936
|
let counts: RemeshCounts | null = null;
|
|
931
937
|
let built: ProcessResult | null = null;
|
|
@@ -1005,7 +1011,24 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1005
1011
|
await writeFile(join(dir, 'mesh.json'), JSON.stringify(entry, null, 1) + '\n', { mode: 0o600 });
|
|
1006
1012
|
const rest = args.filter((_, i) => args[i] !== '--part' && args[i - 1] !== '--part');
|
|
1007
1013
|
push(`review: ${producer.package} ${producer.version} remesh ${part} (its patched meshes entry) → scratch rig`);
|
|
1008
|
-
|
|
1014
|
+
const checkArgs = inputCheck ? inputCheck.record ? ['--record-input-check'] : ['--input-check', inputCheck.path] : [];
|
|
1015
|
+
const run = await runTool(producer.bin, [...remeshArgs({ rig: inputs.rig, config: inputs.config, parts: inputs.parts, part, mesh: 'mesh.json', out: 'rig' }), ...rest, ...checkArgs], { cwd: dir, onLine: push });
|
|
1016
|
+
if (run.exitCode === 0 && inputCheck?.record) {
|
|
1017
|
+
// The record is beside remesh.json but is not in its revision hash rows. Move only this auxiliary file;
|
|
1018
|
+
// revisionBinding must still reject every extra or changed revision file. Keep it outside any candidate,
|
|
1019
|
+
// so a failed compile's cleanup or a later Apply's adoption cannot remove the shared input check.
|
|
1020
|
+
try {
|
|
1021
|
+
const record = join(dir, 'rig', 'input-check.json');
|
|
1022
|
+
if (!(await lstat(record)).isFile()) throw new Error('not a regular file');
|
|
1023
|
+
await rename(record, inputCheck.path);
|
|
1024
|
+
} catch {
|
|
1025
|
+
const line = 'DENSITY_STRIP_INPUT_CHECK_MISSING: the producer succeeded without an available regular input-check.json; no unrecorded retry was run';
|
|
1026
|
+
inputCheck.problem = line; push(line);
|
|
1027
|
+
return { ...run, exitCode: 1, lines: [...run.lines, line] };
|
|
1028
|
+
}
|
|
1029
|
+
inputCheck.recorded?.();
|
|
1030
|
+
}
|
|
1031
|
+
return run;
|
|
1009
1032
|
},
|
|
1010
1033
|
wallMs: ms => { job.wallMs.op = ms; },
|
|
1011
1034
|
async flags(push) {
|
|
@@ -1165,7 +1188,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1165
1188
|
// Bounded disk: the strip on screen is the only one this package keeps; its predecessor's folder goes first.
|
|
1166
1189
|
const root = stripRoot(options.stateDir, p.sourceHash);
|
|
1167
1190
|
await rm(root, { recursive: true, force: true });
|
|
1168
|
-
const concurrency =
|
|
1191
|
+
const concurrency = boundedStripConcurrency(options.stripConcurrency);
|
|
1169
1192
|
const candidates: StripCandidate[] = planned.map(({ patch }, i) => {
|
|
1170
1193
|
const cid = patchId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patch, producer, compiler, op: 'remesh' });
|
|
1171
1194
|
const job: PatchJob = { schema: REBUILD_SCHEMA, kind: 'patch', id: cid, status: 'running', reused: false, op: 'remesh', request: { patch, patchSha256: patchSha256(patch), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { op: null, compile: null, check: null }, check: null, diff: null, files: null, remesh: null, build: null };
|
|
@@ -1175,13 +1198,44 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1175
1198
|
strip = current;
|
|
1176
1199
|
// A bounded pool: each worker takes the next candidate in order; the strip is done when every candidate settled.
|
|
1177
1200
|
let next = 0;
|
|
1201
|
+
const shared = versionAtLeast(resolved.producer.version, INPUT_CHECK_SINCE) ? join(root, id, 'input-check.json') : null;
|
|
1202
|
+
const runCandidate = (i: number, check?: StripInputCheck) => {
|
|
1203
|
+
const c = candidates[i]!;
|
|
1204
|
+
return executePatch(c.job, c.dir, inputs, resolved, planned[i]!.patched, shape.part, null, null, null, true, check);
|
|
1205
|
+
};
|
|
1178
1206
|
const worker = async () => {
|
|
1179
1207
|
for (let i = next++; i < candidates.length; i = next++) {
|
|
1180
|
-
|
|
1181
|
-
await executePatch(c.job, c.dir, inputs, resolved, planned[i]!.patched, shape.part, null, null, null, true).catch(() => {});
|
|
1208
|
+
await runCandidate(i, shared ? { path: shared, record: false } : undefined);
|
|
1182
1209
|
}
|
|
1183
1210
|
};
|
|
1184
|
-
|
|
1211
|
+
const runStrip = async () => {
|
|
1212
|
+
if (!shared) { await Promise.all(Array.from({ length: Math.min(concurrency, candidates.length) }, worker)); return; }
|
|
1213
|
+
let ready!: (recorded: boolean) => void;
|
|
1214
|
+
const recorded = new Promise<boolean>(resolve => { ready = resolve; });
|
|
1215
|
+
// A refused revision writes no record. Preserve its refusal and let the next candidate try to record.
|
|
1216
|
+
// Once a producer succeeds, release the other slots before its Review compile finishes (still at most four).
|
|
1217
|
+
const seed = (async () => {
|
|
1218
|
+
for (let i = next++; i < candidates.length; i = next++) {
|
|
1219
|
+
let hasRecord = false;
|
|
1220
|
+
const check: StripInputCheck = { path: shared, record: true, recorded: () => { hasRecord = true; ready(true); } };
|
|
1221
|
+
await runCandidate(i, check);
|
|
1222
|
+
if (hasRecord) return;
|
|
1223
|
+
if (check.problem) {
|
|
1224
|
+
for (; next < candidates.length; next++) {
|
|
1225
|
+
const job = candidates[next]!.job;
|
|
1226
|
+
job.status = 'refused'; job.producerLine = check.problem; job.lines.push(check.problem);
|
|
1227
|
+
}
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
})().finally(() => ready(false));
|
|
1232
|
+
if (await recorded) {
|
|
1233
|
+
const others = Array.from({ length: Math.min(concurrency - 1, candidates.length - next) }, worker);
|
|
1234
|
+
await seed;
|
|
1235
|
+
await Promise.all([...others, worker()]);
|
|
1236
|
+
} else await seed;
|
|
1237
|
+
};
|
|
1238
|
+
void runStrip().finally(() => {
|
|
1185
1239
|
if (strip === current) { (current as { status: 'running' | 'done' }).status = 'done'; (current as { wallMs: number | null }).wallMs = Math.round(performance.now() - current.started); }
|
|
1186
1240
|
});
|
|
1187
1241
|
return { status: 202, body: stripView() };
|
package/src/setup-mesh.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The setup-space mesh reader shared by Setup · Weights (#107) and Edit Mesh (#100): one slot's mesh attachment read
|
|
3
|
-
* from the compiled model document as stored
|
|
3
|
+
* from the compiled model document as stored. Geometry is read separately through Play's public readSetupMesh. Pure — no
|
|
4
4
|
* DOM, no I/O; nothing here edits, normalises or infers a vertex or a weight.
|
|
5
5
|
*
|
|
6
6
|
* `rigc-compiled/3` stores a mesh attachment's setup-pose vertices at `skins[].attachments.<slot>.<name>.vertices`:
|
|
@@ -66,28 +66,15 @@ function attachmentOf(model:Obj,skin:string,slot:string,name:string):Obj|undefin
|
|
|
66
66
|
const entry=isObject(slots)?slots[name]:undefined;
|
|
67
67
|
return isObject(entry)?entry:undefined;
|
|
68
68
|
}
|
|
69
|
-
/**
|
|
70
|
-
function
|
|
71
|
-
const found:{skin:string;attachment:string}[]=[];
|
|
72
|
-
for(const s of Array.isArray(model.skins)?model.skins:[]){
|
|
73
|
-
if(!isObject(s)||typeof s.name!=='string'||!isObject(s.attachments)||!isObject(s.attachments[slot]))continue;
|
|
74
|
-
for(const [name,a]of Object.entries(s.attachments[slot] as Obj))if(isObject(a)&&(a.kind==='mesh'||a.kind==='linkedmesh'))found.push({skin:s.name,attachment:name});
|
|
75
|
-
}
|
|
76
|
-
return found.find(f=>f.attachment===slot)??(found.length===1?found[0]!:null);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* The setup-pose mesh of `slot`'s attachment `active` (the identity the setup pose resolved; null takes the slot's mesh
|
|
81
|
-
* named like the slot, else its only one), exactly as stored.
|
|
82
|
-
*/
|
|
83
|
-
export function readSetupMesh(model:unknown,slot:string|null,active:{skin:string;attachment:string}|null,codes:SetupMeshCodes):SetupMeshReadResult {
|
|
69
|
+
/** Stored metadata for the exact supplying table/key reported by the public setup identity. Null is never guessed. */
|
|
70
|
+
export function readStoredSetupMesh(model:unknown,slot:string|null,active:{skin:string;attachment:string}|null,codes:SetupMeshCodes):SetupMeshReadResult {
|
|
84
71
|
const doc=setupModel(model,codes);if(!doc.ok)return doc;
|
|
85
72
|
if(!slot)return {ok:false,code:`${codes.select}: ${codes.selectText}`};
|
|
86
73
|
const slots=Array.isArray(doc.model.slots)?doc.model.slots:[];
|
|
87
74
|
const slotEntry=slots.find(s=>isObject(s)&&s.name===slot) as Obj|undefined;
|
|
88
75
|
if(!slotEntry||typeof slotEntry.bone!=='string')return {ok:false,code:`${codes.noMesh} ${slot}: the model has no such slot`};
|
|
89
|
-
const key=active
|
|
90
|
-
if(!key)return {ok:false,code:`${codes.noMesh} ${slot}: no mesh attachment`};
|
|
76
|
+
const key=active;
|
|
77
|
+
if(!key)return {ok:false,code:`${codes.noMesh} ${slot}: no mesh attachment at the setup pose`};
|
|
91
78
|
const own=attachmentOf(doc.model,key.skin,slot,key.attachment);let entry=own,source={skin:key.skin,slot,attachment:key.attachment};
|
|
92
79
|
if(entry?.kind==='linkedmesh'){
|
|
93
80
|
if(typeof entry.skin!=='string'||typeof entry.slot!=='string'||typeof entry.source!=='string')return {ok:false,code:`${codes.invalid}: ${slot} / ${key.attachment} is a linked mesh without skin, slot and source`};
|
|
@@ -120,25 +107,3 @@ export function readSetupMesh(model:unknown,slot:string|null,active:{skin:string
|
|
|
120
107
|
const order:string[]=[];for(const row of vertices)for(const i of row)if(!order.includes(i.bone))order.push(i.bone);
|
|
121
108
|
return {ok:true,mesh:{slot,skin:key.skin,attachment:key.attachment,source,linked:own.kind==='linkedmesh',weighted:v.weighted,vertices,bones:order,triangles:[...triangles] as number[],hull,entry:own}};
|
|
122
109
|
}
|
|
123
|
-
|
|
124
|
-
/** A bone's world transform as the published player reports it (`BoneWorld`'s x, y, a, b, c, d). */
|
|
125
|
-
export interface WorldTransform {x:number;y:number;a:number;b:number;c:number;d:number}
|
|
126
|
-
/**
|
|
127
|
-
* One vertex's world position from its stored influences and the bones' world transforms: the published player's own
|
|
128
|
-
* skinning formula (`worldVertices` in `@rig-c/play`: coordinates and weights through `Math.fround`, each influence
|
|
129
|
-
* `(x·a + y·b + worldX, x·c + y·d + worldY) · weight`, summed; an unweighted vertex is its slot bone's transform at 1).
|
|
130
|
-
*/
|
|
131
|
-
export function skinVertex(influences:readonly WeightInfluence[],world:(bone:string)=>WorldTransform,weighted:boolean):{x:number;y:number} {
|
|
132
|
-
let wx=0,wy=0;
|
|
133
|
-
for(const i of influences){
|
|
134
|
-
const t=world(i.bone),x=Math.fround(i.x),y=Math.fround(i.y),w=weighted?Math.fround(i.weight):1;
|
|
135
|
-
wx+=(x*t.a+y*t.b+t.x)*w;wy+=(x*t.c+y*t.d+t.y)*w;
|
|
136
|
-
}
|
|
137
|
-
return {x:wx,y:wy};
|
|
138
|
-
}
|
|
139
|
-
/** Every vertex of a read mesh placed in world space through `world` (flat x, y pairs), or null when one is not finite. */
|
|
140
|
-
export function placeSetupMesh(mesh:Pick<SetupMeshRead,'vertices'|'weighted'>,world:(bone:string)=>WorldTransform):number[]|null {
|
|
141
|
-
const out:number[]=[];
|
|
142
|
-
for(const influences of mesh.vertices){const p=skinVertex(influences,world,mesh.weighted);if(!Number.isFinite(p.x)||!Number.isFinite(p.y))return null;out.push(p.x,p.y);}
|
|
143
|
-
return out;
|
|
144
|
-
}
|
package/src/weights.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* Setup-space mesh weights (#107), read from the compiled model document as stored. Watch-only: nothing here edits,
|
|
3
3
|
* normalises or infers a weight. The reader itself is shared with Edit Mesh (#100): `src/setup-mesh.ts`.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import {readStoredSetupMesh,setupModel,type SetupMeshCodes,type WeightInfluence} from './setup-mesh.ts';
|
|
6
6
|
export const WEIGHTS_MODEL_UNAVAILABLE='WEIGHTS_MODEL_UNAVAILABLE';
|
|
7
7
|
export const WEIGHTS_SELECT_SLOT='WEIGHTS_SELECT_SLOT';
|
|
8
8
|
export const WEIGHTS_NO_MESH='WEIGHTS_NO_MESH';
|
|
9
9
|
export const WEIGHTS_MODEL_INVALID='WEIGHTS_MODEL_INVALID';
|
|
10
10
|
|
|
11
|
-
export type {WeightInfluence
|
|
12
|
-
export {skinVertex} from './setup-mesh.ts';
|
|
11
|
+
export type {WeightInfluence} from './setup-mesh.ts';
|
|
13
12
|
export interface MeshWeights {
|
|
14
13
|
slot:string;skin:string;attachment:string;
|
|
15
14
|
/** Where the vertices were read: the attachment itself, or a linked mesh's source. */
|
|
@@ -31,11 +30,11 @@ export const WEIGHTS_CODES:SetupMeshCodes=Object.freeze({unavailable:WEIGHTS_MOD
|
|
|
31
30
|
export const weightsModel=(model:unknown)=>setupModel(model,WEIGHTS_CODES);
|
|
32
31
|
|
|
33
32
|
/**
|
|
34
|
-
* The setup-pose weights of `slot`'s mesh attachment `active` (the identity the
|
|
35
|
-
*
|
|
33
|
+
* The setup-pose weights of `slot`'s mesh attachment `active` (the identity the setup pose resolved; null preserves its
|
|
34
|
+
* absence as reported by the public setup identity). Weights are returned exactly as stored.
|
|
36
35
|
*/
|
|
37
36
|
export function meshWeights(model:unknown,slot:string|null,active:{skin:string;attachment:string}|null):WeightsResult {
|
|
38
|
-
const read=
|
|
37
|
+
const read=readStoredSetupMesh(model,slot,active,WEIGHTS_CODES);if(!read.ok)return read;
|
|
39
38
|
const {slot:s,skin,attachment,source,weighted,vertices,bones,triangles}=read.mesh;
|
|
40
39
|
return {ok:true,weights:{slot:s,skin,attachment,source,weighted,vertices,bones,triangles}};
|
|
41
40
|
}
|
package/web/main.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {chestOverlayCircles,chestOverlayPolygons,chestRegionRows,chestRowId,eval
|
|
|
14
14
|
import {bindChestEditor,canvasToCrop,bustLabel,bustSides,LANDMARKS,landmarkInputId,rowCircle,type ChestEditor,type LandmarkKey} from './chest-editor.ts';
|
|
15
15
|
import {bindMeshEditor,meshEditSource,sha256Hex,type MeshEditor} from './mesh-editor.ts';
|
|
16
16
|
import {bindWeightsView,setupPoseMesh,type WeightsView} from './weights-view.ts';
|
|
17
|
-
import {
|
|
17
|
+
import {readSetupPose} from './setup-pose.ts';
|
|
18
18
|
import {bindBonesPanel,type BonesPanel,type BonesCheckView} from './bones-panel.ts';
|
|
19
19
|
import {bindCutsPanel,type CutsPanel} from './cuts-panel.ts';
|
|
20
20
|
import {bindClothPanel,type ClothPanel} from './cloth-panel.ts';
|
|
@@ -1367,7 +1367,7 @@ function reposePreview():void {if(player?.adjust)player.repose();}
|
|
|
1367
1367
|
/** Edit Mesh's source (#100): the selected slot's mesh in the model document at the setup pose (a never-played player's bones). */
|
|
1368
1368
|
function meshSource(){
|
|
1369
1369
|
if(data.manifest.model&&!partModel)return {ok:false as const,code:'MESH_EDIT_RIG_UNAVAILABLE: the compiled model document is still loading'};
|
|
1370
|
-
return meshEditSource({slot:parts?.value()?.slot??null,model:data.manifest.model?partModel:null,pose:slot=>assets?readSetupPose(assets,slot,
|
|
1370
|
+
return meshEditSource({slot:parts?.value()?.slot??null,model:data.manifest.model?partModel:null,pose:slot=>assets?readSetupPose(assets,slot,document.createElement('div')):null,mapping:chestMapping});
|
|
1371
1371
|
}
|
|
1372
1372
|
async function exportMesh(doc:MeshEditDocument):Promise<void> {
|
|
1373
1373
|
const text=meshEditText(doc),digest=await sha256Hex(text);
|
package/web/mesh-editor.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mesh edit (Setup): a view-bar toggle, an editor bar of sub-modes (Modify / Create / Delete / Edges) and direct
|
|
3
3
|
* manipulation of the selected slot's actual mesh on the rig pane. The mesh is the slot's attachment in the compiled
|
|
4
|
-
* model document at the **setup pose** (#100):
|
|
5
|
-
*
|
|
4
|
+
* model document at the **setup pose** (#100): Play's public readSetupMesh coordinates, topology and hull
|
|
5
|
+
* (`web/setup-pose.ts`, shared with Setup · Weights), drawn over the stage with the stage's own camera — the
|
|
6
6
|
* stage itself stays paused at the pose it held, under the veil. Everything is a typed delta (`src/mesh-edit.ts`,
|
|
7
7
|
* `basis: "setup"`) previewed over the original triangulation; nothing is retriangulated or written into the package
|
|
8
8
|
* from here. Apply · Rebuild (#135) hands the exported document to the host, which sends it through the shared runner as
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* inspected build, and the local draft keeps the applied delta (edits over the revision are not written to it).
|
|
12
12
|
*/
|
|
13
13
|
import type {CropWorldMapping} from '../src/chest-regions.ts';
|
|
14
|
-
import {
|
|
14
|
+
import {readStoredSetupMesh,setupModel} from '../src/setup-mesh.ts';
|
|
15
15
|
import {
|
|
16
16
|
MESH_EDIT_CODES,MESH_EDIT_NO_MESH,MESH_EDIT_PREVIEW_NOTE,MESH_EDIT_RIG_UNAVAILABLE,MESH_EDIT_SELECT_SLOT,MeshEditSession,affineOf,applyAffine,applyMeshOps,attachmentHashText,draftOps,hitMesh,
|
|
17
17
|
invertAffine,meshEditDocument,parseMeshDraft,screenDeltaToCrop,verticesInBox,writeMeshDraft,type MeshEditDocument,type MeshEditResult,type MeshOp,type MeshSource,
|
|
@@ -56,7 +56,7 @@ export function cropFromWorld(mapping:Extract<CropWorldMapping,{available:true}>
|
|
|
56
56
|
type SourceResult={ok:true;source:Omit<MeshSource,'hash'>;entry:unknown;world:number[]}|{ok:false;code:string};
|
|
57
57
|
/**
|
|
58
58
|
* The selected slot's mesh in setup space: its attachment's entry in the hash-verified compiled model document (the
|
|
59
|
-
* attachment the setup
|
|
59
|
+
* attachment the public setup identity resolves), its public readSetupMesh vertices (`world`, flat x, y)
|
|
60
60
|
* and mapped to crop px. Refuses, in order: no model document (`MESH_EDIT_MODEL_UNAVAILABLE`), whole rig, no setup pose,
|
|
61
61
|
* a non-mesh or linked attachment, no crop mapping.
|
|
62
62
|
*/
|
|
@@ -66,17 +66,18 @@ export function meshEditSource(input:{slot:string|null;model:unknown;pose:(slot:
|
|
|
66
66
|
let pose:SetupPose|null;
|
|
67
67
|
try{pose=input.pose(slot);}catch(error){return {ok:false,code:`${MESH_EDIT_RIG_UNAVAILABLE}: ${error instanceof Error?error.message:String(error)}`};}
|
|
68
68
|
if(!pose)return {ok:false,code:`${MESH_EDIT_RIG_UNAVAILABLE}: no rig loaded`};
|
|
69
|
-
const read=
|
|
69
|
+
const read=readStoredSetupMesh(input.model,slot,pose.identity,MESH_EDIT_CODES);if(!read.ok)return !pose.identity&&pose.reason?{ok:false,code:`${read.code} (${pose.reason})`}:read;
|
|
70
70
|
const mesh=read.mesh,noMesh=(why:string)=>({ok:false as const,code:`${MESH_EDIT_NO_MESH} ${slot}: ${why}`});
|
|
71
71
|
if(mesh.linked)return noMesh(`${mesh.skin} / ${mesh.attachment} is a linked mesh of ${mesh.source.slot} / ${mesh.source.attachment}; edit its source mesh`);
|
|
72
|
-
if(mesh.hull===null)return noMesh('the model entry stores no hull');
|
|
73
72
|
if(!input.mapping.available)return {ok:false,code:`${input.mapping.reason}: mesh edit needs the crop mapping`};
|
|
74
|
-
let
|
|
75
|
-
try{
|
|
76
|
-
if(!
|
|
73
|
+
let geometry:ReturnType<SetupPose['readMesh']>;
|
|
74
|
+
try{geometry=pose.readMesh();}catch(error){return {ok:false,code:`${MESH_EDIT_RIG_UNAVAILABLE}: ${error instanceof Error?error.message:String(error)}`};}
|
|
75
|
+
if(!geometry||geometry.slot!==slot||geometry.skin!==mesh.skin||geometry.attachment!==mesh.attachment||geometry.vertices.length!==mesh.vertices.length*2)return {ok:false,code:`MESH_EDIT_SOURCE_MISMATCH: ${slot} setup geometry does not match its stored identity or vertex count`};
|
|
76
|
+
if(geometry.hull===null)return noMesh('the model entry stores no hull');
|
|
77
|
+
const world=geometry.vertices;
|
|
77
78
|
const toCrop=cropFromWorld(input.mapping);if(!toCrop)return {ok:false,code:'MESH_EDIT_MAPPING_SINGULAR'};
|
|
78
79
|
const vertices:Vec[]=[];for(let i=0;i<world.length;i+=2)vertices.push(toCrop(world[i]!,world[i+1]!));
|
|
79
|
-
return {ok:true,source:{slot,skin:mesh.skin,attachment:mesh.attachment,vertices,triangles:
|
|
80
|
+
return {ok:true,source:{slot,skin:mesh.skin,attachment:mesh.attachment,vertices,triangles:geometry.triangles,hull:geometry.hull},entry:mesh.entry,world};
|
|
80
81
|
}
|
|
81
82
|
/** Apply · Rebuild with an empty delta: nothing is sent. */
|
|
82
83
|
export const MESH_EDIT_NOTHING_PENDING='MESH_EDIT_NOTHING_PENDING: the delta holds no op; nothing is sent';
|
package/web/setup-pose.ts
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* before this returns, so every bone the caller may ask for is read here.
|
|
2
|
+
* Setup identity through the published player, followed by named geometry through Play's public readSetupMesh.
|
|
3
|
+
* The short-lived player reads identity only: setup sliders can select an attachment different from slot.setup.
|
|
4
|
+
* It never plays or reads bone matrices, and never touches the stage player.
|
|
6
5
|
*/
|
|
7
|
-
import {RigPlayer,type ModelAssets,type RenderMode} from '@rig-c/play';
|
|
8
|
-
import type {WorldTransform} from '../src/setup-mesh.ts';
|
|
6
|
+
import {RigPlayer,readSetupMesh,type ModelAssets,type RenderMode,type SetupMesh,type SlotIdentityReason} from '@rig-c/play';
|
|
9
7
|
|
|
10
8
|
export interface SetupPose {
|
|
11
|
-
/**
|
|
9
|
+
/** Exact supplying table/key, or the public null identity (never guessed from declarations). */
|
|
12
10
|
identity:{skin:string;attachment:string}|null;
|
|
13
|
-
/**
|
|
14
|
-
|
|
11
|
+
/** Active skin selection, distinct from the supplying table in identity.skin. Null means a fresh no-skin player. */
|
|
12
|
+
skin:string|null;
|
|
13
|
+
reason:SlotIdentityReason|null;
|
|
14
|
+
/** Detached geometry from the public API; lazy so callers can name non-mesh/linked refusals first. */
|
|
15
|
+
readMesh:()=>SetupMesh|null;
|
|
15
16
|
}
|
|
16
|
-
/** Reads
|
|
17
|
-
export function readSetupPose(assets:ModelAssets,slot:string,
|
|
18
|
-
const player=new RigPlayer(root,assets,{mode,slots:[slot]});
|
|
17
|
+
/** Reads setup identity without advancing time. Throws public setup/identity/geometry refusals unchanged. */
|
|
18
|
+
export function readSetupPose(assets:ModelAssets,slot:string,root:HTMLElement,mode:RenderMode='dom',skin?:string):SetupPose {
|
|
19
|
+
const player=new RigPlayer(root,assets,{mode,slots:[slot],...(skin===undefined?{}:{skin})});
|
|
19
20
|
try{
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
const snapshot=player.slotIdentity(),row=snapshot.slots.find(r=>r.slot===slot);
|
|
22
|
+
const identity=row&&row.attachment!==null&&row.skin!==null?{skin:row.skin,attachment:row.attachment}:null;
|
|
23
|
+
return {identity,skin:snapshot.skin,reason:row?.reason??null,
|
|
24
|
+
readMesh:()=>identity?readSetupMesh(assets,{slot,attachment:identity.attachment,skin:snapshot.skin}):null};
|
|
24
25
|
}finally{player.dispose();}
|
|
25
26
|
}
|
|
26
27
|
/** The bone names a compiled model document declares, in stored order. */
|
package/web/weights-view.ts
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
* coloured by that bone's stored weight on the theme's ramp (0 → 1), or switch to max influence and every vertex takes
|
|
4
4
|
* its dominant bone's colour; hovering a vertex reads its stored value. Weights come from the compiled model document
|
|
5
5
|
* (`src/weights.ts` over the shared reader `src/setup-mesh.ts`); vertex positions come from the published player's
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* readSetupMesh API (`web/setup-pose.ts`, shared with Edit Mesh). A never-played player reads setup identity only.
|
|
7
|
+
* Nothing is edited, applied, recorded or sent.
|
|
8
8
|
*/
|
|
9
|
-
import type {ModelAssets,RenderMode} from '@rig-c/play';
|
|
10
|
-
import {placeSetupMesh} from '../src/setup-mesh.ts';
|
|
9
|
+
import type {ModelAssets,RenderMode,SetupMesh as PlaySetupMesh} from '@rig-c/play';
|
|
11
10
|
import {WEIGHTS_SELECT_SLOT,dominantInfluence,meanColor,meshWeights,rampColor,weightOf,weightText,weightsModel,type MeshWeights} from '../src/weights.ts';
|
|
12
11
|
import {modelBoneNames,readSetupPose} from './setup-pose.ts';
|
|
13
12
|
import type {OverlayPoint,OverlayProject,OverlayWeights} from './rig-overlays.ts';
|
|
@@ -20,19 +19,20 @@ export const WEIGHTS_SOURCE_MISMATCH='WEIGHTS_SOURCE_MISMATCH';
|
|
|
20
19
|
export const WEIGHTS_RIG_UNAVAILABLE='WEIGHTS_RIG_UNAVAILABLE';
|
|
21
20
|
|
|
22
21
|
/** The selected slot's mesh at the setup pose: world vertices (one per stored vertex), triangles and identity. */
|
|
23
|
-
export interface SetupMesh {
|
|
22
|
+
export interface SetupMesh extends PlaySetupMesh {identity:{skin:string;attachment:string}|null}
|
|
24
23
|
export type SetupMeshResult={ok:true;mesh:SetupMesh;weights:MeshWeights}|{ok:false;code:string};
|
|
25
24
|
/**
|
|
26
|
-
* The
|
|
27
|
-
* the
|
|
25
|
+
* The public setup identity names the target. Play owns its setup coordinates, topology, hull and deform.
|
|
26
|
+
* The active skin selection is passed independently of the identity's supplying table; the stage is not touched.
|
|
28
27
|
*/
|
|
29
|
-
export function setupPoseMesh(assets:ModelAssets,model:unknown,slot:string,root:HTMLElement,mode:RenderMode='dom'):SetupMeshResult {
|
|
28
|
+
export function setupPoseMesh(assets:ModelAssets,model:unknown,slot:string,root:HTMLElement,mode:RenderMode='dom',skin?:string):SetupMeshResult {
|
|
30
29
|
const available=weightsModel(model);if(!available.ok)return available;
|
|
31
30
|
try{
|
|
32
|
-
const pose=readSetupPose(assets,slot,
|
|
33
|
-
const result=meshWeights(model,slot,pose.identity);if(!result.ok)return result;
|
|
34
|
-
const
|
|
35
|
-
|
|
31
|
+
const pose=readSetupPose(assets,slot,root,mode,skin);
|
|
32
|
+
const result=meshWeights(model,slot,pose.identity);if(!result.ok)return !pose.identity&&pose.reason?{ok:false,code:`${result.code} (${pose.reason})`}:result;
|
|
33
|
+
const mesh=pose.readMesh();
|
|
34
|
+
if(!mesh||mesh.slot!==slot||mesh.skin!==result.weights.skin||mesh.attachment!==result.weights.attachment||mesh.vertices.length!==result.weights.vertices.length*2)return {ok:false,code:`${WEIGHTS_SOURCE_MISMATCH}: ${slot} setup geometry does not match its stored identity or vertex count`};
|
|
35
|
+
return {ok:true,weights:result.weights,mesh:{...mesh,identity:pose.identity}};
|
|
36
36
|
}catch(error){return {ok:false,code:`${WEIGHTS_RIG_UNAVAILABLE}: ${error instanceof Error?error.message:String(error)}`};}
|
|
37
37
|
}
|
|
38
38
|
|