@rig-c/pose 0.31.1 → 0.33.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/seams.ts ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The seam reading's projection (rig-c/pose#216): which neighbour pairs a rig
3
+ * declares, and what `rigc render --seam` found between them, as report rows.
4
+ * Pure — the render itself is `src/rigc.ts`'s.
5
+ *
6
+ * **Where the pairs come from.** A rig spec does not carry them: `rig.json`
7
+ * holds bones, slots and skins, and @rig-c/parts records a limb's segments in
8
+ * `parts.json` beside the rig directory (each part's `limb`: `name`,
9
+ * `segment`, and below a joint `joint`). So the pairs are read from the
10
+ * `parts.json` the run is given (`--parts`), the way @rig-c/parts' own
11
+ * `check` reads them (rig-c/parts#503): each limb's consecutive segments, the
12
+ * upper and the one below, a pair named twice counting once in either order.
13
+ * Each part is drawn by the slot of its name.
14
+ *
15
+ * **The floor** is @rig-c/parts' (#503), so the two tools agree on what a seam
16
+ * opening is: a frame's gap or ghost counts when it holds more cells than its
17
+ * box is long (its longer side, in cells) — thicker than one cell. rigc counts
18
+ * a cell drawn when its centre is painted at half opacity, so an outline that
19
+ * moves by less than a cell flips the cells along it: a line one cell deep
20
+ * holds at most as many cells as its box is long, and a hole of any area more.
21
+ * The rule types no number; it reads rigc's own `px`, `cell` and `box`.
22
+ *
23
+ * A reading, never a gate: nothing here refuses a run for what it finds.
24
+ */
25
+ import { existsSync, statSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { isObject, parseJson, Refusal } from './problems.ts';
28
+
29
+ export type SeamKind = 'gap' | 'ghost';
30
+ export const SEAM_KINDS: readonly SeamKind[] = ['gap', 'ghost'];
31
+
32
+ /** One declared neighbour pair: two part names, each drawn by the slot of its name. */
33
+ export interface SeamPair {
34
+ /** `"<a>|<b>"`, the form rigc and @rig-c/parts name a pair by. */
35
+ pair: string;
36
+ a: string;
37
+ b: string;
38
+ /** Where it was declared: `config.limbs.<limb> segment <n>, joint "<joint>"`, once per declaration. */
39
+ declared: string[];
40
+ }
41
+
42
+ /** One `report.json` `seams` row: one consecutive span of one kind over the floor (#216). */
43
+ export interface SeamSpan {
44
+ pair: string;
45
+ kind: SeamKind;
46
+ /** Inclusive render frame indices, rigc's `f<index>.png`. */
47
+ frames: [number, number];
48
+ /** Those frames' times in seconds, rigc's. */
49
+ times: [number, number];
50
+ /** The most cells of this kind on any frame of the span. */
51
+ maxPx: number;
52
+ }
53
+
54
+ /** One `report.json` `seamPairs` row: what the reading said of one declared pair. */
55
+ export interface SeamPairReading {
56
+ pair: string;
57
+ declared: string[];
58
+ /** `read`: rigc read a band; `skip`: rigc read none (the two drawings share no cell at rest); `unread`: not rendered, `reason` says why. */
59
+ status: 'read' | 'skip' | 'unread';
60
+ /** Frames over the floor, of either kind, ascending. */
61
+ opens: number[];
62
+ /** The most cells over the floor on any frame; 0 when none opens. */
63
+ maxPx: number;
64
+ /** Frames rigc named open whose every opening is one cell thick — under the floor, not in `seams`. */
65
+ underFloor: number[];
66
+ reason?: string;
67
+ }
68
+
69
+ const PARTS_SPEC = 'PARTS_JSON';
70
+
71
+ /** `--parts`: a `parts.json`, or a directory holding one. */
72
+ export function partsPath(path: string): string {
73
+ if (!existsSync(path)) throw new Refusal([{ code: 'ARG_PATH', text: `--parts ${path} does not exist` }]);
74
+ if (statSync(path).isDirectory()) {
75
+ const file = join(path, 'parts.json');
76
+ if (!existsSync(file)) throw new Refusal([{ code: 'ARG_PATH', text: `--parts ${path} is a directory with no parts.json (@rig-c/parts writes it to <out>/, beside rig/)` }]);
77
+ return file;
78
+ }
79
+ return path;
80
+ }
81
+
82
+ /**
83
+ * The neighbour pairs `parts.json` declares, in its limbs' order: each limb's
84
+ * consecutive segments by `segment`. A record that is not one is refused by
85
+ * name; a part with no `limb` declares nothing.
86
+ */
87
+ export function readSeamPairs(text: string, path: string): SeamPair[] {
88
+ const doc = parseJson(text, path, PARTS_SPEC);
89
+ if (!isObject(doc) || !Array.isArray(doc.parts)) throw new Refusal([{ code: PARTS_SPEC, text: `${path} is not @rig-c/parts' parts.json: it has no "parts" array` }]);
90
+ const problems: Array<{ code: string; text: string }> = [];
91
+ const limbs = new Map<string, Array<{ part: string; segment: number; joint: string | undefined }>>();
92
+ doc.parts.forEach((p: unknown, i: number) => {
93
+ if (!isObject(p) || typeof p.name !== 'string' || p.name === '') {
94
+ problems.push({ code: PARTS_SPEC, text: `${path} parts[${i}]: expected a record with a "name" string` });
95
+ return;
96
+ }
97
+ if (p.limb === undefined) return;
98
+ const l = p.limb;
99
+ if (!isObject(l) || typeof l.name !== 'string' || !Number.isSafeInteger(l.segment) || (l.segment as number) < 0 || (l.joint !== undefined && typeof l.joint !== 'string')) {
100
+ problems.push({ code: PARTS_SPEC, text: `${path} parts[${i}] ("${p.name}") limb: expected { name: string, segment: integer ≥ 0, joint?: string }, found ${JSON.stringify(l)}` });
101
+ return;
102
+ }
103
+ const segs = limbs.get(l.name) ?? [];
104
+ if (segs.some((s) => s.segment === l.segment)) problems.push({ code: PARTS_SPEC, text: `${path} parts[${i}] ("${p.name}"): limb "${l.name}" segment ${String(l.segment)} is already part "${segs.find((s) => s.segment === l.segment)!.part}"` });
105
+ segs.push({ part: p.name, segment: l.segment as number, joint: l.joint as string | undefined });
106
+ limbs.set(l.name, segs);
107
+ });
108
+ if (problems.length > 0) throw new Refusal(problems);
109
+ const out: SeamPair[] = [];
110
+ for (const [limb, segs] of limbs) {
111
+ segs.sort((x, y) => x.segment - y.segment);
112
+ for (let i = 1; i < segs.length; i++) {
113
+ const [up, low] = [segs[i - 1]!, segs[i]!];
114
+ const declared = `config.limbs.${limb} segment ${low.segment}${low.joint === undefined ? '' : `, joint "${low.joint}"`}`;
115
+ const hit = out.find((x) => (x.a === up.part && x.b === low.part) || (x.a === low.part && x.b === up.part));
116
+ if (hit !== undefined) hit.declared.push(declared);
117
+ else out.push({ pair: `${up.part}|${low.part}`, a: up.part, b: low.part, declared: [declared] });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** A frame's opening of one kind is over the floor when it holds more cells than its box, in cells, is long. */
124
+ export function overFloor(hit: { px: number; cell: number; box: readonly number[] }): boolean {
125
+ const long = Math.round(Math.max(hit.box[2]! - hit.box[0]!, hit.box[3]! - hit.box[1]!) / hit.cell);
126
+ return hit.px > long;
127
+ }
128
+
129
+ interface SeamHit {
130
+ index: number;
131
+ time: number;
132
+ px: number;
133
+ cell: number;
134
+ box: number[];
135
+ }
136
+ interface SeamDocPair {
137
+ a: string;
138
+ b: string;
139
+ status: 'read' | 'skip';
140
+ opens: number[];
141
+ gap: { frames: SeamHit[] };
142
+ ghost: { frames: SeamHit[] };
143
+ }
144
+
145
+ /** `seam.json` (`rigc-seam/1`): the fields this projection reads, checked; anything else is rigc's. */
146
+ export function readSeamDoc(text: string, path: string): SeamDocPair[] {
147
+ const doc = parseJson(text, path, 'SEAM_JSON');
148
+ const bad = (what: string): never => {
149
+ throw new Refusal([{ code: 'SEAM_JSON', text: `${path}: ${what}; rig-c's rigc-seam/1 (its AUTHORING §8.8) is the contract this reads` }]);
150
+ };
151
+ if (!isObject(doc) || doc.spec !== 'rigc-seam/1' || !Array.isArray(doc.pairs)) return bad('expected spec "rigc-seam/1" with a "pairs" array');
152
+ return doc.pairs.map((p: unknown, i: number) => {
153
+ if (!isObject(p) || typeof p.a !== 'string' || typeof p.b !== 'string' || (p.status !== 'read' && p.status !== 'skip') || !Array.isArray(p.opens)) return bad(`pairs[${i}]: expected a, b, status read|skip, opens`);
154
+ const kind = (k: SeamKind) => {
155
+ const o = p[k];
156
+ if (!isObject(o) || !Array.isArray(o.frames)) return bad(`pairs[${i}].${k}: expected { frames }`);
157
+ const frames = o.frames.map((h: unknown, j: number) => {
158
+ if (!isObject(h) || !Number.isSafeInteger(h.index) || typeof h.time !== 'number' || !Number.isSafeInteger(h.px) || typeof h.cell !== 'number' || !(h.cell > 0) || !Array.isArray(h.box) || h.box.length !== 4 || !h.box.every((v) => typeof v === 'number')) {
159
+ return bad(`pairs[${i}].${k}.frames[${j}]: expected index, time, px, cell > 0, box [minX, minY, maxX, maxY]`);
160
+ }
161
+ return { index: h.index as number, time: h.time, px: h.px as number, cell: h.cell, box: h.box as number[] };
162
+ });
163
+ return { frames };
164
+ };
165
+ return { a: p.a, b: p.b, status: p.status, opens: p.opens.filter((n): n is number => typeof n === 'number'), gap: kind('gap'), ghost: kind('ghost') };
166
+ });
167
+ }
168
+
169
+ /** Runs of consecutive frame indices among `hits` (ascending), each with its first and last time and its most cells. */
170
+ function runsOf(hits: readonly SeamHit[]): Array<{ frames: [number, number]; times: [number, number]; maxPx: number }> {
171
+ const out: Array<{ frames: [number, number]; times: [number, number]; maxPx: number }> = [];
172
+ for (const h of hits) {
173
+ const last = out[out.length - 1];
174
+ if (last !== undefined && h.index === last.frames[1] + 1) {
175
+ last.frames[1] = h.index;
176
+ last.times[1] = h.time;
177
+ last.maxPx = Math.max(last.maxPx, h.px);
178
+ } else out.push({ frames: [h.index, h.index], times: [h.time, h.time], maxPx: h.px });
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * The report's rows from one `seam.json`: per declared pair its reading, and
185
+ * per pair and kind one row per consecutive span of frames over the floor —
186
+ * in the pairs' order, gap before ghost, spans in frame order.
187
+ */
188
+ export function projectSeams(pairs: readonly SeamPair[], doc: readonly SeamDocPair[], unread: ReadonlyMap<string, string>): { seams: SeamSpan[]; seamPairs: SeamPairReading[] } {
189
+ const seams: SeamSpan[] = [];
190
+ const seamPairs: SeamPairReading[] = [];
191
+ for (const p of pairs) {
192
+ const why = unread.get(p.pair);
193
+ const d = doc.find((x) => x.a === p.a && x.b === p.b);
194
+ if (why !== undefined || d === undefined) {
195
+ seamPairs.push({ pair: p.pair, declared: p.declared, status: 'unread', opens: [], maxPx: 0, underFloor: [], reason: why ?? 'seam.json holds no row for it' });
196
+ continue;
197
+ }
198
+ const opens = new Set<number>();
199
+ let maxPx = 0;
200
+ for (const k of SEAM_KINDS) {
201
+ const over = d[k].frames.filter(overFloor);
202
+ for (const h of over) {
203
+ opens.add(h.index);
204
+ maxPx = Math.max(maxPx, h.px);
205
+ }
206
+ for (const r of runsOf(over)) seams.push({ pair: p.pair, kind: k, frames: r.frames, times: r.times, maxPx: r.maxPx });
207
+ }
208
+ const sorted = [...opens].sort((x, y) => x - y);
209
+ seamPairs.push({ pair: p.pair, declared: p.declared, status: d.status, opens: sorted, maxPx, underFloor: d.opens.filter((f) => !opens.has(f)) });
210
+ }
211
+ return { seams, seamPairs };
212
+ }
213
+
214
+ /** Frame indices as runs, `[0-6, 8, 13]`, the form rigc prints a frame list in. */
215
+ export function frameRuns(frames: readonly number[]): string {
216
+ const runs: string[] = [];
217
+ for (let i = 0; i < frames.length; ) {
218
+ let j = i;
219
+ while (j + 1 < frames.length && frames[j + 1] === frames[j]! + 1) j++;
220
+ runs.push(i === j ? String(frames[i]) : `${frames[i]}-${frames[j]}`);
221
+ i = j + 1;
222
+ }
223
+ return `[${runs.join(', ')}]`;
224
+ }
225
+
226
+ /** The run's `SEAM` lines: one per span, one per pair not read or skipped, and the count. */
227
+ export function seamLines(r: { seams: readonly SeamSpan[]; seamPairs: readonly SeamPairReading[] }, fps: number): string[] {
228
+ const out: string[] = [];
229
+ for (const s of r.seams) {
230
+ out.push(`pair "${s.pair}" ${s.kind} on frame(s) ${s.frames[0]}-${s.frames[1]} (t=${s.times[0].toFixed(3)}–${s.times[1].toFixed(3)}s) at ${fps} fps, max ${s.maxPx} px — over the one-cell floor; a reading, not a gate`);
231
+ }
232
+ for (const p of r.seamPairs) {
233
+ if (p.status === 'unread') out.push(`pair "${p.pair}": not read — ${p.reason}`);
234
+ else if (p.status === 'skip') out.push(`pair "${p.pair}": SKIP — rigc read no band (the two drawings share no cell at the setup pose), so nothing was measured`);
235
+ else if (p.underFloor.length > 0) out.push(`pair "${p.pair}": ${p.underFloor.length} frame(s) ${frameRuns(p.underFloor)} open one cell thick, under the floor — not reported`);
236
+ }
237
+ const read = r.seamPairs.filter((p) => p.status === 'read').length;
238
+ const pairsOpen = new Set(r.seams.map((s) => s.pair)).size;
239
+ out.push(`${r.seams.length} span(s) over ${pairsOpen} pair(s) at ${fps} fps — ${read} of ${r.seamPairs.length} declared pair(s) read; a reading, not a gate`);
240
+ return out;
241
+ }
package/src/verify.ts CHANGED
@@ -10,10 +10,12 @@
10
10
  */
11
11
  import { poseRawAnimation, poseRawSetup, type CompiledDocument, type RawBone } from 'rig-c/core';
12
12
  import type { Body18 } from './joints.ts';
13
- import { aimAngle, aimEnds, pointAt, type JointMap } from './map.ts';
13
+ import { aimAngle, aimEnds, pointAt, type Chain, type JointMap } from './map.ts';
14
14
  import type { HipsInferred, Joint, PelvisFit, Run } from './pose.ts';
15
15
  import type { Retarget } from './retarget.ts';
16
- import { pictureAngle, pointOn, wrap } from './retarget.ts';
16
+ import { meanSecondDiff, pictureAngle, pointOn, wrap } from './retarget.ts';
17
+
18
+ const DEG = 180 / Math.PI;
17
19
 
18
20
  export interface Landing {
19
21
  bone: string;
@@ -708,3 +710,110 @@ export function postureLines(q: Posture): string[] {
708
710
  }
709
711
  return out;
710
712
  }
713
+
714
+ /**
715
+ * One keyed bone's jitter against its map pair's (rig-c/pose#236): the mean
716
+ * |second difference| of its local rotation as rig-c's poser posed the gated
717
+ * build — its world matrix in its parent's axes, P⁻¹C, so a rotate key's value
718
+ * up to a constant — against the same figure of its pair's picture angle, less
719
+ * the pair of the nearest keyed bone above it (as the rig's local rotation is
720
+ * against its parent). Both are read on the same triples: three consecutive
721
+ * frames on which every pose point the two pairs need is good.
722
+ */
723
+ export interface JitterRow {
724
+ bone: string;
725
+ chain: string;
726
+ joints: [string, string];
727
+ /** The nearest keyed bone above it, whose pair the pose's local angle is read against; null at the top. */
728
+ against: string | null;
729
+ /** °/frame², rounded to 1e-4; null when no triple was read. */
730
+ rig: number | null;
731
+ pair: number | null;
732
+ /** rig ÷ pair; null when either is unread or the pair is still (its figure rounds to 0). */
733
+ ratio: number | null;
734
+ triples: number;
735
+ /** Whether the bone is aimed by a `reach` chain. */
736
+ reach: boolean;
737
+ }
738
+
739
+ /** #236: a JITTER row for every bone a chain keys with a rotate track, in map order (a face or body dial bone excepted: its key drives a slider, not a pair). */
740
+ export function measureJitter(posed: ReadonlyArray<ReadonlyMap<string, RawBone>>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number): JitterRow[] {
741
+ const dials = new Set([r.face?.yaw?.bone, r.face?.pitch?.bone, r.body?.yaw?.bone].filter((b): b is string => b !== undefined));
742
+ const rotated = new Set(r.animation.tracks.filter((tr) => 'bone' in tr && tr.property === 'rotate').map((tr) => ('bone' in tr ? tr.bone : '')));
743
+ const pairs = new Map<string, { chain: Chain; joints: [string, string] }>();
744
+ for (const c of map.chains) {
745
+ c.bones.forEach((b, k) => {
746
+ if (k + 1 >= c.joints.length || dials.has(b) || !rotated.has(b) || pairs.has(b)) return;
747
+ pairs.set(b, { chain: c, joints: [c.joints[k]!, c.joints[k + 1]!] });
748
+ });
749
+ }
750
+ const n = Math.min(posed.length, frames.length);
751
+ const at = (i: number, j: string): [number, number] | null => {
752
+ const v = pointOn(frames[i]!, j, minScore);
753
+ return typeof v === 'string' ? null : v;
754
+ };
755
+ const pairAngle = (b: string, i: number): number | null => {
756
+ const [j0, j1] = pairs.get(b)!.joints;
757
+ const p0 = at(i, j0);
758
+ const p1 = at(i, j1);
759
+ return p0 === null || p1 === null || (p0[0] === p1[0] && p0[1] === p1[1]) ? null : pictureAngle(p0, p1);
760
+ };
761
+ const out: JitterRow[] = [];
762
+ for (const [b, { chain, joints }] of pairs) {
763
+ let against: string | null = null;
764
+ const first = posed[0]?.get(b);
765
+ for (let a = first?.parent ?? null; a !== null; a = posed[0]!.get(a)?.parent ?? null) {
766
+ if (pairs.has(a)) {
767
+ against = a;
768
+ break;
769
+ }
770
+ }
771
+ const rig: number[] = [];
772
+ const pose: number[] = [];
773
+ const good: boolean[] = [];
774
+ for (let i = 0; i < n; i++) {
775
+ const bone = posed[i]!.get(b)!;
776
+ const parent = bone.parent === null ? undefined : posed[i]!.get(bone.parent);
777
+ rig.push(parent === undefined ? Math.atan2(bone.c, bone.a) * DEG : localAngle(parent, bone));
778
+ const own = pairAngle(b, i);
779
+ const up = against === null ? 0 : pairAngle(against, i);
780
+ good.push(own !== null && up !== null);
781
+ pose.push(own === null || up === null ? 0 : wrap(own - up));
782
+ }
783
+ const ok = (i: number) => good[i] === true;
784
+ const jr = meanSecondDiff([rig], ok);
785
+ const jp = meanSecondDiff([pose], ok);
786
+ const r4 = (v: number | null) => (v === null ? null : Math.round(v * 10000) / 10000);
787
+ out.push({
788
+ bone: b,
789
+ chain: chain.name,
790
+ joints,
791
+ against,
792
+ rig: r4(jr.value),
793
+ pair: r4(jp.value),
794
+ // A pair whose figure rounds to 0 is still: a ratio over its float noise would name nothing.
795
+ ratio: jr.value === null || jp.value === null || r4(jp.value) === 0 ? null : Number((jr.value / jp.value).toFixed(3)),
796
+ triples: jr.triples,
797
+ reach: chain.reach,
798
+ });
799
+ }
800
+ return out;
801
+ }
802
+
803
+ /** A bone's rotation in its parent's axes, degrees: the angle of P⁻¹C's x axis. */
804
+ function localAngle(p: RawBone, c: RawBone): number {
805
+ const det = p.a * p.d - p.b * p.c;
806
+ if (det === 0) return Math.atan2(c.c, c.a) * DEG;
807
+ const la = (p.d * c.a - p.b * c.c) / det;
808
+ const lc = (-p.c * c.a + p.a * c.c) / det;
809
+ return Math.atan2(lc, la) * DEG;
810
+ }
811
+
812
+ /** The `JITTER` line of one keyed bone (#236). */
813
+ export function jitterLine(j: JitterRow): string {
814
+ const f = (v: number | null) => (v === null ? 'not measured' : `${v.toFixed(4)}°/f²`);
815
+ const vs = j.against === null ? `the pair's angle` : `the pair's angle against "${j.against}"'s pair`;
816
+ if (j.triples === 0) return `bone "${j.bone}" (chain "${j.chain}", ${j.joints[0]} → ${j.joints[1]}${j.reach ? ', reach' : ''}): not measured — no three consecutive frames on which the pose points of ${vs} are good`;
817
+ const ratio = j.ratio === null ? (j.pair === 0 ? ' — the pair is still' : '') : ` (${j.ratio.toFixed(2)}×)`;
818
+ return `bone "${j.bone}" (chain "${j.chain}", ${j.joints[0]} → ${j.joints[1]}${j.reach ? ', reach' : ''}): mean |Δ²| of the keyed local rotation as posed ${f(j.rig)} against ${vs} ${f(j.pair)}${ratio}, over ${j.triples} frame triple(s); a reading`;
819
+ }