@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
|
@@ -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
|
+
}
|
package/src/manifest.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { parseReport, type ReportSeeds } from './report.ts';
|
|
|
6
6
|
import { readFile, realpath, stat, lstat, readdir } from 'node:fs/promises';
|
|
7
7
|
import { createReadStream } from 'node:fs';
|
|
8
8
|
import { basename, dirname, isAbsolute, relative, resolve, posix } from 'node:path';
|
|
9
|
+
import { parseHandoffDeclaration, resolveHandoff, type Handoff, type HandoffDeclaration, type HandoffFlags, type HandoffRecord } from './handoff.ts';
|
|
9
10
|
|
|
10
11
|
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
11
12
|
export interface Reference { id: string; label: string; type: 'image' | 'video' | 'pose'; path: string; width?: number; height?: number }
|
|
@@ -16,9 +17,13 @@ export interface ReviewManifest {
|
|
|
16
17
|
checkpoints: Checkpoint[]; authoring: { label: string; path: string }[];
|
|
17
18
|
axes: { id: string; label: string; animation: string; min: number; max: number }[];
|
|
18
19
|
audio: { clips: { id: string; label?: string; path: string }[]; cues: { id: string; clip: string; time: number; animation?: string }[] };
|
|
20
|
+
/** Optional producer handoff (#101): package-relative literal paths; checked when the package loads. */
|
|
21
|
+
producer?: HandoffDeclaration;
|
|
19
22
|
}
|
|
20
|
-
export interface ReviewedSource { readonly packageRevision:string; readonly viewerVersion:string; readonly runtimeVersion:string; readonly rig:{readonly path:string;readonly sha256:string}|null; readonly motion:{readonly path:string;readonly sha256:string}|null }
|
|
21
|
-
export interface ReviewPackage { root: string; manifestPath: string; manifest: ReviewManifest; sourceHash: string; reviewedSource:ReviewedSource; files: Set<string>; report:ReportSeeds|null; feedbackCatalog:FeedbackCatalog
|
|
23
|
+
export interface ReviewedSource { readonly packageRevision:string; readonly viewerVersion:string; readonly runtimeVersion:string; readonly rig:{readonly path:string;readonly sha256:string}|null; readonly motion:{readonly path:string;readonly sha256:string}|null; readonly handoff?:HandoffRecord }
|
|
24
|
+
export interface ReviewPackage { root: string; manifestPath: string; manifest: ReviewManifest; sourceHash: string; reviewedSource:ReviewedSource; files: Set<string>; report:ReportSeeds|null; feedbackCatalog:FeedbackCatalog;
|
|
25
|
+
/** The resolved producer handoff (#101) and, when there is none, the refusal line naming where it was looked for. */
|
|
26
|
+
handoff: Handoff|null; handoffReason: string|null; handoffInputs?: HandoffFlags }
|
|
22
27
|
function fail(field: string, expected: string): never { throw new Error(`${field}: expected ${expected}`); }
|
|
23
28
|
export function object(value: unknown, field: string): Record<string, unknown> { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(field, 'object'); return value as Record<string, unknown>; }
|
|
24
29
|
export function string(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim() || value.length > 10000) fail(field, 'nonempty string (at most 10000 characters)'); return value; }
|
|
@@ -56,6 +61,7 @@ export function parseManifest(input: unknown): ReviewManifest {
|
|
|
56
61
|
checkpoints: list(m.checkpoints,'checkpoints',(v,f) => ({id:id(v.id,f+'.id'),title:string(v.title,f+'.title'),time:optionalNumber(v.time,f+'.time'),animation:optionalString(v.animation,f+'.animation'),reference:optionalString(v.reference,f+'.reference'),note:optionalString(v.note,f+'.note'),workspace:v.workspace === undefined ? undefined : choice(v.workspace,['modeling','motion'] as const,f+'.workspace'),axis:v.axis===undefined?undefined:(()=>{const a=object(v.axis,f+'.axis');return {id:id(a.id,f+'.axis.id'),value:number(a.value,f+'.axis.value',-Number.MAX_VALUE)};})(),camera:v.camera === undefined ? undefined : (() => {const c=object(v.camera,f+'.camera'); const zoom=number(c.zoom,f+'.camera.zoom',Number.EPSILON); return {zoom,x:number(c.x,f+'.camera.x',-Number.MAX_VALUE),y:number(c.y,f+'.camera.y',-Number.MAX_VALUE)};})()})),
|
|
57
62
|
authoring:list(m.authoring,'authoring',(v,f)=>({label:string(v.label,f+'.label'),path:packagePath(v.path,f+'.path')})),
|
|
58
63
|
axes:list(m.axes,'axes',(v,f)=>({id:id(v.id,f+'.id'),label:string(v.label,f+'.label'),animation:string(v.animation,f+'.animation'),min:number(v.min,f+'.min',-Number.MAX_VALUE),max:number(v.max,f+'.max',-Number.MAX_VALUE)})),
|
|
64
|
+
...(m.producer===undefined?{}:{producer:parseHandoffDeclaration(m.producer)}),
|
|
59
65
|
audio:{clips:list(audio.clips,'audio.clips',(v,f)=>({id:id(v.id,f+'.id'),label:optionalString(v.label,f+'.label'),path:packagePath(v.path,f+'.path')})),cues:list(audio.cues,'audio.cues',(v,f)=>({id:id(v.id,f+'.id'),clip:string(v.clip,f+'.clip'),time:number(v.time,f+'.time'),animation:optionalString(v.animation,f+'.animation')}))}
|
|
60
66
|
};
|
|
61
67
|
if(manifest.model&&!manifest.model.endsWith('.model.json'))fail('model','compiled model path ending .model.json (the play runtime input)');
|
|
@@ -65,9 +71,10 @@ export function parseManifest(input: unknown): ReviewManifest {
|
|
|
65
71
|
for(const c of manifest.checkpoints) if(c.axis){const a=manifest.axes.find(a=>a.id===c.axis!.id);if(!a)fail(`checkpoint ${c.id}.axis`,'declared axis id');if(c.axis.value<a.min||c.axis.value>a.max)fail(`checkpoint ${c.id}.axis.value`,`number in [${a.min}, ${a.max}]`);}
|
|
66
72
|
return manifest;
|
|
67
73
|
}
|
|
68
|
-
|
|
74
|
+
/** `options.handoff` are the explicit `--config …` flags; they win over the manifest block and discovery. */
|
|
75
|
+
export async function loadReview(input: string, options: { handoff?: HandoffFlags } = {}): Promise<ReviewPackage> {
|
|
69
76
|
const inputPath=await realpath(resolve(input));const directory=(await stat(inputPath)).isDirectory();
|
|
70
|
-
if(directory){try{await stat(resolve(inputPath,'review.json'));return loadReview(resolve(inputPath,'review.json'));}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}}
|
|
77
|
+
if(directory){try{await stat(resolve(inputPath,'review.json'));return loadReview(resolve(inputPath,'review.json'),options);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}}
|
|
71
78
|
const manifestPath=inputPath;const root=directory?inputPath:await realpath(dirname(inputPath));
|
|
72
79
|
const manifest=directory?await folderManifest(root):parseManifest(JSON.parse(await readFile(manifestPath,'utf8')));
|
|
73
80
|
const files = new Set([...(directory?[]:[relative(root,manifestPath)]),...manifest.references.map(v=>v.path),...manifest.authoring.map(v=>v.path),...manifest.audio.clips.map(v=>v.path)]);
|
|
@@ -98,9 +105,11 @@ export async function loadReview(input: string): Promise<ReviewPackage> {
|
|
|
98
105
|
if(p===manifest.model){if(fileHashes.get(p)!==modelBytesHash)throw new Error(`Model ${p} changed while hashing; retry with stable package inputs`);modelMetadata=object(JSON.parse(Buffer.concat(metadataChunks).toString('utf8')),'model');}
|
|
99
106
|
if(p===reportPath){let value:unknown;try{value=JSON.parse(Buffer.concat(metadataChunks).toString('utf8'));}catch(e){throw new Error(`${p}: ${e instanceof Error?e.message:String(e)}`);}report=parseReport(value,p);}
|
|
100
107
|
}
|
|
101
|
-
|
|
108
|
+
// The handoff files are hashed beside the package: their record joins the reviewed identity (and so the session file).
|
|
109
|
+
const {handoff,reason:handoffReason}=await resolveHandoff(root,manifest.producer,options.handoff);
|
|
110
|
+
const reviewedSource:ReviewedSource={packageRevision:manifest.revision,...await toolVersions(),rig:rigPath?{path:rigPath,sha256:fileHashes.get(rigPath)!}:null,motion:motionPath?{path:motionPath,sha256:fileHashes.get(motionPath)!}:null,...(handoff?{handoff:handoff.record}:{})};
|
|
102
111
|
const feedbackCatalog:FeedbackCatalog={rig:manifest.id,bones:modelNames(modelMetadata,'bones'),slots:modelNames(modelMetadata,'slots'),animations:modelAnimations(modelMetadata),clock:report?.clock??null,clockAnimation:report?.animation??null};
|
|
103
|
-
return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog};
|
|
112
|
+
return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{})};
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
/** Same text for every command; `root` lets the open launcher name the refused input. */
|
package/src/mesh-edit.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mesh edit (Setup): a typed delta over the actual loaded mesh attachment of one slot. Pure — no DOM, no I/O.
|
|
3
3
|
*
|
|
4
|
-
* The source is the attachment the hash-verified model
|
|
5
|
-
*
|
|
4
|
+
* The source is the attachment's entry in the hash-verified compiled model document, in **setup space** (#100): its
|
|
5
|
+
* stored setup-pose vertices (`src/setup-mesh.ts`, the reader Setup · Weights shares) placed through the setup-pose
|
|
6
|
+
* bones and mapped to crop pixels of the rig canvas (x right, y down) through the same crop mapping as the chest
|
|
7
|
+
* regions — the space the producer's `rig-parts remesh --delta` binds positions in, so the delta is a producer input
|
|
8
|
+
* as it is (`basis: "setup"`). A package without the model document refuses `MESH_EDIT_MODEL_UNAVAILABLE`. Ops are
|
|
6
9
|
* recorded in order; nothing is retriangulated here — the preview draws the delta over the original triangulation and
|
|
7
|
-
* the producer
|
|
8
|
-
*
|
|
10
|
+
* the producer owns the mesher. Vertex ids are stable: the source vertices keep their indices, an added vertex takes
|
|
11
|
+
* the next id after every vertex that existed before it, and a removed vertex keeps its id (it is only marked). The
|
|
12
|
+
* producer owns every triangulation edge: only a constraint edge this delta added can be removed (`unedge`), and a
|
|
13
|
+
* vertex that ends a constraint edge is removed only after that edge is.
|
|
9
14
|
*/
|
|
15
|
+
import type {SetupMeshCodes} from './setup-mesh.ts';
|
|
10
16
|
export type Vec = [number, number];
|
|
11
17
|
export type MeshOp =
|
|
12
18
|
| { op: 'move'; v: number; from: Vec; to: Vec }
|
|
@@ -18,12 +24,12 @@ export type EdgeKind = 'hull' | 'interior' | 'constraint';
|
|
|
18
24
|
export interface MeshEdge { a: number; b: number; kind: EdgeKind }
|
|
19
25
|
export interface MeshSource {
|
|
20
26
|
slot: string; skin: string; attachment: string;
|
|
21
|
-
/**
|
|
27
|
+
/** Setup-pose crop px of the rig canvas, x right, y down, one per source vertex, unrounded (the producer holds `move.from` to 1e-3 px). */
|
|
22
28
|
vertices: readonly Vec[];
|
|
23
29
|
triangles: readonly number[];
|
|
24
30
|
/** The model's `hull`: the first `hull` vertices, in order, are the hull. */
|
|
25
31
|
hull: number;
|
|
26
|
-
/** SHA-256 of the attachment's entry in the hash-verified model (see `attachmentHashText`). */
|
|
32
|
+
/** SHA-256 of the attachment's entry in the hash-verified model document (see `attachmentHashText`). */
|
|
27
33
|
hash: string;
|
|
28
34
|
}
|
|
29
35
|
export interface MeshView {
|
|
@@ -41,9 +47,19 @@ export type MeshEditResult = { ok: true; changed: boolean; code?: string } | { o
|
|
|
41
47
|
|
|
42
48
|
export const MESH_EDIT_SCHEMA = 'rig-review-mesh-edit/1';
|
|
43
49
|
export const MESH_EDIT_BINDING_SCHEMA = 'rig-review-mesh-edit-binding/1';
|
|
44
|
-
|
|
50
|
+
/** `/2` since #100: a `/1` draft holds ops drawn over a posed basis, which are not restored over the setup-space source. */
|
|
51
|
+
export const MESH_EDIT_DRAFT_SCHEMA = 'rig-review-mesh-edit-draft/2';
|
|
45
52
|
export const MESH_EDIT_SELECT_SLOT = 'MESH_EDIT_SELECT_SLOT';
|
|
46
53
|
export const MESH_EDIT_NO_MESH = 'MESH_EDIT_NO_MESH';
|
|
54
|
+
/** The package supplies no compiled model document (a Spine export without `skeleton.model.json`): no setup mesh to edit. */
|
|
55
|
+
export const MESH_EDIT_MODEL_UNAVAILABLE = 'MESH_EDIT_MODEL_UNAVAILABLE';
|
|
56
|
+
export const MESH_EDIT_MODEL_INVALID = 'MESH_EDIT_MODEL_INVALID';
|
|
57
|
+
export const MESH_EDIT_RIG_UNAVAILABLE = 'MESH_EDIT_RIG_UNAVAILABLE';
|
|
58
|
+
export const MESH_EDIT_CONSTRAINT_END = 'MESH_EDIT_CONSTRAINT_END';
|
|
59
|
+
/** The export's `basis`: the producer's delta reader accepts exactly this (rig-c/parts `remesh --delta`). */
|
|
60
|
+
export const MESH_EDIT_BASIS = 'setup';
|
|
61
|
+
/** Edit Mesh's refusals over the shared setup-space reader. */
|
|
62
|
+
export const MESH_EDIT_CODES: SetupMeshCodes = Object.freeze({ unavailable: MESH_EDIT_MODEL_UNAVAILABLE, select: MESH_EDIT_SELECT_SLOT, noMesh: MESH_EDIT_NO_MESH, invalid: MESH_EDIT_MODEL_INVALID, subject: 'the setup mesh is', selectText: 'select one slot in the parts list; whole-rig mesh edit is not a thing' });
|
|
47
63
|
export const MESH_EDIT_HULL_EDGE = 'MESH_EDIT_HULL_EDGE';
|
|
48
64
|
export const MESH_EDIT_EDGE_EXISTS = 'MESH_EDIT_EDGE_EXISTS';
|
|
49
65
|
export const MESH_EDIT_TRIANGULATION_EDGE = 'MESH_EDIT_TRIANGULATION_EDGE';
|
|
@@ -100,7 +116,9 @@ export function applyMeshOps(source: MeshSource, ops: readonly MeshOp[]): MeshVi
|
|
|
100
116
|
view.added.add(id); break;
|
|
101
117
|
}
|
|
102
118
|
case 'remove': {
|
|
103
|
-
const v = live(op.v, i);
|
|
119
|
+
const v = live(op.v, i);
|
|
120
|
+
for (const edge of edges.values()) if (edge.kind === 'constraint' && (edge.a === v || edge.b === v)) fail(i, `removes vertex ${v}, an end of constraint edge ${edge.a}-${edge.b}; unedge it first`);
|
|
121
|
+
view.vertices[v] = null; view.removed.add(v); view.moved.delete(v);
|
|
104
122
|
for (const [key, edge] of [...edges]) if (edge.a === v || edge.b === v) removeEdge(key);
|
|
105
123
|
break;
|
|
106
124
|
}
|
|
@@ -112,6 +130,7 @@ export function applyMeshOps(source: MeshSource, ops: readonly MeshOp[]): MeshVi
|
|
|
112
130
|
case 'unedge': {
|
|
113
131
|
const a = live(op.a, i), b = live(op.b, i), edge = edges.get(edgeKey(a, b));
|
|
114
132
|
if (!edge) fail(i, `removes a missing edge ${a}-${b}`); if (edge!.kind === 'hull') fail(i, `removes hull edge ${a}-${b}`);
|
|
133
|
+
if (edge!.kind !== 'constraint') fail(i, `removes triangulation edge ${a}-${b}; only a constraint edge this delta added can be removed`);
|
|
115
134
|
removeEdge(edgeKey(a, b)); break;
|
|
116
135
|
}
|
|
117
136
|
default: fail(i, 'has an unknown op');
|
|
@@ -161,12 +180,17 @@ export class MeshEditSession {
|
|
|
161
180
|
}
|
|
162
181
|
/** Create: a vertex on empty space, or a split of an edge at the click point. */
|
|
163
182
|
addVertex(at: Vec, onEdge?: [number, number]): MeshEditResult { return this.record([{ op: 'add', at: roundVec(at), ...(onEdge ? { onEdge: [onEdge[0], onEdge[1]] as [number, number] } : {}) }]); }
|
|
164
|
-
/** Delete on a vertex: the vertex and its edges. */
|
|
165
|
-
removeVertex(v: number): MeshEditResult {
|
|
166
|
-
|
|
183
|
+
/** Delete on a vertex: the vertex and its triangulation edges; a vertex that ends a constraint edge is refused until that edge is removed. */
|
|
184
|
+
removeVertex(v: number): MeshEditResult {
|
|
185
|
+
const held = this.current.edges.find(e => e.kind === 'constraint' && (e.a === v || e.b === v));
|
|
186
|
+
if (held) return { ok: false, code: `${MESH_EDIT_CONSTRAINT_END}: vertex ${v} ends constraint edge ${held.a}-${held.b} — remove that edge first` };
|
|
187
|
+
return this.record([{ op: 'remove', v }]);
|
|
188
|
+
}
|
|
189
|
+
/** Delete on an edge: only a constraint edge this delta added; hull and triangulation edges are the producer's. */
|
|
167
190
|
deleteEdge(a: number, b: number): MeshEditResult {
|
|
168
191
|
const edge = this.findEdge(a, b); if (!edge) return { ok: false, code: `${MESH_EDIT_INVALID_OP}: no edge ${a}-${b}` };
|
|
169
192
|
if (edge.kind === 'hull') return { ok: false, code: `${MESH_EDIT_HULL_EDGE}: ${a}-${b} is a hull edge and cannot be removed` };
|
|
193
|
+
if (edge.kind !== 'constraint') return { ok: false, code: `${MESH_EDIT_TRIANGULATION_EDGE}: ${a}-${b} is a triangulation edge — the producer retriangulates; only an added constraint edge can be removed` };
|
|
170
194
|
return this.record([{ op: 'unedge', a, b }]);
|
|
171
195
|
}
|
|
172
196
|
/**
|
|
@@ -231,19 +255,25 @@ export interface MeshEditDocument {
|
|
|
231
255
|
schema: typeof MESH_EDIT_SCHEMA;
|
|
232
256
|
slot: string; skin: string; attachment: string;
|
|
233
257
|
source: { vertexCount: number; triangleCount: number; hullLength: number; hash: string };
|
|
234
|
-
units: string; basis:
|
|
258
|
+
units: string; basis: typeof MESH_EDIT_BASIS; preview: string;
|
|
235
259
|
ops: MeshOp[];
|
|
236
260
|
}
|
|
237
|
-
|
|
261
|
+
/** The export: `rig-review-mesh-edit/1`, `basis: "setup"`, as rig-c/parts `remesh --delta` reads it. */
|
|
262
|
+
export function meshEditDocument(session: MeshEditSession): MeshEditDocument {
|
|
238
263
|
const s = session.source;
|
|
239
264
|
return {
|
|
240
265
|
schema: MESH_EDIT_SCHEMA, slot: s.slot, skin: s.skin, attachment: s.attachment,
|
|
241
266
|
source: { vertexCount: s.vertices.length, triangleCount: Math.floor(s.triangles.length / 3), hullLength: s.hull, hash: s.hash },
|
|
242
|
-
units: 'crop px, x right, y down; vertex ids are source indices, added vertices take the next id in op order, removed ids are not reused',
|
|
243
|
-
basis, preview: `${MESH_EDIT_PREVIEW_NOTE}; the ops are a request over the original triangulation, not a mesh`,
|
|
267
|
+
units: 'setup-pose crop px of the rig canvas, x right, y down; vertex ids are source indices, added vertices take the next id in op order, removed ids are not reused',
|
|
268
|
+
basis: MESH_EDIT_BASIS, preview: `${MESH_EDIT_PREVIEW_NOTE}; the ops are a request over the original triangulation, not a mesh`,
|
|
244
269
|
ops: session.ops(),
|
|
245
270
|
};
|
|
246
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* The delta's bytes: what Download JSON saves and what Apply · Rebuild (#135) writes as the producer's
|
|
274
|
+
* `mesh-edit.json`, so the binding's `file.sha256` and the applied record's `deltaSha256` name the same bytes.
|
|
275
|
+
*/
|
|
276
|
+
export const meshEditText = (document: unknown) => JSON.stringify(document, null, 2) + '\n';
|
|
247
277
|
export function meshEditBinding(input: { fileSha256: string; sourceHash: string; reviewedSource: { viewerVersion: string; runtimeVersion: string; rig: { sha256: string } | null; motion: { sha256: string } | null }; exportedAt: string; mapping: string; attachment: { slot: string; skin: string; name: string; sha256: string } }) {
|
|
248
278
|
return {
|
|
249
279
|
schema: MESH_EDIT_BINDING_SCHEMA,
|