@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 +9 -4
- package/docs/STABILITY.md +3 -2
- package/package.json +1 -1
- package/src/assemble.ts +14 -2
- package/src/check.ts +23 -5
- package/src/cloth.ts +29 -2
- package/src/config.ts +54 -4
- package/src/coords.ts +1 -1
- package/src/motion.ts +15 -5
- package/src/propose.ts +430 -27
- package/src/rig.ts +13 -2
- package/src/turn.ts +665 -1
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,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` (
|
|
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.<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`
|
|
@@ -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.<part>** — 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.<part>** — a See-through depth
|
|
165
|
+
- **turn.deform.<key>** — 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.
|
|
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
|
|
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
|
-
|
|
3335
|
-
writeFileSync(join(stressDir, '
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
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) —
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
-
|
|
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. */
|