@rig-c/parts 1.33.0 → 1.35.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/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';
@@ -1161,6 +1162,14 @@ export function buildRig(
1161
1162
  (m.contour.regions ?? []).forEach((rg, i) => {
1162
1163
  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
1164
  });
1165
+ if (m.contour.bust !== undefined) {
1166
+ for (const [k, bone] of [
1167
+ ['bone_r', m.contour.bust.bone_r ?? DEFAULT_BUST_BONE_R],
1168
+ ['bone_l', m.contour.bust.bone_l ?? DEFAULT_BUST_BONE_L],
1169
+ ] as const) {
1170
+ if (!B.has(bone)) fail('RIG_NAME_RESOLVES', `config.meshes.${part}.contour.bust.${k}`, `names the bone "${bone}", which config.bones does not declare`);
1171
+ }
1172
+ }
1164
1173
  }
1165
1174
  if ('auto' in m) {
1166
1175
  (m.auto.regions ?? []).forEach((rg, i) => {
@@ -1274,7 +1283,7 @@ export function buildRig(
1274
1283
  for (const segs of meshSegments.values()) for (const s of segs) meshBones.add(s.bone);
1275
1284
  // 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
1285
  // 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);
1286
+ for (const m of Object.values(cfg.meshes)) if ('contour' in m) for (const rg of contourRegionsOf(m.contour)) meshBones.add(rg.bone);
1278
1287
  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
1288
  const meshKeyed = controlledBones(motion, meshBones);
1280
1289
  const controls = idleKeys === 'ctl' ? meshKeyed : [];
@@ -1600,7 +1609,7 @@ export function buildRig(
1600
1609
  const h = img.height;
1601
1610
  const alpha = new Uint8Array(w * h);
1602
1611
  for (let i = 0; i < alpha.length; i++) alpha[i] = img.data[i * 4 + 3];
1603
- const regions = spec.regions ?? [];
1612
+ const regions = contourRegionsOf(spec);
1604
1613
  const inPart: ContourRegion[] = regions.map((rg) =>
1605
1614
  rg.shape === 'circle'
1606
1615
  ? { name: rg.name, shape: 'circle', cx: rg.cx - ox, cy: rg.cy - oy, r: rg.r, spacing: rg.spacing, band: rg.band }
@@ -1635,10 +1644,25 @@ export function buildRig(
1635
1644
  const li = localInfluences([wx, wy], vSegs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
1636
1645
  if ('first' in li) {
1637
1646
  if (!overlapped) {
1647
+ let bustHint = '';
1648
+ if (spec.bust !== undefined) {
1649
+ const need: number[] = [];
1650
+ for (const rg of [regions[li.first], regions[li.second]]) {
1651
+ if (rg.name === 'bust_r' || rg.name === 'bust_l') {
1652
+ const apex = rg.name === 'bust_r' ? spec.bust.apex_r : spec.bust.apex_l;
1653
+ const d = edgeLength(spec.bust.cleavage[0] - apex[0], spec.bust.cleavage[1] - apex[1]);
1654
+ const dist = edgeLength(wx - apex[0], wy - apex[1]);
1655
+ need.push(pyRound(d - dist, 2));
1656
+ }
1657
+ }
1658
+ if (need.length > 0) {
1659
+ bustHint = ` (contour.bust.clearance >= ${Math.min(...need)} clears vertex ${vi})`;
1660
+ }
1661
+ }
1638
1662
  out.push({
1639
1663
  code: 'RIG_CONTOUR_REGIONS_OVERLAP',
1640
1664
  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`,
1665
+ 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
1666
  });
1643
1667
  }
1644
1668
  overlapped = true;
@@ -2296,7 +2320,10 @@ export function buildRig(
2296
2320
  if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
2297
2321
  const faceImg = outImages.find(([k]) => k === 'face.png')?.[1];
2298
2322
  const facePart = parts.parts.find((p) => p.name === 'face');
2323
+ const partAreas: Record<string, number> = {};
2324
+ for (const p of parts.parts) partAreas[p.name] = p.opaque_px;
2299
2325
  bindFaceSurfaceDeform(motion, (merged) => {
2326
+ expandSetAttachmentKeys(rig, sets, merged);
2300
2327
  applyFaceSurfaceDeform(rig, merged, {
2301
2328
  imageWidth: faceImg?.width,
2302
2329
  imageHeight: faceImg?.height,
@@ -2304,6 +2331,9 @@ export function buildRig(
2304
2331
  opaqueHeight: facePart?.h,
2305
2332
  overrides: cfg.turn?.deform,
2306
2333
  hasAlternateFaceSet,
2334
+ turn: cfg.turn,
2335
+ partAreas,
2336
+ requireTurn: true,
2307
2337
  });
2308
2338
  if (sets !== undefined && (merged.animations.turn !== undefined || merged.animations.tilt !== undefined)) {
2309
2339
  for (const entry of Object.values(sets.sets)) {
@@ -2329,6 +2359,132 @@ export function buildRig(
2329
2359
  };
2330
2360
  }
2331
2361
 
2362
+ /**
2363
+ * Issue #473: expand `{"t": …, "set": "<name>" | null}` keys on slot attachment
2364
+ * tracks in `merged.animations` into `{"t": …, "v": "<attachment>" | null}`
2365
+ * keys against `rig.slots` and `sets.json`. When no key carries `"set"`, nothing
2366
+ * is touched and no byte moves.
2367
+ */
2368
+ function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefined, merged: MotionSpec): void {
2369
+ let hasAnySetKey = false;
2370
+ for (const [animName, anim] of Object.entries(merged.animations)) {
2371
+ if (animName === 'idle' || typeof anim !== 'object' || anim === null || Array.isArray(anim)) continue;
2372
+ const tracks = (anim as Record<string, unknown>).tracks;
2373
+ if (!Array.isArray(tracks)) continue;
2374
+ for (const tr of tracks) {
2375
+ if (typeof tr !== 'object' || tr === null || Array.isArray(tr)) continue;
2376
+ const keys = (tr as Record<string, unknown>).keys;
2377
+ if (!Array.isArray(keys)) continue;
2378
+ for (const k of keys) {
2379
+ if (typeof k === 'object' && k !== null && !Array.isArray(k) && 'set' in k) {
2380
+ hasAnySetKey = true;
2381
+ break;
2382
+ }
2383
+ }
2384
+ if (hasAnySetKey) break;
2385
+ }
2386
+ if (hasAnySetKey) break;
2387
+ }
2388
+ if (!hasAnySetKey) return;
2389
+
2390
+ const baseSlots = new Map<string, string | null>();
2391
+ for (const sl of rig.slots) {
2392
+ baseSlots.set(sl.name, sl.attachment ?? null);
2393
+ }
2394
+ const declaredSets =
2395
+ sets === undefined || Object.keys(sets.sets).length === 0
2396
+ ? 'none'
2397
+ : Object.keys(sets.sets)
2398
+ .map((n) => `"${n}"`)
2399
+ .join(', ');
2400
+ const problems: Problem[] = [];
2401
+
2402
+ for (const [animName, anim] of Object.entries(merged.animations)) {
2403
+ if (animName === 'idle' || typeof anim !== 'object' || anim === null || Array.isArray(anim)) continue;
2404
+ const tracks = (anim as Record<string, unknown>).tracks;
2405
+ if (!Array.isArray(tracks)) continue;
2406
+ tracks.forEach((tr, ti) => {
2407
+ if (typeof tr !== 'object' || tr === null || Array.isArray(tr)) return;
2408
+ const trRec = tr as Record<string, unknown>;
2409
+ const keys = trRec.keys;
2410
+ if (!Array.isArray(keys)) return;
2411
+ const slot = typeof trRec.slot === 'string' && trRec.slot !== '' ? trRec.slot : null;
2412
+ const isAttachmentTrack = trRec.property === 'attachment' && slot !== null;
2413
+ keys.forEach((k, ki) => {
2414
+ if (typeof k !== 'object' || k === null || Array.isArray(k) || !('set' in k)) return;
2415
+ const kRec = k as Record<string, unknown>;
2416
+ const keyAt = `animations.${animName}.tracks[${ti}].keys[${ki}].set`;
2417
+ if (!isAttachmentTrack || slot === null) {
2418
+ problems.push({
2419
+ code: 'CONFIG_SET_COVERS_SLOT',
2420
+ object: keyAt,
2421
+ 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}]})`,
2422
+ });
2423
+ return;
2424
+ }
2425
+ if ('v' in kRec) {
2426
+ problems.push({
2427
+ code: 'CONFIG_FIELD_TYPE',
2428
+ object: keyAt,
2429
+ detail: `is declared beside v (${JSON.stringify(kRec.v)}); state either "set": "<name>" | null or "v": "<attachment>" | null on an attachment key, not both`,
2430
+ });
2431
+ return;
2432
+ }
2433
+ const setVal = kRec.set;
2434
+ let resolvedV: string | null = null;
2435
+ if (setVal === null) {
2436
+ if (!baseSlots.has(slot)) {
2437
+ problems.push({
2438
+ code: 'CONFIG_SET_COVERS_SLOT',
2439
+ object: keyAt,
2440
+ 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(', ')})`,
2441
+ });
2442
+ return;
2443
+ }
2444
+ resolvedV = baseSlots.get(slot) ?? null;
2445
+ } else if (typeof setVal !== 'string' || setVal === '') {
2446
+ problems.push({
2447
+ code: 'CONFIG_FIELD_TYPE',
2448
+ object: keyAt,
2449
+ detail: `is ${JSON.stringify(setVal)}; a non-empty alternate set name or null (the base pose) is required`,
2450
+ });
2451
+ return;
2452
+ } else {
2453
+ const entry = sets?.sets[setVal];
2454
+ if (entry === undefined) {
2455
+ problems.push({
2456
+ code: 'CONFIG_SET_COVERS_SLOT',
2457
+ object: keyAt,
2458
+ detail: `names alternate set "${setVal}" for slot "${slot}", which config.assemble.alternates does not declare (declared sets: ${declaredSets})`,
2459
+ });
2460
+ return;
2461
+ }
2462
+ if (Object.hasOwn(entry.slots, slot)) {
2463
+ resolvedV = entry.slots[slot];
2464
+ } else if (entry.hides?.includes(slot) === true) {
2465
+ resolvedV = null;
2466
+ } else {
2467
+ const covered = [...Object.keys(entry.slots), ...(entry.hides ?? [])].map((s) => `"${s}"`).join(', ') || 'none';
2468
+ problems.push({
2469
+ code: 'CONFIG_SET_COVERS_SLOT',
2470
+ object: keyAt,
2471
+ 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`,
2472
+ });
2473
+ return;
2474
+ }
2475
+ }
2476
+ const expanded: Record<string, unknown> = {};
2477
+ for (const [prop, val] of Object.entries(kRec)) {
2478
+ if (prop === 'set') expanded.v = resolvedV;
2479
+ else expanded[prop] = val;
2480
+ }
2481
+ keys[ki] = expanded;
2482
+ });
2483
+ });
2484
+ }
2485
+ refuseIfAny(problems);
2486
+ }
2487
+
2332
2488
  /**
2333
2489
  * Re-parent every bone that hangs from a stopped link onto that link's drawn
2334
2490
  * 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}]`);