@rig-c/review 0.4.0 → 0.6.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 +87 -12
- package/docs/DESIGN.md +18 -8
- package/docs/REVIEW_PACKAGE.md +2 -0
- package/package.json +1 -1
- package/src/chest-regions.ts +306 -0
- package/src/environment.ts +213 -0
- package/src/feedback-export.ts +9 -1
- package/src/mesh-edit.ts +287 -0
- package/src/session.ts +3 -0
- package/web/chest-editor.ts +179 -0
- package/web/chest-regions-tool.ts +56 -0
- package/web/compare.css +1 -0
- package/web/index.html +3 -6
- package/web/main.ts +210 -42
- package/web/mesh-editor.ts +268 -0
- package/web/part-selection.ts +13 -5
- package/web/part-thumbnails.ts +76 -0
- package/web/rig-overlays.ts +74 -3
- package/web/styles.css +104 -23
- package/web/view-controls.ts +11 -5
- package/web/workspace-layout.ts +2 -1
- package/web/workspace-preferences.ts +17 -12
- package/web/workspace-ui.ts +26 -12
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
import type {PosedSlot} from '@rig-c/play';
|
|
8
|
+
import type {CropWorldMapping} from '../src/chest-regions.ts';
|
|
9
|
+
import {
|
|
10
|
+
MESH_EDIT_NO_MESH,MESH_EDIT_PREVIEW_NOTE,MESH_EDIT_SELECT_SLOT,MeshEditSession,affineOf,applyAffine,applyMeshOps,attachmentHashText,draftOps,hitMesh,
|
|
11
|
+
invertAffine,meshEditDocument,parseMeshDraft,screenDeltaToCrop,verticesInBox,writeMeshDraft,type MeshEditDocument,type MeshEditResult,type MeshOp,type MeshSource,
|
|
12
|
+
type MeshView,type ScreenPoint,type Vec,
|
|
13
|
+
} from '../src/mesh-edit.ts';
|
|
14
|
+
import {MODELING_INPUT_UNAVAILABLE,requestRebuild} from './chest-editor.ts';
|
|
15
|
+
import type {OverlayMeshEdit,OverlayPoint,OverlayProject} from './rig-overlays.ts';
|
|
16
|
+
import {textEntryFocused} from './view-controls.ts';
|
|
17
|
+
|
|
18
|
+
export type MeshMode='modify'|'create'|'delete'|'edges';
|
|
19
|
+
export const meshModes:readonly MeshMode[]=['modify','create','delete','edges'];
|
|
20
|
+
/** Single-key sub-mode shortcuts. Free of I / M / B (layers), Space (pan), Enter, arrows (nudge) and Z (undo). */
|
|
21
|
+
export const meshModeKeys:Readonly<Record<string,MeshMode>>=Object.freeze({v:'modify',c:'create',x:'delete',e:'edges'});
|
|
22
|
+
export const meshModeLabel:Readonly<Record<MeshMode,string>>=Object.freeze({modify:'Modify',create:'Create',delete:'Delete',edges:'Edges'});
|
|
23
|
+
export const meshEditVisible=(inspector:string|undefined)=>inspector==='rig';
|
|
24
|
+
|
|
25
|
+
export type MeshKeyAction={type:'mode';mode:MeshMode}|{type:'escape'}|{type:'undo'}|{type:'redo'}|{type:'nudge';dx:number;dy:number};
|
|
26
|
+
interface KeyLike {key:string;shiftKey:boolean;ctrlKey:boolean;metaKey:boolean;altKey:boolean;repeat?:boolean;defaultPrevented?:boolean;target:unknown}
|
|
27
|
+
/** Focus inside these keeps its own keys (tabs, separators, the part list, menus and buttons use arrows/Escape). */
|
|
28
|
+
const ownKeys='[role=separator],[role=tab],[data-part-list],[data-part-search],details,dialog';
|
|
29
|
+
/** Keyboard map while mesh edit is on. Text entry keeps every key. */
|
|
30
|
+
export function meshEditShortcut(event:KeyLike,state:{active:boolean;mode:MeshMode|null;selected:number}):MeshKeyAction|null {
|
|
31
|
+
if(!state.active||event.defaultPrevented||typeof event.key!=='string'||textEntryFocused(event.target))return null;
|
|
32
|
+
const key=event.key.toLowerCase(),within=(selector:string)=>{const node=event.target as {closest?:(s:string)=>unknown}|null;return !!node&&typeof node.closest==='function'&&!!node.closest(selector);};
|
|
33
|
+
if((event.metaKey||event.ctrlKey)&&!event.altKey&&key==='z')return event.shiftKey?{type:'redo'}:{type:'undo'};
|
|
34
|
+
if(event.metaKey||event.ctrlKey||event.altKey)return null;
|
|
35
|
+
if(key==='escape')return within('dialog[open],details[open]')?null:{type:'escape'};
|
|
36
|
+
if(event.key.startsWith('Arrow')){
|
|
37
|
+
if(state.mode!=='modify'||!state.selected||within(ownKeys))return null;
|
|
38
|
+
const step=event.shiftKey?10:1,d=({ArrowLeft:[-step,0],ArrowRight:[step,0],ArrowUp:[0,-step],ArrowDown:[0,step]} as Record<string,[number,number]>)[event.key];
|
|
39
|
+
return d?{type:'nudge',dx:d[0],dy:d[1]}:null;
|
|
40
|
+
}
|
|
41
|
+
if(event.repeat||event.shiftKey)return null;
|
|
42
|
+
const mode=meshModeKeys[key];return mode?{type:'mode',mode}:null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** World → crop through the inverse of the crop mapping (crop px, x right, y down). */
|
|
46
|
+
export function cropFromWorld(mapping:Extract<CropWorldMapping,{available:true}>):((x:number,y:number)=>Vec)|null {
|
|
47
|
+
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];};
|
|
49
|
+
}
|
|
50
|
+
type SourceResult={ok:true;source:Omit<MeshSource,'hash'>;entry:unknown}|{ok:false;code:string};
|
|
51
|
+
/**
|
|
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.
|
|
54
|
+
*/
|
|
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
|
+
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})`};
|
|
68
|
+
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};
|
|
71
|
+
}
|
|
72
|
+
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
|
+
|
|
74
|
+
export interface MeshEditorHost {
|
|
75
|
+
pane:HTMLElement;canvas:HTMLElement;
|
|
76
|
+
project:()=>OverlayProject|null;
|
|
77
|
+
mapping:()=>CropWorldMapping;
|
|
78
|
+
inspector:()=>string|undefined;
|
|
79
|
+
/** The selected slot's source, or the refusal that names why there is none. */
|
|
80
|
+
source:()=>SourceResult;
|
|
81
|
+
/** How the vertices were obtained (pose, mapping), for the export. */
|
|
82
|
+
basis:()=>string;
|
|
83
|
+
/** Hold the pose being edited still. */
|
|
84
|
+
pause:()=>void;
|
|
85
|
+
changed:()=>void;
|
|
86
|
+
download:(document:MeshEditDocument)=>void;
|
|
87
|
+
message:(text:string,error?:boolean)=>void;
|
|
88
|
+
confirm:(text:string)=>boolean;
|
|
89
|
+
token:(name:string)=>string;
|
|
90
|
+
}
|
|
91
|
+
export interface MeshEditor {active:()=>boolean;overlay:()=>OverlayMeshEdit|null;sync:()=>void;selectionChanged:()=>void;dispose:()=>void;session:()=>MeshEditSession|null;mode:()=>MeshMode|null}
|
|
92
|
+
|
|
93
|
+
type Drag={pointer:number;start:ScreenPoint;ids:number[];base:MeshView;ops:MeshOp[]}|{pointer:number;start:ScreenPoint;box:ScreenPoint;add:boolean};
|
|
94
|
+
|
|
95
|
+
export function bindMeshEditor(host:MeshEditorHost):MeshEditor {
|
|
96
|
+
const node=<T extends HTMLElement>(id:string)=>document.getElementById(id) as T;
|
|
97
|
+
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
|
+
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;
|
|
100
|
+
let preview:MeshView|null=null;const selected=new Set<number>();
|
|
101
|
+
const view=()=>preview??session?.view()??null;
|
|
102
|
+
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);});};
|
|
103
|
+
const screenPoints=():(ScreenPoint|null)[]=>{const m=cropToScreen(),v=view();if(!m||!v)return [];return v.vertices.map(p=>p?applyAffine(m,p[0],p[1]):null);};
|
|
104
|
+
const toCrop=(p:ScreenPoint):Vec|null=>{const m=cropToScreen(),inverse=m&&invertAffine(m);if(!inverse)return null;const q=applyAffine(inverse,p.x,p.y);return [q.x,q.y];};
|
|
105
|
+
const local=(event:PointerEvent):ScreenPoint=>{const rect=host.canvas.getBoundingClientRect();return {x:event.clientX-rect.left,y:event.clientY-rect.top};};
|
|
106
|
+
const hint:Record<MeshMode,string>={
|
|
107
|
+
modify:'drag a vertex or edge · Shift-click selects more · drag empty space to box-select · arrows nudge 1 px (Shift 10)',
|
|
108
|
+
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',
|
|
110
|
+
edges:'click vertex A then vertex B to add a constraint edge · click a constraint edge to remove it',
|
|
111
|
+
};
|
|
112
|
+
const sync=()=>{
|
|
113
|
+
const visible=meshEditVisible(host.inspector());toggle.hidden=!visible;
|
|
114
|
+
if(!visible&&active){setActive(false);return;}
|
|
115
|
+
toggle.setAttribute('aria-pressed',String(active));bar.hidden=!active;
|
|
116
|
+
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;
|
|
118
|
+
if(!session){status.textContent='';return;}
|
|
119
|
+
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`;
|
|
121
|
+
status.title=`${s.slot} / ${s.skin} / ${s.attachment} · ${s.vertices.length} vertices · ${s.triangles.length/3} triangles · hull ${s.hull} · ${status.textContent}`;
|
|
122
|
+
status.classList.toggle('error',noteError);
|
|
123
|
+
if(mode)host.pane.dataset.meshMode=mode;else delete host.pane.dataset.meshMode;
|
|
124
|
+
};
|
|
125
|
+
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}));};
|
|
127
|
+
/** Reports a step: a refusal by its code, a no-op by its reason, a change by what it did. */
|
|
128
|
+
const after=(result:MeshEditResult,what:()=>string)=>{
|
|
129
|
+
if(!result.ok)say(result.code,true);
|
|
130
|
+
else if(result.changed){persist();say('');host.message(`${what()} · ${MESH_EDIT_PREVIEW_NOTE}`);}
|
|
131
|
+
else say(result.code??'');
|
|
132
|
+
host.changed();
|
|
133
|
+
};
|
|
134
|
+
const lastAdded=()=>(session?.view().vertices.length??1)-1;
|
|
135
|
+
const setMode=(next:MeshMode|null)=>{mode=next;pending=null;drag=null;preview=null;sync();host.changed();};
|
|
136
|
+
const leave=()=>{active=false;mode=null;session=null;drag=null;preview=null;pending=null;hover=null;selected.clear();note='';delete host.pane.dataset.meshHover;delete host.pane.dataset.meshMode;};
|
|
137
|
+
const setActive=(on:boolean)=>{if(!on){leave();sync();host.changed();return;}void enter();};
|
|
138
|
+
const enter=async()=>{
|
|
139
|
+
if(entering)return;entering=true;
|
|
140
|
+
try{
|
|
141
|
+
const result=host.source();if(!result.ok){leave();sync();host.message(result.code,true);host.changed();return;}
|
|
142
|
+
const hash=await sha256Hex(attachmentHashText(result.entry)),source:MeshSource={...result.source,hash};
|
|
143
|
+
const restored=draftOps(parseMeshDraft(draft.value),source);session=new MeshEditSession(source,restored??[]);
|
|
144
|
+
if(chestToggle?.getAttribute('aria-pressed')==='true')chestToggle.click();
|
|
145
|
+
host.pause();active=true;mode='modify';selected.clear();pending=null;note=restored?.length?`restored ${restored.length} draft op${restored.length===1?'':'s'}`:'';noteError=false;
|
|
146
|
+
sync();host.changed();
|
|
147
|
+
}finally{entering=false;}
|
|
148
|
+
};
|
|
149
|
+
toggle.addEventListener('click',()=>setActive(!active));
|
|
150
|
+
for(const button of bar.querySelectorAll<HTMLButtonElement>('[data-mesh-mode]'))button.addEventListener('click',()=>setMode(button.dataset.meshMode as MeshMode));
|
|
151
|
+
const undo=()=>{if(session?.undo()){selected.clear();persist();say('');host.changed();}};
|
|
152
|
+
const redo=()=>{if(session?.redo()){selected.clear();persist();say('');host.changed();}};
|
|
153
|
+
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);});
|
|
157
|
+
const keyDown=(event:KeyboardEvent)=>{
|
|
158
|
+
if(event.code==='Space'&&!textEntryFocused(event.target))space=true;
|
|
159
|
+
const action=meshEditShortcut(event,{active,mode,selected:selected.size});if(!action)return;
|
|
160
|
+
event.preventDefault();event.stopPropagation();
|
|
161
|
+
if(action.type==='mode')setMode(action.mode);
|
|
162
|
+
else if(action.type==='escape'){if(mode)setMode(null);else setActive(false);}
|
|
163
|
+
else if(action.type==='undo')undo();else if(action.type==='redo')redo();
|
|
164
|
+
else if(session)after(session.record(session.moveOps([...selected],action.dx,action.dy)),()=>`nudged ${selected.size} vertex${selected.size===1?'':'es'} by ${action.dx}, ${action.dy} crop px`);
|
|
165
|
+
};
|
|
166
|
+
const keyUp=(event:KeyboardEvent)=>{if(event.code==='Space')space=false;};
|
|
167
|
+
const blur=()=>{space=false;};
|
|
168
|
+
const down=(event:PointerEvent)=>{
|
|
169
|
+
if(!active||!mode||!session||space||!event.isPrimary||event.button!==0)return;
|
|
170
|
+
const target=event.target;if(target instanceof Element&&target.closest('button,input,select,textarea,a,summary'))return;
|
|
171
|
+
// In a sub-mode every primary press edits; the viewport's pan never sees it (Space-drag still pans).
|
|
172
|
+
event.stopPropagation();event.preventDefault();
|
|
173
|
+
const p=local(event),points=screenPoints(),current=session.view(),hit=hitMesh(points,current.edges,p,event.pointerType==='touch');
|
|
174
|
+
const capture=()=>host.pane.setPointerCapture(event.pointerId);
|
|
175
|
+
if(mode==='modify'){
|
|
176
|
+
if(hit?.kind==='vertex'){
|
|
177
|
+
if(event.shiftKey){if(selected.has(hit.v))selected.delete(hit.v);else selected.add(hit.v);host.changed();return;}
|
|
178
|
+
if(!selected.has(hit.v)){selected.clear();selected.add(hit.v);}
|
|
179
|
+
drag={pointer:event.pointerId,start:p,ids:[...selected],base:current,ops:[]};capture();host.changed();return;
|
|
180
|
+
}
|
|
181
|
+
if(hit?.kind==='edge'){
|
|
182
|
+
if(event.shiftKey){selected.add(hit.a);selected.add(hit.b);host.changed();return;}
|
|
183
|
+
if(!(selected.has(hit.a)&&selected.has(hit.b))){selected.clear();selected.add(hit.a);selected.add(hit.b);}
|
|
184
|
+
drag={pointer:event.pointerId,start:p,ids:[...selected],base:current,ops:[]};capture();host.changed();return;
|
|
185
|
+
}
|
|
186
|
+
if(!event.shiftKey)selected.clear();
|
|
187
|
+
drag={pointer:event.pointerId,start:p,box:p,add:event.shiftKey};capture();host.changed();return;
|
|
188
|
+
}
|
|
189
|
+
if(mode==='create'){
|
|
190
|
+
if(hit?.kind==='vertex'){say('Create: that is a vertex already — click empty space or an edge');return;}
|
|
191
|
+
const at=toCrop(hit?.kind==='edge'?hit.at:p);if(!at)return;
|
|
192
|
+
const edge=hit?.kind==='edge'?[hit.a,hit.b] as [number,number]:undefined;after(session.addVertex(at,edge),()=>edge?`edge ${edge[0]}–${edge[1]} split by vertex ${lastAdded()}`:`vertex ${lastAdded()} added`);return;
|
|
193
|
+
}
|
|
194
|
+
if(mode==='delete'){
|
|
195
|
+
if(hit?.kind==='vertex'){const v=hit.v;selected.delete(v);after(session.removeVertex(v),()=>`vertex ${v} removed with its edges`);}
|
|
196
|
+
else if(hit?.kind==='edge'){const {a,b}=hit;after(session.deleteEdge(a,b),()=>`edge ${a}–${b} removed`);}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Edges
|
|
200
|
+
if(hit?.kind==='vertex'){
|
|
201
|
+
if(pending===null||pending===hit.v){pending=pending===hit.v?null:hit.v;say(pending===null?'':`edge from ${hit.v}: click the second vertex`);host.changed();return;}
|
|
202
|
+
const from=pending,to=hit.v,had=!!session.findEdge(from,to);pending=null;after(session.toggleConstraint(from,to),()=>`constraint edge ${from}–${to} ${had?'removed':'added'}`);return;
|
|
203
|
+
}
|
|
204
|
+
if(hit?.kind==='edge'){const {a,b}=hit;pending=null;after(session.toggleConstraint(a,b),()=>`constraint edge ${a}–${b} removed`);return;}
|
|
205
|
+
pending=null;say('');host.changed();
|
|
206
|
+
};
|
|
207
|
+
const move=(event:PointerEvent)=>{
|
|
208
|
+
if(drag&&event.pointerId===drag.pointer){
|
|
209
|
+
event.stopPropagation();const p=local(event);
|
|
210
|
+
if('box' in drag){drag.box=p;host.changed();return;}
|
|
211
|
+
const m=cropToScreen(),d=m&&screenDeltaToCrop(m,p.x-drag.start.x,p.y-drag.start.y);if(!d||!session)return;
|
|
212
|
+
drag.ops=session.moveOps(drag.ids,d[0],d[1],drag.base);
|
|
213
|
+
try{preview=applyMeshOps(session.source,[...session.ops(),...drag.ops]);}catch{preview=null;}
|
|
214
|
+
host.changed();return;
|
|
215
|
+
}
|
|
216
|
+
if(!active||!mode||space||!session)return;
|
|
217
|
+
const next=hitMesh(screenPoints(),session.view().edges,local(event),event.pointerType==='touch');
|
|
218
|
+
const key=(h:typeof next)=>h?h.kind==='vertex'?`v${h.v}`:`e${h.a}:${h.b}`:'';
|
|
219
|
+
if(key(next)!==key(hover)){hover=next;if(next)host.pane.dataset.meshHover=next.kind;else delete host.pane.dataset.meshHover;host.changed();}
|
|
220
|
+
};
|
|
221
|
+
const end=(event:PointerEvent)=>{
|
|
222
|
+
if(!drag||event.pointerId!==drag.pointer)return;const finished=drag;drag=null;preview=null;
|
|
223
|
+
if(host.pane.hasPointerCapture(event.pointerId))host.pane.releasePointerCapture(event.pointerId);
|
|
224
|
+
if('box' in finished){
|
|
225
|
+
if(event.type!=='pointercancel'){const ids=verticesInBox(screenPoints(),finished.start,finished.box);if(!finished.add)selected.clear();for(const v of ids)selected.add(v);}
|
|
226
|
+
host.changed();return;
|
|
227
|
+
}
|
|
228
|
+
if(event.type==='pointercancel'||!session){host.changed();return;}
|
|
229
|
+
const moved=new Set(finished.ops.map(op=>op.op==='move'?op.v:-1)).size;after(session.record(finished.ops),()=>`moved ${moved} vertex${moved===1?'':'es'}`);
|
|
230
|
+
};
|
|
231
|
+
host.pane.addEventListener('pointerdown',down,true);host.pane.addEventListener('pointermove',move,true);
|
|
232
|
+
for(const type of ['pointerup','pointercancel'] as const)host.pane.addEventListener(type,end,true);
|
|
233
|
+
document.addEventListener('keydown',keyDown,true);window.addEventListener('keyup',keyUp,true);window.addEventListener('blur',blur);
|
|
234
|
+
const observer=new MutationObserver(()=>sync());
|
|
235
|
+
for(const selector of document.querySelectorAll('[data-inspector]'))observer.observe(selector,{attributes:true,attributeFilter:['aria-pressed','aria-selected']});
|
|
236
|
+
// The chest editor and mesh edit both own the pane's pointer; turning one on turns the other off.
|
|
237
|
+
const chestObserver=new MutationObserver(()=>{if(active&&chestToggle?.getAttribute('aria-pressed')==='true'){setActive(false);}});
|
|
238
|
+
if(chestToggle)chestObserver.observe(chestToggle,{attributes:true,attributeFilter:['aria-pressed']});
|
|
239
|
+
const overlay=():OverlayMeshEdit|null=>{
|
|
240
|
+
const v=view(),mapping=host.mapping();if(!active||!session||!v||!mapping.available)return null;
|
|
241
|
+
const world=(p:Vec):OverlayPoint=>mapping.toWorld(p[0],p[1]),s=session.source,source=s.vertices;
|
|
242
|
+
const hoverKey=hover?.kind==='edge'?`${Math.min(hover.a,hover.b)}:${Math.max(hover.a,hover.b)}`:'';
|
|
243
|
+
const triangles:OverlayPoint[][]=[];for(let i=0;i+2<s.triangles.length;i+=3){const ids=[s.triangles[i]!,s.triangles[i+1]!,s.triangles[i+2]!];if(ids.every(id=>source[id]))triangles.push(ids.map(id=>world(source[id]!)));}
|
|
244
|
+
type EdgeRow=OverlayMeshEdit['edges'][number];type VertexRow=OverlayMeshEdit['vertices'][number];
|
|
245
|
+
const edges:EdgeRow[]=[];
|
|
246
|
+
for(const e of v.removedEdges){const a=v.vertices[e.a]??source[e.a]??null,b=v.vertices[e.b]??source[e.b]??null;if(a&&b)edges.push({from:world(a),to:world(b),kind:'removed'});}
|
|
247
|
+
for(const e of v.edges){const a=v.vertices[e.a],b=v.vertices[e.b];if(a&&b)edges.push({from:world(a),to:world(b),kind:e.kind,hover:`${e.a}:${e.b}`===hoverKey});}
|
|
248
|
+
const ghosts=[...v.moved].flatMap(([id,from])=>{const to=v.vertices[id];return to?[{from:world(from),to:world(to)}]:[];});
|
|
249
|
+
const vertices=v.vertices.flatMap((p,id):VertexRow[]=>{
|
|
250
|
+
if(!p){const at=source[id];return at?[{at:world(at),state:'removed',selected:false,hover:false,pending:false}]:[];}
|
|
251
|
+
return [{at:world(p),state:v.added.has(id)?'added':v.moved.has(id)?'moved':'source',selected:selected.has(id),hover:hover?.kind==='vertex'&&hover.v===id,pending:pending===id}];
|
|
252
|
+
});
|
|
253
|
+
const box=drag&&'box' in drag?{from:drag.start,to:drag.box}:undefined;
|
|
254
|
+
const t=host.token;
|
|
255
|
+
return {triangles,edges,ghosts,vertices,...(box?{box}:{}),colors:{veil:t('--rv-mesh-veil'),edge:t('--rv-mesh-edge'),hull:t('--rv-mesh-hull'),constraint:t('--rv-mesh-constraint'),removed:t('--rv-mesh-removed'),vertex:t('--rv-mesh-vertex'),ink:t('--rv-mesh-ink'),selected:t('--rv-mesh-selected'),added:t('--rv-mesh-added'),hover:t('--rv-mesh-hover'),box:t('--rv-mesh-box'),boxFill:t('--rv-mesh-box-fill')}};
|
|
256
|
+
};
|
|
257
|
+
const selectionChanged=()=>{
|
|
258
|
+
if(!active||!session)return;const result=host.source();
|
|
259
|
+
if(!result.ok){leave();sync();host.message(result.code,true);host.changed();return;}
|
|
260
|
+
if(result.source.slot!==session.source.slot||result.source.attachment!==session.source.attachment||result.source.skin!==session.source.skin){leave();void enter();}
|
|
261
|
+
};
|
|
262
|
+
sync();
|
|
263
|
+
return {
|
|
264
|
+
active:()=>active,overlay,sync,selectionChanged,session:()=>session,mode:()=>mode,
|
|
265
|
+
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
|
+
};
|
|
267
|
+
}
|
|
268
|
+
export {MODELING_INPUT_UNAVAILABLE};
|
package/web/part-selection.ts
CHANGED
|
@@ -14,10 +14,12 @@ export function partCatalog(value:unknown,expected:readonly string[]):PartEntry[
|
|
|
14
14
|
let count=0;for(const [i,v]of model.skins.entries()){const skin=object(v,`skin ${i}`),skinName=name(skin.name,'skin.name');const slots=object(skin.attachments??{},'skin.attachments');for(const [slot,raw]of Object.entries(slots)){const entry=bySlot.get(slot);if(!entry)throw new Error(`Part catalog unknown slot ${slot}`);for(const [attachment,a]of Object.entries(object(raw,'slot attachments'))){if(++count>100000)throw new Error('Part declaration count exceeds 100000');const spec=object(a,'attachment');entry.declarations.push({skin:skinName,attachment:name(attachment,'attachment name'),kind:name(spec.kind,'attachment.kind'),path:typeof spec.path==='string'?name(spec.path,'attachment.path'):null});}}}
|
|
15
15
|
return entries;
|
|
16
16
|
}
|
|
17
|
-
export async function loadPartCatalog(url:string,sha256:string,slots:readonly string[],fetcher:typeof fetch=fetch):Promise<PartEntry[]>{
|
|
17
|
+
export async function loadPartCatalog(url:string,sha256:string,slots:readonly string[],fetcher:typeof fetch=fetch):Promise<PartEntry[]>{return (await loadPartModel(url,sha256,slots,fetcher)).entries;}
|
|
18
|
+
/** The hash-bound catalog plus the parsed model document it was read from (for the page regions thumbnails cut). */
|
|
19
|
+
export async function loadPartModel(url:string,sha256:string,slots:readonly string[],fetcher:typeof fetch=fetch):Promise<{entries:PartEntry[];model:unknown}>{
|
|
18
20
|
const response=await fetcher(url);if(!response.ok)throw new Error('Part catalog model unavailable');const bytes=await response.arrayBuffer();if(bytes.byteLength>128*1024*1024)throw new Error('Part catalog exceeds 128 MiB');
|
|
19
21
|
const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(v=>v.toString(16).padStart(2,'0')).join('');if(hash!==sha256)throw new Error('PART_SOURCE_CHANGED: model bytes differ from reviewed SHA-256');
|
|
20
|
-
|
|
22
|
+
const model:unknown=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes));return {entries:partCatalog(model,slots),model};
|
|
21
23
|
}
|
|
22
24
|
/** Resolve clips on the whole frame first. Filtering player.slots would drop clipping slots. */
|
|
23
25
|
export function selectedFrame(full:PosedFrame,part:ControlPart|null,side:ControlPart['side']):PosedFrame{return part?.side===side&&part.mode==='solo'?{...(full.generation===undefined?{}:{generation:full.generation}),order:full.order.filter(row=>row.slot===part.slot)}:full;}
|
|
@@ -92,7 +94,10 @@ export class PartInspection {
|
|
|
92
94
|
|
|
93
95
|
feedback():Record<string,Json>|null {const value=this.observation();return value?{...value,identity:value.identity?{...value.identity}:null,activeAttachment:value.activeAttachment?{...value.activeAttachment}:null,bounds:value.bounds?{...value.bounds}:null,declarations:value.declarations.map(item=>({...item}))}:null;}
|
|
94
96
|
}
|
|
97
|
+
/** Draws a row's thumbnail into its icon element, or leaves the type glyph; returns the tooltip note naming which and why. */
|
|
98
|
+
export interface PartThumbnailSource {show(icon:HTMLElement,entry:PartEntry,identity:ControlPartIdentity|null,glyph:string):string}
|
|
95
99
|
export class PartExplorer extends PartInspection {
|
|
100
|
+
private thumbnails=new Map<ControlPart['side'],PartThumbnailSource>();private rowParts=new Map<HTMLButtonElement,{icon:HTMLElement;label:HTMLElement}>();
|
|
96
101
|
private rows=new Map<string,HTMLButtonElement>();private rowEntries=new Map<HTMLButtonElement,PartEntry>();private detailKey='';private orderKey='';private selectionKey='';
|
|
97
102
|
private detailNodes:{title:HTMLHeadingElement;info:HTMLParagraphElement;declarations:HTMLParagraphElement;bounds:HTMLParagraphElement;unavailable:HTMLParagraphElement;feedback:HTMLButtonElement;status:HTMLParagraphElement}|null=null;
|
|
98
103
|
constructor(private host:HTMLElement,private detail:HTMLElement,private changed:(previous:ControlPart|null)=>void,private target:(part:ControlPart)=>void){
|
|
@@ -102,7 +107,7 @@ export class PartExplorer extends PartInspection {
|
|
|
102
107
|
this.whole.onclick=()=>this.action(()=>this.select(null));this.solo.onchange=()=>this.action(()=>{if(this.selected)this.select({...this.selected,mode:this.solo.checked?'solo':'context'});});this.renderList();this.renderDetail();
|
|
103
108
|
}
|
|
104
109
|
private get search(){return this.host.querySelector<HTMLInputElement>('[data-part-search]')!;}private get list(){return this.host.querySelector<HTMLElement>('[data-part-list]')!;}private get solo(){return this.host.querySelector<HTMLInputElement>('[data-part-solo]')!;}private get whole(){return this.host.querySelector<HTMLButtonElement>('[data-part-whole]')!;}
|
|
105
|
-
register(side:ControlPart['side'],entries:PartEntry[],player:PartPlayer){super.register(side,entries,player);this.renderList();}
|
|
110
|
+
register(side:ControlPart['side'],entries:PartEntry[],player:PartPlayer,thumbnails?:PartThumbnailSource){super.register(side,entries,player);if(thumbnails)this.thumbnails.set(side,thumbnails);else this.thumbnails.delete(side);this.renderList();}
|
|
106
111
|
refuseCatalog(side:ControlPart['side'],error:unknown):void {super.refuseCatalog(side,error);this.renderList();this.renderDetail();}
|
|
107
112
|
select(value:ControlPart|null):void {const previous=this.value();super.select(value);this.renderSelection();this.renderDetail();this.changed(previous);}
|
|
108
113
|
/** Runs on every drawn frame. The held copy and Solo draw are per frame; the list is rewritten only when what it shows changed. */
|
|
@@ -110,10 +115,13 @@ export class PartExplorer extends PartInspection {
|
|
|
110
115
|
/** Everything the rows and Solo control display: selection, per-side drawn slots in order, and identity. Generation is excluded. */
|
|
111
116
|
private viewKey():string {let key=JSON.stringify([this.selected,[...this.refused]]);for(const [side,state]of this.sides){key+='\u0001'+side+'\u0002'+(state.frame?state.frame.order.map(row=>row.slot).join('\u0000'):'\u0003');for(const row of state.identity?.slots??[])key+='\u0002'+row.slot+'\u0000'+row.z+'\u0000'+row.skin+'\u0000'+row.attachment+'\u0000'+row.reason;}return key;}
|
|
112
117
|
private action(run:()=>void){try{run();}catch(error){this.renderSelection(true);this.host.querySelector<HTMLElement>('[data-part-status]')!.textContent=`Part selection refused: ${error instanceof Error?error.message:String(error)}`;}}
|
|
113
|
-
private renderList(){this.rows.clear();this.rowEntries.clear();this.list.replaceChildren();const query=this.search.value;for(const [side,state]of this.sides)for(const entry of orderedParts(state.entries,state.frame,state.identity)){if(!entry.slot.includes(query))continue;const row=document.createElement('button');row.type='button';row.dataset.side=side;row.dataset.slot=entry.slot;row.title=`${entry.slot} · bone ${entry.bone}`;
|
|
118
|
+
private renderList(){this.rows.clear();this.rowEntries.clear();this.rowParts.clear();this.list.replaceChildren();const query=this.search.value;for(const [side,state]of this.sides)for(const entry of orderedParts(state.entries,state.frame,state.identity)){if(!entry.slot.includes(query))continue;const row=document.createElement('button');row.type='button';row.dataset.side=side;row.dataset.slot=entry.slot;row.title=`${entry.slot} · bone ${entry.bone}`;const icon=document.createElement('span'),label=document.createElement('span');icon.className='part-thumb';icon.setAttribute('aria-hidden','true');icon.textContent=entry.declarations.some(d=>d.kind==='mesh')?'◈':'◇';label.className='part-label';label.textContent=`${side==='single'?'':side.toUpperCase()+' · '}${entry.slot}`;row.append(icon,label);this.rowParts.set(row,{icon,label});
|
|
119
|
+
// Selecting the selected slot again (click, or Space/Enter on the focused row) returns to Whole rig.
|
|
120
|
+
row.onclick=()=>this.action(()=>{const live=this.live();this.select(live?.side===side&&live.slot===entry.slot?null:{side,slot:entry.slot,mode:this.selected?.mode??'context'});});const item=document.createElement('li');item.append(row);this.list.append(item);this.rows.set(JSON.stringify([side,entry.slot]),row);this.rowEntries.set(row,entry);}this.orderKey='';this.reorder();this.renderSelection(true);}
|
|
114
121
|
private statusText():string {const refused=[...this.refused].map(([side,r])=>`${side==='single'?'':side.toUpperCase()+' '}${r.code}`);if(refused.length)return `Parts unavailable · ${refused.join(' · ')}. The rig keeps drawing whole; selection, Solo and feedback targeting are off ${[...this.refused.values()].some(r=>r.catalog)?'until the package reloads with a matching catalog':'until a frame with a valid identity snapshot'}.`;return this.sides.size?`${[...this.sides.values()].reduce((n,s)=>n+s.entries.length,0)} declared slots · ${this.rows.size} results`:'No compiled rig catalog available';}
|
|
115
122
|
private reorder(){const keys=[...this.sides].flatMap(([side,state])=>orderedParts(state.entries,state.frame,state.identity).map(entry=>JSON.stringify([side,entry.slot]))).filter(key=>this.rows.has(key)),key=JSON.stringify(keys);if(key===this.orderKey)return;this.orderKey=key;const active=document.activeElement;for(const item of keys){const row=this.rows.get(item)!;this.list.append(row.parentElement!);}if(active instanceof HTMLElement&&this.list.contains(active)&&document.activeElement!==active)active.focus({preventScroll:true});}
|
|
116
|
-
private renderSelection(force=false){const key=this.viewKey();if(!force&&key===this.selectionKey)return;this.selectionKey=key;const status=this.host.querySelector<HTMLElement>('[data-part-status]')!;status.textContent=this.statusText();if(this.refused.size)this.host.dataset.parts='unavailable';else delete this.host.dataset.parts;const drawnBySide=new Map([...this.sides].map(([side,state])=>[side,new Set(state.frame?.order.map(row=>row.slot)??[])]));this.whole.setAttribute('aria-pressed',String(!this.selected));this.solo.disabled=!this.live();this.solo.checked=this.live()?.mode==='solo';for(const [key,row]of this.rows){row.setAttribute('aria-pressed',String(key===JSON.stringify([this.live()?.side,this.live()?.slot])));const refusal=this.refused.get(row.dataset.side as ControlPart['side']);row.disabled=!!refusal;if(refusal){const label=`${row.dataset.side==='single'?'':row.dataset.side!.toUpperCase()+' · '}${row.dataset.slot} · parts unavailable · ${refusal.code}`;if(
|
|
123
|
+
private renderSelection(force=false){const key=this.viewKey();if(!force&&key===this.selectionKey)return;this.selectionKey=key;const status=this.host.querySelector<HTMLElement>('[data-part-status]')!;status.textContent=this.statusText();if(this.refused.size)this.host.dataset.parts='unavailable';else delete this.host.dataset.parts;const drawnBySide=new Map([...this.sides].map(([side,state])=>[side,new Set(state.frame?.order.map(row=>row.slot)??[])]));this.whole.setAttribute('aria-pressed',String(!this.selected));this.solo.disabled=!this.live();this.solo.checked=this.live()?.mode==='solo';for(const [key,row]of this.rows){row.setAttribute('aria-pressed',String(key===JSON.stringify([this.live()?.side,this.live()?.slot])));const refusal=this.refused.get(row.dataset.side as ControlPart['side']);row.disabled=!!refusal;const parts=this.rowParts.get(row)!;if(refusal){const label=`${row.dataset.side==='single'?'':row.dataset.side!.toUpperCase()+' · '}${row.dataset.slot} · parts unavailable · ${refusal.code}`;if(parts.label.textContent!==label){parts.label.textContent=label;parts.icon.textContent='';delete parts.icon.dataset.thumbnailPlan;}row.title=label;row.setAttribute('aria-label',label);row.dataset.drawn='false';continue;}const state=this.sides.get(row.dataset.side as ControlPart['side']);const drawn=drawnBySide.get(row.dataset.side as ControlPart['side'])?.has(row.dataset.slot!);row.dataset.drawn=String(!!drawn);const entry=this.rowEntries.get(row),identity=entry?partIdentity(state?.identity??null,row.dataset.slot!):null;const glyph=entry?.declarations.some(d=>d.kind==='mesh')?'◈':'◇',source=this.thumbnails.get(row.dataset.side as ControlPart['side']),note=entry&&source?source.show(parts.icon,entry,identity,glyph):null;if(!note&&parts.icon.textContent!==glyph){parts.icon.textContent=glyph;delete parts.icon.dataset.thumbnailPlan;}
|
|
124
|
+
row.title=`${row.dataset.slot} · ${state?.frame?drawn?'drawn':'not drawn':'awaiting draw'}${identity?identity.reason?' · '+identity.reason:' · '+identity.skin+' / '+identity.attachment:' · awaiting identity snapshot'}${note?' · '+note:''}`;const label=`${row.dataset.side==='single'?'':row.dataset.side!.toUpperCase()+' · '}${row.dataset.slot} · ${state?.frame?drawn?'drawn':'not drawn':'awaiting draw'} · ${identity?identity.reason?'no active attachment · '+identity.reason:identity.skin+' / '+identity.attachment:'awaiting identity snapshot'}`;if(parts.label.textContent!==label)parts.label.textContent=label;row.setAttribute('aria-label',`${glyph} ${label} · ${state?.frame?drawn?'drawn':'not drawn':'awaiting draw'}`);}}
|
|
117
125
|
private renderDetail(){
|
|
118
126
|
const observation=this.observation(),refusal=this.selected?this.refused.get(this.selected.side):[...this.refused.values()][0],key=JSON.stringify([observation,refusal??null]);if(key===this.detailKey)return;this.detailKey=key;
|
|
119
127
|
// Keep focused controls connected while the selected pose's bounds animate.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Slot thumbnails cut from the actual page images by the region the compiled model declares for the drawn attachment.
|
|
2
|
+
* Nothing is inferred from the art: an attachment without a declared, unrotated page region shows its type icon and a
|
|
3
|
+
* named reason, and a whole page is never shown as a part. */
|
|
4
|
+
import type {ControlPartIdentity} from '../src/control.ts';
|
|
5
|
+
import type {PartEntry,PartThumbnailSource} from './part-selection.ts';
|
|
6
|
+
|
|
7
|
+
export interface PageRegion {page:string;name:string;x:number;y:number;width:number;height:number;degrees:number}
|
|
8
|
+
export interface PageRegionTable {pages:Map<string,{width:number;height:number}>;regions:Map<string,PageRegion[]>;absent:string|null}
|
|
9
|
+
/** The source rectangle on the page, as page UVs (the page spanning [0, 1]) and as page pixels derived from them. */
|
|
10
|
+
export interface ThumbnailCrop {kind:'crop';key:string;page:string;region:string;u0:number;v0:number;u1:number;v1:number;sx:number;sy:number;sw:number;sh:number}
|
|
11
|
+
export interface ThumbnailIcon {kind:'icon';key:string;reason:string}
|
|
12
|
+
export type ThumbnailPlan=ThumbnailCrop|ThumbnailIcon;
|
|
13
|
+
/** CSS box edge; the canvas backing store is twice that. */
|
|
14
|
+
export const thumbnailBox=24,thumbnailScale=2;
|
|
15
|
+
|
|
16
|
+
const record=(v:unknown):Record<string,unknown>|null=>v&&typeof v==='object'&&!Array.isArray(v)?v as Record<string,unknown>:null;
|
|
17
|
+
const finite=(v:unknown,min:number):v is number=>typeof v==='number'&&Number.isFinite(v)&&v>=min;
|
|
18
|
+
/** Reads `pages[].regions[]` of a `rigc-compiled/3` document. A malformed entry is dropped (its attachment shows the icon), never repaired. */
|
|
19
|
+
export function pageRegionTable(model:unknown):PageRegionTable {
|
|
20
|
+
const pages=new Map<string,{width:number;height:number}>(),regions=new Map<string,PageRegion[]>(),list=record(model)?.pages;
|
|
21
|
+
if(!Array.isArray(list))return {pages,regions,absent:'the model has no pages section'};
|
|
22
|
+
for(const raw of list.slice(0,256)){
|
|
23
|
+
const page=record(raw);if(!page||typeof page.name!=='string'||!finite(page.width,1)||!finite(page.height,1)||!Array.isArray(page.regions))continue;
|
|
24
|
+
pages.set(page.name,{width:page.width,height:page.height});
|
|
25
|
+
for(const value of page.regions.slice(0,100000)){const r=record(value);if(!r||typeof r.name!=='string'||!finite(r.x,0)||!finite(r.y,0)||!finite(r.width,1)||!finite(r.height,1))continue;const region:PageRegion={page:page.name,name:r.name,x:r.x,y:r.y,width:r.width,height:r.height,degrees:typeof r.degrees==='number'?r.degrees:0};regions.set(r.name,[...regions.get(r.name)??[],region]);}
|
|
26
|
+
}
|
|
27
|
+
return {pages,regions,absent:null};
|
|
28
|
+
}
|
|
29
|
+
/** What a row shows for the attachment the held public identity says is active. */
|
|
30
|
+
export function thumbnailPlan(table:PageRegionTable,entry:PartEntry,identity:ControlPartIdentity|null):ThumbnailPlan {
|
|
31
|
+
if(!identity)return {kind:'icon',key:'',reason:'awaiting identity snapshot'};
|
|
32
|
+
if(identity.reason!==null||identity.attachment===null||identity.skin===null)return {kind:'icon',key:'',reason:`no active attachment (${identity.reason??'none'})`};
|
|
33
|
+
const key=JSON.stringify([identity.skin,identity.attachment]),declaration=entry.declarations.find(d=>d.skin===identity.skin&&d.attachment===identity.attachment);
|
|
34
|
+
if(!declaration)return {kind:'icon',key,reason:`THUMBNAIL_UNDECLARED: ${identity.skin} / ${identity.attachment} is not in the catalog`};
|
|
35
|
+
if(declaration.kind!=='region'&&declaration.kind!=='mesh')return {kind:'icon',key,reason:`${declaration.kind} attachment draws no page image`};
|
|
36
|
+
if(table.absent)return {kind:'icon',key,reason:`THUMBNAIL_REGION_UNAVAILABLE: ${table.absent}`};
|
|
37
|
+
const name=declaration.path??declaration.attachment,found=table.regions.get(name)??[];
|
|
38
|
+
if(!found.length)return {kind:'icon',key,reason:`THUMBNAIL_NO_PAGE_REGION: the model declares no page region "${name}"`};
|
|
39
|
+
if(found.length>1)return {kind:'icon',key,reason:`THUMBNAIL_AMBIGUOUS_REGION: ${found.length} page regions are named "${name}" (sequence frames)`};
|
|
40
|
+
const region=found[0]!,page=table.pages.get(region.page)!;
|
|
41
|
+
if(region.degrees!==0)return {kind:'icon',key,reason:`THUMBNAIL_ROTATED_REGION: "${name}" is packed at ${region.degrees}°`};
|
|
42
|
+
if(region.x+region.width>page.width||region.y+region.height>page.height)return {kind:'icon',key,reason:`THUMBNAIL_REGION_OUTSIDE_PAGE: "${name}" exceeds ${region.page}`};
|
|
43
|
+
const u0=region.x/page.width,v0=region.y/page.height,u1=(region.x+region.width)/page.width,v1=(region.y+region.height)/page.height;
|
|
44
|
+
// Page pixels back from the UVs, rounded off float residue (a region edge sits on a whole or half texel).
|
|
45
|
+
const px=(value:number)=>Math.round(value*1e6)/1e6;
|
|
46
|
+
return {kind:'crop',key,page:region.page,region:name,u0,v0,u1,v1,sx:px(u0*page.width),sy:px(v0*page.height),sw:px((u1-u0)*page.width),sh:px((v1-v0)*page.height)};
|
|
47
|
+
}
|
|
48
|
+
/** Destination rectangle on the backing store: the crop fitted inside the box, aspect kept, centred. */
|
|
49
|
+
export function thumbnailFit(sw:number,sh:number,size=thumbnailBox*thumbnailScale):{dx:number;dy:number;dw:number;dh:number} {
|
|
50
|
+
const scale=Math.min(size/sw,size/sh),dw=sw*scale,dh=sh*scale;return {dx:(size-dw)/2,dy:(size-dh)/2,dw,dh};
|
|
51
|
+
}
|
|
52
|
+
type PageImage=CanvasImageSource&{complete?:boolean;naturalWidth?:number};
|
|
53
|
+
/** Lazy (IntersectionObserver) drawing, one cached bitmap per attachment key. */
|
|
54
|
+
export class PartThumbnails implements PartThumbnailSource {
|
|
55
|
+
private cache=new Map<string,HTMLCanvasElement>();private pending=new Map<Element,ThumbnailCrop>();private observer:IntersectionObserver|null=null;
|
|
56
|
+
constructor(private table:PageRegionTable,private images:ReadonlyMap<string,PageImage>){
|
|
57
|
+
if(typeof IntersectionObserver==='function')this.observer=new IntersectionObserver(items=>{for(const item of items)if(item.isIntersecting){const plan=this.pending.get(item.target);this.observer?.unobserve(item.target);this.pending.delete(item.target);if(plan)this.paint(item.target as HTMLCanvasElement,plan);}});
|
|
58
|
+
}
|
|
59
|
+
show(icon:HTMLElement,entry:PartEntry,identity:ControlPartIdentity|null,glyph:string):string {
|
|
60
|
+
const plan=thumbnailPlan(this.table,entry,identity),image=plan.kind==='crop'?this.images.get(plan.page):undefined;
|
|
61
|
+
const shown:ThumbnailPlan=plan.kind==='crop'&&!image?{kind:'icon',key:plan.key,reason:`THUMBNAIL_PAGE_UNAVAILABLE: page ${plan.page} is not loaded`}:plan;
|
|
62
|
+
const stamp=JSON.stringify(shown);
|
|
63
|
+
if(icon.dataset.thumbnailPlan!==stamp){
|
|
64
|
+
icon.dataset.thumbnailPlan=stamp;for(const child of icon.querySelectorAll('canvas')){this.observer?.unobserve(child);this.pending.delete(child);}
|
|
65
|
+
if(shown.kind==='crop'){const canvas=document.createElement('canvas');canvas.width=canvas.height=thumbnailBox*thumbnailScale;canvas.setAttribute('aria-hidden','true');icon.replaceChildren(canvas);icon.dataset.thumbnail='image';if(this.observer){this.pending.set(canvas,shown);this.observer.observe(canvas);}else this.paint(canvas,shown);}
|
|
66
|
+
else{icon.textContent=glyph;icon.dataset.thumbnail='icon';}
|
|
67
|
+
}
|
|
68
|
+
return shown.kind==='crop'?`thumbnail: region ${shown.region} on ${shown.page}`:`type icon: ${shown.reason}`;
|
|
69
|
+
}
|
|
70
|
+
private paint(canvas:HTMLCanvasElement,plan:ThumbnailCrop):void {
|
|
71
|
+
let source=this.cache.get(plan.key);
|
|
72
|
+
if(!source){const image=this.images.get(plan.page);if(!image)return;source=document.createElement('canvas');source.width=source.height=thumbnailBox*thumbnailScale;const fit=thumbnailFit(plan.sw,plan.sh);source.getContext('2d')?.drawImage(image,plan.sx,plan.sy,plan.sw,plan.sh,fit.dx,fit.dy,fit.dw,fit.dh);this.cache.set(plan.key,source);}
|
|
73
|
+
const context=canvas.getContext('2d');if(!context)return;context.clearRect(0,0,canvas.width,canvas.height);context.drawImage(source,0,0);
|
|
74
|
+
}
|
|
75
|
+
dispose():void {this.observer?.disconnect();this.pending.clear();this.cache.clear();}
|
|
76
|
+
}
|
package/web/rig-overlays.ts
CHANGED
|
@@ -14,7 +14,35 @@ export interface OverlaySnapshot {
|
|
|
14
14
|
available:boolean;needsCapture:boolean;meshEdges:number;boneCount:number;
|
|
15
15
|
capabilities:{images:boolean;mesh:boolean;bones:boolean};warnings:string[];
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
/** Designation polygons in rig world coordinates (Setup's Chest regions tool); colours are theme token values. */
|
|
18
|
+
export interface OverlayRegion {name:string;points:OverlayPoint[];stroke:string;fill:string}
|
|
19
|
+
/**
|
|
20
|
+
* A circle designation in rig world units (crop px = world px under the crop mapping): core r at full weight, dashed
|
|
21
|
+
* outer circle at r + band. With handles it is the chest editor's drawing (centre, core edge on the right, band on
|
|
22
|
+
* top, a label under the outer circle). Colours are theme token values.
|
|
23
|
+
*/
|
|
24
|
+
export interface OverlayCircle {center:OverlayPoint;r:number;band:number;label?:string;/** Label rows below the outer circle, so two labels never overlap. */labelRow?:number;handles:boolean;flash?:boolean;colors:{stroke:string;fill:string;handle:string;ink:string;labelBg:string;flash:string}}
|
|
25
|
+
/**
|
|
26
|
+
* Mesh edit preview in rig world coordinates: the selected slot's original triangles (cut out of the veil that dims the
|
|
27
|
+
* rest of the rig), the edges after the delta by kind, moved-vertex ghosts and the vertices as handles. A box is in
|
|
28
|
+
* canvas px. It is drawn over the original triangulation; nothing here retriangulates. Colours are theme token values.
|
|
29
|
+
*/
|
|
30
|
+
export interface OverlayMeshEdit {
|
|
31
|
+
triangles:readonly OverlayPoint[][];
|
|
32
|
+
edges:readonly {from:OverlayPoint;to:OverlayPoint;kind:'hull'|'interior'|'constraint'|'removed';hover?:boolean}[];
|
|
33
|
+
ghosts:readonly {from:OverlayPoint;to:OverlayPoint}[];
|
|
34
|
+
vertices:readonly {at:OverlayPoint;state:'source'|'moved'|'added'|'removed';selected:boolean;hover:boolean;pending:boolean}[];
|
|
35
|
+
box?:{from:OverlayPoint;to:OverlayPoint};
|
|
36
|
+
colors:{veil:string;edge:string;hull:string;constraint:string;removed:string;vertex:string;ink:string;selected:string;added:string;hover:string;box:string;boxFill:string};
|
|
37
|
+
}
|
|
38
|
+
export interface OverlayView {project:OverlayProject;settings:RigOverlaySettings;dpr?:number;regions?:readonly OverlayRegion[];circles?:readonly OverlayCircle[];meshEdit?:OverlayMeshEdit|null}
|
|
39
|
+
/** Canvas-pixel geometry of a circle designation, the same numbers the editor hit-tests against. */
|
|
40
|
+
export function projectCircle(project:OverlayProject,circle:Pick<OverlayCircle,'center'|'r'|'band'>):{center:OverlayPoint;pxR:number;pxBand:number}|null {
|
|
41
|
+
const {x,y}=circle.center,center=project(x,y),edge=project(x+circle.r,y),top=project(x,y+circle.r+circle.band);
|
|
42
|
+
if(![center,edge,top].every(finite))return null;
|
|
43
|
+
const pxR=Math.max(2,Math.hypot(edge.x-center.x,edge.y-center.y));
|
|
44
|
+
return {center,pxR,pxBand:Math.max(pxR+2,Math.hypot(top.x-center.x,top.y-center.y))};
|
|
45
|
+
}
|
|
18
46
|
export interface OverlayRefresh extends OverlayView {player:OverlayPlayer|null;bones:readonly BoneOverlaySpec[];frame?:PosedFrame;selectedSlot?:string|null}
|
|
19
47
|
const point=(x:number,y:number):OverlayPoint=>({x,y});
|
|
20
48
|
const finite=(p:OverlayPoint)=>Number.isFinite(p.x)&&Number.isFinite(p.y);
|
|
@@ -91,7 +119,7 @@ export class RigOverlays {
|
|
|
91
119
|
this.geometry=input.player?captureRigGeometry(input.player,input.bones,input.settings,input.frame,input.selectedSlot):null;
|
|
92
120
|
return this.redraw(input);
|
|
93
121
|
}
|
|
94
|
-
redraw({project,settings,dpr=globalThis.devicePixelRatio??1}:OverlayView):OverlaySnapshot{
|
|
122
|
+
redraw({project,settings,dpr=globalThis.devicePixelRatio??1,regions=[],circles=[],meshEdit=null}:OverlayView):OverlaySnapshot{
|
|
95
123
|
if(this.disposed)return this.unavailable('Overlay has been disposed');
|
|
96
124
|
this.host.rigRoot.style.visibility=settings.images?this.visibility:'hidden';
|
|
97
125
|
const warnings=[...(this.geometry?.warnings??[])],ctx=this.host.canvas.getContext('2d');
|
|
@@ -132,10 +160,53 @@ export class RigOverlays {
|
|
|
132
160
|
ctx.fillStyle='#ffe26b';ctx.strokeStyle='#44310d';ctx.lineWidth=1;ctx.beginPath();ctx.arc(center.x,center.y,2.8,0,Math.PI*2);ctx.fill();ctx.stroke();
|
|
133
161
|
}
|
|
134
162
|
}
|
|
135
|
-
|
|
163
|
+
// Designations are drawn over the drawn rig only; they are values the reviewer typed, not rig geometry.
|
|
164
|
+
if(this.playerAvailable)for(const region of regions){
|
|
165
|
+
const points=region.points.map(projected);if(points.length<3||points.some(p=>!p))continue;
|
|
166
|
+
ctx.globalAlpha=1;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(points[0]!.x,points[0]!.y);for(const p of points.slice(1))ctx.lineTo(p!.x,p!.y);ctx.closePath();
|
|
167
|
+
ctx.fillStyle=region.fill;ctx.fill();ctx.strokeStyle=region.stroke;ctx.lineWidth=1.6;ctx.stroke();
|
|
168
|
+
}
|
|
169
|
+
if(this.playerAvailable)for(const circle of circles){
|
|
170
|
+
const at=projectCircle(project,circle);if(!at)continue;const {center,pxR,pxBand}=at,c=circle.colors;
|
|
171
|
+
ctx.globalAlpha=.95;
|
|
172
|
+
ctx.beginPath();ctx.setLineDash([5,4]);ctx.strokeStyle=c.stroke;ctx.lineWidth=1.6;ctx.arc(center.x,center.y,pxBand,0,Math.PI*2);ctx.stroke();
|
|
173
|
+
ctx.beginPath();ctx.setLineDash([]);ctx.fillStyle=c.fill;ctx.lineWidth=2.2;ctx.arc(center.x,center.y,pxR,0,Math.PI*2);ctx.fill();ctx.stroke();
|
|
174
|
+
if(!circle.handles)continue;
|
|
175
|
+
ctx.globalAlpha=1;
|
|
176
|
+
ctx.beginPath();ctx.moveTo(center.x-7,center.y);ctx.lineTo(center.x+7,center.y);ctx.moveTo(center.x,center.y-7);ctx.lineTo(center.x,center.y+7);ctx.stroke();
|
|
177
|
+
for(const [x,y,fill,stroke] of [[center.x,center.y,c.handle,c.stroke],[center.x+pxR,center.y,c.stroke,c.ink],[center.x,center.y-pxBand,c.ink,c.stroke]] as const){ctx.beginPath();ctx.fillStyle=fill;ctx.strokeStyle=stroke;ctx.lineWidth=2;ctx.arc(x,y,4.5,0,Math.PI*2);ctx.fill();ctx.stroke();}
|
|
178
|
+
if(circle.label){
|
|
179
|
+
ctx.font='600 11px ui-monospace, monospace';const width=ctx.measureText(circle.label).width+8,lx=center.x-width/2,ly=center.y+pxBand+14+(circle.labelRow??0)*17;
|
|
180
|
+
ctx.fillStyle=circle.flash?c.flash:c.labelBg;ctx.fillRect(lx,ly-11,width,15);ctx.fillStyle=circle.flash?c.ink:c.stroke;ctx.fillText(circle.label,lx+4,ly);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
ctx.globalAlpha=1;
|
|
184
|
+
if(this.playerAvailable&&meshEdit)this.drawMeshEdit(ctx,meshEdit,projected,width,height);
|
|
185
|
+
if(this.geometry?.selection.length&&!meshEdit){ctx.globalAlpha=1;ctx.strokeStyle='#ffbb47';ctx.lineWidth=2;ctx.setLineDash([5,3]);ctx.beginPath();for(const edge of this.geometry.selection)line(edge);ctx.stroke();ctx.setLineDash([]);}
|
|
136
186
|
ctx.restore();
|
|
137
187
|
return this.snapshot(true,needsCapture,warnings);
|
|
138
188
|
}
|
|
189
|
+
private drawMeshEdit(ctx:CanvasRenderingContext2D,edit:OverlayMeshEdit,projected:(p:OverlayPoint)=>OverlayPoint|null,width:number,height:number):void{
|
|
190
|
+
const c=edit.colors;ctx.save();ctx.globalAlpha=1;ctx.setLineDash([]);
|
|
191
|
+
// The rest of the rig dims; the edited slot's own triangles are cut out of the veil so its art stays at full opacity.
|
|
192
|
+
ctx.fillStyle=c.veil;ctx.fillRect(0,0,width,height);
|
|
193
|
+
ctx.globalCompositeOperation='destination-out';ctx.fillStyle=c.ink;ctx.beginPath();
|
|
194
|
+
for(const triangle of edit.triangles){const points=triangle.map(projected);if(points.length<3||points.some(p=>!p))continue;ctx.moveTo(points[0]!.x,points[0]!.y);for(const p of points.slice(1))ctx.lineTo(p!.x,p!.y);ctx.closePath();}
|
|
195
|
+
ctx.fill();ctx.globalCompositeOperation='source-over';
|
|
196
|
+
const segment=(from:OverlayPoint,to:OverlayPoint)=>{const a=projected(from),b=projected(to);if(a&&b){ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);}};
|
|
197
|
+
const stroke=(kind:OverlayMeshEdit['edges'][number]['kind'],color:string,width:number,dash:number[]=[])=>{ctx.beginPath();ctx.setLineDash(dash);ctx.strokeStyle=color;ctx.lineWidth=width;for(const e of edit.edges)if(e.kind===kind)segment(e.from,e.to);ctx.stroke();};
|
|
198
|
+
stroke('removed',c.removed,1.4,[4,3]);stroke('interior',c.edge,1);stroke('constraint',c.constraint,2.4);stroke('hull',c.hull,2.2);
|
|
199
|
+
ctx.beginPath();ctx.setLineDash([2,3]);ctx.strokeStyle=c.ink;ctx.lineWidth=1;for(const g of edit.ghosts)segment(g.from,g.to);ctx.stroke();
|
|
200
|
+
ctx.beginPath();ctx.setLineDash([]);ctx.strokeStyle=c.hover;ctx.lineWidth=3.2;for(const e of edit.edges)if(e.hover)segment(e.from,e.to);ctx.stroke();
|
|
201
|
+
for(const v of edit.vertices){
|
|
202
|
+
const p=projected(v.at);if(!p)continue;
|
|
203
|
+
if(v.state==='removed'){ctx.beginPath();ctx.strokeStyle=c.removed;ctx.lineWidth=1.6;ctx.moveTo(p.x-4,p.y-4);ctx.lineTo(p.x+4,p.y+4);ctx.moveTo(p.x+4,p.y-4);ctx.lineTo(p.x-4,p.y+4);ctx.stroke();continue;}
|
|
204
|
+
const r=v.selected?4.6:3.4;ctx.beginPath();ctx.fillStyle=v.selected?c.selected:v.state==='added'?c.added:c.vertex;ctx.strokeStyle=v.state==='moved'?c.selected:c.ink;ctx.lineWidth=v.state==='moved'?2:1.2;ctx.arc(p.x,p.y,r,0,Math.PI*2);ctx.fill();ctx.stroke();
|
|
205
|
+
if(v.hover||v.pending){ctx.beginPath();ctx.strokeStyle=v.pending?c.constraint:c.hover;ctx.lineWidth=2;ctx.arc(p.x,p.y,7.5,0,Math.PI*2);ctx.stroke();}
|
|
206
|
+
}
|
|
207
|
+
if(edit.box){const {from,to}=edit.box,x=Math.min(from.x,to.x),y=Math.min(from.y,to.y),w=Math.abs(to.x-from.x),h=Math.abs(to.y-from.y);ctx.fillStyle=c.boxFill;ctx.fillRect(x,y,w,h);ctx.setLineDash([4,3]);ctx.strokeStyle=c.box;ctx.lineWidth=1;ctx.strokeRect(x+.5,y+.5,w,h);}
|
|
208
|
+
ctx.restore();
|
|
209
|
+
}
|
|
139
210
|
clear():void{
|
|
140
211
|
this.geometry=null;this.playerAvailable=false;
|
|
141
212
|
const ctx=this.host.canvas.getContext('2d');if(ctx){ctx.save();ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,this.host.canvas.width,this.host.canvas.height);ctx.restore();}
|