@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,294 @@
1
+ /**
2
+ * The chest-region editor (Setup): a view-bar toggle, an editor bar of R / L circles and direct manipulation of the
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 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.
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';
9
+ import {chestRowId,type ChestFieldReader} from './chest-regions-tool.ts';
10
+ import {projectCircle,type OverlayPoint,type OverlayProject} from './rig-overlays.ts';
11
+ import {textEntryFocused} from './view-controls.ts';
12
+
13
+ export type BustSide='r'|'l';
14
+ export type BustMode='move'|'r'|'band';
15
+ export const bustSides=[{side:'r',row:0,tag:'R'},{side:'l',row:1,tag:'L'}] as const;
16
+ export const bustFields=['x','y','r','band'] as const;
17
+ export const bustInputId=(side:BustSide,field:typeof bustFields[number])=>`bust-${side}-${field}`;
18
+ export const CHEST_REGIONS_CLAMPED='CHEST_REGIONS_CLAMPED';
19
+ const sideRow=(side:BustSide)=>side==='r'?0:1;
20
+ const sideTag=(side:BustSide)=>side==='r'?'R':'L';
21
+
22
+ /** The toggle belongs to Setup only. */
23
+ export function chestEditVisible(inspector:string|undefined):boolean {return inspector==='rig';}
24
+
25
+ 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:null;};
26
+ /** A row is a circle when its semi-axes are equal and it has no medial clip; then r is that semi-axis. */
27
+ export function rowCircle(read:ChestFieldReader,row:number):ChestCircle|null {
28
+ const cx=number(read,chestRowId(row,'cx')),cy=number(read,chestRowId(row,'cy')),ax=number(read,chestRowId(row,'ax')),ay=number(read,chestRowId(row,'ay')),band=number(read,chestRowId(row,'band'));
29
+ if(cx===null||cy===null||ax===null||band===null||ax<=0||ay!==ax||(read(chestRowId(row,'clip'))?.value??'').trim())return null;
30
+ return {cx,cy,r:ax,band};
31
+ }
32
+ /** Field values that store a circle in a detail row: r writes both semi-axes. */
33
+ export function circleFields(row:number,circle:Partial<ChestCircle>):Record<string,string> {
34
+ const out:Record<string,string>={};
35
+ if(circle.cx!==undefined)out[chestRowId(row,'cx')]=String(circle.cx);
36
+ if(circle.cy!==undefined)out[chestRowId(row,'cy')]=String(circle.cy);
37
+ if(circle.r!==undefined){out[chestRowId(row,'ax')]=String(circle.r);out[chestRowId(row,'ay')]=String(circle.r);}
38
+ if(circle.band!==undefined)out[chestRowId(row,'band')]=String(circle.band);
39
+ return out;
40
+ }
41
+ export const bustLabel=(side:BustSide,c:ChestCircle)=>`${sideTag(side)} (${Math.round(c.cx)},${Math.round(c.cy)}) r:${Math.round(c.r)} b:${Math.round(c.band)}`;
42
+
43
+ export interface ProjectedBust {side:BustSide;center:OverlayPoint;pxR:number;pxBand:number}
44
+ /** The example's hit order: core handle or core edge → r, band handle or outer edge → band, inside the core → move. */
45
+ export function hitBustHandle(circles:readonly ProjectedBust[],p:OverlayPoint):{side:BustSide;mode:BustMode}|null {
46
+ for(const c of circles){
47
+ const toR=Math.hypot(p.x-(c.center.x+c.pxR),p.y-c.center.y),toBand=Math.hypot(p.x-c.center.x,p.y-(c.center.y-c.pxBand)),toCenter=Math.hypot(p.x-c.center.x,p.y-c.center.y);
48
+ if(toR<=10||Math.abs(toCenter-c.pxR)<=6)return {side:c.side,mode:'r'};
49
+ if(toBand<=10||Math.abs(toCenter-c.pxBand)<=6)return {side:c.side,mode:'band'};
50
+ if(toCenter<=Math.max(12,c.pxR))return {side:c.side,mode:'move'};
51
+ }
52
+ return null;
53
+ }
54
+ const limit=(value:number,[low,high]:readonly [number,number])=>Math.max(low,Math.min(high,value));
55
+ export interface BustDrag {side:BustSide;mode:BustMode;start:OverlayPoint;centre:OverlayPoint;from:ChestCircle}
56
+ /** Pointer (canvas px) → source px through the camera scale (canvas px per source px), rounded as the example does. */
57
+ export function dragBust(drag:BustDrag,p:OverlayPoint,scale:number):ChestCircle {
58
+ const from=drag.from;if(!(scale>0)||!Number.isFinite(scale))return {...from};
59
+ if(drag.mode==='move')return {...from,cx:Math.round(from.cx+(p.x-drag.start.x)/scale),cy:Math.round(from.cy+(p.y-drag.start.y)/scale)};
60
+ const distance=Math.hypot(p.x-drag.centre.x,p.y-drag.centre.y)/scale;
61
+ if(drag.mode==='r')return {...from,r:limit(Math.round(distance),CHEST_CIRCLE_LIMITS.r)};
62
+ return {...from,band:limit(Math.round(distance-from.r),CHEST_CIRCLE_LIMITS.band)};
63
+ }
64
+ /** Mirror: L takes R's y, core radius and band; L keeps its own x. */
65
+ export const mirrorBust=(right:ChestCircle,left:Pick<ChestCircle,'cx'>):ChestCircle=>({cx:left.cx,cy:right.cy,r:right.r,band:right.band});
66
+ const same=(a:ChestCircle,b:ChestCircle)=>a.cx===b.cx&&a.cy===b.cy&&a.r===b.r&&a.band===b.band;
67
+ /** Live non-overlap clamp for a pair of circles; names the sides it changed. */
68
+ export function settleBust(right:ChestCircle,left:ChestCircle):{right:ChestCircle;left:ChestCircle;clamped:BustSide[]} {
69
+ const out=clampNonOverlapping(right,left),round=clampNonOverlapping(right,left,-Infinity);
70
+ // `round` is the same integer/range pass without the distance rule (−∞ margin leaves every pair fitting), so only the non-overlap change flashes.
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))};
72
+ }
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;
139
+ }
140
+
141
+ export interface ChestEditorHost {
142
+ /** Receives capture-phase pointer events before the viewport's pan. */
143
+ pane:HTMLElement;
144
+ /** The overlay canvas: pointer coordinates are local to it, as the projection's are. */
145
+ canvas:HTMLElement;
146
+ project:()=>OverlayProject|null;
147
+ mapping:()=>CropWorldMapping;
148
+ /** Re-render the detail block and the overlay after a value changed. */
149
+ changed:()=>void;
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;
155
+ message:(text:string,error?:boolean)=>void;
156
+ inspector:()=>string|undefined;
157
+ }
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}
169
+
170
+ export function bindChestEditor(host:ChestEditorHost):ChestEditor {
171
+ const node=<T extends HTMLElement>(id:string)=>document.getElementById(id) as T;
172
+ const toggle=node<HTMLButtonElement>('chest-edit'),bar=node<HTMLElement>('chest-editor-bar'),status=node<HTMLElement>('chest-editor-status');
173
+ const field=(id:string)=>document.getElementById(id) as HTMLInputElement|null;
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;};
186
+ let active=false,space=false,drag:(BustDrag&{pointer:number;other:ChestCircle|null})|null=null,flashTimer:ReturnType<typeof setTimeout>|null=null;
187
+ const flash=new Set<BustSide>();
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;}};
189
+ const flashSides=(sides:readonly BustSide[])=>{
190
+ if(!sides.length)return;for(const side of sides)flash.add(side);
191
+ host.message(`${CHEST_REGIONS_CLAMPED}: ${sides.map(sideTag).join(' and ')} — both cores plus 8 px must fit the centre distance minus 2 px and the bands share the rest`,true);
192
+ if(flashTimer)clearTimeout(flashTimer);flashTimer=setTimeout(()=>{flash.clear();flashTimer=null;host.changed();},700);
193
+ };
194
+ /** Writes a changed side; when both rows are circles the pair is clamped as the rebuild script would. */
195
+ const commit=(side:BustSide,next:ChestCircle,other:ChestCircle|null)=>{
196
+ if(!other){write(sideRow(side),next);return;}
197
+ const [right,left]=side==='r'?[next,other]:[other,next],settled=settleBust(right,left);
198
+ write(0,settled.right);write(1,settled.left);flashSides(settled.clamped);
199
+ };
200
+ const projected=():ProjectedBust[]=>{
201
+ const project=host.project(),mapping=host.mapping();if(!project||!mapping.available)return [];
202
+ return bustSides.flatMap(({side,row})=>{const c=rowCircle(read,row);if(!c)return [];const at=projectCircle(project,{center:mapping.toWorld(c.cx,c.cy),r:c.r,band:c.band});return at?[{side,...at}]:[];});
203
+ };
204
+ const local=(event:PointerEvent):OverlayPoint=>{const rect=host.canvas.getBoundingClientRect();return {x:event.clientX-rect.left,y:event.clientY-rect.top};};
205
+ const scale=()=>{const project=host.project();if(!project)return 0;const a=project(0,0),b=project(1,0);return Math.hypot(b.x-a.x,b.y-a.y);};
206
+ const sync=()=>{
207
+ const visible=chestEditVisible(host.inspector());toggle.hidden=!visible;
208
+ if(!visible&&active)setActive(false);
209
+ for(const {side,row} of bustSides){
210
+ const circle=rowCircle(read,row),values={x:read(chestRowId(row,'cx'))?.value??'',y:read(chestRowId(row,'cy'))?.value??'',r:circle?String(circle.r):'',band:read(chestRowId(row,'band'))?.value??''};
211
+ for(const f of bustFields){const input=field(bustInputId(side,f));if(input&&document.activeElement!==input)input.value=values[f];}
212
+ const r=field(bustInputId(side,'r'));if(r)r.placeholder=circle||!(read(chestRowId(row,'ax'))?.value??'').trim()?'':'ellipse';
213
+ }
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);
223
+ };
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();};
225
+ toggle.addEventListener('click',()=>setActive(!active));
226
+ bar.addEventListener('input',event=>{
227
+ const target=event.target as HTMLInputElement,match=/^bust-([rl])-(x|y|r|band)$/.exec(target.id);if(!match)return;
228
+ const value=Number(target.value);if(!target.value.trim()||!Number.isFinite(value))return;
229
+ const side=match[1] as BustSide,key=({x:'cx',y:'cy',r:'r',band:'band'} as const)[match[2] as typeof bustFields[number]];
230
+ write(sideRow(side),{[key]:value});host.changed();
231
+ });
232
+ bar.addEventListener('change',event=>{
233
+ const target=event.target as HTMLInputElement,match=/^bust-([rl])-/.exec(target.id);if(!match)return;
234
+ const side=match[1] as BustSide,mine=rowCircle(read,sideRow(side)),other=rowCircle(read,sideRow(side==='r'?'l':'r'));
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);
236
+ sync();host.changed();
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();}});
241
+ node<HTMLButtonElement>('bust-mirror').addEventListener('click',()=>{
242
+ const right=rowCircle(read,0),leftX=Number((read(chestRowId(1,'cx'))?.value??'').trim()||NaN);
243
+ if(!right||!Number.isFinite(leftX)){host.message('Mirror needs a complete R circle and an L x.',true);return;}
244
+ commit('l',mirrorBust(right,{cx:leftX}),right);sync();host.changed();
245
+ });
246
+ node<HTMLButtonElement>('bust-download').addEventListener('click',()=>host.download());
247
+ apply.addEventListener('click',()=>host.apply());
248
+ reset.addEventListener('click',()=>host.reset());
249
+ const keyDown=(event:KeyboardEvent)=>{if(event.code==='Space'&&!textEntryFocused(event.target))space=true;};
250
+ const keyUp=(event:KeyboardEvent)=>{if(event.code==='Space')space=false;};
251
+ const blur=()=>{space=false;};
252
+ const down=(event:PointerEvent)=>{
253
+ if(!active||space||!event.isPrimary||event.button!==0)return;
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
+ }
265
+ const p=local(event),circles=projected(),hit=hitBustHandle(circles,p);if(!hit)return;
266
+ const row=sideRow(hit.side),from=rowCircle(read,row),at=circles.find(c=>c.side===hit.side);if(!from||!at)return;
267
+ // A handle drag edits values; it never reaches the viewport's pan and never moves the camera.
268
+ event.stopPropagation();event.preventDefault();
269
+ drag={side:hit.side,mode:hit.mode,start:p,centre:at.center,from,pointer:event.pointerId,other:rowCircle(read,sideRow(hit.side==='r'?'l':'r'))};
270
+ host.pane.setPointerCapture(event.pointerId);host.pane.dataset.chestHover=hit.mode;
271
+ };
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;}
275
+ if(drag&&event.pointerId===drag.pointer){event.stopPropagation();commit(drag.side,dragBust(drag,local(event),scale()),drag.other);sync();host.changed();return;}
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;}
277
+ };
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();};
281
+ host.pane.addEventListener('pointerdown',down,true);host.pane.addEventListener('pointermove',move,true);
282
+ for(const type of ['pointerup','pointercancel','lostpointercapture'] as const)host.pane.addEventListener(type,end,true);
283
+ document.addEventListener('keydown',keyDown,true);window.addEventListener('keyup',keyUp,true);window.addEventListener('blur',blur);
284
+ // The selected section is the header switch's pressed state; follow it without touching the switch's own wiring.
285
+ const observer=new MutationObserver(()=>sync());
286
+ for(const selector of document.querySelectorAll('[data-inspector]'))observer.observe(selector,{attributes:true,attributeFilter:['aria-pressed','aria-selected']});
287
+ sync();
288
+ return {
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;},
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);},
293
+ };
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})};
@@ -44,8 +45,13 @@ export function evaluateChestForm(read:ChestFieldReader,mapping:CropWorldMapping
44
45
  catch(error){if(error instanceof ChestRegionError)return {ok:false,error:error.message};throw error;}
45
46
  }
46
47
 
47
- /** Polygons in rig world coordinates for the overlay; none without a model-fixed mapping. */
48
- export function chestOverlayPolygons(evaluation:ChestEvaluation,mapping:CropWorldMapping):{name:string;points:{x:number;y:number}[]}[] {
48
+ /** Polygon regions in rig world coordinates for the overlay; none without a model-fixed mapping. Circles are drawn as circles. */
49
+ export function chestOverlayPolygons(evaluation:ChestEvaluation,mapping:CropWorldMapping):{name:string;row:number;points:{x:number;y:number}[]}[] {
49
50
  if(!evaluation.ok||!mapping.available)return [];
50
- return evaluation.document.mesh.value.contour.regions.map(region=>({name:region.name,points:region.points.map(([x,y])=>mapping.toWorld(x,y))}));
51
+ return evaluation.document.mesh.value.contour.regions.flatMap((region,row)=>region.shape==='polygon'?[{name:region.name,row,points:region.points.map(([x,y])=>mapping.toWorld(x,y))}]:[]);
52
+ }
53
+ /** Circle regions of a valid designation: world centre, core r and band (crop px = world px under the mapping). */
54
+ export function chestOverlayCircles(evaluation:ChestEvaluation,mapping:CropWorldMapping):{name:string;row:number;center:{x:number;y:number};r:number;band:number}[] {
55
+ if(!evaluation.ok||!mapping.available)return [];
56
+ return evaluation.document.mesh.value.contour.regions.flatMap((region,row)=>region.shape==='circle'?[{name:region.name,row,center:mapping.toWorld(region.cx,region.cy),r:region.r,band:region.band}]:[]);
51
57
  }
@@ -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
+ }