@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/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/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 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
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
  *