@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,18 +1,24 @@
1
1
  /**
2
2
  * Mesh edit (Setup): a view-bar toggle, an editor bar of sub-modes (Modify / Create / Delete / Edges) and direct
3
- * manipulation of the selected slot's actual mesh on the rig pane. Everything is a typed delta (`src/mesh-edit.ts`)
4
- * previewed over the original triangulation; nothing is retriangulated, written into the package or sent to a
5
- * producer — Apply · Rebuild refuses by name as the chest editor's does.
3
+ * manipulation of the selected slot's actual mesh on the rig pane. The mesh is the slot's attachment in the compiled
4
+ * model document at the **setup pose** (#100): the stored vertices placed through the setup-pose bones of a never-played
5
+ * player (`web/setup-pose.ts`, shared with Setup · Weights) and drawn over the stage with the stage's own camera — the
6
+ * stage itself stays paused at the pose it held, under the veil. Everything is a typed delta (`src/mesh-edit.ts`,
7
+ * `basis: "setup"`) previewed over the original triangulation; nothing is retriangulated or written into the package
8
+ * from here. Apply · Rebuild (#135) hands the exported document to the host, which sends it through the shared runner as
9
+ * `rig-parts remesh --delta`; a built revision loads in place and the editor re-reads the revision's setup mesh, a
10
+ * refusal leaves the stage and the delta as they were. While a delta revision is on the stage, Reset returns to the
11
+ * inspected build, and the local draft keeps the applied delta (edits over the revision are not written to it).
6
12
  */
7
- import type {PosedSlot} from '@rig-c/play';
8
13
  import type {CropWorldMapping} from '../src/chest-regions.ts';
14
+ import {placeSetupMesh,readSetupMesh,setupModel} from '../src/setup-mesh.ts';
9
15
  import {
10
- MESH_EDIT_NO_MESH,MESH_EDIT_PREVIEW_NOTE,MESH_EDIT_SELECT_SLOT,MeshEditSession,affineOf,applyAffine,applyMeshOps,attachmentHashText,draftOps,hitMesh,
16
+ MESH_EDIT_CODES,MESH_EDIT_NO_MESH,MESH_EDIT_PREVIEW_NOTE,MESH_EDIT_RIG_UNAVAILABLE,MESH_EDIT_SELECT_SLOT,MeshEditSession,affineOf,applyAffine,applyMeshOps,attachmentHashText,draftOps,hitMesh,
11
17
  invertAffine,meshEditDocument,parseMeshDraft,screenDeltaToCrop,verticesInBox,writeMeshDraft,type MeshEditDocument,type MeshEditResult,type MeshOp,type MeshSource,
12
18
  type MeshView,type ScreenPoint,type Vec,
13
19
  } from '../src/mesh-edit.ts';
14
- import {MODELING_INPUT_UNAVAILABLE,requestRebuild} from './chest-editor.ts';
15
20
  import type {OverlayMeshEdit,OverlayPoint,OverlayProject} from './rig-overlays.ts';
21
+ import type {SetupPose} from './setup-pose.ts';
16
22
  import {textEntryFocused} from './view-controls.ts';
17
23
 
18
24
  export type MeshMode='modify'|'create'|'delete'|'edges';
@@ -42,33 +48,38 @@ export function meshEditShortcut(event:KeyLike,state:{active:boolean;mode:MeshMo
42
48
  const mode=meshModeKeys[key];return mode?{type:'mode',mode}:null;
43
49
  }
44
50
 
45
- /** World → crop through the inverse of the crop mapping (crop px, x right, y down). */
51
+ /** World → crop through the inverse of the crop mapping (crop px, x right, y down), unrounded: the producer holds a `move.from` to 1e-3 px. */
46
52
  export function cropFromWorld(mapping:Extract<CropWorldMapping,{available:true}>):((x:number,y:number)=>Vec)|null {
47
53
  const inverse=invertAffine(affineOf((x,y)=>mapping.toWorld(x,y)));if(!inverse)return null;
48
- return (x,y)=>{const p=applyAffine(inverse,x,y);return [Math.round(p.x*100)/100,Math.round(p.y*100)/100];};
54
+ return (x,y)=>{const p=applyAffine(inverse,x,y);return [p.x,p.y];};
49
55
  }
50
- type SourceResult={ok:true;source:Omit<MeshSource,'hash'>;entry:unknown}|{ok:false;code:string};
56
+ type SourceResult={ok:true;source:Omit<MeshSource,'hash'>;entry:unknown;world:number[]}|{ok:false;code:string};
51
57
  /**
52
- * The actual mesh of the selected slot: the active attachment's entry in the hash-verified model, and the slot's last
53
- * drawn posed vertices mapped to crop px. A count or triangle mismatch between the two is refused.
58
+ * The selected slot's mesh in setup space: its attachment's entry in the hash-verified compiled model document (the
59
+ * attachment the setup pose resolves), its stored vertices placed through the setup-pose bones (`world`, flat x, y)
60
+ * and mapped to crop px. Refuses, in order: no model document (`MESH_EDIT_MODEL_UNAVAILABLE`), whole rig, no setup pose,
61
+ * a non-mesh or linked attachment, no crop mapping.
54
62
  */
55
- export function meshEditSource(input:{slot:string|null;activeAttachment:{skin:string;attachment:string}|null;row:PosedSlot|undefined;model:unknown;mapping:CropWorldMapping}):SourceResult {
56
- const {slot}=input;if(!slot)return {ok:false,code:`${MESH_EDIT_SELECT_SLOT}: select one slot in the parts list; whole-rig mesh edit is not a thing`};
57
- const noMesh=(why:string)=>({ok:false as const,code:`${MESH_EDIT_NO_MESH} ${slot}: ${why}`});
58
- const active=input.activeAttachment;if(!active)return noMesh('no active attachment at the held pose');
59
- const model=input.model as {skins?:{name?:unknown;attachments?:Record<string,Record<string,unknown>>}[]}|null;
60
- const entry=model?.skins?.find(skin=>skin.name===active.skin)?.attachments?.[slot]?.[active.attachment] as {kind?:unknown;uvs?:unknown;triangles?:unknown;hull?:unknown}|undefined;
61
- if(!entry||entry.kind!=='mesh')return noMesh(`${active.skin} / ${active.attachment} is not a mesh attachment`);
62
- if(!input.row||input.row.kind!=='mesh')return noMesh('the slot is not drawn as a mesh at the held pose');
63
+ export function meshEditSource(input:{slot:string|null;model:unknown;pose:(slot:string)=>SetupPose|null;mapping:CropWorldMapping}):SourceResult {
64
+ const available=setupModel(input.model,MESH_EDIT_CODES);if(!available.ok)return available;
65
+ const {slot}=input;if(!slot)return {ok:false,code:`${MESH_EDIT_SELECT_SLOT}: ${MESH_EDIT_CODES.selectText}`};
66
+ let pose:SetupPose|null;
67
+ try{pose=input.pose(slot);}catch(error){return {ok:false,code:`${MESH_EDIT_RIG_UNAVAILABLE}: ${error instanceof Error?error.message:String(error)}`};}
68
+ if(!pose)return {ok:false,code:`${MESH_EDIT_RIG_UNAVAILABLE}: no rig loaded`};
69
+ const read=readSetupMesh(input.model,slot,pose.identity,MESH_EDIT_CODES);if(!read.ok)return read;
70
+ const mesh=read.mesh,noMesh=(why:string)=>({ok:false as const,code:`${MESH_EDIT_NO_MESH} ${slot}: ${why}`});
71
+ if(mesh.linked)return noMesh(`${mesh.skin} / ${mesh.attachment} is a linked mesh of ${mesh.source.slot} / ${mesh.source.attachment}; edit its source mesh`);
72
+ if(mesh.hull===null)return noMesh('the model entry stores no hull');
63
73
  if(!input.mapping.available)return {ok:false,code:`${input.mapping.reason}: mesh edit needs the crop mapping`};
64
- const triangles=entry.triangles,uvs=entry.uvs,hull=entry.hull;
65
- if(!Array.isArray(triangles)||!Array.isArray(uvs)||typeof hull!=='number'||!Number.isSafeInteger(hull)||hull<0)return noMesh('the model entry has no triangles, uvs or hull');
66
- const count=uvs.length/2,posed=input.row.vertices;
67
- if(posed.length/2!==count||hull>count||input.row.triangles.length!==triangles.length||input.row.triangles.some((v,i)=>v!==triangles[i]))return {ok:false,code:`MESH_EDIT_SOURCE_MISMATCH: ${slot} drawn mesh (${posed.length/2} vertices) differs from the model attachment (${count})`};
74
+ let world:number[]|null;
75
+ try{world=placeSetupMesh(mesh,pose.world);}catch(error){return {ok:false,code:`${MESH_EDIT_RIG_UNAVAILABLE}: ${error instanceof Error?error.message:String(error)}`};}
76
+ if(!world)return {ok:false,code:`MESH_EDIT_SOURCE_MISMATCH: ${slot} has a non-finite setup-pose vertex`};
68
77
  const toCrop=cropFromWorld(input.mapping);if(!toCrop)return {ok:false,code:'MESH_EDIT_MAPPING_SINGULAR'};
69
- const vertices:Vec[]=[];for(let i=0;i<posed.length;i+=2)vertices.push(toCrop(posed[i]!,posed[i+1]!));
70
- return {ok:true,source:{slot,skin:active.skin,attachment:active.attachment,vertices,triangles:[...triangles] as number[],hull},entry};
78
+ const vertices:Vec[]=[];for(let i=0;i<world.length;i+=2)vertices.push(toCrop(world[i]!,world[i+1]!));
79
+ return {ok:true,source:{slot,skin:mesh.skin,attachment:mesh.attachment,vertices,triangles:mesh.triangles,hull:mesh.hull},entry:mesh.entry,world};
71
80
  }
81
+ /** Apply · Rebuild with an empty delta: nothing is sent. */
82
+ export const MESH_EDIT_NOTHING_PENDING='MESH_EDIT_NOTHING_PENDING: the delta holds no op; nothing is sent';
72
83
  export async function sha256Hex(text:string):Promise<string> {return [...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(text)))].map(b=>b.toString(16).padStart(2,'0')).join('');}
73
84
 
74
85
  export interface MeshEditorHost {
@@ -76,14 +87,18 @@ export interface MeshEditorHost {
76
87
  project:()=>OverlayProject|null;
77
88
  mapping:()=>CropWorldMapping;
78
89
  inspector:()=>string|undefined;
79
- /** The selected slot's source, or the refusal that names why there is none. */
90
+ /** The selected slot's setup-space source, or the refusal that names why there is none. */
80
91
  source:()=>SourceResult;
81
- /** How the vertices were obtained (pose, mapping), for the export. */
82
- basis:()=>string;
83
- /** Hold the pose being edited still. */
92
+ /** Hold the stage still under the setup-pose overlay. */
84
93
  pause:()=>void;
85
94
  changed:()=>void;
86
95
  download:(document:MeshEditDocument)=>void;
96
+ /** Apply · Rebuild (#135): send the delta through the shared runner; `built` when the revision is on the stage. Lines stream to `line`. */
97
+ apply:(document:MeshEditDocument,line:(text:string)=>void)=>Promise<{built:boolean;text:string;error:boolean}>;
98
+ /** Whether the stage holds a revision this editor's Apply built. */
99
+ applied:()=>boolean;
100
+ /** Return the stage to the inspected build. */
101
+ resetBuild:()=>Promise<void>;
87
102
  message:(text:string,error?:boolean)=>void;
88
103
  confirm:(text:string)=>boolean;
89
104
  token:(name:string)=>string;
@@ -96,7 +111,7 @@ export function bindMeshEditor(host:MeshEditorHost):MeshEditor {
96
111
  const node=<T extends HTMLElement>(id:string)=>document.getElementById(id) as T;
97
112
  const toggle=node<HTMLButtonElement>('mesh-edit'),bar=node<HTMLElement>('mesh-editor-bar'),status=node<HTMLElement>('mesh-editor-status'),draft=node<HTMLTextAreaElement>('mesh-edit-draft');
98
113
  const chestToggle=document.getElementById('chest-edit') as HTMLButtonElement|null;
99
- let active=false,entering=false,mode:MeshMode|null=null,session:MeshEditSession|null=null,space=false,drag:Drag|null=null,pending:number|null=null,hover:ReturnType<typeof hitMesh>=null,note='',noteError=false;
114
+ let active=false,entering=false,applying=false,mode:MeshMode|null=null,session:MeshEditSession|null=null,space=false,drag:Drag|null=null,pending:number|null=null,hover:ReturnType<typeof hitMesh>=null,note='',noteError=false;
100
115
  let preview:MeshView|null=null;const selected=new Set<number>();
101
116
  const view=()=>preview??session?.view()??null;
102
117
  const cropToScreen=()=>{const project=host.project(),mapping=host.mapping();if(!project||!mapping.available)return null;return affineOf((x,y)=>{const w=mapping.toWorld(x,y);return project(w.x,w.y);});};
@@ -106,7 +121,7 @@ export function bindMeshEditor(host:MeshEditorHost):MeshEditor {
106
121
  const hint:Record<MeshMode,string>={
107
122
  modify:'drag a vertex or edge · Shift-click selects more · drag empty space to box-select · arrows nudge 1 px (Shift 10)',
108
123
  create:'click empty space to add a vertex · click an edge to split it',
109
- delete:'click a vertex (with its edges) or an edge to remove it · hull edges stay',
124
+ delete:'click a vertex (with its edges) or an added constraint edge to remove it · hull and triangulation edges are the producer\'s',
110
125
  edges:'click vertex A then vertex B to add a constraint edge · click a constraint edge to remove it',
111
126
  };
112
127
  const sync=()=>{
@@ -114,16 +129,17 @@ export function bindMeshEditor(host:MeshEditorHost):MeshEditor {
114
129
  if(!visible&&active){setActive(false);return;}
115
130
  toggle.setAttribute('aria-pressed',String(active));bar.hidden=!active;
116
131
  for(const button of bar.querySelectorAll<HTMLButtonElement>('[data-mesh-mode]'))button.setAttribute('aria-pressed',String(button.dataset.meshMode===mode));
117
- node<HTMLButtonElement>('mesh-undo').disabled=!session?.canUndo();node<HTMLButtonElement>('mesh-redo').disabled=!session?.canRedo();node<HTMLButtonElement>('mesh-reset').disabled=!session?.canUndo()&&!session?.ops().length;
132
+ node<HTMLButtonElement>('mesh-undo').disabled=!session?.canUndo();node<HTMLButtonElement>('mesh-redo').disabled=!session?.canRedo();node<HTMLButtonElement>('mesh-reset').disabled=!session?.canUndo()&&!session?.ops().length&&!host.applied();node<HTMLButtonElement>('mesh-apply').disabled=applying;
118
133
  if(!session){status.textContent='';return;}
119
134
  const s=session.source,count=session.ops().length;
120
- status.textContent=`${note?note+' · ':''}${count} op${count===1?'':'s'} · ${MESH_EDIT_PREVIEW_NOTE} · ${mode?hint[mode]:'no sub-mode — Escape again leaves mesh edit'} · Space-drag pans`;
135
+ status.textContent=`${note?note+' · ':''}${count} op${count===1?'':'s'} · setup pose · ${MESH_EDIT_PREVIEW_NOTE} · ${mode?hint[mode]:'no sub-mode — Escape again leaves mesh edit'} · Space-drag pans`;
121
136
  status.title=`${s.slot} / ${s.skin} / ${s.attachment} · ${s.vertices.length} vertices · ${s.triangles.length/3} triangles · hull ${s.hull} · ${status.textContent}`;
122
137
  status.classList.toggle('error',noteError);
123
138
  if(mode)host.pane.dataset.meshMode=mode;else delete host.pane.dataset.meshMode;
124
139
  };
125
140
  const say=(text:string,error=false)=>{note=text;noteError=error;if(text)host.message(text,error);sync();};
126
- const persist=()=>{if(!session)return;const text=writeMeshDraft(parseMeshDraft(draft.value),session.source,session.ops());if(text.length>32768){say('MESH_EDIT_DRAFT_LIMIT: the local draft is full — download the JSON; edits after this are not kept on reload',true);return;}draft.value=text;draft.dispatchEvent(new Event('input',{bubbles:true}));};
141
+ // While a delta revision is on the stage the draft keeps the delta that built it: edits over the revision are not written.
142
+ const persist=()=>{if(!session||host.applied())return;const text=writeMeshDraft(parseMeshDraft(draft.value),session.source,session.ops());if(text.length>32768){say('MESH_EDIT_DRAFT_LIMIT: the local draft is full — download the JSON; edits after this are not kept on reload',true);return;}draft.value=text;draft.dispatchEvent(new Event('input',{bubbles:true}));};
127
143
  /** Reports a step: a refusal by its code, a no-op by its reason, a change by what it did. */
128
144
  const after=(result:MeshEditResult,what:()=>string)=>{
129
145
  if(!result.ok)say(result.code,true);
@@ -151,9 +167,29 @@ export function bindMeshEditor(host:MeshEditorHost):MeshEditor {
151
167
  const undo=()=>{if(session?.undo()){selected.clear();persist();say('');host.changed();}};
152
168
  const redo=()=>{if(session?.redo()){selected.clear();persist();say('');host.changed();}};
153
169
  node<HTMLButtonElement>('mesh-undo').addEventListener('click',undo);node<HTMLButtonElement>('mesh-redo').addEventListener('click',redo);
154
- node<HTMLButtonElement>('mesh-reset').addEventListener('click',()=>{if(!session)return;if(!host.confirm(`Drop the whole mesh-edit delta for ${session.source.slot} / ${session.source.attachment}? This cannot be undone.`))return;session.reset();selected.clear();persist();say('delta reset');host.changed();});
155
- node<HTMLButtonElement>('mesh-download').addEventListener('click',()=>{if(session)host.download(meshEditDocument(session,host.basis()));});
156
- node<HTMLButtonElement>('mesh-apply').addEventListener('click',()=>{const result=requestRebuild((text,error)=>host.message(text,error));say(result.error,true);});
170
+ /** Leave and re-read the selected slot's setup mesh from whatever model the stage now draws (the draft restores by hash). */
171
+ const reenter=async()=>{leave();sync();await enter();};
172
+ node<HTMLButtonElement>('mesh-reset').addEventListener('click',()=>{
173
+ if(!session||applying)return;
174
+ // A delta revision on the stage: Reset returns to the inspected build, whose setup mesh the draft's delta was drawn over.
175
+ if(host.applied()){if(!host.confirm('Return to the inspected build? The applied delta stays in the local draft.'))return;void (async()=>{applying=true;sync();try{await host.resetBuild();await reenter();say('Reset: the inspected build is on the stage; the draft\'s delta is restored over its setup mesh');}finally{applying=false;sync();}})();return;}
176
+ if(!host.confirm(`Drop the whole mesh-edit delta for ${session.source.slot} / ${session.source.attachment}? This cannot be undone.`))return;session.reset();selected.clear();persist();say('delta reset');host.changed();
177
+ });
178
+ node<HTMLButtonElement>('mesh-download').addEventListener('click',()=>{if(session)host.download(meshEditDocument(session));});
179
+ // #135: the delta goes to the producer (`rig-parts remesh --delta`) through the host's shared runner; a built revision
180
+ // loads in place and the editor re-reads its setup mesh; a refusal shows the producer's line verbatim and keeps the delta.
181
+ node<HTMLButtonElement>('mesh-apply').addEventListener('click',()=>{
182
+ if(!session||applying)return;
183
+ if(!session.ops().length){say(MESH_EDIT_NOTHING_PENDING,true);return;}
184
+ const doc=meshEditDocument(session);applying=true;say(`Applying ${doc.ops.length} op(s) on ${doc.slot}: remesh --delta, compile…`);
185
+ void (async()=>{
186
+ try{const result=await host.apply(doc,line=>{if(line.trim()){note=line.trim();noteError=false;sync();}});
187
+ if(result.built)await reenter();
188
+ say(result.text,result.error);
189
+ }catch(error){say(`REBUILD_FAILED: ${error instanceof Error?error.message:String(error)}`,true);}
190
+ finally{applying=false;sync();}
191
+ })();
192
+ });
157
193
  const keyDown=(event:KeyboardEvent)=>{
158
194
  if(event.code==='Space'&&!textEntryFocused(event.target))space=true;
159
195
  const action=meshEditShortcut(event,{active,mode,selected:selected.size});if(!action)return;
@@ -265,4 +301,3 @@ export function bindMeshEditor(host:MeshEditorHost):MeshEditor {
265
301
  dispose:()=>{observer.disconnect();chestObserver.disconnect();host.pane.removeEventListener('pointerdown',down,true);host.pane.removeEventListener('pointermove',move,true);for(const type of ['pointerup','pointercancel'] as const)host.pane.removeEventListener(type,end,true);document.removeEventListener('keydown',keyDown,true);window.removeEventListener('keyup',keyUp,true);window.removeEventListener('blur',blur);},
266
302
  };
267
303
  }
268
- export {MODELING_INPUT_UNAVAILABLE};
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Setup · Mesh panel (#106), page side: for the selected mesh part, the config's `meshes.<part>` entry as a pending
3
+ * entry — mode-specific sliders, regions (circles drawn as the chest overlay draws them, polygons placed and dragged
4
+ * on the stage with a bone and a band), segments picked from the bone list, ribs stations per chain — its diff against
5
+ * the config's entry, review's pre-check in the loader's names, Apply (`remesh --mesh` through the patch runner, one
6
+ * op at `/meshes/<part>`), Reset, the counts and `TEXTURE_STRETCH` after a build and the folds reading on the
7
+ * revision. The draft (`#mesh-pending`) keeps the part and its pending entry; nothing reaches the package.
8
+ */
9
+ import {
10
+ addSegment,entryChanges,entryRegions,entrySegments,entryValue,freeRegionName,hitRegionPoint,meshDraftText,meshMode,modeNotEditable,moveRegionPoint,
11
+ paramsFor,parseMeshDraft,polygonRegion,precheckEntry,removeSegment,segmentChoices,setEntryValue,setRibs,snapPoint,withRegions,FLOORS,RULES,MESH_PANEL_SCHEMA,
12
+ type MeshEntry,type MeshMode,type MeshParam,type MeshRegion,type Point,
13
+ } from '../src/mesh-entry.ts';
14
+ import type {JsonValue} from '../src/config-patch.ts';
15
+ import type {CropWorldMapping} from '../src/chest-regions.ts';
16
+ import type {OverlayCircle,OverlayProject,OverlayRegion} from './rig-overlays.ts';
17
+
18
+ export interface MeshPanelHost {
19
+ pane:HTMLElement;
20
+ project:()=>OverlayProject|null;
21
+ mapping:()=>CropWorldMapping;
22
+ /** The loaded model's bone names (segments and region bones are picked from them). */
23
+ bones:()=>readonly string[];
24
+ apply:(part:string,entry:MeshEntry)=>Promise<void>;
25
+ reset:()=>Promise<void>;
26
+ folds:()=>Promise<void>;
27
+ changed:()=>void;
28
+ saved:()=>void;
29
+ token:(name:string)=>string;
30
+ }
31
+ export interface MeshPanel {
32
+ /** The config's meshes entries (`GET /api/rebuild`), or the reason none can be read. */
33
+ load(meshes:Record<string,unknown>|null,reason:string|null):void;
34
+ select(part:string|null):void;
35
+ part():string|null;
36
+ entry():MeshEntry|null;
37
+ configEntry():MeshEntry|null;
38
+ set(path:readonly string[],value:JsonValue|undefined):void;
39
+ setEntry(entry:MeshEntry):void;
40
+ regions:{list():MeshRegion[];add(region:MeshRegion):void;move(region:number,vertex:number,to:Point):void;remove(index:number):void;draw(on:boolean):void;placing():Point[]|null};
41
+ precheck():string[];
42
+ restore():void;
43
+ status(text:string,error:boolean):void;
44
+ counts(lines:{label:string;text:string;error?:boolean}[]):void;
45
+ readout(lines:{name:string;before:string;after:string}[]):void;
46
+ busy(value:boolean):void;
47
+ overlayRegions():OverlayRegion[];
48
+ overlayCircles():OverlayCircle[];
49
+ dispose():void;
50
+ }
51
+
52
+ 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;};
53
+ 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;};
54
+ const shown=(v:JsonValue|undefined)=>v===undefined?'(absent)':JSON.stringify(v);
55
+ const isObject=(v:unknown):v is Record<string,JsonValue>=>!!v&&typeof v==='object'&&!Array.isArray(v);
56
+
57
+ /** Rig px (crop, y down) → world, and pane px → rig px, through the crop mapping and the camera's projection. */
58
+ export function paneToRig(project:OverlayProject,mapping:Extract<CropWorldMapping,{available:true}>,at:{x:number;y:number}):Point|null {
59
+ const o=project(0,0),ux=project(1,0),uy=project(0,1),sx=ux.x-o.x,sy=uy.y-o.y;
60
+ if(!sx||!sy||!Number.isFinite(sx)||!Number.isFinite(sy))return null;
61
+ const world={x:(at.x-o.x)/sx,y:(at.y-o.y)/sy},w0=mapping.toWorld(0,0),w1=mapping.toWorld(1,1);
62
+ const kx=w1.x-w0.x,ky=w1.y-w0.y;if(!kx||!ky)return null;
63
+ return [(world.x-w0.x)/kx,(world.y-w0.y)/ky];
64
+ }
65
+
66
+ export function bindMeshPanel(host:MeshPanelHost):MeshPanel {
67
+ const state=node('mesh-panel-state'),params=node('mesh-params'),regionsArea=node('mesh-regions'),segmentsList=node('mesh-segments'),diffList=node('mesh-diff'),precheckList=node('mesh-precheck');
68
+ const addCircle=node<HTMLButtonElement>('mesh-add-circle'),drawPolygon=node<HTMLButtonElement>('mesh-add-polygon'),dragToggle=node<HTMLButtonElement>('mesh-regions-edit');
69
+ const segmentBone=node<HTMLSelectElement>('mesh-segment-bone'),segmentAdd=node<HTMLButtonElement>('mesh-segment-add');
70
+ const applyButton=node<HTMLButtonElement>('mesh-panel-apply'),resetButton=node<HTMLButtonElement>('mesh-panel-reset'),foldsButton=node<HTMLButtonElement>('mesh-folds');
71
+ const statusOut=node('mesh-status'),countsArea=node('mesh-counts'),readoutArea=node('mesh-readout'),draft=node<HTMLTextAreaElement>('mesh-pending');
72
+ let meshes:Record<string,unknown>|null=null,loadReason:string|null=null,part:string|null=null,base:MeshEntry|null=null,pending:MeshEntry|null=null,busy=false;
73
+ let placing:Point[]|null=null,dragging=false,drag:{region:number;vertex:number;pointer:number}|null=null,activeRegion:number|null=null;
74
+
75
+ function save(){draft.value=pending&&part&&base&&JSON.stringify(pending)!==JSON.stringify(base)?meshDraftText({schema:MESH_PANEL_SCHEMA,part,entry:pending}):'';draft.dispatchEvent(new Event('input',{bubbles:true}));host.saved();}
76
+ function change(next:MeshEntry){pending=next;save();render();host.changed();}
77
+ function status(text:string,error:boolean){statusOut.textContent=text;statusOut.title=text;statusOut.classList.toggle('error',error);}
78
+ const mode=():MeshMode|null=>{if(!pending||!part)return null;const m=meshMode(pending,part);return 'mode' in m?m.mode:null;};
79
+ const editable=()=>{const m=mode();return !!m&&!modeNotEditable(m);};
80
+
81
+ function renderParams(){
82
+ const m=mode();if(!pending||!m||!editable()){params.replaceChildren();return;}
83
+ const rows:HTMLElement[]=[];
84
+ for(const p of paramsFor(m))rows.push(paramRow(p));
85
+ // rule and the floor are closed sets: a select each, absent first.
86
+ rows.push(selectRow('rule',['rule'],['',...RULES],'the weight rule: "distance" (w = 1/(d + r)^exponent, what absent means) or "heat" (bone heat over the part\'s silhouette)'));
87
+ rows.push(selectRow('floor',['influences','floor'],['',...FLOORS],'"cut" (shares under 0.03 dropped, what absent means) or "continuous"'));
88
+ if(m==='contour'){
89
+ const box=make('input',{type:'checkbox',checked:entryValue(pending,['contour','bridges'])===true,disabled:busy});box.setAttribute('aria-label','contour bridges');box.dataset.param='bridges';
90
+ box.onchange=()=>change(setEntryValue(pending!,['contour','bridges'],box.checked?true:undefined));
91
+ rows.push(make('label',{className:'physics-toggle mesh-param-toggle',title:'every island joined into one outline by bridges (true is its one value; absent is the one-island rule)'},box,'bridges'));
92
+ }
93
+ params.replaceChildren(make('p',{className:'physics-editor-title'},make('strong',{textContent:part!}),` · mode ${m} · config.meshes.${part}`),...rows);
94
+ }
95
+ function paramRow(p:MeshParam):HTMLElement {
96
+ const value=entryValue(pending!,p.path),configValue=base?entryValue(base,p.path):undefined,present=typeof value==='number';
97
+ const label=`${p.key}${p.path.length>1&&p.path[0]!=='contour'?` (${p.path[0]})`:''}`,current=present?value as number:p.min;
98
+ const changed=JSON.stringify(value)!==JSON.stringify(configValue);
99
+ const range=make('input',{type:'range',min:String(Math.min(p.min,current)),max:String(Math.max(p.max,current)),step:String(p.step),value:String(current),disabled:busy||!present});range.setAttribute('aria-label',label);range.dataset.param=p.path.join('.');
100
+ const number=make('input',{type:'number',step:'any',value:present?String(value):'',disabled:busy||!present,inputMode:'decimal',placeholder:'absent'});number.setAttribute('aria-label',`${label} value`);number.dataset.param=p.path.join('.');
101
+ const commit=(raw:string)=>{const v=Number(raw);if(raw.trim()===''||!Number.isFinite(v)){status(`MESH_VALUE_INVALID: ${p.path.join('.')} must be a finite number`,true);return;}change(setEntryValue(pending!,p.path,v));};
102
+ range.oninput=()=>{number.value=range.value;};range.onchange=()=>commit(range.value);number.onchange=()=>commit(number.value);
103
+ const parts:(Node|string)[]=[make('span',{textContent:label,title:p.rule}),range,number];
104
+ if(p.optional){const on=make('input',{type:'checkbox',checked:present,disabled:busy});on.setAttribute('aria-label',`${label} declared`);on.onchange=()=>change(setEntryValue(pending!,p.path,on.checked?(typeof configValue==='number'?configValue:p.min):undefined));parts.push(on);}
105
+ else parts.push(make('span'));
106
+ parts.push(make('output',{textContent:changed?`was ${shown(configValue)}`:''}));
107
+ return make('label',{className:changed?'physics-control mesh-param pending':'physics-control mesh-param',title:p.rule},...parts);
108
+ }
109
+ function selectRow(label:string,path:readonly string[],options:readonly string[],rule:string):HTMLElement {
110
+ const value=entryValue(pending!,path),select=make('select',{disabled:busy});select.setAttribute('aria-label',label);select.dataset.param=path.join('.');
111
+ select.replaceChildren(...options.map(o=>make('option',{value:o,textContent:o||'(absent)'})));select.value=typeof value==='string'?value:'';
112
+ select.onchange=()=>change(setEntryValue(pending!,path,select.value||undefined));
113
+ const changed=JSON.stringify(value)!==JSON.stringify(base?entryValue(base,path):undefined);
114
+ return make('label',{className:changed?'physics-control mesh-param pending':'physics-control mesh-param',title:rule},make('span',{textContent:label}),select,make('span'),make('span'),make('output',{textContent:changed?`was ${shown(base?entryValue(base,path):undefined)}`:''}));
115
+ }
116
+
117
+ function renderRegions(){
118
+ const contour=mode()==='contour'&&editable();
119
+ for(const b of [addCircle,drawPolygon,dragToggle])b.disabled=busy||!contour;
120
+ drawPolygon.setAttribute('aria-pressed',String(!!placing));dragToggle.setAttribute('aria-pressed',String(dragging));
121
+ if(!contour||!pending){regionsArea.replaceChildren(make('p',{className:'hint',textContent:mode()==='grid'?'A lattice (grid) entry has no regions; regions belong to the contour mode.':''}));return;}
122
+ const regions=entryRegions(pending),bones=host.bones();
123
+ const items=regions.map((r,i)=>{
124
+ const field=(key:string,value:JsonValue|undefined,commit:(v:string)=>void,type='number')=>{const box=make('input',{type,value:value===undefined?'':String(value),disabled:busy,step:'any'});box.setAttribute('aria-label',`${r.name} ${key}`);box.dataset.region=String(i);box.dataset.field=key;box.onchange=()=>commit(box.value);return make('label',{},key,box);};
125
+ const set=(key:string,raw:string,numeric=true)=>{const next=entryRegions(pending!).map(x=>({...x})) as unknown as Record<string,JsonValue>[];const v=numeric?Number(raw):raw;if(numeric&&(raw.trim()===''||!Number.isFinite(v as number))){status(`MESH_VALUE_INVALID: ${r.name}.${key} must be a finite number`,true);return;}next[i]![key]=v as JsonValue;change(withRegions(pending!,next as unknown as MeshRegion[]));};
126
+ const bone=make('select',{disabled:busy});bone.setAttribute('aria-label',`${r.name} bone`);bone.dataset.region=String(i);bone.dataset.field='bone';
127
+ bone.replaceChildren(...[...new Set([r.bone,...bones])].map(b=>make('option',{value:b,textContent:b})));bone.value=r.bone;bone.onchange=()=>set('bone',bone.value,false);
128
+ const remove=make('button',{type:'button',textContent:'Remove',disabled:busy});remove.setAttribute('aria-label',`Remove region ${r.name}`);remove.onclick=()=>{const next=entryRegions(pending!).filter((_,k)=>k!==i);activeRegion=null;change(withRegions(pending!,next));};
129
+ const shape=r.shape==='circle'?[field('cx',r.cx,v=>set('cx',v)),field('cy',r.cy,v=>set('cy',v)),field('r',r.r,v=>set('r',v))]:[make('span',{className:'hint',textContent:`${r.points.length} points (drag them on the stage)`})];
130
+ const row=make('fieldset',{className:activeRegion===i?'mesh-region selected':'mesh-region'},make('legend',{textContent:`${r.name} · ${r.shape}`}),field('name',r.name,v=>set('name',v,false),'text'),make('label',{},'bone',bone),field('spacing',r.spacing,v=>set('spacing',v)),field('band',r.band,v=>set('band',v)),...shape,remove);
131
+ return row;
132
+ });
133
+ const bust=entryValue(pending,['contour','bust']);
134
+ regionsArea.replaceChildren(...(items.length?items:[make('p',{className:'hint',textContent:'No regions. Add a circle, or draw a polygon on the stage.'})]),...(bust===undefined?[]:[make('p',{className:'hint',textContent:'contour.bust stays as written (the Chest regions editor\'s Landmarks mode edits it).'})]));
135
+ }
136
+ function renderSegments(){
137
+ const ok=editable()&&!!pending;
138
+ segmentBone.disabled=busy||!ok;segmentAdd.disabled=busy||!ok;
139
+ if(!ok||!pending){segmentsList.replaceChildren();return;}
140
+ const keep=segmentBone.value;
141
+ segmentBone.replaceChildren(make('option',{value:'',textContent:'Pick a bone'}),...segmentChoices(host.bones(),pending).map(b=>make('option',{value:b,textContent:b})));
142
+ if([...segmentBone.options].some(o=>o.value===keep))segmentBone.value=keep;
143
+ const ribs=entryValue(pending,['ribs']),contour=mode()==='contour';
144
+ segmentsList.replaceChildren(...entrySegments(pending).map((s,i)=>{
145
+ const remove=make('button',{type:'button',textContent:'Remove',disabled:busy});remove.setAttribute('aria-label',`Remove segment ${i}`);remove.onclick=()=>change(removeSegment(pending!,i));
146
+ const item=make('li',{},make('code',{textContent:typeof s==='string'?s:JSON.stringify(s)}),remove);
147
+ if(typeof s==='string'&&contour){
148
+ const value=isObject(ribs)&&isObject(ribs[s])?(ribs[s] as Record<string,JsonValue>).stations:undefined;
149
+ const stations=make('input',{type:'number',step:'1',min:'0',value:value===undefined?'':String(value),placeholder:'no ribs',disabled:busy});stations.setAttribute('aria-label',`${s} ribs stations`);stations.dataset.ribs=s;
150
+ stations.onchange=()=>{const raw=stations.value.trim();change(setRibs(pending!,s,raw===''?null:Number(raw)));};
151
+ item.append(make('label',{className:'mesh-ribs',title:'ribs: a row of vertices across the part at each link origin and at stations points between (a chain the segments name; contour mode)'},'ribs stations',stations));
152
+ }
153
+ return item;
154
+ }));
155
+ }
156
+ function renderDiff(){
157
+ const changes=pending&&base?entryChanges(base,pending):[];
158
+ diffList.replaceChildren(...changes.map(c=>make('li',{},make('code',{textContent:`/meshes/${part}${c.path}`}),` ${shown(c.before)} → ${shown(c.after)}`)));
159
+ if(!changes.length)diffList.append(make('li',{className:'hint',textContent:pending?'The pending entry is the config\'s entry.':'No mesh part selected.'}));
160
+ const problems=pending&&part&&editable()?precheckEntry(part,pending,host.bones()):[];
161
+ precheckList.replaceChildren(...problems.map(p=>make('li',{className:'error',textContent:p})));
162
+ applyButton.disabled=busy||!pending||!editable()||!changes.length||problems.length>0;
163
+ resetButton.disabled=busy||!part;foldsButton.disabled=busy;
164
+ }
165
+ function renderState(){
166
+ if(loadReason&&!meshes){state.textContent=loadReason;state.classList.add('error');return;}
167
+ state.classList.remove('error');
168
+ if(!part){state.textContent='Select a mesh part in the parts list.';return;}
169
+ if(!base){state.textContent=`MESH_PANEL_NO_ENTRY: ${part} has no meshes entry in the handoff config`;state.classList.add('error');return;}
170
+ const m=meshMode(base,part);
171
+ if('error' in m){state.textContent=m.error;state.classList.add('error');return;}
172
+ const why=modeNotEditable(m.mode);
173
+ if(why){state.textContent=why;state.classList.add('error');return;}
174
+ state.textContent=`${part}: ${m.mode} entry from the handoff config. Apply sends the whole entry to remesh --mesh (one op at /meshes/${part}).`;
175
+ }
176
+ function render(){renderState();renderParams();renderRegions();renderSegments();renderDiff();}
177
+
178
+ function pick(next:string|null){
179
+ if(next===part&&meshes)return;
180
+ part=next;placing=null;activeRegion=null;
181
+ const entry=part&&meshes&&isObject(meshes[part])?structuredClone(meshes[part]) as MeshEntry:null;
182
+ base=entry;pending=entry?structuredClone(entry):null;
183
+ const kept=parseMeshDraft(draft.value);if(kept&&kept.part===part&&pending)pending=kept.entry;
184
+ render();host.changed();
185
+ }
186
+ addCircle.onclick=()=>{
187
+ if(!pending)return;const regions=entryRegions(pending),mapping=host.mapping();
188
+ // A new circle starts on the part: the first segment's bone start when it is a point segment, else the stage centre.
189
+ const seg=entrySegments(pending).find(s=>Array.isArray(s)) as [string,Point,Point]|undefined;
190
+ const centre:Point=seg?snapPoint((seg[1][0]+seg[2][0])/2,(seg[1][1]+seg[2][1])/2):mapping.available?snapPoint(mapping.crop.w/2,mapping.crop.h/2):[0,0];
191
+ const bone=entryValue(pending,['contour','regions'])?regions[0]?.bone??host.bones()[0]??'':host.bones()[0]??'';
192
+ change(withRegions(pending,[...regions,{name:freeRegionName(regions,'circle'),shape:'circle',cx:centre[0],cy:centre[1],r:24,bone,spacing:14,band:16}]));
193
+ activeRegion=regions.length;status('Added a circle region; drag its centre on the stage (Drag on stage), set its bone, spacing and band here.',false);
194
+ };
195
+ drawPolygon.onclick=()=>{
196
+ if(placing){finishPolygon();return;}
197
+ placing=[];dragging=false;status('Click the stage to place polygon points; press Draw polygon again (or Enter) to close it (3 or more points), Escape to drop it.',false);render();host.changed();
198
+ };
199
+ dragToggle.onclick=()=>{dragging=!dragging;placing=null;status(dragging?'Drag a polygon vertex or a circle centre on the stage.':'',false);render();host.changed();};
200
+ segmentAdd.onclick=()=>{if(pending&&segmentBone.value)change(addSegment(pending,segmentBone.value));};
201
+ applyButton.onclick=()=>{if(pending&&part&&!busy)void host.apply(part,structuredClone(pending));};
202
+ resetButton.onclick=()=>{if(busy)return;if(base)pending=structuredClone(base);placing=null;activeRegion=null;save();render();host.changed();void host.reset();};
203
+ foldsButton.onclick=()=>{if(!busy)void host.folds();};
204
+ function finishPolygon(){
205
+ const points=placing??[];placing=null;
206
+ if(points.length<3){status(`MESH_POLYGON_POINTS: ${points.length} point(s) placed; a polygon region needs 3 or more`,true);render();host.changed();return;}
207
+ const regions=entryRegions(pending!),bone=regions[0]?.bone??host.bones()[0]??'';
208
+ try{change(withRegions(pending!,[...regions,polygonRegion(freeRegionName(regions,'polygon'),points,bone,14,16)]));activeRegion=regions.length;status('Added a polygon region; drag its vertices on the stage (Drag on stage).',false);}
209
+ catch(error){status(error instanceof Error?error.message:String(error),true);}
210
+ }
211
+ const local=(event:PointerEvent)=>{const box=host.pane.getBoundingClientRect();return {x:event.clientX-box.left,y:event.clientY-box.top};};
212
+ const rigAt=(event:PointerEvent):Point|null=>{const project=host.project(),mapping=host.mapping();if(!project||!mapping.available)return null;return paneToRig(project,mapping,local(event));};
213
+ const projectRig=()=>{const project=host.project(),mapping=host.mapping();if(!project||!mapping.available)return null;return (x:number,y:number)=>{const w=mapping.toWorld(x,y);return project(w.x,w.y);};};
214
+ const down=(event:PointerEvent)=>{
215
+ if(!event.isPrimary||event.button!==0||!pending||(!placing&&!dragging))return;
216
+ const target=event.target;if(target instanceof Element&&target.closest('button,input,select,textarea,a'))return;
217
+ if(placing){const at=rigAt(event);if(!at)return;event.stopPropagation();event.preventDefault();placing.push(snapPoint(at[0],at[1]));host.changed();return;}
218
+ const project=projectRig();if(!project)return;
219
+ const hit=hitRegionPoint(entryRegions(pending),project,local(event));if(!hit)return;
220
+ // A vertex drag edits the region; it never reaches the viewport's pan.
221
+ event.stopPropagation();event.preventDefault();drag={...hit,pointer:event.pointerId};activeRegion=hit.region;host.pane.setPointerCapture(event.pointerId);render();host.changed();
222
+ };
223
+ const move=(event:PointerEvent)=>{
224
+ if(!drag||event.pointerId!==drag.pointer||!pending)return;event.stopPropagation();
225
+ const at=rigAt(event);if(!at)return;
226
+ pending=withRegions(pending,moveRegionPoint(entryRegions(pending),drag.region,drag.vertex,at));host.changed();
227
+ };
228
+ const end=(event:PointerEvent)=>{if(!drag||event.pointerId!==drag.pointer)return;drag=null;if(host.pane.hasPointerCapture(event.pointerId))host.pane.releasePointerCapture(event.pointerId);if(pending)change(pending);};
229
+ const keyDown=(event:KeyboardEvent)=>{if(!placing)return;if(event.key==='Enter'){event.preventDefault();finishPolygon();}else if(event.key==='Escape'){event.preventDefault();placing=null;status('Polygon dropped.',false);render();host.changed();}};
230
+ host.pane.addEventListener('pointerdown',down,true);host.pane.addEventListener('pointermove',move,true);
231
+ for(const type of ['pointerup','pointercancel'] as const)host.pane.addEventListener(type,end,true);
232
+ document.addEventListener('keydown',keyDown,true);
233
+
234
+ const colors=()=>({stroke:host.token('--rv-mesh-region'),fill:host.token('--rv-mesh-region-fill'),handle:host.token('--rv-mesh-handle'),ink:host.token('--rv-bust-ink'),labelBg:host.token('--rv-bust-label-bg')});
235
+ const toWorld=(p:Point)=>{const m=host.mapping();return m.available?m.toWorld(p[0],p[1]):null;};
236
+ const panel:MeshPanel={
237
+ load(value,reason){meshes=value;loadReason=reason;const keep=part;part=null;pick(keep);},
238
+ select:pick,
239
+ part:()=>part,entry:()=>pending?structuredClone(pending):null,configEntry:()=>base?structuredClone(base):null,
240
+ set(path,value){if(!pending)throw new Error('MESH_PANEL_NO_PART');change(setEntryValue(pending,path,value));},
241
+ setEntry(entry){change(structuredClone(entry));},
242
+ regions:{
243
+ list:()=>pending?entryRegions(pending):[],
244
+ add(region){if(!pending)throw new Error('MESH_PANEL_NO_PART');change(withRegions(pending,[...entryRegions(pending),region]));},
245
+ move(region,vertex,to){if(!pending)throw new Error('MESH_PANEL_NO_PART');activeRegion=region;change(withRegions(pending,moveRegionPoint(entryRegions(pending),region,vertex,to)));},
246
+ remove(index){if(!pending)return;change(withRegions(pending,entryRegions(pending).filter((_,i)=>i!==index)));},
247
+ draw(on){dragging=on;placing=null;render();host.changed();},
248
+ placing:()=>placing?placing.map(p=>[p[0],p[1]] as Point):null,
249
+ },
250
+ precheck:()=>pending&&part?precheckEntry(part,pending,host.bones()):[],
251
+ restore(){const kept=parseMeshDraft(draft.value);if(kept&&kept.part===part&&base){pending=kept.entry;render();host.changed();}},
252
+ status,
253
+ counts(lines){countsArea.replaceChildren(...lines.map(l=>make('div',{className:'physics-readout-line'},make('span',{className:'eyebrow',textContent:l.label}),make('code',{textContent:l.text,title:l.text,className:l.error?'error':''}))));},
254
+ readout(lines){readoutArea.replaceChildren(...lines.flatMap(l=>[make('div',{className:'physics-readout-line'},make('span',{className:'eyebrow',textContent:'BEFORE'}),make('code',{textContent:l.before,title:l.before})),make('div',{className:'physics-readout-line'},make('span',{className:'eyebrow',textContent:'AFTER'}),make('code',{textContent:l.after,title:l.after}))]));},
255
+ busy(value){busy=value;render();},
256
+ overlayRegions(){
257
+ if(!pending||mode()!=='contour')return [];const c=colors();
258
+ const out:OverlayRegion[]=[];
259
+ entryRegions(pending).forEach((r,i)=>{if(r.shape!=='polygon')return;const points=r.points.map(toWorld);if(points.some(p=>!p))return;out.push({name:r.name,points:points as {x:number;y:number}[],stroke:c.stroke,fill:c.fill,handles:dragging||activeRegion===i,active:activeRegion===i&&drag?drag.vertex:null,colors:{handle:c.handle,ink:c.ink,labelBg:c.labelBg}});});
260
+ if(placing&&placing.length){const points=placing.map(toWorld);if(points.every(Boolean))out.push({name:'new polygon',points:points as {x:number;y:number}[],stroke:c.stroke,fill:c.fill,handles:true,open:true,active:placing.length-1,colors:{handle:c.handle,ink:c.ink,labelBg:c.labelBg}});}
261
+ return out;
262
+ },
263
+ overlayCircles(){
264
+ if(!pending||mode()!=='contour')return [];const c=colors();
265
+ return entryRegions(pending).flatMap((r,i)=>{if(r.shape!=='circle')return [];const at=toWorld([r.cx,r.cy]);if(!at)return [];
266
+ // Crop px are world px under the crop mapping (a translation and a y flip), so r and band carry over as lengths.
267
+ return [{center:at,r:r.r,band:r.band,label:`${r.name} → ${r.bone}`,labelRow:i,handles:dragging||activeRegion===i,colors:{stroke:c.stroke,fill:c.fill,handle:c.handle,ink:c.ink,labelBg:c.labelBg,flash:c.handle}}];});
268
+ },
269
+ dispose(){host.pane.removeEventListener('pointerdown',down,true);host.pane.removeEventListener('pointermove',move,true);for(const type of ['pointerup','pointercancel'] as const)host.pane.removeEventListener(type,end,true);document.removeEventListener('keydown',keyDown,true);},
270
+ };
271
+ render();
272
+ return panel;
273
+ }