@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/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. An occluded wrist is a
37
- * hand the painting does not show.
38
- * - `PAINTING_JOINT_ON_FIGURE` — every observed joint's pixel is figure, not
39
- * background. A joint off the figure is a keypoint file for another painting,
40
- * or a limb the painting does not hold where the file says.
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 (j.state !== 'observed' || j.at === null) continue;
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.state !== 'observed' || jb.state !== 'observed' || ja.at === null || jb.at === null) continue;
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 (j.state === 'observed' && j.at !== null) continue;
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: `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`,
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
- out.push(` joints: ${reading.joints.length} observed, ${on} on the figure${on === reading.joints.length ? '' : ` (off: ${reading.joints.filter((j) => !j.onFigure).map((j) => j.name).join(', ')})`}`);
242
- for (const g of reading.gaps) out.push(` gap ${g.what}: ${g.background} of ${g.samples} sampled pixels are background`);
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
- parseConfig({
419
- key: 'proposal',
420
- assemble: proposalAssemble(P),
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(P: PartSet, joints?: RigJoints, wristSnap: number | null = null, gridRule: GridRule = DEFAULT_GRID_RULE): { proposal: Proposal; basis: ProposalBasis } {
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
- return { proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, notes }, basis: { frame, bones: records } };
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 spine-html --pack`, the compile and the packed pages on disk),
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