@rig-c/review 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +242 -7
- package/docs/DESIGN.md +33 -5
- package/docs/REVIEW_PACKAGE.md +115 -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 +65 -6
- package/src/cli.ts +19 -8
- package/src/config-patch.ts +213 -0
- package/src/cut-edits.ts +184 -0
- package/src/environment.ts +226 -0
- package/src/feedback-export.ts +25 -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 +317 -0
- 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 +12 -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 +294 -0
- package/web/chest-regions-tool.ts +10 -4
- package/web/cuts-panel.ts +266 -0
- package/web/idle-panel.ts +280 -0
- package/web/index.html +2 -2
- package/web/inspector-blocks.ts +77 -0
- package/web/main.ts +813 -24
- package/web/mesh-editor.ts +303 -0
- package/web/mesh-panel.ts +273 -0
- package/web/part-selection.ts +13 -5
- package/web/part-thumbnails.ts +76 -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 +171 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +52 -5
- package/web/weights-view.ts +159 -0
- package/web/workspace-preferences.ts +17 -12
- package/web/workspace-ui.ts +5 -3
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animate · Retarget (#114), page side: a `rig-pose-map/1` editor — per chain, include it and pick the rig bone that
|
|
3
|
+
* carries each segment (and its bend), the translated bone and its scale points — the clean and retarget flags as typed
|
|
4
|
+
* controls, the declared pose sequence to read, then Apply · Retarget. The server runs the installed `rig-pose clean`,
|
|
5
|
+
* `rig-pose retarget` and the compile into scratch; this draws what came back: the producer's lines (every refusal
|
|
6
|
+
* verbatim) and the run's FEET / STANCE / FLIP / SHORT / END-ON / LEG-SCALE / REACH lines, each a button that seeks to
|
|
7
|
+
* its frame. The map starts from a loaded `rig-pose-map/1` file (read in the page, sent by content) or the body-18
|
|
8
|
+
* chains with no bone picked. Review authors no keys: the motion is the producer's.
|
|
9
|
+
*/
|
|
10
|
+
import {DEFAULT_CLEAN,DEFAULT_FLAGS,POSE_MAP_SCHEMA,type RetargetFlags,type RetargetLine} from '../src/retarget.ts';
|
|
11
|
+
import type {RetargetJobView,RetargetStatusView,RebuildRefusal} from './rebuild-client.ts';
|
|
12
|
+
|
|
13
|
+
type Chain={name:string;bones:string[];joints:string[];bend?:string;[key:string]:unknown};
|
|
14
|
+
export type PoseMap={spec:typeof POSE_MAP_SCHEMA;chains:Chain[];translate?:{bone:string;joint:string;scale?:{pose:[string,string];rig:[string,string]}};[key:string]:unknown};
|
|
15
|
+
/** The body-18 chains a front-facing figure maps (README, "The map"), no bone picked: the editor's start without a map file. */
|
|
16
|
+
export function templateMap():PoseMap {
|
|
17
|
+
return {spec:POSE_MAP_SCHEMA,chains:[
|
|
18
|
+
{name:'trunk',bones:[''],joints:['mid_hip','neck'],aim:['neck']},
|
|
19
|
+
{name:'arm_r',bones:['',''],joints:['r_shoulder','r_elbow','r_wrist'],bend:'free'},
|
|
20
|
+
{name:'arm_l',bones:['',''],joints:['l_shoulder','l_elbow','l_wrist'],bend:'free'},
|
|
21
|
+
{name:'leg_r',bones:['',''],joints:['r_hip','r_knee','r_ankle'],bend:'free'},
|
|
22
|
+
{name:'leg_l',bones:['',''],joints:['l_hip','l_knee','l_ankle'],bend:'free'},
|
|
23
|
+
],translate:{bone:'',joint:'mid_hip',scale:{pose:['mid_hip','neck'],rig:['','']}}};
|
|
24
|
+
}
|
|
25
|
+
/** A bone not yet picked, refused before anything is sent; whether a picked bone is the rig's is the producer's to say. */
|
|
26
|
+
export function mapPrecheck(map:PoseMap):string|null {
|
|
27
|
+
if(!map.chains.length)return 'RETARGET_MAP_INCOMPLETE: no chain is included';
|
|
28
|
+
for(const c of map.chains){const i=c.bones.findIndex(b=>!b);if(i>=0)return `RETARGET_MAP_INCOMPLETE: chain "${c.name}" bone ${i+1} (${c.joints[i]} → ${c.joints[i+1]??'?'}) is not picked`;}
|
|
29
|
+
if(map.translate&&!map.translate.bone)return 'RETARGET_MAP_INCOMPLETE: the translated bone is not picked';
|
|
30
|
+
if(map.translate?.scale&&map.translate.scale.rig.some(b=>!b))return 'RETARGET_MAP_INCOMPLETE: the translate scale\'s rig points are not picked';
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
/** A document read from a file or the JSON field: a `rig-pose-map/1` with chains, else the line saying why not. */
|
|
34
|
+
export function readMap(text:string):{map:PoseMap}|{error:string} {
|
|
35
|
+
let value:unknown;try{value=JSON.parse(text);}catch(error){return {error:`RETARGET_MAP_INVALID: ${error instanceof Error?error.message:String(error)}`};}
|
|
36
|
+
const v=value as PoseMap;
|
|
37
|
+
if(!v||typeof v!=='object'||v.spec!==POSE_MAP_SCHEMA||!Array.isArray(v.chains)||!v.chains.every(c=>c&&typeof c.name==='string'&&Array.isArray(c.bones)&&Array.isArray(c.joints)))return {error:`RETARGET_MAP_INVALID: expected a ${POSE_MAP_SCHEMA} document with chains of {name, bones, joints}`};
|
|
38
|
+
return {map:JSON.parse(JSON.stringify(v)) as PoseMap};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RetargetPanelDeps {
|
|
42
|
+
apply:(body:{sequence:string;map:PoseMap;flags:RetargetFlags})=>Promise<RetargetJobView|RebuildRefusal|null>;
|
|
43
|
+
reset:()=>void;
|
|
44
|
+
seek:(animation:string,frame:number,time:number|null,label:string)=>void;
|
|
45
|
+
}
|
|
46
|
+
export interface RetargetPanel {
|
|
47
|
+
load(status:RetargetStatusView|null,reason:string|null):void;
|
|
48
|
+
map():PoseMap;setMap(map:PoseMap):void;
|
|
49
|
+
/** Include or leave out a chain by name. */
|
|
50
|
+
include(chain:string,on:boolean):void;
|
|
51
|
+
pick(chain:string,index:number,bone:string):void;
|
|
52
|
+
flags():RetargetFlags|{error:string};setFlags(flags:Partial<RetargetFlags>):void;
|
|
53
|
+
sequence():string;
|
|
54
|
+
busy(value:boolean):void;
|
|
55
|
+
status(text:string,error:boolean):void;
|
|
56
|
+
line(text:string):void;
|
|
57
|
+
result(job:RetargetJobView|RebuildRefusal|null):void;
|
|
58
|
+
shownLines():string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
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;};
|
|
62
|
+
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;};
|
|
63
|
+
// A checkbox reads after its box; a field after its name.
|
|
64
|
+
const labelled=(text:string,control:HTMLElement)=>control instanceof HTMLInputElement&&control.type==='checkbox'?make('label',{},control,text):make('label',{},text,control);
|
|
65
|
+
const numberInput=(id:string,value:number,step:string,label:string)=>{const n=make('input',{id,type:'number',step,value:String(value)});n.setAttribute('aria-label',label);return n;};
|
|
66
|
+
const checkbox=(id:string,checked=false)=>make('input',{id,type:'checkbox',checked});
|
|
67
|
+
|
|
68
|
+
export function bindRetargetPanel(deps:RetargetPanelDeps):RetargetPanel {
|
|
69
|
+
const body=node('retarget-body');
|
|
70
|
+
let status:RetargetStatusView|null=null,entries:{chain:Chain;on:boolean}[]=[],rest:Omit<PoseMap,'chains'>={spec:POSE_MAP_SCHEMA},shown:string[]=[],isBusy=false;
|
|
71
|
+
const log:string[]=[];
|
|
72
|
+
// Controls.
|
|
73
|
+
const sequence=make('select',{id:'retarget-sequence'});sequence.setAttribute('aria-label','Pose sequence');
|
|
74
|
+
const person=make('select',{id:'retarget-person'});person.setAttribute('aria-label','Person');
|
|
75
|
+
const template=make('button',{type:'button',textContent:'Body-18 chains'}),file=make('input',{id:'retarget-map-file',type:'file',accept:'.json,application/json'});file.setAttribute('aria-label','Load a rig-pose-map/1 file');
|
|
76
|
+
const chains=make('div',{id:'retarget-chains',className:'retarget-chains'}),translate=make('div',{id:'retarget-translate',className:'row retarget-translate'});
|
|
77
|
+
const json=make('textarea',{id:'retarget-map-json',rows:8,spellcheck:false}),useJson=make('button',{type:'button',textContent:'Use this JSON'});json.setAttribute('aria-label','Map JSON');
|
|
78
|
+
const clean=checkbox('retarget-clean',true),loop=checkbox('retarget-loop'),legs=numberInput('retarget-legs',DEFAULT_CLEAN.legs,'2','clean legs window'),restWin=numberInput('retarget-rest',DEFAULT_CLEAN.rest,'2','clean rest window'),hold=checkbox('retarget-hold',true),hips=checkbox('retarget-hips-inferred');
|
|
79
|
+
const animation=make('input',{id:'retarget-animation',type:'text',value:DEFAULT_FLAGS.animation,autocomplete:'off'});animation.setAttribute('aria-label','New animation name');
|
|
80
|
+
const opt=(id:string,value:number,step:string,label:string)=>({on:checkbox(`${id}-on`),value:numberInput(id,value,step,label)});
|
|
81
|
+
const minScore=opt('retarget-min-score',0.3,'0.05','min score'),flip=opt('retarget-flip',90,'1','flip degrees'),short=opt('retarget-short',0.35,'0.05','short fraction'),tolerance=opt('retarget-bend-tolerance',0,'1','bend tolerance degrees'),stance=opt('retarget-stance',0.07,'0.01','stance leg lengths');
|
|
82
|
+
const bends=make('select',{id:'retarget-bends'},make('option',{value:'refuse',textContent:'refuse'}),make('option',{value:'clamp',textContent:'clamp'}));bends.setAttribute('aria-label','Wrong-way bends');
|
|
83
|
+
const legScale={on:checkbox('retarget-leg-scale-on'),min:numberInput('retarget-leg-scale-min',0.8,'0.01','leg scale min'),max:numberInput('retarget-leg-scale-max',1.05,'0.01','leg scale max')};
|
|
84
|
+
const plant=checkbox('retarget-plant-feet'),raised=checkbox('retarget-raised-by-head'),carry=checkbox('retarget-carry-idle');
|
|
85
|
+
const apply=make('button',{id:'retarget-apply',type:'button',className:'primary',textContent:'Apply · Retarget',disabled:true}),reset=make('button',{id:'retarget-reset',type:'button',textContent:'Reset'});
|
|
86
|
+
const statusLine=make('p',{id:'retarget-status',className:'hint'});statusLine.setAttribute('aria-live','polite');
|
|
87
|
+
const logView=make('pre',{id:'retarget-log',className:'retarget-log',hidden:true}),refusals=make('ul',{id:'retarget-refusals',className:'requirements-refusals'}),readings=make('ul',{id:'retarget-lines',className:'readings-worst'});readings.setAttribute('aria-label','Retarget lines');
|
|
88
|
+
body.append(
|
|
89
|
+
make('div',{className:'row'},labelled('Sequence',sequence),labelled('Person',person)),
|
|
90
|
+
make('div',{className:'row'},make('span',{className:'eyebrow',textContent:'MAP · rig-pose-map/1'}),template,file),
|
|
91
|
+
chains,translate,
|
|
92
|
+
make('details',{className:'retarget-json'},make('summary',{textContent:'Map JSON (aims, ranges, alternates…)'}),json,make('div',{className:'row'},useJson)),
|
|
93
|
+
make('div',{className:'row'},make('span',{className:'eyebrow',textContent:'CLEAN'}),labelled('clean first',clean),labelled('loop',loop),labelled('legs',legs),labelled('rest',restWin),labelled('hold',hold),labelled('hips inferred',hips)),
|
|
94
|
+
make('div',{className:'row'},make('span',{className:'eyebrow',textContent:'RETARGET'}),labelled('as',animation)),
|
|
95
|
+
make('div',{className:'row'},labelled('flip °',flip.on),flip.value,labelled('short',short.on),short.value,labelled('min score',minScore.on),minScore.value),
|
|
96
|
+
make('div',{className:'row'},labelled('bends',bends),labelled('tolerance °',tolerance.on),tolerance.value,labelled('leg scale',legScale.on),legScale.min,legScale.max),
|
|
97
|
+
make('div',{className:'row'},labelled('plant feet',plant),labelled('stance',stance.on),stance.value,labelled('raised by head',raised),labelled('carry idle',carry)),
|
|
98
|
+
make('div',{className:'row'},apply,reset),statusLine,refusals,logView,readings,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const bonesOf=()=>status?.bones??[];
|
|
102
|
+
const boneSelect=(value:string,label:string,change:(v:string)=>void)=>{
|
|
103
|
+
const s=make('select');s.setAttribute('aria-label',label);
|
|
104
|
+
const names=bonesOf(),options=[make('option',{value:'',textContent:'— pick a bone —'}),...names.map(n=>make('option',{value:n,textContent:n}))];
|
|
105
|
+
// A bone the map names that the rig lacks stays visible: Apply sends it and the producer refuses it by name.
|
|
106
|
+
if(value&&!names.includes(value))options.push(make('option',{value,textContent:`${value} (not in the rig)`}));
|
|
107
|
+
s.append(...options);s.value=value;s.onchange=()=>change(s.value);return s;
|
|
108
|
+
};
|
|
109
|
+
const map=():PoseMap=>({...JSON.parse(JSON.stringify(rest)),chains:entries.filter(e=>e.on).map(e=>JSON.parse(JSON.stringify(e.chain)))}) as PoseMap;
|
|
110
|
+
function render(){
|
|
111
|
+
chains.replaceChildren(...entries.map((e,ci)=>{
|
|
112
|
+
const on=checkbox(`retarget-chain-${ci}`,e.on);on.onchange=()=>{e.on=on.checked;render();};
|
|
113
|
+
const row=make('div',{className:`retarget-chain${e.on?'':' off'}`},labelled(e.chain.name,on));
|
|
114
|
+
e.chain.bones.forEach((bone,bi)=>{row.append(make('code',{textContent:`${e.chain.joints[bi]??'?'}→${e.chain.joints[bi+1]??'?'}`}),boneSelect(bone,`${e.chain.name} bone ${bi+1}`,v=>{e.chain.bones[bi]=v;render();}));});
|
|
115
|
+
const bend=make('select',{},...['','rest','free','ccw','cw'].map(v=>make('option',{value:v,textContent:v||'bend: rest (default)'})));bend.setAttribute('aria-label',`${e.chain.name} bend`);bend.value=typeof e.chain.bend==='string'?e.chain.bend:'';bend.onchange=()=>{if(bend.value)e.chain.bend=bend.value;else delete e.chain.bend;render();};
|
|
116
|
+
row.append(bend);return row;
|
|
117
|
+
}));
|
|
118
|
+
translate.replaceChildren();
|
|
119
|
+
const t=rest.translate as PoseMap['translate'];
|
|
120
|
+
if(t){
|
|
121
|
+
translate.append(make('span',{textContent:`translate ${t.joint} →`}),boneSelect(t.bone,'translated bone',v=>{t.bone=v;render();}));
|
|
122
|
+
if(t.scale){translate.append(make('span',{textContent:`scale ${t.scale.pose.join('→')} =`}));t.scale.rig.forEach((b,i)=>translate.append(boneSelect(b,`scale rig point ${i+1}`,v=>{t.scale!.rig[i]=v;render();})));}
|
|
123
|
+
}
|
|
124
|
+
if(document.activeElement!==json)json.value=JSON.stringify(map(),null,1);
|
|
125
|
+
syncApply();
|
|
126
|
+
}
|
|
127
|
+
function setMap(next:PoseMap){const {chains:list,...others}=JSON.parse(JSON.stringify(next)) as PoseMap;rest=others;entries=list.map(chain=>({chain,on:true}));render();}
|
|
128
|
+
function syncApply(){apply.disabled=isBusy||!status?.available||!sequence.value;}
|
|
129
|
+
function people(){const s=status?.sequences.find(x=>x.id===sequence.value);person.replaceChildren(make('option',{value:'',textContent:s&&s.people.length>1?'— pick —':'(the one person)'}),...(s?.people??[]).map(p=>make('option',{value:p,textContent:p})));}
|
|
130
|
+
const say=(text:string,error:boolean)=>{statusLine.textContent=text;statusLine.title=text;statusLine.classList.toggle('error',error);};
|
|
131
|
+
|
|
132
|
+
template.onclick=()=>setMap(templateMap());
|
|
133
|
+
file.onchange=()=>{const f=file.files?.[0];if(!f)return;void f.text().then(text=>{const read=readMap(text);if('error' in read)say(read.error,true);else{setMap(read.map);say(`Map loaded from ${f.name} (by content; nothing is sent until Apply).`,false);}file.value='';});};
|
|
134
|
+
useJson.onclick=()=>{const read=readMap(json.value);if('error' in read)say(read.error,true);else{setMap(read.map);say('Map taken from the JSON field.',false);}};
|
|
135
|
+
sequence.onchange=()=>{people();syncApply();};
|
|
136
|
+
reset.onclick=()=>deps.reset();
|
|
137
|
+
|
|
138
|
+
function flags():RetargetFlags|{error:string}{
|
|
139
|
+
const n=(input:HTMLInputElement,what:string)=>{const v=Number(input.value);return input.value.trim()&&Number.isFinite(v)?v:null;};
|
|
140
|
+
const optional=(o:{on:HTMLInputElement;value:HTMLInputElement},what:string):number|null|{error:string}=>{if(!o.on.checked)return null;const v=n(o.value,what);return v===null?{error:`RETARGET_FLAG_INCOMPLETE: ${what}`}:v;};
|
|
141
|
+
const name=animation.value.trim();if(!name)return {error:'RETARGET_FLAG_INCOMPLETE: name the new animation'};
|
|
142
|
+
const values:Record<string,number|null>={};
|
|
143
|
+
for(const [key,o,what] of [['minScore',minScore,'min score'],['flip',flip,'flip degrees'],['short',short,'short fraction'],['bendTolerance',tolerance,'bend tolerance'],['stance',stance,'stance']] as const){const v=optional(o,what);if(v&&typeof v==='object')return v;values[key]=v;}
|
|
144
|
+
const lmin=n(legScale.min,'leg scale min'),lmax=n(legScale.max,'leg scale max');
|
|
145
|
+
if(legScale.on.checked&&(lmin===null||lmax===null))return {error:'RETARGET_FLAG_INCOMPLETE: leg scale min and max'};
|
|
146
|
+
const l=n(legs,'legs'),r=n(restWin,'rest');if(clean.checked&&(l===null||r===null))return {error:'RETARGET_FLAG_INCOMPLETE: clean windows'};
|
|
147
|
+
return {animation:name,person:person.value||null,clean:clean.checked?{loop:loop.checked,legs:l!,rest:r!,hold:hold.checked,hipsInferred:hips.checked}:null,
|
|
148
|
+
minScore:values.minScore??null,flip:values.flip??null,short:values.short??null,bends:bends.value as 'refuse'|'clamp',bendTolerance:values.bendTolerance??null,
|
|
149
|
+
legScale:legScale.on.checked?[lmin!,lmax!]:null,plantFeet:plant.checked,stance:values.stance??null,raisedByHead:raised.checked,carryIdle:carry.checked};
|
|
150
|
+
}
|
|
151
|
+
function setFlags(f:Partial<RetargetFlags>){
|
|
152
|
+
if(f.animation!==undefined)animation.value=f.animation;
|
|
153
|
+
if(f.person!==undefined)person.value=f.person??'';
|
|
154
|
+
if(f.clean!==undefined){clean.checked=!!f.clean;if(f.clean){loop.checked=f.clean.loop;legs.value=String(f.clean.legs);restWin.value=String(f.clean.rest);hold.checked=f.clean.hold;hips.checked=f.clean.hipsInferred;}}
|
|
155
|
+
for(const [key,o] of [['minScore',minScore],['flip',flip],['short',short],['bendTolerance',tolerance],['stance',stance]] as const){const v=f[key];if(v===undefined)continue;o.on.checked=v!==null;if(v!==null)o.value.value=String(v);}
|
|
156
|
+
if(f.bends)bends.value=f.bends;
|
|
157
|
+
if(f.legScale!==undefined){legScale.on.checked=!!f.legScale;if(f.legScale){legScale.min.value=String(f.legScale[0]);legScale.max.value=String(f.legScale[1]);}}
|
|
158
|
+
if(f.plantFeet!==undefined)plant.checked=f.plantFeet;if(f.raisedByHead!==undefined)raised.checked=f.raisedByHead;if(f.carryIdle!==undefined)carry.checked=f.carryIdle;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
apply.onclick=()=>{
|
|
162
|
+
const m=map(),problem=mapPrecheck(m);if(problem){say(`${problem} (review pre-check; nothing sent)`,true);return;}
|
|
163
|
+
const f=flags();if('error' in f){say(`${f.error} (review pre-check; nothing sent)`,true);return;}
|
|
164
|
+
log.length=0;logView.textContent='';logView.hidden=true;
|
|
165
|
+
void deps.apply({sequence:sequence.value,map:m,flags:f});
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
function lineItem(r:RetargetLine,animationName:string){
|
|
169
|
+
const item=make('li',{className:'readings-line scratch'}),swatch=make('span',{className:'tick-swatch scratch'});swatch.setAttribute('aria-hidden','true');
|
|
170
|
+
if(r.frame!==null){const go=make('button',{type:'button',className:'readings-go',textContent:r.line,title:r.line});go.onclick=()=>deps.seek(animationName,r.frame!,r.time,`${r.code} F${r.frame}`);item.append(swatch,go);}
|
|
171
|
+
else item.append(swatch,make('code',{textContent:r.line,title:r.line}));
|
|
172
|
+
return item;
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
load(next,reason){
|
|
176
|
+
status=next;const prior=sequence.value;
|
|
177
|
+
sequence.replaceChildren(...(next?.sequences??[]).map(s=>make('option',{value:s.id,textContent:`${s.label} · ${s.frames} frame(s) · ${s.cleaned?'cleaned':'raw'}`})));
|
|
178
|
+
if(prior&&next?.sequences.some(s=>s.id===prior))sequence.value=prior;
|
|
179
|
+
const s=next?.sequences.find(x=>x.id===sequence.value);if(s?.cleaned&&!prior)clean.checked=false;
|
|
180
|
+
people();if(!entries.length)setMap(templateMap());else render();
|
|
181
|
+
say(next?.available?`${next.producer?.package} ${next.producer?.version} with ${next.compiler?.package} ${next.compiler?.version} on the handoff rig${next.rig?` (${next.rig})`:''} · ${next.bones.length} bone(s), animations ${next.animations.join(', ')||'none'}`:(next?.reason??reason??'RETARGET_UNAVAILABLE'),!next?.available);
|
|
182
|
+
syncApply();
|
|
183
|
+
},
|
|
184
|
+
map,setMap,
|
|
185
|
+
include(name,on){const e=entries.find(x=>x.chain.name===name);if(e){e.on=on;render();}},
|
|
186
|
+
pick(name,index,bone){const e=entries.find(x=>x.chain.name===name);if(e&&index>=0&&index<e.chain.bones.length){e.chain.bones[index]=bone;render();}},
|
|
187
|
+
flags,setFlags,sequence:()=>sequence.value,
|
|
188
|
+
busy(value){isBusy=value;apply.textContent=value?'Retargeting…':'Apply · Retarget';syncApply();},
|
|
189
|
+
status:say,
|
|
190
|
+
line(text){log.push(text);logView.hidden=false;logView.textContent=log.slice(-12).join('\n');},
|
|
191
|
+
result(job){
|
|
192
|
+
refusals.replaceChildren();readings.replaceChildren();shown=[];
|
|
193
|
+
if(!job)return;
|
|
194
|
+
if(!('id' in job)){refusals.append(make('li',{},make('code',{textContent:job.producerLine??job.error})));return;}
|
|
195
|
+
for(const line of job.refusals??[])refusals.append(make('li',{},make('code',{textContent:line})));
|
|
196
|
+
if(job.status!=='built')return;
|
|
197
|
+
const name=job.request.record.flags.animation;
|
|
198
|
+
for(const r of job.readings){readings.append(lineItem(r,name));shown.push(r.line);}
|
|
199
|
+
if(!job.readings.length)readings.append(make('li',{className:'hint',textContent:'No FEET / STANCE / FLIP / SHORT / END-ON / LEG-SCALE / REACH line in this run (FEET needs both legs mapped; FLIP and SHORT their flags).'}));
|
|
200
|
+
},
|
|
201
|
+
shownLines:()=>[...shown],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -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
|
+
}
|