rig-parts 1.77.0 → 1.78.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/docs/AUTHORING.md +11 -0
- package/docs/STABILITY.md +1 -1
- package/package.json +1 -1
- package/src/assemble.ts +102 -3
- package/src/build.ts +3 -1
- package/src/config.ts +21 -3
package/docs/AUTHORING.md
CHANGED
|
@@ -121,6 +121,7 @@ read by no CPU stage).
|
|
|
121
121
|
| `assemble.alternates` | assemble, rig, check (`--set`, `--all-sets`) | **authored**, optional (issues #454, #465, #472, #473, #476): `[{name, source?, full?, head?, head_box?, parts?, hides?, cuts?, skeleton?, anchor?, base_anchor?, drawn?, tips?, front?}]` — alternate painting sets assembled into `<out>/rig/parts/<set>_<part>.png` (`parts.json` `alternates`), attached on each covered slot in `rig.json` as `<set>_<part>` beside the base attachment, and indexed in `<out>/rig/sets.json` (`rig-parts-sets/1`: `sets.<set>.slots`, `.hides`, `.drawn` when present, and `.threshold: {yaw: 15, pitch: 12}` when the set replaces `face` on a rig with `turn` or `turn`/`tilt` slider animations). `source`, `full` and `head` may instead be given on the CLI with `--alt <name>=<source>:<full>[:<head>]`. Windows absolute drive paths such as `C:\paint\painting.png` are accepted as written; each segment's leading drive colon belongs to that path, while other colons still separate `source`, `full` and `head`. `parts` lists the subset of parts the set replaces (default: every part not in `hides`); a **subset set** (issue #473, e.g. mouth visemes `a`, `i`, `u` with `"parts": ["mouth"]`) cuts only its covered parts from the alternate runs (refusing with `ASSEMBLE_ALT_INPUT_PRESENT` if a covered part's layer is missing or empty), writes only `{slot: "<set>_<slot>"}` in `sets.json` so `@rig-c/play`'s `setSet(name)` switches those slots while leaving every other slot on its base attachment (and `setSet(null)` restores the base pose), may be keyed on a slot attachment track in `motion.animations_from` as `{"t": …, "set": "<name>" \| null}` (`CONFIG_SET_COVERS_SLOT` if `"set"` is undeclared or does not cover the track's slot), and under `check --set <name>` counts `ANIMATIONS_DO_NOT_FOLD` only on its own covered slots; `hides` lists slots the set hides (`null` in `sets.json`). A **gesture subset set** (issue #476) covers a limb chain declared in `config.limbs.<limb>` (all of its segment `part`s; with `drawn` alone, cut at the base's joints posed to the set's own `drawn` angles by `poseBoneEntriesForDrawn`, each segment keeping the base's length; with `skeleton` (issue #539), cut at the skeleton's own joint positions by `placeBoneEntriesForSkeleton` — each covered bone the body-18 table names (`sleeve_*`, `arm_*0`/`arm_*1`, `leg_*0`/`leg_*1`) at its skeleton origin with the skeleton segment's own length and direction (`drawn` overriding the direction of a bone it names), and a covered bone the table does not name (a hand link `arm_r2`, a single `foot_r`) placed from its parent's new place — its base offset from the parent, stretched along the parent by the parent's length change and turned by its turn, so a link or a foot at the parent's end lands on the skeleton's end — with its base length and the drawn or base direction, so a foreshortened shin or forearm is cut where it is painted; naming the unsplit `from` layer, covering only some segments of the limb, or declaring polygon `cuts` on a limb set is refused with `CONFIG_LIMB_SET_JOINTS`) and carries `drawn` (or `skeleton`) for the covered bones only (giving `drawn` for an uncovered bone is refused with `CONFIG_SET_COVERS_BONE` / `RIG_SET_COVERS_BONE`; uncovered bones keep the base's `drawn`). A subset set's limb part takes no pixel that a base part drawn after it, which the set neither covers nor hides, covers at setup (issue #587): the set's attachment is drawn in the base slot's place, so that base part is drawn over it and what the set's painting shows there is that base part (shorts over a leg set's thigh top, which See-through synthesised in the set's leg layer); `assemble` clears those pixels — the base part's PNG at alpha above 8, at its `parts.json` x, y, on the shared canvas — from the set's part once its limbs are cut, before any count — its `merged_px` and its cap's record too (issue #603): a cap pixel it clears leaves the count it was in (`layer_px`, `image_px`, `upper_px`, `lower_px`, `lower_kept_px`) for the cap's `excluded_px`, written only when above 0, so every count is the written PNG's — and prints `LINT set "<set>" <part>: <n> px under <base part> excluded` per part and base part that took a pixel (n counts alpha above 8); a set that carries that base part itself (`"parts": ["thigh_l", "shin_l", "bottomwear"]`) excludes nothing and writes what it wrote before. A subset set that names one limb side of a layer more limbs take (issue #588: `"parts": ["thigh_r", "shin_r"]` where `leg_r` and `leg_l` both take the one `legs` layer) would put every pixel of that layer on its one limb, so each of its parts would hold a piece of both legs; when the set's layer holds more than one island (8-connected pixels with alpha above 8) of at least 1 % of its largest (issue #608: the ghost clean-up's own share; a smaller island is a speck, which goes with the island nearest it — the one holding the pixel centre nearest one of the speck's, none when two are equally near — and is never an island of its own), each segment part keeps the island its joints fall on — its bone's origin and the next segment's bone's origin, at the set's own bones, a joint on the island of a pixel whose square holds the point, and a joint on no island on the one island whose nearest pixel centre lies within the radius of the cap cut at that joint (`cap.radius`: an ankle just past the legwear's edge, on the shoe) — and drops the rest, once its limbs are cut and before the exclusion above, and `assemble` prints `LINT set "<set>" <part>: <layer part> holds <n> islands (8-connected, alpha above 8)[, and <s> speck(s) under 1 % of the largest (<f> px), each with the island nearest it], kept the one under <bone> [x, y] and <bone> [x, y][ (<e> px off it, within its cap's radius <r>)] (<k> px), dropped <m> (<d> px)` per part (k and d count the part's pixels above alpha 8, m the islands it lost one from, and `dropped none` when it lost none); a joint on no island with none or two within that radius, or with no cap cut at it (a limb's first joint), and a segment whose joints fall on two, are refused with `ASSEMBLE_ALT_LIMB_ISLAND` rather than guessed. A layer of one island — specks aside — may hold both legs over each other (a folded shin across the standing leg; issue #609): each of the set's segment parts then keeps the pixels nearer its own limb's segment line — the polyline of only its segments taking that source, their bone origins and the last segment's supplied tip or following segment's joint (issue #638) — than the other limb's, both in the set's pose, and gives the rest away, once its limbs are cut and before the exclusion above; a pixel whose two distances differ by 0.5 px or less (the overlap band: half a pixel, the reach by which a pixel centre is read as on a line) goes to the limb whose part there is drawn later in the base's draw order, and the `LINT set` line above counts what each part kept, gave and held in the band. The other limb stands where the set's `skeleton` places it — its keypoints place every limb bone of the body-18 table, the other leg's as well as the set's own (its `drawn` and `drawnLength` stay the covered bones') — and never at the base's place: a set that does not place it (a `drawn` set, which may not name a bone it does not cover, or a set with neither) is refused with `ASSEMBLE_ALT_LIMB_POSE` when its layer has art where the base draws that limb, and when it has none nothing says the layer holds it and the layer is cut as before; two limbs whose segment lines run closer than the limb's half-width over most of where their art can meet are refused with `ASSEMBLE_ALT_LIMB_OVERLAP`. A set that carries every limb of the layer is cut as before. For a subset set, where a limb's own source layer holds art (`alpha > 8`) under another layer of that run drawn in front (`alpha > 8`, by the projection's plan/depth order), the limb keeps its own layer pixels through projection, limb cutting, island/nearest-line assignment, base-footprint exclusion, fringe clearing and seam correction (issue #637). Those pixels are occluded, never counted as painting projection; no base footprint or painting colour replaces them. After source-art island/nearest-line assignment, a source-transparent cap pixel is assigned before protection, in both doors: its centre is compared against only the segments taking the recipient's source, at the set's supplied bones (ankle-to-toe for a separate foot source), with the existing 0.5 px overlap band and base draw order. A missing last endpoint is refused with `ASSEMBLE_ALT_LIMB_POSE`; an actual following joint may supply that endpoint, never a guessed point. An unposed other limb with base art beneath these cap pixels is likewise refused; without such overlap the existing compatible path retains the cap without inventing the other limb's pose. A pixel cleared by this assignment is never restored. `CAP_FROM_UPPER` records a copy made before assignment and alone proves no ownership: only a retained copy is protected beneath the upper segment, which is drawn over it. The source-art island selection itself is unchanged. Other retained cap pixels (from the layer or a supplied cap image) are protected under an actual front layer even where the lower source is transparent. Protection follows each assigned piece through exclusion, fringe clearing and seam correction; it never restores a pixel ownership removed. Cap pixels removed during ownership leave their original counts for `excluded_px`, just as excluded pixels do, so the cap ledger describes the written PNG. Target assignment reconciles this ledger even when there are no source-transparent cap candidates and when a direct caller does not request cover protection. Unprotected source pixels still obey the base-footprint exclusion. Missing limb/cap art is never extended or guessed. Base builds and uncovered, unexcluded sets keep their existing bytes. `skeleton` (a pose file path relative to the config's directory, `{"width": W, "height": H, "points": {…}}` with all 18 body-18 keypoints on the canvas, validated by the same rules as `generation.control.skeleton`: `CONFIG_FILE_PRESENT`, `CONFIG_IS_JSON`, `CONFIG_FIELD_TYPE`, `CONFIG_KEY_KNOWN`, `CONFIG_KEY_UNIQUE`, `CONFIG_POSE_CANVAS`, `CONFIG_POSE_KEYPOINTS`, `CONFIG_POSE_IN_CANVAS`) or `drawn` (`{<bone>: <worldDegrees>}`, each bone resolved against `bones`, `CONFIG_NAME_RESOLVES`) declares the posed angles the alternate was painted at (issue #465); when `skeleton` is given, `assemble` derives `drawn` from its 18 keypoints (filtered to covered bones on a subset set, with `drawn` overriding any bone named in both), records both `skeleton` (scaled by `assemble.rig_scale` to rig px) and `drawn` in `parts.json` `alternates` — and, when the skeleton placed the set's limb bones (issue #539), `drawnLength` (`{<bone>: <ratio>}`, each covered table bone's skeleton segment length over its base length, 6 places) and `registration` (`{offset: [dx, dy], scale}`: the similarity without rotation, about the canvas origin, that maps the skeleton's torso — the mean of its shoulders `r_shoulder`/`l_shoulder` to the mean of its hips `r_hip`/`l_hip`, read where the base declares the bone rooted there, `sleeve_*0`/`arm_*0` and `leg_*0` — onto those base bones' origins, 6 places), both also written to `sets.json`; the set's painting must be registered, its torso within 1 rig px and 1 % in scale of the base's, or the set is refused by name before any joint is cut (`ASSEMBLE_ALT_SKELETON_REGISTERED`, with the offset and scale the painting would need) — and the `rig` stage writes `<set>_*` region and mesh attachments in the bone-local frame of `poseRigBonesForDrawn(bones, drawn)` (weighting limb segments directly from `skeleton`'s joint endpoints when `skeleton` is present) and emits `drawn` in `sets.json` so `rig-pose` and `check --set <name>` / `--all-sets` pose the bones at `drawn` without rotating the already-posed art a second time. `front` (issue #664) lists covered limb segment parts the set draws on their **front slot** instead of the base slot: each must be in the set's `parts` and not its `hides`, and listed in some `config.limbs.<limb>.front.parts` (`CONFIG_SET_FRONT`); the set then maps `<part>_front` (never `<part>`) to `<set>_<part>` in `parts.json` and `sets.json` and hides `<part>` (and `<part>_still`), its #587 exclusion counts only base parts drawn after the front slot's target, and keying that part's set on a slot track is refused (`CONFIG_SET_FRONT_KEYED`) — *Front slots (issue #664)* below `layers` optionally maps covered, nonhidden output part names to layer names in their existing source run (issue #666); for example `"layers": {"upperarm_r": "author:raised", "forearm_r": "author:raised"}`. Limb cuts, caps, islands and base exclusions read the selected layer; the base plan stays unchanged. |
|
|
122
122
|
| `assemble.head_face_underfill` | assemble, `assemble --propose-plan` | **authored**, optional (issue #492): `boolean` (`CONFIG_FIELD_TYPE` on any other). When both runs exist and `full:face` has at least `200` opaque run px (`PROPOSE_MIN_PX`), the stage maps `full:face` into the head canvas via `fullLayerToHead` and measures how much of its hole-filled silhouette (`alpha > 8`) is covered by `head:face` (`alpha > 8`). A head-run `face` covering less than `75 %` (`HEAD_FACE_COVERAGE_FLOOR`, measured `0.0 %..48.6 %` on hollow/omitted head runs vs `84.8 %..99.6 %` on clean head runs across the 4 public examples and 9-standee corpus) is refused with `SEETHROUGH_HEAD_RUN_HOLLOW` unless `head_face_underfill: true` is declared. When `head_face_underfill: true` is declared and `head:face` exists (`opaqueRunPx >= 200`), `fullLayerToHead(full:face)` is composited under `head:face` on the head canvas (`alpha = max(headAlpha, fullAlpha)`, RGB blended by `headAlpha / 255`) before `head:face` is placed in rig space (`underfill=<N>px(full:face)` on the `face` part log line and a `face: head_face_underfill composited …` note in `assemble --propose-plan`). The fill restores transparent pixels only and leaves the cut's own edge where it is — a hollow whose edge is not under hair stays visible; follow [Source before scissors](#source-before-scissors) for its source remedy. When `head:face` has fewer than `200` opaque pixels, `SEETHROUGH_HEAD_RUN_HOLLOW` still fires even under `head_face_underfill: true` because the head run omitted the face layer and cannot supply a jawline |
|
|
123
123
|
| `assemble.yield_refused` | assemble | **authored**, optional boolean, default `false` (issue #819; other types: `CONFIG_FIELD_TYPE`). With `true`, only an original front projection-core pixel refused at max-channel drift > `DRIFT_LIMIT` (90) can yield: its original source pixel must still be opaque, and the actual top-most opaque holder below it (alpha >= `CORE_ALPHA`, 250), after earlier yields, must have colour within 90 of the painting. A deeper matching part cannot bypass an intervening opaque mismatch. This setting also applies to alternate sets. Its alpha becomes 0; RGB is not repainted. Merge/ring replacements are not original refused pixels. Front is relative to the matching part behind, so original refusals in another run are eligible even when a different part is the global top holder. The reading proceeds back to front against the current behind pixels; alpha changes do not repaint or create a new matching colour. `yieldReadings` records skipped merge/ring/nonopaque/no-match counts and original/final global holders; a yielded pixel also taken by projection closing is omitted from the written `source_px_taken` count. Existing cuts/patches and thresholds are unchanged. The `drift:` line reports `yielded N px` per affected part, even below the drift-body size bar; with the setting off it names `assemble.yield_refused: true` as the conditional remedy. Rebuild and inspect the newly exposed layer in motion before considering any cut; a mismatching or absent behind holder cannot be repaired by this setting. |
|
|
124
|
+
| `assemble.keep_under_occluders` | assemble | **authored**, optional array of unique source part names from `assemble.plan`, default `[]` (issue #825). For each named part, keep its own See-through RGBA where the front-most covering part in the final draw stack has alpha greater than 0 and below `CORE_ALPHA` (250), including a cover in the other run. For an explicitly named part, original alpha 1–8 fringe removed by ghost cleanup also counts as source cover, within its final piece region: those pixels still contributed to the painting. Only original opaque own-layer samples are restored after source projection/closing and protected from later seam re-take; merged/rim, cap, under-fill and authored replacement pixels are excluded. This keeps their existing provenance and makes no holes; fully opaque cover and absent cover keep the old path. Cuts and limb pieces inherit the source part's protection. Unknown names: `CONFIG_NAME_RESOLVES`; duplicates: `CONFIG_PART_UNIQUE`; invalid shape: `CONFIG_FIELD_TYPE`. Inspect the part's own amodal layer first: this handle preserves its existing content and cannot complete a missing pixel. Example: `"keep_under_occluders": ["shin_r"]`. |
|
|
124
125
|
| `limbs` | assemble (the cut and the caps), rig (through `regions`), check (`LIMB_ONE_BONE`) | **proposed** by `propose --keypoints` (issue #349), then checked: `{<limb>: {segments: [{part, from, bone}, {part, from, bone, joint, range?, cap?, edge?, cap_shape?, cut_band?, hybrid?}, …], split?, layout?, front?}}`, the upper segment first. The rule is *Limbs: cut at their joints* below the table. `part` is a new part (`CONFIG_PART_NAME`, `CONFIG_PART_UNIQUE` against the plan, patches, cuts and every other limb part); `from` a plan part or a cut's piece (`CONFIG_NAME_RESOLVES`) — the layer the segment's pixels come from, which is cut into the limb parts and is not itself made: a `meshes` or `regions` entry for it is `CONFIG_NAME_RESOLVES`, and the segments of one limb that take one part are consecutive (`CONFIG_LIMB_FROM_RUN`); `bone` a declared bone, each segment's the child of the one before (`CONFIG_LIMB_BONES_CHAIN`); the upper segment names no `joint`, `range`, `cap`, `edge`, `cap_shape` or `cut_band` (`CONFIG_KEY_KNOWN`), every other names its `joint` (`CONFIG_FIELD_PRESENT`) — the joint is its bone's origin; `range` is `[min, max]` degrees from rest with `min <= 0 <= max` (`CONFIG_LIMB_RANGE`), which `assemble` writes into the lower part's `parts.json` record and `check` poses the joint at — its two ends and its middle — reading the seam there (`SEAM_OVER_RANGE`, issue #360; rig-c needs no rotation limit); `cap` a PNG path relative to the config's directory (`CONFIG_FIELD_TYPE`); `edge` `"feather"` (issue #360), the one value it takes, and `cap_shape` `"disc"` (issue #360) or `"ball"` (issue #579) (`CONFIG_FIELD_TYPE` on any other) — the cut's geometry at that joint, *Limbs: cut at their joints* below; absent, the cut is hard and the cap the layer's half-disc of half the limb's width; `cut_band` a whole number of px, 1 or more (`CONFIG_FIELD_TYPE`), on a segment that takes the segment above's part — between two layers nothing is cut, so a band there is `CONFIG_KEY_KNOWN` — places the cut within that many px of the joint line (issue #360, *Limbs: cut at their joints*); absent, the cut is the joint line. Each limb part is attached like any part — `regions.<part>`, which must be the segment's `bone` (`CONFIG_LIMB_BONE`), or a mesh, which `check` holds to that bone alone (`LIMB_ONE_BONE`). With `limbs`, `assemble` reads `bones` too (`CONFIG_FIELD_PRESENT` without it). Absent, nothing is cut at a joint and no byte of any output moves. Optional `front` (issue #664), `{parts: [<own segment part>, …], after: "<produced part>"}`, gives each named segment a second slot `<part>_front`, empty at setup, placed after `after`'s slot family, for a gesture set to draw on (`CONFIG_FRONT_SCOPE`, `CONFIG_FRONT_NAME`, `CONFIG_FRONT_LAYOUT`; *Front slots (issue #664)* below) |
|
|
125
126
|
| `bones` | rig; `propose --from-config` | **proposed** by `propose`, then corrected. A single bone `{name, parent, at, tip?}` or a chain `{chain, parent, points, tip, link_inherit?}` whose links are named `<chain>0 … <chain>n`. Parents come before children. In `rig.json` each link is **turned along its chain** — `rotation` is the direction from its origin to the next link's (the last link's to `tip`) and `length` that distance (issue #73), so a physics constraint added downstream finds a lever; under `--idle-keys ctl` the link's `<link>_ctl` carries the turn and the length and the link sits at local rotation 0 beneath it. A single bone with a `tip` is turned the same way, towards its tip, with that distance as its length (issue #239); a tip on the bone's own origin is refused (`RIG_TIP_DISTINCT`). The five face bones `eye_r`, `eye_l`, `brow_r`, `brow_l` and `mouth` (by name, when one has no tip of its own) are turned to the eye line — the line through the origins of `eye_r` and `eye_l` as `bones` writes them, read image-left to image-right, drawn when parts.json holds both an `eyewhite-r` and an `eyewhite-l` part — so the blink's `scaley` squashes across the eye line on a tilted head; move an eye bone and the line moves with it. With one eyewhite or none, or an eye bone missing, the rig stage's `face axis:` line says so and they keep the page's axes. A bone under a face bone keeps the face's axis. With the line, `rig.json` also declares `invariants.axes` — one entry, `"face"`, through `eye_r` and `eye_l`, holding the turned face bones (and their controls) — so rig-c's gate holds the build to the line; the line is the one rigc draws through the same two bones, so the tolerance is only the float floor — the largest gap the emitted (float32) bones have to it, rounded up to 6 places plus one 6-place unit — and the `face axis:` line prints it. With the line the rig stage also adds `head_up` (issue #250): a slotless bone under `head`, at head's origin, turned to the eye line + 90 degrees and as long as the eye midpoint lies from head's origin along that axis — the head's frame, which a rule that needs it reads; `head` keeps its own frame, so no key on it or under it changes meaning, and a config bone of that name is refused (`RIG_HEAD_UP_NAME_FREE`). A face bone with its own `tip` is held too (issue #258), in a second entry, `"face_tips"`, when its tip lies along the line within whole-pixel rounding — a tip written in whole pixels sits at most sqrt(2)/2 px off the point of the line it stands for, so its direction may sit up to asin((sqrt(2)/2) / length) degrees off — with its tolerance measured the same way; a tip further off is the bone's own axis, and the `face axis:` line names it with its gap and that bound. A failure is rig-c's `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` line, which names the bone and the fix (rig-c's `docs/AUTHORING.md` §3.7 and §5.2); with no line no entry is written and A51 reports SKIP. Every region on a turned bone is turned back, so the setup pose does not move. Any other single bone keeps world rotation 0; one whose parent is a link is turned back upright. A single bone may declare `inherit` (issue #348): what it takes from its parent's transform, one of rig-c's own mode names exactly as rig-c's table spells them — `normal`, `onlyTranslation`, `noRotationOrReflection`, `noScale`, `noScaleOrReflection` (rig-c's `RigBone.inherit`) — written to that bone in `rig.json` as declared; any other spelling, `"NoScale"` included (the runtime would fold its first letter; nothing here folds or maps), is refused (`CONFIG_BONE_INHERIT`), and a chain takes no `inherit` (`CONFIG_KEY_KNOWN`). A chain may instead declare `link_inherit` (issue #369), an object mapping its own expanded link names to those same modes, for example `"link_inherit": {"leg_r1": "noScale"}`. Only the named link changes; omitted links inherit normally. A key outside this chain is `CONFIG_NAME_RESOLVES`, with this chain's valid link names in the detail; a non-object or non-string mode is `CONFIG_FIELD_TYPE`, and another mode spelling is `CONFIG_BONE_INHERIT`. The mode is forwarded to the expanded link, or to its control under `--idle-keys ctl`, by the same rule as a single bone. This is an authored opt-in: `propose` does not add it. Its setup rotation is the turn from where rig-c's own poser puts the bone with no rotation of its own under that mode, so it still stands upright (or along its tip) at the setup pose. Where the idle gives the bone a `<bone>_ctl`, the mode goes on the control: the control is the bone the parent's rotation reaches, and the bone under it inherits normally, so the idle's keys moved onto the control still turn it. A foot that does not take its shin's rotation (`noRotationOrReflection` or `onlyTranslation`) keeps its shoe level while the leg is posed (`FEET_STAY_LEVEL`, §7); which mode is the author's. `noScale` keeps a shin or shoe's shape under a scaled parent but still takes the parent's rotation: a shoe that must also stay level needs a separate rotation constraint, for example a transform constraint from an unrotated bone. This does not change the proposed feet's existing inheritance or `FEET_STAY_LEVEL` default. An automatic mesh weighted through a bone with a mode other than `normal` gets no derived amplitude (its row names the bone) and no residual envelope (rig-c's `SKINNING_RANGE_UNSUPPORTED`). Absent, `rig.json` carries no `inherit` and no byte moves |
|
|
126
127
|
| `meshes.<part>` | rig | **proposed**, then corrected. Generated modes below; `explicit` transfers an accepted mesh (see “An accepted mesh”). Exactly one mode is allowed. `grid` (the lattice: cell size, px — what `propose` writes: by default one spacing for the figure, round(H / 37) rig px with H the height of every part's box together, a part whose tenth of its short side is already coarser keeping that, and under `propose --grid-rule part` the reference's tenth of the part's short side clamped to 5..36, issue #265), `contour` (the outline mode, authored; the next row and "A contour mesh" below) or `auto` (the automatic mode, authored; "An automatic mesh" below) — two, or none, is `CONFIG_MESH_MODE`. In distance-generated modes: `r` (added to every distance before weighting: `w = 1/(d + r)²`), `segments` (a chain name, a bone name with a `tip`, or `[bone, [x0,y0], [x1,y1]]`), and, **authored**, optional, `exponent` (issue #161): a number above 0 that replaces the 2 in that rule, `w = 1/(d + r)^exponent` — a larger one hands each vertex more fully to its nearest bone; absent is 2 and changes no byte, a value that is not a number above 0 is `CONFIG_FIELD_TYPE` at `meshes.<part>.exponent`, and a declared one is echoed as `exponent` in the mesh's `mesh_report.json` row; the exponent is the distance rule's, so declared beside `rule: "heat"` it is refused (`CONFIG_FIELD_TYPE` at `meshes.<part>.exponent`). The segment list is the one authored decision about a layer: which bones may pull it. The slot's bone is the first segment's. Optional `rule` (issue #161): `"distance"` — the rule above, and what an absent `rule` means (every byte is the same) — or `"heat"`, bone heat over the part's own silhouette, in every mode: the lattice's art (alpha above 8, less any island a declared `stray` left out) with holes filled, or for `contour` and `auto` the silhouette their outline is traced from (that mode's art — alpha above 8 for `contour`, alpha 1 and above for `auto` — less any island a declared `stray` left out, holes filled, grown by its `margin`); each bound bone's sources are the silhouette pixels whose centre lies within 0.75 px of its segment (a pixel two bones reach goes to the nearer, a tie to the first listed), held at 1 for that bone and 0 for the others; the field is solved by row-major Gauss–Seidel over-relaxation to a sweep change under 1e-7 within 20000 sweeps; a vertex reads the nearest silhouette pixel and takes the same cap and floor (and `regions`) as the distance rule; `r` is not read. The mesh's `mesh_report.json` row gains `heat` (the constants, each bone's sources and sweeps, the residual). Any other value is `CONFIG_FIELD_TYPE`; a bound bone with no source pixel is `RIG_HEAT_BONE_SOURCE`, an island no bone has a source in `RIG_HEAT_ISLAND_SOURCE`, a solve past its sweep bound `RIG_HEAT_CONVERGED` — none is weighted 0. The rule is chosen per mesh, so `RIG_HEAT_BONE_SOURCE` names three remedies: move the segment onto the art, take the bone out of `segments`, or keep this part on the distance rule — the one to take when the bone anchors the part from outside its art (a torso bone under a neck), since the first moves a bone another part placed and the second drops the anchor (issue #303). On a lattice, `RIG_HEAT_ISLAND_SOURCE` on an island that is dust — at most 4 px and at most 0.1 % of the part's art px, `mesh-draft`'s line — names `meshes.<part>.stray` and the figure that leaves it out, with the value declared; an island above that line keeps the segment remedy, a real piece being a real authoring question. In every mode the mesh's `mesh_report.json` row's `bones` names the bones the emitted model binds for the attachment — the sorted set its weights in the written `rig.json` name, so a `turn` twin (`turn_<part>_<bone>`) or a stopped link's `<link>_d` where those rebind it, never the segment bone it stands for — the same list in every mode, `remesh --delta`'s included (issue #536) Optional `gather: false` keeps an exclusively bound non-segment limb rider at its authored draw position (issue #680); its binding and mesh geometry are unchanged. Remove the key for normal gathering; a non-rider is refused as `RIG_GATHER_RIDER`. |
|
|
@@ -3385,6 +3386,16 @@ crop or settings if needed. Next measure the declared depth/turn role and, for a
|
|
|
3385
3386
|
`drift:` line with a matching opaque behind holder, opt in with
|
|
3386
3387
|
`assemble.yield_refused: true`. This config step only clears the original refused
|
|
3387
3388
|
front alpha under the conditions in the field table; it does not paint a repair.
|
|
3389
|
+
For a moving part under a soft hem, inspect its own amodal See-through layer;
|
|
3390
|
+
if the painting's blended hem is re-taken onto otherwise complete hidden art,
|
|
3391
|
+
set `assemble.keep_under_occluders: ["<source-part>"]` at this config step.
|
|
3392
|
+
An accepted re-take need not produce a drift refusal or an uncovered hole.
|
|
3393
|
+
The informational `drift: "<part>" source re-take changed N own-layer px`
|
|
3394
|
+
line reads actual final soft front support and original own samples. It names
|
|
3395
|
+
`assemble.keep_under_occluders` as the remedy and, when enabled, reports the
|
|
3396
|
+
kept count. This is a source/layer disagreement reading, not a visual defect
|
|
3397
|
+
gate; verify the own layer and the rendered motion before choosing the handle.
|
|
3398
|
+
`yield_refused` addresses a different ownership case.
|
|
3388
3399
|
Rebuild and inspect setup and the animation extremes. A polygon cut separates a
|
|
3389
3400
|
piece that must move or change draw order independently; it is not the next way
|
|
3390
3401
|
to conceal a visual defect. Measure those source/config alternatives before
|
package/docs/STABILITY.md
CHANGED
|
@@ -126,7 +126,7 @@ part name, `[i]` an entry of a list):
|
|
|
126
126
|
- **generation.control.skeleton**, the pose file it names when it is no built-in — required: `width`, `height`, `points`
|
|
127
127
|
- **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
|
|
128
128
|
- **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
|
|
129
|
-
- **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`
|
|
129
|
+
- **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`
|
|
130
130
|
- **assemble.alternates[i]** — required: `name`; optional: `source`, `full`, `head`, `parts`, `hides`, `head_box`, `cuts`, `skeleton`, `anchor`, `base_anchor`, `drawn`, `tips`, `front`, `layers`, `focus` (issues #454, #465, #543, #652, #664, #666, #719, #780). `focus` is an authored finite `[x, y]` in painting pixels, within its canvas (edges included), refused as CONFIG_SET_FOCUS before assemble output when invalid; only when present, `parts.json` records it with focus_source_size and `sets.json` emits it in setup-world coordinates through the actual rig resize and `src/coords.ts`. Absent focus emits no field or inferred value; changing/removing it requires reassembly
|
|
131
131
|
- **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
|
|
132
132
|
- **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.78.0",
|
|
4
4
|
"description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/assemble.ts
CHANGED
|
@@ -243,6 +243,8 @@ export interface Geometry {
|
|
|
243
243
|
headFaceUnderfill?: boolean;
|
|
244
244
|
/** `assemble.yield_refused`, default false; alpha-only ownership transfer from original refused core pixels. */
|
|
245
245
|
yieldRefused?: boolean;
|
|
246
|
+
/** Plan parts whose own samples survive source re-take beneath soft front cover. */
|
|
247
|
+
keepUnderOccluders?: readonly string[];
|
|
246
248
|
}
|
|
247
249
|
|
|
248
250
|
/**
|
|
@@ -362,6 +364,8 @@ export interface AssembleInput {
|
|
|
362
364
|
headFaceUnderfill?: boolean;
|
|
363
365
|
/** `assemble.yield_refused`, default false; alpha-only ownership transfer from original refused core pixels. */
|
|
364
366
|
yieldRefused?: boolean;
|
|
367
|
+
/** Plan parts whose own samples survive source re-take beneath soft front cover. */
|
|
368
|
+
keepUnderOccluders?: readonly string[];
|
|
365
369
|
/**
|
|
366
370
|
* Issue #587: rig pixels taken out of named parts once the cuts and the limbs are made, before the fringe pass and
|
|
367
371
|
* every figure a part's record holds — each entry clears `part`'s pixels (alpha, colour and region) where `mask` is
|
|
@@ -4386,6 +4390,8 @@ export interface DriftYieldReading {
|
|
|
4386
4390
|
yielded: number;
|
|
4387
4391
|
/** Yielded refusals also accepted by projection closing; removed from the written projected count. */
|
|
4388
4392
|
yieldedTaken: number;
|
|
4393
|
+
/** Original refused pixels explicitly kept from their own layer (#825). */
|
|
4394
|
+
preserved?: number;
|
|
4389
4395
|
skipped: { merge: number; ring: number; notOpaque: number; noMatchingBehind: number };
|
|
4390
4396
|
/** Informational only: no fixed global-holder eligibility guard is used. */
|
|
4391
4397
|
originalGlobalHolderWouldSkip: number;
|
|
@@ -4393,7 +4399,24 @@ export interface DriftYieldReading {
|
|
|
4393
4399
|
finalHolders: Array<{ part: string | null; px: number }>;
|
|
4394
4400
|
}
|
|
4395
4401
|
|
|
4402
|
+
export interface SoftOcclusionReading {
|
|
4403
|
+
part: string;
|
|
4404
|
+
/** Config accepts original plan names, not derived cut/limb output names. */
|
|
4405
|
+
sourcePart?: string;
|
|
4406
|
+
from: string;
|
|
4407
|
+
changed: number;
|
|
4408
|
+
kept: number;
|
|
4409
|
+
front: Array<{ part: string; px: number }>;
|
|
4410
|
+
}
|
|
4411
|
+
|
|
4412
|
+
/** Informational source/layer disagreement under actual soft front cover; no gate. */
|
|
4413
|
+
export function softOcclusionLine(r: SoftOcclusionReading): string {
|
|
4414
|
+
return `drift: "${r.part}" source re-take changed ${r.changed} own-layer px of ${r.from} under soft front cover (${r.front.map(q => `"${q.part}" ${q.px} px`).join(', ')}; alpha > 0 and < ${CORE_ALPHA}); ${r.kept > 0 ? `kept ${r.kept} original own-layer px (assemble.keep_under_occluders)` : `inspect its own amodal layer and motion extremes; to retain those samples ${r.sourcePart === undefined ? `resolve ${r.from} to its original assemble.plan part before setting assemble.keep_under_occluders` : `set assemble.keep_under_occluders: ["${r.sourcePart}"]`}`}; informational, not a drift-refusal or uncovered-hole gate`;
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4396
4417
|
export interface AssembleResult {
|
|
4418
|
+
/** Report-only source re-take under actual soft front support (#825). */
|
|
4419
|
+
softOcclusion?: SoftOcclusionReading[];
|
|
4397
4420
|
parts: PartsFile;
|
|
4398
4421
|
/** In plan order, each cropped to its record's box. */
|
|
4399
4422
|
images: PlacedPart[];
|
|
@@ -4662,6 +4685,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
4662
4685
|
const patchIds = patches.map((_, i) => editId(`patches[${i}]`));
|
|
4663
4686
|
const layerWriters = new Map<string, Int32Array>();
|
|
4664
4687
|
const valueBaselines = new Map<string, ValueBaseline | null>();
|
|
4688
|
+
const cleanedSoftCover = new Map<string, Mask>();
|
|
4665
4689
|
// Extend-only rasters are still projected/merged, but are never emitted as own sources.
|
|
4666
4690
|
const readKeys = new Set(sourcePlan.map(([, run, tag]) => `${run}:${tag}`));
|
|
4667
4691
|
const rig = new Map<string, Raster>();
|
|
@@ -4735,6 +4759,17 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
4735
4759
|
}
|
|
4736
4760
|
// Keep only samples that could equal a final opaque pixel; full raw and
|
|
4737
4761
|
// prepared normalization temporaries end with this layer's callback.
|
|
4762
|
+
// A low-alpha source fringe removed by cleanGhosts still contributed to
|
|
4763
|
+
// the painting. Retain its source footprint only for explicit opt-in;
|
|
4764
|
+
// final piece regions below still decide which side of a cut owns it.
|
|
4765
|
+
if ((input.keepUnderOccluders?.length ?? 0) > 0 && raw !== null && preparedWriter === 0) {
|
|
4766
|
+
let footprint: Mask | null = null;
|
|
4767
|
+
for (let p = 0; p < W * H; p++) if (raw.data[p * 4 + 3] > 0 && raw.data[p * 4 + 3] <= OPAQUE_ALPHA_ABOVE && rigLayers[i].data[p * 4 + 3] === 0) {
|
|
4768
|
+
footprint ??= newMask(W, H);
|
|
4769
|
+
footprint.data[p] = 1;
|
|
4770
|
+
}
|
|
4771
|
+
if (footprint !== null) cleanedSoftCover.set(key, footprint);
|
|
4772
|
+
}
|
|
4738
4773
|
valueBaselines.set(key, raw === null ? null : compactValueBaseline(raw));
|
|
4739
4774
|
return ids;
|
|
4740
4775
|
});
|
|
@@ -5170,7 +5205,66 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5170
5205
|
return { record: whole, image: b.r };
|
|
5171
5206
|
});
|
|
5172
5207
|
const featheredInOrder: Array<Mask | null> = order.map((d) => ('patch' in d ? null : (pieceAt(d).b.feathered ?? null)));
|
|
5173
|
-
|
|
5208
|
+
// #825: decide ownership on the final draw stack, after merges, cuts,
|
|
5209
|
+
// limb splits and exclusions. Restore only original own-layer samples:
|
|
5210
|
+
// no merge/ring, under-fill, cap, patch or authored replacement is borrowed.
|
|
5211
|
+
const wanted = new Set(input.keepUnderOccluders ?? []);
|
|
5212
|
+
refuseIfAny([...wanted].filter(name => !input.plan.some(([n]) => n === name)).map(name => ({ code: 'CONFIG_NAME_RESOLVES', object: 'config.assemble.keep_under_occluders', detail: `names "${name}"; a source part in assemble.plan is required` })));
|
|
5213
|
+
const sourceKeys = new Set(input.plan.filter(([name]) => wanted.has(name)).map(([, run, tag]) => `${run}:${tag}`));
|
|
5214
|
+
const keptRetake: Array<Mask | null> = order.map(() => null);
|
|
5215
|
+
const softOcclusion: SoftOcclusionReading[] = [];
|
|
5216
|
+
const keepInOrder = order.map((d, i): Mask | null => {
|
|
5217
|
+
const limb = 'limb' in d ? limbKeep[d.limb] : null;
|
|
5218
|
+
if ('patch' in d) return limb;
|
|
5219
|
+
const b = pieceAt(d).b, baseline = valueBaselines.get(b.key);
|
|
5220
|
+
if (baseline === undefined || baseline === null) return limb;
|
|
5221
|
+
const enabled = sourceKeys.has(b.key);
|
|
5222
|
+
const mask = enabled ? newMask(W, H) : limb;
|
|
5223
|
+
if (enabled && limb !== null) mask!.data.set(limb.data);
|
|
5224
|
+
const restored = enabled ? newMask(W, H) : null;
|
|
5225
|
+
const sourcePart = input.plan.find(([, run, tag]) => `${run}:${tag}` === b.key)?.[0];
|
|
5226
|
+
const reading: SoftOcclusionReading = { part: pieceAt(d).name, ...(sourcePart === undefined ? {} : { sourcePart }), from: b.key, changed: 0, kept: 0, front: [] };
|
|
5227
|
+
const holders = new Map<string, number>();
|
|
5228
|
+
for (let by = 0; by < baseline.image.height; by++) for (let bx = 0; bx < baseline.image.width; bx++) {
|
|
5229
|
+
const p = (baseline.y + by) * W + baseline.x + bx;
|
|
5230
|
+
if (b.trace.from[p] >= 0 || b.trace.ring.data[p] !== 0 || (b.region !== null && b.region.data[p] === 0)) continue;
|
|
5231
|
+
const x = p % W - baseline.x, y = Math.floor(p / W) - baseline.y;
|
|
5232
|
+
if (x < 0 || y < 0 || x >= baseline.image.width || y >= baseline.image.height) continue;
|
|
5233
|
+
const q = (y * baseline.image.width + x) * 4, raw = baseline.image.data;
|
|
5234
|
+
if (raw[q + 3] < CORE_ALPHA || b.r.data[p * 4 + 3] !== raw[q + 3]) continue;
|
|
5235
|
+
const writer = b.writers?.[p] ?? 0;
|
|
5236
|
+
if (!enabled && (writer !== AW.sourceProjection || rgbaEqual(b.r.data, p * 4, raw, q))) continue;
|
|
5237
|
+
if (writer !== AW.sourceProjection && !rgbaEqual(b.r.data, p * 4, raw, q)) continue;
|
|
5238
|
+
for (let j = stack.length - 1; j > i; j--) {
|
|
5239
|
+
let alpha = stack[j].image.data[p * 4 + 3];
|
|
5240
|
+
const frontSlot = order[j];
|
|
5241
|
+
if (alpha === 0 && enabled && !('patch' in frontSlot)) {
|
|
5242
|
+
const cover = pieceAt(frontSlot).b;
|
|
5243
|
+
if ((cover.region === null || cover.region.data[p] === 1) && cover.trace.from[p] < 0 && cover.trace.ring.data[p] === 0 && cleanedSoftCover.get(cover.key)?.data[p] === 1) alpha = OPAQUE_ALPHA_ABOVE;
|
|
5244
|
+
}
|
|
5245
|
+
if (alpha === 0) continue;
|
|
5246
|
+
if (alpha < CORE_ALPHA) {
|
|
5247
|
+
const different = !rgbaEqual(b.r.data, p * 4, raw, q);
|
|
5248
|
+
if (different) reading.changed++;
|
|
5249
|
+
if (different || enabled) holders.set(stack[j].record.name, (holders.get(stack[j].record.name) ?? 0) + 1);
|
|
5250
|
+
if (enabled) {
|
|
5251
|
+
b.r.data.set(raw.subarray(q, q + 4), p * 4);
|
|
5252
|
+
mask!.data[p] = 1;
|
|
5253
|
+
restored!.data[p] = 1;
|
|
5254
|
+
reading.kept++;
|
|
5255
|
+
}
|
|
5256
|
+
}
|
|
5257
|
+
break;
|
|
5258
|
+
}
|
|
5259
|
+
}
|
|
5260
|
+
keptRetake[i] = restored;
|
|
5261
|
+
if (reading.changed > 0 || reading.kept > 0) {
|
|
5262
|
+
reading.front = [...holders].map(([part, px]) => ({ part, px }));
|
|
5263
|
+
softOcclusion.push(reading);
|
|
5264
|
+
}
|
|
5265
|
+
return mask;
|
|
5266
|
+
});
|
|
5267
|
+
const retakeByName = new Map(order.flatMap((d, i) => 'patch' in d ? [] : [[pieceAt(d).name, keptRetake[i]] as const]));
|
|
5174
5268
|
const patchWriters = patches.map((_, i) => new Int32Array(W * H).fill(patchIds[i]));
|
|
5175
5269
|
const writersInOrder = order.map(d => 'patch' in d ? patchWriters[d.patch] : pieceAt(d).b.writers as Int32Array);
|
|
5176
5270
|
const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null, featheredInOrder, keepInOrder, input.turnMovingPair ?? null, (i, p) => { writersInOrder[i][p] = AW.fringeRestore; });
|
|
@@ -5227,6 +5321,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5227
5321
|
for (let p = 0; p < W * H; p++) {
|
|
5228
5322
|
if (refused.data[p] !== 1) continue;
|
|
5229
5323
|
reading.refusedCandidates++;
|
|
5324
|
+
if (keptRetake[orderIdx]?.data[p] === 1) { reading.preserved = (reading.preserved ?? 0) + 1; continue; }
|
|
5230
5325
|
if (b.trace.from[p] >= 0) { reading.skipped.merge++; continue; }
|
|
5231
5326
|
if (b.trace.ring.data[p] !== 0) { reading.skipped.ring++; continue; }
|
|
5232
5327
|
if (b.r.data[p * 4 + 3] < CORE_ALPHA) { reading.skipped.notOpaque++; continue; }
|
|
@@ -5293,7 +5388,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5293
5388
|
const own = e < 0 ? st : (stats.get(extendKeys[e]) as ProjectionStats);
|
|
5294
5389
|
// A pixel outside the piece's region is a base pixel filled from a cut's under image (issue #193): occluded, never visible.
|
|
5295
5390
|
if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE && own.clear.data[p] === 1 && (region === null || region.data[p] === 1)) visible.data[p] = 1;
|
|
5296
|
-
if (yields.get(name)?.mask.data[p] !== 1 && trace.ring.data[p] === 0 && own.takenMask.data[p] === 1 && (region === null || region.data[p] === 1)) projected.data[p] = 1;
|
|
5391
|
+
if (retakeByName.get(name)?.data[p] !== 1 && yields.get(name)?.mask.data[p] !== 1 && trace.ring.data[p] === 0 && own.takenMask.data[p] === 1 && (region === null || region.data[p] === 1)) projected.data[p] = 1;
|
|
5297
5392
|
}
|
|
5298
5393
|
const { x0, y0, x1, y1, opaque } = measureBox(r);
|
|
5299
5394
|
if (opaque === 0) {
|
|
@@ -5318,7 +5413,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5318
5413
|
visible_px: vc.visible_px,
|
|
5319
5414
|
occluded_px: vc.occluded_px,
|
|
5320
5415
|
projected_core_px: region === null ? st.core : count(st.coreMask, region),
|
|
5321
|
-
source_px_taken: (region === null ? st.taken : count(st.takenMask, region)) - (yields.get(name)?.reading.yieldedTaken ?? 0),
|
|
5416
|
+
source_px_taken: (region === null ? st.taken : count(st.takenMask, region)) - (yields.get(name)?.reading.yieldedTaken ?? 0) - (retakeByName.get(name) == null ? 0 : count(st.takenMask, retakeByName.get(name)!)),
|
|
5322
5417
|
visible_not_projected_px: vc.visible_not_projected_px,
|
|
5323
5418
|
refused_drift_px: region === null ? st.refused : count(st.refusedMask, region),
|
|
5324
5419
|
merged_px: merged,
|
|
@@ -5383,6 +5478,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5383
5478
|
bodyMinPx(frame),
|
|
5384
5479
|
);
|
|
5385
5480
|
return {
|
|
5481
|
+
...(softOcclusion.length === 0 ? {} : { softOcclusion }),
|
|
5386
5482
|
parts: {
|
|
5387
5483
|
rig_size: [W, H],
|
|
5388
5484
|
scale_rig_per_source: frame.S,
|
|
@@ -6230,6 +6326,8 @@ export function stageFields(
|
|
|
6230
6326
|
headFaceUnderfill?: boolean;
|
|
6231
6327
|
/** `assemble.yield_refused`, default false; alpha-only ownership transfer from original refused core pixels. */
|
|
6232
6328
|
yieldRefused?: boolean;
|
|
6329
|
+
/** Plan parts whose own samples survive source re-take beneath soft front cover. */
|
|
6330
|
+
keepUnderOccluders?: readonly string[];
|
|
6233
6331
|
turnMovingPair?: (a: string, b: string) => boolean;
|
|
6234
6332
|
} {
|
|
6235
6333
|
const problems: Problem[] = [];
|
|
@@ -6251,6 +6349,7 @@ export function stageFields(
|
|
|
6251
6349
|
...(keySkeleton === undefined ? {} : { keySkeleton }),
|
|
6252
6350
|
...(cfg.assemble.head_face_underfill === undefined ? {} : { headFaceUnderfill: cfg.assemble.head_face_underfill }),
|
|
6253
6351
|
...(cfg.assemble.yield_refused === undefined ? {} : { yieldRefused: cfg.assemble.yield_refused }),
|
|
6352
|
+
...(cfg.assemble.keep_under_occluders === undefined ? {} : { keepUnderOccluders: cfg.assemble.keep_under_occluders }),
|
|
6254
6353
|
...(turnMovingPair === null ? {} : { turnMovingPair }),
|
|
6255
6354
|
rigScale: cfg.assemble.rig_scale,
|
|
6256
6355
|
plan: cfg.assemble.plan,
|
package/src/build.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { createHash } from 'node:crypto';
|
|
|
49
49
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
50
50
|
import { dirname, join, resolve } from 'node:path';
|
|
51
51
|
import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
|
|
52
|
-
import { assemble, type AssembleResult, cutLines, type ExcludedCount, excludedLines, setExclusions, islandLines, setIslandSources, type IslandCount, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, measureRecomposite, type ProjectRule, recomposite, recompositeRecord, scenePhrase, type SeamRule, sourceInRig, stageFields, uncoveredLine } from './assemble.ts';
|
|
52
|
+
import { assemble, type AssembleResult, cutLines, type ExcludedCount, excludedLines, setExclusions, islandLines, setIslandSources, type IslandCount, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, softOcclusionLine, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, measureRecomposite, type ProjectRule, recomposite, recompositeRecord, scenePhrase, type SeamRule, sourceInRig, stageFields, uncoveredLine } from './assemble.ts';
|
|
53
53
|
import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
|
|
54
54
|
import { residualClause } from './autoenvelope.ts';
|
|
55
55
|
import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, prepareComparison, type ReplayCandidate, runComparison, runPreparedComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
|
|
@@ -646,6 +646,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
646
646
|
capOwnership: input.setCapOwnership ?? 'target',
|
|
647
647
|
full: altRuns.full,
|
|
648
648
|
head: altRuns.head,
|
|
649
|
+
...(fields.keepUnderOccluders === undefined ? {} : { keepUnderOccluders: fields.keepUnderOccluders.filter(name => altPlanNames.has(name)) }),
|
|
649
650
|
resolution: fields.resolution,
|
|
650
651
|
headBox: altHeadBox,
|
|
651
652
|
rigScale: fields.rigScale,
|
|
@@ -811,6 +812,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
811
812
|
if (result.figures.uncoveredErrorPx > 0) log(holeKindsLine(result.holeKinds, scene));
|
|
812
813
|
for (const u of result.uncovered) log(uncoveredLine(u, scene));
|
|
813
814
|
for (const d of result.drift) log(driftLine(d));
|
|
815
|
+
for (const d of result.softOcclusion ?? []) log(softOcclusionLine(d));
|
|
814
816
|
// Issue #588: per subset set, one line per limb part cut from a layer of more than one island, then (issue #587) one
|
|
815
817
|
// per limb part and base part over it that took a pixel. None otherwise.
|
|
816
818
|
// Issue #609: then one per limb part cut from a layer of one island that also holds a limb the set does not carry.
|
package/src/config.ts
CHANGED
|
@@ -309,6 +309,8 @@ export interface Assemble {
|
|
|
309
309
|
head_face_underfill?: boolean;
|
|
310
310
|
/** Opt-in: yield original drift-refused front pixels to an opaque behind holder matching the painting. */
|
|
311
311
|
yield_refused?: boolean;
|
|
312
|
+
/** Named plan parts retain their own pixels beneath a drawn-in-front soft occluder. */
|
|
313
|
+
keep_under_occluders?: string[];
|
|
312
314
|
}
|
|
313
315
|
|
|
314
316
|
export interface SingleBone {
|
|
@@ -1614,7 +1616,7 @@ export interface EarlyConfig {
|
|
|
1614
1616
|
depth?: DepthConfig;
|
|
1615
1617
|
key: string;
|
|
1616
1618
|
seethrough: SeeThrough;
|
|
1617
|
-
assemble: { rig_scale: number; head_face_underfill?: boolean; yield_refused?: boolean };
|
|
1619
|
+
assemble: { rig_scale: number; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[] };
|
|
1618
1620
|
}
|
|
1619
1621
|
|
|
1620
1622
|
/** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
|
|
@@ -1683,12 +1685,13 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1683
1685
|
if (door === 'layers') {
|
|
1684
1686
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1685
1687
|
if ('assemble' in t) {
|
|
1686
|
-
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused']);
|
|
1688
|
+
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders']);
|
|
1687
1689
|
if (a !== null) {
|
|
1688
1690
|
if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
|
|
1689
1691
|
if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
|
|
1690
1692
|
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.head_face_underfill', `is ${show(a.head_face_underfill)}; true or false is required`);
|
|
1691
1693
|
}
|
|
1694
|
+
checkKeepUnderOccluders(c, a, null);
|
|
1692
1695
|
if ('yield_refused' in a && typeof a.yield_refused !== 'boolean') {
|
|
1693
1696
|
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.yield_refused', `is ${show(a.yield_refused)}; true or false is required`);
|
|
1694
1697
|
}
|
|
@@ -2396,9 +2399,23 @@ function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
|
|
|
2396
2399
|
* out of the generic presence check so that its absence says which command
|
|
2397
2400
|
* writes one — the step a config without a plan has most likely skipped.
|
|
2398
2401
|
*/
|
|
2402
|
+
/** Validate at the early door too; resolve against the plan once that door reads it. */
|
|
2403
|
+
function checkKeepUnderOccluders(c: Check, a: Record<string, Json>, plan: readonly string[] | null): void {
|
|
2404
|
+
if (!('keep_under_occluders' in a)) return;
|
|
2405
|
+
const at = 'config.assemble.keep_under_occluders';
|
|
2406
|
+
if (!c.array(at, a.keep_under_occluders, false)) return;
|
|
2407
|
+
const seen = new Set<string>();
|
|
2408
|
+
(a.keep_under_occluders as Json[]).forEach((name, i) => {
|
|
2409
|
+
if (!c.string(`${at}[${i}]`, name)) return;
|
|
2410
|
+
if (seen.has(name)) c.fail('CONFIG_PART_UNIQUE', `${at}[${i}]`, `names "${name}" again; each part is named once`);
|
|
2411
|
+
seen.add(name);
|
|
2412
|
+
if (plan !== null && !plan.includes(name)) c.fail('CONFIG_NAME_RESOLVES', `${at}[${i}]`, `names "${name}"; a part named in assemble.plan is required — name the source part before cuts or limb splitting`);
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2399
2416
|
function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
|
|
2400
2417
|
const required = sectionRequired(door, 'assemble');
|
|
2401
|
-
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused']);
|
|
2418
|
+
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders']);
|
|
2402
2419
|
if (a === null) return { parts: [], patches: [], cuts: [] };
|
|
2403
2420
|
if (required.includes('plan') && !('plan' in a)) {
|
|
2404
2421
|
c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
|
|
@@ -2432,6 +2449,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
|
|
|
2432
2449
|
parts.push(name);
|
|
2433
2450
|
});
|
|
2434
2451
|
}
|
|
2452
|
+
checkKeepUnderOccluders(c, a, parts);
|
|
2435
2453
|
if ('extend_below_crop' in a && c.array(`${p}.extend_below_crop`, a.extend_below_crop, false)) {
|
|
2436
2454
|
(a.extend_below_crop as Json[]).forEach((entry, i) => {
|
|
2437
2455
|
const at = `${p}.extend_below_crop[${i}]`;
|