@rig-c/parts 1.24.0 → 1.26.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,11 +132,11 @@
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, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, type RibsSpec, ROOT_BONE, weightsABone } from './config.ts';
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';
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';
139
- import type { RigBoneInherit } from 'rig-c/src/rig.ts';
139
+ import type { RigBoneInherit, RigStop } from 'rig-c/src/rig.ts';
140
140
  import { f32 } from 'rig-c/compile';
141
141
  import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
142
142
  import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
@@ -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;
@@ -450,6 +501,12 @@ export interface RigSpec {
450
501
  * when the config has the field.
451
502
  */
452
503
  constraints?: Array<Record<string, unknown>>;
504
+ /**
505
+ * `config.stops` (issue #404), in the order written, each with its `links`
506
+ * expanded from `chain` when omitted and its records and annotations left out
507
+ * ({@link stopForRig}). Written only when the config has the field.
508
+ */
509
+ stops?: RigStop[];
453
510
  /**
454
511
  * `idleDrivesMeshes`: written only under `idleKeys: 'direct'`, and only when
455
512
  * the idle keys a mesh-driving bone. `detached`: written only when a
@@ -951,7 +1008,8 @@ export function buildRig(
951
1008
  }
952
1009
  e.points.forEach(([x, y], k) => {
953
1010
  const n = `${e.chain}${k}`;
954
- 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 }) });
955
1013
  if (names.length > 0) child.set(names[names.length - 1], [x, y]);
956
1014
  names.push(n);
957
1015
  });
@@ -1033,6 +1091,17 @@ export function buildRig(
1033
1091
  if (!byPart.has(part)) fail('RIG_PART_PRESENT', `config.regions.${part}`, `names the part "${part}", which is not in parts.json; ${where(part)}`);
1034
1092
  if (!B.has(bone)) fail('RIG_NAME_RESOLVES', `config.regions.${part}`, `names the bone "${bone}", which config.bones does not declare`);
1035
1093
  }
1094
+ if (cfg.stops !== undefined) {
1095
+ cfg.stops.forEach((s, i) => {
1096
+ const at = `config.stops[${i}]`;
1097
+ if (!chains.has(s.chain)) fail('RIG_NAME_RESOLVES', `${at}.chain`, `names the chain "${s.chain}", which config.bones does not declare`);
1098
+ const links = s.links ?? chains.get(s.chain) ?? [];
1099
+ links.forEach((l, k) => {
1100
+ if (!B.has(l)) fail('RIG_NAME_RESOLVES', `${at}.links[${k}]`, `names the bone "${l}", which config.bones does not declare`);
1101
+ });
1102
+ if (!B.has(s.against)) fail('RIG_NAME_RESOLVES', `${at}.against`, `names the bone "${s.against}", which config.bones does not declare`);
1103
+ });
1104
+ }
1036
1105
  for (const p of parts.parts) {
1037
1106
  const inM = p.name in cfg.meshes;
1038
1107
  const inR = p.name in cfg.regions;
@@ -1377,6 +1446,31 @@ export function buildRig(
1377
1446
  return [{ chain, points: line.map(([x, y]) => [x - ox, y - oy] as [number, number]), stations: declared.stations }];
1378
1447
  });
1379
1448
  };
1449
+ // Issue #417: on a declared dress (a topwear mesh carrying chest, hip and hip-rooted skirt tree chains, as
1450
+ // cloth.bottomwear.part drafts), skirt tree links carry no weight on vertices above the hip line (wy < hip.y): the
1451
+ // bodice is weighted by the torso's own bones (chest, hip, and any local region like a bust) at any influences cap;
1452
+ // below the hip line nothing changes.
1453
+ const hipChainLinks = new Set<string>();
1454
+ for (const links of chains.values()) {
1455
+ for (const l of links) {
1456
+ let cur: string | null = B.get(l)?.parent ?? null;
1457
+ while (cur !== null && cur !== 'hip' && cur !== 'chest') cur = B.get(cur)?.parent ?? null;
1458
+ if (cur === 'hip') hipChainLinks.add(l);
1459
+ }
1460
+ }
1461
+ const dressSegsAt = (p: PartsFile['parts'][number], segs: Segment[]): ((wy: number) => Segment[]) => {
1462
+ const hipBone = B.get('hip');
1463
+ if (hipBone === undefined) return () => segs;
1464
+ const isDress =
1465
+ tagOf(p.from) === 'topwear' &&
1466
+ segs.some((s) => s.bone === 'chest') &&
1467
+ segs.some((s) => s.bone === 'hip') &&
1468
+ segs.some((s) => hipChainLinks.has(s.bone));
1469
+ if (!isDress) return () => segs;
1470
+ const torso = segs.filter((s) => !hipChainLinks.has(s.bone));
1471
+ if (torso.length === 0) return () => segs;
1472
+ return (wy: number): Segment[] => (wy < hipBone.y ? torso : segs);
1473
+ };
1380
1474
  // A contour mesh (issue #84): `src/contour.ts` over the padded image, every refusal collected with the others;
1381
1475
  // the weights by `src/localweights.ts`; slot, uvs, bind positions and by-name weights written as the lattice's.
1382
1476
  // The config's region numbers are rig px; the part image's are rig px less (p.x − PAD, p.y − PAD) — a
@@ -1420,6 +1514,7 @@ export function buildRig(
1420
1514
  out.push(...heat);
1421
1515
  return null;
1422
1516
  }
1517
+ const segsAt = dressSegsAt(p, segs);
1423
1518
  const weights: WeightEntry[][] = [];
1424
1519
  const reached = regions.map(() => 0);
1425
1520
  const whole = regions.map(() => 0);
@@ -1431,7 +1526,8 @@ export function buildRig(
1431
1526
  cm.vertices.forEach(([vx, vy], vi) => {
1432
1527
  const wx = vx + ox;
1433
1528
  const wy = vy + oy;
1434
- const li = localInfluences([wx, wy], segs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
1529
+ const vSegs = segsAt(wy);
1530
+ const li = localInfluences([wx, wy], vSegs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
1435
1531
  if ('first' in li) {
1436
1532
  if (!overlapped) {
1437
1533
  out.push({
@@ -1445,7 +1541,7 @@ export function buildRig(
1445
1541
  }
1446
1542
  if (li.influences.length === 0) {
1447
1543
  // issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be); a region's bone would be kept.
1448
- if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx, vy], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
1544
+ if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], vSegs, r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx, vy], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
1449
1545
  else emptiedMore++;
1450
1546
  return;
1451
1547
  }
@@ -1551,7 +1647,7 @@ export function buildRig(
1551
1647
  out.push(...heat);
1552
1648
  return null;
1553
1649
  }
1554
- const sw = sourceWeights(source.vertices, ox, oy, segs, r, spec, exponent ?? DISTANCE_EXPONENT, heat);
1650
+ const sw = sourceWeights(source.vertices, ox, oy, segs, r, spec, exponent ?? DISTANCE_EXPONENT, heat, dressSegsAt(p, segs));
1555
1651
  if ('overlap' in sw) {
1556
1652
  out.push({
1557
1653
  code: 'RIG_CONTOUR_REGIONS_OVERLAP',
@@ -1807,19 +1903,41 @@ export function buildRig(
1807
1903
  let maxInfl = 0;
1808
1904
  // issue #284: the mesh's declared cap and floor form, or DEFAULT_LIMITS itself when it declares none.
1809
1905
  const limits: InfluenceLimits = meshLimits(mesh.influences);
1906
+ const segsAt = dressSegsAt(p, segs);
1810
1907
  let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
1811
1908
  let emptiedMore = 0;
1909
+ const rawShares: Influence[][] = [];
1812
1910
  for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
1813
1911
  const wx = vx0 + ox;
1814
1912
  const wy = vy0 + oy;
1815
- const shares = heat === null ? influences([wx, wy], segs, mesh.r, limits, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], limits);
1913
+ const vSegs = segsAt(wy);
1914
+ const shares = heat === null ? influences([wx, wy], vSegs, mesh.r, limits, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], limits);
1816
1915
  if (shares.length === 0) {
1817
1916
  // issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be).
1818
- if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, mesh.r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
1917
+ if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], vSegs, mesh.r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
1819
1918
  else emptiedMore++;
1820
1919
  continue;
1821
1920
  }
1822
- 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 }) => {
1823
1941
  const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
1824
1942
  return { bone, x: places(x), y: places(y), weight };
1825
1943
  });
@@ -1827,10 +1945,6 @@ export function buildRig(
1827
1945
  infl += ent.length;
1828
1946
  maxInfl = Math.max(maxInfl, ent.length);
1829
1947
  }
1830
- if (emptied !== null) {
1831
- problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
1832
- continue;
1833
- }
1834
1948
  const uvs: number[] = [];
1835
1949
  for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
1836
1950
  skin[p.name] = { [p.name]: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights } };
@@ -1852,6 +1966,10 @@ export function buildRig(
1852
1966
  });
1853
1967
  }
1854
1968
  refuseIfAny(problems);
1969
+ // ---- leg stops (issue #404) --------------------------------------------
1970
+ // Only when the config has `stops`: each link after the first (and any bone hanging from a stopped link) hangs
1971
+ // from the drawn link `<link>_d` above it, and slots and weights on a stopped link bind `<link>_d`.
1972
+ const stops = cfg.stops === undefined ? null : applyStops(cfg.stops, chains, bones, slots, skin);
1855
1973
  // ---- the turn control (issue #251) -------------------------------------
1856
1974
  // Only when the config has `turn`: every other build adds nothing here, so no byte moves.
1857
1975
  const turned = cfg.turn === undefined ? null : applyTurn(cfg.turn, parts.parts.map((p) => p.name), bones, slots, skin, cfg.constraints ?? []);
@@ -1868,6 +1986,7 @@ export function buildRig(
1868
1986
  };
1869
1987
  if (cfg.constraints !== undefined) rig.constraints = cfg.constraints.map(constraintForRig);
1870
1988
  if (turned !== null) rig.constraints = [...(rig.constraints ?? []), ...turned.constraints];
1989
+ if (stops !== null) rig.stops = stops;
1871
1990
  const detached = [...detachedRules(cfg.constraints ?? []), ...(turned?.detached ?? [])];
1872
1991
  if (idleKeys === 'direct' && meshKeyed.length > 0) rig.invariants = { idleDrivesMeshes: { why: IDLE_DRIVES_MESHES_WHY } };
1873
1992
  if (detached.length > 0) rig.invariants = { ...rig.invariants, detached };
@@ -1880,6 +1999,49 @@ export function buildRig(
1880
1999
  return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
1881
2000
  }
1882
2001
 
2002
+ /**
2003
+ * Re-parent every bone that hangs from a stopped link onto that link's drawn
2004
+ * link `<link>_d`, move slots and mesh weights from a stopped link onto
2005
+ * `<link>_d`, and return `rig.json`'s `stops` array (issue #404). At the setup
2006
+ * pose `<link>_d` has the same world origin and rotation as `<link>`, so every
2007
+ * local offset and bind coordinate is unchanged.
2008
+ */
2009
+ function applyStops(
2010
+ stops: readonly ConfigStop[],
2011
+ chains: ReadonlyMap<string, readonly string[]>,
2012
+ bones: RigBone[],
2013
+ slots: RigSpec['slots'],
2014
+ skin: RigSpec['skins']['default'],
2015
+ ): RigStop[] {
2016
+ const out: RigStop[] = [];
2017
+ const drawn = new Map<string, string>();
2018
+ for (const s of stops) {
2019
+ const links = s.links ?? chains.get(s.chain) ?? [];
2020
+ for (const l of links) drawn.set(l, `${l}_d`);
2021
+ out.push(stopForRig(s, links));
2022
+ }
2023
+ for (const b of bones) {
2024
+ const d = b.parent === undefined ? undefined : drawn.get(b.parent);
2025
+ if (d !== undefined) b.parent = d;
2026
+ }
2027
+ for (const s of slots) {
2028
+ const d = drawn.get(s.bone);
2029
+ if (d !== undefined) s.bone = d;
2030
+ }
2031
+ for (const atts of Object.values(skin)) {
2032
+ for (const att of Object.values(atts)) {
2033
+ if (!('type' in att)) continue;
2034
+ for (const v of att.weights) {
2035
+ for (const w of v) {
2036
+ const d = drawn.get(w.bone);
2037
+ if (d !== undefined) w.bone = d;
2038
+ }
2039
+ }
2040
+ }
2041
+ }
2042
+ return out;
2043
+ }
2044
+
1883
2045
  /**
1884
2046
  * The JSON text of a rig output file. One space of indent, as the reference
1885
2047
  * wrote, and a closing newline; the key order is the order the objects were
@@ -1926,7 +2088,10 @@ export function rigJsonText(value: unknown): string {
1926
2088
  */
1927
2089
  export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1928
2090
  const problems: Problem[] = [];
1929
- const read = rig.bones.map((b) => ({ ...b, rotation: (b.rotation ?? 0) * sense }));
2091
+ const undrawn = new Map<string, string>();
2092
+ for (const s of rig.stops ?? []) for (const l of s.links) undrawn.set(`${l}_d`, l);
2093
+ const realBone = (n: string): string => undrawn.get(n) ?? n;
2094
+ const read = rig.bones.map((b) => ({ ...b, ...(b.parent === undefined ? {} : { parent: realBone(b.parent) }), rotation: (b.rotation ?? 0) * sense }));
1930
2095
  const world = computeExactFrameTransforms(read);
1931
2096
  const turn = new Map<string, number>();
1932
2097
  // A bone with a declared inherit mode (issue #348) is read off rig-c's poser, which honours the mode; every other is the sum, as before.
@@ -1934,11 +2099,11 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1934
2099
  turn.set(
1935
2100
  b.name,
1936
2101
  b.inherit === undefined
1937
- ? placed((b.parent === undefined ? 0 : (turn.get(b.parent) as number)) + (b.rotation ?? 0), OFFSET_PLACES)
2102
+ ? placed((b.parent === undefined ? 0 : (turn.get(realBone(b.parent)) as number)) + (b.rotation ?? 0), OFFSET_PLACES)
1938
2103
  : placed(normaliseDegrees((world.get(b.name) as BoneTransform).worldRotation), OFFSET_PLACES),
1939
2104
  );
1940
2105
  }
1941
- const origin = (bone: string): BoneTransform => world.get(bone) as BoneTransform;
2106
+ const origin = (bone: string): BoneTransform => world.get(realBone(bone)) as BoneTransform;
1942
2107
  const out = (m: BoneTransform, x: number, y: number): [number, number] => {
1943
2108
  const [wx, wy] = toWorld(m, x, y);
1944
2109
  return [placed(wx - m.worldX, FLAT_PLACES), placed(wy - m.worldY, FLAT_PLACES)];
@@ -1962,12 +2127,12 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1962
2127
  })) };
1963
2128
  continue;
1964
2129
  }
1965
- const left = normaliseDegrees((turn.get(bone) as number) + (att.rotation ?? 0));
2130
+ const left = normaliseDegrees((turn.get(realBone(bone)) as number) + (att.rotation ?? 0));
1966
2131
  if (Math.abs(left) > 1e-6) {
1967
2132
  problems.push({
1968
2133
  code: 'FLATTEN_REGION_UPRIGHT',
1969
2134
  object: `skins.default.${slot}.${name}`,
1970
- detail: `rides "${bone}", turned ${turn.get(bone)} degrees, with rotation ${att.rotation ?? 'absent'}, so it is drawn turned ${placed(left, OFFSET_PLACES)} degrees; the flat form draws every region upright, so the region's rotation must cancel its bone's turn`,
2135
+ detail: `rides "${bone}", turned ${turn.get(realBone(bone))} degrees, with rotation ${att.rotation ?? 'absent'}, so it is drawn turned ${placed(left, OFFSET_PLACES)} degrees; the flat form draws every region upright, so the region's rotation must cancel its bone's turn`,
1971
2136
  });
1972
2137
  continue;
1973
2138
  }
@@ -1989,6 +2154,9 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1989
2154
  return { ...c, properties: { ...props, rotate: { ...rotate, offset: placed(offset - turned, OFFSET_PLACES) } } };
1990
2155
  });
1991
2156
  }
2157
+ if (rig.stops !== undefined) {
2158
+ flat.stops = rig.stops.map((s) => ({ ...s, at: out(origin(s.against), s.at[0], s.at[1]) }));
2159
+ }
1992
2160
  if (rig.invariants?.axes !== undefined) {
1993
2161
  const { axes: _axes, ...rest } = rig.invariants;
1994
2162
  if (Object.keys(rest).length > 0) flat.invariants = rest;
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);