@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/readings.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readings as watch instruments (issue #108): the installed compiler's own readings — `rigc render --folds`,
|
|
3
|
+
* `--pass-through <cloth>:<leg>`, `--seam <slot>:<slot>`, `--overlaps`, `--angle-range <bone>:<relative-to>` — run on
|
|
4
|
+
* the inspected build and on the latest scratch build, and their results become timeline ticks and one worst-frame line
|
|
5
|
+
* per pair. Pure: typed requests to argv (every path the server's), the JSON each reading writes (and, for
|
|
6
|
+
* `--angle-range`, which writes nothing, its printed line) to frames and a worst frame, and rigc's refusal line verbatim.
|
|
7
|
+
* Review judges nothing here: a reading is not a gate, and an unknown slot or bone is rigc's to refuse.
|
|
8
|
+
*/
|
|
9
|
+
import { firstRefusal } from './producer.ts';
|
|
10
|
+
|
|
11
|
+
export const READINGS_SCHEMA = 'rig-review-readings/1';
|
|
12
|
+
/** The first `rig-c` release with all five readings (its CHANGELOG: 2.63.0, "render: --overlaps names which slot covers which"). */
|
|
13
|
+
export const READINGS_SINCE = '2.63.0';
|
|
14
|
+
export const MAX_READINGS = 8;
|
|
15
|
+
const NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
|
|
16
|
+
|
|
17
|
+
export type ReadingRequest =
|
|
18
|
+
| { kind: 'folds' }
|
|
19
|
+
| { kind: 'overlaps' }
|
|
20
|
+
| { kind: 'pass-through'; cloth: string; leg: string }
|
|
21
|
+
| { kind: 'seam'; a: string; b: string }
|
|
22
|
+
| { kind: 'angle-range'; bone: string; relative: string };
|
|
23
|
+
export type ReadingKind = ReadingRequest['kind'];
|
|
24
|
+
export const READING_KINDS: readonly ReadingKind[] = ['folds', 'pass-through', 'seam', 'overlaps', 'angle-range'];
|
|
25
|
+
/** The world the physics answers while the readings are posed: the Environment block's wind and gravity as they stand (x right, y down). */
|
|
26
|
+
export interface ReadingEnvironment { wind: [number, number]; gravity: [number, number] }
|
|
27
|
+
export interface ReadingsRequest { animation: string; readings: ReadingRequest[]; environment: ReadingEnvironment | null }
|
|
28
|
+
|
|
29
|
+
export class ReadingsError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
|
|
30
|
+
const bad = (detail: string): never => { throw new ReadingsError('READINGS_REQUEST_INVALID', detail); };
|
|
31
|
+
const record = (value: unknown, field: string): Record<string, unknown> => (value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : bad(`${field} must be an object`));
|
|
32
|
+
const exactly = (v: Record<string, unknown>, keys: string[], field: string) => { if (Object.keys(v).sort().join(',') !== [...keys].sort().join(',')) bad(`${field} holds exactly ${keys.join(', ')}`); };
|
|
33
|
+
/** A slot or bone name as rigc's list syntax can carry it (no `:` or `,`, no leading `-`); whether the rig has it is rigc's to say. */
|
|
34
|
+
const name = (value: unknown, field: string): string => (typeof value === 'string' && NAME.test(value) ? value : bad(`${field} must be a slot or bone name`));
|
|
35
|
+
const pair = (value: unknown, field: string): [number, number] => {
|
|
36
|
+
if (!Array.isArray(value) || value.length !== 2 || !value.every(n => typeof n === 'number' && Number.isFinite(n) && Math.abs(n) <= 1e4)) bad(`${field} must be [x, y], finite`);
|
|
37
|
+
const [x, y] = value as [number, number];
|
|
38
|
+
return [x, y];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function parseReading(value: unknown, field: string): ReadingRequest {
|
|
42
|
+
const v = record(value, field);
|
|
43
|
+
switch (v.kind) {
|
|
44
|
+
case 'folds': case 'overlaps': exactly(v, ['kind'], field); return { kind: v.kind };
|
|
45
|
+
case 'pass-through': exactly(v, ['kind', 'cloth', 'leg'], field); return { kind: 'pass-through', cloth: name(v.cloth, `${field}.cloth`), leg: name(v.leg, `${field}.leg`) };
|
|
46
|
+
case 'seam': exactly(v, ['kind', 'a', 'b'], field); return { kind: 'seam', a: name(v.a, `${field}.a`), b: name(v.b, `${field}.b`) };
|
|
47
|
+
case 'angle-range': exactly(v, ['kind', 'bone', 'relative'], field); return { kind: 'angle-range', bone: name(v.bone, `${field}.bone`), relative: name(v.relative, `${field}.relative`) };
|
|
48
|
+
default: return bad(`${field}.kind must be one of ${READING_KINDS.join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** `{animation, readings, environment}` only: names and numbers, never a path or a flag. */
|
|
52
|
+
export function parseReadingsRequest(input: unknown): ReadingsRequest {
|
|
53
|
+
const v = record(input, 'readings request');
|
|
54
|
+
exactly(v, ['animation', 'readings', 'environment'], 'readings request');
|
|
55
|
+
const animation = name(v.animation, 'animation');
|
|
56
|
+
if (!Array.isArray(v.readings) || v.readings.length < 1 || v.readings.length > MAX_READINGS) bad(`readings must hold 1–${MAX_READINGS} entries`);
|
|
57
|
+
const readings = (v.readings as unknown[]).map((r, i) => parseReading(r, `readings[${i}]`));
|
|
58
|
+
const labels = readings.map(readingLabel);
|
|
59
|
+
if (new Set(labels).size !== labels.length) bad('readings: one entry named twice');
|
|
60
|
+
let environment: ReadingEnvironment | null = null;
|
|
61
|
+
if (v.environment !== null) { const e = record(v.environment, 'environment'); exactly(e, ['wind', 'gravity'], 'environment'); environment = { wind: pair(e.wind, 'environment.wind'), gravity: pair(e.gravity, 'environment.gravity') }; }
|
|
62
|
+
return { animation, readings, environment };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The reading as the worst-frame line names it: `folds`, `seam topwear:bottomwear`, `angle-range head:chest`. */
|
|
66
|
+
export function readingLabel(r: ReadingRequest): string {
|
|
67
|
+
switch (r.kind) {
|
|
68
|
+
case 'folds': case 'overlaps': return r.kind;
|
|
69
|
+
case 'pass-through': return `pass-through ${r.cloth}:${r.leg}`;
|
|
70
|
+
case 'seam': return `seam ${r.a}:${r.b}`;
|
|
71
|
+
case 'angle-range': return `angle-range ${r.bone}:${r.relative}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The flag as `rigc render --help` states it. */
|
|
75
|
+
export function readingFlag(r: ReadingRequest): string[] {
|
|
76
|
+
switch (r.kind) {
|
|
77
|
+
case 'folds': return ['--folds'];
|
|
78
|
+
case 'overlaps': return ['--overlaps'];
|
|
79
|
+
case 'pass-through': return ['--pass-through', `${r.cloth}:${r.leg}`];
|
|
80
|
+
case 'seam': return ['--seam', `${r.a}:${r.b}`];
|
|
81
|
+
case 'angle-range': return ['--angle-range', `${r.bone}:${r.relative}`];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The file each reading writes into the set's directory, or null (`--angle-range` prints its line and writes nothing). */
|
|
85
|
+
export const READING_FILE: Record<ReadingKind, string | null> = { folds: 'folds.json', 'pass-through': 'pass-through.json', seam: 'seam.json', overlaps: 'overlaps.json', 'angle-range': null };
|
|
86
|
+
export interface RenderPaths { candidate: string; out: string; environment: string | null }
|
|
87
|
+
/** One `rigc render` per reading: the candidate build, the one animation, the default 12 fps, the server's scratch `--out`. */
|
|
88
|
+
export function renderArgs(r: ReadingRequest, animation: string, paths: RenderPaths): string[] {
|
|
89
|
+
return ['render', '--candidate', paths.candidate, '--animation', animation, ...(paths.environment ? ['--environment', paths.environment] : []), ...readingFlag(r), '--out', paths.out];
|
|
90
|
+
}
|
|
91
|
+
/** The schedule file `--environment` reads: the world as it stands, from t = 0. */
|
|
92
|
+
export const environmentSchedule = (e: ReadingEnvironment) => ({ spec: 'rigc-environment/1', steps: [{ t: 0, wind: e.wind, gravity: e.gravity }] });
|
|
93
|
+
/** Whether this rigc's `render --help` lists `--environment` (the schedule is passed only then). */
|
|
94
|
+
export const helpHasEnvironment = (lines: readonly string[]) => lines.some(l => /^\s*--environment\s/.test(l));
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* rigc's refusal, verbatim: its first `rigc: …` line that is not a write notice (an unknown slot or bone, a malformed
|
|
98
|
+
* list, a pair named twice — rigc names them), else a named `CODE:` / `FAIL` line, else the exit.
|
|
99
|
+
*/
|
|
100
|
+
export function readingRefusal(lines: readonly string[], exitCode: number): string {
|
|
101
|
+
for (const line of lines) if (/^rigc: /.test(line) && !/^rigc: wrote /.test(line)) return line.trimEnd();
|
|
102
|
+
return firstRefusal(lines) ?? `rigc render exited ${exitCode} without a named refusal`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ReadingWorst { frame: number; figure: string; line: string }
|
|
106
|
+
export interface ReadingItem { pair: string; frames: number[]; worst: ReadingWorst | null }
|
|
107
|
+
export type ReadingStatus = 'read' | 'refused' | 'failed';
|
|
108
|
+
export interface ReadingResult {
|
|
109
|
+
reading: ReadingRequest; label: string; status: ReadingStatus;
|
|
110
|
+
/** The set's sampling rate from frames.json; a frame's time is frame / fps (rigc's own `time` fields agree). */
|
|
111
|
+
fps: number | null;
|
|
112
|
+
/** Every frame the reading's JSON lists (union over its pairs or meshes), ascending. */
|
|
113
|
+
frames: number[];
|
|
114
|
+
/** Where the frames come from: the reading's JSON, or (angle-range) its printed line. */
|
|
115
|
+
source: 'json' | 'line' | null;
|
|
116
|
+
worst: ReadingWorst | null; items: ReadingItem[];
|
|
117
|
+
/** The reading's own printed lines (rigc's ` .. <tag> …` rows for it), tag column removed. */
|
|
118
|
+
lines: string[];
|
|
119
|
+
refusal: string | null; exitCode: number | null; wallMs: number | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const TAG: Record<ReadingKind, string> = { folds: 'folds', 'pass-through': 'pass', seam: 'seam', overlaps: 'overlap', 'angle-range': 'angle' };
|
|
123
|
+
/** The reading's printed rows with rigc's ` .. <tag> ` column removed. */
|
|
124
|
+
export function readingLines(kind: ReadingKind, lines: readonly string[]): string[] {
|
|
125
|
+
const tag = new RegExp(`^\\s*\\.\\.\\s+${TAG[kind]}\\s+(.*)$`);
|
|
126
|
+
return lines.flatMap(l => { const m = tag.exec(l); return m ? [m[1]!.trimEnd()] : []; });
|
|
127
|
+
}
|
|
128
|
+
const sorted = (frames: Iterable<number>) => [...new Set(frames)].sort((a, b) => a - b);
|
|
129
|
+
const obj = (v: unknown): Record<string, unknown> => (v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {});
|
|
130
|
+
const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
|
|
131
|
+
const int = (v: unknown, field: string): number => { if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0) throw new ReadingsError('READINGS_JSON_UNREAD', `${field} is not a frame index`); return v; };
|
|
132
|
+
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : NaN);
|
|
133
|
+
const q = (s: string) => JSON.stringify(s);
|
|
134
|
+
const find = (rows: readonly string[], ...parts: string[]) => rows.find(l => parts.every(p => l.includes(p))) ?? '';
|
|
135
|
+
|
|
136
|
+
/** Frames and the worst frame of one reading, from its JSON (or, for angle-range, its printed line). */
|
|
137
|
+
export function readingFrames(r: ReadingRequest, json: unknown, rows: readonly string[]): { frames: number[]; worst: ReadingWorst | null; items: ReadingItem[]; source: 'json' | 'line' } {
|
|
138
|
+
const doc = obj(json);
|
|
139
|
+
switch (r.kind) {
|
|
140
|
+
case 'folds': {
|
|
141
|
+
const items = list(doc.meshes).map(obj).filter(m => list(m.frames).length).map((m): ReadingItem & { count: number } => {
|
|
142
|
+
const frames = list(m.frames).map((f, i) => int(obj(f).index, `folds.meshes[].frames[${i}].index`)), worst = obj(m.worst), count = list(worst.triangles).length;
|
|
143
|
+
const slot = String(m.slot), attachment = String(m.attachment);
|
|
144
|
+
return { pair: attachment === slot ? slot : `${slot}/${attachment}`, frames, count, worst: { frame: int(worst.index, 'folds worst.index'), figure: `${count} of ${num(m.triangleCount)} triangle(s) folded`, line: find(rows, `mesh ${q(attachment)} (slot ${q(slot)})`) } };
|
|
145
|
+
});
|
|
146
|
+
const top = items.reduce<(typeof items)[number] | null>((best, i) => (!best || i.count > best.count ? i : best), null);
|
|
147
|
+
return { frames: sorted(items.flatMap(i => i.frames)), worst: top ? { ...top.worst!, figure: `${top.worst!.figure} (mesh ${top.pair})` } : null, items: items.map(({ count: _, ...i }) => i), source: 'json' };
|
|
148
|
+
}
|
|
149
|
+
case 'pass-through': {
|
|
150
|
+
const p = obj(list(doc.pairs).map(obj).find(x => x.cloth === r.cloth && x.leg === r.leg));
|
|
151
|
+
const frames = list(p.frames).map((f, i) => int(obj(f).index, `pass-through.frames[${i}].index`)), d = obj(p.deepest), line = find(rows, `cloth ${q(r.cloth)} over leg ${q(r.leg)} passes through on`);
|
|
152
|
+
const worst = frames.length ? { frame: int(d.index, 'pass-through deepest.index'), figure: `${num(d.depth).toFixed(2)} units (${num(d.cells)} step(s)) on its ${String(d.side)} side`, line } : null;
|
|
153
|
+
return { frames: sorted(frames), worst, items: [{ pair: `${r.cloth}:${r.leg}`, frames: sorted(frames), worst }], source: 'json' };
|
|
154
|
+
}
|
|
155
|
+
case 'seam': {
|
|
156
|
+
const p = obj(list(doc.pairs).map(obj).find(x => x.a === r.a && x.b === r.b));
|
|
157
|
+
const frames = list(p.opens).map((f, i) => int(f, `seam.opens[${i}]`)), line = find(rows, `seam ${q(r.a)} | ${q(r.b)}`);
|
|
158
|
+
const kinds = (['gap', 'ghost'] as const).map(k => ({ k, w: obj(obj(p[k]).worst) })).filter(x => typeof x.w.index === 'number');
|
|
159
|
+
const top = kinds.reduce<(typeof kinds)[number] | null>((best, x) => (!best || num(x.w.px) > num(best.w.px) ? x : best), null);
|
|
160
|
+
const worst = frames.length && top ? { frame: int(top.w.index, `seam ${top.k}.worst.index`), figure: `${top.k} ${num(top.w.px)} px (${num(top.w.area).toFixed(2)} units²)`, line } : null;
|
|
161
|
+
return { frames: sorted(frames), worst, items: [{ pair: `${r.a}:${r.b}`, frames: sorted(frames), worst }], source: 'json' };
|
|
162
|
+
}
|
|
163
|
+
case 'overlaps': {
|
|
164
|
+
const items = list(doc.pairs).map(obj).filter(x => x.varies === true).map((x): ReadingItem & { px: number } => {
|
|
165
|
+
const frames = list(x.runs).flatMap((run, i) => { const [a, b] = list(run).map(v => int(v, `overlaps.runs[${i}]`)); const out: number[] = []; for (let f = a!; f <= b!; f++) out.push(f); return out; });
|
|
166
|
+
const most = obj(x.most), under = String(x.under), over = String(x.over);
|
|
167
|
+
return { pair: `${under}<${over}`, frames: sorted(frames), px: num(most.px), worst: { frame: int(most.index, 'overlaps most.index'), figure: `${num(most.px)} px of ${under} covered by ${over}`, line: find(rows, `OVERLAP ${q(under)} under ${q(over)}`) } };
|
|
168
|
+
});
|
|
169
|
+
const top = items.reduce<(typeof items)[number] | null>((best, i) => (!best || i.px > best.px ? i : best), null);
|
|
170
|
+
return { frames: sorted(items.flatMap(i => i.frames)), worst: top?.worst ?? null, items: items.map(({ px: _, ...i }) => i), source: 'json' };
|
|
171
|
+
}
|
|
172
|
+
case 'angle-range': {
|
|
173
|
+
// rigc writes no file for this reading; its line names the lowest and the highest angle and the frame of each.
|
|
174
|
+
const line = find(rows, `bone ${q(r.bone)} against ${q(r.relative)}`);
|
|
175
|
+
const m = /ranges (-?[\d.]+)° to (-?[\d.]+)° \(a span of ([\d.]+)°\).*lowest at frame (\d+) .*highest at frame (\d+) /.exec(line + ' ');
|
|
176
|
+
if (!m) throw new ReadingsError('READINGS_LINE_UNREAD', `no angle line for ${r.bone}:${r.relative}`);
|
|
177
|
+
const low = Number(m[1]), high = Number(m[2]), lowFrame = Number(m[4]), highFrame = Number(m[5]);
|
|
178
|
+
const frame = Math.abs(high) > Math.abs(low) ? highFrame : lowFrame;
|
|
179
|
+
const worst = { frame, figure: `span ${m[3]}° (${m[1]}° at F${lowFrame}, ${m[2]}° at F${highFrame})`, line };
|
|
180
|
+
const frames = sorted([lowFrame, highFrame]);
|
|
181
|
+
return { frames, worst, items: [{ pair: `${r.bone}:${r.relative}`, frames, worst }], source: 'line' };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One finished run of one reading: refused when rigc exited non-zero (its line verbatim), else its frames. */
|
|
187
|
+
export function readingResult(r: ReadingRequest, run: { exitCode: number; lines: string[]; wallMs: number }, json: unknown, fps: number | null): ReadingResult {
|
|
188
|
+
const base = { reading: r, label: readingLabel(r), fps, exitCode: run.exitCode, wallMs: run.wallMs, lines: readingLines(r.kind, run.lines) };
|
|
189
|
+
if (run.exitCode !== 0) return { ...base, status: 'refused', frames: [], source: null, worst: null, items: [], refusal: readingRefusal(run.lines, run.exitCode) };
|
|
190
|
+
try { return { ...base, status: 'read', ...readingFrames(r, json, base.lines), refusal: null }; }
|
|
191
|
+
catch (e) { return { ...base, status: 'failed', frames: [], source: null, worst: null, items: [], refusal: e instanceof Error ? e.message : String(e) }; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type ReadingBuild = 'inspected' | 'scratch';
|
|
195
|
+
/** `<build> · <reading> <pair>: worst at frame N — <figure> · <rigc line>`; no frame, or rigc's refusal, said as such. */
|
|
196
|
+
export function worstLine(build: ReadingBuild, result: ReadingResult, item?: ReadingItem): string {
|
|
197
|
+
const label = item && result.items.length > 1 ? `${result.reading.kind} ${item.pair}` : result.label, worst = item ? item.worst : result.worst;
|
|
198
|
+
if (result.status !== 'read') return `${build} · ${result.label}: ${result.refusal}`;
|
|
199
|
+
if (!worst) return `${build} · ${label}: no frame${result.lines.length ? ` · ${result.lines[0]}` : ''}`;
|
|
200
|
+
return `${build} · ${label}: worst at frame ${worst.frame} — ${worst.figure}${worst.line ? ` · ${worst.line}` : ''}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ReadingTick { build: ReadingBuild; label: string; frame: number; time: number | null }
|
|
204
|
+
/** The timeline ticks: one per frame each reading's JSON lists, per build. A frame's time is frame / fps. */
|
|
205
|
+
export function readingTicks(runs: readonly { build: ReadingBuild; results: readonly ReadingResult[] }[]): ReadingTick[] {
|
|
206
|
+
return runs.flatMap(run => run.results.filter(r => r.status === 'read').flatMap(r => r.frames.map(frame => ({ build: run.build, label: r.label, frame, time: r.fps ? frame / r.fps : null }))));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** rigc samples the last frame at frame / fps; the runtime's clip duration can round a hair below it. Within this, the tick is the clip's end. */
|
|
210
|
+
export const TICK_EDGE = 1e-4;
|
|
211
|
+
/** The ticks the scrub draws: the job's animation is the one drawn, each with a time inside the clip (the last sample kept). */
|
|
212
|
+
export function ticksOnClip(ticks: readonly ReadingTick[], jobAnimation: string, animation: string, duration: number): ReadingTick[] {
|
|
213
|
+
if (jobAnimation !== animation || !(duration > 0)) return [];
|
|
214
|
+
return ticks.filter(t => t.time !== null && t.time <= duration + TICK_EDGE);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `context.readings`: the pairs chosen and where they ran (the builds' identities), never the results' bytes. */
|
|
218
|
+
export interface ReadingsContext {
|
|
219
|
+
schema: typeof READINGS_SCHEMA; animation: string; requests: ReadingRequest[]; environment: ReadingEnvironment | null; environmentPassed: boolean;
|
|
220
|
+
builds: { inspected: string; scratch: string | null }; compiler: { package: string; version: string };
|
|
221
|
+
ticks: { inspected: number; scratch: number | null };
|
|
222
|
+
}
|
|
223
|
+
export function parseReadingsContext(value: unknown): ReadingsContext | null {
|
|
224
|
+
if (value === null) return null;
|
|
225
|
+
const v = record(value, 'context.readings');
|
|
226
|
+
if (v.schema !== READINGS_SCHEMA) bad(`context.readings.schema must be ${READINGS_SCHEMA}`);
|
|
227
|
+
const parsed = parseReadingsRequest({ animation: v.animation, readings: v.requests, environment: v.environment ?? null });
|
|
228
|
+
const builds = record(v.builds, 'context.readings.builds'), ticks = record(v.ticks, 'context.readings.ticks'), compiler = record(v.compiler, 'context.readings.compiler');
|
|
229
|
+
if (typeof builds.inspected !== 'string' || (builds.scratch !== null && typeof builds.scratch !== 'string')) bad('context.readings.builds');
|
|
230
|
+
if (typeof compiler.package !== 'string' || typeof compiler.version !== 'string') bad('context.readings.compiler');
|
|
231
|
+
const count = (n: unknown) => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0;
|
|
232
|
+
if (!count(ticks.inspected) || !(ticks.scratch === null || count(ticks.scratch))) bad('context.readings.ticks');
|
|
233
|
+
if (typeof v.environmentPassed !== 'boolean') bad('context.readings.environmentPassed');
|
|
234
|
+
return { schema: READINGS_SCHEMA, animation: parsed.animation, requests: parsed.readings, environment: parsed.environment, environmentPassed: v.environmentPassed as boolean, builds: { inspected: builds.inspected as string, scratch: builds.scratch as string | null }, compiler: { package: compiler.package as string, version: compiler.version as string }, ticks: { inspected: ticks.inspected as number, scratch: ticks.scratch as number | null } };
|
|
235
|
+
}
|