@rig-c/pose 0.0.0-stage → 0.1.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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Every refusal in this package is a list of named problems, collected and
3
+ * thrown once, so one run names everything that is wrong.
4
+ *
5
+ * A problem's text names the object, the value found and the value required:
6
+ * `MAP_BONE_EXISTS: chain "arm_r" — bone "sleeve_r9" is not in the rig; its bones are …`.
7
+ */
8
+ export interface Problem {
9
+ /** The rule, SCREAMING_SNAKE, stable across releases of a 0.x line only. */
10
+ code: string;
11
+ /** The object, the value found, the value required. */
12
+ text: string;
13
+ }
14
+
15
+ export class Refusal extends Error {
16
+ readonly problems: readonly Problem[];
17
+ constructor(problems: readonly Problem[]) {
18
+ super(problems.map(lineOf).join('\n'));
19
+ this.name = 'Refusal';
20
+ this.problems = problems;
21
+ }
22
+ }
23
+
24
+ export function lineOf(p: Problem): string {
25
+ return `${p.code}: ${p.text}`;
26
+ }
27
+
28
+ /** A collector: `add` while reading, `throwIfAny` once at the end. */
29
+ export class Problems {
30
+ readonly list: Problem[] = [];
31
+ add(code: string, text: string): void {
32
+ this.list.push({ code, text });
33
+ }
34
+ get any(): boolean {
35
+ return this.list.length > 0;
36
+ }
37
+ throwIfAny(): void {
38
+ if (this.list.length > 0) throw new Refusal(this.list);
39
+ }
40
+ }
41
+
42
+ /** A JSON value's type as a reader names it in a refusal. */
43
+ export function typeName(v: unknown): string {
44
+ if (v === null) return 'null';
45
+ if (Array.isArray(v)) return 'an array';
46
+ if (typeof v === 'object') return 'an object';
47
+ if (typeof v === 'string') return `the string ${JSON.stringify(v)}`;
48
+ return `${typeof v} ${String(v)}`;
49
+ }
50
+
51
+ export function isObject(v: unknown): v is Record<string, unknown> {
52
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
53
+ }
54
+
55
+ /** Refuse the keys of `obj` a reader does not know, by name, listing the ones it does. */
56
+ export function refuseUnknownKeys(problems: Problems, code: string, where: string, obj: Record<string, unknown>, known: readonly string[]): void {
57
+ for (const k of Object.keys(obj)) {
58
+ if (!known.includes(k)) problems.add(code, `${where} has a key this reader does not know: ${JSON.stringify(k)}; the keys it reads are ${known.join(', ')}`);
59
+ }
60
+ }
61
+
62
+ /** Parse JSON text, refusing with the file and the parser's own message. */
63
+ export function parseJson(text: string, where: string, code: string): unknown {
64
+ try {
65
+ return JSON.parse(text) as unknown;
66
+ } catch (e) {
67
+ throw new Refusal([{ code, text: `${where} is not JSON: ${(e as Error).message}` }]);
68
+ }
69
+ }
@@ -0,0 +1,461 @@
1
+ /**
2
+ * The retarget: a pose sequence and a joint map, over a rig's setup pose, to one
3
+ * animation in rig-c's motion format. Pure — no file, no clock, no process.
4
+ *
5
+ * Per frame and per mapped bone, the segment the bone carries points somewhere
6
+ * in the picture. Its angle in Spine's world (y up, counter-clockwise; the flip
7
+ * is rig-c's `screenToSpineDegrees`) minus the bone's rest angle (its aim at
8
+ * the setup pose, by rig-c's poser) is how far the bone must turn in the world,
9
+ * `D`. rig-c's `rotate` key ADDS to the setup rotation, and a child with the
10
+ * `normal` inherit mode turns with its parent, so the key is the bone's turn
11
+ * less the turn of the nearest mapped bone above it: `key = D(bone) − D(parent)`.
12
+ * Keys are unwrapped across frames, because the runtime interpolates an angle as
13
+ * a plain number (350 → 10 passes through 180).
14
+ *
15
+ * A chain with `reach` (a trunk from mid_hip) is the one exception to "the
16
+ * bone points along the pose's segment": its bone is aimed from where its
17
+ * origin is posed — the translated bone moved by the hip's travel, and every
18
+ * bone between turned by the nearest mapped bone above it — at the point the
19
+ * pose puts the segment's end, relative to the translated joint and scaled. So
20
+ * the hip and its travel are computed before any bone's target.
21
+ *
22
+ * Smoothing is Savitzky–Golay: a centred least-squares cubic over the window,
23
+ * evaluated at the frame, on each joint's positions after its holds. Centred,
24
+ * it moves no motion in time; a cubic keeps a peak a moving average flattens.
25
+ *
26
+ * What this cannot know is held to account afterwards: `verify.ts` poses the
27
+ * gated build with rig-c's poser and measures where each bone landed.
28
+ */
29
+ import { screenToSpineDegrees } from 'rig-c/transform';
30
+ import type { Body18 } from './joints.ts';
31
+ import { isBody18, VIRTUAL } from './joints.ts';
32
+ import type { Chain, JointMap, Rest } from './map.ts';
33
+ import { aimAngle, rigPoint } from './map.ts';
34
+ import type { Joint } from './pose.ts';
35
+ import { Problems } from './problems.ts';
36
+
37
+ const DEG = 180 / Math.PI;
38
+
39
+ export interface RetargetOptions {
40
+ /** A joint with a score below this is not good on that frame (a joint with no score is). */
41
+ minScore: number;
42
+ /** Savitzky–Golay window over joint positions, in frames: 1 = none, else odd and ≥ 5. */
43
+ smooth: number;
44
+ /** What a hinge bent the wrong way does: refuse the run, or straighten that hinge on that frame. */
45
+ bends: 'refuse' | 'clamp';
46
+ /** Degrees past straight a hinge may bend the wrong way before it counts. */
47
+ bendTolerance: number;
48
+ }
49
+
50
+ export interface Hold {
51
+ point: string;
52
+ /** First and last frame index held. */
53
+ from: number;
54
+ to: number;
55
+ /** The frame whose value was used. */
56
+ source: number;
57
+ why: string;
58
+ }
59
+
60
+ export interface BendEvent {
61
+ chain: string;
62
+ joint: string;
63
+ frame: number;
64
+ t: number;
65
+ /** The inner angle found, degrees, Spine's sense (counter-clockwise +). */
66
+ angle: number;
67
+ /** The side the hinge may bend: +1 counter-clockwise, −1 clockwise. */
68
+ side: 1 | -1;
69
+ rest: number;
70
+ }
71
+
72
+ export interface MotionKey {
73
+ t: number;
74
+ v: number[];
75
+ }
76
+ export interface MotionTrack {
77
+ bone: string;
78
+ property: 'rotate' | 'translate';
79
+ keys: MotionKey[];
80
+ }
81
+ export interface Animation {
82
+ duration: number;
83
+ tracks: MotionTrack[];
84
+ }
85
+
86
+ export interface Retarget {
87
+ animation: Animation;
88
+ /** Key times, seconds from the first frame, as written. */
89
+ times: number[];
90
+ /** Per mapped bone, the world angle (Spine degrees) its aim must reach on each frame. */
91
+ targets: Map<string, number[]>;
92
+ /** The translated bone's world origin on each frame, when the map translates one. */
93
+ origin: { bone: string; at: Array<[number, number]> } | null;
94
+ holds: Hold[];
95
+ bends: BendEvent[];
96
+ /** The figure scale, rig units per picture pixel, and the lengths it came from. */
97
+ scale: { factor: number; pose: number; rig: number } | null;
98
+ }
99
+
100
+ type Pt = [number, number];
101
+
102
+ /** The value of a point on one frame, or why it is not good. */
103
+ export function pointOn(joints: Partial<Record<Body18, Joint>>, name: string, minScore: number): Pt | string {
104
+ if (isBody18(name)) {
105
+ const j = joints[name];
106
+ if (j === undefined || j.state === 'missing') return 'missing';
107
+ if (j.at === undefined) return `${j.state} without a position`;
108
+ if (j.score !== undefined && j.score < minScore) return `score ${j.score.toFixed(3)} < ${minScore}`;
109
+ return j.at;
110
+ }
111
+ const pair = VIRTUAL[name];
112
+ if (pair === undefined) return 'not a point';
113
+ const a = pointOn(joints, pair[0], minScore);
114
+ const b = pointOn(joints, pair[1], minScore);
115
+ if (typeof a === 'string') return `${pair[0]} ${a}`;
116
+ if (typeof b === 'string') return `${pair[1]} ${b}`;
117
+ return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
118
+ }
119
+
120
+ export function wrap(deg: number): number {
121
+ let d = deg % 360;
122
+ if (d > 180) d -= 360;
123
+ if (d <= -180) d += 360;
124
+ return d;
125
+ }
126
+
127
+ /** Spine world degrees of a picture-space direction (y down). */
128
+ export function pictureAngle(from: Pt, to: Pt): number {
129
+ return screenToSpineDegrees(Math.atan2(to[1] - from[1], to[0] - from[0]) * DEG);
130
+ }
131
+
132
+ /** The rest angle of a mapped bone's aim, Spine world degrees, from rig-c's setup pose. */
133
+ export function restAngle(rest: Rest, bone: string, aim: string): number {
134
+ return aimAngle(rest, new Map([...rest].map(([n, b]) => [n, b.length])), bone, aim);
135
+ }
136
+
137
+ function median(xs: number[]): number {
138
+ const s = [...xs].sort((a, b) => a - b);
139
+ const m = s.length >> 1;
140
+ return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
141
+ }
142
+
143
+ /**
144
+ * Savitzky–Golay, centred: at each frame, the least-squares polynomial of
145
+ * `order` over the frames of the window that exist (clipped at the ends), read
146
+ * at that frame. With fewer points than order + 1 the order drops to fit them
147
+ * exactly, so a window clipped to 4 frames or fewer returns the input there.
148
+ * A polynomial of degree ≤ `order` passes through unchanged.
149
+ */
150
+ export function savitzkyGolay(xs: readonly Pt[], window: number, order = 3): Pt[] {
151
+ if (window <= 1) return xs.map((x) => [x[0], x[1]]);
152
+ const h = window >> 1;
153
+ const n = xs.length;
154
+ const cache = new Map<string, number[]>();
155
+ return xs.map((_, i) => {
156
+ const lo = Math.max(0, i - h) - i;
157
+ const hi = Math.min(n - 1, i + h) - i;
158
+ const key = `${lo},${hi}`;
159
+ let w = cache.get(key);
160
+ if (w === undefined) {
161
+ w = sgWeights(lo, hi, Math.min(order, hi - lo));
162
+ cache.set(key, w);
163
+ }
164
+ let x = 0;
165
+ let y = 0;
166
+ for (let u = lo; u <= hi; u++) {
167
+ const c = w[u - lo]!;
168
+ x += c * xs[i + u]![0];
169
+ y += c * xs[i + u]![1];
170
+ }
171
+ return [x, y];
172
+ });
173
+ }
174
+
175
+ /** The weights that read a least-squares polynomial of degree q over u = lo..hi at u = 0. */
176
+ function sgWeights(lo: number, hi: number, q: number): number[] {
177
+ const m = q + 1;
178
+ // Normal equations: A[j][l] = Σ u^(j+l); solve A z = e0, then w(u) = Σ z_j u^j.
179
+ const A = Array.from({ length: m }, (_, j) => Array.from({ length: m + 1 }, (_, l) => {
180
+ if (l === m) return j === 0 ? 1 : 0;
181
+ let s = 0;
182
+ for (let u = lo; u <= hi; u++) s += u ** (j + l);
183
+ return s;
184
+ }));
185
+ for (let c = 0; c < m; c++) {
186
+ let piv = c;
187
+ for (let r = c + 1; r < m; r++) if (Math.abs(A[r]![c]!) > Math.abs(A[piv]![c]!)) piv = r;
188
+ [A[c], A[piv]] = [A[piv]!, A[c]!];
189
+ for (let r = 0; r < m; r++) {
190
+ if (r === c) continue;
191
+ const f = A[r]![c]! / A[c]![c]!;
192
+ for (let l = c; l <= m; l++) A[r]![l]! -= f * A[c]![l]!;
193
+ }
194
+ }
195
+ const z = A.map((row, j) => row[m]! / row[j]!);
196
+ const w: number[] = [];
197
+ for (let u = lo; u <= hi; u++) w.push(z.reduce((s, zj, j) => s + zj * u ** j, 0));
198
+ return w;
199
+ }
200
+
201
+ const r3 = (v: number) => {
202
+ const r = Math.round(v * 1000) / 1000;
203
+ return Object.is(r, -0) ? 0 : r;
204
+ };
205
+
206
+ /**
207
+ * Retarget. Refuses (every reason at once) when a point the map needs is never
208
+ * good, when a hinge's side cannot be read off a straight rest, and — with
209
+ * `bends: 'refuse'` — when a hinge bends the wrong way.
210
+ */
211
+ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions): Retarget {
212
+ const p = new Problems();
213
+ const n = frames.length;
214
+ const t0 = times[0]!;
215
+ const keyTimes = times.map((t) => Math.round((t - t0) * 1e6) / 1e6);
216
+ for (let i = 1; i < n; i++) {
217
+ if (keyTimes[i]! <= keyTimes[i - 1]!) p.add('POSE_TIME', `frames ${i - 1} and ${i} are ${keyTimes[i - 1]}s and ${keyTimes[i]}s after rounding to the microsecond; keys must strictly increase`);
218
+ }
219
+
220
+ // Every point the map reads, held where it is not good.
221
+ const needed = new Set<string>();
222
+ for (const c of map.chains) c.joints.forEach((j) => needed.add(j));
223
+ if (map.translate !== null) {
224
+ needed.add(map.translate.joint);
225
+ map.translate.scale.pose.forEach((j) => needed.add(j));
226
+ }
227
+ const series = new Map<string, Pt[]>();
228
+ const good = new Map<string, boolean[]>();
229
+ const holds: Hold[] = [];
230
+ for (const name of [...needed].sort()) {
231
+ const raw = frames.map((f) => pointOn(f, name, opts.minScore));
232
+ const firstGood = raw.findIndex((v) => typeof v !== 'string');
233
+ if (firstGood < 0) {
234
+ const why = [...new Set(raw as string[])].slice(0, 3).join('; ');
235
+ p.add('POSE_POINT_NEVER_GOOD', `point "${name}" is good on none of the ${n} frames (${why}); the map needs it, and there is no value to hold`);
236
+ continue;
237
+ }
238
+ const out: Pt[] = [];
239
+ let last = firstGood;
240
+ let span: Hold | null = null;
241
+ for (let i = 0; i < n; i++) {
242
+ const v = raw[i]!;
243
+ if (typeof v !== 'string') {
244
+ out.push(v);
245
+ last = i;
246
+ span = null;
247
+ continue;
248
+ }
249
+ out.push(raw[last]! as Pt);
250
+ if (span !== null && span.source === last && span.why === v) span.to = i;
251
+ else {
252
+ span = { point: name, from: i, to: i, source: last, why: v };
253
+ holds.push(span);
254
+ }
255
+ }
256
+ series.set(name, savitzkyGolay(out, opts.smooth));
257
+ good.set(name, raw.map((v) => typeof v !== 'string'));
258
+ }
259
+ p.throwIfAny();
260
+
261
+ // The hip: the travel of one point, scaled, in its parent's axes. It comes
262
+ // first, because a reach is aimed from where the hip has put its bone.
263
+ let origin: Retarget['origin'] = null;
264
+ let scale: Retarget['scale'] = null;
265
+ let translateTrack: MotionTrack | null = null;
266
+ /** The translated bone's world travel from its setup origin, per frame. */
267
+ const travel: Pt[] = [];
268
+ const t = map.translate;
269
+ if (t !== null) {
270
+ const [pa, pb] = t.scale.pose;
271
+ const ga = good.get(pa)!;
272
+ const gb = good.get(pb)!;
273
+ const lengths: number[] = [];
274
+ const A = series.get(pa)!;
275
+ const B = series.get(pb)!;
276
+ for (let i = 0; i < n; i++) if (ga[i] && gb[i]) lengths.push(Math.hypot(B[i]![0] - A[i]![0], B[i]![1] - A[i]![1]));
277
+ const [ra, rb] = t.scale.rig.map((r) => rigPoint(rest, r)!) as [Pt, Pt];
278
+ const rigLen = Math.hypot(rb[0] - ra[0], rb[1] - ra[1]);
279
+ if (lengths.length === 0) p.add('POSE_SCALE', `"translate" "scale.pose" ${pa} → ${pb} is good on no frame where both points are; the figure scale cannot be measured`);
280
+ else {
281
+ const poseLen = median(lengths);
282
+ if (poseLen < 1e-6) p.add('POSE_SCALE', `"translate" "scale.pose" ${pa} → ${pb} has median length ${poseLen}px; the figure scale cannot be measured`);
283
+ else {
284
+ const s = rigLen / poseLen;
285
+ scale = { factor: s, pose: poseLen, rig: rigLen };
286
+ const P = series.get(t.joint)!;
287
+ const bone = rest.get(t.bone)!;
288
+ const parent = bone.parent === null ? null : rest.get(bone.parent)!;
289
+ const keys: MotionKey[] = [];
290
+ const at: Pt[] = [];
291
+ for (let i = 0; i < n; i++) {
292
+ const wx = (P[i]![0] - P[0]![0]) * s;
293
+ const wy = -(P[i]![1] - P[0]![1]) * s;
294
+ let lx = wx;
295
+ let ly = wy;
296
+ if (parent !== null) {
297
+ const det = parent.a * parent.d - parent.b * parent.c;
298
+ lx = (parent.d * wx - parent.b * wy) / det;
299
+ ly = (-parent.c * wx + parent.a * wy) / det;
300
+ }
301
+ keys.push({ t: keyTimes[i]!, v: [r3(lx), r3(ly)] });
302
+ at.push([bone.worldX + wx, bone.worldY + wy]);
303
+ travel.push([wx, wy]);
304
+ }
305
+ translateTrack = { bone: t.bone, property: 'translate', keys };
306
+ origin = { bone: t.bone, at };
307
+ }
308
+ }
309
+ }
310
+ p.throwIfAny();
311
+
312
+ // Each mapped bone's target world angle per frame, reaches last.
313
+ const targets = new Map<string, number[]>();
314
+ const restOf = new Map<string, number>();
315
+ for (const c of map.chains) {
316
+ c.bones.forEach((b, k) => {
317
+ restOf.set(b, restAngle(rest, b, c.aim[k]!));
318
+ if (c.reach) return;
319
+ const from = series.get(c.joints[k]!)!;
320
+ const to = series.get(c.joints[k + 1]!)!;
321
+ const angles: number[] = [];
322
+ for (let i = 0; i < n; i++) {
323
+ const d = Math.hypot(to[i]![0] - from[i]![0], to[i]![1] - from[i]![1]);
324
+ angles.push(d > 1e-9 ? pictureAngle(from[i]!, to[i]!) : (angles[i - 1] ?? NaN));
325
+ }
326
+ fillLeading(angles, () => p.add('POSE_SEGMENT_ZERO', `chain "${c.name}" — bone "${b}"'s segment ${c.joints[k]} → ${c.joints[k + 1]} has zero length on every frame; it has no direction`));
327
+ targets.set(b, angles);
328
+ });
329
+ }
330
+ p.throwIfAny();
331
+
332
+ // Hinges: each joint strictly inside a chain (a reach has one bone, so none).
333
+ const bends: BendEvent[] = [];
334
+ for (const c of map.chains) {
335
+ for (let k = 1; k < c.bones.length; k++) checkHinge(c, k, targets, restOf, keyTimes, opts, bends, p);
336
+ }
337
+ if (opts.bends === 'refuse' && bends.length > 0) {
338
+ for (const e of bends) p.add('POSE_BEND_ONE_WAY', bendLine(e));
339
+ }
340
+ p.throwIfAny();
341
+
342
+ // Reaches: from the bone's posed origin at the pose's end point, anchored at
343
+ // the translated bone. A reach above another is aimed first.
344
+ const depth = (b: string): number => {
345
+ let d = 0;
346
+ for (let a = rest.get(b)!.parent; a !== null; a = rest.get(a)!.parent) d++;
347
+ return d;
348
+ };
349
+ const reaches = map.chains.filter((c) => c.reach).sort((x, y) => depth(x.bones[0]!) - depth(y.bones[0]!));
350
+ if (reaches.length > 0 && t !== null && scale !== null) {
351
+ const s = scale.factor;
352
+ const tb = rest.get(t.bone)!;
353
+ /** The world turn of `b`'s nearest mapped bone-or-ancestor, degrees, on frame i. */
354
+ const turnAbove = (b: string, i: number): number => {
355
+ for (let a: string | null = b; a !== null; a = rest.get(a)!.parent) {
356
+ const tg = targets.get(a);
357
+ if (tg !== undefined) return tg[i]! - restOf.get(a)!;
358
+ }
359
+ return 0;
360
+ };
361
+ /** Where the poser will put `b`'s origin on frame i: forward from the translated bone. */
362
+ const posedOrigin = (b: string, i: number): Pt => {
363
+ if (b === t.bone) return [tb.worldX + travel[i]![0], tb.worldY + travel[i]![1]];
364
+ const parent = rest.get(b)!.parent!;
365
+ const po = posedOrigin(parent, i);
366
+ const r = turnAbove(parent, i) / DEG;
367
+ const ox = rest.get(b)!.worldX - rest.get(parent)!.worldX;
368
+ const oy = rest.get(b)!.worldY - rest.get(parent)!.worldY;
369
+ return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy, po[1] + Math.sin(r) * ox + Math.cos(r) * oy];
370
+ };
371
+ for (const c of reaches) {
372
+ const b = c.bones[0]!;
373
+ const anchor = series.get(c.joints[0]!)!;
374
+ const end = series.get(c.joints[1]!)!;
375
+ const angles: number[] = [];
376
+ for (let i = 0; i < n; i++) {
377
+ const o = posedOrigin(b, i);
378
+ const tx = tb.worldX + travel[i]![0] + (end[i]![0] - anchor[i]![0]) * s;
379
+ const ty = tb.worldY + travel[i]![1] - (end[i]![1] - anchor[i]![1]) * s;
380
+ const d = Math.hypot(tx - o[0], ty - o[1]);
381
+ angles.push(d > 1e-9 ? Math.atan2(ty - o[1], tx - o[0]) * DEG : (angles[i - 1] ?? NaN));
382
+ }
383
+ fillLeading(angles, () => p.add('POSE_SEGMENT_ZERO', `chain "${c.name}" — bone "${b}" reaches for ${c.joints[1]}, which the pose puts on the bone's posed origin on every frame; it has no direction`));
384
+ targets.set(b, angles);
385
+ }
386
+ }
387
+ p.throwIfAny();
388
+
389
+ // Keys: the bone's turn less the turn of the nearest mapped bone above it.
390
+ const mappedAbove = (b: string): string | null => {
391
+ for (let a = rest.get(b)!.parent; a !== null; a = rest.get(a)!.parent) if (targets.has(a)) return a;
392
+ return null;
393
+ };
394
+ const tracks: MotionTrack[] = [];
395
+ for (const c of map.chains) {
396
+ for (const b of c.bones) {
397
+ const above = mappedAbove(b);
398
+ const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
399
+ let prev = 0;
400
+ const keys: MotionKey[] = [];
401
+ for (let i = 0; i < n; i++) {
402
+ const raw = turn(b, i) - (above === null ? 0 : turn(above, i));
403
+ const v = i === 0 ? wrap(raw) : prev + wrap(raw - prev);
404
+ prev = v;
405
+ keys.push({ t: keyTimes[i]!, v: [r3(v)] });
406
+ }
407
+ tracks.push({ bone: b, property: 'rotate', keys });
408
+ }
409
+ }
410
+ if (translateTrack !== null) tracks.push(translateTrack);
411
+
412
+ return { animation: { duration: keyTimes[n - 1]!, tracks }, times: keyTimes, targets, origin, holds, bends, scale };
413
+ }
414
+
415
+ /** Frames before the first with a direction take its value; none at all is a problem. */
416
+ function fillLeading(angles: number[], none: () => void): void {
417
+ const firstReal = angles.findIndex((a) => !Number.isNaN(a));
418
+ if (firstReal < 0) {
419
+ none();
420
+ return;
421
+ }
422
+ for (let i = 0; i < firstReal; i++) angles[i] = angles[firstReal]!;
423
+ }
424
+
425
+ function checkHinge(c: Chain, k: number, targets: Map<string, number[]>, restOf: Map<string, number>, times: readonly number[], opts: RetargetOptions, out: BendEvent[], p: Problems): void {
426
+ if (c.bend === 'free') return;
427
+ const upper = c.bones[k - 1]!;
428
+ const lower = c.bones[k]!;
429
+ const joint = c.joints[k]!;
430
+ const restInner = wrap(restOf.get(lower)! - restOf.get(upper)!);
431
+ let side: 1 | -1;
432
+ if (c.bend === 'ccw') side = 1;
433
+ else if (c.bend === 'cw') side = -1;
434
+ else {
435
+ if (Math.abs(restInner) < 0.5) {
436
+ p.add('MAP_BEND_SIDE', `chain "${c.name}" — the hinge at ${joint} is straight at rest (inner angle ${restInner.toFixed(3)}°), so the rest pose does not say which way it bends; state "bend": "ccw", "cw" or "free"`);
437
+ return;
438
+ }
439
+ side = restInner > 0 ? 1 : -1;
440
+ }
441
+ const up = targets.get(upper)!;
442
+ const lo = targets.get(lower)!;
443
+ for (let i = 0; i < up.length; i++) {
444
+ const inner = wrap(lo[i]! - up[i]!);
445
+ if (inner * side < -opts.bendTolerance) {
446
+ out.push({ chain: c.name, joint, frame: i, t: times[i]!, angle: inner, side, rest: restInner });
447
+ if (opts.bends === 'clamp') lo[i] = up[i]!;
448
+ }
449
+ }
450
+ }
451
+
452
+ export function bendLine(e: BendEvent): string {
453
+ const sense = e.side > 0 ? 'counter-clockwise (+)' : 'clockwise (−)';
454
+ return `chain "${e.chain}" hinge ${e.joint}, frame ${e.frame} (t=${e.t.toFixed(3)}s) — inner angle ${e.angle.toFixed(1)}°; it bends ${sense} at rest (${e.rest.toFixed(1)}°), so the angle must stay ${e.side > 0 ? '≥' : '≤'} 0`;
455
+ }
456
+
457
+ /** Holds grouped per point, one line each span. */
458
+ export function holdLine(h: Hold, times: readonly number[]): string {
459
+ const span = h.from === h.to ? `frame ${h.from} (t=${times[h.from]!.toFixed(3)}s)` : `frames ${h.from}–${h.to} (t=${times[h.from]!.toFixed(3)}–${times[h.to]!.toFixed(3)}s)`;
460
+ return `point "${h.point}" ${span}: ${h.why}; held frame ${h.source}'s value`;
461
+ }
package/src/rigc.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Everything this package asks of rig-c that is not pure: its gate (`rigc build`),
3
+ * its fold reading (`rigc render --folds`), and the files they write. This is
4
+ * the one module in `src/` that touches the disk or starts a process.
5
+ *
6
+ * rig-c has no programmatic gate and no way to append an animation to a build
7
+ * (its `docs/INGEST.md`: "There is no append — build re-emits the whole
8
+ * skeleton"), so a retarget merges its animation into the rig's motion spec and
9
+ * builds the rig again, exactly as @rig-c/parts does with `animations_from`.
10
+ */
11
+ import { spawnSync } from 'node:child_process';
12
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { poseRawSetup, readModel, type CompiledDocument } from 'rig-c/core';
16
+ import type { Rest, RestBone } from './map.ts';
17
+ import { isObject, parseJson, Problems, Refusal, typeName } from './problems.ts';
18
+
19
+ export interface RigInput {
20
+ /** The rig spec (`rigc-rig/1`). */
21
+ rig: string;
22
+ /** Its name, which a motion spec's `archetype` must equal. */
23
+ name: string;
24
+ /** The motion spec the rig is built with, or null when it has none. */
25
+ motion: string | null;
26
+ }
27
+
28
+ /**
29
+ * `--rig` is the rig spec rigc builds from: a `rig.json`, or a directory holding
30
+ * one (with its `motion.json` beside it, when it has one — @rig-c/parts writes
31
+ * the pair to `<out>/rig/`). `--base-motion` names the motion spec explicitly.
32
+ */
33
+ export function resolveRig(path: string, baseMotion: string | undefined): RigInput {
34
+ const p = new Problems();
35
+ let rig = resolve(path);
36
+ let motion: string | null = baseMotion === undefined ? null : resolve(baseMotion);
37
+ if (!existsSync(rig)) p.add('RIG_PATH', `--rig ${path} does not exist`);
38
+ else if (statSync(rig).isDirectory()) {
39
+ const dir = rig;
40
+ rig = join(dir, 'rig.json');
41
+ if (!existsSync(rig)) {
42
+ const built = existsSync(join(dir, 'skeleton.model.json'));
43
+ p.add('RIG_PATH', `--rig ${path} is a directory with no rig.json${built ? ' (it holds skeleton.model.json, a build: the gate rebuilds the rig with the animation in, so it needs the spec the build came from — for @rig-c/parts, <out>/rig/)' : ''}`);
44
+ } else if (motion === null && existsSync(join(dir, 'motion.json'))) motion = join(dir, 'motion.json');
45
+ }
46
+ if (motion !== null && !existsSync(motion)) p.add('RIG_PATH', `--base-motion ${baseMotion} does not exist`);
47
+ p.throwIfAny();
48
+ const doc = parseJson(readFileSync(rig, 'utf8'), rig, 'RIG_JSON');
49
+ if (!isObject(doc) || doc.spec !== 'rigc-rig/1' || typeof doc.name !== 'string') {
50
+ throw new Refusal([{ code: 'RIG_SPEC', text: `${rig} is not a rig spec: "spec" is ${isObject(doc) ? typeName(doc.spec) : typeName(doc)} and "name" ${isObject(doc) ? typeName(doc.name) : '-'}; it must be "rigc-rig/1" with a name` }]);
51
+ }
52
+ return { rig, name: doc.name, motion };
53
+ }
54
+
55
+ /** The motion spec the rig is built with, or an empty one under its name. */
56
+ export function baseMotionOf(input: RigInput): Record<string, unknown> {
57
+ if (input.motion === null) return { spec: 'rigc-motion/1', archetype: input.name, cut: input.name, easings: {}, animations: {} };
58
+ const doc = parseJson(readFileSync(input.motion, 'utf8'), input.motion, 'MOTION_JSON');
59
+ if (!isObject(doc) || doc.spec !== 'rigc-motion/1' || !isObject(doc.animations)) {
60
+ throw new Refusal([{ code: 'MOTION_SPEC', text: `${input.motion} is not a rigc-motion/1 spec with an "animations" object` }]);
61
+ }
62
+ if (doc.archetype !== input.name) throw new Refusal([{ code: 'MOTION_SPEC', text: `${input.motion} "archetype" is ${typeName(doc.archetype)}; the rig ${input.rig} is named "${input.name}", and rigc builds only a pair that agree` }]);
63
+ return doc;
64
+ }
65
+
66
+ /**
67
+ * rig-c's own launcher, run under the Bun running this one. It picks rigc's
68
+ * entry the way an install of rig-c does: the spine-core round trip where
69
+ * `@esotericsoftware/spine-core` resolves beside rig-c (this repository's
70
+ * development dependency), rigc's own gate over the model document where it
71
+ * does not (an install of this package), and the gate's report says which ran.
72
+ */
73
+ export function rigcCli(): string {
74
+ return fileURLToPath(import.meta.resolve('rig-c/bin/rigc'));
75
+ }
76
+
77
+ export interface RigcRun {
78
+ args: string[];
79
+ status: number;
80
+ out: string;
81
+ }
82
+
83
+ export function runRigc(args: string[]): RigcRun {
84
+ const r = spawnSync(process.execPath, [rigcCli(), ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
85
+ return { args, status: r.status ?? 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
86
+ }
87
+
88
+ export interface Gate {
89
+ run: RigcRun;
90
+ green: boolean;
91
+ /** The `.. N assertions: …` summary line rigc prints, when it printed one. */
92
+ summary: string | null;
93
+ /** Every `FAIL` line, verbatim. */
94
+ fails: string[];
95
+ build: string;
96
+ }
97
+
98
+ /** Build `rig` with `motion` under rigc's gate into `dir/build`; nothing is read from a red build. */
99
+ export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string): Gate {
100
+ mkdirSync(dir, { recursive: true });
101
+ const motionPath = join(dir, 'motion.json');
102
+ writeFileSync(motionPath, `${JSON.stringify(motion, null, 2)}\n`);
103
+ const build = join(dir, 'build');
104
+ // --copy-images: a build's page paths are relative to its directory, and the
105
+ // gated build is copied out of the scratch directory for `--build`, so its
106
+ // pages travel with it (rigc refuses to draw a moved build otherwise).
107
+ const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', build, '--profile', profile, '--copy-images', '--report', join(dir, 'build-report.json')]);
108
+ const lines = run.out.split('\n');
109
+ return {
110
+ run,
111
+ green: run.status === 0,
112
+ summary: lines.find((l) => /assertions: \d+ measured/.test(l))?.replace(/^[\s.]+/, '') ?? null,
113
+ fails: lines.filter((l) => /^\s*FAIL\s/.test(l) || l.startsWith('rigc compile error')).map((l) => l.trim()),
114
+ build,
115
+ };
116
+ }
117
+
118
+ export function readBuild(build: string): CompiledDocument {
119
+ const path = join(build, 'skeleton.model.json');
120
+ return readModel(readFileSync(path, 'utf8'), path);
121
+ }
122
+
123
+ /** Every bone's setup pose, by rig-c's poser, with its model `length`. */
124
+ export function restOf(doc: CompiledDocument): Rest {
125
+ const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
126
+ const out = new Map<string, RestBone>();
127
+ for (const b of poseRawSetup(doc).bones) out.set(b.name, { ...b, length: lengths.get(b.name) ?? 0 });
128
+ return out;
129
+ }
130
+
131
+ export interface MeshFolds {
132
+ slot: string;
133
+ attachment: string;
134
+ /** Times (seconds) of the sampled frames on which the mesh folds. */
135
+ times: number[];
136
+ }
137
+
138
+ /** `rigc render --folds` over one animation: which meshes fold, and when. */
139
+ export function folds(build: string, animation: string, dir: string, fps: number): { run: RigcRun; meshes: MeshFolds[] | null; frames: string } {
140
+ const out = join(dir, 'render');
141
+ const run = runRigc(['render', '--candidate', build, '--animation', animation, '--folds', '--fps', String(fps), '--out', out]);
142
+ if (run.status !== 0) return { run, meshes: null, frames: out };
143
+ const framesDoc = JSON.parse(readFileSync(join(out, 'frames.json'), 'utf8')) as { sets: Array<{ dir: string; animation: string }> };
144
+ const set = framesDoc.sets.find((s) => s.animation === animation);
145
+ if (set === undefined) return { run, meshes: null, frames: out };
146
+ const setDir = resolve(dirname(join(out, 'frames.json')), set.dir);
147
+ const foldsPath = existsSync(join(setDir, 'folds.json')) ? join(setDir, 'folds.json') : join(out, set.dir, 'folds.json');
148
+ const doc = JSON.parse(readFileSync(foldsPath, 'utf8')) as { meshes: Array<{ slot: string; attachment: string; frames: Array<{ time: number }> }> };
149
+ return {
150
+ run,
151
+ meshes: doc.meshes.filter((m) => m.frames.length > 0).map((m) => ({ slot: m.slot, attachment: m.attachment, times: m.frames.map((f) => f.time) })),
152
+ frames: setDir,
153
+ };
154
+ }