@rig-c/parts 1.19.0 → 1.21.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/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
+ }