@rig-c/review 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +242 -7
- package/docs/DESIGN.md +33 -5
- package/docs/REVIEW_PACKAGE.md +115 -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 +65 -6
- package/src/cli.ts +19 -8
- package/src/config-patch.ts +213 -0
- package/src/cut-edits.ts +184 -0
- package/src/environment.ts +226 -0
- package/src/feedback-export.ts +25 -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 +317 -0
- 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 +12 -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 +294 -0
- package/web/chest-regions-tool.ts +10 -4
- package/web/cuts-panel.ts +266 -0
- package/web/idle-panel.ts +280 -0
- package/web/index.html +2 -2
- package/web/inspector-blocks.ts +77 -0
- package/web/main.ts +813 -24
- package/web/mesh-editor.ts +303 -0
- package/web/mesh-panel.ts +273 -0
- package/web/part-selection.ts +13 -5
- package/web/part-thumbnails.ts +76 -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 +238 -0
- package/web/retarget-panel.ts +203 -0
- package/web/rig-fields-panel.ts +269 -0
- package/web/rig-overlays.ts +171 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +52 -5
- package/web/weights-view.ts +159 -0
- package/web/workspace-preferences.ts +17 -12
- package/web/workspace-ui.ts +5 -3
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Producer handoff (#101): which producer config, parts and inspected rig the package was built from, so an Apply can
|
|
3
|
+
* rebuild through the installed producer and the AI receiving the review can rebuild exactly that state.
|
|
4
|
+
*
|
|
5
|
+
* Declared first — `rig-review open <dir> --config <path> …`, else the `producer` block in review.json — then a fixed
|
|
6
|
+
* discovery order, never a search: `config.json`, then `rig/config.json` inside the opened folder, and as the last step
|
|
7
|
+
* `../config.json` beside an opened `out/`. That last one sits outside the opened folder, so the record names it
|
|
8
|
+
* `discovered` with `outside: true`, its path and its hash. Every path is literal and must stay inside its root (the
|
|
9
|
+
* package root; for the `../config.json` step, the folder that holds `out/`); a symlink that leaves it is refused by name.
|
|
10
|
+
* The record (paths relative to the package root, hashes, how it was found) is part of `reviewedSource`, so changing a
|
|
11
|
+
* handoff file's bytes starts a new pending session.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
15
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const HANDOFF_SCHEMA = 'rig-review-handoff/1';
|
|
18
|
+
/** The fixed discovery order, as the refusal names it. */
|
|
19
|
+
export const DISCOVERY_PLACES = ['config.json', 'rig/config.json', '../config.json (beside an opened out/)'] as const;
|
|
20
|
+
export const HANDOFF_DECLARATION = 'rig-review open <dir> --config <path> [--parts <dir>] or the review.json "producer" block';
|
|
21
|
+
export const MODELING_INPUT_UNAVAILABLE = `MODELING_INPUT_UNAVAILABLE: config.json — looked at ${DISCOVERY_PLACES.join(', ')}; declare it with ${HANDOFF_DECLARATION}`;
|
|
22
|
+
/** Files the producer reads by the config's own naming, resolved against the config's directory (rig-parts `relativeConfigPaths`). */
|
|
23
|
+
export const CONFIG_NAMED_FILES = [['motion', 'animations_from'], ['generation', 'control', 'skeleton']] as const;
|
|
24
|
+
/** What `rig-parts rig` writes into its --out and remesh reads from --rig: the rig spec, its motion, the mesh report and the part images. */
|
|
25
|
+
export const RIG_FILES = ['rig.json', 'motion.json', 'mesh_report.json'] as const;
|
|
26
|
+
const MAX_LISTED = 4096;
|
|
27
|
+
|
|
28
|
+
export interface HandoffDeclaration { config: string; parts?: string; source?: string; full?: string; head?: string }
|
|
29
|
+
export type HandoffFlags = HandoffDeclaration;
|
|
30
|
+
export const HANDOFF_FLAGS = ['config', 'parts', 'source', 'full', 'head'] as const;
|
|
31
|
+
export interface HandoffFile { path: string; sha256: string }
|
|
32
|
+
/** The identity record: paths relative to the package root (`../` only for the discovered parent config), every hashed file. */
|
|
33
|
+
export interface HandoffRecord {
|
|
34
|
+
schema: typeof HANDOFF_SCHEMA;
|
|
35
|
+
how: 'declared' | 'discovered';
|
|
36
|
+
via: 'open --config' | 'review.json producer' | 'discovery';
|
|
37
|
+
outside: boolean;
|
|
38
|
+
config: HandoffFile;
|
|
39
|
+
parts: string | null; rig: string | null; source: HandoffFile | null; full: string | null; head: string | null;
|
|
40
|
+
files: HandoffFile[];
|
|
41
|
+
missing: string[];
|
|
42
|
+
}
|
|
43
|
+
/** The record plus the absolute paths the producer is run with (never served, never in the identity). */
|
|
44
|
+
/** `source` (#109): the declared source painting's absolute path, which `propose --from-config` reads; null when none was declared. */
|
|
45
|
+
/** `full` / `head` (#115): the declared See-through runs' absolute paths, which `rig-parts build` reads; null when none was declared. */
|
|
46
|
+
export interface Handoff { record: HandoffRecord; config: string; parts: string | null; rig: string | null; root: string; flags?: HandoffFlags; source?: string | null; full?: string | null; head?: string | null }
|
|
47
|
+
|
|
48
|
+
export class HandoffError extends Error {
|
|
49
|
+
constructor(readonly code: string, readonly field: string, readonly detail: string) { super(`${code} ${field}: ${detail}`); }
|
|
50
|
+
}
|
|
51
|
+
const sha256 = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex');
|
|
52
|
+
const inside = (base: string, path: string, allowSelf = false) => { const rel = relative(base, path); return rel === '' ? allowSelf : rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel); };
|
|
53
|
+
const code = (e: unknown) => (e as NodeJS.ErrnoException).code ?? '';
|
|
54
|
+
async function exists(path: string): Promise<boolean> { try { await lstat(path); return true; } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ENOTDIR') return false; throw e; } }
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One handoff path: it must exist, its canonical location must stay inside `base`, and it must be the expected kind.
|
|
58
|
+
* `lexicalInside` says whether the path as written was inside the root, so a canonical escape is named a symlink escape.
|
|
59
|
+
*/
|
|
60
|
+
async function contained(base: string, path: string, field: string, kind: 'file' | 'dir', lexicalInside: boolean): Promise<string> {
|
|
61
|
+
if (!(await exists(path))) throw new HandoffError('HANDOFF_INPUT_MISSING', field, `${path} does not exist`);
|
|
62
|
+
let real: string;
|
|
63
|
+
try { real = await realpath(path); } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ELOOP') throw new HandoffError('HANDOFF_SYMLINK_ESCAPE', field, `${path} is a dangling or looping symlink`); throw e; }
|
|
64
|
+
if (!inside(base, real, kind === 'dir')) {
|
|
65
|
+
if (lexicalInside) throw new HandoffError('HANDOFF_SYMLINK_ESCAPE', field, `${path} resolves outside ${base} (symlinks may not escape)`);
|
|
66
|
+
throw new HandoffError('HANDOFF_PATH_OUTSIDE_ROOT', field, `${path} is outside ${base}`);
|
|
67
|
+
}
|
|
68
|
+
const info = await stat(real);
|
|
69
|
+
if (kind === 'file' ? !info.isFile() : !info.isDirectory()) throw new HandoffError('HANDOFF_INPUT_INVALID', field, `${path} is not a ${kind === 'file' ? 'regular file' : 'directory'}`);
|
|
70
|
+
return real;
|
|
71
|
+
}
|
|
72
|
+
/** A CLI path: written relative to the working directory or absolute; canonical inside the package root. */
|
|
73
|
+
async function flagPath(root: string, value: string, field: string, kind: 'file' | 'dir'): Promise<string> {
|
|
74
|
+
const lexical = resolve(value);
|
|
75
|
+
// The parent chain is canonicalized so a platform alias (/tmp → /private/tmp) is not taken for an escape; the last
|
|
76
|
+
// component is left as written so a symlink there is named as one.
|
|
77
|
+
let parent: string;
|
|
78
|
+
try { parent = await realpath(dirname(lexical)); } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ENOTDIR') throw new HandoffError('HANDOFF_INPUT_MISSING', field, `${value} does not exist`); throw e; }
|
|
79
|
+
const written = join(parent, basename(lexical));
|
|
80
|
+
return contained(root, written, field, kind, inside(root, written, kind === 'dir'));
|
|
81
|
+
}
|
|
82
|
+
/** A review.json `producer` path: literal, portable, relative to the package root. */
|
|
83
|
+
async function blockPath(root: string, value: unknown, field: string, kind: 'file' | 'dir'): Promise<string> {
|
|
84
|
+
if (typeof value !== 'string' || !value) throw new HandoffError('HANDOFF_PATH_INVALID', field, 'expected a nonempty literal relative path');
|
|
85
|
+
if (isAbsolute(value) || /^[a-zA-Z]:/.test(value) || value.split('/').includes('..')) throw new HandoffError('HANDOFF_PATH_OUTSIDE_ROOT', field, `${value} leaves the package root (absolute or ..)`);
|
|
86
|
+
if (/[:\\%?#\x00-\x1f]/.test(value) || value.split('/').some(s => !s || s === '.') || posix.normalize(value) !== value) throw new HandoffError('HANDOFF_PATH_INVALID', field, `${value} is not a literal portable relative path`);
|
|
87
|
+
return contained(root, join(root, value), field, kind, true);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `producer: {config, parts?, source?, full?, head?}` — strings only, no other key. Paths are checked at load. */
|
|
91
|
+
export function parseHandoffDeclaration(value: unknown): HandoffDeclaration | undefined {
|
|
92
|
+
if (value === undefined) return undefined;
|
|
93
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new HandoffError('HANDOFF_PATH_INVALID', 'producer', 'expected an object {config, parts?, source?, full?, head?}');
|
|
94
|
+
const v = value as Record<string, unknown>;
|
|
95
|
+
for (const key of Object.keys(v)) if (!(HANDOFF_FLAGS as readonly string[]).includes(key)) throw new HandoffError('HANDOFF_PATH_INVALID', `producer.${key}`, 'unknown key; expected config, parts, source, full, head');
|
|
96
|
+
if (typeof v.config !== 'string') throw new HandoffError('HANDOFF_PATH_INVALID', 'producer.config', 'required: the producer config path');
|
|
97
|
+
const out: HandoffDeclaration = { config: v.config };
|
|
98
|
+
for (const key of ['parts', 'source', 'full', 'head'] as const) if (v[key] !== undefined) { if (typeof v[key] !== 'string') throw new HandoffError('HANDOFF_PATH_INVALID', `producer.${key}`, 'expected a string path'); out[key] = v[key] as string; }
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** `--source` and `--full` travel together, `--head` only with them, and everything else only with `--config`. */
|
|
102
|
+
export function checkHandoffFlags(flags: Partial<HandoffFlags>): string | null {
|
|
103
|
+
const present = HANDOFF_FLAGS.filter(f => flags[f] !== undefined);
|
|
104
|
+
if (!present.length) return null;
|
|
105
|
+
if (flags.config === undefined) return `--${present[0]}: requires --config <path>`;
|
|
106
|
+
if ((flags.source === undefined) !== (flags.full === undefined)) return `--${flags.source === undefined ? 'full' : 'source'}: --source <png> and --full <dir> are given together`;
|
|
107
|
+
if (flags.head !== undefined && flags.full === undefined) return '--head: requires --source <png> --full <dir>';
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function hashFile(path: string): Promise<string> { return sha256(await readFile(path)); }
|
|
112
|
+
/** Regular files directly in `dir` (a fixed directory, not a search); each must stay inside `base`. */
|
|
113
|
+
async function listed(base: string, dir: string, field: string): Promise<string[]> {
|
|
114
|
+
const entries = (await readdir(dir, { withFileTypes: true })).filter(e => !e.isDirectory()).map(e => e.name).sort();
|
|
115
|
+
if (entries.length > MAX_LISTED) throw new HandoffError('HANDOFF_INPUT_INVALID', field, `${dir} holds more than ${MAX_LISTED} files`);
|
|
116
|
+
const out: string[] = [];
|
|
117
|
+
for (const name of entries) { const real = await contained(base, join(dir, name), `${field}/${name}`, 'file', true).catch(e => { if (e instanceof HandoffError && e.code === 'HANDOFF_INPUT_INVALID') return null; throw e; }); if (real) out.push(real); }
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Hash every handoff file and build the record. `root` is the package root (display paths are relative to it). */
|
|
122
|
+
async function record(root: string, base: string, how: HandoffRecord['how'], via: HandoffRecord['via'], config: string, partsPath: string | null, extra: { source?: string; full?: string; head?: string }, partsDeclared: boolean): Promise<Handoff> {
|
|
123
|
+
const show = (path: string) => relative(root, path).split(sep).join('/') || '.';
|
|
124
|
+
const files = new Map<string, string>(), missing: string[] = [];
|
|
125
|
+
const add = async (path: string) => { files.set(show(path), await hashFile(path)); };
|
|
126
|
+
await add(config);
|
|
127
|
+
// Files the config names that the producer reads, resolved against the config's own directory.
|
|
128
|
+
let parsed: unknown = null;
|
|
129
|
+
try { parsed = JSON.parse(await readFile(config, 'utf8')); } catch { /* the producer refuses an unreadable config by name */ }
|
|
130
|
+
for (const keys of CONFIG_NAMED_FILES) {
|
|
131
|
+
let value: unknown = parsed;
|
|
132
|
+
for (const k of keys) value = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>)[k] : undefined;
|
|
133
|
+
if (typeof value !== 'string' || !value) continue;
|
|
134
|
+
const field = `config ${keys.join('.')}`, lexical = resolve(dirname(config), value);
|
|
135
|
+
if (!inside(base, lexical)) throw new HandoffError('HANDOFF_PATH_OUTSIDE_ROOT', field, `${value} resolves outside ${base}`);
|
|
136
|
+
if (!(await exists(lexical))) { missing.push(`${keys.join('.')} ${show(lexical)}`); continue; }
|
|
137
|
+
await add(await contained(base, lexical, field, 'file', true));
|
|
138
|
+
}
|
|
139
|
+
let parts: string | null = null, rig: string | null = null;
|
|
140
|
+
if (partsPath && (partsDeclared || await exists(partsPath))) {
|
|
141
|
+
parts = await contained(base, partsPath, partsDeclared ? '--parts' : 'parts', 'dir', true);
|
|
142
|
+
const partsJson = join(parts, 'parts.json');
|
|
143
|
+
if (await exists(partsJson)) await add(await contained(base, partsJson, 'parts.json', 'file', true)); else missing.push(`parts.json ${show(partsJson)}`);
|
|
144
|
+
if (await exists(join(parts, 'parts'))) for (const file of await listed(base, await contained(base, join(parts, 'parts'), 'parts/', 'dir', true), 'parts')) await add(file);
|
|
145
|
+
const rigDir = join(parts, 'rig');
|
|
146
|
+
if (await exists(rigDir)) {
|
|
147
|
+
rig = await contained(base, rigDir, 'rig', 'dir', true);
|
|
148
|
+
for (const name of RIG_FILES) if (!(await exists(join(rig, name)))) missing.push(`rig ${show(join(rig, name))}`);
|
|
149
|
+
// Every file remesh carries over: the rig directory's regular files (rig.json, motion.json, mesh_report.json, sets.json, …) and images/.
|
|
150
|
+
for (const file of await listed(base, rig, 'rig')) await add(file);
|
|
151
|
+
if (await exists(join(rig, 'images'))) for (const file of await listed(base, await contained(base, join(rig, 'images'), 'rig/images', 'dir', true), 'rig/images')) await add(file);
|
|
152
|
+
} else missing.push(`rig ${show(rigDir)}`);
|
|
153
|
+
} else missing.push(`parts ${partsPath ? show(partsPath) : '(none)'}`);
|
|
154
|
+
let source: HandoffFile | null = null;
|
|
155
|
+
if (extra.source) { await add(extra.source); source = { path: show(extra.source), sha256: files.get(show(extra.source))! }; }
|
|
156
|
+
for (const dir of [extra.full, extra.head]) if (dir) for (const file of await listed(base, dir, show(dir))) await add(file);
|
|
157
|
+
const sorted = [...files].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([path, hash]) => ({ path, sha256: hash }));
|
|
158
|
+
return {
|
|
159
|
+
record: { schema: HANDOFF_SCHEMA, how, via, outside: !inside(root, config), config: { path: show(config), sha256: files.get(show(config))! }, parts: parts ? show(parts) : null, rig: rig ? show(rig) : null, source, full: extra.full ? show(extra.full) : null, head: extra.head ? show(extra.head) : null, files: sorted, missing },
|
|
160
|
+
config, parts, rig, root: base, source: extra.source ?? null, full: extra.full ?? null, head: extra.head ?? null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The one handoff lookup (used by load, identity and `/api/rebuild`): CLI flags, else the review.json block, else the
|
|
166
|
+
* fixed discovery order. `root` is the canonical package root (the opened folder). A declared path that is missing,
|
|
167
|
+
* outside its root or a symlink escape is refused by name; nothing found is `{handoff: null}` with the refusal line.
|
|
168
|
+
*/
|
|
169
|
+
export async function resolveHandoff(root: string, declared: HandoffDeclaration | undefined, flags: HandoffFlags | undefined): Promise<{ handoff: Handoff | null; reason: string | null }> {
|
|
170
|
+
if (flags) {
|
|
171
|
+
const problem = checkHandoffFlags(flags);
|
|
172
|
+
if (problem) throw new HandoffError('HANDOFF_FLAGS_INVALID', problem.split(':')[0]!, problem);
|
|
173
|
+
const config = await flagPath(root, flags.config, '--config', 'file');
|
|
174
|
+
const parts = flags.parts !== undefined ? await flagPath(root, flags.parts, '--parts', 'dir') : join(dirname(config), 'out');
|
|
175
|
+
const source = flags.source !== undefined ? await flagPath(root, flags.source, '--source', 'file') : undefined;
|
|
176
|
+
const full = flags.full !== undefined ? await flagPath(root, flags.full, '--full', 'dir') : undefined;
|
|
177
|
+
const head = flags.head !== undefined ? await flagPath(root, flags.head, '--head', 'dir') : undefined;
|
|
178
|
+
const handoff = await record(root, root, 'declared', 'open --config', config, parts, { source, full, head }, flags.parts !== undefined);
|
|
179
|
+
// Canonical absolute flags: the background worker and later reloads read exactly these.
|
|
180
|
+
handoff.flags = { config, ...(flags.parts !== undefined ? { parts } : {}), ...(source ? { source } : {}), ...(full ? { full } : {}), ...(head ? { head } : {}) };
|
|
181
|
+
return { handoff, reason: null };
|
|
182
|
+
}
|
|
183
|
+
if (declared) {
|
|
184
|
+
const config = await blockPath(root, declared.config, 'producer.config', 'file');
|
|
185
|
+
const parts = declared.parts !== undefined ? await blockPath(root, declared.parts, 'producer.parts', 'dir') : join(dirname(config), 'out');
|
|
186
|
+
const source = declared.source !== undefined ? await blockPath(root, declared.source, 'producer.source', 'file') : undefined;
|
|
187
|
+
const full = declared.full !== undefined ? await blockPath(root, declared.full, 'producer.full', 'dir') : undefined;
|
|
188
|
+
const head = declared.head !== undefined ? await blockPath(root, declared.head, 'producer.head', 'dir') : undefined;
|
|
189
|
+
if ((source === undefined) !== (full === undefined) || (head !== undefined && full === undefined)) throw new HandoffError('HANDOFF_FLAGS_INVALID', 'producer', 'source and full are given together, head only with them');
|
|
190
|
+
return { handoff: await record(root, root, 'declared', 'review.json producer', config, parts, { source, full, head }, declared.parts !== undefined), reason: null };
|
|
191
|
+
}
|
|
192
|
+
for (const name of ['config.json', 'rig/config.json']) {
|
|
193
|
+
const path = join(root, name);
|
|
194
|
+
if (!(await exists(path))) continue;
|
|
195
|
+
const config = await contained(root, path, name, 'file', true);
|
|
196
|
+
return { handoff: await record(root, root, 'discovered', 'discovery', config, join(dirname(path), 'out'), {}, false), reason: null };
|
|
197
|
+
}
|
|
198
|
+
// The last step: an opened `out/` whose config sits beside it, one level up. Outside the folder, so named.
|
|
199
|
+
if (basename(root) === 'out') {
|
|
200
|
+
const parent = dirname(root), path = join(parent, 'config.json');
|
|
201
|
+
if (await exists(path)) {
|
|
202
|
+
const config = await contained(parent, path, '../config.json', 'file', true);
|
|
203
|
+
if (config !== path) throw new HandoffError('HANDOFF_SYMLINK_ESCAPE', '../config.json', `${path} is a symlink; only the file beside out/ is discovered`);
|
|
204
|
+
return { handoff: await record(root, parent, 'discovered', 'discovery', config, root, {}, false), reason: null };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { handoff: null, reason: MODELING_INPUT_UNAVAILABLE };
|
|
208
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle tracks (#104), the pure half: the config's `motion.tracks` and `motion.blink` as the panel edits them, the sine
|
|
3
|
+
* the producer keys, the periods it accepts, review's pre-checks under the producer's own names and words, the pending
|
|
4
|
+
* edits as config patch ops at `/motion/tracks/<i>/…` and `/motion/blink/…`, and what the preview writes through play's
|
|
5
|
+
* `adjust`. No DOM, no IO, no clock: the producer (`rig --config`, then `check`) judges every value on Apply.
|
|
6
|
+
*
|
|
7
|
+
* The sine is rig-parts `src/motion.ts` `sineTrack`: `v(t) = base + amp·sin(2π(t/period − phase))`, keyed at
|
|
8
|
+
* `KEYS_PER_PERIOD` keys per period with the sine's exact Hermite tangents as bezier handles. A chain track keys
|
|
9
|
+
* `rotate` on every link, link `i` at phase `phase + lag·i` and amplitude `amps[i]`, about 0 (`idleMotion`).
|
|
10
|
+
*/
|
|
11
|
+
import { formatPointer, parsePointer, type JsonValue, type PatchOp } from './config-patch.ts';
|
|
12
|
+
|
|
13
|
+
/** rig-parts `SINGLE_PROPS` (`src/config.ts`). */
|
|
14
|
+
export const SINGLE_PROPS = ['rotate', 'translatex', 'translatey', 'scalex', 'scaley', 'shearx', 'sheary'] as const;
|
|
15
|
+
export type SingleProp = typeof SINGLE_PROPS[number];
|
|
16
|
+
/** rig-parts `KEYS_PER_PERIOD` and `IDLE_FPS` (`src/motion.ts`). */
|
|
17
|
+
export const KEYS_PER_PERIOD = 8;
|
|
18
|
+
export const IDLE_FPS = 12;
|
|
19
|
+
/** rig-parts `periodFits` (`src/config.ts`): `duration / period` within this of a whole number. */
|
|
20
|
+
export const PERIOD_SLACK = 1e-9;
|
|
21
|
+
/** The periods the panel offers: `duration / n` for n = 1 … this. A typed period is still checked, never offered. */
|
|
22
|
+
export const MAX_CYCLES_OFFERED = 12;
|
|
23
|
+
/** rig-parts `BLINK` (`src/motion.ts`): the blink's fixed timing after `blink.t`, in seconds. */
|
|
24
|
+
export const BLINK = { shut: 0.07, hold: 0.084, open: 0.16, browShut: 0.08, browHold: 0.084, browOpen: 0.2 } as const;
|
|
25
|
+
/** rig-parts `blinkSpan`: the last blink key after `t`; without brows only the eyes' keys must fit inside the idle. */
|
|
26
|
+
export const blinkSpan = (blink: { brows?: readonly string[] }) => blink.brows === undefined ? BLINK.shut + BLINK.hold + BLINK.open : Math.max(BLINK.shut + BLINK.hold + BLINK.open, BLINK.browShut + BLINK.browHold + BLINK.browOpen);
|
|
27
|
+
/** rig-parts `CONTROL_SUFFIX`: under `--idle-keys ctl` a keyed bone a mesh is weighted to is keyed on `<bone>_ctl`. */
|
|
28
|
+
export const CONTROL_SUFFIX = '_ctl';
|
|
29
|
+
/** The check bars (and check's `loop:` line) the readout shows before and after an Apply. */
|
|
30
|
+
export const IDLE_READOUT_BARS = ['CHAIN_LAG', 'TIP_OVER_ROOT', 'BREATH_VISIBLE', 'loop'] as const;
|
|
31
|
+
/** Slider extents for the panel; every number field takes any finite value. */
|
|
32
|
+
export const AMP_RANGE: Readonly<Record<SingleProp, { min: number; max: number; step: number }>> = {
|
|
33
|
+
rotate: { min: -10, max: 10, step: 0.05 }, translatex: { min: -10, max: 10, step: 0.1 }, translatey: { min: -10, max: 10, step: 0.1 },
|
|
34
|
+
scalex: { min: -0.05, max: 0.05, step: 0.001 }, scaley: { min: -0.05, max: 0.05, step: 0.001 }, shearx: { min: -10, max: 10, step: 0.1 }, sheary: { min: -10, max: 10, step: 0.1 },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface SingleTrack { bone: string; prop: SingleProp; amp: number; period: number; phase: number; base?: number }
|
|
38
|
+
export interface ChainTrack { chain: string; amps: number[]; period: number; phase: number; lag: number }
|
|
39
|
+
export type IdleTrack = SingleTrack | ChainTrack;
|
|
40
|
+
export interface IdleBlink { t: number; eyes: string[]; brows?: string[]; squash: number; brow_drop?: number; [key: string]: JsonValue | undefined }
|
|
41
|
+
export const isChain = (track: IdleTrack): track is ChainTrack => 'chain' in track;
|
|
42
|
+
|
|
43
|
+
/** The handoff config's idle as written: duration, tracks (each as written), blink, and the bones and chains it declares. */
|
|
44
|
+
export interface IdleSource { duration: number; tracks: IdleTrack[]; blink: IdleBlink | null; bones: string[]; chains: Record<string, number> }
|
|
45
|
+
export type IdleSourceResult = { available: true; source: IdleSource } | { available: false; reason: string };
|
|
46
|
+
/** One track of the edited idle: `origin` is its index in the handoff config (null for a track the panel added). */
|
|
47
|
+
export interface IdleEntry { key: string; origin: number | null; track: IdleTrack }
|
|
48
|
+
export interface IdleState { entries: IdleEntry[]; blink: IdleBlink | null }
|
|
49
|
+
|
|
50
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
51
|
+
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
52
|
+
const clone = <T>(v: T): T => structuredClone(v);
|
|
53
|
+
const unavailable = (reason: string): IdleSourceResult => ({ available: false, reason: `IDLE_TRACKS_UNAVAILABLE: ${reason}` });
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reads `config.motion` and `config.bones` (shapes only: what the panel needs to draw and edit). Anything the panel
|
|
57
|
+
* cannot read as the producer's track shapes makes the panel unavailable by name; values are the producer's to judge.
|
|
58
|
+
*/
|
|
59
|
+
export function idleSource(config: unknown): IdleSourceResult {
|
|
60
|
+
if (!isObject(config)) return unavailable('the handoff config is not an object');
|
|
61
|
+
const motion = config.motion;
|
|
62
|
+
if (!isObject(motion)) return unavailable('the handoff config states no motion');
|
|
63
|
+
if (!finite(motion.duration) || motion.duration <= 0) return unavailable('config.motion.duration is not a positive number');
|
|
64
|
+
if (!Array.isArray(motion.tracks)) return unavailable('config.motion.tracks is not a list');
|
|
65
|
+
const tracks: IdleTrack[] = [];
|
|
66
|
+
for (const [i, raw] of motion.tracks.entries()) {
|
|
67
|
+
if (!isObject(raw)) return unavailable(`config.motion.tracks[${i}] is not an object`);
|
|
68
|
+
if ('chain' in raw) {
|
|
69
|
+
if (typeof raw.chain !== 'string' || !Array.isArray(raw.amps) || !raw.amps.every(finite) || !finite(raw.period) || !finite(raw.phase) || !finite(raw.lag)) return unavailable(`config.motion.tracks[${i}] is not a chain track {chain, amps, period, phase, lag}`);
|
|
70
|
+
} else if (typeof raw.bone !== 'string' || !SINGLE_PROPS.includes(raw.prop as SingleProp) || !finite(raw.amp) || !finite(raw.period) || !finite(raw.phase) || ('base' in raw && !finite(raw.base))) {
|
|
71
|
+
return unavailable(`config.motion.tracks[${i}] is not a single track {bone, prop, amp, period, phase, base?}`);
|
|
72
|
+
}
|
|
73
|
+
tracks.push(clone(raw) as unknown as IdleTrack);
|
|
74
|
+
}
|
|
75
|
+
let blink: IdleBlink | null = null;
|
|
76
|
+
if (motion.blink !== undefined) {
|
|
77
|
+
const b = motion.blink;
|
|
78
|
+
if (!isObject(b) || !finite(b.t) || !finite(b.squash) || !Array.isArray(b.eyes)) return unavailable('config.motion.blink is not {t, eyes, squash, …}');
|
|
79
|
+
blink = clone(b) as unknown as IdleBlink;
|
|
80
|
+
}
|
|
81
|
+
const bones: string[] = [], chains: Record<string, number> = {};
|
|
82
|
+
for (const entry of Array.isArray(config.bones) ? config.bones : []) {
|
|
83
|
+
if (!isObject(entry)) continue;
|
|
84
|
+
if (typeof entry.chain === 'string' && Array.isArray(entry.points)) { chains[entry.chain] = entry.points.length; entry.points.forEach((_, k) => bones.push(`${entry.chain}${k}`)); }
|
|
85
|
+
else if (typeof entry.name === 'string') bones.push(entry.name);
|
|
86
|
+
}
|
|
87
|
+
return { available: true, source: { duration: motion.duration, tracks, blink, bones, chains } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The state the panel starts from: every track as written, in order, keyed by its config index. */
|
|
91
|
+
export const initialState = (source: IdleSource): IdleState => ({ entries: source.tracks.map((track, i) => ({ key: `o${i}`, origin: i, track: clone(track) })), blink: clone(source.blink) });
|
|
92
|
+
|
|
93
|
+
/** The producer's sine (rig-parts `sineTrack`): `base + amp·sin(2π(t/period − phase))`. */
|
|
94
|
+
export function sineValue(p: { amp: number; period: number; phase: number; base?: number }, t: number): number {
|
|
95
|
+
return (p.base ?? 0) + p.amp * Math.sin(2 * Math.PI * (t / p.period - p.phase));
|
|
96
|
+
}
|
|
97
|
+
/** A chain's link `i` as the producer keys it (rig-parts `idleMotion`): rotate at phase `phase + lag·i`, amplitude `amps[i]`, about 0. */
|
|
98
|
+
export const linkSine = (track: ChainTrack, i: number) => ({ amp: track.amps[i]!, period: track.period, phase: track.phase + track.lag * i, base: 0 });
|
|
99
|
+
|
|
100
|
+
/** rig-parts `periodFits`: true when `duration / period` is within 1e-9 of a whole number (and the period is positive). */
|
|
101
|
+
export function periodFits(duration: number, period: number): boolean {
|
|
102
|
+
if (!finite(period) || period <= 0) return false;
|
|
103
|
+
const cycles = duration / period;
|
|
104
|
+
return Math.abs(cycles - Math.round(cycles)) <= PERIOD_SLACK;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The periods offered: exactly the ones the producer accepts. `duration / period` must be a whole number n (periodFits),
|
|
108
|
+
* so a period is `duration / n`; n = 0 is no period, so n runs from 1 (the whole idle) up to `MAX_CYCLES_OFFERED`.
|
|
109
|
+
*/
|
|
110
|
+
export function periodOffers(duration: number, max = MAX_CYCLES_OFFERED): { cycles: number; period: number }[] {
|
|
111
|
+
return Array.from({ length: max }, (_, i) => ({ cycles: i + 1, period: duration / (i + 1) }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** rig-parts `pyRound` for the numbers its lines print (`toFixed` rounding; an exact binary tie is not reproduced). */
|
|
115
|
+
const round6 = (x: number) => Number(x.toFixed(6));
|
|
116
|
+
/** The producer's refusal line without its `FAIL ` lead and its `(see rig-parts guide …)` tail: `CODE: object — detail`. */
|
|
117
|
+
export const producerLine = (code: string, object: string, detail: string) => `${code}: ${object} — ${detail}`;
|
|
118
|
+
|
|
119
|
+
/** The patched config's track order: the kept tracks in config order, then the added ones (the ops apply in that order). */
|
|
120
|
+
export const orderedEntries = (state: IdleState) => [...state.entries.filter(e => e.origin !== null).sort((a, b) => a.origin! - b.origin!), ...state.entries.filter(e => e.origin === null)];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Review's pre-checks, run before Apply on the idle the patch would produce, under the producer's names and in its words
|
|
124
|
+
* (rig-parts `checkMotion`, `refuseSharedTargets`, the rig stage's `RIG_BLINK_INSIDE_IDLE`). Each line is the producer's
|
|
125
|
+
* own line for the same config, less its `FAIL ` lead and guide tail. Empty when none applies; the producer still
|
|
126
|
+
* judges everything on Apply (`RIG_KEY_FRAME_UNTURNED` and `RIG_BLINK_HOLD_SPANS_A_FRAME` are left to it).
|
|
127
|
+
*/
|
|
128
|
+
export function precheck(source: IdleSource, state: IdleState): string[] {
|
|
129
|
+
const out: string[] = [], p = 'config.motion', duration = source.duration, bones = new Set(source.bones);
|
|
130
|
+
const claims = new Map<string, string[]>();
|
|
131
|
+
const claim = (target: string, by: string) => claims.set(target, [...(claims.get(target) ?? []), by]);
|
|
132
|
+
const periodLine = (at: string, period: number) => {
|
|
133
|
+
if (!finite(period) || period <= 0 || periodFits(duration, period)) return;
|
|
134
|
+
out.push(producerLine('CONFIG_PERIOD_DIVIDES_DURATION', at, `is ${period} s against a ${duration} s idle, ${(duration / period).toFixed(4)} cycles; a whole number of cycles is required or the loop's last frame is not its first`));
|
|
135
|
+
};
|
|
136
|
+
orderedEntries(state).forEach(({ track }, i) => {
|
|
137
|
+
const at = `${p}.tracks[${i}]`;
|
|
138
|
+
if (isChain(track)) {
|
|
139
|
+
periodLine(`${at}.period`, track.period);
|
|
140
|
+
const links = source.chains[track.chain];
|
|
141
|
+
if (links === undefined) out.push(producerLine('CONFIG_NAME_RESOLVES', `${at}.chain`, `is ${JSON.stringify(track.chain)}; a chain declared in config.bones is required`));
|
|
142
|
+
else for (let k = 0; k < links; k++) claim(`${track.chain}${k}.rotate`, `${at} (chain "${track.chain}", link ${k})`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!bones.has(track.bone)) out.push(producerLine('CONFIG_NAME_RESOLVES', `${at}.bone`, `is ${JSON.stringify(track.bone)}; a declared bone is required`));
|
|
146
|
+
else claim(`${track.bone}.${track.prop}`, at);
|
|
147
|
+
periodLine(`${at}.period`, track.period);
|
|
148
|
+
});
|
|
149
|
+
const blink = state.blink;
|
|
150
|
+
if (blink) {
|
|
151
|
+
const groups: ['eyes' | 'brows', string][] = [['eyes', 'scaley'], ...(blink.brows !== undefined && blink.brow_drop !== undefined ? [['brows', 'translatey'] as ['brows', string]] : [])];
|
|
152
|
+
for (const [key, prop] of groups) {
|
|
153
|
+
const first = new Map<string, number>();
|
|
154
|
+
(blink[key] ?? []).forEach((name, k) => { if (!first.has(name)) first.set(name, k); });
|
|
155
|
+
for (const [name, k] of first) if (bones.has(name)) claim(`${name}.${prop}`, `${p}.blink.${key}[${k}] (the blink's ${key} group, which keys ${prop} on every member)`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const [target, by] of claims) {
|
|
159
|
+
if (by.length < 2) continue;
|
|
160
|
+
out.push(producerLine('CONFIG_BONE_PROPERTY_KEYED_ONCE', `bone property "${target}"`, `is keyed by ${by.length} tracks: ${by.join(' and ')}; one track per bone property is required — the idle would hold ${by.length} timelines on ${target}, and rigc refuses two tracks on one bone property. Merge them into one track, or key another property or bone`));
|
|
161
|
+
}
|
|
162
|
+
if (blink) {
|
|
163
|
+
const span = blinkSpan(blink);
|
|
164
|
+
if (!(blink.t > 0 && blink.t + span < duration)) out.push(producerLine('RIG_BLINK_INSIDE_IDLE', `${p}.blink.t`, `is ${blink.t} s; the blink's keys run from t to t + ${round6(span)} s between the idle's first key at 0 and its last at ${duration} s, so 0 < t < ${round6(duration - span)} is required`));
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- edits (immutable) -------------------------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
const entryOf = (state: IdleState, key: string) => { const e = state.entries.find(x => x.key === key); if (!e) throw new Error(`IDLE_TRACK_UNKNOWN: ${key}`); return e; };
|
|
172
|
+
const replaceEntry = (state: IdleState, key: string, track: IdleTrack): IdleState => ({ ...state, entries: state.entries.map(e => e.key === key ? { ...e, track } : e) });
|
|
173
|
+
const assertFinite = (value: number, what: string) => { if (!finite(value)) throw new Error(`IDLE_VALUE_INVALID: ${what} must be a finite number`); };
|
|
174
|
+
|
|
175
|
+
export type SingleField = 'amp' | 'period' | 'phase' | 'base';
|
|
176
|
+
export type ChainField = 'period' | 'phase' | 'lag';
|
|
177
|
+
/** Set one field of a track. `base: null` takes the field out (the sine about 0, as an absent base is written). */
|
|
178
|
+
export function setField(state: IdleState, key: string, field: SingleField | ChainField, value: number | null): IdleState {
|
|
179
|
+
const entry = entryOf(state, key), track = clone(entry.track) as unknown as Record<string, unknown>;
|
|
180
|
+
if (isChain(entry.track) ? !['period', 'phase', 'lag'].includes(field) : !['amp', 'period', 'phase', 'base'].includes(field)) throw new Error(`IDLE_FIELD_UNKNOWN: ${field} is not a field of a ${isChain(entry.track) ? 'chain' : 'single'} track`);
|
|
181
|
+
if (value === null) { if (field !== 'base') throw new Error(`IDLE_VALUE_INVALID: ${field} cannot be absent`); delete track.base; }
|
|
182
|
+
else { assertFinite(value, field); track[field] = value; }
|
|
183
|
+
return replaceEntry(state, key, track as unknown as IdleTrack);
|
|
184
|
+
}
|
|
185
|
+
/** Set one chain link's amplitude (`amps[link]`). */
|
|
186
|
+
export function setLinkAmp(state: IdleState, key: string, link: number, value: number): IdleState {
|
|
187
|
+
const entry = entryOf(state, key);
|
|
188
|
+
if (!isChain(entry.track)) throw new Error(`IDLE_FIELD_UNKNOWN: ${key} is not a chain track`);
|
|
189
|
+
if (!Number.isInteger(link) || link < 0 || link >= entry.track.amps.length) throw new Error(`IDLE_LINK_UNKNOWN: link ${link} of ${entry.track.chain}`);
|
|
190
|
+
assertFinite(value, `amps[${link}]`);
|
|
191
|
+
const amps = [...entry.track.amps]; amps[link] = value;
|
|
192
|
+
return replaceEntry(state, key, { ...entry.track, amps });
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Add a track: a single track on `bone`/`prop`, or a chain track on a declared chain (one amplitude per link). It starts
|
|
196
|
+
* still (amplitude 0) on the whole idle's period at phase 0, so adding it changes nothing until a value is set.
|
|
197
|
+
*/
|
|
198
|
+
export function addTrack(source: IdleSource, state: IdleState, spec: { bone: string; prop: string } | { chain: string }): IdleState {
|
|
199
|
+
let track: IdleTrack;
|
|
200
|
+
if ('chain' in spec) {
|
|
201
|
+
const links = source.chains[spec.chain];
|
|
202
|
+
if (links === undefined) throw new Error(`IDLE_CHAIN_UNKNOWN: ${spec.chain} is not a chain config.bones declares`);
|
|
203
|
+
track = { chain: spec.chain, amps: Array.from({ length: links }, () => 0), period: source.duration, phase: 0, lag: 0 };
|
|
204
|
+
} else {
|
|
205
|
+
if (!SINGLE_PROPS.includes(spec.prop as SingleProp)) throw new Error(`IDLE_PROP_UNKNOWN: ${spec.prop} (one of ${SINGLE_PROPS.join(', ')})`);
|
|
206
|
+
if (!spec.bone) throw new Error('IDLE_BONE_UNKNOWN: name a bone');
|
|
207
|
+
track = { bone: spec.bone, prop: spec.prop as SingleProp, amp: 0, period: source.duration, phase: 0 };
|
|
208
|
+
}
|
|
209
|
+
const n = Math.max(-1, ...state.entries.filter(e => e.origin === null).map(e => Number(e.key.slice(1)))) + 1;
|
|
210
|
+
return { ...state, entries: [...state.entries, { key: `n${n}`, origin: null, track }] };
|
|
211
|
+
}
|
|
212
|
+
export function removeTrack(state: IdleState, key: string): IdleState { entryOf(state, key); return { ...state, entries: state.entries.filter(e => e.key !== key) }; }
|
|
213
|
+
export type BlinkField = 't' | 'squash' | 'brow_drop';
|
|
214
|
+
export function setBlink(state: IdleState, field: BlinkField, value: number): IdleState {
|
|
215
|
+
if (!state.blink) throw new Error('IDLE_BLINK_ABSENT: the config states no motion.blink');
|
|
216
|
+
if (field === 'brow_drop' && state.blink.brow_drop === undefined) throw new Error('IDLE_BLINK_ABSENT: the config states no blink.brow_drop (no brows group)');
|
|
217
|
+
assertFinite(value, `blink.${field}`);
|
|
218
|
+
return { ...state, blink: { ...state.blink, [field]: value } };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- ops ---------------------------------------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/** The order fields are diffed in (then any other key, sorted), so the same edit always gives the same ops. */
|
|
224
|
+
const FIELD_ORDER = ['bone', 'prop', 'chain', 'amp', 'amps', 'period', 'phase', 'lag', 'base', 't', 'squash', 'brow_drop'];
|
|
225
|
+
const keyOrder = (a: Record<string, unknown>, b: Record<string, unknown>) => { const all = [...new Set([...Object.keys(a), ...Object.keys(b)])]; return [...FIELD_ORDER.filter(k => all.includes(k)), ...all.filter(k => !FIELD_ORDER.includes(k)).sort()]; };
|
|
226
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
227
|
+
function diffObject(path: (string | number)[], before: Record<string, unknown>, after: Record<string, unknown>, ops: PatchOp[]): void {
|
|
228
|
+
for (const k of keyOrder(before, after)) {
|
|
229
|
+
const had = Object.hasOwn(before, k), has = Object.hasOwn(after, k), at = formatPointer([...path, k]);
|
|
230
|
+
if (had && !has) ops.push({ op: 'remove', path: at });
|
|
231
|
+
else if (!had && has) ops.push({ op: 'add', path: at, value: clone(after[k]) as JsonValue });
|
|
232
|
+
else if (!same(before[k], after[k])) {
|
|
233
|
+
const x = before[k], y = after[k];
|
|
234
|
+
if (k === 'amps' && Array.isArray(x) && Array.isArray(y) && x.length === y.length) y.forEach((v, i) => { if (!same(x[i], v)) ops.push({ op: 'replace', path: formatPointer([...path, k, i]), value: v as JsonValue }); });
|
|
235
|
+
else ops.push({ op: 'replace', path: at, value: clone(y) as JsonValue });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The pending edits as config patch ops, applied in order: field ops on the kept tracks at their config index, then the
|
|
241
|
+
* removed tracks from the highest index down, then the added tracks appended (`add /motion/tracks/-`), then the blink's
|
|
242
|
+
* fields. Empty when the state is the config's own.
|
|
243
|
+
*/
|
|
244
|
+
export function idleOps(source: IdleSource, state: IdleState): PatchOp[] {
|
|
245
|
+
const ops: PatchOp[] = [];
|
|
246
|
+
const kept = state.entries.filter(e => e.origin !== null).sort((a, b) => a.origin! - b.origin!);
|
|
247
|
+
for (const e of kept) diffObject(['motion', 'tracks', e.origin!], source.tracks[e.origin!] as unknown as Record<string, unknown>, e.track as unknown as Record<string, unknown>, ops);
|
|
248
|
+
const keptOrigins = new Set(kept.map(e => e.origin));
|
|
249
|
+
for (let i = source.tracks.length - 1; i >= 0; i--) if (!keptOrigins.has(i)) ops.push({ op: 'remove', path: formatPointer(['motion', 'tracks', i]) });
|
|
250
|
+
for (const e of state.entries.filter(x => x.origin === null)) ops.push({ op: 'add', path: '/motion/tracks/-', value: clone(e.track) as unknown as JsonValue });
|
|
251
|
+
if (source.blink && state.blink) diffObject(['motion', 'blink'], source.blink as Record<string, unknown>, state.blink as Record<string, unknown>, ops);
|
|
252
|
+
return ops;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The state back from ops (the draft, a submitted patch): the inverse of `idleOps`. Refused by name unless the ops are
|
|
257
|
+
* exactly what `idleOps` writes for the state they give back — a hand-made op the panel cannot show is never half-read.
|
|
258
|
+
*/
|
|
259
|
+
export function stateFromOps(source: IdleSource, ops: readonly PatchOp[]): IdleState {
|
|
260
|
+
let state = initialState(source);
|
|
261
|
+
const tracks = new Map(state.entries.map(e => [e.origin!, clone(e.track) as unknown as Record<string, JsonValue>]));
|
|
262
|
+
const removed = new Set<number>(), added: IdleTrack[] = [];
|
|
263
|
+
let blink = clone(state.blink) as Record<string, JsonValue> | null;
|
|
264
|
+
const bad = (i: number, why: string): never => { throw new Error(`IDLE_DRAFT_UNREADABLE: ops[${i}] ${why}`); };
|
|
265
|
+
ops.forEach((op, i) => {
|
|
266
|
+
const t = parsePointer(op.path);
|
|
267
|
+
if (t[0] !== 'motion') bad(i, `${op.path} is not under /motion`);
|
|
268
|
+
if (t[1] === 'tracks') {
|
|
269
|
+
if (t.length === 3 && t[2] === '-' && op.op === 'add') { added.push(clone(op.value) as unknown as IdleTrack); return; }
|
|
270
|
+
const index = Number(t[2]);
|
|
271
|
+
if (!/^(0|[1-9][0-9]*)$/.test(t[2] ?? '') || !tracks.has(index)) bad(i, `${op.path} names no config track`);
|
|
272
|
+
if (t.length === 3 && op.op === 'remove') { removed.add(index); return; }
|
|
273
|
+
const track = tracks.get(index)!;
|
|
274
|
+
if (t.length === 4) { if (op.op === 'remove') delete track[t[3]!]; else track[t[3]!] = clone(op.value!); return; }
|
|
275
|
+
if (t.length === 5 && t[3] === 'amps' && Array.isArray(track.amps) && op.op === 'replace') { track.amps[Number(t[4])] = clone(op.value!); return; }
|
|
276
|
+
bad(i, `${op.op} ${op.path} is not an idle track edit`);
|
|
277
|
+
}
|
|
278
|
+
if (t[1] === 'blink' && t.length === 3 && blink) { if (op.op === 'remove') delete blink[t[2]!]; else blink[t[2]!] = clone(op.value!); return; }
|
|
279
|
+
bad(i, `${op.op} ${op.path} is not an idle edit`);
|
|
280
|
+
});
|
|
281
|
+
state = { entries: [...[...tracks].filter(([i]) => !removed.has(i)).map(([i, track]) => ({ key: `o${i}`, origin: i, track: track as unknown as IdleTrack })), ...added.map((track, k) => ({ key: `n${k}`, origin: null, track }))], blink: blink as IdleBlink | null };
|
|
282
|
+
if (!same(idleOps(source, state), ops)) throw new Error('IDLE_DRAFT_UNREADABLE: the ops are not the ones the panel writes for the idle they describe');
|
|
283
|
+
return state;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Whether a track differs from the config's own (a new track always does). */
|
|
287
|
+
export function entryChanged(source: IdleSource, entry: IdleEntry): boolean { return entry.origin === null || !same(source.tracks[entry.origin], entry.track); }
|
|
288
|
+
|
|
289
|
+
/** The draft's record of the panel: the pending ops (nothing about the preview). */
|
|
290
|
+
export const IDLE_DRAFT_SCHEMA = 'rig-review-idle-draft/1';
|
|
291
|
+
export const idleDraftText = (ops: readonly PatchOp[]) => JSON.stringify({ schema: IDLE_DRAFT_SCHEMA, ops });
|
|
292
|
+
/** The state a draft records, or the config's own when the draft is absent, foreign or no longer applies. */
|
|
293
|
+
export function idleDraftState(text: string, source: IdleSource): IdleState {
|
|
294
|
+
try {
|
|
295
|
+
const doc = JSON.parse(text) as unknown;
|
|
296
|
+
if (!isObject(doc) || doc.schema !== IDLE_DRAFT_SCHEMA || !Array.isArray(doc.ops)) return initialState(source);
|
|
297
|
+
return stateFromOps(source, doc.ops as PatchOp[]);
|
|
298
|
+
} catch { return initialState(source); }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---- which bone the idle keys, and what the preview writes ---------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Where the inspected idle keys each config track (#104): read off the handoff rig's `motion.json` (the idle's track
|
|
305
|
+
* bones) and `rig.json` (`invariants.idleDrivesMeshes`, which `rig --idle-keys direct` declares). `keyed` maps
|
|
306
|
+
* `<config bone>.<prop>` to the bone the idle keys — `<bone>_ctl` when the rig stage moved the keys to a control.
|
|
307
|
+
*/
|
|
308
|
+
/** `GET /api/rebuild`'s `idle`: the config's idle and where the inspected idle keys it. */
|
|
309
|
+
export interface IdleStatus { source: IdleSourceResult; keying: IdleKeying | { error: string } }
|
|
310
|
+
export interface IdleKeying { keys: 'ctl' | 'direct'; source: string; keyed: Record<string, string>; controls: string[] }
|
|
311
|
+
export function idleKeying(rig: unknown, motion: unknown): IdleKeying | { error: string } {
|
|
312
|
+
if (!isObject(rig) || !Array.isArray(rig.bones)) return { error: 'IDLE_KEYS_UNKNOWN: the handoff rig.json has no bones list' };
|
|
313
|
+
const idle = isObject(motion) && isObject(motion.animations) ? motion.animations.idle : undefined;
|
|
314
|
+
if (!isObject(idle) || !Array.isArray(idle.tracks)) return { error: 'IDLE_KEYS_UNKNOWN: the handoff motion.json has no idle' };
|
|
315
|
+
const parent = new Map<string, string | null>(rig.bones.filter(isObject).map(b => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
316
|
+
// A control is `<bone>_ctl` whose child `<bone>` it parents (rig-parts `moveKeysToControls`).
|
|
317
|
+
const configBone = (keyed: string) => keyed.endsWith(CONTROL_SUFFIX) && parent.get(keyed.slice(0, -CONTROL_SUFFIX.length)) === keyed ? keyed.slice(0, -CONTROL_SUFFIX.length) : keyed;
|
|
318
|
+
const keyed: Record<string, string> = {}, groups = isObject(motion) && isObject(motion.groups) ? motion.groups : {};
|
|
319
|
+
for (const t of idle.tracks) {
|
|
320
|
+
if (!isObject(t) || typeof t.property !== 'string') continue;
|
|
321
|
+
const members = typeof t.bone === 'string' ? [t.bone] : typeof t.group === 'string' && Array.isArray(groups[t.group]) ? (groups[t.group] as unknown[]).filter((m): m is string => typeof m === 'string') : [];
|
|
322
|
+
for (const m of members) keyed[`${configBone(m)}.${t.property}`] = m;
|
|
323
|
+
}
|
|
324
|
+
const controls = [...parent.keys()].filter(n => configBone(n) !== n).sort();
|
|
325
|
+
const direct = isObject(rig.invariants) && rig.invariants.idleDrivesMeshes !== undefined;
|
|
326
|
+
return direct
|
|
327
|
+
? { keys: 'direct', source: 'rig.json declares invariants.idleDrivesMeshes (rig --idle-keys direct)', keyed, controls }
|
|
328
|
+
: { keys: 'ctl', source: `rig.json declares no invariants.idleDrivesMeshes, so the rig was built under --idle-keys ctl (the producer's default; a rig with no mesh-bound keyed bone is the same under both)${controls.length ? `; the idle keys ${controls.length} control(s): ${controls.join(', ')}` : ''}`, keyed, controls };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The bone the preview drives for `<bone>.<prop>`: the bone the inspected idle keys for it; else, for a property the
|
|
333
|
+
* idle does not key yet, the bone itself under direct keys, `<bone>_ctl` when the model already has that control, the
|
|
334
|
+
* bone itself when the idle keys it directly on another property (no mesh is weighted to it). Otherwise review cannot
|
|
335
|
+
* know whether the producer adds a control, and the preview is refused by name.
|
|
336
|
+
*/
|
|
337
|
+
export function previewTarget(bone: string, prop: string, keying: IdleKeying, modelBones: ReadonlySet<string>): { bone: string } | { refusal: string } {
|
|
338
|
+
const known = keying.keyed[`${bone}.${prop}`];
|
|
339
|
+
let target: string | null = known ?? null;
|
|
340
|
+
if (target === null) {
|
|
341
|
+
if (keying.keys === 'direct') target = bone;
|
|
342
|
+
else if (modelBones.has(bone + CONTROL_SUFFIX)) target = bone + CONTROL_SUFFIX;
|
|
343
|
+
else if (Object.values(keying.keyed).includes(bone)) target = bone;
|
|
344
|
+
}
|
|
345
|
+
if (target === null) return { refusal: `IDLE_PREVIEW_TARGET_UNKNOWN: ${bone}.${prop} is not keyed by the inspected idle; under --idle-keys ctl the producer keys ${bone}${CONTROL_SUFFIX} instead when a mesh is weighted to ${bone}, which review does not decide — Apply decides` };
|
|
346
|
+
if (!modelBones.has(target)) return { refusal: `IDLE_PREVIEW_BONE_ABSENT: ${target} is not a bone of the drawn model — Apply decides` };
|
|
347
|
+
return { bone: target };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export type LocalChannel = 'rotation' | 'x' | 'y' | 'scaleX' | 'scaleY' | 'shearX' | 'shearY';
|
|
351
|
+
/** The bone local a property keys (play's `BoneLocals`). */
|
|
352
|
+
export const CHANNEL: Readonly<Record<SingleProp, LocalChannel>> = { rotate: 'rotation', translatex: 'x', translatey: 'y', scalex: 'scaleX', scaley: 'scaleY', shearx: 'shearX', sheary: 'shearY' };
|
|
353
|
+
/** play's `LOCAL_CHANNELS`: a channel a bone does not state. */
|
|
354
|
+
export const SETUP_DEFAULT: Readonly<Record<LocalChannel, number>> = { rotation: 0, x: 0, y: 0, scaleX: 1, scaleY: 1, shearX: 0, shearY: 0 };
|
|
355
|
+
/** A keyed value as the bone local it poses (the Spine runtime's timelines, setup blend): a scale multiplies the setup, the rest add. */
|
|
356
|
+
export const localValue = (prop: SingleProp, setup: number, v: number) => prop === 'scalex' || prop === 'scaley' ? setup * v : setup + v;
|
|
357
|
+
/** A property with no track: the bone holds its setup value. */
|
|
358
|
+
export const STILL: Readonly<Record<SingleProp, number>> = { rotate: 0, translatex: 0, translatey: 0, scalex: 1, scaley: 1, shearx: 0, sheary: 0 };
|
|
359
|
+
|
|
360
|
+
export interface PreviewWrite { bone: string; channel: LocalChannel; prop: SingleProp; sine: { amp: number; period: number; phase: number; base: number } | null }
|
|
361
|
+
export interface PreviewPlan { writes: PreviewWrite[]; refusals: string[]; tracks: number }
|
|
362
|
+
/**
|
|
363
|
+
* What the preview drives: every changed or added track (each chain link), and a removed track's bones held still. Each
|
|
364
|
+
* write names the bone the idle keys (`previewTarget`) and its sine; a target review cannot know is refused by name and
|
|
365
|
+
* left to Apply. Blink edits are not previewed (their keys are eased, not sines).
|
|
366
|
+
*/
|
|
367
|
+
export function previewPlan(source: IdleSource, state: IdleState, keying: IdleKeying, modelBones: ReadonlySet<string>): PreviewPlan {
|
|
368
|
+
const writes: PreviewWrite[] = [], refusals: string[] = [];
|
|
369
|
+
const add = (bone: string, prop: SingleProp, sine: PreviewWrite['sine']) => {
|
|
370
|
+
const target = previewTarget(bone, prop, keying, modelBones);
|
|
371
|
+
if ('refusal' in target) refusals.push(target.refusal); else writes.push({ bone: target.bone, channel: CHANNEL[prop], prop, sine });
|
|
372
|
+
};
|
|
373
|
+
const each = (track: IdleTrack, sine: (bone: string, prop: SingleProp, p: { amp: number; period: number; phase: number; base: number }) => void) => {
|
|
374
|
+
if (isChain(track)) track.amps.forEach((_, i) => sine(`${track.chain}${i}`, 'rotate', linkSine(track, i)));
|
|
375
|
+
else sine(track.bone, track.prop, { amp: track.amp, period: track.period, phase: track.phase, base: track.base ?? 0 });
|
|
376
|
+
};
|
|
377
|
+
let tracks = 0;
|
|
378
|
+
const kept = new Set(state.entries.map(e => e.origin));
|
|
379
|
+
source.tracks.forEach((track, i) => { if (!kept.has(i)) { tracks++; each(track, (bone, prop) => add(bone, prop, null)); } });
|
|
380
|
+
for (const entry of state.entries) if (entryChanged(source, entry)) { tracks++; each(entry.track, (bone, prop, p) => add(bone, prop, p)); }
|
|
381
|
+
return { writes, refusals, tracks };
|
|
382
|
+
}
|
|
383
|
+
/** The local a write holds at idle time `t` (`setup` is the bone's setup value of the channel). */
|
|
384
|
+
export function previewLocal(write: PreviewWrite, setup: number, t: number): number {
|
|
385
|
+
return localValue(write.prop, setup, write.sine ? sineValue(write.sine, t) : STILL[write.prop]);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Bar 1's bound, derived from how the producer keys a sine before measuring: within one key span h = period/8 the
|
|
390
|
+
* bezier with the exact Hermite tangents is the cubic Hermite interpolant, off the sine by at most
|
|
391
|
+
* h⁴/384·max|v⁗| = |amp|·(π/4)⁴/384; the runtime draws a bezier as 10 straight pieces (`BEZIER_PIECES`), off the
|
|
392
|
+
* curve by at most (h/10)²/8·max|v''| = |amp|·(π/40)²/8; values and handles are written to 4 decimals (5e-5 between
|
|
393
|
+
* them, a convex combination) and times to 6 (5e-7 s at slope |amp|·2π/period); the runtime holds the pieces' points
|
|
394
|
+
* as float32 (`Math.fround`, 2⁻²⁴ relative: |base| + |amp| in value, `duration` in time at that slope). In the
|
|
395
|
+
* local's units: × |setup| for a scale, and two float32 roundings of the setup-sized local (2·2⁻²⁴·|setup|).
|
|
396
|
+
*/
|
|
397
|
+
export function keySamplingBound(p: { amp: number; period: number; base?: number }, duration: number, prop: SingleProp = 'rotate', setup = 1): number {
|
|
398
|
+
const a = Math.abs(p.amp), slope = a * 2 * Math.PI / p.period, f32 = 2 ** -24;
|
|
399
|
+
const v = a * (Math.PI / 4) ** 4 / 384 + a * (Math.PI / 40) ** 2 / 8 + 5e-5 + slope * 5e-7 + (Math.abs(p.base ?? 0) + a) * f32 + slope * duration * f32;
|
|
400
|
+
return (prop === 'scalex' || prop === 'scaley' ? Math.abs(setup) * (v + (Math.abs(p.base ?? 0) + a) * f32) : v) + Math.abs(setup) * 2 * f32 + 1e-9;
|
|
401
|
+
}
|