rig-parts 2.6.0 → 2.7.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 +22 -0
- package/docs/STABILITY.md +7 -1
- package/package.json +1 -1
- package/src/assemble.ts +53 -2
- package/src/build.ts +7 -1
- package/src/config.ts +9 -4
- package/src/paintingedges.ts +70 -4
- package/src/projectrule.ts +15 -0
package/docs/AUTHORING.md
CHANGED
|
@@ -149,6 +149,7 @@ read by no CPU stage).
|
|
|
149
149
|
| `seethrough.arm_split` | assemble, `assemble --propose-plan` — separates a single full-run `handwear-r` or `handwear-l` layer that holds both overlapping arms into both arm tags | **authored**, optional (issue #439): `{x?: number, line?: [[x, y], …], order: ["handwear-r", "handwear-l"] \| ["handwear-l", "handwear-r"]}` in `seethrough.resolution` full-run pixels. Exactly one of `x` (image column, `0 <= x <= resolution`) or `line` (a polyline of two or more `[x, y]` points in `[0, resolution]`, interpolated in x along y and clamped above/below its endpoints) is required (`CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE`), and `order` is `[back_tag, front_tag]`, the draw order of the two separated arm layers (`CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE`). Pixels at `< splitX(y)` go to `handwear-r` (image left, the subject's right arm) and pixels at `>= splitX(y)` go to `handwear-l` (image right, the subject's left arm). A split line that cuts a horizontal opaque run of the arm layer into more than two segments is refused (`ASSEMBLE_ARM_SPLIT_CUTS_ARM`). Without the declaration, when one arm layer's 8-connected component reaches both wrists under `propose --keypoints`, `propose` emits an `overlapping-arms` `LINT` naming the part, both wrists and `seethrough.arm_split`; absent, no byte of any output moves |
|
|
150
150
|
| `assemble.seam` | assemble, build | **authored**, optional `"near-white"` or `"silhouette"` (issue #905). An explicit `--seam` overrides the config; otherwise this field overrides the existing `near-white` default. An invalid value is `CONFIG_FIELD_TYPE` at `config.assemble.seam`, including when a CLI override is given. Absent, existing build bytes are unchanged. |
|
|
151
151
|
| `assemble.project` | assemble, build | **authored**, optional `"core"` or `"visible"` (issue #953): the projection rule for this character. An explicit `--project` overrides the config; otherwise this field overrides the existing `core` default. An invalid value is `CONFIG_FIELD_TYPE` at `config.assemble.project`, including when a CLI override is given. Absent, existing build bytes are unchanged. |
|
|
152
|
+
| `assemble.ground` | assemble, build | **authored**, optional `"plain"` or `"busy"` (issue #996): what lies behind the figure in the painting. `plain` (the default) is a ground of one colour; `busy` is an opaque ground of many colours (a wall, a banner). Read by `--project visible` only: a busy, opaque ground needs `assemble.ground: busy`; without it, visible takes backdrop pixels. An invalid value is `CONFIG_FIELD_TYPE` at `config.assemble.ground`. Absent or `plain`, existing build bytes are unchanged. `assemble` warns (`ASSEMBLE_GROUND_PLAIN`, §5), under project: visible, where paintingEdges reads the ground, when a plain ground's border ring is not one colour. |
|
|
152
153
|
| `assemble.rig_scale` | assemble — the rig is `trunc(w × rig_scale)` x `trunc(h × rig_scale)`, and every rig-px value after it (`bones`, `meshes`, `motion`, `patches`, `cuts`) is in that rig's pixels; `check --source` (`CHECK_SOURCE_SIZE`) and `compose` (`SCENE_RIG_SCALE_AGREES`) read it back off `parts.json` | authored; rig pixels per source pixel. 0.5 in both examples (a 1664x2432 painting makes an 832x1216 rig). **Keep the figure about 1,100 to 1,200 rig px tall** — the `H` of `propose`'s `mesh spacing:` note, the height of the parts' boxes together — so for another painting, about 1,150 over the figure's height in painting px: that is the size the proposer's spacing divisor was calibrated at (H 1106–1185, the `mesh spacing` note says so) and the size of the two examples `check`'s bars are derived from, and the proposer and `check` also hold fixed rig-px values that do not scale with it (the fringe's 10 px step, the trunk segments' 18, 20, 30 and 100 px, the 15 px wrist tolerance, the 10-row pendant floor, the seam bar's 50 rig px over 40). What else it trades, measured on `demo` with the README loop from a fresh config at 0.25, 0.5 and 1 (issue #345, rig-c 2.47.1): the same plan and the same ten chains; every bar but the one the uncorrected `demo` always fails (`bottomwear` `TIP_OVER_ROOT`, the README loop's `RL01`) passing at all three; the atlas page 418x606, 832x1205 and 1825x2047; the figure's mesh spacing 16, 32 and 64, so 830, 868 and 932 triangles; the breath 2.5 % of a torso that scales with it; and the proposed bones, scaled to 0.5, off the 0.5 proposal's by up to 38 px at 0.25 and 22.5 px at 1 (the hairpin's origin; the skirt roots 20 and 10.5) — the fixed-px values above. `propose` records it on the proposal as `proposed_rig_scale` (issue #354), and changing `assemble.rig_scale` after the rig sections are written is refused (`CONFIG_PROPOSED_RIG_SCALE`): without that refusal `demo`'s tracked config built at 0.25 gated green and `check` failed only `TIP_OVER_ROOT` on both sleeves (`propose --from-config` named 54 `LINT` lines, links off the art) — re-run `assemble` and `propose` at the new scale. Absent: `CONFIG_FIELD_PRESENT`; 0, negative or not a number: `CONFIG_FIELD_TYPE`; a rig under 1x1: `ASSEMBLE_RIG_SIZE` |
|
|
153
154
|
| `assemble.plan` | assemble; also the list every `meshes`/`regions` key must come from | **proposed** by `assemble --propose-plan`. `[part name, "full" \| "head", See-through tag]`, in draw order back to front. Names are free; roles come from the tag. A layer the author made and added to a run takes `author:<label>` in the tag's place, and has no role (§5, *A layer the author made*). When `generation.control.skeleton` is present and a full-run `handwear-r` or `handwear-l` layer merges sleeve, bare hand and overgarment drape into one layer — a skin-coloured hand cluster (`>= 40` run px, `GHOST_MIN_AREA`, measured `516..4,787` run px across the 9-standee corpus) lies within `0.35` forearm lengths of `<s>_wrist` and non-skin pixels reach more than `1.0x` the forearm length (`<s>_elbow -> <s>_wrist`) below `<s>_wrist` (issue #490; measured `-0.15x..0.45x` on the 6 clean standees vs `1.25x..2.02x` on the 3 merged standees, placing `1.0x` inside the `0.45x..1.25x` gap) — the layer is split into `drape-<s>`, `handwear-<s>` and `hand-<s>` (back to front) when the Euclidean RGB distance between the sleeve median colour above `<s>_wrist` and the drape median colour below `<s>_wrist` is at least `80` (`ARM_DRAPE_COLOR_DISTANCE`, measured `103.6..236.0` on the two distinct-colour merged standees vs `20.8..28.2` on the same-colour merged standee, placing `80` between `28.2` and `103.6`), or refused with `SEETHROUGH_ARM_RUN_MERGED` when the distance is under `80` |
|
|
154
155
|
| `assemble.extend_below_crop` | assemble | **proposed** with the plan: `{part, run, tag}` — a head-run part that reaches the bottom of the head crop is continued from a full-run layer, whole connected components |
|
|
@@ -3990,6 +3991,7 @@ and a usage error with `--head-box`, `--cloth` or `--from-config`.
|
|
|
3990
3991
|
| `layers` | the table, then one `author-made: layer "author:<label>" — …` line per layer the author made (group `author` in the table; §5, *A layer the author made*), then the `WARN` lines | every tag the plan will need has opaque pixels; one `face` in the head run; `0 WARN line(s)` | a run whose layer PNG is not its box's size (`LAYERS_PNG_MATCHES_BBOX`); a `WARN PLAN_LAYER_…` line (§6, *Plausibility*) |
|
|
3991
3992
|
| `sheet` of both runs | the tile list (and the sheet, if you can see) | eyes, irises, lashes and brows as left/right pairs in the head run | a head box that cut off an ornament: move `head_box`, re-run the head crop |
|
|
3992
3993
|
| `assemble` | one line per part — its counts (`op=` opaque, `vis=` visible, `src=` taken / candidates, `unproj=` visible but not projected, `drift=<n> (<p>% of core)`, `merged=`, `seam=`; *parts.json*, below). `drift=` is `refused_drift_px` in rig px and its share of the part's candidates, `projected_core_px`, which it is a subset of (issue #314); no bar reads either. On the four public examples the share runs to 94.5 % (`scarf` `hair_back`, 479 of 507) and the count to 26,472 (`scarf` `topwear`, 22.3 %), and the seam override repaints refused top-most pixels afterwards, so a large count says where See-through's colour was not taken, not that the part is unlike the painting: the `drift:` line below is the one that names a body of them another part holds. After the part lines, `provenance:` per part and `provenance total:` report the value partition, followed by `ownership:` per cut for structural moves, independent of RGBA equality; alternate sets get their own `set "<set>"`-prefixed lines ([the two axes](#source-before-scissors)). The author-made/cut/limb/fringe lines described below precede the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`, then one `uncovered: <n> of the <N> uncovered error px lie on the full run's "<tag>", in a body of <px> px at x,y wxh; <the rule that left it out>` line per full-run layer the plan does not take from the full run whose uncovered error holds a body (5x5-opened, 8-connected, at least 150 full-run px in rig px), then the `ASSEMBLE_PINHOLES: PASS — 0 pinholes …` or `ASSEMBLE_PINHOLES: SKIP — not measured, not a pass: …` line ([Pinholes at rest](#pinholes-at-rest-issue-944)), then one `drift: "<part>" refuses <n> px of <run>:<tag> (its colour more than 90 off the painting, max channel), in a body of <px> px at x,y wxh (…); by the plan's draw order "<holder>" holds <px> px of it (…)[, "<holder>" drawn in front of "<part>"][; the painting is within 90 of "<behind>" (<px> px at alpha >= 250, drawn behind "<part>")]; informational: no gate reads this. Swapping the plan order of the parts named here can clear it (when only the front one differs from the painting) or move it to the other part (when both do): swap, rebuild and read this line again; if it moves, removing it needs different input layers. At rest, the seam pass repaints the holder's pixels that pass its rule from the painting` line per part whose drift refusals hold a body (the same opening and bar) that another part holds in the draw order or that a part drawn behind it matches within 90 of the painting (issues #255, #351, #758) — that layer is not what the painting shows there; no gate reads this line, swapping the plan order of the parts named here can clear it (when only the front one differs from the painting) or move it to the other part (when both do) — swap, rebuild and read this line again, and if it moves, removing it needs different input layers — and at rest the seam pass repaints the holder's pixels that pass its rule from the painting (none on the five public paintings), then informational `drift: "<part>" source re-take changed N own-layer px` lines under soft front cover, naming `assemble.keep_under_occluders` and its kept count when enabled ([source ladder](#source-before-scissors)), then, per subset alternate set, one `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 <joints> (<k> px), dropped <m> (<d> px)` line per limb part cut from a layer of more than one island when the set names one limb side of it (issue #588; issue #608: a joint taken to the nearest island reads `<bone> [x, y] (<e> px off it, within its cap's radius <r>)`, and a part that lost no pixel `dropped none`), then one `LINT set "<set>" <part>: <layer part> holds <limb> and <other limb> in one island (8-connected, alpha above 8), split by the nearest segment line at the set's bones: kept <k> px (<share>), gave <m> px (<share>) to <other limb>; <b> px in the overlap band (nearer one line than the other by 0.5 px or less) went to the limb drawn in front, <bk> kept and <bg> given` line per limb part cut from a layer of one island that a limb the set does not carry also takes (issue #609; k, m and b count the part's pixels above alpha 8, each share of the part's pixels before the split, 3 places) and one `LINT set "<set>" <part>: <n> px under <base part> excluded` line per subset alternate set's limb part and base part drawn over it that the set does not replace which took a pixel (issue #587, `assemble.alternates` above; none on the public examples, which declare no alternate set); and look at `render/recomposite_error_rig.png`. The opt-in `assemble.yield_refused` adds `yielded N px` to each affected part's drift line (including a yielded refusal smaller than the body bar); with it off the line names `assemble.yield_refused: true` as the conditional config remedy before any cut. Between the part lines and the totals, one `author-made: part "<part>" (<run>:author:<label>) — …` line per part from a layer the author made (below, *A layer the author made*), then one `cut: "<part>" opaque <n> = "<part>" <kept> + "<piece>" <taken> …` line per part an `assemble.cuts` entry cut (below; it ends `; under <n> px filled, <m> px left empty` when a cut of that part has `under`), then — with `config.limbs` (issue #349) — one `limb cut: "<part>" opaque <n> = "<segment part>" <px> + …` line per part a limb takes and one `limb cap:` line per joint, its cap's pixels from the cap image, from the layer and missing, and its box (under `cap_shape: "ball"`, issue #579, then the lower half's pixels written, left as painted and missing), followed on a short cap by one `LINT "<part>" cap at [x, y] … short_px left <n>, right <m>: no layer gives them …` line (issue #557) — from the upper segment where it gave a pixel (under `cap_shape: "disc"` always), under `edge: "feather"` the pixels faded, under `cut_band` the line the cut was placed on and the stroke pixels it and the joint line cross (issue #360) (§3, *Limbs: cut at their joints*), then one `fringe pushed back: "<part>" <px> px, where the painting shows "<holder>" <px> px, …` line per part whose fringe was cleared (below) | on the examples: `reference-sample` 0.83 / 98.0 % / 4,359 / 1,185, 259 holes, the largest 94 px; `reference-demo` 2.34 / 95.8 % / 10,340 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (reference-demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. Use [Source before scissors](#source-before-scissors) for the source ladder; a missing independently moving structural piece can use a painting patch (below) |
|
|
3994
|
+
| `assemble` (the ground) | a `WARN ASSEMBLE_GROUND_PLAIN` line | no such line: the ground is plain and its border ring one colour (the five public examples read 0.0006 or less), or it is declared `busy` | `N % of the border ring is not one colour` — more than 2 % of the ring (`SILHOUETTE_BORDER` px wide) differs from its median by more than `SILHOUETTE_LIMIT`, under a ground absent or declared `plain`, under project: visible, where paintingEdges reads the ground. Two causes: the ground is not plain (declare `assemble.ground: busy`), or the figure crosses the border (leave the ground as it is). A warning, never a refusal; the ground stays as declared |
|
|
3993
3995
|
| `propose` | `note:` lines, `LINT` lines, the `coverage` lines, `landmarks.png`, `basis.json` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter; the `coverage:` summary says how many bones that verdict covers — a `not checked` bone was read by no check (its line says why), and a `ratio` or `fallback` bone in `basis.json` is a guess | a link off the art (a bone on the background) — move it onto the layer; `LINT <link> at … runs to … N degrees off the head's down axis …` — a fringe link that does not lean with the head (§3, the fringe in the head's frame): move it along the lock it drives; `LINT <bone> under <parent>: amplitude <a> below the link above's <b> in the keyed chain …` — under `--cloth`, a keyed sibling the recipe left the only keyed child of its parent, which `CHAIN_LAG` would refuse (issue #572, §3 *A keyed sibling left behind*): raise the field it names to `<b>` or more in size; `LINT hairback_… on mesh '<part>' (side <r\|l>): <N> tree(s) for <M> visible hair mass(es) (mass 0 columns x=<minX>..<maxX>, y=<minY>..<maxY>; …)` — back-hair cloth trees whose count does not match the side's visible hair masses (`max(1, floor(w / (4 * grid)) + 1)` per mass) or whose link or tip leaves its mass's column range (issues #445, #461): omit `chains` on `cloth."back hair"` or draft trees per visible hair mass inside its columns; `LINT <a_limb> and <b_limb> on <part> (<from>) cross at [x, y] with no per-limb split: …` — two limbs sharing one layer cross in an X without a per-limb split (issue #446): declare `config.limbs.<covering>.split` (`{ "line": [[x, y], …] }` or `{ "edge": "painting" }`, §3 *Which limb*); `LINT foot_<s> at [x, y], the <s>_ankle joint and leg_<s>1's tip, lies on none of leg_<s>'s art within 1 px along its joint line (the nearest of it on that line is the pixel [x, y], D px from the joint): …` — an ankle keypoint off its leg's art on the line `assemble` sizes the ankle's cap by (issue #517): `propose` drafts every leg without its ankle cut, the shoe layer stays one part, and nothing is moved onto the art; move that ankle keypoint onto the leg's or the shoe's art and re-run `propose` (`(the line crosses none of it)` when no art lies on that line at all); `LINT stop on <chain> (<firstLink> at [x0, y0]) against <against> (at [ax, ay]) places <chain>_touch at [tx, ty], above <firstLink>'s root (rest R deg, \|rest\| + outward B deg): …` — a stop's contact point `<chain>_touch` lies above `links[0]`'s root in rig px or pushes `\|rest\| + outward` past `180°` (issue #447, §2 `stops`): stop against the descending limb bone below `firstLink`, set `links` to start at a link below `<chain>_touch`, or omit the stop on a rising limb; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head`; a `no face part: face box derived from …` note (always the first) — `head`, `neck` and every face-height scale come from a box guessed off the hair and neck (§3): check `head` and `neck` on `landmarks_head.png` and move them; when it adds `the blink shuts the eyes over no face part`, read `BLINK_NO_HOLE` after `check` — an eye with no face under it can open a hole (the `reference-sample` with only its face removed: 587 px). Under `--keypoints`: the `lint rule set:` line first (joints, or screen and why), then the joint notes — `missing: the rule "…" placed bone X instead` is a bone still guessed; `not used — …` names why an arm's joints did not place its sleeve (for a wrist on no part, the nearest part and the `--wrist-snap` that would bind it); a `wrist snap: …` note is a wrist bound past 15 px by the flag — check that chain's tip on `landmarks.png`; a `one handwear part holds both wrists` note is two chains on one mesh — use another layer/run first; when no separate layer exists, a structural `assemble.cuts` entry separates the independently moving arms ([Source before scissors](#source-before-scissors)); `LINT chest at … is not between the neck joint …`, `LINT hip at … is not past chest …`, `LINT hip at … is nearer the neck joint …` and `LINT <chain><i> at … does not advance from <s>_shoulder toward <s>_wrist` are the joint-frame lines: move that bone between, past or along the joints they name |
|
|
3994
3996
|
| `compare` | one line per pair (origin, parent, tip, length, direction — or SKIP and why), then the per-row summary, the unmapped bones of each side, `required:`, each side's roles and the `controls:` line | proposal against the config you corrected: the rows you changed and no others. Config against the `rig.json` `build` writes for it: measured on both examples, every origin within 6.19e-7 px (the rig's offsets are written to 6 places) and every chain link's tip and length within 4.99e-4 px (`length` is written to 3), printed to three places as `0.000` (a signed figure as `+0.000` or `-0.000`); `parent … is the right's ancestor at depth 2, <bone>_ctl between` for every keyed mesh bone, no `DIFFERENT` and no `NOT MAPPED` | `parent DIFFERENT: left a, right b` — a bone hangs from another parent than the other side's; `frames not related` — a rig with no stage (`skeleton.width`/`height`), two configs at different `rig_scale`: the distance rows SKIP, so declare the frame in a `--map` file if the author knows it; `FAIL STRUCTURE_REQUIRED_PRESENT` — a bone the map requires is missing through its pairs |
|
|
3995
3997
|
| `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`); in `rig.json`, each chain link's (or its `_ctl`'s) `length` and `rotation` | `cover 1.00000` on every mesh, both gate summaries `0 failed` (the compile and the packed pages); every link's `length` the distance to the next link and `rotation` its direction (Spine degrees, counter-clockwise, y up, local to the parent), every offset under it — a child bone's `x, y`, a weight's bind `x, y`, a region's `x, y` — in that turned frame, and a region on a link carrying `rotation` that turns it back upright. Nothing moved: `flattenRig` (`src/rig.ts`) turns every offset back and gives the unturned numbers | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
|
|
@@ -4222,6 +4224,25 @@ and 13,872 to 1,110 (`wind`), and the holes from 1,020 to 62, 453 to 40, 259 to
|
|
|
4222
4224
|
291 to 34 and 1,118 to 62; `check` passes the same bars as before on all five. A
|
|
4223
4225
|
painting patch is read placed on the rig canvas, and is never written. `core` is unchanged.
|
|
4224
4226
|
|
|
4227
|
+
**A busy ground (`assemble.ground`, issue #996).** The step above reads the painting
|
|
4228
|
+
as a figure on a ground of one colour: a painted pixel no part covers is the figure's
|
|
4229
|
+
when it differs from the border ring's colour, and the colour behind a part's edge is
|
|
4230
|
+
one colour across the window. On an opaque ground of many colours — a wall, a banner —
|
|
4231
|
+
both readings take the ground: the ground beside a thin part (a strand of hair, a
|
|
4232
|
+
finger) becomes the part's, opaque, in the ground's own colour. A busy, opaque ground
|
|
4233
|
+
needs `assemble.ground: busy`; without it, visible takes backdrop pixels. Under `busy`
|
|
4234
|
+
a part's alpha rises only where See-through's own layer reaches (its alpha above 0
|
|
4235
|
+
before the step), a painted pixel no part covers is a gap only where two parts' layers
|
|
4236
|
+
meet, and a pixel on the ground's side of the edge is read only where it is a mix of
|
|
4237
|
+
the two colours (within 20 of the line between them). Measured on a busy ground planted
|
|
4238
|
+
under two public examples (`fixtures/busy_ground.ts`): part pixels outside the `core`
|
|
4239
|
+
build's footprint went from 8,065 to 0 (`wind`) and 6,119 to 0 (`demo`); 50 and 31
|
|
4240
|
+
pixels inside See-through's soft rim still show the ground's colour where `core` showed
|
|
4241
|
+
none. The price of the bound is the painted outline beyond See-through's reach, which a
|
|
4242
|
+
plain ground keeps, so `busy` is a declaration, not a default: on the five public
|
|
4243
|
+
examples it would raise the recomposite's error pixels. The painting cannot tell the two
|
|
4244
|
+
grounds apart near the figure, so the ground is the author's to declare.
|
|
4245
|
+
|
|
4225
4246
|
**`rig --idle-keys ctl|direct`** (and `build --idle-keys`, which forwards the
|
|
4226
4247
|
value to its rig stage; without the flag, or with `ctl`, a build writes and prints
|
|
4227
4248
|
what it did before `build` took the flag). rig-c's `A15_IDLE_NO_MESH_BONE_KEYS`
|
|
@@ -4779,6 +4800,7 @@ row in this page's `rule` and `line` tables; the selftest holds that (`TY16`).
|
|
|
4779
4800
|
| <a id="LAYERS_EXPORT_DIR_EMPTY"></a>`LAYERS_EXPORT_DIR_EMPTY` | `layers --export <dir>` was given a file, or a directory that is not empty (issue #359): a file left there could be read in place of the one written | a new directory, or empty it |
|
|
4780
4801
|
| <a id="LAYERS_EXPORT_ROUND_TRIP"></a>`LAYERS_EXPORT_ROUND_TRIP` | the export, read back, differs from the input's reading (the detail names the first difference) — an exporter bug, not an input problem | report it with the input named |
|
|
4781
4802
|
| <a id="ASSEMBLE_SOURCE_PRESENT"></a><a id="ASSEMBLE_SOURCE_DECODES"></a>`ASSEMBLE_SOURCE_PRESENT`, `ASSEMBLE_SOURCE_DECODES` | the painting is missing or not a PNG | `--source` |
|
|
4803
|
+
| <a id="ASSEMBLE_GROUND_PLAIN"></a>`ASSEMBLE_GROUND_PLAIN` | WARN only, never a refusal (issue #996): under project: visible, where paintingEdges reads the ground, with `assemble.ground` absent or `plain`, more than 2 % of the painting's border ring is not one colour (the ring is `SILHOUETTE_BORDER` px wide; a pixel counts when it differs from the ring's median by more than `SILHOUETTE_LIMIT`). Two causes: the ground is not plain, or the figure crosses the border | if it is the ground, declare `assemble.ground: busy`; if the figure crosses the border, leave the ground as it is (the WARN never changes output) |
|
|
4782
4804
|
| <a id="SHEET_SOURCE_PRESENT"></a><a id="SHEET_PNG_PRESENT"></a>`SHEET_SOURCE_PRESENT`, `SHEET_PNG_PRESENT` | `sheet`'s painting, or a part PNG a `parts.json` lists, is missing | `--source`, `--layers` |
|
|
4783
4805
|
|
|
4784
4806
|
### Plausibility (`WARN` in `layers`, a note in `assemble --propose-plan`)
|
package/docs/STABILITY.md
CHANGED
|
@@ -147,7 +147,7 @@ part name, `[i]` an entry of a list):
|
|
|
147
147
|
- **generation.control.skeleton**, the pose file it names when it is no built-in — required: `width`, `height`, `points`
|
|
148
148
|
- **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
|
|
149
149
|
- **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
|
|
150
|
-
- **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `seam`, `project`, `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`, `take`, `editor_receipts`
|
|
150
|
+
- **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `seam`, `project`, `ground`, `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`, `take`, `editor_receipts`
|
|
151
151
|
- **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
|
|
152
152
|
- **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
|
|
153
153
|
- **assemble.extend_painting[i]** — required: `part`, `band`, `max_distance`; optional: `min_held_alpha`, `order` (issues #848, #854, #855; explicit final layer-derived part, half-open rig band and positive four-neighbour path bound; base painting only). The optional integer floor 1–255 admits exact original alpha-over coverage strictly below it; absent or 1 blocks every nonzero holder. Only selected target samples below the floor may take painting RGBA; target samples at/above the floor and every non-target holder remain exact. By default overlapping reachable claims refuse. With `order` on every entry (unique nonnegative safe integers), lower values claim first and their gains block later candidates and paths; array order never breaks ties or supplies missing precedence
|
|
@@ -232,6 +232,12 @@ an absent field preserves existing output bytes. The bytes `visible` writes
|
|
|
232
232
|
are not promised across versions (issue #953 resolves its edges from the
|
|
233
233
|
painting).
|
|
234
234
|
|
|
235
|
+
`assemble.ground` accepts `plain` or `busy` (issue #996). Invalid values refuse
|
|
236
|
+
as `CONFIG_FIELD_TYPE` at `config.assemble.ground`; an absent field and `plain`
|
|
237
|
+
preserve existing output bytes. It is read by `visible` only, whose bytes are
|
|
238
|
+
not promised. The `ASSEMBLE_GROUND_PLAIN` warning prints under `visible` only, is a
|
|
239
|
+
report line and never changes output.
|
|
240
|
+
|
|
235
241
|
`TY14` holds the set of field names in backticks above to the set of names the
|
|
236
242
|
loader's object checks spell (every `c.object(…)` in `src/config.ts`, with the lists
|
|
237
243
|
they name), in both directions, and refuses an object check whose key list it cannot
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type SeamRule } from './seamrule.ts';
|
|
2
|
-
import { DEFAULT_PROJECT_RULE, type ProjectRule } from './projectrule.ts';
|
|
2
|
+
import { DEFAULT_GROUND, DEFAULT_PROJECT_RULE, type Ground, type ProjectRule } from './projectrule.ts';
|
|
3
3
|
import { paintingEdges, type PaintingEdgeSkips } from './paintingedges.ts';
|
|
4
4
|
import { edgeLength } from './length.ts';
|
|
5
5
|
import { materialSource, type MaterialSource, materialRows, polygonMaterialEdges, type MaterialRow, type MaterialEdge, type PlacedArt } from './cutmaterial.ts';
|
|
@@ -193,7 +193,7 @@ export const SILHOUETTE_LIMIT = 40;
|
|
|
193
193
|
|
|
194
194
|
export { DEFAULT_SEAM_RULE, SEAM_RULES, type SeamRule } from './seamrule.ts';
|
|
195
195
|
|
|
196
|
-
export { DEFAULT_PROJECT_RULE, PROJECT_RULES, type ProjectRule } from './projectrule.ts';
|
|
196
|
+
export { DEFAULT_GROUND, DEFAULT_PROJECT_RULE, GROUNDS, type Ground, PROJECT_RULES, type ProjectRule } from './projectrule.ts';
|
|
197
197
|
|
|
198
198
|
// ---------------------------------------------------------------------------
|
|
199
199
|
// inputs
|
|
@@ -331,6 +331,8 @@ export interface AssembleInput {
|
|
|
331
331
|
under?: ReadonlyArray<Raster | null>;
|
|
332
332
|
seamRule: SeamRule;
|
|
333
333
|
projectRule: ProjectRule;
|
|
334
|
+
/** `config.assemble.ground` (issue #996), read by `paintingEdges` under `visible` only; absent, `plain`. */
|
|
335
|
+
ground?: Ground;
|
|
334
336
|
/** `config.scene` (issue #242): the painting is a scene, so the recomposite is read inside the figure ({@link sceneFigure}). Absent or false: nothing changes. */
|
|
335
337
|
scene?: boolean;
|
|
336
338
|
/** `config.limbs` (issue #349): the parts cut at their joints ({@link LimbCount}). Absent or empty: nothing changes. */
|
|
@@ -2619,6 +2621,47 @@ export function paintedFigure(srcr: Raster): Uint8Array {
|
|
|
2619
2621
|
return painted;
|
|
2620
2622
|
}
|
|
2621
2623
|
|
|
2624
|
+
/**
|
|
2625
|
+
* Issue #996: the share of the border ring that `paintedFigure`'s own one-colour
|
|
2626
|
+
* test reads as figure — the ring's pixels (the outer `SILHOUETTE_BORDER` rig
|
|
2627
|
+
* pixels) whose `paintedFigure` value is 1, over the ring's pixel count. A plain
|
|
2628
|
+
* ground reads near 0; a ground of many colours, or a figure crossing the
|
|
2629
|
+
* border, reads more.
|
|
2630
|
+
*/
|
|
2631
|
+
export function groundRingShare(srcr: Raster): number {
|
|
2632
|
+
const { width: W, height: H } = srcr;
|
|
2633
|
+
const painted = paintedFigure(srcr);
|
|
2634
|
+
let ring = 0;
|
|
2635
|
+
let figure = 0;
|
|
2636
|
+
for (let y = 0; y < H; y++) {
|
|
2637
|
+
for (let x = 0; x < W; x++) {
|
|
2638
|
+
if (y >= SILHOUETTE_BORDER && y < H - SILHOUETTE_BORDER && x >= SILHOUETTE_BORDER && x < W - SILHOUETTE_BORDER) continue;
|
|
2639
|
+
ring++;
|
|
2640
|
+
figure += painted[y * W + x];
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
return ring === 0 ? 0 : figure / ring;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/**
|
|
2647
|
+
* Share of the border ring that paintedFigure's own one-colour test reads as figure. Plain grounds measured at most 0.001 and the busy fixtures 0.464; 0.02 sits near the geometric middle of that gap, an order of magnitude from each side. A figure crossing the border counts too, so the WARN names both causes and leaves the ground as declared.
|
|
2648
|
+
*/
|
|
2649
|
+
export const GROUND_RING_SHARE_LIMIT = 0.02;
|
|
2650
|
+
|
|
2651
|
+
/**
|
|
2652
|
+
* Issue #996: the WARN for a ground declared or defaulted `plain` whose border ring
|
|
2653
|
+
* is not one colour (more than {@link GROUND_RING_SHARE_LIMIT} of it reads as
|
|
2654
|
+
* figure). Report-level only: it never changes the ground and never refuses. Null
|
|
2655
|
+
* when the ground is declared `busy` or the share is at or under the limit, and under
|
|
2656
|
+
* `project: core`: only the visible rule's paintingEdges reads the ground, so under
|
|
2657
|
+
* core the declaration changes nothing and there is nothing to warn about.
|
|
2658
|
+
*/
|
|
2659
|
+
export function groundWarning(share: number, ground: Ground | undefined, rule: ProjectRule): Problem | null {
|
|
2660
|
+
if (rule !== 'visible' || (ground ?? DEFAULT_GROUND) !== 'plain' || share <= GROUND_RING_SHARE_LIMIT) return null;
|
|
2661
|
+
const pct = (100 * share).toFixed(1);
|
|
2662
|
+
return { code: 'ASSEMBLE_GROUND_PLAIN', object: 'config.assemble.ground', detail: `${pct} % of the border ring is not one colour: either the ground is not plain or the figure crosses the border; if it is the ground, declare assemble.ground: busy` };
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2622
2665
|
export interface PlacedPart {
|
|
2623
2666
|
depthImage?: Raster;
|
|
2624
2667
|
record: PartRecord;
|
|
@@ -4586,6 +4629,8 @@ export interface AssembleResult {
|
|
|
4586
4629
|
projectRule: ProjectRule;
|
|
4587
4630
|
/** Issue #953: what `paintingEdges` resolved, under `projectRule: 'visible'` only. */
|
|
4588
4631
|
paintingEdges?: PaintingEdgeReading;
|
|
4632
|
+
/** Issue #996: {@link groundRingShare} of the painting in the rig, read by the ground WARN. */
|
|
4633
|
+
groundShare: number;
|
|
4589
4634
|
depthReadings?: Array<{ from: string; reading: import('./depthsheets.ts').DepthReading }>;
|
|
4590
4635
|
/** Visible registered samples clamped within <1 source pixel of a crop. */
|
|
4591
4636
|
depthClamps?: Array<{ part: string; samples: number }>;
|
|
@@ -4801,6 +4846,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
4801
4846
|
checkPlanAgainstRuns(sourcePlan, extend, full, head);
|
|
4802
4847
|
const { W, H } = frame;
|
|
4803
4848
|
const srcr = sourceInRig(source, W, H);
|
|
4849
|
+
const groundShare = groundRingShare(srcr);
|
|
4804
4850
|
// Issue #359: a part's hidden-art layer is the under of a cut that names none — and, with one named, a refusal, since
|
|
4805
4851
|
// reading either would be a guess. Its uses are counted for `hiddenLines`.
|
|
4806
4852
|
const hiddenArt = hiddenArtByPart(plan, input.cuts, { full, head }, frame, input.resolution);
|
|
@@ -5608,6 +5654,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5608
5654
|
};
|
|
5609
5655
|
const e = paintingEdges(stack.map(onRig), srcr, {
|
|
5610
5656
|
painted: paintedFigure(srcr),
|
|
5657
|
+
...(input.ground === undefined ? {} : { ground: input.ground }),
|
|
5611
5658
|
editable: order.map((d) => !('patch' in d)),
|
|
5612
5659
|
keep: keepInOrder,
|
|
5613
5660
|
feathered: featheredInOrder,
|
|
@@ -5821,6 +5868,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5821
5868
|
hidden: [...hiddenUses].map(([layer, readBy]) => ({ layer, readBy })),
|
|
5822
5869
|
seamRule,
|
|
5823
5870
|
projectRule,
|
|
5871
|
+
groundShare,
|
|
5824
5872
|
...(edgeReading === null ? {} : { paintingEdges: edgeReading }),
|
|
5825
5873
|
...(headFacePrep.underfillPx > 0 ? { headFaceUnderfillPx: headFacePrep.underfillPx } : {}),
|
|
5826
5874
|
excluded,
|
|
@@ -6705,6 +6753,8 @@ export function stageFields(
|
|
|
6705
6753
|
regions?: Record<string, RegionSpec>;
|
|
6706
6754
|
meshes?: Record<string, MeshSpec>;
|
|
6707
6755
|
motion?: CharacterConfig['motion'];
|
|
6756
|
+
/** `assemble.ground` (issue #996); absent, `plain`. */
|
|
6757
|
+
ground?: Ground;
|
|
6708
6758
|
} {
|
|
6709
6759
|
const problems: Problem[] = [];
|
|
6710
6760
|
if (cfg.seethrough === undefined) {
|
|
@@ -6726,6 +6776,7 @@ export function stageFields(
|
|
|
6726
6776
|
...(cfg.assemble.head_face_underfill === undefined ? {} : { headFaceUnderfill: cfg.assemble.head_face_underfill }),
|
|
6727
6777
|
...(cfg.assemble.yield_refused === undefined ? {} : { yieldRefused: cfg.assemble.yield_refused }),
|
|
6728
6778
|
...(cfg.assemble.keep_under_occluders === undefined ? {} : { keepUnderOccluders: cfg.assemble.keep_under_occluders }),
|
|
6779
|
+
...(cfg.assemble.ground === undefined ? {} : { ground: cfg.assemble.ground }),
|
|
6729
6780
|
...(cfg.assemble.extend_painting === undefined ? {} : { paintingExtension: cfg.assemble.extend_painting }),
|
|
6730
6781
|
...(turnMovingPair === null ? {} : { turnMovingPair }),
|
|
6731
6782
|
...(cfg.turn === undefined ? {} : { turn: cfg.turn, regions: cfg.regions, meshes: cfg.meshes, motion: cfg.motion }),
|
package/src/build.ts
CHANGED
|
@@ -58,7 +58,7 @@ import { createHash } from 'node:crypto';
|
|
|
58
58
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
59
59
|
import { dirname, join, resolve } from 'node:path';
|
|
60
60
|
import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
|
|
61
|
-
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, measurePinholes, measureRecomposite, pinholeProblems, pinholesLine, type ProjectRule, recomposite, recompositeRecord, scenePhrase, type SeamRule, sourceInRig, stageFields, uncoveredLine } from './assemble.ts';
|
|
61
|
+
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, groundWarning, holeKindsLine, holeLines, measurePinholes, measureRecomposite, pinholeProblems, pinholesLine, type ProjectRule, recomposite, recompositeRecord, scenePhrase, type SeamRule, sourceInRig, stageFields, uncoveredLine } from './assemble.ts';
|
|
62
62
|
import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
|
|
63
63
|
import { residualClause } from './autoenvelope.ts';
|
|
64
64
|
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';
|
|
@@ -689,6 +689,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
689
689
|
...(fields.armSplit !== undefined && altPlan.some((e) => e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l')) ? { armSplit: fields.armSplit } : {}),
|
|
690
690
|
...(fields.headFaceUnderfill === undefined ? {} : { headFaceUnderfill: fields.headFaceUnderfill }),
|
|
691
691
|
...(fields.yieldRefused === undefined ? {} : { yieldRefused: fields.yieldRefused }),
|
|
692
|
+
...(fields.ground === undefined ? {} : { ground: fields.ground }),
|
|
692
693
|
...(fields.turnMovingPair === undefined ? {} : { turnMovingPair: fields.turnMovingPair }),
|
|
693
694
|
...(fields.turn === undefined ? {} : { turn: fields.turn, regions: fields.regions, meshes: fields.meshes, motion: fields.motion }),
|
|
694
695
|
seamRule: seam,
|
|
@@ -809,10 +810,15 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
809
810
|
if (figurePath !== null) writePng(figurePath, result.figure as Raster);
|
|
810
811
|
const [W, H] = result.parts.rig_size;
|
|
811
812
|
log(`rig-parts assemble: ${result.images.length} part(s) on a ${W}x${H} rig (${result.parts.scale_rig_per_source} rig px per source px), seam rule ${result.seamRule}, projection rule ${result.projectRule}`);
|
|
813
|
+
// Issue #996: a ground declared or defaulted plain whose border ring is not one colour, under project: visible only.
|
|
814
|
+
const groundWarn = groundWarning(result.groundShare, fields.ground, result.projectRule);
|
|
815
|
+
if (groundWarn !== null) log(` WARN ${groundWarn.code}: ${groundWarn.object} — ${groundWarn.detail}`);
|
|
812
816
|
// Issue #953: what the painting's edges resolved, under the visible rule only.
|
|
813
817
|
if (result.paintingEdges !== undefined) {
|
|
814
818
|
const e = result.paintingEdges;
|
|
815
819
|
log(`painting edges: ${e.resolved} edge px resolved from the painting (${e.unseparated} against the two reference colours where the window's far sides overlap), ${e.changed} part px changed; left as they were: ${e.skipped.cover} under a part the two-layer model does not carry, ${e.skipped.contrast} where the painting draws no edge (contrast below ${EDGE_CONTRAST}), ${e.skipped.locked} kept, feathered, a patch or a turn's moving pair, ${e.skipped.noBehind} with no behind pixel in the window; ${e.skipped.pieces} part px not written so a part's art keeps its pieces`);
|
|
820
|
+
// Issue #996: under a declared busy ground, what the two ground rules left.
|
|
821
|
+
if (fields.ground === 'busy') log(` busy ground (assemble.ground): ${e.skipped.mix} behind-side px left that are not a mix of the two reference colours, ${e.skipped.support} part px not raised outside the part's own support`);
|
|
816
822
|
}
|
|
817
823
|
// Issue #359: one run, said, so a reader of the lines knows no head run was placed.
|
|
818
824
|
if (runs.single) log(` one run: ${input.full} holds the face (${SINGLE_RUN_FACE_TAGS.join(', ')}), so no head run is read; every part comes from the full run`);
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SEAM_RULES, type SeamRule } from './seamrule.ts';
|
|
2
|
-
import { PROJECT_RULES, type ProjectRule } from './projectrule.ts';
|
|
2
|
+
import { GROUNDS, type Ground, PROJECT_RULES, type ProjectRule } from './projectrule.ts';
|
|
3
3
|
import type { TakeLayers } from './takelayers.ts';
|
|
4
4
|
import { paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
|
|
5
5
|
import { baseAnchorProblems, type BaseAnchor } from './baseanchor.ts';
|
|
@@ -307,6 +307,8 @@ export interface Assemble {
|
|
|
307
307
|
seam?: SeamRule;
|
|
308
308
|
/** Optional per-character projection rule (issue #953); an explicit CLI flag wins. */
|
|
309
309
|
project?: ProjectRule;
|
|
310
|
+
/** Optional per-character painting ground (issue #996); absent, `plain`. Read by `visible` only. */
|
|
311
|
+
ground?: Ground;
|
|
310
312
|
/** Verified named replacements from another See-through run (#866). */
|
|
311
313
|
take?: TakeLayers[];
|
|
312
314
|
/** Issue #946: receipts for images edited in an image editor; absent, no file is read and no byte moves. */
|
|
@@ -1629,7 +1631,7 @@ export interface EarlyConfig {
|
|
|
1629
1631
|
depth?: DepthConfig;
|
|
1630
1632
|
key: string;
|
|
1631
1633
|
seethrough: SeeThrough;
|
|
1632
|
-
assemble: { rig_scale: number; seam?: SeamRule; project?: ProjectRule; take?: TakeLayers[]; editor_receipts?: EditorReceipts; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
|
|
1634
|
+
assemble: { rig_scale: number; seam?: SeamRule; project?: ProjectRule; ground?: Ground; take?: TakeLayers[]; editor_receipts?: EditorReceipts; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
|
|
1633
1635
|
}
|
|
1634
1636
|
|
|
1635
1637
|
/** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
|
|
@@ -1705,7 +1707,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1705
1707
|
if (door === 'layers') {
|
|
1706
1708
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1707
1709
|
if ('assemble' in t) {
|
|
1708
|
-
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', 'extend_painting', 'take', 'seam', 'project', 'editor_receipts']);
|
|
1710
|
+
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', 'extend_painting', 'take', 'seam', 'project', 'ground', 'editor_receipts']);
|
|
1709
1711
|
if (a !== null) {
|
|
1710
1712
|
checkAssembleRules(c, a);
|
|
1711
1713
|
if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
|
|
@@ -2513,11 +2515,14 @@ function checkAssembleRules(c: Check, a: Record<string, Json>): void {
|
|
|
2513
2515
|
if ('project' in a && !(PROJECT_RULES as readonly unknown[]).includes(a.project)) {
|
|
2514
2516
|
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.project', `is ${show(a.project)}; one of ${PROJECT_RULES.join(', ')} is required`);
|
|
2515
2517
|
}
|
|
2518
|
+
if ('ground' in a && !(GROUNDS as readonly unknown[]).includes(a.ground)) {
|
|
2519
|
+
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.ground', `is ${show(a.ground)}; one of ${GROUNDS.join(', ')} is required (busy for an opaque ground of many colours, issue #996)`);
|
|
2520
|
+
}
|
|
2516
2521
|
}
|
|
2517
2522
|
|
|
2518
2523
|
function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
|
|
2519
2524
|
const required = sectionRequired(door, 'assemble');
|
|
2520
|
-
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', 'extend_painting', 'take', 'seam', 'project', 'editor_receipts']);
|
|
2525
|
+
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', 'extend_painting', 'take', 'seam', 'project', 'ground', 'editor_receipts']);
|
|
2521
2526
|
if (a === null) return { parts: [], patches: [], cuts: [] };
|
|
2522
2527
|
checkAssembleRules(c, a);
|
|
2523
2528
|
if (required.includes('plan') && !('plan' in a)) {
|
package/src/paintingedges.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
2
2
|
import { connectedComponents } from './raster/components.ts';
|
|
3
|
+
import { type Ground } from './projectrule.ts';
|
|
3
4
|
import { type Mask, newMask, type Raster, RasterError } from './raster/types.ts';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -100,6 +101,31 @@ import { type Mask, newMask, type Raster, RasterError } from './raster/types.ts'
|
|
|
100
101
|
* and pixel). Where that leaves out BEHIND's fill under FRONT, FRONT takes the
|
|
101
102
|
* painting's pixel at full alpha instead, under the same rule.
|
|
102
103
|
*
|
|
104
|
+
* ⚖️ A busy ground (`ground: 'busy'`, issue #996). The two premises above —
|
|
105
|
+
* `painted` separates figure from ground, and BEHIND is one colour `Cb` across
|
|
106
|
+
* the window — hold only where the ground is one colour. On an opaque ground of
|
|
107
|
+
* many colours `paintedFigure` marks the ground as figure, so the gap rule hands
|
|
108
|
+
* ground pixels to the nearest part, and a ground pixel that is not `Cb`
|
|
109
|
+
* projects along `Cb -> Cf` as FRONT. Under a busy ground three rules bound what
|
|
110
|
+
* a part may take:
|
|
111
|
+
*
|
|
112
|
+
* - a part's alpha rises at a pixel only where its own alpha before the step is
|
|
113
|
+
* above 0 (its support, the reach of See-through's layer through the
|
|
114
|
+
* resampling into the rig) — FRONT's coverage, BEHIND's fill and the
|
|
115
|
+
* painting's pixel FRONT takes where BEHIND's fill is held back alike;
|
|
116
|
+
* a write that would raise one is not made (counted as `support`);
|
|
117
|
+
* - the gap rule takes a pixel only where the supports of at least two parts
|
|
118
|
+
* meet (a gap between two soft rims, which is what it is for), and gives it
|
|
119
|
+
* to the nearest of those parts;
|
|
120
|
+
* - a pixel on BEHIND's side of See-through's line is read only where it is a
|
|
121
|
+
* mix of `Cb` and `Cf`: the painting differs from the nearest point of the
|
|
122
|
+
* segment `Cb..Cf` by at most half of {@link EDGE_CONTRAST} (max channel, the
|
|
123
|
+
* tolerance the composite is held to above); otherwise it is left as it was
|
|
124
|
+
* (counted as `mix`).
|
|
125
|
+
*
|
|
126
|
+
* Under a plain ground (the default) none of the three applies, and the step is
|
|
127
|
+
* the one described above, byte for byte.
|
|
128
|
+
*
|
|
103
129
|
* Every decision is read off the stack as it stood before the step, then
|
|
104
130
|
* applied, so the result does not depend on the order pixels are visited.
|
|
105
131
|
* Deterministic: integer rig arithmetic, medians by sort, no float reduction
|
|
@@ -119,6 +145,10 @@ export interface PaintingEdgeSkips {
|
|
|
119
145
|
noBehind: number;
|
|
120
146
|
/** Part writes left out so a part's art keeps its 4-connected pieces (counted per part and pixel, not per edge pixel). */
|
|
121
147
|
pieces: number;
|
|
148
|
+
/** Busy ground only (issue #996): BEHIND-side pixels that are not a mix of the two reference colours. */
|
|
149
|
+
mix: number;
|
|
150
|
+
/** Busy ground only (issue #996): part writes left out because they would raise a part's alpha outside its own support (per part and pixel). */
|
|
151
|
+
support: number;
|
|
122
152
|
}
|
|
123
153
|
|
|
124
154
|
export interface PaintingEdgeResult {
|
|
@@ -148,6 +178,8 @@ export interface PaintingEdgeOptions {
|
|
|
148
178
|
movingPair?: ((a: number, b: number) => boolean) | null;
|
|
149
179
|
/** Called once per part and pixel whose RGBA changed. */
|
|
150
180
|
written?: (part: number, pixel: number) => void;
|
|
181
|
+
/** Issue #996: the painting's ground; absent, `plain` (see the invariant above {@link EDGE_RADIUS}). */
|
|
182
|
+
ground?: Ground;
|
|
151
183
|
}
|
|
152
184
|
|
|
153
185
|
/** Chebyshev distance to the nearest set pixel, capped at `cap` (two-pass, exact for the 8-neighbour metric). */
|
|
@@ -219,6 +251,9 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
|
|
|
219
251
|
});
|
|
220
252
|
const R = EDGE_RADIUS;
|
|
221
253
|
const cap = 2 * R + 2;
|
|
254
|
+
const busy = options.ground === 'busy';
|
|
255
|
+
/** A part's support before the step: where its own alpha is above 0. */
|
|
256
|
+
const supports = (i: number, p: number): boolean => parts[i].data[p * 4 + 3] > 0;
|
|
222
257
|
const owner = new Int16Array(W * H).fill(-1);
|
|
223
258
|
for (let p = 0; p < W * H; p++) {
|
|
224
259
|
for (let i = n - 1; i >= 0; i--) {
|
|
@@ -239,9 +274,15 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
|
|
|
239
274
|
const fill: Array<[number, number]> = [];
|
|
240
275
|
for (let p = 0; p < W * H; p++) {
|
|
241
276
|
if (owner[p] !== -1 || options.painted[p] !== 1) continue;
|
|
277
|
+
// Busy ground: only where two parts' supports meet, and only to a part whose support holds the pixel.
|
|
278
|
+
if (busy) {
|
|
279
|
+
let holders = 0;
|
|
280
|
+
for (let k = 0; k < n && holders < 2; k++) if (supports(k, p)) holders++;
|
|
281
|
+
if (holders < 2) continue;
|
|
282
|
+
}
|
|
242
283
|
let best = -1;
|
|
243
284
|
let bd = cap;
|
|
244
|
-
for (let k = n - 1; k >= 0; k--) if (near[k][p] < bd) { best = k; bd = near[k][p]; }
|
|
285
|
+
for (let k = n - 1; k >= 0; k--) if (near[k][p] < bd && (!busy || supports(k, p))) { best = k; bd = near[k][p]; }
|
|
245
286
|
if (best >= 0) fill.push([p, best]);
|
|
246
287
|
}
|
|
247
288
|
for (const [p, k] of fill) owner[p] = k;
|
|
@@ -261,7 +302,7 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
|
|
|
261
302
|
dist.push(any ? chebyshev(set, W, H, cap) : new Uint8Array(W * H).fill(cap));
|
|
262
303
|
}
|
|
263
304
|
const distOf = (label: number): Uint8Array => dist[label < 0 ? n : label];
|
|
264
|
-
const skipped: PaintingEdgeSkips = { cover: 0, contrast: 0, locked: 0, noBehind: 0, pieces: 0 };
|
|
305
|
+
const skipped: PaintingEdgeSkips = { cover: 0, contrast: 0, locked: 0, noBehind: 0, pieces: 0, mix: 0, support: 0 };
|
|
265
306
|
const decisions: Decision[] = [];
|
|
266
307
|
let resolved = 0;
|
|
267
308
|
let unseparated = 0;
|
|
@@ -363,6 +404,17 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
|
|
|
363
404
|
const sideHi = sideF.length === 0 ? -Infinity : Math.min(...sideF.map(along));
|
|
364
405
|
const separate = sideHi > sideLo;
|
|
365
406
|
if (!separate) unseparated++;
|
|
407
|
+
// Busy ground: a pixel on BEHIND's side is read only where it is a mix of Cb and Cf (its distance, max channel, from
|
|
408
|
+
// the nearest point of the segment Cb..Cf within half the contrast floor); a ground of many colours is not Cb.
|
|
409
|
+
if (busy && a !== front) {
|
|
410
|
+
const ax2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
|
|
411
|
+
const t = Math.min(1, Math.max(0, along(p) / ax2));
|
|
412
|
+
const off = Math.max(...[0, 1, 2].map((c) => Math.abs(srcr.data[p * 4 + c] - (cb[c] + t * axis[c]))));
|
|
413
|
+
if (2 * off > EDGE_CONTRAST) {
|
|
414
|
+
skipped.mix++;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
366
418
|
const lo = separate ? sideLo : 0;
|
|
367
419
|
const hi = separate ? sideHi : along(ref);
|
|
368
420
|
resolved++;
|
|
@@ -428,6 +480,10 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
|
|
|
428
480
|
const d = parts[i].data;
|
|
429
481
|
const s = p * 4;
|
|
430
482
|
if (d[s] === r && d[s + 1] === g && d[s + 2] === bl && d[s + 3] === al) return;
|
|
483
|
+
if (busy && al > d[s + 3] && !supports(i, p)) {
|
|
484
|
+
skipped.support++;
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
431
487
|
planned[i].set(p, [r, g, bl, al]);
|
|
432
488
|
};
|
|
433
489
|
/** Per part, the pixels it fills as BEHIND, with the FRONT part over them. */
|
|
@@ -444,7 +500,14 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
|
|
|
444
500
|
if (alpha > 0 && alpha < 255) while (alpha < 255 && 2 * miss(alpha) > EDGE_CONTRAST) alpha++;
|
|
445
501
|
if (alpha >= 255 - OPAQUE_ALPHA_ABOVE) alpha = 255;
|
|
446
502
|
const unmix = (c: number): number => unmixAt(c, alpha);
|
|
447
|
-
|
|
503
|
+
// Busy ground: a BEHIND raise outside BEHIND's own support is refused in `plan`, so FRONT is not lowered there either —
|
|
504
|
+
// FRONT keeps the coverage it had, and a seam whose two supports do not overlap is never left transparent.
|
|
505
|
+
// The refused BEHIND raise below is the one counted in `skipped.support`.
|
|
506
|
+
const behindRefused = busy && q.behind >= 0 && alpha < 255 && parts[q.behind].data[s + 3] < 255 && !supports(q.behind, q.p);
|
|
507
|
+
const keepFront = behindRefused && alpha < parts[q.front].data[s + 3];
|
|
508
|
+
if (keepFront) {
|
|
509
|
+
// FRONT keeps its coverage: nothing is planned for it here.
|
|
510
|
+
} else if (alpha === 0) plan(q.front, q.p, 0, 0, 0, 0);
|
|
448
511
|
else if (alpha === 255) plan(q.front, q.p, P[0], P[1], P[2], 255);
|
|
449
512
|
else plan(q.front, q.p, unmix(0), unmix(1), unmix(2), alpha);
|
|
450
513
|
if (q.behind >= 0 && alpha < 255) {
|
|
@@ -526,7 +589,10 @@ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEd
|
|
|
526
589
|
// A fill BEHIND cannot take: FRONT takes the painting's pixel whole, so the composite there is still the painting
|
|
527
590
|
// (FRONT is later in draw order, so its own pieces are held after this).
|
|
528
591
|
const front = fills[i].get(p);
|
|
529
|
-
if (front !== undefined)
|
|
592
|
+
if (front !== undefined) {
|
|
593
|
+
if (busy && !supports(front, p)) skipped.support++;
|
|
594
|
+
else planned[front].set(p, [srcr.data[p * 4], srcr.data[p * 4 + 1], srcr.data[p * 4 + 2], 255]);
|
|
595
|
+
}
|
|
530
596
|
}
|
|
531
597
|
}
|
|
532
598
|
});
|
package/src/projectrule.ts
CHANGED
|
@@ -20,3 +20,18 @@ export const PROJECT_RULES: readonly ProjectRule[] = ['core', 'visible'];
|
|
|
20
20
|
* it is opt-in.
|
|
21
21
|
*/
|
|
22
22
|
export const DEFAULT_PROJECT_RULE: ProjectRule = 'core';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Issue #996: what lies behind the figure in the painting. `plain` is a ground
|
|
26
|
+
* of one colour, the assumption `paintedFigure` (src/assemble.ts) makes when it
|
|
27
|
+
* reads the border ring's median as the ground. `busy` is an opaque ground of
|
|
28
|
+
* many colours (a wall, a banner), where that test marks the ground as figure
|
|
29
|
+
* and the painting alone cannot tell a part's painted outline from the ground
|
|
30
|
+
* next to it. Under `visible`, `paintingEdges` then bounds what a part may take
|
|
31
|
+
* (see its invariant). The ground is declared by the author, never inferred.
|
|
32
|
+
*/
|
|
33
|
+
export type Ground = 'plain' | 'busy';
|
|
34
|
+
export const GROUNDS: readonly Ground[] = ['plain', 'busy'];
|
|
35
|
+
|
|
36
|
+
/** The ground when none is declared: `plain`, the code path every build ran before issue #996. */
|
|
37
|
+
export const DEFAULT_GROUND: Ground = 'plain';
|