rig-parts 1.62.0 → 1.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -546,9 +546,12 @@ bun run typecheck # tsc --noEmit, strict
546
546
  bun run lint # one rule: no explicit any
547
547
  bun run fetch-examples # the public examples' paintings and layers, into examples/*/inputs (needs a network); each file held to its blob id at the pinned commit, a stale one replaced by name
548
548
  bun run selftest # every gate's controls; with the examples fetched, build on each of them against its expected/
549
+ bun run selftest -- --summary-json summary.json # also write rig-selftest-summary/1 (per-suite PASS/FAIL/SKIP counts, holes, partial flag and exit)
549
550
  bun run smoke # pack, install into an empty directory, run from the install (needs a network)
550
551
  ```
551
552
 
553
+ `bun run selftest -- --summary-json <file>` writes a machine-readable summary (`spec: "rig-selftest-summary/1"`, `tool: "rig-parts"`) derived from the same `RunTally` that prints the console summary: `exit` (`0 | 1 | 2`), `partial` (`boolean`), `only` (`string[] | null`), `faults`, `totals` (`suites`, `ran`, `skipped_suites`, `controls`, `passed`, `failed`, `skipped`, `holes`), per-suite `suites` entries (`suite`, `ran`, `controls`, `passed`, `failed`, `skipped`, `status`, `reason`), `skips` (`{ suite, name, reason, hole }`), and `holes` (`{ suite, name, reason }`). See [AUTHORING §8](docs/AUTHORING.md#machine-readable-selftest-summary---summary-json-issue-725) for the full shape.
554
+
552
555
  The generated `foot-ownership` suite can be run alone with
553
556
  `bun run selftest -- --only foot-ownership` (exit 2 means a green partial run,
554
557
  not a full-tree verdict). Issue #638's revised acceptance requires zero foreign-leg
package/docs/AUTHORING.md CHANGED
@@ -726,8 +726,10 @@ with the selected lower one. A target limb is its whole exclusive block; a targe
726
726
  part uses its actual generated slot family, including a still piece, without
727
727
  suffix matching. A requested segment or target with mixed bindings is refused.
728
728
 
729
- Only disjoint placements are supported. Self, reciprocal, chained/shared-slot,
730
- and pair/part conflicts name both declaration paths and the shared slot; no
729
+ Several placements may share one stationary target (a star); between same-side
730
+ co-movers base draw order decides, and a depth-driven order among them is not
731
+ provided. Self, reciprocal, chained, shared-mover, mover-as-target, and partially
732
+ overlapping target conflicts name both declaration paths and the shared slot; no
731
733
  constraint solver chooses a winner. Internal block order and all unselected
732
734
  slots' mutual order are stable. Declaring placement may change visible occlusion;
733
735
  assembly's part/PNG/recomposite order and every setup seam bar remain unchanged,
@@ -1014,7 +1016,7 @@ still judges the mesh before the rig stage publishes anything.
1014
1016
 
1015
1017
  `segments` is required in this mode too, but names bones directly: first the
1016
1018
  accepted slot bone, then any other weighted bones. All weight bones must be
1017
- listed. A declared stopped link may be named by its emitted `<link>_d` name; no other unknown name resolves by suffix. Chain shorthand and coordinate segments are refused here. These names
1019
+ listed. A declared stopped link may be named by its emitted `<link>_d` name; no other unknown name resolves by suffix (`RIG_EXPLICIT_BINDING` refuses a raw stopped link that `stops` would rewrite). When the part is also in `turn.depths` (issue #740), state `segments`, `weights` and any island `bone` in those pre-turn bone names; `turn` wraps them into `turn_<part>_<bone>` at zero offset (`x: 0, y: 0`) after the stop-binding check, leaving the bone-local bind coordinates `(x, y, weight)` unchanged. Chain shorthand and coordinate segments are refused here. These names
1018
1020
  are bindings, not a recipe to generate geometry. Do not supply `r`, `grid`,
1019
1021
  `contour`, `auto`, `influence`, `exponent`, `rule`, `ribs`, `influences` or `stray`
1020
1022
  beside `explicit` (`CONFIG_EXPLICIT_MODE`). `gather: false` retains its usual
@@ -1044,6 +1046,21 @@ influence counts and art coverage and records the verified guards. It does not
1044
1046
  carry an earlier generator's report. Consumer provenance, formatting and files
1045
1047
  written by other tools are not reproduced by declaring a mesh payload.
1046
1048
 
1049
+ When an accepted rig splits one part across island slots (`<part>` and
1050
+ `<part>__island2`, `<part>__island3`, …; issue #734), state `explicit.islands`
1051
+ (at least two entries) in place of top-level `uvs`, `triangles`, `hull` and
1052
+ `weights` (`CONFIG_EXPLICIT_ISLANDS`). Entry `i` (`0`-based) carries `slot`
1053
+ (`"<part>"` at `i = 0`, `"<part>__island<i+1>"` at `i >= 1`;
1054
+ `CONFIG_EXPLICIT_ISLAND_SLOT`), optional `bone` (defaulting to `segments[0]`;
1055
+ must be declared in `segments`), optional `before` on `i >= 1` (the base part
1056
+ slot in `rig.json` to splice `<part>__island<i+1>` immediately before; when
1057
+ omitted, `<part>__island<i+1>` sits immediately after the part's previous
1058
+ island slot), and that island's `uvs`, `triangles`, `hull` and `weights`. On
1059
+ `<part>__island2` and later, `rig` writes `"path": "<part>"` alongside
1060
+ `"image": "<part>.png"` so the slot name and atlas region stay distinct, and
1061
+ refuses when the declared island count disagrees with the non-dust 4-connected
1062
+ art islands in `parts/<part>.png` (`RIG_EXPLICIT_ISLAND_COUNT`).
1063
+
1047
1064
  ### The weight cut (`meshes.<part>.influences`, issue #284)
1048
1065
 
1049
1066
  This section and the mesh sections after it are for parts with no joints; a limb is cut at
@@ -1738,6 +1755,8 @@ exactly a value rig-c accepts, a field the mode does not have (`CONFIG_FIELD_PRE
1738
1755
  | <a id="CONFIG_EXPLICIT_MODE"></a>`CONFIG_EXPLICIT_MODE` | a generator field beside `explicit`; remove it to transfer the accepted payload without generation |
1739
1756
  | <a id="CONFIG_EXPLICIT_HASH"></a>`CONFIG_EXPLICIT_HASH` | an explicit image or region guard is not 64 lowercase hexadecimal SHA-256 digits; derive both from the immutable accepted input as stated in §3 |
1740
1757
  | <a id="CONFIG_EXPLICIT_SHAPE"></a>`CONFIG_EXPLICIT_SHAPE` | unpaired UVs, non-triple or out-of-range triangle indices, a hull past the vertex count or a weights row count unlike the vertices; transfer the whole accepted payload together |
1758
+ | <a id="CONFIG_EXPLICIT_ISLANDS"></a>`CONFIG_EXPLICIT_ISLANDS` | `explicit.islands` declared beside top-level `uvs`, `triangles`, `hull` or `weights`, or with fewer than 2 entries, or `before` declared on island 1 (`<part>`); state either a single-slot payload or at least two `islands` entries |
1759
+ | <a id="CONFIG_EXPLICIT_ISLAND_SLOT"></a>`CONFIG_EXPLICIT_ISLAND_SLOT` | `explicit.islands[i].slot` is not `"<part>"` for `i = 0` or `"<part>__island<i+1>"` for `i >= 1` |
1741
1760
  | <a id="CONFIG_MESH_MODE"></a>`CONFIG_MESH_MODE` | two of `grid`, `contour`, `auto`, `influence` and `explicit`, or no mode at all |
1742
1761
  | <a id="CONFIG_REGION_NAME_UNIQUE"></a>`CONFIG_REGION_NAME_UNIQUE` | a region named twice in one mesh's `regions` (or `bust_r` / `bust_l` declared in `contour.regions` alongside `contour.bust`) — every one named in one run |
1743
1762
  | <a id="CONFIG_REGION_BAND_NEEDS_BONE"></a><a id="CONFIG_REGION_BONE_NEEDS_BAND"></a>`CONFIG_REGION_BAND_NEEDS_BONE`, `CONFIG_REGION_BONE_NEEDS_BAND` | a region with a `band` and no `bone`, or a `bone` and no `band`: write both for a region that weights its bone, neither for a density-only region (issue #155) |
@@ -3819,12 +3838,13 @@ says why each requirement exists and what breaks without it.
3819
3838
  | <a id="RIG_SET_FRONT_BOTH"></a>`RIG_SET_FRONT_BOTH` | a `parts.json` alternate record (issue #664) maps both a part's slot and its front slot `<part>_front`; the detail names the set and the part | re-run `assemble`, which maps one or the other |
3820
3839
  | <a id="RIG_LAYOUT_NONEXCLUSIVE"></a>`RIG_LAYOUT_NONEXCLUSIVE` | a requested segment family/target, or an accepted replay's bindings, span owners or leave the selected segment scope across base/alternate attachments | correct the named bindings or scope; mixed ownership is never forced and no stale proof is emitted |
3821
3840
  | <a id="RIG_LAYOUT_UNREPRESENTABLE"></a>`RIG_LAYOUT_UNREPRESENTABLE` | the named slot family is absent, a target is not contiguous, the accepted slot population/adjacency changed, or final rig text is stale | use an emitted contiguous target and the exact final accepted source; unrelated slots are not absorbed |
3822
- | <a id="RIG_LAYOUT_CONFLICT"></a>`RIG_LAYOUT_CONFLICT` | layout declarations share a slot, target themselves, or form a reciprocal/chained pair; both field paths are printed | declare one disjoint pair, with each participating slot in only that placement |
3841
+ | <a id="RIG_LAYOUT_CONFLICT"></a>`RIG_LAYOUT_CONFLICT` | layout declarations share a moving slot, use a moving slot as a target, target themselves, form a reciprocal/chained pair, or partially overlap a shared target block; both field paths are printed | move each participating slot in at most one placement, and share only a stationary target block |
3823
3842
  | <a id="RIG_STILL_ROW_INSIDE_PART"></a><a id="RIG_STILL_ROW_CLEAR"></a><a id="RIG_STILL_PIECES_HAVE_ART"></a><a id="RIG_STILL_NAME_FREE"></a>`RIG_STILL_ROW_INSIDE_PART`, `RIG_STILL_ROW_CLEAR`, `RIG_STILL_PIECES_HAVE_ART`, `RIG_STILL_NAME_FREE` | a `motion.blink.still` row that is not strictly inside the part, that crosses art (a cut through art changes the render even at rest), that leaves one piece with no art, or whose `<part>_still` slot name another part already has | that entry's `row` — a row with no art between the crease and the lash line — or rename the other part |
3824
3843
  | <a id="RIG_SET_COVERS_BONE"></a>`RIG_SET_COVERS_BONE` | `assemble.alternates[i].drawn.<bone>` (issue #476): a subset set in `parts.json` `alternates` (`parts` present) states a `drawn` world angle for a bone none of its covered slots ride; the detail names the bone, the set, and the covered bones | remove the uncovered bone from that set's `drawn`, or add the part that rides it to `parts` |
3825
- | <a id="RIG_EXPLICIT_BINDING"></a>`RIG_EXPLICIT_BINDING` | the declared stops or turn would change the accepted payload's bindings | transfer the accepted final emitted bone names and local coordinates, or use a generated mesh; no normalization is applied |
3844
+ | <a id="RIG_EXPLICIT_BINDING"></a>`RIG_EXPLICIT_BINDING` | the declared stops would change the accepted payload's bindings | transfer the accepted final emitted bone names and local coordinates, or use a generated mesh; no normalization is applied |
3826
3845
  | <a id="RIG_EXPLICIT_IMAGE_HASH"></a>`RIG_EXPLICIT_IMAGE_HASH` | the decoded part RGBA differs from `explicit.image_sha256`; both digests are named | restore the accepted image or transfer its matching accepted payload and guard; nothing is written |
3827
3846
  | <a id="RIG_EXPLICIT_REGION_HASH"></a>`RIG_EXPLICIT_REGION_HASH` | the canvas or placed part rectangle differs from `explicit.region_sha256`; both digests are named | restore the accepted region or transfer a payload bound to this placement; nothing is written |
3847
+ | <a id="RIG_EXPLICIT_ISLAND_COUNT"></a>`RIG_EXPLICIT_ISLAND_COUNT` | `explicit.islands.length` differs from the non-dust 4-connected art island count of `parts/<part>.png`; the missing or extra `<part>__island<N>` slot names and island pixel counts are named | supply one `explicit.islands` entry per non-dust art island (`<part>`, `<part>__island2`, …); nothing is written |
3828
3848
  | <a id="RIG_EXPLICIT_ALTERNATE"></a>`RIG_EXPLICIT_ALTERNATE` | an alternate set replaces a part bound to one accepted explicit payload | use a generated mesh for alternate artwork; no alternate is inferred from a base payload |
3829
3849
  | <a id="RIG_INFLUENCE_REACH"></a>`RIG_INFLUENCE_REACH` | an influence mesh's `radius` is at or under the distance from its farthest lattice vertex to that vertex's nearest link (issue #378): a link pulls only when it is nearer than `radius`, so that vertex would be pulled by none. The vertex, where it is and the distance are named | a `radius` above the distance named, or a segment nearer that part of the art |
3830
3850
  | <a id="RIG_INFLUENCE_LATTICE"></a>`RIG_INFLUENCE_LATTICE` | rig-c's segments lattice refused the part at the declared `cell` (no pixel of the part holds art, or the cell is not a whole number of at least 1); rig-c's message is quoted | a part with art, or a whole `cell` of at least 1 |
@@ -5084,3 +5104,66 @@ machine under a load average of 5 to 11, the difference in user + sys was +5.6 a
5084
5104
  the load moves the same build by (the pre-change reference-demo build alone took 43.6 to
5085
5105
  45.8 s there). Since issue #123 that build and render are gone again — the still regions read the
5086
5106
  idle's geometry — and the `build` row was not re-measured for it either.
5107
+
5108
+ ### Machine-readable selftest summary (`--summary-json`, issue #725)
5109
+
5110
+ `bun run selftest -- --summary-json <file>` writes a JSON summary beside the normal console output
5111
+ so automated lanes and reports read the run's own `RunTally` rather than regex-parsing logs. It is
5112
+ supported on full runs, partial `--only` runs, and `--merge` runs (and refused with exit `2` when
5113
+ missing a path, given twice, or combined with `--shard`):
5114
+
5115
+ ```json
5116
+ {
5117
+ "spec": "rig-selftest-summary/1",
5118
+ "tool": "rig-parts",
5119
+ "exit": 0,
5120
+ "partial": false,
5121
+ "only": null,
5122
+ "faults": [],
5123
+ "totals": {
5124
+ "suites": 63,
5125
+ "ran": 62,
5126
+ "skipped_suites": 1,
5127
+ "controls": 2250,
5128
+ "passed": 2250,
5129
+ "failed": 0,
5130
+ "skipped": 1,
5131
+ "holes": 1
5132
+ },
5133
+ "suites": [
5134
+ {
5135
+ "suite": "png",
5136
+ "ran": true,
5137
+ "controls": 2,
5138
+ "passed": 2,
5139
+ "failed": 0,
5140
+ "skipped": 0,
5141
+ "status": "pass",
5142
+ "reason": null
5143
+ }
5144
+ ],
5145
+ "skips": [
5146
+ {
5147
+ "suite": "corpus",
5148
+ "name": "corpus",
5149
+ "reason": "no --corpus <dir>, no RIG_PARTS_CORPUS (or SPINE_PARTS_CORPUS) and no fetched examples/*/inputs",
5150
+ "hole": true
5151
+ }
5152
+ ],
5153
+ "holes": [
5154
+ {
5155
+ "suite": "corpus",
5156
+ "name": "corpus",
5157
+ "reason": "no --corpus <dir>, no RIG_PARTS_CORPUS (or SPINE_PARTS_CORPUS) and no fetched examples/*/inputs"
5158
+ }
5159
+ ]
5160
+ }
5161
+ ```
5162
+
5163
+ - Every count in `totals` and `suites` is read from the same `RunTally` instance that prints the
5164
+ console summary (`RT15`).
5165
+ - Only two-space-indented `SKIP` gutter lines enter `skips`, and only those followed by a
5166
+ `This is a HOLE in this run` continuation line (or a suite returning `null` outside `--only`)
5167
+ enter `holes`; check names or indented detail lines containing `HOLE` or `SKIP` (such as
5168
+ `RG82_BLINK_NO_HOLE_…` or `SO21_ZERO_SETUP_BANDS_REMAIN_NAMED_SKIPS_…`) are counted as `PASS` or
5169
+ `FAIL` controls and never as `skips` or `holes` (`RT16`, `RT17`).
package/docs/STABILITY.md CHANGED
@@ -143,7 +143,8 @@ part name, `[i]` an entry of a list):
143
143
  - **stops[i]** — required: `chain`, `against`, `at`, `inward`, `outward`; optional: `links`, `lower` (issue #404)
144
144
  - **stops[i].lower** — required: `inward`, `outward`
145
145
  - **meshes.&lt;part&gt;** — exactly one of `grid`, `contour`, `auto`, `influence`, `explicit`; required in every mode: `segments`, and `r` in generated modes other than `influence`; `explicit` refuses generation fields and uses direct bone names in `segments`, the first the slot bone; optional in every mode: `exponent`, `rule`, `gather` (issue #680, false only: preserve an exclusive non-segment rider's authored draw position without changing its binding; absent, output bytes unchanged); optional with `contour` or `auto`: `ribs`; optional with `grid` or `contour`: `influences` (issue #284; beside `auto` it is refused, CONFIG_INFLUENCES_MODE); optional with `grid`: `stray` (issue #303; beside `contour` or `auto` it is refused, CONFIG_STRAY_MODE)
146
- - **meshes.&lt;part&gt;.explicit** — required: `image_sha256`, `region_sha256`, `uvs`, `triangles`, `hull`, `weights` (issue #658); direct payload numbers are transferred without normalization; weights require `bone`, `x`, `y`, `weight`. Image SHA-256 is over decoded unpadded RGBA bytes; region SHA-256 is over JSON.stringify([rig width, rig height, part x, part y, part w, part h]). Both must match before output. All weighted bones must occur in segments; declared stopped links may use their emitted `<link>_d` names. Other unknown names remain refused. Alternate artwork for this part is refused; use a generated mesh for it.
146
+ - **meshes.&lt;part&gt;.explicit** — required: `image_sha256`, `region_sha256`, and either `uvs`, `triangles`, `hull`, `weights` (single-slot, issue #658) or `islands` (at least two island entries, issue #734); direct payload numbers are transferred without normalization; weights require `bone`, `x`, `y`, `weight`. Image SHA-256 is over decoded unpadded RGBA bytes; region SHA-256 is over JSON.stringify([rig width, rig height, part x, part y, part w, part h]). Both must match before output, and when `islands` is declared its entry count must match the part's non-dust 4-connected art island count. All weighted and island slot bones must occur in segments; declared stopped links may use their emitted `<link>_d` names. Other unknown names remain refused. Alternate artwork for this part is refused; use a generated mesh for it.
147
+ - **meshes.&lt;part&gt;.explicit.islands[i]** — required: `slot` (`<part>` at index 0, `<part>__island<i+1>` at index i >= 1), `uvs`, `triangles`, `hull`, `weights`; optional: `bone` (defaults to `segments[0]`), `before` (index i >= 1 only: the base part slot in `rig.json` to splice `<part>__island<i+1>` immediately before; absent, immediately after the part's previous island slot)
147
148
  - **meshes.&lt;part&gt;.influence** — required: `cell`, `radius`, `across`, `max`, `shape` (issue #378); optional: `hold_tip` (issue #615, the value true only); beside it `r`, `exponent`, `rule`, `influences`, `stray` and `ribs` are refused by name
148
149
  - **meshes.&lt;part&gt;.influences** — optional, at least one: `maxInfluences` (a whole number, 1 or more, or null for no cap), `floor` ("cut" or "continuous")
149
150
  - **meshes.&lt;part&gt;.ribs.&lt;chain&gt;** — required: `stations`
@@ -289,8 +290,10 @@ in the package.
289
290
 
290
291
  ## rig-c
291
292
 
292
- `dependencies` takes `rig-c` `^2.69.0`: 2.69.0 or any later 2.x. Bun 1.2 or later
293
- runs the package (`engines`). What this package relies on in rig-c, every import
293
+ `dependencies` takes `rig-c` `^2.69.0`: 2.69.0 or any later 2.x. Bun 1.4 or later
294
+ is the declared floor (`engines.bun` `>=1.4.0`; older 1.4 releases are not tested in
295
+ CI, which runs `latest`, and compatibility with an older 1.4 release is handled when
296
+ reported as an issue). What this package relies on in rig-c, every import
294
297
  read off `src/` and `cli.ts`:
295
298
 
296
299
  - the named entries `rig-c/mesh` (the outline functions, `measureAuthoredMeshFit`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.62.0",
3
+ "version": "1.64.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": {
@@ -8,7 +8,7 @@
8
8
  "spine-parts": "./bin/rig-parts.cjs"
9
9
  },
10
10
  "engines": {
11
- "bun": ">=1.2.0"
11
+ "bun": ">=1.4.0"
12
12
  },
13
13
  "license": "MIT",
14
14
  "author": "firejune",
@@ -2,7 +2,7 @@
2
2
  name: rig-parts
3
3
  description: Turn one anime character painting and its See-through layer decomposition into a verified 2D character rig (Spine 4.3 skeleton data) — measured parts, weighted meshes, bone chains and a looping idle — with rig-parts, which gates everything through rig-c before it is written. Use for a request to rig or animate a single character painting, to check a painting before See-through (rig-parts painting), to run or read rig-parts layers, sheet, assemble, propose, rig, check, loop or build, or to write or fix a rig-parts config.json. Not for cutting art into parts by hand, for authoring a rig from loose PNGs (that is the rigc skill), for lip-sync or swapping a part's image, or for running See-through itself.
4
4
  license: MIT
5
- compatibility: Requires Bun 1.2 or later. The tool is the npm package @rig-c/parts (npm i -D @rig-c/parts, or bun add -d @rig-c/parts), whose command is rig-parts; rig-c is installed with it. See-through is run separately.
5
+ compatibility: Requires Bun 1.4 or later (declared in engines.bun >=1.4.0; CI tests latest only). The tool is the npm package @rig-c/parts (npm i -D @rig-c/parts, or bun add -d @rig-c/parts), whose command is rig-parts; rig-c is installed with it. See-through is run separately.
6
6
  ---
7
7
 
8
8
  # rig-parts — a painting to a Spine rig, for agents
package/src/check.ts CHANGED
@@ -164,7 +164,10 @@ import {
164
164
  import { RIG_BONE_INHERIT, type RigSpec as RigcRigSpec } from 'rig-c/src/rig.ts';
165
165
  import { expandStops } from 'rig-c/src/stop.ts';
166
166
  import { ENVIRONMENT_SPEC, EnvironmentError, environmentResponse, readEnvironmentSchedule } from 'rig-c/src/environment.ts';
167
+ import { ART_ALPHA } from './mesh.ts';
168
+ import { connectedComponents } from './raster/components.ts';
167
169
  import { alphaComposite } from './raster/composite.ts';
170
+ import { alphaAbove } from './raster/index.ts';
168
171
  import { readPng, writePng } from './raster/png.ts';
169
172
  import { newFloatImage, newRaster, type Raster } from './raster/types.ts';
170
173
  import type { BaseTag } from './tags.ts';
@@ -1240,16 +1243,180 @@ export interface SeamFigures {
1240
1243
  map: { sx: number; sy: number; tx: number; ty: number };
1241
1244
  }
1242
1245
 
1246
+ /**
1247
+ * Partition a multi-island part's raster into one raster per declared island
1248
+ * slot (`<part>`, `<part>__island2..<part>__island<N>`), ordered by descending
1249
+ * `alpha > ART_ALPHA` 4-connected component area (tie-broken by ascending
1250
+ * row-major label). Anti-aliased fringe pixels (`1 <= alpha <= ART_ALPHA`)
1251
+ * propagate from their connected island seed via 4-connected BFS over
1252
+ * `alpha > 0`, with any disconnected sub-threshold pixel assigned to the
1253
+ * nearest seed centroid so every non-transparent pixel of `image` lands in
1254
+ * exactly one island raster.
1255
+ */
1256
+ export function splitPartIslands(image: Raster, count: number): Raster[] {
1257
+ if (count <= 1) return [image];
1258
+ const { width, height, data } = image;
1259
+ const cc = connectedComponents(alphaAbove(image, ART_ALPHA), 4);
1260
+ const seeds = cc.stats
1261
+ .slice(1)
1262
+ .sort((a, b) => b.area - a.area || a.label - b.label)
1263
+ .slice(0, count);
1264
+ if (seeds.length <= 1) return [image];
1265
+ const owner = new Int16Array(width * height).fill(-1);
1266
+ const labelToSeed = new Int16Array(cc.count + 1).fill(-1);
1267
+ seeds.forEach((s, idx) => {
1268
+ labelToSeed[s.label] = idx;
1269
+ });
1270
+ const queue = new Int32Array(width * height);
1271
+ let head = 0;
1272
+ let tail = 0;
1273
+ for (let i = 0; i < width * height; i++) {
1274
+ const seedIdx = labelToSeed[cc.labels[i]];
1275
+ if (seedIdx >= 0) {
1276
+ owner[i] = seedIdx;
1277
+ queue[tail++] = i;
1278
+ }
1279
+ }
1280
+ while (head < tail) {
1281
+ const cur = queue[head++];
1282
+ const seedIdx = owner[cur];
1283
+ const x = cur % width;
1284
+ const y = (cur - x) / width;
1285
+ if (x > 0) {
1286
+ const n = cur - 1;
1287
+ if (owner[n] < 0 && data[n * 4 + 3] > 0) {
1288
+ owner[n] = seedIdx;
1289
+ queue[tail++] = n;
1290
+ }
1291
+ }
1292
+ if (x + 1 < width) {
1293
+ const n = cur + 1;
1294
+ if (owner[n] < 0 && data[n * 4 + 3] > 0) {
1295
+ owner[n] = seedIdx;
1296
+ queue[tail++] = n;
1297
+ }
1298
+ }
1299
+ if (y > 0) {
1300
+ const n = cur - width;
1301
+ if (owner[n] < 0 && data[n * 4 + 3] > 0) {
1302
+ owner[n] = seedIdx;
1303
+ queue[tail++] = n;
1304
+ }
1305
+ }
1306
+ if (y + 1 < height) {
1307
+ const n = cur + width;
1308
+ if (owner[n] < 0 && data[n * 4 + 3] > 0) {
1309
+ owner[n] = seedIdx;
1310
+ queue[tail++] = n;
1311
+ }
1312
+ }
1313
+ }
1314
+ const out = Array.from({ length: count }, () => newRaster(width, height));
1315
+ for (let y = 0; y < height; y++) {
1316
+ for (let x = 0; x < width; x++) {
1317
+ const i = y * width + x;
1318
+ const p = i * 4;
1319
+ if (data[p + 3] === 0) continue;
1320
+ let idx = owner[i];
1321
+ if (idx < 0) {
1322
+ let best = 0;
1323
+ let bestD2 = Infinity;
1324
+ seeds.forEach((s, si) => {
1325
+ const dx = x - s.cx;
1326
+ const dy = y - s.cy;
1327
+ const d2 = dx * dx + dy * dy;
1328
+ if (d2 < bestD2) {
1329
+ bestD2 = d2;
1330
+ best = si;
1331
+ }
1332
+ });
1333
+ idx = best;
1334
+ }
1335
+ out[idx].data.set(data.subarray(p, p + 4), p);
1336
+ }
1337
+ }
1338
+ return out;
1339
+ }
1340
+
1341
+ export interface ReferencePlacement {
1342
+ name: string;
1343
+ png: string;
1344
+ box: PartRecord;
1345
+ islandIndex?: number;
1346
+ islandCount?: number;
1347
+ }
1348
+
1349
+ function partIslandSlots(part: string, slotOrder?: readonly string[]): string[] {
1350
+ if (slotOrder === undefined || !slotOrder.includes(`${part}__island2`)) return [part];
1351
+ const out = [part];
1352
+ for (let k = 2; slotOrder.includes(`${part}__island${k}`); k++) {
1353
+ out.push(`${part}__island${k}`);
1354
+ }
1355
+ return out;
1356
+ }
1357
+
1358
+ export function resolvePlacedAlpha(placements: readonly ReferencePlacement[], partsDir: string): PlacedAlpha[] {
1359
+ const rawCache = new Map<string, Raster>();
1360
+ const splitCache = new Map<string, Raster[]>();
1361
+ const readRaw = (png: string): Raster => {
1362
+ const hit = rawCache.get(png);
1363
+ if (hit !== undefined) return hit;
1364
+ const img = readPng(join(partsDir, `${png}.png`));
1365
+ rawCache.set(png, img);
1366
+ return img;
1367
+ };
1368
+ return placements.map((pl) => {
1369
+ const raw = readRaw(pl.png);
1370
+ if (pl.islandCount !== undefined && pl.islandCount > 1 && pl.islandIndex !== undefined) {
1371
+ const key = `${pl.png}:${pl.islandCount}`;
1372
+ let islands = splitCache.get(key);
1373
+ if (islands === undefined) {
1374
+ islands = splitPartIslands(raw, pl.islandCount);
1375
+ splitCache.set(key, islands);
1376
+ }
1377
+ return { name: pl.name, from: pl.box.from, x: pl.box.x, y: pl.box.y, image: islands[pl.islandIndex] ?? raw };
1378
+ }
1379
+ return { name: pl.name, from: pl.box.from, x: pl.box.x, y: pl.box.y, image: raw };
1380
+ });
1381
+ }
1382
+
1383
+ export function basePlacements(parts: PartsFile, slotOrder?: readonly string[]): ReferencePlacement[] {
1384
+ return setPlacements(
1385
+ parts,
1386
+ {
1387
+ name: '',
1388
+ slots: {},
1389
+ records: [],
1390
+ ghost_px: {},
1391
+ recomposite: parts.recomposite ?? {
1392
+ mean_abs: 0,
1393
+ within_limit: 0,
1394
+ within_share: 1,
1395
+ error_limit: 0,
1396
+ error_px: 0,
1397
+ covered_alpha: 0,
1398
+ uncovered_error_px: 0,
1399
+ hole_count: 0,
1400
+ holes_listed: 0,
1401
+ holes: [],
1402
+ },
1403
+ },
1404
+ slotOrder,
1405
+ );
1406
+ }
1407
+
1243
1408
  /**
1244
1409
  * The flat stack of parts over the background, in rig pixels: PIL's
1245
- * `alpha_composite` of every part at its `x, y`, in parts.json order, over an
1246
- * opaque canvas of the render's own background colour.
1410
+ * `alpha_composite` of every part at its `x, y`, in parts.json order (with
1411
+ * multi-island explicit slots placed at their declared `slotOrder` positions
1412
+ * when `slotOrder` is supplied), over an opaque canvas of the render's own
1413
+ * background colour.
1247
1414
  */
1248
- export function flatComposite(parts: PartsFile, partsDir: string, background: readonly [number, number, number, number]): Raster {
1415
+ export function flatComposite(parts: PartsFile, partsDir: string, background: readonly [number, number, number, number], slotOrder?: readonly string[]): Raster {
1249
1416
  const [W, H] = parts.rig_size;
1250
1417
  let canvas = newRaster(W, H);
1251
1418
  for (let i = 0; i < W * H; i++) canvas.data.set(background, i * 4);
1252
- for (const p of parts.parts) canvas = alphaComposite(canvas, readPng(join(partsDir, `${p.name}.png`)), p.x, p.y);
1419
+ for (const p of resolvePlacedAlpha(basePlacements(parts, slotOrder), partsDir)) canvas = alphaComposite(canvas, p.image, p.x, p.y);
1253
1420
  return canvas;
1254
1421
  }
1255
1422
 
@@ -1260,9 +1427,11 @@ export function flatComposite(parts: PartsFile, partsDir: string, background: re
1260
1427
  * slot (`alt.front`) is drawn from its record although `alt.hides` names it (the
1261
1428
  * hide is its base slot's) — with `slotOrder`, the rig's final setup slot names,
1262
1429
  * right after the nearest part slot before `<part>_front` there; without it (or
1263
- * with no such slot), in its own place, as the base slot would draw it.
1430
+ * with no such slot), in its own place, as the base slot would draw it. When
1431
+ * `slotOrder` names multi-island explicit slots (`<part>__island2..<part>__island<N>`),
1432
+ * each island's placement follows its declared slot position in `slotOrder`.
1264
1433
  */
1265
- export function setPlacements(parts: PartsFile, alt: AlternatePartsRecord, slotOrder?: readonly string[]): Array<{ name: string; png: string; box: PartRecord }> {
1434
+ export function setPlacements(parts: PartsFile, alt: AlternatePartsRecord, slotOrder?: readonly string[]): ReferencePlacement[] {
1266
1435
  const hides = new Set(alt.hides ?? []);
1267
1436
  const front = new Set(alt.front ?? []);
1268
1437
  const byName = new Map(alt.records.map((r) => [r.name, r]));
@@ -1281,18 +1450,73 @@ export function setPlacements(parts: PartsFile, alt: AlternatePartsRecord, slotO
1281
1450
  }
1282
1451
  }
1283
1452
  const moved = [...after.keys()].sort((a, b) => (slotOrder as readonly string[]).indexOf(`${a}${FRONT_SUFFIX}`) - (slotOrder as readonly string[]).indexOf(`${b}${FRONT_SUFFIX}`));
1284
- const out: Array<{ name: string; png: string; box: PartRecord }> = [];
1453
+ type PlacedIslandEntry = ReferencePlacement & { at: number };
1454
+ const inlineIslands = new Map<string, PlacedIslandEntry[]>();
1455
+ const beforeTarget = new Map<string | null, PlacedIslandEntry[]>();
1456
+ if (slotOrder !== undefined) {
1457
+ for (const p of parts.parts) {
1458
+ if (byName.has(p.name)) continue;
1459
+ const islandSlots = partIslandSlots(p.name, slotOrder);
1460
+ if (islandSlots.length <= 1) continue;
1461
+ const baseIdx = slotOrder.indexOf(p.name);
1462
+ for (let k = 1; k < islandSlots.length; k++) {
1463
+ const islSlot = islandSlots[k];
1464
+ if ((hides.has(p.name) && !front.has(p.name)) || hides.has(islSlot)) continue;
1465
+ const at = slotOrder.indexOf(islSlot);
1466
+ let reordered = at < baseIdx;
1467
+ if (!reordered) {
1468
+ for (let j = baseIdx + 1; j < at; j++) {
1469
+ if (partNames.has(slotOrder[j]) && !after.has(slotOrder[j])) {
1470
+ reordered = true;
1471
+ break;
1472
+ }
1473
+ }
1474
+ }
1475
+ const entry: PlacedIslandEntry = { name: islSlot, png: p.name, box: p, islandIndex: k, islandCount: islandSlots.length, at };
1476
+ if (!reordered) {
1477
+ const list = inlineIslands.get(p.name) ?? [];
1478
+ list.push(entry);
1479
+ inlineIslands.set(p.name, list);
1480
+ } else {
1481
+ let target: string | null = null;
1482
+ for (let j = at + 1; j < slotOrder.length; j++) {
1483
+ if (partNames.has(slotOrder[j]) && !after.has(slotOrder[j])) {
1484
+ target = slotOrder[j];
1485
+ break;
1486
+ }
1487
+ }
1488
+ const list = beforeTarget.get(target) ?? [];
1489
+ list.push(entry);
1490
+ beforeTarget.set(target, list);
1491
+ }
1492
+ }
1493
+ }
1494
+ for (const list of inlineIslands.values()) list.sort((a, b) => a.at - b.at);
1495
+ for (const list of beforeTarget.values()) list.sort((a, b) => a.at - b.at);
1496
+ }
1497
+ const out: ReferencePlacement[] = [];
1498
+ const pushIsland = ({ at: _at, ...pl }: PlacedIslandEntry): void => {
1499
+ out.push(pl);
1500
+ };
1285
1501
  const draw = (name: string, base: PartRecord): void => {
1286
1502
  if (hides.has(name) && !front.has(name)) return;
1287
1503
  const rec = byName.get(name);
1504
+ const islandSlots = rec === undefined ? partIslandSlots(name, slotOrder) : [name];
1505
+ if (islandSlots.length > 1) {
1506
+ out.push({ name, png: name, box: base, islandIndex: 0, islandCount: islandSlots.length });
1507
+ for (const isl of inlineIslands.get(name) ?? []) pushIsland(isl);
1508
+ return;
1509
+ }
1288
1510
  out.push({ name, png: rec === undefined ? name : `${alt.name}_${rec.name}`, box: rec ?? base });
1289
1511
  };
1290
1512
  const baseOf = new Map(parts.parts.map((p) => [p.name, p]));
1291
1513
  for (const p of parts.parts) {
1292
1514
  if (after.has(p.name)) continue;
1515
+ for (const isl of beforeTarget.get(p.name) ?? []) pushIsland(isl);
1293
1516
  draw(p.name, p);
1294
1517
  for (const m of moved) if (after.get(m) === p.name) draw(m, baseOf.get(m) as PartRecord);
1295
1518
  }
1519
+ for (const isl of beforeTarget.get(null) ?? []) pushIsland(isl);
1296
1520
  return out;
1297
1521
  }
1298
1522
 
@@ -1328,16 +1552,17 @@ export function flatCompositeForSet(parts: PartsFile, alt: AlternatePartsRecord,
1328
1552
  const [W, H] = parts.rig_size;
1329
1553
  let canvas = newRaster(W, H);
1330
1554
  for (let i = 0; i < W * H; i++) canvas.data.set(background, i * 4);
1331
- for (const { png, box } of setPlacements(parts, alt, slotOrder)) canvas = alphaComposite(canvas, readPng(join(partsDir, `${png}.png`)), box.x, box.y);
1555
+ for (const p of resolvePlacedAlpha(setPlacements(parts, alt, slotOrder), partsDir)) canvas = alphaComposite(canvas, p.image, p.x, p.y);
1332
1556
  return canvas;
1333
1557
  }
1334
1558
 
1335
1559
  /**
1336
1560
  * The setup-pose rig spec with an alternate set applied (issues #454, #465):
1337
- * hidden slots (and `<slot>_still` for any hidden or covered slot) have no
1338
- * setup attachment, covered slots have `alt.slots[slot]`, and when the set
1339
- * carries `drawn` bone rotations its bones are posed at those world rotations
1340
- * so the throwaway still renders the alternate in the pose it was painted in.
1561
+ * hidden slots (and `<slot>_still` or `<slot>__island<k>` for any hidden or
1562
+ * covered slot) have no setup attachment, covered slots have `alt.slots[slot]`,
1563
+ * and when the set carries `drawn` bone rotations its bones are posed at those
1564
+ * world rotations so the throwaway still renders the alternate in the pose it
1565
+ * was painted in.
1341
1566
  */
1342
1567
  export function stillRigForSet(
1343
1568
  rig: Record<string, unknown>,
@@ -1348,7 +1573,13 @@ export function stillRigForSet(
1348
1573
  const slots = (Array.isArray(rig.slots) ? rig.slots : []).map((s) => {
1349
1574
  if (!isRecord(s) || typeof s.name !== 'string') return s;
1350
1575
  const stillBase = s.name.endsWith('_still') ? s.name.slice(0, -'_still'.length) : null;
1351
- if (hides.has(s.name) || (stillBase !== null && (hides.has(stillBase) || Object.hasOwn(alt.slots, stillBase)))) {
1576
+ const islandMatch = /^(.+)__island[2-9]\d*$/.exec(s.name);
1577
+ const islandBase = islandMatch !== null ? islandMatch[1] : null;
1578
+ if (
1579
+ hides.has(s.name) ||
1580
+ (stillBase !== null && (hides.has(stillBase) || Object.hasOwn(alt.slots, stillBase))) ||
1581
+ (islandBase !== null && (hides.has(islandBase) || Object.hasOwn(alt.slots, islandBase)))
1582
+ ) {
1352
1583
  return { ...s, attachment: null };
1353
1584
  }
1354
1585
  if (Object.hasOwn(alt.slots, s.name)) return { ...s, attachment: alt.slots[s.name] };
@@ -5110,7 +5341,15 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
5110
5341
  const checkedAlt = set !== undefined && !allSets && inp.parts !== null ? (inp.parts.alternates ?? []).find((a) => a.name === set) : undefined;
5111
5342
  const subsetFoldSlots =
5112
5343
  checkedAlt !== undefined && checkedAlt.parts !== undefined
5113
- ? new Set<string>([...Object.keys(checkedAlt.slots), ...(checkedAlt.hides ?? [])])
5344
+ ? (() => {
5345
+ const baseSet = new Set<string>([...Object.keys(checkedAlt.slots), ...(checkedAlt.hides ?? [])]);
5346
+ for (const sl of (Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord)) {
5347
+ if (typeof sl.name !== 'string') continue;
5348
+ const m = /^(.+)__island[2-9]\d*$/.exec(sl.name);
5349
+ if (m !== null && baseSet.has(m[1])) baseSet.add(sl.name);
5350
+ }
5351
+ return baseSet;
5352
+ })()
5114
5353
  : null;
5115
5354
  const filterFolds = (map: Map<string, FoldingMesh[]>): Map<string, FoldingMesh[]> => {
5116
5355
  if (subsetFoldSlots === null) return map;
@@ -5526,9 +5765,10 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
5526
5765
  const canonicalSet = canonical(join(stillDir, 'build'), join(stillDir, 'seam'));
5527
5766
  seamViewport = canonicalSet.viewport;
5528
5767
  if (keepStills) cpSync(join(stillDir, 'seam'), keptDir, { recursive: true });
5529
- const composite = flatComposite(inp.parts, inp.partsDir, stillSet.background);
5768
+ const rigSlotOrder = (Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => String(sl.name));
5769
+ const composite = flatComposite(inp.parts, inp.partsDir, stillSet.background, rigSlotOrder);
5530
5770
  const autoSeam = seamFigures(composite, stillSet.frames[0].image, inp.stage, stillSet.viewport, stillSet.background, undefined, inp.parts.scene !== undefined);
5531
- const placed = inp.parts.parts.map(p => ({ name: p.name, from: p.from, x: p.x, y: p.y, image: readPng(join(inp.partsDir, `${p.name}.png`)) }));
5771
+ const placed = resolvePlacedAlpha(basePlacements(inp.parts, rigSlotOrder), inp.partsDir);
5532
5772
  const basePopulation = createSeamPopulation(placed, inp.parts.rig_size, canonicalSet.viewport);
5533
5773
  seam = seamFigures(composite, canonicalSet.frames[0].image, inp.stage, canonicalSet.viewport, canonicalSet.background, undefined, inp.parts.scene !== undefined, undefined, true, basePopulation.observe);
5534
5774
  seamScope = { mode: 'source-grid/base', domain, denominator_px: canonicalDenominator(seam), auto_frame_raw: seamReading(autoSeam, stillSet.viewport), whole: seamReading(seam, canonicalSet.viewport), population: basePopulation.finish() };
@@ -5547,11 +5787,10 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
5547
5787
  if (asr.status !== 0) refuseIfAny(rigcFailed(`render (the setup-pose still of set "${alt.name}")`, asr, asr.out.split('\n').filter((l) => l.includes('FAIL'))));
5548
5788
  const altStillSet = readFrameSet(join(setDir, 'render'));
5549
5789
  if (keepStills) cpSync(join(setDir, 'render'), join(out, 'setup_still_auto', alt.name), { recursive: true });
5550
- const rigSlotOrder = (Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => String(sl.name));
5551
5790
  const altComposite = flatCompositeForSet(inp.parts, alt, inp.partsDir, altStillSet.background, rigSlotOrder);
5552
5791
  const altCanonical = canonical(join(setDir, 'build'), join(setDir, 'seam'));
5553
5792
  if (keepStills) cpSync(join(setDir, 'seam'), join(keptDir, alt.name), { recursive: true });
5554
- const altPlaced = setPlacements(inp.parts, alt, rigSlotOrder).map(({ name, png, box }) => ({ name, from: box.from, x: box.x, y: box.y, image: readPng(join(inp.partsDir, `${png}.png`)) }));
5793
+ const altPlaced = resolvePlacedAlpha(setPlacements(inp.parts, alt, rigSlotOrder), inp.partsDir);
5555
5794
  const altSupport = seamSupport(join(setDir, 'build'), join(setDir, 'rig.json'), altCanonical, altPlaced, seamStacks, inp.stage);
5556
5795
  const effects = seamEffectSelection(baseSupport, altSupport);
5557
5796
  const autoAltSeam = seamFigures(altComposite, altStillSet.frames[0].image, inp.stage, altStillSet.viewport, altStillSet.background, undefined, alt.scene !== undefined);