@rig-c/parts 1.24.0 → 1.26.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
@@ -86,7 +86,7 @@
86
86
  */
87
87
  import { existsSync, readFileSync, statSync } from 'node:fs';
88
88
  import { dirname, resolve } from 'node:path';
89
- import { RIG_BONE_INHERIT, RIG_SKIN_CONSTRAINT_KEYS, type RigBoneInherit, type RigConstraint, type RigSkinConstraintKey } from 'rig-c/src/rig.ts';
89
+ import { RIG_BONE_INHERIT, RIG_SKIN_CONSTRAINT_KEYS, type RigBoneInherit, type RigConstraint, type RigSkinConstraintKey, type RigStop } from 'rig-c/src/rig.ts';
90
90
  import { pyRound } from './round.ts';
91
91
  import { CLOTH_TARGETS, type ClothRecipes, RECIPE_STRENGTH, RIGC_DEFAULT_DAMPING, springStable } from './cloth.ts';
92
92
  import { GRID, MAX_SIDE } from './contour.ts';
@@ -256,6 +256,8 @@ export interface ChainBones {
256
256
  parent: string;
257
257
  points: Point[];
258
258
  tip: Point;
259
+ /** Expanded link name -> rig-c inherit mode (issue #369); absent links keep normal inheritance. */
260
+ link_inherit?: Record<string, RigBoneInherit>;
259
261
  }
260
262
 
261
263
  export type BoneEntry = SingleBone | ChainBones;
@@ -648,6 +650,26 @@ export interface ConfigConstraint {
648
650
  [field: string]: unknown;
649
651
  }
650
652
 
653
+ /**
654
+ * One entry of `config.stops` (issue #404): rig-c's `RigStop` (`rig-c` 2.56.0),
655
+ * a cloth chain's rotation clamped against a leg bone so the skirt cannot swing
656
+ * into the leg. `chain` names a chain declared in `config.bones`; `links` is
657
+ * the stopped links along it (when omitted, every link of `chain` in link
658
+ * order); `against` is the leg bone the point rides on; `at` is `[x, y]` in
659
+ * `against`'s local space; `inward` and `outward` are the first link's clamp
660
+ * bounds in degrees (positive away from `against`), and `lower` the lower
661
+ * links' bounds when more than one link is stopped.
662
+ */
663
+ export interface ConfigStop {
664
+ chain: string;
665
+ links?: string[];
666
+ against: string;
667
+ at: Point;
668
+ inward: number;
669
+ outward: number;
670
+ lower?: { inward: number; outward: number };
671
+ }
672
+
651
673
  /**
652
674
  * One segment of a limb (issue #349): the part it makes, the part whose
653
675
  * pixels it takes (`from`: a plan part or a cut's piece), and the one bone it
@@ -697,12 +719,16 @@ export interface CharacterConfig {
697
719
  regions: Record<string, string>;
698
720
  motion: Motion;
699
721
  constraints?: ConfigConstraint[];
722
+ /** Leg stops (issue #404, rig-c 2.56.0's `RigStop`): absent, no stop is added and no byte of any output moves. */
723
+ stops?: ConfigStop[];
700
724
  /** The turn control (issue #251, `src/turn.ts`): absent, no control is added and no byte of any output moves. */
701
725
  turn?: TurnSpec;
702
726
  /** The painting is a scene (issue #242): absent, no byte of any output moves. */
703
727
  scene?: SceneSpec;
704
728
  /** The cloth and hair recipe `propose --cloth` drafts from (issue #364, `src/cloth.ts`): read by propose only, so no byte of any rig output moves with it. */
705
729
  cloth?: ClothRecipes;
730
+ /** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) `propose` drafted the rig sections at (issue #354): absent on a hand-authored config, held equal to `assemble.rig_scale` when present. */
731
+ proposed_rig_scale?: number;
706
732
  }
707
733
 
708
734
  /** `config.scene` (issue #242): the declaration that the painting is a scene. It holds no field today. */
@@ -760,6 +786,23 @@ export function constraintForRig(c: ConfigConstraint): Record<string, unknown> {
760
786
  return out;
761
787
  }
762
788
 
789
+ /**
790
+ * A stop as the rig stage hands it to rigc (issue #404): its fields in
791
+ * `RigStop` order, `links` expanded from `chain` when omitted, and any records
792
+ * (`x-…`) or annotations (`note`, `…_note`) left out.
793
+ */
794
+ export function stopForRig(s: ConfigStop, links: readonly string[]): RigStop {
795
+ return {
796
+ chain: s.chain,
797
+ links: [...links],
798
+ against: s.against,
799
+ at: [s.at[0], s.at[1]],
800
+ inward: s.inward,
801
+ outward: s.outward,
802
+ ...(s.lower === undefined ? {} : { lower: { inward: s.lower.inward, outward: s.lower.outward } }),
803
+ };
804
+ }
805
+
763
806
  /** The single-value bone properties a sine track may drive — rigc's one-channel bone timelines. */
764
807
  export const SINGLE_PROPS = ['rotate', 'translatex', 'translatey', 'scalex', 'scaley', 'shearx', 'sheary'] as const;
765
808
  export type SingleProp = (typeof SINGLE_PROPS)[number];
@@ -990,9 +1033,11 @@ export interface AssembleConfig {
990
1033
  /** Issue #349: read with `bones` (the joints are the bones' origins) when present. */
991
1034
  limbs?: Record<string, Limb>;
992
1035
  bones?: BoneEntry[];
1036
+ /** Issue #354: the `rig_scale` `propose` drafted the rig sections at, held equal to `assemble.rig_scale` when present. */
1037
+ proposed_rig_scale?: number;
993
1038
  }
994
1039
 
995
- const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene', 'cloth'] as const;
1040
+ const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'stops', 'turn', 'scene', 'cloth', 'proposed_rig_scale'] as const;
996
1041
 
997
1042
  /**
998
1043
  * The one partial entry point. It validates exactly what its door requires —
@@ -1042,6 +1087,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1042
1087
  if (door === 'assemble') {
1043
1088
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1044
1089
  const made = 'assemble' in t ? checkAssemble(c, t.assemble, door) : { parts: [], patches: [], cuts: [] };
1090
+ checkProposedRigScale(c, t);
1045
1091
  if ('scene' in t) checkScene(c, t.scene);
1046
1092
  // Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
1047
1093
  if ('limbs' in t) {
@@ -1195,6 +1241,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1195
1241
  if ('generation' in t) checkGeneration(c, t.generation);
1196
1242
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1197
1243
  const { parts, patches, cuts } = 'assemble' in t ? checkAssemble(c, t.assemble, 'full') : { parts: [], patches: [], cuts: [] };
1244
+ checkProposedRigScale(c, t);
1198
1245
  const names = 'bones' in t ? checkBones(c, t.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1199
1246
  const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
1200
1247
  if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
@@ -1202,6 +1249,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1202
1249
  if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions, limbs);
1203
1250
  if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
1204
1251
  if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
1252
+ if ('stops' in t) checkStops(c, t.stops, names.bones, names.chains);
1205
1253
  if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
1206
1254
  if ('scene' in t) checkScene(c, t.scene);
1207
1255
  if ('cloth' in t) checkCloth(c, t.cloth);
@@ -1212,6 +1260,29 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1212
1260
  return { config: raw as CharacterConfig, animations };
1213
1261
  }
1214
1262
 
1263
+ /**
1264
+ * Issue #354: `propose` records `parts.json`'s `scale_rig_per_source` on the
1265
+ * proposal as `proposed_rig_scale`, and pasting the proposal into `config.json`
1266
+ * carries it. When present, `assemble`, `rig` and `build` compare it against
1267
+ * `assemble.rig_scale` and refuse a mismatch: the bones, mesh spacings, segment
1268
+ * coordinates and motion amplitudes are in the proposed rig canvas's pixels and
1269
+ * do not scale when `assemble.rig_scale` changes. A hand-authored config with
1270
+ * no `proposed_rig_scale` is not refused.
1271
+ */
1272
+ function checkProposedRigScale(c: Check, t: Record<string, Json>): void {
1273
+ if (!('proposed_rig_scale' in t)) return;
1274
+ if (!c.number('config.proposed_rig_scale', t.proposed_rig_scale, 'positive')) return;
1275
+ const a = isPlainObject(t.assemble) ? t.assemble : null;
1276
+ if (a === null || !('rig_scale' in a) || !(typeof a.rig_scale === 'number' && Number.isFinite(a.rig_scale) && a.rig_scale > 0)) return;
1277
+ if (t.proposed_rig_scale !== a.rig_scale) {
1278
+ c.fail(
1279
+ 'CONFIG_PROPOSED_RIG_SCALE',
1280
+ 'config.proposed_rig_scale',
1281
+ `is ${t.proposed_rig_scale}, and config.assemble.rig_scale is ${a.rig_scale} — the bones, meshes, limbs and motion were proposed at rig_scale ${t.proposed_rig_scale} and are in that rig canvas's pixels, so they do not scale when assemble.rig_scale changes; re-run assemble and propose at rig_scale ${a.rig_scale} and paste the new proposal, or restore config.assemble.rig_scale to ${t.proposed_rig_scale}`,
1282
+ );
1283
+ }
1284
+ }
1285
+
1215
1286
  function isPlainObject(v: Json): v is Record<string, Json> {
1216
1287
  return typeof v === 'object' && v !== null && !Array.isArray(v);
1217
1288
  }
@@ -1364,9 +1435,9 @@ export interface SkeletonSections {
1364
1435
  /**
1365
1436
  * A `proposal.json` as `propose` writes it — `bones`, `meshes`, `regions`,
1366
1437
  * `motion` and `notes` (strings), `limbs` when `propose --keypoints` drafted
1367
- * them (issue #349) and `constraints` when `propose --cloth` did (issue #364;
1438
+ * them (issue #349), `constraints` when `propose --cloth` did (issue #364;
1368
1439
  * both read here since issue #372, whose `wind` example tracks such a
1369
- * proposal) — under the full loader's own rules for those sections (issue
1440
+ * proposal), and `proposed_rig_scale` (issue #354) — under the full loader's own rules for those sections (issue
1370
1441
  * #85). There is no plan to hold the meshes, regions and limbs against, so
1371
1442
  * the coverage rule (every plan part exactly one of a mesh or a region) is
1372
1443
  * not run, and a limb segment's `from` is held to no plan part (the parts
@@ -1374,9 +1445,9 @@ export interface SkeletonSections {
1374
1445
  * both before writing (`checkProposal`). A key the loader would not take is
1375
1446
  * refused by name, as in a config. Refusals name the fields `proposal.…`.
1376
1447
  */
1377
- export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[] } {
1448
+ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[]; proposed_rig_scale?: number } {
1378
1449
  const c = new Check();
1379
- const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints']);
1450
+ const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints', 'stops', 'proposed_rig_scale']);
1380
1451
  if (top !== null) {
1381
1452
  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>() };
1382
1453
  if ('limbs' in top) {
@@ -1389,10 +1460,12 @@ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: R
1389
1460
  if ('regions' in top) checkRegions(c, top.regions, names.bones);
1390
1461
  if ('motion' in top) checkMotion(c, top.motion, names.bones, names.chains, 'regions' in top ? top.regions : undefined);
1391
1462
  if ('constraints' in top) checkConstraints(c, top.constraints, names.bones, names.parents);
1463
+ if ('stops' in top) checkStops(c, top.stops, names.bones, names.chains);
1464
+ if ('proposed_rig_scale' in top) c.number('config.proposed_rig_scale', top.proposed_rig_scale, 'positive');
1392
1465
  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));
1393
1466
  }
1394
1467
  refuseIfAny(c.problems.map((p) => ({ ...p, object: p.object.replace(/^config\b/, 'proposal') })));
1395
- return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[] };
1468
+ return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[]; proposed_rig_scale?: number };
1396
1469
  }
1397
1470
 
1398
1471
  /**
@@ -1720,7 +1793,7 @@ function checkBones(c: Check, v: Json): { bones: Set<string>; chains: Map<string
1720
1793
  const at = `config.bones[${i}]`;
1721
1794
  const isChain = typeof entry === 'object' && entry !== null && 'chain' in entry;
1722
1795
  if (isChain) {
1723
- const b = c.object(at, entry, ['chain', 'parent', 'points', 'tip'], []);
1796
+ const b = c.object(at, entry, ['chain', 'parent', 'points', 'tip'], ['link_inherit']);
1724
1797
  if (b === null) return;
1725
1798
  if ('parent' in b && c.string(`${at}.parent`, b.parent) && !bones.has(b.parent)) {
1726
1799
  c.fail('CONFIG_NAME_RESOLVES', `${at}.parent`, `names "${b.parent}", which is not "${ROOT_BONE}" or a bone declared above it; parents come first`);
@@ -1738,6 +1811,21 @@ function checkBones(c: Check, v: Json): { bones: Set<string>; chains: Map<string
1738
1811
  else if (typeof b.parent === 'string' && !parents.has(`${name}0`)) parents.set(`${name}0`, b.parent);
1739
1812
  });
1740
1813
  chains.set(name, points.length);
1814
+ if ('link_inherit' in b) {
1815
+ const path = `${at}.link_inherit`;
1816
+ const links = points.map((_, k) => `${name}${k}`);
1817
+ if (!isPlainObject(b.link_inherit)) {
1818
+ c.fail('CONFIG_FIELD_TYPE', path, `is ${show(b.link_inherit)}; an object mapping this chain's expanded link names to rig-c inherit modes is required`);
1819
+ } else {
1820
+ for (const [link, mode] of Object.entries(b.link_inherit)) {
1821
+ const field = `${path}.${link}`;
1822
+ if (!links.includes(link)) c.fail('CONFIG_NAME_RESOLVES', field, `names "${link}", which is not a link of chain "${name}"; its declared links are ${links.map((n) => `"${n}"`).join(', ')}`);
1823
+ if (c.string(field, mode) && !(RIG_BONE_INHERIT as readonly string[]).includes(mode)) {
1824
+ c.fail('CONFIG_BONE_INHERIT', field, `is ${show(mode)}; one of rig-c's inherit modes, spelled as its table spells them, is required — ${RIG_BONE_INHERIT.join(', ')} (rig-c's RigBone.inherit; nothing here folds a case or maps a name)`);
1825
+ }
1826
+ }
1827
+ }
1828
+ }
1741
1829
  }
1742
1830
  return;
1743
1831
  }
@@ -2614,6 +2702,65 @@ function checkConstraints(c: Check, v: Json, bones: Set<string>, parents: Map<st
2614
2702
  });
2615
2703
  }
2616
2704
 
2705
+ /**
2706
+ * `config.stops` (issue #404): rig-c 2.56.0's `RigStop` entries, each holding
2707
+ * a swinging chain off a leg bone (`against`). `chain` resolves against the
2708
+ * chains `config.bones` declares; `links` (when given) and `against` resolve
2709
+ * against the bones `config.bones` declares (`CONFIG_NAME_RESOLVES`).
2710
+ */
2711
+ function checkStops(c: Check, v: Json, bones: Set<string>, chains: Map<string, number>): void {
2712
+ const p = 'config.stops';
2713
+ if (!c.array(p, v, false)) return;
2714
+ const declaredBones = [...bones].join(', ');
2715
+ const declaredChains = [...chains.keys()].join(', ') || 'none';
2716
+ (v as Json[]).forEach((entry, i) => {
2717
+ const at = `${p}[${i}]`;
2718
+ const o = c.object(at, entry, ['chain', 'against', 'at', 'inward', 'outward'], ['links', 'lower']);
2719
+ if (o === null) return;
2720
+ let chainLinks: number | undefined;
2721
+ if ('chain' in o && c.string(`${at}.chain`, o.chain)) {
2722
+ chainLinks = chains.get(o.chain);
2723
+ if (chainLinks === undefined) {
2724
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.chain`, `names "${o.chain}", which config.bones does not declare as a chain; the chains that exist: ${declaredChains}`);
2725
+ }
2726
+ }
2727
+ let linkCount = chainLinks ?? 0;
2728
+ if ('links' in o && c.array(`${at}.links`, o.links, true)) {
2729
+ const list = o.links as Json[];
2730
+ linkCount = list.length;
2731
+ list.forEach((l, k) => {
2732
+ if (c.string(`${at}.links[${k}]`, l) && !bones.has(l)) {
2733
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.links[${k}]`, `names "${l}", which config.bones does not declare; the bones that exist: ${declaredBones}`);
2734
+ }
2735
+ });
2736
+ }
2737
+ if ('against' in o && c.string(`${at}.against`, o.against) && !bones.has(o.against)) {
2738
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.against`, `names "${o.against}", which config.bones does not declare; the bones that exist: ${declaredBones}`);
2739
+ }
2740
+ if ('at' in o) c.point(`${at}.at`, o.at);
2741
+ const inward = 'inward' in o && c.number(`${at}.inward`, o.inward) ? (o.inward as number) : null;
2742
+ const outward = 'outward' in o && c.number(`${at}.outward`, o.outward) ? (o.outward as number) : null;
2743
+ if (inward !== null && outward !== null && inward > outward) {
2744
+ c.fail('CONFIG_FIELD_TYPE', `${at}.inward`, `is ${inward}, above outward ${outward}; inward <= outward is required`);
2745
+ }
2746
+ if ('lower' in o) {
2747
+ const lo = c.object(`${at}.lower`, o.lower, ['inward', 'outward'], []);
2748
+ if (lo !== null) {
2749
+ const li = 'inward' in lo && c.number(`${at}.lower.inward`, lo.inward) ? (lo.inward as number) : null;
2750
+ const loOut = 'outward' in lo && c.number(`${at}.lower.outward`, lo.outward) ? (lo.outward as number) : null;
2751
+ if (li !== null && loOut !== null && li > loOut) {
2752
+ c.fail('CONFIG_FIELD_TYPE', `${at}.lower.inward`, `is ${li}, above lower.outward ${loOut}; inward <= outward is required`);
2753
+ }
2754
+ }
2755
+ if (linkCount === 1) {
2756
+ c.fail('CONFIG_FIELD_TYPE', `${at}.lower`, `is ${show(o.lower)} on a 1-link stop; lower holds the links below the first, so leave lower out when links has 1 bone`);
2757
+ }
2758
+ } else if (linkCount > 1) {
2759
+ c.fail('CONFIG_FIELD_PRESENT', `${at}.lower`, `is absent and required when the stop holds ${linkCount} links (more than 1); state lower: { inward, outward } for the links below the first`);
2760
+ }
2761
+ });
2762
+ }
2763
+
2617
2764
  /** The parts `meshes` and `regions` attach, read off whatever they hold; `turn.depths` may name only these. */
2618
2765
  function attachedParts(meshes: Json, regions: Json): Set<string> {
2619
2766
  const out = new Set<string>();
@@ -2666,7 +2813,7 @@ function attachedParts(meshes: Json, regions: Json): Set<string> {
2666
2813
  * - `part` on a tag other than `bottomwear` (`CONFIG_CLOTH_PART_ON_BOTTOMWEAR`):
2667
2814
  * the field hands the skirt's recipe to a part of another tag (issue #402),
2668
2815
  * and only the skirt has a recipe that part can take; whether the part
2669
- * exists, and is a `topwear` that hangs below the hip, is read off the parts
2816
+ * exists, and is a `topwear` part or its cut piece hanging below the hip, is read off the assembled parts
2670
2817
  * by `propose` (`PROPOSE_CLOTH_PART`);
2671
2818
  * - a strength, declared or the recipe's default, whose spring is unstable at
2672
2819
  * a damping the recipe runs at — k = strength·massInverse·step² at or past
@@ -2695,15 +2842,17 @@ function checkCloth(c: Check, v: Json): void {
2695
2842
  for (const tag of CLOTH_TARGETS) {
2696
2843
  if (!(tag in v)) continue;
2697
2844
  const path = `${at}.${JSON.stringify(tag)}`;
2698
- const r = c.object(path, v[tag], ['chains', 'links', 'wind', 'gravity'], ['split', 'length_ratio', 'strength', 'damping', 'alternate_damping', 'translate', 'swing', 'part']);
2845
+ const r = c.object(path, v[tag], ['links', 'wind', 'gravity'], ['chains', 'split', 'length_ratio', 'strength', 'damping', 'alternate_damping', 'translate', 'swing', 'part']);
2699
2846
  if (r === null) continue;
2847
+ if (tag !== 'back hair' && !('chains' in r)) c.fail('CONFIG_FIELD_PRESENT', `${path}.chains`, 'is absent and required');
2700
2848
  const chains = 'chains' in r && c.int(`${path}.chains`, r.chains, 1) ? (r.chains as number) : null;
2701
2849
  const links = 'links' in r && c.int(`${path}.links`, r.links, 1) ? (r.links as number) : null;
2702
2850
  if ('split' in r) {
2703
- const sp = c.object(`${path}.split`, r.split, ['at', 'into'], []);
2851
+ const sp = c.object(`${path}.split`, r.split, ['at', 'into'], ['hold_tip']);
2704
2852
  if (sp !== null) {
2705
2853
  const sAt = 'at' in sp && c.int(`${path}.split.at`, sp.at, 1) ? (sp.at as number) : null;
2706
2854
  if ('into' in sp) c.int(`${path}.split.into`, sp.into, 2);
2855
+ if ('hold_tip' in sp && typeof sp.hold_tip !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${path}.split.hold_tip`, `is ${show(sp.hold_tip)}; true or false is required`);
2707
2856
  if (sAt !== null && links !== null && sAt >= links) {
2708
2857
  c.fail('CONFIG_CLOTH_SPLIT_BEFORE_TIP', `${path}.split.at`, `is ${sAt} and links is ${links}; the trunk's ${sAt} link(s) would leave no link for the branches — split.at below links is required`);
2709
2858
  }
@@ -2750,7 +2899,7 @@ function checkCloth(c: Check, v: Json): void {
2750
2899
  }
2751
2900
  if ('part' in r) {
2752
2901
  if (tag !== 'bottomwear') {
2753
- c.fail('CONFIG_CLOTH_PART_ON_BOTTOMWEAR', `${path}.part`, `is declared on "${tag}"; part hands the skirt's recipe (bottomwear) to a topwear part that holds a skirt (a dress) — leave it out here`);
2902
+ c.fail('CONFIG_CLOTH_PART_ON_BOTTOMWEAR', `${path}.part`, `is declared on "${tag}"; part hands the skirt's recipe (bottomwear) to a topwear part or its cut skirt piece (a dress) — leave it out here`);
2754
2903
  } else c.string(`${path}.part`, r.part);
2755
2904
  }
2756
2905
  if ('swing' in r) {
package/src/headbox.ts CHANGED
@@ -42,6 +42,20 @@ export const HEAD_LAYER_MIN_PX = 150;
42
42
 
43
43
  export type Box = [number, number, number, number];
44
44
 
45
+ export interface HeadBoxOptions {
46
+ /** Whether `"back hair"` is included in the union (issue #351); default `true`. */
47
+ backHair?: boolean;
48
+ }
49
+
50
+ export interface HeadBoxWithoutBackHair {
51
+ /** The square box's side in source px without `"back hair"`. */
52
+ edge: number;
53
+ /** The box without `"back hair"` before clamping to the painting. */
54
+ unclamped: Box;
55
+ /** The box without `"back hair"` held inside the painting, or `null` when `edge` exceeds a canvas side. */
56
+ head_box: Box | null;
57
+ }
58
+
45
59
  export interface HeadBoxProposal {
46
60
  /** The square box, inside the painting: what goes into `seethrough.head_box`. */
47
61
  head_box: Box;
@@ -49,8 +63,10 @@ export interface HeadBoxProposal {
49
63
  unclamped: Box;
50
64
  /** How far the box was moved to stay inside, in source px. */
51
65
  shift: [number, number];
52
- /** The layers that contributed, with their opaque pixel counts after the clean-up. */
66
+ /** The layers that contributed, with their opaque pixel counts after the ghost clean-up. */
53
67
  layers: Array<[string, number]>;
68
+ /** Present when `"back hair"` contributed (with `backHair !== false`, issue #351): the box without `"back hair"`. */
69
+ without_back_hair?: HeadBoxWithoutBackHair;
54
70
  }
55
71
 
56
72
  /**
@@ -90,7 +106,7 @@ function cleanedOpaque(pixels: { width: number; height: number; data: Uint8Clamp
90
106
  * `source.w` x `source.h` pixels. Every refusal names the value found and the
91
107
  * value required.
92
108
  */
93
- export function proposeHeadBox(full: LayerSet, source: { w: number; h: number }): HeadBoxProposal {
109
+ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number }, opts?: HeadBoxOptions): HeadBoxProposal {
94
110
  const problems: Problem[] = [];
95
111
  if (!(Number.isInteger(source.w) && Number.isInteger(source.h) && source.w > 0 && source.h > 0)) {
96
112
  problems.push({ code: 'HEADBOX_CANVAS_SIZE', object: '--canvas', detail: `is ${source.w}x${source.h}; the painting's size in positive integer pixels is required` });
@@ -102,12 +118,14 @@ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number })
102
118
 
103
119
  const pad = squarePad(source.w, source.h);
104
120
  const k = pad.side / full.canvas.w;
121
+ const includeBackHair = opts?.backHair !== false;
105
122
  // The union does not depend on the order the layers are read in; a name
106
123
  // that appears twice is last-wins in draw order (the reference's dict was
107
124
  // last-wins in manifest order, and no wrapper output read so far repeats one).
108
125
  const boxes = new Map<string, { left: number; top: number; xs: number[]; ys: number[] }>();
109
126
  for (const L of full.layers) {
110
127
  if (!(HEAD_LAYERS as readonly string[]).includes(L.name)) continue;
128
+ if (!includeBackHair && L.name === 'back hair') continue;
111
129
  const { xs, ys } = cleanedOpaque(L.pixels);
112
130
  if (xs.length < HEAD_LAYER_MIN_PX) continue;
113
131
  boxes.set(L.name, { left: L.left, top: L.top, xs, ys });
@@ -130,44 +148,60 @@ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number })
130
148
  if (f.top + y > fy1) fy1 = f.top + y;
131
149
  }
132
150
  const cut = fy1 + 0.35 * (fy1 - fy0);
133
- let x0 = Infinity;
134
- let x1 = -Infinity;
135
- let y0 = Infinity;
136
- let y1 = -Infinity;
137
- for (const b of boxes.values()) {
138
- for (let i = 0; i < b.xs.length; i++) {
139
- const y = b.top + b.ys[i];
140
- if (y > cut) continue;
141
- const x = b.left + b.xs[i];
142
- if (x < x0) x0 = x;
143
- if (x > x1) x1 = x;
144
- if (y < y0) y0 = y;
145
- if (y > y1) y1 = y;
151
+ const measureUnion = (entries: Iterable<{ left: number; top: number; xs: number[]; ys: number[] }>): { edge: number; unclamped: Box; shift: [number, number]; head_box: Box | null } => {
152
+ let x0 = Infinity;
153
+ let x1 = -Infinity;
154
+ let y0 = Infinity;
155
+ let y1 = -Infinity;
156
+ for (const b of entries) {
157
+ for (let i = 0; i < b.xs.length; i++) {
158
+ const y = b.top + b.ys[i];
159
+ if (y > cut) continue;
160
+ const x = b.left + b.xs[i];
161
+ if (x < x0) x0 = x;
162
+ if (x > x1) x1 = x;
163
+ if (y < y0) y0 = y;
164
+ if (y > y1) y1 = y;
165
+ }
146
166
  }
147
- }
148
- const size = Math.max(x1 - x0, y1 - y0) * 1.12;
149
- const cx = (x0 + x1) / 2;
150
- const cy = (y0 + y1) / 2;
151
- // Each edge rounded as the reference rounds it; then the side is the larger
152
- // of the two rounded extents, so the box is square, never holds less than
153
- // the reference's, and equals it wherever the reference's was square.
154
- const ex = [pyRound((cx - size / 2) * k), pyRound((cx + size / 2) * k)];
155
- const ey = [pyRound((cy - size / 2) * k), pyRound((cy + size / 2) * k)];
156
- const edge = Math.max(ex[1] - ex[0], ey[1] - ey[0]);
157
- const bx = ex[0] - pad.left;
158
- const by = ey[0] - pad.top;
159
- const unclamped: Box = [bx, by, bx + edge, by + edge];
160
- if (edge > source.w || edge > source.h) {
167
+ const size = Math.max(x1 - x0, y1 - y0) * 1.12;
168
+ const cx = (x0 + x1) / 2;
169
+ const cy = (y0 + y1) / 2;
170
+ // Each edge rounded as the reference rounds it; then the side is the larger
171
+ // of the two rounded extents, so the box is square, never holds less than
172
+ // the reference's, and equals it wherever the reference's was square.
173
+ const ex = [pyRound((cx - size / 2) * k), pyRound((cx + size / 2) * k)];
174
+ const ey = [pyRound((cy - size / 2) * k), pyRound((cy + size / 2) * k)];
175
+ const edge = Math.max(ex[1] - ex[0], ey[1] - ey[0]);
176
+ const bx = ex[0] - pad.left;
177
+ const by = ey[0] - pad.top;
178
+ const unclamped: Box = [bx, by, bx + edge, by + edge];
179
+ if (edge > source.w || edge > source.h) return { edge, unclamped, shift: [0, 0], head_box: null };
180
+ const hold = (lo: number, extent: number): number => (lo < 0 ? -lo : lo + edge > extent ? extent - edge - lo : 0);
181
+ const shift: [number, number] = [hold(bx, source.w), hold(by, source.h)];
182
+ const head_box: Box = [bx + shift[0], by + shift[1], bx + shift[0] + edge, by + shift[1] + edge];
183
+ return { edge, unclamped, shift, head_box };
184
+ };
185
+ const main = measureUnion(boxes.values());
186
+ const without = includeBackHair && boxes.has('back hair') ? measureUnion([...boxes.entries()].filter(([n]) => n !== 'back hair').map(([, b]) => b)) : null;
187
+ if (main.head_box === null) {
188
+ const hairNote =
189
+ without !== null && without.edge < main.edge
190
+ ? ` ("back hair" grew the side from ${without.edge} to ${main.edge} px; without it${without.head_box !== null ? `, [${without.head_box.join(', ')}] (${without.edge}x${without.edge}) fits —` : ''} pass --no-back-hair)`
191
+ : '';
161
192
  refuseIfAny([
162
193
  {
163
194
  code: 'HEADBOX_FITS_CANVAS',
164
195
  object: 'head box',
165
- detail: `is ${edge}x${edge} source px, proposed at [${unclamped.join(', ')}]; the ${source.w}x${source.h} painting cannot hold it at that size, and shrinking it would cut the head`,
196
+ detail: `is ${main.edge}x${main.edge} source px, proposed at [${main.unclamped.join(', ')}]; the ${source.w}x${source.h} painting cannot hold it at that size, and shrinking it would cut the head${hairNote}`,
166
197
  },
167
198
  ]);
168
199
  }
169
- const hold = (lo: number, extent: number): number => (lo < 0 ? -lo : lo + edge > extent ? extent - edge - lo : 0);
170
- const shift: [number, number] = [hold(bx, source.w), hold(by, source.h)];
171
- const head_box: Box = [bx + shift[0], by + shift[1], bx + shift[0] + edge, by + shift[1] + edge];
172
- return { head_box, unclamped, shift, layers: [...boxes.entries()].map(([n, b]) => [n, b.xs.length]) };
200
+ return {
201
+ head_box: main.head_box as Box,
202
+ unclamped: main.unclamped,
203
+ shift: main.shift,
204
+ layers: [...boxes.entries()].map(([n, b]) => [n, b.xs.length]),
205
+ ...(without === null ? {} : { without_back_hair: { edge: without.edge, unclamped: without.unclamped, head_box: without.head_box } }),
206
+ };
173
207
  }
package/src/limbs.ts CHANGED
@@ -81,6 +81,17 @@
81
81
  * cap's disc and the limb's width stay where the bone's origin puts them;
82
82
  * only the line that splits the pixels between the two pieces moves, and
83
83
  * the cap takes the disc's pixels on the upper side of that line.
84
+ * - **Overlapping hands** (issue #400, {@link assignOverlappingHands}). When
85
+ * two forearm segments (`joint: "elbow"`, with a wrist end) share a part,
86
+ * each hand's keypoint region is the disc of radius half its forearm's
87
+ * length (`L / 2`) about its wrist, and its cuff is the line across the
88
+ * forearm at `L - L / 2 = L / 2` from the elbow, where that disc meets the
89
+ * bone's axis. When the two hand discs overlap, the overlapping hand region
90
+ * goes to the front piece (the limb `config.limbs` declares later, which
91
+ * `limbOrder` draws in front) in one piece, and the back piece ends at its
92
+ * cuff; any 4-connected island on a forearm piece that leaves its own hand's
93
+ * keypoint region is transferred to the other forearm piece, so no slivers
94
+ * are cut out of the front hand for the back arm.
84
95
  */
85
96
  import type { BoneEntry, Limb } from './config.ts';
86
97
 
@@ -429,3 +440,163 @@ export function capBox(at: Pt, radius: number): [number, number, number, number]
429
440
  export function inDisc(at: Pt, radius: number, px: number, py: number): boolean {
430
441
  return (px + 0.5 - at[0]) ** 2 + (py + 0.5 - at[1]) ** 2 <= radius * radius;
431
442
  }
443
+
444
+ /** A forearm segment on a shared part, for {@link assignOverlappingHands} (issue #400). */
445
+ export interface ForearmShare {
446
+ /** The limb's index in `Object.keys(limbs)`. */
447
+ limb: number;
448
+ /** The forearm segment's index in `limbs[limb].segments`. */
449
+ seg: number;
450
+ /** The elbow bone origin, in rig px. */
451
+ elbow: Pt;
452
+ /** The wrist bone end (`tip`), in rig px. */
453
+ wrist: Pt;
454
+ /** Whether pixel `(x, y)` on `limb` falls on this forearm segment (`segmentOf === seg`). */
455
+ isSeg: (x: number, y: number) => boolean;
456
+ }
457
+
458
+ /**
459
+ * Issue #400: when two forearm segments (`back` and `front`, in `config.limbs`
460
+ * order so `front` is drawn over `back`) share a part, each hand's keypoint
461
+ * region is the disc of radius `r = L / 2` about its wrist (`L` the forearm
462
+ * bone's length), and `back`'s cuff is the line across the forearm at
463
+ * `L - r = L / 2` from `back.elbow`. When the two wrist discs overlap, `back`'s
464
+ * pixels past its cuff (or inside `front`'s hand disc) go to `front`'s forearm
465
+ * in one piece so `back` ends at its cuff; and any 4-connected island on a
466
+ * forearm piece that leaves its own hand's keypoint region goes to the other
467
+ * forearm piece. Mutates `own` and `segOverride` in place.
468
+ */
469
+ export function assignOverlappingHands(back: ForearmShare, front: ForearmShare, W: number, H: number, own: Int32Array, segOverride: Int32Array): void {
470
+ const dx0 = back.wrist[0] - back.elbow[0];
471
+ const dy0 = back.wrist[1] - back.elbow[1];
472
+ const L0 = Math.sqrt(dx0 * dx0 + dy0 * dy0);
473
+ const dx1 = front.wrist[0] - front.elbow[0];
474
+ const dy1 = front.wrist[1] - front.elbow[1];
475
+ const L1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
476
+ if (L0 === 0 || L1 === 0) return;
477
+ const r0 = L0 / 2;
478
+ const r1 = L1 / 2;
479
+ const u0x = dx0 / L0;
480
+ const u0y = dy0 / L0;
481
+ const cuff0 = L0 - r0;
482
+ const proj0 = (x: number, y: number): number => (x + 0.5 - back.elbow[0]) * u0x + (y + 0.5 - back.elbow[1]) * u0y;
483
+ const onSeg = (f: ForearmShare, p: number): boolean => own[p] === f.limb && (segOverride[p] === f.seg || (segOverride[p] === -1 && f.isSeg(p % W, Math.floor(p / W))));
484
+ const wristsOverlap = (back.wrist[0] - front.wrist[0]) ** 2 + (back.wrist[1] - front.wrist[1]) ** 2 <= (r0 + r1) ** 2;
485
+ if (wristsOverlap) {
486
+ let cuffHasArt = false;
487
+ for (let y = 0; y < H && !cuffHasArt; y++) {
488
+ for (let x = 0; x < W && !cuffHasArt; x++) {
489
+ if (onSeg(back, y * W + x) && proj0(x, y) <= cuff0) cuffHasArt = true;
490
+ }
491
+ }
492
+ if (cuffHasArt) {
493
+ for (let y = 0; y < H; y++) {
494
+ for (let x = 0; x < W; x++) {
495
+ const p = y * W + x;
496
+ if (onSeg(back, p) && (proj0(x, y) > cuff0 || inDisc(front.wrist, r1, x, y)) && !inDisc(back.elbow, r0 / 2, x, y)) {
497
+ own[p] = front.limb;
498
+ segOverride[p] = front.seg;
499
+ } else if (own[p] === back.limb && inDisc(front.wrist, r1, x, y) && !inDisc(back.elbow, r0, x, y)) {
500
+ own[p] = front.limb;
501
+ segOverride[p] = front.seg;
502
+ }
503
+ }
504
+ }
505
+ }
506
+ }
507
+ const componentsOf = (f: ForearmShare): number[][] => {
508
+ const seen = new Uint8Array(W * H);
509
+ const comps: number[][] = [];
510
+ for (let p = 0; p < W * H; p++) {
511
+ if (seen[p] === 1 || !onSeg(f, p)) continue;
512
+ seen[p] = 1;
513
+ const comp: number[] = [p];
514
+ for (let h = 0; h < comp.length; h++) {
515
+ const q = comp[h];
516
+ const x = q % W;
517
+ const y = (q - x) / W;
518
+ for (const [nx, ny] of [
519
+ [x - 1, y],
520
+ [x + 1, y],
521
+ [x, y - 1],
522
+ [x, y + 1],
523
+ ]) {
524
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
525
+ const np = ny * W + nx;
526
+ if (seen[np] === 0 && onSeg(f, np)) {
527
+ seen[np] = 1;
528
+ comp.push(np);
529
+ }
530
+ }
531
+ }
532
+ comps.push(comp);
533
+ }
534
+ return comps;
535
+ };
536
+ const elbowDist2 = (comp: readonly number[], elbow: Pt): number => {
537
+ let best = Infinity;
538
+ for (const p of comp) {
539
+ const x = p % W;
540
+ const y = (p - x) / W;
541
+ const d = (x + 0.5 - elbow[0]) ** 2 + (y + 0.5 - elbow[1]) ** 2;
542
+ if (d < best) best = d;
543
+ }
544
+ return best;
545
+ };
546
+ const backComps = componentsOf(back);
547
+ if (backComps.length > 1) {
548
+ let main = 0;
549
+ let mainD = elbowDist2(backComps[0], back.elbow);
550
+ for (let i = 1; i < backComps.length; i++) {
551
+ const d = elbowDist2(backComps[i], back.elbow);
552
+ if (d < mainD) {
553
+ mainD = d;
554
+ main = i;
555
+ }
556
+ }
557
+ backComps.forEach((comp, i) => {
558
+ if (i === main) return;
559
+ const leavesBack = comp.some((p) => !inDisc(back.wrist, r0, p % W, Math.floor(p / W)));
560
+ const inFront = comp.some((p) => inDisc(front.wrist, r1, p % W, Math.floor(p / W)));
561
+ if (wristsOverlap || leavesBack || inFront) {
562
+ for (const p of comp) {
563
+ own[p] = front.limb;
564
+ segOverride[p] = front.seg;
565
+ }
566
+ }
567
+ });
568
+ }
569
+ const frontComps = componentsOf(front);
570
+ if (frontComps.length > 1) {
571
+ let main = 0;
572
+ let mainD = elbowDist2(frontComps[0], front.elbow);
573
+ for (let i = 1; i < frontComps.length; i++) {
574
+ const d = elbowDist2(frontComps[i], front.elbow);
575
+ if (d < mainD) {
576
+ mainD = d;
577
+ main = i;
578
+ }
579
+ }
580
+ frontComps.forEach((comp, i) => {
581
+ if (i === main) return;
582
+ const leavesFront = comp.some((p) => !inDisc(front.wrist, r1, p % W, Math.floor(p / W)));
583
+ const touchesBack = comp.some((p) => {
584
+ const x = p % W;
585
+ const y = (p - x) / W;
586
+ return (
587
+ (x > 0 && onSeg(back, p - 1)) ||
588
+ (x + 1 < W && onSeg(back, p + 1)) ||
589
+ (y > 0 && onSeg(back, p - W)) ||
590
+ (y + 1 < H && onSeg(back, p + W))
591
+ );
592
+ });
593
+ if (leavesFront && touchesBack) {
594
+ for (const p of comp) {
595
+ own[p] = back.limb;
596
+ segOverride[p] = back.seg;
597
+ }
598
+ }
599
+ });
600
+ }
601
+ }
602
+