@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/README.md +16 -3
- package/cli.ts +24 -6
- package/docs/AUTHORING.md +172 -12
- package/docs/MIGRATION.md +39 -0
- package/docs/PAINTING.md +4 -3
- package/docs/STABILITY.md +11 -7
- package/package.json +2 -2
- package/skills/rig-parts/SKILL.md +17 -3
- package/src/assemble.ts +109 -21
- package/src/build.ts +20 -2
- package/src/check.ts +397 -7
- package/src/cloth.ts +378 -0
- package/src/config.ts +199 -18
- package/src/limbs.ts +87 -5
- package/src/parts.ts +44 -6
- package/src/propose.ts +187 -15
- package/src/rig.ts +21 -0
- package/src/scene.ts +1 -1
- package/src/structure.ts +2 -1
package/src/propose.ts
CHANGED
|
@@ -139,6 +139,7 @@ import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
|
|
|
139
139
|
import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
|
|
140
140
|
import { edgeLength } from './length.ts';
|
|
141
141
|
import { chainSqueezes, type SqueezeFinding } from './squeeze.ts';
|
|
142
|
+
import { RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
|
|
142
143
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
143
144
|
|
|
144
145
|
// ---------------------------------------------------------------------------
|
|
@@ -264,11 +265,31 @@ export interface Proposal {
|
|
|
264
265
|
/** Absent when no part feeds the `eyes` group ({@link EYE_GROUP_TAGS}): a blink with nothing to blink is not written. */
|
|
265
266
|
blink?: ProposedBlink;
|
|
266
267
|
};
|
|
268
|
+
/** Only with `propose --cloth` (issue #364, `src/cloth.ts`): the physics (and a skirt swing's transforms) of the trees it drafted; absent otherwise, so a proposal without it is the one written before it. */
|
|
269
|
+
constraints?: ConfigConstraint[];
|
|
267
270
|
/** Only with `propose --full`/`--head` (issue #251, {@link draftTurn}); absent otherwise, so a proposal without them is the one written before it. */
|
|
268
271
|
turn?: TurnSpec;
|
|
269
272
|
notes: string[];
|
|
270
273
|
}
|
|
271
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Every section of a proposal that is a config section, in the order the
|
|
277
|
+
* proposal writes them: what an author copies into the config (issue #375).
|
|
278
|
+
* `notes` is the proposal's own and is copied nowhere. Two of them are written
|
|
279
|
+
* only on request — `limbs` under `--keypoints`, `turn` under `--full`/`--head`
|
|
280
|
+
* — and a `limbs` block left behind is refused at the next step, by the name of
|
|
281
|
+
* every part it cuts. {@link checkProposal} reads the proposal through the config
|
|
282
|
+
* loader by this list, the type below refuses a {@link Proposal} key it does not
|
|
283
|
+
* hold, and the selftest's `TY18` holds every paste sentence in the documents to it.
|
|
284
|
+
*/
|
|
285
|
+
export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
|
|
286
|
+
|
|
287
|
+
/** A {@link Proposal} key that is neither `notes` nor in {@link PROPOSAL_CONFIG_SECTIONS}; `never` while the list is whole. */
|
|
288
|
+
type UnlistedProposalSection = Exclude<keyof Proposal, 'notes' | (typeof PROPOSAL_CONFIG_SECTIONS)[number]>;
|
|
289
|
+
|
|
290
|
+
/** Fails to type-check, naming the key, when a section is added to {@link Proposal} and not to the list. */
|
|
291
|
+
export const PROPOSAL_SECTIONS_LISTED: [UnlistedProposalSection] extends [never] ? true : UnlistedProposalSection = true;
|
|
292
|
+
|
|
272
293
|
// ---------------------------------------------------------------------------
|
|
273
294
|
// what each proposed bone rests on (issue #86)
|
|
274
295
|
// ---------------------------------------------------------------------------
|
|
@@ -415,16 +436,9 @@ export function proposalAssemble(P: PartSet): Record<string, unknown> {
|
|
|
415
436
|
* refusals.
|
|
416
437
|
*/
|
|
417
438
|
export function checkProposal(P: PartSet, p: Proposal): void {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
bones: p.bones,
|
|
422
|
-
meshes: p.meshes,
|
|
423
|
-
regions: p.regions,
|
|
424
|
-
...(p.limbs === undefined ? {} : { limbs: p.limbs }),
|
|
425
|
-
motion: p.motion,
|
|
426
|
-
...(p.turn === undefined ? {} : { turn: p.turn }),
|
|
427
|
-
});
|
|
439
|
+
const sections: Record<string, unknown> = {};
|
|
440
|
+
for (const k of PROPOSAL_CONFIG_SECTIONS) if (p[k] !== undefined) sections[k] = p[k];
|
|
441
|
+
parseConfig({ key: 'proposal', assemble: proposalAssemble(P), ...sections });
|
|
428
442
|
}
|
|
429
443
|
|
|
430
444
|
/** Fixed key order, two-space indent, trailing newline: the same inputs write the same bytes. */
|
|
@@ -588,7 +602,7 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
|
|
|
588
602
|
}
|
|
589
603
|
turn = { pivot: turn.pivot, depths };
|
|
590
604
|
}
|
|
591
|
-
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(turn === null ? {} : { turn }), notes: [...p.notes, ...draft.notes] };
|
|
605
|
+
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), notes: [...p.notes, ...draft.notes] };
|
|
592
606
|
}
|
|
593
607
|
|
|
594
608
|
// ---------------------------------------------------------------------------
|
|
@@ -1131,8 +1145,8 @@ const SIDES = [
|
|
|
1131
1145
|
* which rule stood in; without it every value and note is the one this
|
|
1132
1146
|
* function wrote before the parameter existed.
|
|
1133
1147
|
*/
|
|
1134
|
-
export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null = null, gridRule: GridRule = DEFAULT_GRID_RULE): Proposal {
|
|
1135
|
-
return proposeWithBasis(P, joints, wristSnap, gridRule).proposal;
|
|
1148
|
+
export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null = null, gridRule: GridRule = DEFAULT_GRID_RULE, cloth: ClothRecipes | null = null): Proposal {
|
|
1149
|
+
return proposeWithBasis(P, joints, wristSnap, gridRule, cloth).proposal;
|
|
1136
1150
|
}
|
|
1137
1151
|
|
|
1138
1152
|
/**
|
|
@@ -1145,7 +1159,13 @@ export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null
|
|
|
1145
1159
|
* `joints`: see {@link armsFromJoints}. `null` is the built-in tolerance
|
|
1146
1160
|
* alone, and every value and note is the one written before it existed.
|
|
1147
1161
|
*/
|
|
1148
|
-
export function proposeWithBasis(
|
|
1162
|
+
export function proposeWithBasis(
|
|
1163
|
+
P: PartSet,
|
|
1164
|
+
joints?: RigJoints,
|
|
1165
|
+
wristSnap: number | null = null,
|
|
1166
|
+
gridRule: GridRule = DEFAULT_GRID_RULE,
|
|
1167
|
+
cloth: ClothRecipes | null = null,
|
|
1168
|
+
): { proposal: Proposal; basis: ProposalBasis } {
|
|
1149
1169
|
// Issue #230: a piece is not given its base's rule a second time, which
|
|
1150
1170
|
// needs to know which part is the piece. A file that cannot say is refused
|
|
1151
1171
|
// here, and only here: every other reader of parts.json still takes it.
|
|
@@ -1304,6 +1324,26 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1304
1324
|
bones.push({ chain, parent, points: pts.map(rnd), tip: rnd(tip) });
|
|
1305
1325
|
pts.forEach((_, k) => records.push({ bone: `${chain}${k}`, entry: chain, origin: why[k], tip: k === pts.length - 1 ? tipWhy : null }));
|
|
1306
1326
|
};
|
|
1327
|
+
// Issue #364: the trees a cloth recipe drafts, their constraints, and the tags it was used on.
|
|
1328
|
+
const clothConstraints: ConfigConstraint[] = [];
|
|
1329
|
+
const clothUsed = new Set<ClothTarget>();
|
|
1330
|
+
const recipeOf = (tag: ClothTarget): ClothRecipe | null => cloth?.[tag] ?? null;
|
|
1331
|
+
const T = (p: PartRecord, tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
|
|
1332
|
+
const trees = draftTrees(input, r);
|
|
1333
|
+
const constants = [konst('cloth_chains', r.chains), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
|
|
1334
|
+
for (const t of trees) {
|
|
1335
|
+
C(
|
|
1336
|
+
t.chain,
|
|
1337
|
+
t.parent,
|
|
1338
|
+
t.points,
|
|
1339
|
+
t.tip,
|
|
1340
|
+
t.why.map((w) => placement('mask', w, { parts: [p.name], bones: [input.parent], constants })),
|
|
1341
|
+
placement('mask', t.tipWhy, { parts: [p.name], bones: [input.parent], constants }),
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
clothUsed.add(tag);
|
|
1345
|
+
return trees;
|
|
1346
|
+
};
|
|
1307
1347
|
B('hip', 'root', hip, hipWhy);
|
|
1308
1348
|
B(
|
|
1309
1349
|
'chest',
|
|
@@ -1444,6 +1484,34 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1444
1484
|
);
|
|
1445
1485
|
meshes[p.name] = { grid: g, r, segments: [['head', rnd(head), rnd([axis, top + 13])], 'bun'] };
|
|
1446
1486
|
tracks.push({ bone: 'bun', prop: 'rotate', amp: 0.8, period: 4.0, phase: 0.2 });
|
|
1487
|
+
} else if (recipeOf('back hair') !== null) {
|
|
1488
|
+
// Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
|
|
1489
|
+
// 4 px above the part's bottom, in place of the two keyed chains.
|
|
1490
|
+
const rcp = recipeOf('back hair') as ClothRecipe;
|
|
1491
|
+
const mask = P.alpha(p);
|
|
1492
|
+
const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
|
|
1493
|
+
const all: TreeChain[] = [];
|
|
1494
|
+
let n = 0;
|
|
1495
|
+
for (const [side] of SIDES) {
|
|
1496
|
+
const prefix = P.headFirst('back hair').length === 1 ? `hairback_${side}` : `hairback_${p.name}_${side}`;
|
|
1497
|
+
const trees = T(p, 'back hair', rcp, {
|
|
1498
|
+
prefix,
|
|
1499
|
+
parent: 'head',
|
|
1500
|
+
rootY: eyeY,
|
|
1501
|
+
tipY: bot - 4,
|
|
1502
|
+
xAt: (y, f) => bandX(mask, y, { frac: f, side, axis }),
|
|
1503
|
+
fallbackX: () => axis,
|
|
1504
|
+
frame: `${p.name}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`,
|
|
1505
|
+
edges: side,
|
|
1506
|
+
});
|
|
1507
|
+
for (const t of trees) segs.push(t.chain);
|
|
1508
|
+
const made = treeConstraints(trees, rcp, null);
|
|
1509
|
+
clothConstraints.push(...made);
|
|
1510
|
+
n += made.length;
|
|
1511
|
+
all.push(...trees);
|
|
1512
|
+
}
|
|
1513
|
+
notes.push(clothNote(p.name, 'back hair', all, rcp, n, ' per side of the eye axis, from head, rooted on the eye line and tipped 4 px above its bottom'));
|
|
1514
|
+
meshes[p.name] = { grid: g, r, segments: segs };
|
|
1447
1515
|
} else {
|
|
1448
1516
|
const mask = P.alpha(p);
|
|
1449
1517
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
|
|
@@ -1532,6 +1600,13 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1532
1600
|
);
|
|
1533
1601
|
segs.push(tag);
|
|
1534
1602
|
columns.push(tag);
|
|
1603
|
+
const fringeRecipe = recipeOf('front hair');
|
|
1604
|
+
if (fringeRecipe !== null) {
|
|
1605
|
+
// Issue #364: under the recipe the fringe's two links carry physics, an outer column the edge's, and no key.
|
|
1606
|
+
clothConstraints.push(...chainConstraints(tag, 2, columns.length - 1, tag !== 'bang_c', fringeRecipe));
|
|
1607
|
+
clothUsed.add('front hair');
|
|
1608
|
+
continue;
|
|
1609
|
+
}
|
|
1535
1610
|
const a = tag === 'bang_c' ? 0.8 : 1.0;
|
|
1536
1611
|
tracks.push({ chain: tag, amps: [a, Number((a * 2.2).toFixed(4))], period: 4.0, phase: 0.18, lag: 0.1 });
|
|
1537
1612
|
}
|
|
@@ -1540,6 +1615,11 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1540
1615
|
`the opaque shape does not separate the locks painted inside one mass of hair, so a lock that runs elsewhere is not followed — move the chains onto the locks` +
|
|
1541
1616
|
('none' in roll ? `; no head frame to turn the columns to: ${roll.none}` : ''),
|
|
1542
1617
|
);
|
|
1618
|
+
if (recipeOf('front hair') !== null) {
|
|
1619
|
+
notes.push(
|
|
1620
|
+
`${p.name} (${p.from}): config.cloth."front hair" -> ${columns.join(', ')} carry physics on both links (bang_r and bang_l the edge's strength, bang_c the trunk's; wind and gravity on the recipe's root-to-tip ramp over their two links) and no idle key; the recipe's chains, links and split shape the hanging strands, not the fringe`,
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1543
1623
|
const below = newMask(P.W, P.H);
|
|
1544
1624
|
below.data.set(mask.data);
|
|
1545
1625
|
const [c0, c1] = pySlice(0, pyInt(chin) + 4, P.H);
|
|
@@ -1562,6 +1642,26 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1562
1642
|
}
|
|
1563
1643
|
}
|
|
1564
1644
|
const name = k === 0 ? 'lock' : `lock${k}_`;
|
|
1645
|
+
const lockRecipe = recipeOf('front hair');
|
|
1646
|
+
if (lockRecipe !== null) {
|
|
1647
|
+
// Issue #364: the strand hangs as the recipe's trees, across its own art, from the same root row to 5 px below it.
|
|
1648
|
+
const trees = T(p, 'front hair', lockRecipe, {
|
|
1649
|
+
prefix: `${name.replace(/_$/, '')}t`,
|
|
1650
|
+
parent: 'head',
|
|
1651
|
+
rootY: y0,
|
|
1652
|
+
tipY: bot + 5,
|
|
1653
|
+
xAt: (y, f) => bandX(cm, y, { frac: f }),
|
|
1654
|
+
fallbackX: () => st.cx,
|
|
1655
|
+
frame: `the strand of ${p.name} that hangs below the chin (with ${p.name}'s art within 25 px of its centre x; no art in the band: the strand's centre x)`,
|
|
1656
|
+
edges: 'both',
|
|
1657
|
+
});
|
|
1658
|
+
for (const t of trees) segs.push(t.chain);
|
|
1659
|
+
const made = treeConstraints(trees, lockRecipe, null);
|
|
1660
|
+
clothConstraints.push(...made);
|
|
1661
|
+
notes.push(clothNote(p.name, 'front hair', trees, lockRecipe, made.length, ` on the strand at x ${pyFixed(st.cx, 0)}, from head, rooted 0.22 face heights above the eye line and tipped 5 px below the strand`));
|
|
1662
|
+
k++;
|
|
1663
|
+
continue;
|
|
1664
|
+
}
|
|
1565
1665
|
const why: Placement[] = [];
|
|
1566
1666
|
const pts: Array<[number, number]> = [0, 1, 2, 3].map((j) => {
|
|
1567
1667
|
const w = placement(
|
|
@@ -2019,6 +2119,54 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
2019
2119
|
`${p.name} (${p.from}): its art starts ${pyFixed(artTop - chestAt, 1)} px below chest's origin, so it hangs from the waist — its mesh takes the hip line and its skirt chains, no chest segment, and the breath does not carry its root`,
|
|
2020
2120
|
);
|
|
2021
2121
|
}
|
|
2122
|
+
// Issue #364: with config.cloth.bottomwear, the recipe's trees hang from the hip in place of the three keyed chains.
|
|
2123
|
+
const skirtRecipe = recipeOf('bottomwear');
|
|
2124
|
+
if (skirtRecipe !== null) {
|
|
2125
|
+
const prefix = bw.length === 1 ? 'skirt' : `skirt_${p.name}`;
|
|
2126
|
+
const trees = T(p, 'bottomwear', skirtRecipe, {
|
|
2127
|
+
prefix,
|
|
2128
|
+
parent: 'hip',
|
|
2129
|
+
rootY: y0,
|
|
2130
|
+
tipY: bot - 4,
|
|
2131
|
+
xAt: (y, f) => bandX(mask, y, { frac: f }),
|
|
2132
|
+
fallbackX: (f) => p.x + f * p.w,
|
|
2133
|
+
frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
|
|
2134
|
+
edges: 'both',
|
|
2135
|
+
});
|
|
2136
|
+
for (const t of trees) segs.push(t.chain);
|
|
2137
|
+
let swing: { bone: string; rest: number } | null = null;
|
|
2138
|
+
if (skirtRecipe.swing !== undefined) {
|
|
2139
|
+
// One link long, straight down from the centre of the root row: the hip is drafted with no tip, so it is
|
|
2140
|
+
// unturned and the swing's setup rotation in its frame is its own direction, -90 degrees (Spine, y up).
|
|
2141
|
+
const cx = or(bandX(mask, y0, { frac: 0.5 }), p.x + 0.5 * p.w);
|
|
2142
|
+
const first = trees[0].points.length > 1 ? trees[0].points[1][1] : trees[0].tip[1];
|
|
2143
|
+
const at: [number, number] = [cx, y0];
|
|
2144
|
+
const tipAt: [number, number] = [cx, first];
|
|
2145
|
+
B(
|
|
2146
|
+
swingName(prefix),
|
|
2147
|
+
'hip',
|
|
2148
|
+
at,
|
|
2149
|
+
placement('mask', `the centre of ${p.name}'s art in the 24-row band at the root row (20 px below hip's y)`, { parts: [p.name], bones: ['hip'], constants: [konst('share_across', 0.5)] }),
|
|
2150
|
+
tipAt,
|
|
2151
|
+
placement('derived', `straight down from the swing's origin to the row of the first tree's second link (one link long)`, { bones: [`${trees[0].chain}0`] }),
|
|
2152
|
+
);
|
|
2153
|
+
swing = { bone: swingName(prefix), rest: roundTo((Math.atan2(-(rnd(tipAt)[1] - rnd(at)[1]), rnd(tipAt)[0] - rnd(at)[0]) * 180) / Math.PI, 6) };
|
|
2154
|
+
}
|
|
2155
|
+
const made = treeConstraints(trees, skirtRecipe, swing);
|
|
2156
|
+
clothConstraints.push(...made);
|
|
2157
|
+
notes.push(
|
|
2158
|
+
clothNote(
|
|
2159
|
+
p.name,
|
|
2160
|
+
'bottomwear',
|
|
2161
|
+
trees,
|
|
2162
|
+
skirtRecipe,
|
|
2163
|
+
made.length,
|
|
2164
|
+
`${swing === null ? '' : ` on one shared swing, ${swing.bone} (each tree's first link takes its rotation times ${skirtRecipe.swing?.scales.join('/')}, its setup rotation ${swing.rest} degrees subtracted)`} from hip, rooted 20 px below its y and tipped 4 px above ${p.name}'s bottom, laid down the screen`,
|
|
2165
|
+
),
|
|
2166
|
+
);
|
|
2167
|
+
meshes[p.name] = { grid: g, r, segments: segs };
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2022
2170
|
for (const [tag, f, ph] of [
|
|
2023
2171
|
['skirt_r', 0.3, 0.3],
|
|
2024
2172
|
['skirt_c', 0.5, 0.34],
|
|
@@ -2160,7 +2308,31 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
2160
2308
|
const scale = IDLE_SECONDS / REFERENCE_IDLE_SECONDS;
|
|
2161
2309
|
const idleTracks = tracks.map((tr) => ({ ...tr, period: tr.period * scale }));
|
|
2162
2310
|
const drafted = Object.keys(limbs).length === 0 ? {} : { limbs };
|
|
2163
|
-
|
|
2311
|
+
// Issue #364: a recipe that drafted nothing is a declaration that says something false about the rig.
|
|
2312
|
+
if (cloth !== null) {
|
|
2313
|
+
refuseIfAny(
|
|
2314
|
+
CLOTH_TARGETS.filter((t) => cloth[t] !== undefined && !clothUsed.has(t)).map((t) => ({
|
|
2315
|
+
code: 'PROPOSE_CLOTH_DRAFTED',
|
|
2316
|
+
object: `config.cloth."${t}"`,
|
|
2317
|
+
detail:
|
|
2318
|
+
P.byTag(t).length === 0 && P.headFirst(t).length === 0
|
|
2319
|
+
? `declares a recipe for "${t}", and no part is tagged "${t}"; leave the recipe out, or assemble the layer it is for`
|
|
2320
|
+
: t === 'back hair'
|
|
2321
|
+
? `declares a recipe for "back hair", and every back hair part ends above the shoulders (0.5 face heights below the neck), which the proposer drafts as a bun bone with no chain; leave the recipe out`
|
|
2322
|
+
: `declares a recipe for "${t}", and the proposer drafted no chain on it`,
|
|
2323
|
+
})),
|
|
2324
|
+
);
|
|
2325
|
+
if (clothUsed.has('bottomwear') && legPlan !== null && legPlan.chains !== null) {
|
|
2326
|
+
notes.push(
|
|
2327
|
+
`stop at the legs: leg chains ${legPlan.chains.map((c) => c.chain).join(', ')} are drafted, and no stop is drafted for the skirt's trees — on the recipe's fixture the environment alone pushed no hem link through a thigh, but a hip the consumer moves can (AUTHORING §3, *Recipe: cloth and hair*); that case is rig-c/core#1413 (open), and until it lands a stop is the author's own constraint network in config.constraints`,
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
const constrained = clothConstraints.length === 0 ? {} : { constraints: clothConstraints };
|
|
2332
|
+
return {
|
|
2333
|
+
proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, notes },
|
|
2334
|
+
basis: { frame, bones: records },
|
|
2335
|
+
};
|
|
2164
2336
|
}
|
|
2165
2337
|
|
|
2166
2338
|
/**
|
package/src/rig.ts
CHANGED
|
@@ -1911,6 +1911,16 @@ export function rigJsonText(value: unknown): string {
|
|
|
1911
1911
|
* (`FLATTEN_REGION_UPRIGHT`) rather than flattened into an upright one the
|
|
1912
1912
|
* oriented rig does not draw.
|
|
1913
1913
|
*
|
|
1914
|
+
* A transform constraint that reads its source's **local** rotation and adds
|
|
1915
|
+
* it to its targets' local rotations (`localSource`, `localTarget`,
|
|
1916
|
+
* `additive`: the cloth recipe's swing, issue #364) reads the source's setup
|
|
1917
|
+
* rotation, which the flat form drops; spine-core takes the source's rotation
|
|
1918
|
+
* less the `rotate` property's `offset`, so the flat form's offset is the
|
|
1919
|
+
* offset less that setup rotation, and the targets turn by the same amount in
|
|
1920
|
+
* both forms (issue #372: copied as written, the swing turned every skirt tree
|
|
1921
|
+
* 90 degrees in the flat form of the `wind` example). Every other constraint
|
|
1922
|
+
* is copied as written.
|
|
1923
|
+
*
|
|
1914
1924
|
* `sense` is the direction rotations are read in: 1, always, but for the
|
|
1915
1925
|
* planted control that shows a flatten turning the wrong way is caught.
|
|
1916
1926
|
*/
|
|
@@ -1968,6 +1978,17 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
|
|
|
1968
1978
|
}
|
|
1969
1979
|
refuseIfAny(problems);
|
|
1970
1980
|
const flat: RigSpec = { ...rig, bones, skins: { default: skin } };
|
|
1981
|
+
if (rig.constraints !== undefined) {
|
|
1982
|
+
const setup = new Map(read.map((b) => [b.name, b.rotation]));
|
|
1983
|
+
flat.constraints = rig.constraints.map((c) => {
|
|
1984
|
+
const props = c.properties as Record<string, Record<string, unknown>> | undefined;
|
|
1985
|
+
const rotate = props?.rotate;
|
|
1986
|
+
const turned = typeof c.source === 'string' ? (setup.get(c.source) ?? 0) : 0;
|
|
1987
|
+
if (c.type !== 'transform' || c.localSource !== true || c.localTarget !== true || c.additive !== true || rotate === undefined || turned === 0) return c;
|
|
1988
|
+
const offset = typeof rotate.offset === 'number' ? rotate.offset : 0;
|
|
1989
|
+
return { ...c, properties: { ...props, rotate: { ...rotate, offset: placed(offset - turned, OFFSET_PLACES) } } };
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1971
1992
|
if (rig.invariants?.axes !== undefined) {
|
|
1972
1993
|
const { axes: _axes, ...rest } = rig.invariants;
|
|
1973
1994
|
if (Object.keys(rest).length > 0) flat.invariants = rest;
|
package/src/scene.ts
CHANGED
|
@@ -1340,7 +1340,7 @@ export interface ComposeResult {
|
|
|
1340
1340
|
/**
|
|
1341
1341
|
* Load the scene and every build, compose, gate the result through rig-c
|
|
1342
1342
|
* exactly as `rig` and `build` gate theirs ({@link gateThroughRigc}: `build
|
|
1343
|
-
* --profile
|
|
1343
|
+
* --profile rig-play --pack`, the compile and the packed pages on disk),
|
|
1344
1344
|
* write `rig/` and `scene.json` only when that is green, then run `check` over
|
|
1345
1345
|
* the composed rig (it reads a rig it did not build, #77; `--requirements`
|
|
1346
1346
|
* forwarded, #93). Lines go to `log` under `[compose]` and `[check]`, as
|
package/src/structure.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* - a **config**: an object with a `key`, read by the full config loader;
|
|
16
16
|
* - a **proposal**: an object with `bones` and no `key` or `spec`, as
|
|
17
17
|
* `propose` writes it, read by `parseProposalSections` (the loader's rules
|
|
18
|
-
* for bones, meshes, regions and motion
|
|
18
|
+
* for bones, meshes, regions and motion, and for the constraints
|
|
19
|
+
* `propose --cloth` writes).
|
|
19
20
|
*
|
|
20
21
|
* Anything else is refused by name. A file's name is never read.
|
|
21
22
|
*
|