@rig-c/parts 1.20.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/config.ts CHANGED
@@ -213,6 +213,16 @@ export interface Cut {
213
213
  * pixels; it is read nowhere else. Absent: the base keeps only its band.
214
214
  */
215
215
  under?: string;
216
+ /**
217
+ * Issue #360 (item 11), optional, one value: `"colour"` — where the under
218
+ * fills a removed pixel, the base takes the under's colour and keeps the
219
+ * alpha the cut removed there, so the part's silhouette is the uncut
220
+ * layer's and never grows. The under is `under`'s image or the part's
221
+ * hidden-art layer (issue #359); a cut with neither is refused by the
222
+ * assemble stage (`ASSEMBLE_UNDER_FILL_SOURCE`). Absent: the base takes the
223
+ * under's colour and alpha, issue #193's rule.
224
+ */
225
+ under_fill?: 'colour';
216
226
  }
217
227
 
218
228
  export interface Assemble {
@@ -643,9 +653,15 @@ export interface ConfigConstraint {
643
653
  * pixels it takes (`from`: a plan part or a cut's piece), and the one bone it
644
654
  * rides with weight 1. Every segment after the first sits below a joint at
645
655
  * its bone's origin: `joint` names it, `range` is the bend the joint may take
646
- * from rest, `[min, max]` degrees (metadata: read by the stress readers, not
647
- * by rig-c), and `cap` an image, relative to the config's directory, of the
648
- * cap that overlaps the joint (`limbCaps` in `src/assemble.ts`).
656
+ * from rest, `[min, max]` degrees (carried into `parts.json` and posed by
657
+ * `check`'s `SEAM_OVER_RANGE`, issue #360; not read by rig-c), and `cap` an
658
+ * image, relative to the config's directory, of the cap that overlaps the
659
+ * joint (`limbCaps` in `src/assemble.ts`). Two opt-in fields shape the cut at
660
+ * the joint (issue #360, `src/limbs.ts`): `edge: "feather"` fades the upper
661
+ * piece out across the cap, and `cap_shape: "disc"` sizes the cap by the
662
+ * limb's half-width about the joint and fills it from the art, the upper
663
+ * segment's included. Absent, the cut
664
+ * is hard and the cap the layer's half-disc of half the limb's width.
649
665
  */
650
666
  export interface LimbSegment {
651
667
  part: string;
@@ -654,6 +670,8 @@ export interface LimbSegment {
654
670
  joint?: string;
655
671
  range?: [number, number];
656
672
  cap?: string;
673
+ edge?: 'feather';
674
+ cap_shape?: 'disc';
657
675
  }
658
676
 
659
677
  /** A limb (issue #349): its segments, upper first; each segment's bone is the child of the one before. */
@@ -1340,25 +1358,36 @@ export interface SkeletonSections {
1340
1358
 
1341
1359
  /**
1342
1360
  * A `proposal.json` as `propose` writes it — `bones`, `meshes`, `regions`,
1343
- * `motion` and `notes` (strings) — under the full loader's own rules for the
1344
- * four rig sections (issue #85). There is no plan to hold the meshes and
1345
- * regions against, so the coverage rule (every plan part exactly one of a
1346
- * mesh or a region) is the one rule not run; `propose` held its proposal to
1347
- * it before writing (`checkProposal`). A key the loader would not take is
1361
+ * `motion` and `notes` (strings), `limbs` when `propose --keypoints` drafted
1362
+ * them (issue #349) and `constraints` when `propose --cloth` did (issue #364;
1363
+ * both read here since issue #372, whose `wind` example tracks such a
1364
+ * proposal) — under the full loader's own rules for those sections (issue
1365
+ * #85). There is no plan to hold the meshes, regions and limbs against, so
1366
+ * the coverage rule (every plan part exactly one of a mesh or a region) is
1367
+ * not run, and a limb segment's `from` is held to no plan part (the parts
1368
+ * the limbs take are the ones they name); `propose` held its proposal to
1369
+ * both before writing (`checkProposal`). A key the loader would not take is
1348
1370
  * refused by name, as in a config. Refusals name the fields `proposal.…`.
1349
1371
  */
1350
- export function parseProposalSections(raw: Json): SkeletonSections {
1372
+ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[] } {
1351
1373
  const c = new Check();
1352
- const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes']);
1374
+ const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints']);
1353
1375
  if (top !== null) {
1354
1376
  const names = 'bones' in top ? checkBones(c, top.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1377
+ if ('limbs' in top) {
1378
+ const taken = isPlainObject(top.limbs)
1379
+ ? Object.values(top.limbs).flatMap((l) => (isPlainObject(l) && Array.isArray(l.segments) ? l.segments.flatMap((sg) => (isPlainObject(sg) && typeof sg.from === 'string' ? [sg.from] : [])) : []))
1380
+ : [];
1381
+ checkLimbs(c, top.limbs, { parts: [...new Set(taken)], patches: [], cuts: [] }, names);
1382
+ }
1355
1383
  if ('meshes' in top) checkMeshes(c, top.meshes, names.bones, names.chains);
1356
1384
  if ('regions' in top) checkRegions(c, top.regions, names.bones);
1357
1385
  if ('motion' in top) checkMotion(c, top.motion, names.bones, names.chains, 'regions' in top ? top.regions : undefined);
1386
+ if ('constraints' in top) checkConstraints(c, top.constraints, names.bones, names.parents);
1358
1387
  if ('notes' in top && c.array('config.notes', top.notes, false)) (top.notes as Json[]).forEach((n, i) => c.string(`config.notes[${i}]`, n, false));
1359
1388
  }
1360
1389
  refuseIfAny(c.problems.map((p) => ({ ...p, object: p.object.replace(/^config\b/, 'proposal') })));
1361
- return raw as SkeletonSections;
1390
+ return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[] };
1362
1391
  }
1363
1392
 
1364
1393
  /**
@@ -1548,7 +1577,9 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
1548
1577
  /**
1549
1578
  * `assemble.cuts` (issue #170): each entry `{from, into, polygon, draw, overlap}`
1550
1579
  * and optionally `under` (issue #193, a non-empty path string; the assemble
1551
- * stage reads the file), refused field by field. `from` must be a plan part — a cut of a patch or of
1580
+ * stage reads the file) and `under_fill` (issue #360, the one value
1581
+ * `"colour"`; whether the cut has an under to fill with is the assemble
1582
+ * stage's to judge, since a hidden-art layer can be one), refused field by field. `from` must be a plan part — a cut of a patch or of
1552
1583
  * another cut's piece is refused, so every piece's provenance is one layer and
1553
1584
  * one polygon. `into` is a new part name: not a plan part, not a patch, not
1554
1585
  * another cut's. Returns the names the cuts make, in order.
@@ -1560,11 +1591,14 @@ function checkCuts(c: Check, v: Json, plan: string[], patches: string[]): string
1560
1591
  const seen = new Map<string, number>();
1561
1592
  (v as Json[]).forEach((entry, i) => {
1562
1593
  const at = `${p}[${i}]`;
1563
- const e = c.object(at, entry, ['from', 'into', 'polygon', 'draw', 'overlap'], ['under']);
1594
+ const e = c.object(at, entry, ['from', 'into', 'polygon', 'draw', 'overlap'], ['under', 'under_fill']);
1564
1595
  if (e === null) return;
1565
1596
  if ('under' in e && !(typeof e.under === 'string' && e.under !== '')) {
1566
1597
  c.fail('CONFIG_FIELD_TYPE', `${at}.under`, `is ${show(e.under)}; a non-empty path to a PNG is required, relative to the config's directory — the image of what lies under the piece, in rig pixels over the polygon's bounding box`);
1567
1598
  }
1599
+ if ('under_fill' in e && e.under_fill !== 'colour') {
1600
+ c.fail('CONFIG_FIELD_TYPE', `${at}.under_fill`, `is ${show(e.under_fill)}; "colour" is required — the base takes the under's colour and keeps the alpha the cut removed, so its silhouette does not grow; leave under_fill out for the under's colour and alpha`);
1601
+ }
1568
1602
  if ('overlap' in e && !(typeof e.overlap === 'number' && Number.isInteger(e.overlap) && e.overlap >= 0)) {
1569
1603
  c.fail('CONFIG_FIELD_TYPE', `${at}.overlap`, `is ${show(e.overlap)}; a non-negative integer of rig pixels is required — the band inside the cut's edge the part keeps under the piece, 0 for none`);
1570
1604
  }
@@ -2165,7 +2199,10 @@ interface LimbNames {
2165
2199
  * the joint, its origin); the first segment has no joint, every other names
2166
2200
  * one (`CONFIG_KEY_KNOWN`, `CONFIG_FIELD_PRESENT`); `range` two finite
2167
2201
  * degrees `[min, max]` with `min <= 0 <= max` (`CONFIG_LIMB_RANGE`: the range
2168
- * is measured from rest, so rest is in it); `cap` a non-empty path. A limb
2202
+ * is measured from rest, so rest is in it); `cap` a non-empty path; `edge`
2203
+ * `"feather"` and `cap_shape` `"disc"` (issue #360), each the one value its
2204
+ * field takes — absent is the hard cut and the layer's half-disc
2205
+ * (`CONFIG_FIELD_TYPE` on any other). A limb
2169
2206
  * has two segments or more. With `names` null (no `bones`), bones are not
2170
2207
  * resolved. Returns the limb parts and the parts the limbs take.
2171
2208
  */
@@ -2190,13 +2227,13 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2190
2227
  const bones: Array<string | null> = [];
2191
2228
  segs.forEach((entry, i) => {
2192
2229
  const sat = `${at}.segments[${i}]`;
2193
- const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap']);
2230
+ const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap', 'edge', 'cap_shape']);
2194
2231
  froms.push(null);
2195
2232
  bones.push(null);
2196
2233
  if (e === null) return;
2197
2234
  // The upper segment sits below no joint; every other sits below one, which it names.
2198
2235
  if (i === 0) {
2199
- for (const k of ['joint', 'range', 'cap'].filter((q) => q in e)) c.fail('CONFIG_KEY_KNOWN', `${sat}.${k}`, `is not a field on the upper segment; it sits below no joint, so ${k} belongs to the segment below the joint it names (segments[1] and on)`);
2236
+ for (const k of ['joint', 'range', 'cap', 'edge', 'cap_shape'].filter((q) => q in e)) c.fail('CONFIG_KEY_KNOWN', `${sat}.${k}`, `is not a field on the upper segment; it sits below no joint, so ${k} belongs to the segment below the joint it names (segments[1] and on)`);
2200
2237
  } else if (!('joint' in e)) c.fail('CONFIG_FIELD_PRESENT', `${sat}.joint`, 'is absent and required; a segment after the first sits below a joint, at its bone\'s origin, and names it');
2201
2238
  if ('part' in e && c.string(`${sat}.part`, e.part)) {
2202
2239
  const name = e.part;
@@ -2234,6 +2271,13 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2234
2271
  if ('cap' in e && !(typeof e.cap === 'string' && e.cap !== '')) {
2235
2272
  c.fail('CONFIG_FIELD_TYPE', `${sat}.cap`, `is ${show(e.cap)}; a non-empty path to a PNG is required, relative to the config's directory — the cap's image over the box assemble's limb line prints`);
2236
2273
  }
2274
+ // Issue #360: the cut's geometry at the joint, each field one value; absent is the hard cut and the layer's half-disc.
2275
+ if ('edge' in e && e.edge !== 'feather') {
2276
+ c.fail('CONFIG_FIELD_TYPE', `${sat}.edge`, `is ${show(e.edge)}; "feather" is required — the upper piece fades out across the cap instead of stopping at the joint line; leave edge out for the hard cut`);
2277
+ }
2278
+ if ('cap_shape' in e && e.cap_shape !== 'disc') {
2279
+ c.fail('CONFIG_FIELD_TYPE', `${sat}.cap_shape`, `is ${show(e.cap_shape)}; "disc" is required — the cap a disc of the limb's half-width about the joint, filled from the art, the upper segment's included; leave cap_shape out for the layer's half-disc of half the limb's width`);
2280
+ }
2237
2281
  });
2238
2282
  froms.forEach((f, i) => {
2239
2283
  if (f === null || i === 0 || froms[i - 1] === f) return;
package/src/limbs.ts CHANGED
@@ -41,6 +41,27 @@
41
41
  * shows no gap. The tool invents none of them: each is the config's cap
42
42
  * image's where that image is not transparent, else the layer's where the
43
43
  * limb has art there, else missing — counted, and named in the limb line.
44
+ * - **`cap_shape: "disc"`** (issue #360, items 10 and 12; opt-in on the
45
+ * segment below the joint). The radius is the limb's half-width about the
46
+ * joint, not half its width: the distance from the joint to the nearer end
47
+ * of the same run along the joint line, each end half a pixel past its
48
+ * centre ({@link halfWidthAbout}) — so the disc lies on the limb's art
49
+ * across the line however far off the run's middle the bone sits. The cap
50
+ * takes, after the image's and the layer's, the upper segment's art at a
51
+ * pixel the lower's layer has none of: the whole half-disc comes from the
52
+ * art. The upper piece's corner at the cut is kept where it is: on a joint
53
+ * off the limb's middle, a corner trimmed to the disc leaves a notch at
54
+ * rest when nothing takes it, or a stub out of the bent lower piece's
55
+ * outline when the lower takes it (issue #360, unit B's sheets).
56
+ * - **`edge: "feather"`** (issue #360, item 9; opt-in on the segment below the
57
+ * joint). The overlap band is the disc about the joint that the lower
58
+ * piece covers at every turn ({@link coveredRadius}: the cap's disc, less
59
+ * any pixel the cap is missing and a bilinear sample's reach). Inside it,
60
+ * on the upper side of the line, the upper piece's alpha is scaled by
61
+ * {@link featherWeight}: 0 on the line, 1 on the band's arc. The band fades
62
+ * out across its width over art that stays opaque under it at any angle,
63
+ * so at rest the two pieces composite to what they did and a bend never
64
+ * shows through it what lies behind the limb.
44
65
  */
45
66
  import type { BoneEntry, Limb } from './config.ts';
46
67
 
@@ -144,11 +165,12 @@ export function segmentOf(lines: ReadonlyArray<JointLine | null>, first: number,
144
165
  }
145
166
 
146
167
  /**
147
- * The limb's width at a joint (module comment): `art(x, y)` says whether the
148
- * limb has art at a pixel; the box `[x0, y0, x1, y1]` (inclusive) bounds the
149
- * search. Null when no pixel of the run lies within 1 px of the joint.
168
+ * The limb's run of art along a joint line (module comment): the offsets
169
+ * along the line, from the joint, of the first and the last pixel centre of
170
+ * the run through the joint, `[first, last]` with `first <= last`. Null when
171
+ * no pixel of the run lies within 1 px of the joint.
150
172
  */
151
- export function limbWidth(line: JointLine, art: (x: number, y: number) => boolean, box: readonly [number, number, number, number]): number | null {
173
+ export function limbRun(line: JointLine, art: (x: number, y: number) => boolean, box: readonly [number, number, number, number]): [number, number] | null {
152
174
  const [ax, ay] = line.axis;
153
175
  const ts: number[] = [];
154
176
  for (let y = box[1]; y <= box[3]; y++) {
@@ -170,7 +192,67 @@ export function limbWidth(line: JointLine, art: (x: number, y: number) => boolea
170
192
  let hi = near;
171
193
  while (lo > 0 && ts[lo] - ts[lo - 1] <= 1.5) lo--;
172
194
  while (hi + 1 < ts.length && ts[hi + 1] - ts[hi] <= 1.5) hi++;
173
- return ts[hi] - ts[lo] + 1;
195
+ return [ts[lo], ts[hi]];
196
+ }
197
+
198
+ /** The limb's width at a joint (module comment): its run's first to last pixel centre, plus one pixel. Null with no run. */
199
+ export function limbWidth(line: JointLine, art: (x: number, y: number) => boolean, box: readonly [number, number, number, number]): number | null {
200
+ const r = limbRun(line, art, box);
201
+ return r === null ? null : r[1] - r[0] + 1;
202
+ }
203
+
204
+ /**
205
+ * The limb's half-width about the joint (issue #360, `cap_shape: "disc"`): the
206
+ * distance from the joint to the nearer end of its run, each end half a pixel
207
+ * past its centre — `min(last + 0.5, 0.5 - first)`. Half the width when the
208
+ * joint sits in the middle of the run; less when it sits off it. Zero or below
209
+ * when the joint sits on the run's edge.
210
+ */
211
+ export function halfWidthAbout(run: readonly [number, number]): number {
212
+ return Math.min(run[1] + 0.5, 0.5 - run[0]);
213
+ }
214
+
215
+ /** Where a pixel's centre lies from a joint: `along` the axis (above 0 past the line), and `across` it (the run's offset). */
216
+ export function jointOffsets(line: JointLine, px: number, py: number): { along: number; across: number } {
217
+ const dx = px + 0.5 - line.at[0];
218
+ const dy = py + 0.5 - line.at[1];
219
+ return { along: dx * line.axis[0] + dy * line.axis[1], across: -dx * line.axis[1] + dy * line.axis[0] };
220
+ }
221
+
222
+ /**
223
+ * The radius the feathered edge fades over (issue #360, `edge: "feather"`):
224
+ * the disc about the joint the lower piece covers at every turn. It is the
225
+ * distance from the joint to the nearest pixel centre the lower piece does not
226
+ * hold at alpha 255 (`opaque` false; searched out to `radius` + 2), less
227
+ * sqrt(2), the farthest a bilinear sample reaches to its four texels — so a
228
+ * pixel within it, turned by any angle about the joint, still samples only
229
+ * the lower's opaque art — and at most `radius`. Zero or below: no pixel is
230
+ * covered at every turn, and nothing is feathered.
231
+ */
232
+ export function coveredRadius(at: Pt, radius: number, opaque: (x: number, y: number) => boolean): number {
233
+ const [x0, y0, w, h] = capBox(at, radius + 2);
234
+ let near = radius + 2;
235
+ for (let y = y0; y < y0 + h; y++) {
236
+ for (let x = x0; x < x0 + w; x++) {
237
+ if (opaque(x, y)) continue;
238
+ const d = Math.sqrt((x + 0.5 - at[0]) ** 2 + (y + 0.5 - at[1]) ** 2);
239
+ if (d < near) near = d;
240
+ }
241
+ }
242
+ return Math.min(radius, near - Math.SQRT2);
243
+ }
244
+
245
+ /**
246
+ * The feathered edge's weight on the upper piece (issue #360, `edge:
247
+ * "feather"`) at a pixel inside the joint's disc on the upper side of its line:
248
+ * its distance above the line over the disc's height there,
249
+ * `-along / sqrt(radius² - across²)` — 0 on the line, 1 on the disc's arc, so
250
+ * the overlap band fades out across its width. 1 where the disc has no height.
251
+ */
252
+ export function featherWeight(radius: number, along: number, across: number): number {
253
+ const h2 = radius * radius - across * across;
254
+ if (h2 <= 0) return 1;
255
+ return Math.max(0, Math.min(1, -along / Math.sqrt(h2)));
174
256
  }
175
257
 
176
258
  /** The box `[x, y, w, h]` (rig px) of the pixels whose centre lies within `radius` of `at`: the cap image's size and place. */
package/src/parts.ts CHANGED
@@ -45,7 +45,8 @@
45
45
  * 1.6.0 has none, and still reads. `limb`, written only on a part a
46
46
  * `config.limbs` segment made (issue #349), names the limb, the segment's
47
47
  * index (0 the upper), the part it was cut from (`of`), the one bone it rides
48
- * and, below a joint, the joint and its cap ({@link LimbRecord}).
48
+ * and, below a joint, the joint, its declared `range` when there is one
49
+ * (issue #360) and its cap ({@link LimbRecord}).
49
50
  *
50
51
  * ⭐ **A patch is 100 % source.** Every pixel of a `painting:` part is the
51
52
  * painting's own, so it is all visible (the painting shows it; nothing of it
@@ -118,7 +119,9 @@ export interface PartRecord {
118
119
  /**
119
120
  * A limb segment's provenance (issue #349): the limb, the segment's index
120
121
  * (0 the upper), the part its pixels were cut from (`of`), the bone it rides
121
- * with weight 1, and, on a segment below a joint, the joint's name and its cap.
122
+ * with weight 1, and, on a segment below a joint, the joint's name, the
123
+ * `range` its config segment declares (issue #360: `[min, max]` degrees from
124
+ * rest, written only when declared; `check` poses the joint there) and its cap.
122
125
  */
123
126
  export interface LimbRecord {
124
127
  name: string;
@@ -126,6 +129,7 @@ export interface LimbRecord {
126
129
  of: string;
127
130
  bone: string;
128
131
  joint?: string;
132
+ range?: [number, number];
129
133
  cap?: CapRecord;
130
134
  }
131
135
 
@@ -147,10 +151,27 @@ export interface CapRecord {
147
151
  /** Issue #359: with `image_px`, the hidden-art layer the cap's image came from (`<run>:<layer>:hidden`), when it came from one rather than the config's cap file. */
148
152
  image_layer?: string;
149
153
  missing_px: number;
154
+ /**
155
+ * Issue #360, written only under the segment's `cap_shape: "disc"`: the
156
+ * shape; the cap's pixels taken from the upper segment's art where the
157
+ * lower's layer had none (`upper_px`). `radius` is then the half-width about the
158
+ * joint, at most half of `width`.
159
+ */
160
+ shape?: 'disc';
161
+ upper_px?: number;
162
+ /**
163
+ * Issue #360, written only under the segment's `edge: "feather"`: the edge,
164
+ * the radius of the band it fades over (the disc the lower piece covers at
165
+ * every turn, `coveredRadius` in `src/limbs.ts`), and the upper piece's
166
+ * pixels whose alpha it lowered.
167
+ */
168
+ edge?: 'feather';
169
+ feather_radius?: number;
170
+ feathered_px?: number;
150
171
  }
151
172
 
152
- const LIMB_KEYS = ['name', 'segment', 'of', 'bone', 'joint', 'cap'] as const;
153
- const CAP_KEYS = ['at', 'width', 'radius', 'box', 'layer_px', 'image_px', 'image_layer', 'missing_px'] as const;
173
+ const LIMB_KEYS = ['name', 'segment', 'of', 'bone', 'joint', 'range', 'cap'] as const;
174
+ const CAP_KEYS = ['at', 'width', 'radius', 'box', 'layer_px', 'image_px', 'image_layer', 'missing_px', 'shape', 'upper_px', 'edge', 'feather_radius', 'feathered_px'] as const;
154
175
 
155
176
  /** The limb record with its keys in the fixed order, so two runs write the same bytes. */
156
177
  function orderedLimb(l: LimbRecord): Record<string, unknown> {
@@ -470,6 +491,14 @@ function checkParts(raw: unknown, path: string): PartsFile {
470
491
  const below = isCount(q.segment) && q.segment > 0;
471
492
  if (below !== ('joint' in q) || below !== ('cap' in q)) fail('PARTS_FIELD_PRESENT', object, `segment ${show(q.segment)} with${'joint' in q ? '' : 'out'} a joint and with${'cap' in q ? '' : 'out'} a cap; a segment below a joint (index above 0) has both, the upper segment neither`);
472
493
  if ('joint' in q && !(typeof q.joint === 'string' && q.joint !== '')) fail('PARTS_FIELD_TYPE', `${object} "joint"`, `is ${show(q.joint)}; the joint's name is required`);
494
+ // Issue #360: a declared range rides below a joint only, and holds the loader's rule — two degrees, rest in it.
495
+ if ('range' in q) {
496
+ const r = q.range;
497
+ if (!below) fail('PARTS_FIELD_PRESENT', `${object} "range"`, `is on segment ${show(q.segment)}; a range is the bend of a joint, so only a segment below one (index above 0) carries it`);
498
+ else if (!(Array.isArray(r) && r.length === 2 && r.every((n) => typeof n === 'number' && Number.isFinite(n)) && (r[0] as number) <= 0 && 0 <= (r[1] as number))) {
499
+ fail('PARTS_FIELD_TYPE', `${object} "range"`, `is ${show(r)}; [min, max], two finite degrees from rest with min <= 0 <= max, is required — config.limbs' rule (CONFIG_LIMB_RANGE)`);
500
+ }
501
+ }
473
502
  if ('cap' in q) {
474
503
  const c = q.cap;
475
504
  const co = typeof c === 'object' && c !== null && !Array.isArray(c) ? (c as Record<string, unknown>) : null;
@@ -484,8 +513,17 @@ function checkParts(raw: unknown, path: string): PartsFile {
484
513
  isCount(co.layer_px) &&
485
514
  isCount(co.missing_px) &&
486
515
  (!('image_px' in co) || isCount(co.image_px)) &&
487
- (!('image_layer' in co) || ('image_px' in co && typeof co.image_layer === 'string' && co.image_layer !== ''));
488
- if (!ok) fail('PARTS_FIELD_TYPE', `${object} "cap"`, `is ${show(c)}; {at: [x, y], width, radius, box: [x, y, w, h], layer_px, image_px?, image_layer? (with image_px), missing_px} is required`);
516
+ (!('image_layer' in co) || ('image_px' in co && typeof co.image_layer === 'string' && co.image_layer !== '')) &&
517
+ // Issue #360: a disc cap carries its count, a feathered edge its band and count, and neither carries them alone.
518
+ ('shape' in co ? co.shape === 'disc' && isCount(co.upper_px) : !('upper_px' in co)) &&
519
+ ('edge' in co ? co.edge === 'feather' && typeof co.feather_radius === 'number' && Number.isFinite(co.feather_radius) && co.feather_radius >= 0 && isCount(co.feathered_px) : !('feather_radius' in co) && !('feathered_px' in co));
520
+ if (!ok) {
521
+ fail(
522
+ 'PARTS_FIELD_TYPE',
523
+ `${object} "cap"`,
524
+ `is ${show(c)}; {at: [x, y], width, radius, box: [x, y, w, h], layer_px, image_px?, image_layer? (with image_px), missing_px, shape? ("disc", with upper_px), edge? ("feather", with feather_radius and feathered_px)} is required`,
525
+ );
526
+ }
489
527
  }
490
528
  }
491
529
  }
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/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
  *