@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
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup · Rig fields (#110), page side: the bone a rigid part rides (`regions.<part>`), a lash's blink still row dragged
|
|
3
|
+
* on the stage (`motion.blink.still.<part>`), each stop's inward / outward arc dragged at its pivot (`stops[i]`), a bone's
|
|
4
|
+
* inherit mode (`bones[i].inherit`, a chain's `link_inherit`), and `turn`'s pivot and per-part depth with a live dial —
|
|
5
|
+
* play's `adjust` holding the `turn` bone's x, labelled "preview — Apply decides" and never recorded. Every edit is a
|
|
6
|
+
* pending config patch op with a per-op revert; Apply is the whole-rig runner (`rig --config`, then `check`) behind the
|
|
7
|
+
* baseline guard; Reset returns to the inspected build. The source draft keeps the pending ops (`#rig-fields-pending`);
|
|
8
|
+
* the generated controls carry no ids.
|
|
9
|
+
*/
|
|
10
|
+
import {arcBound,dragRow,readingPair,fieldsApplyEnabled,fieldsDraftEdits,fieldsDraftText,INHERIT_MODES,rigFieldOps,rigFieldsSource,seedStill,setInherit,setRegion,setStill,setStop,setTurn,stillParts,stopArcs,turnDial,TURN_DIAL_RANGE,TURN_PREVIEW_LABEL,type FieldEdit,type RigFieldsSource,type RigPoint,type StillEntry} from '../src/rig-fields.ts';
|
|
11
|
+
import type {JsonValue,PatchOp} from '../src/config-patch.ts';
|
|
12
|
+
|
|
13
|
+
export type DialAdjust=((bones:{bone(name:string):{x:number}})=>void)|null;
|
|
14
|
+
export interface PaneBox {left:number;right:number;top:number;bottom:number}
|
|
15
|
+
export interface RigFieldsDeps {
|
|
16
|
+
apply:(ops:PatchOp[])=>Promise<void>;
|
|
17
|
+
reset:()=>Promise<void>;
|
|
18
|
+
/** The angle-range reading (#108) of a bone relative to another, on the inspected and the latest scratch build. */
|
|
19
|
+
reading:(bone:string,relative:string)=>Promise<void>;
|
|
20
|
+
/** The selected part's slot, if any. */
|
|
21
|
+
part:()=>string|null;
|
|
22
|
+
/** Rig px (y down) → pane px of the stage handles' layer; null when nothing is drawn or the crop mapping is unavailable. */
|
|
23
|
+
toPane:(x:number,y:number)=>{x:number;y:number}|null;
|
|
24
|
+
/** Pane px → rig px; null as above. */
|
|
25
|
+
toRig:(x:number,y:number)=>RigPoint|null;
|
|
26
|
+
/** The drawn box of a slot in pane px, or null. */
|
|
27
|
+
slotBox:(slot:string)=>PaneBox|null;
|
|
28
|
+
/** Hand the dial to the player (`adjust` on the `turn` bone), or null to clear it. */
|
|
29
|
+
dial:(fn:DialAdjust)=>void;
|
|
30
|
+
/** Whether the `turn` bone is in the drawn model. */
|
|
31
|
+
hasTurn:()=>boolean;
|
|
32
|
+
inspector:()=>string|undefined;
|
|
33
|
+
pause:()=>void;
|
|
34
|
+
saved:()=>void;
|
|
35
|
+
}
|
|
36
|
+
export interface RigFieldsPanel {
|
|
37
|
+
load(config:unknown|null,reason:string|null):void;
|
|
38
|
+
source():RigFieldsSource|null;edits():FieldEdit[];ops():PatchOp[];
|
|
39
|
+
region(part:string,bone:string):void;
|
|
40
|
+
still(part:string,entry:StillEntry|null):void;
|
|
41
|
+
stop(index:number,bounds:{inward?:number;outward?:number}):void;
|
|
42
|
+
inherit(index:number,mode:string|null,link?:string):void;
|
|
43
|
+
turn(target:{pivot:number}|{part:string;depth:number}):void;
|
|
44
|
+
select(kind:'part'|'still'|'stop'|'bone',value:string|number|null):void;
|
|
45
|
+
/** The dial (rig px), and whether its preview is on with its label. */
|
|
46
|
+
setDial(x:number):void;dialState():{x:number;on:boolean;label:string};
|
|
47
|
+
/** The stage handles' client positions, for automation that drags with a real pointer. */
|
|
48
|
+
handles():{kind:string;x:number;y:number}[];
|
|
49
|
+
revert(path:string):void;
|
|
50
|
+
applied(ops:readonly PatchOp[]|null):void;
|
|
51
|
+
status(text:string,error:boolean):void;
|
|
52
|
+
readout(lines:{name:string;before:string;after:string}[]):void;
|
|
53
|
+
busy(value:boolean):void;
|
|
54
|
+
place():void;restore():void;clear():void;dispose():void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
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;};
|
|
58
|
+
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;};
|
|
59
|
+
const SVG='http://www.w3.org/2000/svg';
|
|
60
|
+
const svg=<K extends keyof SVGElementTagNameMap>(tag:K,attrs:Record<string,string|number>):SVGElementTagNameMap[K]=>{const e=document.createElementNS(SVG,tag);for(const [k,v] of Object.entries(attrs))e.setAttribute(k,String(v));return e;};
|
|
61
|
+
const shown=(v:JsonValue|undefined)=>v===undefined?'(absent)':typeof v==='string'?v:JSON.stringify(v);
|
|
62
|
+
/** Arc radius on the stage, pane px. */
|
|
63
|
+
const ARC_R=46;
|
|
64
|
+
const rad=(deg:number)=>deg*Math.PI/180;
|
|
65
|
+
/** A point on a circle at a Spine angle (counter-clockwise, y up) in pane px (y down). */
|
|
66
|
+
const onCircle=(c:{x:number;y:number},deg:number,r=ARC_R)=>({x:c.x+r*Math.cos(rad(deg)),y:c.y-r*Math.sin(rad(deg))});
|
|
67
|
+
/** The SVG path of an arc from Spine angle `a` to `b` (b >= a), counter-clockwise on the page. */
|
|
68
|
+
export function arcPath(c:{x:number;y:number},a:number,b:number,r=ARC_R):string {
|
|
69
|
+
const p=onCircle(c,a,r),q=onCircle(c,b,r),large=b-a>180?1:0;
|
|
70
|
+
return `M ${c.x} ${c.y} L ${p.x.toFixed(2)} ${p.y.toFixed(2)} A ${r} ${r} 0 ${large} 0 ${q.x.toFixed(2)} ${q.y.toFixed(2)} Z`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function bindRigFieldsPanel(deps:RigFieldsDeps):RigFieldsPanel {
|
|
74
|
+
const stateLine=node('rig-fields-state'),regionArea=node('rig-fields-region'),stillArea=node('rig-fields-still'),stopsArea=node('rig-fields-stops'),inheritArea=node('rig-fields-inherit'),turnArea=node('rig-fields-turn');
|
|
75
|
+
const pending=node('rig-fields-pending-list'),applyButton=node<HTMLButtonElement>('rig-fields-apply'),resetButton=node<HTMLButtonElement>('rig-fields-reset'),readingButton=node<HTMLButtonElement>('rig-fields-reading');
|
|
76
|
+
const statusOut=node('rig-fields-status'),readoutArea=node('rig-fields-readout'),draft=node<HTMLTextAreaElement>('rig-fields-pending'),stage=node('rig-fields-stage'),dialLabel=node('rig-fields-dial-label');
|
|
77
|
+
let source:RigFieldsSource|null=null,edits=new Map<string,FieldEdit>(),appliedOps:string[]=[],busy=false,dial=0;
|
|
78
|
+
let selPart:string|null=null,selStill:string|null=null,selStop:number|null=null,selBone:number|null=null;
|
|
79
|
+
let drag:{kind:'still'|'inward'|'outward';pointer:number;start:number;from:number}|null=null;
|
|
80
|
+
|
|
81
|
+
function panelStatus(text:string,error:boolean){statusOut.textContent=text;statusOut.title=text;statusOut.classList.toggle('error',error);}
|
|
82
|
+
const ops=()=>source?rigFieldOps(source,edits):[];
|
|
83
|
+
const save=()=>{draft.value=fieldsDraftText(edits);draft.dispatchEvent(new Event('input',{bubbles:true}));deps.saved();};
|
|
84
|
+
const change=(make:()=>Map<string,FieldEdit>,persist=true)=>{try{edits=make();if(persist)save();render();}catch(error){panelStatus(error instanceof Error?error.message:String(error),true);}};
|
|
85
|
+
const valueAt=(path:string,fallback:JsonValue|undefined)=>edits.has(path)?(edits.get(path)!.op==='remove'?undefined:edits.get(path)!.value):fallback;
|
|
86
|
+
const select=(options:{value:string;label:string}[],value:string,aria:string,onchange:(v:string)=>void)=>{const s=make('select',{disabled:busy});s.setAttribute('aria-label',aria);s.append(...options.map(o=>make('option',{value:o.value,textContent:o.label})));s.value=value;s.onchange=()=>onchange(s.value);return s;};
|
|
87
|
+
const number=(value:number,aria:string,step:string,commit:(v:number)=>void)=>{const n=make('input',{type:'number',step,value:String(value),disabled:busy,inputMode:'decimal'});n.setAttribute('aria-label',aria);n.onchange=()=>{const v=Number(n.value);if(!n.value.trim()||!Number.isFinite(v)){panelStatus(`RIG_FIELDS_VALUE_INVALID: ${aria} must be a number`,true);return;}commit(v);};return n;};
|
|
88
|
+
const title=(text:string,hint?:string)=>[make('p',{className:'eyebrow',textContent:text}),...(hint?[make('p',{className:'hint',textContent:hint})]:[])];
|
|
89
|
+
const boneOptions=()=>(source?.boneNames??[]).map(b=>({value:b,label:b}));
|
|
90
|
+
|
|
91
|
+
function renderRegion(){
|
|
92
|
+
if(!source?.regions){regionArea.replaceChildren(...title('REGIONS.<PART> · THE BONE A RIGID PART RIDES','The config states no regions.'));return;}
|
|
93
|
+
const parts=Object.keys(source.regions).sort(),slot=deps.part();
|
|
94
|
+
if(slot&&source.regions[slot]!==undefined&&selPart===null)selPart=slot;
|
|
95
|
+
const part=selPart&&source.regions[selPart]!==undefined?selPart:null;
|
|
96
|
+
const pick=select([{value:'',label:'Pick a rigid part (or select it on the stage)'},...parts.map(p=>({value:p,label:`${p} · ${valueAt(`/regions/${p}`,source!.regions![p])}`}))],part??'','Rigid part',v=>{selPart=v||null;render();});
|
|
97
|
+
const rows:Node[]=[pick];
|
|
98
|
+
if(part){
|
|
99
|
+
const now=String(valueAt(`/regions/${part}`,source.regions[part]));
|
|
100
|
+
rows.push(make('div',{className:'row'},make('span',{textContent:`${part} rides`}),select(boneOptions(),now,`regions.${part} bone`,v=>change(()=>setRegion(source!,edits,part,v)))));
|
|
101
|
+
if(slot&&source.regions[slot]===undefined)rows.push(make('p',{className:'hint',textContent:`${slot} is not a rigid part (its mesh rides its weights).`}));
|
|
102
|
+
}
|
|
103
|
+
regionArea.replaceChildren(...title('REGIONS.<PART> · THE BONE A RIGID PART RIDES'),...rows);
|
|
104
|
+
}
|
|
105
|
+
function renderStill(){
|
|
106
|
+
if(!source?.blink){stillArea.replaceChildren(...title('MOTION.BLINK.STILL · A LASH\'S STILL ROW','The config states no motion.blink.'));return;}
|
|
107
|
+
const parts=stillParts(source);
|
|
108
|
+
if(!parts.length){stillArea.replaceChildren(...title('MOTION.BLINK.STILL · A LASH\'S STILL ROW',`No region part rides a bone the blink's eyes name (${source.blink.eyes.join(', ')||'none'}).`));return;}
|
|
109
|
+
const stillOf=(part:string)=>valueAt(`/motion/blink/still/${part}`,source!.blink!.still?.[part] as JsonValue|undefined) as StillEntry|undefined;
|
|
110
|
+
const part=selStill&&parts.includes(selStill)?selStill:null;
|
|
111
|
+
const pick=select([{value:'',label:'Pick a part on an eye bone'},...parts.map(p=>{const e=stillOf(p);return {value:p,label:`${p} · ${e?`row ${e.row} on ${e.bone}`:'no still row'}`};})],part??'','Still row part',v=>{selStill=v||null;if(selStill)deps.pause();render();});
|
|
112
|
+
const rows:Node[]=[pick];
|
|
113
|
+
if(part){
|
|
114
|
+
const now=stillOf(part);
|
|
115
|
+
if(!now){const add=make('button',{type:'button',textContent:'Add still row',disabled:busy});add.onclick=()=>{const seed=seedStill(source!,part);if(!seed){panelStatus(`RIG_FIELDS_NO_SEED: ${part}'s eye bone states no origin`,true);return;}change(()=>setStill(source!,edits,part,seed));};rows.push(make('div',{className:'row'},make('span',{className:'hint',textContent:`${part}: no still row`}),add));}
|
|
116
|
+
else{const remove=make('button',{type:'button',textContent:'Remove',disabled:busy});remove.onclick=()=>change(()=>setStill(source!,edits,part,null));
|
|
117
|
+
rows.push(make('div',{className:'row'},make('span',{textContent:`${part} row`}),number(now.row,`${part} still row`,'1',v=>change(()=>setStill(source!,edits,part,{...now,row:v}))),make('span',{textContent:'on'}),select(boneOptions(),now.bone,`${part} still bone`,v=>change(()=>setStill(source!,edits,part,{...now,bone:v}))),remove));}
|
|
118
|
+
}
|
|
119
|
+
stillArea.replaceChildren(...title('MOTION.BLINK.STILL · A LASH\'S STILL ROW','Rows above the line (rig px, y down) are drawn on the still bone and do not blink. propose drafts one for a lash; the producer takes any region on an eye bone. Drag the row on the stage.'),...rows);
|
|
120
|
+
}
|
|
121
|
+
function renderStops(){
|
|
122
|
+
if(!source?.stops?.length){stopsArea.replaceChildren(...title('STOPS · INWARD / OUTWARD ARCS','The config states no stops (adding one is out of scope).'));return;}
|
|
123
|
+
const rows=source.stops.map(stop=>{
|
|
124
|
+
const now=valueAt(`/stops/${stop.index}`,stop.raw) as Record<string,number>;
|
|
125
|
+
const pick=make('button',{type:'button',className:'physics-row-pick',textContent:`stops[${stop.index}] ${stop.chain} against ${stop.against}`});pick.setAttribute('aria-pressed',String(selStop===stop.index));pick.onclick=()=>{selStop=selStop===stop.index?null:stop.index;deps.pause();render();};
|
|
126
|
+
return make('div',{className:'row'},pick,make('span',{textContent:'in'}),number(now.inward!,`stops[${stop.index}] inward`,'1',v=>change(()=>setStop(source!,edits,stop.index,{inward:v}))),make('span',{textContent:'out'}),number(now.outward!,`stops[${stop.index}] outward`,'1',v=>change(()=>setStop(source!,edits,stop.index,{outward:v}))));
|
|
127
|
+
});
|
|
128
|
+
stopsArea.replaceChildren(...title('STOPS · INWARD / OUTWARD ARCS','Degrees around the rest aim from the first stopped link\'s origin; pick a stop to drag its arc ends on the stage.'),...rows);
|
|
129
|
+
}
|
|
130
|
+
function renderInherit(){
|
|
131
|
+
const bones=source?.bones??[];
|
|
132
|
+
if(!bones.length){inheritArea.replaceChildren(...title('BONES[I].INHERIT','The config states no bones.'));return;}
|
|
133
|
+
const bone=bones.find(b=>b.index===selBone)??null;
|
|
134
|
+
const pick=select([{value:'',label:'Pick a bone'},...bones.map(b=>({value:String(b.index),label:`${b.name}${b.chain?' (chain)':''} · ${b.chain?Object.keys(b.linkInherit??{}).length?'link_inherit':'normal':b.inherit??'normal'}`}))],bone?String(bone.index):'','Bone for inherit',v=>{selBone=v===''?null:Number(v);render();});
|
|
135
|
+
const modes=[{value:'',label:'(absent: normal)'},...INHERIT_MODES.map(m=>({value:m,label:m}))];
|
|
136
|
+
const rows:Node[]=[pick];
|
|
137
|
+
if(bone&&!bone.chain){const now=valueAt(`/bones/${bone.index}/inherit`,bone.inherit??undefined) as string|undefined;rows.push(make('div',{className:'row'},make('span',{textContent:`${bone.name} inherit`}),select(modes,now??'',`${bone.name} inherit`,v=>change(()=>setInherit(source!,edits,bone.index,v||null)))));}
|
|
138
|
+
if(bone?.chain){const now=(valueAt(`/bones/${bone.index}/link_inherit`,bone.linkInherit??undefined) as Record<string,string>|undefined)??{};
|
|
139
|
+
rows.push(...bone.links.map(link=>make('div',{className:'row'},make('span',{textContent:`${link} inherit`}),select(modes,now[link]??'',`${link} inherit`,v=>change(()=>setInherit(source!,edits,bone.index,v||null,link))))));}
|
|
140
|
+
inheritArea.replaceChildren(...title('BONES[I].INHERIT · WHAT A BONE TAKES FROM ITS PARENT'),...rows);
|
|
141
|
+
}
|
|
142
|
+
function renderTurn(){
|
|
143
|
+
if(!source?.turn){turnArea.replaceChildren(...title('TURN · DEPTH, PIVOT AND THE DIAL','The config states no turn.'));dialLabel.hidden=true;return;}
|
|
144
|
+
const t=source.turn,parts=Object.keys(t.depths).sort(),depths=Object.values(t.depths),lo=Math.min(...depths,t.pivot),hi=Math.max(...depths,t.pivot);
|
|
145
|
+
const slider=(value:number,aria:string,commit:(v:number)=>void)=>{const r=make('input',{type:'range',min:String(Math.max(0,lo-.1)),max:String(hi+.1),step:'0.001',value:String(value),disabled:busy});r.setAttribute('aria-label',aria);r.onchange=()=>commit(Number(r.value));return r;};
|
|
146
|
+
const pivot=valueAt('/turn/pivot',t.pivot) as number;
|
|
147
|
+
const slot=deps.part();const part=selPart&&t.depths[selPart]!==undefined?selPart:slot&&t.depths[slot]!==undefined?slot:null;
|
|
148
|
+
const rows:Node[]=[make('div',{className:'physics-control'},make('span',{textContent:'pivot'}),slider(pivot,'turn.pivot',v=>change(()=>setTurn(source!,edits,{pivot:v}))),number(pivot,'turn.pivot value','any',v=>change(()=>setTurn(source!,edits,{pivot:v}))))];
|
|
149
|
+
rows.push(select([{value:'',label:'Pick a part for its depth'},...parts.map(p=>({value:p,label:`${p} · ${valueAt(`/turn/depths/${p}`,t.depths[p])}`}))],part??'','Part for turn depth',v=>{selPart=v||null;render();}));
|
|
150
|
+
if(part){const d=valueAt(`/turn/depths/${part}`,t.depths[part]) as number;rows.push(make('div',{className:'physics-control'},make('span',{textContent:`${part} depth`}),slider(d,`turn.depths.${part}`,v=>change(()=>setTurn(source!,edits,{part,depth:v}))),number(d,`turn.depths.${part} value`,'any',v=>change(()=>setTurn(source!,edits,{part,depth:v})))));}
|
|
151
|
+
const has=deps.hasTurn();
|
|
152
|
+
const dialInput=make('input',{type:'range',min:String(-TURN_DIAL_RANGE),max:String(TURN_DIAL_RANGE),step:'1',value:String(dial),disabled:!has});dialInput.setAttribute('aria-label','turn dial (rig px)');
|
|
153
|
+
dialInput.oninput=()=>panel.setDial(Number(dialInput.value));
|
|
154
|
+
const zero=make('button',{type:'button',textContent:'0',disabled:!has});zero.setAttribute('aria-label','Dial back to 0');zero.onclick=()=>panel.setDial(0);
|
|
155
|
+
rows.push(make('div',{className:'physics-control'},make('span',{textContent:'dial'}),dialInput,make('output',{textContent:`${dial} px`}),zero));
|
|
156
|
+
rows.push(make('p',{className:'hint',textContent:has?'The dial holds the drawn build\'s turn bone at x (play adjust): it shows the build on the stage, not the pending depths — Apply builds those. Nothing is recorded.':'The drawn model has no turn bone; the dial is off.'}));
|
|
157
|
+
turnArea.replaceChildren(...title('TURN · DEPTH, PIVOT AND THE DIAL','(pivot − depth) / (pivot − nearest depth) of the dial carries each part; turn.deform is out of scope.'),...rows);
|
|
158
|
+
}
|
|
159
|
+
function renderPending(){
|
|
160
|
+
const items=[...edits.values()];
|
|
161
|
+
pending.replaceChildren(...items.map(e=>{
|
|
162
|
+
const revert=make('button',{type:'button',textContent:'Revert',disabled:busy});revert.setAttribute('aria-label',`Revert ${e.path}`);revert.onclick=()=>panel.revert(e.path);
|
|
163
|
+
const applied=appliedOps.includes(JSON.stringify({path:e.path,value:e.value??null}));
|
|
164
|
+
return make('li',{className:applied?'applied':''},make('code',{textContent:`${e.op} ${e.path}`}),` ${shown(e.from)} → ${e.op==='remove'?'(removed)':shown(e.value)}${e.name?` · ${e.name}`:''}${applied?' · applied':''}`,revert);
|
|
165
|
+
}));
|
|
166
|
+
if(!items.length)pending.append(make('li',{className:'hint',textContent:'No pending ops.'}));
|
|
167
|
+
applyButton.disabled=!fieldsApplyEnabled({busy,pending:items.length});resetButton.disabled=busy;
|
|
168
|
+
const pair=pairNow();readingButton.disabled=busy||!pair;readingButton.title=pair?`angle-range ${pair.bone}:${pair.relative}`:'Pick a bone with a parent (inherit) or a rigid part';
|
|
169
|
+
readingButton.textContent=pair?`Angle range ${pair.bone}:${pair.relative}`:'Angle range';
|
|
170
|
+
}
|
|
171
|
+
const pairNow=()=>source?readingPair(source,edits,{bone:selBone,part:selPart}):null;
|
|
172
|
+
function renderLabel(){
|
|
173
|
+
const on=dial!==0&&deps.hasTurn();dialLabel.hidden=!on;dialLabel.textContent=on?`${TURN_PREVIEW_LABEL} · turn x ${dial} px`:TURN_PREVIEW_LABEL;dialLabel.title=dialLabel.textContent;
|
|
174
|
+
}
|
|
175
|
+
function render(){renderRegion();renderStill();renderStops();renderInherit();renderTurn();renderPending();renderLabel();place();}
|
|
176
|
+
|
|
177
|
+
// ---- stage: the still row and the stop arcs ----
|
|
178
|
+
const svgLayer=svg('svg',{class:'rig-fields-svg','aria-hidden':'true'});
|
|
179
|
+
function stillGeometry(){
|
|
180
|
+
if(!source||!selStill)return null;const now=valueAt(`/motion/blink/still/${selStill}`,source.blink?.still?.[selStill] as JsonValue|undefined) as StillEntry|undefined;if(!now)return null;
|
|
181
|
+
const eye=source.bones.find(b=>!b.chain&&b.name===source!.regions?.[selStill!]),box=deps.slotBox(selStill),cx=eye?.at?.[0]??0;
|
|
182
|
+
const a=deps.toPane(cx,now.row);if(!a)return null;
|
|
183
|
+
const left=box?box.left-8:a.x-50,right=box?box.right+8:a.x+50;
|
|
184
|
+
return {row:now.row,y:a.y,left,right};
|
|
185
|
+
}
|
|
186
|
+
function stopGeometry(){
|
|
187
|
+
if(!source?.stops||selStop===null)return null;const stop=source.stops.find(s=>s.index===selStop);if(!stop)return null;
|
|
188
|
+
const now=valueAt(`/stops/${stop.index}`,stop.raw) as Record<string,number>,arcs=stopArcs(source,stop,{inward:now.inward!,outward:now.outward!});
|
|
189
|
+
if('reason' in arcs)return {reason:arcs.reason};const c=deps.toPane(...arcs.pivot);if(!c)return null;
|
|
190
|
+
return {stop,arcs,c};
|
|
191
|
+
}
|
|
192
|
+
function place(){
|
|
193
|
+
const visible=deps.inspector()==='rig'&&!!source;const still=visible?stillGeometry():null,stop=visible?stopGeometry():null;
|
|
194
|
+
if(!still&&(!stop||'reason' in stop)){stage.hidden=true;return;}
|
|
195
|
+
stage.hidden=false;svgLayer.replaceChildren();
|
|
196
|
+
const handles:HTMLButtonElement[]=[];
|
|
197
|
+
const handle=(kind:'still'|'inward'|'outward',at:{x:number;y:number},label:string)=>{const h=make('button',{type:'button',className:`rig-fields-handle ${kind}`});h.dataset.kind=kind;h.setAttribute('aria-label',label);h.style.left=`${at.x}px`;h.style.top=`${at.y}px`;h.append(make('span',{textContent:label}));h.onpointerdown=event=>down(event,kind);handles.push(h);};
|
|
198
|
+
if(still){svgLayer.append(svg('line',{class:'rig-fields-still-line',x1:still.left,y1:still.y,x2:still.right,y2:still.y}));handle('still',{x:still.right,y:still.y},`${selStill} still row ${still.row}`);}
|
|
199
|
+
if(stop&&!('reason' in stop)){
|
|
200
|
+
const {arcs,c}=stop;
|
|
201
|
+
svgLayer.append(svg('path',{class:'rig-fields-arc inward',d:arcPath(c,arcs.from,arcs.rest)}),svg('path',{class:'rig-fields-arc outward',d:arcPath(c,arcs.rest,arcs.to)}));
|
|
202
|
+
const r=onCircle(c,arcs.rest,ARC_R+14);svgLayer.append(svg('line',{class:'rig-fields-rest',x1:c.x,y1:c.y,x2:r.x,y2:r.y}));
|
|
203
|
+
handle('inward',onCircle(c,arcs.from),`inward ${Number((arcs.rest-arcs.from).toFixed(3))}°`);handle('outward',onCircle(c,arcs.to),`outward ${Number((arcs.to-arcs.rest).toFixed(3))}°`);
|
|
204
|
+
}
|
|
205
|
+
stage.replaceChildren(svgLayer,...handles);
|
|
206
|
+
}
|
|
207
|
+
const angleAt=(event:PointerEvent,c:{x:number;y:number})=>{const rect=stage.getBoundingClientRect();return Math.atan2(-(event.clientY-rect.top-c.y),event.clientX-rect.left-c.x)*180/Math.PI;};
|
|
208
|
+
function down(event:PointerEvent,kind:'still'|'inward'|'outward'){
|
|
209
|
+
if(busy||!source)return;event.preventDefault();event.stopPropagation();deps.pause();
|
|
210
|
+
const rect=stage.getBoundingClientRect();
|
|
211
|
+
if(kind==='still'){const g=stillGeometry();const rig=deps.toRig(event.clientX-rect.left,event.clientY-rect.top);if(!g||!rig)return;drag={kind,pointer:event.pointerId,start:rig[1],from:g.row};}
|
|
212
|
+
else drag={kind,pointer:event.pointerId,start:0,from:0};
|
|
213
|
+
// The handles are redrawn while dragging, so the drag follows the window, not the handle.
|
|
214
|
+
window.addEventListener('pointermove',moveDrag,true);window.addEventListener('pointerup',endDrag,true);window.addEventListener('pointercancel',endDrag,true);
|
|
215
|
+
}
|
|
216
|
+
function dragValue(event:PointerEvent):(()=>Map<string,FieldEdit>)|null {
|
|
217
|
+
if(!drag||!source||event.pointerId!==drag.pointer)return null;
|
|
218
|
+
const rect=stage.getBoundingClientRect();
|
|
219
|
+
if(drag.kind==='still'){const rig=deps.toRig(event.clientX-rect.left,event.clientY-rect.top),part=selStill!,now=valueAt(`/motion/blink/still/${part}`,source.blink?.still?.[part] as JsonValue|undefined) as StillEntry|undefined;if(!rig||!now)return null;const row=dragRow(drag.from,drag.start,rig[1]);return ()=>setStill(source!,edits,part,{...now,row});}
|
|
220
|
+
const g=stopGeometry();if(!g||'reason' in g)return null;const angle=angleAt(event,g.c),v=arcBound(g.arcs.rest,drag.kind,angle),key=drag.kind;
|
|
221
|
+
return ()=>setStop(source!,edits,g.stop.index,{[key]:v});
|
|
222
|
+
}
|
|
223
|
+
function moveDrag(event:PointerEvent){const next=dragValue(event);if(next){event.stopPropagation();change(next,false);}}
|
|
224
|
+
function endDrag(event:PointerEvent){if(!drag||event.pointerId!==drag.pointer)return;const next=dragValue(event);drag=null;unlisten();if(next)change(next);else save();}
|
|
225
|
+
const unlisten=()=>{window.removeEventListener('pointermove',moveDrag,true);window.removeEventListener('pointerup',endDrag,true);window.removeEventListener('pointercancel',endDrag,true);};
|
|
226
|
+
|
|
227
|
+
applyButton.onclick=()=>{if(!edits.size||busy)return;panel.setDial(0);void deps.apply(ops());};
|
|
228
|
+
resetButton.onclick=()=>{if(busy)return;void deps.reset();};
|
|
229
|
+
readingButton.onclick=()=>{const pair=pairNow();if(pair&&!busy)void deps.reading(pair.bone,pair.relative);};
|
|
230
|
+
const observer=new MutationObserver(()=>place());
|
|
231
|
+
for(const selector of document.querySelectorAll('[data-inspector]'))observer.observe(selector,{attributes:true,attributeFilter:['aria-pressed','aria-selected']});
|
|
232
|
+
|
|
233
|
+
const panel:RigFieldsPanel={
|
|
234
|
+
load(config,reason){
|
|
235
|
+
if(config===null){source=null;edits=new Map();stateLine.textContent=reason??'RIG_FIELDS_UNAVAILABLE: no producer handoff';stateLine.classList.add('error');render();return;}
|
|
236
|
+
source=rigFieldsSource(config);edits=fieldsDraftEdits(draft.value,source);
|
|
237
|
+
const s=source,count=(n:number|undefined,what:string)=>`${n??0} ${what}`;
|
|
238
|
+
stateLine.classList.toggle('error',!!s.skipped.length);
|
|
239
|
+
stateLine.textContent=[count(s.regions?Object.keys(s.regions).length:0,'regions'),s.blink?`blink (${s.blink.still?Object.keys(s.blink.still).length:0} still rows)`:'no blink',count(s.stops?.length,'stops'),`${s.bones.filter(b=>b.inherit||b.linkInherit).length} of ${s.bones.length} bones state inherit`,s.turn?`turn (${Object.keys(s.turn.depths).length} depths)`:'no turn',...(s.skipped.length?[`not read: ${s.skipped.join('; ')}`]:[])].join(' · ');
|
|
240
|
+
render();
|
|
241
|
+
},
|
|
242
|
+
source:()=>source?structuredClone(source):null,
|
|
243
|
+
edits:()=>[...edits.values()].map(e=>structuredClone(e)),ops,
|
|
244
|
+
region(part,bone){if(source)change(()=>setRegion(source!,edits,part,bone));},
|
|
245
|
+
still(part,entry){if(source)change(()=>setStill(source!,edits,part,entry));},
|
|
246
|
+
stop(index,bounds){if(source)change(()=>setStop(source!,edits,index,bounds));},
|
|
247
|
+
inherit(index,mode,link){if(source)change(()=>setInherit(source!,edits,index,mode,link));},
|
|
248
|
+
turn(target){if(source)change(()=>setTurn(source!,edits,target));},
|
|
249
|
+
select(kind,value){if(kind==='part')selPart=value===null?null:String(value);else if(kind==='still')selStill=value===null?null:String(value);else if(kind==='stop')selStop=value===null?null:Number(value);else selBone=value===null?null:Number(value);render();},
|
|
250
|
+
setDial(x){
|
|
251
|
+
const v=Math.max(-TURN_DIAL_RANGE,Math.min(TURN_DIAL_RANGE,Math.round(Number.isFinite(x)?x:0)));dial=deps.hasTurn()?v:0;
|
|
252
|
+
deps.dial(dial!==0?turnDial(dial):null);renderLabel();
|
|
253
|
+
const out=turnArea.querySelector('output');if(out)out.textContent=`${dial} px`;const r=turnArea.querySelector<HTMLInputElement>('input[aria-label="turn dial (rig px)"]');if(r&&Number(r.value)!==dial)r.value=String(dial);
|
|
254
|
+
},
|
|
255
|
+
dialState:()=>({x:dial,on:!dialLabel.hidden,label:dialLabel.textContent??''}),
|
|
256
|
+
handles(){const rect=stage.getBoundingClientRect();return stage.hidden?[]:[...stage.querySelectorAll<HTMLElement>('.rig-fields-handle')].map(h=>({kind:h.dataset.kind!,x:rect.left+parseFloat(h.style.left),y:rect.top+parseFloat(h.style.top)}));},
|
|
257
|
+
revert(path){const next=new Map(edits);next.delete(path);change(()=>next);},
|
|
258
|
+
applied(list){appliedOps=(list??[]).map(o=>JSON.stringify({path:o.path,value:o.value??null}));renderPending();},
|
|
259
|
+
status:panelStatus,
|
|
260
|
+
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}))]));},
|
|
261
|
+
busy(value){busy=value;render();},
|
|
262
|
+
place,
|
|
263
|
+
restore(){if(source){edits=fieldsDraftEdits(draft.value,source);render();}},
|
|
264
|
+
clear(){edits=new Map();save();render();},
|
|
265
|
+
dispose(){observer.disconnect();unlisten();panel.setDial(0);stage.replaceChildren();},
|
|
266
|
+
};
|
|
267
|
+
render();
|
|
268
|
+
return panel;
|
|
269
|
+
}
|
package/web/rig-overlays.ts
CHANGED
|
@@ -9,13 +9,18 @@ export interface RigOverlaySettings {images:boolean;mesh:boolean;bones:boolean;m
|
|
|
9
9
|
export interface OverlayPlayer extends Pick<RigPlayer,'posedFrame'|'bone'> {}
|
|
10
10
|
export interface OverlayLine {from:OverlayPoint;to:OverlayPoint;slot?:string}
|
|
11
11
|
export interface OverlayBone {name:string;point:OverlayPoint;xAxis:OverlayPoint;yAxis:OverlayPoint}
|
|
12
|
-
|
|
12
|
+
/** `meshTriangles`: per slot, the triangles the mesh layer draws from the captured pose (#106 bar 1 reads it). */
|
|
13
|
+
export interface RigOverlayGeometry {selection:OverlayLine[];mesh:OverlayLine[];meshTriangles:Record<string,number>;bones:OverlayLine[];joints:OverlayBone[];warnings:string[];captured:{mesh:boolean;bones:boolean}}
|
|
13
14
|
export interface OverlaySnapshot {
|
|
14
|
-
available:boolean;needsCapture:boolean;meshEdges:number;boneCount:number;
|
|
15
|
+
available:boolean;needsCapture:boolean;meshEdges:number;meshTriangles:Record<string,number>;boneCount:number;
|
|
15
16
|
capabilities:{images:boolean;mesh:boolean;bones:boolean};warnings:string[];
|
|
16
17
|
}
|
|
17
18
|
/** Designation polygons in rig world coordinates (Setup's Chest regions tool); colours are theme token values. */
|
|
18
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `handles` (#106, the Mesh panel's polygon regions): a square on every vertex, the `active` one filled with `handle`,
|
|
21
|
+
* and the name as a label; `open` draws the outline unclosed (a polygon still being placed).
|
|
22
|
+
*/
|
|
23
|
+
export interface OverlayRegion {name:string;points:OverlayPoint[];stroke:string;fill:string;handles?:boolean;active?:number|null;open?:boolean;colors?:{handle:string;ink:string;labelBg:string}}
|
|
19
24
|
/**
|
|
20
25
|
* A circle designation in rig world units (crop px = world px under the crop mapping): core r at full weight, dashed
|
|
21
26
|
* outer circle at r + band. With handles it is the chest editor's drawing (centre, core edge on the right, band on
|
|
@@ -35,7 +40,34 @@ export interface OverlayMeshEdit {
|
|
|
35
40
|
box?:{from:OverlayPoint;to:OverlayPoint};
|
|
36
41
|
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
42
|
}
|
|
38
|
-
|
|
43
|
+
/**
|
|
44
|
+
* A chest landmark (#105) in rig world units: a diamond with its tag, and a guide from the cleavage to each apex (the
|
|
45
|
+
* distance the producer derives a radius from). Colours are theme token values.
|
|
46
|
+
*/
|
|
47
|
+
export interface OverlayMarker {at:OverlayPoint;tag:string;active:boolean;colors:{stroke:string;fill:string;ink:string;labelBg:string};guideTo?:OverlayPoint[]}
|
|
48
|
+
/**
|
|
49
|
+
* Setup weights (#107) in rig world coordinates: the selected slot's setup-pose mesh, each triangle filled with the mean
|
|
50
|
+
* of its vertices' ramp colours and each vertex a dot in its own colour, over a veil that dims the rest of the rig.
|
|
51
|
+
* `value` is the weight the colour was taken from (stored, never normalised); `hover` indexes `vertices` and draws its
|
|
52
|
+
* label. Colours are theme token values.
|
|
53
|
+
*/
|
|
54
|
+
export interface OverlayWeights {
|
|
55
|
+
triangles:readonly {points:readonly [number,number,number];color:string}[];
|
|
56
|
+
vertices:readonly {at:OverlayPoint;index:number;value:number;color:string;label:string}[];
|
|
57
|
+
hover:number|null;
|
|
58
|
+
colors:{veil:string;edge:string;ink:string;labelBg:string;hover:string};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Setup bones (#109) in rig world coordinates: every config anchor as a handle — an origin a square, a chain point a
|
|
62
|
+
* circle, a tip a diamond — joined by its chain or bone-to-tip line; a pending anchor draws a dashed ghost from the
|
|
63
|
+
* config's value; the hovered or selected anchor is ringed and labelled. Colours are theme token values.
|
|
64
|
+
*/
|
|
65
|
+
export interface OverlayAnchors {
|
|
66
|
+
lines:readonly {from:OverlayPoint;to:OverlayPoint}[];
|
|
67
|
+
anchors:readonly {at:OverlayPoint;from:OverlayPoint|null;kind:'at'|'point'|'tip';label:string;selected:boolean;hover:boolean}[];
|
|
68
|
+
colors:{line:string;origin:string;point:string;tip:string;pending:string;selected:string;ink:string;labelBg:string};
|
|
69
|
+
}
|
|
70
|
+
export interface OverlayView {project:OverlayProject;settings:RigOverlaySettings;dpr?:number;regions?:readonly OverlayRegion[];circles?:readonly OverlayCircle[];markers?:readonly OverlayMarker[];meshEdit?:OverlayMeshEdit|null;weights?:OverlayWeights|null;anchors?:OverlayAnchors|null}
|
|
39
71
|
/** Canvas-pixel geometry of a circle designation, the same numbers the editor hit-tests against. */
|
|
40
72
|
export function projectCircle(project:OverlayProject,circle:Pick<OverlayCircle,'center'|'r'|'band'>):{center:OverlayPoint;pxR:number;pxBand:number}|null {
|
|
41
73
|
const {x,y}=circle.center,center=project(x,y),edge=project(x+circle.r,y),top=project(x,y+circle.r+circle.band);
|
|
@@ -48,7 +80,7 @@ const point=(x:number,y:number):OverlayPoint=>({x,y});
|
|
|
48
80
|
const finite=(p:OverlayPoint)=>Number.isFinite(p.x)&&Number.isFinite(p.y);
|
|
49
81
|
const message=(error:unknown)=>error instanceof Error?error.message:String(error);
|
|
50
82
|
|
|
51
|
-
function meshLines(frame:PosedFrame,warnings:string[]):OverlayLine[]{
|
|
83
|
+
function meshLines(frame:PosedFrame,warnings:string[],triangles:Record<string,number>={}):OverlayLine[]{
|
|
52
84
|
const lines:OverlayLine[]=[];
|
|
53
85
|
for(const slot of frame.order){
|
|
54
86
|
if(slot.kind!=='mesh')continue;
|
|
@@ -59,6 +91,7 @@ function meshLines(frame:PosedFrame,warnings:string[]):OverlayLine[]{
|
|
|
59
91
|
if(ids.some(index=>!Number.isSafeInteger(index)||index<0||index*2+1>=slot.vertices.length)){warnings.push(`Mesh overlay ${slot.slot}: triangle index outside posed vertices`);continue;}
|
|
60
92
|
const points=ids.map(index=>point(slot.vertices[index*2]!,slot.vertices[index*2+1]!));
|
|
61
93
|
if(points.some(p=>!finite(p))){warnings.push(`Mesh overlay ${slot.slot}: nonfinite posed vertex`);continue;}
|
|
94
|
+
triangles[slot.slot]=(triangles[slot.slot]??0)+1;
|
|
62
95
|
for(let edge=0;edge<3;edge++){
|
|
63
96
|
const a=ids[edge]!,b=ids[(edge+1)%3]!,key=a<b?`${a}:${b}`:`${b}:${a}`;
|
|
64
97
|
if(edges.has(key))continue;edges.add(key);
|
|
@@ -71,10 +104,10 @@ function meshLines(frame:PosedFrame,warnings:string[]):OverlayLine[]{
|
|
|
71
104
|
|
|
72
105
|
/** Takes copies; later seeks cannot turn an old overlay into a prepared pose. */
|
|
73
106
|
export function captureRigGeometry(player:OverlayPlayer,specs:readonly BoneOverlaySpec[],layers:Pick<RigOverlaySettings,'mesh'|'bones'>,frame?:PosedFrame,selectedSlot?:string|null):RigOverlayGeometry{
|
|
74
|
-
const geometry:RigOverlayGeometry={selection:[],mesh:[],bones:[],joints:[],warnings:[],captured:{mesh:false,bones:false}};
|
|
107
|
+
const geometry:RigOverlayGeometry={selection:[],mesh:[],meshTriangles:{},bones:[],joints:[],warnings:[],captured:{mesh:false,bones:false}};
|
|
75
108
|
if(selectedSlot){try{const bounds=partBounds(frame??player.posedFrame(null),selectedSlot);if(bounds){const {x,y,width,height}=bounds,points=[point(x,y),point(x+width,y),point(x+width,y+height),point(x,y+height)];geometry.selection=points.map((from,i)=>({from,to:points[(i+1)%4]!,slot:selectedSlot}));}}catch(error){geometry.warnings.push(`Selection bounds unavailable: ${message(error)}`);}}
|
|
76
109
|
if(layers.mesh){
|
|
77
|
-
try{geometry.mesh=meshLines(frame??player.posedFrame(),geometry.warnings);geometry.captured.mesh=true;}
|
|
110
|
+
try{geometry.mesh=meshLines(frame??player.posedFrame(),geometry.warnings,geometry.meshTriangles);geometry.captured.mesh=true;}
|
|
78
111
|
catch(error){geometry.warnings.push(`Mesh overlay unavailable: ${message(error)}`);}
|
|
79
112
|
}
|
|
80
113
|
if(layers.bones){
|
|
@@ -119,7 +152,7 @@ export class RigOverlays {
|
|
|
119
152
|
this.geometry=input.player?captureRigGeometry(input.player,input.bones,input.settings,input.frame,input.selectedSlot):null;
|
|
120
153
|
return this.redraw(input);
|
|
121
154
|
}
|
|
122
|
-
redraw({project,settings,dpr=globalThis.devicePixelRatio??1,regions=[],circles=[],meshEdit=null}:OverlayView):OverlaySnapshot{
|
|
155
|
+
redraw({project,settings,dpr=globalThis.devicePixelRatio??1,regions=[],circles=[],markers=[],meshEdit=null,weights=null,anchors=null}:OverlayView):OverlaySnapshot{
|
|
123
156
|
if(this.disposed)return this.unavailable('Overlay has been disposed');
|
|
124
157
|
this.host.rigRoot.style.visibility=settings.images?this.visibility:'hidden';
|
|
125
158
|
const warnings=[...(this.geometry?.warnings??[])],ctx=this.host.canvas.getContext('2d');
|
|
@@ -162,9 +195,16 @@ export class RigOverlays {
|
|
|
162
195
|
}
|
|
163
196
|
// Designations are drawn over the drawn rig only; they are values the reviewer typed, not rig geometry.
|
|
164
197
|
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);
|
|
167
|
-
|
|
198
|
+
const points=region.points.map(projected);if(points.length<(region.open?1:3)||points.some(p=>!p))continue;
|
|
199
|
+
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);
|
|
200
|
+
if(!region.open){ctx.closePath();ctx.fillStyle=region.fill;ctx.fill();}
|
|
201
|
+
ctx.strokeStyle=region.stroke;ctx.lineWidth=1.6;ctx.stroke();
|
|
202
|
+
if(region.handles&®ion.colors){
|
|
203
|
+
const c=region.colors;
|
|
204
|
+
points.forEach((p,i)=>{const size=region.active===i?5:3.5;ctx.fillStyle=region.active===i?c.handle:c.ink;ctx.strokeStyle=region.stroke;ctx.lineWidth=1.5;ctx.fillRect(p!.x-size,p!.y-size,size*2,size*2);ctx.strokeRect(p!.x-size,p!.y-size,size*2,size*2);});
|
|
205
|
+
const top=points.reduce((a,p)=>p!.y<a!.y?p:a,points[0]);
|
|
206
|
+
ctx.font='600 11px ui-monospace, monospace';const width=ctx.measureText(region.name).width+8;ctx.fillStyle=c.labelBg;ctx.fillRect(top!.x-width/2,top!.y-24,width,15);ctx.fillStyle=region.stroke;ctx.fillText(region.name,top!.x-width/2+4,top!.y-13);
|
|
207
|
+
}
|
|
168
208
|
}
|
|
169
209
|
if(this.playerAvailable)for(const circle of circles){
|
|
170
210
|
const at=projectCircle(project,circle);if(!at)continue;const {center,pxR,pxBand}=at,c=circle.colors;
|
|
@@ -180,9 +220,21 @@ export class RigOverlays {
|
|
|
180
220
|
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
221
|
}
|
|
182
222
|
}
|
|
223
|
+
if(this.playerAvailable)for(const marker of markers){
|
|
224
|
+
const at=projected(marker.at);if(!at)continue;const c=marker.colors;
|
|
225
|
+
ctx.globalAlpha=.9;ctx.setLineDash([3,3]);ctx.strokeStyle=c.stroke;ctx.lineWidth=1.2;
|
|
226
|
+
for(const to of marker.guideTo??[]){const end=projected(to);if(!end)continue;ctx.beginPath();ctx.moveTo(at.x,at.y);ctx.lineTo(end.x,end.y);ctx.stroke();}
|
|
227
|
+
ctx.setLineDash([]);ctx.globalAlpha=1;const size=marker.active?7:5.5;
|
|
228
|
+
ctx.beginPath();ctx.moveTo(at.x,at.y-size);ctx.lineTo(at.x+size,at.y);ctx.lineTo(at.x,at.y+size);ctx.lineTo(at.x-size,at.y);ctx.closePath();
|
|
229
|
+
ctx.fillStyle=c.fill;ctx.fill();ctx.strokeStyle=c.ink;ctx.lineWidth=marker.active?2.4:1.6;ctx.stroke();
|
|
230
|
+
ctx.font='600 11px ui-monospace, monospace';const width=ctx.measureText(marker.tag).width+8,lx=at.x+size+4,ly=at.y-size-2;
|
|
231
|
+
ctx.fillStyle=c.labelBg;ctx.fillRect(lx,ly-11,width,15);ctx.fillStyle=c.stroke;ctx.fillText(marker.tag,lx+4,ly);
|
|
232
|
+
}
|
|
183
233
|
ctx.globalAlpha=1;
|
|
184
234
|
if(this.playerAvailable&&meshEdit)this.drawMeshEdit(ctx,meshEdit,projected,width,height);
|
|
185
|
-
if(this.
|
|
235
|
+
else if(this.playerAvailable&&weights)this.drawWeights(ctx,weights,projected,width,height);
|
|
236
|
+
if(this.playerAvailable&&anchors)this.drawAnchors(ctx,anchors,projected);
|
|
237
|
+
if(this.geometry?.selection.length&&!meshEdit&&!weights&&!anchors){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([]);}
|
|
186
238
|
ctx.restore();
|
|
187
239
|
return this.snapshot(true,needsCapture,warnings);
|
|
188
240
|
}
|
|
@@ -207,6 +259,48 @@ export class RigOverlays {
|
|
|
207
259
|
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
260
|
ctx.restore();
|
|
209
261
|
}
|
|
262
|
+
private drawWeights(ctx:CanvasRenderingContext2D,w:OverlayWeights,projected:(p:OverlayPoint)=>OverlayPoint|null,width:number,height:number):void{
|
|
263
|
+
const c=w.colors,points=w.vertices.map(v=>projected(v.at));ctx.save();ctx.globalAlpha=1;ctx.setLineDash([]);
|
|
264
|
+
ctx.fillStyle=c.veil;ctx.fillRect(0,0,width,height);
|
|
265
|
+
for(const t of w.triangles){
|
|
266
|
+
const [a,b,d]=t.points.map(i=>points[i]);if(!a||!b||!d)continue;
|
|
267
|
+
ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.lineTo(d.x,d.y);ctx.closePath();ctx.globalAlpha=.72;ctx.fillStyle=t.color;ctx.fill();ctx.globalAlpha=.5;ctx.strokeStyle=c.edge;ctx.lineWidth=.8;ctx.stroke();
|
|
268
|
+
}
|
|
269
|
+
ctx.globalAlpha=1;
|
|
270
|
+
w.vertices.forEach((v,i)=>{const p=points[i];if(!p)return;ctx.beginPath();ctx.fillStyle=v.color;ctx.strokeStyle=c.ink;ctx.lineWidth=1.1;ctx.arc(p.x,p.y,3.4,0,Math.PI*2);ctx.fill();ctx.stroke();});
|
|
271
|
+
const h=w.hover===null?null:w.vertices[w.hover],hp=w.hover===null?null:points[w.hover];
|
|
272
|
+
if(h&&hp){
|
|
273
|
+
ctx.beginPath();ctx.strokeStyle=c.hover;ctx.lineWidth=2;ctx.arc(hp.x,hp.y,7.5,0,Math.PI*2);ctx.stroke();
|
|
274
|
+
ctx.font='600 11px ui-monospace, monospace';const tw=ctx.measureText(h.label).width+10,lx=Math.min(Math.max(2,hp.x+11),width-tw-2),ly=Math.max(14,hp.y-11);
|
|
275
|
+
ctx.fillStyle=c.labelBg;ctx.fillRect(lx,ly-12,tw,17);ctx.fillStyle=c.hover;ctx.fillText(h.label,lx+5,ly);
|
|
276
|
+
}
|
|
277
|
+
ctx.restore();
|
|
278
|
+
}
|
|
279
|
+
private drawAnchors(ctx:CanvasRenderingContext2D,b:OverlayAnchors,projected:(p:OverlayPoint)=>OverlayPoint|null):void{
|
|
280
|
+
const c=b.colors;ctx.save();ctx.globalAlpha=1;
|
|
281
|
+
ctx.setLineDash([]);ctx.lineWidth=1.6;ctx.strokeStyle=c.line;ctx.beginPath();
|
|
282
|
+
for(const l of b.lines){const from=projected(l.from),to=projected(l.to);if(from&&to){ctx.moveTo(from.x,from.y);ctx.lineTo(to.x,to.y);}}
|
|
283
|
+
ctx.stroke();
|
|
284
|
+
ctx.setLineDash([3,3]);ctx.strokeStyle=c.pending;ctx.lineWidth=1.2;ctx.beginPath();
|
|
285
|
+
for(const a of b.anchors){if(!a.from)continue;const from=projected(a.from),to=projected(a.at);if(from&&to){ctx.moveTo(from.x,from.y);ctx.lineTo(to.x,to.y);}}
|
|
286
|
+
ctx.stroke();ctx.setLineDash([]);
|
|
287
|
+
const labels:{at:OverlayPoint;text:string;color:string}[]=[];
|
|
288
|
+
for(const a of b.anchors){
|
|
289
|
+
const p=projected(a.at);if(!p)continue;
|
|
290
|
+
if(a.from){const g=projected(a.from);if(g){ctx.beginPath();ctx.strokeStyle=c.pending;ctx.lineWidth=1;ctx.arc(g.x,g.y,2.5,0,Math.PI*2);ctx.stroke();}}
|
|
291
|
+
const fill=a.from?c.pending:a.kind==='at'?c.origin:a.kind==='point'?c.point:c.tip,size=a.selected||a.hover?6:4.5;
|
|
292
|
+
ctx.beginPath();
|
|
293
|
+
if(a.kind==='at')ctx.rect(p.x-size,p.y-size,size*2,size*2);
|
|
294
|
+
else if(a.kind==='point')ctx.arc(p.x,p.y,size,0,Math.PI*2);
|
|
295
|
+
else{ctx.moveTo(p.x,p.y-size-1);ctx.lineTo(p.x+size+1,p.y);ctx.lineTo(p.x,p.y+size+1);ctx.lineTo(p.x-size-1,p.y);ctx.closePath();}
|
|
296
|
+
ctx.fillStyle=fill;ctx.fill();ctx.strokeStyle=c.ink;ctx.lineWidth=1.3;ctx.stroke();
|
|
297
|
+
if(a.selected||a.hover){ctx.beginPath();ctx.strokeStyle=c.selected;ctx.lineWidth=2;ctx.arc(p.x,p.y,size+4,0,Math.PI*2);ctx.stroke();}
|
|
298
|
+
if(a.selected||a.hover||a.from)labels.push({at:p,text:a.label,color:a.selected||a.hover?c.selected:c.pending});
|
|
299
|
+
}
|
|
300
|
+
ctx.font='600 11px ui-monospace, monospace';
|
|
301
|
+
for(const l of labels){const w=ctx.measureText(l.text).width+8,lx=l.at.x+10,ly=l.at.y-9;ctx.fillStyle=c.labelBg;ctx.fillRect(lx,ly-11,w,15);ctx.fillStyle=l.color;ctx.fillText(l.text,lx+4,ly);}
|
|
302
|
+
ctx.restore();
|
|
303
|
+
}
|
|
210
304
|
clear():void{
|
|
211
305
|
this.geometry=null;this.playerAvailable=false;
|
|
212
306
|
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();}
|
|
@@ -215,7 +309,7 @@ export class RigOverlays {
|
|
|
215
309
|
if(this.disposed)return;this.clear();this.host.rigRoot.style.visibility=this.visibility;this.host.canvas.style.pointerEvents=this.pointerEvents;this.disposed=true;
|
|
216
310
|
}
|
|
217
311
|
private snapshot(canvasAvailable:boolean,needsCapture:boolean,warnings:string[]):OverlaySnapshot{
|
|
218
|
-
return {available:this.playerAvailable&&canvasAvailable,needsCapture,meshEdges:this.geometry?.mesh.length??0,boneCount:this.geometry?.joints.length??0,capabilities:{images:this.playerAvailable,mesh:this.playerAvailable&&canvasAvailable,bones:this.playerAvailable&&canvasAvailable},warnings};
|
|
312
|
+
return {available:this.playerAvailable&&canvasAvailable,needsCapture,meshEdges:this.geometry?.mesh.length??0,meshTriangles:{...(this.geometry?.meshTriangles??{})},boneCount:this.geometry?.joints.length??0,capabilities:{images:this.playerAvailable,mesh:this.playerAvailable&&canvasAvailable,bones:this.playerAvailable&&canvasAvailable},warnings};
|
|
219
313
|
}
|
|
220
|
-
private unavailable(warning:string):OverlaySnapshot{return {available:false,needsCapture:false,meshEdges:0,boneCount:0,capabilities:{images:false,mesh:false,bones:false},warnings:[warning]};}
|
|
314
|
+
private unavailable(warning:string):OverlaySnapshot{return {available:false,needsCapture:false,meshEdges:0,meshTriangles:{},boneCount:0,capabilities:{images:false,mesh:false,bones:false},warnings:[warning]};}
|
|
221
315
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup pose, read from the published player: a throwaway `RigPlayer` over the loaded assets that never plays, so
|
|
3
|
+
* its `bone()` and `slotIdentity()` answer the setup pose (the published `posedFrame()` is empty before `play`). Shared
|
|
4
|
+
* by Setup · Weights (#107) and Edit Mesh (#100). The stage player is never touched; the throwaway one is disposed
|
|
5
|
+
* before this returns, so every bone the caller may ask for is read here.
|
|
6
|
+
*/
|
|
7
|
+
import {RigPlayer,type ModelAssets,type RenderMode} from '@rig-c/play';
|
|
8
|
+
import type {WorldTransform} from '../src/setup-mesh.ts';
|
|
9
|
+
|
|
10
|
+
export interface SetupPose {
|
|
11
|
+
/** The slot's attachment at the setup pose, or null when it shows none there. */
|
|
12
|
+
identity:{skin:string;attachment:string}|null;
|
|
13
|
+
/** A named bone's setup-pose world transform; throws for a bone that was not read. */
|
|
14
|
+
world:(bone:string)=>WorldTransform;
|
|
15
|
+
}
|
|
16
|
+
/** Reads `slot`'s setup-pose identity and every bone in `bones` (unknown names are skipped). Throws what the player throws. */
|
|
17
|
+
export function readSetupPose(assets:ModelAssets,slot:string,bones:readonly string[],root:HTMLElement,mode:RenderMode='dom'):SetupPose {
|
|
18
|
+
const player=new RigPlayer(root,assets,{mode,slots:[slot]});
|
|
19
|
+
try{
|
|
20
|
+
const row=player.slotIdentity().slots.find(r=>r.slot===slot),identity=row&&row.attachment&&row.skin?{skin:row.skin,attachment:row.attachment}:null;
|
|
21
|
+
const map=new Map<string,WorldTransform>();
|
|
22
|
+
for(const name of bones){try{const b=player.bone(name);map.set(name,{x:b.x,y:b.y,a:b.a,b:b.b,c:b.c,d:b.d});}catch{/* not a bone of this rig */}}
|
|
23
|
+
return {identity,world:name=>{const t=map.get(name);if(!t)throw new Error(`no setup-pose transform for bone ${name}`);return t;}};
|
|
24
|
+
}finally{player.dispose();}
|
|
25
|
+
}
|
|
26
|
+
/** The bone names a compiled model document declares, in stored order. */
|
|
27
|
+
export function modelBoneNames(model:unknown):string[] {
|
|
28
|
+
const list=(model as {bones?:unknown}|null)?.bones;
|
|
29
|
+
return Array.isArray(list)?list.map(b=>b&&typeof (b as {name?:unknown}).name==='string'?(b as {name:string}).name:'').filter(Boolean):[];
|
|
30
|
+
}
|