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 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. It defaults to
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 to
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') ?? DEFAULT_PROJECT_RULE;
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') ?? DEFAULT_PROJECT_RULE;
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 `paintingExtension`. `limbSplit` and `limbHybrid` remain
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.4.1",
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.0"
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: ProjectRule;
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: input.project });
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: input.project,
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: ProjectRule;
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
- log(`rig-parts build: ${input.config} -> ${out}, seam rule ${seam}, projection rule ${input.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)`}` : ''}`);
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: input.project, ...(req === null ? {} : { requirements: req.read }), ...(input.alts === undefined ? {} : { alts: input.alts }) },
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
- checkSeam(c, a);
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 checkSeam(c: Check, a: Record<string, Json>): void {
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
- checkSeam(c, a);
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 {