@rig-c/parts 1.34.0 → 1.36.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/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);
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 ConfigStop, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
130
+ import { type BoneEntry, type ConfigConstraint, type ConfigStop, contourRegionsOf, deriveBustRegions, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
131
+ export { contourRegionsOf, deriveBustRegions };
131
132
  import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
132
133
  import { PartsError, type Problem, refuseIfAny } from './errors.ts';
133
134
  import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
package/src/rig.ts CHANGED
@@ -132,7 +132,7 @@
132
132
  * touches the disk, and the same inputs give the same bytes (key order is the
133
133
  * order the objects are built in, and every number is rounded by `pyRound`).
134
134
  */
135
- import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, type ConfigStop, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, type RibsSpec, ROOT_BONE, stopForRig, weightsABone } from './config.ts';
135
+ import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, type ConfigStop, CONSTRAINT_FOLLOWS, constraintForRig, contourRegionsOf, type ContourSpec, DEFAULT_BUST_BONE_L, DEFAULT_BUST_BONE_R, type Point, type RibsSpec, ROOT_BONE, stopForRig, weightsABone } from './config.ts';
136
136
  import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
137
137
  import { type Problem, refuseIfAny } from './errors.ts';
138
138
  import { eyeLineDegrees } from './headframe.ts';
@@ -164,6 +164,7 @@ import {
164
164
  } from './automesh.ts';
165
165
  import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
166
166
  import { type LocalInfluence, localInfluences } from './localweights.ts';
167
+ import { edgeLength } from './length.ts';
167
168
  import { artCoverage, ART_ALPHA, latticeCells, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
168
169
  import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
169
170
  import { type AlternateSetMap, PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
@@ -1006,8 +1007,12 @@ export function deriveAlternateDrawn(
1006
1007
  const limbPairs: ReadonlyArray<readonly [string, readonly [number, number], readonly [number, number]]> = [
1007
1008
  ['sleeve_r0', pts.r_shoulder, pts.r_elbow],
1008
1009
  ['sleeve_r1', pts.r_elbow, pts.r_wrist],
1010
+ ['arm_r0', pts.r_shoulder, pts.r_elbow],
1011
+ ['arm_r1', pts.r_elbow, pts.r_wrist],
1009
1012
  ['sleeve_l0', pts.l_shoulder, pts.l_elbow],
1010
1013
  ['sleeve_l1', pts.l_elbow, pts.l_wrist],
1014
+ ['arm_l0', pts.l_shoulder, pts.l_elbow],
1015
+ ['arm_l1', pts.l_elbow, pts.l_wrist],
1011
1016
  ['leg_r0', pts.r_hip, pts.r_knee],
1012
1017
  ['leg_r1', pts.r_knee, pts.r_ankle],
1013
1018
  ['leg_l0', pts.l_hip, pts.l_knee],
@@ -1161,6 +1166,14 @@ export function buildRig(
1161
1166
  (m.contour.regions ?? []).forEach((rg, i) => {
1162
1167
  if (!B.has(rg.bone)) fail('RIG_NAME_RESOLVES', `config.meshes.${part}.contour.regions[${i}].bone`, `names the bone "${rg.bone}", which config.bones does not declare`);
1163
1168
  });
1169
+ if (m.contour.bust !== undefined) {
1170
+ for (const [k, bone] of [
1171
+ ['bone_r', m.contour.bust.bone_r ?? DEFAULT_BUST_BONE_R],
1172
+ ['bone_l', m.contour.bust.bone_l ?? DEFAULT_BUST_BONE_L],
1173
+ ] as const) {
1174
+ if (!B.has(bone)) fail('RIG_NAME_RESOLVES', `config.meshes.${part}.contour.bust.${k}`, `names the bone "${bone}", which config.bones does not declare`);
1175
+ }
1176
+ }
1164
1177
  }
1165
1178
  if ('auto' in m) {
1166
1179
  (m.auto.regions ?? []).forEach((rg, i) => {
@@ -1274,7 +1287,7 @@ export function buildRig(
1274
1287
  for (const segs of meshSegments.values()) for (const s of segs) meshBones.add(s.bone);
1275
1288
  // A contour region's control bone is weighted to like a segment's bone (issue #84), so the idle's keys on it move
1276
1289
  // to its control under `ctl` as theirs do. A config with no contour region adds nothing here.
1277
- for (const m of Object.values(cfg.meshes)) if ('contour' in m) for (const rg of m.contour.regions ?? []) meshBones.add(rg.bone);
1290
+ for (const m of Object.values(cfg.meshes)) if ('contour' in m) for (const rg of contourRegionsOf(m.contour)) meshBones.add(rg.bone);
1278
1291
  for (const m of Object.values(cfg.meshes)) if ('auto' in m) for (const rg of m.auto.regions ?? []) if (weightsABone(rg)) meshBones.add(rg.bone);
1279
1292
  const meshKeyed = controlledBones(motion, meshBones);
1280
1293
  const controls = idleKeys === 'ctl' ? meshKeyed : [];
@@ -1600,7 +1613,7 @@ export function buildRig(
1600
1613
  const h = img.height;
1601
1614
  const alpha = new Uint8Array(w * h);
1602
1615
  for (let i = 0; i < alpha.length; i++) alpha[i] = img.data[i * 4 + 3];
1603
- const regions = spec.regions ?? [];
1616
+ const regions = contourRegionsOf(spec);
1604
1617
  const inPart: ContourRegion[] = regions.map((rg) =>
1605
1618
  rg.shape === 'circle'
1606
1619
  ? { name: rg.name, shape: 'circle', cx: rg.cx - ox, cy: rg.cy - oy, r: rg.r, spacing: rg.spacing, band: rg.band }
@@ -1635,10 +1648,25 @@ export function buildRig(
1635
1648
  const li = localInfluences([wx, wy], vSegs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
1636
1649
  if ('first' in li) {
1637
1650
  if (!overlapped) {
1651
+ let bustHint = '';
1652
+ if (spec.bust !== undefined) {
1653
+ const need: number[] = [];
1654
+ for (const rg of [regions[li.first], regions[li.second]]) {
1655
+ if (rg.name === 'bust_r' || rg.name === 'bust_l') {
1656
+ const apex = rg.name === 'bust_r' ? spec.bust.apex_r : spec.bust.apex_l;
1657
+ const d = edgeLength(spec.bust.cleavage[0] - apex[0], spec.bust.cleavage[1] - apex[1]);
1658
+ const dist = edgeLength(wx - apex[0], wy - apex[1]);
1659
+ need.push(pyRound(d - dist, 2));
1660
+ }
1661
+ }
1662
+ if (need.length > 0) {
1663
+ bustHint = ` (contour.bust.clearance >= ${Math.min(...need)} clears vertex ${vi})`;
1664
+ }
1665
+ }
1638
1666
  out.push({
1639
1667
  code: 'RIG_CONTOUR_REGIONS_OVERLAP',
1640
1668
  object: `config.meshes.${p.name}.contour.regions`,
1641
- detail: `vertex ${vi} at rig (${wx}, ${wy}) takes weight ${pyRound(li.g1, 5)} from region "${regions[li.first].name}" and ${pyRound(li.g2, 5)} from region "${regions[li.second].name}"; each vertex may be reached by one region's falloff (its region and band) — move the regions apart or narrow a band`,
1669
+ detail: `vertex ${vi} at rig (${wx}, ${wy}) takes weight ${pyRound(li.g1, 5)} from region "${regions[li.first].name}" and ${pyRound(li.g2, 5)} from region "${regions[li.second].name}"; each vertex may be reached by one region's falloff (its region and band) — move the regions apart or narrow a band${bustHint}`,
1642
1670
  });
1643
1671
  }
1644
1672
  overlapped = true;
@@ -2085,17 +2113,44 @@ export function buildRig(
2085
2113
  for (const [altIdx, alt] of parts.alternates.entries()) {
2086
2114
  const cfgAlt = cfg.alternates?.find((a) => a.name === alt.name);
2087
2115
  const explicitDrawn = alt.drawn ?? cfgAlt?.drawn;
2116
+ const isSubsetSet = alt.parts !== undefined || cfgAlt?.parts !== undefined;
2117
+ let coveredBones: Set<string> | null = null;
2118
+ if (isSubsetSet) {
2119
+ coveredBones = new Set<string>();
2120
+ for (const pName of Object.keys(alt.slots)) {
2121
+ if (pName in cfg.regions) coveredBones.add(cfg.regions[pName]);
2122
+ for (const s of meshSegments.get(pName) ?? []) coveredBones.add(s.bone);
2123
+ const m = cfg.meshes[pName];
2124
+ if (m !== undefined) {
2125
+ if ('contour' in m) for (const rg of contourRegionsOf(m.contour)) coveredBones.add(rg.bone);
2126
+ if ('auto' in m) for (const rg of m.auto.regions ?? []) if (weightsABone(rg)) coveredBones.add(rg.bone);
2127
+ }
2128
+ }
2129
+ }
2088
2130
  if (explicitDrawn !== undefined) {
2089
2131
  for (const boneName of Object.keys(explicitDrawn)) {
2090
2132
  if (!B.has(boneName)) {
2091
2133
  fail('RIG_NAME_RESOLVES', `config.alternates[${altIdx}].drawn.${boneName}`, `names the bone "${boneName}", which config.bones does not declare`);
2134
+ } else if (coveredBones !== null && !coveredBones.has(boneName)) {
2135
+ const coveredList = [...coveredBones].map((b) => `"${b}"`).join(', ') || 'none';
2136
+ fail(
2137
+ 'RIG_SET_COVERS_BONE',
2138
+ `config.assemble.alternates[${altIdx}].drawn.${boneName}`,
2139
+ `names "${boneName}", which alternate set "${alt.name}" does not cover (covered bones: ${coveredList}); a subset set may state drawn angles only for the bones its covered parts ride`,
2140
+ );
2092
2141
  }
2093
2142
  }
2094
2143
  }
2095
2144
  refuseIfAny(problems);
2096
- const derived = alt.skeleton === undefined ? undefined : deriveAlternateDrawn(alt.skeleton, B, turn, CX, H);
2145
+ const rawDerived = alt.skeleton === undefined ? undefined : deriveAlternateDrawn(alt.skeleton, B, turn, CX, H);
2146
+ const derived =
2147
+ rawDerived === undefined
2148
+ ? undefined
2149
+ : coveredBones === null
2150
+ ? rawDerived
2151
+ : Object.fromEntries(Object.entries(rawDerived).filter(([k]) => coveredBones.has(k)));
2097
2152
  const drawn: Record<string, number> | undefined =
2098
- derived === undefined && explicitDrawn === undefined
2153
+ (derived === undefined || Object.keys(derived).length === 0) && explicitDrawn === undefined
2099
2154
  ? undefined
2100
2155
  : {
2101
2156
  ...(derived ?? {}),
@@ -2299,6 +2354,7 @@ export function buildRig(
2299
2354
  const partAreas: Record<string, number> = {};
2300
2355
  for (const p of parts.parts) partAreas[p.name] = p.opaque_px;
2301
2356
  bindFaceSurfaceDeform(motion, (merged) => {
2357
+ expandSetAttachmentKeys(rig, sets, merged);
2302
2358
  applyFaceSurfaceDeform(rig, merged, {
2303
2359
  imageWidth: faceImg?.width,
2304
2360
  imageHeight: faceImg?.height,
@@ -2334,6 +2390,186 @@ export function buildRig(
2334
2390
  };
2335
2391
  }
2336
2392
 
2393
+ /**
2394
+ * Issue #473: expand `{"t": …, "set": "<name>" | null}` keys on slot attachment
2395
+ * tracks in `merged.animations` into `{"t": …, "v": "<attachment>" | null}`
2396
+ * keys against `rig.slots` and `sets.json`. When no key carries `"set"`, nothing
2397
+ * is touched and no byte moves.
2398
+ */
2399
+ export function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefined, merged: MotionSpec): void {
2400
+ let hasAnySetKey = false;
2401
+ for (const [animName, anim] of Object.entries(merged.animations)) {
2402
+ if (animName === 'idle' || typeof anim !== 'object' || anim === null || Array.isArray(anim)) continue;
2403
+ const tracks = (anim as Record<string, unknown>).tracks;
2404
+ if (!Array.isArray(tracks)) continue;
2405
+ for (const tr of tracks) {
2406
+ if (typeof tr !== 'object' || tr === null || Array.isArray(tr)) continue;
2407
+ const keys = (tr as Record<string, unknown>).keys;
2408
+ if (!Array.isArray(keys)) continue;
2409
+ for (const k of keys) {
2410
+ if (typeof k === 'object' && k !== null && !Array.isArray(k) && 'set' in k) {
2411
+ hasAnySetKey = true;
2412
+ break;
2413
+ }
2414
+ }
2415
+ if (hasAnySetKey) break;
2416
+ }
2417
+ if (hasAnySetKey) break;
2418
+ }
2419
+ if (!hasAnySetKey) return;
2420
+
2421
+ const baseSlots = new Map<string, string | null>();
2422
+ const slotBoneMap = new Map<string, string>();
2423
+ for (const sl of rig.slots) {
2424
+ baseSlots.set(sl.name, sl.attachment ?? null);
2425
+ const rawBone = sl.bone.startsWith(`${sl.name}__`) ? sl.bone.slice(`${sl.name}__`.length) : sl.bone;
2426
+ slotBoneMap.set(sl.name, rawBone);
2427
+ }
2428
+ const setupRotByBone = new Map<string, number>();
2429
+ for (const b of rig.bones) setupRotByBone.set(b.name, b.rotation ?? 0);
2430
+ const setBoneDeltas = new Map<string, Map<string, number>>();
2431
+ if (sets !== undefined) {
2432
+ for (const [setName, entry] of Object.entries(sets.sets)) {
2433
+ if (entry.drawn === undefined || Object.keys(entry.drawn).length === 0) continue;
2434
+ const posed = poseRigBonesForDrawn(rig.bones, entry.drawn, rig.stops);
2435
+ const deltas = new Map<string, number>();
2436
+ for (const b of posed) {
2437
+ if (Object.hasOwn(entry.drawn, b.name)) {
2438
+ const d = places(normaliseDegrees((b.rotation ?? 0) - (setupRotByBone.get(b.name) ?? 0)));
2439
+ deltas.set(b.name, d);
2440
+ }
2441
+ }
2442
+ setBoneDeltas.set(setName, deltas);
2443
+ }
2444
+ }
2445
+ const declaredSets =
2446
+ sets === undefined || Object.keys(sets.sets).length === 0
2447
+ ? 'none'
2448
+ : Object.keys(sets.sets)
2449
+ .map((n) => `"${n}"`)
2450
+ .join(', ');
2451
+ const problems: Problem[] = [];
2452
+
2453
+ for (const [animName, anim] of Object.entries(merged.animations)) {
2454
+ if (animName === 'idle' || typeof anim !== 'object' || anim === null || Array.isArray(anim)) continue;
2455
+ const tracks = (anim as Record<string, unknown>).tracks;
2456
+ if (!Array.isArray(tracks)) continue;
2457
+ const existingRotateBones = new Set<string>();
2458
+ for (const tr of tracks) {
2459
+ if (typeof tr === 'object' && tr !== null && !Array.isArray(tr)) {
2460
+ const r = tr as Record<string, unknown>;
2461
+ if (r.property === 'rotate' && typeof r.bone === 'string') existingRotateBones.add(r.bone);
2462
+ }
2463
+ }
2464
+ const synthesizedRotate = new Map<string, Array<Record<string, unknown>>>();
2465
+ tracks.forEach((tr, ti) => {
2466
+ if (typeof tr !== 'object' || tr === null || Array.isArray(tr)) return;
2467
+ const trRec = tr as Record<string, unknown>;
2468
+ const keys = trRec.keys;
2469
+ if (!Array.isArray(keys)) return;
2470
+ const slot = typeof trRec.slot === 'string' && trRec.slot !== '' ? trRec.slot : null;
2471
+ const isAttachmentTrack = trRec.property === 'attachment' && slot !== null;
2472
+ const slotBone = slot === null ? undefined : slotBoneMap.get(slot);
2473
+ let trackTouchesDrawnBone = false;
2474
+ const rawSetKeysForBone: Array<{ t: unknown; setVal: string | null }> = [];
2475
+ keys.forEach((k, ki) => {
2476
+ if (typeof k !== 'object' || k === null || Array.isArray(k) || !('set' in k)) return;
2477
+ const kRec = k as Record<string, unknown>;
2478
+ const keyAt = `animations.${animName}.tracks[${ti}].keys[${ki}].set`;
2479
+ if (!isAttachmentTrack || slot === null) {
2480
+ problems.push({
2481
+ code: 'CONFIG_SET_COVERS_SLOT',
2482
+ object: keyAt,
2483
+ detail: `is ${JSON.stringify(kRec.set)} on a ${JSON.stringify(trRec.property)} track${slot === null ? '' : ` for slot "${slot}"`}; a "set" key belongs on a slot attachment track ({"slot": "<part>", "property": "attachment", "keys": [{"t": …, "set": "<name>" | null}]})`,
2484
+ });
2485
+ return;
2486
+ }
2487
+ if ('v' in kRec) {
2488
+ problems.push({
2489
+ code: 'CONFIG_FIELD_TYPE',
2490
+ object: keyAt,
2491
+ detail: `is declared beside v (${JSON.stringify(kRec.v)}); state either "set": "<name>" | null or "v": "<attachment>" | null on an attachment key, not both`,
2492
+ });
2493
+ return;
2494
+ }
2495
+ const setVal = kRec.set;
2496
+ let resolvedV: string | null = null;
2497
+ if (setVal === null) {
2498
+ if (!baseSlots.has(slot)) {
2499
+ problems.push({
2500
+ code: 'CONFIG_SET_COVERS_SLOT',
2501
+ object: keyAt,
2502
+ detail: `restores the base attachment on slot "${slot}", which is not a declared slot of the rig (declared slots: ${[...baseSlots.keys()].map((s) => `"${s}"`).join(', ')})`,
2503
+ });
2504
+ return;
2505
+ }
2506
+ resolvedV = baseSlots.get(slot) ?? null;
2507
+ rawSetKeysForBone.push({ t: kRec.t, setVal: null });
2508
+ } else if (typeof setVal !== 'string' || setVal === '') {
2509
+ problems.push({
2510
+ code: 'CONFIG_FIELD_TYPE',
2511
+ object: keyAt,
2512
+ detail: `is ${JSON.stringify(setVal)}; a non-empty alternate set name or null (the base pose) is required`,
2513
+ });
2514
+ return;
2515
+ } else {
2516
+ const entry = sets?.sets[setVal];
2517
+ if (entry === undefined) {
2518
+ problems.push({
2519
+ code: 'CONFIG_SET_COVERS_SLOT',
2520
+ object: keyAt,
2521
+ detail: `names alternate set "${setVal}" for slot "${slot}", which config.assemble.alternates does not declare (declared sets: ${declaredSets})`,
2522
+ });
2523
+ return;
2524
+ }
2525
+ if (Object.hasOwn(entry.slots, slot)) {
2526
+ resolvedV = entry.slots[slot];
2527
+ } else if (entry.hides?.includes(slot) === true) {
2528
+ resolvedV = null;
2529
+ } else {
2530
+ const covered = [...Object.keys(entry.slots), ...(entry.hides ?? [])].map((s) => `"${s}"`).join(', ') || 'none';
2531
+ problems.push({
2532
+ code: 'CONFIG_SET_COVERS_SLOT',
2533
+ object: keyAt,
2534
+ detail: `names alternate set "${setVal}" on slot "${slot}", which set "${setVal}" does not cover (set "${setVal}" covers: ${covered}); add "${slot}" to that set's parts (or hides) in config.assemble.alternates, or key a slot the set covers`,
2535
+ });
2536
+ return;
2537
+ }
2538
+ if (slotBone !== undefined && setBoneDeltas.get(setVal)?.has(slotBone) === true) {
2539
+ trackTouchesDrawnBone = true;
2540
+ }
2541
+ rawSetKeysForBone.push({ t: kRec.t, setVal });
2542
+ }
2543
+ const expanded: Record<string, unknown> = {};
2544
+ for (const [prop, val] of Object.entries(kRec)) {
2545
+ if (prop === 'set') expanded.v = resolvedV;
2546
+ else expanded[prop] = val;
2547
+ }
2548
+ keys[ki] = expanded;
2549
+ });
2550
+ if (
2551
+ problems.length === 0 &&
2552
+ trackTouchesDrawnBone &&
2553
+ slotBone !== undefined &&
2554
+ !existingRotateBones.has(slotBone) &&
2555
+ !synthesizedRotate.has(slotBone)
2556
+ ) {
2557
+ const rotKeys = rawSetKeysForBone.map((rk, idx) => {
2558
+ const d = rk.setVal === null ? 0 : (setBoneDeltas.get(rk.setVal)?.get(slotBone) ?? 0);
2559
+ return idx + 1 < rawSetKeysForBone.length ? { t: rk.t, v: [d], curve: 'stepped' } : { t: rk.t, v: [d] };
2560
+ });
2561
+ synthesizedRotate.set(slotBone, rotKeys);
2562
+ }
2563
+ });
2564
+ if (problems.length === 0) {
2565
+ for (const [boneName, rotKeys] of synthesizedRotate) {
2566
+ tracks.push({ bone: boneName, property: 'rotate', keys: rotKeys });
2567
+ }
2568
+ }
2569
+ }
2570
+ refuseIfAny(problems);
2571
+ }
2572
+
2337
2573
  /**
2338
2574
  * Re-parent every bone that hangs from a stopped link onto that link's drawn
2339
2575
  * link `<link>_d`, move slots and mesh weights from a stopped link onto
package/src/structure.ts CHANGED
@@ -72,7 +72,7 @@
72
72
  import { existsSync, readFileSync } from 'node:fs';
73
73
  import { CompileError } from 'rig-c/src/errors.ts';
74
74
  import { parseRigSpec, RIG_SPEC_VERSION, type RigBone as RigcBone, type RigSpec as RigcSpec, splitRigSkin } from 'rig-c/src/rig.ts';
75
- import { type ConfigConstraint, CONSTRAINT_FOLLOWS, parseConfig, parseProposalSections, type Point, ROOT_BONE, type SkeletonSections, weightsABone } from './config.ts';
75
+ import { type ConfigConstraint, CONSTRAINT_FOLLOWS, contourRegionsOf, parseConfig, parseProposalSections, type Point, ROOT_BONE, type SkeletonSections, weightsABone } from './config.ts';
76
76
  import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
77
77
  import { PartsError, type Problem, refuseIfAny } from './errors.ts';
78
78
  import { edgeLength } from './length.ts';
@@ -183,8 +183,11 @@ export function configRoles(s: SkeletonSections & { constraints?: readonly Confi
183
183
  if (typeof sp === 'string') for (const b of chains.get(sp) ?? [sp]) push(bound, b, at);
184
184
  else push(bound, sp[0], at);
185
185
  });
186
- // A contour mesh's region bone is weighted to, like a segment's (issue #84).
187
- if ('contour' in m) (m.contour.regions ?? []).forEach((rg, i) => push(bound, rg.bone, `meshes.${part}.contour.regions[${i}]`));
186
+ // A contour mesh's region bone is weighted to, like a segment's (issue #84; including contour.bust, issue #475).
187
+ if ('contour' in m) {
188
+ const explicitCount = (m.contour.regions ?? []).length;
189
+ contourRegionsOf(m.contour).forEach((rg, i) => push(bound, rg.bone, i < explicitCount ? `meshes.${part}.contour.regions[${i}]` : `meshes.${part}.contour.bust`));
190
+ }
188
191
  if ('auto' in m) (m.auto.regions ?? []).forEach((rg, i) => {
189
192
  // A density-only region binds no bone (issue #155).
190
193
  if (weightsABone(rg)) push(bound, rg.bone, `meshes.${part}.auto.regions[${i}]`);