@rig-c/parts 1.19.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/cli.ts +24 -6
- package/docs/AUTHORING.md +136 -6
- package/docs/MIGRATION.md +20 -0
- package/docs/STABILITY.md +7 -3
- package/package.json +2 -2
- package/skills/rig-parts/SKILL.md +17 -3
- package/src/build.ts +2 -2
- package/src/check.ts +10 -2
- package/src/cloth.ts +378 -0
- package/src/config.ts +139 -2
- package/src/propose.ts +187 -15
- package/src/scene.ts +1 -1
package/src/cloth.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cloth and hair recipe (issue #364): what `propose --cloth` drafts for a
|
|
3
|
+
* `bottomwear` part and for `back hair` and `front hair` when the config's
|
|
4
|
+
* `cloth` block names that tag — dense trees hung from the attachment bone,
|
|
5
|
+
* physics on every link, and no idle key on any of them.
|
|
6
|
+
*
|
|
7
|
+
* The purpose is the world, not the loop: cloth and hair move because the
|
|
8
|
+
* body under them moves and because the consumer blows wind or turns gravity
|
|
9
|
+
* (the owner's direction, 2026-10-13). So each link carries a declared
|
|
10
|
+
* response — rig-c's physics `wind` and `gravity`, each a scalar multiplier of
|
|
11
|
+
* the environment the consumer sets (spine-core's skeleton-level `windX/windY`
|
|
12
|
+
* and `gravityX/gravityY`) — that grows from the root to the tip. The
|
|
13
|
+
* direction is the consumer's, and nothing here writes one.
|
|
14
|
+
*
|
|
15
|
+
* ⭐ Strength does not fall along a chain. A tip reads soft because each
|
|
16
|
+
* link turns on top of every link above it, so the motion accumulates down
|
|
17
|
+
* the chain at one strength; only the edge branch is declared softer.
|
|
18
|
+
*
|
|
19
|
+
* The rules, each a function of the recipe and the part's art (AUTHORING §3,
|
|
20
|
+
* *Recipe: cloth and hair that answer the world*):
|
|
21
|
+
*
|
|
22
|
+
* - **Trees.** `chains` trees hang across the piece: tree k owns the cell
|
|
23
|
+
* [k/N, (k+1)/N] of the art's width in every row (for `back hair`, of each
|
|
24
|
+
* side's art, either side of the eye axis), and its links sit at the cell's
|
|
25
|
+
* centre in the 24-row band at their row. With `split: {at, into}` the first
|
|
26
|
+
* `at` links are the tree's trunk, and the trunk's tip forks into `into`
|
|
27
|
+
* branches, each the centre of its share of the cell, down to the tip row.
|
|
28
|
+
* Without `split`, each tree is one chain.
|
|
29
|
+
* - **Lengths.** Every path from root to tip has `links` links, and link j's
|
|
30
|
+
* length is the first's times q^j, q = `length_ratio`^(1/(links − 1)), the
|
|
31
|
+
* whole spanning the root row to the tip row: the links shorten toward the
|
|
32
|
+
* tip.
|
|
33
|
+
* - **Stiffness.** A trunk link takes `strength.trunk`, a branch link
|
|
34
|
+
* `strength.branch`, and the **edge** — the outermost branch on each outer
|
|
35
|
+
* side of the piece (without `split`, the outermost tree) — `strength.edge`.
|
|
36
|
+
* Every spring is held to the step's stability bound ({@link springStable}).
|
|
37
|
+
* - **Response.** Link j of a path of n links takes `wind` and `gravity` at
|
|
38
|
+
* t = j/(n − 1) of the way from the declared root value to the tip value (a
|
|
39
|
+
* single-link path takes the tip value: it is a dangling end).
|
|
40
|
+
* - **Out of phase.** `alternate_damping`, when declared, is the damping of
|
|
41
|
+
* every odd tree; the even ones take `damping`.
|
|
42
|
+
* - **Translate.** `translate`, when declared, is the `x` and `y` mix of a
|
|
43
|
+
* second physics constraint on the last link of every path, beside its
|
|
44
|
+
* rotation, with no wind or gravity response ({@link physics} says why).
|
|
45
|
+
* - **Swing** (`bottomwear` only). `swing.scales` declares one shared pendulum:
|
|
46
|
+
* a bone `<prefix>swing` under the attachment at the centre of the root row,
|
|
47
|
+
* one link long, with physics; each tree's first link takes the swing's
|
|
48
|
+
* rotation times its scale (a transform constraint, local source and target,
|
|
49
|
+
* additive) instead of a physics constraint of its own.
|
|
50
|
+
*
|
|
51
|
+
* Two numbers have defaults, {@link RECIPE_LENGTH_RATIO} and
|
|
52
|
+
* {@link RECIPE_STRENGTH}: the recipe's own, chosen and measured on this
|
|
53
|
+
* package's synthetic fixture (`fixtures/cloth.ts`); each says what was
|
|
54
|
+
* measured. Every other number is the author's.
|
|
55
|
+
*
|
|
56
|
+
* Pure: no clock, no randomness; every value is a function of its inputs.
|
|
57
|
+
*/
|
|
58
|
+
import type { ConfigConstraint, Point } from './config.ts';
|
|
59
|
+
import { pyRound } from './round.ts';
|
|
60
|
+
|
|
61
|
+
/** The tags a `cloth` recipe may name: the parts the proposer drafts chains on that hang. */
|
|
62
|
+
export const CLOTH_TARGETS = ['bottomwear', 'back hair', 'front hair'] as const;
|
|
63
|
+
export type ClothTarget = (typeof CLOTH_TARGETS)[number];
|
|
64
|
+
|
|
65
|
+
/** The per-branch stiffness of a tree. */
|
|
66
|
+
export interface ClothStrength {
|
|
67
|
+
trunk: number;
|
|
68
|
+
branch: number;
|
|
69
|
+
edge: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One target's recipe, as `config.cloth.<tag>` declares it. */
|
|
73
|
+
export interface ClothRecipe {
|
|
74
|
+
chains: number;
|
|
75
|
+
links: number;
|
|
76
|
+
split?: { at: number; into: number };
|
|
77
|
+
length_ratio?: number;
|
|
78
|
+
strength?: ClothStrength;
|
|
79
|
+
damping?: number;
|
|
80
|
+
alternate_damping?: number;
|
|
81
|
+
/** `[root, tip]`: the wind response of a path's first link and of its last. */
|
|
82
|
+
wind: [number, number];
|
|
83
|
+
/** `[root, tip]`: the gravity response of a path's first link and of its last. */
|
|
84
|
+
gravity: [number, number];
|
|
85
|
+
translate?: number;
|
|
86
|
+
swing?: { scales: number[] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type ClothRecipes = Partial<Record<ClothTarget, ClothRecipe>>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 📐 The default ratio of a path's last link to its first: 0.5, the recipe's
|
|
93
|
+
* own. Measured on the cloth fixture (fixtures/cloth.ts, rig-c 2.47.1, the
|
|
94
|
+
* selftest's CK420 build): see AUTHORING §3, *Recipe: cloth and hair*, which
|
|
95
|
+
* holds the figures. Links halve from root to tip, whatever their count.
|
|
96
|
+
*/
|
|
97
|
+
export const RECIPE_LENGTH_RATIO = 0.5;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 📐 The default stiffness: trunk and branch 100 — rig-c's own default
|
|
101
|
+
* `strength`, so the trunk and the main branches are as stiff as an
|
|
102
|
+
* undeclared spring — and the edge 50, half of it: the edge is the one part
|
|
103
|
+
* declared softer. The recipe's own, measured on the cloth fixture
|
|
104
|
+
* (AUTHORING §3, *Recipe: cloth and hair*). Each is below the stability bound
|
|
105
|
+
* at every damping in [0, 1] ({@link springStable}: 100/3600 against at
|
|
106
|
+
* least 2).
|
|
107
|
+
*/
|
|
108
|
+
export const RECIPE_STRENGTH: Readonly<ClothStrength> = { trunk: 100, branch: 100, edge: 50 };
|
|
109
|
+
|
|
110
|
+
/** rig-c's default physics `damping` (its `RigPhysicsConstraint` doc: "Defaults: 0.5 / 100 / 0.85 / 1 / 0 / 0 / 1"), which a recipe without `damping` runs at. */
|
|
111
|
+
export const RIGC_DEFAULT_DAMPING = 0.85;
|
|
112
|
+
|
|
113
|
+
/** rig-c's default physics `fps` (`step = 1/fps`) and `mass` (`massInverse = 1/mass`), neither of which a recipe sets. */
|
|
114
|
+
export const RIGC_DEFAULT_FPS = 60;
|
|
115
|
+
export const RIGC_DEFAULT_MASS = 1;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The stability of one physics spring at its step (A23's inequality). Per step
|
|
119
|
+
* spine-core does `v += -strength·x·step·massInverse; x += v·step; v *=
|
|
120
|
+
* d`, d = damping^(60·step). In (x, v·step) that is the matrix
|
|
121
|
+
* [[1 − k, 1], [−d·k, d]], k = strength·massInverse·step², with trace
|
|
122
|
+
* 1 − k + d and determinant d; both eigenvalues lie inside the unit circle
|
|
123
|
+
* exactly when 0 < k < 2(1 + d) (and d < 1; at d = 1 the spring rings without
|
|
124
|
+
* decay but does not grow). Returns k, the bound and whether k is below it.
|
|
125
|
+
*/
|
|
126
|
+
export function springStable(strength: number, damping: number, fps = RIGC_DEFAULT_FPS, mass = RIGC_DEFAULT_MASS): { k: number; bound: number; ok: boolean } {
|
|
127
|
+
const step = 1 / fps;
|
|
128
|
+
const k = (strength / mass) * step * step;
|
|
129
|
+
const bound = 2 * (1 + damping ** (60 * step));
|
|
130
|
+
return { k, bound, ok: k > 0 && k < bound };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The decimals a drafted physics number is written to: enough for a ramp of a few links, short enough to read. */
|
|
134
|
+
export const CLOTH_DECIMALS = 6;
|
|
135
|
+
|
|
136
|
+
/** What a link is in its tree, which sets its stiffness. */
|
|
137
|
+
export type LinkRole = 'trunk' | 'branch' | 'edge';
|
|
138
|
+
|
|
139
|
+
/** One drafted chain of a tree. */
|
|
140
|
+
export interface TreeChain {
|
|
141
|
+
chain: string;
|
|
142
|
+
parent: string;
|
|
143
|
+
points: Point[];
|
|
144
|
+
tip: Point;
|
|
145
|
+
/** One rule per point, then the tip's: what each was read off. */
|
|
146
|
+
why: string[];
|
|
147
|
+
tipWhy: string;
|
|
148
|
+
/** Which tree (0-based, across the piece or its side) this chain belongs to. */
|
|
149
|
+
tree: number;
|
|
150
|
+
/** The role of each link. */
|
|
151
|
+
roles: LinkRole[];
|
|
152
|
+
/** Each link's depth from its tree's root (0 = the root link). */
|
|
153
|
+
depths: number[];
|
|
154
|
+
/** Whether each link is the last of its path. */
|
|
155
|
+
last: boolean[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Which outer sides of the piece are edges: both (a skirt, a lock), or one side of the eye axis (back hair). */
|
|
159
|
+
export type EdgeSides = 'both' | 'r' | 'l';
|
|
160
|
+
|
|
161
|
+
export interface TreeInput {
|
|
162
|
+
/** The chains' name prefix: tree k's trunk is `<prefix><k>_`, its branches `<prefix><k><a|b|…>_`. */
|
|
163
|
+
prefix: string;
|
|
164
|
+
parent: string;
|
|
165
|
+
/** The root row (the first link's origin) and the tip row (the last link's tip), rig px, y down. */
|
|
166
|
+
rootY: number;
|
|
167
|
+
tipY: number;
|
|
168
|
+
/** The art's x at row y, `frac` of the way across the cell's frame (0 = the frame's left edge), or null when the band holds no art there. */
|
|
169
|
+
xAt: (y: number, frac: number) => number | null;
|
|
170
|
+
/** Where a link falls when its band holds no art. */
|
|
171
|
+
fallbackX: (frac: number) => number;
|
|
172
|
+
/** What the cell's frame is, for the rules: "skirt's art", "the r side of back's art". */
|
|
173
|
+
frame: string;
|
|
174
|
+
edges: EdgeSides;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The rows the n links of a path start at, then its tip's: lengths in the ratio q^j, from root row to tip row. */
|
|
178
|
+
export function linkRows(rootY: number, tipY: number, links: number, ratio: number): number[] {
|
|
179
|
+
const q = links === 1 ? 1 : ratio ** (1 / (links - 1));
|
|
180
|
+
const w: number[] = [];
|
|
181
|
+
for (let j = 0; j < links; j++) w.push(q ** j);
|
|
182
|
+
const total = w.reduce((a, b) => a + b, 0);
|
|
183
|
+
const rows = [rootY];
|
|
184
|
+
let acc = 0;
|
|
185
|
+
for (let j = 0; j < links; j++) {
|
|
186
|
+
acc += w[j];
|
|
187
|
+
rows.push(j === links - 1 ? tipY : rootY + ((tipY - rootY) * acc) / total);
|
|
188
|
+
}
|
|
189
|
+
return rows;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
193
|
+
|
|
194
|
+
/** The trees of one piece, in tree order, each trunk before its branches. */
|
|
195
|
+
export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
196
|
+
const n = r.chains;
|
|
197
|
+
const ratio = r.length_ratio ?? RECIPE_LENGTH_RATIO;
|
|
198
|
+
const rows = linkRows(input.rootY, input.tipY, r.links, ratio);
|
|
199
|
+
const at = r.split === undefined ? r.links : r.split.at;
|
|
200
|
+
const into = r.split === undefined ? 1 : r.split.into;
|
|
201
|
+
const out: TreeChain[] = [];
|
|
202
|
+
const x = (y: number, f: number): number => input.xAt(y, f) ?? input.fallbackX(f);
|
|
203
|
+
const pt = (y: number, f: number): Point => [pyRound(x(y, f), 0), pyRound(y, 0)];
|
|
204
|
+
const rowRule = (j: number): string =>
|
|
205
|
+
`link ${j}'s row: ${j === 0 ? 'the root row' : `the root row + ${pyRound((rows[j] - rows[0]) / (rows[r.links] - rows[0]), 4)} of the span to the tip row`} (lengths in the ratio ${pyRound(ratio, 6)} last to first)`;
|
|
206
|
+
const outerSide = (k: number, b: number): boolean => {
|
|
207
|
+
const left = k === 0 && b === 0;
|
|
208
|
+
const right = k === n - 1 && b === into - 1;
|
|
209
|
+
return input.edges === 'both' ? left || right : input.edges === 'r' ? left : right;
|
|
210
|
+
};
|
|
211
|
+
for (let k = 0; k < n; k++) {
|
|
212
|
+
const fc = (k + 0.5) / n;
|
|
213
|
+
const trunkLinks = Math.min(at, r.links);
|
|
214
|
+
const trunkEdge = into === 1 && outerSide(k, 0);
|
|
215
|
+
const trunk: TreeChain = {
|
|
216
|
+
chain: `${input.prefix}${k}_`,
|
|
217
|
+
parent: input.parent,
|
|
218
|
+
points: [],
|
|
219
|
+
tip: pt(rows[trunkLinks], fc),
|
|
220
|
+
why: [],
|
|
221
|
+
tipWhy: `${trunkLinks === r.links ? 'the tip row' : `link ${trunkLinks}'s row, the fork`}, ${pyRound(fc, 6)} of the way across ${input.frame} (tree ${k} of ${n}, the centre of its cell)`,
|
|
222
|
+
tree: k,
|
|
223
|
+
roles: [],
|
|
224
|
+
depths: [],
|
|
225
|
+
last: [],
|
|
226
|
+
};
|
|
227
|
+
for (let j = 0; j < trunkLinks; j++) {
|
|
228
|
+
trunk.points.push(pt(rows[j], fc));
|
|
229
|
+
trunk.why.push(`${rowRule(j)}, ${pyRound(fc, 6)} of the way across ${input.frame} in the 24-row band there (tree ${k} of ${n}, the centre of its cell)`);
|
|
230
|
+
trunk.roles.push(into === 1 ? (trunkEdge ? 'edge' : 'trunk') : 'trunk');
|
|
231
|
+
trunk.depths.push(j);
|
|
232
|
+
trunk.last.push(into === 1 && j === trunkLinks - 1);
|
|
233
|
+
}
|
|
234
|
+
out.push(trunk);
|
|
235
|
+
if (into === 1) continue;
|
|
236
|
+
for (let b = 0; b < into; b++) {
|
|
237
|
+
const fb = (k + (b + 0.5) / into) / n;
|
|
238
|
+
const ch: TreeChain = {
|
|
239
|
+
chain: `${input.prefix}${k}${LETTERS[b]}_`,
|
|
240
|
+
parent: `${trunk.chain}${trunkLinks - 1}`,
|
|
241
|
+
points: [],
|
|
242
|
+
tip: pt(rows[r.links], fb),
|
|
243
|
+
why: [],
|
|
244
|
+
tipWhy: `the tip row, ${pyRound(fb, 6)} of the way across ${input.frame} (branch ${b} of ${into} of tree ${k}, the centre of its share of the cell)`,
|
|
245
|
+
tree: k,
|
|
246
|
+
roles: [],
|
|
247
|
+
depths: [],
|
|
248
|
+
last: [],
|
|
249
|
+
};
|
|
250
|
+
const edge = outerSide(k, b);
|
|
251
|
+
for (let j = trunkLinks; j < r.links; j++) {
|
|
252
|
+
ch.points.push(j === trunkLinks ? trunk.tip : pt(rows[j], fb));
|
|
253
|
+
ch.why.push(j === trunkLinks ? `the fork: the trunk ${trunk.chain}'s tip` : `${rowRule(j)}, ${pyRound(fb, 6)} of the way across ${input.frame} in the 24-row band there (branch ${b} of ${into} of tree ${k})`);
|
|
254
|
+
ch.roles.push(edge ? 'edge' : 'branch');
|
|
255
|
+
ch.depths.push(j);
|
|
256
|
+
ch.last.push(j === r.links - 1);
|
|
257
|
+
}
|
|
258
|
+
out.push(ch);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The value t of the way from a `[root, tip]` pair's root to its tip, to {@link CLOTH_DECIMALS}. */
|
|
265
|
+
function ramp(pair: readonly [number, number], t: number): number {
|
|
266
|
+
return pyRound(pair[0] + t * (pair[1] - pair[0]), CLOTH_DECIMALS);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Where link `depth` of a path of `links` links sits on the root-to-tip ramp. */
|
|
270
|
+
export function rampAt(depth: number, links: number): number {
|
|
271
|
+
return links === 1 ? 1 : depth / (links - 1);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The physics constraint name of a bone. */
|
|
275
|
+
export function physicsName(bone: string): string {
|
|
276
|
+
return `${bone}_physics`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The translate constraint name of a bone (a dangling end's flutter). */
|
|
280
|
+
export function translateName(bone: string): string {
|
|
281
|
+
return `${bone}_translate`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A link's physics, its fields in a fixed order: the rotation with the link's
|
|
286
|
+
* wind and gravity response, and — on the last link of a path, when the recipe
|
|
287
|
+
* declares `translate` — a second constraint moving x and y by that mix, with
|
|
288
|
+
* the same stiffness and damping and no response at all.
|
|
289
|
+
*
|
|
290
|
+
* ⭐ Why the flutter carries no response: spine-core scales a translate
|
|
291
|
+
* spring's wind and gravity by the skeleton's reference scale (100 by
|
|
292
|
+
* default) and a rotation's by the link's length over it, so one response on
|
|
293
|
+
* both components pushes the end ~100/length times further sideways than it
|
|
294
|
+
* turns it. Measured on the cloth fixture (fixtures/cloth.ts, its recipe, the
|
|
295
|
+
* recipe's default strength, rig-c 2.47.1's render under spine-core's default
|
|
296
|
+
* environment), check's TEXTURE_STRETCH severity (ceiling 1.927) with the
|
|
297
|
+
* translate mix at 0.2 / 0.5 / 1: split so, 1.382 / 1.388 / 1.398; with the
|
|
298
|
+
* link's response on the translate too, 1.65 / 3.233 / 3.814.
|
|
299
|
+
*/
|
|
300
|
+
function physics(bone: string, r: ClothRecipe, role: LinkRole, t: number, tree: number, last: boolean): ConfigConstraint[] {
|
|
301
|
+
const s = r.strength ?? RECIPE_STRENGTH;
|
|
302
|
+
const damping = tree % 2 === 1 && r.alternate_damping !== undefined ? r.alternate_damping : r.damping;
|
|
303
|
+
const c: ConfigConstraint = { type: 'physics', name: physicsName(bone), bone, rotate: 1, strength: s[role] };
|
|
304
|
+
if (damping !== undefined) c.damping = damping;
|
|
305
|
+
c.wind = ramp(r.wind, t);
|
|
306
|
+
c.gravity = ramp(r.gravity, t);
|
|
307
|
+
if (!last || r.translate === undefined) return [c];
|
|
308
|
+
const tr: ConfigConstraint = { type: 'physics', name: translateName(bone), bone, x: r.translate, y: r.translate, strength: s[role] };
|
|
309
|
+
if (damping !== undefined) tr.damping = damping;
|
|
310
|
+
return [c, tr];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The swing bone's name for a prefix. */
|
|
314
|
+
export function swingName(prefix: string): string {
|
|
315
|
+
return `${prefix}_swing`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The constraints of a piece's trees: with a swing, the swing's physics and
|
|
320
|
+
* one transform per tree first, then a physics constraint per link in bone
|
|
321
|
+
* order — a parent's before its child's, so each spring reads its parent's
|
|
322
|
+
* settled pose. `swingRest` is the swing's setup rotation in its parent's
|
|
323
|
+
* frame, Spine degrees (y up), which the transform subtracts so the trees take
|
|
324
|
+
* only the swing's motion.
|
|
325
|
+
*/
|
|
326
|
+
export function treeConstraints(trees: readonly TreeChain[], r: ClothRecipe, swing: { bone: string; rest: number } | null): ConfigConstraint[] {
|
|
327
|
+
const out: ConfigConstraint[] = [];
|
|
328
|
+
if (swing !== null && r.swing !== undefined) {
|
|
329
|
+
out.push(...physics(swing.bone, r, 'trunk', 0, 0, false));
|
|
330
|
+
const scales = r.swing.scales;
|
|
331
|
+
for (const t of trees) {
|
|
332
|
+
if (t.depths[0] !== 0) continue;
|
|
333
|
+
out.push({
|
|
334
|
+
type: 'transform',
|
|
335
|
+
name: `${swing.bone}_${t.tree}`,
|
|
336
|
+
source: swing.bone,
|
|
337
|
+
bones: [`${t.chain}0`],
|
|
338
|
+
localSource: true,
|
|
339
|
+
localTarget: true,
|
|
340
|
+
additive: true,
|
|
341
|
+
properties: { rotate: { offset: swing.rest, to: { rotate: { scale: scales[t.tree] } } } },
|
|
342
|
+
mixRotate: 1,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (const t of trees) {
|
|
347
|
+
t.points.forEach((_, i) => {
|
|
348
|
+
if (swing !== null && t.depths[i] === 0) return;
|
|
349
|
+
out.push(...physics(`${t.chain}${i}`, r, t.roles[i], rampAt(t.depths[i], r.links), t.tree, t.last[i]));
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The physics of a chain the recipe does not shape (the fringe's columns,
|
|
357
|
+
* whose two links the fringe rule places): every link a trunk link of a
|
|
358
|
+
* one-tree path, an outer column's links the edge's, on the recipe's ramp
|
|
359
|
+
* over the chain's own links.
|
|
360
|
+
*/
|
|
361
|
+
export function chainConstraints(chain: string, links: number, tree: number, edge: boolean, r: ClothRecipe): ConfigConstraint[] {
|
|
362
|
+
const out: ConfigConstraint[] = [];
|
|
363
|
+
for (let i = 0; i < links; i++) out.push(...physics(`${chain}${i}`, r, edge ? 'edge' : 'trunk', rampAt(i, links), tree, i === links - 1));
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The one note a piece drafted by the recipe gets. */
|
|
368
|
+
export function clothNote(part: string, tag: ClothTarget, trees: readonly TreeChain[], r: ClothRecipe, constraints: number, extra: string): string {
|
|
369
|
+
const defaults: string[] = [];
|
|
370
|
+
if (r.length_ratio === undefined) defaults.push(`length_ratio ${RECIPE_LENGTH_RATIO}`);
|
|
371
|
+
if (r.strength === undefined) defaults.push(`strength ${RECIPE_STRENGTH.trunk}/${RECIPE_STRENGTH.branch}/${RECIPE_STRENGTH.edge}`);
|
|
372
|
+
const shape = r.split === undefined ? `${r.links} link(s) each` : `a ${r.split.at}-link trunk forking into ${r.split.into} branches, ${r.links} links root to tip`;
|
|
373
|
+
return (
|
|
374
|
+
`${part} (${tag}): config.cloth."${tag}" -> ${r.chains} tree(s)${extra}, ${shape} (${trees.map((t) => t.chain).join(', ')}); ` +
|
|
375
|
+
`${constraints} constraint(s), physics on every link, wind ${r.wind[0]} -> ${r.wind[1]} and gravity ${r.gravity[0]} -> ${r.gravity[1]} root to tip (multipliers of the consumer's environment; no direction is drafted), no idle key` +
|
|
376
|
+
(defaults.length === 0 ? '' : `; the recipe's own defaults, measured on its synthetic fixture (AUTHORING §3): ${defaults.join(', ')}`)
|
|
377
|
+
);
|
|
378
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -87,6 +87,8 @@
|
|
|
87
87
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
88
88
|
import { dirname, resolve } from 'node:path';
|
|
89
89
|
import { RIG_BONE_INHERIT, RIG_SKIN_CONSTRAINT_KEYS, type RigBoneInherit, type RigConstraint, type RigSkinConstraintKey } from 'rig-c/src/rig.ts';
|
|
90
|
+
import { pyRound } from './round.ts';
|
|
91
|
+
import { CLOTH_TARGETS, type ClothRecipes, RECIPE_STRENGTH, RIGC_DEFAULT_DAMPING, springStable } from './cloth.ts';
|
|
90
92
|
import { GRID, MAX_SIDE } from './contour.ts';
|
|
91
93
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
92
94
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
@@ -676,6 +678,8 @@ export interface CharacterConfig {
|
|
|
676
678
|
turn?: TurnSpec;
|
|
677
679
|
/** The painting is a scene (issue #242): absent, no byte of any output moves. */
|
|
678
680
|
scene?: SceneSpec;
|
|
681
|
+
/** The cloth and hair recipe `propose --cloth` drafts from (issue #364, `src/cloth.ts`): read by propose only, so no byte of any rig output moves with it. */
|
|
682
|
+
cloth?: ClothRecipes;
|
|
679
683
|
}
|
|
680
684
|
|
|
681
685
|
/** `config.scene` (issue #242): the declaration that the painting is a scene. It holds no field today. */
|
|
@@ -909,7 +913,7 @@ export function loadConfigAndAnimations(path: string): { config: CharacterConfig
|
|
|
909
913
|
export type EarlyDoor = 'paint' | 'layers' | 'assemble';
|
|
910
914
|
|
|
911
915
|
/** Every loader: the three early doors, and `full` — `parseConfig`, in front of `rig`, `build` and `propose --from-config`/`--compare`. */
|
|
912
|
-
export type ConfigDoor = EarlyDoor | 'full';
|
|
916
|
+
export type ConfigDoor = EarlyDoor | 'full' | 'cloth';
|
|
913
917
|
|
|
914
918
|
/**
|
|
915
919
|
* 🔒 The one statement of what each loader requires, as dotted field paths.
|
|
@@ -923,6 +927,7 @@ export const CONFIG_REQUIRES: Readonly<Record<ConfigDoor, readonly string[]>> =
|
|
|
923
927
|
layers: ['key', 'seethrough', 'assemble.rig_scale'],
|
|
924
928
|
assemble: ['key', 'seethrough', 'assemble.rig_scale', 'assemble.plan'],
|
|
925
929
|
full: ['key', 'assemble.rig_scale', 'assemble.plan', 'bones', 'meshes', 'regions', 'motion'],
|
|
930
|
+
cloth: ['key', 'cloth'],
|
|
926
931
|
};
|
|
927
932
|
|
|
928
933
|
/** The top-level keys a loader requires, in `CONFIG_REQUIRES` order. */
|
|
@@ -964,7 +969,7 @@ export interface AssembleConfig {
|
|
|
964
969
|
bones?: BoneEntry[];
|
|
965
970
|
}
|
|
966
971
|
|
|
967
|
-
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene'] as const;
|
|
972
|
+
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene', 'cloth'] as const;
|
|
968
973
|
|
|
969
974
|
/**
|
|
970
975
|
* The one partial entry point. It validates exactly what its door requires —
|
|
@@ -1022,6 +1027,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1022
1027
|
checkLimbs(c, t.limbs, made, names);
|
|
1023
1028
|
}
|
|
1024
1029
|
}
|
|
1030
|
+
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
1025
1031
|
const pose = base === null ? null : poseBeside(c, t.generation, base);
|
|
1026
1032
|
refuseIfAny(c.problems);
|
|
1027
1033
|
return { config: raw as PaintConfig | EarlyConfig | AssembleConfig, pose };
|
|
@@ -1175,6 +1181,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1175
1181
|
if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
|
|
1176
1182
|
if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
|
|
1177
1183
|
if ('scene' in t) checkScene(c, t.scene);
|
|
1184
|
+
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
1178
1185
|
const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
|
|
1179
1186
|
const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
|
|
1180
1187
|
if (base !== null) poseBeside(c, t.generation, base);
|
|
@@ -2582,6 +2589,136 @@ function attachedParts(meshes: Json, regions: Json): Set<string> {
|
|
|
2582
2589
|
* declaration. Not `true`: an object is what a later field can be added to
|
|
2583
2590
|
* without changing what an existing config means.
|
|
2584
2591
|
*/
|
|
2592
|
+
/**
|
|
2593
|
+
* `config.cloth` (issue #364): a recipe per tag `propose --cloth` drafts trees
|
|
2594
|
+
* for. Every field is checked here, and every combination that would draft
|
|
2595
|
+
* something other than what it says is refused by name:
|
|
2596
|
+
*
|
|
2597
|
+
* - a tag that is not one of {@link CLOTH_TARGETS} (`CONFIG_KEY_KNOWN`);
|
|
2598
|
+
* - `split.at` at or past `links` — no link would be left for the branches
|
|
2599
|
+
* (`CONFIG_CLOTH_SPLIT_BEFORE_TIP`);
|
|
2600
|
+
* - a `wind` or `gravity` pair whose tip is below its root: the response is
|
|
2601
|
+
* growing toward the tip (`CONFIG_CLOTH_RESPONSE_GROWS_TO_TIP`);
|
|
2602
|
+
* - `alternate_damping` without `damping`, or equal to it: neighbouring trees
|
|
2603
|
+
* are declared out of phase, and an alternate that alternates nothing is
|
|
2604
|
+
* not a declaration (`CONFIG_CLOTH_ALTERNATE_DIFFERS`);
|
|
2605
|
+
* - `swing` on a tag other than `bottomwear`, or `swing.scales` not one per
|
|
2606
|
+
* tree (`CONFIG_CLOTH_SWING_SCALE_PER_TREE`);
|
|
2607
|
+
* - a strength, declared or the recipe's default, whose spring is unstable at
|
|
2608
|
+
* a damping the recipe runs at — k = strength·massInverse·step² at or past
|
|
2609
|
+
* 2(1 + damping^(60·step)), rig-c's default fps 60 and mass 1, the damping
|
|
2610
|
+
* `damping`, `alternate_damping` or rig-c's default 0.85
|
|
2611
|
+
* (`CONFIG_CLOTH_SPRING_STABLE`, {@link springStable}).
|
|
2612
|
+
*/
|
|
2613
|
+
function checkCloth(c: Check, v: Json): void {
|
|
2614
|
+
const at = 'config.cloth';
|
|
2615
|
+
if (!isPlainObject(v)) {
|
|
2616
|
+
c.fail('CONFIG_FIELD_TYPE', at, `is ${show(v)}; an object is required — one recipe per tag, ${CLOTH_TARGETS.map((t) => `"${t}"`).join(', ')}`);
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
// The keys are tags, not fields: read here rather than by c.object, whose lists are field names.
|
|
2620
|
+
for (const key of Object.keys(v)) {
|
|
2621
|
+
if (isRecordKey(key) || (CLOTH_TARGETS as readonly string[]).includes(key)) continue;
|
|
2622
|
+
if (key === 'note' || key.endsWith('_note')) {
|
|
2623
|
+
if (typeof v[key] !== 'string') c.fail('CONFIG_FIELD_TYPE', `${at}.${key}`, `is ${show(v[key])}; an annotation is a string`);
|
|
2624
|
+
continue;
|
|
2625
|
+
}
|
|
2626
|
+
c.fail('CONFIG_KEY_KNOWN', `${at}.${JSON.stringify(key)}`, `is not a tag a recipe is drafted for; known: ${CLOTH_TARGETS.map((t) => `"${t}"`).join(', ')} — ${DOORS}`);
|
|
2627
|
+
}
|
|
2628
|
+
if (Object.keys(v).filter((k) => (CLOTH_TARGETS as readonly string[]).includes(k)).length === 0) {
|
|
2629
|
+
c.fail('CONFIG_FIELD_PRESENT', at, `names no tag; one recipe per tag, ${CLOTH_TARGETS.map((t) => `"${t}"`).join(', ')}, is required (leave cloth out for none)`);
|
|
2630
|
+
}
|
|
2631
|
+
for (const tag of CLOTH_TARGETS) {
|
|
2632
|
+
if (!(tag in v)) continue;
|
|
2633
|
+
const path = `${at}.${JSON.stringify(tag)}`;
|
|
2634
|
+
const r = c.object(path, v[tag], ['chains', 'links', 'wind', 'gravity'], ['split', 'length_ratio', 'strength', 'damping', 'alternate_damping', 'translate', 'swing']);
|
|
2635
|
+
if (r === null) continue;
|
|
2636
|
+
const chains = 'chains' in r && c.int(`${path}.chains`, r.chains, 1) ? (r.chains as number) : null;
|
|
2637
|
+
const links = 'links' in r && c.int(`${path}.links`, r.links, 1) ? (r.links as number) : null;
|
|
2638
|
+
if ('split' in r) {
|
|
2639
|
+
const sp = c.object(`${path}.split`, r.split, ['at', 'into'], []);
|
|
2640
|
+
if (sp !== null) {
|
|
2641
|
+
const sAt = 'at' in sp && c.int(`${path}.split.at`, sp.at, 1) ? (sp.at as number) : null;
|
|
2642
|
+
if ('into' in sp) c.int(`${path}.split.into`, sp.into, 2);
|
|
2643
|
+
if (sAt !== null && links !== null && sAt >= links) {
|
|
2644
|
+
c.fail('CONFIG_CLOTH_SPLIT_BEFORE_TIP', `${path}.split.at`, `is ${sAt} and links is ${links}; the trunk's ${sAt} link(s) would leave no link for the branches — split.at below links is required`);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
if ('length_ratio' in r) c.number(`${path}.length_ratio`, r.length_ratio, 'positive');
|
|
2649
|
+
if ('strength' in r) {
|
|
2650
|
+
const st = c.object(`${path}.strength`, r.strength, ['trunk', 'branch', 'edge'], []);
|
|
2651
|
+
if (st !== null) for (const k of ['trunk', 'branch', 'edge'] as const) if (k in st) c.number(`${path}.strength.${k}`, st[k], 'positive');
|
|
2652
|
+
}
|
|
2653
|
+
const damping = 'damping' in r && c.number(`${path}.damping`, r.damping, 'unit') ? (r.damping as number) : null;
|
|
2654
|
+
if ('alternate_damping' in r && c.number(`${path}.alternate_damping`, r.alternate_damping, 'unit')) {
|
|
2655
|
+
if (!('damping' in r)) c.fail('CONFIG_CLOTH_ALTERNATE_DIFFERS', `${path}.alternate_damping`, 'is declared and damping is absent; the odd trees take alternate_damping in place of the even trees\' damping, so damping is required beside it');
|
|
2656
|
+
else if (damping !== null && damping === r.alternate_damping) c.fail('CONFIG_CLOTH_ALTERNATE_DIFFERS', `${path}.alternate_damping`, `is ${damping}, the same as damping; neighbouring trees are declared out of phase, so a different value is required (or leave alternate_damping out)`);
|
|
2657
|
+
}
|
|
2658
|
+
const st = isPlainObject(r.strength) ? r.strength : null;
|
|
2659
|
+
const strengths = (['trunk', 'branch', 'edge'] as const).map((k) => [k, st === null ? RECIPE_STRENGTH[k] : st[k]] as const).filter((e): e is readonly ['trunk' | 'branch' | 'edge', number] => typeof e[1] === 'number' && Number.isFinite(e[1]) && e[1] > 0);
|
|
2660
|
+
const dampings = [damping ?? RIGC_DEFAULT_DAMPING, ...(typeof r.alternate_damping === 'number' && r.alternate_damping >= 0 && r.alternate_damping <= 1 ? [r.alternate_damping] : [])];
|
|
2661
|
+
for (const [k, value] of strengths) {
|
|
2662
|
+
for (const d of dampings) {
|
|
2663
|
+
const b = springStable(value, d);
|
|
2664
|
+
if (!b.ok) {
|
|
2665
|
+
c.fail(
|
|
2666
|
+
'CONFIG_CLOTH_SPRING_STABLE',
|
|
2667
|
+
`${path}.strength.${k}`,
|
|
2668
|
+
`is ${value}${st === null ? " (the recipe's default)" : ''}; at damping ${d}, rig-c's default fps 60 and mass 1, k = strength·massInverse·step² = ${pyRound(b.k, 6)}, and a spring is stable only below 2(1 + damping^(60·step)) = ${pyRound(b.bound, 6)} — lower the strength`,
|
|
2669
|
+
);
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
for (const k of ['wind', 'gravity'] as const) {
|
|
2674
|
+
if (!(k in r)) continue;
|
|
2675
|
+
const pair = r[k];
|
|
2676
|
+
if (!Array.isArray(pair) || pair.length !== 2 || !pair.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
|
|
2677
|
+
c.fail('CONFIG_FIELD_TYPE', `${path}.${k}`, `is ${show(pair)}; [root, tip], two numbers at or above 0 — the ${k} response of a path's first link and of its last — is required`);
|
|
2678
|
+
continue;
|
|
2679
|
+
}
|
|
2680
|
+
if ((pair[1] as number) < (pair[0] as number)) {
|
|
2681
|
+
c.fail('CONFIG_CLOTH_RESPONSE_GROWS_TO_TIP', `${path}.${k}`, `is [${pair[0]}, ${pair[1]}]; the response grows from the root to the tip, where the motion accumulates down the chain, so tip >= root is required`);
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
if ('translate' in r && c.number(`${path}.translate`, r.translate, 'unit') && r.translate === 0) {
|
|
2685
|
+
c.fail('CONFIG_FIELD_TYPE', `${path}.translate`, 'is 0; a mix above 0 and at most 1 is required (leave translate out for none)');
|
|
2686
|
+
}
|
|
2687
|
+
if ('swing' in r) {
|
|
2688
|
+
if (tag !== 'bottomwear') {
|
|
2689
|
+
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`);
|
|
2690
|
+
} else {
|
|
2691
|
+
const sw = c.object(`${path}.swing`, r.swing, ['scales'], []);
|
|
2692
|
+
if (sw !== null && 'scales' in sw && c.array(`${path}.swing.scales`, sw.scales, true)) {
|
|
2693
|
+
const scales = sw.scales as Json[];
|
|
2694
|
+
scales.forEach((n, i) => c.number(`${path}.swing.scales[${i}]`, n, 'positive'));
|
|
2695
|
+
if (chains !== null && scales.length !== chains) {
|
|
2696
|
+
c.fail('CONFIG_CLOTH_SWING_SCALE_PER_TREE', `${path}.swing.scales`, `holds ${scales.length} scale(s) and chains is ${chains}; one scale per tree, in tree order across the piece, is required`);
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
/**
|
|
2705
|
+
* `propose --cloth`'s loader (issue #364): the `cloth` door — `key` and
|
|
2706
|
+
* `cloth`, under the full loader's rules for each — and nothing else read.
|
|
2707
|
+
* Every other key must still be one a config knows.
|
|
2708
|
+
*/
|
|
2709
|
+
export function loadClothConfig(path: string): { key: string; cloth: ClothRecipes } {
|
|
2710
|
+
const raw = readConfigFile(path);
|
|
2711
|
+
const c = new Check();
|
|
2712
|
+
const required = topRequired('cloth');
|
|
2713
|
+
const top = c.object('config', raw, required, TOP_KEYS.filter((k) => !required.includes(k)));
|
|
2714
|
+
if (top === null) refuseIfAny(c.problems);
|
|
2715
|
+
const t = top as Record<string, Json>;
|
|
2716
|
+
if ('key' in t) c.string('config.key', t.key);
|
|
2717
|
+
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
2718
|
+
refuseIfAny(c.problems);
|
|
2719
|
+
return raw as { key: string; cloth: ClothRecipes };
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2585
2722
|
function checkScene(c: Check, v: Json): void {
|
|
2586
2723
|
if (!isPlainObject(v)) {
|
|
2587
2724
|
c.fail('CONFIG_FIELD_TYPE', 'config.scene', `is ${show(v)}; an object is required — {} declares the painting a scene`);
|