@rig-c/parts 1.35.0 → 1.37.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/build.ts CHANGED
@@ -78,6 +78,7 @@ import { encodeGif } from './gif.ts';
78
78
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
79
79
  import type { PaletteError } from './palette.ts';
80
80
  import { type LayerSet, readLayers } from './layers.ts';
81
+ import { poseBoneEntriesForDrawn } from './limbs.ts';
81
82
  import { type AlternatePartsRecord, type PartRecord, readParts, type RecompositeRecord, type SingleRunPad, tagOf, writeParts } from './parts.ts';
82
83
  import { encodePngBytes, readPng, writePng } from './raster/png.ts';
83
84
  import type { Raster } from './raster/types.ts';
@@ -444,24 +445,69 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
444
445
  const altCuts: Cut[] = rawCuts.map((c) => ({ ...c, draw: remapDraw(c.draw) }));
445
446
  const altUnder = alt.cuts !== undefined ? readUnders(input.config, altCuts) : altCuts.map(() => null);
446
447
  const altHeadBox = alt.head_box ?? (altRuns.single ? singleRunHeadBox(altSrc.width, altSrc.height, fields.resolution) : fields.headBox);
447
- const altRes = assemble({
448
- source: altSrc,
449
- full: altRuns.full,
450
- head: altRuns.head,
451
- resolution: fields.resolution,
452
- headBox: altHeadBox,
453
- rigScale: fields.rigScale,
454
- plan: altPlan,
455
- extend: altExtend,
456
- patches: altPatches,
457
- cuts: altCuts,
458
- under: altUnder,
459
- ...(Object.keys(altLimbs).length > 0 ? { limbs: altLimbs, bones: fields.bones } : {}),
460
- ...(fields.scene === undefined ? {} : { scene: fields.scene }),
461
- ...(fields.armSplit !== undefined && altPlan.some((e) => e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l')) ? { armSplit: fields.armSplit } : {}),
462
- seamRule: input.seam,
463
- projectRule: input.project,
464
- });
448
+ let altBones = fields.bones;
449
+ if (Object.keys(altLimbs).length > 0 && fields.bones !== undefined && (altSkeleton !== undefined || alt.drawn !== undefined)) {
450
+ const coveredLimbBones = new Set(Object.values(altLimbs).flatMap((l) => l.segments.map((sg) => sg.bone)));
451
+ const limbDrawn: Record<string, number> = {};
452
+ if (altSkeleton !== undefined) {
453
+ const deg = (a: readonly [number, number], b: readonly [number, number]): number =>
454
+ (Math.atan2(-(b[1] - a[1]), b[0] - a[0]) * 180) / Math.PI;
455
+ const skPairs: ReadonlyArray<readonly [string, readonly [number, number], readonly [number, number]]> = [
456
+ ['sleeve_r0', altSkeleton.r_shoulder, altSkeleton.r_elbow],
457
+ ['sleeve_r1', altSkeleton.r_elbow, altSkeleton.r_wrist],
458
+ ['arm_r0', altSkeleton.r_shoulder, altSkeleton.r_elbow],
459
+ ['arm_r1', altSkeleton.r_elbow, altSkeleton.r_wrist],
460
+ ['sleeve_l0', altSkeleton.l_shoulder, altSkeleton.l_elbow],
461
+ ['sleeve_l1', altSkeleton.l_elbow, altSkeleton.l_wrist],
462
+ ['arm_l0', altSkeleton.l_shoulder, altSkeleton.l_elbow],
463
+ ['arm_l1', altSkeleton.l_elbow, altSkeleton.l_wrist],
464
+ ['leg_r0', altSkeleton.r_hip, altSkeleton.r_knee],
465
+ ['leg_r1', altSkeleton.r_knee, altSkeleton.r_ankle],
466
+ ['leg_l0', altSkeleton.l_hip, altSkeleton.l_knee],
467
+ ['leg_l1', altSkeleton.l_knee, altSkeleton.l_ankle],
468
+ ];
469
+ for (const [bName, a, b] of skPairs) {
470
+ if (alt.parts === undefined || coveredLimbBones.has(bName)) limbDrawn[bName] = deg(a, b);
471
+ }
472
+ }
473
+ if (alt.drawn !== undefined) {
474
+ for (const [bName, degVal] of Object.entries(alt.drawn)) limbDrawn[bName] = degVal;
475
+ }
476
+ altBones = poseBoneEntriesForDrawn(fields.bones, limbDrawn);
477
+ }
478
+ let altRes: ReturnType<typeof assemble>;
479
+ try {
480
+ altRes = assemble({
481
+ source: altSrc,
482
+ full: altRuns.full,
483
+ head: altRuns.head,
484
+ resolution: fields.resolution,
485
+ headBox: altHeadBox,
486
+ rigScale: fields.rigScale,
487
+ plan: altPlan,
488
+ extend: altExtend,
489
+ patches: altPatches,
490
+ cuts: altCuts,
491
+ under: altUnder,
492
+ ...(Object.keys(altLimbs).length > 0 ? { limbs: altLimbs, bones: altBones } : {}),
493
+ ...(fields.scene === undefined ? {} : { scene: fields.scene }),
494
+ ...(fields.armSplit !== undefined && altPlan.some((e) => e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l')) ? { armSplit: fields.armSplit } : {}),
495
+ ...(fields.headFaceUnderfill === undefined ? {} : { headFaceUnderfill: fields.headFaceUnderfill }),
496
+ seamRule: input.seam,
497
+ projectRule: input.project,
498
+ });
499
+ } catch (err) {
500
+ if (err instanceof PartsError) {
501
+ for (const p of err.problems) {
502
+ altProblems.push({
503
+ ...p,
504
+ object: `${p.object} (alternate set "${alt.name}")`,
505
+ });
506
+ }
507
+ continue;
508
+ }
509
+ throw err;
510
+ }
465
511
  const keptImages = altRes.images
466
512
  .filter(({ record }) => coveredSet.has(record.name))
467
513
  .map(({ record, image }) => ({ name: `${alt.name}_${record.name}`, image }));
@@ -524,10 +570,11 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
524
570
  const pad = result.parts.single_run_pad;
525
571
  if (pad !== undefined) log(singleRunPadLine(input.full, pad));
526
572
  for (const p of result.parts.parts) {
573
+ const underfillSuffix = p.from === 'head:face' && (result.headFaceUnderfillPx ?? 0) > 0 ? ` underfill=${result.headFaceUnderfillPx}px(full:face)` : '';
527
574
  log(
528
575
  ` ${p.name.padEnd(11)} ${p.from.padEnd(16)} ${`${p.w}x${p.h}`.padEnd(9)} @${String(p.x).padStart(4)},${String(p.y).padStart(4)} ` +
529
576
  `op=${String(p.opaque_px).padStart(6)} vis=${String(p.visible_px).padStart(6)} src=${String(p.source_px_taken).padStart(6)}/${String(p.projected_core_px).padStart(6)} ` +
530
- `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}`,
577
+ `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}${underfillSuffix}`,
531
578
  );
532
579
  }
533
580
  // Issue #256: one line per part from an author-made layer, so it never reads as See-through's. None without one.
package/src/config.ts CHANGED
@@ -92,7 +92,7 @@ import { CLOTH_TARGETS, type ClothRecipes, RECIPE_STRENGTH, RIGC_DEFAULT_DAMPING
92
92
  import { GRID, MAX_SIDE } from './contour.ts';
93
93
  import { type Problem, refuseIfAny } from './errors.ts';
94
94
  import { edgeLength } from './length.ts';
95
- import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
95
+ import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, scaledPoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
96
96
  import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
97
97
  import { EAR_DEPTH_RANGE, FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, FACE_RATED_PITCH_BAND, FACE_RATED_YAW_BAND, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
98
98
  import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
@@ -267,6 +267,8 @@ export interface Assemble {
267
267
  patches?: Patch[];
268
268
  cuts?: Cut[];
269
269
  alternates?: AlternateSetConfig[];
270
+ /** Issue #492: composite full:face under head:face on the head canvas to fill transparent holes inside the face silhouette. */
271
+ head_face_underfill?: boolean;
270
272
  }
271
273
 
272
274
  export interface SingleBone {
@@ -1140,7 +1142,7 @@ export interface PaintConfig {
1140
1142
  export interface EarlyConfig {
1141
1143
  key: string;
1142
1144
  seethrough: SeeThrough;
1143
- assemble: { rig_scale: number };
1145
+ assemble: { rig_scale: number; head_face_underfill?: boolean };
1144
1146
  }
1145
1147
 
1146
1148
  /** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
@@ -1199,8 +1201,13 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1199
1201
  if (door === 'layers') {
1200
1202
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1201
1203
  if ('assemble' in t) {
1202
- const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
1203
- if (a !== null && 'rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
1204
+ const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill']);
1205
+ if (a !== null) {
1206
+ if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
1207
+ if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
1208
+ c.fail('CONFIG_FIELD_TYPE', 'config.assemble.head_face_underfill', `is ${show(a.head_face_underfill)}; true or false is required`);
1209
+ }
1210
+ }
1204
1211
  }
1205
1212
  }
1206
1213
  if (door === 'assemble') {
@@ -1210,19 +1217,23 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1210
1217
  if ('scene' in t) checkScene(c, t.scene);
1211
1218
  // Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
1212
1219
  let limbs: LimbNames | null = null;
1220
+ let names: { bones: Set<string>; chains: Map<string, number>; parents: Map<string, string> } | null = null;
1213
1221
  if ('limbs' in t) {
1214
1222
  if (!('bones' in t)) c.fail('CONFIG_FIELD_PRESENT', 'config.bones', 'is absent; config.limbs names bones, and each joint is its bone\'s origin, so assemble reads the bones with the limbs');
1215
- const names = 'bones' in t ? checkBones(c, t.bones) : null;
1223
+ names = 'bones' in t ? checkBones(c, t.bones) : null;
1216
1224
  limbs = checkLimbs(c, t.limbs, made, names);
1217
1225
  }
1218
1226
  if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1219
- checkAlternates(c, t.assemble.alternates, made, limbs, null);
1227
+ checkAlternates(c, t.assemble.alternates, made, limbs, names?.bones ?? null, names?.chains ?? null, t.regions, t.meshes);
1220
1228
  }
1221
1229
  }
1222
1230
  if ('cloth' in t) checkCloth(c, t.cloth);
1223
1231
  const pose = base === null ? null : poseBeside(c, t.generation, base);
1224
1232
  refuseIfAny(c.problems);
1225
- return { config: raw as PaintConfig | EarlyConfig | AssembleConfig, pose };
1233
+ const config = raw as PaintConfig | EarlyConfig | AssembleConfig;
1234
+ const controlSkel = resolveControlSkeleton(t.generation, pose);
1235
+ if (controlSkel !== undefined) controlSkeletons.set(config, controlSkel);
1236
+ return { config, pose };
1226
1237
  }
1227
1238
 
1228
1239
  export function loadEarlyConfig(path: string, door: 'paint'): PaintConfig;
@@ -1242,6 +1253,40 @@ export function loadPaintConfig(path: string): { config: PaintConfig; pose: Cont
1242
1253
  return { config: config as PaintConfig, pose };
1243
1254
  }
1244
1255
 
1256
+ /**
1257
+ * Issue #490: the control skeleton `config.generation.control.skeleton` names,
1258
+ * with its points on the `generation.latent` canvas (`[w, h]`, x right, y down).
1259
+ */
1260
+ export interface ControlSkeleton {
1261
+ latent: readonly [number, number];
1262
+ points: PosePoints;
1263
+ }
1264
+
1265
+ const controlSkeletons = new WeakMap<object, ControlSkeleton>();
1266
+
1267
+ function resolveControlSkeleton(gen: Json, pose: ControlPose | null): ControlSkeleton | undefined {
1268
+ if (!isPlainObject(gen) || !isPlainObject(gen.control)) return undefined;
1269
+ const l = gen.latent;
1270
+ if (!(Array.isArray(l) && l.length === 2 && typeof l[0] === 'number' && typeof l[1] === 'number' && l[0] > 0 && l[1] > 0)) return undefined;
1271
+ const latent: readonly [number, number] = [l[0], l[1]];
1272
+ if (pose !== null) return { latent, points: pose.points };
1273
+ const skel = gen.control.skeleton;
1274
+ if (isSkeletonName(skel)) return { latent, points: scaledPoints(skel, latent[0], latent[1]) };
1275
+ return undefined;
1276
+ }
1277
+
1278
+ /**
1279
+ * Return the {@link ControlSkeleton} resolved for a loaded or parsed config
1280
+ * (`loadEarlyConfig`, `loadConfig`, `parseEarlyConfig`, `parseConfig`), or
1281
+ * computed from an inline `generation.control.skeleton` built-in when present.
1282
+ */
1283
+ export function controlSkeletonOf(cfg: object): ControlSkeleton | undefined {
1284
+ const cached = controlSkeletons.get(cfg);
1285
+ if (cached !== undefined) return cached;
1286
+ const gen = (cfg as { generation?: unknown }).generation;
1287
+ return resolveControlSkeleton(gen, null);
1288
+ }
1289
+
1245
1290
  /** The pose file a checked `generation` block's control names, read relative to `base`; null for a built-in, an absent control, or a block already refused. */
1246
1291
  function poseBeside(c: Check, gen: Json, base: string): ControlPose | null {
1247
1292
  if (!isPlainObject(gen) || !isPlainObject(gen.control)) return null;
@@ -1368,7 +1413,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1368
1413
  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>() };
1369
1414
  const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
1370
1415
  if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1371
- checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs, names.bones);
1416
+ checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs, names.bones, names.chains, t.regions, t.meshes);
1372
1417
  }
1373
1418
  if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
1374
1419
  if ('regions' in t) checkRegions(c, t.regions, names.bones);
@@ -1387,9 +1432,12 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1387
1432
  checkFaceAnimationBand(c, animations, hasAltFace, t.turn, attached);
1388
1433
  checkAnimationSetKeys(c, animations, t.assemble, new Set<string>([...parts, ...patches, ...cuts, ...(limbs?.parts.keys() ?? [])]));
1389
1434
  }
1390
- if (base !== null) poseBeside(c, t.generation, base);
1435
+ const pose = base === null ? null : poseBeside(c, t.generation, base);
1391
1436
  refuseIfAny(c.problems);
1392
- return { config: raw as CharacterConfig, animations };
1437
+ const config = raw as CharacterConfig;
1438
+ const controlSkel = resolveControlSkeleton(t.generation, pose);
1439
+ if (controlSkel !== undefined) controlSkeletons.set(config, controlSkel);
1440
+ return { config, animations };
1393
1441
  }
1394
1442
 
1395
1443
  /**
@@ -1790,13 +1838,16 @@ function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
1790
1838
  */
1791
1839
  function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
1792
1840
  const required = sectionRequired(door, 'assemble');
1793
- const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
1841
+ const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill']);
1794
1842
  if (a === null) return { parts: [], patches: [], cuts: [] };
1795
1843
  if (required.includes('plan') && !('plan' in a)) {
1796
1844
  c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
1797
1845
  }
1798
1846
  const p = 'config.assemble';
1799
1847
  if ('rig_scale' in a) c.number(`${p}.rig_scale`, a.rig_scale, 'positive');
1848
+ if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
1849
+ c.fail('CONFIG_FIELD_TYPE', `${p}.head_face_underfill`, `is ${show(a.head_face_underfill)}; true or false is required`);
1850
+ }
1800
1851
  const parts: string[] = [];
1801
1852
  if ('plan' in a && c.array(`${p}.plan`, a.plan, true)) {
1802
1853
  const seenPart = new Map<string, number>();
@@ -1902,10 +1953,18 @@ function checkAlternates(
1902
1953
  made: { parts: string[]; patches: string[]; cuts: string[] },
1903
1954
  limbs: LimbNames | null,
1904
1955
  bones: Set<string> | null = null,
1956
+ chains: Map<string, number> | null = null,
1957
+ regions: Json = undefined,
1958
+ meshes: Json = undefined,
1905
1959
  ): void {
1906
1960
  const p = 'config.assemble.alternates';
1907
1961
  if (!c.array(p, v, true)) return;
1908
- const allSlots = new Set<string>([...made.parts, ...made.patches, ...made.cuts, ...(limbs?.parts.keys() ?? [])]);
1962
+ const allSlots = new Set<string>([
1963
+ ...made.parts.filter((n) => !(limbs?.consumed.has(n) ?? false)),
1964
+ ...made.patches,
1965
+ ...made.cuts,
1966
+ ...(limbs?.parts.keys() ?? []),
1967
+ ]);
1909
1968
  const seenNames = new Map<string, number>();
1910
1969
  (v as Json[]).forEach((entry, i) => {
1911
1970
  const at = `${p}[${i}]`;
@@ -1934,29 +1993,135 @@ function checkAlternates(
1934
1993
  }
1935
1994
  if ('head_box' in e) checkHeadBox(c, `${at}.head_box`, e.head_box);
1936
1995
  const altCuts = 'cuts' in e ? checkCuts(c, e.cuts, made.parts, [], `${at}.cuts`) : [];
1996
+ let cutsConsumedLimb = false;
1997
+ if (limbs !== null && 'cuts' in e && Array.isArray(e.cuts)) {
1998
+ e.cuts.forEach((ce, ci) => {
1999
+ if (!isPlainObject(ce) || typeof ce.from !== 'string') return;
2000
+ const byLimb = limbs.consumed.get(ce.from);
2001
+ if (byLimb !== undefined) {
2002
+ cutsConsumedLimb = true;
2003
+ const segList = (limbs.chains.get(byLimb)?.parts ?? []).map((s) => `"${s}"`).join(', ');
2004
+ c.fail(
2005
+ 'CONFIG_LIMB_SET_JOINTS',
2006
+ `${at}.cuts[${ci}].from`,
2007
+ `cuts "${ce.from}", which config.limbs.${byLimb} cuts at the base's joints into ${segList}; a gesture set over a limb chain is cut at the base's joints and may not declare its own polygon cuts for that layer`,
2008
+ );
2009
+ }
2010
+ });
2011
+ }
1937
2012
  const validParts = new Set<string>([...allSlots, ...altCuts]);
2013
+ const seenParts = new Set<string>();
1938
2014
  if ('parts' in e && c.array(`${at}.parts`, e.parts, true)) {
1939
- const seen = new Set<string>();
1940
2015
  (e.parts as Json[]).forEach((pn, k) => {
1941
2016
  if (!c.string(`${at}.parts[${k}]`, pn)) return;
1942
- if (!validParts.has(pn)) {
2017
+ const byLimb = limbs?.consumed.get(pn);
2018
+ if (byLimb !== undefined) {
2019
+ const segList = (limbs?.chains.get(byLimb)?.parts ?? []).map((s) => `"${s}"`).join(', ');
2020
+ c.fail(
2021
+ 'CONFIG_LIMB_SET_JOINTS',
2022
+ `${at}.parts[${k}]`,
2023
+ `names "${pn}", which config.limbs.${byLimb} cuts at the base's joints into ${segList}; a gesture set covers the limb chain's segment parts at the base's joints, not the unsplit layer`,
2024
+ );
2025
+ } else if (!validParts.has(pn)) {
1943
2026
  c.fail('CONFIG_NAME_RESOLVES', `${at}.parts[${k}]`, `is ${show(pn)}; a slot of the base rig (or a part made by this alternate's cuts) is required`);
1944
- } else if (seen.has(pn)) {
2027
+ } else if (seenParts.has(pn)) {
1945
2028
  c.fail('CONFIG_PART_UNIQUE', `${at}.parts[${k}]`, `repeats "${pn}"; each covered part is named once`);
1946
- } else seen.add(pn);
2029
+ } else seenParts.add(pn);
1947
2030
  });
1948
2031
  }
2032
+ const seenHides = new Set<string>();
1949
2033
  if ('hides' in e && c.array(`${at}.hides`, e.hides, false)) {
1950
- const seen = new Set<string>();
1951
2034
  (e.hides as Json[]).forEach((hn, k) => {
1952
2035
  if (!c.string(`${at}.hides[${k}]`, hn)) return;
1953
- if (!allSlots.has(hn)) {
2036
+ const byLimb = limbs?.consumed.get(hn);
2037
+ if (byLimb !== undefined) {
2038
+ const segList = (limbs?.chains.get(byLimb)?.parts ?? []).map((s) => `"${s}"`).join(', ');
2039
+ c.fail(
2040
+ 'CONFIG_LIMB_SET_JOINTS',
2041
+ `${at}.hides[${k}]`,
2042
+ `names "${hn}", which config.limbs.${byLimb} cuts at the base's joints into ${segList}; a gesture set covers the limb chain's segment parts at the base's joints, not the unsplit layer`,
2043
+ );
2044
+ } else if (!allSlots.has(hn)) {
1954
2045
  c.fail('CONFIG_NAME_RESOLVES', `${at}.hides[${k}]`, `is ${show(hn)}; a slot of the base rig is required`);
1955
- } else if (seen.has(hn)) {
2046
+ } else if (seenHides.has(hn)) {
1956
2047
  c.fail('CONFIG_PART_UNIQUE', `${at}.hides[${k}]`, `repeats "${hn}"; each hidden slot is named once`);
1957
- } else seen.add(hn);
2048
+ } else seenHides.add(hn);
1958
2049
  });
1959
2050
  }
2051
+ if ('parts' in e && limbs !== null) {
2052
+ for (const [limbName, chain] of limbs.chains) {
2053
+ const coveredSegs = chain.parts.filter((sp) => seenParts.has(sp) && !seenHides.has(sp));
2054
+ const hiddenSegs = chain.parts.filter((sp) => seenHides.has(sp));
2055
+ if ((coveredSegs.length > 0 || hiddenSegs.length > 0) && coveredSegs.length !== chain.parts.length) {
2056
+ c.fail(
2057
+ 'CONFIG_LIMB_SET_JOINTS',
2058
+ `${at}.parts`,
2059
+ `covers [${coveredSegs.map((s) => `"${s}"`).join(', ')}]${hiddenSegs.length > 0 ? ` (and hides [${hiddenSegs.map((s) => `"${s}"`).join(', ')}])` : ''} of config.limbs.${limbName}, which cuts at the base's joints into [${chain.parts.map((s) => `"${s}"`).join(', ')}]; a gesture set over a limb chain must cover all ${chain.parts.length} of its segment parts so it is cut at the base's joints`,
2060
+ );
2061
+ } else if (coveredSegs.length === chain.parts.length && !cutsConsumedLimb && 'cuts' in e && Array.isArray(e.cuts) && e.cuts.length > 0) {
2062
+ c.fail(
2063
+ 'CONFIG_LIMB_SET_JOINTS',
2064
+ `${at}.cuts`,
2065
+ `declares custom polygon cuts on a gesture set covering config.limbs.${limbName}; a gesture set is cut at the base's joints and declares no cuts of its own`,
2066
+ );
2067
+ }
2068
+ }
2069
+ }
2070
+ if ('parts' in e && 'drawn' in e && isPlainObject(e.drawn)) {
2071
+ const activeParts = [...seenParts].filter((pn) => !seenHides.has(pn));
2072
+ const coveredBones = new Set<string>();
2073
+ let allPartsResolved = activeParts.length > 0;
2074
+ for (const pn of activeParts) {
2075
+ const limbSeg = limbs?.parts.get(pn);
2076
+ if (limbSeg !== undefined) {
2077
+ coveredBones.add(limbSeg.bone);
2078
+ } else if (isPlainObject(regions) && typeof regions[pn] === 'string') {
2079
+ coveredBones.add(regions[pn]);
2080
+ } else if (isPlainObject(meshes) && isPlainObject(meshes[pn])) {
2081
+ const m = meshes[pn] as Record<string, Json>;
2082
+ if (Array.isArray(m.segments)) {
2083
+ for (const s of m.segments) {
2084
+ if (typeof s === 'string') {
2085
+ const links = chains?.get(s);
2086
+ if (links !== undefined) {
2087
+ for (let lk = 0; lk < links; lk++) coveredBones.add(`${s}${lk}`);
2088
+ } else {
2089
+ coveredBones.add(s);
2090
+ }
2091
+ } else if (Array.isArray(s) && typeof s[0] === 'string') {
2092
+ coveredBones.add(s[0]);
2093
+ }
2094
+ }
2095
+ }
2096
+ if (isPlainObject(m.contour)) {
2097
+ if (Array.isArray(m.contour.regions)) {
2098
+ for (const rg of m.contour.regions) if (isPlainObject(rg) && typeof rg.bone === 'string') coveredBones.add(rg.bone);
2099
+ }
2100
+ if (isPlainObject(m.contour.bust)) {
2101
+ coveredBones.add(typeof m.contour.bust.bone_r === 'string' ? m.contour.bust.bone_r : DEFAULT_BUST_BONE_R);
2102
+ coveredBones.add(typeof m.contour.bust.bone_l === 'string' ? m.contour.bust.bone_l : DEFAULT_BUST_BONE_L);
2103
+ }
2104
+ }
2105
+ if (isPlainObject(m.auto) && Array.isArray(m.auto.regions)) {
2106
+ for (const rg of m.auto.regions) if (isPlainObject(rg) && typeof rg.bone === 'string') coveredBones.add(rg.bone);
2107
+ }
2108
+ } else {
2109
+ allPartsResolved = false;
2110
+ }
2111
+ }
2112
+ if (allPartsResolved) {
2113
+ const coveredList = [...coveredBones].map((b) => `"${b}"`).join(', ') || 'none';
2114
+ for (const bName of Object.keys(e.drawn)) {
2115
+ if ((bones === null || bones.has(bName)) && !coveredBones.has(bName)) {
2116
+ c.fail(
2117
+ 'CONFIG_SET_COVERS_BONE',
2118
+ `${at}.drawn.${bName}`,
2119
+ `names "${bName}", which alternate set "${String(e.name ?? '')}" does not cover (covered bones: ${coveredList}); a subset set may state drawn angles only for the bones its covered parts ride`,
2120
+ );
2121
+ }
2122
+ }
2123
+ }
2124
+ }
1960
2125
  });
1961
2126
  }
1962
2127
 
@@ -2593,6 +2758,7 @@ function checkRegions(c: Check, v: Json, bones: Set<string>): void {
2593
2758
  interface LimbNames {
2594
2759
  parts: Map<string, { bone: string; at: string }>;
2595
2760
  consumed: Map<string, string>;
2761
+ chains: Map<string, { parts: string[]; bones: string[]; froms: string[] }>;
2596
2762
  }
2597
2763
 
2598
2764
  /**
@@ -2618,7 +2784,7 @@ interface LimbNames {
2618
2784
  * resolved. Returns the limb parts and the parts the limbs take.
2619
2785
  */
2620
2786
  function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[]; cuts: string[] }, names: { bones: Set<string>; parents: Map<string, string> } | null): LimbNames {
2621
- const out: LimbNames = { parts: new Map(), consumed: new Map() };
2787
+ const out: LimbNames = { parts: new Map(), consumed: new Map(), chains: new Map() };
2622
2788
  if (!isPlainObject(v)) {
2623
2789
  c.fail('CONFIG_FIELD_TYPE', 'config.limbs', `is ${show(v)}; an object of limb name -> {"segments": [...]} is required`);
2624
2790
  return out;
@@ -2663,11 +2829,13 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2663
2829
  continue;
2664
2830
  }
2665
2831
  const segs = o.segments as Json[];
2832
+ const segParts: Array<string | null> = [];
2666
2833
  const froms: Array<string | null> = [];
2667
2834
  const bones: Array<string | null> = [];
2668
2835
  segs.forEach((entry, i) => {
2669
2836
  const sat = `${at}.segments[${i}]`;
2670
2837
  const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap', 'edge', 'cap_shape', 'cut_band']);
2838
+ segParts.push(null);
2671
2839
  froms.push(null);
2672
2840
  bones.push(null);
2673
2841
  if (e === null) return;
@@ -2683,6 +2851,7 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2683
2851
  } else if (firstAt.has(name)) c.fail('CONFIG_PART_UNIQUE', `${sat}.part`, `names the part "${name}" again (first at ${firstAt.get(name)})`);
2684
2852
  else {
2685
2853
  firstAt.set(name, sat);
2854
+ segParts[i] = name;
2686
2855
  if ('bone' in e && typeof e.bone === 'string') out.parts.set(name, { bone: e.bone, at: `config.limbs.${limb} segment ${i}` });
2687
2856
  }
2688
2857
  }
@@ -2727,6 +2896,11 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2727
2896
  }
2728
2897
  }
2729
2898
  });
2899
+ out.chains.set(limb, {
2900
+ parts: segParts.filter((q): q is string => q !== null),
2901
+ bones: bones.filter((q): q is string => q !== null),
2902
+ froms: [...new Set(froms.filter((q): q is string => q !== null))],
2903
+ });
2730
2904
  limbFroms.set(limb, new Set(froms.filter((f): f is string => f !== null)));
2731
2905
  froms.forEach((f, i) => {
2732
2906
  if (f === null || i === 0 || froms[i - 1] === f) return;
package/src/limbs.ts CHANGED
@@ -125,6 +125,98 @@ export function bonePlaces(bones: readonly BoneEntry[]): Map<string, BonePlace>
125
125
  return out;
126
126
  }
127
127
 
128
+ /**
129
+ * Issue #476: pose `bones` (in rig pixels, y down) so every bone named in
130
+ * `drawn` takes that world angle (in Spine degrees, CCW, y up) while keeping
131
+ * its base length and parent-to-child joint offsets. Used by `assemble` when
132
+ * an alternate set covers a limb chain at its own `drawn` angles, so `cutLimbs`
133
+ * cuts the set's painting at the base's joints posed to `drawn`.
134
+ */
135
+ export function poseBoneEntriesForDrawn(bones: readonly BoneEntry[], drawn: Readonly<Record<string, number>>): BoneEntry[] {
136
+ if (Object.keys(drawn).length === 0) return [...bones];
137
+ interface FlatNode {
138
+ name: string;
139
+ parent: string;
140
+ at0: Pt;
141
+ end0: Pt | null;
142
+ theta0: number;
143
+ length: number;
144
+ inherit?: string;
145
+ }
146
+ const flat = new Map<string, FlatNode>();
147
+ for (const b of bones) {
148
+ if ('chain' in b) {
149
+ b.points.forEach((p, k) => {
150
+ const name = `${b.chain}${k}`;
151
+ const parent = k === 0 ? b.parent : `${b.chain}${k - 1}`;
152
+ const at0: Pt = [p[0], p[1]];
153
+ const end0: Pt = k + 1 < b.points.length ? [b.points[k + 1][0], b.points[k + 1][1]] : [b.tip[0], b.tip[1]];
154
+ const dx = end0[0] - at0[0];
155
+ const dy = end0[1] - at0[1];
156
+ const theta0 = (Math.atan2(-dy, dx) * 180) / Math.PI;
157
+ const length = Math.sqrt(dx * dx + dy * dy);
158
+ flat.set(name, { name, parent, at0, end0, theta0, length, inherit: b.link_inherit?.[name] });
159
+ });
160
+ } else {
161
+ const at0: Pt = [b.at[0], b.at[1]];
162
+ const end0: Pt | null = b.tip === undefined ? null : [b.tip[0], b.tip[1]];
163
+ const dx = end0 === null ? 0 : end0[0] - at0[0];
164
+ const dy = end0 === null ? 0 : end0[1] - at0[1];
165
+ const theta0 = end0 === null ? 0 : (Math.atan2(-dy, dx) * 180) / Math.PI;
166
+ const length = end0 === null ? 0 : Math.sqrt(dx * dx + dy * dy);
167
+ flat.set(b.name, { name: b.name, parent: b.parent, at0, end0, theta0, length, inherit: b.inherit });
168
+ }
169
+ }
170
+ const posed = new Map<string, { at: Pt; end: Pt | null; theta: number }>();
171
+ const solve = (name: string): { at: Pt; end: Pt | null; theta: number } => {
172
+ const cached = posed.get(name);
173
+ if (cached !== undefined) return cached;
174
+ const node = flat.get(name);
175
+ if (node === undefined) return { at: [0, 0], end: null, theta: 0 };
176
+ let at: Pt = [node.at0[0], node.at0[1]];
177
+ let dParent = 0;
178
+ const pNode = flat.get(node.parent);
179
+ if (pNode !== undefined) {
180
+ const pPosed = solve(node.parent);
181
+ dParent = pPosed.theta - pNode.theta0;
182
+ const rad = (dParent * Math.PI) / 180;
183
+ const cos = Math.cos(rad);
184
+ const sin = Math.sin(rad);
185
+ const dx0 = node.at0[0] - pNode.at0[0];
186
+ const dy0 = node.at0[1] - pNode.at0[1];
187
+ at = [pPosed.at[0] + dx0 * cos + dy0 * sin, pPosed.at[1] - dx0 * sin + dy0 * cos];
188
+ }
189
+ const inheritsRot = node.inherit !== 'noRotationOrReflection' && node.inherit !== 'onlyTranslation';
190
+ const theta = drawn[name] !== undefined ? drawn[name] : node.theta0 + (inheritsRot ? dParent : 0);
191
+ let end: Pt | null = null;
192
+ if (node.end0 !== null) {
193
+ const rad = (theta * Math.PI) / 180;
194
+ end = [at[0] + node.length * Math.cos(rad), at[1] - node.length * Math.sin(rad)];
195
+ }
196
+ const res = { at, end, theta };
197
+ posed.set(name, res);
198
+ return res;
199
+ };
200
+ for (const name of flat.keys()) solve(name);
201
+ return bones.map((b): BoneEntry => {
202
+ if ('chain' in b) {
203
+ const points = b.points.map((_, k): Pt => {
204
+ const s = solve(`${b.chain}${k}`);
205
+ return [s.at[0], s.at[1]];
206
+ });
207
+ const last = solve(`${b.chain}${b.points.length - 1}`);
208
+ const tip: Pt = last.end === null ? [b.tip[0], b.tip[1]] : [last.end[0], last.end[1]];
209
+ return { ...b, points, tip };
210
+ }
211
+ const s = solve(b.name);
212
+ return {
213
+ ...b,
214
+ at: [s.at[0], s.at[1]],
215
+ ...(b.tip === undefined || s.end === null ? {} : { tip: [s.end[0], s.end[1]] }),
216
+ };
217
+ });
218
+ }
219
+
128
220
  /** A limb's polyline: its segments' bone origins in order, then the last bone's end when it has one. */
129
221
  export function limbPolyline(limb: Limb, places: ReadonlyMap<string, BonePlace>): Pt[] {
130
222
  const pts = limb.segments.map((s) => (places.get(s.bone) as BonePlace).at);