@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,216 @@
1
+ /**
2
+ * Apply · Rebuild, page side (#99): the chest editor's rows become the producer's region entries, one typed request
3
+ * goes to `/api/rebuild`, the job's lines stream back by polling, and the applied state is recorded. No DOM here
4
+ * (main.ts owns the elements and the player); the server owns every path and the producer's flags.
5
+ */
6
+ import {ellipsePolygon} from '../src/chest-regions.ts';
7
+ import type {BustSpec} from '../src/chest-landmarks.ts';
8
+ import {CONFIG_PATCH_SCHEMA,type ConfigPatch,type PatchCmd,type PatchOp} from '../src/config-patch.ts';
9
+ import {APPLIED_DELTA_SCHEMA,APPLIED_PATCH_SCHEMA,APPLIED_RETARGET_SCHEMA,filesDiffText,type AppliedAssembled,type AppliedDelta,type AppliedPatch,type AppliedRetarget,type CheckSummary,type FilesDiff} from '../src/applied-context.ts';
10
+ import type {MotionSummary,RetargetFlags,RetargetLine,RetargetRecord} from '../src/retarget.ts';
11
+ export type {ConfigPatch,PatchOp,AppliedPatch,AppliedDelta,AppliedRetarget,CheckSummary,BustSpec};
12
+ import {chestContourFields,chestRegionRows,chestRowId,type ChestFieldReader} from './chest-regions-tool.ts';
13
+ import type {InspectedCheckView} from '../src/physics-tuning.ts';
14
+
15
+ export type RegionEntry=
16
+ |{name:string;shape:'circle';cx:number;cy:number;r:number;bone:string;spacing:number;band:number}
17
+ |{name:string;bone:string;shape:'polygon';points:[number,number][];spacing:number;band:number};
18
+ /** The producer handoff (#101) as `/api/rebuild` reports it: paths relative to the package root, how it was found. */
19
+ export interface HandoffView {how:'declared'|'discovered';via:string;outside:boolean;config:{path:string;sha256:string};parts:string|null;rig:string|null;files:{path:string;sha256:string}[];missing:string[]}
20
+ export interface BaselineView {status:'running'|'reproduced'|'not-reproduced'|'refused'|'failed';firstDifference:string|null;hashes:{baseline:{rigJson:string|null;tree:string};inspected:{rigJson:string|null;tree:string}}|null;line:string;wallMs:number|null}
21
+ export interface RetargetStatusView {available:boolean;reason:string|null;producer:{package:string;version:string}|null;compiler:{package:string;version:string}|null;rig:string|null;sequences:{id:string;label:string;path:string;cleaned:boolean;frames:number;people:string[]}[];bones:string[];animations:string[]}
22
+ export interface RebuildStatusView {retarget?:RetargetStatusView;available:boolean;handoff:HandoffView|null;baseline:BaselineView|null;inputs:{config:string;parts:string|null;rig:string|null;configHash:string}|null;parts:{name:string;regions:unknown[]}[];contour:Record<string,Record<string,unknown>>;meshes?:Record<string,unknown>;constraints?:unknown[]|null;bones?:unknown[]|null;fields?:Record<string,unknown>|null;cuts?:{config:Record<string,unknown>;build:string|null}|null;inspectedCheck?:InspectedCheckView|null;idle?:import('../src/idle-tracks.ts').IdleStatus|null;producer:{package:string;version:string}|null;compiler:{package:string;version:string}|null;reason:string|null}
23
+ export interface RebuildJobView {id:string;status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;exitCode:number|null;wallMs:{remesh:number|null;compile:number|null};build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{part:string;regions:RegionEntry[];configHash:string;entrySha256:string;bust?:BustSpec};producer:{package:string;version:string};compiler:{package:string;version:string}}
24
+ export interface RebuildRefusal {status:'refused';error:string;producerLine:string|null}
25
+ /** #99 regions or #105 landmarks: exactly one of `regions` and `bust`. */
26
+ export type RebuildRequestBody={part:string;regions:RegionEntry[];configHash:string}|{part:string;bust:BustSpec;configHash:string};
27
+ /** What the draft keeps and the feedback context carries: the exact last applied inputs and the producer's line. */
28
+ export interface AppliedRecord {schema:'rig-review-applied/1';buildId:string;part:string;regions:RegionEntry[];bust?:BustSpec;configHash:string;entrySha256:string;producer:{package:string;version:string};compiler:{package:string;version:string};line:string|null;modelHash:string}
29
+
30
+ const number=(read:ChestFieldReader,id:string)=>{const raw=(read(id)?.value??'').trim();if(!raw)return null;const value=Number(raw);return Number.isFinite(value)?value:Number.NaN;};
31
+
32
+ /** Rows with a centre x are designated; a row with equal semi-axes and no clip is the config's circle, else the 32-sample polygon. */
33
+ export function designatedRegions(read:ChestFieldReader):{regions:RegionEntry[]}|{error:string} {
34
+ const regions:RegionEntry[]=[];
35
+ for(const row of chestRegionRows){
36
+ if(number(read,chestRowId(row,'cx'))===null)continue;
37
+ const name=(read(chestRowId(row,'name'))?.value??'').trim(),bone=(read(chestRowId(row,'bone'))?.value??'').trim(),tag=`row ${row+1}`;
38
+ if(!name)return {error:`REBUILD_REGION_INCOMPLETE: ${tag} name`};if(!bone)return {error:`REBUILD_REGION_INCOMPLETE: ${name} bone`};
39
+ const values:Record<string,number|null>={};
40
+ for(const field of ['cx','cy','ax','ay','spacing','band','clip'] as const){values[field]=number(read,chestRowId(row,field));if(Number.isNaN(values[field]))return {error:`REBUILD_REGION_INCOMPLETE: ${name} ${field}`};}
41
+ for(const field of ['cy','ax','spacing','band'] as const)if(values[field]===null)return {error:`REBUILD_REGION_INCOMPLETE: ${name} ${field}`};
42
+ const {cx,cy,ax,spacing,band,clip}=values as Record<string,number>,ay=values.ay??ax;
43
+ if(ax===ay&&values.clip===null)regions.push({name,shape:'circle',cx,cy,r:ax,bone,spacing,band});
44
+ else regions.push({name,bone,shape:'polygon',points:ellipsePolygon([cx,cy],[ax,ay],32,values.clip===null?undefined:clip),spacing,band});
45
+ }
46
+ return regions.length?{regions}:{error:'REBUILD_REGION_INCOMPLETE: no region has a centre'};
47
+ }
48
+
49
+ /** The contour part to rebuild: the one whose regions name the first designated bone, else the only one, else none. */
50
+ export function pickPart(status:Pick<RebuildStatusView,'parts'>,regions:readonly RegionEntry[]):string|null {
51
+ const bones=new Set(regions.map(r=>r.bone));
52
+ const named=status.parts.filter(p=>p.regions.some(r=>!!r&&typeof r==='object'&&bones.has(String((r as {bone?:unknown}).bone))));
53
+ if(named.length===1)return named[0]!.name;
54
+ return status.parts.length===1?status.parts[0]!.name:null;
55
+ }
56
+
57
+ /** The part a landmarks Apply rebuilds: the contour part whose entry already states `contour.bust`, else as for regions. */
58
+ export function pickBustPart(status:Pick<RebuildStatusView,'parts'|'contour'>,derived:readonly RegionEntry[]):string|null {
59
+ const stated=status.parts.filter(p=>{const c=status.contour[p.name]?.contour;return !!c&&typeof c==='object'&&'bust' in (c as Record<string,unknown>);});
60
+ return stated.length===1?stated[0]!.name:pickPart(status,derived);
61
+ }
62
+ /**
63
+ * A landmarks Apply (#105): `{part, bust, configHash}`, or the line that refuses it before anything is sent — a point not
64
+ * placed, or review's pre-check of the producer's `CONFIG_BUST_CLEARANCE` on the same numbers. Bone names are the
65
+ * producer's to resolve (`CONFIG_NAME_RESOLVES` comes back verbatim).
66
+ */
67
+ export function landmarksRequest(status:Pick<RebuildStatusView,'parts'|'contour'|'inputs'>,read:import('./chest-editor.ts').LandmarkRead):{body:RebuildRequestBody;part:string}|{error:string} {
68
+ if('error' in read)return {error:read.error};
69
+ if('missing' in read)return {error:`LANDMARKS_INCOMPLETE: place ${read.missing.join(', ')}`};
70
+ if(read.derived.refusal)return {error:read.derived.refusal};
71
+ if(!status.inputs)return {error:'REBUILD_STATUS_UNAVAILABLE'};
72
+ const part=pickBustPart(status,read.derived.regions);
73
+ if(!part)return {error:`REBUILD_PART_AMBIGUOUS: the config states ${status.parts.length} contour meshes and the landmark bones name none of them alone`};
74
+ return {part,body:{part,bust:read.spec,configHash:status.inputs.configHash}};
75
+ }
76
+
77
+ /**
78
+ * Field values that start the editor from the producer config: the first contour part's circle regions (at most two
79
+ * rows) and its contour numbers. Only for an untouched editor — `null` once any row has a centre.
80
+ */
81
+ export function seedFields(status:Pick<RebuildStatusView,'parts'|'contour'>,read:ChestFieldReader):Record<string,string>|null {
82
+ if(chestRegionRows.some(row=>(read(chestRowId(row,'cx'))?.value??'').trim()))return null;
83
+ const part=status.parts.find(p=>p.regions.length),entry=part?status.contour[part.name]:undefined;if(!part||!entry)return null;
84
+ const out:Record<string,string>={},contour=(entry.contour??{}) as Record<string,unknown>;
85
+ part.regions.slice(0,chestRegionRows.length).forEach((value,row)=>{
86
+ const r=value as Record<string,unknown>;if(r.shape!=='circle')return;
87
+ for(const [field,key] of [['name','name'],['bone','bone'],['cx','cx'],['cy','cy'],['ax','r'],['ay','r'],['spacing','spacing'],['band','band']] as const)if(r[key]!==undefined)out[chestRowId(row,field)]=String(r[key]);
88
+ });
89
+ for(const [key,id] of chestContourFields)if(typeof contour[key]==='number')out[id]=String(contour[key]);
90
+ if(typeof contour.bridges==='boolean')out['chest-bridges']=String(contour.bridges);
91
+ if(typeof entry.r==='number')out['chest-r']=String(entry.r);
92
+ if(Array.isArray(entry.segments))out['chest-segments']=JSON.stringify(entry.segments);
93
+ return Object.keys(out).length?out:null;
94
+ }
95
+
96
+ /**
97
+ * Landmark field values (#105) from a config entry that already states `contour.bust`, for an untouched Landmarks mode
98
+ * only (`null` once any landmark has a value). Absent alpha / clearance seed the producer's defaults.
99
+ */
100
+ export function seedLandmarks(status:Pick<RebuildStatusView,'parts'|'contour'>,read:ChestFieldReader):Record<string,string>|null {
101
+ if(['apex_r','apex_l','cleavage'].some(k=>(read(`lm-${k}-x`)?.value??'').trim()||(read(`lm-${k}-y`)?.value??'').trim()))return null;
102
+ for(const p of status.parts){
103
+ const bust=(status.contour[p.name]?.contour as Record<string,unknown>|undefined)?.bust as Record<string,unknown>|undefined;if(!bust||typeof bust!=='object')continue;
104
+ const out:Record<string,string>={};
105
+ for(const k of ['apex_r','apex_l','cleavage'])if(Array.isArray(bust[k])&&(bust[k] as unknown[]).length===2){out[`lm-${k}-x`]=String((bust[k] as number[])[0]);out[`lm-${k}-y`]=String((bust[k] as number[])[1]);}
106
+ for(const k of ['alpha','clearance'])if(typeof bust[k]==='number')out[`lm-${k}`]=String(bust[k]);
107
+ for(const k of ['bone_r','bone_l'])if(typeof bust[k]==='string')out[`lm-${k}`]=bust[k] as string;
108
+ return Object.keys(out).length?out:null;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ export const appliedRecord=(job:RebuildJobView):AppliedRecord=>({schema:'rig-review-applied/1',buildId:job.id,part:job.request.part,regions:job.request.regions,...(job.request.bust?{bust:job.request.bust}:{}),configHash:job.request.configHash,entrySha256:job.request.entrySha256,producer:job.producer,compiler:job.compiler,line:job.producerLine,modelHash:job.build!.modelHash});
114
+ export function parseApplied(text:string):AppliedRecord|AppliedPatch|AppliedDelta|AppliedRetarget|null {try{const value=JSON.parse(text);return value?.schema==='rig-review-applied/1'||value?.schema===APPLIED_PATCH_SCHEMA||value?.schema===APPLIED_DELTA_SCHEMA||value?.schema===APPLIED_RETARGET_SCHEMA?value as AppliedRecord|AppliedPatch|AppliedDelta|AppliedRetarget:null;}catch{return null;}}
115
+
116
+ /** A patch Apply's job (#102): the patch and its hash, the op/compile/check wall clocks, check's summary and the rig.json diff. */
117
+ /** #115: a build job's assemble readout (`AssembledView` in src/rebuild.ts). */
118
+ export type AssembledJobView=import('../src/rebuild.ts').AssembledView;
119
+ export interface PatchJobView {id:string;kind:'patch';op?:'rig'|'retune'|'remesh'|'build';assembled?:AssembledJobView;status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;exitCode:number|null;wallMs:{op:number|null;compile:number|null;check:number|null};check:CheckSummary|null;diff:{count:number;paths:string[]}|null;files?:FilesDiff|null;remesh?:import('../src/mesh-entry.ts').RemeshCounts&{modelTriangles:number|null}|null;build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{patch:ConfigPatch;patchSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
120
+ export interface PatchRequestBody {patch:ConfigPatch}
121
+ /** Edit Mesh's delta Apply (#135): `remesh --delta`, the per-file diff and the part's counts; remesh's own gate holds check. */
122
+ export interface DeltaJobView {id:string;kind:'delta';op:'remesh';status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;exitCode:number|null;wallMs:{op:number|null;compile:number|null};files:FilesDiff|null;remesh:import('../src/mesh-entry.ts').RemeshCounts&{modelTriangles:number|null}|null;build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{part:string;delta:Record<string,unknown>;deltaSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
123
+ /** `{part, delta, configHash}`: the server owns every path and flag; the delta is the editor's export as it stands. */
124
+ export interface DeltaRequestBody {part:string;delta:import('../src/mesh-edit.ts').MeshEditDocument;configHash:string}
125
+ /** A retarget Apply's job (#114): clean, retarget, compile; every FAIL line of the step that refused, verbatim; the listed lines. */
126
+ export interface RetargetJobView {id:string;kind:'retarget';op:'retarget';status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;refusals:string[];exitCode:number|null;wallMs:{clean:number|null;retarget:number|null;compile:number|null};files:FilesDiff|null;motion:MotionSummary|null;readings:RetargetLine[];build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{record:RetargetRecord;recordSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
127
+ /** `{retarget: {sequence, map, flags, configHash}}`: a declared reference id, the map by content and typed flags — no path, no flag string. */
128
+ export interface RetargetRequestBody {retarget:{sequence:string;map:Record<string,unknown>;flags:RetargetFlags;configHash:string}}
129
+ /** What the draft keeps and `context.applied` carries for a retarget: the record, the producer's line, the motion summary, the diff and the lines. */
130
+ export const retargetAppliedRecord=(job:RetargetJobView):AppliedRetarget=>({schema:APPLIED_RETARGET_SCHEMA,buildId:job.id,retarget:JSON.parse(JSON.stringify(job.request.record)),recordSha256:job.request.recordSha256,configHash:job.request.configHash,producer:job.producer,compiler:job.compiler,line:job.producerLine,motion:JSON.parse(JSON.stringify(job.motion)),files:job.files,readings:job.readings.map(r=>({...r})),modelHash:job.build!.modelHash});
131
+ /** A retarget job's status line: built (wall clocks, the new animation, the diff) or the producer's line verbatim. */
132
+ export function retargetOutcomeLine(result:RetargetJobView|RebuildRefusal):{text:string;error:boolean} {
133
+ if(!('id' in result))return {text:result.producerLine??result.error,error:true};
134
+ if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
135
+ const r=result.request.record,wall=result.reused?'reused the scratch build of this exact retarget':`${r.op.clean?`clean ${seconds(result.wallMs.clean)} + `:''}retarget ${seconds(result.wallMs.retarget)} + compile ${seconds(result.wallMs.compile)}`;
136
+ const kept=result.motion?(result.motion.kept?'the rig\'s own animations unchanged':`changed: ${result.motion.changed.join(', ')}`):'';
137
+ return {text:`Retargeted "${r.flags.animation}" from ${r.sequence.reference} with ${result.producer.package} ${result.producer.version} · ${wall} · ${kept} · ${result.files?filesDiffText(result.files):'no diff'} · Reset returns to the inspected build`,error:!!result.motion&&!result.motion.kept};
138
+ }
139
+ export type AnyJobView=RebuildJobView|PatchJobView|DeltaJobView|RetargetJobView;
140
+ export const isPatchJob=(job:AnyJobView):job is PatchJobView=>(job as PatchJobView).kind==='patch';
141
+
142
+ export interface RebuildDeps<B=RebuildRequestBody|PatchRequestBody,J extends AnyJobView=AnyJobView> {post:(body:B)=>Promise<J|RebuildRefusal>;get:(id:string)=>Promise<J>;onLine:(line:string,index:number)=>void;sleep:(ms:number)=>Promise<void>;intervalMs?:number}
143
+ /** One Apply: post, then poll until the job leaves `running`, handing every new line over once, in order. */
144
+ export async function runRebuild<B,J extends AnyJobView>(body:B,deps:RebuildDeps<B,J>):Promise<J|RebuildRefusal> {
145
+ let job=await deps.post(body);if(job.status==='refused'&&!('id' in job))return job;
146
+ let seen=0;const deliver=(lines:string[])=>{for(;seen<lines.length;seen++)deps.onLine(lines[seen]!,seen);};
147
+ deliver((job as J).lines);
148
+ while(job.status==='running'){await deps.sleep(deps.intervalMs??700);job=await deps.get((job as J).id);deliver(job.lines);}
149
+ return job;
150
+ }
151
+
152
+ /**
153
+ * A patch from the config the status read: `config_sha256` is the handoff config's hash and the producer is the
154
+ * installed one, so an edit card only states its ops and the op. Null when Apply cannot run here.
155
+ */
156
+ export function makePatch(status:Pick<RebuildStatusView,'inputs'|'producer'>,ops:PatchOp[],op:{cmd:PatchCmd;args?:string[]}={cmd:'rig'}):ConfigPatch|null {
157
+ if(!status.inputs||!status.producer)return null;
158
+ return {schema:CONFIG_PATCH_SCHEMA,config_sha256:status.inputs.configHash,ops:ops.map(o=>({...o})),producer:{name:status.producer.package,version:status.producer.version},op:{cmd:op.cmd,args:[...(op.args??[])]}};
159
+ }
160
+ /** What the draft keeps and `context.applied` carries for a patch: the patch, its hash, the producer's line and check's summary. */
161
+ export const patchAppliedRecord=(job:PatchJobView):AppliedPatch=>({schema:APPLIED_PATCH_SCHEMA,buildId:job.id,patch:job.request.patch,patchSha256:job.request.patchSha256,configHash:job.request.configHash,producer:job.producer,compiler:job.compiler,line:job.producerLine,check:job.check,diff:job.diff,...(job.files?{files:job.files}:{}),modelHash:job.build!.modelHash,...(job.op?{op:job.op}:{}),...(job.remesh?{mesh:{part:job.remesh.part,before:{...job.remesh.before},after:{...job.remesh.after}}}:{}),...(job.op==='build'?{assembled:assembledRecord(job)}:{})});
162
+ /** #115: what a build Apply records: the build's own wall clock, the recomposite figures, the parts it changed and its `seam pair` lines. */
163
+ export function assembledRecord(job:PatchJobView):AppliedAssembled {
164
+ const a=job.assembled,fig=(f:Record<string,number>|null|undefined)=>f?{...f}:null;
165
+ return {wallMs:job.wallMs.op,recomposite:{before:fig(a?.recomposite.before as Record<string,number>|null),after:fig(a?.recomposite.after as Record<string,number>|null)},partsChanged:[...(a?.parts?.changed??[])],seamPairs:(a?.seamPairs??[]).slice(0,64)};
166
+ }
167
+ /** What the draft keeps and `context.applied` carries for a delta: the document and its hash, the producer's line, op "remesh", the diff and the counts. */
168
+ export const deltaAppliedRecord=(job:DeltaJobView):AppliedDelta=>({schema:APPLIED_DELTA_SCHEMA,buildId:job.id,part:job.request.part,delta:JSON.parse(JSON.stringify(job.request.delta)),deltaSha256:job.request.deltaSha256,configHash:job.request.configHash,producer:job.producer,compiler:job.compiler,line:job.producerLine,op:'remesh',files:job.files,...(job.remesh?{mesh:{part:job.remesh.part,before:{...job.remesh.before},after:{...job.remesh.after}}}:{}),modelHash:job.build!.modelHash});
169
+ /** A delta job's status line: built (wall clocks, counts, the per-file diff) or the producer's line verbatim. */
170
+ export function deltaOutcomeLine(result:DeltaJobView|RebuildRefusal):{text:string;error:boolean} {
171
+ if(!('id' in result))return {text:result.producerLine??result.error,error:true};
172
+ if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
173
+ const ops=Array.isArray(result.request.delta.ops)?result.request.delta.ops.length:0;
174
+ const wall=result.reused?'reused the scratch build of this exact delta':`remesh --delta ${seconds(result.wallMs.op)} + compile ${seconds(result.wallMs.compile)}`;
175
+ const counts=result.remesh?` · ${result.remesh.part} v ${result.remesh.before.vertices} t ${result.remesh.before.triangles} → v ${result.remesh.after.vertices} t ${result.remesh.after.triangles}`:'';
176
+ return {text:`Applied mesh edit ${result.request.deltaSha256.slice(0,12)}… (${ops} op(s) on ${result.request.part}) with ${result.producer.package} ${result.producer.version} · ${wall}${counts} · ${result.files?filesDiffText(result.files):'no diff'} · Reset returns to the inspected build`,error:false};
177
+ }
178
+ /**
179
+ * What the stage draws after an Apply: the scratch build when the job built one, else what it drew before — a
180
+ * refusal, a failure or a busy reply leaves the stage's model (and its hash) as it was.
181
+ */
182
+ export function stageModel(result:AnyJobView|RebuildRefusal,current:{url:string;hash:string}):{url:string;hash:string;id:string|null} {
183
+ if('id' in result&&result.status==='built'&&result.build)return {url:result.build.modelUrl,hash:result.build.modelHash,id:result.id};
184
+ return {...current,id:null};
185
+ }
186
+ const seconds=(ms:number|null)=>ms===null?'—':`${(ms/1000).toFixed(1)} s`;
187
+ /** A patch job's status line: built (wall clocks, check's verdict, the per-file diff — #125) or the producer's line verbatim. */
188
+ export function patchOutcomeLine(result:PatchJobView|RebuildRefusal):{text:string;error:boolean} {
189
+ if(!('id' in result))return {text:result.producerLine??result.error,error:true};
190
+ if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
191
+ const check=result.check?(result.check.pass===null?`check refused: ${result.check.line??`exit ${result.check.exitCode}`}`:`check ${result.check.pass?'PASS':'FAIL'}`):'check not run';
192
+ // #115: a build's own wall clock covers assemble, rig and check; the recorded figure is the same number.
193
+ const wall=result.op==='build'?`${result.reused?'reused the scratch build of this exact patch · ':''}build ${seconds(result.wallMs.op)} (assemble → rig → check) + compile ${seconds(result.wallMs.compile)}`:result.reused?'reused the scratch build of this exact patch':`${result.op??result.request.patch.op.cmd} ${seconds(result.wallMs.op)} + compile ${seconds(result.wallMs.compile)} + check ${seconds(result.wallMs.check)}`;
194
+ return {text:`Applied patch ${result.request.patchSha256.slice(0,12)}… (${result.request.patch.ops.length} op(s)) with ${result.producer.package} ${result.producer.version} · ${wall} · ${check} · ${result.files?filesDiffText(result.files):`rig.json differs at ${result.diff?.count??'?'} path(s)`} · Reset returns to the inspected build`,error:!!result.check&&result.check.pass!==true};
195
+ }
196
+ /** The status line after a job: built (with the wall clock and whether the scratch build was reused) or the producer's line verbatim. */
197
+ export function outcomeLine(result:RebuildJobView|RebuildRefusal):{text:string;error:boolean} {
198
+ if(!('id' in result))return {text:result.producerLine??result.error,error:true};
199
+ if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
200
+ const wall=[result.wallMs.remesh,result.wallMs.compile].every(v=>v!==null)?` · remesh ${(result.wallMs.remesh!/1000).toFixed(1)} s + compile ${(result.wallMs.compile!/1000).toFixed(1)} s`:'';
201
+ return {text:`Applied ${result.request.part} with ${result.producer.package} ${result.producer.version}${result.reused?' · reused the scratch build of this exact designation':wall} · Reset returns to the inspected build`,error:false};
202
+ }
203
+
204
+ /**
205
+ * The editor bar's handoff line, shown before any Apply: how the config was found (declared, or discovered — and
206
+ * outside the opened folder when it is the `../config.json` beside an opened out/), its path and hash, then the
207
+ * baseline. Without a handoff it is the refusal naming where it was looked for and how to declare it.
208
+ */
209
+ export function handoffText(status:Pick<RebuildStatusView,'handoff'|'baseline'|'reason'>):{text:string;error:boolean} {
210
+ const h=status.handoff;
211
+ if(!h)return {text:status.reason??'MODELING_HANDOFF_MISSING',error:true};
212
+ const how=h.how==='declared'?`declared (${h.via})`:h.outside?'discovered outside the opened folder':'discovered';
213
+ const parts=h.parts?` · parts ${h.parts==='.'?'(the opened folder)':h.parts}`:'';
214
+ const base=status.baseline?` · ${status.baseline.wallMs!==null&&status.baseline.status!=='running'?`${status.baseline.line} (${(status.baseline.wallMs/1000).toFixed(1)} s)`:status.baseline.line}`:' · baseline: waits for the producer';
215
+ return {text:`Handoff ${how}: ${h.config.path} sha256 ${h.config.sha256.slice(0,12)}…${parts}${base}`,error:!!status.baseline&&['not-reproduced','refused','failed'].includes(status.baseline.status)};
216
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Animate · Requirements (#113), page side: declare what the scene asks of the motion — pick a slot (its mesh
3
+ * attachment, its bone) on the stage or by name, an animation and a kind, then set the bar with a slider that shows the
4
+ * last measured value — and Check: review writes the `spine-parts-requirements/1` file and the installed producer's
5
+ * `rig-parts check --requirements` measures it, on the inspected rig or the latest scratch build. Each requirement gets
6
+ * one line with its worst frame, a button that seeks there; `REQUIREMENTS_*` refusals and `CHECK_REQUIREMENT_*` lines are
7
+ * shown verbatim. The file rides submitted feedback by hash and content (`context.requirements`).
8
+ * A kind the rig cannot offer (no mesh slot, no ik or transform constraint, fewer than two slots) is disabled with the
9
+ * reason. Scene targets, stage-point targets, the part exception lists and waivers (`may_fold`) are not written here.
10
+ */
11
+ import {KIND_FIELDS,REQUIREMENT_KINDS,REQUIREMENT_NAME,REQUIREMENTS_CONTEXT_SCHEMA,requirementEntry,requirementsDocument,requirementsFileText,type BonePoint,type Requirement,type RequirementKind,type RequirementRow,type RequirementsBuild,type RequirementsContext} from '../src/requirements.ts';
12
+
13
+ export interface RequirementsCatalogView {status:'ready'|'unavailable';reason:string|null;bones:string[];slots:string[];meshes:Record<string,string[]>;slotBones:Record<string,string>;constraints:{name:string;type:'ik'|'transform';bones:string[];drives:string[]}[];animations:{name:string;duration:number|null}[];scratch:string|null}
14
+ export interface RequirementsJobView {id:string;status:'running'|'checked'|'refused'|'failed';build:RequirementsBuild;buildId:string|null;file:{sha256:string;size:number;text:string};producer:{package:string;version:string};exitCode:number|null;wallMs:number|null;rows:RequirementRow[];summary:string|null;checkLine:string|null;refusals:string[];problems:string[];error:string|null;lines:string[]}
15
+ export interface RequirementsRefusal {status:'refused';error:string}
16
+ export interface RequirementsPanelDeps {
17
+ /** Runs the check of this file text's requirements; the panel shows what comes back. */
18
+ check:(body:{fps:number;build:RequirementsBuild;requirements:Record<string,unknown>[]})=>Promise<RequirementsJobView|RequirementsRefusal|null>;
19
+ seek:(animation:string,frame:number,time:number|null,label:string)=>void;
20
+ /** The drawn animation and the slot picked on the stage. */
21
+ animation:()=>string;slot:()=>string|null;
22
+ /** The file's sha256 (hex) for the feedback context. */
23
+ hash:(text:string)=>Promise<string>;
24
+ }
25
+ export interface RequirementsPanel {
26
+ load(catalog:RequirementsCatalogView|null,reason:string|null):void;
27
+ /** Prefill the subject from the stage's selection. */
28
+ selectionChanged():void;
29
+ requirements():Requirement[];
30
+ add(r:Requirement):void;remove(name:string):void;setBar(name:string,field:string,value:number):void;
31
+ text():string|null;
32
+ checked(job:RequirementsJobView|RequirementsRefusal|null):void;
33
+ context():RequirementsContext|null;
34
+ shownLines():string[];
35
+ }
36
+ /** A kind the catalog cannot offer, and why; null when it can. */
37
+ export function kindUnavailable(kind:RequirementKind,c:Pick<RequirementsCatalogView,'bones'|'slots'|'meshes'|'constraints'>):string|null {
38
+ if(kind==='stretch'&&!Object.values(c.meshes).some(list=>list.length))return 'no slot of the rig spec holds a mesh attachment';
39
+ if(kind==='follow'&&!c.constraints.length)return 'the rig spec declares no ik or transform constraint';
40
+ if(kind==='seam'&&c.slots.length<2)return 'a seam needs two slots';
41
+ if((kind==='contact'||kind==='aim'||kind==='range')&&!c.bones.length)return 'the rig spec declares no bone';
42
+ return null;
43
+ }
44
+ /** Each kind's bar fields and the slider's span; the span grows to hold a measured value past it. */
45
+ export const BAR_FIELDS:Record<RequirementKind,{field:string;min:number;max:number;step:number;initial:number}[]>={
46
+ contact:[{field:'within_px',min:0,max:50,step:.1,initial:1}],
47
+ aim:[{field:'within_degrees',min:0,max:180,step:.5,initial:5}],
48
+ range:[{field:'lo_degrees',min:-180,max:180,step:1,initial:-45},{field:'hi_degrees',min:-180,max:180,step:1,initial:45}],
49
+ stretch:[{field:'within_ratio',min:1,max:3,step:.01,initial:1.3}],
50
+ follow:[{field:'fraction',min:-1,max:2,step:.01,initial:.5}],
51
+ seam:[{field:'within_px',min:0,max:20,step:.1,initial:1}],
52
+ };
53
+ /** A requirement name the reader takes, unique among `taken`: `KIND_SUBJECT`, then `_2`, `_3`… */
54
+ export function requirementName(kind:RequirementKind,subject:string,taken:readonly string[]):string {
55
+ let base=`${kind}_${subject}`.toUpperCase().replace(/[^A-Z0-9_]+/g,'_').replace(/_+/g,'_').replace(/^_|_$/g,'');if(!/^[A-Z]/.test(base))base=`R_${base}`;
56
+ let name=base;for(let n=2;taken.includes(name);n++)name=`${base}_${n}`;return name;
57
+ }
58
+ /**
59
+ * A contact against its own bone and point, or an aim at its own bone, measures 0 by construction and the producer
60
+ * reports it as PASS (rig-c/review#142; the reader's refusal is asked in rig-c/parts#540). Refused here by name.
61
+ */
62
+ export function selfTargetRefusal(r:{kind:RequirementKind;bone?:string;point?:BonePoint;target?:{bone:string;point:BonePoint}}):string|null {
63
+ if(!r.target||!r.bone)return null;
64
+ if(r.kind==='contact'&&r.target.bone===r.bone&&r.target.point===r.point)return `REQUIREMENTS_SELF_TARGET: contact of bone "${r.bone}" ${r.point} against itself measures 0 in every frame; pick another bone or point`;
65
+ if(r.kind==='aim'&&r.target.bone===r.bone)return `REQUIREMENTS_SELF_TARGET: aim of bone "${r.bone}" at its own ${r.target.point} has no line to measure; pick another target bone`;
66
+ return null;
67
+ }
68
+ /** The target a fresh form proposes: a bone other than the subject (the next one in rig order, else the first other one). */
69
+ export function defaultTargetBone(bones:readonly string[],subject:string):string|null {
70
+ const i=bones.indexOf(subject),next=i>=0?bones.slice(i+1).find(b=>b!==subject):undefined;
71
+ return next??bones.find(b=>b!==subject)??null;
72
+ }
73
+ /** The measured figure a bar field is read against, from a row. */
74
+ export function measuredFor(row:RequirementRow|undefined,field:string):number|null {
75
+ if(!row)return null;if(field==='lo_degrees')return row.least;if(field==='hi_degrees')return row.greatest;return row.measured;
76
+ }
77
+
78
+ const node=<T extends HTMLElement>(id:string)=>{const found=document.getElementById(id);if(!found)throw new Error(`Missing UI element: ${id}`);return found as T;};
79
+ const make=<K extends keyof HTMLElementTagNameMap>(tag:K,props:Record<string,unknown>={},...children:(Node|string)[]):HTMLElementTagNameMap[K]=>{const item=document.createElement(tag);Object.assign(item,props);item.append(...children);return item;};
80
+ const options=(select:HTMLSelectElement,values:readonly string[],label=(v:string)=>v)=>{const keep=select.value;select.replaceChildren(...values.map(v=>make('option',{value:v,textContent:label(v)})));if(values.includes(keep))select.value=keep;};
81
+ const labelled=(text:string,control:HTMLElement)=>make('label',{},text,control);
82
+
83
+ export function bindRequirementsPanel(deps:RequirementsPanelDeps):RequirementsPanel {
84
+ const body=node('requirements-body');
85
+ const state=make('p',{className:'hint',id:'requirements-state',textContent:'No producer handoff loaded.'});
86
+ const kind=make('select',{id:'requirements-kind'}),animation=make('select',{id:'requirements-animation'});
87
+ const fields=make('div',{className:'requirements-fields',id:'requirements-fields'}),note=make('p',{className:'hint',id:'requirements-note'});
88
+ const name=make('input',{type:'text',id:'requirements-name',autocomplete:'off',placeholder:'NAME (letters, digits, _)'});
89
+ const add=make('button',{type:'button',id:'requirements-add',textContent:'Add requirement'});
90
+ const list=make('ol',{className:'requirements-list',id:'requirements-list'});
91
+ const build=make('select',{id:'requirements-build'}),fps=make('input',{type:'number',id:'requirements-fps',min:'1',max:'240',step:'1',value:'12'});
92
+ const check=make('button',{type:'button',id:'requirements-check',className:'primary',textContent:'Check',disabled:true});
93
+ const status=make('output',{id:'requirements-status',className:'environment-readout'}),refusals=make('ul',{className:'requirements-refusals',id:'requirements-refusals'}),summary=make('p',{className:'hint',id:'requirements-summary'});
94
+ options(build,['inspected','scratch'],v=>v==='inspected'?'Inspected rig':'Latest scratch build');
95
+ body.replaceChildren(state,make('div',{className:'row'},labelled('Kind ',kind),labelled('Animation ',animation)),fields,note,make('div',{className:'row'},name,add),list,
96
+ make('div',{className:'row'},labelled('Check on ',build),labelled('fps ',fps),check),status,refusals,summary);
97
+ let catalog:RequirementsCatalogView|null=null,declared:Requirement[]=[],job:RequirementsJobView|null=null,busy=false,shown:string[]=[];
98
+ let hashed:{text:string;sha256:string}|null=null;
99
+ const controls:Record<string,HTMLSelectElement|HTMLInputElement>={};
100
+
101
+ const sel=(id:string,values:readonly string[],label?:(v:string)=>string)=>{const s=make('select',{id:`requirements-${id}`});options(s,values,label);controls[id]=s;return s;};
102
+ const numberField=(id:string,value:number,min?:number)=>{const i=make('input',{type:'number',id:`requirements-${id}`,value:String(value),step:'any',...(min===undefined?{}:{min:String(min)})});controls[id]=i;return i;};
103
+ /** The subject inputs for the chosen kind, prefilled from the stage's slot (its bone, its mesh). */
104
+ function renderFields(){
105
+ fields.replaceChildren();for(const k of Object.keys(controls))delete controls[k];
106
+ if(!catalog)return;
107
+ const k=kind.value as RequirementKind,slot=deps.slot(),slotBone=slot?catalog.slotBones[slot]??null:null;
108
+ const bones=catalog.bones,points=['origin','tip'];
109
+ const pickBone=(id:string)=>{const s=sel(id,bones);if(slotBone&&bones.includes(slotBone))s.value=slotBone;return s;};
110
+ /** The subject and target selects, the target defaulting to another bone and moved off the subject when the subject changes to it. */
111
+ const pickPair=()=>{const subject=pickBone('bone'),target=sel('target-bone',bones);const away=()=>{const d=defaultTargetBone(bones,subject.value);if(d&&target.value===subject.value)target.value=d;};away();subject.addEventListener('change',away);return [subject,target] as const;};
112
+ if(k==='contact'){const [b,t]=pickPair();fields.append(make('div',{className:'row'},labelled('Bone ',b),labelled('point ',sel('point',points))),make('div',{className:'row'},labelled('Target bone ',t),labelled('point ',sel('target-point',points))));}
113
+ else if(k==='aim'){const [b,t]=pickPair();fields.append(make('div',{className:'row'},labelled('Bone ',b)),make('div',{className:'row'},labelled('Target bone ',t),labelled('point ',sel('target-point',points))));}
114
+ else if(k==='range'){fields.append(make('div',{className:'row'},labelled('Bone ',pickBone('bone'))));}
115
+ else if(k==='stretch'){
116
+ const meshSlots=catalog.slots.filter(s=>catalog!.meshes[s]?.length),slotSel=sel('slot',meshSlots);if(slot&&meshSlots.includes(slot))slotSel.value=slot;
117
+ const att=sel('attachment',catalog.meshes[slotSel.value]??[]);slotSel.onchange=()=>options(att as HTMLSelectElement,catalog!.meshes[slotSel.value]??[]);
118
+ fields.append(make('div',{className:'row'},labelled('Slot ',slotSel),labelled('mesh ',att)));
119
+ } else if(k==='seam'){
120
+ const part=sel('part',catalog.slots);if(slot&&catalog.slots.includes(slot))part.value=slot;const neighbour=sel('neighbour',catalog.slots.filter(s=>s!==part.value));
121
+ part.onchange=()=>options(neighbour as HTMLSelectElement,catalog!.slots.filter(s=>s!==part.value));
122
+ fields.append(make('div',{className:'row'},labelled('Part ',part),labelled('neighbour ',neighbour)));
123
+ } else {
124
+ const keys=catalog.constraints.map(c=>`${c.type}:${c.name}`),constraint=sel('constraint',keys,v=>v.replace(':',' '));
125
+ const pick=()=>catalog!.constraints[Math.max(0,keys.indexOf(constraint.value))];
126
+ const bone=sel('bone',pick()?.bones??[]),prop=sel('property',pick()?.drives??[]);
127
+ constraint.onchange=()=>{options(bone as HTMLSelectElement,pick()?.bones??[]);options(prop as HTMLSelectElement,pick()?.drives??[]);};
128
+ fields.append(make('div',{className:'row'},labelled('Constraint ',constraint)),make('div',{className:'row'},labelled('Bone ',bone),labelled('property ',prop)),
129
+ make('div',{className:'row'},labelled('tolerance ',numberField('tolerance',.05,0)),labelled('least drive ',numberField('least-drive',2,0))));
130
+ }
131
+ const why=kindUnavailable(k,catalog);note.textContent=why?`${k} is not offered here: ${why}.`:`The bar (${BAR_FIELDS[k].map(b=>b.field).join(', ')}) is set on the slider once added; the producer reads ${KIND_FIELDS[k].join(', ')}.`;note.classList.toggle('error',!!why);
132
+ add.disabled=!!why||busy;
133
+ name.placeholder=requirementName(k,subjectOf(k)??'',declared.map(r=>r.name));
134
+ }
135
+ const v=(id:string)=>controls[id]?.value??'';
136
+ function subjectOf(k:RequirementKind):string|null {return k==='stretch'?v('slot'):k==='seam'?`${v('part')}_${v('neighbour')}`:v('bone')||null;}
137
+ /** The requirement the inputs describe, with each bar at its initial value; a missing name says which. */
138
+ function fromInputs():Requirement|string {
139
+ if(!catalog)return 'REQUIREMENTS_UNAVAILABLE: no rig spec loaded';
140
+ const k=kind.value as RequirementKind,anim=animation.value;if(!anim)return 'an animation is required';
141
+ const why=kindUnavailable(k,catalog);if(why)return `${k} is not offered here: ${why}`;
142
+ const subject=subjectOf(k);if(!subject)return 'pick the subject';
143
+ const n=name.value.trim()||requirementName(k,subject,declared.map(r=>r.name));
144
+ if(!REQUIREMENT_NAME.test(n))return `name ${JSON.stringify(n)}: a letter, then letters, digits and underscores`;
145
+ if(declared.some(r=>r.name===n))return `name ${n} is declared already`;
146
+ const bar=Object.fromEntries(BAR_FIELDS[k].map(b=>[b.field,b.initial]));const common={name:n,animation:anim};
147
+ const target=()=>({bone:v('target-bone'),point:v('target-point') as BonePoint});
148
+ for(const id of Object.keys(controls))if(!v(id))return `${id.replace('-',' ')} is required`;
149
+ if(k==='contact'||k==='aim'){const self=selfTargetRefusal({kind:k,bone:v('bone'),point:v('point') as BonePoint,target:target()});if(self)return self;}
150
+ if(k==='contact')return {...common,kind:k,bone:v('bone'),point:v('point') as BonePoint,target:target(),within_px:bar.within_px!};
151
+ if(k==='aim')return {...common,kind:k,bone:v('bone'),target:target(),within_degrees:bar.within_degrees!};
152
+ if(k==='range')return {...common,kind:k,bone:v('bone'),lo_degrees:bar.lo_degrees!,hi_degrees:bar.hi_degrees!};
153
+ if(k==='stretch')return {...common,kind:k,slot:v('slot'),attachment:v('attachment'),within_ratio:bar.within_ratio!};
154
+ if(k==='seam')return {...common,kind:k,part:v('part'),neighbour:v('neighbour'),within_px:bar.within_px!};
155
+ const [type,...rest]=v('constraint').split(':'),tolerance=Number(v('tolerance')),least=Number(v('least-drive'));
156
+ if(!Number.isFinite(tolerance)||tolerance<0)return 'tolerance: a number 0 or more';if(!Number.isFinite(least)||least<=0)return 'least drive: a number above 0';
157
+ return {...common,kind:'follow',constraint:rest.join(':'),constraint_type:type as 'ik'|'transform',bone:v('bone'),property:v('property') as 'rotate'|'translate',fraction:bar.fraction!,tolerance,least_drive:least};
158
+ }
159
+ const fpsValue=()=>{const n=Number(fps.value);return Number.isFinite(n)&&n>0&&n<=240?n:null;};
160
+ function fileText():string|null {const f=fpsValue();return declared.length&&f!==null?requirementsFileText(requirementsDocument(f,declared.map(requirementEntry))):null;}
161
+ function rehash(){const text=fileText();if(text===null){hashed=null;return;}void deps.hash(text).then(sha256=>{if(fileText()===text)hashed={text,sha256};});}
162
+ function panelStatus(text:string,error:boolean){status.textContent=text;status.title=text;status.classList.toggle('error',error);}
163
+ /** One row per declared requirement: its bar slider(s) (the last measured value marked on the track), its line, a seek to its worst frame. */
164
+ function renderList(){
165
+ list.replaceChildren();shown=[];
166
+ const current=job&&job.file.text===fileText()?job:null;
167
+ for(const r of declared){
168
+ const row=job?.rows.find(x=>x.name===r.name),stale=!!row&&!current;
169
+ const item=make('li',{className:`requirements-item${row?` ${row.status==='PASS'?'pass':'fail'}`:''}`});item.dataset.name=r.name;
170
+ const head=make('div',{className:'row'},make('code',{textContent:`${r.name} · ${r.kind} · ${r.animation}`}));
171
+ const remove=make('button',{type:'button',textContent:'Remove',title:`Remove ${r.name}`});remove.onclick=()=>panel.remove(r.name);head.append(remove);item.append(head);
172
+ for(const b of BAR_FIELDS[r.kind]){
173
+ const value=(r as unknown as Record<string,number>)[b.field]!,measured=measuredFor(row,b.field);
174
+ const lo=Math.min(b.min,value,measured??b.min),hi=Math.max(b.max,value,measured===null?b.max:Math.ceil(measured*1.25*100)/100);
175
+ const slider=make('input',{type:'range',min:String(lo),max:String(hi),step:String(b.step),value:String(value)});slider.setAttribute('aria-label',`${r.name} ${b.field}`);
176
+ if(measured!==null){const marks=make('datalist',{id:`requirements-mark-${r.name}-${b.field}`},make('option',{value:String(measured)}));slider.setAttribute('list',marks.id);item.append(marks);}
177
+ const out=make('output',{textContent:`${b.field} ${value}`});
178
+ slider.oninput=()=>{out.textContent=`${b.field} ${slider.value}`;};slider.onchange=()=>panel.setBar(r.name,b.field,Number(slider.value));
179
+ item.append(make('div',{className:'row requirements-bar'},slider,out,make('span',{className:'subtle',textContent:measured===null?'not measured yet':`measured ${measured}${stale?' (before this change)':''}`})));
180
+ }
181
+ if(row){
182
+ const text=row.line,worst=row.worst;shown.push(text);
183
+ if(worst){const go=make('button',{type:'button',className:'readings-go',textContent:text,title:`Seek to ${worst.text}`});go.onclick=()=>deps.seek(row.animation||r.animation,worst.frame,worst.time,`${r.name} worst`);item.append(go);}
184
+ else item.append(make('code',{textContent:text,title:text}));
185
+ }
186
+ list.append(item);
187
+ }
188
+ check.disabled=busy||!declared.length||!catalog||catalog.status!=='ready';
189
+ }
190
+ function changed(){rehash();renderList();renderFields();}
191
+
192
+ kind.onchange=renderFields;
193
+ add.onclick=()=>{const r=fromInputs();if(typeof r==='string'){panelStatus(r,true);return;}declared.push(r);name.value='';panelStatus(`Added ${r.name}; set its bar, then Check.`,false);changed();};
194
+ fps.onchange=changed;
195
+ check.onclick=()=>{void (async()=>{
196
+ const f=fpsValue();if(f===null){panelStatus('fps: a number above 0, at most 240',true);return;}
197
+ busy=true;check.disabled=true;check.textContent='Checking…';panelStatus(`rig-parts check --requirements on the ${build.value==='inspected'?'inspected rig':'latest scratch build'}…`,false);
198
+ try{panel.checked(await deps.check({fps:f,build:build.value as RequirementsBuild,requirements:declared.map(requirementEntry)}));}
199
+ finally{busy=false;check.textContent='Check';renderList();renderFields();}
200
+ })();};
201
+
202
+ const panel:RequirementsPanel={
203
+ load(next,reason){
204
+ catalog=next;
205
+ if(!next||next.status!=='ready'){state.textContent=reason??next?.reason??'REQUIREMENTS_UNAVAILABLE: no producer handoff';state.classList.add('error');kind.disabled=animation.disabled=true;add.disabled=check.disabled=true;fields.replaceChildren();return;}
206
+ state.textContent=`Rig spec: ${next.bones.length} bones, ${next.slots.length} slots, ${Object.keys(next.meshes).length} mesh slot(s), ${next.constraints.length} ik/transform constraint(s), ${next.animations.length} animation(s)${next.scratch?' · a scratch build exists':''}.`;state.classList.remove('error');
207
+ kind.disabled=animation.disabled=false;
208
+ options(kind,REQUIREMENT_KINDS,k=>{const why=kindUnavailable(k as RequirementKind,next);return why?`${k} — unavailable`:k;});
209
+ options(animation,next.animations.map(a=>a.name));const drawn=deps.animation();if(next.animations.some(a=>a.name===drawn))animation.value=drawn;
210
+ renderFields();renderList();
211
+ },
212
+ selectionChanged(){const drawn=deps.animation();if(catalog?.animations.some(a=>a.name===drawn))animation.value=drawn;renderFields();},
213
+ requirements:()=>declared.map(r=>JSON.parse(JSON.stringify(r)) as Requirement),
214
+ add(r){declared.push(JSON.parse(JSON.stringify(r)) as Requirement);changed();},
215
+ remove(n){declared=declared.filter(r=>r.name!==n);changed();},
216
+ setBar(n,field,value){const r=declared.find(x=>x.name===n) as unknown as Record<string,unknown>|undefined;if(!r||!(KIND_FIELDS[(r.kind as RequirementKind)]??[]).includes(field)||!Number.isFinite(value))return;r[field]=value;changed();},
217
+ text:fileText,
218
+ checked(result){
219
+ refusals.replaceChildren();summary.textContent='';
220
+ if(!result){panelStatus('REQUIREMENTS_FAILED: no answer from the check',true);return;}
221
+ if(!('id' in result)){panelStatus(result.error,true);return;}
222
+ job=result;
223
+ for(const line of [...result.refusals,...result.problems])refusals.append(make('li',{},make('code',{textContent:line,title:line})));
224
+ summary.textContent=[result.summary,result.checkLine].filter(Boolean).join(' · ');
225
+ if(result.status==='checked')panelStatus(`${result.producer.package} ${result.producer.version} check on the ${result.build==='inspected'?'inspected rig':'scratch build'}: ${result.rows.length} line(s)${result.wallMs===null?'':` · ${(result.wallMs/1000).toFixed(1)} s`}`,result.rows.some(r=>r.status!=='PASS'));
226
+ else panelStatus(result.error??`requirements check ${result.status}`,true);
227
+ renderList();
228
+ },
229
+ context(){
230
+ const text=fileText();if(text===null||!hashed||hashed.text!==text)return null;
231
+ const last=job&&job.file.text===text&&job.status!=='running'&&job.status!=='failed'?job:null;
232
+ return {schema:REQUIREMENTS_CONTEXT_SCHEMA,sha256:hashed.sha256,size:new TextEncoder().encode(text).length,text,
233
+ checked:last?{build:last.build,producer:{...last.producer},summary:last.summary,refusals:[...last.refusals],rows:last.rows.map(r=>({name:r.name,status:r.status,worst:r.worst?{...r.worst}:null}))}:null};
234
+ },
235
+ shownLines:()=>[...shown],
236
+ };
237
+ return panel;
238
+ }