@rig-c/review 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +206 -12
  2. package/docs/DESIGN.md +26 -4
  3. package/docs/REVIEW_PACKAGE.md +113 -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 +10 -4
  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 +19 -6
  16. package/src/feedback-export.ts +17 -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 +45 -15
  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 +9 -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 +128 -13
  42. package/web/chest-regions-tool.ts +2 -1
  43. package/web/cuts-panel.ts +266 -0
  44. package/web/idle-panel.ts +280 -0
  45. package/web/index.html +1 -1
  46. package/web/inspector-blocks.ts +77 -0
  47. package/web/main.ts +701 -49
  48. package/web/mesh-editor.ts +73 -38
  49. package/web/mesh-panel.ts +273 -0
  50. package/web/physics-panel.ts +151 -0
  51. package/web/readings-panel.ts +105 -0
  52. package/web/rebuild-client.ts +216 -0
  53. package/web/requirements-panel.ts +241 -0
  54. package/web/retarget-panel.ts +203 -0
  55. package/web/rig-fields-panel.ts +269 -0
  56. package/web/rig-overlays.ts +108 -14
  57. package/web/setup-pose.ts +30 -0
  58. package/web/styles.css +28 -5
  59. package/web/weights-view.ts +159 -0
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * The chest-region editor (Setup): a view-bar toggle, an editor bar of R / L circles and direct manipulation of the
3
3
  * circles on the rig pane. The values live in the Chest regions detail block's fields (row 0 = R, row 1 = L), so the
4
- * draft, export and binding are the detail block's. Review never regenerates anything: Apply · Rebuild refuses by name.
4
+ * draft, export and binding are the detail block's. Review builds no mesh itself: Apply · Rebuild hands the rows to the
5
+ * host, which sends them to the installed producer (#99); this module never reaches a process or the server.
5
6
  */
6
7
  import {clampNonOverlapping,CHEST_CIRCLE_LIMITS,type ChestCircle,type CropWorldMapping} from '../src/chest-regions.ts';
8
+ import {BUST_DEFAULTS,deriveBust,type BustDerivation,type BustSpec,type RigPoint} from '../src/chest-landmarks.ts';
7
9
  import {chestRowId,type ChestFieldReader} from './chest-regions-tool.ts';
8
10
  import {projectCircle,type OverlayPoint,type OverlayProject} from './rig-overlays.ts';
9
11
  import {textEntryFocused} from './view-controls.ts';
@@ -13,7 +15,6 @@ export type BustMode='move'|'r'|'band';
13
15
  export const bustSides=[{side:'r',row:0,tag:'R'},{side:'l',row:1,tag:'L'}] as const;
14
16
  export const bustFields=['x','y','r','band'] as const;
15
17
  export const bustInputId=(side:BustSide,field:typeof bustFields[number])=>`bust-${side}-${field}`;
16
- export const MODELING_INPUT_UNAVAILABLE='MODELING_INPUT_UNAVAILABLE: no producer handoff — export the JSON and rebuild with the producer';
17
18
  export const CHEST_REGIONS_CLAMPED='CHEST_REGIONS_CLAMPED';
18
19
  const sideRow=(side:BustSide)=>side==='r'?0:1;
19
20
  const sideTag=(side:BustSide)=>side==='r'?'R':'L';
@@ -69,9 +70,72 @@ export function settleBust(right:ChestCircle,left:ChestCircle):{right:ChestCircl
69
70
  // `round` is the same integer/range pass without the distance rule (−∞ margin leaves every pair fitting), so only the non-overlap change flashes.
70
71
  return {right:out.right,left:out.left,clamped:(['r','l'] as const).filter(side=>!same(side==='r'?out.right:out.left,side==='r'?round.right:round.left))};
71
72
  }
72
- /** Apply · Rebuild: review has no producer handoff (#45), so it refuses by name and runs nothing. */
73
- export function requestRebuild(notify:(text:string,error:boolean)=>void):{ok:false;error:string} {
74
- notify(MODELING_INPUT_UNAVAILABLE,true);return {ok:false,error:MODELING_INPUT_UNAVAILABLE};
73
+ /** The editor-bar status: a held rebuild notice (its latest line, or the producer's refusal verbatim) wins over the drag help. */
74
+ export function editorStatus(notice:{text:string;error:boolean}|null,mapping:CropWorldMapping,mode:'circles'|'landmarks'='circles'):{text:string;error:boolean} {
75
+ if(notice)return notice;
76
+ if(mapping.available&&mode==='landmarks')return {text:'Click the part to place the armed landmark · drag a marker to move it · Space-drag pans',error:false};
77
+ return mapping.available?{text:'Drag a centre to move · the right-edge handle sets r · the dashed top handle sets band · Space-drag pans',error:false}:{text:`${mapping.reason} — the values still edit and export; nothing is drawn on the stage.`,error:true};
78
+ }
79
+
80
+ /** Circles (#92/#99: the R / L rows) or Landmarks (#105: `contour.bust`). The mode lives in a hidden input, so the draft keeps it. */
81
+ export type ChestMode='circles'|'landmarks';
82
+ export const LANDMARKS=[{key:'apex_r',tag:'Apex R'},{key:'apex_l',tag:'Apex L'},{key:'cleavage',tag:'Cleavage'}] as const;
83
+ export type LandmarkKey=typeof LANDMARKS[number]['key'];
84
+ export const landmarkInputId=(key:LandmarkKey,axis:'x'|'y')=>`lm-${key}-${axis}`;
85
+ /** Slider ranges; the producer's defaults sit inside them. */
86
+ export const LANDMARK_SLIDERS={alpha:{min:0.05,max:0.95,step:0.01},clearance:{min:0,max:40,step:0.1}} as const;
87
+ export type LandmarkRead={spec:BustSpec;derived:BustDerivation}|{missing:LandmarkKey[]}|{error:string};
88
+ /**
89
+ * The landmark fields → the `contour.bust` entry and the producer's derivation of it. Points are rig px (integers from a
90
+ * click, any 1/256 value typed); alpha and clearance are always stated; an empty bone field is the producer's default.
91
+ */
92
+ export function readLandmarks(read:ChestFieldReader):LandmarkRead {
93
+ const value=(id:string)=>(read(id)?.value??'').trim();
94
+ const points:Partial<Record<LandmarkKey,RigPoint>>={},missing:LandmarkKey[]=[];
95
+ for(const {key} of LANDMARKS){
96
+ const x=value(landmarkInputId(key,'x')),y=value(landmarkInputId(key,'y'));
97
+ if(!x||!y){missing.push(key);continue;}
98
+ const p:RigPoint=[Number(x),Number(y)];
99
+ if(!p.every(Number.isFinite))return {error:`LANDMARK_VALUE_INVALID: ${key}`};
100
+ if(!p.every(n=>Number.isInteger(n*256)))return {error:`LANDMARK_VALUE_INVALID: ${key} is off the 1/256 px grid`};
101
+ points[key]=p;
102
+ }
103
+ if(missing.length)return {missing};
104
+ const alpha=Number(value('lm-alpha')||BUST_DEFAULTS.alpha),clearance=Number(value('lm-clearance')||BUST_DEFAULTS.clearance);
105
+ if(!Number.isFinite(alpha)||alpha<=0||alpha>=1)return {error:'LANDMARK_VALUE_INVALID: alpha is a core ratio in (0, 1)'};
106
+ if(!Number.isFinite(clearance))return {error:'LANDMARK_VALUE_INVALID: clearance'};
107
+ const spec:BustSpec={apex_r:points.apex_r!,apex_l:points.apex_l!,cleavage:points.cleavage!,alpha,clearance};
108
+ for(const key of ['bone_r','bone_l'] as const){const bone=value(`lm-${key}`);if(bone)spec[key]=bone;}
109
+ return {spec,derived:deriveBust(spec)};
110
+ }
111
+ /** The landmark bar's readout: the derived circles, the producer's refusal verbatim, or which click comes next. */
112
+ export function landmarkReadout(result:LandmarkRead):{text:string;error:boolean} {
113
+ if('error' in result)return {text:result.error,error:true};
114
+ if('missing' in result)return {text:`Click the part to place ${LANDMARKS.find(l=>l.key===result.missing[0])!.tag}${result.missing.length>1?` (then ${result.missing.slice(1).map(k=>LANDMARKS.find(l=>l.key===k)!.tag).join(', ')})`:''} · drag a marker to move it`,error:false};
115
+ if(result.derived.refusal)return {text:result.derived.refusal,error:true};
116
+ const [r,l]=result.derived.regions;
117
+ return {text:`bust_r (${r.cx},${r.cy}) r ${r.r} band ${r.band} → ${r.bone} · bust_l (${l.cx},${l.cy}) r ${l.r} band ${l.band} → ${l.bone} · D ${result.derived.D.toFixed(2)} px · the producer derives these again`,error:false};
118
+ }
119
+ /** The next point a click places: the armed one, else the first one not yet placed, else none. */
120
+ export function nextLandmark(armed:LandmarkKey|null,missing:readonly LandmarkKey[]):LandmarkKey|null {return armed??missing[0]??null;}
121
+ /**
122
+ * Canvas px → crop px through the camera projection and the crop → world mapping, both affine: each is read off three
123
+ * points and inverted. Null when either is degenerate.
124
+ */
125
+ export function canvasToCrop(project:OverlayProject,toWorld:(x:number,y:number)=>{x:number;y:number},p:OverlayPoint):RigPoint|null {
126
+ const invert=(f:(x:number,y:number)=>{x:number;y:number},q:{x:number;y:number})=>{
127
+ const o=f(0,0),ex=f(1,0),ey=f(0,1),a=ex.x-o.x,b=ey.x-o.x,c=ex.y-o.y,d=ey.y-o.y,det=a*d-b*c;
128
+ if(!Number.isFinite(det)||Math.abs(det)<1e-12)return null;
129
+ const dx=q.x-o.x,dy=q.y-o.y;return {x:(d*dx-b*dy)/det,y:(a*dy-c*dx)/det};
130
+ };
131
+ const world=invert(project,p);if(!world)return null;
132
+ const crop=invert(toWorld,world);return crop?[crop.x,crop.y]:null;
133
+ }
134
+ /** The marker under the pointer (canvas px), within 10 px. */
135
+ export function hitLandmark(markers:readonly {key:LandmarkKey;at:OverlayPoint}[],p:OverlayPoint):LandmarkKey|null {
136
+ let best:LandmarkKey|null=null,distance=10;
137
+ for(const m of markers){const d=Math.hypot(p.x-m.at.x,p.y-m.at.y);if(d<=distance){best=m.key;distance=d;}}
138
+ return best;
75
139
  }
76
140
 
77
141
  export interface ChestEditorHost {
@@ -84,16 +148,41 @@ export interface ChestEditorHost {
84
148
  /** Re-render the detail block and the overlay after a value changed. */
85
149
  changed:()=>void;
86
150
  download:()=>void;
151
+ /** Apply · Rebuild: the host runs the installed producer on the current rows and reloads the stage. */
152
+ apply:()=>void;
153
+ /** Reset: the host reloads the inspected build. */
154
+ reset:()=>void;
87
155
  message:(text:string,error?:boolean)=>void;
88
156
  inspector:()=>string|undefined;
89
157
  }
90
- export interface ChestEditor {active:()=>boolean;flashing:(side:BustSide)=>boolean;sync:()=>void;dispose:()=>void}
158
+ export interface ChestEditor {active:()=>boolean;flashing:(side:BustSide)=>boolean;sync:()=>void;dispose:()=>void;
159
+ /** Circles or Landmarks; Apply sends regions or `contour.bust` accordingly. */
160
+ mode:()=>ChestMode;
161
+ /** The landmark fields read as the producer's entry and derivation (Landmarks mode). */
162
+ landmarks:()=>LandmarkRead;
163
+ /** The point the next click places, for the overlay's highlight. */
164
+ armed:()=>LandmarkKey|null;
165
+ /** Hold a status line (null clears it); `log` is the producer's latest lines. */
166
+ notify:(text:string|null,error?:boolean,log?:readonly string[])=>void;
167
+ /** Apply is disabled while a rebuild runs; Reset is enabled while a rebuilt build is on the stage. */
168
+ rebuildState:(state:{busy:boolean;applied:boolean})=>void}
91
169
 
92
170
  export function bindChestEditor(host:ChestEditorHost):ChestEditor {
93
171
  const node=<T extends HTMLElement>(id:string)=>document.getElementById(id) as T;
94
172
  const toggle=node<HTMLButtonElement>('chest-edit'),bar=node<HTMLElement>('chest-editor-bar'),status=node<HTMLElement>('chest-editor-status');
95
173
  const field=(id:string)=>document.getElementById(id) as HTMLInputElement|null;
96
174
  const read:ChestFieldReader=id=>{const f=field(id);return f?{value:f.value}:undefined;};
175
+ const log=node<HTMLElement>('chest-editor-log'),apply=node<HTMLButtonElement>('bust-apply'),reset=node<HTMLButtonElement>('bust-reset');
176
+ let notice:{text:string;error:boolean}|null=null;
177
+ const modeInput=field('bust-mode'),landmarkGroups=[...bar.querySelectorAll<HTMLElement>('[data-chest-mode]')],readout=node<HTMLElement>('lm-derived');
178
+ const mode=():ChestMode=>modeInput?.value==='landmarks'?'landmarks':'circles';
179
+ let armed:LandmarkKey|null=null,landmarkDrag:{key:LandmarkKey;pointer:number}|null=null;
180
+ const writePoint=(key:LandmarkKey,[x,y]:RigPoint)=>{for(const [axis,v] of [['x',x],['y',y]] as const){const f=field(landmarkInputId(key,axis));if(f)f.value=String(Math.round(v));}};
181
+ const landmarkMarkers=():{key:LandmarkKey;at:OverlayPoint}[]=>{
182
+ const project=host.project(),mapping=host.mapping();if(!project||!mapping.available)return [];
183
+ return LANDMARKS.flatMap(({key})=>{const x=Number((read(landmarkInputId(key,'x'))?.value??'').trim()||NaN),y=Number((read(landmarkInputId(key,'y'))?.value??'').trim()||NaN);if(!Number.isFinite(x)||!Number.isFinite(y))return [];const w=mapping.toWorld(x,y);return [{key,at:project(w.x,w.y)}];});
184
+ };
185
+ const cropAt=(p:OverlayPoint):RigPoint|null=>{const project=host.project(),mapping=host.mapping();return project&&mapping.available?canvasToCrop(project,mapping.toWorld,p):null;};
97
186
  let active=false,space=false,drag:(BustDrag&{pointer:number;other:ChestCircle|null})|null=null,flashTimer:ReturnType<typeof setTimeout>|null=null;
98
187
  const flash=new Set<BustSide>();
99
188
  const write=(row:number,circle:Partial<ChestCircle>)=>{for(const [id,value] of Object.entries(circleFields(row,circle))){const f=field(id);if(f)f.value=value;}};
@@ -122,11 +211,17 @@ export function bindChestEditor(host:ChestEditorHost):ChestEditor {
122
211
  for(const f of bustFields){const input=field(bustInputId(side,f));if(input&&document.activeElement!==input)input.value=values[f];}
123
212
  const r=field(bustInputId(side,'r'));if(r)r.placeholder=circle||!(read(chestRowId(row,'ax'))?.value??'').trim()?'':'ellipse';
124
213
  }
125
- const mapping=host.mapping();
126
- status.textContent=mapping.available?'Drag a centre to move · the right-edge handle sets r · the dashed top handle sets band · Space-drag pans':`${mapping.reason} — the values still edit and export; nothing is drawn on the stage.`;
127
- status.classList.toggle('error',!mapping.available);
214
+ const m=mode();
215
+ for(const group of landmarkGroups)group.hidden=group.dataset.chestMode!==m;
216
+ for(const button of bar.querySelectorAll<HTMLButtonElement>('[data-bust-mode]'))button.setAttribute('aria-pressed',String(button.dataset.bustMode===m));
217
+ const landmarks=readLandmarks(read),shownReadout=landmarkReadout(landmarks),next=nextLandmark(armed,'missing' in landmarks?landmarks.missing:[]);
218
+ readout.textContent=shownReadout.text;readout.title=shownReadout.text;readout.classList.toggle('error',shownReadout.error);
219
+ for(const button of bar.querySelectorAll<HTMLButtonElement>('[data-landmark]'))button.setAttribute('aria-pressed',String(button.dataset.landmark===next));
220
+ for(const id of ['lm-alpha','lm-clearance']){const out=document.getElementById(`${id}-value`);const f=field(id);if(out&&f)out.textContent=f.value;}
221
+ const shown=editorStatus(notice,host.mapping(),m);
222
+ status.textContent=shown.text;status.title=shown.text;status.classList.toggle('error',shown.error);
128
223
  };
129
- const setActive=(on:boolean)=>{active=on;toggle.setAttribute('aria-pressed',String(on));bar.hidden=!on;if(!on){drag=null;delete host.pane.dataset.chestHover;}sync();host.changed();};
224
+ const setActive=(on:boolean)=>{active=on;toggle.setAttribute('aria-pressed',String(on));bar.hidden=!on;if(!on){drag=null;landmarkDrag=null;armed=null;delete host.pane.dataset.chestHover;}sync();host.changed();};
130
225
  toggle.addEventListener('click',()=>setActive(!active));
131
226
  bar.addEventListener('input',event=>{
132
227
  const target=event.target as HTMLInputElement,match=/^bust-([rl])-(x|y|r|band)$/.exec(target.id);if(!match)return;
@@ -140,19 +235,33 @@ export function bindChestEditor(host:ChestEditorHost):ChestEditor {
140
235
  if(mine)commit(side,{...mine,r:limit(Math.round(mine.r),CHEST_CIRCLE_LIMITS.r),band:limit(Math.round(mine.band),CHEST_CIRCLE_LIMITS.band)},other);
141
236
  sync();host.changed();
142
237
  });
238
+ for(const button of bar.querySelectorAll<HTMLButtonElement>('[data-bust-mode]'))button.addEventListener('click',()=>{if(modeInput){modeInput.value=button.dataset.bustMode==='landmarks'?'landmarks':'circles';modeInput.dispatchEvent(new Event('input',{bubbles:true}));}armed=null;sync();host.changed();});
239
+ for(const button of bar.querySelectorAll<HTMLButtonElement>('[data-landmark]'))button.addEventListener('click',()=>{const key=button.dataset.landmark as LandmarkKey;armed=armed===key?null:key;sync();host.changed();});
240
+ bar.addEventListener('input',event=>{const id=(event.target as HTMLElement).id;if(/^lm-/.test(id)){sync();host.changed();}});
143
241
  node<HTMLButtonElement>('bust-mirror').addEventListener('click',()=>{
144
242
  const right=rowCircle(read,0),leftX=Number((read(chestRowId(1,'cx'))?.value??'').trim()||NaN);
145
243
  if(!right||!Number.isFinite(leftX)){host.message('Mirror needs a complete R circle and an L x.',true);return;}
146
244
  commit('l',mirrorBust(right,{cx:leftX}),right);sync();host.changed();
147
245
  });
148
246
  node<HTMLButtonElement>('bust-download').addEventListener('click',()=>host.download());
149
- node<HTMLButtonElement>('bust-apply').addEventListener('click',()=>{const result=requestRebuild((text,error)=>host.message(text,error));status.textContent=result.error;status.classList.add('error');});
247
+ apply.addEventListener('click',()=>host.apply());
248
+ reset.addEventListener('click',()=>host.reset());
150
249
  const keyDown=(event:KeyboardEvent)=>{if(event.code==='Space'&&!textEntryFocused(event.target))space=true;};
151
250
  const keyUp=(event:KeyboardEvent)=>{if(event.code==='Space')space=false;};
152
251
  const blur=()=>{space=false;};
153
252
  const down=(event:PointerEvent)=>{
154
253
  if(!active||space||!event.isPrimary||event.button!==0)return;
155
254
  const target=event.target;if(target instanceof Element&&target.closest('button,input,select,textarea,a'))return;
255
+ if(mode()==='landmarks'){
256
+ const p=local(event),hitKey=hitLandmark(landmarkMarkers(),p),landmarks=readLandmarks(read),place=hitKey?null:nextLandmark(armed,'missing' in landmarks?landmarks.missing:[]);
257
+ if(!hitKey&&!place)return;
258
+ const at=cropAt(p);if(!at)return;
259
+ // A landmark click or drag edits values; it never reaches the viewport's pan.
260
+ event.stopPropagation();event.preventDefault();
261
+ const key=(hitKey??place)!;writePoint(key,at);if(place)armed=null;
262
+ landmarkDrag={key,pointer:event.pointerId};host.pane.setPointerCapture(event.pointerId);host.pane.dataset.chestHover='move';
263
+ sync();host.changed();return;
264
+ }
156
265
  const p=local(event),circles=projected(),hit=hitBustHandle(circles,p);if(!hit)return;
157
266
  const row=sideRow(hit.side),from=rowCircle(read,row),at=circles.find(c=>c.side===hit.side);if(!from||!at)return;
158
267
  // A handle drag edits values; it never reaches the viewport's pan and never moves the camera.
@@ -161,10 +270,14 @@ export function bindChestEditor(host:ChestEditorHost):ChestEditor {
161
270
  host.pane.setPointerCapture(event.pointerId);host.pane.dataset.chestHover=hit.mode;
162
271
  };
163
272
  const move=(event:PointerEvent)=>{
273
+ if(landmarkDrag&&event.pointerId===landmarkDrag.pointer){event.stopPropagation();const at=cropAt(local(event));if(at){writePoint(landmarkDrag.key,at);sync();host.changed();}return;}
274
+ if(active&&!space&&mode()==='landmarks'){if(hitLandmark(landmarkMarkers(),local(event)))host.pane.dataset.chestHover='move';else delete host.pane.dataset.chestHover;return;}
164
275
  if(drag&&event.pointerId===drag.pointer){event.stopPropagation();commit(drag.side,dragBust(drag,local(event),scale()),drag.other);sync();host.changed();return;}
165
276
  if(active&&!space&&!drag){const hit=hitBustHandle(projected(),local(event));if(hit)host.pane.dataset.chestHover=hit.mode;else delete host.pane.dataset.chestHover;}
166
277
  };
167
- const end=(event:PointerEvent)=>{if(!drag||event.pointerId!==drag.pointer)return;drag=null;delete host.pane.dataset.chestHover;if(host.pane.hasPointerCapture(event.pointerId))host.pane.releasePointerCapture(event.pointerId);host.changed();};
278
+ const end=(event:PointerEvent)=>{
279
+ if(landmarkDrag&&event.pointerId===landmarkDrag.pointer){landmarkDrag=null;delete host.pane.dataset.chestHover;if(host.pane.hasPointerCapture(event.pointerId))host.pane.releasePointerCapture(event.pointerId);host.changed();return;}
280
+ if(!drag||event.pointerId!==drag.pointer)return;drag=null;delete host.pane.dataset.chestHover;if(host.pane.hasPointerCapture(event.pointerId))host.pane.releasePointerCapture(event.pointerId);host.changed();};
168
281
  host.pane.addEventListener('pointerdown',down,true);host.pane.addEventListener('pointermove',move,true);
169
282
  for(const type of ['pointerup','pointercancel','lostpointercapture'] as const)host.pane.addEventListener(type,end,true);
170
283
  document.addEventListener('keydown',keyDown,true);window.addEventListener('keyup',keyUp,true);window.addEventListener('blur',blur);
@@ -173,7 +286,9 @@ export function bindChestEditor(host:ChestEditorHost):ChestEditor {
173
286
  for(const selector of document.querySelectorAll('[data-inspector]'))observer.observe(selector,{attributes:true,attributeFilter:['aria-pressed','aria-selected']});
174
287
  sync();
175
288
  return {
176
- active:()=>active,flashing:side=>flash.has(side),sync,
289
+ active:()=>active,flashing:side=>flash.has(side),sync,mode,landmarks:()=>readLandmarks(read),armed:()=>{const l=readLandmarks(read);return nextLandmark(armed,'missing' in l?l.missing:[]);},
290
+ notify:(text,error=false,lines)=>{notice=text===null?null:{text,error};if(lines){log.textContent=lines.join('\n');log.hidden=!lines.length;log.scrollTop=log.scrollHeight;}sync();},
291
+ rebuildState:({busy,applied})=>{apply.disabled=busy;apply.setAttribute('aria-busy',String(busy));reset.disabled=busy||!applied;},
177
292
  dispose:()=>{observer.disconnect();if(flashTimer)clearTimeout(flashTimer);host.pane.removeEventListener('pointerdown',down,true);host.pane.removeEventListener('pointermove',move,true);for(const type of ['pointerup','pointercancel','lostpointercapture'] as const)host.pane.removeEventListener(type,end,true);document.removeEventListener('keydown',keyDown,true);window.removeEventListener('keyup',keyUp,true);window.removeEventListener('blur',blur);},
178
293
  };
179
294
  }
@@ -27,7 +27,8 @@ export function readChestForm(read:ChestFieldReader,mapping:CropWorldMapping,bon
27
27
  const crop=mapping.available?mapping.crop:{w:numberField(read,'chest-crop-w','crop w')!,h:numberField(read,'chest-crop-h','crop h')!};
28
28
  const contour={tolerance:0,margin:0,spacing:0,maxEdge:0,stray:0,bridges:read('chest-bridges')?.checked===true};
29
29
  for(const [key,id] of chestContourFields)contour[key]=numberField(read,id,`contour ${key}`)!;
30
- const regions=chestRegionRows.map(row=>{
30
+ // A row without a centre x is not designated (#118): one row exports alone, as Apply · Rebuild sends it.
31
+ const regions=chestRegionRows.filter(row=>(read(chestRowId(row,'cx'))?.value??'').trim()).map(row=>{
31
32
  const name=(read(chestRowId(row,'name'))?.value??'').trim()||`row ${row+1}`,num=(field:typeof chestRegionFields[number],optional=false)=>numberField(read,chestRowId(row,field),`${name} ${field}`,optional);
32
33
  const clip=num('clip',true);
33
34
  return {name:(read(chestRowId(row,'name'))?.value??'').trim(),bone:read(chestRowId(row,'bone'))?.value??'',center:[num('cx')!,num('cy')!] as [number,number],semiAxes:[num('ax')!,num('ay')!] as [number,number],spacing:num('spacing')!,band:num('band')!,...(clip===undefined?{}:{clip})};
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Setup · Cuts (#115), page side: the producer config's `assemble.cuts` polygons, `assemble.patches` boxes and each limb
3
+ * joint with its `range` arc, drawn on the stage in rig px of the setup pose (the crop → world mapping the chest, mesh
4
+ * and bones views use) and edited there: drag a cut vertex or a box corner, drag a joint (its lower bone's origin — a
5
+ * `bones` op, since the joint is the bone's origin), type a range; place a new cut polygon or a new patch box by
6
+ * clicking. Every edit is a config patch op (`src/cut-edits.ts`); Apply sends them as one `rig` patch, which the server
7
+ * runs as `rig-parts build` (assemble → rig → check) from the handoff's painting and runs, and the scratch build reloads
8
+ * in place. The producer's refusals (`ASSEMBLE_CUT_*`, `ASSEMBLE_UNDER_*`, `CONFIG_LIMB_*`, `CONFIG_PART_UNIQUE`, …) come
9
+ * back verbatim; `LIMB_ONE_BONE` and `SEAM_OVER_RANGE` are check's bars, read from the build's check. Review paints no
10
+ * `under` or `cap` image: a new cut names none. The draft keeps the pending edits (`#cuts-pending`).
11
+ */
12
+ import {anchorAt,boneAnchors,configBones,setAnchor,type BoneAnchor,type BoneEdit} from '../src/bone-anchors.ts';
13
+ import type {PatchOp} from '../src/config-patch.ts';
14
+ import type {CropWorldMapping} from '../src/chest-regions.ts';
15
+ import {
16
+ boxAt,CutEditError,cutOps,cutsDraft,cutsDraftText,cutsSource,dragPoint,rangeArc,rangeAt,setBox,setRange,setVertex,vertexAt,
17
+ type CutAdd,type CutDraw,type CutEdit,type CutsSource,type PatchBox,type RigPoint,
18
+ } from '../src/cut-edits.ts';
19
+ import {canvasToCrop} from './chest-editor.ts';
20
+ import type {OverlayMarker,OverlayPoint,OverlayProject,OverlayRegion} from './rig-overlays.ts';
21
+ import {textEntryFocused} from './view-controls.ts';
22
+ import type {PatchJobView,RebuildRefusal} from './rebuild-client.ts';
23
+
24
+ export interface CutsPanelDeps {
25
+ pane:HTMLElement;canvas:HTMLElement;
26
+ project:()=>OverlayProject|null;mapping:()=>CropWorldMapping;inspector:()=>string|undefined;
27
+ pause:()=>void;changed:()=>void;saved:()=>void;
28
+ apply:(ops:PatchOp[])=>Promise<PatchJobView|RebuildRefusal|null>;
29
+ reset:()=>Promise<void>;
30
+ token:(name:string)=>string;
31
+ /** The served URL of a page-relative route (the capability-bearing access route). */
32
+ route:(path:string)=>string;
33
+ }
34
+ /** One drawn handle: a cut vertex, a patch box corner or a limb joint. */
35
+ export interface CutHandle {id:string;label:string;at:RigPoint}
36
+ export interface CutsPanel {
37
+ load(config:unknown|null,bones:unknown[]|null,reason:string|null,build:string|null):void;
38
+ source():CutsSource;ops():PatchOp[];
39
+ setVertex(cut:number,k:number,value:RigPoint):void;setBox(patch:number,value:PatchBox):void;setRange(limb:string,k:number,value:[number,number]|null):void;
40
+ active():boolean;setActive(on:boolean):void;
41
+ handles():{id:string;label:string;x:number;y:number}[];
42
+ overlayRegions():OverlayRegion[];overlayMarkers():OverlayMarker[];
43
+ apply():Promise<PatchJobView|RebuildRefusal|null>;
44
+ shown(job:PatchJobView|RebuildRefusal|null):void;
45
+ status(text:string,error:boolean):void;busy(value:boolean):void;
46
+ restore():void;clear():void;sync():void;dispose():void;
47
+ }
48
+ 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;};
49
+ 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;};
50
+ const fmt=(p:readonly number[])=>`[${p.join(', ')}]`;
51
+ /** The handle under the pointer (canvas px), within 9 px; the nearest wins. */
52
+ export function hitHandle(points:readonly {id:string;at:OverlayPoint}[],p:OverlayPoint):string|null {
53
+ let best:string|null=null,distance=9;
54
+ for(const h of points){const d=Math.hypot(h.at.x-p.x,h.at.y-p.y);if(d<=distance){best=h.id;distance=d;}}
55
+ return best;
56
+ }
57
+ const drawText=(d:CutDraw)=>typeof d==='string'?d:`before ${d.before}`;
58
+
59
+ export function bindCutsPanel(deps:CutsPanelDeps):CutsPanel {
60
+ const state=node('cuts-panel-state'),toggle=node<HTMLButtonElement>('cuts-edit'),picker=node<HTMLSelectElement>('cuts-select'),editor=node('cuts-editor'),pending=node('cuts-pending-list');
61
+ const applyButton=node<HTMLButtonElement>('cuts-apply'),resetButton=node<HTMLButtonElement>('cuts-reset'),statusOut=node('cuts-status'),readout=node('cuts-readout'),draft=node<HTMLTextAreaElement>('cuts-pending');
62
+ const addForm=node('cuts-add');
63
+ let config:unknown=null,source:CutsSource={cuts:[],patches:[],segments:[],plan:[],skipped:[]},anchors:BoneAnchor[]=[],buildReason:string|null=null,loaded=false;
64
+ let edits=new Map<string,CutEdit>(),joints=new Map<string,BoneEdit>(),adds:CutAdd[]=[];
65
+ let selected='',active=false,busy=false,space=false,hover:string|null=null;
66
+ let drag:{id:string;pointer:number;start:RigPoint;from:RigPoint}|null=null;
67
+ let placing:{kind:'cut'|'patch';points:RigPoint[]}|null=null;
68
+
69
+ const panelStatus=(text:string,error:boolean)=>{statusOut.textContent=text;statusOut.title=text;statusOut.classList.toggle('error',error);};
70
+ const save=()=>{draft.value=JSON.stringify({cuts:JSON.parse(cutsDraftText(edits,adds)),joints:[...joints.values()]});draft.dispatchEvent(new Event('input',{bubbles:true}));deps.saved();};
71
+ const jointAnchor=(bone:string)=>anchors.find(a=>a.label===bone&&(a.kind==='at'||a.kind==='point'));
72
+ const allOps=():PatchOp[]=>[...cutOps(edits,adds,config),...[...joints.values()].map(e=>({op:'replace' as const,path:e.path,value:[...e.value],name:e.name}))];
73
+ const guard=(fn:()=>void)=>{try{fn();}catch(error){if(error instanceof CutEditError){panelStatus(error.message,true);return;}throw error;}};
74
+ const change=(persist=true)=>{if(persist)save();render();deps.changed();};
75
+
76
+ /** Every handle in rig px: each cut's vertices, each patch box's two corners, each joint below a limb's upper segment. */
77
+ function handlesRig():CutHandle[] {
78
+ const out:CutHandle[]=[];
79
+ for(const c of source.cuts)c.polygon.forEach((_,k)=>out.push({id:`v:${c.index}:${k}`,label:`${c.into} vertex ${k}`,at:vertexAt(edits,c,k)}));
80
+ for(const q of source.patches){const b=boxAt(edits,q);out.push({id:`b0:${q.index}`,label:`${q.name} box min`,at:[b[0],b[1]]},{id:`b1:${q.index}`,label:`${q.name} box max`,at:[b[2],b[3]]});}
81
+ for(const s of source.segments){if(!s.joint)continue;const a=jointAnchor(s.bone);if(a)out.push({id:`j:${a.path}`,label:`${s.limb} ${s.joint} (${s.bone} origin)`,at:anchorAt(a,joints)});}
82
+ return out;
83
+ }
84
+ const projected=()=>{const project=deps.project(),mapping=deps.mapping();if(!project||!mapping.available)return [];return handlesRig().map(h=>{const w=mapping.toWorld(h.at[0],h.at[1]);return {id:h.id,at:project(w.x,w.y)};});};
85
+ const local=(event:PointerEvent):OverlayPoint=>{const rect=deps.canvas.getBoundingClientRect();return {x:event.clientX-rect.left,y:event.clientY-rect.top};};
86
+ const cropAt=(p:OverlayPoint):RigPoint|null=>{const project=deps.project(),mapping=deps.mapping();return project&&mapping.available?canvasToCrop(project,mapping.toWorld,p):null;};
87
+ /** Move one handle to a rig px value. */
88
+ function moveHandle(id:string,value:RigPoint){
89
+ const [kind,a,b]=id.split(':');
90
+ if(kind==='v'){const cut=source.cuts.find(c=>c.index===Number(a))!;edits=setVertex(edits,cut,Number(b),value);}
91
+ else if(kind==='b0'||kind==='b1'){const q=source.patches.find(p=>p.index===Number(a))!,box=[...boxAt(edits,q)] as PatchBox;if(kind==='b0'){box[0]=value[0];box[1]=value[1];}else{box[2]=value[0];box[3]=value[1];}edits=setBox(edits,q,box);}
92
+ else if(kind==='j'){const anchor=anchors.find(x=>x.path===id.slice(2))!;joints=setAnchor(joints,anchor,value);}
93
+ }
94
+
95
+ function renderPicker(){
96
+ const keep=selected;
97
+ const options=[make('option',{value:'',textContent:source.cuts.length||source.patches.length||source.segments.length?'Pick a cut, patch or limb (or drag a handle)':'The config states no cut, patch or limb'}),
98
+ ...source.cuts.map(c=>make('option',{value:`cut:${c.index}`,textContent:`cut ${c.into} ← ${c.from} · assemble.cuts[${c.index}]`})),
99
+ ...source.patches.map(q=>make('option',{value:`patch:${q.index}`,textContent:`patch ${q.name} · assemble.patches[${q.index}]`})),
100
+ ...source.segments.filter(s=>s.joint).map(s=>make('option',{value:`seg:${s.limb}:${s.index}`,textContent:`limb ${s.limb} · ${s.joint} → ${s.part} (${s.bone})`}))];
101
+ picker.replaceChildren(...options);picker.value=[...picker.options].some(o=>o.value===keep)?keep:'';picker.disabled=busy||options.length<2;
102
+ }
103
+ const numberInput=(label:string,value:number,set:(v:number)=>void,step='1')=>{const input=make('input',{type:'number',step,value:String(value),disabled:busy,inputMode:'numeric'});input.setAttribute('aria-label',label);input.onchange=()=>{const v=Number(input.value);if(!input.value.trim()||!Number.isFinite(v)){panelStatus(`CUTS_VALUE_INVALID: ${label} must be a number`,true);return;}guard(()=>{set(v);change();});};return input;};
104
+ function renderEditor(){
105
+ const [kind,a,b]=selected.split(':');
106
+ if(kind==='cut'){const c=source.cuts.find(x=>x.index===Number(a));if(!c){editor.replaceChildren();return;}
107
+ editor.replaceChildren(make('p',{className:'physics-editor-title'},make('strong',{textContent:c.into}),` · cut from ${c.from} · draw ${drawText(c.draw)} · overlap ${c.overlap}${c.under?` · under ${c.under} (the polygon box's size — moving the box's edge is the producer's ASSEMBLE_UNDER_SIZE)`:''}`),
108
+ ...c.polygon.map((_,k)=>{const now=vertexAt(edits,c,k);return make('div',{className:edits.has(`/assemble/cuts/${c.index}/polygon/${k}`)?'bones-anchor pending':'bones-anchor'},make('code',{textContent:`/assemble/cuts/${c.index}/polygon/${k}`}),numberInput(`${c.into} vertex ${k} x`,now[0],v=>{edits=setVertex(edits,c,k,[v,vertexAt(edits,c,k)[1]]);}),numberInput(`${c.into} vertex ${k} y`,now[1],v=>{edits=setVertex(edits,c,k,[vertexAt(edits,c,k)[0],v]);}));}));
109
+ return;}
110
+ if(kind==='patch'){const q=source.patches.find(x=>x.index===Number(a));if(!q){editor.replaceChildren();return;}const box=boxAt(edits,q);
111
+ editor.replaceChildren(make('p',{className:'physics-editor-title'},make('strong',{textContent:q.name}),` · patch · alpha ${q.alpha} · draw ${drawText(q.draw)} · box [x0, y0, x1, y1), x1 / y1 exclusive`),
112
+ make('div',{className:'bones-anchor'},make('code',{textContent:`/assemble/patches/${q.index}/box`}),...box.map((v,i)=>numberInput(`${q.name} box ${['x0','y0','x1','y1'][i]}`,v,n=>{const next=[...boxAt(edits,q)] as PatchBox;next[i]=n;edits=setBox(edits,q,next);}))));
113
+ return;}
114
+ if(kind==='seg'){const s=source.segments.find(x=>x.limb===a&&x.index===Number(b));if(!s){editor.replaceChildren();return;}const range=rangeAt(edits,s),anchor=jointAnchor(s.bone);
115
+ const none=make('input',{type:'checkbox',checked:!range,disabled:busy});none.setAttribute('aria-label',`${s.part} no range`);none.onchange=()=>guard(()=>{edits=setRange(edits,s,none.checked?null:rangeAt(edits,s)??[-30,30]);change();});
116
+ editor.replaceChildren(make('p',{className:'physics-editor-title'},make('strong',{textContent:`${s.limb} · ${s.joint}`}),` · ${s.part} on ${s.bone}, cut from ${s.from} · range is degrees from rest, min ≤ 0 ≤ max (check poses the joint there: SEAM_OVER_RANGE)`),
117
+ make('div',{className:'bones-anchor'},make('code',{textContent:`/limbs/${s.limb}/segments/${s.index}/range`}),...(range?[numberInput(`${s.part} range min`,range[0],v=>{edits=setRange(edits,s,[v,rangeAt(edits,s)![1]]);},'any'),numberInput(`${s.part} range max`,range[1],v=>{edits=setRange(edits,s,[rangeAt(edits,s)![0],v]);},'any')]:[]),make('label',{},none,' no range')),
118
+ ...(anchor?[make('div',{className:joints.has(anchor.path)?'bones-anchor pending':'bones-anchor'},make('code',{textContent:anchor.path}),make('span',{textContent:`joint = ${s.bone} origin`}),...[0,1].map(i=>numberInput(`${s.joint} ${i?'y':'x'}`,anchorAt(anchor,joints)[i]!,v=>{const p=[...anchorAt(anchor,joints)] as RigPoint;p[i]=v;joints=setAnchor(joints,anchor,p);})))]:[make('p',{className:'hint',textContent:`${s.bone}'s origin is not a config bone anchor; the joint is not drawn`})]));
119
+ return;}
120
+ editor.replaceChildren(make('p',{className:'hint',textContent:'Pick a cut, a patch or a limb joint to edit it by number, or drag a handle on the stage.'}));
121
+ }
122
+ function renderPending(){
123
+ const items:HTMLElement[]=[];
124
+ for(const e of edits.values()){const revert=make('button',{type:'button',textContent:'Revert',disabled:busy});revert.onclick=()=>{edits.delete(e.path);change();};
125
+ items.push(make('li',{},make('code',{textContent:`${e.kind==='range'&&e.value===null?'remove':e.kind==='range'&&e.from===null?'add':'replace'} ${e.path}`}),` ${e.from?fmt(e.from):'—'} → ${e.value?fmt(e.value):'—'} · ${e.name}`,revert));}
126
+ for(const e of joints.values()){const revert=make('button',{type:'button',textContent:'Revert',disabled:busy});revert.onclick=()=>{joints.delete(e.path);change();};
127
+ items.push(make('li',{},make('code',{textContent:`replace ${e.path}`}),` ${fmt(e.from)} → ${fmt(e.value)} · ${e.name} (a joint is its bone's origin)`,revert));}
128
+ adds.forEach((a,i)=>{const revert=make('button',{type:'button',textContent:'Revert',disabled:busy});revert.onclick=()=>{adds.splice(i,1);change();};
129
+ items.push(make('li',{},make('code',{textContent:`add /assemble/${a.kind==='cut'?'cuts':'patches'}/-`}),` ${a.kind==='cut'?`cut ${a.into} ← ${a.from}, ${a.polygon.length} point(s)`:`patch ${a.name} ${fmt(a.box)}`} · regions → ${a.bone}`,revert));});
130
+ pending.replaceChildren(...(items.length?items:[make('li',{className:'hint',textContent:'No pending ops.'})]));
131
+ applyButton.disabled=busy||!items.length||!!buildReason;resetButton.disabled=busy;
132
+ toggle.disabled=!loaded;toggle.setAttribute('aria-pressed',String(active));
133
+ }
134
+ function renderAdd(){
135
+ const field=(id:string)=>addForm.querySelector<HTMLInputElement|HTMLSelectElement>(`[data-cuts-add="${id}"]`);
136
+ const from=field('from') as HTMLSelectElement|null,before=field('draw') as HTMLSelectElement|null;
137
+ if(from&&from.options.length!==source.plan.length){from.replaceChildren(...source.plan.map(n=>make('option',{value:n,textContent:n})));}
138
+ if(before&&before.options.length!==source.plan.length+2){before.replaceChildren(make('option',{value:'front',textContent:'front'}),make('option',{value:'back',textContent:'back'}),...source.plan.map(n=>make('option',{value:`before:${n}`,textContent:`before ${n}`})));}
139
+ for(const el of addForm.querySelectorAll<HTMLInputElement|HTMLSelectElement|HTMLButtonElement>('input,select,button'))el.disabled=busy||!loaded;
140
+ const finish=addForm.querySelector<HTMLButtonElement>('[data-cuts-add="finish"]');if(finish)finish.disabled=busy||!placing||(placing.kind==='cut'?placing.points.length<3:placing.points.length<2);
141
+ }
142
+ function render(){renderPicker();renderEditor();renderPending();renderAdd();}
143
+ function renderState(){
144
+ if(!loaded)return;const mapping=deps.mapping();
145
+ state.classList.toggle('error',!mapping.available||!!source.skipped.length||!!buildReason);
146
+ state.textContent=`${source.cuts.length} cut(s) · ${source.patches.length} patch(es) · ${source.segments.filter(s=>s.joint).length} limb joint(s) in rig px of the setup pose${source.skipped.length?` · not read: ${source.skipped.join('; ')}`:''}${buildReason?` · Apply: ${buildReason}`:''}${mapping.available?'':` · ${mapping.reason}: no handles are drawn`}`;
147
+ }
148
+
149
+ const setActive=(on:boolean)=>{active=on&&loaded;if(active)deps.pause();if(!active){drag=null;hover=null;placing=null;delete deps.pane.dataset.cutsHover;}render();deps.changed();};
150
+ toggle.onclick=()=>setActive(!active);
151
+ picker.onchange=()=>{selected=picker.value;render();deps.changed();};
152
+ applyButton.onclick=()=>{void panel.apply();};
153
+ resetButton.onclick=()=>{if(!busy)void deps.reset();};
154
+ addForm.addEventListener('click',event=>{
155
+ const target=(event.target as HTMLElement).closest<HTMLButtonElement>('button[data-cuts-add]');if(!target)return;
156
+ const kind=target.dataset.cutsAdd;
157
+ if(kind==='place-cut'||kind==='place-patch'){placing={kind:kind==='place-cut'?'cut':'patch',points:[]};setActive(true);panelStatus(kind==='place-cut'?'Click the polygon\'s points on the stage (rig px, whole numbers), then Finish.':'Click the box\'s two corners on the stage, then Finish.',false);render();return;}
158
+ if(kind==='finish'&&placing){
159
+ const value=(id:string)=>(addForm.querySelector<HTMLInputElement|HTMLSelectElement>(`[data-cuts-add="${id}"]`)?.value??'').trim();
160
+ const drawValue=value('draw'),draw:CutDraw=drawValue.startsWith('before:')?{before:drawValue.slice('before:'.length)}:drawValue==='back'?'back':'front';
161
+ const name=value('name'),bone=value('bone');
162
+ if(!name||!bone){panelStatus('CUTS_ADD_INCOMPLETE: a new part needs a name and the bone its region rides',true);return;}
163
+ if(placing.kind==='cut')adds.push({kind:'cut',from:value('from'),into:name,polygon:placing.points.map(p=>[...p] as RigPoint),draw,overlap:Number(value('overlap')||'0'),bone});
164
+ else{const [p,q]=placing.points as [RigPoint,RigPoint];adds.push({kind:'patch',name,box:[Math.min(p[0],q[0]),Math.min(p[1],q[1]),Math.max(p[0],q[0]),Math.max(p[1],q[1])],alpha:value('alpha')==='box'?'box':'silhouette',draw,bone});}
165
+ placing=null;change();panelStatus(`Added ${name}: Apply runs rig-parts build; the producer judges the polygon or box.`,false);return;
166
+ }
167
+ if(kind==='cancel'){placing=null;render();deps.changed();}
168
+ });
169
+ const keyDown=(event:KeyboardEvent)=>{if(event.code==='Space'&&!textEntryFocused(event.target))space=true;};
170
+ const keyUp=(event:KeyboardEvent)=>{if(event.code==='Space')space=false;};
171
+ const down=(event:PointerEvent)=>{
172
+ if(!active||busy||space||!event.isPrimary||event.button!==0)return;
173
+ const target=event.target;if(target instanceof Element&&target.closest('button,input,select,textarea,a'))return;
174
+ const p=local(event),at=cropAt(p);if(!at)return;
175
+ if(placing){event.stopPropagation();event.preventDefault();placing.points.push([Math.round(at[0]),Math.round(at[1])]);if(placing.kind==='patch'&&placing.points.length>2)placing.points.shift();render();deps.changed();return;}
176
+ const id=hitHandle(projected(),p);if(!id)return;
177
+ event.stopPropagation();event.preventDefault();
178
+ const handle=handlesRig().find(h=>h.id===id)!;drag={id,pointer:event.pointerId,start:at,from:[...handle.at]};
179
+ const [kind,a,b]=id.split(':');selected=kind==='v'?`cut:${a}`:kind==='j'?(()=>{const anchor=anchors.find(x=>x.path===id.slice(2));const s=source.segments.find(g=>g.joint&&jointAnchor(g.bone)===anchor);return s?`seg:${s.limb}:${s.index}`:selected;})():`patch:${a}`;void b;
180
+ deps.pane.setPointerCapture(event.pointerId);deps.pane.dataset.cutsHover='move';render();deps.changed();
181
+ };
182
+ const move=(event:PointerEvent)=>{
183
+ if(drag&&event.pointerId===drag.pointer){event.stopPropagation();const now=cropAt(local(event));if(!now)return;guard(()=>{moveHandle(drag!.id,dragPoint(drag!.from,drag!.start,now));});render();deps.changed();return;}
184
+ if(!active||space)return;const id=hitHandle(projected(),local(event));if(id!==hover){hover=id;if(id)deps.pane.dataset.cutsHover='move';else delete deps.pane.dataset.cutsHover;deps.changed();}
185
+ };
186
+ const end=(event:PointerEvent)=>{if(!drag||event.pointerId!==drag.pointer)return;drag=null;delete deps.pane.dataset.cutsHover;if(deps.pane.hasPointerCapture(event.pointerId))deps.pane.releasePointerCapture(event.pointerId);save();render();deps.changed();};
187
+ deps.pane.addEventListener('pointerdown',down,true);deps.pane.addEventListener('pointermove',move,true);
188
+ for(const type of ['pointerup','pointercancel','lostpointercapture'] as const)deps.pane.addEventListener(type,end,true);
189
+ document.addEventListener('keydown',keyDown,true);window.addEventListener('keyup',keyUp,true);
190
+ const observer=new MutationObserver(()=>panel.sync());
191
+ for(const selector of document.querySelectorAll('[data-inspector]'))observer.observe(selector,{attributes:true,attributeFilter:['aria-pressed','aria-selected']});
192
+
193
+ const colors=()=>({stroke:deps.token('--rv-mesh-region'),fill:deps.token('--rv-mesh-region-fill'),handle:deps.token('--rv-mesh-handle'),ink:deps.token('--rv-bone-ink'),labelBg:deps.token('--rv-bone-label-bg'),joint:deps.token('--rv-landmark'),pending:deps.token('--rv-bone-pending')});
194
+ const world=(p:RigPoint)=>{const m=deps.mapping();return m.available?m.toWorld(p[0],p[1]):null;};
195
+ const restore=()=>{
196
+ let doc:unknown=null;try{doc=JSON.parse(draft.value||'null');}catch{doc=null;}
197
+ const kept=doc&&typeof doc==='object'&&'cuts' in (doc as object)?cutsDraft(JSON.stringify((doc as {cuts:unknown}).cuts),source):{edits:new Map<string,CutEdit>(),adds:[] as CutAdd[]};
198
+ edits=kept.edits;adds=kept.adds;joints=new Map();
199
+ const list=doc&&typeof doc==='object'&&Array.isArray((doc as {joints?:unknown}).joints)?(doc as {joints:BoneEdit[]}).joints:[];
200
+ for(const e of list){const a=anchors.find(x=>x.path===e.path&&x.name===e.name&&x.value[0]===e.from[0]&&x.value[1]===e.from[1]);if(a)joints=setAnchor(joints,a,e.value);}
201
+ };
202
+
203
+ const panel:CutsPanel={
204
+ load(next,bones,reason,build){
205
+ if(next===null){config=null;source={cuts:[],patches:[],segments:[],plan:[],skipped:[]};loaded=false;state.textContent=reason??'CUTS_UNAVAILABLE: no producer handoff';state.classList.add('error');setActive(false);render();return;}
206
+ config=next;source=cutsSource(next);anchors=boneAnchors(configBones(bones??[]).bones);buildReason=build;loaded=true;
207
+ restore();renderState();render();deps.changed();
208
+ },
209
+ source:()=>structuredClone(source),
210
+ ops:allOps,
211
+ setVertex(cut,k,value){const c=source.cuts.find(x=>x.index===cut);if(!c)throw new Error(`CUTS_UNKNOWN: assemble.cuts[${cut}]`);edits=setVertex(edits,c,k,value);change();},
212
+ setBox(index,value){const q=source.patches.find(x=>x.index===index);if(!q)throw new Error(`CUTS_UNKNOWN: assemble.patches[${index}]`);edits=setBox(edits,q,value);change();},
213
+ setRange(limb,k,value){const s=source.segments.find(x=>x.limb===limb&&x.index===k);if(!s)throw new Error(`CUTS_UNKNOWN: limbs.${limb}.segments[${k}]`);edits=setRange(edits,s,value);change();},
214
+ active:()=>active,setActive,
215
+ handles(){const rect=deps.canvas.getBoundingClientRect(),labels=new Map(handlesRig().map(h=>[h.id,h.label]));return projected().map(h=>({id:h.id,label:labels.get(h.id)!,x:h.at.x+rect.left,y:h.at.y+rect.top}));},
216
+ overlayRegions(){
217
+ if(!active||!loaded)return [];const c=colors(),out:OverlayRegion[]=[],handleColors={handle:c.handle,ink:c.ink,labelBg:c.labelBg};
218
+ const region=(name:string,points:RigPoint[],focus:boolean,extra:Partial<OverlayRegion>={})=>{const w=points.map(world);if(w.some(p=>!p))return;out.push({name,points:w as OverlayPoint[],stroke:c.stroke,fill:c.fill,handles:true,active:focus&&drag?Number(drag.id.split(':')[2]??0):null,colors:handleColors,...extra});};
219
+ for(const cut of source.cuts)region(cut.into,cut.polygon.map((_,k)=>vertexAt(edits,cut,k)),selected===`cut:${cut.index}`);
220
+ for(const q of source.patches){const b=boxAt(edits,q);region(q.name,[[b[0],b[1]],[b[2],b[1]],[b[2],b[3]],[b[0],b[3]]],false);}
221
+ for(const s of source.segments){
222
+ const r=rangeAt(edits,s),a=s.joint?jointAnchor(s.bone):undefined;if(!r||!a)continue;
223
+ // The rest direction: the lower bone toward its next point or tip, in config rig px.
224
+ const next=anchors.find(x=>x.index===a.index&&(x.kind==='point'&&a.kind==='point'?x.link===(a.link??0)+1:x.kind==='tip'));
225
+ if(!next)continue;region(`${s.joint} ${fmt(r)}°`,rangeArc(anchorAt(a,joints),anchorAt(next,joints),r,40),false,{handles:false});
226
+ }
227
+ if(placing&&placing.points.length)region(placing.kind==='cut'?'new cut':'new patch',placing.kind==='patch'&&placing.points.length===2?[placing.points[0]!,[placing.points[1]![0],placing.points[0]![1]],placing.points[1]!,[placing.points[0]![0],placing.points[1]![1]]]:placing.points,false,{open:placing.kind==='cut',active:placing.points.length-1});
228
+ for(const a of adds)region(a.kind==='cut'?a.into:a.name,a.kind==='cut'?a.polygon:[[a.box[0],a.box[1]],[a.box[2],a.box[1]],[a.box[2],a.box[3]],[a.box[0],a.box[3]]],false);
229
+ return out;
230
+ },
231
+ overlayMarkers(){
232
+ if(!active||!loaded)return [];const c=colors();
233
+ return source.segments.flatMap(s=>{const a=s.joint?jointAnchor(s.bone):undefined;const at=a?world(anchorAt(a,joints)):null;return at?[{at,tag:`${s.joint} · ${s.part}`,active:hover===`j:${a!.path}`||drag?.id===`j:${a!.path}`,colors:{stroke:joints.has(a!.path)?c.pending:c.joint,fill:c.joint,ink:c.ink,labelBg:c.labelBg}}]:[];});
234
+ },
235
+ async apply(){
236
+ const ops=allOps();if(!ops.length||busy){panelStatus('CUTS_NOTHING_PENDING: no pending ops',true);return null;}
237
+ const result=await deps.apply(ops);panel.shown(result);return result;
238
+ },
239
+ shown(job){
240
+ if(!job){readout.replaceChildren();return;}
241
+ const lines:HTMLElement[]=[],line=(label:string,text:string,error=false)=>lines.push(make('div',{className:'physics-readout-line'},make('span',{className:'eyebrow',textContent:label}),make('code',{textContent:text,title:text,className:error?'error':''})));
242
+ if(!('id' in job)){line('REFUSED',job.error,true);readout.replaceChildren(...lines);return;}
243
+ const a=job.assembled;
244
+ for(const r of a?.refusals??[])line('REFUSED',r,true);
245
+ for(const r of a?.checkFails??[])line('CHECK FAIL',r,true);
246
+ if(a){
247
+ if(a.recomposite.line)line('RECOMPOSITE',a.recomposite.line);
248
+ const before=a.recomposite.before,after=a.recomposite.after;if(before&&after)line('ERROR PX',`${before.error_px} → ${after.error_px} (uncovered ${before.uncovered_error_px} → ${after.uncovered_error_px}, holes ${before.hole_count} → ${after.hole_count})`);
249
+ for(const l of a.lines)line(l.startsWith('uncovered')?'HOLE':l.split(':')[0]!.toUpperCase(),l);
250
+ for(const l of a.seam)line('SEAM',l);for(const l of a.seamPairs)line('SEAM PAIR',l);
251
+ if(a.parts)line('PARTS',a.parts.changed.length?`changed: ${a.parts.changed.join(', ')} · ${a.parts.unchanged} unchanged · parts.json differs at ${a.parts.partsJson.count} path(s)`:`no part changed · parts.json differs at ${a.parts.partsJson.count} path(s)`);
252
+ }
253
+ if(job.status==='built'){line('BUILD',`${job.wallMs.op===null?'—':`${(job.wallMs.op/1000).toFixed(1)} s`} wall clock (assemble → rig → check) · compile ${job.wallMs.compile===null?'—':`${(job.wallMs.compile/1000).toFixed(1)} s`}${job.reused?' · reused':''}`);if(job.check?.line)line('CHECK',job.check.line,job.check.pass!==true);}
254
+ const map=a?.recomposite.errorMap;
255
+ readout.replaceChildren(...lines,...(map&&job.status==='built'?[make('figure',{className:'cuts-error-map'},make('img',{src:deps.route(map.url),alt:'Recomposite error map of the scratch build: red is painting no part holds, blue a part more than 40 off'}),make('figcaption',{textContent:`recomposite_error_rig.png · sha256 ${map.sha256.slice(0,12)}…`}))]:[]));
256
+ },
257
+ status:panelStatus,
258
+ busy(value){busy=value;render();},
259
+ restore(){restore();render();deps.changed();},
260
+ clear(){edits=new Map();joints=new Map();adds=[];placing=null;change();},
261
+ sync(){renderState();if(active&&deps.inspector()!=='rig')setActive(false);},
262
+ dispose(){observer.disconnect();deps.pane.removeEventListener('pointerdown',down,true);deps.pane.removeEventListener('pointermove',move,true);for(const type of ['pointerup','pointercancel','lostpointercapture'] as const)deps.pane.removeEventListener(type,end,true);document.removeEventListener('keydown',keyDown,true);window.removeEventListener('keyup',keyUp,true);},
263
+ };
264
+ render();
265
+ return panel;
266
+ }