@rig-c/review 0.7.1 → 0.8.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 +27 -7
- package/docs/DESIGN.md +2 -1
- package/docs/REVIEW_PACKAGE.md +17 -7
- package/package.json +1 -1
- package/src/applied-context.ts +35 -7
- package/src/cli.ts +1 -1
- package/src/cloth-recipe.ts +199 -0
- package/src/compare-server.ts +1 -1
- package/src/environment-check.ts +149 -0
- package/src/environment-schedule.ts +183 -0
- package/src/feedback-export.ts +8 -1
- package/src/frame-clock.ts +30 -0
- package/src/front.ts +4 -1
- package/src/handoff.ts +20 -13
- package/src/open-protocol.ts +4 -3
- package/src/open.ts +1 -1
- package/src/readings-run.ts +2 -1
- package/src/readings.ts +12 -6
- package/src/rebuild.ts +198 -34
- package/src/retune-check.ts +100 -0
- package/src/server.ts +12 -2
- package/src/session.ts +3 -0
- package/web/cloth-panel.ts +168 -0
- package/web/compare.ts +7 -5
- package/web/environment-world.ts +42 -0
- package/web/index.html +1 -1
- package/web/label-placement.ts +46 -0
- package/web/main.ts +127 -17
- package/web/readings-panel.ts +1 -1
- package/web/rebuild-client.ts +14 -9
- package/web/rig-overlays.ts +26 -10
- package/web/styles.css +2 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloth recipe form (#111): the producer config's `cloth` block — one recipe per tag (`bottomwear`, `back hair`,
|
|
3
|
+
* `front hair`, whatever the installed reader accepts) — edited as config patch ops at `/cloth/<tag>/…`. An Apply runs
|
|
4
|
+
* the installed producer's `rig-parts propose --cloth <patched config> --keypoints <handoff keypoints> --write-config
|
|
5
|
+
* <scratch>/config.json`, which writes the config's own text with what the recipe drafts spliced in (rig-parts
|
|
6
|
+
* AUTHORING, *The config written*, issues #537 and #552), then builds that written config through the shared tail.
|
|
7
|
+
* Review never reads the producer's draft file and never merges a draft itself: the written config is the producer's
|
|
8
|
+
* one output review consumes. Pure: no IO.
|
|
9
|
+
*/
|
|
10
|
+
import { formatPointer, jsonDiff, parsePointer, type JsonValue, type PatchOp } from './config-patch.ts';
|
|
11
|
+
|
|
12
|
+
/** The first `@rig-c/parts` release whose `propose --cloth --write-config` splices bones, constraints and tracks entry by entry (its CHANGELOG: 1.49.1, parts#552). */
|
|
13
|
+
export const CLOTH_WRITE_SINCE = '1.49.1';
|
|
14
|
+
/** The config section a cloth patch edits. */
|
|
15
|
+
export const CLOTH_PATH = 'cloth';
|
|
16
|
+
/** Decision 1 (#111): without the declared keypoints the draft moves the body bones, so a cloth Apply is refused before anything runs. */
|
|
17
|
+
export const CLOTH_KEYPOINTS_UNDECLARED = 'CLOTH_KEYPOINTS_UNDECLARED: a cloth recipe Apply runs rig-parts propose, which places the body bones from the figure\'s keypoints; without them the draft moves the body bones the config states — declare the keypoint file with rig-review open <dir> --config <path> --keypoints <file> or the review.json "producer" block\'s "keypoints"';
|
|
18
|
+
/** A cloth patch holds only `/cloth` ops: what else the patch edits would be routed and gated differently. */
|
|
19
|
+
export const CLOTH_PATCH_MIXED = 'CLOTH_PATCH_MIXED: a cloth recipe patch holds /cloth ops only; apply the other edits separately';
|
|
20
|
+
/** propose reads the painting the parts were assembled from (`--source`). */
|
|
21
|
+
export const CLOTH_SOURCE_UNAVAILABLE = 'MODELING_INPUT_UNAVAILABLE: source painting — rig-parts propose reads the painting the parts were assembled from (--source), and the handoff declares none; declare it with rig-review open <dir> --config <path> --source <png> --full <dir> [--head <dir>] or the review.json "producer" block';
|
|
22
|
+
|
|
23
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
24
|
+
const under = (tokens: readonly string[], prefix: readonly string[]) => prefix.length <= tokens.length && prefix.every((t, i) => tokens[i] === t);
|
|
25
|
+
|
|
26
|
+
/** True when the op's pointer lies under `/cloth` (the whole block included). */
|
|
27
|
+
export const isClothOp = (op: Pick<PatchOp, 'path'>) => parsePointer(op.path)[0] === CLOTH_PATH;
|
|
28
|
+
/** `cloth` when every op is a cloth op, `mixed` when some are, else `none`. */
|
|
29
|
+
export function clothPatchKind(ops: readonly Pick<PatchOp, 'path'>[]): 'cloth' | 'mixed' | 'none' {
|
|
30
|
+
const n = ops.filter(isClothOp).length;
|
|
31
|
+
return n === 0 ? 'none' : n === ops.length ? 'cloth' : 'mixed';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The tags the installed reader accepts, read from its own source text (`export const CLOTH_TARGETS = [...]`), or null
|
|
36
|
+
* when the text does not state them in that form.
|
|
37
|
+
*/
|
|
38
|
+
export function readClothTargets(sourceText: string): string[] | null {
|
|
39
|
+
const m = /export const CLOTH_TARGETS\s*=\s*\[([^\]]*)\]/.exec(sourceText);
|
|
40
|
+
if (!m) return null;
|
|
41
|
+
const tags = [...m[1]!.matchAll(/'([^']+)'|"([^"]+)"/g)].map(x => x[1] ?? x[2]!);
|
|
42
|
+
return tags.length ? tags : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One recipe field the form edits: its pointer below `/cloth/<tag>` and how it is typed. The producer judges every value. */
|
|
46
|
+
export interface RecipeField { key: string; path: readonly string[]; kind: 'int' | 'number' | 'bool' | 'pair' | 'list' | 'names' | 'shape'; label: string; required?: boolean }
|
|
47
|
+
/** The reader's fields (rig-parts `checkCloth`, AUTHORING §3 `cloth` row), in its order. */
|
|
48
|
+
export const RECIPE_FIELDS: readonly RecipeField[] = [
|
|
49
|
+
{ key: 'chains', path: ['chains'], kind: 'int', label: 'chains' },
|
|
50
|
+
{ key: 'links', path: ['links'], kind: 'int', label: 'links', required: true },
|
|
51
|
+
{ key: 'split.at', path: ['split', 'at'], kind: 'int', label: 'split at' },
|
|
52
|
+
{ key: 'split.into', path: ['split', 'into'], kind: 'int', label: 'split into' },
|
|
53
|
+
{ key: 'split.hold_tip', path: ['split', 'hold_tip'], kind: 'bool', label: 'hold tip' },
|
|
54
|
+
{ key: 'length_ratio', path: ['length_ratio'], kind: 'number', label: 'length ratio' },
|
|
55
|
+
{ key: 'strength.trunk', path: ['strength', 'trunk'], kind: 'number', label: 'strength trunk' },
|
|
56
|
+
{ key: 'strength.branch', path: ['strength', 'branch'], kind: 'number', label: 'strength branch' },
|
|
57
|
+
{ key: 'strength.edge', path: ['strength', 'edge'], kind: 'number', label: 'strength edge' },
|
|
58
|
+
{ key: 'damping', path: ['damping'], kind: 'number', label: 'damping' },
|
|
59
|
+
{ key: 'alternate_damping', path: ['alternate_damping'], kind: 'number', label: 'alternate damping' },
|
|
60
|
+
{ key: 'wind', path: ['wind'], kind: 'pair', label: 'wind [root, tip]', required: true },
|
|
61
|
+
{ key: 'gravity', path: ['gravity'], kind: 'pair', label: 'gravity [root, tip]', required: true },
|
|
62
|
+
{ key: 'translate', path: ['translate'], kind: 'number', label: 'translate' },
|
|
63
|
+
{ key: 'swing.scales', path: ['swing', 'scales'], kind: 'list', label: 'swing scales' },
|
|
64
|
+
{ key: 'part', path: ['part'], kind: 'names', label: 'part' },
|
|
65
|
+
{ key: 'parts', path: ['parts'], kind: 'names', label: 'parts' },
|
|
66
|
+
{ key: 'influence.across', path: ['influence', 'across'], kind: 'bool', label: 'influence across' },
|
|
67
|
+
{ key: 'influence.max', path: ['influence', 'max'], kind: 'int', label: 'influence max' },
|
|
68
|
+
{ key: 'influence.shape', path: ['influence', 'shape'], kind: 'shape', label: 'influence shape' },
|
|
69
|
+
{ key: 'influence.radius', path: ['influence', 'radius'], kind: 'number', label: 'influence radius' },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export class ClothFieldError extends Error { constructor(readonly field: string, detail: string) { super(`CLOTH_FIELD_INVALID ${field}: ${detail}`); } }
|
|
73
|
+
|
|
74
|
+
/** A field's text as the form shows it, from the recipe as written ('' when absent). */
|
|
75
|
+
export function fieldText(recipe: unknown, field: RecipeField): string {
|
|
76
|
+
let v: unknown = recipe;
|
|
77
|
+
for (const k of field.path) v = isObject(v) ? v[k] : undefined;
|
|
78
|
+
if (v === undefined) return '';
|
|
79
|
+
if (field.kind === 'pair' || field.kind === 'list') return Array.isArray(v) ? v.join(', ') : JSON.stringify(v);
|
|
80
|
+
if (field.kind === 'names') return Array.isArray(v) ? v.join(', ') : String(v);
|
|
81
|
+
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A field's typed value from the form's text; `undefined` for an empty field. Only the type is checked here. */
|
|
85
|
+
export function fieldValue(field: RecipeField, text: string): JsonValue | undefined {
|
|
86
|
+
const raw = text.trim();
|
|
87
|
+
if (!raw) return undefined;
|
|
88
|
+
const num = (s: string) => { const n = Number(s.trim()); if (!s.trim() || !Number.isFinite(n)) throw new ClothFieldError(field.key, `${JSON.stringify(s.trim())} is not a number`); return n; };
|
|
89
|
+
switch (field.kind) {
|
|
90
|
+
case 'int': { const n = num(raw); if (!Number.isInteger(n)) throw new ClothFieldError(field.key, `${raw} is not a whole number`); return n; }
|
|
91
|
+
case 'number': return num(raw);
|
|
92
|
+
case 'bool': if (raw === 'true' || raw === 'false') return raw === 'true'; throw new ClothFieldError(field.key, 'true or false');
|
|
93
|
+
case 'shape': return raw;
|
|
94
|
+
case 'pair': { const parts = raw.split(',').map(num); if (parts.length !== 2) throw new ClothFieldError(field.key, 'two numbers, root and tip'); return parts; }
|
|
95
|
+
case 'list': return raw.split(',').map(num);
|
|
96
|
+
case 'names': { const names = raw.split(',').map(s => s.trim()).filter(Boolean); return field.key === 'part' && names.length === 1 && !raw.includes(',') ? names[0]! : names; }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The recipe object the form states: every non-empty field set at its path; `{}` when every field is empty. */
|
|
101
|
+
export function recipeFromFields(values: Readonly<Record<string, string>>): Record<string, JsonValue> {
|
|
102
|
+
const out: Record<string, JsonValue> = {};
|
|
103
|
+
for (const f of RECIPE_FIELDS) {
|
|
104
|
+
const v = fieldValue(f, values[f.key] ?? '');
|
|
105
|
+
if (v === undefined) continue;
|
|
106
|
+
let holder = out;
|
|
107
|
+
for (const k of f.path.slice(0, -1)) { if (!isObject(holder[k])) holder[k] = {}; holder = holder[k] as Record<string, JsonValue>; }
|
|
108
|
+
holder[f.path.at(-1)!] = v;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
114
|
+
/**
|
|
115
|
+
* The ops that turn the config's recipe for `tag` into `next` (null: the tag's recipe removed). Keys the form does not
|
|
116
|
+
* know (an annotation, a record) are kept: only form keys are replaced, added or removed. A tag the config does not
|
|
117
|
+
* state is added whole; a config without a `cloth` block gets one.
|
|
118
|
+
*/
|
|
119
|
+
export function recipeOps(config: unknown, tag: string, next: Record<string, JsonValue> | null): PatchOp[] {
|
|
120
|
+
const cloth = isObject(config) ? config.cloth : undefined;
|
|
121
|
+
const current = isObject(cloth) ? cloth[tag] : undefined;
|
|
122
|
+
const at = (...tokens: string[]) => formatPointer([CLOTH_PATH, tag, ...tokens]);
|
|
123
|
+
if (next === null) return current === undefined ? [] : [{ op: 'remove', path: at() }];
|
|
124
|
+
if (!isObject(cloth)) return [{ op: 'add', path: formatPointer([CLOTH_PATH]), value: { [tag]: next } }];
|
|
125
|
+
if (!isObject(current)) return [{ op: current === undefined ? 'add' : 'replace', path: at(), value: next }];
|
|
126
|
+
const known = new Set(RECIPE_FIELDS.map(f => f.path[0]!));
|
|
127
|
+
const ops: PatchOp[] = [];
|
|
128
|
+
for (const key of [...new Set([...Object.keys(current), ...Object.keys(next)])]) {
|
|
129
|
+
if (!known.has(key)) continue;
|
|
130
|
+
const was = current[key], now = next[key];
|
|
131
|
+
if (same(was, now)) continue;
|
|
132
|
+
if (now === undefined) { ops.push({ op: 'remove', path: at(key) }); continue; }
|
|
133
|
+
if (was === undefined) { ops.push({ op: 'add', path: at(key), value: now }); continue; }
|
|
134
|
+
// An object field (split, strength, swing, influence) is edited key by key, so its other keys keep their place.
|
|
135
|
+
if (isObject(was) && isObject(now)) {
|
|
136
|
+
for (const sub of [...new Set([...Object.keys(was), ...Object.keys(now)])]) {
|
|
137
|
+
if (same(was[sub], now[sub])) continue;
|
|
138
|
+
const known2 = RECIPE_FIELDS.some(f => f.path[0] === key && f.path[1] === sub);
|
|
139
|
+
if (!known2) continue;
|
|
140
|
+
if (now[sub] === undefined) ops.push({ op: 'remove', path: at(key, sub) });
|
|
141
|
+
else ops.push({ op: was[sub] === undefined ? 'add' : 'replace', path: at(key, sub), value: now[sub] as JsonValue });
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
ops.push({ op: 'replace', path: at(key), value: now });
|
|
146
|
+
}
|
|
147
|
+
return ops;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One chain of links as the config states it: its name and its points then its tip, in rig px. */
|
|
151
|
+
export interface ClothChain { chain: string; points: [number, number][] }
|
|
152
|
+
/** Per tag: the parts the recipe drafts on (by See-through tag, plus any part the recipe names) and their chains' links. */
|
|
153
|
+
export interface ClothTagView { tag: string; parts: string[]; chains: ClothChain[] }
|
|
154
|
+
const point = (p: unknown): [number, number] | null => Array.isArray(p) && p.length === 2 && p.every(n => typeof n === 'number' && Number.isFinite(n)) ? [p[0] as number, p[1] as number] : null;
|
|
155
|
+
/**
|
|
156
|
+
* The link preview's data (#111): for each tag, the parts tagged with it in `parts.json` (`from` "<run>:<tag>") and the
|
|
157
|
+
* parts its recipe names, and every chain those parts' `meshes.<part>.segments` name, with the chain's `points` and
|
|
158
|
+
* `tip` from `bones` as written. Read off whatever config is given — the inspected one, or the config the producer wrote.
|
|
159
|
+
*/
|
|
160
|
+
export function clothLinks(config: unknown, partsJson: unknown, tags: readonly string[]): ClothTagView[] {
|
|
161
|
+
const cfg = isObject(config) ? config : {};
|
|
162
|
+
const parts = isObject(partsJson) && Array.isArray(partsJson.parts) ? partsJson.parts : [];
|
|
163
|
+
const recipes = isObject(cfg.cloth) ? cfg.cloth : {};
|
|
164
|
+
const meshes = isObject(cfg.meshes) ? cfg.meshes : {};
|
|
165
|
+
const bones = Array.isArray(cfg.bones) ? cfg.bones : [];
|
|
166
|
+
return tags.map(tag => {
|
|
167
|
+
const names = new Set<string>();
|
|
168
|
+
for (const p of parts) if (isObject(p) && typeof p.name === 'string' && typeof p.from === 'string' && p.from.slice(p.from.indexOf(':') + 1) === tag) names.add(p.name);
|
|
169
|
+
const recipe = recipes[tag];
|
|
170
|
+
if (isObject(recipe)) for (const key of ['part', 'parts']) { const v = recipe[key]; for (const n of Array.isArray(v) ? v : [v]) if (typeof n === 'string' && n) names.add(n); }
|
|
171
|
+
const chains: ClothChain[] = [], seen = new Set<string>();
|
|
172
|
+
for (const part of names) {
|
|
173
|
+
const entry = meshes[part], segments = isObject(entry) && Array.isArray(entry.segments) ? entry.segments : [];
|
|
174
|
+
for (const s of segments) {
|
|
175
|
+
if (typeof s !== 'string' || seen.has(s)) continue;
|
|
176
|
+
const bone = bones.find(b => isObject(b) && b.chain === s);
|
|
177
|
+
if (!isObject(bone) || !Array.isArray(bone.points)) continue;
|
|
178
|
+
const pts = [...bone.points.map(point), point(bone.tip)].filter((x): x is [number, number] => !!x);
|
|
179
|
+
if (pts.length >= 2) { chains.push({ chain: s, points: pts }); seen.add(s); }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { tag, parts: [...names].sort(), chains };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The sections `propose --write-config` splices (rig-parts `PROPOSAL_CONFIG_SECTIONS`, and of `motion` only `tracks`,
|
|
188
|
+
* AUTHORING *The config written*): a written config may differ from its input only at or under these.
|
|
189
|
+
*/
|
|
190
|
+
export const DRAFTED_PREFIXES: readonly (readonly string[])[] = [['bones'], ['constraints'], ['meshes'], ['regions'], ['limbs'], ['motion', 'tracks'], ['turn'], ['proposed_rig_scale']];
|
|
191
|
+
export const MAX_WRITTEN_PATHS = 200;
|
|
192
|
+
/** Bar 1: every JSON Pointer at which the written config differs from its input, and those outside the drafted sections. */
|
|
193
|
+
export interface WrittenDiff { count: number; paths: string[]; outside: string[]; sections: string[] }
|
|
194
|
+
export function writtenDiff(input: unknown, written: unknown): WrittenDiff {
|
|
195
|
+
const paths = jsonDiff(input, written);
|
|
196
|
+
const outside = paths.filter(p => !DRAFTED_PREFIXES.some(prefix => under(parsePointer(p), prefix)));
|
|
197
|
+
const sections = [...new Set(paths.map(p => { const t = parsePointer(p); return t[0] === 'motion' ? t.slice(0, 2).join('.') : t[0] === 'meshes' || t[0] === 'regions' ? t.slice(0, 2).join('.') : t[0] ?? ''; }))];
|
|
198
|
+
return { count: paths.length, paths: paths.slice(0, MAX_WRITTEN_PATHS), outside: outside.slice(0, MAX_WRITTEN_PATHS), sections };
|
|
199
|
+
}
|
package/src/compare-server.ts
CHANGED
|
@@ -36,7 +36,7 @@ export async function startCompareServer(p:ComparePackage,options:{port?:number;
|
|
|
36
36
|
const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try{
|
|
37
37
|
const url=new URL(req.url),origin=`http://127.0.0.1:${port}`;
|
|
38
38
|
if(url.origin!==origin)return json({error:'Loopback host required'},403);
|
|
39
|
-
const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
|
|
39
|
+
const refused=openNonceGate(req,options.open)??accessGate(req,options.capability,!!control);if(refused)return refused;
|
|
40
40
|
const controlled=await control?.(req,origin);if(controlled)return controlled;
|
|
41
41
|
options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await assertCompareUnchanged(p);}catch{return staleOpenStatus(options.open!);}}return gate;}
|
|
42
42
|
if(req.method==='PUT'&&url.pathname==='/api/compare/session'){
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /api/environment-check` (issue #112): "Check under this world". The Environment block's exported
|
|
3
|
+
* `rigc-environment/1` schedule is written by review into `<state>/environment-check/<package hash>/<id>/environment.json`,
|
|
4
|
+
* then `rig-parts check --rig <rig> --out out --parts <handoff parts> --page-edges free --pack-shape polygon
|
|
5
|
+
* --environment environment.json` runs with the installed producer (@rig-c/parts 1.48.0 or later, parts#538), on the
|
|
6
|
+
* inspected rig spec (the handoff's, read only) or on the session's latest scratch revision. The pack flags are the
|
|
7
|
+
* inspected build's own background check's (`src/baseline.ts`), so the default-world line beside it differs only by the
|
|
8
|
+
* world. The job keeps what check said: its `environment:` line, check.json's `environment` record (path, sha256, steps),
|
|
9
|
+
* the ANIMATIONS_DO_NOT_FOLD and TEXTURE_STRETCH lines, the summary, and a `CHECK_ENVIRONMENT` refusal verbatim. The
|
|
10
|
+
* check's output directory is removed after the run; the result is stored so the same schedule on the same build is read
|
|
11
|
+
* back. The client sends `{build, schedule}` only — never a path or a flag. Nothing is written into the package.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
15
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
16
|
+
import type { ReviewPackage } from './manifest.ts';
|
|
17
|
+
import { firstRefusal, modelingProducerTooOld, resolveProducer, runTool, versionAtLeast } from './producer.ts';
|
|
18
|
+
import { producerInputs, RebuildError } from './rebuild.ts';
|
|
19
|
+
import { checkSummary, type CheckSummary } from './applied-context.ts';
|
|
20
|
+
import { barLine, READOUT_BARS } from './physics-tuning.ts';
|
|
21
|
+
import { CHECK_ENVIRONMENT_SINCE, parseSchedule, ScheduleError, scheduleText } from './environment-schedule.ts';
|
|
22
|
+
|
|
23
|
+
export const ENVIRONMENT_CHECK_SCHEMA = 'rig-review-environment-check/1';
|
|
24
|
+
export const ENVIRONMENT_CHECK_LIMIT = 64 * 1024;
|
|
25
|
+
const MAX_LINES = 4000;
|
|
26
|
+
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
|
|
27
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
28
|
+
|
|
29
|
+
export class EnvironmentCheckError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
|
|
30
|
+
export type EnvironmentCheckBuild = 'inspected' | 'scratch';
|
|
31
|
+
export interface EnvironmentCheckRequest { build: EnvironmentCheckBuild; text: string }
|
|
32
|
+
/** `{build, schedule}` only: the schedule as data (re-serialized here, so its hash is the exported bytes'), never a path. */
|
|
33
|
+
export function parseEnvironmentCheckRequest(input: unknown): EnvironmentCheckRequest {
|
|
34
|
+
if (!isRecord(input) || Object.keys(input).sort().join(',') !== 'build,schedule') throw new EnvironmentCheckError('ENVIRONMENT_CHECK_REQUEST_INVALID', 'expected exactly {build, schedule}');
|
|
35
|
+
if (input.build !== 'inspected' && input.build !== 'scratch') throw new EnvironmentCheckError('ENVIRONMENT_CHECK_REQUEST_INVALID', 'build must be inspected or scratch');
|
|
36
|
+
try { return { build: input.build, text: scheduleText(parseSchedule(input.schedule)) }; }
|
|
37
|
+
catch (e) { if (e instanceof ScheduleError) throw new EnvironmentCheckError('ENVIRONMENT_CHECK_REQUEST_INVALID', e.message); throw e; }
|
|
38
|
+
}
|
|
39
|
+
/** check's argv as `rig-parts check --help` states it (1.48.0); every path the server's. */
|
|
40
|
+
export const environmentCheckArgs = (r: { rig: string; parts: string; out: string; environment: string }): string[] =>
|
|
41
|
+
['check', '--rig', r.rig, '--out', r.out, '--parts', r.parts, '--page-edges', 'free', '--pack-shape', 'polygon', '--environment', r.environment];
|
|
42
|
+
|
|
43
|
+
/** What check.json records under `--environment` (parts#538): the path as given, the bytes' sha256, the step count. */
|
|
44
|
+
export interface CheckEnvironmentRecord { path: string; sha256: string; steps: number }
|
|
45
|
+
export function checkEnvironmentRecord(checkJson: unknown): CheckEnvironmentRecord | null {
|
|
46
|
+
const e = isRecord(checkJson) && isRecord(checkJson.environment) ? checkJson.environment : null;
|
|
47
|
+
return e && typeof e.path === 'string' && typeof e.sha256 === 'string' && Number.isSafeInteger(e.steps) ? { path: e.path, sha256: e.sha256, steps: e.steps as number } : null;
|
|
48
|
+
}
|
|
49
|
+
/** check's `environment: <path>, rigc-environment/1, <n> step(s), sha256 <16 hex> — …` line, verbatim (trimmed). */
|
|
50
|
+
export const environmentLineOf = (lines: readonly string[]) => lines.map(l => l.trim()).find(l => /^environment: \S.*rigc-environment\/1, \d+ step\(s\), sha256 [0-9a-f]{16}/.test(l)) ?? null;
|
|
51
|
+
|
|
52
|
+
export interface EnvironmentCheckJob {
|
|
53
|
+
schema: typeof ENVIRONMENT_CHECK_SCHEMA; id: string; status: 'running' | 'checked' | 'refused' | 'failed';
|
|
54
|
+
build: EnvironmentCheckBuild; buildId: string | null;
|
|
55
|
+
/** The schedule as written: its sha256, byte length, step count. */
|
|
56
|
+
schedule: { sha256: string; size: number; steps: number };
|
|
57
|
+
producer: { package: string; version: string };
|
|
58
|
+
exitCode: number | null; wallMs: number | null;
|
|
59
|
+
check: CheckSummary | null;
|
|
60
|
+
/** check.json's `environment`; its sha256 must be the schedule's (a mismatch fails the job). */
|
|
61
|
+
environment: CheckEnvironmentRecord | null;
|
|
62
|
+
environmentLine: string | null;
|
|
63
|
+
/** The readout bars' lines (ANIMATIONS_DO_NOT_FOLD, TEXTURE_STRETCH) as check printed them. */
|
|
64
|
+
bars: { name: string; line: string }[];
|
|
65
|
+
/** A named refusal verbatim (`FAIL CHECK_ENVIRONMENT: …`), or why the run failed. */
|
|
66
|
+
error: string | null;
|
|
67
|
+
lines: string[];
|
|
68
|
+
}
|
|
69
|
+
export interface EnvironmentCheckOptions {
|
|
70
|
+
stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>;
|
|
71
|
+
latest: () => { id: string; spec: string } | null;
|
|
72
|
+
}
|
|
73
|
+
export const environmentCheckRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'environment-check', sourceHash);
|
|
74
|
+
|
|
75
|
+
/** One check at a time per review instance; finished jobs stay readable by id, and a stored result is read back. */
|
|
76
|
+
export function createEnvironmentCheck(p: ReviewPackage, options: EnvironmentCheckOptions) {
|
|
77
|
+
const jobs = new Map<string, EnvironmentCheckJob>();
|
|
78
|
+
let running: string | null = null;
|
|
79
|
+
const snapshot = (job: EnvironmentCheckJob): EnvironmentCheckJob => JSON.parse(JSON.stringify(job)) as EnvironmentCheckJob;
|
|
80
|
+
function assertStateOutside(): void {
|
|
81
|
+
for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
|
|
82
|
+
const inside = relative(root, options.stateDir);
|
|
83
|
+
if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new EnvironmentCheckError('ENVIRONMENT_CHECK_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function execute(job: EnvironmentCheckJob, dir: string, bin: string, rig: string, parts: string, text: string): Promise<void> {
|
|
88
|
+
try {
|
|
89
|
+
await rm(dir, { recursive: true, force: true });
|
|
90
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
91
|
+
await writeFile(join(dir, 'environment.json'), text, { mode: 0o600 });
|
|
92
|
+
const run = await runTool(bin, environmentCheckArgs({ rig, parts, out: 'out', environment: 'environment.json' }), { cwd: dir, onLine: line => { if (job.lines.length < MAX_LINES) job.lines.push(line); } });
|
|
93
|
+
let checkJson: unknown = null;
|
|
94
|
+
try { checkJson = JSON.parse(await readFile(join(dir, 'out', 'check.json'), 'utf8')); } catch { /* refused before check.json; the lines say why */ }
|
|
95
|
+
const refusal = firstRefusal(run.lines);
|
|
96
|
+
Object.assign(job, { exitCode: run.exitCode, wallMs: run.wallMs, environmentLine: environmentLineOf(run.lines), environment: checkEnvironmentRecord(checkJson) });
|
|
97
|
+
try { await options.unchanged(); } catch { throw new EnvironmentCheckError('ENVIRONMENT_CHECK_INPUTS_CHANGED', 'the package or its producer handoff changed during the check'); }
|
|
98
|
+
if (checkJson === null) { job.status = 'refused'; job.error = refusal ?? `rig-parts check exited ${run.exitCode} without a named refusal`; }
|
|
99
|
+
else {
|
|
100
|
+
job.check = checkSummary(checkJson, run.exitCode, run.lines, refusal);
|
|
101
|
+
job.bars = READOUT_BARS.map(name => ({ name, line: barLine(name, run.lines, job.check) }));
|
|
102
|
+
if (!job.environment || job.environment.sha256 !== job.schedule.sha256) throw new EnvironmentCheckError('ENVIRONMENT_CHECK_UNRECORDED', `check.json's environment ${job.environment ? `sha256 ${job.environment.sha256}` : 'is absent'}; the schedule written was sha256 ${job.schedule.sha256}`);
|
|
103
|
+
job.status = 'checked';
|
|
104
|
+
}
|
|
105
|
+
await writeFile(join(dir, 'result.json'), JSON.stringify({ ...job, lines: job.lines.slice(-MAX_LINES) }, null, 1) + '\n', { mode: 0o600 });
|
|
106
|
+
} catch (error) { job.status = 'failed'; job.error = error instanceof Error ? error.message : String(error); }
|
|
107
|
+
finally { await rm(join(dir, 'out'), { recursive: true, force: true }).catch(() => {}); if (job.status === 'failed') await rm(dir, { recursive: true, force: true }).catch(() => {}); }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Refusals before anything runs; a malformed request is ENVIRONMENT_CHECK_REQUEST_INVALID (400 at the route). */
|
|
111
|
+
async function start(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
112
|
+
const request = parseEnvironmentCheckRequest(input);
|
|
113
|
+
assertStateOutside();
|
|
114
|
+
await options.unchanged();
|
|
115
|
+
let inputs: Awaited<ReturnType<typeof producerInputs>>;
|
|
116
|
+
try { inputs = await producerInputs(p.handoff, p.handoffReason); } catch (e) { if (e instanceof RebuildError) return { status: 200, body: { status: 'refused', error: e.message } }; throw e; }
|
|
117
|
+
let rig = inputs.rig, buildId: string | null = null;
|
|
118
|
+
if (request.build === 'scratch') {
|
|
119
|
+
const latest = options.latest();
|
|
120
|
+
if (!latest) return { status: 200, body: { status: 'refused', error: 'ENVIRONMENT_CHECK_NO_SCRATCH: no scratch build in this session yet; Apply builds one, or check the inspected rig' } };
|
|
121
|
+
rig = latest.spec; buildId = latest.id;
|
|
122
|
+
}
|
|
123
|
+
const resolved = resolveProducer(options.starts);
|
|
124
|
+
if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error } };
|
|
125
|
+
const producer = { package: resolved.producer.package, version: resolved.producer.version };
|
|
126
|
+
if (!versionAtLeast(producer.version, CHECK_ENVIRONMENT_SINCE)) return { status: 200, body: { status: 'refused', error: modelingProducerTooOld(producer.version || 'unknown', CHECK_ENVIRONMENT_SINCE, 'rig-parts check --environment') } };
|
|
127
|
+
const fileSha = sha256(request.text);
|
|
128
|
+
const id = sha256(JSON.stringify([ENVIRONMENT_CHECK_SCHEMA, p.sourceHash, fileSha, request.build, buildId, inputs.configHash, `${producer.package}@${producer.version}`]));
|
|
129
|
+
const current = jobs.get(id);
|
|
130
|
+
if (current && current.status !== 'failed') return { status: current.status === 'running' ? 202 : 200, body: snapshot(current) };
|
|
131
|
+
const dir = join(environmentCheckRoot(options.stateDir, p.sourceHash), id);
|
|
132
|
+
try { const stored = JSON.parse(await readFile(join(dir, 'result.json'), 'utf8')) as EnvironmentCheckJob; if (stored.schema === ENVIRONMENT_CHECK_SCHEMA && stored.id === id) { jobs.set(id, stored); return { status: 200, body: snapshot(stored) }; } } catch { /* not run yet */ }
|
|
133
|
+
if (running) return { status: 200, body: { status: 'refused', error: `ENVIRONMENT_CHECK_BUSY: check ${running} is still running` } };
|
|
134
|
+
const job: EnvironmentCheckJob = {
|
|
135
|
+
schema: ENVIRONMENT_CHECK_SCHEMA, id, status: 'running', build: request.build, buildId,
|
|
136
|
+
schedule: { sha256: fileSha, size: Buffer.byteLength(request.text), steps: (JSON.parse(request.text) as { steps: unknown[] }).steps.length }, producer,
|
|
137
|
+
exitCode: null, wallMs: null, check: null, environment: null, environmentLine: null, bars: [], error: null, lines: [],
|
|
138
|
+
};
|
|
139
|
+
jobs.set(id, job); running = id;
|
|
140
|
+
void execute(job, dir, resolved.producer.bin, rig, inputs.parts, request.text).finally(() => { if (running === id) running = null; });
|
|
141
|
+
return { status: 202, body: snapshot(job) };
|
|
142
|
+
}
|
|
143
|
+
function read(id: string): EnvironmentCheckJob {
|
|
144
|
+
const job = jobs.get(id);
|
|
145
|
+
if (!job) throw new EnvironmentCheckError('ENVIRONMENT_CHECK_NOT_FOUND', id);
|
|
146
|
+
return snapshot(job);
|
|
147
|
+
}
|
|
148
|
+
return { start, read, running: () => running };
|
|
149
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Environment block's wind as a `rigc-environment/1` schedule (issue #112): what `rigc render --environment` and
|
|
3
|
+
* `rig-parts check --environment` (@rig-c/parts 1.48.0, parts#538) read — `{spec, steps: [{t, wind?, gravity?}]}`, each
|
|
4
|
+
* step holding from its `t` until the next, x right and y down, the runtime's wind [1, 0] and gravity [0, 1] before the
|
|
5
|
+
* first. Pure: no DOM, no IO, no clock.
|
|
6
|
+
*
|
|
7
|
+
* The panel's wind (`./environment.ts`) is `base + breeze(t) + gust(t)` on a clock the player's steps advance. From a
|
|
8
|
+
* fresh clock (`restore`, which starts a repeating gust at 0) that is a function of time alone, so a schedule can carry
|
|
9
|
+
* it with t = 0 at the animation's first frame:
|
|
10
|
+
* - the step form (no breeze, no repeating gust): one step, the standing world — exact;
|
|
11
|
+
* - the sampled form (breeze and/or repeat on): one step per frame of the render's grid (`SCHEDULE_FPS`), each the
|
|
12
|
+
* panel's wind at that frame's time. rigc sets a step before the physics step that poses its frame, as the panel sets
|
|
13
|
+
* the wind before the player's step, so the two agree at every frame time; between frames rigc holds the frame's
|
|
14
|
+
* value, within `scheduleTolerance` of the panel's.
|
|
15
|
+
* A one-off Gust (Repeat off) is an event on the live clock, not part of the settings, and is not in the schedule.
|
|
16
|
+
*/
|
|
17
|
+
import { breezeAt, gustEnvelope, gustPeriod, type WindSnapshot } from './environment.ts';
|
|
18
|
+
|
|
19
|
+
export const ENVIRONMENT_SPEC = 'rigc-environment/1';
|
|
20
|
+
/** The grid the schedule is sampled on: `rigc render`'s default fps and `rig-parts check`'s IDLE_FPS. */
|
|
21
|
+
export const SCHEDULE_FPS = 12;
|
|
22
|
+
/** At most this many steps (50 s at 12 fps, ~36 KiB of JSON); a longer clip is refused by name. */
|
|
23
|
+
export const MAX_SCHEDULE_STEPS = 600;
|
|
24
|
+
/** The first `@rig-c/parts` release whose `check` reads `--environment` (its CHANGELOG: 1.48.0, parts#538). */
|
|
25
|
+
export const CHECK_ENVIRONMENT_SINCE = '1.48.0';
|
|
26
|
+
/** The runtime's world before the first step (rig-c `src/environment.ts`). */
|
|
27
|
+
export const RUNTIME_WORLD = { wind: [1, 0] as [number, number], gravity: [0, 1] as [number, number] };
|
|
28
|
+
|
|
29
|
+
export interface EnvironmentStep { t: number; wind?: [number, number]; gravity?: [number, number] }
|
|
30
|
+
export interface EnvironmentSchedule { spec: typeof ENVIRONMENT_SPEC; steps: EnvironmentStep[] }
|
|
31
|
+
export class ScheduleError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
|
|
32
|
+
|
|
33
|
+
/** The standing world as one step from t = 0 (#108's readings schedule; the step form below is this). */
|
|
34
|
+
export const standingSchedule = (e: { wind: [number, number]; gravity: [number, number] }): EnvironmentSchedule => ({ spec: ENVIRONMENT_SPEC, steps: [{ t: 0, wind: e.wind, gravity: e.gravity }] });
|
|
35
|
+
|
|
36
|
+
/** The panel's wind at clock time `t` from a fresh clock (`createWindModel().restore(snapshot)`, then advanced to `t`). */
|
|
37
|
+
export function windAt(s: WindSnapshot, t: number): number {
|
|
38
|
+
const gentle = s.breeze ? breezeAt(t) : 0;
|
|
39
|
+
let gust = 0;
|
|
40
|
+
if (s.repeat) {
|
|
41
|
+
const period = gustPeriod(s.gustDuration), age = t - Math.floor(t / period) * period;
|
|
42
|
+
gust = (s.base < 0 ? -1 : 1) * gustEnvelope(age, s.gustPeak, s.gustDuration);
|
|
43
|
+
}
|
|
44
|
+
return s.base + gentle + gust;
|
|
45
|
+
}
|
|
46
|
+
/** Whether the wind changes in time (breeze or a repeating gust): the sampled form. */
|
|
47
|
+
export const windVaries = (s: WindSnapshot) => s.breeze || (s.repeat && s.gustPeak > 0);
|
|
48
|
+
/**
|
|
49
|
+
* The largest difference between the panel's wind and the schedule's held value inside one frame interval:
|
|
50
|
+
* max |dw/dt| / fps, with |breeze'| <= 2·2.2 + 0.8·5.1 and |gust'| <= peak·π / duration. 0 for the step form.
|
|
51
|
+
*/
|
|
52
|
+
export function scheduleTolerance(s: WindSnapshot, fps = SCHEDULE_FPS): number {
|
|
53
|
+
if (!windVaries(s)) return 0;
|
|
54
|
+
return ((s.breeze ? 2 * 2.2 + 0.8 * 5.1 : 0) + (s.repeat ? s.gustPeak * Math.PI / s.gustDuration : 0)) / fps;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ScheduleForm = 'step' | 'sampled';
|
|
58
|
+
export interface WindScheduleExport { schedule: EnvironmentSchedule; form: ScheduleForm; tolerance: number; fps: number; duration: number }
|
|
59
|
+
/**
|
|
60
|
+
* The panel's settings and gravity (the slider's value, applied as [0, g]) as a schedule over a clip of `duration`
|
|
61
|
+
* seconds: the step form when nothing varies, else one step per frame of `fps` up to the clip's last frame (rigc refuses a
|
|
62
|
+
* step after it), a step whose wind equals the one before left out.
|
|
63
|
+
*/
|
|
64
|
+
export function windSchedule(s: WindSnapshot, gravity: number, duration: number, fps = SCHEDULE_FPS): WindScheduleExport {
|
|
65
|
+
const g: [number, number] = [0, gravity];
|
|
66
|
+
if (!windVaries(s)) return { schedule: standingSchedule({ wind: [s.base, 0], gravity: g }), form: 'step', tolerance: 0, fps, duration };
|
|
67
|
+
if (!(duration > 0) || !Number.isFinite(duration)) throw new ScheduleError('ENVIRONMENT_SCHEDULE_NO_CLIP', 'a breeze or a repeating gust is sampled over a clip; no clip with a duration is drawn');
|
|
68
|
+
const last = Math.round(duration * fps);
|
|
69
|
+
if (last + 1 > MAX_SCHEDULE_STEPS) throw new ScheduleError('ENVIRONMENT_SCHEDULE_TOO_LONG', `${duration} s at ${fps} fps is ${last + 1} steps; at most ${MAX_SCHEDULE_STEPS}`);
|
|
70
|
+
const steps: EnvironmentStep[] = [];
|
|
71
|
+
let previous = NaN;
|
|
72
|
+
for (let k = 0; k <= last; k++) {
|
|
73
|
+
const t = k / fps, w = windAt(s, t);
|
|
74
|
+
if (k === 0) steps.push({ t, wind: [w, 0], gravity: g });
|
|
75
|
+
else if (w !== previous) steps.push({ t, wind: [w, 0] });
|
|
76
|
+
previous = w;
|
|
77
|
+
}
|
|
78
|
+
return { schedule: { spec: ENVIRONMENT_SPEC, steps }, form: 'sampled', tolerance: scheduleTolerance(s, fps), fps, duration };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The exported bytes: what is downloaded, written for rigc and rig-parts, and hashed into feedback. */
|
|
82
|
+
export const scheduleText = (s: EnvironmentSchedule) => JSON.stringify(s, null, 1) + '\n';
|
|
83
|
+
|
|
84
|
+
const bad = (detail: string): never => { throw new ScheduleError('ENVIRONMENT_SCHEDULE_INVALID', detail); };
|
|
85
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
86
|
+
const pair = (v: unknown, field: string): [number, number] => {
|
|
87
|
+
if (!Array.isArray(v) || v.length !== 2 || !v.every(n => typeof n === 'number' && Number.isFinite(n) && Math.abs(n) <= 1e4)) bad(`${field} must be [x, y], finite`);
|
|
88
|
+
return [(v as number[])[0]!, (v as number[])[1]!];
|
|
89
|
+
};
|
|
90
|
+
/** A schedule as rig-c's reader takes it (exact keys, ascending t from 0, a half stated per step), bounded; data only. */
|
|
91
|
+
export function parseSchedule(value: unknown): EnvironmentSchedule {
|
|
92
|
+
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'spec,steps') return bad('expected exactly {spec, steps}');
|
|
93
|
+
if (value.spec !== ENVIRONMENT_SPEC) bad(`spec must be ${ENVIRONMENT_SPEC}`);
|
|
94
|
+
if (!Array.isArray(value.steps) || value.steps.length < 1 || value.steps.length > MAX_SCHEDULE_STEPS) bad(`steps must hold 1–${MAX_SCHEDULE_STEPS} entries`);
|
|
95
|
+
const steps: EnvironmentStep[] = [];
|
|
96
|
+
(value.steps as unknown[]).forEach((raw, i) => {
|
|
97
|
+
if (!isRecord(raw) || !Object.keys(raw).every(k => k === 't' || k === 'wind' || k === 'gravity')) bad(`steps[${i}] holds only t, wind, gravity`);
|
|
98
|
+
const step = raw as Record<string, unknown>, t = step.t;
|
|
99
|
+
if (typeof t !== 'number' || !Number.isFinite(t) || t < 0 || t > 3600) bad(`steps[${i}].t must be seconds in [0, 3600]`);
|
|
100
|
+
if (i > 0 && !((t as number) > steps[i - 1]!.t)) bad(`steps[${i}].t must come after steps[${i - 1}].t`);
|
|
101
|
+
if (step.wind === undefined && step.gravity === undefined) bad(`steps[${i}] states neither wind nor gravity`);
|
|
102
|
+
steps.push({ t: t as number, ...(step.wind === undefined ? {} : { wind: pair(step.wind, `steps[${i}].wind`) }), ...(step.gravity === undefined ? {} : { gravity: pair(step.gravity, `steps[${i}].gravity`) }) });
|
|
103
|
+
});
|
|
104
|
+
return { spec: ENVIRONMENT_SPEC, steps };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** rig-c's `environmentFrame`: the first frame `i` whose time `i / fps` is at or after `t`. */
|
|
108
|
+
export function environmentFrame(t: number, fps: number): number {
|
|
109
|
+
let i = Math.max(0, Math.ceil(t * fps));
|
|
110
|
+
while (i > 0 && (i - 1) / fps >= t) i--;
|
|
111
|
+
while (i / fps < t) i++;
|
|
112
|
+
return i;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The world as rigc reads the schedule at time `t` on the `fps` grid: the physics step that ends on frame
|
|
116
|
+
* `environmentFrame(t)` runs under every step that lands on that frame or before (the runtime's world before the first).
|
|
117
|
+
*/
|
|
118
|
+
export function scheduleWorldAt(s: EnvironmentSchedule, t: number, fps = SCHEDULE_FPS): { wind: [number, number]; gravity: [number, number] } {
|
|
119
|
+
const frame = environmentFrame(t, fps);
|
|
120
|
+
let wind = RUNTIME_WORLD.wind, gravity = RUNTIME_WORLD.gravity;
|
|
121
|
+
for (const step of s.steps) { if (environmentFrame(step.t, fps) > frame) break; if (step.wind) wind = step.wind; if (step.gravity) gravity = step.gravity; }
|
|
122
|
+
return { wind, gravity };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One line: form, steps, the hash's first 16 hex (as check's `environment:` line prints it), the tolerance. */
|
|
126
|
+
export function scheduleSummary(e: WindScheduleExport, sha256: string): string {
|
|
127
|
+
const form = e.form === 'step' ? 'step form (exact)' : `sampled at ${e.fps} fps over ${e.duration.toFixed(3)} s (held between frames, within ${e.tolerance.toFixed(3)} of the panel)`;
|
|
128
|
+
return `${ENVIRONMENT_SPEC}, ${e.schedule.steps.length} step(s), sha256 ${sha256.slice(0, 16)} · ${form}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `context.environmentSchedule` (#112): the exported bytes by hash and content, and whether readings and Apply used them. */
|
|
132
|
+
export const SCHEDULE_CONTEXT_SCHEMA = 'rig-review-environment-schedule/1';
|
|
133
|
+
export interface ScheduleContext { schema: typeof SCHEDULE_CONTEXT_SCHEMA; sha256: string; size: number; text: string; usedForReadings: boolean }
|
|
134
|
+
const ctx = (detail: string): never => { throw new Error(`feedback.context.environmentSchedule: ${detail}`); };
|
|
135
|
+
/** The context's exact shape, its sha256 recomputed over `text` (`hash` is the caller's) and `text` a schedule. */
|
|
136
|
+
export function parseScheduleContext(value: unknown, hash: (text: string) => string): ScheduleContext | null {
|
|
137
|
+
if (value === null) return null;
|
|
138
|
+
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'schema,sha256,size,text,usedForReadings') return ctx('expected exactly {schema, sha256, size, text, usedForReadings}');
|
|
139
|
+
if (value.schema !== SCHEDULE_CONTEXT_SCHEMA) ctx(`schema must be ${SCHEDULE_CONTEXT_SCHEMA}`);
|
|
140
|
+
if (typeof value.text !== 'string' || value.text.length > 128 * 1024) ctx('text must be the exported schedule, at most 128 KiB');
|
|
141
|
+
const text = value.text as string;
|
|
142
|
+
if (typeof value.sha256 !== 'string' || value.sha256 !== hash(text)) ctx('sha256 is not the sha256 of text');
|
|
143
|
+
if (value.size !== new TextEncoder().encode(text).length) ctx('size is not the byte length of text');
|
|
144
|
+
if (typeof value.usedForReadings !== 'boolean') ctx('usedForReadings must be a boolean');
|
|
145
|
+
let doc: unknown;
|
|
146
|
+
try { doc = JSON.parse(text); } catch { return ctx('text does not parse as JSON'); }
|
|
147
|
+
try { parseSchedule(doc); } catch (e) { ctx(`text is not a ${ENVIRONMENT_SPEC} schedule (${e instanceof Error ? e.message : String(e)})`); }
|
|
148
|
+
return value as unknown as ScheduleContext;
|
|
149
|
+
}
|
|
150
|
+
/** "41 step(s) · sha256 … · used for readings": one line for the issue export. */
|
|
151
|
+
export function scheduleContextLine(value: unknown): string | null {
|
|
152
|
+
if (!isRecord(value) || value.schema !== SCHEDULE_CONTEXT_SCHEMA || typeof value.sha256 !== 'string' || typeof value.text !== 'string') return null;
|
|
153
|
+
let steps = 0;
|
|
154
|
+
try { const doc = JSON.parse(value.text) as { steps?: unknown[] }; steps = Array.isArray(doc.steps) ? doc.steps.length : 0; } catch { return null; }
|
|
155
|
+
return `${ENVIRONMENT_SPEC}, ${steps} step(s) · schedule sha256 ${value.sha256}${value.usedForReadings ? ' · used for readings and Apply' : ' · not used for readings'}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** SHA-256 of a string's UTF-8 bytes, hex — synchronous, so the page can hash the exported bytes where it records them. */
|
|
159
|
+
export function sha256Hex(text: string): string {
|
|
160
|
+
const bytes = new TextEncoder().encode(text), K = new Uint32Array([
|
|
161
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
162
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
163
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
164
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]);
|
|
165
|
+
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
|
166
|
+
const length = Math.ceil((bytes.length + 9) / 64) * 64, m = new Uint8Array(length);
|
|
167
|
+
m.set(bytes); m[bytes.length] = 0x80;
|
|
168
|
+
const view = new DataView(m.buffer), bits = bytes.length * 8;
|
|
169
|
+
view.setUint32(length - 8, Math.floor(bits / 0x100000000)); view.setUint32(length - 4, bits >>> 0);
|
|
170
|
+
const w = new Uint32Array(64), rot = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
|
171
|
+
for (let o = 0; o < length; o += 64) {
|
|
172
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(o + i * 4);
|
|
173
|
+
for (let i = 16; i < 64; i++) { const a = w[i - 15]!, b = w[i - 2]!; w[i] = (w[i - 16]! + (rot(a, 7) ^ rot(a, 18) ^ (a >>> 3)) + w[i - 7]! + (rot(b, 17) ^ rot(b, 19) ^ (b >>> 10))) >>> 0; }
|
|
174
|
+
let [a, b, c, d, e, f, g, hh] = h as unknown as number[];
|
|
175
|
+
for (let i = 0; i < 64; i++) {
|
|
176
|
+
const t1 = (hh! + (rot(e!, 6) ^ rot(e!, 11) ^ rot(e!, 25)) + ((e! & f!) ^ (~e! & g!)) + K[i]! + w[i]!) >>> 0;
|
|
177
|
+
const t2 = ((rot(a!, 2) ^ rot(a!, 13) ^ rot(a!, 22)) + ((a! & b!) ^ (a! & c!) ^ (b! & c!))) >>> 0;
|
|
178
|
+
hh = g; g = f; f = e; e = (d! + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
|
179
|
+
}
|
|
180
|
+
h[0] += a!; h[1] += b!; h[2] += c!; h[3] += d!; h[4] += e!; h[5] += f!; h[6] += g!; h[7] += hh!;
|
|
181
|
+
}
|
|
182
|
+
return [...h].map(x => x.toString(16).padStart(8, '0')).join('');
|
|
183
|
+
}
|
package/src/feedback-export.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { ReferenceBinding } from './reference.ts';
|
|
|
7
7
|
import { environmentSummary, parseEnvironmentContext } from './environment.ts';
|
|
8
8
|
import { appliedSummary } from './applied-context.ts';
|
|
9
9
|
import { requirementsSummaryLine } from './requirements.ts';
|
|
10
|
+
import { scheduleContextLine } from './environment-schedule.ts';
|
|
10
11
|
type Category = Feedback['category'];
|
|
11
12
|
type Side = 'single' | 'a' | 'b';
|
|
12
13
|
type ParsedFeedbackSession = FeedbackHistory<ReviewSession | CompareSession>;
|
|
@@ -176,6 +177,12 @@ function requirementsLine(context: Json | undefined): string[] {
|
|
|
176
177
|
const text = requirementsSummaryLine(context.requirements);
|
|
177
178
|
return text === null ? [] : [`- Requirements: ${feedbackMarkdownText(text)}`];
|
|
178
179
|
}
|
|
180
|
+
/** "Environment schedule: rigc-environment/1, 41 step(s) · schedule sha256 …": the exported world the note carries (#112). */
|
|
181
|
+
function scheduleLine(context: Json | undefined): string[] {
|
|
182
|
+
if (context === undefined || context === null || typeof context !== 'object' || Array.isArray(context) || !('environmentSchedule' in context)) return [];
|
|
183
|
+
const text = scheduleContextLine(context.environmentSchedule);
|
|
184
|
+
return text === null ? [] : [`- Environment schedule: ${feedbackMarkdownText(text)}`];
|
|
185
|
+
}
|
|
179
186
|
/** Local issue-ready text only. No issue creation, clock inference or source IO. */
|
|
180
187
|
export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
|
|
181
188
|
const model = groupFeedbackIssues(input), md = feedbackMarkdownText;
|
|
@@ -209,7 +216,7 @@ export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
|
|
|
209
216
|
`- Summary: ${md(item.summary)}`,
|
|
210
217
|
...environmentLine(item.context),
|
|
211
218
|
...appliedLine(item.context),
|
|
212
|
-
...requirementsLine(item.context), '',
|
|
219
|
+
...requirementsLine(item.context), ...scheduleLine(item.context), '',
|
|
213
220
|
'Selected build identity:', '', jsonBlock(item.selectedSource));
|
|
214
221
|
if (model.pairedBinding) lines.push('', 'Paired comparison binding:', '', jsonBlock(model.pairedBinding));
|
|
215
222
|
if (item.warnings.length) lines.push('', 'Validation warnings (record retained):', '', jsonBlock(item.warnings));
|
package/src/frame-clock.ts
CHANGED
|
@@ -34,3 +34,33 @@ export function frameAtTime(clock:FrameClock|null|undefined,time:number):number|
|
|
|
34
34
|
while(low<=high){const middle=low+Math.floor((high-low)/2);const sample=timeAtFrame(clock,middle);if(sample===null)return null;if(sample<=time)low=middle+1;else high=middle-1;}
|
|
35
35
|
return high>=0?high:null;
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/** The most frame times a scratch build's report may state (a bound for the served job, not a timing claim). */
|
|
39
|
+
export const MAX_STAGE_FRAME_TIMES=100_000;
|
|
40
|
+
|
|
41
|
+
/** A producer report's `frameTimes`, kept only when it is a bounded, finite, strictly increasing list; else null. */
|
|
42
|
+
export function samplesClock(times:unknown):FrameClock|null {
|
|
43
|
+
if(!Array.isArray(times)||!times.length||times.length>MAX_STAGE_FRAME_TIMES)return null;
|
|
44
|
+
for(let i=0;i<times.length;i++){const t=times[i];if(typeof t!=='number'||!Number.isFinite(t)||(i>0&&t<=times[i-1]))return null;}
|
|
45
|
+
return {kind:'samples',frameTimes:[...times as number[]]};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The clock a scratch build states for one of its animations (a retarget's `motion.report.json`), bound to that build. */
|
|
49
|
+
export interface StageClock {animation:string;modelHash:string;clock:FrameClock}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The transport's clock. The package report's clock when it names the active animation; else, with a player, the
|
|
53
|
+
* clock the build on the stage states for the active animation (#164: a retarget's own report frameTimes). The
|
|
54
|
+
* stage clock binds by animation and by the drawn model's hash, so a reset or another rebuild drops it.
|
|
55
|
+
*/
|
|
56
|
+
export function transportClock(report:{clock:FrameClock|null|undefined;animation:string|undefined},active:string,hasPlayer:boolean,stage:StageClock|null,stageModelHash:string|null):{clock:FrameClock;source:'report'|'stage'}|null {
|
|
57
|
+
const own=clockForAnimation(report.clock,report.animation,active,hasPlayer);if(own)return {clock:own,source:'report'};
|
|
58
|
+
if(hasPlayer&&stage&&active&&stage.animation===active&&stage.modelHash===stageModelHash)return {clock:stage.clock,source:'stage'};
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The rig pane's header: the animation, frame and time the drawn pose is at — the transport's clock, the one a seek sets. */
|
|
63
|
+
export function rigPaneLabel(state:{hasPlayer:boolean;animation:string;time:number;frame:number|null}):string {
|
|
64
|
+
if(!state.hasPlayer)return 'Generated rig · read only';
|
|
65
|
+
return ['Generated rig',state.animation||'no animation',...(state.frame===null?[]:[`F${state.frame}`]),`${state.time.toFixed(3)} s`].join(' · ');
|
|
66
|
+
}
|