rig-parts 2.4.1 → 2.5.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/cli.ts +11 -9
- package/docs/AUTHORING.md +30 -2
- package/docs/STABILITY.md +8 -1
- package/package.json +2 -2
- package/src/assemble.ts +70 -19
- package/src/build.ts +17 -6
- package/src/config.ts +12 -6
- package/src/paintingedges.ts +543 -0
- package/src/projectrule.ts +22 -0
- package/src/provenance.ts +1 -1
package/cli.ts
CHANGED
|
@@ -783,7 +783,9 @@ usage:
|
|
|
783
783
|
--seam overrides config.assemble.seam, otherwise ${DEFAULT_SEAM_RULE}. --project says where a layer takes
|
|
784
784
|
the painting's pixel: core (the reference's) erodes every layer's top-most
|
|
785
785
|
opaque area by 5x5 first, so a part a few pixels wide takes none; visible
|
|
786
|
-
erodes only along a rim where a later layer is in front
|
|
786
|
+
erodes only along a rim where a later layer is in front, and resolves the
|
|
787
|
+
parts' edges from the painting (the line and coverage where two parts
|
|
788
|
+
meet, issue #953). --project overrides config.assemble.project, otherwise
|
|
787
789
|
${DEFAULT_PROJECT_RULE}.
|
|
788
790
|
|
|
789
791
|
rig-parts assemble --propose-plan --source <painting.png> --full <dir|psd>
|
|
@@ -864,8 +866,8 @@ usage:
|
|
|
864
866
|
green build ends with the pack line beside the spineboy yardstick and the
|
|
865
867
|
three artifact paths — skeleton .json, .atlas and the packed page: the
|
|
866
868
|
packed atlas is the result, the loose parts are the intermediate it was
|
|
867
|
-
made from. --seam overrides config.assemble.seam, otherwise ${DEFAULT_SEAM_RULE}; --project
|
|
868
|
-
${DEFAULT_PROJECT_RULE} (both as for assemble), --page-edges to ${DEFAULT_PAGE_EDGES} and
|
|
869
|
+
made from. --seam overrides config.assemble.seam, otherwise ${DEFAULT_SEAM_RULE}; --project overrides
|
|
870
|
+
config.assemble.project, otherwise ${DEFAULT_PROJECT_RULE} (both as for assemble), --page-edges to ${DEFAULT_PAGE_EDGES} and
|
|
869
871
|
--pack-shape to ${DEFAULT_PACK_SHAPE} (as for check; both packed builds, rig's gate
|
|
870
872
|
and check's artifact, take them). --requirements is forwarded to check
|
|
871
873
|
(as for check); the file's own shape is read before assemble runs, and
|
|
@@ -1745,8 +1747,8 @@ function cmdAssemble(args: string[]): number {
|
|
|
1745
1747
|
if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
|
|
1746
1748
|
const seam = flags.get('--seam');
|
|
1747
1749
|
if (seam !== undefined && !(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
|
|
1748
|
-
const project = flags.get('--project')
|
|
1749
|
-
if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
|
|
1750
|
+
const project = flags.get('--project');
|
|
1751
|
+
if (project !== undefined && !(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
|
|
1750
1752
|
const [source, full, config] = ['--source', '--full', '--config'].map((f) => flags.get(f) as string);
|
|
1751
1753
|
const head = flags.get('--head') ?? null;
|
|
1752
1754
|
const writePath = flags.get('--write-config');
|
|
@@ -1772,7 +1774,7 @@ function cmdAssemble(args: string[]): number {
|
|
|
1772
1774
|
}
|
|
1773
1775
|
const out = flags.get('--out') as string;
|
|
1774
1776
|
assembleStage(
|
|
1775
|
-
{ source, full, head, config, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), ...(seam === undefined ? {} : { seam: seam as SeamRule }), project: project as ProjectRule, ...(alts.length === 0 ? {} : { alts }) },
|
|
1777
|
+
{ source, full, head, config, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), ...(seam === undefined ? {} : { seam: seam as SeamRule }), ...(project === undefined ? {} : { project: project as ProjectRule }), ...(alts.length === 0 ? {} : { alts }) },
|
|
1776
1778
|
{ partsJson: join(out, 'rig', 'parts.json'), partsDir: join(out, 'rig', 'parts'), recomposite: join(out, 'render', 'recomposite_rig.png'), errorMap: join(out, 'render', ERROR_MAP_FILE) },
|
|
1777
1779
|
console.log,
|
|
1778
1780
|
);
|
|
@@ -1965,8 +1967,8 @@ function cmdBuild(args: string[]): number {
|
|
|
1965
1967
|
if (on.has('--all-sets') && flags.has('--set')) return usage('--set and --all-sets are two modes; give one');
|
|
1966
1968
|
const seam = flags.get('--seam');
|
|
1967
1969
|
if (seam !== undefined && !(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
|
|
1968
|
-
const project = flags.get('--project')
|
|
1969
|
-
if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
|
|
1970
|
+
const project = flags.get('--project');
|
|
1971
|
+
if (project !== undefined && !(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
|
|
1970
1972
|
const edges = pageEdgesOf(flags.get('--page-edges'));
|
|
1971
1973
|
if (!isPageEdges(edges)) return usage(edges);
|
|
1972
1974
|
const shape = packShapeOf(flags.get('--pack-shape'));
|
|
@@ -1988,7 +1990,7 @@ function cmdBuild(args: string[]): number {
|
|
|
1988
1990
|
const scratch = mkdtempSync(join(tmpdir(), 'rig-parts-build-'));
|
|
1989
1991
|
try {
|
|
1990
1992
|
const r = build(
|
|
1991
|
-
{ config, source, full, head, out, ...(seam === undefined ? {} : { seam: seam as SeamRule }), project: project as ProjectRule, loop, pageEdges: edges, packShape: shape, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), idleKeys: keys, ...(leadText === undefined ? {} : { leadIn: Number(leadText) }), seamPairs: on.has('--seam-pairs'), keepStills: on.has('--keep-stills'), noExtremes: on.has('--no-extremes'), ...(alts.length === 0 ? {} : { alts }), ...(flags.has('--set') ? { set: flags.get('--set') as string } : {}), allSets: on.has('--all-sets'), ...(flags.has('--environment') ? { environment: flags.get('--environment') as string } : {}), ...(flags.has('--wind-band') ? { windBand: flags.get('--wind-band') as string } : {}) },
|
|
1993
|
+
{ config, source, full, head, out, ...(seam === undefined ? {} : { seam: seam as SeamRule }), ...(project === undefined ? {} : { project: project as ProjectRule }), loop, pageEdges: edges, packShape: shape, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), idleKeys: keys, ...(leadText === undefined ? {} : { leadIn: Number(leadText) }), seamPairs: on.has('--seam-pairs'), keepStills: on.has('--keep-stills'), noExtremes: on.has('--no-extremes'), ...(alts.length === 0 ? {} : { alts }), ...(flags.has('--set') ? { set: flags.get('--set') as string } : {}), allSets: on.has('--all-sets'), ...(flags.has('--environment') ? { environment: flags.get('--environment') as string } : {}), ...(flags.has('--wind-band') ? { windBand: flags.get('--wind-band') as string } : {}) },
|
|
1992
1994
|
{ rig: rigcRunner(bin), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate'), stages: {
|
|
1993
1995
|
assemble: (input, outputs) => runBuildProcess({ stage: 'assemble', bin, input, outputs }, join(scratch, 'workers')),
|
|
1994
1996
|
rig: (input, _rigc, gateScratch) => runBuildProcess({ stage: 'rig', bin, input, scratch: gateScratch }, join(scratch, 'workers')),
|
package/docs/AUTHORING.md
CHANGED
|
@@ -148,6 +148,7 @@ read by no CPU stage).
|
|
|
148
148
|
| `seethrough.head_box` | inputs (cuts the head image there; optional at the first call); assemble — where the head run sits on the painting | **proposed** by `propose --head-box` from the full run, square, source pixels, shifted inside the painting when needed; the head run must then be fed exactly that crop. With one run (no `--head`, issue #359: an artist's own file, §4 *For artists*) it is left out — there is no crop to place — and a config that sets it is refused (`ASSEMBLE_SINGLE_RUN_HEAD_BOX`) |
|
|
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
|
+
| `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. |
|
|
151
152
|
| `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` |
|
|
152
153
|
| `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` |
|
|
153
154
|
| `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 |
|
|
@@ -4191,7 +4192,33 @@ takes every other top-most `alpha >= 250` pixel; neither rule takes a fringe pix
|
|
|
4191
4192
|
projected from 36,227 to 22,476 (`reference-sample`) and 76,801 to 49,672 (`reference-demo`), recomposite
|
|
4192
4193
|
error pixels from 4,512 to 4,116 and 11,050 to 9,820, and the check seam from 0.207 to
|
|
4193
4194
|
0.206 and 0.326 to 0.325, with no other check figure changed. The default stays the
|
|
4194
|
-
reference's so the examples stay comparable with it. `build` takes both flags
|
|
4195
|
+
reference's so the examples stay comparable with it. `build` takes both flags, and
|
|
4196
|
+
`config.assemble.project` sets the rule per character (an explicit `--project` wins).
|
|
4197
|
+
|
|
4198
|
+
Under `visible` the parts' edges are also resolved from the painting (issue #953,
|
|
4199
|
+
`src/paintingedges.ts`). A See-through run is capped at its resolution, so every run
|
|
4200
|
+
layer is enlarged into the rig and its alpha edge is a soft ramp the painting does not
|
|
4201
|
+
have; where two parts meet along a painted line each layer repaints the line on its own
|
|
4202
|
+
side of its ramp — a doubled line, a stair-step, or the background through a gap where
|
|
4203
|
+
neither is opaque. Within 3 rig px of where two parts' half-coverage lines meet (or a
|
|
4204
|
+
part's and the background's), the part drawn in front takes the painting's coverage
|
|
4205
|
+
along the axis from the colour behind to its own, its colour unmixed from the colour
|
|
4206
|
+
behind, and the part behind is made opaque under it in that colour, so the composite
|
|
4207
|
+
is the painting and the rim stays the occluding part's. Where a painted line is darker or
|
|
4208
|
+
lighter than both colours, the front part's alpha rises until the composite is within 20
|
|
4209
|
+
of the painting. A pixel is left as it was where
|
|
4210
|
+
a third part with alpha above 8 covers it, where the painting draws no edge between the
|
|
4211
|
+
two (contrast below 40, the recomposite's error limit), where either part is a patch,
|
|
4212
|
+
keeps the pixel, feathers it, or is a turn's moving pair. A part's art keeps its
|
|
4213
|
+
4-connected pieces (`CONTOUR_ONE_ISLAND` reads them so): a write that would split a piece
|
|
4214
|
+
or start a new one is left out. The assemble line says how
|
|
4215
|
+
many pixels it resolved and why it left the rest; the pixels it writes are
|
|
4216
|
+
`provenance.automatic[].stage: "paintingEdge"`. On the five public examples it lowered
|
|
4217
|
+
the recomposite's error pixels under `visible` from 11,780 to 765 (`demo`), 7,427 to
|
|
4218
|
+
703 (`reference-demo`), 3,965 to 243 (`reference-sample`), 6,749 to 2,190 (`scarf`)
|
|
4219
|
+
and 13,872 to 1,110 (`wind`), and the holes from 1,020 to 62, 453 to 40, 259 to 20,
|
|
4220
|
+
291 to 34 and 1,118 to 62; `check` passes the same bars as before on all five. A
|
|
4221
|
+
painting patch is read placed on the rig canvas, and is never written. `core` is unchanged.
|
|
4195
4222
|
|
|
4196
4223
|
**`rig --idle-keys ctl|direct`** (and `build --idle-keys`, which forwards the
|
|
4197
4224
|
value to its rig stage; without the flag, or with `ctl`, a build writes and prints
|
|
@@ -4492,8 +4519,9 @@ still ends in the unchanged class by the first comparison.
|
|
|
4492
4519
|
|
|
4493
4520
|
Automatic value changes are named `sourceProjection`, `extend`, `growRim`,
|
|
4494
4521
|
`limbCap`, `limbFeather`, `fringeRestore`, `seamOverride`, `armSplit`,
|
|
4495
|
-
`headFaceUnderfill`, `ghostCleanup` or `
|
|
4522
|
+
`headFaceUnderfill`, `ghostCleanup`, `paintingExtension` or `paintingEdge`. `limbSplit` and `limbHybrid` remain
|
|
4496
4523
|
recognized stage names, but their pure copies do not claim a value change.
|
|
4524
|
+
`paintingEdge` (under `visible` only) is a part's edge resolved from the painting;
|
|
4497
4525
|
`paintingExtension` copies unheld painting samples within an explicit band;
|
|
4498
4526
|
`painting_extension_px` counts that ownership assignment independently, even
|
|
4499
4527
|
when restoring raw-layer values makes its final value class unchanged.
|
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`, `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`, `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
|
|
@@ -225,6 +225,13 @@ part name, `[i]` an entry of a list):
|
|
|
225
225
|
Invalid values refuse as `CONFIG_FIELD_TYPE` at `config.assemble.seam`;
|
|
226
226
|
an absent field preserves existing output bytes.
|
|
227
227
|
|
|
228
|
+
`assemble.project` accepts `core` or `visible` (issue #953). CLI
|
|
229
|
+
`--project` overrides config, then the existing `core` default applies.
|
|
230
|
+
Invalid values refuse as `CONFIG_FIELD_TYPE` at `config.assemble.project`;
|
|
231
|
+
an absent field preserves existing output bytes. The bytes `visible` writes
|
|
232
|
+
are not promised across versions (issue #953 resolves its edges from the
|
|
233
|
+
painting).
|
|
234
|
+
|
|
228
235
|
`TY14` holds the set of field names in backticks above to the set of names the
|
|
229
236
|
loader's object checks spell (every `c.object(…)` in `src/config.ts`, with the lists
|
|
230
237
|
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.5.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": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"ag-psd": "^31.0.2",
|
|
71
|
-
"@rig-c/core": "3.2.
|
|
71
|
+
"@rig-c/core": "3.2.1"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@esotericsoftware/spine-core": "4.3.13",
|
package/src/assemble.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type SeamRule } from './seamrule.ts';
|
|
2
|
+
import { DEFAULT_PROJECT_RULE, type ProjectRule } from './projectrule.ts';
|
|
3
|
+
import { paintingEdges, type PaintingEdgeSkips } from './paintingedges.ts';
|
|
2
4
|
import { edgeLength } from './length.ts';
|
|
3
5
|
import { materialSource, type MaterialSource, materialRows, polygonMaterialEdges, type MaterialRow, type MaterialEdge, type PlacedArt } from './cutmaterial.ts';
|
|
4
6
|
import { unionTakenSources } from './takesource.ts';
|
|
@@ -191,23 +193,7 @@ export const SILHOUETTE_LIMIT = 40;
|
|
|
191
193
|
|
|
192
194
|
export { DEFAULT_SEAM_RULE, SEAM_RULES, type SeamRule } from './seamrule.ts';
|
|
193
195
|
|
|
194
|
-
|
|
195
|
-
* Where a layer may take the painting's pixel (`projectSource`). `core` is the
|
|
196
|
-
* reference's rule: the layer's top-most `alpha >= 250` pixels eroded by a 5x5
|
|
197
|
-
* square, so a part a few pixels wide has no core and none of its visible
|
|
198
|
-
* pixels is projected. `visible` keeps the erosion only where it guards
|
|
199
|
-
* something — along a rim where a later layer of the run is in front — and
|
|
200
|
-
* projects every other top-most `alpha >= 250` pixel of the layer.
|
|
201
|
-
*/
|
|
202
|
-
export type ProjectRule = 'core' | 'visible';
|
|
203
|
-
export const PROJECT_RULES: readonly ProjectRule[] = ['core', 'visible'];
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* The projection rule a run uses when none is named: the reference's, so the
|
|
207
|
-
* examples stay comparable with it. `visible` was measured on both public
|
|
208
|
-
* examples against it (the change that added the flag); it is opt-in.
|
|
209
|
-
*/
|
|
210
|
-
export const DEFAULT_PROJECT_RULE: ProjectRule = 'core';
|
|
196
|
+
export { DEFAULT_PROJECT_RULE, PROJECT_RULES, type ProjectRule } from './projectrule.ts';
|
|
211
197
|
|
|
212
198
|
// ---------------------------------------------------------------------------
|
|
213
199
|
// inputs
|
|
@@ -4598,6 +4584,8 @@ export interface AssembleResult {
|
|
|
4598
4584
|
hidden: HiddenArtUse[];
|
|
4599
4585
|
seamRule: SeamRule;
|
|
4600
4586
|
projectRule: ProjectRule;
|
|
4587
|
+
/** Issue #953: what `paintingEdges` resolved, under `projectRule: 'visible'` only. */
|
|
4588
|
+
paintingEdges?: PaintingEdgeReading;
|
|
4601
4589
|
depthReadings?: Array<{ from: string; reading: import('./depthsheets.ts').DepthReading }>;
|
|
4602
4590
|
/** Visible registered samples clamped within <1 source pixel of a crop. */
|
|
4603
4591
|
depthClamps?: Array<{ part: string; samples: number }>;
|
|
@@ -4613,6 +4601,14 @@ export interface AssembleResult {
|
|
|
4613
4601
|
capSplits: SplitCount[];
|
|
4614
4602
|
}
|
|
4615
4603
|
|
|
4604
|
+
/** Issue #953: the edge pixels `paintingEdges` resolved, the part pixels it changed, and the edge pixels it left, by reason. */
|
|
4605
|
+
export interface PaintingEdgeReading {
|
|
4606
|
+
resolved: number;
|
|
4607
|
+
unseparated: number;
|
|
4608
|
+
changed: number;
|
|
4609
|
+
skipped: PaintingEdgeSkips;
|
|
4610
|
+
}
|
|
4611
|
+
|
|
4616
4612
|
/** A hidden-art layer of a run (issue #359) and what read it: `cuts[i].under`, or a limb segment's cap; none is said. */
|
|
4617
4613
|
export interface HiddenArtUse {
|
|
4618
4614
|
/** `<run>:<layer>:hidden`. */
|
|
@@ -5588,6 +5584,48 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5588
5584
|
}
|
|
5589
5585
|
}
|
|
5590
5586
|
});
|
|
5587
|
+
// Issue #953: under `visible`, the parts' visible edges are the painting's (`paintingEdges`); See-through keeps the hidden surface.
|
|
5588
|
+
const edged = new Map<string, Mask>();
|
|
5589
|
+
let edgeReading: PaintingEdgeReading | null = null;
|
|
5590
|
+
if (projectRule === 'visible') {
|
|
5591
|
+
// paintingEdges reads every image as the whole rig canvas. A plan part's image already is one; a patch is its cut,
|
|
5592
|
+
// placed at record.x / record.y, so it is placed on a canvas here. The step never writes a patch, so a copy is enough.
|
|
5593
|
+
const onRig = (q: PlacedPart): Raster => {
|
|
5594
|
+
const { image, record } = q;
|
|
5595
|
+
if (image.width === W && image.height === H && record.x === 0 && record.y === 0) return image;
|
|
5596
|
+
const whole = newRaster(W, H);
|
|
5597
|
+
for (let y = 0; y < image.height; y++) {
|
|
5598
|
+
const ry = record.y + y;
|
|
5599
|
+
if (ry < 0 || ry >= H) continue;
|
|
5600
|
+
for (let x = 0; x < image.width; x++) {
|
|
5601
|
+
const rx = record.x + x;
|
|
5602
|
+
if (rx < 0 || rx >= W) continue;
|
|
5603
|
+
const s = (y * image.width + x) * 4;
|
|
5604
|
+
whole.data.set(image.data.subarray(s, s + 4), (ry * W + rx) * 4);
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
return whole;
|
|
5608
|
+
};
|
|
5609
|
+
const e = paintingEdges(stack.map(onRig), srcr, {
|
|
5610
|
+
painted: paintedFigure(srcr),
|
|
5611
|
+
editable: order.map((d) => !('patch' in d)),
|
|
5612
|
+
keep: keepInOrder,
|
|
5613
|
+
feathered: featheredInOrder,
|
|
5614
|
+
movingPair: input.turnMovingPair === undefined || input.turnMovingPair === null ? null : (a, b) => (input.turnMovingPair as (x: string, y: string) => boolean)(stack[a].record.name, stack[b].record.name),
|
|
5615
|
+
written: (i, p) => {
|
|
5616
|
+
writersInOrder[i][p] = AW.paintingEdge;
|
|
5617
|
+
const d = order[i];
|
|
5618
|
+
if (!('patch' in d)) {
|
|
5619
|
+
const region = pieceAt(d).b.region;
|
|
5620
|
+
if (region !== null && stack[i].image.data[p * 4 + 3] > 0) region.data[p] = 1;
|
|
5621
|
+
}
|
|
5622
|
+
},
|
|
5623
|
+
});
|
|
5624
|
+
order.forEach((d, i) => {
|
|
5625
|
+
if (!('patch' in d) && e.changed[i].data.some((v) => v === 1)) edged.set(pieceAt(d).name, e.changed[i]);
|
|
5626
|
+
});
|
|
5627
|
+
edgeReading = { resolved: e.resolved, unseparated: e.unseparated, changed: e.changed.reduce((a, m) => a + m.data.reduce((x, v) => x + v, 0), 0), skipped: e.skipped };
|
|
5628
|
+
}
|
|
5591
5629
|
const pushedOf: FringePushed[][] = plan.map(() => []);
|
|
5592
5630
|
const pushedOfCut: FringePushed[][] = cuts.map(() => []);
|
|
5593
5631
|
const pushedOfLimb: FringePushed[][] = lim.parts.map(() => []);
|
|
@@ -5610,6 +5648,18 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5610
5648
|
...cuts.map((q, i) => ({ name: q.into, b: pieces[i] as Built, pushed: pushedOfCut[i], at: `config.assemble.cuts[${i}]`, cutFrom: q.from, set: (pp: PlacedPart) => (cutPlaced[i] = pp) })),
|
|
5611
5649
|
...lim.parts.map((q, i) => ({ name: q.part, b: limbPieces[i] as Built, pushed: pushedOfLimb[i], at: `config.limbs.${q.limb}.segments[${q.segment}]`, cutFrom: null, limb: q.record, set: (pp: PlacedPart) => (limbPlaced[i] = pp) })),
|
|
5612
5650
|
].filter((q) => !taken.has(q.name));
|
|
5651
|
+
/** Issue #953: the part's projected pixels `paintingEdges` rewrote, less those an earlier exclusion already took out of `source_px_taken`. */
|
|
5652
|
+
const edgedTaken = (name: string, st: ProjectionStats, region: Mask | null): number => {
|
|
5653
|
+
const m = edged.get(name);
|
|
5654
|
+
if (m === undefined) return 0;
|
|
5655
|
+
let k = 0;
|
|
5656
|
+
for (let p = 0; p < W * H; p++) {
|
|
5657
|
+
if (m.data[p] !== 1 || st.takenMask.data[p] !== 1 || (region !== null && region.data[p] !== 1)) continue;
|
|
5658
|
+
if (paintingGains.get(name)?.data[p] === 1 || retakeByName.get(name)?.data[p] === 1 || yields.get(name)?.mask.data[p] === 1) continue;
|
|
5659
|
+
k++;
|
|
5660
|
+
}
|
|
5661
|
+
return k;
|
|
5662
|
+
};
|
|
5613
5663
|
pieceList.forEach(({ name, b, pushed: pushedHere, at, cutFrom, limb, set }) => {
|
|
5614
5664
|
const { key, r, st, extendKeys, trace, merged, region } = b;
|
|
5615
5665
|
// A pixel the merge wrote is judged in the extend layer's run, every other
|
|
@@ -5625,7 +5675,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5625
5675
|
// A pixel outside the piece's region is a base pixel filled from a cut's under image (issue #193): occluded, never visible.
|
|
5626
5676
|
if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE && own.clear.data[p] === 1 && (region === null || region.data[p] === 1)) visible.data[p] = 1;
|
|
5627
5677
|
if (paintingGains.get(name)?.data[p] === 1) visible.data[p] = 1;
|
|
5628
|
-
if (paintingGains.get(name)?.data[p] !== 1 && 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;
|
|
5678
|
+
if (edged.get(name)?.data[p] !== 1 && paintingGains.get(name)?.data[p] !== 1 && 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;
|
|
5629
5679
|
}
|
|
5630
5680
|
const { x0, y0, x1, y1, opaque } = measureBox(r);
|
|
5631
5681
|
if (opaque === 0) {
|
|
@@ -5650,7 +5700,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5650
5700
|
visible_px: vc.visible_px,
|
|
5651
5701
|
occluded_px: vc.occluded_px,
|
|
5652
5702
|
projected_core_px: region === null ? st.core : count(st.coreMask, region),
|
|
5653
|
-
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)!)) - (paintingGains.has(name) ? count(st.takenMask, paintingGains.get(name)!) : 0),
|
|
5703
|
+
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)!)) - (paintingGains.has(name) ? count(st.takenMask, paintingGains.get(name)!) : 0) - edgedTaken(name, st, region),
|
|
5654
5704
|
visible_not_projected_px: vc.visible_not_projected_px,
|
|
5655
5705
|
refused_drift_px: region === null ? st.refused : count(st.refusedMask, region),
|
|
5656
5706
|
merged_px: merged,
|
|
@@ -5771,6 +5821,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5771
5821
|
hidden: [...hiddenUses].map(([layer, readBy]) => ({ layer, readBy })),
|
|
5772
5822
|
seamRule,
|
|
5773
5823
|
projectRule,
|
|
5824
|
+
...(edgeReading === null ? {} : { paintingEdges: edgeReading }),
|
|
5774
5825
|
...(headFacePrep.underfillPx > 0 ? { headFaceUnderfillPx: headFacePrep.underfillPx } : {}),
|
|
5775
5826
|
excluded,
|
|
5776
5827
|
islands: islandKept.counts,
|
package/src/build.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_SEAM_RULE } from './seamrule.ts';
|
|
2
|
+
import { DEFAULT_PROJECT_RULE } from './projectrule.ts';
|
|
3
|
+
import { EDGE_CONTRAST } from './paintingedges.ts';
|
|
2
4
|
import { SETUP_PAINTING_LINE, setupPaintingLines } from './setuppainting.ts';
|
|
3
5
|
import { boundaryColourLines } from './boundarycolour.ts';
|
|
4
6
|
import { writeMaterialSources } from './cutmaterialio.ts';
|
|
@@ -169,7 +171,8 @@ export interface AssembleStageInput {
|
|
|
169
171
|
head: string | null;
|
|
170
172
|
config: string;
|
|
171
173
|
seam?: SeamRule;
|
|
172
|
-
project
|
|
174
|
+
/** `--project`; absent, `config.assemble.project`, then {@link DEFAULT_PROJECT_RULE} (issue #953). */
|
|
175
|
+
project?: ProjectRule;
|
|
173
176
|
/** `--alt` entries, merged with `config.assemble.alternates` by set name (issue #454). */
|
|
174
177
|
alts?: readonly AlternateCliInput[];
|
|
175
178
|
/**
|
|
@@ -335,6 +338,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
335
338
|
// regions and motion, so it requires only what it reads (issue #20).
|
|
336
339
|
const cfg = loadEarlyConfig(input.config, 'assemble');
|
|
337
340
|
const seam = input.seam ?? cfg.assemble.seam ?? DEFAULT_SEAM_RULE;
|
|
341
|
+
const project = input.project ?? cfg.assemble.project ?? DEFAULT_PROJECT_RULE;
|
|
338
342
|
const src = readSource(input.source);
|
|
339
343
|
const read = readRuns(input.full, input.head);
|
|
340
344
|
// Issue #946: every declared receipt is held to its file, and under `required` every edited image needs one, before anything is assembled.
|
|
@@ -346,7 +350,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
346
350
|
const fields = stageFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
|
|
347
351
|
const under = readUnders(input.config, fields.cuts);
|
|
348
352
|
const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
|
|
349
|
-
const result = assemble({ source: src, full: runs.full, head: runs.head, depth: cfg.depth, depthBase: dirname(resolve(input.config)), ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: seam, projectRule:
|
|
353
|
+
const result = assemble({ source: src, full: runs.full, head: runs.head, depth: cfg.depth, depthBase: dirname(resolve(input.config)), ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: seam, projectRule: project });
|
|
350
354
|
|
|
351
355
|
// Issue #454: alternate painting sets, assembled before any file is written.
|
|
352
356
|
const configDir = dirname(resolve(input.config));
|
|
@@ -688,7 +692,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
688
692
|
...(fields.turnMovingPair === undefined ? {} : { turnMovingPair: fields.turnMovingPair }),
|
|
689
693
|
...(fields.turn === undefined ? {} : { turn: fields.turn, regions: fields.regions, meshes: fields.meshes, motion: fields.motion }),
|
|
690
694
|
seamRule: seam,
|
|
691
|
-
projectRule:
|
|
695
|
+
projectRule: project,
|
|
692
696
|
...(altExclude.length === 0 ? {} : { exclude: altExclude }),
|
|
693
697
|
...(altIslands.length === 0 ? {} : { limbIslands: altIslands }),
|
|
694
698
|
...(altOthers === undefined ? {} : { otherLimbs: alternateLayerOthers(altOthers, selectedLayers.groups) }),
|
|
@@ -805,6 +809,11 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
805
809
|
if (figurePath !== null) writePng(figurePath, result.figure as Raster);
|
|
806
810
|
const [W, H] = result.parts.rig_size;
|
|
807
811
|
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}`);
|
|
812
|
+
// Issue #953: what the painting's edges resolved, under the visible rule only.
|
|
813
|
+
if (result.paintingEdges !== undefined) {
|
|
814
|
+
const e = result.paintingEdges;
|
|
815
|
+
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`);
|
|
816
|
+
}
|
|
808
817
|
// Issue #359: one run, said, so a reader of the lines knows no head run was placed.
|
|
809
818
|
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`);
|
|
810
819
|
// Issue #366: a file that is not the resolution square was padded to it; said, with where parts.json records it. None for a square file.
|
|
@@ -2241,7 +2250,8 @@ export interface BuildInput {
|
|
|
2241
2250
|
head: string | null;
|
|
2242
2251
|
out: string;
|
|
2243
2252
|
seam?: SeamRule;
|
|
2244
|
-
project
|
|
2253
|
+
/** `--project`; absent, `config.assemble.project`, then {@link DEFAULT_PROJECT_RULE} (issue #953). */
|
|
2254
|
+
project?: ProjectRule;
|
|
2245
2255
|
loop: boolean;
|
|
2246
2256
|
/** `--page-edges` for both packed builds, the rig stage's gate and the check's artifact. */
|
|
2247
2257
|
pageEdges: PageEdges;
|
|
@@ -2446,7 +2456,8 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
|
|
|
2446
2456
|
// Issue #607: the bytes this read parsed are the ones check.json's config record hashes, under the path as typed.
|
|
2447
2457
|
const loaded = loadConfigWithBytes(input.config);
|
|
2448
2458
|
const seam = input.seam ?? loaded.config.assemble.seam ?? DEFAULT_SEAM_RULE;
|
|
2449
|
-
|
|
2459
|
+
const project = input.project ?? loaded.config.assemble.project ?? DEFAULT_PROJECT_RULE;
|
|
2460
|
+
log(`rig-parts build: ${input.config} -> ${out}, seam rule ${seam}, projection rule ${project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ? `, with the idle loop${input.leadIn === undefined ? '' : ` after a lead-in of ${input.leadIn} period(s)`}` : ''}`);
|
|
2450
2461
|
configRecord = inputFileRecord(loaded.bytes, input.config);
|
|
2451
2462
|
req = buildRequirements(input.requirements, loaded.config.requirements, input.config);
|
|
2452
2463
|
// The same for the requirements file's own shape; its names resolve against the rig, in the check, before it builds.
|
|
@@ -2456,7 +2467,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
|
|
|
2456
2467
|
// And the steady-wind band spec (issue #590), as check reads it.
|
|
2457
2468
|
if (input.windBand !== undefined) readWindBand(input.windBand);
|
|
2458
2469
|
stages.assemble(
|
|
2459
|
-
{ source: input.source, full: input.full, head: input.head, config: input.config, seam, project
|
|
2470
|
+
{ source: input.source, full: input.full, head: input.head, config: input.config, seam, project, ...(req === null ? {} : { requirements: req.read }), ...(input.alts === undefined ? {} : { alts: input.alts }) },
|
|
2460
2471
|
{ partsJson: join(out, 'parts.json'), partsDir: join(out, 'parts'), recomposite: join(out, 'recomposite_rig.png'), errorMap: join(out, ERROR_MAP_FILE) },
|
|
2461
2472
|
prefixed('assemble'),
|
|
2462
2473
|
);
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SEAM_RULES, type SeamRule } from './seamrule.ts';
|
|
2
|
+
import { PROJECT_RULES, type ProjectRule } from './projectrule.ts';
|
|
2
3
|
import type { TakeLayers } from './takelayers.ts';
|
|
3
4
|
import { paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
|
|
4
5
|
import { baseAnchorProblems, type BaseAnchor } from './baseanchor.ts';
|
|
@@ -304,6 +305,8 @@ export interface AlternateSetConfig {
|
|
|
304
305
|
export interface Assemble {
|
|
305
306
|
/** Optional per-character seam rule; an explicit CLI flag wins. */
|
|
306
307
|
seam?: SeamRule;
|
|
308
|
+
/** Optional per-character projection rule (issue #953); an explicit CLI flag wins. */
|
|
309
|
+
project?: ProjectRule;
|
|
307
310
|
/** Verified named replacements from another See-through run (#866). */
|
|
308
311
|
take?: TakeLayers[];
|
|
309
312
|
/** Issue #946: receipts for images edited in an image editor; absent, no file is read and no byte moves. */
|
|
@@ -1626,7 +1629,7 @@ export interface EarlyConfig {
|
|
|
1626
1629
|
depth?: DepthConfig;
|
|
1627
1630
|
key: string;
|
|
1628
1631
|
seethrough: SeeThrough;
|
|
1629
|
-
assemble: { rig_scale: number; seam?: SeamRule; take?: TakeLayers[]; editor_receipts?: EditorReceipts; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
|
|
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[] };
|
|
1630
1633
|
}
|
|
1631
1634
|
|
|
1632
1635
|
/** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
|
|
@@ -1702,9 +1705,9 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1702
1705
|
if (door === 'layers') {
|
|
1703
1706
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1704
1707
|
if ('assemble' in t) {
|
|
1705
|
-
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', 'editor_receipts']);
|
|
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']);
|
|
1706
1709
|
if (a !== null) {
|
|
1707
|
-
|
|
1710
|
+
checkAssembleRules(c, a);
|
|
1708
1711
|
if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
|
|
1709
1712
|
if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
|
|
1710
1713
|
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.head_face_underfill', `is ${show(a.head_face_underfill)}; true or false is required`);
|
|
@@ -2503,17 +2506,20 @@ function checkEditorReceipts(c: Check, value: Json): void {
|
|
|
2503
2506
|
});
|
|
2504
2507
|
}
|
|
2505
2508
|
|
|
2506
|
-
function
|
|
2509
|
+
function checkAssembleRules(c: Check, a: Record<string, Json>): void {
|
|
2507
2510
|
if ('seam' in a && !(SEAM_RULES as readonly unknown[]).includes(a.seam)) {
|
|
2508
2511
|
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.seam', `is ${show(a.seam)}; one of ${SEAM_RULES.join(', ')} is required`);
|
|
2509
2512
|
}
|
|
2513
|
+
if ('project' in a && !(PROJECT_RULES as readonly unknown[]).includes(a.project)) {
|
|
2514
|
+
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.project', `is ${show(a.project)}; one of ${PROJECT_RULES.join(', ')} is required`);
|
|
2515
|
+
}
|
|
2510
2516
|
}
|
|
2511
2517
|
|
|
2512
2518
|
function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
|
|
2513
2519
|
const required = sectionRequired(door, 'assemble');
|
|
2514
|
-
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', 'editor_receipts']);
|
|
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']);
|
|
2515
2521
|
if (a === null) return { parts: [], patches: [], cuts: [] };
|
|
2516
|
-
|
|
2522
|
+
checkAssembleRules(c, a);
|
|
2517
2523
|
if (required.includes('plan') && !('plan' in a)) {
|
|
2518
2524
|
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');
|
|
2519
2525
|
}
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
2
|
+
import { connectedComponents } from './raster/components.ts';
|
|
3
|
+
import { type Mask, newMask, type Raster, RasterError } from './raster/types.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Issue #953, under `projectRule: 'visible'` only: the parts' visible edges are
|
|
7
|
+
* resolved from the painting, at the rig's own density, and See-through keeps
|
|
8
|
+
* what lies under another part (its hidden surface) and the partition into
|
|
9
|
+
* parts. Not in the reference.
|
|
10
|
+
*
|
|
11
|
+
* Why: a See-through run is capped at its resolution (1024 on the public
|
|
12
|
+
* examples, over the padded square of the painting), so every run layer is
|
|
13
|
+
* enlarged into the rig and its alpha edge is a soft ramp the painting does not
|
|
14
|
+
* have; where two parts meet along a painted line each layer repaints the line
|
|
15
|
+
* on its own side of its own ramp, which reads as a doubled line, a stair-step
|
|
16
|
+
* and, where neither layer is opaque, the background through a gap. Projection
|
|
17
|
+
* (`projectSource`) recolours only a layer's opaque area, so it cannot reach
|
|
18
|
+
* either.
|
|
19
|
+
*
|
|
20
|
+
* The step reads the final stack (every part on the full rig canvas, in draw
|
|
21
|
+
* order) and the painting resampled into the rig, and writes alpha and colour
|
|
22
|
+
* at the pixels near a visible outline. Everything else is unchanged.
|
|
23
|
+
*
|
|
24
|
+
* ⚖️ Invariant. Let OWNER(p) be the top-most part with alpha above
|
|
25
|
+
* {@link EDGE_OWNER_ALPHA} at p, or the background (-1): See-through's own
|
|
26
|
+
* half-coverage line, which says which parts meet and roughly where. A pixel
|
|
27
|
+
* is an EDGE pixel when another owner lies within {@link EDGE_RADIUS}
|
|
28
|
+
* (Chebyshev) — the nearest one, ties to the part drawn in front. Of the pair
|
|
29
|
+
* (p's owner, that nearest other), FRONT is the one drawn in front and BEHIND
|
|
30
|
+
* the other (the background is behind every part). Inside the
|
|
31
|
+
* `(2 * EDGE_RADIUS + 1)²` window around p:
|
|
32
|
+
*
|
|
33
|
+
* - the behind colour `Cb` is the per-channel median of the painting over the
|
|
34
|
+
* window's BEHIND-owned pixels farthest from FRONT (the window pixels least
|
|
35
|
+
* likely to be the edge), so a painted line between the two never sets it;
|
|
36
|
+
* - the front reference `Cf` is the painting at the window's FRONT-owned pixel
|
|
37
|
+
* farthest from `Cb` (max channel, the distance every other assemble limit
|
|
38
|
+
* is in; first in scan order on a tie) — a painted line along FRONT's
|
|
39
|
+
* outline counts as FRONT, which gives the line to the part drawn in front,
|
|
40
|
+
* the part whose outline it is in the rig;
|
|
41
|
+
* - FRONT's coverage at p is P's position along `Cb -> Cf` (integer dot
|
|
42
|
+
* products), normalised by a trimap: 0 at the highest position among the
|
|
43
|
+
* BEHIND-side pixels above, 1 at the lowest among FRONT's window pixels
|
|
44
|
+
* farthest from BEHIND. Where those two sides do not separate along the axis
|
|
45
|
+
* (a side is all edge, or the textures overlap) — counted as `unseparated` —
|
|
46
|
+
* the ends are `Cb` and `Cf` themselves. On a mix of the two colours it is
|
|
47
|
+
* the mixing weight, and a texture of BEHIND across that axis reads as 0
|
|
48
|
+
* rather than as FRONT (measured: a distance-only weight left a 10–27 % halo
|
|
49
|
+
* of the face over the hair at 3–4 rig px from the face's edge on
|
|
50
|
+
* reference-sample);
|
|
51
|
+
* - on FRONT's side of See-through's line, a pixel whose colour FRONT itself
|
|
52
|
+
* holds deeper in (within half of {@link EDGE_CONTRAST}, and nearer that
|
|
53
|
+
* than any BEHIND-side pixel) is FRONT's interior — a lining, a highlight —
|
|
54
|
+
* and reads 1, however it falls along the axis.
|
|
55
|
+
*
|
|
56
|
+
* A painted pixel no part covers above the owner floor — the gap between two
|
|
57
|
+
* soft rims — is owned by the nearest part (within `2 * EDGE_RADIUS + 2`), so
|
|
58
|
+
* the gap is never read as the background's colour.
|
|
59
|
+
*
|
|
60
|
+
* The ring rule. Rings are counted in Chebyshev distance from the other owner.
|
|
61
|
+
* FRONT's coverage never rises going away from FRONT and never falls going
|
|
62
|
+
* into it: on the inner ring (FRONT-owned, next to the line) it is at least
|
|
63
|
+
* that of its resolved outer-ring neighbours (a painted line just outside
|
|
64
|
+
* See-through's line is FRONT's outline, so what lies between is FRONT's too);
|
|
65
|
+
* deeper inside it is the greater of See-through's own alpha and the
|
|
66
|
+
* painting's (the ramp past the half line is filled; a light interior is never
|
|
67
|
+
* holed); farther out it is no more than its neighbour one ring nearer.
|
|
68
|
+
* Resolved ring by ring, outer before inner.
|
|
69
|
+
*
|
|
70
|
+
* FRONT's alpha becomes that coverage (as 0..255, rounded; within
|
|
71
|
+
* {@link OPAQUE_ALPHA_ABOVE} of 0 or 255 it is snapped there), and its colour
|
|
72
|
+
* the painting unmixed from `Cb` (`Cb + (P - Cb) / a`, clamped); at `a = 1` that
|
|
73
|
+
* is the painting's pixel. Where that colour clamps so the composite would
|
|
74
|
+
* miss the painting by more than half of {@link EDGE_CONTRAST} (a painted line
|
|
75
|
+
* darker or lighter than both colours), FRONT's alpha rises to the least that
|
|
76
|
+
* brings it within. Where FRONT does not cover p fully and BEHIND is a
|
|
77
|
+
* part, BEHIND is made opaque there in `Cb` (in the painting's own colour
|
|
78
|
+
* where FRONT's alpha is 0), so the flat composite at p is the painting, the
|
|
79
|
+
* rim stays FRONT's, and BEHIND's hidden surface past the window is untouched.
|
|
80
|
+
*
|
|
81
|
+
* A pixel is left as it was — and counted, by reason — when a part drawn in
|
|
82
|
+
* front of FRONT, or between BEHIND and FRONT, has alpha above
|
|
83
|
+
* {@link OPAQUE_ALPHA_ABOVE} there (its coverage is not modelled: a
|
|
84
|
+
* translucent part is never erased; at or below that floor a part is not
|
|
85
|
+
* opaque anywhere in this package, and it is left as it was); when
|
|
86
|
+
* `|Cf - Cb|` (max channel) is below {@link EDGE_CONTRAST} (the painting draws
|
|
87
|
+
* no edge between the two: a pair under the recomposite's error limit cannot
|
|
88
|
+
* hold an error pixel); when
|
|
89
|
+
* either part may not be edited (`editable`: a painting patch is the painting
|
|
90
|
+
* already); when either part keeps the pixel (`keep`, issue #637), feathers it
|
|
91
|
+
* (`feathered`, a limb's authored soft edge) or the two are a turn's moving
|
|
92
|
+
* pair; or when the window holds no BEHIND-owned pixel.
|
|
93
|
+
*
|
|
94
|
+
* A part's art (alpha above {@link OPAQUE_ALPHA_ABOVE}) keeps its pieces,
|
|
95
|
+
* read 4-connected as `CONTOUR_ONE_ISLAND` reads them: where the step would
|
|
96
|
+
* split a piece, each cut-off part is joined back to the piece's largest part
|
|
97
|
+
* along the path that restores the fewest lowered pixels, and those are not
|
|
98
|
+
* written; a new piece
|
|
99
|
+
* holding none of the part's art is not written (`pieces`, counted per part
|
|
100
|
+
* and pixel). Where that leaves out BEHIND's fill under FRONT, FRONT takes the
|
|
101
|
+
* painting's pixel at full alpha instead, under the same rule.
|
|
102
|
+
*
|
|
103
|
+
* Every decision is read off the stack as it stood before the step, then
|
|
104
|
+
* applied, so the result does not depend on the order pixels are visited.
|
|
105
|
+
* Deterministic: integer rig arithmetic, medians by sort, no float reduction
|
|
106
|
+
* whose order varies.
|
|
107
|
+
*/
|
|
108
|
+
export const EDGE_RADIUS = 3;
|
|
109
|
+
/** The owner's alpha floor: above it a part covers a pixel. Equal to `COVERED_ALPHA` in `src/assemble.ts` (held by the selftest). */
|
|
110
|
+
export const EDGE_OWNER_ALPHA = 128;
|
|
111
|
+
/** The least front contrast resolved, max channel. Equal to `ERROR_LIMIT` in `src/assemble.ts` (held by the selftest). */
|
|
112
|
+
export const EDGE_CONTRAST = 40;
|
|
113
|
+
|
|
114
|
+
/** Why an edge pixel was left as it was; see {@link paintingEdges}. */
|
|
115
|
+
export interface PaintingEdgeSkips {
|
|
116
|
+
cover: number;
|
|
117
|
+
contrast: number;
|
|
118
|
+
locked: number;
|
|
119
|
+
noBehind: number;
|
|
120
|
+
/** Part writes left out so a part's art keeps its 4-connected pieces (counted per part and pixel, not per edge pixel). */
|
|
121
|
+
pieces: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface PaintingEdgeResult {
|
|
125
|
+
/** Per part (stack index), the pixels whose RGBA the step changed. */
|
|
126
|
+
changed: Mask[];
|
|
127
|
+
/** Edge pixels resolved (whether or not a value changed), and the ones left. */
|
|
128
|
+
resolved: number;
|
|
129
|
+
/** Of `resolved`, the pixels whose two far sides did not separate along the axis, read against `Cb` and `Cf` themselves. */
|
|
130
|
+
unseparated: number;
|
|
131
|
+
skipped: PaintingEdgeSkips;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface PaintingEdgeOptions {
|
|
135
|
+
/**
|
|
136
|
+
* Per rig pixel, 1 where the painting shows figure (`paintedFigure` in
|
|
137
|
+
* `src/assemble.ts`). A figure pixel no part covers above
|
|
138
|
+
* {@link EDGE_OWNER_ALPHA} — a gap between two parts' soft rims — is owned by
|
|
139
|
+
* the nearest part within the cap, not the background: the painting holds
|
|
140
|
+
* figure there, and taking the behind colour from it would take a painted line.
|
|
141
|
+
*/
|
|
142
|
+
painted: Uint8Array;
|
|
143
|
+
/** Per part: whether the step may write it (false for a painting patch). */
|
|
144
|
+
editable: readonly boolean[];
|
|
145
|
+
keep?: ReadonlyArray<Mask | null>;
|
|
146
|
+
feathered?: ReadonlyArray<Mask | null>;
|
|
147
|
+
/** Two parts a turn moves against each other; their shared edge is left alone. */
|
|
148
|
+
movingPair?: ((a: number, b: number) => boolean) | null;
|
|
149
|
+
/** Called once per part and pixel whose RGBA changed. */
|
|
150
|
+
written?: (part: number, pixel: number) => void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Chebyshev distance to the nearest set pixel, capped at `cap` (two-pass, exact for the 8-neighbour metric). */
|
|
154
|
+
function chebyshev(set: Uint8Array, W: number, H: number, cap: number): Uint8Array {
|
|
155
|
+
const d = new Uint8Array(W * H).fill(cap);
|
|
156
|
+
for (let p = 0; p < W * H; p++) if (set[p] === 1) d[p] = 0;
|
|
157
|
+
for (let y = 0; y < H; y++) {
|
|
158
|
+
for (let x = 0; x < W; x++) {
|
|
159
|
+
const p = y * W + x;
|
|
160
|
+
let v = d[p];
|
|
161
|
+
if (v === 0) continue;
|
|
162
|
+
if (x > 0) v = Math.min(v, d[p - 1] + 1);
|
|
163
|
+
if (y > 0) {
|
|
164
|
+
v = Math.min(v, d[p - W] + 1);
|
|
165
|
+
if (x > 0) v = Math.min(v, d[p - W - 1] + 1);
|
|
166
|
+
if (x < W - 1) v = Math.min(v, d[p - W + 1] + 1);
|
|
167
|
+
}
|
|
168
|
+
d[p] = Math.min(v, cap);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (let y = H - 1; y >= 0; y--) {
|
|
172
|
+
for (let x = W - 1; x >= 0; x--) {
|
|
173
|
+
const p = y * W + x;
|
|
174
|
+
let v = d[p];
|
|
175
|
+
if (v === 0) continue;
|
|
176
|
+
if (x < W - 1) v = Math.min(v, d[p + 1] + 1);
|
|
177
|
+
if (y < H - 1) {
|
|
178
|
+
v = Math.min(v, d[p + W] + 1);
|
|
179
|
+
if (x < W - 1) v = Math.min(v, d[p + W + 1] + 1);
|
|
180
|
+
if (x > 0) v = Math.min(v, d[p + W - 1] + 1);
|
|
181
|
+
}
|
|
182
|
+
d[p] = Math.min(v, cap);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return d;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function maxChannel(a: Uint8ClampedArray, i: number, c: readonly number[]): number {
|
|
189
|
+
return Math.max(Math.abs(a[i] - c[0]), Math.abs(a[i + 1] - c[1]), Math.abs(a[i + 2] - c[2]));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Lower median of a list of integers (sorted copy; deterministic). */
|
|
193
|
+
function median(v: number[]): number {
|
|
194
|
+
const s = v.slice().sort((a, b) => a - b);
|
|
195
|
+
return s[(s.length - 1) >> 1];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface Decision {
|
|
199
|
+
p: number;
|
|
200
|
+
front: number;
|
|
201
|
+
behind: number;
|
|
202
|
+
/** FRONT's coverage read off the painting (0..255, before the ring rule), and FRONT's own alpha there before the step. */
|
|
203
|
+
matte: number;
|
|
204
|
+
own: number;
|
|
205
|
+
/** Which side of See-through's half-coverage line p is on (FRONT-owned or not), and its distance to the other owner. */
|
|
206
|
+
inside: boolean;
|
|
207
|
+
ring: number;
|
|
208
|
+
cb: [number, number, number];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** See the invariant above {@link EDGE_RADIUS}. Modifies `parts` (full rig canvases, draw order) in place. */
|
|
212
|
+
export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEdgeOptions): PaintingEdgeResult {
|
|
213
|
+
const W = srcr.width;
|
|
214
|
+
const H = srcr.height;
|
|
215
|
+
const n = parts.length;
|
|
216
|
+
// Every part is read as the whole rig canvas; a cropped image would be read at the wrong place.
|
|
217
|
+
parts.forEach((r, i) => {
|
|
218
|
+
if (r.width !== W || r.height !== H) throw new RasterError(`paintingEdges: part ${i} is ${r.width}x${r.height}; the rig canvas ${W}x${H} is required (place a cropped part on it first)`);
|
|
219
|
+
});
|
|
220
|
+
const R = EDGE_RADIUS;
|
|
221
|
+
const cap = 2 * R + 2;
|
|
222
|
+
const owner = new Int16Array(W * H).fill(-1);
|
|
223
|
+
for (let p = 0; p < W * H; p++) {
|
|
224
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
225
|
+
if (parts[i].data[p * 4 + 3] > EDGE_OWNER_ALPHA) {
|
|
226
|
+
owner[p] = i;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// A painted pixel no part covers goes to the nearest part (ties to the one drawn in front); farther than the cap, it stays background.
|
|
232
|
+
{
|
|
233
|
+
const near: Uint8Array[] = [];
|
|
234
|
+
for (let k = 0; k < n; k++) {
|
|
235
|
+
const set = new Uint8Array(W * H);
|
|
236
|
+
for (let p = 0; p < W * H; p++) if (owner[p] === k) set[p] = 1;
|
|
237
|
+
near.push(chebyshev(set, W, H, cap));
|
|
238
|
+
}
|
|
239
|
+
const fill: Array<[number, number]> = [];
|
|
240
|
+
for (let p = 0; p < W * H; p++) {
|
|
241
|
+
if (owner[p] !== -1 || options.painted[p] !== 1) continue;
|
|
242
|
+
let best = -1;
|
|
243
|
+
let bd = cap;
|
|
244
|
+
for (let k = n - 1; k >= 0; k--) if (near[k][p] < bd) { best = k; bd = near[k][p]; }
|
|
245
|
+
if (best >= 0) fill.push([p, best]);
|
|
246
|
+
}
|
|
247
|
+
for (const [p, k] of fill) owner[p] = k;
|
|
248
|
+
}
|
|
249
|
+
// Distance to each owner, the background at index n.
|
|
250
|
+
const dist: Uint8Array[] = [];
|
|
251
|
+
for (let k = 0; k <= n; k++) {
|
|
252
|
+
const set = new Uint8Array(W * H);
|
|
253
|
+
const label = k === n ? -1 : k;
|
|
254
|
+
let any = false;
|
|
255
|
+
for (let p = 0; p < W * H; p++) {
|
|
256
|
+
if (owner[p] === label) {
|
|
257
|
+
set[p] = 1;
|
|
258
|
+
any = true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
dist.push(any ? chebyshev(set, W, H, cap) : new Uint8Array(W * H).fill(cap));
|
|
262
|
+
}
|
|
263
|
+
const distOf = (label: number): Uint8Array => dist[label < 0 ? n : label];
|
|
264
|
+
const skipped: PaintingEdgeSkips = { cover: 0, contrast: 0, locked: 0, noBehind: 0, pieces: 0 };
|
|
265
|
+
const decisions: Decision[] = [];
|
|
266
|
+
let resolved = 0;
|
|
267
|
+
let unseparated = 0;
|
|
268
|
+
for (let y = 0; y < H; y++) {
|
|
269
|
+
for (let x = 0; x < W; x++) {
|
|
270
|
+
const p = y * W + x;
|
|
271
|
+
const a = owner[p];
|
|
272
|
+
// The nearest other owner within R; ties to the one drawn in front (the background is behind every part).
|
|
273
|
+
let b = -2;
|
|
274
|
+
let bd = R + 1;
|
|
275
|
+
for (let k = n; k >= 0; k--) {
|
|
276
|
+
const label = k === n ? -1 : k;
|
|
277
|
+
if (label === a) continue;
|
|
278
|
+
const d = dist[k][p];
|
|
279
|
+
if (d < bd || (d === bd && b !== -2 && label > b)) {
|
|
280
|
+
if (d <= R) {
|
|
281
|
+
b = label;
|
|
282
|
+
bd = d;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (b === -2) continue;
|
|
287
|
+
const front = Math.max(a, b);
|
|
288
|
+
const behind = Math.min(a, b);
|
|
289
|
+
// Coverage the two-layer model cannot carry: anything in front of FRONT, or between BEHIND and FRONT.
|
|
290
|
+
let covered = false;
|
|
291
|
+
for (let j = behind + 1; j < n && !covered; j++) if (j !== front && parts[j].data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) covered = true;
|
|
292
|
+
if (covered) {
|
|
293
|
+
skipped.cover++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (
|
|
297
|
+
!options.editable[front] ||
|
|
298
|
+
(behind >= 0 && !options.editable[behind]) ||
|
|
299
|
+
options.keep?.[front]?.data[p] === 1 ||
|
|
300
|
+
(behind >= 0 && options.keep?.[behind]?.data[p] === 1) ||
|
|
301
|
+
options.feathered?.[front]?.data[p] === 1 ||
|
|
302
|
+
(behind >= 0 && options.feathered?.[behind]?.data[p] === 1) ||
|
|
303
|
+
(behind >= 0 && options.movingPair?.(front, behind) === true)
|
|
304
|
+
) {
|
|
305
|
+
skipped.locked++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const df = distOf(front);
|
|
309
|
+
const db = distOf(behind);
|
|
310
|
+
// BEHIND's and FRONT's window pixels farthest from the other: the two sides least likely to be the edge itself.
|
|
311
|
+
let farB = -1;
|
|
312
|
+
let farF = -1;
|
|
313
|
+
const sideB: number[] = [];
|
|
314
|
+
const sideF: number[] = [];
|
|
315
|
+
for (let qy = Math.max(0, y - R); qy <= Math.min(H - 1, y + R); qy++) {
|
|
316
|
+
for (let qx = Math.max(0, x - R); qx <= Math.min(W - 1, x + R); qx++) {
|
|
317
|
+
const q = qy * W + qx;
|
|
318
|
+
if (owner[q] === behind) {
|
|
319
|
+
if (df[q] > farB) {
|
|
320
|
+
farB = df[q];
|
|
321
|
+
sideB.length = 0;
|
|
322
|
+
}
|
|
323
|
+
if (df[q] === farB) sideB.push(q);
|
|
324
|
+
} else if (owner[q] === front) {
|
|
325
|
+
if (db[q] > farF) {
|
|
326
|
+
farF = db[q];
|
|
327
|
+
sideF.length = 0;
|
|
328
|
+
}
|
|
329
|
+
if (db[q] === farF) sideF.push(q);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (sideB.length === 0) {
|
|
334
|
+
skipped.noBehind++;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const channel = (c: number): number[] => sideB.map((q) => srcr.data[q * 4 + c]);
|
|
338
|
+
const cb = [median(channel(0)), median(channel(1)), median(channel(2))];
|
|
339
|
+
// The front reference: the window's FRONT-owned pixel farthest from Cb (max channel), first in scan order on a tie.
|
|
340
|
+
let contrast = 0;
|
|
341
|
+
let ref = -1;
|
|
342
|
+
for (let qy = Math.max(0, y - R); qy <= Math.min(H - 1, y + R); qy++) {
|
|
343
|
+
for (let qx = Math.max(0, x - R); qx <= Math.min(W - 1, x + R); qx++) {
|
|
344
|
+
const q = qy * W + qx;
|
|
345
|
+
if (owner[q] !== front) continue;
|
|
346
|
+
const c = maxChannel(srcr.data, q * 4, cb);
|
|
347
|
+
if (c > contrast) {
|
|
348
|
+
contrast = c;
|
|
349
|
+
ref = q;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Every pixel's position along Cb -> Cf, as an integer dot product.
|
|
354
|
+
const axis = ref < 0 ? [0, 0, 0] : [0, 1, 2].map((c) => srcr.data[ref * 4 + c] - cb[c]);
|
|
355
|
+
const along = (q: number): number => (srcr.data[q * 4] - cb[0]) * axis[0] + (srcr.data[q * 4 + 1] - cb[1]) * axis[1] + (srcr.data[q * 4 + 2] - cb[2]) * axis[2];
|
|
356
|
+
if (ref < 0 || contrast < EDGE_CONTRAST) {
|
|
357
|
+
skipped.contrast++;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// The trimap: BEHIND's far side reads 0 up to its highest position, FRONT's far side 1 from its lowest.
|
|
361
|
+
// Where the two far sides do not separate along the axis (a side is all edge, or the texture overlaps), Cb and Cf themselves.
|
|
362
|
+
const sideLo = Math.max(...sideB.map(along));
|
|
363
|
+
const sideHi = sideF.length === 0 ? -Infinity : Math.min(...sideF.map(along));
|
|
364
|
+
const separate = sideHi > sideLo;
|
|
365
|
+
if (!separate) unseparated++;
|
|
366
|
+
const lo = separate ? sideLo : 0;
|
|
367
|
+
const hi = separate ? sideHi : along(ref);
|
|
368
|
+
resolved++;
|
|
369
|
+
let matte = Math.round((255 * Math.min(hi - lo, Math.max(0, along(p) - lo))) / (hi - lo));
|
|
370
|
+
// On FRONT's side of the line, a colour FRONT itself holds deeper in (within half the contrast floor, and nearer FRONT than
|
|
371
|
+
// BEHIND) is FRONT's interior — a light lining, a highlight — not a mix with BEHIND, however it falls along the axis.
|
|
372
|
+
if (a === front && matte < 255) {
|
|
373
|
+
let dF = Infinity;
|
|
374
|
+
let dFdeep = Infinity;
|
|
375
|
+
for (let qy = Math.max(0, y - R); qy <= Math.min(H - 1, y + R); qy++) {
|
|
376
|
+
for (let qx = Math.max(0, x - R); qx <= Math.min(W - 1, x + R); qx++) {
|
|
377
|
+
const q = qy * W + qx;
|
|
378
|
+
if (q === p || owner[q] !== front) continue;
|
|
379
|
+
const d = Math.max(Math.abs(srcr.data[q * 4] - srcr.data[p * 4]), Math.abs(srcr.data[q * 4 + 1] - srcr.data[p * 4 + 1]), Math.abs(srcr.data[q * 4 + 2] - srcr.data[p * 4 + 2]));
|
|
380
|
+
dF = Math.min(dF, d);
|
|
381
|
+
if (db[q] >= 2) dFdeep = Math.min(dFdeep, d);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const dOwn = dFdeep === Infinity ? dF : dFdeep;
|
|
385
|
+
let dB = Infinity;
|
|
386
|
+
for (const q of sideB) dB = Math.min(dB, Math.max(Math.abs(srcr.data[q * 4] - srcr.data[p * 4]), Math.abs(srcr.data[q * 4 + 1] - srcr.data[p * 4 + 1]), Math.abs(srcr.data[q * 4 + 2] - srcr.data[p * 4 + 2])));
|
|
387
|
+
if (2 * dOwn <= EDGE_CONTRAST && dOwn < dB) matte = 255;
|
|
388
|
+
}
|
|
389
|
+
const own = parts[front].data[p * 4 + 3];
|
|
390
|
+
decisions.push({ p, front, behind, matte, own, inside: a === front, ring: bd, cb: [cb[0], cb[1], cb[2]] });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// The ring rule (see the invariant above), resolved ring by ring, the outer ring before the inner one.
|
|
394
|
+
const at = new Int32Array(W * H).fill(-1);
|
|
395
|
+
decisions.forEach((q, k) => { at[q.p] = k; });
|
|
396
|
+
const alphaOf = new Int16Array(decisions.length);
|
|
397
|
+
const stage = (q: Decision): number => (q.inside ? 2 * q.ring : 2 * q.ring - 1);
|
|
398
|
+
const order = decisions.map((_, k) => k).sort((u, v) => stage(decisions[u]) - stage(decisions[v]) || u - v);
|
|
399
|
+
/** The greatest coverage already resolved for the same FRONT among p's 8 neighbours on the given side and ring. */
|
|
400
|
+
const neighbours = (q: Decision, inside: boolean, ring: number): number => {
|
|
401
|
+
let best = 0;
|
|
402
|
+
const x = q.p % W;
|
|
403
|
+
const y = (q.p - x) / W;
|
|
404
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
405
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
406
|
+
const nx = x + dx;
|
|
407
|
+
const ny = y + dy;
|
|
408
|
+
if ((dx === 0 && dy === 0) || nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
|
409
|
+
const j = at[ny * W + nx];
|
|
410
|
+
if (j >= 0 && decisions[j].front === q.front && decisions[j].inside === inside && decisions[j].ring === ring) best = Math.max(best, alphaOf[j]);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return best;
|
|
414
|
+
};
|
|
415
|
+
for (const k of order) {
|
|
416
|
+
const q = decisions[k];
|
|
417
|
+
let alpha = q.matte;
|
|
418
|
+
if (q.inside && q.ring > 1) alpha = Math.max(q.own, q.matte);
|
|
419
|
+
else if (q.inside) alpha = Math.max(alpha, neighbours(q, false, 1));
|
|
420
|
+
else if (q.ring > 1) alpha = Math.min(alpha, neighbours(q, false, q.ring - 1));
|
|
421
|
+
if (alpha <= OPAQUE_ALPHA_ABOVE) alpha = 0;
|
|
422
|
+
else if (alpha >= 255 - OPAQUE_ALPHA_ABOVE) alpha = 255;
|
|
423
|
+
alphaOf[k] = alpha;
|
|
424
|
+
}
|
|
425
|
+
// Every write is planned first, per part, so a part's pieces can be held before anything lands.
|
|
426
|
+
const planned: Array<Map<number, readonly [number, number, number, number]>> = parts.map(() => new Map());
|
|
427
|
+
const plan = (i: number, p: number, r: number, g: number, bl: number, al: number): void => {
|
|
428
|
+
const d = parts[i].data;
|
|
429
|
+
const s = p * 4;
|
|
430
|
+
if (d[s] === r && d[s + 1] === g && d[s + 2] === bl && d[s + 3] === al) return;
|
|
431
|
+
planned[i].set(p, [r, g, bl, al]);
|
|
432
|
+
};
|
|
433
|
+
/** Per part, the pixels it fills as BEHIND, with the FRONT part over them. */
|
|
434
|
+
const fills: Array<Map<number, number>> = parts.map(() => new Map());
|
|
435
|
+
decisions.forEach((q, k) => {
|
|
436
|
+
const s = q.p * 4;
|
|
437
|
+
const P = [srcr.data[s], srcr.data[s + 1], srcr.data[s + 2]];
|
|
438
|
+
const unmixAt = (c: number, a: number): number => Math.min(255, Math.max(0, Math.round(q.cb[c] + ((P[c] - q.cb[c]) * 255) / a)));
|
|
439
|
+
// FRONT's colour is clamped to 0..255, so a pixel the two colours cannot mix at that coverage (a painted line darker or
|
|
440
|
+
// lighter than both) would miss the painting: FRONT's coverage rises to the least that brings the composite within half
|
|
441
|
+
// the contrast floor of it (at full coverage it is the painting).
|
|
442
|
+
const miss = (a: number): number => Math.max(...[0, 1, 2].map((c) => Math.abs(Math.round((unmixAt(c, a) * a + q.cb[c] * (255 - a)) / 255) - P[c])));
|
|
443
|
+
let alpha = alphaOf[k];
|
|
444
|
+
if (alpha > 0 && alpha < 255) while (alpha < 255 && 2 * miss(alpha) > EDGE_CONTRAST) alpha++;
|
|
445
|
+
if (alpha >= 255 - OPAQUE_ALPHA_ABOVE) alpha = 255;
|
|
446
|
+
const unmix = (c: number): number => unmixAt(c, alpha);
|
|
447
|
+
if (alpha === 0) plan(q.front, q.p, 0, 0, 0, 0);
|
|
448
|
+
else if (alpha === 255) plan(q.front, q.p, P[0], P[1], P[2], 255);
|
|
449
|
+
else plan(q.front, q.p, unmix(0), unmix(1), unmix(2), alpha);
|
|
450
|
+
if (q.behind >= 0 && alpha < 255) {
|
|
451
|
+
if (alpha === 0) plan(q.behind, q.p, P[0], P[1], P[2], 255);
|
|
452
|
+
else plan(q.behind, q.p, q.cb[0], q.cb[1], q.cb[2], 255);
|
|
453
|
+
fills[q.behind].set(q.p, q.front);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
// A part's art (alpha above the floor) keeps its pieces: rigc takes one closed outline per mesh, read 4-connected
|
|
457
|
+
// (`CONTOUR_ONE_ISLAND`). A new piece holding none of the part's art is not written.
|
|
458
|
+
planned.forEach((writes, i) => {
|
|
459
|
+
if (writes.size === 0) return;
|
|
460
|
+
const before = newMask(W, H);
|
|
461
|
+
for (let p = 0; p < W * H; p++) before.data[p] = parts[i].data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE ? 1 : 0;
|
|
462
|
+
const after = (): Mask => {
|
|
463
|
+
const m = newMask(W, H);
|
|
464
|
+
m.data.set(before.data);
|
|
465
|
+
for (const [p, v] of writes) m.data[p] = v[3] > OPAQUE_ALPHA_ABOVE ? 1 : 0;
|
|
466
|
+
return m;
|
|
467
|
+
};
|
|
468
|
+
const was = connectedComponents(before, 4);
|
|
469
|
+
let now = connectedComponents(after(), 4);
|
|
470
|
+
const pieces = new Map<number, Set<number>>();
|
|
471
|
+
for (let p = 0; p < W * H; p++) {
|
|
472
|
+
const o = was.labels[p];
|
|
473
|
+
const l = now.labels[p];
|
|
474
|
+
if (o === 0 || l === 0) continue;
|
|
475
|
+
const set = pieces.get(o) ?? new Set<number>();
|
|
476
|
+
set.add(l);
|
|
477
|
+
pieces.set(o, set);
|
|
478
|
+
}
|
|
479
|
+
// A piece of art the step would split: each cut-off part of it is joined back to its largest part along the path that
|
|
480
|
+
// restores the fewest lowered pixels (a 0-1 search inside the piece as it was), and only those are not written.
|
|
481
|
+
for (const [o, set] of pieces) {
|
|
482
|
+
if (set.size < 2) continue;
|
|
483
|
+
const labels = [...set].sort((u, v) => now.stats[v].area - now.stats[u].area || u - v);
|
|
484
|
+
const main = labels[0];
|
|
485
|
+
const cost = new Int32Array(W * H).fill(-1);
|
|
486
|
+
const from = new Int32Array(W * H).fill(-1);
|
|
487
|
+
const deque = new Int32Array(2 * W * H + 2);
|
|
488
|
+
let head = W * H + 1;
|
|
489
|
+
let tail = head;
|
|
490
|
+
const nowOver = after();
|
|
491
|
+
for (let p = 0; p < W * H; p++) {
|
|
492
|
+
if (now.labels[p] === main && was.labels[p] === o) {
|
|
493
|
+
cost[p] = 0;
|
|
494
|
+
deque[tail++] = p;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
while (head < tail) {
|
|
498
|
+
const p = deque[head++];
|
|
499
|
+
const x = p % W;
|
|
500
|
+
for (const q of [x > 0 ? p - 1 : -1, x < W - 1 ? p + 1 : -1, p - W, p + W]) {
|
|
501
|
+
if (q < 0 || q >= W * H || was.labels[q] !== o) continue;
|
|
502
|
+
const step = nowOver.data[q] === 1 ? 0 : 1;
|
|
503
|
+
if (cost[q] !== -1 && cost[q] <= cost[p] + step) continue;
|
|
504
|
+
cost[q] = cost[p] + step;
|
|
505
|
+
from[q] = p;
|
|
506
|
+
if (step === 0) deque[--head] = q;
|
|
507
|
+
else deque[tail++] = q;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
for (const l of labels.slice(1)) {
|
|
511
|
+
let best = -1;
|
|
512
|
+
for (let p = 0; p < W * H; p++) if (now.labels[p] === l && cost[p] >= 0 && (best < 0 || cost[p] < cost[best])) best = p;
|
|
513
|
+
for (let p = best; p >= 0 && cost[p] > 0; p = from[p]) {
|
|
514
|
+
if (nowOver.data[p] === 0 && writes.delete(p)) skipped.pieces++;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
now = connectedComponents(after(), 4);
|
|
519
|
+
const holdsArt = new Uint8Array(now.count);
|
|
520
|
+
for (let p = 0; p < W * H; p++) if (before.data[p] === 1) holdsArt[now.labels[p]] = 1;
|
|
521
|
+
for (const [p] of [...writes]) {
|
|
522
|
+
const l = now.labels[p];
|
|
523
|
+
if (l !== 0 && holdsArt[l] === 0) {
|
|
524
|
+
writes.delete(p);
|
|
525
|
+
skipped.pieces++;
|
|
526
|
+
// A fill BEHIND cannot take: FRONT takes the painting's pixel whole, so the composite there is still the painting
|
|
527
|
+
// (FRONT is later in draw order, so its own pieces are held after this).
|
|
528
|
+
const front = fills[i].get(p);
|
|
529
|
+
if (front !== undefined) planned[front].set(p, [srcr.data[p * 4], srcr.data[p * 4 + 1], srcr.data[p * 4 + 2], 255]);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
const changed = parts.map(() => newMask(W, H));
|
|
534
|
+
planned.forEach((writes, i) => {
|
|
535
|
+
const d = parts[i].data;
|
|
536
|
+
for (const [p, v] of writes) {
|
|
537
|
+
d.set(v, p * 4);
|
|
538
|
+
changed[i].data[p] = 1;
|
|
539
|
+
options.written?.(i, p);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
return { changed, resolved, unseparated, skipped };
|
|
543
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Shared projection choices for the config reader and assembly. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a layer may take the painting's pixel (`projectSource` in
|
|
5
|
+
* `src/assemble.ts`). `core` is the reference's rule: the layer's top-most
|
|
6
|
+
* `alpha >= 250` pixels eroded by a 5x5 square, so a part a few pixels wide has
|
|
7
|
+
* no core and none of its visible pixels is projected. `visible` keeps the
|
|
8
|
+
* erosion only where it guards something — along a rim where a later layer of
|
|
9
|
+
* the run is in front — and projects every other top-most `alpha >= 250` pixel
|
|
10
|
+
* of the layer; the parts' visible edges are then resolved from the painting
|
|
11
|
+
* (`paintingEdges`).
|
|
12
|
+
*/
|
|
13
|
+
export type ProjectRule = 'core' | 'visible';
|
|
14
|
+
export const PROJECT_RULES: readonly ProjectRule[] = ['core', 'visible'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The projection rule a run uses when none is named: the reference's, so the
|
|
18
|
+
* examples stay comparable with it. `visible` was measured on the public
|
|
19
|
+
* examples against it (the changes that added the flag and `paintingEdges`);
|
|
20
|
+
* it is opt-in.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_PROJECT_RULE: ProjectRule = 'core';
|
package/src/provenance.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { crop, type Raster } from './raster/index.ts';
|
|
|
8
8
|
export const AUTOMATIC_WRITERS = {
|
|
9
9
|
sourceProjection: -1, extend: -2, growRim: -3, limbSplit: -4,
|
|
10
10
|
limbCap: -5, limbHybrid: -6, limbFeather: -7, fringeRestore: -8,
|
|
11
|
-
seamOverride: -9, armSplit: -10, headFaceUnderfill: -11, ghostCleanup: -12, unattributed: -13, paintingExtension: -14,
|
|
11
|
+
seamOverride: -9, armSplit: -10, headFaceUnderfill: -11, ghostCleanup: -12, unattributed: -13, paintingExtension: -14, paintingEdge: -15,
|
|
12
12
|
} as const;
|
|
13
13
|
export type AutomaticWriter = keyof typeof AUTOMATIC_WRITERS;
|
|
14
14
|
export interface PixelProvenance {
|