spine-rigc 1.2.3 → 1.4.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/types.ts CHANGED
@@ -1414,6 +1414,14 @@ export interface RigInfo {
1414
1414
  * muted constraint as before (issue #784).
1415
1415
  */
1416
1416
  consumerDrivenMix: Array<{ type: 'ik' | 'transform'; constraint: string; why: string }>;
1417
+ /**
1418
+ * The `why` of `invariants.idleDrivesMeshes`, or null when the rig does not
1419
+ * declare that its `idle` deforms meshes on purpose. Declared, `A15` SKIPs
1420
+ * with the renderer cost it measured, or FAILs when the `idle` keys no
1421
+ * mesh-driving bone and the declaration switches off nothing. A bare
1422
+ * `validate <dir>` has no rig, so null, and A15 refuses per bone as before.
1423
+ */
1424
+ idleDrivesMeshes: string | null;
1417
1425
  /**
1418
1426
  * The atlas region(s) the build names as its base plate — the one image
1419
1427
  * `A19_OVERLAY_PNGS_HAVE_ALPHA` lets be opaque — in compile order.
@@ -1557,6 +1565,28 @@ export interface CompileResult {
1557
1565
  * and a nose does not wobble.
1558
1566
  */
1559
1567
  soft?: { mask: string; digest: string; bone: string; carried: number; ramped: number };
1568
+ /**
1569
+ * How a `segments` mesh's bones share its vertices — the figures `build`
1570
+ * and `explain` print under its line. Only `segments` has one: it is the
1571
+ * generator whose weights are the whole point, and every other generator's
1572
+ * weighting is fixed by its kind.
1573
+ */
1574
+ influence?: {
1575
+ /** Most bones any one vertex binds, and the mean over the mesh. */
1576
+ maxBones: number;
1577
+ meanBones: number;
1578
+ /** Vertices bound to exactly one bone. */
1579
+ singleBone: number;
1580
+ /** The bones some vertex binds, in the order `bones` names them. */
1581
+ bound: string[];
1582
+ /** The lattice: its cell, cells across and down, cells with art, cells kept, islands joined. */
1583
+ cell: number;
1584
+ cols: number;
1585
+ rows: number;
1586
+ artCells: number;
1587
+ keptCells: number;
1588
+ islands: number;
1589
+ };
1560
1590
  /**
1561
1591
  * What a depth map put on this mesh's vertices, when one was named.
1562
1592
  *
package/src/validate.ts CHANGED
@@ -2317,23 +2317,57 @@ export function validate(input: ValidateInput): ValidateReport {
2317
2317
  });
2318
2318
 
2319
2319
  // --- A15: idle must not key a mesh-driving bone (dirty-skip lever) -----
2320
+ //
2321
+ // 🔑 The rule assumes meshes are mostly static: the renderer it serves skips
2322
+ // redrawing a mesh nothing moved, and an `idle` keying one of its bones spends
2323
+ // that skip on every frame. A painting rig is the genre where the assumption
2324
+ // is false by design (issues #855, #858) — one illustration in layers, most
2325
+ // of them weighted meshes, and an `idle` whose job is to move them — so the
2326
+ // rig can say so in `invariants.idleDrivesMeshes`, and the rule then reports
2327
+ // what the declaration costs instead of refusing each bone.
2320
2328
  check('A15_IDLE_NO_MESH_BONE_KEYS', () => {
2321
- const meshBoneNames = new Set<string>();
2322
- for (const slotIndex of meshSlots) meshBoneNames.add(data.slots[slotIndex].boneData.name);
2323
- // The slot's own bone is not the whole story once weights exist: a ring
2324
- // mesh is driven by its CONTROL bone, which is a different bone entirely.
2325
- // Checking only the slot bone would let `idle` key the one bone that
2326
- // actually dirties the canvas every frame.
2327
- for (const mesh of meshAttachments) {
2328
- if (!mesh.bones) continue;
2329
- for (let i = 0; i < mesh.bones.length; ) {
2330
- const boneCount = mesh.bones[i++];
2331
- for (let n = 0; n < boneCount; n++, i++) {
2332
- const bone = data.bones[mesh.bones[i]];
2333
- if (bone) meshBoneNames.add(bone.name);
2329
+ const A15 = 'A15_IDLE_NO_MESH_BONE_KEYS';
2330
+ /**
2331
+ * Every mesh attachment, loaded, with the bones that drive it — its slot's
2332
+ * bone, and, once weights exist, every bone its weights name. A ring mesh
2333
+ * is driven by its CONTROL bone, a different bone from the slot's, and
2334
+ * checking only the slot bone would let `idle` key the one bone that
2335
+ * actually dirties the canvas every frame.
2336
+ */
2337
+ const meshDrivers: Array<{ mesh: MeshAttachment; bones: Set<string> }> = [];
2338
+ for (const skin of data.skins) {
2339
+ for (const entry of skin.getAttachments()) {
2340
+ const mesh = entry.attachment;
2341
+ if (!(mesh instanceof MeshAttachment)) continue;
2342
+ const bones = new Set<string>([data.slots[entry.slotIndex].boneData.name]);
2343
+ if (mesh.bones) {
2344
+ for (let i = 0; i < mesh.bones.length; ) {
2345
+ const boneCount = mesh.bones[i++];
2346
+ for (let n = 0; n < boneCount; n++, i++) {
2347
+ const bone = data.bones[mesh.bones[i]];
2348
+ if (bone) bones.add(bone.name);
2349
+ }
2350
+ }
2334
2351
  }
2352
+ meshDrivers.push({ mesh, bones });
2335
2353
  }
2336
2354
  }
2355
+ const meshBoneNames = new Set<string>();
2356
+ for (const { bones } of meshDrivers) for (const name of bones) meshBoneNames.add(name);
2357
+ const declared = input.rig?.idleDrivesMeshes ?? null;
2358
+ /**
2359
+ * A declaration that switches off nothing is refused rather than skipped,
2360
+ * the standard `consumerDrivenMix` is held to: an opt-out that exempts
2361
+ * nothing reads exactly like one that worked, and the next reader cannot
2362
+ * tell the rig that needs it from the rig it was copied onto.
2363
+ */
2364
+ const stale = (why: string): void => {
2365
+ fail(
2366
+ A15,
2367
+ `the rig "${input.rig?.archetype}" declares invariants.idleDrivesMeshes ("${declared}"), but ${why}, so the ` +
2368
+ 'declaration switches off nothing — remove it',
2369
+ );
2370
+ };
2337
2371
  // The same shape as A06's and A17's guards, found by auditing for it
2338
2372
  // (#568): a rig with no `idle` at all has nothing here to be wrong, and a
2339
2373
  // rule that reports "held" over a subject that does not exist is the
@@ -2342,15 +2376,45 @@ export function validate(input: ValidateInput): ValidateReport {
2342
2376
  // versus one that keys no bone.
2343
2377
  const idle = isObj(raw?.animations) ? (raw.animations as Json).idle : undefined;
2344
2378
  if (!isObj(idle)) {
2345
- return skip('A15_IDLE_NO_MESH_BONE_KEYS', 'the skeleton declares no "idle" animation, so nothing here can key a mesh-driving bone');
2379
+ if (declared !== null) return stale('the skeleton declares no "idle" animation');
2380
+ return skip(A15, 'the skeleton declares no "idle" animation, so nothing here can key a mesh-driving bone');
2346
2381
  }
2347
2382
  if (!isObj(idle.bones)) {
2348
- return skip('A15_IDLE_NO_MESH_BONE_KEYS', '"idle" carries no bone timeline at all, so there is no key to hold against the mesh-driving bones');
2383
+ if (declared !== null) return stale('"idle" carries no bone timeline at all');
2384
+ return skip(A15, '"idle" carries no bone timeline at all, so there is no key to hold against the mesh-driving bones');
2349
2385
  }
2350
- for (const boneName of Object.keys(idle.bones as Json)) {
2351
- if (meshBoneNames.has(boneName)) {
2352
- fail('A15_IDLE_NO_MESH_BONE_KEYS', `idle keys bone "${boneName}", which drives a mesh — meshes never idle-skip`);
2353
- }
2386
+ const keyed = Object.keys(idle.bones as Json).filter((name) => meshBoneNames.has(name));
2387
+ if (declared !== null) {
2388
+ if (keyed.length === 0) return stale('"idle" keys no bone that drives a mesh');
2389
+ const keyedSet = new Set(keyed);
2390
+ const moved = meshDrivers.filter(({ bones }) => [...bones].some((name) => keyedSet.has(name)));
2391
+ // `worldVerticesLength` is two numbers per vertex whatever the encoding,
2392
+ // which is the count the runtime recomputes; reading `vertices.length`
2393
+ // would count a weighted mesh's bone entries instead.
2394
+ const vertices = moved.reduce((sum, { mesh }) => sum + mesh.worldVerticesLength / 2, 0);
2395
+ const SHOWN = 8;
2396
+ const names =
2397
+ keyed.slice(0, SHOWN).map((name) => `"${name}"`).join(', ') + (keyed.length > SHOWN ? ` +${keyed.length - SHOWN}` : '');
2398
+ return skip(
2399
+ A15,
2400
+ `declared by the rig ("${declared}"): idle keys ${keyed.length} bone(s) that drive ${moved.length} mesh ` +
2401
+ `attachment(s) totalling ${vertices} vertices — ${names} — and each of those meshes is recomputed on every ` +
2402
+ 'frame it is shown',
2403
+ );
2404
+ }
2405
+ for (const [i, boneName] of keyed.entries()) {
2406
+ // The case is named once, on the first line an agent reads, rather than
2407
+ // after every bone: the first painting rig this met printed 42 of these,
2408
+ // and 42 identical sentences read as 42 separate mistakes. It rides the
2409
+ // first finding rather than being a finding of its own, so the count of
2410
+ // FAIL lines is still the count of bones.
2411
+ const hint =
2412
+ i > 0
2413
+ ? ''
2414
+ : `. ${keyed.length} bone(s) keyed by idle drive meshes; if this idle is meant to deform them (a painting ` +
2415
+ 'rig), declare invariants.idleDrivesMeshes: { "why": … } in the rig spec — or, where the motion belongs to ' +
2416
+ 'a pivot above the mesh, key that pivot one link up (FACE.md §3)';
2417
+ fail(A15, `idle keys bone "${boneName}", which drives a mesh — meshes never idle-skip${hint}`);
2354
2418
  }
2355
2419
  });
2356
2420
 
@@ -2532,13 +2596,26 @@ export function validate(input: ValidateInput): ValidateReport {
2532
2596
  // a linked mesh has no rim of its OWN at all — the one it draws belongs to
2533
2597
  // its source, and is measured there. Nothing to measure is a SKIP — never
2534
2598
  // a pass, and never a failure on somebody else's correct geometry.
2535
- const measurable = meshAttachments.filter((m) => m.bones && kindOf(m) !== 'authored');
2599
+ //
2600
+ // 🔸 A `segments` mesh is rigc's own geometry and still has no rim to pin:
2601
+ // its outline is traced off the art and weighted by distance like every
2602
+ // other vertex, because a layer pulled by named bones is SUPPOSED to move
2603
+ // at its edge. "Pinned to the slot bone" is not a claim that generator
2604
+ // makes, so there is nothing here to hold it to — and `A20`'s coherence
2605
+ // rules, which it does make, still apply to it in full.
2606
+ const rimless = (m: MeshAttachment): boolean => kindOf(m) === 'authored' || kindOf(m) === 'segments';
2607
+ const measurable = meshAttachments.filter((m) => m.bones && !rimless(m));
2536
2608
  if (measurable.length === 0) {
2537
2609
  const authored = authoredMeshNames(meshAttachments.filter((m) => m.bones));
2610
+ const segmented = meshAttachments.filter((m) => m.bones && kindOf(m) === 'segments').map((m) => `"${m.name}"`);
2538
2611
  return skip(
2539
2612
  'A21_MESH_RIM_PINNED',
2540
- `every weighted mesh here is authored or linked geometry (${authored.join(', ')}), not a rigc ring or ` +
2541
- 'ribbon — rigc did not place its rim, so it has no rim of its own to find unpinned',
2613
+ segmented.length === 0
2614
+ ? `every weighted mesh here is authored or linked geometry (${authored.join(', ')}), not a rigc ring or ` +
2615
+ 'ribbon — rigc did not place its rim, so it has no rim of its own to find unpinned'
2616
+ : `every weighted mesh here is ${authored.length ? `authored or linked geometry (${authored.join(', ')}) or ` : ''}` +
2617
+ `a "segments" lattice (${segmented.join(', ')}), not a rigc ring or ribbon — a segments mesh weights its ` +
2618
+ 'outline by distance to the bones it names, so it has no rim pinned to the slot bone to find unpinned',
2542
2619
  );
2543
2620
  }
2544
2621
  for (const mesh of measurable) {
@@ -5831,12 +5908,12 @@ export function validate(input: ValidateInput): ValidateReport {
5831
5908
  // of one whose meshes are contours — and the last two are cases where a
5832
5909
  // reader should know that a mesh went unmeasured on purpose.
5833
5910
  const unpaired = Object.entries(kinds)
5834
- .filter(([, kind]) => kind === 'authored' || kind === 'contour')
5911
+ .filter(([, kind]) => kind === 'authored' || kind === 'contour' || kind === 'segments')
5835
5912
  .map(([slot, kind]) => `"${slot}" (${kind})`);
5836
5913
  return skip(
5837
5914
  'A28_RIBBON_ROWS_SHARE_WEIGHTS',
5838
5915
  unpaired.length
5839
- ? `the rig "${input.rig.archetype}" declares no ribbon mesh on this cut — its mesh slot(s) ${unpaired.join(', ')} have no rows to pair: authored geometry is somebody else's topology, and a contour is one silhouette loop`
5916
+ ? `the rig "${input.rig.archetype}" declares no ribbon mesh on this cut — its mesh slot(s) ${unpaired.join(', ')} have no rows to pair: authored geometry is somebody else's topology, a contour is one silhouette loop, and a segments lattice has cells rather than cross rows`
5840
5917
  : `the rig "${input.rig.archetype}" declares no ribbon mesh on this cut`,
5841
5918
  );
5842
5919
  }