@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/README.md +1 -1
- package/cli.ts +22 -6
- package/docs/AUTHORING.md +76 -36
- package/docs/MIGRATION.md +1 -1
- package/docs/STABILITY.md +10 -8
- package/package.json +2 -2
- package/skills/rig-parts/SKILL.md +1 -1
- package/src/assemble.ts +142 -29
- package/src/automesh.ts +2 -1
- package/src/build.ts +23 -9
- package/src/check.ts +227 -44
- package/src/cloth.ts +22 -7
- package/src/config.ts +161 -12
- package/src/headbox.ts +68 -34
- package/src/limbs.ts +171 -0
- package/src/propose.ts +301 -49
- package/src/rig.ts +186 -18
- package/src/scene.ts +87 -7
- package/src/squeeze.ts +161 -1
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
|
-
|
|
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
|
|
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],
|
|
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
|
|
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],
|
|
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
|
-
|
|
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
|
|
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), `
|
|
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
|
|
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});
|
|
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)
|
|
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);
|