@rig-c/review 0.6.0 → 0.7.1
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 +206 -12
- package/docs/DESIGN.md +26 -4
- package/docs/REVIEW_PACKAGE.md +113 -0
- package/package.json +1 -1
- package/src/applied-context.ts +285 -0
- package/src/baseline.ts +175 -0
- package/src/bone-anchors.ts +123 -0
- package/src/bones-check.ts +105 -0
- package/src/build-route.ts +109 -0
- package/src/chest-landmarks.ts +122 -0
- package/src/chest-regions.ts +10 -4
- package/src/cli.ts +19 -8
- package/src/config-patch.ts +213 -0
- package/src/cut-edits.ts +184 -0
- package/src/environment.ts +19 -6
- package/src/feedback-export.ts +17 -1
- package/src/front.ts +11 -0
- package/src/handoff.ts +208 -0
- package/src/idle-tracks.ts +401 -0
- package/src/manifest.ts +15 -6
- package/src/mesh-edit.ts +45 -15
- package/src/mesh-entry.ts +351 -0
- package/src/open.ts +12 -5
- package/src/physics-tuning.ts +236 -0
- package/src/producer-files.ts +11 -0
- package/src/producer.ts +158 -0
- package/src/readings-run.ts +129 -0
- package/src/readings.ts +235 -0
- package/src/rebuild.ts +938 -0
- package/src/reference.ts +4 -3
- package/src/requirements-run.ts +160 -0
- package/src/requirements.ts +230 -0
- package/src/retarget.ts +211 -0
- package/src/retune-route.ts +52 -0
- package/src/rig-fields.ts +295 -0
- package/src/server.ts +59 -5
- package/src/session.ts +9 -0
- package/src/setup-mesh.ts +144 -0
- package/src/weights.ts +74 -0
- package/web/bones-panel.ts +230 -0
- package/web/chest-editor.ts +128 -13
- package/web/chest-regions-tool.ts +2 -1
- package/web/cuts-panel.ts +266 -0
- package/web/idle-panel.ts +280 -0
- package/web/index.html +1 -1
- package/web/inspector-blocks.ts +77 -0
- package/web/main.ts +701 -49
- package/web/mesh-editor.ts +73 -38
- package/web/mesh-panel.ts +273 -0
- package/web/physics-panel.ts +151 -0
- package/web/readings-panel.ts +105 -0
- package/web/rebuild-client.ts +216 -0
- package/web/requirements-panel.ts +241 -0
- package/web/retarget-panel.ts +203 -0
- package/web/rig-fields-panel.ts +269 -0
- package/web/rig-overlays.ts +108 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +28 -5
- package/web/weights-view.ts +159 -0
package/src/retarget.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retarget tuning (#114): a pose sequence the package declares goes onto the inspected rig through the installed
|
|
3
|
+
* `@rig-c/pose` — `rig-pose clean`, then `rig-pose retarget` with the person's `rig-pose-map/1` and flags, then the
|
|
4
|
+
* compile — into a scratch build that plays beside the reference. Review authors no keys: the motion is the producer's,
|
|
5
|
+
* written in scratch; the package's motion.json is never written.
|
|
6
|
+
*
|
|
7
|
+
* Pure: the typed request (`{sequence, map, flags, configHash}` — a declared reference id, the map document by content
|
|
8
|
+
* and a closed set of typed flags; never a path, never a flag string), the argv each step runs with (the server maps the
|
|
9
|
+
* typed flags; the client sends none), the record of what was applied (`rig-review-retarget/1`, whose `op.args` and
|
|
10
|
+
* `op.clean` hold the flag set as argv) and the run's FEET / STANCE / FLIP / SHORT / END-ON / LEG-SCALE / REACH lines with
|
|
11
|
+
* the frame each one names.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
export const RETARGET_SCHEMA = 'rig-review-retarget/1';
|
|
16
|
+
export const POSE_MAP_SCHEMA = 'rig-pose-map/1';
|
|
17
|
+
/** The map document as sent, at most this many bytes of JSON. */
|
|
18
|
+
export const MAX_MAP_BYTES = 32 * 1024;
|
|
19
|
+
const NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
|
|
20
|
+
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
|
|
21
|
+
|
|
22
|
+
export class RetargetError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
|
|
23
|
+
const bad = (detail: string): never => { throw new RetargetError('RETARGET_REQUEST_INVALID', detail); };
|
|
24
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
25
|
+
const exactly = (v: unknown, keys: string, field: string): Record<string, unknown> => {
|
|
26
|
+
if (!isObject(v) || Object.keys(v).sort().join(',') !== keys) bad(`${field} holds exactly ${keys.split(',').join(', ')}`);
|
|
27
|
+
return v as Record<string, unknown>;
|
|
28
|
+
};
|
|
29
|
+
const num = (v: unknown, field: string, ok: (n: number) => boolean, what: string): number => {
|
|
30
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || !ok(v)) bad(`${field} must be ${what}`);
|
|
31
|
+
return v as number;
|
|
32
|
+
};
|
|
33
|
+
const orNull = <T>(v: unknown, read: (v: unknown) => T): T | null => (v === null ? null : read(v));
|
|
34
|
+
const bool = (v: unknown, field: string): boolean => (typeof v === 'boolean' ? v : bad(`${field} must be true or false`));
|
|
35
|
+
|
|
36
|
+
/** `rig-pose clean`'s flags, typed. `legs` / `rest`: 1 (none), or odd and ≥ 5, as clean's help states. */
|
|
37
|
+
export interface CleanFlags { loop: boolean; legs: number; rest: number; hold: boolean; hipsInferred: boolean }
|
|
38
|
+
/**
|
|
39
|
+
* `rig-pose retarget`'s flags, typed; `clean: null` retargets the sequence as the package holds it (retarget refuses one
|
|
40
|
+
* without clean's record, `POSE_NOT_CLEANED`). A null figure leaves the producer's default (`--flip`/`--short` off).
|
|
41
|
+
*/
|
|
42
|
+
export interface RetargetFlags {
|
|
43
|
+
animation: string; person: string | null; clean: CleanFlags | null;
|
|
44
|
+
minScore: number | null; flip: number | null; short: number | null;
|
|
45
|
+
bends: 'refuse' | 'clamp'; bendTolerance: number | null;
|
|
46
|
+
legScale: [number, number] | null; plantFeet: boolean; stance: number | null;
|
|
47
|
+
raisedByHead: boolean; carryIdle: boolean;
|
|
48
|
+
}
|
|
49
|
+
export const DEFAULT_CLEAN: CleanFlags = { loop: false, legs: 5, rest: 7, hold: true, hipsInferred: false };
|
|
50
|
+
export const DEFAULT_FLAGS: RetargetFlags = { animation: 'retarget', person: null, clean: { ...DEFAULT_CLEAN }, minScore: null, flip: null, short: null, bends: 'refuse', bendTolerance: null, legScale: null, plantFeet: false, stance: null, raisedByHead: false, carryIdle: false };
|
|
51
|
+
const FLAG_KEYS = 'animation,bendTolerance,bends,carryIdle,clean,flip,legScale,minScore,person,plantFeet,raisedByHead,short,stance';
|
|
52
|
+
const windowOk = (n: number) => Number.isInteger(n) && (n === 1 || (n >= 5 && n <= 61 && n % 2 === 1));
|
|
53
|
+
|
|
54
|
+
export function parseCleanFlags(v: unknown, field = 'flags.clean'): CleanFlags {
|
|
55
|
+
const c = exactly(v, 'hipsInferred,hold,legs,loop,rest', field);
|
|
56
|
+
return { loop: bool(c.loop, `${field}.loop`), legs: num(c.legs, `${field}.legs`, windowOk, '1, or odd and 5–61'), rest: num(c.rest, `${field}.rest`, windowOk, '1, or odd and 5–61'), hold: bool(c.hold, `${field}.hold`), hipsInferred: bool(c.hipsInferred, `${field}.hipsInferred`) };
|
|
57
|
+
}
|
|
58
|
+
export function parseRetargetFlags(v: unknown): RetargetFlags {
|
|
59
|
+
const f = exactly(v, FLAG_KEYS, 'flags');
|
|
60
|
+
if (typeof f.animation !== 'string' || !NAME.test(f.animation)) bad('flags.animation must be an animation name ([A-Za-z0-9_][A-Za-z0-9_.-], at most 64)');
|
|
61
|
+
const person = orNull(f.person, p => (typeof p === 'string' && NAME.test(p) ? p : bad('flags.person must be a person id or null')));
|
|
62
|
+
const bends = f.bends === 'refuse' || f.bends === 'clamp' ? f.bends : bad('flags.bends must be refuse or clamp');
|
|
63
|
+
const legScale = orNull(f.legScale, l => {
|
|
64
|
+
if (!Array.isArray(l) || l.length !== 2) bad('flags.legScale must be [min, max] or null');
|
|
65
|
+
const [min, max] = (l as unknown[]).map((n, i) => num(n, `flags.legScale[${i}]`, x => x > 0 && x <= 4, 'in (0, 4]'));
|
|
66
|
+
if (!(min! <= 1 && 1 <= max!)) bad('flags.legScale must hold 0 < min ≤ 1 ≤ max');
|
|
67
|
+
return [min!, max!] as [number, number];
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
animation: f.animation as string, person, clean: orNull(f.clean, c => parseCleanFlags(c)),
|
|
71
|
+
minScore: orNull(f.minScore, n => num(n, 'flags.minScore', x => x >= 0 && x <= 100, 'in [0, 100]')),
|
|
72
|
+
flip: orNull(f.flip, n => num(n, 'flags.flip', x => x > 0 && x <= 180, 'degrees in (0, 180]')),
|
|
73
|
+
short: orNull(f.short, n => num(n, 'flags.short', x => x > 0 && x < 1, 'a fraction in (0, 1)')),
|
|
74
|
+
bends, bendTolerance: orNull(f.bendTolerance, n => num(n, 'flags.bendTolerance', x => x >= 0 && x <= 180, 'degrees in [0, 180]')),
|
|
75
|
+
legScale, plantFeet: bool(f.plantFeet, 'flags.plantFeet'),
|
|
76
|
+
stance: orNull(f.stance, n => num(n, 'flags.stance', x => x > 0 && x <= 1, 'leg lengths in (0, 1]')),
|
|
77
|
+
raisedByHead: bool(f.raisedByHead, 'flags.raisedByHead'), carryIdle: bool(f.carryIdle, 'flags.carryIdle'),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const figure = (n: number) => String(n);
|
|
82
|
+
/** `rig-pose clean`'s flags as argv; the paths (`--pose`, `--out`) are the server's. */
|
|
83
|
+
export function cleanArgv(c: CleanFlags, person: string | null): string[] {
|
|
84
|
+
return [...(c.loop ? ['--loop'] : []), '--legs', figure(c.legs), '--rest', figure(c.rest), ...(c.hold ? [] : ['--hold', 'off']), ...(c.hipsInferred ? ['--hips-inferred'] : []), ...(person ? ['--person', person] : [])];
|
|
85
|
+
}
|
|
86
|
+
/** `rig-pose retarget`'s flags as argv, only what the typed flags state; the paths (`--rig`, `--map`, `--sequence`, `--out`) are the server's. */
|
|
87
|
+
export function retargetArgv(f: RetargetFlags): string[] {
|
|
88
|
+
return [
|
|
89
|
+
'--animation', f.animation,
|
|
90
|
+
// A cleaned sequence holds the one person clean wrote; --person reaches retarget only when clean is skipped.
|
|
91
|
+
...(f.person && !f.clean ? ['--person', f.person] : []),
|
|
92
|
+
...(f.minScore !== null ? ['--min-score', figure(f.minScore)] : []),
|
|
93
|
+
...(f.flip !== null ? ['--flip', figure(f.flip)] : []),
|
|
94
|
+
...(f.short !== null ? ['--short', figure(f.short)] : []),
|
|
95
|
+
...(f.bends === 'clamp' ? ['--bends', 'clamp'] : []),
|
|
96
|
+
...(f.bendTolerance !== null ? ['--bend-tolerance', figure(f.bendTolerance)] : []),
|
|
97
|
+
...(f.legScale ? ['--leg-scale', `${figure(f.legScale[0])},${figure(f.legScale[1])}`] : []),
|
|
98
|
+
...(f.plantFeet ? ['--plant-feet'] : []),
|
|
99
|
+
...(f.stance !== null ? ['--stance', figure(f.stance)] : []),
|
|
100
|
+
...(f.raisedByHead ? ['--raised-by-head'] : []),
|
|
101
|
+
...(f.carryIdle ? ['--carry-idle'] : []),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
export const cleanArgs = (r: { pose: string; out: string; argv: readonly string[] }): string[] => ['clean', '--pose', r.pose, '--out', r.out, ...r.argv];
|
|
105
|
+
export const retargetArgs = (r: { rig: string; map: string; sequence: string; out: string; argv: readonly string[] }): string[] => ['retarget', '--rig', r.rig, '--map', r.map, '--sequence', r.sequence, '--out', r.out, ...r.argv];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The map's envelope: `rig-pose-map/1`, chains each with a name, bones and joints (strings), at most `MAX_MAP_BYTES`.
|
|
109
|
+
* Whether its bones are the rig's, its chains descend or its aims resolve is the producer's to judge (`MAP_*`, verbatim).
|
|
110
|
+
*/
|
|
111
|
+
export function parsePoseMap(v: unknown): Record<string, unknown> {
|
|
112
|
+
if (!isObject(v) || v.spec !== POSE_MAP_SCHEMA) bad(`map must be a ${POSE_MAP_SCHEMA} document`);
|
|
113
|
+
const map = v as Record<string, unknown>;
|
|
114
|
+
if (Buffer.byteLength(JSON.stringify(map)) > MAX_MAP_BYTES) bad(`map is over ${MAX_MAP_BYTES} bytes`);
|
|
115
|
+
if (!Array.isArray(map.chains) || map.chains.length < 1 || map.chains.length > 64) bad('map.chains must hold 1–64 chains');
|
|
116
|
+
for (const [i, raw] of (map.chains as unknown[]).entries()) {
|
|
117
|
+
if (!isObject(raw)) bad(`map.chains[${i}] must be an object`);
|
|
118
|
+
const c = raw as Record<string, unknown>;
|
|
119
|
+
if (typeof c.name !== 'string' || !c.name || c.name.length > 64) bad(`map.chains[${i}].name`);
|
|
120
|
+
for (const key of ['bones', 'joints'] as const) if (!Array.isArray(c[key]) || !(c[key] as unknown[]).length || (c[key] as unknown[]).length > 32 || !(c[key] as unknown[]).every(s => typeof s === 'string' && s.length > 0 && s.length <= 128)) bad(`map.chains[${i}].${key} must hold 1–32 names`);
|
|
121
|
+
}
|
|
122
|
+
return structuredClone(map);
|
|
123
|
+
}
|
|
124
|
+
/** The map file review writes into scratch; `map_sha256` is its hash. */
|
|
125
|
+
export const mapText = (map: Record<string, unknown>) => JSON.stringify(map, null, 1) + '\n';
|
|
126
|
+
|
|
127
|
+
export interface RetargetRequest { sequence: string; map: Record<string, unknown>; flags: RetargetFlags; configHash: string }
|
|
128
|
+
/** `{sequence, map, flags, configHash}`: a declared pose reference id, the map by content, typed flags, the handoff config hash read. */
|
|
129
|
+
export function parseRetargetRequest(input: unknown): RetargetRequest {
|
|
130
|
+
const v = exactly(input, 'configHash,flags,map,sequence', 'retarget');
|
|
131
|
+
if (typeof v.sequence !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(v.sequence)) bad('sequence must be a declared pose reference id');
|
|
132
|
+
if (typeof v.configHash !== 'string' || !/^[a-f0-9]{64}$/.test(v.configHash)) bad('configHash');
|
|
133
|
+
return { sequence: v.sequence as string, map: parsePoseMap(v.map), flags: parseRetargetFlags(v.flags), configHash: v.configHash as string };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What was applied, the data form a feedback record carries: the handoff config and rig it ran against, the sequence
|
|
138
|
+
* (reference id, package path, sha256), the map by content and hash, the typed flags, the producer, and `op` — the flag
|
|
139
|
+
* set as the argv each step ran with (`args` for retarget, `clean` for clean, null when clean was skipped).
|
|
140
|
+
*/
|
|
141
|
+
export interface RetargetRecord {
|
|
142
|
+
schema: typeof RETARGET_SCHEMA;
|
|
143
|
+
config_sha256: string;
|
|
144
|
+
rig: { path: string; sha256: string };
|
|
145
|
+
sequence: { reference: string; path: string; sha256: string };
|
|
146
|
+
map: Record<string, unknown>; map_sha256: string;
|
|
147
|
+
flags: RetargetFlags;
|
|
148
|
+
producer: { name: string; version: string };
|
|
149
|
+
op: { cmd: 'retarget'; args: string[]; clean: string[] | null };
|
|
150
|
+
}
|
|
151
|
+
export function retargetRecord(r: RetargetRequest, at: { rig: { path: string; sha256: string }; sequence: { path: string; sha256: string }; producer: { name: string; version: string } }): RetargetRecord {
|
|
152
|
+
return {
|
|
153
|
+
schema: RETARGET_SCHEMA, config_sha256: r.configHash, rig: { ...at.rig }, sequence: { reference: r.sequence, ...at.sequence },
|
|
154
|
+
map: structuredClone(r.map), map_sha256: sha256(mapText(r.map)), flags: structuredClone(r.flags), producer: { ...at.producer },
|
|
155
|
+
op: { cmd: 'retarget', args: retargetArgv(r.flags), clean: r.flags.clean ? cleanArgv(r.flags.clean, r.flags.person) : null },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const hex = (v: unknown) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
159
|
+
/** A record exactly its shape, its argv the typed flags' own and its map hash the map's. Throws a plain Error. */
|
|
160
|
+
export function assertRetargetRecord(value: unknown, field: string): RetargetRecord {
|
|
161
|
+
const fail = (detail: string): never => { throw new Error(`${field}: ${detail}`); };
|
|
162
|
+
if (!isObject(value) || Object.keys(value).sort().join(',') !== 'config_sha256,flags,map,map_sha256,op,producer,rig,schema,sequence') fail('expected exactly {schema, config_sha256, rig, sequence, map, map_sha256, flags, producer, op}');
|
|
163
|
+
const v = value as Record<string, unknown>;
|
|
164
|
+
if (v.schema !== RETARGET_SCHEMA) fail(`schema must be ${RETARGET_SCHEMA}`);
|
|
165
|
+
if (![v.config_sha256, v.map_sha256].every(hex)) fail('config_sha256 and map_sha256 are SHA-256 hex');
|
|
166
|
+
const rig = v.rig as Record<string, unknown>, seq = v.sequence as Record<string, unknown>, producer = v.producer as Record<string, unknown>;
|
|
167
|
+
if (!isObject(rig) || typeof rig.path !== 'string' || !hex(rig.sha256) || Object.keys(rig).length !== 2) fail('rig must be {path, sha256}');
|
|
168
|
+
if (!isObject(seq) || typeof seq.reference !== 'string' || typeof seq.path !== 'string' || !hex(seq.sha256) || Object.keys(seq).length !== 3) fail('sequence must be {reference, path, sha256}');
|
|
169
|
+
if (!isObject(producer) || typeof producer.name !== 'string' || typeof producer.version !== 'string' || Object.keys(producer).length !== 2) fail('producer must be {name, version}');
|
|
170
|
+
let flags: RetargetFlags, map: Record<string, unknown>;
|
|
171
|
+
try { flags = parseRetargetFlags(v.flags); map = parsePoseMap(v.map); } catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
|
|
172
|
+
if (sha256(mapText(map)) !== v.map_sha256) fail('map_sha256 is not the map\'s');
|
|
173
|
+
const op = v.op as Record<string, unknown>;
|
|
174
|
+
if (!isObject(op) || op.cmd !== 'retarget' || JSON.stringify(op.args) !== JSON.stringify(retargetArgv(flags)) || JSON.stringify(op.clean) !== JSON.stringify(flags.clean ? cleanArgv(flags.clean, flags.person) : null) || Object.keys(op).length !== 3) fail('op must be {cmd: "retarget", args, clean}, the argv of its flags');
|
|
175
|
+
return value as unknown as RetargetRecord;
|
|
176
|
+
}
|
|
177
|
+
export const retargetRecordText = (r: RetargetRecord) => JSON.stringify(r);
|
|
178
|
+
|
|
179
|
+
/** The run's lines the card lists, each with the first frame it names (and that frame's key time, when known). */
|
|
180
|
+
export const RETARGET_LINE_CODES = ['FEET', 'STANCE', 'FLIP', 'SHORT', 'END-ON', 'LEG-SCALE', 'REACH'] as const;
|
|
181
|
+
export type RetargetLineCode = typeof RETARGET_LINE_CODES[number];
|
|
182
|
+
export interface RetargetLine { code: RetargetLineCode; line: string; frame: number | null; time: number | null }
|
|
183
|
+
const CODE = new RegExp(`^\\s*(${RETARGET_LINE_CODES.map(c => c.replace('-', '\\-')).join('|')})\\s`);
|
|
184
|
+
/**
|
|
185
|
+
* Each listed line verbatim (trimmed), in run order. The frame is the first `frame N` / `frames N–M` / `from frame N`
|
|
186
|
+
* it names; its time is the motion's own key time for that frame (`frameTimes`, the run's report), else the line's `t=`.
|
|
187
|
+
*/
|
|
188
|
+
export function retargetLines(lines: readonly string[], frameTimes: readonly number[] | null): RetargetLine[] {
|
|
189
|
+
const out: RetargetLine[] = [];
|
|
190
|
+
for (const raw of lines) {
|
|
191
|
+
const match = CODE.exec(raw);
|
|
192
|
+
if (!match) continue;
|
|
193
|
+
const line = raw.trim(), f = /\bframes? (\d+)/.exec(line), t = /\bt=(-?\d+(?:\.\d+)?)/.exec(line);
|
|
194
|
+
const frame = f ? Number(f[1]) : null;
|
|
195
|
+
const keyed = frame !== null && frameTimes && Number.isFinite(frameTimes[frame]) ? frameTimes[frame]! : null;
|
|
196
|
+
out.push({ code: match[1] as RetargetLineCode, line, frame, time: keyed ?? (t ? Number(t[1]) : null) });
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The scratch motion against the rig's own: the new animation's name, whether every animation the rig already had is
|
|
203
|
+
* byte-for-byte what it was (retarget writes "the rig's own animations plus the new one"), and the new one's hash.
|
|
204
|
+
*/
|
|
205
|
+
export interface MotionSummary { animation: string; animations: string[]; kept: boolean; changed: string[]; animationSha256: string | null; motionSha256: string }
|
|
206
|
+
export function motionSummary(inspected: unknown, scratch: unknown, scratchText: string, animation: string): MotionSummary {
|
|
207
|
+
const anims = (doc: unknown): Record<string, unknown> => (isObject(doc) && isObject(doc.animations) ? doc.animations : {});
|
|
208
|
+
const a = anims(inspected), b = anims(scratch);
|
|
209
|
+
const changed = Object.keys(a).filter(name => JSON.stringify(a[name]) !== JSON.stringify(b[name]));
|
|
210
|
+
return { animation, animations: Object.keys(b), kept: !changed.length, changed, animationSha256: b[animation] === undefined ? null : sha256(JSON.stringify(b[animation])), motionSha256: sha256(scratchText) };
|
|
211
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which producer op a `rig` config patch runs (#136). `rig-parts retune` (`@rig-c/parts` 1.44.0, parts#530) splices
|
|
3
|
+
* what a config states for the constraints and the idle onto the gated inspected rig and holds every other byte, so a
|
|
4
|
+
* repaired build — whose baseline the whole-rig `rig --config` cannot reproduce — can still take a physics or idle
|
|
5
|
+
* edit. Its help states the scope; the routing below is read off that statement, not off the panels:
|
|
6
|
+
*
|
|
7
|
+
* "rig.json takes the build's constraints and invariants.detached, motion.json its animations.idle, every other
|
|
8
|
+
* byte is the input's (RETUNE_PRESERVED)" and the build "must state the input's stops, bones, slots, skeleton,
|
|
9
|
+
* images, every invariant but detached and every byte of motion.json but animations.idle (RETUNE_REQUEST_HELD): a
|
|
10
|
+
* moved bone, a redrawn image, a stops change or an animations_from change is no retune."
|
|
11
|
+
*
|
|
12
|
+
* So a patch routes to `retune` only when every op's pointer is one of `RETUNE_PATHS`; anything else — and any patch
|
|
13
|
+
* for a producer older than `RETUNE_SINCE` — keeps the whole-rig `rig` path behind the reproduced baseline. Within
|
|
14
|
+
* `motion.blink`, `eyes` and `brows` name the blink's motion.json groups and `still` adds a `<part>_still` slot (rig-parts
|
|
15
|
+
* `src/motion.ts`, `src/config.ts` BlinkStill), all held, so only the blink's numbers route to retune. What the producer
|
|
16
|
+
* still judges per value (a track that newly keys a mesh-bound bone gets a `<bone>_ctl` under `--idle-keys ctl`, a bone
|
|
17
|
+
* change) comes back as its own `RETUNE_REQUEST_HELD` line, verbatim. Pure: no IO.
|
|
18
|
+
*/
|
|
19
|
+
import { parsePointer, type PatchOp } from './config-patch.ts';
|
|
20
|
+
import { versionAtLeast } from './producer.ts';
|
|
21
|
+
|
|
22
|
+
/** The first `@rig-c/parts` release with `rig-parts retune` (its CHANGELOG: 1.44.0, "retune: splice constraints and the idle onto a gated rig, every other byte held"). */
|
|
23
|
+
export const RETUNE_SINCE = '1.44.0';
|
|
24
|
+
export type PatchRoute = 'retune' | 'rig';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The config pointers retune carries, as token prefixes: an op routes to retune when its pointer is the prefix or lies
|
|
28
|
+
* under it. `/constraints` → rig.json `constraints` and `invariants.detached`; `/motion/duration`, `/motion/tracks` and the
|
|
29
|
+
* blink's `t`, `squash` and `brow_drop` → motion.json `animations.idle`.
|
|
30
|
+
*/
|
|
31
|
+
export const RETUNE_PATHS: readonly (readonly string[])[] = [
|
|
32
|
+
['constraints'],
|
|
33
|
+
['motion', 'duration'],
|
|
34
|
+
['motion', 'tracks'],
|
|
35
|
+
['motion', 'blink', 't'],
|
|
36
|
+
['motion', 'blink', 'squash'],
|
|
37
|
+
['motion', 'blink', 'brow_drop'],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** One op's route: `retune` when its pointer lies at or under a `RETUNE_PATHS` prefix, else `rig`. */
|
|
41
|
+
export function opRoute(path: string): PatchRoute {
|
|
42
|
+
const tokens = parsePointer(path);
|
|
43
|
+
return RETUNE_PATHS.some(prefix => prefix.length <= tokens.length && prefix.every((t, i) => tokens[i] === t)) ? 'retune' : 'rig';
|
|
44
|
+
}
|
|
45
|
+
/** A `rig` patch's route from its ops alone: `retune` only when every op routes there (an empty list is `rig`). */
|
|
46
|
+
export function routeForPatch(ops: readonly Pick<PatchOp, 'path'>[]): PatchRoute {
|
|
47
|
+
return ops.length > 0 && ops.every(op => opRoute(op.path) === 'retune') ? 'retune' : 'rig';
|
|
48
|
+
}
|
|
49
|
+
/** The op the runner runs for a `rig` patch with this producer: `retune` when the ops allow it and the producer has it. */
|
|
50
|
+
export function patchRoute(ops: readonly Pick<PatchOp, 'path'>[], producerVersion: string): PatchRoute {
|
|
51
|
+
return versionAtLeast(producerVersion, RETUNE_SINCE) ? routeForPatch(ops) : 'rig';
|
|
52
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup · Rig fields (#110): the small producer config fields the Fields block edits, read from the handoff config and
|
|
3
|
+
* written back as config patch ops (`src/config-patch.ts`), one pointer per field (rig-parts AUTHORING §3):
|
|
4
|
+
* - `regions.<part>` — the bone a rigid part rides: `replace /regions/<part>` with a bone name.
|
|
5
|
+
* - `motion.blink.still.<part>` — `{row, bone}`: the rows of a lash above `row` (rig px, y down) drawn by `<part>_still`
|
|
6
|
+
* on `bone` and kept off the blink: `add /motion/blink/still/<part>` (preceded by `add /motion/blink/still {}` when
|
|
7
|
+
* the config states no `still`), `remove` to drop it.
|
|
8
|
+
* - `stops[i]` — `{chain, links?, against, at, inward, outward, lower?}`, rig-c's leg stop: `replace /stops/<i>` with the
|
|
9
|
+
* entry, `inward` / `outward` changed (degrees), naming its chain.
|
|
10
|
+
* - `bones[i].inherit` (a single bone) or `bones[i].link_inherit` (a chain, `{<link>: mode}`): one of rig-c's five
|
|
11
|
+
* mode names, `add` (or `remove` back to absent), naming the bone.
|
|
12
|
+
* - `turn.pivot` and `turn.depths.<part>` — `replace /turn/pivot`, `replace /turn/depths/<part>` (numbers).
|
|
13
|
+
* The producer judges every value (`CONFIG_STILL_OFF_THE_BLINK`, `CONFIG_BONE_INHERIT`, `CONFIG_TURN_*`,
|
|
14
|
+
* `CONFIG_NAME_RESOLVES`, `RIG_STILL_ROW_CLEAR`, `RIG_STOP_BOUNDS`): review pre-checks nothing it would refuse and shows
|
|
15
|
+
* its line verbatim. Pure: no IO, no DOM, no clock.
|
|
16
|
+
*/
|
|
17
|
+
import {formatPointer,parsePointer,type JsonValue,type PatchOp} from './config-patch.ts';
|
|
18
|
+
|
|
19
|
+
/** rig-c's five inherit modes as rig-parts AUTHORING (`CONFIG_BONE_INHERIT`) spells them. */
|
|
20
|
+
export const INHERIT_MODES=['normal','onlyTranslation','noRotationOrReflection','noScale','noScaleOrReflection'] as const;
|
|
21
|
+
export type InheritMode=typeof INHERIT_MODES[number];
|
|
22
|
+
/** The check bars the Fields readout quotes before and after an Apply. */
|
|
23
|
+
export const RIG_FIELDS_READOUT_BARS=['BLINK_NO_HOLE','FEET_STAY_LEVEL'] as const;
|
|
24
|
+
/** The bone the rig stage adds for `turn`: its local x is the dial, in rig px; nothing keys it. */
|
|
25
|
+
export const TURN_BONE='turn';
|
|
26
|
+
/** The dial's range, rig px either way. */
|
|
27
|
+
export const TURN_DIAL_RANGE=120;
|
|
28
|
+
export const TURN_PREVIEW_LABEL='preview — Apply decides';
|
|
29
|
+
|
|
30
|
+
export type RigPoint=[number,number];
|
|
31
|
+
export interface StillEntry {row:number;bone:string}
|
|
32
|
+
export interface StopEntry {index:number;chain:string;links:string[]|null;against:string;at:RigPoint;inward:number;outward:number;raw:Record<string,JsonValue>}
|
|
33
|
+
export interface FieldBone {index:number;name:string;chain:boolean;links:string[];parent:string|null;at:RigPoint|null;points:RigPoint[];tip:RigPoint|null;inherit:string|null;linkInherit:Record<string,string>|null}
|
|
34
|
+
export interface RigFieldsSource {
|
|
35
|
+
/** `regions` as written (part → bone), or null when the config states none. */
|
|
36
|
+
regions:Record<string,string>|null;
|
|
37
|
+
/** `motion.blink`: its `eyes` and its `still` (null when absent); null when the config states no blink. */
|
|
38
|
+
blink:{eyes:string[];still:Record<string,StillEntry>|null}|null;
|
|
39
|
+
stops:StopEntry[]|null;
|
|
40
|
+
bones:FieldBone[];
|
|
41
|
+
/** Every bone the config declares (chain links expanded), plus `root`, which the rig stage adds. */
|
|
42
|
+
boneNames:string[];
|
|
43
|
+
turn:{pivot:number;depths:Record<string,number>}|null;
|
|
44
|
+
/** Entries not read, named. */
|
|
45
|
+
skipped:string[];
|
|
46
|
+
}
|
|
47
|
+
export class RigFieldsError extends Error {constructor(readonly code:string,detail:string){super(`${code}: ${detail}`);}}
|
|
48
|
+
|
|
49
|
+
const isObject=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
|
|
50
|
+
const asPoint=(v:unknown):RigPoint|null=>Array.isArray(v)&&v.length===2&&v.every(n=>typeof n==='number'&&Number.isFinite(n))?[v[0] as number,v[1] as number]:null;
|
|
51
|
+
const finite=(v:unknown):v is number=>typeof v==='number'&&Number.isFinite(v);
|
|
52
|
+
|
|
53
|
+
/** The config's fields as the Fields block reads them; an entry it cannot read is named in `skipped`, never guessed. */
|
|
54
|
+
export function rigFieldsSource(config:unknown):RigFieldsSource {
|
|
55
|
+
const c=isObject(config)?config:{},skipped:string[]=[];
|
|
56
|
+
const regions=isObject(c.regions)?Object.fromEntries(Object.entries(c.regions).filter(([part,bone])=>{if(typeof bone==='string')return true;skipped.push(`regions.${part} is not a bone name`);return false;})) as Record<string,string>:null;
|
|
57
|
+
const bones:FieldBone[]=[];
|
|
58
|
+
(Array.isArray(c.bones)?c.bones:[]).forEach((raw,index)=>{
|
|
59
|
+
if(!isObject(raw))return;
|
|
60
|
+
const chain=typeof raw.chain==='string'&&typeof raw.name!=='string',name=typeof raw.name==='string'?raw.name:typeof raw.chain==='string'?raw.chain:null;if(!name)return;
|
|
61
|
+
const points=Array.isArray(raw.points)?raw.points.map(asPoint).filter((p):p is RigPoint=>!!p):[];
|
|
62
|
+
const linkInherit=isObject(raw.link_inherit)?Object.fromEntries(Object.entries(raw.link_inherit).filter(([,m])=>typeof m==='string')) as Record<string,string>:null;
|
|
63
|
+
bones.push({index,name,chain,links:chain?points.map((_,k)=>`${name}${k}`):[],parent:typeof raw.parent==='string'?raw.parent:null,at:asPoint(raw.at),points,tip:asPoint(raw.tip),inherit:typeof raw.inherit==='string'?raw.inherit:null,linkInherit});
|
|
64
|
+
});
|
|
65
|
+
const boneNames=['root',...bones.flatMap(b=>b.chain?b.links:[b.name])];
|
|
66
|
+
const motion=isObject(c.motion)?c.motion:{},blinkRaw=isObject(motion.blink)?motion.blink:null;
|
|
67
|
+
let blink:RigFieldsSource['blink']=null;
|
|
68
|
+
if(blinkRaw){
|
|
69
|
+
const eyes=Array.isArray(blinkRaw.eyes)?blinkRaw.eyes.filter((e):e is string=>typeof e==='string'):[];
|
|
70
|
+
let still:Record<string,StillEntry>|null=null;
|
|
71
|
+
if(isObject(blinkRaw.still)){still={};for(const [part,v] of Object.entries(blinkRaw.still)){if(isObject(v)&&finite(v.row)&&typeof v.bone==='string')still[part]={row:v.row,bone:v.bone};else skipped.push(`motion.blink.still.${part} is not {row, bone}`);}}
|
|
72
|
+
blink={eyes,still};
|
|
73
|
+
}
|
|
74
|
+
let stops:StopEntry[]|null=null;
|
|
75
|
+
if(Array.isArray(c.stops)){stops=[];c.stops.forEach((raw,index)=>{
|
|
76
|
+
const at=isObject(raw)?asPoint(raw.at):null;
|
|
77
|
+
if(!isObject(raw)||typeof raw.chain!=='string'||typeof raw.against!=='string'||!at||!finite(raw.inward)||!finite(raw.outward)){skipped.push(`stops[${index}] is not {chain, against, at, inward, outward}`);return;}
|
|
78
|
+
stops!.push({index,chain:raw.chain,links:Array.isArray(raw.links)?raw.links.filter((l):l is string=>typeof l==='string'):null,against:raw.against,at,inward:raw.inward,outward:raw.outward,raw:structuredClone(raw) as Record<string,JsonValue>});
|
|
79
|
+
});}
|
|
80
|
+
let turn:RigFieldsSource['turn']=null;
|
|
81
|
+
if(isObject(c.turn)){
|
|
82
|
+
const depths=isObject(c.turn.depths)?Object.fromEntries(Object.entries(c.turn.depths).filter(([,d])=>finite(d))) as Record<string,number>:{};
|
|
83
|
+
if(finite(c.turn.pivot))turn={pivot:c.turn.pivot,depths};else skipped.push('turn.pivot is not a number');
|
|
84
|
+
}
|
|
85
|
+
return {regions,blink,stops,bones,boneNames,turn,skipped};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- pending edits ----
|
|
89
|
+
/** One pending field edit, keyed by its pointer: what the config holds (`from`, undefined when absent) and the value. */
|
|
90
|
+
export interface FieldEdit {field:'region'|'still'|'stop'|'inherit'|'turn';path:string;op:'replace'|'add'|'remove';from:JsonValue|undefined;value?:JsonValue;name?:string;label:string}
|
|
91
|
+
export type FieldEdits=ReadonlyMap<string,FieldEdit>;
|
|
92
|
+
const same=(a:unknown,b:unknown)=>JSON.stringify(a)===JSON.stringify(b);
|
|
93
|
+
function put(edits:FieldEdits,edit:FieldEdit):Map<string,FieldEdit> {
|
|
94
|
+
const next=new Map(edits);
|
|
95
|
+
// Back to what the config holds: no pending op.
|
|
96
|
+
if(edit.op==='remove'?edit.from===undefined:same(edit.value,edit.from))next.delete(edit.path);else next.set(edit.path,edit);
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
/** The pointers, one function per field: the patch path an edit writes (the bars' wrong-path mutants change these). */
|
|
100
|
+
export const regionPath=(part:string)=>formatPointer(['regions',part]);
|
|
101
|
+
export const stillPath=(part:string)=>formatPointer(['motion','blink','still',part]);
|
|
102
|
+
export const stopPath=(index:number)=>formatPointer(['stops',index]);
|
|
103
|
+
export const inheritPath=(bone:FieldBone)=>formatPointer(['bones',bone.index,bone.chain?'link_inherit':'inherit']);
|
|
104
|
+
export const turnPivotPath=()=>formatPointer(['turn','pivot']);
|
|
105
|
+
export const turnDepthPath=(part:string)=>formatPointer(['turn','depths',part]);
|
|
106
|
+
|
|
107
|
+
/** The bone a rigid part rides. A part `regions` does not hold is refused here (it is a mesh, or no part). */
|
|
108
|
+
export function setRegion(source:RigFieldsSource,edits:FieldEdits,part:string,bone:string):Map<string,FieldEdit> {
|
|
109
|
+
const from=source.regions?.[part];
|
|
110
|
+
if(from===undefined)throw new RigFieldsError('RIG_FIELDS_NOT_A_REGION',`${part} is not in the config's regions (a meshed part rides its weights)`);
|
|
111
|
+
if(typeof bone!=='string'||!bone)throw new RigFieldsError('RIG_FIELDS_VALUE_INVALID',`regions.${part} must name a bone`);
|
|
112
|
+
return put(edits,{field:'region',path:regionPath(part),op:'replace',from,value:bone,label:`regions.${part}`});
|
|
113
|
+
}
|
|
114
|
+
/** A lash's still row: `{row, bone}` (row snapped to whole rig px by the drag), or null to drop the entry. */
|
|
115
|
+
export function setStill(source:RigFieldsSource,edits:FieldEdits,part:string,entry:StillEntry|null):Map<string,FieldEdit> {
|
|
116
|
+
if(!source.blink)throw new RigFieldsError('RIG_FIELDS_NO_BLINK','the config states no motion.blink; a still row belongs to the blink');
|
|
117
|
+
const from=source.blink.still?.[part],path=stillPath(part),label=`motion.blink.still.${part}`;
|
|
118
|
+
if(entry===null)return put(edits,{field:'still',path,op:'remove',from:from as JsonValue|undefined,label});
|
|
119
|
+
if(!finite(entry.row)||typeof entry.bone!=='string'||!entry.bone)throw new RigFieldsError('RIG_FIELDS_VALUE_INVALID',`${label} must be {row: number, bone: name}`);
|
|
120
|
+
return put(edits,{field:'still',path,op:'add',from:from as JsonValue|undefined,value:{row:entry.row,bone:entry.bone},label});
|
|
121
|
+
}
|
|
122
|
+
/** A stop's inward / outward bound (degrees): the whole entry is replaced, every other key as written. */
|
|
123
|
+
export function setStop(source:RigFieldsSource,edits:FieldEdits,index:number,bounds:{inward?:number;outward?:number}):Map<string,FieldEdit> {
|
|
124
|
+
const stop=source.stops?.find(s=>s.index===index);
|
|
125
|
+
if(!stop)throw new RigFieldsError('RIG_FIELDS_NO_STOP',`the config states no stops[${index}]`);
|
|
126
|
+
const path=stopPath(index),current=(edits.get(path)?.value as Record<string,JsonValue>|undefined)??stop.raw,value={...structuredClone(current)};
|
|
127
|
+
for(const key of ['inward','outward'] as const){const v=bounds[key];if(v===undefined)continue;if(!finite(v))throw new RigFieldsError('RIG_FIELDS_VALUE_INVALID',`stops[${index}].${key} must be a finite number`);value[key]=v;}
|
|
128
|
+
return put(edits,{field:'stop',path,op:'replace',from:stop.raw,value,name:stop.chain,label:`stops[${index}] ${stop.chain}`});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A bone's inherit mode, or null back to absent (rig-c's `normal`). A chain's link is set in its `link_inherit`, the
|
|
132
|
+
* whole object written at once. The mode is passed as given: one rig-c does not name is the producer's to refuse
|
|
133
|
+
* (`CONFIG_BONE_INHERIT`).
|
|
134
|
+
*/
|
|
135
|
+
export function setInherit(source:RigFieldsSource,edits:FieldEdits,index:number,mode:string|null,link?:string):Map<string,FieldEdit> {
|
|
136
|
+
const bone=source.bones.find(b=>b.index===index);
|
|
137
|
+
if(!bone)throw new RigFieldsError('RIG_FIELDS_NO_BONE',`the config states no bones[${index}]`);
|
|
138
|
+
const path=inheritPath(bone),label=bone.chain?`bones[${index}] ${bone.name}.link_inherit`:`bones[${index}] ${bone.name}.inherit`;
|
|
139
|
+
if(!bone.chain){
|
|
140
|
+
if(link!==undefined)throw new RigFieldsError('RIG_FIELDS_VALUE_INVALID',`${bone.name} is a single bone; it has no links`);
|
|
141
|
+
return put(edits,mode===null?{field:'inherit',path,op:'remove',from:bone.inherit??undefined,name:bone.name,label}:{field:'inherit',path,op:'add',from:bone.inherit??undefined,value:mode,name:bone.name,label});
|
|
142
|
+
}
|
|
143
|
+
if(link===undefined||!bone.links.includes(link))throw new RigFieldsError('RIG_FIELDS_VALUE_INVALID',`${bone.name} is a chain; name one of its links (${bone.links.join(', ')})`);
|
|
144
|
+
const current={...((edits.get(path)?.value as Record<string,string>|undefined)??bone.linkInherit??{})};
|
|
145
|
+
if(mode===null)delete current[link];else current[link]=mode;
|
|
146
|
+
const from=bone.linkInherit??undefined;
|
|
147
|
+
return put(edits,Object.keys(current).length?{field:'inherit',path,op:'add',from,value:current,name:bone.name,label}:{field:'inherit',path,op:'remove',from,name:bone.name,label});
|
|
148
|
+
}
|
|
149
|
+
/** `turn.pivot`, or one part's `turn.depths` entry (numbers; the producer judges the pivot against the nearest depth). */
|
|
150
|
+
export function setTurn(source:RigFieldsSource,edits:FieldEdits,target:{pivot:number}|{part:string;depth:number}):Map<string,FieldEdit> {
|
|
151
|
+
if(!source.turn)throw new RigFieldsError('RIG_FIELDS_NO_TURN','the config states no turn');
|
|
152
|
+
if('pivot' in target){
|
|
153
|
+
if(!finite(target.pivot))throw new RigFieldsError('RIG_FIELDS_VALUE_INVALID','turn.pivot must be a finite number');
|
|
154
|
+
return put(edits,{field:'turn',path:turnPivotPath(),op:'replace',from:source.turn.pivot,value:target.pivot,label:'turn.pivot'});
|
|
155
|
+
}
|
|
156
|
+
const from=source.turn.depths[target.part];
|
|
157
|
+
if(from===undefined)throw new RigFieldsError('RIG_FIELDS_VALUE_INVALID',`turn.depths states no ${target.part}`);
|
|
158
|
+
if(!finite(target.depth))throw new RigFieldsError('RIG_FIELDS_VALUE_INVALID',`turn.depths.${target.part} must be a finite number`);
|
|
159
|
+
return put(edits,{field:'turn',path:turnDepthPath(target.part),op:'replace',from,value:target.depth,label:`turn.depths.${target.part}`});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The pending edits as config patch ops, in a fixed order (regions, still, stops, inherit, turn; then by path). A still
|
|
164
|
+
* row added to a config with no `motion.blink.still` is preceded by `add /motion/blink/still {}`.
|
|
165
|
+
*/
|
|
166
|
+
export function rigFieldOps(source:RigFieldsSource,edits:FieldEdits):PatchOp[] {
|
|
167
|
+
const order=['region','still','stop','inherit','turn'],list=[...edits.values()].sort((a,b)=>order.indexOf(a.field)-order.indexOf(b.field)||(a.path<b.path?-1:a.path>b.path?1:0));
|
|
168
|
+
const ops:PatchOp[]=[];
|
|
169
|
+
if(source.blink&&!source.blink.still&&list.some(e=>e.field==='still'&&e.op==='add'))ops.push({op:'add',path:formatPointer(['motion','blink','still']),value:{}});
|
|
170
|
+
for(const e of list){
|
|
171
|
+
const tag=e.name===undefined?{}:{name:e.name};
|
|
172
|
+
ops.push(e.op==='remove'?{op:'remove',path:e.path,...tag}:{op:e.op,path:e.path,value:structuredClone(e.value!),...tag});
|
|
173
|
+
}
|
|
174
|
+
return ops;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const PART=/^[^/~]+$/;
|
|
178
|
+
/**
|
|
179
|
+
* The Fields block's scope: every op is one of the card's pointers with a value of its shape; a list entry addressed by
|
|
180
|
+
* index names its entry (a bone by `name` or `chain`, a stop by `chain`). Anything else is `RIG_FIELDS_OP_OUT_OF_SCOPE`.
|
|
181
|
+
*/
|
|
182
|
+
export function assertRigFieldOps(ops:readonly PatchOp[]):void {
|
|
183
|
+
const out=(i:number,op:PatchOp,why:string)=>new RigFieldsError('RIG_FIELDS_OP_OUT_OF_SCOPE',`ops[${i}] ${op.op} ${op.path}: ${why}`);
|
|
184
|
+
for(const [i,op] of ops.entries()){
|
|
185
|
+
const t=parsePointer(op.path),v=op.value;
|
|
186
|
+
if(t[0]==='regions'&&t.length===2&&PART.test(t[1]!)){if(op.op!=='replace'||typeof v!=='string'||!v)throw out(i,op,'a region is replaced with a bone name');continue;}
|
|
187
|
+
if(t[0]==='motion'&&t[1]==='blink'&&t[2]==='still'){
|
|
188
|
+
if(t.length===3){if(op.op!=='add'||!isObject(v)||Object.keys(v).length)throw out(i,op,'only {} may be added at /motion/blink/still');continue;}
|
|
189
|
+
if(t.length===4){if(op.op==='remove')continue;if(op.op!=='add'||!isObject(v)||Object.keys(v).sort().join(',')!=='bone,row'||!finite(v.row)||typeof v.bone!=='string')throw out(i,op,'a still entry is {row, bone}');continue;}
|
|
190
|
+
}
|
|
191
|
+
if(t[0]==='stops'&&t.length===2&&/^(0|[1-9][0-9]*)$/.test(t[1]!)){if(op.op!=='replace'||!isObject(v)||typeof op.name!=='string')throw out(i,op,'a stop is replaced whole and names its chain');continue;}
|
|
192
|
+
if(t[0]==='bones'&&t.length===3&&/^(0|[1-9][0-9]*)$/.test(t[1]!)&&(t[2]==='inherit'||t[2]==='link_inherit')){
|
|
193
|
+
if(typeof op.name!=='string')throw out(i,op,'the op must name its bone entry');
|
|
194
|
+
if(op.op==='remove')continue;
|
|
195
|
+
if(op.op!=='add'||(t[2]==='inherit'?typeof v!=='string':!isObject(v)||!Object.values(v).every(m=>typeof m==='string')))throw out(i,op,t[2]==='inherit'?'inherit is a mode name':'link_inherit is {<link>: mode}');
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if(t[0]==='turn'&&((t.length===2&&t[1]==='pivot')||(t.length===3&&t[1]==='depths'))){if(op.op!=='replace'||!finite(v))throw out(i,op,'a turn value is replaced with a number');continue;}
|
|
199
|
+
throw out(i,op,'the Fields block writes /regions/<part>, /motion/blink/still/<part>, /stops/<i>, /bones/<i>/inherit (link_inherit), /turn/pivot and /turn/depths/<part> only (turn.deform is out of scope)');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- draft ----
|
|
204
|
+
export const RIG_FIELDS_DRAFT_SCHEMA='rig-review-rig-fields-draft/1';
|
|
205
|
+
export const fieldsDraftText=(edits:FieldEdits)=>JSON.stringify({schema:RIG_FIELDS_DRAFT_SCHEMA,edits:[...edits.values()]});
|
|
206
|
+
/** Edits back from a draft, kept only where the field still holds the value the edit was made from. */
|
|
207
|
+
export function fieldsDraftEdits(text:string,source:RigFieldsSource):Map<string,FieldEdit> {
|
|
208
|
+
let doc:unknown;try{doc=JSON.parse(text);}catch{return new Map();}
|
|
209
|
+
if(!isObject(doc)||doc.schema!==RIG_FIELDS_DRAFT_SCHEMA||!Array.isArray(doc.edits))return new Map();
|
|
210
|
+
const fresh=new Map<string,FieldEdit>();
|
|
211
|
+
for(const raw of doc.edits){
|
|
212
|
+
if(!isObject(raw)||typeof raw.path!=='string')continue;
|
|
213
|
+
const now=currentAt(source,raw.path);
|
|
214
|
+
if(!same(now,raw.from))continue;
|
|
215
|
+
fresh.set(raw.path,raw as unknown as FieldEdit);
|
|
216
|
+
}
|
|
217
|
+
return fresh;
|
|
218
|
+
}
|
|
219
|
+
/** What the config holds at a field pointer (undefined when absent). */
|
|
220
|
+
export function currentAt(source:RigFieldsSource,path:string):JsonValue|undefined {
|
|
221
|
+
const t=parsePointer(path);
|
|
222
|
+
if(t[0]==='regions')return source.regions?.[t[1]!];
|
|
223
|
+
if(t[0]==='motion')return source.blink?.still?.[t[3]!] as JsonValue|undefined;
|
|
224
|
+
if(t[0]==='stops')return source.stops?.find(s=>s.index===Number(t[1]))?.raw;
|
|
225
|
+
if(t[0]==='bones'){const b=source.bones.find(x=>x.index===Number(t[1]));return (t[2]==='inherit'?b?.inherit:b?.linkInherit)??undefined;}
|
|
226
|
+
if(t[0]==='turn')return t[1]==='pivot'?source.turn?.pivot:source.turn?.depths[t[2]!];
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- stage geometry (rig px, y down; angles in Spine degrees, counter-clockwise, y up) ----
|
|
231
|
+
/** Snap a row drag (rig px) to whole px, keeping the decimals the row is written with. */
|
|
232
|
+
export function dragRow(from:number,startY:number,nowY:number):number {
|
|
233
|
+
const d=Math.round(nowY-startY),dot=String(from).indexOf('.'),places=dot<0?0:String(from).length-dot-1;
|
|
234
|
+
return Number((from+(d===0?0:d)).toFixed(places));
|
|
235
|
+
}
|
|
236
|
+
/** The eligible lashes: region parts on a bone the blink's `eyes` names. */
|
|
237
|
+
export function stillParts(source:RigFieldsSource):string[] {
|
|
238
|
+
const eyes=new Set(source.blink?.eyes??[]);
|
|
239
|
+
return Object.entries(source.regions??{}).filter(([,bone])=>eyes.has(bone)).map(([part])=>part).sort();
|
|
240
|
+
}
|
|
241
|
+
/** A new still row's start: the eye bone's origin row, held by that bone's parent (`head` as proposed). */
|
|
242
|
+
export function seedStill(source:RigFieldsSource,part:string):StillEntry|null {
|
|
243
|
+
const eye=source.regions?.[part],bone=source.bones.find(b=>!b.chain&&b.name===eye);
|
|
244
|
+
return bone?.at?{row:bone.at[1],bone:bone.parent??'head'}:null;
|
|
245
|
+
}
|
|
246
|
+
/** Where a bone (or chain link) starts and which way it points, from the config: a link to the next point or the tip. */
|
|
247
|
+
function frame(source:RigFieldsSource,name:string):{origin:RigPoint;dir:RigPoint}|null {
|
|
248
|
+
for(const b of source.bones){
|
|
249
|
+
if(!b.chain&&b.name===name&&b.at){const to=b.tip,d=to?[to[0]-b.at[0],to[1]-b.at[1]] as RigPoint:[1,0] as RigPoint,n=Math.hypot(...d)||1;return {origin:b.at,dir:[d[0]/n,d[1]/n]};}
|
|
250
|
+
const k=b.chain?b.links.indexOf(name):-1;
|
|
251
|
+
if(k>=0){const o=b.points[k]!,to=b.points[k+1]??b.tip;if(!to)return null;const d=[to[0]-o[0],to[1]-o[1]] as RigPoint,n=Math.hypot(...d)||1;return {origin:o,dir:[d[0]/n,d[1]/n]};}
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const norm=(deg:number)=>{let a=((deg+180)%360+360)%360-180;if(a===-180)a=180;return a;};
|
|
256
|
+
/**
|
|
257
|
+
* A stop's arcs as rig-c's `expandStops` aims them: the pivot is the first stopped link's origin, the touch point is
|
|
258
|
+
* `at` in `against`'s frame (`+x` along it, `+y` 90° counter-clockwise in Spine world), and `rest` is the direction
|
|
259
|
+
* from the pivot to the touch; the link is clamped inside `[rest − inward, rest + outward]`.
|
|
260
|
+
*/
|
|
261
|
+
export function stopArcs(source:RigFieldsSource,stop:StopEntry,bounds:{inward:number;outward:number}={inward:stop.inward,outward:stop.outward}):{pivot:RigPoint;touch:RigPoint;rest:number;from:number;to:number}|{reason:string} {
|
|
262
|
+
const chain=source.bones.find(b=>b.chain&&b.name===stop.chain);if(!chain)return {reason:`CONFIG_NAME_RESOLVES: stops[${stop.index}].chain ${stop.chain} is not a chain bones declares`};
|
|
263
|
+
const first=stop.links?.[0]??chain.links[0],k=first?chain.links.indexOf(first):-1;if(k<0)return {reason:`CONFIG_NAME_RESOLVES: stops[${stop.index}] names no link of ${stop.chain}`};
|
|
264
|
+
const against=frame(source,stop.against);if(!against)return {reason:`CONFIG_NAME_RESOLVES: stops[${stop.index}].against ${stop.against} has no origin and direction in bones`};
|
|
265
|
+
const pivot=chain.points[k]!,[u0,u1]=against.dir,v:RigPoint=[u1,-u0];
|
|
266
|
+
const touch:RigPoint=[against.origin[0]+stop.at[0]*u0+stop.at[1]*v[0],against.origin[1]+stop.at[0]*u1+stop.at[1]*v[1]];
|
|
267
|
+
const rest=Math.atan2(-(touch[1]-pivot[1]),touch[0]-pivot[0])*180/Math.PI;
|
|
268
|
+
return {pivot,touch,rest,from:rest-bounds.inward,to:rest+bounds.outward};
|
|
269
|
+
}
|
|
270
|
+
/** A drag of a stop's arc end to `angle` (Spine degrees): the bound it sets, in whole degrees, never below 0. */
|
|
271
|
+
export function arcBound(rest:number,side:'inward'|'outward',angle:number):number {
|
|
272
|
+
const d=Math.round(norm(side==='outward'?angle-rest:rest-angle));
|
|
273
|
+
return d<=0?0:d;
|
|
274
|
+
}
|
|
275
|
+
/** The dial's write: the `turn` bone's local x (rig px) held at the dial on every pose; nothing else. */
|
|
276
|
+
export function turnDial(x:number):(bones:{bone(name:string):{x:number}})=>void {
|
|
277
|
+
if(!finite(x))throw new RigFieldsError('RIG_FIELDS_VALUE_INVALID','the dial must be a finite number');
|
|
278
|
+
return bones=>{bones.bone(TURN_BONE).x=x;};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The angle-range reading (#108) the block's one-click button runs: a bone picked for inherit against its parent (how
|
|
282
|
+
* far it turns from what it inherits); else a rigid part whose bone is being changed, its new bone against the bone it
|
|
283
|
+
* rode (how differently the part now moves); else the part's bone against its parent. Null when there is no such pair.
|
|
284
|
+
*/
|
|
285
|
+
export function readingPair(source:RigFieldsSource,edits:FieldEdits,sel:{bone:number|null;part:string|null}):{bone:string;relative:string}|null {
|
|
286
|
+
const single=source.bones.find(b=>b.index===sel.bone&&!b.chain);
|
|
287
|
+
if(single)return single.parent?{bone:single.name,relative:single.parent}:null;
|
|
288
|
+
const held=sel.part!==null?source.regions?.[sel.part]:undefined;if(held===undefined)return null;
|
|
289
|
+
const edit=edits.get(regionPath(sel.part!)),now=typeof edit?.value==='string'?edit.value:held;
|
|
290
|
+
if(now!==held)return {bone:now,relative:held};
|
|
291
|
+
const bone=source.bones.find(b=>!b.chain&&b.name===held);
|
|
292
|
+
return bone?.parent?{bone:held,relative:bone.parent}:null;
|
|
293
|
+
}
|
|
294
|
+
/** Apply runs only with pending ops and no job running. */
|
|
295
|
+
export const fieldsApplyEnabled=(state:{busy:boolean;pending:number})=>!state.busy&&state.pending>0;
|