@rig-c/review 0.6.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 +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 +238 -0
- package/web/retarget-panel.ts +203 -0
- package/web/rig-fields-panel.ts +269 -0
- package/web/rig-overlays.ts +108 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +28 -5
- package/web/weights-view.ts +159 -0
package/src/cut-edits.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cuts, patches and limbs (#115): the producer config's `assemble.cuts`, `assemble.patches` and `limbs` as the Setup
|
|
3
|
+
* Cuts block edits them on the stage. Every coordinate is rig px of the setup pose, y down — the space of `parts.json`
|
|
4
|
+
* and of the cut polygons and patch boxes (rig-parts AUTHORING §3, `assemble.cuts`: "non-negative integer rig pixels").
|
|
5
|
+
*
|
|
6
|
+
* - a cut's polygon vertex is one anchor at `/assemble/cuts/<i>/polygon/<k>`, a `replace` op naming the cut by `into`;
|
|
7
|
+
* - a patch's box is two corner anchors whose edit is one `replace` at `/assemble/patches/<i>/box` naming the patch;
|
|
8
|
+
* - a limb segment below a joint has its `range` (`[min, max]` degrees from rest, `min <= 0 <= max`), a `replace` (or
|
|
9
|
+
* `add` when the config states none, `remove` to drop it) at `/limbs/<limb>/segments/<k>/range` naming its `part`; its
|
|
10
|
+
* joint is the lower bone's origin, which is a `bones` anchor (`src/bone-anchors.ts`) — a joint drag is a bone op;
|
|
11
|
+
* - a new cut is `add /assemble/cuts/-` with `{from, into, polygon, draw, overlap}` (no `under`: review paints no image)
|
|
12
|
+
* plus `add /regions/<into>` (the coverage rule wants every part a mesh or a region); a new patch is
|
|
13
|
+
* `add /assemble/patches/-` with `{name, box, alpha, draw}` plus `add /regions/<name>` (a patch is always a region).
|
|
14
|
+
*
|
|
15
|
+
* What only the producer can judge (a polygon point past the rig, two cuts overlapping, a cut taking no pixel, an
|
|
16
|
+
* `under` no longer the polygon box's size, a name already taken, a range out of order) is the producer's refusal,
|
|
17
|
+
* shown verbatim. Review refuses before sending only what is not the loader's type: a non-integer or negative point, a
|
|
18
|
+
* polygon under three points, a range that is not two numbers. Pure: no IO, no DOM.
|
|
19
|
+
*/
|
|
20
|
+
import type { JsonValue, PatchOp } from './config-patch.ts';
|
|
21
|
+
|
|
22
|
+
export type RigPoint = [number, number];
|
|
23
|
+
export type PatchBox = [number, number, number, number];
|
|
24
|
+
export type CutDraw = 'back' | 'front' | { before: string };
|
|
25
|
+
export interface ConfigCut { index: number; from: string; into: string; polygon: RigPoint[]; draw: CutDraw; overlap: number; under: string | null }
|
|
26
|
+
export interface ConfigPatchEntry { index: number; name: string; box: PatchBox; alpha: string; draw: CutDraw }
|
|
27
|
+
export interface ConfigLimbSegment { limb: string; index: number; part: string; from: string; bone: string; joint: string | null; range: [number, number] | null }
|
|
28
|
+
export interface CutsSource { cuts: ConfigCut[]; patches: ConfigPatchEntry[]; segments: ConfigLimbSegment[]; plan: string[]; skipped: string[] }
|
|
29
|
+
|
|
30
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
31
|
+
const intPoint = (v: unknown): RigPoint | null => Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number' && Number.isFinite(n)) ? [v[0] as number, v[1] as number] : null;
|
|
32
|
+
const draw = (v: unknown): CutDraw | null => v === 'back' || v === 'front' ? v : isObject(v) && typeof v.before === 'string' ? { before: v.before } : null;
|
|
33
|
+
|
|
34
|
+
/** The config's cuts, patches, limb segments and plan part names as written; an unreadable entry is named in `skipped`. */
|
|
35
|
+
export function cutsSource(config: unknown): CutsSource {
|
|
36
|
+
const out: CutsSource = { cuts: [], patches: [], segments: [], plan: [], skipped: [] };
|
|
37
|
+
if (!isObject(config)) return out;
|
|
38
|
+
const assemble = isObject(config.assemble) ? config.assemble : {};
|
|
39
|
+
if (Array.isArray(assemble.plan)) for (const row of assemble.plan) if (Array.isArray(row) && typeof row[0] === 'string') out.plan.push(row[0]);
|
|
40
|
+
if (Array.isArray(assemble.cuts)) assemble.cuts.forEach((q, index) => {
|
|
41
|
+
const polygon = isObject(q) && Array.isArray(q.polygon) ? q.polygon.map(intPoint) : [];
|
|
42
|
+
const d = isObject(q) ? draw(q.draw) : null;
|
|
43
|
+
if (!isObject(q) || typeof q.from !== 'string' || typeof q.into !== 'string' || !d || typeof q.overlap !== 'number' || polygon.length < 3 || polygon.some(p => !p)) { out.skipped.push(`assemble.cuts[${index}] is not {from, into, polygon, draw, overlap}`); return; }
|
|
44
|
+
out.cuts.push({ index, from: q.from, into: q.into, polygon: polygon as RigPoint[], draw: d, overlap: q.overlap, under: typeof q.under === 'string' ? q.under : null });
|
|
45
|
+
});
|
|
46
|
+
if (Array.isArray(assemble.patches)) assemble.patches.forEach((q, index) => {
|
|
47
|
+
const d = isObject(q) ? draw(q.draw) : null;
|
|
48
|
+
if (!isObject(q) || typeof q.name !== 'string' || !Array.isArray(q.box) || q.box.length !== 4 || !q.box.every(n => typeof n === 'number' && Number.isFinite(n)) || typeof q.alpha !== 'string' || !d) { out.skipped.push(`assemble.patches[${index}] is not {name, box, alpha, draw}`); return; }
|
|
49
|
+
out.patches.push({ index, name: q.name, box: [...q.box] as PatchBox, alpha: q.alpha, draw: d });
|
|
50
|
+
});
|
|
51
|
+
if (isObject(config.limbs)) for (const [limb, entry] of Object.entries(config.limbs)) {
|
|
52
|
+
if (!isObject(entry) || !Array.isArray(entry.segments)) { out.skipped.push(`limbs.${limb} has no segments`); continue; }
|
|
53
|
+
entry.segments.forEach((sg, index) => {
|
|
54
|
+
if (!isObject(sg) || typeof sg.part !== 'string' || typeof sg.from !== 'string' || typeof sg.bone !== 'string') { out.skipped.push(`limbs.${limb}.segments[${index}] is not {part, from, bone}`); return; }
|
|
55
|
+
const range = Array.isArray(sg.range) && sg.range.length === 2 && sg.range.every(n => typeof n === 'number' && Number.isFinite(n)) ? [sg.range[0], sg.range[1]] as [number, number] : null;
|
|
56
|
+
out.segments.push({ limb, index, part: sg.part, from: sg.from, bone: sg.bone, joint: typeof sg.joint === 'string' ? sg.joint : null, range });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class CutEditError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
|
|
63
|
+
|
|
64
|
+
/** One pending edit, keyed by its pointer; `value` is the whole new value at that pointer (or `null` to remove a range). */
|
|
65
|
+
export type CutEdit =
|
|
66
|
+
| { kind: 'vertex'; path: string; name: string; label: string; from: RigPoint; value: RigPoint }
|
|
67
|
+
| { kind: 'box'; path: string; name: string; label: string; from: PatchBox; value: PatchBox }
|
|
68
|
+
| { kind: 'range'; path: string; name: string; label: string; from: [number, number] | null; value: [number, number] | null };
|
|
69
|
+
/** A new cut or patch, placed on the stage; it becomes two `add` ops (the entry and its `regions` bone). */
|
|
70
|
+
export type CutAdd =
|
|
71
|
+
| { kind: 'cut'; from: string; into: string; polygon: RigPoint[]; draw: CutDraw; overlap: number; bone: string }
|
|
72
|
+
| { kind: 'patch'; name: string; box: PatchBox; alpha: 'silhouette' | 'box'; draw: CutDraw; bone: string };
|
|
73
|
+
|
|
74
|
+
export const vertexPath = (cut: number, k: number) => `/assemble/cuts/${cut}/polygon/${k}`;
|
|
75
|
+
export const boxPath = (patch: number) => `/assemble/patches/${patch}/box`;
|
|
76
|
+
export const rangePath = (limb: string, k: number) => `/limbs/${limb.replace(/~/g, '~0').replace(/\//g, '~1')}/segments/${k}/range`;
|
|
77
|
+
|
|
78
|
+
/** A rig px value as the loader takes it: a non-negative integer (`CONFIG_FIELD_TYPE` otherwise). */
|
|
79
|
+
function rigInt(v: number, what: string): number {
|
|
80
|
+
if (!Number.isInteger(v) || v < 0) throw new CutEditError('CUTS_VALUE_INVALID', `${what} must be a non-negative integer of rig px (the config's polygons and boxes are)`);
|
|
81
|
+
return v;
|
|
82
|
+
}
|
|
83
|
+
const same = (a: readonly number[] | null, b: readonly number[] | null) => a === b || (!!a && !!b && a.length === b.length && a.every((v, i) => v === b[i]));
|
|
84
|
+
|
|
85
|
+
/** Set a cut vertex; a value equal to the config's removes the pending edit. */
|
|
86
|
+
export function setVertex(edits: ReadonlyMap<string, CutEdit>, cut: ConfigCut, k: number, value: RigPoint): Map<string, CutEdit> {
|
|
87
|
+
const from = cut.polygon[k];
|
|
88
|
+
if (!from) throw new CutEditError('CUTS_VERTEX_UNKNOWN', `${cut.into} has no vertex ${k}`);
|
|
89
|
+
const v: RigPoint = [rigInt(value[0], `${cut.into} vertex ${k} x`), rigInt(value[1], `${cut.into} vertex ${k} y`)];
|
|
90
|
+
const next = new Map(edits), path = vertexPath(cut.index, k);
|
|
91
|
+
if (same(v, from)) next.delete(path); else next.set(path, { kind: 'vertex', path, name: cut.into, label: `${cut.into} vertex ${k}`, from: [...from], value: v });
|
|
92
|
+
return next;
|
|
93
|
+
}
|
|
94
|
+
/** Set a patch box (`[x0, y0, x1, y1]`, `x1`/`y1` exclusive); a box equal to the config's removes the edit. */
|
|
95
|
+
export function setBox(edits: ReadonlyMap<string, CutEdit>, patch: ConfigPatchEntry, value: PatchBox): Map<string, CutEdit> {
|
|
96
|
+
const v = value.map((n, i) => rigInt(n, `${patch.name} box[${i}]`)) as PatchBox;
|
|
97
|
+
if (v[2] <= v[0] || v[3] <= v[1]) throw new CutEditError('CUTS_VALUE_INVALID', `${patch.name} box must have x1 > x0 and y1 > y0`);
|
|
98
|
+
const next = new Map(edits), path = boxPath(patch.index);
|
|
99
|
+
if (same(v, patch.box)) next.delete(path); else next.set(path, { kind: 'box', path, name: patch.name, label: `${patch.name} box`, from: [...patch.box] as PatchBox, value: v });
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
/** Set (or with null, drop) a segment's range; only a segment below a joint takes one (the loader's `CONFIG_KEY_KNOWN`). */
|
|
103
|
+
export function setRange(edits: ReadonlyMap<string, CutEdit>, segment: ConfigLimbSegment, value: [number, number] | null): Map<string, CutEdit> {
|
|
104
|
+
if (!segment.joint) throw new CutEditError('CUTS_RANGE_UPPER', `${segment.part} is the upper segment of ${segment.limb}; only a segment below a joint takes a range`);
|
|
105
|
+
if (value && !value.every(n => typeof n === 'number' && Number.isFinite(n))) throw new CutEditError('CUTS_VALUE_INVALID', `${segment.part} range must be two numbers of degrees`);
|
|
106
|
+
const next = new Map(edits), path = rangePath(segment.limb, segment.index);
|
|
107
|
+
if (same(value, segment.range)) next.delete(path); else next.set(path, { kind: 'range', path, name: segment.part, label: `${segment.limb} ${segment.joint} range (${segment.part})`, from: segment.range ? [...segment.range] : null, value: value ? [...value] : null });
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
/** Where a vertex stands now: its pending value, else the config's. */
|
|
111
|
+
export const vertexAt = (edits: ReadonlyMap<string, CutEdit>, cut: ConfigCut, k: number): RigPoint => (edits.get(vertexPath(cut.index, k)) as Extract<CutEdit, { kind: 'vertex' }> | undefined)?.value ?? cut.polygon[k]!;
|
|
112
|
+
export const boxAt = (edits: ReadonlyMap<string, CutEdit>, patch: ConfigPatchEntry): PatchBox => (edits.get(boxPath(patch.index)) as Extract<CutEdit, { kind: 'box' }> | undefined)?.value ?? patch.box;
|
|
113
|
+
export const rangeAt = (edits: ReadonlyMap<string, CutEdit>, s: ConfigLimbSegment): [number, number] | null => { const e = edits.get(rangePath(s.limb, s.index)) as Extract<CutEdit, { kind: 'range' }> | undefined; return e ? e.value : s.range; };
|
|
114
|
+
|
|
115
|
+
/** A drag moves a point by the pointer's delta snapped to whole rig px. */
|
|
116
|
+
export const dragPoint = (from: RigPoint, start: RigPoint, now: RigPoint): RigPoint => [0, 1].map(i => { const d = Math.round(now[i]! - start[i]!); return from[i]! + (d === 0 ? 0 : d); }) as RigPoint;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The pending edits and additions as config patch ops, in order: the edits (each one op at its own pointer, naming its
|
|
120
|
+
* entry), then each addition's entry and its `regions` bone. A new name is not checked against the config: the
|
|
121
|
+
* producer's `CONFIG_PART_UNIQUE` says so, verbatim.
|
|
122
|
+
*/
|
|
123
|
+
export function cutOps(edits: ReadonlyMap<string, CutEdit>, adds: readonly CutAdd[], config: unknown): PatchOp[] {
|
|
124
|
+
const ops: PatchOp[] = [];
|
|
125
|
+
for (const e of edits.values()) {
|
|
126
|
+
if (e.kind === 'range') {
|
|
127
|
+
if (e.value === null) ops.push({ op: 'remove', path: e.path, name: e.name });
|
|
128
|
+
else ops.push({ op: e.from === null ? 'add' : 'replace', path: e.path, value: [...e.value], name: e.name });
|
|
129
|
+
} else ops.push({ op: 'replace', path: e.path, value: [...e.value], name: e.name });
|
|
130
|
+
}
|
|
131
|
+
const assemble = isObject(config) && isObject(config.assemble) ? config.assemble : {};
|
|
132
|
+
let cuts = Array.isArray(assemble.cuts) ? assemble.cuts.length : null, patches = Array.isArray(assemble.patches) ? assemble.patches.length : null;
|
|
133
|
+
let regions = isObject(config) && isObject(config.regions);
|
|
134
|
+
for (const a of adds) {
|
|
135
|
+
const list = a.kind === 'cut' ? 'cuts' : 'patches', has = a.kind === 'cut' ? cuts !== null : patches !== null;
|
|
136
|
+
const entry: JsonValue = a.kind === 'cut'
|
|
137
|
+
? { from: a.from, into: a.into, polygon: a.polygon.map(p => [rigInt(p[0], `${a.into} x`), rigInt(p[1], `${a.into} y`)]), draw: a.draw as JsonValue, overlap: rigInt(a.overlap, `${a.into} overlap`) }
|
|
138
|
+
: { name: a.name, box: a.box.map((n, i) => rigInt(n, `${a.name} box[${i}]`)), alpha: a.alpha, draw: a.draw as JsonValue };
|
|
139
|
+
if (a.kind === 'cut' && a.polygon.length < 3) throw new CutEditError('CUTS_POLYGON_SHORT', `${a.into} needs at least three points`);
|
|
140
|
+
ops.push(has ? { op: 'add', path: `/assemble/${list}/-`, value: entry } : { op: 'add', path: `/assemble/${list}`, value: [entry] });
|
|
141
|
+
if (a.kind === 'cut') cuts = (cuts ?? 0) + 1; else patches = (patches ?? 0) + 1;
|
|
142
|
+
const name = a.kind === 'cut' ? a.into : a.name;
|
|
143
|
+
if (!regions) { ops.push({ op: 'add', path: '/regions', value: {} }); regions = true; }
|
|
144
|
+
ops.push({ op: 'add', path: `/regions/${name.replace(/~/g, '~0').replace(/\//g, '~1')}`, value: a.bone });
|
|
145
|
+
}
|
|
146
|
+
return ops;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A segment's range as an arc for the overlay: the joint (the lower bone's origin), the bone's rest direction (rig px,
|
|
151
|
+
* y down) and the two ends `min`, `max` turned counter-clockwise on screen (rigc's sense), sampled every `step` degrees.
|
|
152
|
+
*/
|
|
153
|
+
export function rangeArc(joint: RigPoint, toward: RigPoint, range: [number, number], radius: number, step = 5): RigPoint[] {
|
|
154
|
+
const rest = Math.atan2(toward[1] - joint[1], toward[0] - joint[0]);
|
|
155
|
+
const [lo, hi] = range[0] <= range[1] ? range : [range[1], range[0]];
|
|
156
|
+
const out: RigPoint[] = [[joint[0], joint[1]]];
|
|
157
|
+
for (let d = lo; ; d = Math.min(hi, d + step)) {
|
|
158
|
+
// Counter-clockwise on screen with y down is a negative angle in rig px.
|
|
159
|
+
const a = rest - (d * Math.PI) / 180;
|
|
160
|
+
out.push([joint[0] + radius * Math.cos(a), joint[1] + radius * Math.sin(a)]);
|
|
161
|
+
if (d >= hi) break;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const CUTS_DRAFT_SCHEMA = 'rig-review-cuts-draft/1';
|
|
167
|
+
export const cutsDraftText = (edits: ReadonlyMap<string, CutEdit>, adds: readonly CutAdd[]) => JSON.stringify({ schema: CUTS_DRAFT_SCHEMA, edits: [...edits.values()], adds });
|
|
168
|
+
/** Edits back from a draft, kept only where the pointer still names the same entry with the same config value. */
|
|
169
|
+
export function cutsDraft(text: string, source: CutsSource): { edits: Map<string, CutEdit>; adds: CutAdd[] } {
|
|
170
|
+
const empty = { edits: new Map<string, CutEdit>(), adds: [] as CutAdd[] };
|
|
171
|
+
let doc: unknown;
|
|
172
|
+
try { doc = JSON.parse(text); } catch { return empty; }
|
|
173
|
+
if (!isObject(doc) || doc.schema !== CUTS_DRAFT_SCHEMA || !Array.isArray(doc.edits)) return empty;
|
|
174
|
+
let edits = new Map<string, CutEdit>();
|
|
175
|
+
for (const e of doc.edits as CutEdit[]) {
|
|
176
|
+
try {
|
|
177
|
+
if (e.kind === 'vertex') { const m = /^\/assemble\/cuts\/(\d+)\/polygon\/(\d+)$/.exec(e.path); const cut = m ? source.cuts.find(c => c.index === Number(m[1])) : undefined; if (cut && cut.into === e.name && same(cut.polygon[Number(m![2])] ?? null, e.from)) edits = setVertex(edits, cut, Number(m![2]), e.value); }
|
|
178
|
+
else if (e.kind === 'box') { const patch = source.patches.find(q => boxPath(q.index) === e.path); if (patch && patch.name === e.name && same(patch.box, e.from)) edits = setBox(edits, patch, e.value); }
|
|
179
|
+
else if (e.kind === 'range') { const s = source.segments.find(g => rangePath(g.limb, g.index) === e.path); if (s && s.part === e.name && same(s.range, e.from)) edits = setRange(edits, s, e.value); }
|
|
180
|
+
} catch { /* a draft value the editor would refuse is dropped */ }
|
|
181
|
+
}
|
|
182
|
+
const adds = Array.isArray(doc.adds) ? (doc.adds as CutAdd[]).filter(a => isObject(a) && (a.kind === 'cut' || a.kind === 'patch')) : [];
|
|
183
|
+
return { edits, adds };
|
|
184
|
+
}
|
package/src/environment.ts
CHANGED
|
@@ -51,7 +51,10 @@ export interface WindModel {
|
|
|
51
51
|
setBase(value:number):void;setGustPeak(value:number):void;setGustDuration(value:number):void;
|
|
52
52
|
setBreeze(value:boolean):void;setRepeat(value:boolean):void;triggerGust():void;
|
|
53
53
|
advance(dt:number):WindReading;
|
|
54
|
+
/** The whole standing state, clock and active pulse included (#103: an Apply's reload resumes the same world). */
|
|
55
|
+
state():WindState;resume(value:WindState):void;
|
|
54
56
|
}
|
|
57
|
+
export interface WindState {snapshot:WindSnapshot;time:number;gustStart:number|null;gustDirection:number;nextGust:number|null;activeGustPeak:number;activeGustDuration:number}
|
|
55
58
|
export const defaultWindSnapshot=():WindSnapshot=>({version:1,base:0,breeze:false,repeat:false,gustPeak:GUST_PEAK,gustDuration:GUST_DURATION});
|
|
56
59
|
|
|
57
60
|
export function createWindModel():WindModel {
|
|
@@ -94,6 +97,11 @@ export function createWindModel():WindModel {
|
|
|
94
97
|
if(repeat){if(read().gusting)nextGust=gustStart!+gustPeriod(activeGustDuration);else start(time);}
|
|
95
98
|
else nextGust=null; // Finish the active pulse before returning to base.
|
|
96
99
|
},
|
|
100
|
+
state:()=>({snapshot:{version:1,base,breeze,repeat,gustPeak,gustDuration},time,gustStart,gustDirection,nextGust,activeGustPeak,activeGustDuration}),
|
|
101
|
+
resume(value){
|
|
102
|
+
const saved=structuredClone(value);this.restore(saved.snapshot);
|
|
103
|
+
time=saved.time;gustStart=saved.gustStart;gustDirection=saved.gustDirection;nextGust=saved.nextGust;activeGustPeak=saved.activeGustPeak;activeGustDuration=saved.activeGustDuration;
|
|
104
|
+
},
|
|
97
105
|
advance(dt){
|
|
98
106
|
if(!Number.isFinite(dt)||dt<0||!Number.isFinite(time+dt))throw new RangeError('wind dt must be finite, non-negative and keep a finite clock');
|
|
99
107
|
time+=dt;
|
|
@@ -157,14 +165,19 @@ export function physicsConstraints(document:unknown):PhysicsConstraints {
|
|
|
157
165
|
return {available:true,rows};
|
|
158
166
|
}
|
|
159
167
|
|
|
168
|
+
/** The reviewed model document, its bytes hash-checked against the reviewed identity first. */
|
|
169
|
+
export async function loadModelDocument(url:string,sha256:string,fetcher:typeof fetch=fetch):Promise<{ok:true;document:unknown}|{ok:false;reason:string}> {
|
|
170
|
+
const response=await fetcher(url);if(!response.ok)return {ok:false,reason:`model request failed (${response.status})`};
|
|
171
|
+
const bytes=await response.arrayBuffer();if(bytes.byteLength>128*1024*1024)return {ok:false,reason:'model exceeds 128 MiB'};
|
|
172
|
+
const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(v=>v.toString(16).padStart(2,'0')).join('');
|
|
173
|
+
if(hash!==sha256)return {ok:false,reason:'model bytes differ from reviewed SHA-256'};
|
|
174
|
+
try{return {ok:true,document:JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes))};}
|
|
175
|
+
catch(error){return {ok:false,reason:error instanceof Error?error.message:String(error)};}
|
|
176
|
+
}
|
|
160
177
|
/** Reads the reviewed model bytes (hash-checked against the reviewed identity) and lists its physics constraints. */
|
|
161
178
|
export async function loadPhysicsConstraints(url:string,sha256:string,fetcher:typeof fetch=fetch):Promise<PhysicsConstraints> {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(v=>v.toString(16).padStart(2,'0')).join('');
|
|
165
|
-
if(hash!==sha256)return {available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: model bytes differ from reviewed SHA-256'};
|
|
166
|
-
try{return physicsConstraints(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes)));}
|
|
167
|
-
catch(error){return {available:false,reason:`PHYSICS_CONSTRAINTS_UNAVAILABLE: ${error instanceof Error?error.message:String(error)}`};}
|
|
179
|
+
const loaded=await loadModelDocument(url,sha256,fetcher);
|
|
180
|
+
return loaded.ok?physicsConstraints(loaded.document):{available:false,reason:`PHYSICS_CONSTRAINTS_UNAVAILABLE: ${loaded.reason}`};
|
|
168
181
|
}
|
|
169
182
|
|
|
170
183
|
/** What a feedback record keeps of the environment it was written under. */
|
package/src/feedback-export.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { CompareSession } from './compare-session.ts';
|
|
|
5
5
|
import type { FeedbackHistory, HistoryRetrieval } from './session-history.ts';
|
|
6
6
|
import type { ReferenceBinding } from './reference.ts';
|
|
7
7
|
import { environmentSummary, parseEnvironmentContext } from './environment.ts';
|
|
8
|
+
import { appliedSummary } from './applied-context.ts';
|
|
9
|
+
import { requirementsSummaryLine } from './requirements.ts';
|
|
8
10
|
type Category = Feedback['category'];
|
|
9
11
|
type Side = 'single' | 'a' | 'b';
|
|
10
12
|
type ParsedFeedbackSession = FeedbackHistory<ReviewSession | CompareSession>;
|
|
@@ -162,6 +164,18 @@ function environmentLine(context: Json | undefined): string[] {
|
|
|
162
164
|
try { const value = parseEnvironmentContext(context.environment); return [`- Environment: ${value === null ? 'no compiled model drawn' : feedbackMarkdownText(environmentSummary(value))}`]; }
|
|
163
165
|
catch { return []; }
|
|
164
166
|
}
|
|
167
|
+
/** "Applied: patch … · check PASS": the build the note was written on, when the record carries a valid applied state. */
|
|
168
|
+
function appliedLine(context: Json | undefined): string[] {
|
|
169
|
+
if (context === undefined || context === null || typeof context !== 'object' || Array.isArray(context) || !('applied' in context)) return [];
|
|
170
|
+
const text = appliedSummary(context.applied);
|
|
171
|
+
return text === null ? [] : [`- Applied: ${feedbackMarkdownText(text)}`];
|
|
172
|
+
}
|
|
173
|
+
/** "Requirements: 3 declared · requirements file sha256 … · requirements: …": the declared requirements the note carries (#113). */
|
|
174
|
+
function requirementsLine(context: Json | undefined): string[] {
|
|
175
|
+
if (context === undefined || context === null || typeof context !== 'object' || Array.isArray(context) || !('requirements' in context)) return [];
|
|
176
|
+
const text = requirementsSummaryLine(context.requirements);
|
|
177
|
+
return text === null ? [] : [`- Requirements: ${feedbackMarkdownText(text)}`];
|
|
178
|
+
}
|
|
165
179
|
/** Local issue-ready text only. No issue creation, clock inference or source IO. */
|
|
166
180
|
export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
|
|
167
181
|
const model = groupFeedbackIssues(input), md = feedbackMarkdownText;
|
|
@@ -193,7 +207,9 @@ export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
|
|
|
193
207
|
`- Inclusive frames: ${item.frameStart}–${item.frameEnd}`,
|
|
194
208
|
`- Side: ${item.side}`,
|
|
195
209
|
`- Summary: ${md(item.summary)}`,
|
|
196
|
-
...environmentLine(item.context),
|
|
210
|
+
...environmentLine(item.context),
|
|
211
|
+
...appliedLine(item.context),
|
|
212
|
+
...requirementsLine(item.context), '',
|
|
197
213
|
'Selected build identity:', '', jsonBlock(item.selectedSource));
|
|
198
214
|
if (model.pairedBinding) lines.push('', 'Paired comparison binding:', '', jsonBlock(model.pairedBinding));
|
|
199
215
|
if (item.warnings.length) lines.push('', 'Validation warnings (record retained):', '', jsonBlock(item.warnings));
|
package/src/front.ts
CHANGED
|
@@ -55,6 +55,17 @@ export function frontRoute(mode:'single'|'compare',method:string,path:string):Ro
|
|
|
55
55
|
const reads=mode==='single'?['/api/review','/review-reference']:['/api/compare','/api/compare/reference-frames','/compare-reference'];
|
|
56
56
|
if(reads.includes(path))return read?get:null;
|
|
57
57
|
if((mode==='single'?/^\/assets\/[^/]/:/^\/compare-assets\/(a|b)\/[^/]/).test(path))return read?get:null;
|
|
58
|
+
// Apply · Rebuild (single only): status, one typed request, a job's status and its served scratch build.
|
|
59
|
+
if(mode==='single'&&path==='/api/rebuild')return read?get:method==='POST'?{mutation:true,limit:64*1024,timeoutMs:30000,control:false}:null;
|
|
60
|
+
if(mode==='single'&&(/^\/api\/rebuild\/[a-f0-9]{64}$/.test(path)||/^\/rebuild\/[a-f0-9]{64}\/[^/]/.test(path)))return read?get:null;
|
|
61
|
+
// Bones check (#109, single only): one typed request {ops, configHash}, answered in the response.
|
|
62
|
+
if(mode==='single'&&path==='/api/bones-check')return method==='POST'?{mutation:true,limit:64*1024,timeoutMs:60000,control:false}:null;
|
|
63
|
+
// Readings (#108, single only): one typed request, then its job's status.
|
|
64
|
+
if(mode==='single'&&path==='/api/readings')return method==='POST'?{mutation:true,limit:16*1024,timeoutMs:30000,control:false}:null;
|
|
65
|
+
if(mode==='single'&&/^\/api\/readings\/[a-f0-9]{64}$/.test(path))return read?get:null;
|
|
66
|
+
// Declared requirements (#113, single only): the stage's catalog, one typed request, then its job's status.
|
|
67
|
+
if(mode==='single'&&path==='/api/requirements')return read?get:method==='POST'?{mutation:true,limit:64*1024,timeoutMs:30000,control:false}:null;
|
|
68
|
+
if(mode==='single'&&/^\/api\/requirements\/[a-f0-9]{64}$/.test(path))return read?get:null;
|
|
58
69
|
return null;
|
|
59
70
|
}
|
|
60
71
|
async function boundedBody(req:Request,limit:number,timeoutMs=10000):Promise<ArrayBuffer>{
|
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Producer handoff (#101): which producer config, parts and inspected rig the package was built from, so an Apply can
|
|
3
|
+
* rebuild through the installed producer and the AI receiving the review can rebuild exactly that state.
|
|
4
|
+
*
|
|
5
|
+
* Declared first — `rig-review open <dir> --config <path> …`, else the `producer` block in review.json — then a fixed
|
|
6
|
+
* discovery order, never a search: `config.json`, then `rig/config.json` inside the opened folder, and as the last step
|
|
7
|
+
* `../config.json` beside an opened `out/`. That last one sits outside the opened folder, so the record names it
|
|
8
|
+
* `discovered` with `outside: true`, its path and its hash. Every path is literal and must stay inside its root (the
|
|
9
|
+
* package root; for the `../config.json` step, the folder that holds `out/`); a symlink that leaves it is refused by name.
|
|
10
|
+
* The record (paths relative to the package root, hashes, how it was found) is part of `reviewedSource`, so changing a
|
|
11
|
+
* handoff file's bytes starts a new pending session.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
15
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const HANDOFF_SCHEMA = 'rig-review-handoff/1';
|
|
18
|
+
/** The fixed discovery order, as the refusal names it. */
|
|
19
|
+
export const DISCOVERY_PLACES = ['config.json', 'rig/config.json', '../config.json (beside an opened out/)'] as const;
|
|
20
|
+
export const HANDOFF_DECLARATION = 'rig-review open <dir> --config <path> [--parts <dir>] or the review.json "producer" block';
|
|
21
|
+
export const MODELING_INPUT_UNAVAILABLE = `MODELING_INPUT_UNAVAILABLE: config.json — looked at ${DISCOVERY_PLACES.join(', ')}; declare it with ${HANDOFF_DECLARATION}`;
|
|
22
|
+
/** Files the producer reads by the config's own naming, resolved against the config's directory (rig-parts `relativeConfigPaths`). */
|
|
23
|
+
export const CONFIG_NAMED_FILES = [['motion', 'animations_from'], ['generation', 'control', 'skeleton']] as const;
|
|
24
|
+
/** What `rig-parts rig` writes into its --out and remesh reads from --rig: the rig spec, its motion, the mesh report and the part images. */
|
|
25
|
+
export const RIG_FILES = ['rig.json', 'motion.json', 'mesh_report.json'] as const;
|
|
26
|
+
const MAX_LISTED = 4096;
|
|
27
|
+
|
|
28
|
+
export interface HandoffDeclaration { config: string; parts?: string; source?: string; full?: string; head?: string }
|
|
29
|
+
export type HandoffFlags = HandoffDeclaration;
|
|
30
|
+
export const HANDOFF_FLAGS = ['config', 'parts', 'source', 'full', 'head'] as const;
|
|
31
|
+
export interface HandoffFile { path: string; sha256: string }
|
|
32
|
+
/** The identity record: paths relative to the package root (`../` only for the discovered parent config), every hashed file. */
|
|
33
|
+
export interface HandoffRecord {
|
|
34
|
+
schema: typeof HANDOFF_SCHEMA;
|
|
35
|
+
how: 'declared' | 'discovered';
|
|
36
|
+
via: 'open --config' | 'review.json producer' | 'discovery';
|
|
37
|
+
outside: boolean;
|
|
38
|
+
config: HandoffFile;
|
|
39
|
+
parts: string | null; rig: string | null; source: HandoffFile | null; full: string | null; head: string | null;
|
|
40
|
+
files: HandoffFile[];
|
|
41
|
+
missing: string[];
|
|
42
|
+
}
|
|
43
|
+
/** The record plus the absolute paths the producer is run with (never served, never in the identity). */
|
|
44
|
+
/** `source` (#109): the declared source painting's absolute path, which `propose --from-config` reads; null when none was declared. */
|
|
45
|
+
/** `full` / `head` (#115): the declared See-through runs' absolute paths, which `rig-parts build` reads; null when none was declared. */
|
|
46
|
+
export interface Handoff { record: HandoffRecord; config: string; parts: string | null; rig: string | null; root: string; flags?: HandoffFlags; source?: string | null; full?: string | null; head?: string | null }
|
|
47
|
+
|
|
48
|
+
export class HandoffError extends Error {
|
|
49
|
+
constructor(readonly code: string, readonly field: string, readonly detail: string) { super(`${code} ${field}: ${detail}`); }
|
|
50
|
+
}
|
|
51
|
+
const sha256 = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex');
|
|
52
|
+
const inside = (base: string, path: string, allowSelf = false) => { const rel = relative(base, path); return rel === '' ? allowSelf : rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel); };
|
|
53
|
+
const code = (e: unknown) => (e as NodeJS.ErrnoException).code ?? '';
|
|
54
|
+
async function exists(path: string): Promise<boolean> { try { await lstat(path); return true; } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ENOTDIR') return false; throw e; } }
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One handoff path: it must exist, its canonical location must stay inside `base`, and it must be the expected kind.
|
|
58
|
+
* `lexicalInside` says whether the path as written was inside the root, so a canonical escape is named a symlink escape.
|
|
59
|
+
*/
|
|
60
|
+
async function contained(base: string, path: string, field: string, kind: 'file' | 'dir', lexicalInside: boolean): Promise<string> {
|
|
61
|
+
if (!(await exists(path))) throw new HandoffError('HANDOFF_INPUT_MISSING', field, `${path} does not exist`);
|
|
62
|
+
let real: string;
|
|
63
|
+
try { real = await realpath(path); } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ELOOP') throw new HandoffError('HANDOFF_SYMLINK_ESCAPE', field, `${path} is a dangling or looping symlink`); throw e; }
|
|
64
|
+
if (!inside(base, real, kind === 'dir')) {
|
|
65
|
+
if (lexicalInside) throw new HandoffError('HANDOFF_SYMLINK_ESCAPE', field, `${path} resolves outside ${base} (symlinks may not escape)`);
|
|
66
|
+
throw new HandoffError('HANDOFF_PATH_OUTSIDE_ROOT', field, `${path} is outside ${base}`);
|
|
67
|
+
}
|
|
68
|
+
const info = await stat(real);
|
|
69
|
+
if (kind === 'file' ? !info.isFile() : !info.isDirectory()) throw new HandoffError('HANDOFF_INPUT_INVALID', field, `${path} is not a ${kind === 'file' ? 'regular file' : 'directory'}`);
|
|
70
|
+
return real;
|
|
71
|
+
}
|
|
72
|
+
/** A CLI path: written relative to the working directory or absolute; canonical inside the package root. */
|
|
73
|
+
async function flagPath(root: string, value: string, field: string, kind: 'file' | 'dir'): Promise<string> {
|
|
74
|
+
const lexical = resolve(value);
|
|
75
|
+
// The parent chain is canonicalized so a platform alias (/tmp → /private/tmp) is not taken for an escape; the last
|
|
76
|
+
// component is left as written so a symlink there is named as one.
|
|
77
|
+
let parent: string;
|
|
78
|
+
try { parent = await realpath(dirname(lexical)); } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ENOTDIR') throw new HandoffError('HANDOFF_INPUT_MISSING', field, `${value} does not exist`); throw e; }
|
|
79
|
+
const written = join(parent, basename(lexical));
|
|
80
|
+
return contained(root, written, field, kind, inside(root, written, kind === 'dir'));
|
|
81
|
+
}
|
|
82
|
+
/** A review.json `producer` path: literal, portable, relative to the package root. */
|
|
83
|
+
async function blockPath(root: string, value: unknown, field: string, kind: 'file' | 'dir'): Promise<string> {
|
|
84
|
+
if (typeof value !== 'string' || !value) throw new HandoffError('HANDOFF_PATH_INVALID', field, 'expected a nonempty literal relative path');
|
|
85
|
+
if (isAbsolute(value) || /^[a-zA-Z]:/.test(value) || value.split('/').includes('..')) throw new HandoffError('HANDOFF_PATH_OUTSIDE_ROOT', field, `${value} leaves the package root (absolute or ..)`);
|
|
86
|
+
if (/[:\\%?#\x00-\x1f]/.test(value) || value.split('/').some(s => !s || s === '.') || posix.normalize(value) !== value) throw new HandoffError('HANDOFF_PATH_INVALID', field, `${value} is not a literal portable relative path`);
|
|
87
|
+
return contained(root, join(root, value), field, kind, true);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `producer: {config, parts?, source?, full?, head?}` — strings only, no other key. Paths are checked at load. */
|
|
91
|
+
export function parseHandoffDeclaration(value: unknown): HandoffDeclaration | undefined {
|
|
92
|
+
if (value === undefined) return undefined;
|
|
93
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new HandoffError('HANDOFF_PATH_INVALID', 'producer', 'expected an object {config, parts?, source?, full?, head?}');
|
|
94
|
+
const v = value as Record<string, unknown>;
|
|
95
|
+
for (const key of Object.keys(v)) if (!(HANDOFF_FLAGS as readonly string[]).includes(key)) throw new HandoffError('HANDOFF_PATH_INVALID', `producer.${key}`, 'unknown key; expected config, parts, source, full, head');
|
|
96
|
+
if (typeof v.config !== 'string') throw new HandoffError('HANDOFF_PATH_INVALID', 'producer.config', 'required: the producer config path');
|
|
97
|
+
const out: HandoffDeclaration = { config: v.config };
|
|
98
|
+
for (const key of ['parts', 'source', 'full', 'head'] as const) if (v[key] !== undefined) { if (typeof v[key] !== 'string') throw new HandoffError('HANDOFF_PATH_INVALID', `producer.${key}`, 'expected a string path'); out[key] = v[key] as string; }
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** `--source` and `--full` travel together, `--head` only with them, and everything else only with `--config`. */
|
|
102
|
+
export function checkHandoffFlags(flags: Partial<HandoffFlags>): string | null {
|
|
103
|
+
const present = HANDOFF_FLAGS.filter(f => flags[f] !== undefined);
|
|
104
|
+
if (!present.length) return null;
|
|
105
|
+
if (flags.config === undefined) return `--${present[0]}: requires --config <path>`;
|
|
106
|
+
if ((flags.source === undefined) !== (flags.full === undefined)) return `--${flags.source === undefined ? 'full' : 'source'}: --source <png> and --full <dir> are given together`;
|
|
107
|
+
if (flags.head !== undefined && flags.full === undefined) return '--head: requires --source <png> --full <dir>';
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function hashFile(path: string): Promise<string> { return sha256(await readFile(path)); }
|
|
112
|
+
/** Regular files directly in `dir` (a fixed directory, not a search); each must stay inside `base`. */
|
|
113
|
+
async function listed(base: string, dir: string, field: string): Promise<string[]> {
|
|
114
|
+
const entries = (await readdir(dir, { withFileTypes: true })).filter(e => !e.isDirectory()).map(e => e.name).sort();
|
|
115
|
+
if (entries.length > MAX_LISTED) throw new HandoffError('HANDOFF_INPUT_INVALID', field, `${dir} holds more than ${MAX_LISTED} files`);
|
|
116
|
+
const out: string[] = [];
|
|
117
|
+
for (const name of entries) { const real = await contained(base, join(dir, name), `${field}/${name}`, 'file', true).catch(e => { if (e instanceof HandoffError && e.code === 'HANDOFF_INPUT_INVALID') return null; throw e; }); if (real) out.push(real); }
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Hash every handoff file and build the record. `root` is the package root (display paths are relative to it). */
|
|
122
|
+
async function record(root: string, base: string, how: HandoffRecord['how'], via: HandoffRecord['via'], config: string, partsPath: string | null, extra: { source?: string; full?: string; head?: string }, partsDeclared: boolean): Promise<Handoff> {
|
|
123
|
+
const show = (path: string) => relative(root, path).split(sep).join('/') || '.';
|
|
124
|
+
const files = new Map<string, string>(), missing: string[] = [];
|
|
125
|
+
const add = async (path: string) => { files.set(show(path), await hashFile(path)); };
|
|
126
|
+
await add(config);
|
|
127
|
+
// Files the config names that the producer reads, resolved against the config's own directory.
|
|
128
|
+
let parsed: unknown = null;
|
|
129
|
+
try { parsed = JSON.parse(await readFile(config, 'utf8')); } catch { /* the producer refuses an unreadable config by name */ }
|
|
130
|
+
for (const keys of CONFIG_NAMED_FILES) {
|
|
131
|
+
let value: unknown = parsed;
|
|
132
|
+
for (const k of keys) value = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>)[k] : undefined;
|
|
133
|
+
if (typeof value !== 'string' || !value) continue;
|
|
134
|
+
const field = `config ${keys.join('.')}`, lexical = resolve(dirname(config), value);
|
|
135
|
+
if (!inside(base, lexical)) throw new HandoffError('HANDOFF_PATH_OUTSIDE_ROOT', field, `${value} resolves outside ${base}`);
|
|
136
|
+
if (!(await exists(lexical))) { missing.push(`${keys.join('.')} ${show(lexical)}`); continue; }
|
|
137
|
+
await add(await contained(base, lexical, field, 'file', true));
|
|
138
|
+
}
|
|
139
|
+
let parts: string | null = null, rig: string | null = null;
|
|
140
|
+
if (partsPath && (partsDeclared || await exists(partsPath))) {
|
|
141
|
+
parts = await contained(base, partsPath, partsDeclared ? '--parts' : 'parts', 'dir', true);
|
|
142
|
+
const partsJson = join(parts, 'parts.json');
|
|
143
|
+
if (await exists(partsJson)) await add(await contained(base, partsJson, 'parts.json', 'file', true)); else missing.push(`parts.json ${show(partsJson)}`);
|
|
144
|
+
if (await exists(join(parts, 'parts'))) for (const file of await listed(base, await contained(base, join(parts, 'parts'), 'parts/', 'dir', true), 'parts')) await add(file);
|
|
145
|
+
const rigDir = join(parts, 'rig');
|
|
146
|
+
if (await exists(rigDir)) {
|
|
147
|
+
rig = await contained(base, rigDir, 'rig', 'dir', true);
|
|
148
|
+
for (const name of RIG_FILES) if (!(await exists(join(rig, name)))) missing.push(`rig ${show(join(rig, name))}`);
|
|
149
|
+
// Every file remesh carries over: the rig directory's regular files (rig.json, motion.json, mesh_report.json, sets.json, …) and images/.
|
|
150
|
+
for (const file of await listed(base, rig, 'rig')) await add(file);
|
|
151
|
+
if (await exists(join(rig, 'images'))) for (const file of await listed(base, await contained(base, join(rig, 'images'), 'rig/images', 'dir', true), 'rig/images')) await add(file);
|
|
152
|
+
} else missing.push(`rig ${show(rigDir)}`);
|
|
153
|
+
} else missing.push(`parts ${partsPath ? show(partsPath) : '(none)'}`);
|
|
154
|
+
let source: HandoffFile | null = null;
|
|
155
|
+
if (extra.source) { await add(extra.source); source = { path: show(extra.source), sha256: files.get(show(extra.source))! }; }
|
|
156
|
+
for (const dir of [extra.full, extra.head]) if (dir) for (const file of await listed(base, dir, show(dir))) await add(file);
|
|
157
|
+
const sorted = [...files].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([path, hash]) => ({ path, sha256: hash }));
|
|
158
|
+
return {
|
|
159
|
+
record: { schema: HANDOFF_SCHEMA, how, via, outside: !inside(root, config), config: { path: show(config), sha256: files.get(show(config))! }, parts: parts ? show(parts) : null, rig: rig ? show(rig) : null, source, full: extra.full ? show(extra.full) : null, head: extra.head ? show(extra.head) : null, files: sorted, missing },
|
|
160
|
+
config, parts, rig, root: base, source: extra.source ?? null, full: extra.full ?? null, head: extra.head ?? null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The one handoff lookup (used by load, identity and `/api/rebuild`): CLI flags, else the review.json block, else the
|
|
166
|
+
* fixed discovery order. `root` is the canonical package root (the opened folder). A declared path that is missing,
|
|
167
|
+
* outside its root or a symlink escape is refused by name; nothing found is `{handoff: null}` with the refusal line.
|
|
168
|
+
*/
|
|
169
|
+
export async function resolveHandoff(root: string, declared: HandoffDeclaration | undefined, flags: HandoffFlags | undefined): Promise<{ handoff: Handoff | null; reason: string | null }> {
|
|
170
|
+
if (flags) {
|
|
171
|
+
const problem = checkHandoffFlags(flags);
|
|
172
|
+
if (problem) throw new HandoffError('HANDOFF_FLAGS_INVALID', problem.split(':')[0]!, problem);
|
|
173
|
+
const config = await flagPath(root, flags.config, '--config', 'file');
|
|
174
|
+
const parts = flags.parts !== undefined ? await flagPath(root, flags.parts, '--parts', 'dir') : join(dirname(config), 'out');
|
|
175
|
+
const source = flags.source !== undefined ? await flagPath(root, flags.source, '--source', 'file') : undefined;
|
|
176
|
+
const full = flags.full !== undefined ? await flagPath(root, flags.full, '--full', 'dir') : undefined;
|
|
177
|
+
const head = flags.head !== undefined ? await flagPath(root, flags.head, '--head', 'dir') : undefined;
|
|
178
|
+
const handoff = await record(root, root, 'declared', 'open --config', config, parts, { source, full, head }, flags.parts !== undefined);
|
|
179
|
+
// Canonical absolute flags: the background worker and later reloads read exactly these.
|
|
180
|
+
handoff.flags = { config, ...(flags.parts !== undefined ? { parts } : {}), ...(source ? { source } : {}), ...(full ? { full } : {}), ...(head ? { head } : {}) };
|
|
181
|
+
return { handoff, reason: null };
|
|
182
|
+
}
|
|
183
|
+
if (declared) {
|
|
184
|
+
const config = await blockPath(root, declared.config, 'producer.config', 'file');
|
|
185
|
+
const parts = declared.parts !== undefined ? await blockPath(root, declared.parts, 'producer.parts', 'dir') : join(dirname(config), 'out');
|
|
186
|
+
const source = declared.source !== undefined ? await blockPath(root, declared.source, 'producer.source', 'file') : undefined;
|
|
187
|
+
const full = declared.full !== undefined ? await blockPath(root, declared.full, 'producer.full', 'dir') : undefined;
|
|
188
|
+
const head = declared.head !== undefined ? await blockPath(root, declared.head, 'producer.head', 'dir') : undefined;
|
|
189
|
+
if ((source === undefined) !== (full === undefined) || (head !== undefined && full === undefined)) throw new HandoffError('HANDOFF_FLAGS_INVALID', 'producer', 'source and full are given together, head only with them');
|
|
190
|
+
return { handoff: await record(root, root, 'declared', 'review.json producer', config, parts, { source, full, head }, declared.parts !== undefined), reason: null };
|
|
191
|
+
}
|
|
192
|
+
for (const name of ['config.json', 'rig/config.json']) {
|
|
193
|
+
const path = join(root, name);
|
|
194
|
+
if (!(await exists(path))) continue;
|
|
195
|
+
const config = await contained(root, path, name, 'file', true);
|
|
196
|
+
return { handoff: await record(root, root, 'discovered', 'discovery', config, join(dirname(path), 'out'), {}, false), reason: null };
|
|
197
|
+
}
|
|
198
|
+
// The last step: an opened `out/` whose config sits beside it, one level up. Outside the folder, so named.
|
|
199
|
+
if (basename(root) === 'out') {
|
|
200
|
+
const parent = dirname(root), path = join(parent, 'config.json');
|
|
201
|
+
if (await exists(path)) {
|
|
202
|
+
const config = await contained(parent, path, '../config.json', 'file', true);
|
|
203
|
+
if (config !== path) throw new HandoffError('HANDOFF_SYMLINK_ESCAPE', '../config.json', `${path} is a symlink; only the file beside out/ is discovered`);
|
|
204
|
+
return { handoff: await record(root, parent, 'discovered', 'discovery', config, root, {}, false), reason: null };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { handoff: null, reason: MODELING_INPUT_UNAVAILABLE };
|
|
208
|
+
}
|