@rig-c/parts 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/AUTHORING.md CHANGED
@@ -512,7 +512,10 @@ layers, as follows; every step is arithmetic on the declared bones and the layer
512
512
  upper pixel inside it, above the line, keeps alpha × (its distance above the line ÷ the band's
513
513
  height there): 0 on the line, 1 on the band's arc. Over art that stays opaque under it at any
514
514
  angle, the rest pose composites to the hard cut's bytes and no bend shows through the fade what
515
- lies behind the limb. The `limb cap:` line adds `edge "feather": N px of "<upper>" faded …`, and
515
+ lies behind the limb. A pixel the fade lowers to alpha 8 or below (`OPAQUE_ALPHA_ABOVE`) leaves the
516
+ upper piece's region (its opacity is held by the lower piece's cap beneath), and the rest of the
517
+ ramp is kept through `pushBackFringe` when another limb crosses in front of the joint (issue #444).
518
+ The `limb cap:` line adds `edge "feather": N px of "<upper>" faded …`, and
516
519
  the record `edge`, `feather_radius` and `feathered_px`.
517
520
  - **`cut_band: N`** (issue #360, opt-in on the segment below a joint whose two segments take one
518
521
  part). The cut is placed rather than typed: of the lines parallel to the joint line at whole-pixel
@@ -1591,7 +1594,7 @@ What each rule drafts (`src/cloth.ts` holds them):
1591
1594
  | response | link j of an n-link path takes `wind` and `gravity` at j/(n − 1) of the way from the root value to the tip value; a single-link path takes the tip value. Each is a scalar **multiplier** of the environment the consumer sets (spine-core's skeleton `windX, windY`, `gravityX, gravityY`; rig-c 2.52.0's live track `setEnvironment`, rig-play 1.10.0's `environment()`): no direction is drafted | `wind`, `gravity` |
1592
1595
  | out of phase | the odd trees take `alternate_damping`, the even ones `damping` | `damping`, `alternate_damping` |
1593
1596
  | flutter | the last link of every path gets a second physics constraint, `<bone>_translate`, moving x and y by `translate`, with the link's stiffness and damping and **no** wind or gravity response. spine-core scales a translate spring's response by the skeleton's reference scale (100) and a rotation's by the link's length over it, so one response on both pushes an end about 100/length times further than it turns it (the table below) | `translate` |
1594
- | part | `bottomwear` only (issue #402): the named `topwear` part takes the skirt's rule as the `bottomwear` parts do — trees from `hip` over its art below the hip, its waist line, and, as its art reaches above the chest, the chest segment the long-robe `bottomwear` keeps — in place of the two robe chains the topwear rule drafts; the prefix is `skirt` when it is the only skirt, `skirt_<part>` beside another. A `bottomwear` part lying on the named part's tree links rides them and drafts no trees of its own (issue #416, *What rides a dress* below) | `part` |
1597
+ | part / parts | `part` (a string on `bottomwear` only, issue #402): the named `topwear` part takes the skirt's rule as the `bottomwear` parts do — trees from `hip` over its art below the hip, its waist line, and, as its art reaches above the chest, the chest segment the long-robe `bottomwear` keeps — in place of the two robe chains the topwear rule drafts; the prefix is `skirt` when it is the only skirt, `skirt_<part>` beside another. A `bottomwear` part lying on the named part's tree links rides them and drafts no trees of its own (issue #416, *What rides a dress* below). `parts` (or `part` as a list, on any tag, issue #437): a non-empty list of distinct part names in root-to-tip order (`["hair_back", "hair_back_front"]`) that share one set of trees drafted from the root part's origin to the last part's tip, each part's mesh receiving only the link segments whose span crosses its opaque art (`spanCrosses > 0`) | `part`, `parts` |
1595
1598
  | swing | `bottomwear` only: a bone `<prefix>_swing` under `hip` at the centre of the root row, one link long, straight down, with physics (the trunk's stiffness, the root's response); each tree's first link takes the swing's rotation times its scale — a transform `<swing>_<k>`, local source and target, additive, the swing's setup rotation in hip's frame (−90: hip is drafted unturned) subtracted — and no physics of its own | `swing.scales` |
1596
1599
 
1597
1600
  Every link but a swing-driven first link gets `<bone>_physics` (`rotate` 1), and no
@@ -2630,7 +2633,8 @@ See-through with another seed.
2630
2633
  | <a id="CONFIG_CLOTH_RESPONSE_GROWS_TO_TIP"></a>`CONFIG_CLOTH_RESPONSE_GROWS_TO_TIP` | a `cloth` recipe's `wind` or `gravity` is `[root, tip]` with tip below root: the response grows from the root to the tip, where the motion accumulates down the chain | that pair |
2631
2634
  | <a id="CONFIG_CLOTH_ALTERNATE_DIFFERS"></a>`CONFIG_CLOTH_ALTERNATE_DIFFERS` | a `cloth` recipe's `alternate_damping` without `damping`, or equal to it: neighbouring trees are declared out of phase, and an alternate that alternates nothing declares nothing | `damping` beside it, a different value, or leave `alternate_damping` out |
2632
2635
  | <a id="CONFIG_CLOTH_SWING_SCALE_PER_TREE"></a>`CONFIG_CLOTH_SWING_SCALE_PER_TREE` | `swing` on a tag other than `bottomwear`, or `swing.scales` not one scale per tree (`chains`) | one scale per tree, in tree order across the skirt |
2633
- | <a id="CONFIG_CLOTH_PART_ON_BOTTOMWEAR"></a>`CONFIG_CLOTH_PART_ON_BOTTOMWEAR` | `part` on a `cloth` recipe for a tag other than `bottomwear` (issue #402) | leave it out there; `part` hands the skirt's recipe to a `topwear` part that holds a skirt (a dress) |
2636
+ | <a id="CONFIG_CLOTH_PART_ON_BOTTOMWEAR"></a>`CONFIG_CLOTH_PART_ON_BOTTOMWEAR` | `part` as a string on a `cloth` recipe for a tag other than `bottomwear` (issue #402; a list of parts in `parts` or `part` is accepted on any tag, issue #437) | leave it out there, or pass a list of parts in root-to-tip order (`parts: [...]`); a single string `part` hands the skirt's recipe to a `topwear` part that holds a skirt (a dress) |
2637
+ | <a id="CONFIG_CLOTH_PARTS_UNIQUE"></a>`CONFIG_CLOTH_PARTS_UNIQUE` | a `cloth` recipe sets both `part` and `parts`, or its part list names the same part more than once (issue #437) | set at most one of `part` or `parts`, and list each part once in root-to-tip order |
2634
2638
  | <a id="CONFIG_CLOTH_SPRING_STABLE"></a>`CONFIG_CLOTH_SPRING_STABLE` | a `cloth` recipe's strength — declared, or the default 100 / 100 / 50 — whose spring is unstable at a damping the recipe runs at (`damping`, `alternate_damping`, or rig-c's 0.85 when `damping` is left out): k = strength·massInverse·step² at or past 2(1 + damping^(60·step)), step 1/60 and mass 1 (rig-c's defaults; the recipe sets neither). The detail gives k and the bound | a lower strength, or a higher damping |
2635
2639
  | <a id="CONFIG_TURN_BONE_FREE"></a>`CONFIG_TURN_BONE_FREE` | the config has `turn` and `bones` declares a bone named `turn`, the control the rig stage adds | rename that bone |
2636
2640
  | <a id="CONFIG_PROPOSED_RIG_SCALE"></a>`CONFIG_PROPOSED_RIG_SCALE` | `proposed_rig_scale` (the `scale_rig_per_source` `propose` drafted the rig sections at, issue #354) differs from `assemble.rig_scale`: the coordinates in `bones`, `meshes`, `limbs` and `motion` are in that rig canvas's pixels and do not scale when `assemble.rig_scale` changes | re-run `assemble` and `propose` at the new `assemble.rig_scale`, or restore `assemble.rig_scale` to `proposed_rig_scale` |
@@ -2714,7 +2718,7 @@ says why each requirement exists and what breaks without it.
2714
2718
  | <a id="PROPOSE_CUT_FROM_RECORDED"></a>`PROPOSE_CUT_FROM_RECORDED` | two parts share one layer and none of them records `cut_from`: a `parts.json` an `assemble` before 1.6.0 wrote with `assemble.cuts`. The records are in draw order, so which is the plan part and which the piece cannot be read off the file; the message names the parts and the layer. Every other reader still takes the file | `--parts`: re-run assemble (1.6.0+) to record `cut_from` |
2715
2719
  | <a id="PROPOSE_LIMB_PARTS"></a>`PROPOSE_LIMB_PARTS` | `parts.json` holds parts `config.limbs` cut at their joints (issue #349); the limbs are drafted from the parts before the cut, which the rules read by layer | `--parts` a build assembled with the config's `limbs` left out; keep the block the config holds (its caps and ranges are the author's). `propose --from-config` reads the config's bones on either build |
2716
2720
  | <a id="PROPOSE_CLOTH_DRAFTED"></a>`PROPOSE_CLOTH_DRAFTED` | with `--cloth` (issue #364): the config's `cloth` block holds a recipe for a tag the proposer drafts no chain on — no part has that tag, or every `back hair` part ends above the shoulders (a bun bone, no chain). A `bottomwear` recipe with no `bottomwear` part names every `topwear` part shaped like a skirt (issue #402) | leave that recipe out, or assemble the layer it is for, or declare the named part in `cloth.bottomwear.part` |
2717
- | <a id="PROPOSE_CLOTH_PART"></a>`PROPOSE_CLOTH_PART` | with `--cloth` (issue #402): `cloth.bottomwear.part` names no part, a part or cut piece whose source is not `topwear` (a `bottomwear` part takes the recipe by its tag already), or a target whose box ends above the hip + 0.25 face heights, the topwear rule's waist line | name the `topwear` part or its cut skirt piece that holds the skirt, or leave `part` out |
2721
+ | <a id="PROPOSE_CLOTH_PART"></a>`PROPOSE_CLOTH_PART` | with `--cloth` (issues #402, #437): `cloth.bottomwear.part` (a string) names no part, a part or cut piece whose source is not `topwear` (a `bottomwear` part takes the recipe by its tag already), or a target whose box ends above the hip + 0.25 face heights, the topwear rule's waist line; or `cloth.<tag>.parts` (or `part` as a list, issue #437) names a part not in `parts.json`, a part no link of the drafted tree crosses (`spanCrosses == 0`), or parts out of root-to-tip depth order along the chain | name the `topwear` part or its cut skirt piece that holds the skirt (or leave `part` out), or list only parts in `parts.json` that the drafted tree links cross in root-to-tip order |
2718
2722
  | <a id="PROPOSE_CLOTH_BACK_HAIR_CHAINS"></a>`PROPOSE_CLOTH_BACK_HAIR_CHAINS` | with `--cloth` (issue #390): `cloth."back hair".chains` exceeds what the narrower side's opaque width at the eye-line root row holds at the part's mesh grid (`max(1, min(floor(w_r / grid), floor(w_l / grid)))`), or places a tree root outside the 15 px dilated art mask of the `back hair` part (e.g. an inner twin-tail root over the face where the hair has no pixel); the message names the part, each side's root-row width, the grid spacing, and any off-art root with its pixel alpha | lower `cloth."back hair".chains` to at most the max fit named in the message, or omit `chains` so `propose --cloth` derives the tree count per side from the root-row tail widths |
2719
2723
  | <a id="PROPOSE_ACCESSORY_BODY"></a>`PROPOSE_ACCESSORY_BODY` | an accessory has nothing above its pendant rows to hang its bone on | that part's plan entry, or author its bones by hand |
2720
2724
  | <a id="PROPOSE_TURN_RUN_GIVEN"></a><a id="PROPOSE_TURN_LAYER_PRESENT"></a><a id="PROPOSE_TURN_DEPTH_PRESENT"></a>`PROPOSE_TURN_RUN_GIVEN`, `PROPOSE_TURN_LAYER_PRESENT`, `PROPOSE_TURN_DEPTH_PRESENT` | with `--full`/`--head` (issue #251): a part came from a run whose layers were not given, the run given holds no layer of the part's tag, or its layer carries no `depth_median` while another part's layer carries one; a run with no depth on any part's layer (a PSD, or an export of one) is not refused — the turn is skipped, and the note says `turn: SKIP` and names the run (issue #359) | give the run the parts were assembled from, as the wrapper's `layers.json` |
package/docs/STABILITY.md CHANGED
@@ -149,7 +149,7 @@ part name, `[i]` an entry of a list):
149
149
  - **meshes.&lt;part&gt;.auto.regions[i]** — required: `name`, `shape`, `maxEdgeLength`, `transition`, `grade`, `minArtSamples`, and `cx`, `cy`, `r` for a circle or `points` for a polygon; optional: `bone`, `band` (both or neither; with neither the region is density only)
150
150
  - **meshes.&lt;part&gt;.auto.boundaryRuns** — required: `maxVertices`
151
151
  - **meshes.&lt;part&gt;.auto.stimulus**, or each entry of it when a list — required: `chain`, `degrees`
152
- - **cloth.&lt;tag&gt;**, the tag bottomwear, back hair or front hair (issue #364; read by propose --cloth only) — required: `chains` (optional on `back hair`, issue #390), `links`, `wind`, `gravity`; optional: `split`, `length_ratio`, `strength`, `damping`, `alternate_damping`, `translate`, `swing` (on bottomwear only), `part` (on bottomwear only)
152
+ - **cloth.&lt;tag&gt;**, the tag bottomwear, back hair or front hair (issue #364; read by propose --cloth only) — required: `chains` (optional on `back hair`, issue #390), `links`, `wind`, `gravity`; optional: `split`, `length_ratio`, `strength`, `damping`, `alternate_damping`, `translate`, `swing` (on bottomwear only), `part` (a string on bottomwear, or a list of parts in root-to-tip order on any tag, issue #437), `parts` (a list of parts in root-to-tip order on any tag, issue #437)
153
153
  - **cloth.&lt;tag&gt;.split** — required: `at`, `into`; optional: `hold_tip`
154
154
  - **cloth.&lt;tag&gt;.strength** — required: `trunk`, `branch`, `edge`
155
155
  - **cloth.&lt;tag&gt;.swing** — required: `scales`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/assemble.ts CHANGED
@@ -1208,7 +1208,7 @@ function keepArtWhole(part: PlacedPart, cleared: Cleared[], holderAt: (q: number
1208
1208
  * hold the pixel after it); and every pixel at alpha >= `CORE_ALPHA`, which
1209
1209
  * is the seam override's.
1210
1210
  */
1211
- export function pushBackFringe(parts: PlacedPart[], srcr: Raster, rule: SeamRule, silhouette: Mask | null): FringePushed[][] {
1211
+ export function pushBackFringe(parts: PlacedPart[], srcr: Raster, rule: SeamRule, silhouette: Mask | null, feathered: ReadonlyArray<Mask | null> | null = null): FringePushed[][] {
1212
1212
  const { width: W } = srcr;
1213
1213
  const { holder, cand } = seamCandidates(parts, srcr, rule, silhouette);
1214
1214
  const at = (i: number, d: number): number => {
@@ -1226,6 +1226,7 @@ export function pushBackFringe(parts: PlacedPart[], srcr: Raster, rule: SeamRule
1226
1226
  // cover itself the loop below has nothing to clear, and the seam override acts on the pixel as before.
1227
1227
  if (cand[d] === 0 || !shows(d)) continue;
1228
1228
  for (let i = holder[d] + 1; i < parts.length; i++) {
1229
+ if (feathered?.[i]?.data[d] === 1) continue;
1229
1230
  const s = at(i, d);
1230
1231
  if (s < 0) continue;
1231
1232
  const img = parts[i].image.data;
@@ -1889,6 +1890,8 @@ interface Built {
1889
1890
  merged: number;
1890
1891
  /** The rig pixels this piece of the layer owns when the layer is cut, null when it is not: the counts are split by it. */
1891
1892
  region: Mask | null;
1893
+ /** The rig pixels on an upper limb segment whose alpha `edge: "feather"` scaled down (issue #444). */
1894
+ feathered?: Mask;
1892
1895
  }
1893
1896
 
1894
1897
  // ---------------------------------------------------------------------------
@@ -2263,6 +2266,9 @@ function cutLimbs(
2263
2266
  let featheredPx = 0;
2264
2267
  let band = 0;
2265
2268
  if (sg.edge === 'feather') {
2269
+ const upperRegion = regions[upAt];
2270
+ const upPiece = pieces[upAt] as Built;
2271
+ const featheredMask = (upPiece.feathered ??= newMask(W, H));
2266
2272
  band = pyRound(Math.max(0, coveredRadius(line.at, radius, (x, y) => x >= 0 && y >= 0 && x < W && y < H && lower.data[(y * W + x) * 4 + 3] === 255)), 3);
2267
2273
  const fb = capBox(line.at, band);
2268
2274
  for (let y = Math.max(0, fb[1]); y < Math.min(H, fb[1] + fb[3]); y++) {
@@ -2276,6 +2282,11 @@ function cutLimbs(
2276
2282
  if (na === ua) continue;
2277
2283
  if (na === 0) upper.data.fill(0, p * 4, p * 4 + 4);
2278
2284
  else upper.data[p * 4 + 3] = na;
2285
+ // Issue #444: a pixel the feather ramp fades to alpha <= OPAQUE_ALPHA_ABOVE is no longer opaque on the
2286
+ // upper piece (its opacity is carried by the lower segment's opaque cap beneath), so it leaves the upper
2287
+ // piece's region and its remaining ramp is marked feathered so pushBackFringe does not clear the fade.
2288
+ if (na <= OPAQUE_ALPHA_ABOVE) upperRegion.data[p] = 0;
2289
+ else featheredMask.data[p] = 1;
2279
2290
  featheredPx++;
2280
2291
  }
2281
2292
  }
@@ -2840,7 +2851,8 @@ export function assemble(input: AssembleInput): AssembleResult {
2840
2851
  const whole: PartRecord = { name, from: b.key, x: 0, y: 0, w: W, h: H, opaque_px: 0, projected_core_px: 0, source_px_taken: 0, refused_drift_px: 0, merged_px: 0, seam_override_px: 0 };
2841
2852
  return { record: whole, image: b.r };
2842
2853
  });
2843
- const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
2854
+ const featheredInOrder: Array<Mask | null> = order.map((d) => ('patch' in d ? null : (pieceAt(d).b.feathered ?? null)));
2855
+ const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null, featheredInOrder);
2844
2856
  // Issue #255: a part's drift refusals in a body, and who holds the body in the draw order (the push-back clears
2845
2857
  // only fringe below CORE_ALPHA, and the seam override only recolours, so the holders read here are final).
2846
2858
  const holderAt = new Int32Array(W * H).fill(-1);
package/src/cloth.ts CHANGED
@@ -95,8 +95,10 @@ export interface ClothRecipe {
95
95
  gravity: [number, number];
96
96
  translate?: number;
97
97
  swing?: { scales: number[] };
98
- /** `bottomwear` only: a `topwear` part or its named cut skirt piece the skirt's trees are drafted on. */
99
- part?: string;
98
+ /** `bottomwear` only when a string (a `topwear` part or its cut skirt piece, issue #402); on any target when a list of parts the recipe's trees run through from root to tip (issue #437; the single-part form is the one-element list). */
99
+ part?: string | string[];
100
+ /** A list of parts the recipe's trees run through in order from root to tip, one chain across the union and each link weighted to the part whose pixels it crosses (issue #437; the single-part form is the one-element list). */
101
+ parts?: string[];
100
102
  }
101
103
 
102
104
  export type ClothRecipes = Partial<Record<ClothTarget, ClothRecipe>>;
@@ -469,3 +471,28 @@ export function tailOverlap(tail: Tail, part: Mask, box: { x: number; y: number;
469
471
  }
470
472
  return { counts, over, total };
471
473
  }
474
+
475
+ /**
476
+ * Issue #437: how many interior pixel steps along `span` (`a -> b`, sampled at
477
+ * `(s + 0.5) / ceil(|b - a|)` for `s = 0 .. ceil(|b - a|) - 1`) land on an
478
+ * opaque pixel of `mask`. Consecutive vertical links `[y0, y1]`, `[y1, y2]`
479
+ * sample half-open row intervals `[y0, y1)`, `[y1, y2)` with no shared boundary
480
+ * pixel at the joint `y1`, so a link strictly on one side of a horizontal seam
481
+ * takes 0 pixels on the other part.
482
+ */
483
+ export function spanCrosses(span: Pick<TailSpan, 'a' | 'b'>, mask: Mask): number {
484
+ const [x0, y0] = span.a;
485
+ const [x1, y1] = span.b;
486
+ const dx = x1 - x0;
487
+ const dy = y1 - y0;
488
+ const steps = Math.max(1, Math.ceil(Math.sqrt(dx * dx + dy * dy)));
489
+ let count = 0;
490
+ for (let s = 0; s < steps; s++) {
491
+ const t = (s + 0.5) / steps;
492
+ const x = Math.floor(x0 + t * dx);
493
+ const y = Math.floor(y0 + t * dy);
494
+ if (x >= 0 && x < mask.width && y >= 0 && y < mask.height && mask.data[y * mask.width + x] !== 0) count++;
495
+ }
496
+ return count;
497
+ }
498
+
package/src/config.ts CHANGED
@@ -2842,7 +2842,7 @@ function checkCloth(c: Check, v: Json): void {
2842
2842
  for (const tag of CLOTH_TARGETS) {
2843
2843
  if (!(tag in v)) continue;
2844
2844
  const path = `${at}.${JSON.stringify(tag)}`;
2845
- const r = c.object(path, v[tag], ['links', 'wind', 'gravity'], ['chains', 'split', 'length_ratio', 'strength', 'damping', 'alternate_damping', 'translate', 'swing', 'part']);
2845
+ const r = c.object(path, v[tag], ['links', 'wind', 'gravity'], ['chains', 'split', 'length_ratio', 'strength', 'damping', 'alternate_damping', 'translate', 'swing', 'part', 'parts']);
2846
2846
  if (r === null) continue;
2847
2847
  if (tag !== 'back hair' && !('chains' in r)) c.fail('CONFIG_FIELD_PRESENT', `${path}.chains`, 'is absent and required');
2848
2848
  const chains = 'chains' in r && c.int(`${path}.chains`, r.chains, 1) ? (r.chains as number) : null;
@@ -2897,11 +2897,32 @@ function checkCloth(c: Check, v: Json): void {
2897
2897
  if ('translate' in r && c.number(`${path}.translate`, r.translate, 'unit') && r.translate === 0) {
2898
2898
  c.fail('CONFIG_FIELD_TYPE', `${path}.translate`, 'is 0; a mix above 0 and at most 1 is required (leave translate out for none)');
2899
2899
  }
2900
+ const checkPartList = (fieldPath: string, list: Json): void => {
2901
+ if (!c.array(fieldPath, list, true)) return;
2902
+ const seen = new Set<string>();
2903
+ (list as Json[]).forEach((item, i) => {
2904
+ if (c.string(`${fieldPath}[${i}]`, item)) {
2905
+ const s = item as string;
2906
+ if (seen.has(s)) {
2907
+ c.fail('CONFIG_CLOTH_PARTS_UNIQUE', `${fieldPath}[${i}]`, `repeats "${s}"; each part in the list must be named once, in order from root to tip`);
2908
+ }
2909
+ seen.add(s);
2910
+ }
2911
+ });
2912
+ };
2913
+ if ('part' in r && 'parts' in r) {
2914
+ c.fail('CONFIG_CLOTH_PARTS_UNIQUE', `${path}.parts`, 'is declared beside part; declare either parts (a list of parts from root to tip) or part, not both');
2915
+ }
2900
2916
  if ('part' in r) {
2901
- if (tag !== 'bottomwear') {
2917
+ if (Array.isArray(r.part)) {
2918
+ checkPartList(`${path}.part`, r.part);
2919
+ } else if (tag !== 'bottomwear') {
2902
2920
  c.fail('CONFIG_CLOTH_PART_ON_BOTTOMWEAR', `${path}.part`, `is declared on "${tag}"; part hands the skirt's recipe (bottomwear) to a topwear part or its cut skirt piece (a dress) — leave it out here`);
2903
2921
  } else c.string(`${path}.part`, r.part);
2904
2922
  }
2923
+ if ('parts' in r) {
2924
+ checkPartList(`${path}.parts`, r.parts);
2925
+ }
2905
2926
  if ('swing' in r) {
2906
2927
  if (tag !== 'bottomwear') {
2907
2928
  c.fail('CONFIG_CLOTH_SWING_SCALE_PER_TREE', `${path}.swing`, `is declared on "${tag}"; the shared swing is the skirt's (bottomwear) — leave it out here`);
package/src/propose.ts CHANGED
@@ -148,7 +148,7 @@ import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER
148
148
  import { edgeLength } from './length.ts';
149
149
  import { limbWidth } from './limbs.ts';
150
150
  import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.ts';
151
- import { RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
151
+ import { RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
152
152
  import { TEXTURE_STRETCH_CEILING } from './check.ts';
153
153
 
154
154
  // ---------------------------------------------------------------------------
@@ -1377,10 +1377,31 @@ export function proposeWithBasis(
1377
1377
  // Issue #364: the trees a cloth recipe drafts, their constraints, and the tags it was used on.
1378
1378
  const clothConstraints: ConfigConstraint[] = [];
1379
1379
  const clothUsed = new Set<ClothTarget>();
1380
+ const multiPartClaimed = new Set<string>();
1380
1381
  const recipeOf = (tag: ClothTarget): ClothRecipe | null => cloth?.[tag] ?? null;
1382
+ const declaredPartsOf = (tag: ClothTarget): { field: 'parts' | 'part'; list: PartRecord[] } | null => {
1383
+ const rcp = recipeOf(tag);
1384
+ if (rcp === null) return null;
1385
+ const raw = rcp.parts !== undefined ? { field: 'parts' as const, names: rcp.parts } : Array.isArray(rcp.part) ? { field: 'part' as const, names: rcp.part } : null;
1386
+ if (raw === null) return null;
1387
+ const object = `config.cloth.${JSON.stringify(tag)}.${raw.field}`;
1388
+ const list: PartRecord[] = [];
1389
+ for (const name of raw.names) {
1390
+ const q = P.recs.find((x) => x.name === name);
1391
+ if (q === undefined) {
1392
+ throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object, detail: `names "${name}", which is no part in parts.json; every part in the list must exist in parts.json` }]);
1393
+ }
1394
+ if (splitFrom(q.from)[0] === PAINTING_RUN) {
1395
+ throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object, detail: `names "${name}", from ${q.from}; a painting patch has no layer tag and cannot be part of a cloth tree` }]);
1396
+ }
1397
+ list.push(q);
1398
+ }
1399
+ return { field: raw.field, list };
1400
+ };
1381
1401
  // Issue #391: the tails the back hair recipe drafts, each with its mesh's spans, which an accessory wrapped round one rides.
1382
1402
  const tails: Tail[] = [];
1383
- const T = (p: PartRecord, tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
1403
+ const T = (p: PartRecord | readonly PartRecord[], tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
1404
+ const partNames = Array.isArray(p) ? p.map((x) => x.name) : [(p as PartRecord).name];
1384
1405
  const trees = draftTrees(input, r);
1385
1406
  const constants = [konst('cloth_chains', r.chains ?? RECIPE_BACK_HAIR_CHAINS), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
1386
1407
  for (const t of trees) {
@@ -1389,8 +1410,8 @@ export function proposeWithBasis(
1389
1410
  t.parent,
1390
1411
  t.points,
1391
1412
  t.tip,
1392
- t.why.map((w) => placement('mask', w, { parts: [p.name], bones: [input.parent], constants })),
1393
- placement('mask', t.tipWhy, { parts: [p.name], bones: [input.parent], constants }),
1413
+ t.why.map((w) => placement('mask', w, { parts: partNames, bones: [input.parent], constants })),
1414
+ placement('mask', t.tipWhy, { parts: partNames, bones: [input.parent], constants }),
1394
1415
  );
1395
1416
  }
1396
1417
  clothUsed.add(tag);
@@ -1521,7 +1542,225 @@ export function proposeWithBasis(
1521
1542
  };
1522
1543
 
1523
1544
  // ---- back hair
1545
+ const backHairParts = declaredPartsOf('back hair');
1546
+ const backHairSingleSame =
1547
+ backHairParts !== null &&
1548
+ backHairParts.list.length === 1 &&
1549
+ P.headFirst('back hair').length === 1 &&
1550
+ backHairParts.list[0] === P.headFirst('back hair')[0] &&
1551
+ SIDES.every(([s]) => {
1552
+ const m = P.alpha(backHairParts.list[0]);
1553
+ for (let y = 0; y < m.height; y++) {
1554
+ for (let x = 0; x < m.width; x++) {
1555
+ if ((s === 'r' ? x < axis : x >= axis) && m.data[y * m.width + x] !== 0) return true;
1556
+ }
1557
+ }
1558
+ return false;
1559
+ });
1560
+ if (backHairSingleSame) {
1561
+ const p0 = backHairParts.list[0];
1562
+ if (p0.y + p0.h < neckY + 0.5 * fh) {
1563
+ throw new PartsError([
1564
+ {
1565
+ code: 'PROPOSE_CLOTH_PART',
1566
+ object: `config.cloth."back hair".${backHairParts.field}`,
1567
+ detail: `names "${p0.name}" (${p0.from}, box y=${p0.y}..${p0.y + p0.h}), which ends above the shoulders (y=${pyFixed(neckY + 0.5 * fh, 1)}), where the proposer drafts a bun bone with no tree links — no tree link crosses "${p0.name}"`,
1568
+ },
1569
+ ]);
1570
+ }
1571
+ } else if (backHairParts !== null) {
1572
+ const { field, list } = backHairParts;
1573
+ const object = `config.cloth."back hair".${field}`;
1574
+ const rawRcp = recipeOf('back hair') as ClothRecipe;
1575
+ const p0 = list[0];
1576
+ const m0 = P.alpha(p0);
1577
+ const shoulderLine = neckY + 0.5 * fh;
1578
+ const sideHasBelow = (m: Mask, s: 'r' | 'l', yMin: number): boolean => {
1579
+ const r0 = Math.max(0, Math.ceil(yMin));
1580
+ for (let y = r0; y < m.height; y++) {
1581
+ for (let x = 0; x < m.width; x++) {
1582
+ if ((s === 'r' ? x < axis : x >= axis) && m.data[y * m.width + x] !== 0) return true;
1583
+ }
1584
+ }
1585
+ return false;
1586
+ };
1587
+ const activeSides = SIDES.filter(([s]) => list.some((q) => sideHasBelow(P.alpha(q), s, shoulderLine)));
1588
+ if (activeSides.length === 0) {
1589
+ throw new PartsError([
1590
+ {
1591
+ code: 'PROPOSE_CLOTH_PART',
1592
+ object,
1593
+ detail: `names "${p0.name}" (${p0.from}, box y=${p0.y}..${p0.y + p0.h}), which has no art below the shoulders (y=${pyFixed(shoulderLine, 1)}) — no back hair tree link crosses "${p0.name}"`,
1594
+ },
1595
+ ]);
1596
+ }
1597
+ const unionMask = newMask(P.W, P.H);
1598
+ unionMask.data.set(m0.data);
1599
+ for (let idx = 1; idx < list.length; idx++) {
1600
+ const mq = P.alpha(list[idx]);
1601
+ for (const [s] of activeSides) {
1602
+ for (let y = 0; y < P.H; y++) {
1603
+ let p0HasRow = false;
1604
+ for (let x = 0; x < P.W; x++) {
1605
+ if ((s === 'r' ? x < axis : x >= axis) && m0.data[y * P.W + x] !== 0) {
1606
+ p0HasRow = true;
1607
+ break;
1608
+ }
1609
+ }
1610
+ if (p0HasRow) continue;
1611
+ for (let x = 0; x < P.W; x++) {
1612
+ if ((s === 'r' ? x < axis : x >= axis) && mq.data[y * P.W + x] !== 0) {
1613
+ unionMask.data[y * P.W + x] = 1;
1614
+ }
1615
+ }
1616
+ }
1617
+ }
1618
+ }
1619
+ const uRows = rowsAny(unionMask);
1620
+ const top = uRows.length > 0 ? uRows[0] : p0.y;
1621
+ const bot = uRows.length > 0 ? uRows[uRows.length - 1] + 1 : p0.y + p0.h;
1622
+ const [g, r] = gridR(p0);
1623
+ const dilated = dilate(unionMask, 31);
1624
+ const y0 = pyRound(eyeY);
1625
+ const sideOpaqueWidth = (s: 'r' | 'l'): number => {
1626
+ if (y0 < 0 || y0 >= unionMask.height) return 0;
1627
+ let w = 0;
1628
+ for (let x = 0; x < unionMask.width; x++) {
1629
+ if ((s === 'r' ? x < axis : x >= axis) && unionMask.data[y0 * unionMask.width + x]) w++;
1630
+ }
1631
+ return w;
1632
+ };
1633
+ const prefixOf = (s: 'r' | 'l'): string => `hairback_${s}`;
1634
+ const offRootsAt = (cnt: number, s: 'r' | 'l'): Array<{ bone: string; at: Point; alpha: number }> => {
1635
+ const pref = prefixOf(s);
1636
+ const im = P.images.get(p0.name) as Raster;
1637
+ const off: Array<{ bone: string; at: Point; alpha: number }> = [];
1638
+ for (let k = 0; k < cnt; k++) {
1639
+ const fc = (k + 0.5) / cnt;
1640
+ const rx = pyRound(bandX(unionMask, eyeY, { frac: fc, side: s, axis }) ?? axis);
1641
+ if (!isOn(dilated, rx, y0)) {
1642
+ const lx = rx - p0.x;
1643
+ const ly = y0 - p0.y;
1644
+ const alpha = lx >= 0 && lx < p0.w && ly >= 0 && ly < p0.h ? im.data[(ly * p0.w + lx) * 4 + 3] : 0;
1645
+ off.push({ bone: `${pref}${k}_0`, at: [rx, y0], alpha });
1646
+ }
1647
+ }
1648
+ return off;
1649
+ };
1650
+ const sideWidths = activeSides.map(([s]) => sideOpaqueWidth(s));
1651
+ const widthCap = Math.max(1, Math.min(...sideWidths.map((w) => Math.floor(w / g))));
1652
+ let maxFit = 1;
1653
+ for (let cnt = widthCap; cnt >= 1; cnt--) {
1654
+ if (activeSides.every(([s]) => offRootsAt(cnt, s).length === 0)) {
1655
+ maxFit = cnt;
1656
+ break;
1657
+ }
1658
+ }
1659
+ if (rawRcp.chains !== undefined) {
1660
+ const offRoots = activeSides.flatMap(([s]) => offRootsAt(rawRcp.chains as number, s));
1661
+ if (rawRcp.chains > widthCap || offRoots.length > 0) {
1662
+ const wR = sideOpaqueWidth('r');
1663
+ const wL = sideOpaqueWidth('l');
1664
+ throw new PartsError([
1665
+ {
1666
+ code: 'PROPOSE_CLOTH_BACK_HAIR_CHAINS',
1667
+ object: 'config.cloth."back hair".chains',
1668
+ detail:
1669
+ `declares ${rawRcp.chains} tree(s) per side on "${p0.name}", and at the root row y=${y0} the tail is ${wR} px wide on the r side and ${wL} px on the l side on a ${g} px mesh grid (at most ${maxFit} tree(s) per side fit inside the art` +
1670
+ (offRoots.length > 0 ? `; off the art: ${offRoots.map((o) => `${o.bone} at [${o.at[0]}, ${o.at[1]}] (alpha ${o.alpha})`).join(', ')}` : '') +
1671
+ `) — set chains to at most ${maxFit}, or leave chains out to derive it from the tail's width`,
1672
+ },
1673
+ ]);
1674
+ }
1675
+ }
1676
+ const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
1677
+ const all: TreeChain[] = [];
1678
+ let n = 0;
1679
+ const partNamesLabel = list.map((q) => q.name).join(' -> ');
1680
+ for (const [side] of activeSides) {
1681
+ const prefix = prefixOf(side);
1682
+ const trees = T(list, 'back hair', rcp, {
1683
+ prefix,
1684
+ parent: 'head',
1685
+ rootY: eyeY,
1686
+ tipY: bot - 4,
1687
+ xAt: (y, f) => bandX(unionMask, y, { frac: f, side, axis }),
1688
+ fallbackX: () => axis,
1689
+ frame: `${partNamesLabel}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`,
1690
+ edges: side,
1691
+ });
1692
+ const made = treeConstraints(trees, rcp, null);
1693
+ clothConstraints.push(...made);
1694
+ n += made.length;
1695
+ all.push(...trees);
1696
+ }
1697
+ const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
1698
+ for (const t of all) {
1699
+ const line = [...t.points, t.tip];
1700
+ t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true, depth: t.depths[i] }));
1701
+ }
1702
+ let prevMinD = -1;
1703
+ let prevMaxD = -1;
1704
+ const linkSummary: string[] = [];
1705
+ for (let idx = 0; idx < list.length; idx++) {
1706
+ const q = list[idx];
1707
+ const mq = P.alpha(q);
1708
+ const crossedLinks = spans.filter((sp) => sp.link && spanCrosses(sp, mq) > 0);
1709
+ if (crossedLinks.length === 0) {
1710
+ const linkDesc = spans
1711
+ .filter((sp) => sp.link)
1712
+ .map((sp) => `${sp.bone} [${sp.a[0]}, ${sp.a[1]}]->[${sp.b[0]}, ${sp.b[1]}]`)
1713
+ .join(', ');
1714
+ throw new PartsError([
1715
+ {
1716
+ code: 'PROPOSE_CLOTH_PART',
1717
+ object,
1718
+ detail: `names "${q.name}" (${q.from}, box x=${q.x}..${q.x + q.w}, y=${q.y}..${q.y + q.h}), which no link of the drafted tree crosses (${linkDesc}: 0 px on "${q.name}") — every part in ${field} must be crossed by at least one link of the tree`,
1719
+ },
1720
+ ]);
1721
+ }
1722
+ const minD = Math.min(...crossedLinks.map((sp) => sp.depth));
1723
+ const maxD = Math.max(...crossedLinks.map((sp) => sp.depth));
1724
+ if (idx > 0 && (minD < prevMinD || maxD < prevMaxD)) {
1725
+ throw new PartsError([
1726
+ {
1727
+ code: 'PROPOSE_CLOTH_PART',
1728
+ object,
1729
+ detail: `names "${q.name}" after "${list[idx - 1].name}", and "${q.name}" is crossed at link depth ${minD}..${maxD} while "${list[idx - 1].name}" is crossed at depth ${prevMinD}..${prevMaxD} — list parts in order from root to tip`,
1730
+ },
1731
+ ]);
1732
+ }
1733
+ prevMinD = minD;
1734
+ prevMaxD = maxD;
1735
+ const [gq, rq] = q === p0 ? [g, r] : gridR(q);
1736
+ const qSegs: Segment[] = [];
1737
+ const crossedDesc: string[] = [];
1738
+ for (const sp of spans) {
1739
+ const px = spanCrosses(sp, mq);
1740
+ if (px > 0) {
1741
+ qSegs.push(sp.link ? sp.bone : [sp.bone, sp.a, sp.b]);
1742
+ if (sp.link) crossedDesc.push(`${sp.bone} (${px} px)`);
1743
+ }
1744
+ }
1745
+ linkSummary.push(`${q.name}: ${crossedDesc.join(', ')}`);
1746
+ delete regions[q.name];
1747
+ meshes[q.name] = { grid: gq, r: rcp.split?.hold_tip === true ? Math.max(rq, gq) : rq, segments: qSegs };
1748
+ multiPartClaimed.add(q.name);
1749
+ }
1750
+ notes.push(
1751
+ clothNote(
1752
+ partNamesLabel,
1753
+ 'back hair',
1754
+ all,
1755
+ rcp,
1756
+ n,
1757
+ ` across ${list.length} parts in root-to-tip order (${linkSummary.join('; ')}), from head, rooted on the eye line and tipped 4 px above ${list[list.length - 1].name}'s bottom`,
1758
+ ),
1759
+ );
1760
+ tails.push({ part: p0.name, mask: unionMask, spans });
1761
+ }
1524
1762
  for (const p of P.headFirst('back hair')) {
1763
+ if (multiPartClaimed.has(p.name)) continue;
1525
1764
  const [g, r] = gridR(p);
1526
1765
  const top = p.y;
1527
1766
  const bot = p.y + p.h;
@@ -1536,7 +1775,7 @@ export function proposeWithBasis(
1536
1775
  );
1537
1776
  meshes[p.name] = { grid: g, r, segments: [['head', rnd(head), rnd([axis, top + 13])], 'bun'] };
1538
1777
  tracks.push({ bone: 'bun', prop: 'rotate', amp: 0.8, period: 4.0, phase: 0.2 });
1539
- } else if (recipeOf('back hair') !== null) {
1778
+ } else if (recipeOf('back hair') !== null && (backHairParts === null || backHairSingleSame)) {
1540
1779
  // Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
1541
1780
  // 4 px above the part's bottom, in place of the two keyed chains.
1542
1781
  // Issue #390: the tree count per side derives from the tail's opaque width at the root row (`floor(width / grid)`
@@ -1660,6 +1899,7 @@ export function proposeWithBasis(
1660
1899
  const turned = 'none' in roll ? 'on the page' : `along the head's down axis (the eye line's perpendicular, roll ${pyFixed(roll.degrees, 3)} degrees)`;
1661
1900
  const rollBones = 'none' in roll ? [] : ['eye_r', 'eye_l'];
1662
1901
  for (const p of P.headFirst('front hair')) {
1902
+ if (multiPartClaimed.has(p.name)) continue;
1663
1903
  const [g, r] = gridR(p);
1664
1904
  const mask = P.alpha(p);
1665
1905
  const top = p.y;
@@ -1805,7 +2045,7 @@ export function proposeWithBasis(
1805
2045
  // Every part with the tag (a plan may take both runs' layers): the biggest
1806
2046
  // gets the bone and chain, the rest ride the head rigidly. A stable sort,
1807
2047
  // as Python's `sorted` is.
1808
- const cands = [...P.byTag(tag)].sort((a, b) => b.opaque_px - a.opaque_px);
2048
+ const cands = [...P.byTag(tag)].filter((q) => !multiPartClaimed.has(q.name)).sort((a, b) => b.opaque_px - a.opaque_px);
1809
2049
  // Issue #391: a layer of the tag lying on a tail the back hair recipe drafted rides the links it overlaps — the
1810
2050
  // spans of the tail its pixels over the tail's art fall nearest — and not head: its mesh names those bones and no
1811
2051
  // others, so every vertex weights by the rule the tail's own art does. The links are read off the overlap, never
@@ -2173,31 +2413,65 @@ export function proposeWithBasis(
2173
2413
 
2174
2414
  // Issue #402: the topwear part config.cloth.bottomwear.part names takes the skirt's rule and recipe below; every
2175
2415
  // other topwear shaped like a skirt is named in a note when a recipe is in play, never drafted on by its shape.
2416
+ const bottomwearParts = declaredPartsOf('bottomwear');
2417
+ const bottomwearSingleSame =
2418
+ bottomwearParts !== null &&
2419
+ bottomwearParts.list.length === 1 &&
2420
+ bw.length === 1 &&
2421
+ bottomwearParts.list[0] === bw[0];
2176
2422
  const namedSkirt = ((): PartRecord | null => {
2177
- const name = recipeOf('bottomwear')?.part;
2178
- if (name === undefined) return null;
2179
- const refuse = (detail: string): never => {
2180
- throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object: 'config.cloth."bottomwear".part', detail }]);
2181
- };
2182
- const q = P.recs.find((x) => x.name === name);
2183
- if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part or its cut skirt piece whose art hangs below the hip (a dress) is required`);
2184
- // An explicit name may select a topwear cut piece; automatic tag rules still exclude pieces.
2185
- const [run, tag] = splitFrom(q.from);
2186
- if (run === PAINTING_RUN || tag !== 'topwear') {
2187
- return refuse(
2188
- `names "${name}", from ${q.from}; ${splitFrom(q.from)[1] === 'bottomwear' ? 'a bottomwear part takes the recipe by its tag already — leave part out' : "only a topwear part takes the skirt's recipe by name (a skirt See-through returned inside topwear, a dress)"}`,
2189
- );
2423
+ const rawPart = recipeOf('bottomwear')?.part;
2424
+ if (typeof rawPart === 'string') {
2425
+ const name = rawPart;
2426
+ const refuse = (detail: string): never => {
2427
+ throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object: 'config.cloth."bottomwear".part', detail }]);
2428
+ };
2429
+ const q = P.recs.find((x) => x.name === name);
2430
+ if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part or its cut skirt piece whose art hangs below the hip (a dress) is required`);
2431
+ // An explicit name may select a topwear cut piece; automatic tag rules still exclude pieces.
2432
+ const [run, tag] = splitFrom(q.from);
2433
+ if (run === PAINTING_RUN || tag !== 'topwear') {
2434
+ return refuse(
2435
+ `names "${name}", from ${q.from}; ${splitFrom(q.from)[1] === 'bottomwear' ? 'a bottomwear part takes the recipe by its tag already — leave part out' : "only a topwear part takes the skirt's recipe by name (a skirt See-through returned inside topwear, a dress)"}`,
2436
+ );
2437
+ }
2438
+ const line = hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh;
2439
+ if (q.y + q.h < line) {
2440
+ return refuse(`names "${name}", whose box ends at y=${q.y + q.h}, above hip + ${TOPWEAR_HANGS_FACE_HEIGHTS} face heights (y=${pyFixed(line, 1)}), where the topwear rule reads a bodice that stops at the waist: no skirt hangs below the hip to hang trees on`);
2441
+ }
2442
+ return q;
2190
2443
  }
2191
- const line = hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh;
2192
- if (q.y + q.h < line) {
2193
- return refuse(`names "${name}", whose box ends at y=${q.y + q.h}, above hip + ${TOPWEAR_HANGS_FACE_HEIGHTS} face heights (y=${pyFixed(line, 1)}), where the topwear rule reads a bodice that stops at the waist: no skirt hangs below the hip to hang trees on`);
2444
+ if (bottomwearParts !== null && bottomwearParts.list.length === 1 && !bottomwearSingleSame) {
2445
+ const q = bottomwearParts.list[0];
2446
+ const object = `config.cloth."bottomwear".${bottomwearParts.field}`;
2447
+ const [run, tag] = splitFrom(q.from);
2448
+ if (run === PAINTING_RUN || tag !== 'topwear') {
2449
+ throw new PartsError([
2450
+ {
2451
+ code: 'PROPOSE_CLOTH_PART',
2452
+ object,
2453
+ detail: `names "${q.name}" (${q.from}, box x=${q.x}..${q.x + q.w}, y=${q.y}..${q.y + q.h}), which no link of the skirt's trees crosses`,
2454
+ },
2455
+ ]);
2456
+ }
2457
+ const line = hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh;
2458
+ if (q.y + q.h < line) {
2459
+ throw new PartsError([
2460
+ {
2461
+ code: 'PROPOSE_CLOTH_PART',
2462
+ object,
2463
+ detail: `names "${q.name}", whose box ends at y=${q.y + q.h}, above hip + ${TOPWEAR_HANGS_FACE_HEIGHTS} face heights (y=${pyFixed(line, 1)}), where the topwear rule reads a bodice that stops at the waist: no skirt hangs below the hip to hang trees on`,
2464
+ },
2465
+ ]);
2466
+ }
2467
+ return q;
2194
2468
  }
2195
- return q;
2469
+ return null;
2196
2470
  })();
2197
2471
  const skirtHints = new Map<string, string>();
2198
2472
  if (cloth !== null) {
2199
2473
  for (const p of tw) {
2200
- if (p === namedSkirt) continue;
2474
+ if (p === namedSkirt || multiPartClaimed.has(p.name)) continue;
2201
2475
  const hint = skirtInsideNote(p, P.alpha(p), hip[1], fh);
2202
2476
  if (hint !== null) skirtHints.set(p.name, hint);
2203
2477
  }
@@ -2205,7 +2479,7 @@ export function proposeWithBasis(
2205
2479
 
2206
2480
  // ---- outer robe (topwear)
2207
2481
  for (const p of tw) {
2208
- if (p === namedSkirt) continue;
2482
+ if (p === namedSkirt || multiPartClaimed.has(p.name)) continue;
2209
2483
  const hint = skirtHints.get(p.name);
2210
2484
  if (hint !== undefined) notes.push(hint);
2211
2485
  const [g, r] = gridR(p);
@@ -2331,16 +2605,145 @@ export function proposeWithBasis(
2331
2605
  return { o, rides: tail.spans.some((sp, k) => sp.link && o.counts[k] > 0) };
2332
2606
  };
2333
2607
  const dressRecipe = recipeOf('bottomwear');
2608
+ if (bottomwearParts !== null && bottomwearParts.list.length >= 2 && dressRecipe !== null) {
2609
+ const { field, list } = bottomwearParts;
2610
+ const object = `config.cloth."bottomwear".${field}`;
2611
+ const p0 = list[0];
2612
+ const m0 = P.alpha(p0);
2613
+ const y0 = hip[1] + 20;
2614
+ const unionMask = newMask(P.W, P.H);
2615
+ unionMask.data.set(m0.data);
2616
+ for (let idx = 1; idx < list.length; idx++) {
2617
+ const mq = P.alpha(list[idx]);
2618
+ for (let y = p0.y; y < P.H; y++) {
2619
+ let p0HasRow = false;
2620
+ for (let x = 0; x < P.W; x++) {
2621
+ if (m0.data[y * P.W + x] !== 0) {
2622
+ p0HasRow = true;
2623
+ break;
2624
+ }
2625
+ }
2626
+ if (p0HasRow) continue;
2627
+ for (let x = 0; x < P.W; x++) {
2628
+ if (mq.data[y * P.W + x] !== 0) unionMask.data[y * P.W + x] = 1;
2629
+ }
2630
+ }
2631
+ }
2632
+ const uRows = rowsAny(unionMask);
2633
+ const bot = uRows.length > 0 ? uRows[uRows.length - 1] + 1 : p0.y + p0.h;
2634
+ if (bot - 4 <= y0) {
2635
+ throw new PartsError([
2636
+ {
2637
+ code: 'PROPOSE_CLOTH_PART',
2638
+ object,
2639
+ detail: `names "${p0.name}" (${p0.from}, box y=${p0.y}..${p0.y + p0.h}), which ends above the skirt root row (y=${pyFixed(y0, 1)}) — no skirt tree link crosses "${p0.name}"`,
2640
+ },
2641
+ ]);
2642
+ }
2643
+ const [g, r] = gridR(p0);
2644
+ const { segs: fixedSegs, hangs } = skirtFrame(p0, m0);
2645
+ if (hangs !== null) notes.push(hangs);
2646
+ const prefix = 'skirt';
2647
+ const partNamesLabel = list.map((q) => q.name).join(' -> ');
2648
+ const trees = T(list, 'bottomwear', dressRecipe, {
2649
+ ...skirtInput(p0, unionMask, prefix),
2650
+ tipY: bot - 4,
2651
+ frame: `${partNamesLabel}'s art (no art in the band: that share of ${p0.name}'s box width)`,
2652
+ });
2653
+ let swing: { bone: string; rest: number } | null = null;
2654
+ if (dressRecipe.swing !== undefined) {
2655
+ const cx = or(bandX(unionMask, y0, { frac: 0.5 }), p0.x + 0.5 * p0.w);
2656
+ const first = trees[0].points.length > 1 ? trees[0].points[1][1] : trees[0].tip[1];
2657
+ const at: [number, number] = [cx, y0];
2658
+ const tipAt: [number, number] = [cx, first];
2659
+ B(
2660
+ swingName(prefix),
2661
+ 'hip',
2662
+ at,
2663
+ placement('mask', `the centre of ${partNamesLabel}'s art in the 24-row band at the root row (20 px below hip's y)`, { parts: list.map((q) => q.name), bones: ['hip'], constants: [konst('share_across', 0.5)] }),
2664
+ tipAt,
2665
+ placement('derived', `straight down from the swing's origin to the row of the first tree's second link (one link long)`, { bones: [`${trees[0].chain}0`] }),
2666
+ );
2667
+ swing = { bone: swingName(prefix), rest: roundTo((Math.atan2(-(rnd(tipAt)[1] - rnd(at)[1]), rnd(tipAt)[0] - rnd(at)[0]) * 180) / Math.PI, 6) };
2668
+ }
2669
+ const made = treeConstraints(trees, dressRecipe, swing);
2670
+ clothConstraints.push(...made);
2671
+ const spans: Array<TailSpan & { depth: number }> = [];
2672
+ for (const s of fixedSegs) if (typeof s !== 'string') spans.push({ bone: s[0], a: s[1], b: s[2], link: false, depth: -1 });
2673
+ for (const t of trees) {
2674
+ const line = [...t.points, t.tip];
2675
+ t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true, depth: t.depths[i] }));
2676
+ }
2677
+ let prevMinD = -1;
2678
+ let prevMaxD = -1;
2679
+ const linkSummary: string[] = [];
2680
+ for (let idx = 0; idx < list.length; idx++) {
2681
+ const q = list[idx];
2682
+ const mq = P.alpha(q);
2683
+ const crossedLinks = spans.filter((sp) => sp.link && spanCrosses(sp, mq) > 0);
2684
+ if (crossedLinks.length === 0) {
2685
+ const linkDesc = spans
2686
+ .filter((sp) => sp.link)
2687
+ .map((sp) => `${sp.bone} [${sp.a[0]}, ${sp.a[1]}]->[${sp.b[0]}, ${sp.b[1]}]`)
2688
+ .join(', ');
2689
+ throw new PartsError([
2690
+ {
2691
+ code: 'PROPOSE_CLOTH_PART',
2692
+ object,
2693
+ detail: `names "${q.name}" (${q.from}, box x=${q.x}..${q.x + q.w}, y=${q.y}..${q.y + q.h}), which no link of the drafted tree crosses (${linkDesc}: 0 px on "${q.name}") — every part in ${field} must be crossed by at least one link of the tree`,
2694
+ },
2695
+ ]);
2696
+ }
2697
+ const minD = Math.min(...crossedLinks.map((sp) => sp.depth));
2698
+ const maxD = Math.max(...crossedLinks.map((sp) => sp.depth));
2699
+ if (idx > 0 && (minD < prevMinD || maxD < prevMaxD)) {
2700
+ throw new PartsError([
2701
+ {
2702
+ code: 'PROPOSE_CLOTH_PART',
2703
+ object,
2704
+ detail: `names "${q.name}" after "${list[idx - 1].name}", and "${q.name}" is crossed at link depth ${minD}..${maxD} while "${list[idx - 1].name}" is crossed at depth ${prevMinD}..${prevMaxD} — list parts in order from root to tip`,
2705
+ },
2706
+ ]);
2707
+ }
2708
+ prevMinD = minD;
2709
+ prevMaxD = maxD;
2710
+ const [gq, rq] = q === p0 ? [g, r] : gridR(q);
2711
+ const qSegs: Segment[] = idx === 0 ? [...fixedSegs] : [];
2712
+ const crossedDesc: string[] = [];
2713
+ for (const sp of spans) {
2714
+ if (!sp.link) continue;
2715
+ const px = spanCrosses(sp, mq);
2716
+ if (px > 0) {
2717
+ qSegs.push(sp.bone);
2718
+ crossedDesc.push(`${sp.bone} (${px} px)`);
2719
+ }
2720
+ }
2721
+ linkSummary.push(`${q.name}: ${crossedDesc.join(', ')}`);
2722
+ delete regions[q.name];
2723
+ meshes[q.name] = { grid: gq, r: dressRecipe.split?.hold_tip === true ? Math.max(rq, gq) : rq, segments: qSegs };
2724
+ multiPartClaimed.add(q.name);
2725
+ }
2726
+ notes.push(
2727
+ clothNote(
2728
+ partNamesLabel,
2729
+ 'bottomwear',
2730
+ trees,
2731
+ dressRecipe,
2732
+ made.length,
2733
+ `${swing === null ? '' : ` on one shared swing, ${swing.bone} (each tree's first link takes its rotation times ${dressRecipe.swing?.scales.join('/')}, its setup rotation ${swing.rest} degrees subtracted)`} across ${list.length} parts in root-to-tip order (${linkSummary.join('; ')}) from hip, rooted 20 px below its y and tipped 4 px above ${list[list.length - 1].name}'s bottom, laid down the screen`,
2734
+ ),
2735
+ );
2736
+ }
2334
2737
  const riders: PartRecord[] =
2335
2738
  namedSkirt === null || dressRecipe === null
2336
2739
  ? []
2337
2740
  : ((): PartRecord[] => {
2338
2741
  const mask = P.alpha(namedSkirt);
2339
2742
  const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
2340
- return bw.filter((q) => overLinks(tail, q).rides);
2743
+ return bw.filter((q) => !multiPartClaimed.has(q.name) && overLinks(tail, q).rides);
2341
2744
  })();
2342
2745
  let dressDrafted: Tail | null = null;
2343
- const skirts = namedSkirt === null ? bw : [...bw.filter((q) => !riders.includes(q)), namedSkirt];
2746
+ const skirts = (namedSkirt === null ? bw : [...bw.filter((q) => !riders.includes(q)), namedSkirt]).filter((q) => !multiPartClaimed.has(q.name));
2344
2747
  for (const p of skirts) {
2345
2748
  const [g, r] = gridR(p);
2346
2749
  const mask = P.alpha(p);