@rig-c/review 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +242 -7
  2. package/docs/DESIGN.md +33 -5
  3. package/docs/REVIEW_PACKAGE.md +115 -0
  4. package/package.json +1 -1
  5. package/src/applied-context.ts +285 -0
  6. package/src/baseline.ts +175 -0
  7. package/src/bone-anchors.ts +123 -0
  8. package/src/bones-check.ts +105 -0
  9. package/src/build-route.ts +109 -0
  10. package/src/chest-landmarks.ts +122 -0
  11. package/src/chest-regions.ts +65 -6
  12. package/src/cli.ts +19 -8
  13. package/src/config-patch.ts +213 -0
  14. package/src/cut-edits.ts +184 -0
  15. package/src/environment.ts +226 -0
  16. package/src/feedback-export.ts +25 -1
  17. package/src/front.ts +11 -0
  18. package/src/handoff.ts +208 -0
  19. package/src/idle-tracks.ts +401 -0
  20. package/src/manifest.ts +15 -6
  21. package/src/mesh-edit.ts +317 -0
  22. package/src/mesh-entry.ts +351 -0
  23. package/src/open.ts +12 -5
  24. package/src/physics-tuning.ts +236 -0
  25. package/src/producer-files.ts +11 -0
  26. package/src/producer.ts +158 -0
  27. package/src/readings-run.ts +129 -0
  28. package/src/readings.ts +235 -0
  29. package/src/rebuild.ts +938 -0
  30. package/src/reference.ts +4 -3
  31. package/src/requirements-run.ts +160 -0
  32. package/src/requirements.ts +230 -0
  33. package/src/retarget.ts +211 -0
  34. package/src/retune-route.ts +52 -0
  35. package/src/rig-fields.ts +295 -0
  36. package/src/server.ts +59 -5
  37. package/src/session.ts +12 -0
  38. package/src/setup-mesh.ts +144 -0
  39. package/src/weights.ts +74 -0
  40. package/web/bones-panel.ts +230 -0
  41. package/web/chest-editor.ts +294 -0
  42. package/web/chest-regions-tool.ts +10 -4
  43. package/web/cuts-panel.ts +266 -0
  44. package/web/idle-panel.ts +280 -0
  45. package/web/index.html +2 -2
  46. package/web/inspector-blocks.ts +77 -0
  47. package/web/main.ts +813 -24
  48. package/web/mesh-editor.ts +303 -0
  49. package/web/mesh-panel.ts +273 -0
  50. package/web/part-selection.ts +13 -5
  51. package/web/part-thumbnails.ts +76 -0
  52. package/web/physics-panel.ts +151 -0
  53. package/web/readings-panel.ts +105 -0
  54. package/web/rebuild-client.ts +216 -0
  55. package/web/requirements-panel.ts +238 -0
  56. package/web/retarget-panel.ts +203 -0
  57. package/web/rig-fields-panel.ts +269 -0
  58. package/web/rig-overlays.ts +171 -14
  59. package/web/setup-pose.ts +30 -0
  60. package/web/styles.css +52 -5
  61. package/web/weights-view.ts +159 -0
  62. package/web/workspace-preferences.ts +17 -12
  63. package/web/workspace-ui.ts +5 -3
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Mesh panel (issue #106), pure: one part's `meshes.<part>` entry as the installed producer's config loader reads it
3
+ * (`@rig-c/parts` AUTHORING §3, `meshes.<part>` rows), what `rig-parts remesh --mesh` takes. Review edits the two modes
4
+ * remesh documents with a field set this panel draws — `grid` (the lattice) and `contour` (the outline mode) — and
5
+ * reads the rest as written. `auto` is documented by remesh but has no controls here; `influence` is a config mode
6
+ * that `remesh --help` does not name, so it is not offered (that half is the producer's to document).
7
+ *
8
+ * The pre-checks mirror the config loader's rules and names only (`CONFIG_MESH_MODE`, `CONFIG_FIELD_PRESENT`,
9
+ * `CONFIG_FIELD_TYPE`, `CONFIG_REGION_NAME_UNIQUE`, `CONFIG_NAME_RESOLVES`, `CONFIG_RIBS_MODE`, `CONFIG_STRAY_MODE`).
10
+ * What the outline itself refuses at the rig stage — a spacing whose keep radius snaps to 0 (`CONTOUR_PARAMETER`), a
11
+ * bridge no quad makes (`CONTOUR_BRIDGE`), a heat source (`RIG_HEAT_*`), a remesh rule (`REMESH_*`) — is the
12
+ * producer's to say, in its words: those lines come back verbatim. No IO, no clock.
13
+ */
14
+ import { applyConfigPatch, jsonDiff, parsePointer, pointerToken, type ConfigPatch, type JsonValue, type PatchOp } from './config-patch.ts';
15
+
16
+ export const MESH_PANEL_SCHEMA = 'rig-review-mesh-entry/1';
17
+ /** The 1/256 px grid every region number sits on (parts `GRID`). */
18
+ export const MESH_GRID = 256;
19
+ export type MeshMode = 'grid' | 'contour' | 'auto' | 'influence';
20
+ export const MESH_MODES: readonly MeshMode[] = ['grid', 'contour', 'auto', 'influence'];
21
+ /** The modes this panel edits: the two whose fields remesh documents and the panel draws. */
22
+ export const EDITABLE_MODES: readonly MeshMode[] = ['grid', 'contour'];
23
+ /** Every key the loader accepts on `meshes.<part>` (parts `checkMeshes`), in its order. */
24
+ export const ENTRY_KEYS = ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule', 'ribs', 'influences', 'stray', 'influence'] as const;
25
+ /** `contour`'s keys, in the loader's order. */
26
+ export const CONTOUR_KEYS = ['tolerance', 'margin', 'spacing', 'maxEdge', 'budget', 'stray', 'bridges', 'regions', 'bust'] as const;
27
+
28
+ export type Point = [number, number];
29
+ export type Segment = string | [string, Point, Point];
30
+ export type MeshRegion =
31
+ | { name: string; shape: 'circle'; cx: number; cy: number; r: number; bone: string; spacing: number; band: number }
32
+ | { name: string; shape: 'polygon'; points: Point[]; bone: string; spacing: number; band: number };
33
+ export type MeshEntry = Record<string, JsonValue>;
34
+
35
+ export class MeshEntryError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
36
+ const isObject = (v: unknown): v is Record<string, JsonValue> => !!v && typeof v === 'object' && !Array.isArray(v);
37
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
38
+ const whole = (v: unknown, min: number) => typeof v === 'number' && Number.isInteger(v) && v >= min;
39
+ const onGrid = (v: number) => Number.isInteger(v * MESH_GRID);
40
+
41
+ /** The entry's mode, or the loader's refusal when it states none or several. */
42
+ export function meshMode(entry: unknown, part: string): { mode: MeshMode } | { error: string } {
43
+ if (!isObject(entry)) return { error: `CONFIG_FIELD_TYPE: config.meshes.${part} — is not an object` };
44
+ const modes = MESH_MODES.filter(m => m in entry);
45
+ if (modes.length === 1) return { mode: modes[0]! };
46
+ return { error: `CONFIG_MESH_MODE: config.meshes.${part} — ${modes.length ? `has ${modes.join(' and ')}` : 'has no mode'}; exactly one of grid, contour, auto and influence is required` };
47
+ }
48
+ /** Why the panel draws no controls for a mode (null when it does). */
49
+ export function modeNotEditable(mode: MeshMode): string | null {
50
+ if (mode === 'auto') return 'MESH_PANEL_MODE: an auto entry is remeshed by the producer but this panel draws no auto controls; edit it in the config';
51
+ if (mode === 'influence') return 'MESH_PANEL_MODE: remesh --help names grid, contour and auto entries, not influence; an influence entry waits for the producer to document it';
52
+ return null;
53
+ }
54
+
55
+ /** A slider: review's drawing range (never a bound — the number field takes any value), and the producer's rule in its words. */
56
+ export interface MeshParam {
57
+ key: string; path: readonly string[]; mode: 'grid' | 'contour' | 'both';
58
+ min: number; max: number; step: number; optional: boolean; integer: boolean; rule: string;
59
+ }
60
+ /** AUTHORING §3, `meshes.<part>` and `meshes.<part>.contour`: what each field is, as the producer states it. */
61
+ export const MESH_PARAMS: readonly MeshParam[] = [
62
+ { key: 'tolerance', path: ['contour', 'tolerance'], mode: 'contour', min: 0, max: 10, step: 0.5, optional: false, integer: false, rule: "rig-c's Douglas–Peucker tolerance on the traced outline, px, 0 or more" },
63
+ { key: 'margin', path: ['contour', 'margin'], mode: 'contour', min: 0, max: 10, step: 1, optional: false, integer: false, rule: 'how far the silhouette grows before it is traced, px: 0, or 1 or more' },
64
+ { key: 'spacing', path: ['contour', 'spacing'], mode: 'contour', min: 4, max: 80, step: 1, optional: false, integer: false, rule: 'the background interior spacing, px, above 0' },
65
+ { key: 'maxEdge', path: ['contour', 'maxEdge'], mode: 'contour', min: 4, max: 160, step: 1, optional: true, integer: false, rule: 'the longest outline edge, px, above 0 (absent: nothing is cut)' },
66
+ { key: 'budget', path: ['contour', 'budget'], mode: 'contour', min: 3, max: 2000, step: 1, optional: true, integer: true, rule: 'the most vertices the mesh may have (refused above it, nothing thinned)' },
67
+ { key: 'stray', path: ['contour', 'stray'], mode: 'contour', min: 0, max: 2000, step: 1, optional: true, integer: true, rule: 'the largest island, in art pixels, that may be left out of the mesh' },
68
+ { key: 'grid', path: ['grid'], mode: 'grid', min: 2, max: 80, step: 1, optional: false, integer: true, rule: 'the lattice: cell size, px (a whole number, 1 or more)' },
69
+ { key: 'stray', path: ['stray'], mode: 'grid', min: 0, max: 2000, step: 1, optional: true, integer: true, rule: 'the largest island, in art pixels, that may be left out of the lattice' },
70
+ { key: 'r', path: ['r'], mode: 'both', min: 0, max: 60, step: 1, optional: false, integer: false, rule: 'added to every distance before weighting: w = 1/(d + r)²' },
71
+ { key: 'exponent', path: ['exponent'], mode: 'both', min: 0.5, max: 8, step: 0.1, optional: true, integer: false, rule: 'a number above 0 that replaces the 2 in w = 1/(d + r)^exponent (absent is 2)' },
72
+ { key: 'maxInfluences', path: ['influences', 'maxInfluences'], mode: 'both', min: 1, max: 8, step: 1, optional: true, integer: true, rule: 'the most bones that pull one vertex (absent is 4)' },
73
+ ];
74
+ export const RULES = ['distance', 'heat'] as const;
75
+ export const FLOORS = ['cut', 'continuous'] as const;
76
+ export const paramsFor = (mode: MeshMode) => MESH_PARAMS.filter(p => p.mode === 'both' || p.mode === mode);
77
+
78
+ /** The value at a path inside the entry, or undefined. */
79
+ export function entryValue(entry: MeshEntry, path: readonly string[]): JsonValue | undefined {
80
+ let at: JsonValue | undefined = entry;
81
+ for (const key of path) at = isObject(at) && Object.hasOwn(at, key) ? at[key] : undefined;
82
+ return at;
83
+ }
84
+ /**
85
+ * A copy of the entry with one field set (or removed with undefined). A new key goes where the loader lists it, so
86
+ * the pending entry keeps the config's order and only the edited field differs. An emptied `influences` is removed.
87
+ */
88
+ export function setEntryValue(entry: MeshEntry, path: readonly string[], value: JsonValue | undefined): MeshEntry {
89
+ const out = structuredClone(entry);
90
+ let holder: Record<string, JsonValue> = out;
91
+ for (const key of path.slice(0, -1)) {
92
+ if (!isObject(holder[key])) { if (value === undefined) return out; holder[key] = {}; }
93
+ holder = holder[key] as Record<string, JsonValue>;
94
+ }
95
+ const last = path.at(-1)!;
96
+ if (value === undefined) delete holder[last];
97
+ else holder[last] = value;
98
+ const order = path.length === 1 ? ENTRY_KEYS as readonly string[] : path[0] === 'contour' ? CONTOUR_KEYS as readonly string[] : path[0] === 'influences' ? ['maxInfluences', 'floor'] : null;
99
+ if (order && value !== undefined) reorder(holder, order);
100
+ if (isObject(out.influences) && !Object.keys(out.influences).length) delete out.influences;
101
+ if (isObject(out.ribs) && !Object.keys(out.ribs).length) delete out.ribs;
102
+ return out;
103
+ }
104
+ /** Keys the order names first, in that order; any other key keeps its place after them. */
105
+ function reorder(holder: Record<string, JsonValue>, order: readonly string[]): void {
106
+ const known = order.filter(k => Object.hasOwn(holder, k)), rest = Object.keys(holder).filter(k => !order.includes(k));
107
+ const before = Object.keys(holder);
108
+ // An entry as written keeps its own order: only a key that was not there moves into the loader's place.
109
+ const sorted = [...known, ...rest];
110
+ if (before.join() === sorted.join()) return;
111
+ const copy = { ...holder };
112
+ for (const k of before) delete holder[k];
113
+ for (const k of sorted) holder[k] = copy[k]!;
114
+ }
115
+
116
+ /** The region list of a contour entry, as written (empty when it states none). */
117
+ export function entryRegions(entry: MeshEntry): MeshRegion[] {
118
+ const regions = entryValue(entry, ['contour', 'regions']);
119
+ return Array.isArray(regions) ? regions.filter(isObject) as unknown as MeshRegion[] : [];
120
+ }
121
+ export const withRegions = (entry: MeshEntry, regions: readonly MeshRegion[]): MeshEntry => setEntryValue(entry, ['contour', 'regions'], regions.length ? regions.map(r => structuredClone(r)) as unknown as JsonValue : undefined);
122
+ /** Whole rig px: every whole number is a multiple of 1/256, the loader's grid. */
123
+ export const snapPoint = (x: number, y: number): Point => [Math.round(x), Math.round(y)];
124
+ /** A new polygon region from at least three placed points, its bone, spacing and band. */
125
+ export function polygonRegion(name: string, points: readonly Point[], bone: string, spacing: number, band: number): MeshRegion {
126
+ if (points.length < 3) throw new MeshEntryError('MESH_POLYGON_POINTS', `${name} has ${points.length} point(s); a polygon region needs 3 or more`);
127
+ return { name, shape: 'polygon', points: points.map(p => snapPoint(p[0], p[1])), bone, spacing, band };
128
+ }
129
+ /** Move one vertex of a polygon region (or a circle's centre, vertex 0) to a rig px point on the whole-pixel grid. */
130
+ export function moveRegionPoint(regions: readonly MeshRegion[], index: number, vertex: number, to: Point): MeshRegion[] {
131
+ return regions.map((r, i) => {
132
+ if (i !== index) return structuredClone(r);
133
+ const [x, y] = snapPoint(to[0], to[1]);
134
+ if (r.shape === 'circle') return { ...r, cx: x, cy: y };
135
+ return { ...r, points: r.points.map((p, k) => (k === vertex ? [x, y] as Point : [p[0], p[1]] as Point)) };
136
+ });
137
+ }
138
+ /** A free region name: `<stem>`, else `<stem>_2`, `<stem>_3`, … */
139
+ export function freeRegionName(regions: readonly MeshRegion[], stem = 'region'): string {
140
+ const names = new Set(regions.map(r => r.name));
141
+ if (!names.has(stem)) return stem;
142
+ for (let i = 2; ; i++) if (!names.has(`${stem}_${i}`)) return `${stem}_${i}`;
143
+ }
144
+ /** The nearest region handle (polygon vertex or circle centre) to a point in pane px, within `radius`. */
145
+ export function hitRegionPoint(regions: readonly MeshRegion[], project: (x: number, y: number) => { x: number; y: number }, at: { x: number; y: number }, radius = 9): { region: number; vertex: number } | null {
146
+ let best: { region: number; vertex: number; d: number } | null = null;
147
+ regions.forEach((r, region) => {
148
+ const points: Point[] = r.shape === 'circle' ? [[r.cx, r.cy]] : r.points;
149
+ points.forEach((p, vertex) => {
150
+ const q = project(p[0], p[1]), d = Math.hypot(q.x - at.x, q.y - at.y);
151
+ if (d <= radius && (!best || d < best.d)) best = { region, vertex, d };
152
+ });
153
+ });
154
+ return best ? { region: (best as { region: number }).region, vertex: (best as { vertex: number }).vertex } : null;
155
+ }
156
+
157
+ /** The entry's segments as written. */
158
+ export const entrySegments = (entry: MeshEntry): Segment[] => (Array.isArray(entry.segments) ? entry.segments as unknown as Segment[] : []);
159
+ /** Add a bone (or chain) name from the bone list; a name already listed is not added twice. */
160
+ export function addSegment(entry: MeshEntry, name: string): MeshEntry {
161
+ if (!name || entrySegments(entry).some(s => s === name)) return entry;
162
+ return setEntryValue(entry, ['segments'], [...entrySegments(entry), name] as unknown as JsonValue);
163
+ }
164
+ export function removeSegment(entry: MeshEntry, index: number): MeshEntry {
165
+ const list = entrySegments(entry).filter((_, i) => i !== index);
166
+ let out = setEntryValue(entry, ['segments'], list as unknown as JsonValue);
167
+ // A rib keyed by a chain the segments no longer name would be refused (CONFIG_NAME_RESOLVES): it goes with it.
168
+ const ribs = entryValue(out, ['ribs']), named = new Set(list.filter((s): s is string => typeof s === 'string'));
169
+ if (isObject(ribs)) for (const chain of Object.keys(ribs)) if (!named.has(chain)) out = setEntryValue(out, ['ribs', chain], undefined);
170
+ return out;
171
+ }
172
+ /** Ribs stations for one chain (null removes the chain's ribs). */
173
+ export const setRibs = (entry: MeshEntry, chain: string, stations: number | null): MeshEntry => setEntryValue(entry, ['ribs', chain], stations === null ? undefined : { stations });
174
+ /** The bone names a segment may name: the loaded model's bones, plus any string the entry already lists (a chain). */
175
+ export function segmentChoices(bones: readonly string[], entry: MeshEntry): string[] {
176
+ const listed = entrySegments(entry).filter((s): s is string => typeof s === 'string');
177
+ return [...new Set([...listed, ...bones])].sort((a, b) => a.localeCompare(b));
178
+ }
179
+
180
+ /**
181
+ * The config loader's rules for one entry, each a line `CODE: config.meshes.<part>.<field> — <detail>` in the
182
+ * loader's names. `bones`, when given, are the loaded model's bone names: a region bone outside them cannot be one
183
+ * the config declares. Empty when nothing is refused here; the rig stage may still refuse, in its own words.
184
+ */
185
+ export function precheckEntry(part: string, entry: unknown, bones: readonly string[] | null = null): string[] {
186
+ const out: string[] = [], at = `config.meshes.${part}`;
187
+ const fail = (code: string, path: string, detail: string) => out.push(`${code}: ${at}${path} — ${detail}`);
188
+ if (!isObject(entry)) return [`CONFIG_FIELD_TYPE: ${at} — is not an object`];
189
+ for (const key of Object.keys(entry)) if (!(ENTRY_KEYS as readonly string[]).includes(key)) fail('CONFIG_KEY_KNOWN', `.${key}`, 'is not a field of a mesh entry');
190
+ const mode = meshMode(entry, part);
191
+ if ('error' in mode) out.push(mode.error);
192
+ if (!('influence' in entry)) for (const key of ['r', 'segments']) if (!(key in entry)) fail('CONFIG_FIELD_PRESENT', `.${key}`, 'is absent and required');
193
+ if ('grid' in entry && !whole(entry.grid, 1)) fail('CONFIG_FIELD_TYPE', '.grid', `is ${JSON.stringify(entry.grid)}; a whole number, 1 or more, is required`);
194
+ if ('r' in entry && !(finite(entry.r) && entry.r >= 0)) fail('CONFIG_FIELD_TYPE', '.r', `is ${JSON.stringify(entry.r)}; a non-negative number is required`);
195
+ if ('exponent' in entry && !(finite(entry.exponent) && entry.exponent > 0)) fail('CONFIG_FIELD_TYPE', '.exponent', `is ${JSON.stringify(entry.exponent)}; a positive number is required`);
196
+ if ('rule' in entry && !(RULES as readonly unknown[]).includes(entry.rule)) fail('CONFIG_FIELD_TYPE', '.rule', `is ${JSON.stringify(entry.rule)}; "distance" or "heat" is required`);
197
+ if ('exponent' in entry && entry.rule === 'heat') fail('CONFIG_FIELD_TYPE', '.exponent', 'is declared beside rule "heat"; the exponent belongs to the distance rule — remove exponent, or declare rule "distance"');
198
+ if ('segments' in entry) {
199
+ if (!Array.isArray(entry.segments) || !entry.segments.length) fail('CONFIG_FIELD_TYPE', '.segments', 'a non-empty list is required');
200
+ else entry.segments.forEach((s, i) => {
201
+ if (typeof s === 'string' && s) return;
202
+ const point = (p: unknown) => Array.isArray(p) && p.length === 2 && p.every(finite);
203
+ if (Array.isArray(s) && s.length === 3 && typeof s[0] === 'string' && point(s[1]) && point(s[2])) {
204
+ if (bones && !bones.includes(s[0])) fail('CONFIG_NAME_RESOLVES', `.segments[${i}][0]`, `names "${s[0]}", which is not a bone`);
205
+ return;
206
+ }
207
+ fail('CONFIG_FIELD_TYPE', `.segments[${i}]`, 'a chain name, a bone name, or [bone, [x, y], [x, y]] is required');
208
+ });
209
+ }
210
+ if ('ribs' in entry) {
211
+ if ('grid' in entry) fail('CONFIG_RIBS_MODE', '.ribs', 'is declared on a lattice mesh (grid); ribs are placed by the contour and auto modes');
212
+ const named = new Set((Array.isArray(entry.segments) ? entry.segments : []).filter((s): s is string => typeof s === 'string'));
213
+ if (!isObject(entry.ribs) || !Object.keys(entry.ribs).length) fail('CONFIG_FIELD_TYPE', '.ribs', 'an object of chain name -> { stations } naming one or more chains is required');
214
+ else for (const [chain, value] of Object.entries(entry.ribs)) {
215
+ if (!named.has(chain)) fail('CONFIG_NAME_RESOLVES', `.ribs.${chain}`, `names "${chain}", which this mesh's segments do not name`);
216
+ if (!isObject(value) || !whole(value.stations, 0)) fail('CONFIG_FIELD_TYPE', `.ribs.${chain}.stations`, 'a whole number, 0 or more, is required');
217
+ }
218
+ }
219
+ if ('influences' in entry) {
220
+ const l = entry.influences;
221
+ if (!isObject(l) || (!('maxInfluences' in l) && !('floor' in l))) fail('CONFIG_FIELD_TYPE', '.influences', 'names neither maxInfluences nor floor');
222
+ else {
223
+ if ('maxInfluences' in l && l.maxInfluences !== null && !whole(l.maxInfluences, 1)) fail('CONFIG_FIELD_TYPE', '.influences.maxInfluences', `is ${JSON.stringify(l.maxInfluences)}; an integer at or above 1, or null, is required`);
224
+ if ('floor' in l && !(FLOORS as readonly unknown[]).includes(l.floor)) fail('CONFIG_FIELD_TYPE', '.influences.floor', `is ${JSON.stringify(l.floor)}; "cut" or "continuous" is required`);
225
+ }
226
+ }
227
+ if ('stray' in entry) {
228
+ if ('contour' in entry && !('grid' in entry)) fail('CONFIG_STRAY_MODE', '.stray', 'is declared on a contour mesh; that mode states it in contour.stray');
229
+ else if (!whole(entry.stray, 0)) fail('CONFIG_FIELD_TYPE', '.stray', 'a whole number, 0 or more, is required');
230
+ }
231
+ if ('contour' in entry) precheckContour(entry.contour, `${at}.contour`, bones, out);
232
+ return out;
233
+ }
234
+ function precheckContour(v: JsonValue, at: string, bones: readonly string[] | null, out: string[]): void {
235
+ const fail = (code: string, path: string, detail: string) => out.push(`${code}: ${at}${path} — ${detail}`);
236
+ if (!isObject(v)) { out.push(`CONFIG_FIELD_TYPE: ${at} — an object is required`); return; }
237
+ for (const key of Object.keys(v)) if (!(CONTOUR_KEYS as readonly string[]).includes(key)) fail('CONFIG_KEY_KNOWN', `.${key}`, 'is not a field of contour');
238
+ for (const key of ['tolerance', 'margin', 'spacing']) if (!(key in v)) fail('CONFIG_FIELD_PRESENT', `.${key}`, 'is absent and required');
239
+ if ('tolerance' in v && !(finite(v.tolerance) && v.tolerance >= 0)) fail('CONFIG_FIELD_TYPE', '.tolerance', `is ${JSON.stringify(v.tolerance)}; a non-negative number is required`);
240
+ if ('margin' in v) {
241
+ if (!(finite(v.margin) && v.margin >= 0)) fail('CONFIG_FIELD_TYPE', '.margin', `is ${JSON.stringify(v.margin)}; a non-negative number is required`);
242
+ else if (v.margin > 0 && v.margin < 1) fail('CONFIG_FIELD_TYPE', '.margin', `is ${v.margin}; 0, or 1 px or more, is required`);
243
+ }
244
+ for (const key of ['spacing', 'maxEdge']) if (key in v && !(finite(v[key]) && (v[key] as number) > 0)) fail('CONFIG_FIELD_TYPE', `.${key}`, `is ${JSON.stringify(v[key])}; a positive number is required`);
245
+ if ('budget' in v && !whole(v.budget, 3)) fail('CONFIG_FIELD_TYPE', '.budget', `is ${JSON.stringify(v.budget)}; a whole number, 3 or more, is required`);
246
+ if ('stray' in v && !whole(v.stray, 0)) fail('CONFIG_FIELD_TYPE', '.stray', `is ${JSON.stringify(v.stray)}; a whole number, 0 or more, is required`);
247
+ if ('bridges' in v && v.bridges !== true) fail('CONFIG_FIELD_TYPE', '.bridges', `is ${JSON.stringify(v.bridges)}; true is required — remove the field for the one-island rule`);
248
+ if (!('regions' in v)) return;
249
+ if (!Array.isArray(v.regions)) { fail('CONFIG_FIELD_TYPE', '.regions', 'a list is required'); return; }
250
+ const names = new Set<string>();
251
+ v.regions.forEach((rv, i) => {
252
+ const rat = `.regions[${i}]`;
253
+ if (!isObject(rv)) { fail('CONFIG_FIELD_TYPE', rat, 'an object is required'); return; }
254
+ const grid = (path: string, n: unknown, positive: boolean | null) => {
255
+ if (!finite(n) || (positive === true && n <= 0) || (positive === false && n < 0)) fail('CONFIG_FIELD_TYPE', path, `is ${JSON.stringify(n)}; a ${positive === true ? 'positive ' : positive === false ? 'non-negative ' : ''}number is required`);
256
+ else if (!onGrid(n)) fail('CONFIG_FIELD_TYPE', path, `is ${n}; a multiple of 1/${MESH_GRID} px is required`);
257
+ };
258
+ if (typeof rv.name !== 'string' || !rv.name) fail('CONFIG_FIELD_TYPE', `${rat}.name`, 'a string is required');
259
+ else if (names.has(rv.name)) fail('CONFIG_REGION_NAME_UNIQUE', `${rat}.name`, `"${rv.name}" is declared twice in this mesh; each region needs its own name`);
260
+ else names.add(rv.name);
261
+ if (typeof rv.bone !== 'string' || !rv.bone || (bones && !bones.includes(rv.bone))) fail('CONFIG_NAME_RESOLVES', `${rat}.bone`, `is ${JSON.stringify(rv.bone)}; a bone config.bones declares is required — the region's control bone`);
262
+ if (!(finite(rv.spacing) && rv.spacing > 0)) fail('CONFIG_FIELD_TYPE', `${rat}.spacing`, `is ${JSON.stringify(rv.spacing)}; a positive number is required`);
263
+ grid(`${rat}.band`, rv.band, false);
264
+ if (rv.shape === 'circle') { grid(`${rat}.cx`, rv.cx, null); grid(`${rat}.cy`, rv.cy, null); grid(`${rat}.r`, rv.r, true); }
265
+ else if (rv.shape === 'polygon') {
266
+ if (!Array.isArray(rv.points) || rv.points.length < 3) fail('CONFIG_FIELD_TYPE', `${rat}.points`, `has ${Array.isArray(rv.points) ? rv.points.length : 0} point(s); 3 or more [x, y] in rig pixels are required`);
267
+ else rv.points.forEach((p, k) => { if (!Array.isArray(p) || p.length !== 2) fail('CONFIG_FIELD_TYPE', `${rat}.points[${k}]`, '[x, y] is required'); else { grid(`${rat}.points[${k}][0]`, p[0], null); grid(`${rat}.points[${k}][1]`, p[1], null); } });
268
+ } else fail('CONFIG_FIELD_TYPE', `${rat}.shape`, `is ${JSON.stringify(rv.shape)}; "circle" (with cx, cy, r) or "polygon" (with points) is required`);
269
+ });
270
+ }
271
+
272
+ /** One differing field of the pending entry against the config's: its pointer inside the entry and both values. */
273
+ export interface EntryChange { path: string; before: JsonValue | undefined; after: JsonValue | undefined }
274
+ const at = (doc: JsonValue, pointer: string): JsonValue | undefined => {
275
+ let v: JsonValue | undefined = doc;
276
+ for (const t of parsePointer(pointer)) v = Array.isArray(v) ? v[Number(t)] : isObject(v) && Object.hasOwn(v, t) ? v[t] : undefined;
277
+ return v;
278
+ };
279
+ export function entryChanges(config: MeshEntry, pending: MeshEntry): EntryChange[] {
280
+ return jsonDiff(config, pending).map(path => ({ path, before: at(config, path), after: at(pending, path) }));
281
+ }
282
+
283
+ /**
284
+ * Bar 3 (#106): the submitted patch is one op, `replace /meshes/<part>` with the whole pending entry, run by `remesh
285
+ * --part <part>`. Nothing else in the config moves: the server's `remeshPart` already holds every op under
286
+ * `/meshes/<part>`, and this holds the patch to that one path.
287
+ */
288
+ export function meshPatchOps(part: string, entry: MeshEntry): PatchOp[] {
289
+ return [{ op: 'replace', path: `/meshes/${pointerToken(part)}`, value: structuredClone(entry) }];
290
+ }
291
+ export function assertMeshPatch(patch: Pick<ConfigPatch, 'ops' | 'op'>, part: string): void {
292
+ const path = `/meshes/${pointerToken(part)}`;
293
+ if (patch.op.cmd !== 'remesh' || patch.op.args[patch.op.args.indexOf('--part') + 1] !== part) throw new MeshEntryError('MESH_PATCH_SCOPE', `a mesh patch runs remesh --part ${part}`);
294
+ if (patch.ops.length !== 1 || patch.ops[0]!.path !== path || patch.ops[0]!.op !== 'replace') throw new MeshEntryError('MESH_PATCH_SCOPE', `a mesh patch is one op, replace ${path}; this one has ${patch.ops.length}: ${patch.ops.map(o => `${o.op} ${o.path}`).join(', ')}`);
295
+ }
296
+ /** Every config pointer the patch changes (applied to the config it was made against). */
297
+ export const patchChangedPaths = (config: JsonValue, ops: readonly PatchOp[]) => jsonDiff(config, applyConfigPatch(config, ops));
298
+
299
+ /** `remesh.json`'s part counts (spec `rig-parts-remesh/1`, `target.before` / `target.after`). */
300
+ export interface MeshCounts { vertices: number; triangles: number; hull: number }
301
+ export interface RemeshCounts { part: string; before: MeshCounts; after: MeshCounts }
302
+ const counts = (v: unknown): MeshCounts | null => (isObject(v) && whole(v.vertices, 0) && whole(v.triangles, 0) && whole(v.hull, 0) ? { vertices: v.vertices as number, triangles: v.triangles as number, hull: v.hull as number } : null);
303
+ /** The part and its before/after counts, or null when the document does not carry them in that shape. */
304
+ export function remeshCounts(doc: unknown): RemeshCounts | null {
305
+ if (!isObject(doc) || doc.spec !== 'rig-parts-remesh/1' || !isObject(doc.target) || !isObject(doc.request) || typeof doc.request.part !== 'string') return null;
306
+ const before = counts(doc.target.before), after = counts(doc.target.after);
307
+ return before && after ? { part: doc.request.part, before, after } : null;
308
+ }
309
+ export const countsText = (c: MeshCounts) => `v ${c.vertices} · t ${c.triangles} · hull ${c.hull}`;
310
+ /**
311
+ * The triangles of a slot's mesh attachment in a compiled model (`rigc-compiled/3`: `skins[].attachments.<slot>.<name>`
312
+ * with `kind: "mesh"` and a flat `triangles` index list): the attachment named like the slot when there is one, else
313
+ * the slot's only mesh attachment. Null when the slot draws no mesh.
314
+ */
315
+ export function modelMeshTriangles(model: unknown, slot: string): number | null {
316
+ const skins = isObject(model) && Array.isArray(model.skins) ? model.skins : [];
317
+ const found: { name: string; triangles: number }[] = [];
318
+ for (const skin of skins) {
319
+ const attachments = isObject(skin) && isObject(skin.attachments) ? skin.attachments[slot] : undefined;
320
+ if (!isObject(attachments)) continue;
321
+ for (const [name, a] of Object.entries(attachments)) if (isObject(a) && a.kind === 'mesh' && Array.isArray(a.triangles)) found.push({ name, triangles: a.triangles.length / 3 });
322
+ }
323
+ return (found.find(f => f.name === slot) ?? (found.length === 1 ? found[0] : undefined))?.triangles ?? null;
324
+ }
325
+ /**
326
+ * Bar 1 (#106): the revision's triangle count — the compiled model's attachment — equals `remesh.json`'s after-count,
327
+ * and the overlay draws that many triangles for the slot. Each figure is shown; a missing one is said by name.
328
+ */
329
+ export function triangleAgreement(remesh: RemeshCounts | null, model: number | null, overlay: number | null): { ok: boolean; line: string } {
330
+ if (!remesh) return { ok: false, line: 'MESH_COUNTS_UNAVAILABLE: remesh.json carries no part counts' };
331
+ const after = remesh.after.triangles, show = (n: number | null) => (n === null ? 'not read' : String(n));
332
+ const ok = model === after && overlay === after;
333
+ return { ok, line: `${ok ? 'MESH_COUNTS_AGREE' : 'MESH_COUNTS_DIFFER'}: remesh.json after t ${after} · revision model t ${show(model)} · overlay draws ${show(overlay)} triangle(s) on ${remesh.part}` };
334
+ }
335
+
336
+ /** What the panel says after an Apply: the producer's line verbatim on a refusal (bar 2), the counts on a build. */
337
+ export function meshApplyOutcome(result: { status: string; error?: string; producerLine?: string | null; exitCode?: number | null; remesh?: RemeshCounts | null; id?: string }): { text: string; error: boolean } {
338
+ if (result.status !== 'built') return { text: result.producerLine ?? result.error ?? `REBUILD_FAILED: exit ${result.exitCode ?? '?'}`, error: true };
339
+ const c = result.remesh;
340
+ return { text: c ? `Remeshed ${c.part}: ${countsText(c.before)} → ${countsText(c.after)} (remesh.json) · Reset returns to the inspected build` : 'Remeshed · remesh.json carries no part counts', error: false };
341
+ }
342
+
343
+ /** The draft the panel keeps (`#mesh-pending`): the part and its pending entry, or null. */
344
+ export interface MeshDraft { schema: typeof MESH_PANEL_SCHEMA; part: string; entry: MeshEntry }
345
+ export const meshDraftText = (draft: MeshDraft | null) => (draft ? JSON.stringify(draft) : '');
346
+ export function parseMeshDraft(text: string): MeshDraft | null {
347
+ try {
348
+ const v = JSON.parse(text) as unknown;
349
+ return isObject(v) && v.schema === MESH_PANEL_SCHEMA && typeof v.part === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(v.part) && isObject(v.entry) ? { schema: MESH_PANEL_SCHEMA, part: v.part, entry: v.entry } : null;
350
+ } catch { return null; }
351
+ }
package/src/open.ts CHANGED
@@ -16,18 +16,24 @@ import {selectAnimation,AnimationSelectionError,type AnimationSelection} from '.
16
16
  import {capabilityHeader,capabilityPattern,type OpenIdentity} from './open-protocol.ts';
17
17
  import {ensureFront,frontPort} from './front.ts';
18
18
  import {playPackagePath} from './versions.ts';
19
+ import {HandoffError,HANDOFF_FLAGS,type HandoffFlags} from './handoff.ts';
19
20
  /** `direct` chooses how the page is reached (own port instead of the managed front); it is not part of the instance identity. */
20
- export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean;direct?:boolean}
21
+ export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean;direct?:boolean;config?:string;parts?:string;source?:string;full?:string;head?:string}
22
+ /** The producer handoff flags (#101) among the inputs, or undefined when none was given. */
23
+ export function handoffFlags(inputs:OpenInputs):HandoffFlags|undefined{const present=HANDOFF_FLAGS.filter(f=>inputs[f]!==undefined);if(!present.length)return undefined;return Object.fromEntries(present.map(f=>[f,inputs[f]])) as unknown as HandoffFlags;}
21
24
  export type OpenPackage=({mode:'single';p:ReviewPackage;inputs:OpenInputs;contentHash:string}|{mode:'compare';p:ComparePackage;inputs:OpenInputs;contentHash:string})&{selection:AnimationSelection|null};
22
25
  /** Private 0600 record. `capability` is the 256-bit page write capability, handed out only in the printed `#cap=` fragment. */
23
26
  export interface RecordFile extends OpenIdentity {schema:'rig-review-open-instance/1';pid:number;origin:string;mode:'single'|'compare';capability:string;controlToken?:string}
24
- interface WorkerRequest {schema:'rig-review-open-request/1';inputs:OpenInputs;identity:OpenIdentity;stateDir:string;registry:string;request:string;idleMs:number;controlToken?:string}
27
+ /** `cwd` is the launcher's working directory: the worker runs in the state directory, so Apply · Rebuild looks for the producer from here after the package's own directories. */
28
+ interface WorkerRequest {schema:'rig-review-open-request/1';inputs:OpenInputs;identity:OpenIdentity;stateDir:string;registry:string;request:string;idleMs:number;controlToken?:string;cwd?:string}
25
29
  const appRoot=fileURLToPath(new URL('..',import.meta.url));
26
30
  const digest=(value:unknown)=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
27
31
  const sleep=(ms:number)=>new Promise(resolveSleep=>setTimeout(resolveSleep,ms));
28
32
  export const defaultStateDir=()=>process.env.RIG_REVIEW_STATE_DIR??join(homedir(),'.local','state','rig-c-review');
29
33
  export async function loadOpenPackage(inputs:OpenInputs):Promise<OpenPackage>{
30
- if(!inputs.compare){const p=await loadReferencedReview(inputs.input,inputs.reference),selection=selectAnimation([{label:'single',catalog:p.feedbackCatalog,report:p.report}],inputs.animation,true);return {mode:'single',p,selection,contentHash:p.sourceHash,inputs:{input:p.manifestPath,...(selectedReference(p)?{reference:selectedReference(p)!.path}:{}),...(selection?{animation:selection.name}:{}),...(inputs.control?{control:true}:{})}};}
34
+ // Handoff flags become canonical absolute paths here, so the worker and every reload read exactly these files.
35
+ if(!inputs.compare){const p=await loadReferencedReview(inputs.input,inputs.reference,handoffFlags(inputs)),selection=selectAnimation([{label:'single',catalog:p.feedbackCatalog,report:p.report}],inputs.animation,true);return {mode:'single',p,selection,contentHash:p.sourceHash,inputs:{input:p.manifestPath,...(selectedReference(p)?{reference:selectedReference(p)!.path}:{}),...(selection?{animation:selection.name}:{}),...(inputs.control?{control:true}:{}),...(p.handoffInputs??{})}};}
36
+ if(handoffFlags(inputs))throw new HandoffError('HANDOFF_FLAGS_INVALID','--config','compare takes no producer handoff; open one package with --config');
31
37
  if(!inputs.reference)throw new Error('open compare: explicit --reference required');
32
38
  const [a,b]=await Promise.all([loadReview(inputs.input),loadReview(inputs.compare)]),selection=selectAnimation([{label:'a',catalog:a.feedbackCatalog,report:a.report},{label:'b',catalog:b.feedbackCatalog,report:b.report}],inputs.animation)!;
33
39
  const p=await loadCompare({a:inputs.input,b:inputs.compare,reference:inputs.reference,animation:selection.name});
@@ -83,6 +89,7 @@ async function assertOpenInput(path:string):Promise<void>{
83
89
  }
84
90
  async function openRefusal(error:unknown,inputs:OpenInputs):Promise<unknown>{
85
91
  if(error instanceof UnrecognizedOutputError){const first=await realpath(resolve(inputs.input)).catch(()=>null),path=inputs.compare!==undefined&&error.root!==first?inputs.compare:inputs.input;return new Error(`OPEN_INPUT_UNRECOGNIZED ${path}: ${error.message}`,{cause:error});}
92
+ if(error instanceof HandoffError)return new Error(`OPEN_HANDOFF_INVALID ${error.field}: ${error.code}: ${error.detail}`,{cause:error});
86
93
  if(!(error instanceof AnimationSelectionError))return error;
87
94
  const detail=(error.label==='a'||error.label==='b'?`side ${error.label}: `:'')+error.message.replace(/^open\.(?:[a-z]+\.)?animation: /,'');
88
95
  const name=error.kind==='empty'?'OPEN_OPTION_INVALID --animation':error.kind==='unknown'?`OPEN_ANIMATION_UNKNOWN ${JSON.stringify(error.animation)}`:error.kind==='ambiguous'?'OPEN_ANIMATION_AMBIGUOUS':'OPEN_ANIMATION_UNVALIDATABLE';
@@ -112,7 +119,7 @@ async function ensureWorker(p:OpenPackage,key:string,fingerprint:string,stateDir
112
119
  try{
113
120
  const row=await reuse();if(row)return {row,reused:true};
114
121
  const identity:OpenIdentity={key,contentHash:p.contentHash,nonce,fingerprint};request=join(canonicalControl,`${key}.${nonce}.request.json`);const log=join(canonicalControl,`${key}.${nonce}.log`);
115
- const spec:WorkerRequest={schema:'rig-review-open-request/1',inputs:p.inputs,identity,stateDir,registry,request,idleMs,...(p.inputs.control?{controlToken:randomUUID()}:{} )};await writeFile(request,JSON.stringify(spec),{mode:0o600,flag:'wx'});
122
+ const spec:WorkerRequest={schema:'rig-review-open-request/1',inputs:p.inputs,identity,stateDir,registry,request,idleMs,cwd:process.cwd(),...(p.inputs.control?{controlToken:randomUUID()}:{} )};await writeFile(request,JSON.stringify(spec),{mode:0o600,flag:'wx'});
116
123
  const logHandle=await openFile(log,'a',0o600);let child;try{child=spawn(process.execPath,[join(appRoot,'src/open-worker.ts'),request],{cwd:canonicalControl,detached:true,stdio:['ignore',logHandle.fd,logHandle.fd],env:process.env});spawned=child;child.unref();}finally{await logHandle.close();}
117
124
  let exited:string|undefined;child.on('error',error=>{exited=error.message;});child.on('exit',(code,signal)=>{exited=`exit ${code} ${signal??''}`;});
118
125
  while(performance.now()<deadline){const record=await reuse();if(record){handedOff=true;return {row:record,reused:false};}if(exited)throw new Error(`OPEN_START: ${exited}; worker log ${log}`);await sleep(30);}throw new Error(`OPEN_TIMEOUT: worker did not become ready; log ${log}`);
@@ -125,7 +132,7 @@ export async function runOpenWorker(requestPath:string):Promise<void>{
125
132
  if(spec.inputs.control&&(!spec.controlToken||!/^[a-f0-9-]{36}$/.test(spec.controlToken)))throw new Error('open control: invalid private capability');
126
133
  // The worker mints its own page capability; it lives only in this process and the private record.
127
134
  const capability=randomBytes(32).toString('hex');let last=performance.now();const options={port:0,stateDir:spec.stateDir,open:spec.identity,capability,activity:()=>{last=performance.now();},...(spec.inputs.control?{control:{token:spec.controlToken!,instanceKey:spec.identity.key}}:{})};
128
- const server=p.mode==='single'?await startServer(p.p,{...options,initialAnimation:p.selection?.name}):await startCompareServer(p.p,options);
135
+ const server=p.mode==='single'?await startServer(p.p,{...options,initialAnimation:p.selection?.name,...(typeof spec.cwd==='string'&&isAbsolute(spec.cwd)?{cwd:spec.cwd}:{})}):await startCompareServer(p.p,options);
129
136
  const check=()=>p.mode==='single'?assertReviewUnchanged(p.p):assertCompareUnchanged(p.p);
130
137
  await check();const record:RecordFile={schema:'rig-review-open-instance/1',...spec.identity,pid:process.pid,origin:`http://127.0.0.1:${server.port}`,mode:p.mode,capability,...(spec.inputs.control?{controlToken:spec.controlToken}:{})};const tmp=spec.registry+'.'+spec.identity.nonce+'.tmp';await writeFile(tmp,JSON.stringify(record),{mode:0o600,flag:'wx'});await rename(tmp,spec.registry);
131
138
  await unlink(requestPath).catch(e=>{if(e.code!=='ENOENT')throw e;});