@rig-c/parts 1.18.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 +29 -10
- package/docs/AUTHORING.md +138 -8
- package/docs/MIGRATION.md +41 -0
- package/docs/PAINTING.md +200 -45
- package/docs/STABILITY.md +7 -3
- package/package.json +2 -2
- package/skills/rig-parts/SKILL.md +20 -5
- 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/paintcheck.ts +43 -16
- package/src/propose.ts +187 -15
- package/src/scene.ts +1 -1
package/src/paintcheck.ts
CHANGED
|
@@ -33,11 +33,20 @@
|
|
|
33
33
|
* background is what makes the figure readable at all; a border pixel that is
|
|
34
34
|
* not ground is scenery, a floor, a gradient, or the figure cut by the frame.
|
|
35
35
|
* - `PAINTING_JOINTS_STATED` — the eight joints the gap rules read (wrists,
|
|
36
|
-
* hips, knees, ankles) are `observed` with a position
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* hips, knees, ankles) are `observed` with a position, except that a hip may
|
|
37
|
+
* be `occluded` with a position (issue #372). An occluded wrist is a hand the
|
|
38
|
+
* painting does not show; an occluded hip is the honest state of a hip under
|
|
39
|
+
* a skirt, and the arm's gap read from it is **well defined** when the hip
|
|
40
|
+
* carries a position — which the keypoint reader holds inside the image
|
|
41
|
+
* (`KEYPOINTS_POSITION_INSIDE`), so every sample of the wrist-to-hip line is
|
|
42
|
+
* a pixel of the painting — and that position's pixel is figure (the next
|
|
43
|
+
* rule). A hip with no position is still refused: there is no line to read.
|
|
44
|
+
* - `PAINTING_JOINT_ON_FIGURE` — every observed joint's pixel, and every
|
|
45
|
+
* occluded hip's, is figure, not background. A joint off the figure is a
|
|
46
|
+
* keypoint file for another painting, or a limb the painting does not hold
|
|
47
|
+
* where the file says; an occluded hip on the background would also put a
|
|
48
|
+
* background pixel at the end of the arm's line, a gap the hip's own position
|
|
49
|
+
* makes rather than one between the arm and the body.
|
|
41
50
|
* - `PAINTING_ARM_CLEAR` — on each side, a gap between the wrist and the hip
|
|
42
51
|
* of the same side. None is an arm painted against the body.
|
|
43
52
|
* - `PAINTING_HANDS_APART` — a gap between the two wrists. None is hands
|
|
@@ -56,6 +65,15 @@ import type { KeypointName } from './skeleton.ts';
|
|
|
56
65
|
/** The joints the gap rules read, in the order a refusal lists them. */
|
|
57
66
|
export const PAINTING_JOINTS: readonly KeypointName[] = ['r_wrist', 'l_wrist', 'r_hip', 'l_hip', 'r_knee', 'l_knee', 'r_ankle', 'l_ankle'];
|
|
58
67
|
|
|
68
|
+
/** The joints that may be `occluded` with a position and still be read (issue #372): a skirt hides the hips on most clothed figures. */
|
|
69
|
+
export const PAINTING_OCCLUDABLE: readonly KeypointName[] = ['r_hip', 'l_hip'];
|
|
70
|
+
|
|
71
|
+
/** Whether the check reads joint `name` as given: observed with a position, or an occludable joint occluded with one. */
|
|
72
|
+
function readable(name: KeypointName, j: KeypointPerson['joints'][KeypointName]): j is KeypointPerson['joints'][KeypointName] & { at: [number, number] } {
|
|
73
|
+
if (j.at === null) return false;
|
|
74
|
+
return j.state === 'observed' || (j.state === 'occluded' && PAINTING_OCCLUDABLE.includes(name));
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
/** The four gap rules' pairs: the rule, the two joints, and what the pair is called in a line. */
|
|
60
78
|
export const PAINTING_GAPS: ReadonlyArray<readonly [string, KeypointName, KeypointName, string]> = [
|
|
61
79
|
['PAINTING_ARM_CLEAR', 'r_wrist', 'r_hip', 'right arm (r_wrist to r_hip)'],
|
|
@@ -94,9 +112,9 @@ export interface PaintingReading {
|
|
|
94
112
|
firstBorderNotGround: [number, number] | null;
|
|
95
113
|
backgroundPx: number;
|
|
96
114
|
figurePx: number;
|
|
97
|
-
/** Every observed joint, its pixel, and whether that pixel is figure. */
|
|
98
|
-
joints: Array<{ name: KeypointName; at: [number, number]; pixel: [number, number]; onFigure: boolean }>;
|
|
99
|
-
/** The gap readings whose two joints are both observed with a position. */
|
|
115
|
+
/** Every observed joint and every occluded hip with a position, its state, its pixel, and whether that pixel is figure. */
|
|
116
|
+
joints: Array<{ name: KeypointName; state: 'observed' | 'occluded'; at: [number, number]; pixel: [number, number]; onFigure: boolean }>;
|
|
117
|
+
/** The gap readings whose two joints are both read as given (observed with a position, or an occluded hip with one). */
|
|
100
118
|
gaps: GapReading[];
|
|
101
119
|
}
|
|
102
120
|
|
|
@@ -174,15 +192,15 @@ export function readPainting(r: Raster, person: KeypointPerson): PaintingReading
|
|
|
174
192
|
for (const v of bg) backgroundPx += v;
|
|
175
193
|
const joints: PaintingReading['joints'] = [];
|
|
176
194
|
for (const [name, j] of Object.entries(person.joints) as Array<[KeypointName, KeypointPerson['joints'][KeypointName]]>) {
|
|
177
|
-
if (
|
|
195
|
+
if (!readable(name, j)) continue;
|
|
178
196
|
const pixel = pixelOf(j.at, w, h);
|
|
179
|
-
joints.push({ name, at: j.at, pixel, onFigure: bg[pixel[1] * w + pixel[0]] === 0 });
|
|
197
|
+
joints.push({ name, state: j.state === 'observed' ? 'observed' : 'occluded', at: j.at, pixel, onFigure: bg[pixel[1] * w + pixel[0]] === 0 });
|
|
180
198
|
}
|
|
181
199
|
const gaps: GapReading[] = [];
|
|
182
200
|
for (const [rule, a, b, what] of PAINTING_GAPS) {
|
|
183
201
|
const ja = person.joints[a];
|
|
184
202
|
const jb = person.joints[b];
|
|
185
|
-
if (ja
|
|
203
|
+
if (!readable(a, ja) || !readable(b, jb)) continue;
|
|
186
204
|
const line = linePixels(ja.at, jb.at, w, h);
|
|
187
205
|
let background = 0;
|
|
188
206
|
for (const [x, y] of line) background += bg[y * w + x];
|
|
@@ -206,11 +224,14 @@ export function paintingProblems(reading: PaintingReading, person: KeypointPerso
|
|
|
206
224
|
}
|
|
207
225
|
for (const n of PAINTING_JOINTS) {
|
|
208
226
|
const j = person.joints[n];
|
|
209
|
-
if (
|
|
227
|
+
if (readable(n, j)) continue;
|
|
228
|
+
const hip = PAINTING_OCCLUDABLE.includes(n);
|
|
210
229
|
problems.push({
|
|
211
230
|
code: 'PAINTING_JOINTS_STATED',
|
|
212
231
|
object: `${keypoints} person "${person.id}" joint ${n}`,
|
|
213
|
-
detail:
|
|
232
|
+
detail: hip
|
|
233
|
+
? `is ${j.listed ? j.state : 'not listed'} with no position; observed or occluded with a position is required — the arm's gap is read on the line from the wrist to this hip, and a hip with no position gives no line to read`
|
|
234
|
+
: `is ${j.listed ? j.state : 'not listed'}${j.at === null ? '' : ` at ${pt(j.at)}`}; observed with a position is required — the gap rules read ${PAINTING_JOINTS.join(', ')}, and a joint the painting does not show is a limb it cannot rig (only a hip may be occluded, with a position)`,
|
|
214
235
|
});
|
|
215
236
|
}
|
|
216
237
|
for (const j of reading.joints) {
|
|
@@ -218,7 +239,7 @@ export function paintingProblems(reading: PaintingReading, person: KeypointPerso
|
|
|
218
239
|
problems.push({
|
|
219
240
|
code: 'PAINTING_JOINT_ON_FIGURE',
|
|
220
241
|
object: `${keypoints} person "${person.id}" joint ${j.name}`,
|
|
221
|
-
detail: `is at ${pt(j.at)}, and pixel ${pt(j.pixel)} is background (ground connected to the painting's edge); a joint on the figure is required — a file measured on another painting, or a limb that is not where the file says`,
|
|
242
|
+
detail: `is ${j.state === 'occluded' ? 'occluded ' : ''}at ${pt(j.at)}, and pixel ${pt(j.pixel)} is background (ground connected to the painting's edge); a joint on the figure is required — a file measured on another painting, or a limb that is not where the file says${j.state === 'occluded' ? '; an occluded hip is read only from a point on the figure, or the arm\'s gap would be the hip\'s own background' : ''}`,
|
|
222
243
|
});
|
|
223
244
|
}
|
|
224
245
|
for (const g of reading.gaps) {
|
|
@@ -238,8 +259,14 @@ export function paintingLines(reading: PaintingReading): string[] {
|
|
|
238
259
|
` painting ${reading.width}x${reading.height}: background ${reading.backgroundPx} px, figure ${reading.figurePx} px; border ${reading.border} px, ${reading.borderNotGround} not background-coloured`,
|
|
239
260
|
];
|
|
240
261
|
const on = reading.joints.filter((j) => j.onFigure).length;
|
|
241
|
-
|
|
242
|
-
|
|
262
|
+
const observed = reading.joints.filter((j) => j.state === 'observed').length;
|
|
263
|
+
const occluded = reading.joints.filter((j) => j.state === 'occluded').map((j) => j.name);
|
|
264
|
+
const occ = occluded.length === 0 ? '' : `, ${occluded.length} occluded with a position (${occluded.join(', ')})`;
|
|
265
|
+
out.push(` joints: ${observed} observed${occ}, ${on} on the figure${on === reading.joints.length ? '' : ` (off: ${reading.joints.filter((j) => !j.onFigure).map((j) => j.name).join(', ')})`}`);
|
|
266
|
+
for (const g of reading.gaps) {
|
|
267
|
+
const hidden = [g.a, g.b].filter((n) => occluded.includes(n));
|
|
268
|
+
out.push(` gap ${g.what}: ${g.background} of ${g.samples} sampled pixels are background${hidden.length === 0 ? '' : ` (${hidden.join(', ')} occluded, read at its position)`}`);
|
|
269
|
+
}
|
|
243
270
|
return out;
|
|
244
271
|
}
|
|
245
272
|
|
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/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
|