@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 +8 -4
- package/docs/STABILITY.md +1 -1
- package/package.json +1 -1
- package/src/assemble.ts +14 -2
- package/src/cloth.ts +29 -2
- package/src/config.ts +23 -2
- package/src/propose.ts +430 -27
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.
|
|
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
|
|
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` (
|
|
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.<part>.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.<part>.auto.boundaryRuns** — required: `maxVertices`
|
|
151
151
|
- **meshes.<part>.auto.stimulus**, or each entry of it when a list — required: `chain`, `degrees`
|
|
152
|
-
- **cloth.<tag>**, 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
|
|
152
|
+
- **cloth.<tag>**, 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.<tag>.split** — required: `at`, `into`; optional: `hold_tip`
|
|
154
154
|
- **cloth.<tag>.strength** — required: `trunk`, `branch`, `edge`
|
|
155
155
|
- **cloth.<tag>.swing** — required: `scales`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/parts",
|
|
3
|
-
"version": "1.
|
|
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
|
|
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
|
|
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 (
|
|
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:
|
|
1393
|
-
placement('mask', t.tipWhy, { parts:
|
|
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
|
|
2178
|
-
if (
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
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
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
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
|
|
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);
|