@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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chest by landmarks (issue #105): the producer's `meshes.<part>.contour.bust` shorthand — three rig-px points (right
|
|
3
|
+
* apex, left apex, cleavage), `alpha`, `clearance`, optional bone names — and the two circles the producer derives from
|
|
4
|
+
* it (`bust_r`, `bust_l`). Pure: no DOM, no filesystem. Review draws these circles live and pre-checks the one geometric
|
|
5
|
+
* refusal (`CONFIG_BUST_CLEARANCE`); the producer derives them again from the entry and is the judge.
|
|
6
|
+
*
|
|
7
|
+
* The derivation is a transcription of `@rig-c/parts` 1.42.0 `src/config.ts` `deriveBustRegions` and the
|
|
8
|
+
* `CONFIG_BUST_CLEARANCE` test in its contour loader (docs/AUTHORING.md `meshes.<part>.contour`, issue #475):
|
|
9
|
+
*
|
|
10
|
+
* dR = edgeLength(kx - rx, ky - ry); dL = edgeLength(kx - lx, ky - ly)
|
|
11
|
+
* RR = Math.round(dR - clearance); RL = Math.round(dL - clearance)
|
|
12
|
+
* rR = Math.round(alpha * RR); rL = Math.round(alpha * RL)
|
|
13
|
+
* bust_r: cx rx, cy ry, r rR, band RR - rR; bust_l: cx lx, cy ly, r rL, band RL - rL
|
|
14
|
+
* refused when clearance <= 0 || RR + RL >= D || rR <= 0 || rL <= 0 || bandR < 0 || bandL < 0 (D = |apex_r − apex_l|)
|
|
15
|
+
*
|
|
16
|
+
* Coordinates: every config position is rig px (the whole figure's frame, the compiled model's stage when the model is
|
|
17
|
+
* the rig's crop frame). A part image is that frame less the part's box less the producer's pad (`PAD = 4` in
|
|
18
|
+
* `src/rig.ts`): rig = part image + (box.x − 4, box.y − 4). `partToRig` / `rigToPart` are that translation.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const BUST_SPEC_FIELDS = ['apex_r', 'apex_l', 'cleavage', 'alpha', 'clearance', 'bone_r', 'bone_l', 'spacing'] as const;
|
|
22
|
+
/** The producer's defaults (parts `DEFAULT_BUST_*`). */
|
|
23
|
+
export const BUST_DEFAULTS = { alpha: 0.22, clearance: 3.8, spacing: 14, bone_r: 'boob_r', bone_l: 'boob_l' } as const;
|
|
24
|
+
/** The transparent margin the producer pads every part image with (parts `src/rig.ts` `PAD`). */
|
|
25
|
+
export const PART_PAD = 4;
|
|
26
|
+
const GRID = 256;
|
|
27
|
+
|
|
28
|
+
export type RigPoint = [number, number];
|
|
29
|
+
/** The `contour.bust` entry as the config holds it; absent optional fields are the producer's defaults. */
|
|
30
|
+
export interface BustSpec { apex_r: RigPoint; apex_l: RigPoint; cleavage: RigPoint; alpha?: number; clearance?: number; bone_r?: string; bone_l?: string; spacing?: number }
|
|
31
|
+
/** A derived region in the config's circle shape (key order as parts writes it). */
|
|
32
|
+
export interface DerivedBustRegion { name: 'bust_r' | 'bust_l'; shape: 'circle'; cx: number; cy: number; r: number; spacing: number; band: number; bone: string }
|
|
33
|
+
export interface BustDerivation {
|
|
34
|
+
regions: [DerivedBustRegion, DerivedBustRegion];
|
|
35
|
+
/** The unrounded cleavage distances, the rounded outer radii and the apex distance the refusal reads. */
|
|
36
|
+
dR: number; dL: number; RR: number; RL: number; D: number;
|
|
37
|
+
/** `CONFIG_BUST_CLEARANCE` with the producer's detail, or null when the producer's test passes. */
|
|
38
|
+
refusal: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** parts `src/length.ts`: `sqrt(dx·dx + dy·dy)`, not `Math.hypot`. */
|
|
42
|
+
const edgeLength = (dx: number, dy: number) => Math.sqrt(dx * dx + dy * dy);
|
|
43
|
+
/** The refusal's printed figures: parts `src/round.ts` `pyRound(x, n)` is `toFixed(n)` except at an exact binary tie (display only). */
|
|
44
|
+
const pyRound = (value: number, digits: number) => Number(value.toFixed(digits));
|
|
45
|
+
|
|
46
|
+
/** The producer's derivation and its `CONFIG_BUST_CLEARANCE` test, on the same numbers. */
|
|
47
|
+
export function deriveBust(spec: BustSpec): BustDerivation {
|
|
48
|
+
const alpha = spec.alpha ?? BUST_DEFAULTS.alpha, clearance = spec.clearance ?? BUST_DEFAULTS.clearance, spacing = spec.spacing ?? BUST_DEFAULTS.spacing;
|
|
49
|
+
const boneR = spec.bone_r ?? BUST_DEFAULTS.bone_r, boneL = spec.bone_l ?? BUST_DEFAULTS.bone_l;
|
|
50
|
+
const [rx, ry] = spec.apex_r, [lx, ly] = spec.apex_l, [kx, ky] = spec.cleavage;
|
|
51
|
+
const D = edgeLength(lx - rx, ly - ry), dR = edgeLength(kx - rx, ky - ry), dL = edgeLength(kx - lx, ky - ly);
|
|
52
|
+
const RR = Math.round(dR - clearance), RL = Math.round(dL - clearance);
|
|
53
|
+
const rR = Math.round(alpha * RR), rL = Math.round(alpha * RL);
|
|
54
|
+
const bandR = RR - rR, bandL = RL - rL;
|
|
55
|
+
let refusal: string | null = null;
|
|
56
|
+
if (clearance <= 0 || RR + RL >= D || rR <= 0 || rL <= 0 || bandR < 0 || bandL < 0) {
|
|
57
|
+
const minClearance = Math.max(0, (dR + dL - D) / 2);
|
|
58
|
+
refusal = `CONFIG_BUST_CLEARANCE: contour.bust.clearance — is ${clearance} (giving R_r = ${RR}, R_l = ${RL}, R_r + R_l = ${RR + RL} px against apex distance D = ${pyRound(D, 2)} px, r_r = ${rR}, band_r = ${bandR}, r_l = ${rL}, band_l = ${bandL}); a clearance > 0 with R_r + R_l < D (clearance > ${pyRound(minClearance, 2)} px) and positive radii is required`;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
regions: [
|
|
62
|
+
{ name: 'bust_r', shape: 'circle', cx: rx, cy: ry, r: rR, spacing, band: bandR, bone: boneR },
|
|
63
|
+
{ name: 'bust_l', shape: 'circle', cx: lx, cy: ly, r: rL, spacing, band: bandL, bone: boneL },
|
|
64
|
+
],
|
|
65
|
+
dR, dL, RR, RL, D, refusal,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class BustSpecError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
|
|
70
|
+
const bad = (detail: string): never => { throw new BustSpecError('REBUILD_REQUEST_INVALID', detail); };
|
|
71
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
72
|
+
const onGrid = (v: number) => Number.isInteger(v * GRID);
|
|
73
|
+
function point(v: unknown, field: string): RigPoint {
|
|
74
|
+
if (!Array.isArray(v) || v.length !== 2 || !v.every(n => typeof n === 'number' && Number.isFinite(n))) bad(`${field} must be [x, y], finite rig px`);
|
|
75
|
+
const [x, y] = v as [number, number];
|
|
76
|
+
if (!onGrid(x) || !onGrid(y)) bad(`${field} is off the 1/${GRID} px grid`);
|
|
77
|
+
return [x, y];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A `contour.bust` request, typed as the producer's loader types it (points on the 1/256 grid, `alpha` and `spacing`
|
|
81
|
+
* positive numbers, `clearance` a number, bone names non-empty strings); the geometry (`CONFIG_BUST_CLEARANCE`) and
|
|
82
|
+
* whether the bones resolve (`CONFIG_NAME_RESOLVES`) are the producer's to judge. Key order is the producer's.
|
|
83
|
+
*/
|
|
84
|
+
export function parseBustSpec(value: unknown, field = 'bust'): BustSpec {
|
|
85
|
+
if (!isObject(value)) return bad(`${field} must be an object`);
|
|
86
|
+
for (const key of Object.keys(value)) if (!(BUST_SPEC_FIELDS as readonly string[]).includes(key)) bad(`${field}.${key} is not a contour.bust field`);
|
|
87
|
+
const out: BustSpec = { apex_r: point(value.apex_r, `${field}.apex_r`), apex_l: point(value.apex_l, `${field}.apex_l`), cleavage: point(value.cleavage, `${field}.cleavage`) };
|
|
88
|
+
for (const key of ['alpha', 'clearance', 'spacing'] as const) {
|
|
89
|
+
if (!(key in value)) continue;
|
|
90
|
+
const n = value[key];
|
|
91
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || (key !== 'clearance' && n <= 0)) bad(`${field}.${key} must be a finite number${key === 'clearance' ? '' : ' > 0'}`);
|
|
92
|
+
out[key] = n as number;
|
|
93
|
+
}
|
|
94
|
+
for (const key of ['bone_r', 'bone_l'] as const) {
|
|
95
|
+
if (!(key in value)) continue;
|
|
96
|
+
const name = value[key];
|
|
97
|
+
if (typeof name !== 'string' || !name || name.length > 128) bad(`${field}.${key} must name a bone`);
|
|
98
|
+
out[key] = name as string;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The part's `meshes.<part>` entry with `contour.regions` replaced by `contour.bust`: the landmarks are the chest, so
|
|
105
|
+
* the hand-written regions (whose circles the derived ones replace) are dropped; every other field is as written.
|
|
106
|
+
*/
|
|
107
|
+
export function bustMeshEntry(entry: Record<string, unknown>, spec: BustSpec): Record<string, unknown> {
|
|
108
|
+
const out = structuredClone(entry), contour = out.contour;
|
|
109
|
+
if (!isObject(contour)) throw new BustSpecError('REBUILD_PART_NOT_CONTOUR', 'the entry is not a contour; contour.bust applies to contour meshes only');
|
|
110
|
+
delete contour.regions;
|
|
111
|
+
const bust: Record<string, unknown> = {};
|
|
112
|
+
for (const key of BUST_SPEC_FIELDS) if (spec[key] !== undefined) bust[key] = Array.isArray(spec[key]) ? [...(spec[key] as RigPoint)] : spec[key];
|
|
113
|
+
contour.bust = bust;
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The part's box in rig px as `parts.json` states it (`x`, `y` of the unpadded art). */
|
|
118
|
+
export interface PartBox { x: number; y: number }
|
|
119
|
+
/** Part image px → rig px: the image's origin is the box less the pad. */
|
|
120
|
+
export const partToRig = ([x, y]: RigPoint, box: PartBox, pad = PART_PAD): RigPoint => [x + box.x - pad, y + box.y - pad];
|
|
121
|
+
/** Rig px → part image px. */
|
|
122
|
+
export const rigToPart = ([x, y]: RigPoint, box: PartBox, pad = PART_PAD): RigPoint => [x - box.x + pad, y - box.y + pad];
|
package/src/chest-regions.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
/** The generator's coordinate grid: every point is a multiple of 1/256 crop pixel. */
|
|
8
8
|
export const CHEST_REGION_GRID = 256;
|
|
9
9
|
export const CHEST_REGIONS_SPEC = 'family-chest-regions/1';
|
|
10
|
+
/** An export holds 1–8 regions, the same set Apply · Rebuild accepts (#118); the public examples hold two. */
|
|
11
|
+
export const CHEST_REGIONS_MAX = 8;
|
|
10
12
|
|
|
11
13
|
export type CropPoint = [number, number];
|
|
12
14
|
export type ChestSegment = string | [string, [number, number], [number, number]];
|
|
@@ -161,9 +163,11 @@ export function clampNonOverlapping(right: ChestCircle, left: ChestCircle, margi
|
|
|
161
163
|
return { right: r, left: l, clamped };
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/** The examples' sentence for their two regions; any other count lists the bones from screen-left to screen-right. */
|
|
164
167
|
function coordinatesText(crop: { w: number; h: number }, regions: ChestRegionInput[]): string {
|
|
165
|
-
const
|
|
166
|
-
|
|
168
|
+
const sorted = [...regions].sort((a, b) => a.center[0] - b.center[0]), frame = `${crop.w}x${crop.h} crop pixels, x screen-right and y down`;
|
|
169
|
+
if (sorted.length === 2) return `${frame}; ${sorted[0]!.bone} screen-left, ${sorted[1]!.bone} screen-right`;
|
|
170
|
+
return `${frame}; ${sorted.length === 1 ? `one region, ${sorted[0]!.bone}` : `${sorted.length} regions screen-left to screen-right: ${sorted.map(r => r.bone).join(', ')}`}`;
|
|
167
171
|
}
|
|
168
172
|
|
|
169
173
|
function segmentList(value: unknown): ChestSegment[] {
|
|
@@ -179,7 +183,7 @@ function segmentList(value: unknown): ChestSegment[] {
|
|
|
179
183
|
export function buildChestRegions(input: ChestRegionsInput): ChestRegionsDocument {
|
|
180
184
|
const w = finite(input.crop?.w, 'crop', 'w', 0, false), h = finite(input.crop?.h, 'crop', 'h', 0, false);
|
|
181
185
|
if (typeof input.generator !== 'string' || !input.generator.trim()) throw new ChestRegionError('CHEST_REGIONS_GENERATOR_MISSING', '');
|
|
182
|
-
if (input.regions.length
|
|
186
|
+
if (input.regions.length < 1 || input.regions.length > CHEST_REGIONS_MAX) throw new ChestRegionError('CHEST_REGIONS_COUNT', `${input.regions.length} (1–${CHEST_REGIONS_MAX})`);
|
|
183
187
|
const c = input.contour;
|
|
184
188
|
for (const field of ['tolerance', 'margin', 'spacing', 'maxEdge', 'stray'] as const) finite(c[field], 'contour', field, 0);
|
|
185
189
|
if (typeof c.bridges !== 'boolean') throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', 'contour bridges');
|
|
@@ -208,7 +212,9 @@ export function buildChestRegions(input: ChestRegionsInput): ChestRegionsDocumen
|
|
|
208
212
|
if (points.some(([x, y]) => x < 0 || y < 0 || x > w || y > h)) throw new ChestRegionError('CHEST_REGION_OUT_OF_CROP', name);
|
|
209
213
|
regions.push({ name, bone: region.bone, shape: 'polygon', points, spacing, band });
|
|
210
214
|
}
|
|
211
|
-
|
|
215
|
+
for (let i = 0; i < regions.length; i++) for (let j = i + 1; j < regions.length; j++) {
|
|
216
|
+
if (chestRegionsOverlap(regions[i]!, regions[j]!)) throw new ChestRegionError('CHEST_REGIONS_OVERLAP', `${regions[i]!.name} ${regions[j]!.name}`);
|
|
217
|
+
}
|
|
212
218
|
const mesh: ChestMesh = { contour: { tolerance: c.tolerance, margin: c.margin, spacing: c.spacing, maxEdge: c.maxEdge, stray: c.stray, bridges: c.bridges, regions } };
|
|
213
219
|
if (input.r !== undefined) mesh.r = finite(input.r, 'mesh', 'r', 0);
|
|
214
220
|
if (input.segments !== undefined) mesh.segments = segmentList(input.segments);
|
package/src/cli.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { formatFeedbackIssues } from './feedback-export.ts';
|
|
|
6
6
|
import {loadReferencedReview,selectedReference,assertReviewUnchanged} from './reference.ts';
|
|
7
7
|
import {animationDiagnostic,selectAnimation} from './animation-selection.ts';
|
|
8
8
|
import {shellWord} from './open-shell.ts';
|
|
9
|
-
import {openReview,controlReview,loadOpenPackage,defaultStateDir,type OpenInputs} from './open.ts';
|
|
9
|
+
import {openReview,controlReview,loadOpenPackage,defaultStateDir,handoffFlags,type OpenInputs} from './open.ts';
|
|
10
|
+
import {checkHandoffFlags,HANDOFF_FLAGS} from './handoff.ts';
|
|
10
11
|
import { readFeedbackSession, readFeedbackCompareSession } from './session-history.ts';
|
|
11
12
|
import { startServer } from './server.ts';
|
|
12
13
|
import { loadCompare } from './compare.ts';
|
|
@@ -15,7 +16,8 @@ import { readSession, type Feedback } from './session.ts';
|
|
|
15
16
|
import { readCompareSession } from './compare-session.ts';
|
|
16
17
|
import { exportReviewStrip, exportCompareStrip, stripJson, writeStripOutput, validateStripOutput } from './strip-export.ts';
|
|
17
18
|
import type { StripInterval, StripRequest } from './strip.ts';
|
|
18
|
-
const
|
|
19
|
+
const handoffUsage='[--config <path> [--parts <dir>] [--source <png> --full <dir> [--head <dir>]]]';
|
|
20
|
+
const usage='Usage: rig-review open <dir> [--reference <video>] [--compare <dir>] [--animation <name>] [--control] [--direct] '+handoffUsage+'\n --config declares the producer handoff (wins over the review.json "producer" block and discovery of config.json, rig/config.json, ../config.json beside an opened out/); feedback and control take the same flags.\n open is managed by default: one loopback front at 127.0.0.1:4318 (RIG_REVIEW_FRONT_PORT overrides) serves /instances/<key>/; --direct serves the worker on its own port.\n rig-review control <dir> [--reference <video>] [--compare <dir>] [--animation <name>] '+handoffUsage+' [--page <id> --expected-revision <n> --command <JSON> --wait-ms <n>]\n rig-review feedback <dir> [--reference <video>] [--compare <dir>] [--animation <name>] '+handoffUsage+' [--format json|issue]\n Usage: bun run review -- <review.json|output-directory> [--port 4317] [--checkpoint ID]\n bun run check -- <review.json|output-directory>\n bun run feedback -- <review.json|output-directory>\n bun run review -- compare <a> <b> --reference <video> --animation <name> [--port 4317]\n bun run check -- compare <a> <b> --reference <video> --animation <name>\n bun run feedback -- compare <a> <b> --reference <video> --animation <name>';
|
|
19
21
|
async function stripCommand(args:string[],options:{stateDir?:string}){
|
|
20
22
|
const compare=args[0]==='compare';if(compare)args.shift();const a=args.shift(),b=compare?args.shift():undefined;if(!a||compare&&!b)throw new Error('strip: expected package input(s)');
|
|
21
23
|
let reference:string|undefined,referenceVideo:string|undefined,animation:string|undefined,basis:'a'|'b'|undefined,range:string|undefined,output:string|undefined,jsonOnly=false,all=false;
|
|
@@ -33,15 +35,23 @@ async function stripCommand(args:string[],options:{stateDir?:string}){
|
|
|
33
35
|
else {if(basis)throw new Error('strip --basis: single package uses single basis');const p=await loadReferencedReview(a,referenceVideo);if(!reference)reference=selectedReference(p)?.binding.reference.id;roots=[p.root];if(all)intervals=fromFeedback((await readSession(p,options.stateDir)).feedback);if(output){await validateStripOutput(output,roots);if(!jsonOnly)await validateStripOutput(output+'.json',roots);}result=await exportReviewStrip(p,request(p.sourceHash));}
|
|
34
36
|
if(output){await writeStripOutput(result,output,roots);console.log(`Strip exported: ${output}${result.png?' (PNG + .json provenance)':''}`);}else console.log(stripJson(result).trimEnd());
|
|
35
37
|
}
|
|
38
|
+
/** `--config` → `config` for the producer handoff flags (#101), else null. */
|
|
39
|
+
function handoffOption(flag:string):(typeof HANDOFF_FLAGS)[number]|null {const name=flag.slice(2);return flag.startsWith('--')&&(HANDOFF_FLAGS as readonly string[]).includes(name)?name as (typeof HANDOFF_FLAGS)[number]:null;}
|
|
40
|
+
/** The retrieval words for the handoff flags, in their fixed order. */
|
|
41
|
+
const handoffWords=(inputs:OpenInputs)=>HANDOFF_FLAGS.flatMap(f=>inputs[f]!==undefined?['--'+f,inputs[f]!]:[]);
|
|
36
42
|
export function parseOpenInputs(args:string[]):OpenInputs {
|
|
37
43
|
const input=args.shift();if(!input||input.startsWith('--'))throw new Error(usage);const result:OpenInputs={input},seen=new Set<string>();
|
|
38
|
-
while(args.length){const flag=args.shift()!;if(seen.has(flag))throw new Error(`OPEN_OPTION_INVALID ${flag}: Duplicate open option ${flag}`);seen.add(flag);if(flag==='--control'){result.control=true;continue;}if(flag==='--direct'){result.direct=true;continue;}const value=args.shift();if(value===undefined||value==='')throw new Error(`OPEN_OPTION_INVALID ${flag}: Missing value for ${flag}`);if(flag==='--reference')result.reference=value;else if(flag==='--compare')result.compare=value;else if(flag==='--animation')result.animation=value;else throw new Error(`OPEN_OPTION_INVALID ${flag}: Unknown open option ${flag}`);}
|
|
44
|
+
while(args.length){const flag=args.shift()!;if(seen.has(flag))throw new Error(`OPEN_OPTION_INVALID ${flag}: Duplicate open option ${flag}`);seen.add(flag);if(flag==='--control'){result.control=true;continue;}if(flag==='--direct'){result.direct=true;continue;}const value=args.shift();if(value===undefined||value==='')throw new Error(`OPEN_OPTION_INVALID ${flag}: Missing value for ${flag}`);if(flag==='--reference')result.reference=value;else if(flag==='--compare')result.compare=value;else if(flag==='--animation')result.animation=value;else if(handoffOption(flag))result[handoffOption(flag)!]=value;else throw new Error(`OPEN_OPTION_INVALID ${flag}: Unknown open option ${flag}`);}
|
|
45
|
+
const problem=checkHandoffFlags(result);if(problem)throw new Error(`OPEN_OPTION_INVALID ${problem.split(':')[0]}: ${problem.slice(problem.indexOf(':')+2)}`);
|
|
46
|
+
if(result.compare!==undefined&&handoffFlags(result))throw new Error('OPEN_OPTION_INVALID --config: compare takes no producer handoff; open one package with --config');
|
|
47
|
+
return result;
|
|
39
48
|
}
|
|
40
49
|
async function controlCommand(args:string[],stateDir?:string){
|
|
41
50
|
const input=args.shift();if(!input||input==='--help'||input==='-h'){console.log(usage);return;}
|
|
42
51
|
const inputs:OpenInputs={input},options:Parameters<typeof controlReview>[1]={stateDir},seen=new Set<string>();
|
|
43
52
|
while(args.length){const flag=args.shift()!;if(seen.has(flag))throw new Error(`Duplicate control option ${flag}`);seen.add(flag);const value=args.shift();if(value===undefined||value==='')throw new Error(`Missing value for ${flag}`);
|
|
44
53
|
if(flag==='--reference')inputs.reference=value;else if(flag==='--compare')inputs.compare=value;else if(flag==='--animation')inputs.animation=value;
|
|
54
|
+
else if(handoffOption(flag))inputs[handoffOption(flag)!]=value;
|
|
45
55
|
else if(flag==='--page')options.pageId=value;else if(flag==='--request-id')options.requestId=value;
|
|
46
56
|
else if(flag==='--expected-revision'){const n=Number(value);if(!/^\d+$/.test(value)||!Number.isSafeInteger(n))throw new Error('--expected-revision: expected safe integer >= 0');options.expectedRevision=n;}
|
|
47
57
|
else if(flag==='--wait-ms'){const n=Number(value);if(!/^\d+$/.test(value)||!Number.isSafeInteger(n)||n>30000)throw new Error('--wait-ms: expected integer 0..30000');options.waitMs=n;}
|
|
@@ -73,7 +83,7 @@ export function feedbackFormat(args:string[]):{args:string[];format:'json'|'issu
|
|
|
73
83
|
function commandHelpRequested(args:readonly string[],command:'open'|'review'|'check'|'feedback'):boolean {
|
|
74
84
|
if(args[0]==='--help'||args[0]==='-h')return true;
|
|
75
85
|
const compare=command!=='open'&&args[0]==='compare';
|
|
76
|
-
const flags=command==='open'?['--reference','--compare','--animation']:compare?['--reference','--animation','--port']:['--port','--checkpoint','--reference','--compare','--animation'];
|
|
86
|
+
const handoff=HANDOFF_FLAGS.map(f=>'--'+f),flags=command==='open'?['--reference','--compare','--animation',...handoff]:compare?['--reference','--animation','--port']:['--port','--checkpoint','--reference','--compare','--animation',...handoff];
|
|
77
87
|
for(let i=compare?3:1;i<args.length;){
|
|
78
88
|
const flag=args[i];if(flag==='--help'||flag==='-h')return true;
|
|
79
89
|
if(command==='open'&&(flag==='--control'||flag==='--direct')){i++;continue;}if(!flags.includes(flag!))return false;i+=2;
|
|
@@ -84,7 +94,7 @@ export async function main(args:string[],options:{stateDir?:string}={}){
|
|
|
84
94
|
options={stateDir:options.stateDir??defaultStateDir()};if(args[0]==='--')args.shift();
|
|
85
95
|
if(args[0]==='control')return controlCommand(args.slice(1),options.stateDir);
|
|
86
96
|
if(args[0]==='open'){const openArgs=args.slice(1);if(commandHelpRequested(openArgs,'open')){console.log(usage);return;}const inputs=parseOpenInputs(openArgs);const result=await openReview(inputs,options);// Launcher contract (README): stdout is the URL line alone; stderr is diagnostic, control notice, retrieval, in that order.
|
|
87
|
-
console.log(result.url);if(result.selection)console.error(animationDiagnostic(result.selection));if(inputs.control)console.error('Inspection control enabled; query the current connected page with rig-review control and the same inputs.');console.error(`Review ${result.reused?'reused':'started'}; feedback: rig-review feedback ${[result.inputs.input,...(result.inputs.compare?['--compare',result.inputs.compare]:[]),...(result.inputs.reference?['--reference',result.inputs.reference]:[]),...(result.inputs.animation?['--animation',result.inputs.animation]:[])].map(shellWord).join(' ')}`);return;}
|
|
97
|
+
console.log(result.url);if(result.selection)console.error(animationDiagnostic(result.selection));if(inputs.control)console.error('Inspection control enabled; query the current connected page with rig-review control and the same inputs.');console.error(`Review ${result.reused?'reused':'started'}; feedback: rig-review feedback ${[result.inputs.input,...(result.inputs.compare?['--compare',result.inputs.compare]:[]),...(result.inputs.reference?['--reference',result.inputs.reference]:[]),...(result.inputs.animation?['--animation',result.inputs.animation]:[]),...handoffWords(result.inputs)].map(shellWord).join(' ')}`);return;}
|
|
88
98
|
if(args[0]==='strip'){args.shift();if(args.includes('--help')){console.log(usage+'\nStrip: strip <package> --reference <declared-video-id> --animation <name> (--frames FIRST:LAST | --all-feedback) [--json] [--out path]\n strip compare <a> <b> --reference <video> --animation <name> --basis a|b (--frames FIRST:LAST | --all-feedback) [--json] [--out path]');return;}return stripCommand(args,options);}let command:'review'|'check'|'feedback'='review';if(args[0]==='check'||args[0]==='feedback'){command=args.shift() as 'check'|'feedback';}
|
|
89
99
|
if(args[0]==='--help'||args[0]==='-h'){console.log(usage);return;}
|
|
90
100
|
let format:'json'|'issue'='json';if(command==='feedback'){const parsed=feedbackFormat(args);args=parsed.args;format=parsed.format;}
|
|
@@ -102,10 +112,11 @@ export async function main(args:string[],options:{stateDir?:string}={}){
|
|
|
102
112
|
if(command==='check'){console.log(`Comparison valid: ${p.response.title}\nSource SHA-256: ${p.response.sourceHash}\nAnimation: ${animation}\nReference SHA-256: ${p.response.reference.sha256}\nPlayback: two compiled models present`);return;}
|
|
103
113
|
const capability=randomBytes(32).toString('hex'),server=await startCompareServer(p,{port,stateDir:options.stateDir,capability});console.log(`Compare ready: http://127.0.0.1:${server.port}/#cap=${capability}\nComparison: ${p.response.title}\nSource SHA-256: ${p.response.sourceHash}\nPress Ctrl+C to stop.`);return;
|
|
104
114
|
}
|
|
105
|
-
const input=args.shift();if(!input)throw new Error(usage);let port=4317;let checkpoint:string|undefined,reference:string|undefined,compare:string|undefined,animation:string|undefined;
|
|
106
|
-
while(args.length){const flag=args.shift();const value=args.shift();if(!value)throw new Error(`Missing value for ${flag}`);if(flag==='--port'){port=Number(value);if(!Number.isInteger(port)||port<0||port>65535)throw new Error('--port: expected integer 0…65535');}else if(flag==='--checkpoint')checkpoint=value;else if(flag==='--reference')reference=value;else if(flag==='--compare')compare=value;else if(flag==='--animation')animation=value;else throw new Error(`Unknown option ${flag}\n${usage}`);}
|
|
115
|
+
const input=args.shift();if(!input)throw new Error(usage);let port=4317;let checkpoint:string|undefined,reference:string|undefined,compare:string|undefined,animation:string|undefined;const handoff:OpenInputs={input};
|
|
116
|
+
while(args.length){const flag=args.shift();const value=args.shift();if(!value)throw new Error(`Missing value for ${flag}`);if(handoffOption(flag!)){handoff[handoffOption(flag!)!]=value;continue;}if(flag==='--port'){port=Number(value);if(!Number.isInteger(port)||port<0||port>65535)throw new Error('--port: expected integer 0…65535');}else if(flag==='--checkpoint')checkpoint=value;else if(flag==='--reference')reference=value;else if(flag==='--compare')compare=value;else if(flag==='--animation')animation=value;else throw new Error(`Unknown option ${flag}\n${usage}`);}
|
|
117
|
+
const handoffProblem=checkHandoffFlags(handoff);if(handoffProblem)throw new Error(handoffProblem);if(compare&&handoffFlags(handoff))throw new Error('--config: compare takes no producer handoff');
|
|
107
118
|
if(compare){if(command!=='feedback')throw new Error('--compare: use open or feedback');const loaded=await loadOpenPackage({input,compare,reference,animation});if(loaded.mode!=='compare')throw new Error('Expected comparison');printFeedback(await readFeedbackCompareSession(loaded.p,options.stateDir));return;}
|
|
108
|
-
const p=await loadReferencedReview(input,reference);// Serving starts on the report's clip when it names one; check and feedback validate only an explicit flag.
|
|
119
|
+
const p=await loadReferencedReview(input,reference,handoffFlags(handoff));// Serving starts on the report's clip when it names one; check and feedback validate only an explicit flag.
|
|
109
120
|
const fromReport=command==='review'&&animation===undefined&&p.report?.animation!==undefined;
|
|
110
121
|
const selection=animation!==undefined||fromReport?selectAnimation([{label:'single',catalog:p.feedbackCatalog,report:p.report}],animation,true):null;const initialAnimation=selection?.name;if(command==='feedback'){const saved=await readFeedbackSession(p,options.stateDir);await assertReviewUnchanged(p);printFeedback(saved);return;}if(command==='check'){console.log(`Package valid: ${p.manifest.title}\nRevision: ${p.manifest.revision}\nSource SHA-256: ${p.sourceHash}\nDeclared files: ${p.files.size}\nPlayback: ${p.manifest.model?'compiled model present':'unavailable (no model)'}`);return;}
|
|
111
122
|
if(checkpoint&&!p.manifest.checkpoints.some(v=>v.id===checkpoint))throw new Error(`Unknown checkpoint ${checkpoint}`);
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config patch (#102): the one data form every edit card produces. `rig-review-config-patch/1` names the producer config
|
|
3
|
+
* bytes it was made against (`config_sha256`), a list of RFC 6902-style operations over that config addressed by RFC 6901
|
|
4
|
+
* JSON Pointers, the producer it was made for and the producer op that rebuilds from the patched config. Pure: no IO,
|
|
5
|
+
* no clock. Review applies a patch only to a scratch copy of the config; the package's config is never written.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
export const CONFIG_PATCH_SCHEMA = 'rig-review-config-patch/1';
|
|
10
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
11
|
+
export type PatchOpName = 'replace' | 'add' | 'remove';
|
|
12
|
+
export const PATCH_OPS: readonly PatchOpName[] = ['replace', 'add', 'remove'];
|
|
13
|
+
/**
|
|
14
|
+
* `name` (#103), optional: the `name` the entry at `/<list>/<index>` holds in the config the patch was made against — an
|
|
15
|
+
* edit card that addresses a named list entry by index records the name too, and the server refuses the patch
|
|
16
|
+
* (`EDIT_PATCH_NAME_MISMATCH`) unless the index resolves to that name.
|
|
17
|
+
*/
|
|
18
|
+
export interface PatchOp { op: PatchOpName; path: string; value?: JsonValue; name?: string }
|
|
19
|
+
/** `rig` rebuilds the whole rig from the patched config; `remesh` re-fits one part from its patched `meshes.<part>` entry. */
|
|
20
|
+
export type PatchCmd = 'rig' | 'remesh';
|
|
21
|
+
export const PATCH_CMDS: readonly PatchCmd[] = ['rig', 'remesh'];
|
|
22
|
+
export interface ConfigPatch {
|
|
23
|
+
schema: typeof CONFIG_PATCH_SCHEMA;
|
|
24
|
+
config_sha256: string;
|
|
25
|
+
ops: PatchOp[];
|
|
26
|
+
producer: { name: string; version: string };
|
|
27
|
+
op: { cmd: PatchCmd; args: string[] };
|
|
28
|
+
}
|
|
29
|
+
export const MAX_PATCH_OPS = 256;
|
|
30
|
+
/** Flags a patch may pass to the producer op: no path, no command, every value from a closed set. */
|
|
31
|
+
export const PATCH_FLAGS: Readonly<Record<string, readonly string[]>> = { '--idle-keys': ['ctl', 'direct'], '--page-edges': ['pot', 'free'], '--pack-shape': ['rect', 'polygon'] };
|
|
32
|
+
const PART = /^[A-Za-z0-9_-]{1,64}$/;
|
|
33
|
+
|
|
34
|
+
export class ConfigPatchError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
|
|
35
|
+
const invalid = (detail: string): never => { throw new ConfigPatchError('EDIT_PATCH_INVALID', detail); };
|
|
36
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
37
|
+
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
|
|
38
|
+
|
|
39
|
+
/** RFC 6901: "" is the whole document; otherwise "/" then tokens with "~1" = "/" and "~0" = "~"; any other "~" is invalid. */
|
|
40
|
+
export function parsePointer(pointer: unknown): string[] {
|
|
41
|
+
if (typeof pointer !== 'string') throw new ConfigPatchError('EDIT_PATCH_POINTER_INVALID', `${JSON.stringify(pointer)} is not a string`);
|
|
42
|
+
if (pointer === '') return [];
|
|
43
|
+
if (!pointer.startsWith('/')) throw new ConfigPatchError('EDIT_PATCH_POINTER_INVALID', `${JSON.stringify(pointer)} does not start with "/"`);
|
|
44
|
+
return pointer.slice(1).split('/').map(token => {
|
|
45
|
+
if (/~(?![01])/.test(token)) throw new ConfigPatchError('EDIT_PATCH_POINTER_INVALID', `${JSON.stringify(pointer)} has a "~" that is not "~0" or "~1"`);
|
|
46
|
+
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export const pointerToken = (token: string | number) => String(token).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
50
|
+
export const formatPointer = (tokens: readonly (string | number)[]) => tokens.map(t => '/' + pointerToken(t)).join('');
|
|
51
|
+
|
|
52
|
+
function isJson(value: unknown, depth = 0): value is JsonValue {
|
|
53
|
+
if (depth > 64) return false;
|
|
54
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') return true;
|
|
55
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
56
|
+
if (Array.isArray(value)) return value.every(v => isJson(v, depth + 1));
|
|
57
|
+
return isObject(value) && Object.values(value).every(v => isJson(v, depth + 1));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The producer op's flags: `--part <name>` for remesh (required, once), plus the closed-set flags, each at most once. */
|
|
61
|
+
function parseArgs(cmd: PatchCmd, args: unknown): string[] {
|
|
62
|
+
if (!Array.isArray(args) || args.length > 16 || !args.every(a => typeof a === 'string')) invalid('op.args must be an array of at most 16 strings');
|
|
63
|
+
const list = args as string[], seen = new Set<string>();
|
|
64
|
+
for (let i = 0; i < list.length; i += 2) {
|
|
65
|
+
const flag = list[i]!, value = list[i + 1];
|
|
66
|
+
if (seen.has(flag)) invalid(`op.args: ${flag} given twice`);
|
|
67
|
+
seen.add(flag);
|
|
68
|
+
if (value === undefined) invalid(`op.args: ${flag} has no value`);
|
|
69
|
+
if (flag === '--part' && cmd === 'remesh') { if (!PART.test(value!)) invalid('op.args: --part must name a part'); continue; }
|
|
70
|
+
const allowed = PATCH_FLAGS[flag];
|
|
71
|
+
if (!allowed) throw new ConfigPatchError('EDIT_PATCH_ARG_UNKNOWN', `op.args: ${flag} is not a flag a patch may pass to ${cmd} (allowed: ${[...(cmd === 'remesh' ? ['--part'] : []), ...Object.keys(PATCH_FLAGS)].join(', ')})`);
|
|
72
|
+
if (!allowed.includes(value!)) invalid(`op.args: ${flag} ${value} (allowed: ${allowed.join(' | ')})`);
|
|
73
|
+
}
|
|
74
|
+
if (cmd === 'remesh' && !seen.has('--part')) invalid('op.args: remesh needs --part <name>');
|
|
75
|
+
return [...list];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The patch document, exactly its keys; an unknown op name and a malformed pointer are refused by name. */
|
|
79
|
+
export function parseConfigPatch(input: unknown): ConfigPatch {
|
|
80
|
+
if (!isObject(input)) return invalid('expected an object');
|
|
81
|
+
const keys = Object.keys(input).sort().join(',');
|
|
82
|
+
if (keys !== 'config_sha256,op,ops,producer,schema') invalid('expected exactly {schema, config_sha256, ops, producer, op}');
|
|
83
|
+
if (input.schema !== CONFIG_PATCH_SCHEMA) invalid(`schema must be ${CONFIG_PATCH_SCHEMA}`);
|
|
84
|
+
if (typeof input.config_sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(input.config_sha256)) invalid('config_sha256 must be a lowercase SHA-256');
|
|
85
|
+
const producer = input.producer;
|
|
86
|
+
if (!isObject(producer) || Object.keys(producer).sort().join(',') !== 'name,version' || typeof producer.name !== 'string' || typeof producer.version !== 'string' || !producer.name || !producer.version || producer.name.length > 128 || producer.version.length > 64) invalid('producer must be exactly {name, version}');
|
|
87
|
+
const op = input.op;
|
|
88
|
+
if (!isObject(op) || Object.keys(op).sort().join(',') !== 'args,cmd') invalid('op must be exactly {cmd, args}');
|
|
89
|
+
const cmd = (op as Record<string, unknown>).cmd;
|
|
90
|
+
if (!PATCH_CMDS.includes(cmd as PatchCmd)) throw new ConfigPatchError('EDIT_PATCH_CMD_UNKNOWN', `op.cmd ${JSON.stringify(cmd)} (supported: ${PATCH_CMDS.join(', ')})`);
|
|
91
|
+
const args = parseArgs(cmd as PatchCmd, (op as Record<string, unknown>).args);
|
|
92
|
+
if (!Array.isArray(input.ops) || input.ops.length < 1 || input.ops.length > MAX_PATCH_OPS) invalid(`ops must hold 1–${MAX_PATCH_OPS} operations`);
|
|
93
|
+
const ops = (input.ops as unknown[]).map((raw, i): PatchOp => {
|
|
94
|
+
if (!isObject(raw)) return invalid(`ops[${i}] must be an object`);
|
|
95
|
+
if (!PATCH_OPS.includes(raw.op as PatchOpName)) throw new ConfigPatchError('EDIT_PATCH_OP_UNKNOWN', `ops[${i}].op ${JSON.stringify(raw.op)} (supported: ${PATCH_OPS.join(', ')})`);
|
|
96
|
+
const name = raw.op as PatchOpName, want = name === 'remove' ? 'op,path' : 'op,path,value';
|
|
97
|
+
const keys = Object.keys(raw).filter(k => k !== 'name').sort().join(',');
|
|
98
|
+
if (keys !== want) invalid(`ops[${i}]: ${name} holds exactly ${want.split(',').join(', ')} (and an optional name)`);
|
|
99
|
+
const tokens = parsePointer(raw.path);
|
|
100
|
+
if (!tokens.length) throw new ConfigPatchError('EDIT_PATCH_POINTER_INVALID', `ops[${i}].path "" would replace the whole config`);
|
|
101
|
+
if (name !== 'remove' && !isJson(raw.value)) invalid(`ops[${i}].value is not JSON`);
|
|
102
|
+
const entryName = Object.hasOwn(raw, 'name') ? raw.name : undefined;
|
|
103
|
+
if (entryName !== undefined && (typeof entryName !== 'string' || !entryName || entryName.length > 128 || !namedEntry(tokens))) invalid(`ops[${i}].name must be a non-empty string naming the entry ${namedListText(tokens)} holds`);
|
|
104
|
+
const tag = entryName === undefined ? {} : { name: entryName as string };
|
|
105
|
+
return name === 'remove' ? { op: name, path: raw.path as string, ...tag } : { op: name, path: raw.path as string, value: raw.value as JsonValue, ...tag };
|
|
106
|
+
});
|
|
107
|
+
return { schema: CONFIG_PATCH_SCHEMA, config_sha256: input.config_sha256 as string, ops, producer: { name: (producer as Record<string, string>).name!, version: (producer as Record<string, string>).version! }, op: { cmd: cmd as PatchCmd, args } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The patch's own bytes as review hashes them: key order fixed by the parser, no whitespace. */
|
|
111
|
+
export const patchText = (patch: ConfigPatch) => JSON.stringify(parseConfigPatch(patch));
|
|
112
|
+
export const patchSha256 = (patch: ConfigPatch) => sha256(patchText(patch));
|
|
113
|
+
|
|
114
|
+
/** `EDIT_PATCH_STALE` unless the patch was made against exactly these config bytes. */
|
|
115
|
+
export function assertPatchFresh(patch: Pick<ConfigPatch, 'config_sha256'>, configSha256: string): void {
|
|
116
|
+
if (patch.config_sha256 !== configSha256) throw new ConfigPatchError('EDIT_PATCH_STALE', `the patch was made against config ${patch.config_sha256.slice(0, 12)}…, the handoff config is ${configSha256.slice(0, 12)}…; read the config again and remake the patch`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const INDEX = /^(0|[1-9][0-9]*)$/;
|
|
120
|
+
/**
|
|
121
|
+
* The named list entry a pointer addresses, as `{list, index, key}`: a top-level list `/<list>/<i>` (named by `name`;
|
|
122
|
+
* a `bones` chain and a `stops` entry by `chain`), and since #115 `/assemble/cuts/<i>` (named by its `into`),
|
|
123
|
+
* `/assemble/patches/<i>` (by `name`) and `/limbs/<limb>/segments/<k>` (by its `part`). Null for any other pointer.
|
|
124
|
+
*/
|
|
125
|
+
export function namedEntry(tokens: readonly string[]): { list: string[]; index: number; keys: string[] } | null {
|
|
126
|
+
if (tokens[0] === 'assemble' && (tokens[1] === 'cuts' || tokens[1] === 'patches')) return tokens.length >= 3 && INDEX.test(tokens[2]!) ? { list: tokens.slice(0, 2), index: Number(tokens[2]), keys: [tokens[1] === 'cuts' ? 'into' : 'name'] } : null;
|
|
127
|
+
if (tokens[0] === 'limbs') return tokens.length >= 4 && tokens[2] === 'segments' && INDEX.test(tokens[3]!) ? { list: tokens.slice(0, 3), index: Number(tokens[3]), keys: ['part'] } : null;
|
|
128
|
+
if (tokens.length < 2 || !INDEX.test(tokens[1]!)) return null;
|
|
129
|
+
// A `bones` chain entry (#109) and a `stops` entry (#110) name themselves by `chain`; every other named entry by `name`.
|
|
130
|
+
return { list: tokens.slice(0, 1), index: Number(tokens[1]), keys: tokens[0] === 'bones' || tokens[0] === 'stops' ? ['name', 'chain'] : ['name'] };
|
|
131
|
+
}
|
|
132
|
+
const namedListText = (tokens: readonly string[]) => {
|
|
133
|
+
const tail = tokens[0] === 'assemble' ? '/assemble/cuts|patches/<index>' : tokens[0] === 'limbs' ? '/limbs/<limb>/segments/<index>' : `/${pointerToken(tokens[0] ?? '')}/<index>`;
|
|
134
|
+
return tail;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Every op that records a name must address, by index, a list entry of the unpatched config holding exactly that name (a `bones` chain: its `chain`). */
|
|
138
|
+
export function assertOpNames(doc: JsonValue, ops: readonly PatchOp[]): void {
|
|
139
|
+
for (const [i, op] of ops.entries()) {
|
|
140
|
+
if (op.name === undefined) continue;
|
|
141
|
+
const at = namedEntry(parsePointer(op.path));
|
|
142
|
+
if (!at) throw new ConfigPatchError('EDIT_PATCH_NAME_MISMATCH', `ops[${i}] ${op.path} records "${op.name}", but it addresses no named list entry`);
|
|
143
|
+
let entries: JsonValue | undefined = doc;
|
|
144
|
+
for (const t of at.list) entries = isObject(entries) ? (entries as Record<string, JsonValue>)[t] : undefined;
|
|
145
|
+
const entry = Array.isArray(entries) ? entries[at.index] : undefined;
|
|
146
|
+
const held = isObject(entry) ? at.keys.map(k => (entry as Record<string, JsonValue>)[k]).find(v => v !== undefined) : undefined;
|
|
147
|
+
if (held !== op.name) throw new ConfigPatchError('EDIT_PATCH_NAME_MISMATCH', `ops[${i}] ${op.path} records "${op.name}", but ${formatPointer([...at.list, at.index])} ${held === undefined ? 'holds no named entry' : `is "${String(held)}"`} in the handoff config`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const arrayIndex = (token: string, length: number, allowEnd: boolean, path: string): number => {
|
|
152
|
+
if (allowEnd && token === '-') return length;
|
|
153
|
+
if (!/^(0|[1-9][0-9]*)$/.test(token)) throw new ConfigPatchError('EDIT_PATCH_POINTER_INVALID', `${path}: "${token}" is not an array index`);
|
|
154
|
+
const index = Number(token);
|
|
155
|
+
if (index > length || (!allowEnd && index === length)) throw new ConfigPatchError('EDIT_PATCH_PATH_MISSING', `${path}: index ${index} is past the array's ${length} item(s)`);
|
|
156
|
+
return index;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Apply every op in order to a copy of `doc` (RFC 6902 semantics for replace/add/remove); the input is not touched. */
|
|
160
|
+
export function applyConfigPatch(doc: JsonValue, ops: readonly PatchOp[]): JsonValue {
|
|
161
|
+
const root: { v: JsonValue } = { v: structuredClone(doc) };
|
|
162
|
+
for (const [i, op] of ops.entries()) {
|
|
163
|
+
const tokens = parsePointer(op.path), at = `ops[${i}] ${op.path}`;
|
|
164
|
+
let parent: JsonValue = root.v;
|
|
165
|
+
for (const [depth, token] of tokens.slice(0, -1).entries()) {
|
|
166
|
+
const where = formatPointer(tokens.slice(0, depth + 1));
|
|
167
|
+
if (Array.isArray(parent)) parent = parent[arrayIndex(token, parent.length, false, `${at} (at ${where})`)]!;
|
|
168
|
+
else if (isObject(parent) && Object.hasOwn(parent, token)) parent = (parent as Record<string, JsonValue>)[token]!;
|
|
169
|
+
else throw new ConfigPatchError('EDIT_PATCH_PATH_MISSING', `${at}: ${where} is not in the config`);
|
|
170
|
+
}
|
|
171
|
+
const last = tokens.at(-1)!;
|
|
172
|
+
if (Array.isArray(parent)) {
|
|
173
|
+
const index = arrayIndex(last, parent.length, op.op === 'add', at);
|
|
174
|
+
if (op.op === 'add') parent.splice(index, 0, structuredClone(op.value!));
|
|
175
|
+
else if (op.op === 'replace') parent[index] = structuredClone(op.value!);
|
|
176
|
+
else parent.splice(index, 1);
|
|
177
|
+
} else if (isObject(parent)) {
|
|
178
|
+
const target = parent as Record<string, JsonValue>;
|
|
179
|
+
if (op.op !== 'add' && !Object.hasOwn(target, last)) throw new ConfigPatchError('EDIT_PATCH_PATH_MISSING', `${at}: ${op.path} is not in the config`);
|
|
180
|
+
if (op.op === 'remove') delete target[last]; else target[last] = structuredClone(op.value!);
|
|
181
|
+
} else throw new ConfigPatchError('EDIT_PATCH_PATH_MISSING', `${at}: the parent of ${op.path} is not an object or array`);
|
|
182
|
+
}
|
|
183
|
+
return root.v;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Every JSON Pointer at which two documents differ: a changed leaf, a changed type, a key or index only one side has. Sorted. */
|
|
187
|
+
export function jsonDiff(a: unknown, b: unknown): string[] {
|
|
188
|
+
const out: string[] = [];
|
|
189
|
+
const walk = (x: unknown, y: unknown, path: (string | number)[]) => {
|
|
190
|
+
if (Array.isArray(x) && Array.isArray(y)) {
|
|
191
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) { if (i >= x.length || i >= y.length) out.push(formatPointer([...path, i])); else walk(x[i], y[i], [...path, i]); }
|
|
192
|
+
} else if (isObject(x) && isObject(y)) {
|
|
193
|
+
for (const key of [...new Set([...Object.keys(x), ...Object.keys(y)])].sort()) {
|
|
194
|
+
if (!Object.hasOwn(x, key) || !Object.hasOwn(y, key)) out.push(formatPointer([...path, key])); else walk(x[key], y[key], [...path, key]);
|
|
195
|
+
}
|
|
196
|
+
} else if (x !== y) out.push(formatPointer(path));
|
|
197
|
+
};
|
|
198
|
+
walk(a, b, []);
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* For `remesh`: every op must stay inside `meshes.<part>` (the entry remesh takes); the config remesh reads is the
|
|
204
|
+
* handoff's own, unchanged. Returns the part.
|
|
205
|
+
*/
|
|
206
|
+
export function remeshPart(patch: ConfigPatch): string {
|
|
207
|
+
const index = patch.op.args.indexOf('--part'), part = patch.op.args[index + 1]!;
|
|
208
|
+
for (const [i, op] of patch.ops.entries()) {
|
|
209
|
+
const tokens = parsePointer(op.path);
|
|
210
|
+
if (tokens[0] !== 'meshes' || tokens[1] !== part || (tokens.length === 2 && op.op === 'remove')) throw new ConfigPatchError('EDIT_PATCH_OUT_OF_SCOPE', `ops[${i}] ${op.path}: a remesh patch edits /meshes/${pointerToken(part)} and below only (the rest of the config is what the rig was built from)`);
|
|
211
|
+
}
|
|
212
|
+
return part;
|
|
213
|
+
}
|