@rig-c/parts 1.29.0 → 1.31.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/parts.ts CHANGED
@@ -241,6 +241,23 @@ export interface RecompositeRecord {
241
241
  holes: HoleRecord[];
242
242
  }
243
243
 
244
+ export interface AlternateSetMap {
245
+ slots: Record<string, string>;
246
+ hides?: string[];
247
+ }
248
+
249
+ export interface AlternatePartsRecord {
250
+ name: string;
251
+ parts?: string[];
252
+ hides?: string[];
253
+ slots: Record<string, string>;
254
+ records: PartRecord[];
255
+ ghost_px: Record<string, number>;
256
+ recomposite: RecompositeRecord;
257
+ scene?: SceneRecord;
258
+ single_run_pad?: SingleRunPad;
259
+ }
260
+
244
261
  export interface PartsFile {
245
262
  rig_size: [number, number];
246
263
  scale_rig_per_source: number;
@@ -252,6 +269,8 @@ export interface PartsFile {
252
269
  scene?: SceneRecord;
253
270
  /** Written only for one run whose file is not the resolution square (issue #366): where the file sat on the run's square; absent otherwise. */
254
271
  single_run_pad?: SingleRunPad;
272
+ /** Written only when alternate painting sets were assembled (issue #454); absent otherwise. */
273
+ alternates?: AlternatePartsRecord[];
255
274
  }
256
275
 
257
276
  /**
@@ -286,8 +305,8 @@ export interface SceneRecord {
286
305
  figure_mask: string;
287
306
  }
288
307
 
289
- const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad'] as const;
290
- const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad');
308
+ const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad', 'alternates'] as const;
309
+ const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad' && k !== 'alternates');
291
310
  export const SINGLE_RUN_PAD_KEYS = ['file_size', 'side', 'left', 'top'] as const;
292
311
  export const SCENE_KEYS = ['figure_alpha_above', 'figure_px', 'figure_mask'] as const;
293
312
  export const RECOMPOSITE_KEYS = ['mean_abs', 'within_limit', 'within_share', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed', 'holes'] as const;
@@ -346,24 +365,43 @@ export function tagOf(from: string): string | null {
346
365
  return f === null || f.run === 'painting' ? null : f.tag;
347
366
  }
348
367
 
368
+ function serializePartList(parts: readonly PartRecord[]): Array<Record<string, unknown>> {
369
+ return parts.map((p) =>
370
+ Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : p[k]])),
371
+ );
372
+ }
373
+
374
+ function serializeRecomposite(r: RecompositeRecord): Record<string, unknown> {
375
+ return {
376
+ ...Object.fromEntries(RECOMPOSITE_KEYS.filter((k) => k !== 'holes').map((k) => [k, r[k]])),
377
+ holes: r.holes.map((h) => ({ ...Object.fromEntries(HOLE_KEYS.filter((k) => k !== 'borders').map((k) => [k, h[k]])), borders: h.borders.map((b) => ({ part: b.part, px: b.px })) })),
378
+ };
379
+ }
380
+
349
381
  export function serializeParts(file: PartsFile): string {
350
382
  const ordered = {
351
383
  rig_size: file.rig_size,
352
384
  scale_rig_per_source: file.scale_rig_per_source,
353
- parts: file.parts.map((p) =>
354
- Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : p[k]])),
355
- ),
385
+ parts: serializePartList(file.parts),
356
386
  ghost_px: Object.fromEntries(Object.keys(file.ghost_px).sort().map((k) => [k, file.ghost_px[k]])),
357
- ...(file.recomposite === undefined
387
+ ...(file.recomposite === undefined ? {} : { recomposite: serializeRecomposite(file.recomposite) }),
388
+ ...(file.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (file.scene as SceneRecord)[k]])) }),
389
+ ...(file.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (file.single_run_pad as SingleRunPad)[k]])) }),
390
+ ...(file.alternates === undefined
358
391
  ? {}
359
392
  : {
360
- recomposite: {
361
- ...Object.fromEntries(RECOMPOSITE_KEYS.filter((k) => k !== 'holes').map((k) => [k, (file.recomposite as RecompositeRecord)[k]])),
362
- holes: file.recomposite.holes.map((h) => ({ ...Object.fromEntries(HOLE_KEYS.filter((k) => k !== 'borders').map((k) => [k, h[k]])), borders: h.borders.map((b) => ({ part: b.part, px: b.px })) })),
363
- },
393
+ alternates: file.alternates.map((a) => ({
394
+ name: a.name,
395
+ ...(a.parts === undefined ? {} : { parts: a.parts }),
396
+ ...(a.hides === undefined ? {} : { hides: a.hides }),
397
+ slots: a.slots,
398
+ records: serializePartList(a.records),
399
+ ghost_px: Object.fromEntries(Object.keys(a.ghost_px).sort().map((k) => [k, a.ghost_px[k]])),
400
+ recomposite: serializeRecomposite(a.recomposite),
401
+ ...(a.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (a.scene as SceneRecord)[k]])) }),
402
+ ...(a.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (a.single_run_pad as SingleRunPad)[k]])) }),
403
+ })),
364
404
  }),
365
- ...(file.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (file.scene as SceneRecord)[k]])) }),
366
- ...(file.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (file.single_run_pad as SingleRunPad)[k]])) }),
367
405
  };
368
406
  return `${JSON.stringify(ordered, null, 2)}\n`;
369
407
  }
@@ -596,6 +634,9 @@ function checkParts(raw: unknown, path: string): PartsFile {
596
634
  const names = Array.isArray(o.parts) ? new Set(o.parts.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : undefined)).filter((n): n is string => typeof n === 'string')) : new Set<string>();
597
635
  checkRecomposite(o.recomposite, path, size as [number, number], names, fail);
598
636
  }
637
+ if ('alternates' in o && !Array.isArray(o.alternates)) {
638
+ fail('PARTS_FIELD_TYPE', `${path} field "alternates"`, `is ${show(o.alternates)}; an array of alternate set records is required`);
639
+ }
599
640
  refuseIfAny(problems);
600
641
  return raw as PartsFile;
601
642
  }
package/src/propose.ts CHANGED
@@ -127,7 +127,8 @@
127
127
  import { existsSync } from 'node:fs';
128
128
  import { join } from 'node:path';
129
129
  import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
130
- import { type BoneEntry, type ConfigConstraint, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
130
+ import { type BoneEntry, type ConfigConstraint, type ConfigStop, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
131
+ import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
131
132
  import { PartsError, type Problem, refuseIfAny } from './errors.ts';
132
133
  import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
133
134
  import { type LayerSet, OPAQUE_ALPHA_ABOVE } from './layers.ts';
@@ -148,7 +149,7 @@ import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER
148
149
  import { edgeLength } from './length.ts';
149
150
  import { bonePlaces, limbCrossing, limbPolyline, limbWidth } from './limbs.ts';
150
151
  import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.ts';
151
- import { backHairMasses, type BackHairMass, RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput, type TreeMassInput } from './cloth.ts';
152
+ import { backHairMasses, type BackHairMass, backHairMassTrees, RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput, type TreeMassInput } from './cloth.ts';
152
153
  import { TEXTURE_STRETCH_CEILING } from './check.ts';
153
154
 
154
155
  // ---------------------------------------------------------------------------
@@ -1848,12 +1849,29 @@ export function proposeWithBasis(
1848
1849
  const all: TreeChain[] = [];
1849
1850
  let n = 0;
1850
1851
  const partNamesLabel = list.map((q) => q.name).join(' -> ');
1852
+ const sideTreeCounts: number[] = [];
1851
1853
  for (const [side] of activeSides) {
1852
1854
  const prefix = prefixOf(side);
1853
1855
  const sideMasses = massesBySide.get(side) ?? [];
1854
1856
  if (useMasses && sideMasses.length === 0) continue;
1855
- const sideRcp: ClothRecipe = { ...rawRcp, chains: useMasses ? sideMasses.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)) };
1856
1857
  const frame = `${partNamesLabel}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
1858
+ const sideMassInputs: TreeMassInput[] | undefined = useMasses
1859
+ ? sideMasses.flatMap((m): TreeMassInput[] => {
1860
+ const count = backHairMassTrees(m, g);
1861
+ return Array.from({ length: count }, (_, t): TreeMassInput => ({
1862
+ rootY: m.minY <= y0 ? eyeY : m.minY,
1863
+ tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
1864
+ xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
1865
+ fallbackX: m.minY <= y0 && sideMasses.length === 1 && count === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
1866
+ frame,
1867
+ treeInMass: t,
1868
+ treesInMass: count,
1869
+ }));
1870
+ })
1871
+ : undefined;
1872
+ const sideCount = sideMassInputs !== undefined ? sideMassInputs.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit));
1873
+ sideTreeCounts.push(sideCount);
1874
+ const sideRcp: ClothRecipe = { ...rawRcp, chains: sideCount };
1857
1875
  const trees = T(list, 'back hair', sideRcp, {
1858
1876
  prefix,
1859
1877
  parent: 'head',
@@ -1863,15 +1881,7 @@ export function proposeWithBasis(
1863
1881
  fallbackX: () => axis,
1864
1882
  frame,
1865
1883
  edges: side,
1866
- masses: useMasses
1867
- ? sideMasses.map((m): TreeMassInput => ({
1868
- rootY: m.minY <= y0 ? eyeY : m.minY,
1869
- tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
1870
- xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
1871
- fallbackX: m.minY <= y0 && sideMasses.length === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
1872
- frame,
1873
- }))
1874
- : undefined,
1884
+ masses: sideMassInputs,
1875
1885
  });
1876
1886
  const made = treeConstraints(trees, sideRcp, null);
1877
1887
  clothConstraints.push(...made);
@@ -1880,7 +1890,7 @@ export function proposeWithBasis(
1880
1890
  }
1881
1891
  const rcp: ClothRecipe = {
1882
1892
  ...rawRcp,
1883
- chains: useMasses ? Math.max(1, ...(activeSides.map(([s]) => (massesBySide.get(s) ?? []).length))) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
1893
+ chains: useMasses ? Math.max(1, ...sideTreeCounts) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
1884
1894
  };
1885
1895
  const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
1886
1896
  for (const t of all) {
@@ -2036,12 +2046,32 @@ export function proposeWithBasis(
2036
2046
  const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
2037
2047
  const all: TreeChain[] = [];
2038
2048
  let n = 0;
2049
+ const treeCountBySide = new Map<'r' | 'l', number>([
2050
+ ['r', 0],
2051
+ ['l', 0],
2052
+ ]);
2039
2053
  for (const [side] of SIDES) {
2040
2054
  const prefix = prefixOf(side);
2041
2055
  const sideMasses = massesBySide.get(side) ?? [];
2042
2056
  if (useMasses && sideMasses.length === 0) continue;
2043
- const sideRcp: ClothRecipe = { ...rawRcp, chains: useMasses ? sideMasses.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)) };
2044
2057
  const frame = `${p.name}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
2058
+ const sideMassInputs: TreeMassInput[] | undefined = useMasses
2059
+ ? sideMasses.flatMap((m): TreeMassInput[] => {
2060
+ const count = backHairMassTrees(m, g);
2061
+ return Array.from({ length: count }, (_, t): TreeMassInput => ({
2062
+ rootY: m.minY <= y0 ? eyeY : m.minY,
2063
+ tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
2064
+ xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
2065
+ fallbackX: m.minY <= y0 && sideMasses.length === 1 && count === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
2066
+ frame,
2067
+ treeInMass: t,
2068
+ treesInMass: count,
2069
+ }));
2070
+ })
2071
+ : undefined;
2072
+ const sideCount = sideMassInputs !== undefined ? sideMassInputs.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit));
2073
+ treeCountBySide.set(side, sideCount);
2074
+ const sideRcp: ClothRecipe = { ...rawRcp, chains: sideCount };
2045
2075
  const trees = T(p, 'back hair', sideRcp, {
2046
2076
  prefix,
2047
2077
  parent: 'head',
@@ -2051,15 +2081,7 @@ export function proposeWithBasis(
2051
2081
  fallbackX: () => axis,
2052
2082
  frame,
2053
2083
  edges: side,
2054
- masses: useMasses
2055
- ? sideMasses.map((m): TreeMassInput => ({
2056
- rootY: m.minY <= y0 ? eyeY : m.minY,
2057
- tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
2058
- xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
2059
- fallbackX: m.minY <= y0 && sideMasses.length === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
2060
- frame,
2061
- }))
2062
- : undefined,
2084
+ masses: sideMassInputs,
2063
2085
  });
2064
2086
  for (const t of trees) segs.push(t.chain);
2065
2087
  const made = treeConstraints(trees, sideRcp, null);
@@ -2067,11 +2089,14 @@ export function proposeWithBasis(
2067
2089
  n += made.length;
2068
2090
  all.push(...trees);
2069
2091
  }
2070
- const sameSideCounts = !useMasses || rMasses.length === lMasses.length;
2092
+ const rTreeCount = treeCountBySide.get('r') ?? 0;
2093
+ const lTreeCount = treeCountBySide.get('l') ?? 0;
2094
+ const sameSideCounts = !useMasses || rTreeCount === lTreeCount;
2071
2095
  const noteRcp: ClothRecipe = {
2072
2096
  ...rawRcp,
2073
- chains: useMasses ? (sameSideCounts ? rMasses.length : rMasses.length + lMasses.length) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
2097
+ chains: useMasses ? (sameSideCounts ? rTreeCount : rTreeCount + lTreeCount) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
2074
2098
  };
2099
+ const massReason = rTreeCount === rMasses.length && lTreeCount === lMasses.length ? 'one per visible hair mass' : 'across the visible hair masses by root-row width';
2075
2100
  notes.push(
2076
2101
  clothNote(
2077
2102
  p.name,
@@ -2081,7 +2106,7 @@ export function proposeWithBasis(
2081
2106
  n,
2082
2107
  sameSideCounts
2083
2108
  ? ' per side of the eye axis, from head, rooted on the eye line and tipped 4 px above its bottom'
2084
- : ` (${rMasses.length} on the r side and ${lMasses.length} on the l side of the eye axis, one per visible hair mass), from head, rooted on each mass's top row and tipped 4 px above its bottom`,
2109
+ : ` (${rTreeCount} on the r side and ${lTreeCount} on the l side of the eye axis, ${massReason}), from head, rooted on each mass's top row and tipped 4 px above its bottom`,
2085
2110
  ),
2086
2111
  );
2087
2112
  meshes[p.name] = { grid: g, r: rawRcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
@@ -3471,7 +3496,10 @@ function artDistance(m: Mask, at: readonly [number, number]): number {
3471
3496
  * carries one: a part left to the rule beside parts placed from joints would
3472
3497
  * be read by two rules at once. Sides come from the joints, not from the eye
3473
3498
  * axis, so a figure seen from behind or lying across the axis is paired by
3474
- * where its wrists are.
3499
+ * where its wrists are. Issue #458: when pre-separated `handwear-r` and
3500
+ * `handwear-l` parts cross so that `<s>_wrist` lies on the opaque art of its
3501
+ * own side's `handwear-<s>` part and also within 15 px of the opposite side's
3502
+ * `handwear-<other>` part, it resolves to its own side's part.
3475
3503
  *
3476
3504
  * Issue #199: a wrist on no part within that tolerance is measured to every
3477
3505
  * part by the same Chebyshev distance ({@link artDistance}). With
@@ -3498,7 +3526,18 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
3498
3526
  const near = new Map(hw.map((p) => [p, dilate(P.alpha(p), 2 * WRIST_ON_PART_PX + 1)]));
3499
3527
  const arms: JointArm[] = [];
3500
3528
  for (const a of complete) {
3501
- const on = hw.filter((p) => isOn(near.get(p) as Mask, a.wrist[0], a.wrist[1]));
3529
+ const raw = hw.filter((p) => isOn(near.get(p) as Mask, a.wrist[0], a.wrist[1]));
3530
+ const ownTag = `handwear-${a.side}`;
3531
+ const oppTag = `handwear-${a.side === 'r' ? 'l' : 'r'}`;
3532
+ const own =
3533
+ raw.length > 1 &&
3534
+ raw.every((p) => {
3535
+ const t = splitFrom(p.from)[1];
3536
+ return t === ownTag || t === oppTag;
3537
+ })
3538
+ ? raw.filter((p) => splitFrom(p.from)[1] === ownTag && isOn(P.alpha(p), a.wrist[0], a.wrist[1]))
3539
+ : [];
3540
+ const on = own.length === 1 ? own : raw;
3502
3541
  if (on.length > 1) return { arms: null, why: `${a.side}_wrist at ${fmtAt(a.wrist)} lies on the art of ${on.length} handwear parts (${on.map((p) => p.name).join(', ')}), so it does not say which sleeve it ends` };
3503
3542
  if (on.length === 1) {
3504
3543
  arms.push({ ...a, part: on[0], snap: null });
@@ -4166,6 +4205,23 @@ export interface CrossedLimbsFinding {
4166
4205
  at: [number, number];
4167
4206
  }
4168
4207
 
4208
+ /**
4209
+ * Issue #447: a stop's contact point `<chain>_touch` (`at` in `against`'s local frame) lies above the first stopped
4210
+ * link's root (`links[0]` at `[x0, y0]`) in rig pixels (`ty < y0`, i.e. higher Spine y), so `<chain>_rest` aims
4211
+ * upward into the upper half-plane (`|rest| > 90°`) and `|rest| + outward > 180°` wraps past ±180° (`RIG_STOP_BOUNDS`).
4212
+ */
4213
+ export interface StopTouchAboveRootFinding {
4214
+ kind: 'stop-touch-above-root';
4215
+ chain: string;
4216
+ firstLink: string;
4217
+ rootAt: [number, number];
4218
+ against: string;
4219
+ at: [number, number];
4220
+ touchAt: [number, number];
4221
+ rest: number;
4222
+ outward: number;
4223
+ }
4224
+
4169
4225
  export type LintFinding =
4170
4226
  | OffArtFinding
4171
4227
  | OffLockFinding
@@ -4179,7 +4235,8 @@ export type LintFinding =
4179
4235
  | NearEmptyHandFinding
4180
4236
  | BackHairMassesFinding
4181
4237
  | OverlappingArmsFinding
4182
- | CrossedLimbsFinding;
4238
+ | CrossedLimbsFinding
4239
+ | StopTouchAboveRootFinding;
4183
4240
 
4184
4241
  /** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
4185
4242
  export interface LintBasis {
@@ -4279,7 +4336,7 @@ export function coverageOutcome(c: BoneCoverage): CoverageOutcome {
4279
4336
  */
4280
4337
  export function lint(
4281
4338
  P: PartSet,
4282
- spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[]; limbs?: Readonly<Record<string, Limb>> },
4339
+ spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[]; limbs?: Readonly<Record<string, Limb>>; stops?: readonly ConfigStop[] },
4283
4340
  joints?: RigJoints,
4284
4341
  ): LintResult {
4285
4342
  const { byName } = expand(spec.bones);
@@ -4552,11 +4609,12 @@ export function lint(
4552
4609
  .sort((a, b) => Number(trunkRe.exec(a.chain)![1]) - Number(trunkRe.exec(b.chain)![1]));
4553
4610
  if (sideTrunks.length === 0) continue;
4554
4611
  const masses = backHairMasses(mask, side, axis, y0, shoulderLine, g);
4612
+ const massSlots = masses.flatMap((x) => Array.from({ length: backHairMassTrees(x, g) }, () => x));
4555
4613
  const outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }> = [];
4556
4614
  const sideMinX = masses.length > 0 ? Math.min(...masses.map((x) => x.minX)) : 0;
4557
4615
  const sideMaxX = masses.length > 0 ? Math.max(...masses.map((x) => x.maxX)) : P.W - 1;
4558
4616
  sideTrunks.forEach((tr, k) => {
4559
- const target = k < masses.length ? masses[k] : null;
4617
+ const target = k < massSlots.length ? massSlots[k] : null;
4560
4618
  const minX = target !== null ? target.minX : sideMinX;
4561
4619
  const maxX = target !== null ? target.maxX : sideMaxX;
4562
4620
  const treeBase = tr.chain.slice(0, -1);
@@ -4573,7 +4631,7 @@ export function lint(
4573
4631
  if (tx < minX || tx > maxX) outside.push({ point: `${c.chain} tip`, at: [tx, ty], minX, maxX });
4574
4632
  }
4575
4633
  });
4576
- if (sideTrunks.length !== masses.length || outside.length > 0) {
4634
+ if (sideTrunks.length !== massSlots.length || outside.length > 0) {
4577
4635
  findings.push({
4578
4636
  kind: 'back-hair-masses',
4579
4637
  mesh: groupParts.map((q) => q.name).join(' -> '),
@@ -4648,6 +4706,61 @@ export function lint(
4648
4706
  }
4649
4707
  }
4650
4708
  }
4709
+ // Issue #447: when a stop's contact point `<chain>_touch` (`at` in `against`'s local frame) lies above the first
4710
+ // stopped link's root (`links[0]`) in rig pixels (`ty < y0`, i.e. higher Spine y), `<chain>_rest` aims into the
4711
+ // upper half-plane (`|rest| > 90°`) and `|rest| + outward > 180°` wraps past ±180° (`RIG_STOP_BOUNDS`).
4712
+ if (spec.stops !== undefined && spec.stops.length > 0) {
4713
+ const boneEnds = new Map<string, Point>();
4714
+ const chainLinks = new Map<string, string[]>();
4715
+ for (const e of spec.bones) {
4716
+ if ('chain' in e) {
4717
+ const names = e.points.map((_, k) => `${e.chain}${k}`);
4718
+ chainLinks.set(e.chain, names);
4719
+ e.points.forEach((_, k) => {
4720
+ boneEnds.set(names[k], k + 1 < e.points.length ? e.points[k + 1] : e.tip);
4721
+ });
4722
+ } else if (e.tip !== undefined) {
4723
+ boneEnds.set(e.name, e.tip);
4724
+ }
4725
+ }
4726
+ for (const s of spec.stops) {
4727
+ const links = s.links ?? chainLinks.get(s.chain) ?? [];
4728
+ if (links.length === 0) continue;
4729
+ const firstLink = links[0];
4730
+ const rootBone = byName.get(firstLink);
4731
+ const againstBone = byName.get(s.against);
4732
+ if (rootBone === undefined || againstBone === undefined) continue;
4733
+ const againstEnd = boneEnds.get(s.against);
4734
+ let againstRot = 0;
4735
+ if (againstEnd !== undefined) {
4736
+ const dx = againstEnd[0] - againstBone.x;
4737
+ const dy = cropToSpineY(againstEnd[1], P.H) - cropToSpineY(againstBone.y, P.H);
4738
+ if (dx !== 0 || dy !== 0) againstRot = (Math.atan2(dy, dx) * 180) / Math.PI;
4739
+ }
4740
+ const frame = computeExactFrameTransforms([
4741
+ { name: s.against, x: againstBone.x, y: cropToSpineY(againstBone.y, P.H), rotation: againstRot },
4742
+ ]).get(s.against)!;
4743
+ const [wx, wy] = toWorld(frame, s.at[0], s.at[1]);
4744
+ const touchAt: [number, number] = [pyRound(wx * 10) / 10, pyRound(cropToSpineY(wy, P.H) * 10) / 10];
4745
+ const aimDx = wx - rootBone.x;
4746
+ const aimDy = wy - cropToSpineY(rootBone.y, P.H);
4747
+ if (aimDx === 0 && aimDy === 0) continue;
4748
+ const rest = pyRound(normaliseDegrees((Math.atan2(aimDy, aimDx) * 180) / Math.PI) * 100) / 100;
4749
+ if (touchAt[1] < rootBone.y || Math.abs(rest) + s.outward > 180) {
4750
+ findings.push({
4751
+ kind: 'stop-touch-above-root',
4752
+ chain: s.chain,
4753
+ firstLink,
4754
+ rootAt: [rootBone.x, rootBone.y],
4755
+ against: s.against,
4756
+ at: [s.at[0], s.at[1]],
4757
+ touchAt,
4758
+ rest,
4759
+ outward: s.outward,
4760
+ });
4761
+ }
4762
+ }
4763
+ }
4651
4764
  // Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
4652
4765
  // to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
4653
4766
  const partImages = new Map(P.recs.filter((p) => P.images.has(p.name)).map((p) => [p.name, { x: p.x, y: p.y, image: P.images.get(p.name) as Raster }]));
@@ -4686,6 +4799,7 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
4686
4799
  case 'near-empty-hand':
4687
4800
  case 'overlapping-arms':
4688
4801
  case 'crossed-limbs':
4802
+ case 'stop-touch-above-root':
4689
4803
  return [];
4690
4804
  case 'back-hair-masses': {
4691
4805
  const out = new Set<string>();
@@ -4878,6 +4992,8 @@ export function lintLine(f: LintFinding): string {
4878
4992
  return `LINT ${f.part} (${f.from}) holds both arms in one ${f.opaquePx} px component (r_wrist at ${fmtAt(f.rAt)}, l_wrist at ${fmtAt(f.lAt)}): declare config.seethrough.arm_split (x or line, and order: ["handwear-r", "handwear-l"] or ["handwear-l", "handwear-r"]) to separate the overlapping arms into two layers`;
4879
4993
  case 'crossed-limbs':
4880
4994
  return `LINT ${f.a} and ${f.b} on ${f.part}${f.from === '' ? '' : ` (${f.from})`} cross at ${fmtAt(f.at)} with no per-limb split: nearest-bone splitting cuts a straight line through the crossing; declare config.limbs.<covering>.split (line: [[x, y], ...] or edge: "painting") on the covering limb so the seam follows the limb`;
4995
+ case 'stop-touch-above-root':
4996
+ return `LINT stop on ${f.chain} (${f.firstLink} at ${fmtAt(f.rootAt)}) against ${f.against} (at ${fmtAt(f.at)}) places ${f.chain}_touch at ${fmtAt(f.touchAt)}, above ${f.firstLink}'s root (rest ${pyFixed(f.rest, 2)} deg, |rest| + outward ${pyFixed(Math.abs(f.rest) + f.outward, 2)} deg): stop against the descending limb bone below ${f.firstLink}, set links to start at a link below ${f.chain}_touch, or omit the stop on a rising limb`;
4881
4997
  case 'back-hair-masses': {
4882
4998
  const massDesc =
4883
4999
  f.masses.length === 0
package/src/rig.ts CHANGED
@@ -166,7 +166,7 @@ import { type ContourChainRibs, type ContourReport, contourMesh, type ContourReg
166
166
  import { type LocalInfluence, localInfluences } from './localweights.ts';
167
167
  import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
168
168
  import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
169
- import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
169
+ import { type AlternateSetMap, PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
170
170
  import { EYE_GROUP_TAGS } from './propose.ts';
171
171
  import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
172
172
  import { pyRound } from './round.ts';
@@ -896,6 +896,20 @@ export interface AutoMeshReport {
896
896
  motion_report?: unknown;
897
897
  }
898
898
 
899
+ export const SETS_SPEC = 'rig-parts-sets/1';
900
+ export const SETS_FILE = 'sets.json';
901
+
902
+ /**
903
+ * `sets.json` (issue #454): the machine-readable map from each alternate
904
+ * painting set to its `<set>_<part>` attachment on each slot it covers and
905
+ * the slots it hides. Written beside `rig.json` only when `parts.json` carries
906
+ * `alternates`.
907
+ */
908
+ export interface AttachmentSetsFile {
909
+ spec: typeof SETS_SPEC;
910
+ sets: Record<string, AlternateSetMap>;
911
+ }
912
+
899
913
  export interface RigOutput {
900
914
  rig: RigSpec;
901
915
  motion: MotionSpec;
@@ -915,6 +929,8 @@ export interface RigOutput {
915
929
  faceAxis: FaceAxis;
916
930
  /** The turn control's parts (issue #251), in parts.json order; null when the config has no `turn`. */
917
931
  turn: TurnRow[] | null;
932
+ /** `sets.json` (issue #454), present only when `parts.alternates` is non-empty. */
933
+ sets?: AttachmentSetsFile;
918
934
  }
919
935
 
920
936
  /**
@@ -1966,6 +1982,93 @@ export function buildRig(
1966
1982
  });
1967
1983
  }
1968
1984
  refuseIfAny(problems);
1985
+ // ---- alternate painting sets (issue #454) -------------------------------
1986
+ // Only when `parts.alternates` is present: each alternate set's `<set>_<part>`
1987
+ // attachments are added to `skins.default[<part>]` on the existing bones and
1988
+ // slots before `applyStops` and `applyTurn`, so stopped links and turn twins
1989
+ // carry the alternate attachments alongside the base ones.
1990
+ let sets: AttachmentSetsFile | undefined;
1991
+ if (parts.alternates !== undefined && parts.alternates.length > 0) {
1992
+ const setEntries: Record<string, AlternateSetMap> = {};
1993
+ for (const alt of parts.alternates) {
1994
+ for (const p of alt.records) {
1995
+ const attName = `${alt.name}_${p.name}`;
1996
+ const rawImg = images.get(attName);
1997
+ if (rawImg === undefined) continue;
1998
+ const img = pad(rawImg, PAD, PAD, PAD, PAD, [0, 0, 0, 0]);
1999
+ const file = `${attName}.png`;
2000
+ outImages.push([file, img]);
2001
+ const mesh = cfg.meshes[p.name];
2002
+ if (mesh === undefined) {
2003
+ const bone = B.get(cfg.regions[p.name]) as Bone;
2004
+ skin[p.name][attName] = region(file, bone.name, p.x + p.w / 2, p.y + p.h / 2);
2005
+ continue;
2006
+ }
2007
+ const fullSegs = meshSegments.get(p.name) as Segment[];
2008
+ const baseAtt = skin[p.name]?.[p.name];
2009
+ const baseBones = baseAtt !== undefined && 'type' in baseAtt ? new Set(baseAtt.weights.flatMap((v) => v.map((e) => e.bone))) : null;
2010
+ const filteredSegs = baseBones !== null && baseBones.size > 0 ? fullSegs.filter((s) => baseBones.has(s.bone)) : fullSegs;
2011
+ const segs = filteredSegs.length > 0 ? filteredSegs : fullSegs;
2012
+ const allArt = alphaAbove(img, ART_ALPHA);
2013
+ const strayVal = 'auto' in mesh ? mesh.auto.source.stray : ('contour' in mesh ? mesh.contour.stray : mesh.stray);
2014
+ const stray = strayVal === undefined ? null : latticeStray(allArt, strayVal);
2015
+ const art = stray === null ? allArt : stray.art;
2016
+ const grid = 'grid' in mesh ? mesh.grid : 28;
2017
+ const r = 'r' in mesh ? mesh.r : 80;
2018
+ const lm = latticeMesh(attName, art, grid, maxLoopPasses);
2019
+ if ('code' in lm) {
2020
+ skin[p.name][attName] = region(file, segs[0].bone, p.x + p.w / 2, p.y + p.h / 2);
2021
+ continue;
2022
+ }
2023
+ const ox = p.x - PAD;
2024
+ const oy = p.y - PAD;
2025
+ const w = img.width;
2026
+ const h = img.height;
2027
+ const limits: InfluenceLimits = meshLimits('influences' in mesh ? mesh.influences : undefined);
2028
+ const segsAt = dressSegsAt(p, segs);
2029
+ const exponent = mesh.exponent ?? DISTANCE_EXPONENT;
2030
+ const rawShares: Influence[][] = [];
2031
+ for (const [vx0, vy0] of lm.vertices) {
2032
+ const wx = vx0 + ox;
2033
+ const wy = vy0 + oy;
2034
+ const vSegs = segsAt(wy);
2035
+ let shares = influences([wx, wy], vSegs, r, limits, exponent);
2036
+ if (shares.length === 0) {
2037
+ shares = influences([wx, wy], vSegs, Infinity, { maxInfluences: 1, minWeight: 0 }, exponent);
2038
+ }
2039
+ rawShares.push(limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares));
2040
+ }
2041
+ const weights: WeightEntry[][] = [];
2042
+ for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
2043
+ const wx = vx0 + ox;
2044
+ const wy = vy0 + oy;
2045
+ const ent: WeightEntry[] = rawShares[vi].map(({ bone, weight }) => {
2046
+ const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
2047
+ return { bone, x: places(x), y: places(y), weight };
2048
+ });
2049
+ weights.push(ent);
2050
+ }
2051
+ const uvs: number[] = [];
2052
+ for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
2053
+ skin[p.name][attName] = { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights };
2054
+ }
2055
+ const slotMap: Record<string, string> = {};
2056
+ for (const [slotName, att] of Object.entries(alt.slots)) slotMap[slotName] = att;
2057
+ const hideList: string[] = [];
2058
+ for (const h of alt.hides ?? []) {
2059
+ if (!hideList.includes(h)) hideList.push(h);
2060
+ if (stills[h] !== undefined && !hideList.includes(`${h}${STILL_SUFFIX}`)) hideList.push(`${h}${STILL_SUFFIX}`);
2061
+ }
2062
+ for (const slotName of Object.keys(alt.slots)) {
2063
+ if (stills[slotName] !== undefined && !hideList.includes(`${slotName}${STILL_SUFFIX}`)) hideList.push(`${slotName}${STILL_SUFFIX}`);
2064
+ }
2065
+ setEntries[alt.name] = {
2066
+ slots: slotMap,
2067
+ ...(hideList.length > 0 ? { hides: hideList } : {}),
2068
+ };
2069
+ }
2070
+ sets = { spec: SETS_SPEC, sets: setEntries };
2071
+ }
1969
2072
  // ---- leg stops (issue #404) --------------------------------------------
1970
2073
  // Only when the config has `stops`: each link after the first (and any bone hanging from a stopped link) hangs
1971
2074
  // from the drawn link `<link>_d` above it, and slots and weights on a stopped link bind `<link>_d`.
@@ -2007,7 +2110,20 @@ export function buildRig(
2007
2110
  overrides: cfg.turn?.deform,
2008
2111
  });
2009
2112
  });
2010
- return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
2113
+ return {
2114
+ rig,
2115
+ motion,
2116
+ meshReport,
2117
+ images: outImages,
2118
+ controls,
2119
+ meshKeyed,
2120
+ idleKeys,
2121
+ loopPasses,
2122
+ autoMotion,
2123
+ faceAxis,
2124
+ turn: turned?.rows ?? null,
2125
+ ...(sets === undefined ? {} : { sets }),
2126
+ };
2011
2127
  }
2012
2128
 
2013
2129
  /**