@rig-c/review 0.9.0 → 0.10.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 +1 -1
- package/docs/REVIEW_PACKAGE.md +11 -3
- package/package.json +1 -1
- package/src/applied-blocks.ts +48 -0
- package/src/applied-context.ts +4 -3
- package/src/density-strip.ts +201 -0
- package/src/front.ts +4 -0
- package/src/rebuild.ts +174 -28
- package/src/retune-check.ts +48 -28
- package/src/server.ts +23 -3
- package/web/cloth-panel.ts +10 -2
- package/web/cuts-panel.ts +15 -4
- package/web/density-strip-panel.ts +89 -0
- package/web/index.html +1 -1
- package/web/main.ts +114 -12
- package/web/rebuild-client.ts +2 -2
- package/web/styles.css +1 -0
package/README.md
CHANGED
|
@@ -607,7 +607,7 @@ Rig pixels are the whole figure's frame, which is the crop frame the model's sta
|
|
|
607
607
|
- **Inputs.** The producer handoff (`docs/REVIEW_PACKAGE.md`, *Producer handoff*): `open --config` (and `--parts`), else the review.json `producer` block, else `config.json`, then `rig/config.json` inside the opened folder, then `../config.json` beside an opened `out/`. The parts directory defaults to `out/` beside the config and holds `parts.json`, `parts/` and the inspected `rig/`. The editor bar shows, before any Apply, how the config was found (declared, discovered, or discovered outside the opened folder), its path and SHA-256, and the baseline. Without a handoff the bar shows `MODELING_INPUT_UNAVAILABLE: config.json — looked at …` and Apply refuses `MODELING_HANDOFF_MISSING`, naming `--config`. The request carries only `{part, regions, configHash}`; the server reads the config, replaces `meshes.<part>.contour.regions` with the designated regions (every other field as written) and runs `rig-parts remesh --rig <out>/rig --config <config> --parts <out> --part <part> --mesh mesh.json --out revision`, then `rigc build --profile rig-play --pack` with the revision's own `--page-edges` / `--pack-shape`, both with the scratch directory as working directory. A region row with equal semi-axes and no clip is sent as the config's circle, any other as the 32-sample polygon. An untouched editor starts from the config's own circle regions and contour numbers.
|
|
608
608
|
- **Baseline.** Once per session, when the handoff and the producer both resolve, review runs `rig-parts rig --config <config> --parts <parts> --out <state>/baseline/<key>/baseline` and compares every file it writes (rig.json, motion.json, mesh_report.json, sets.json, images/*) with the inspected rig directory: `baseline: reproduced — rig.json <hash> = inspected <hash>`, or `baseline: not reproduced — <first differing path> · <what Apply can still do here>`. The key is the package hash, the handoff record and the producer versions (no clock), so a reopen reuses the result. Ops that rebuild the whole rig refuse `EDIT_BASELINE_MISMATCH <path>` unless the baseline reproduced; `remesh` keeps its own `REMESH_BASELINE_MISMATCH`. A build the config does not reproduce — a hand repair — is not open to every Apply (#148): retune carries a **mesh** repair over but holds every image, so a repair that redrew an image refuses every physics and idle edit (`RETUNE_REQUEST_HELD: images/<part>.png`); remesh from a config entry refuses the repaired parts and re-fits the others; a mesh edit is held to the inspected mesh, not the config; and remesh, its mesh edit and retune all refuse an inspected `rig.json` (or `mesh_report.json`, `motion.json`) that is not the writer's text (`…_INPUT_WRITTEN` — re-serialise it as rig writes it, JSON with one space of indent and a closing newline, in a copy). The baseline line and `EDIT_BASELINE_MISMATCH` name which of these hold here, and a producer refusal review can explain carries `review: …` after the producer's own line (docs/REVIEW_PACKAGE.md, *Baseline reach*). Once it reproduces, `rig-parts check` runs on the baseline output in the background (into `<state>/baseline/<key>/inspected-check/`, with Apply's default `--page-edges free --pack-shape polygon`) and its summary is stored beside the baseline record as `inspected-check.json`; that is the physics readout's BEFORE for the inspected build, and a reopen reads it instead of re-running.
|
|
609
609
|
- **Scratch.** `<state>/rebuild/<package source hash>/<designation hash>/` under `RIG_REVIEW_STATE_DIR` (refused inside the package), named from the package, the config bytes, the part, the entry and both tool versions — no clock. The same designation again reuses its build and says so; a new one replaces the previous scratch build on the stage (the earlier applied records' builds are kept beside the scratch root for undo, at most eight per package). The inspected package's files are only read.
|
|
610
|
-
- **Watching.** The producer's lines stream into the editor bar (the latest line and a short log); a refusal shows the producer's first named line verbatim (for example `FAIL REMESH_REQUEST_HELD: …`) and the stage is unchanged. On success the compiled scratch build (served read-only under `/rebuild/<id>/`, its model and pages hashed) replaces the drawn rig in place with the same camera, animation, time and part set; playback resumes with physics when it was running. **Reset** returns the stage to the inspected build. **Undo last Apply** (the *Applied records* list at the top of the Inspector, its depth in the label) returns the stage to the previous applied build, or to the inspected build when none is left; a predecessor whose build was evicted re-runs its recorded step and must come back byte for byte, else `UNDO_PREDECESSOR_CHANGED: <what>` (docs/REVIEW_PACKAGE.md, *Undo across Applies*).
|
|
610
|
+
- **Watching.** The producer's lines stream into the editor bar (the latest line and a short log); a refusal shows the producer's first named line verbatim (for example `FAIL REMESH_REQUEST_HELD: …`) and the stage is unchanged. On success the compiled scratch build (served read-only under `/rebuild/<id>/`, its model and pages hashed) replaces the drawn rig in place with the same camera, animation, time and part set; playback resumes with physics when it was running. **Reset** returns the stage to the inspected build. **Undo last Apply** (the *Applied records* list at the top of the Inspector, its depth in the label) returns the stage to the previous applied build, or to the inspected build when none is left; a predecessor whose build was evicted re-runs its recorded step and must come back byte for byte, else `UNDO_PREDECESSOR_CHANGED: <what>` (docs/REVIEW_PACKAGE.md, *Undo across Applies*). A change to the handoff config ends the session's undo stack (reopen): Undo is then refused `FRONT_INSTANCE_EXPIRED`. The Cuts and Cloth blocks mark a pending op applied only while the record on the stage holds it, so after an undo they show the predecessor's ops as applied, and none on the inspected build. The mark shows what the build on the stage carries, not every op applied so far: while a cloth build is on the stage, the Cuts block marks nothing.
|
|
611
611
|
- **Record.** The last applied state (`rig-review-applied/1`: part, regions, for a Landmarks Apply also `bust` — the entry as sent, with `regions` its two derived circles — config hash, entry SHA-256, producer and compiler versions, the producer's line and the scratch model's SHA-256) stays in the inspection draft, and feedback added while that build is on the stage carries it as `context.applied`. The review's `reviewedSource` still names the inspected build.
|
|
612
612
|
- **Config patch.** The same loop takes any config edit as data: `POST /api/rebuild {patch}` with a `rig-review-config-patch/1` document (JSON Pointer ops over the handoff config, refused `EDIT_PATCH_STALE` when made against other config bytes) rebuilds the whole rig (`rig`) or one part (`remesh`) into scratch, compiles it and runs `rig-parts check` on it; the page's `window.rigReviewEditor.applyPatch(patch)` streams, reloads and resets like Apply, and the draft and `context.applied` carry `rig-review-applied/2` (the patch, its hash, the producer's line, check's summary and the diff of every document the producer writes, per file, with the images by hash). Schema and refusals: `docs/REVIEW_PACKAGE.md`, *Config patch and Apply*.
|
|
613
613
|
|
package/docs/REVIEW_PACKAGE.md
CHANGED
|
@@ -166,8 +166,9 @@ Every edit card states its edit as one config patch; review applies it to a scra
|
|
|
166
166
|
- **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).
|
|
167
167
|
- **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}`.
|
|
168
168
|
- **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.
|
|
169
|
-
- **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`, `
|
|
170
|
-
- **
|
|
169
|
+
- **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`, `build` and cloth keep running check.
|
|
170
|
+
- **A remesh's check (#174).** A `remesh` patch Apply (#106) reads `rig-parts remesh`'s own check of its revision the same way, with one binding shared with retune: `remesh.json`'s `check.revision` (`line` and `status` since `@rig-c/parts` 1.49.0, `REMESH_CHECK_SINCE`; the same parts#541 change) is bound by its `revision` hash rows to the files under `rig/` (none missing, none but those rows and `remesh.json`), else the Apply is refused `REMESH_CHECK_STALE: <file> sha256 <in remesh.json> … <on disk>; remesh's check is not this revision's, so no build is recorded` and leaves no scratch. remesh checked the revision with its request's `page_edges` / `pack_shape` — the flags review's own check reads from `remesh.json` — and the handoff `--parts`; a patch passes no `--requirements`. Bound, the summary is the same mapping as retune's with `source:"remesh"`; `wallMs.check` is `null` and the line says `review: check not run again — remesh.json carries remesh's check of this revision, bound to it by N revision hash row(s): <line>`. An older `remesh.json` or an Apply under an exported world runs check, and the line says why. The designation remesh (#99) and Edit Mesh's `remesh --delta` (#135) run no review check at all (remesh's own gate holds check there), so they are unchanged.
|
|
171
|
+
- **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"` or `source:"remesh"` when the summary is that op's carried check (#139, #174; absent: check's own output, `check.json` and its stdout).
|
|
171
172
|
|
|
172
173
|
**`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:
|
|
173
174
|
|
|
@@ -223,7 +224,14 @@ The issue export adds an `Applied:` line for a valid record (`patch <hash>… (r
|
|
|
223
224
|
|
|
224
225
|
**Rig fields (#110).** `GET /api/rebuild` also returns `fields`: the handoff config's `regions`, `motion.blink` (as `{motion:{blink}}`), `stops`, `bones` and `turn` as written, an absent section left out (or `null` without a handoff). The Rig fields block's edits are ordinary config patch ops, each at its field's pointer (rig-parts AUTHORING §3): `{op:"replace", path:"/regions/<part>", value:<bone>}`; `{op:"add", path:"/motion/blink/still/<part>", value:{row, bone}}` (preceded by `{op:"add", path:"/motion/blink/still", value:{}}` when the config states no `still`) or `{op:"remove", path:"/motion/blink/still/<part>"}`; `{op:"replace", path:"/stops/<i>", value:<the entry with inward / outward changed>, name:<its chain>}`; `{op:"add"|"remove", path:"/bones/<i>/inherit", value:<mode>, name}` or `path:"/bones/<i>/link_inherit", value:{<link>: mode}`; `{op:"replace", path:"/turn/pivot"|"/turn/depths/<part>", value:<number>}`. A `stops` entry is named by its `chain` and `EDIT_PATCH_NAME_MISMATCH` checks it as it checks a `bones` entry. The page refuses anything else before sending (`RIG_FIELDS_OP_OUT_OF_SCOPE`; `turn.deform` is out of scope) and sends the ops as one `rig` patch (no flag), so it needs the reproduced baseline: none of its pointers is in retune's scope (*Routing*). The producer judges every value and its refusal comes back verbatim as the job's `producerLine`. The turn dial is play's `adjust` on the drawn build's `turn` bone; it is not a patch op and nothing records it. Nothing is written into the package.
|
|
225
226
|
|
|
226
|
-
**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 stack lives in the review process; a restarted review starts with none.
|
|
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
|
+
|
|
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`). 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
|
+
- **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
|
+
- **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
|
+
- **Refine between** two adjacent rows builds the next strip with N values strictly inside their interval.
|
|
234
|
+
- **Scratch.** A package keeps at most one strip on disk: a new strip removes its predecessor's folder before it starts, `POST /api/density-strip/close` (same-origin, capability, 1 KiB, exactly `{id}`; `DENSITY_STRIP_BUSY` while it runs, `DENSITY_STRIP_NOT_FOUND` for another id) removes it, and a refused candidate leaves no folder. A restarted review starts with no strip; the next strip removes what a previous process left. Nothing is written into the package.
|
|
227
235
|
|
|
228
236
|
## Mesh edit export
|
|
229
237
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #172: what the Cuts and Cloth blocks mark as applied, derived from the stage's current applied record — the same
|
|
3
|
+
* record the draft keeps and feedback carries as `context.applied`. After an undo that record is the predecessor's (or
|
|
4
|
+
* none on the inspected build), so a reverted Apply's ops are never marked.
|
|
5
|
+
*
|
|
6
|
+
* The rule: a patch record (`rig-review-applied/2`) belongs to the Cloth block when its op is `cloth` (the server runs
|
|
7
|
+
* a patch whose every op is under `/cloth` as `propose --cloth --write-config`) and to the Cuts block when its op is
|
|
8
|
+
* `build` (a cut, patch, limb or joint edit is read by assemble, so it runs `rig-parts build`). Any other record — a
|
|
9
|
+
* physics or bones `rig` patch, a remesh, a regions, delta or retarget record, or none — marks nothing in either block.
|
|
10
|
+
* A pending op is applied when the block's applied ops hold an op with the same op, path, value and name.
|
|
11
|
+
*/
|
|
12
|
+
import {APPLIED_PATCH_SCHEMA} from './applied-context.ts';
|
|
13
|
+
import type {PatchOp} from './config-patch.ts';
|
|
14
|
+
|
|
15
|
+
export type AppliedBlock = 'cuts' | 'cloth';
|
|
16
|
+
export const APPLIED_BLOCKS: readonly AppliedBlock[] = ['cuts', 'cloth'];
|
|
17
|
+
|
|
18
|
+
/** The record op each block owns. */
|
|
19
|
+
const BLOCK_OP: Record<AppliedBlock, string> = { cuts: 'build', cloth: 'cloth' };
|
|
20
|
+
|
|
21
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
22
|
+
|
|
23
|
+
/** One op's identity for the applied mark: op, path, value and name. */
|
|
24
|
+
export const opKey = (o: PatchOp): string => JSON.stringify([o.op, o.path, o.value ?? null, o.name ?? null]);
|
|
25
|
+
|
|
26
|
+
/** The block a record belongs to, or null when it marks nothing in the Cuts or Cloth block. */
|
|
27
|
+
export function recordBlock(record: unknown): AppliedBlock | null {
|
|
28
|
+
if (!isObject(record) || record.schema !== APPLIED_PATCH_SCHEMA) return null;
|
|
29
|
+
return APPLIED_BLOCKS.find(b => record.op === BLOCK_OP[b]) ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The stage record's applied ops per block: the record's patch ops in the block it belongs to, none in the other. */
|
|
33
|
+
export function appliedBlockOps(record: unknown): Record<AppliedBlock, PatchOp[]> {
|
|
34
|
+
const out: Record<AppliedBlock, PatchOp[]> = { cuts: [], cloth: [] };
|
|
35
|
+
const block = recordBlock(record);
|
|
36
|
+
if (!block) return out;
|
|
37
|
+
const patch = (record as Record<string, unknown>).patch;
|
|
38
|
+
const ops = isObject(patch) && Array.isArray(patch.ops) ? patch.ops as PatchOp[] : [];
|
|
39
|
+
out[block] = ops.map(o => ({ ...o }));
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether a pending item (the ops it sends) is applied: it sends at least one op and every one is in `applied`. */
|
|
44
|
+
export function itemApplied(itemOps: readonly PatchOp[], applied: readonly PatchOp[]): boolean {
|
|
45
|
+
if (!itemOps.length || !applied.length) return false;
|
|
46
|
+
const keys = new Set(applied.map(opKey));
|
|
47
|
+
return itemOps.every(o => keys.has(opKey(o)));
|
|
48
|
+
}
|
package/src/applied-context.ts
CHANGED
|
@@ -44,9 +44,10 @@ export interface CheckSummary {
|
|
|
44
44
|
/**
|
|
45
45
|
* #139: where the summary came from. Absent: review read check's own output (`check.json` and its stdout). `"retune"`:
|
|
46
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.
|
|
47
|
+
* and bound to the revision's files by its hash rows; no second check ran. `"remesh"` (#174): the same, from the check
|
|
48
|
+
* `rig-parts remesh` ran, carried in `remesh.json`.
|
|
48
49
|
*/
|
|
49
|
-
source?: 'retune' | 'check';
|
|
50
|
+
source?: 'retune' | 'remesh' | 'check';
|
|
50
51
|
}
|
|
51
52
|
export interface Tool { package: string; version: string }
|
|
52
53
|
export interface AppliedPatch {
|
|
@@ -186,7 +187,7 @@ export function checkSummary(checkJson: unknown, exitCode: number, lines: readon
|
|
|
186
187
|
function parseCheck(v: unknown, field: string): void {
|
|
187
188
|
if (v === null) return;
|
|
188
189
|
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"`);
|
|
190
|
+
if (Object.hasOwn(c, 'source') && c.source !== 'retune' && c.source !== 'remesh' && c.source !== 'check') throw new Error(`${field}.source: "retune", "remesh" or "check"`);
|
|
190
191
|
if (c.schema !== CHECK_SUMMARY_SCHEMA) throw new Error(`${field}.schema: expected ${CHECK_SUMMARY_SCHEMA}`);
|
|
191
192
|
if (c.pass !== null && typeof c.pass !== 'boolean') throw new Error(`${field}.pass: boolean or null`);
|
|
192
193
|
if (c.gate !== null && typeof c.gate !== 'boolean') throw new Error(`${field}.gate: boolean or null`);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Density strip (#173), pure: N ≤ 5 values of one part's density field — `grid` on a lattice entry, `contour.spacing`
|
|
3
|
+
* on a contour entry — each sent as the one existing Mesh-panel patch (`replace /meshes/<part>`, `remesh --part
|
|
4
|
+
* <part>`, #106) and built side by side. This module holds what needs no IO: the request's shape (the patches may
|
|
5
|
+
* differ only at the density field), the values of a strip and of a refined strip, and the row a settled candidate job
|
|
6
|
+
* reads as — `remesh.json`'s `target.after` counts, the producer's own check lines for the part (folds, stretch, every
|
|
7
|
+
* bar the part is measured on as a ratio against its floor or ceiling) and the verdict (PASS, or the refusal verbatim).
|
|
8
|
+
* The two labels ("fewest triangles passing", "widest margin among ties") name rows; nothing here applies one.
|
|
9
|
+
*/
|
|
10
|
+
import { applyConfigPatch, type ConfigPatch, type JsonValue } from './config-patch.ts';
|
|
11
|
+
import { assertMeshPatch, MeshEntryError, meshMode, type MeshCounts } from './mesh-entry.ts';
|
|
12
|
+
|
|
13
|
+
export const DENSITY_STRIP_SCHEMA = 'rig-review-density-strip/1';
|
|
14
|
+
/** At most this many values per strip (the card's N ≤ 5); at least two (one value is an Apply). */
|
|
15
|
+
export const MAX_STRIP_VALUES = 5, MIN_STRIP_VALUES = 2;
|
|
16
|
+
/** `POST /api/density-strip`: five patches of at most 64 KiB each (the Apply body bound). */
|
|
17
|
+
export const DENSITY_STRIP_LIMIT = MAX_STRIP_VALUES * 64 * 1024;
|
|
18
|
+
/** The density field a strip runs over, by the entry's mode. */
|
|
19
|
+
export type DensityField = 'grid' | 'contour.spacing';
|
|
20
|
+
export const densityPath = (field: DensityField): readonly string[] => (field === 'grid' ? ['grid'] : ['contour', 'spacing']);
|
|
21
|
+
|
|
22
|
+
export class DensityStripError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
|
|
23
|
+
const invalid = (detail: string): never => { throw new DensityStripError('DENSITY_STRIP_REQUEST_INVALID', detail); };
|
|
24
|
+
const isObject = (v: unknown): v is Record<string, JsonValue> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
25
|
+
|
|
26
|
+
/** The density field of an entry: `grid` for a lattice, `contour.spacing` for a contour; any other mode has none here. */
|
|
27
|
+
export function densityField(entry: unknown, part: string): DensityField {
|
|
28
|
+
const mode = meshMode(entry, part);
|
|
29
|
+
if ('error' in mode) throw new DensityStripError('DENSITY_STRIP_FIELD', mode.error);
|
|
30
|
+
if (mode.mode === 'grid') return 'grid';
|
|
31
|
+
if (mode.mode === 'contour') return 'contour.spacing';
|
|
32
|
+
return invalid(`meshes.${part} is a ${mode.mode} entry; a strip runs over grid (lattice) or contour.spacing (contour) only`);
|
|
33
|
+
}
|
|
34
|
+
const valueAt = (entry: Record<string, JsonValue>, path: readonly string[]): JsonValue | undefined => path.reduce<JsonValue | undefined>((v, k) => (isObject(v) ? v[k] : undefined), entry);
|
|
35
|
+
const without = (entry: Record<string, JsonValue>, path: readonly string[]): JsonValue => {
|
|
36
|
+
const out = structuredClone(entry); let holder: Record<string, JsonValue> = out;
|
|
37
|
+
for (const k of path.slice(0, -1)) holder = holder[k] as Record<string, JsonValue>;
|
|
38
|
+
delete holder[path.at(-1)!];
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** `{patches}` exactly, 2–5 of them (each still to be parsed as a config patch). */
|
|
43
|
+
export function stripPatches(input: unknown): unknown[] {
|
|
44
|
+
if (!isObject(input) || Object.keys(input).join(',') !== 'patches') invalid('expected exactly {patches}');
|
|
45
|
+
const patches = (input as { patches: unknown }).patches;
|
|
46
|
+
if (!Array.isArray(patches) || patches.length < MIN_STRIP_VALUES || patches.length > MAX_STRIP_VALUES) invalid(`patches must hold ${MIN_STRIP_VALUES}–${MAX_STRIP_VALUES} config patches`);
|
|
47
|
+
return patches as unknown[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface StripShape { part: string; field: DensityField; values: number[]; entries: JsonValue[] }
|
|
51
|
+
/**
|
|
52
|
+
* The strip a list of parsed patches states: every one the Mesh panel's patch for one part (`MESH_PATCH_SCOPE`
|
|
53
|
+
* otherwise), the same config bytes, producer and flags, and entries that differ only at the part's density field,
|
|
54
|
+
* each at another value. Order is the request's.
|
|
55
|
+
*/
|
|
56
|
+
export function stripShape(patches: readonly ConfigPatch[]): StripShape {
|
|
57
|
+
const first = patches[0]!;
|
|
58
|
+
const part = first.op.args[first.op.args.indexOf('--part') + 1];
|
|
59
|
+
if (first.op.cmd !== 'remesh' || typeof part !== 'string') invalid('a strip is made of remesh patches (remesh --part <part>)');
|
|
60
|
+
const field = densityField(first.ops[0]?.value, part!);
|
|
61
|
+
const path = densityPath(field), entries: JsonValue[] = [], values: number[] = [];
|
|
62
|
+
let rest: string | null = null;
|
|
63
|
+
for (const [i, patch] of patches.entries()) {
|
|
64
|
+
try { assertMeshPatch(patch, part!); } catch (e) { if (e instanceof MeshEntryError) throw new DensityStripError(e.code, `patches[${i}]: ${e.message.slice(e.code.length + 2)}`); throw e; }
|
|
65
|
+
if (patch.config_sha256 !== first.config_sha256) invalid(`patches[${i}] was made against other config bytes than patches[0]`);
|
|
66
|
+
if (JSON.stringify(patch.producer) !== JSON.stringify(first.producer) || JSON.stringify(patch.op) !== JSON.stringify(first.op)) invalid(`patches[${i}] names another producer or other flags than patches[0]`);
|
|
67
|
+
const entry = patch.ops[0]!.value;
|
|
68
|
+
if (!isObject(entry) || densityField(entry, part!) !== field) invalid(`patches[${i}]: the entry is not a ${field === 'grid' ? 'grid' : 'contour'} entry like patches[0]`);
|
|
69
|
+
const value = valueAt(entry as Record<string, JsonValue>, path);
|
|
70
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) invalid(`patches[${i}]: meshes.${part}.${field} is not a number`);
|
|
71
|
+
const others = JSON.stringify(without(entry as Record<string, JsonValue>, path));
|
|
72
|
+
if (rest === null) rest = others;
|
|
73
|
+
else if (others !== rest) throw new DensityStripError('DENSITY_STRIP_FIELD', `patches[${i}] differs from patches[0] outside meshes.${part}.${field}; a strip varies the density field only`);
|
|
74
|
+
if (values.includes(value as number)) invalid(`patches[${i}]: ${field} ${value} is already in the strip`);
|
|
75
|
+
values.push(value as number); entries.push(entry as JsonValue);
|
|
76
|
+
}
|
|
77
|
+
return { part: part!, field, values, entries };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** n values from a to b inclusive, evenly spaced; a lattice's grid is a whole number (rounded, duplicates dropped). */
|
|
81
|
+
export function stripValues(a: number, b: number, n: number, integer: boolean): number[] {
|
|
82
|
+
if (![a, b, n].every(Number.isFinite) || n < MIN_STRIP_VALUES || n > MAX_STRIP_VALUES || !Number.isInteger(n)) throw new DensityStripError('DENSITY_STRIP_VALUES', `N must be a whole number ${MIN_STRIP_VALUES}–${MAX_STRIP_VALUES}, and A and B numbers`);
|
|
83
|
+
const lo = Math.min(a, b), hi = Math.max(a, b);
|
|
84
|
+
const raw = Array.from({ length: n }, (_, k) => lo + ((hi - lo) * k) / (n - 1));
|
|
85
|
+
const out = [...new Set(raw.map(v => (integer ? Math.round(v) : Math.round(v * 1000) / 1000)))].sort((x, y) => x - y);
|
|
86
|
+
if (out.length < MIN_STRIP_VALUES) throw new DensityStripError('DENSITY_STRIP_VALUES', `${lo}…${hi} holds fewer than ${MIN_STRIP_VALUES} distinct ${integer ? 'whole ' : ''}values`);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** "Refine between" two adjacent rows: up to n values strictly inside (a, b), evenly spaced. */
|
|
90
|
+
export function refineValues(a: number, b: number, n: number, integer: boolean): number[] {
|
|
91
|
+
const lo = Math.min(a, b), hi = Math.max(a, b);
|
|
92
|
+
const raw = Array.from({ length: n }, (_, k) => lo + ((hi - lo) * (k + 1)) / (n + 1));
|
|
93
|
+
const out = [...new Set(raw.map(v => (integer ? Math.round(v) : Math.round(v * 1000) / 1000)))].filter(v => v > lo && v < hi).sort((x, y) => x - y);
|
|
94
|
+
if (out.length < MIN_STRIP_VALUES) throw new DensityStripError('DENSITY_STRIP_VALUES', `between ${lo} and ${hi} there are fewer than ${MIN_STRIP_VALUES} ${integer ? 'whole ' : ''}values to try`);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/** The entry with its density field at `value` (every other field as the base entry holds it, in its order). */
|
|
98
|
+
export const entryAt = (entry: Record<string, JsonValue>, field: DensityField, value: number): JsonValue =>
|
|
99
|
+
applyConfigPatch(entry, [{ op: 'replace', path: densityPath(field).map(k => '/' + k).join(''), value }]);
|
|
100
|
+
|
|
101
|
+
/** One bar the part is measured on, as the producer's check printed it: its figure against the bar's floor or ceiling. */
|
|
102
|
+
export interface StripBar { bar: string; status: string; key: string; value: number; bound: number; kind: 'floor' | 'ceiling'; ratio: number; margin: number }
|
|
103
|
+
export interface StripFigures {
|
|
104
|
+
/** `ANIMATIONS_DO_NOT_FOLD`'s folding mesh count, as printed. */
|
|
105
|
+
folds: number | null;
|
|
106
|
+
/** The part's `TEXTURE_STRETCH` severity and the frame of its worst triangle (the max or the min side, whichever is worse). */
|
|
107
|
+
stretch: { severity: number; frame: string | null } | null;
|
|
108
|
+
bars: StripBar[];
|
|
109
|
+
}
|
|
110
|
+
const NUM = '(-?\\d+(?:\\.\\d+)?(?:e[-+]?\\d+)?)';
|
|
111
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
112
|
+
/**
|
|
113
|
+
* The check lines a candidate's figures are read from: the producer op's own check of its revision (remesh prints it
|
|
114
|
+
* as `[check-revision] …`, #174) when the job carries it, else the lines of the check review ran (no bracket prefix).
|
|
115
|
+
*/
|
|
116
|
+
export function revisionCheckLines(lines: readonly string[]): string[] {
|
|
117
|
+
const tagged = lines.filter(l => l.startsWith('[check-revision]')).map(l => l.slice('[check-revision]'.length));
|
|
118
|
+
return tagged.length ? tagged : lines.filter(l => !/^\s*\[/.test(l) && !l.startsWith('review:'));
|
|
119
|
+
}
|
|
120
|
+
/** The part's figures from check's bar lines (` BAR: STATUS — detail`), the producer's numbers as printed. */
|
|
121
|
+
export function stripFigures(lines: readonly string[], part: string): StripFigures {
|
|
122
|
+
const out: StripFigures = { folds: null, stretch: null, bars: [] };
|
|
123
|
+
const marker = new RegExp(`\\b(?:part|slot) ${escape(part)}, `, 'g');
|
|
124
|
+
for (const raw of revisionCheckLines(lines)) {
|
|
125
|
+
const m = /^\s*([A-Z][A-Z0-9_]+): (PASS|FAIL|SKIP|WARN|CLEAR|REPORTED) — (.*)$/.exec(raw);
|
|
126
|
+
if (!m) continue;
|
|
127
|
+
const [, bar, status, detail] = m as unknown as [string, string, string, string];
|
|
128
|
+
if (bar === 'ANIMATIONS_DO_NOT_FOLD') { const f = /(\d+) folding mesh\(es\)/.exec(detail); if (f) out.folds = Number(f[1]); }
|
|
129
|
+
// The part's own segment: the last time the line names it (TEXTURE_STRETCH names its worst slot first, then per mesh).
|
|
130
|
+
let at = -1; for (const hit of detail.matchAll(marker)) at = hit.index!;
|
|
131
|
+
if (at < 0) continue;
|
|
132
|
+
const end = detail.indexOf(' | ', at), segment = detail.slice(at, end < 0 ? undefined : end);
|
|
133
|
+
if (/^\S+ \S+, unmeasured\b/.test(segment)) continue;
|
|
134
|
+
if (bar === 'TEXTURE_STRETCH') {
|
|
135
|
+
const sev = new RegExp(`\\bseverity ${NUM}`).exec(segment), max = new RegExp(`\\bmax_ratio ${NUM}`).exec(segment), min = new RegExp(`\\bmin_ratio ${NUM}`).exec(segment);
|
|
136
|
+
const frames = [...segment.matchAll(/\b([A-Za-z0-9_-]+) frame (\d+)/g)].map(f => `${f[1]} frame ${f[2]}`);
|
|
137
|
+
if (sev) {
|
|
138
|
+
// The worst frame is the side the severity was taken from: the max ratio, or the inverse of the min ratio.
|
|
139
|
+
const s = Number(sev[1]), worstMin = !!(max && min && Number(min[1]) > 0 && Math.abs(1 / Number(min[1]) - s) < Math.abs(Number(max[1]) - s));
|
|
140
|
+
out.stretch = { severity: Number(sev[1]), frame: (worstMin ? frames[1] : frames[0]) ?? null };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const b of detail.matchAll(new RegExp(`\\b([a-z][a-z0-9_]*?)_(floor|ceiling) ${NUM}`, 'g'))) {
|
|
144
|
+
const key = b[1]!, kind = b[2] as 'floor' | 'ceiling', bound = Number(b[3]);
|
|
145
|
+
const v = new RegExp(`\\b${escape(key)} ${NUM}`).exec(segment);
|
|
146
|
+
if (!v || !(bound > 0)) continue;
|
|
147
|
+
const value = Number(v[1]), ratio = value / bound;
|
|
148
|
+
out.bars.push({ bar, status, key, value, bound, kind, ratio, margin: kind === 'floor' ? ratio : value > 0 ? bound / value : Infinity });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A settled candidate job, as much of it as a row reads. */
|
|
155
|
+
export interface CandidateJobLike {
|
|
156
|
+
status: string; producerLine: string | null; lines: readonly string[];
|
|
157
|
+
remesh: { part: string; before: MeshCounts; after: MeshCounts; modelTriangles: number | null } | null;
|
|
158
|
+
check: { pass: boolean | null; line: string | null } | null;
|
|
159
|
+
build: { modelHash: string } | null;
|
|
160
|
+
}
|
|
161
|
+
export interface StripRow extends StripFigures {
|
|
162
|
+
value: number; id: string; status: string;
|
|
163
|
+
/** `remesh.json`'s `target.after` (null when the candidate wrote none: refused before it). */
|
|
164
|
+
counts: { vertices: number; triangles: number } | null;
|
|
165
|
+
modelTriangles: number | null;
|
|
166
|
+
/** PASS, or the producer's refusal (or check's line) verbatim. */
|
|
167
|
+
verdict: string;
|
|
168
|
+
/** Served for viewing while the strip holds it; null for a refused or running row. */
|
|
169
|
+
modelUrl: string | null; modelHash: string | null;
|
|
170
|
+
/** The candidate's build moved into the Apply scratch root when the reader Applied this row's patch. */
|
|
171
|
+
applied: boolean;
|
|
172
|
+
entry: JsonValue;
|
|
173
|
+
}
|
|
174
|
+
export function candidateRow(input: { value: number; id: string; entry: JsonValue; job: CandidateJobLike; part: string; modelUrl: string | null; applied: boolean }): StripRow {
|
|
175
|
+
const { job } = input, built = job.status === 'built';
|
|
176
|
+
const verdict = job.status === 'running' ? 'running' : built ? (job.check?.pass ? 'PASS' : job.check?.line ?? 'check: no summary') : job.producerLine ?? `REBUILD_${job.status.toUpperCase()}`;
|
|
177
|
+
return {
|
|
178
|
+
value: input.value, id: input.id, status: job.status, ...stripFigures(job.lines, input.part),
|
|
179
|
+
counts: job.remesh ? { vertices: job.remesh.after.vertices, triangles: job.remesh.after.triangles } : null,
|
|
180
|
+
modelTriangles: job.remesh?.modelTriangles ?? null, verdict,
|
|
181
|
+
modelUrl: built && !input.applied ? input.modelUrl : null, modelHash: built ? job.build?.modelHash ?? null : null,
|
|
182
|
+
applied: input.applied, entry: input.entry,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** The narrowest margin of a row over every bar it is measured on (> 1: every bar met with room), or null with none. */
|
|
186
|
+
export const rowMargin = (row: Pick<StripRow, 'bars'>): number | null => (row.bars.length ? Math.min(...row.bars.map(b => b.margin)) : null);
|
|
187
|
+
/**
|
|
188
|
+
* The two labels, by row index: the passing row with the fewest triangles, and — when several passing rows tie on that
|
|
189
|
+
* count — the one among them with the widest narrowest margin. Labels only: nothing here applies a row.
|
|
190
|
+
*/
|
|
191
|
+
export function stripLabels(rows: readonly StripRow[]): { fewest: number | null; widest: number | null } {
|
|
192
|
+
const passing = rows.map((r, i) => ({ r, i })).filter(x => x.r.verdict === 'PASS' && x.r.counts);
|
|
193
|
+
if (!passing.length) return { fewest: null, widest: null };
|
|
194
|
+
const least = Math.min(...passing.map(x => x.r.counts!.triangles)), tied = passing.filter(x => x.r.counts!.triangles === least);
|
|
195
|
+
const fewest = tied[0]!.i;
|
|
196
|
+
if (tied.length < 2) return { fewest, widest: null };
|
|
197
|
+
const best = tied.reduce((a, b) => ((rowMargin(b.r) ?? -Infinity) > (rowMargin(a.r) ?? -Infinity) ? b : a));
|
|
198
|
+
return { fewest, widest: best.i };
|
|
199
|
+
}
|
|
200
|
+
/** A bar as the table shows it: `TIP_OVER_ROOT ratio 1.995 / floor 1.4725 = 1.355×`. */
|
|
201
|
+
export const barText = (b: StripBar) => `${b.bar} ${b.key} ${b.value} / ${b.kind} ${b.bound} = ${b.ratio.toFixed(3)}×`;
|
package/src/front.ts
CHANGED
|
@@ -61,6 +61,10 @@ export function frontRoute(mode:'single'|'compare',method:string,path:string):Ro
|
|
|
61
61
|
if(mode==='single'&&path==='/api/rebuild/history')return read?get:null;
|
|
62
62
|
if(mode==='single'&&path==='/api/rebuild/undo')return method==='POST'?{mutation:true,limit:1024,timeoutMs:30000,control:false}:null;
|
|
63
63
|
if(mode==='single'&&(/^\/api\/rebuild\/[a-f0-9]{64}$/.test(path)||/^\/rebuild\/[a-f0-9]{64}\/[^/]/.test(path)))return read?get:null;
|
|
64
|
+
// #173 (single only): a density strip (2–5 patches, 320 KiB), its status, a candidate's job, its served build, and closing it.
|
|
65
|
+
if(mode==='single'&&path==='/api/density-strip')return read?get:method==='POST'?{mutation:true,limit:320*1024,timeoutMs:30000,control:false}:null;
|
|
66
|
+
if(mode==='single'&&path==='/api/density-strip/close')return method==='POST'?{mutation:true,limit:1024,timeoutMs:30000,control:false}:null;
|
|
67
|
+
if(mode==='single'&&(/^\/api\/density-strip\/[a-f0-9]{64}(?:\/[a-f0-9]{64})?$/.test(path)||/^\/density-strip\/[a-f0-9]{64}\/[a-f0-9]{64}\/[^/]/.test(path)))return read?get:null;
|
|
64
68
|
// Bones check (#109, single only): one typed request {ops, configHash}, answered in the response.
|
|
65
69
|
if(mode==='single'&&path==='/api/bones-check')return method==='POST'?{mutation:true,limit:64*1024,timeoutMs:60000,control:false}:null;
|
|
66
70
|
// Readings (#108, single only): one typed request, then its job's status.
|