@rig-c/review 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -12
- package/docs/DESIGN.md +26 -4
- package/docs/REVIEW_PACKAGE.md +113 -0
- package/package.json +1 -1
- package/src/applied-context.ts +285 -0
- package/src/baseline.ts +175 -0
- package/src/bone-anchors.ts +123 -0
- package/src/bones-check.ts +105 -0
- package/src/build-route.ts +109 -0
- package/src/chest-landmarks.ts +122 -0
- package/src/chest-regions.ts +10 -4
- package/src/cli.ts +19 -8
- package/src/config-patch.ts +213 -0
- package/src/cut-edits.ts +184 -0
- package/src/environment.ts +19 -6
- package/src/feedback-export.ts +17 -1
- package/src/front.ts +11 -0
- package/src/handoff.ts +208 -0
- package/src/idle-tracks.ts +401 -0
- package/src/manifest.ts +15 -6
- package/src/mesh-edit.ts +45 -15
- package/src/mesh-entry.ts +351 -0
- package/src/open.ts +12 -5
- package/src/physics-tuning.ts +236 -0
- package/src/producer-files.ts +11 -0
- package/src/producer.ts +158 -0
- package/src/readings-run.ts +129 -0
- package/src/readings.ts +235 -0
- package/src/rebuild.ts +938 -0
- package/src/reference.ts +4 -3
- package/src/requirements-run.ts +160 -0
- package/src/requirements.ts +230 -0
- package/src/retarget.ts +211 -0
- package/src/retune-route.ts +52 -0
- package/src/rig-fields.ts +295 -0
- package/src/server.ts +59 -5
- package/src/session.ts +9 -0
- package/src/setup-mesh.ts +144 -0
- package/src/weights.ts +74 -0
- package/web/bones-panel.ts +230 -0
- package/web/chest-editor.ts +128 -13
- package/web/chest-regions-tool.ts +2 -1
- package/web/cuts-panel.ts +266 -0
- package/web/idle-panel.ts +280 -0
- package/web/index.html +1 -1
- package/web/inspector-blocks.ts +77 -0
- package/web/main.ts +701 -49
- package/web/mesh-editor.ts +73 -38
- package/web/mesh-panel.ts +273 -0
- package/web/physics-panel.ts +151 -0
- package/web/readings-panel.ts +105 -0
- package/web/rebuild-client.ts +216 -0
- package/web/requirements-panel.ts +238 -0
- package/web/retarget-panel.ts +203 -0
- package/web/rig-fields-panel.ts +269 -0
- package/web/rig-overlays.ts +108 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +28 -5
- package/web/weights-view.ts +159 -0
package/web/mesh-editor.ts
CHANGED
|
@@ -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.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 [
|
|
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
|
|
53
|
-
*
|
|
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;
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if(!
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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<
|
|
70
|
-
return {ok:true,source:{slot,skin:
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
node<HTMLButtonElement>('mesh-
|
|
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
|
+
}
|