@rig-c/parts 1.25.0 → 1.27.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
@@ -388,6 +388,57 @@ export function roundShares(list: readonly Influence[]): Influence[] {
388
388
  return kept;
389
389
  }
390
390
 
391
+ /**
392
+ * Issue #392: on a lattice mesh whose segments include sibling branch chains
393
+ * forked from the same chain link with `r >= grid` (`split.hold_tip: true`),
394
+ * the distance rule still concentrates weights along the fork row where the
395
+ * trunk splits into branches and across adjacent trees' ruffled hem steps.
396
+ * Four Laplacian passes (`FORK_SMOOTH_PASSES`, self share 0.5, floor 0.005, cap
397
+ * 8 influences) diffuse each vertex's shares across its triangle neighbours
398
+ * before rounding with {@link roundShares}.
399
+ */
400
+ export const FORK_SMOOTH_PASSES = 4;
401
+ export const FORK_SMOOTH_SELF = 0.5;
402
+ export const FORK_SMOOTH_FLOOR = 0.005;
403
+ export const FORK_SMOOTH_MAX_INFLUENCES = 8;
404
+
405
+ export function smoothForkShares(raw: ReadonlyArray<readonly Influence[]>, triangles: readonly number[]): Influence[][] {
406
+ const adj: number[][] = raw.map(() => []);
407
+ const addEdge = (u: number, v: number): void => {
408
+ if (!adj[u].includes(v)) adj[u].push(v);
409
+ if (!adj[v].includes(u)) adj[v].push(u);
410
+ };
411
+ for (let i = 0; i + 2 < triangles.length; i += 3) {
412
+ const a = triangles[i];
413
+ const b = triangles[i + 1];
414
+ const c = triangles[i + 2];
415
+ addEdge(a, b);
416
+ addEdge(b, c);
417
+ addEdge(c, a);
418
+ }
419
+ let cur: Influence[][] = raw.map((s) => [...s]);
420
+ for (let p = 0; p < FORK_SMOOTH_PASSES; p++) {
421
+ cur = cur.map((own, vi) => {
422
+ const nb = adj[vi];
423
+ if (nb.length === 0) return own;
424
+ const m = new Map<string, number>();
425
+ for (const { bone, weight } of own) m.set(bone, weight * FORK_SMOOTH_SELF);
426
+ const nbShare = (1 - FORK_SMOOTH_SELF) / nb.length;
427
+ for (const u of nb) {
428
+ for (const { bone, weight } of cur[u]) m.set(bone, (m.get(bone) ?? 0) + weight * nbShare);
429
+ }
430
+ const kept = [...m.entries()]
431
+ .filter(([, w]) => w >= FORK_SMOOTH_FLOOR)
432
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
433
+ .slice(0, FORK_SMOOTH_MAX_INFLUENCES);
434
+ const sum = kept.reduce((s, [, w]) => s + w, 0);
435
+ return roundShares(kept.map(([bone, w]) => ({ bone, weight: w / sum })));
436
+ });
437
+ }
438
+ return cur;
439
+ }
440
+
441
+
391
442
  interface Bone {
392
443
  name: string;
393
444
  parent: string | null;
@@ -957,7 +1008,8 @@ export function buildRig(
957
1008
  }
958
1009
  e.points.forEach(([x, y], k) => {
959
1010
  const n = `${e.chain}${k}`;
960
- B.set(n, { name: n, parent: k === 0 ? e.parent : names[names.length - 1], x, y });
1011
+ const inherit = e.link_inherit?.[n];
1012
+ B.set(n, { name: n, parent: k === 0 ? e.parent : names[names.length - 1], x, y, ...(inherit === undefined ? {} : { inherit }) });
961
1013
  if (names.length > 0) child.set(names[names.length - 1], [x, y]);
962
1014
  names.push(n);
963
1015
  });
@@ -1854,6 +1906,7 @@ export function buildRig(
1854
1906
  const segsAt = dressSegsAt(p, segs);
1855
1907
  let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
1856
1908
  let emptiedMore = 0;
1909
+ const rawShares: Influence[][] = [];
1857
1910
  for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
1858
1911
  const wx = vx0 + ox;
1859
1912
  const wy = vy0 + oy;
@@ -1865,7 +1918,26 @@ export function buildRig(
1865
1918
  else emptiedMore++;
1866
1919
  continue;
1867
1920
  }
1868
- const ent: WeightEntry[] = (limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares)).map(({ bone, weight }) => {
1921
+ rawShares.push(limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares));
1922
+ }
1923
+ if (emptied !== null) {
1924
+ problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
1925
+ continue;
1926
+ }
1927
+ const allChainLinks = new Set([...chains.values()].flat());
1928
+ const forkCounts = new Map<string, number>();
1929
+ for (const sp of mesh.segments) {
1930
+ if (typeof sp !== 'string') continue;
1931
+ const links = chains.get(sp);
1932
+ const par = links !== undefined && links.length > 0 ? (B.get(links[0])?.parent ?? null) : null;
1933
+ if (par !== null && allChainLinks.has(par)) forkCounts.set(par, (forkCounts.get(par) ?? 0) + 1);
1934
+ }
1935
+ const heldFork = heat === null && mesh.r >= mesh.grid && mesh.influences === undefined && [...forkCounts.values()].some((c) => c >= 2);
1936
+ const finalShares = heldFork ? smoothForkShares(rawShares, lm.triangles) : rawShares;
1937
+ for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
1938
+ const wx = vx0 + ox;
1939
+ const wy = vy0 + oy;
1940
+ const ent: WeightEntry[] = finalShares[vi].map(({ bone, weight }) => {
1869
1941
  const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
1870
1942
  return { bone, x: places(x), y: places(y), weight };
1871
1943
  });
@@ -1873,10 +1945,6 @@ export function buildRig(
1873
1945
  infl += ent.length;
1874
1946
  maxInfl = Math.max(maxInfl, ent.length);
1875
1947
  }
1876
- if (emptied !== null) {
1877
- problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
1878
- continue;
1879
- }
1880
1948
  const uvs: number[] = [];
1881
1949
  for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
1882
1950
  skin[p.name] = { [p.name]: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights } };
package/src/scene.ts CHANGED
@@ -16,12 +16,13 @@
16
16
  * `rig/images/`, `parts.json` and `check/check.json`:
17
17
  *
18
18
  * - **Names, totally.** Every bone, slot, attachment (skin entry), constraint
19
- * (its name and every bone or slot it names), `invariants.detached` bone,
19
+ * (its name and every bone or slot it names), `stops` entry (its `chain`,
20
+ * its `links` and its `against`; issue #431), `invariants.detached` bone,
20
21
  * `invariants.axes` entry (its name, its `through` and its `bones`; issue #239),
21
22
  * track, group and easing of character `<id>` is renamed `<id>:<name>`
22
23
  * ({@link prefixed}); `root` is the one shared root. A name resolves to
23
24
  * exactly one character, and nothing resolves across characters without its
24
- * prefix: a constraint naming a bone its own build does not declare is
25
+ * prefix: a constraint or stop naming a bone its own build does not declare is
25
26
  * refused (`SCENE_NAME_PREFIXED` when another character has it,
26
27
  * `SCENE_NAME_RESOLVES` when none does).
27
28
  * - **Images are the one name that cannot carry `:`.** rig-c names an
@@ -44,8 +45,8 @@
44
45
  * is local to a bone the shift already moved. In Spine's axes it is the
45
46
  * character's stage corner carried to the canvas's ({@link rootShift}),
46
47
  * through `src/coords.ts`. The root is shared, so a character whose idle
47
- * keys it, or whose constraint names it, is refused (`SCENE_ROOT_SHARED`):
48
- * it would move every character.
48
+ * keys it, or whose constraint or stop names it, is refused
49
+ * (`SCENE_ROOT_SHARED`): it would move every character.
49
50
  * - **The scale, with the offset (issue #197).** `characters[].scale`, a
50
51
  * positive number and 1 when absent, sizes the character about its stage's
51
52
  * top-left corner: a point p of its root frame lands at `scale * p + shift`
@@ -53,7 +54,8 @@
53
54
  * Spine's own loader multiplies by its `scale` (`SkeletonJson.js`): bone
54
55
  * `x`, `y`, `length`; region `x`, `y` (and its drawn size, through
55
56
  * `scaleX`/`scaleY`); each weight's bind `x`, `y`; the constraints' lengths
56
- * ({@link scaledConstraint}); the translate keys and their curves'
57
+ * ({@link scaledConstraint}); each stop's `at` offset in its `against` bone's
58
+ * local frame ({@link scaledStop}; issue #431); the translate keys and their curves'
57
59
  * values. Spine also scales the skeleton's `referenceScale`, which the
58
60
  * composed skeleton holds once for every character, so a physics
59
61
  * constraint with a nonzero `wind` or `gravity` cannot be scaled per
@@ -91,6 +93,7 @@
91
93
  */
92
94
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
93
95
  import { dirname, join, resolve } from 'node:path';
96
+ import type { RigStop } from 'rig-c/src/rig.ts';
94
97
  import { checkStage, gateThroughRigc, type Log } from './build.ts';
95
98
  import { ACCEPTABLE_BARS, barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackMode, requirementsNotPass, type RigcRunner } from './check.ts';
96
99
  import { CONSTRAINT_BONE_FIELDS, ROOT_BONE } from './config.ts';
@@ -382,7 +385,9 @@ export interface CharacterBuild {
382
385
  const BONE_KEYS = ['name', 'parent', 'length', 'rotation', 'x', 'y'];
383
386
  const SLOT_KEYS = ['name', 'bone', 'attachment'];
384
387
  const ATTACHMENT_KEYS = ['type', 'image', 'width', 'height', 'uvs', 'triangles', 'hull', 'weights', 'x', 'y', 'rotation'];
385
- const RIG_KEYS = ['spec', 'name', 'images', 'skeleton', 'bones', 'slots', 'skins', 'constraints', 'invariants'];
388
+ const RIG_KEYS = ['spec', 'name', 'images', 'skeleton', 'bones', 'slots', 'skins', 'constraints', 'stops', 'invariants'];
389
+ const STOP_KEYS = ['chain', 'links', 'against', 'at', 'inward', 'outward', 'lower'];
390
+ const STOP_LOWER_KEYS = ['inward', 'outward'];
386
391
  const INVARIANT_KEYS = ['idleDrivesMeshes', 'detached', 'axes'];
387
392
  const MOTION_KEYS = ['spec', 'archetype', 'cut', 'easings', 'groups', 'animations'];
388
393
  const IDLE_KEYS = ['duration', 'loop', 'note', 'tracks'];
@@ -501,6 +506,48 @@ export function readCharacterBuild(c: SceneCharacter, problems: Problem[], accep
501
506
  problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} rig.json bones[0]`, detail: `is ${show(root)}; {"name": "${ROOT_BONE}", "x": 0, "y": 0}, as the rig stage writes it, is required — the composed root is shared, so a character's root must carry no transform of its own` });
502
507
  }
503
508
  (Array.isArray(r.slots) ? r.slots : []).forEach((s, i) => unknown(`rig.json slots[${i}]`, s, SLOT_KEYS));
509
+ if ('stops' in r) {
510
+ if (!Array.isArray(r.stops)) {
511
+ problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} rig.json stops`, detail: `is ${show(r.stops)}; an array of stop entries is required` });
512
+ } else {
513
+ r.stops.forEach((st, i) => {
514
+ const where = `rig.json stops[${i}]`;
515
+ unknown(where, st, STOP_KEYS);
516
+ if (!isRecord(st)) return;
517
+ if (typeof st.chain !== 'string' || st.chain === '') {
518
+ problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${where}.chain`, detail: `is ${show(st.chain)}; a non-empty chain name is required` });
519
+ }
520
+ if (!(Array.isArray(st.links) && st.links.length > 0 && st.links.every((l) => typeof l === 'string' && l !== ''))) {
521
+ problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${where}.links`, detail: `is ${show(st.links)}; a non-empty list of bone names is required` });
522
+ }
523
+ if (!('against' in st)) {
524
+ problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${where}.against`, detail: 'is absent; a bone name is required' });
525
+ }
526
+ if (!(Array.isArray(st.at) && st.at.length === 2 && st.at.every((n) => typeof n === 'number' && Number.isFinite(n)))) {
527
+ problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${where}.at`, detail: `is ${show(st.at)}; [x, y], two finite numbers in against's local frame, is required` });
528
+ }
529
+ const checkBounds = (bWhere: string, bObj: Record<string, unknown>): void => {
530
+ const inOk = typeof bObj.inward === 'number' && Number.isFinite(bObj.inward);
531
+ const outOk = typeof bObj.outward === 'number' && Number.isFinite(bObj.outward);
532
+ if (!inOk) problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${bWhere}.inward`, detail: `is ${show(bObj.inward)}; a finite number of degrees is required` });
533
+ if (!outOk) problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${bWhere}.outward`, detail: `is ${show(bObj.outward)}; a finite number of degrees is required` });
534
+ if (inOk && outOk && (bObj.inward as number) > (bObj.outward as number)) {
535
+ problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${bWhere}.inward`, detail: `is ${String(bObj.inward)}, above outward ${String(bObj.outward)}; inward <= outward is required` });
536
+ }
537
+ };
538
+ checkBounds(where, st);
539
+ if ('lower' in st) {
540
+ unknown(`${where}.lower`, st.lower, STOP_LOWER_KEYS);
541
+ if (isRecord(st.lower)) checkBounds(`${where}.lower`, st.lower);
542
+ if (Array.isArray(st.links) && st.links.length === 1) {
543
+ problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${where}.lower`, detail: `is ${show(st.lower)} on a 1-link stop; lower holds the links below the first, so leave lower out when links has 1 bone` });
544
+ }
545
+ } else if (Array.isArray(st.links) && st.links.length > 1) {
546
+ problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${where}.lower`, detail: `is absent and required when the stop holds ${st.links.length} links (more than 1); state lower: { inward, outward } for the links below the first` });
547
+ }
548
+ });
549
+ }
550
+ }
504
551
  if (isRecord(r.invariants)) unknown('rig.json invariants', r.invariants, INVARIANT_KEYS);
505
552
  const skins = isRecord(r.skins) ? r.skins : {};
506
553
  unknown('rig.json skins', skins, ['default']);
@@ -732,6 +779,12 @@ export function scaledConstraint(con: Record<string, unknown>, s: number): Recor
732
779
  return out;
733
780
  }
734
781
 
782
+ /** A stop of a character at scale `s` (issue #431): its `at` offset in `against`'s local frame scaled by `s`, its degree bounds unchanged; `s` 1 returns `stop` as given. */
783
+ export function scaledStop(stop: RigStop, s: number): RigStop {
784
+ if (s === 1) return stop;
785
+ return { ...stop, at: [scaledBy(stop.at[0], s), scaledBy(stop.at[1], s)] };
786
+ }
787
+
735
788
  /** A bone track's keys at scale `s`: a length property's values, and its curve's value control points, scaled; any other track as given. */
736
789
  function scaledTrack<T extends { property: string; keys: unknown[] }>(t: T, s: number): T {
737
790
  if (s === 1 || !SCALED_TRACK_PROPERTIES.includes(t.property)) return t;
@@ -962,6 +1015,11 @@ export function composeScene(
962
1015
  fail('SCENE_NAME_RESOLVES', `${where}.slot`, `names the slot "${con.slot}", which character "${b.id}" does not declare`);
963
1016
  }
964
1017
  });
1018
+ (b.rig.stops ?? []).forEach((st, k) => {
1019
+ const where = `character "${b.id}" rig.json stops[${k}] (stop "${String(st.chain)}")`;
1020
+ st.links.forEach((n, j) => ownBone(b.id, n, `${where}.links[${j}]`));
1021
+ ownBone(b.id, st.against, `${where}.against`);
1022
+ });
965
1023
  for (const [k, d] of (b.rig.invariants?.detached ?? []).entries()) {
966
1024
  ownBone(b.id, d.bone, `character "${b.id}" rig.json invariants.detached[${k}].bone`);
967
1025
  ownBone(b.id, d.notUnder, `character "${b.id}" rig.json invariants.detached[${k}].notUnder`);
@@ -988,6 +1046,11 @@ export function composeScene(
988
1046
  });
989
1047
  if (named.includes(ROOT_BONE)) fail('SCENE_ROOT_SHARED', `${where} rig.json constraints[${k}] (${String(con.type)} "${String(con.name)}")`, `names "${ROOT_BONE}", which the scene shares by every character; a constraint on it, or following it, would reach every character — name a bone of the character's own`);
990
1048
  });
1049
+ (b.rig.stops ?? []).forEach((st, k) => {
1050
+ if ([...st.links, st.against].includes(ROOT_BONE)) {
1051
+ fail('SCENE_ROOT_SHARED', `${where} rig.json stops[${k}] (stop "${String(st.chain)}")`, `names "${ROOT_BONE}", which the scene shares by every character; a stop on it, or against it, would reach every character — name a bone of the character's own`);
1052
+ }
1053
+ });
991
1054
  }
992
1055
  const whys = [...new Set(ready.map((b) => b.rig.invariants?.idleDrivesMeshes?.why).filter((w): w is string => w !== undefined))];
993
1056
  if (whys.length > 1) {
@@ -1034,6 +1097,7 @@ export function composeScene(
1034
1097
  images.push([PLATE_IMAGE, plate.bytes]);
1035
1098
  }
1036
1099
  const constraints: Array<Record<string, unknown>> = [];
1100
+ const stops: RigStop[] = [];
1037
1101
  const detached: NonNullable<NonNullable<RigSpec['invariants']>['detached']> = [];
1038
1102
  const axes: NonNullable<NonNullable<RigSpec['invariants']>['axes']> = [];
1039
1103
  scene.characters.forEach((c) => {
@@ -1043,7 +1107,8 @@ export function composeScene(
1043
1107
  const [dx, dy] = rootShift(b.rig.skeleton, stage, c.offset, s);
1044
1108
  // At a scale other than 1 every length the character carries is scaled (issue #197): each bone's x, y and length,
1045
1109
  // each region's x, y (and its drawn size, by scaleX/scaleY), each weight's bind x, y, the constraints' lengths
1046
- // (scaledConstraint) and the translate keys (scaledTrack); what sits in root's own frame is then shifted.
1110
+ // (scaledConstraint), each stop's at offset in against's local frame (scaledStop; issue #431) and the translate keys
1111
+ // (scaledTrack); what sits in root's own frame is then shifted.
1047
1112
  for (const bone of b.rig.bones) {
1048
1113
  if (bone.name === ROOT_BONE) continue;
1049
1114
  const first = bone.parent === ROOT_BONE;
@@ -1095,6 +1160,15 @@ export function composeScene(
1095
1160
  if (type === 'path' && typeof con.slot === 'string') out.slot = prefixed(id, con.slot);
1096
1161
  constraints.push(out);
1097
1162
  }
1163
+ for (const st of b.rig.stops ?? []) {
1164
+ const scaled = scaledStop(st, s);
1165
+ stops.push({
1166
+ ...scaled,
1167
+ chain: prefixed(id, st.chain),
1168
+ links: st.links.map((l) => P(id, l)),
1169
+ against: P(id, st.against),
1170
+ });
1171
+ }
1098
1172
  for (const d of b.rig.invariants?.detached ?? []) detached.push({ ...d, bone: P(id, d.bone), notUnder: P(id, d.notUnder) });
1099
1173
  // An axis entry's name is unique within one rig, and two characters each carry a "face": the name is prefixed too.
1100
1174
  for (const ax of b.rig.invariants?.axes ?? []) axes.push({ name: prefixed(id, ax.name), through: [P(id, ax.through[0]), P(id, ax.through[1])], bones: ax.bones.map((n) => P(id, n)), tolerance: ax.tolerance });
@@ -1106,6 +1180,7 @@ export function composeScene(
1106
1180
  const name = ids.join('+');
1107
1181
  const rig: RigSpec = { spec: 'rigc-rig/1', name, images: 'images', skeleton: stage, bones, slots, skins: { default: skin as unknown as RigSpec['skins']['default'] } };
1108
1182
  if (constraints.length > 0) rig.constraints = constraints;
1183
+ if (stops.length > 0) rig.stops = stops;
1109
1184
  const invariants: NonNullable<RigSpec['invariants']> = {};
1110
1185
  if (whys.length === 1) invariants.idleDrivesMeshes = { why: whys[0] };
1111
1186
  if (detached.length > 0) invariants.detached = detached;
@@ -1251,6 +1326,11 @@ export function unprefixedNames(rig: RigSpec, motion: MotionSpec, ids: readonly
1251
1326
  }
1252
1327
  if (c.type === 'path') name(`constraint ${String(c.name)} slot`, c.slot, false);
1253
1328
  }
1329
+ for (const s of rig.stops ?? []) {
1330
+ name('stop chain', s.chain, false);
1331
+ for (const l of s.links) name(`stop ${s.chain} link`, l, false);
1332
+ name(`stop ${s.chain} against`, s.against, false);
1333
+ }
1254
1334
  for (const d of rig.invariants?.detached ?? []) {
1255
1335
  name('invariants.detached bone', d.bone, false);
1256
1336
  name('invariants.detached notUnder', d.notUnder, false);
package/src/squeeze.ts CHANGED
@@ -318,7 +318,7 @@ export function branchSqueezes(
318
318
  }
319
319
 
320
320
  for (const [mname, m] of Object.entries(spec.meshes)) {
321
- if ('contour' in m || 'auto' in m || m.rule === 'heat') continue;
321
+ if ('contour' in m || 'auto' in m || m.rule === 'heat' || m.r >= m.grid) continue;
322
322
  const byFork = new Map<string, string[]>();
323
323
  for (const sp of m.segments) {
324
324
  if (typeof sp !== 'string') continue;