@rig-c/parts 1.16.0 → 1.18.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_SKIN_CONSTRAINT_KEYS, 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 } from 'rig-c/src/rig.ts';
90
90
  import { GRID, MAX_SIDE } from './contour.ts';
91
91
  import { type Problem, refuseIfAny } from './errors.ts';
92
92
  import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
@@ -227,6 +227,15 @@ export interface SingleBone {
227
227
  parent: string;
228
228
  at: Point;
229
229
  tip?: Point;
230
+ /**
231
+ * What the bone takes from its parent's transform (issue #348): one of rig-c's
232
+ * own mode names (`RIG_BONE_INHERIT`, rig-c's `RigBone.inherit`), passed to
233
+ * rig.json as written. Absent, rig.json carries no `inherit` and the bone
234
+ * takes the parent's whole transform (rig-c's default, `normal`). A spelling
235
+ * outside rig-c's table is refused by name (`CONFIG_BONE_INHERIT`): the
236
+ * runtime would fold the first letter, and this loader folds and maps nothing.
237
+ */
238
+ inherit?: RigBoneInherit;
230
239
  }
231
240
 
232
241
  export interface ChainBones {
@@ -627,12 +636,37 @@ export interface ConfigConstraint {
627
636
  [field: string]: unknown;
628
637
  }
629
638
 
639
+ /**
640
+ * One segment of a limb (issue #349): the part it makes, the part whose
641
+ * pixels it takes (`from`: a plan part or a cut's piece), and the one bone it
642
+ * rides with weight 1. Every segment after the first sits below a joint at
643
+ * its bone's origin: `joint` names it, `range` is the bend the joint may take
644
+ * from rest, `[min, max]` degrees (metadata: read by the stress readers, not
645
+ * by rig-c), and `cap` an image, relative to the config's directory, of the
646
+ * cap that overlaps the joint (`limbCaps` in `src/assemble.ts`).
647
+ */
648
+ export interface LimbSegment {
649
+ part: string;
650
+ from: string;
651
+ bone: string;
652
+ joint?: string;
653
+ range?: [number, number];
654
+ cap?: string;
655
+ }
656
+
657
+ /** A limb (issue #349): its segments, upper first; each segment's bone is the child of the one before. */
658
+ export interface Limb {
659
+ segments: LimbSegment[];
660
+ }
661
+
630
662
  export interface CharacterConfig {
631
663
  key: string;
632
664
  note?: string;
633
665
  generation?: Generation;
634
666
  seethrough?: SeeThrough;
635
667
  assemble: Assemble;
668
+ /** Limbs cut at their joints (issue #349): absent, no part is cut at a joint and no byte of any output moves. */
669
+ limbs?: Record<string, Limb>;
636
670
  bones: BoneEntry[];
637
671
  meshes: Record<string, MeshSpec>;
638
672
  regions: Record<string, string>;
@@ -925,9 +959,12 @@ export interface AssembleConfig {
925
959
  seethrough: SeeThrough;
926
960
  assemble: Assemble;
927
961
  scene?: SceneSpec;
962
+ /** Issue #349: read with `bones` (the joints are the bones' origins) when present. */
963
+ limbs?: Record<string, Limb>;
964
+ bones?: BoneEntry[];
928
965
  }
929
966
 
930
- const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene'] as const;
967
+ const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene'] as const;
931
968
 
932
969
  /**
933
970
  * The one partial entry point. It validates exactly what its door requires —
@@ -976,8 +1013,14 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
976
1013
  }
977
1014
  if (door === 'assemble') {
978
1015
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
979
- if ('assemble' in t) checkAssemble(c, t.assemble, door);
1016
+ const made = 'assemble' in t ? checkAssemble(c, t.assemble, door) : { parts: [], patches: [], cuts: [] };
980
1017
  if ('scene' in t) checkScene(c, t.scene);
1018
+ // Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
1019
+ if ('limbs' in t) {
1020
+ 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');
1021
+ const names = 'bones' in t ? checkBones(c, t.bones) : null;
1022
+ checkLimbs(c, t.limbs, made, names);
1023
+ }
981
1024
  }
982
1025
  const pose = base === null ? null : poseBeside(c, t.generation, base);
983
1026
  refuseIfAny(c.problems);
@@ -1124,9 +1167,10 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1124
1167
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1125
1168
  const { parts, patches, cuts } = 'assemble' in t ? checkAssemble(c, t.assemble, 'full') : { parts: [], patches: [], cuts: [] };
1126
1169
  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>() };
1170
+ const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
1127
1171
  if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
1128
1172
  if ('regions' in t) checkRegions(c, t.regions, names.bones);
1129
- if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions);
1173
+ if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions, limbs);
1130
1174
  if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
1131
1175
  if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
1132
1176
  if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
@@ -1651,8 +1695,16 @@ function checkBones(c: Check, v: Json): { bones: Set<string>; chains: Map<string
1651
1695
  }
1652
1696
  return;
1653
1697
  }
1654
- const b = c.object(at, entry, ['name', 'parent', 'at'], ['tip']);
1698
+ const b = c.object(at, entry, ['name', 'parent', 'at'], ['tip', 'inherit']);
1655
1699
  if (b === null) return;
1700
+ // Issue #348: rig-c's own mode names, as its table spells them; nothing is folded, mapped or defaulted.
1701
+ if ('inherit' in b && c.string(`${at}.inherit`, b.inherit) && !(RIG_BONE_INHERIT as readonly string[]).includes(b.inherit)) {
1702
+ c.fail(
1703
+ 'CONFIG_BONE_INHERIT',
1704
+ `${at}.inherit`,
1705
+ `is ${show(b.inherit)}; 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)`,
1706
+ );
1707
+ }
1656
1708
  if ('parent' in b && c.string(`${at}.parent`, b.parent) && !bones.has(b.parent)) {
1657
1709
  c.fail('CONFIG_NAME_RESOLVES', `${at}.parent`, `names "${b.parent}", which is not "${ROOT_BONE}" or a bone declared above it; parents come first`);
1658
1710
  }
@@ -2087,14 +2139,139 @@ function checkRegions(c: Check, v: Json, bones: Set<string>): void {
2087
2139
  }
2088
2140
  }
2089
2141
 
2142
+ /** What the loader read off `config.limbs` (issue #349): each limb part with its bone, and each part a limb takes, with the first limb taking it. */
2143
+ interface LimbNames {
2144
+ parts: Map<string, { bone: string; at: string }>;
2145
+ consumed: Map<string, string>;
2146
+ }
2147
+
2148
+ /**
2149
+ * `config.limbs` (issue #349): `{<limb>: {segments: [{part, from, bone}, {part,
2150
+ * from, bone, joint, range?, cap?}, …]}}`, upper segment first. Refused field
2151
+ * by field: `part` a new part name (`CONFIG_PART_NAME`, `CONFIG_PART_UNIQUE`
2152
+ * against the plan, the patches, the cuts and every other limb part); `from`
2153
+ * a plan part or a cut's piece (`CONFIG_NAME_RESOLVES`), the segments of one
2154
+ * limb that take one part consecutive (`CONFIG_LIMB_FROM_RUN`: the cut
2155
+ * between them is a joint line, and a joint line has two sides); `bone` a
2156
+ * declared bone, each segment's bone the child of the one before
2157
+ * (`CONFIG_LIMB_BONES_CHAIN`: a bend is a rotation of the lower bone about
2158
+ * the joint, its origin); the first segment has no joint, every other names
2159
+ * one (`CONFIG_KEY_KNOWN`, `CONFIG_FIELD_PRESENT`); `range` two finite
2160
+ * degrees `[min, max]` with `min <= 0 <= max` (`CONFIG_LIMB_RANGE`: the range
2161
+ * is measured from rest, so rest is in it); `cap` a non-empty path. A limb
2162
+ * has two segments or more. With `names` null (no `bones`), bones are not
2163
+ * resolved. Returns the limb parts and the parts the limbs take.
2164
+ */
2165
+ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[]; cuts: string[] }, names: { bones: Set<string>; parents: Map<string, string> } | null): LimbNames {
2166
+ const out: LimbNames = { parts: new Map(), consumed: new Map() };
2167
+ if (!isPlainObject(v)) {
2168
+ c.fail('CONFIG_FIELD_TYPE', 'config.limbs', `is ${show(v)}; an object of limb name -> {"segments": [...]} is required`);
2169
+ return out;
2170
+ }
2171
+ const sources = new Set([...made.parts, ...made.cuts]);
2172
+ const firstAt = new Map<string, string>();
2173
+ for (const [limb, spec] of Object.entries(v)) {
2174
+ const at = `config.limbs.${limb}`;
2175
+ const o = c.object(at, spec, ['segments'], []);
2176
+ if (o === null || !('segments' in o)) continue;
2177
+ if (!Array.isArray(o.segments) || o.segments.length < 2) {
2178
+ c.fail('CONFIG_FIELD_TYPE', `${at}.segments`, `is ${show(o.segments)}; an array of two segments or more, upper first, is required — a limb is cut at a joint, and a joint has a segment on each side`);
2179
+ continue;
2180
+ }
2181
+ const segs = o.segments as Json[];
2182
+ const froms: Array<string | null> = [];
2183
+ const bones: Array<string | null> = [];
2184
+ segs.forEach((entry, i) => {
2185
+ const sat = `${at}.segments[${i}]`;
2186
+ const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap']);
2187
+ froms.push(null);
2188
+ bones.push(null);
2189
+ if (e === null) return;
2190
+ // The upper segment sits below no joint; every other sits below one, which it names.
2191
+ if (i === 0) {
2192
+ for (const k of ['joint', 'range', 'cap'].filter((q) => q in e)) c.fail('CONFIG_KEY_KNOWN', `${sat}.${k}`, `is not a field on the upper segment; it sits below no joint, so ${k} belongs to the segment below the joint it names (segments[1] and on)`);
2193
+ } else if (!('joint' in e)) c.fail('CONFIG_FIELD_PRESENT', `${sat}.joint`, 'is absent and required; a segment after the first sits below a joint, at its bone\'s origin, and names it');
2194
+ if ('part' in e && c.string(`${sat}.part`, e.part)) {
2195
+ const name = e.part;
2196
+ if (!partNameOk(name)) c.fail('CONFIG_PART_NAME', `${sat}.part`, `names the part ${show(name)}; a part name is a file name, so it may not be empty, start with "." or hold a slash`);
2197
+ else if (made.parts.includes(name) || made.patches.includes(name) || made.cuts.includes(name)) {
2198
+ c.fail('CONFIG_PART_UNIQUE', `${sat}.part`, `names the part "${name}", which ${made.parts.includes(name) ? 'assemble.plan' : made.patches.includes(name) ? 'assemble.patches' : 'assemble.cuts'} already makes; a limb segment makes a part of its own`);
2199
+ } else if (firstAt.has(name)) c.fail('CONFIG_PART_UNIQUE', `${sat}.part`, `names the part "${name}" again (first at ${firstAt.get(name)})`);
2200
+ else {
2201
+ firstAt.set(name, sat);
2202
+ if ('bone' in e && typeof e.bone === 'string') out.parts.set(name, { bone: e.bone, at: `config.limbs.${limb} segment ${i}` });
2203
+ }
2204
+ }
2205
+ if ('from' in e) {
2206
+ if (typeof e.from !== 'string' || !sources.has(e.from)) {
2207
+ c.fail('CONFIG_NAME_RESOLVES', `${sat}.from`, `is ${show(e.from)}; a part named in assemble.plan or made by an assemble.cuts entry is required — the layer the segment's pixels are cut from`);
2208
+ } else {
2209
+ froms[i] = e.from;
2210
+ if (!out.consumed.has(e.from)) out.consumed.set(e.from, limb);
2211
+ }
2212
+ }
2213
+ if ('bone' in e && c.string(`${sat}.bone`, e.bone)) {
2214
+ if (names !== null && !names.bones.has(e.bone)) c.fail('CONFIG_NAME_RESOLVES', `${sat}.bone`, `is "${e.bone}"; a bone config.bones declares is required (a chain's links are <chain>0, <chain>1, …)`);
2215
+ else bones[i] = e.bone;
2216
+ }
2217
+ if ('joint' in e) c.string(`${sat}.joint`, e.joint);
2218
+ if ('range' in e) {
2219
+ const r = e.range;
2220
+ const ok = Array.isArray(r) && r.length === 2 && r.every((n) => typeof n === 'number' && Number.isFinite(n));
2221
+ if (!ok) c.fail('CONFIG_FIELD_TYPE', `${sat}.range`, `is ${show(r)}; [min, max], two finite numbers of degrees from rest, is required`);
2222
+ else {
2223
+ const [lo, hi] = r as [number, number];
2224
+ if (!(lo <= 0 && 0 <= hi)) c.fail('CONFIG_LIMB_RANGE', `${sat}.range`, `is [${lo}, ${hi}]; min <= 0 <= max is required — the range is the bend from rest, and rest (0) is a pose the joint takes`);
2225
+ }
2226
+ }
2227
+ if ('cap' in e && !(typeof e.cap === 'string' && e.cap !== '')) {
2228
+ c.fail('CONFIG_FIELD_TYPE', `${sat}.cap`, `is ${show(e.cap)}; a non-empty path to a PNG is required, relative to the config's directory — the cap's image over the box assemble's limb line prints`);
2229
+ }
2230
+ });
2231
+ froms.forEach((f, i) => {
2232
+ if (f === null || i === 0 || froms[i - 1] === f) return;
2233
+ const back = froms.slice(0, i - 1).lastIndexOf(f);
2234
+ if (back >= 0) {
2235
+ c.fail('CONFIG_LIMB_FROM_RUN', `${at}.segments[${i}].from`, `is "${f}", which segments[${back}] takes too with another part between them; the segments that take one part must be consecutive — the cut between two of them is the joint line, and it has two sides`);
2236
+ }
2237
+ });
2238
+ if (names !== null) {
2239
+ bones.forEach((b, i) => {
2240
+ if (i === 0 || b === null || bones[i - 1] === null) return;
2241
+ const parent = names.parents.get(b);
2242
+ if (parent !== bones[i - 1]) {
2243
+ c.fail('CONFIG_LIMB_BONES_CHAIN', `${at}.segments[${i}].bone`, `is "${b}", whose parent is ${parent === undefined ? 'not declared' : `"${parent}"`}; "${bones[i - 1]}", the bone of segments[${i - 1}], is required — the lower segment turns about the joint on a bone that hangs from the upper's`);
2244
+ }
2245
+ });
2246
+ }
2247
+ }
2248
+ return out;
2249
+ }
2250
+
2090
2251
  /**
2091
2252
  * Every plan part — and every cut's piece, which the caller lists with them —
2092
2253
  * is exactly one of a mesh or a region, every patch is a region, and every
2093
2254
  * mesh or region is one of those parts or a patch.
2094
2255
  */
2095
- function checkCoverage(c: Check, parts: string[], patches: string[], meshes: Json, regions: Json): void {
2256
+ function checkCoverage(c: Check, made: string[], patches: string[], meshes: Json, regions: Json, limbs: LimbNames | null = null): void {
2096
2257
  const m = typeof meshes === 'object' && meshes !== null ? Object.keys(meshes) : [];
2097
2258
  const r = typeof regions === 'object' && regions !== null ? Object.keys(regions) : [];
2259
+ // Issue #349: a part a limb takes is cut into the limb's parts and is not made itself; the limb's parts are.
2260
+ const parts = limbs === null ? made : [...made.filter((n) => !limbs.consumed.has(n)), ...limbs.parts.keys()];
2261
+ if (limbs !== null) {
2262
+ for (const name of [...m, ...r]) {
2263
+ const by = limbs.consumed.get(name);
2264
+ if (by !== undefined) {
2265
+ c.fail('CONFIG_NAME_RESOLVES', `config.${m.includes(name) ? 'meshes' : 'regions'}.${name}`, `names "${name}", which config.limbs.${by} cuts into its parts at the joints, so no part of that name is made — attach the limb's parts instead`);
2266
+ }
2267
+ }
2268
+ for (const [part, seg] of limbs.parts) {
2269
+ const bone = isPlainObject(regions) ? regions[part] : undefined;
2270
+ if (typeof bone === 'string' && bone !== seg.bone) {
2271
+ c.fail('CONFIG_LIMB_BONE', `config.regions.${part}`, `is "${bone}"; "${part}" is ${seg.at}, which rides "${seg.bone}" with weight 1, so config.regions.${part} "${seg.bone}" is required`);
2272
+ }
2273
+ }
2274
+ }
2098
2275
  for (const patch of patches) {
2099
2276
  if (m.includes(patch)) c.fail('CONFIG_PART_ATTACHED', `part "${patch}"`, `is an assemble.patches entry and has a meshes entry; a patch is a region — drop config.meshes.${patch} and name its bone in config.regions.${patch}`);
2100
2277
  else if (!r.includes(patch)) c.fail('CONFIG_PART_ATTACHED', `part "${patch}"`, `is an assemble.patches entry with no regions entry; a patch is a region, so config.regions.${patch} naming its bone is required`);
@@ -2106,8 +2283,8 @@ function checkCoverage(c: Check, parts: string[], patches: string[], meshes: Jso
2106
2283
  if (inM && inR) c.fail('CONFIG_PART_ATTACHED', `part "${part}"`, 'has both a meshes entry and a regions entry; exactly one is required');
2107
2284
  }
2108
2285
  for (const name of [...m, ...r]) {
2109
- if (!parts.includes(name) && !patches.includes(name)) {
2110
- c.fail('CONFIG_NAME_RESOLVES', `config.${m.includes(name) ? 'meshes' : 'regions'}.${name}`, 'names a part that neither assemble.plan nor assemble.patches makes (nor an assemble.cuts entry)');
2286
+ if (!parts.includes(name) && !patches.includes(name) && !(limbs?.consumed.has(name) ?? false)) {
2287
+ c.fail('CONFIG_NAME_RESOLVES', `config.${m.includes(name) ? 'meshes' : 'regions'}.${name}`, 'names a part that neither assemble.plan nor assemble.patches makes (nor an assemble.cuts entry, nor config.limbs)');
2111
2288
  }
2112
2289
  }
2113
2290
  }
package/src/inputs.ts CHANGED
@@ -59,7 +59,17 @@ export interface SquarePad {
59
59
  * At most one of the two margins is non-zero.
60
60
  */
61
61
  export function squarePad(w: number, h: number): SquarePad {
62
- const side = Math.max(w, h);
62
+ return padToSquare(w, h, Math.max(w, h));
63
+ }
64
+
65
+ /**
66
+ * The same centring on a square of a given `side` (issue #366): a single run's
67
+ * file padded to the `seethrough.resolution` square. Each margin floors, so an
68
+ * odd remainder goes to the right or the bottom, exactly as {@link squarePad};
69
+ * both margins are non-zero when both sides are below `side`. The caller has
70
+ * refused a side above `side` — padding cannot shrink a canvas.
71
+ */
72
+ export function padToSquare(w: number, h: number, side: number): SquarePad {
63
73
  return { side, left: Math.floor((side - w) / 2), top: Math.floor((side - h) / 2) };
64
74
  }
65
75