@rig-c/parts 1.26.0 → 1.28.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,9 +2633,11 @@ 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 |
2640
+ | <a id="CONFIG_TURN_DEFORM_RANGE"></a>`CONFIG_TURN_DEFORM_RANGE` | an entry in `turn.deform` lies outside its allowed `[min, max]` interval in `FACE_DEFORM_RANGES` (`src/turn.ts`, issue #436); the detail gives the value found, the interval required and the default fraction | put that `turn.deform.<key>` inside the stated range, or remove it to take the Set B ×2.5 default |
2636
2641
  | <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` |
2637
2642
 
2638
2643
  ### inputs
@@ -2714,7 +2719,7 @@ says why each requirement exists and what breaks without it.
2714
2719
  | <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
2720
  | <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
2721
  | <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 |
2722
+ | <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
2723
  | <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
2724
  | <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
2725
  | <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`
@@ -160,8 +160,9 @@ part name, `[i]` an entry of a list):
160
160
  - **motion.blink** — required: `t`, `eyes`, `squash`; optional: `brows`, `brow_drop`, `still`
161
161
  - **motion.blink.still.&lt;part&gt;** — required: `row`, `bone`
162
162
  - **motion.animations_from**, the file it names — required: `spec`, `animations`
163
- - **turn** — required: `pivot`, `depths`
163
+ - **turn** — required: `pivot`, `depths`; optional: `deform`
164
164
  - **turn.depths.&lt;part&gt;** — a See-through depth
165
+ - **turn.deform.&lt;key&gt;** — a per-degree fraction or coupling share (FACE_DEFORM_FRACTIONS, issue #436)
165
166
  - **scene** — no field (`{}`; a record or an annotation only)
166
167
  - **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `limbs` under `--keypoints` where an arm or a leg is drafted from its joints (issue #349), `stops` when supplied, `motion` under the rules above, `turn` with `--full`/`--head`, `proposed_rig_scale` (issue #354), and `notes`
167
168
  <!-- /stable:config -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.26.0",
3
+ "version": "1.28.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/check.ts CHANGED
@@ -1045,6 +1045,21 @@ export interface LoopPhysics {
1045
1045
  in: 'rig.json' | 'motion.json';
1046
1046
  }
1047
1047
 
1048
+ /**
1049
+ * A throwaway copy of `rig` whose accompanying `motion` replaces `animations`
1050
+ * with a synthetic set (`still`, `blink_shut`, `stress ...`, `sweep ...`) omits
1051
+ * any `slider` constraint whose target animation is not in that set.
1052
+ */
1053
+ function withoutSliders(rig: Record<string, unknown>, animations: Record<string, unknown>): Record<string, unknown> {
1054
+ if (!Array.isArray(rig.constraints)) return rig;
1055
+ const kept = rig.constraints.filter((c) => !(isRecord(c) && c.type === 'slider' && typeof c.animation === 'string' && !(c.animation in animations)));
1056
+ if (kept.length === rig.constraints.length) return rig;
1057
+ const out: Record<string, unknown> = { ...rig };
1058
+ if (kept.length > 0) out.constraints = kept;
1059
+ else delete out.constraints;
1060
+ return out;
1061
+ }
1062
+
1048
1063
  /**
1049
1064
  * The rig spec and motion spec with every physics constraint left out, and
1050
1065
  * the constraints it left out; null when there is none, so a rig without one
@@ -3331,8 +3346,9 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3331
3346
  mkdirSync(stressDir, { recursive: true });
3332
3347
  const base = stripped === null ? inp.rig : stripped.rig;
3333
3348
  const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
3334
- writeFileSync(join(stressDir, 'rig.json'), JSON.stringify({ ...base, images }));
3335
- writeFileSync(join(stressDir, 'motion.json'), JSON.stringify({ ...(stripped === null ? inp.motion : stripped.motion), animations: stressAnimations(plan) }));
3349
+ const stressAnims = stressAnimations(plan);
3350
+ writeFileSync(join(stressDir, 'rig.json'), JSON.stringify({ ...withoutSliders(base, stressAnims), images }));
3351
+ writeFileSync(join(stressDir, 'motion.json'), JSON.stringify({ ...(stripped === null ? inp.motion : stripped.motion), animations: stressAnims }));
3336
3352
  const sb = rigc(['build', '--rig', join(stressDir, 'rig.json'), '--motion', join(stressDir, 'motion.json'), '--out', join(stressDir, 'build'), '--profile', 'spine']);
3337
3353
  if (sb.status !== 0) refuseIfAny(rigcFailed(`build (${STRESS_LINE}'s turned copy)`, sb, buildGateLines(sb.out)));
3338
3354
  const sr = rigc(['render', '--candidate', join(stressDir, 'build'), '--fps', String(IDLE_FPS), '--max', String(FOLD_RENDER_MAX), '--geometry', '--out', join(stressDir, 'render')]);
@@ -3380,7 +3396,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3380
3396
  const sets = new Map<string, { under: FrameSet; bare: FrameSet }>();
3381
3397
  if (copy.slots.length > 0) {
3382
3398
  for (const [file, image] of copy.images) writePng(join(imagesDir, file), image);
3383
- writeFileSync(join(sweepDir, 'rig.json'), JSON.stringify({ ...copy.rig, images: imagesDir }));
3399
+ const sweepAnims = isRecord(copy.motion.animations) ? copy.motion.animations : {};
3400
+ writeFileSync(join(sweepDir, 'rig.json'), JSON.stringify({ ...withoutSliders(copy.rig, sweepAnims), images: imagesDir }));
3384
3401
  writeFileSync(join(sweepDir, 'motion.json'), JSON.stringify(copy.motion));
3385
3402
  const sb = rigc(['build', '--rig', join(sweepDir, 'rig.json'), '--motion', join(sweepDir, 'motion.json'), '--out', join(sweepDir, 'build'), '--profile', 'spine']);
3386
3403
  if (sb.status !== 0) refuseIfAny(rigcFailed(`build (${SWEEP_LINE}'s bent copy)`, sb, buildGateLines(sb.out)));
@@ -3423,7 +3440,6 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3423
3440
  try {
3424
3441
  mkdirSync(stillDir, { recursive: true });
3425
3442
  const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
3426
- writeFileSync(join(stillDir, 'rig.json'), JSON.stringify({ ...inp.rig, images }));
3427
3443
  const eyes = full === null ? [] : partsTagged(full.parts, EYE_TAGS);
3428
3444
  const slotBone = new Map((Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => [String(sl.name), String(sl.bone)]));
3429
3445
  const shut = full === null ? [] : blinkTracks(inp.motion, eyeBones(inp.rig, eyes.map((p) => slotBone.get(p.name) ?? '')));
@@ -3437,6 +3453,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3437
3453
  tracks: shut.map((b) => ({ ...b.target, property: 'scaley', keys: [{ t: 0, v: [b.closed] }, { t: STILL_DURATION, v: [b.closed] }] })),
3438
3454
  };
3439
3455
  }
3456
+ const stillRig = { ...withoutSliders(inp.rig, animations), images };
3457
+ writeFileSync(join(stillDir, 'rig.json'), JSON.stringify(stillRig));
3440
3458
  writeFileSync(join(stillDir, 'motion.json'), JSON.stringify({ ...inp.motion, animations }));
3441
3459
  const sb = rigc(['build', '--rig', join(stillDir, 'rig.json'), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'build'), '--profile', 'spine']);
3442
3460
  if (sb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still)', sb, buildGateLines(sb.out)));
@@ -3460,7 +3478,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3460
3478
  }
3461
3479
  if (inp.source !== null) {
3462
3480
  // The same pose with every slot tinted black, built with the same animations so rigc fits the same grid.
3463
- writeFileSync(join(stillDir, BLACK_RIG), JSON.stringify(blackRig({ ...inp.rig, images })));
3481
+ writeFileSync(join(stillDir, BLACK_RIG), JSON.stringify(blackRig(stillRig)));
3464
3482
  const kb = rigc(['build', '--rig', join(stillDir, BLACK_RIG), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'black_build'), '--profile', 'spine']);
3465
3483
  if (kb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still, every slot tinted black)', kb, buildGateLines(kb.out)));
3466
3484
  const kr = rigc(['render', '--candidate', join(stillDir, 'black_build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'black')]);
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
@@ -93,7 +93,7 @@ import { GRID, MAX_SIDE } from './contour.ts';
93
93
  import { type Problem, refuseIfAny } from './errors.ts';
94
94
  import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
95
95
  import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
96
- import { TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
96
+ import { FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
97
97
  import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
98
98
 
99
99
  export type Point = [number, number];
@@ -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`);
@@ -2954,7 +2975,7 @@ function checkScene(c: Check, v: Json): void {
2954
2975
 
2955
2976
  function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>): void {
2956
2977
  const at = 'config.turn';
2957
- const o = c.object(at, v, ['pivot', 'depths'], []);
2978
+ const o = c.object(at, v, ['pivot', 'depths'], ['deform']);
2958
2979
  if (o === null) return;
2959
2980
  const pivot = 'pivot' in o && c.number(`${at}.pivot`, o.pivot) ? (o.pivot as number) : null;
2960
2981
  const depths: Record<string, number> = {};
@@ -2975,6 +2996,35 @@ function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>)
2975
2996
  }
2976
2997
  }
2977
2998
  }
2999
+ if ('deform' in o) {
3000
+ const df = o.deform;
3001
+ if (!isPlainObject(df)) {
3002
+ c.fail('CONFIG_FIELD_TYPE', `${at}.deform`, `is ${show(df)}; an object of face deform key -> per-degree fraction is required`);
3003
+ } else {
3004
+ const known = Object.keys(FACE_DEFORM_FRACTIONS);
3005
+ for (const [key, val] of Object.entries(df)) {
3006
+ if (isRecordKey(key)) continue;
3007
+ if (key === 'note' || key.endsWith('_note')) {
3008
+ if (typeof val !== 'string') c.fail('CONFIG_FIELD_TYPE', `${at}.deform.${key}`, `is ${show(val)}; an annotation is a string`);
3009
+ continue;
3010
+ }
3011
+ if (!Object.hasOwn(FACE_DEFORM_FRACTIONS, key)) {
3012
+ c.fail('CONFIG_KEY_KNOWN', `${at}.deform.${key}`, `is not a known face deform fraction; expected one of ${known.join(', ')} — ${DOORS}`);
3013
+ continue;
3014
+ }
3015
+ if (c.number(`${at}.deform.${key}`, val)) {
3016
+ const [lo, hi] = FACE_DEFORM_RANGES[key as FaceDeformKey];
3017
+ if (val < lo || val > hi) {
3018
+ c.fail(
3019
+ 'CONFIG_TURN_DEFORM_RANGE',
3020
+ `${at}.deform.${key}`,
3021
+ `is ${val}; a per-degree fraction in [${lo}, ${hi}] is required (default ${FACE_DEFORM_FRACTIONS[key as FaceDeformKey]})`,
3022
+ );
3023
+ }
3024
+ }
3025
+ }
3026
+ }
3027
+ }
2978
3028
  if (pivot !== null && depthsOk) {
2979
3029
  const nearest = turnNearest(depths);
2980
3030
  if (nearest !== null && !(pivot > nearest.depth)) {
package/src/coords.ts CHANGED
@@ -22,5 +22,5 @@
22
22
  * `flattenRig` goes back through `toWorld`. No rotation matrix is written out
23
23
  * by hand in this package.
24
24
  */
25
- export { computeExactFrameTransforms, computeWorldTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from 'rig-c/src/transform.ts';
25
+ export { computeExactFrameTransforms, computeWorldTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toBoneLocalVector, toWorld } from 'rig-c/src/transform.ts';
26
26
  export type { BoneTransform } from 'rig-c/src/transform.ts';
package/src/motion.ts CHANGED
@@ -145,16 +145,26 @@ export interface MotionSpec {
145
145
  animations: { idle: { duration: number; loop: true; note: string; tracks: MotionTrack[] } };
146
146
  }
147
147
 
148
+ const FACE_DEFORM_HOOK = new WeakMap<MotionSpec, (merged: MotionSpec & { animations: Record<string, unknown> }) => void>();
149
+
150
+ /** Attach a face-surface-deform hook to `motion` so {@link withAnimations} applies it when `turn`/`tilt` slider animations are merged (issue #436). */
151
+ export function bindFaceSurfaceDeform(motion: MotionSpec, hook: (merged: MotionSpec & { animations: Record<string, unknown> }) => void): void {
152
+ FACE_DEFORM_HOOK.set(motion, hook);
153
+ }
154
+
148
155
  /**
149
156
  * The motion `motion.json` holds: the idle, and after it every animation the
150
- * file `config.motion.animations_from` names, as read (issue #183) — nothing
151
- * added, nothing re-keyed, nothing judged; each is rigc's to accept at the
152
- * gate. With none, `motion` itself, so a config that names no file writes the
153
- * bytes it wrote before the field existed.
157
+ * file `config.motion.animations_from` names, as read (issue #183) — plus, when
158
+ * those animations hold `turn` or `tilt` slider `derive` keys, the Set B ×2.5
159
+ * face surface deform (issue #436). With none, `motion` itself, so a config
160
+ * that names no file writes the bytes it wrote before the field existed.
154
161
  */
155
162
  export function withAnimations(motion: MotionSpec, extra: Readonly<Record<string, unknown>> | null): MotionSpec | Record<string, unknown> {
156
163
  if (extra === null) return motion;
157
- return { ...motion, animations: { ...motion.animations, ...extra } };
164
+ const merged = { ...motion, animations: { ...motion.animations, ...extra } };
165
+ const hook = FACE_DEFORM_HOOK.get(motion);
166
+ if (hook !== undefined) hook(merged);
167
+ return merged;
158
168
  }
159
169
 
160
170
  /** One sine channel over an idle of `duration` seconds. */