@rig-c/review 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +241 -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,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animate · Requirements (#113), page side: declare what the scene asks of the motion — pick a slot (its mesh
|
|
3
|
+
* attachment, its bone) on the stage or by name, an animation and a kind, then set the bar with a slider that shows the
|
|
4
|
+
* last measured value — and Check: review writes the `spine-parts-requirements/1` file and the installed producer's
|
|
5
|
+
* `rig-parts check --requirements` measures it, on the inspected rig or the latest scratch build. Each requirement gets
|
|
6
|
+
* one line with its worst frame, a button that seeks there; `REQUIREMENTS_*` refusals and `CHECK_REQUIREMENT_*` lines are
|
|
7
|
+
* shown verbatim. The file rides submitted feedback by hash and content (`context.requirements`).
|
|
8
|
+
* A kind the rig cannot offer (no mesh slot, no ik or transform constraint, fewer than two slots) is disabled with the
|
|
9
|
+
* reason. Scene targets, stage-point targets, the part exception lists and waivers (`may_fold`) are not written here.
|
|
10
|
+
*/
|
|
11
|
+
import {KIND_FIELDS,REQUIREMENT_KINDS,REQUIREMENT_NAME,REQUIREMENTS_CONTEXT_SCHEMA,requirementEntry,requirementsDocument,requirementsFileText,type BonePoint,type Requirement,type RequirementKind,type RequirementRow,type RequirementsBuild,type RequirementsContext} from '../src/requirements.ts';
|
|
12
|
+
|
|
13
|
+
export interface RequirementsCatalogView {status:'ready'|'unavailable';reason:string|null;bones:string[];slots:string[];meshes:Record<string,string[]>;slotBones:Record<string,string>;constraints:{name:string;type:'ik'|'transform';bones:string[];drives:string[]}[];animations:{name:string;duration:number|null}[];scratch:string|null}
|
|
14
|
+
export interface RequirementsJobView {id:string;status:'running'|'checked'|'refused'|'failed';build:RequirementsBuild;buildId:string|null;file:{sha256:string;size:number;text:string};producer:{package:string;version:string};exitCode:number|null;wallMs:number|null;rows:RequirementRow[];summary:string|null;checkLine:string|null;refusals:string[];problems:string[];error:string|null;lines:string[]}
|
|
15
|
+
export interface RequirementsRefusal {status:'refused';error:string}
|
|
16
|
+
export interface RequirementsPanelDeps {
|
|
17
|
+
/** Runs the check of this file text's requirements; the panel shows what comes back. */
|
|
18
|
+
check:(body:{fps:number;build:RequirementsBuild;requirements:Record<string,unknown>[]})=>Promise<RequirementsJobView|RequirementsRefusal|null>;
|
|
19
|
+
seek:(animation:string,frame:number,time:number|null,label:string)=>void;
|
|
20
|
+
/** The drawn animation and the slot picked on the stage. */
|
|
21
|
+
animation:()=>string;slot:()=>string|null;
|
|
22
|
+
/** The file's sha256 (hex) for the feedback context. */
|
|
23
|
+
hash:(text:string)=>Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
export interface RequirementsPanel {
|
|
26
|
+
load(catalog:RequirementsCatalogView|null,reason:string|null):void;
|
|
27
|
+
/** Prefill the subject from the stage's selection. */
|
|
28
|
+
selectionChanged():void;
|
|
29
|
+
requirements():Requirement[];
|
|
30
|
+
add(r:Requirement):void;remove(name:string):void;setBar(name:string,field:string,value:number):void;
|
|
31
|
+
text():string|null;
|
|
32
|
+
checked(job:RequirementsJobView|RequirementsRefusal|null):void;
|
|
33
|
+
context():RequirementsContext|null;
|
|
34
|
+
shownLines():string[];
|
|
35
|
+
}
|
|
36
|
+
/** A kind the catalog cannot offer, and why; null when it can. */
|
|
37
|
+
export function kindUnavailable(kind:RequirementKind,c:Pick<RequirementsCatalogView,'bones'|'slots'|'meshes'|'constraints'>):string|null {
|
|
38
|
+
if(kind==='stretch'&&!Object.values(c.meshes).some(list=>list.length))return 'no slot of the rig spec holds a mesh attachment';
|
|
39
|
+
if(kind==='follow'&&!c.constraints.length)return 'the rig spec declares no ik or transform constraint';
|
|
40
|
+
if(kind==='seam'&&c.slots.length<2)return 'a seam needs two slots';
|
|
41
|
+
if((kind==='contact'||kind==='aim'||kind==='range')&&!c.bones.length)return 'the rig spec declares no bone';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/** Each kind's bar fields and the slider's span; the span grows to hold a measured value past it. */
|
|
45
|
+
export const BAR_FIELDS:Record<RequirementKind,{field:string;min:number;max:number;step:number;initial:number}[]>={
|
|
46
|
+
contact:[{field:'within_px',min:0,max:50,step:.1,initial:1}],
|
|
47
|
+
aim:[{field:'within_degrees',min:0,max:180,step:.5,initial:5}],
|
|
48
|
+
range:[{field:'lo_degrees',min:-180,max:180,step:1,initial:-45},{field:'hi_degrees',min:-180,max:180,step:1,initial:45}],
|
|
49
|
+
stretch:[{field:'within_ratio',min:1,max:3,step:.01,initial:1.3}],
|
|
50
|
+
follow:[{field:'fraction',min:-1,max:2,step:.01,initial:.5}],
|
|
51
|
+
seam:[{field:'within_px',min:0,max:20,step:.1,initial:1}],
|
|
52
|
+
};
|
|
53
|
+
/** A requirement name the reader takes, unique among `taken`: `KIND_SUBJECT`, then `_2`, `_3`… */
|
|
54
|
+
export function requirementName(kind:RequirementKind,subject:string,taken:readonly string[]):string {
|
|
55
|
+
let base=`${kind}_${subject}`.toUpperCase().replace(/[^A-Z0-9_]+/g,'_').replace(/_+/g,'_').replace(/^_|_$/g,'');if(!/^[A-Z]/.test(base))base=`R_${base}`;
|
|
56
|
+
let name=base;for(let n=2;taken.includes(name);n++)name=`${base}_${n}`;return name;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A contact against its own bone and point, or an aim at its own bone, measures 0 by construction and the producer
|
|
60
|
+
* reports it as PASS (rig-c/review#142; the reader's refusal is asked in rig-c/parts#540). Refused here by name.
|
|
61
|
+
*/
|
|
62
|
+
export function selfTargetRefusal(r:{kind:RequirementKind;bone?:string;point?:BonePoint;target?:{bone:string;point:BonePoint}}):string|null {
|
|
63
|
+
if(!r.target||!r.bone)return null;
|
|
64
|
+
if(r.kind==='contact'&&r.target.bone===r.bone&&r.target.point===r.point)return `REQUIREMENTS_SELF_TARGET: contact of bone "${r.bone}" ${r.point} against itself measures 0 in every frame; pick another bone or point`;
|
|
65
|
+
if(r.kind==='aim'&&r.target.bone===r.bone)return `REQUIREMENTS_SELF_TARGET: aim of bone "${r.bone}" at its own ${r.target.point} has no line to measure; pick another target bone`;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/** The catalog the form renders from: only a ready rig spec. An unavailable one (no producer handoff) is no catalog, so picking a part with no handoff never renders fields from it (#150). */
|
|
69
|
+
export function readyCatalog<C extends {status:'ready'|'unavailable'}>(next:C|null):C|null {return next&&next.status==='ready'?next:null;}
|
|
70
|
+
/** The target a fresh form proposes: a bone other than the subject (the next one in rig order, else the first other one). */
|
|
71
|
+
export function defaultTargetBone(bones:readonly string[],subject:string):string|null {
|
|
72
|
+
const i=bones.indexOf(subject),next=i>=0?bones.slice(i+1).find(b=>b!==subject):undefined;
|
|
73
|
+
return next??bones.find(b=>b!==subject)??null;
|
|
74
|
+
}
|
|
75
|
+
/** The measured figure a bar field is read against, from a row. */
|
|
76
|
+
export function measuredFor(row:RequirementRow|undefined,field:string):number|null {
|
|
77
|
+
if(!row)return null;if(field==='lo_degrees')return row.least;if(field==='hi_degrees')return row.greatest;return row.measured;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
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;};
|
|
81
|
+
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;};
|
|
82
|
+
const options=(select:HTMLSelectElement,values:readonly string[],label=(v:string)=>v)=>{const keep=select.value;select.replaceChildren(...values.map(v=>make('option',{value:v,textContent:label(v)})));if(values.includes(keep))select.value=keep;};
|
|
83
|
+
const labelled=(text:string,control:HTMLElement)=>make('label',{},text,control);
|
|
84
|
+
|
|
85
|
+
export function bindRequirementsPanel(deps:RequirementsPanelDeps):RequirementsPanel {
|
|
86
|
+
const body=node('requirements-body');
|
|
87
|
+
const state=make('p',{className:'hint',id:'requirements-state',textContent:'No producer handoff loaded.'});
|
|
88
|
+
const kind=make('select',{id:'requirements-kind'}),animation=make('select',{id:'requirements-animation'});
|
|
89
|
+
const fields=make('div',{className:'requirements-fields',id:'requirements-fields'}),note=make('p',{className:'hint',id:'requirements-note'});
|
|
90
|
+
const name=make('input',{type:'text',id:'requirements-name',autocomplete:'off',placeholder:'NAME (letters, digits, _)'});
|
|
91
|
+
const add=make('button',{type:'button',id:'requirements-add',textContent:'Add requirement'});
|
|
92
|
+
const list=make('ol',{className:'requirements-list',id:'requirements-list'});
|
|
93
|
+
const build=make('select',{id:'requirements-build'}),fps=make('input',{type:'number',id:'requirements-fps',min:'1',max:'240',step:'1',value:'12'});
|
|
94
|
+
const check=make('button',{type:'button',id:'requirements-check',className:'primary',textContent:'Check',disabled:true});
|
|
95
|
+
const status=make('output',{id:'requirements-status',className:'environment-readout'}),refusals=make('ul',{className:'requirements-refusals',id:'requirements-refusals'}),summary=make('p',{className:'hint',id:'requirements-summary'});
|
|
96
|
+
options(build,['inspected','scratch'],v=>v==='inspected'?'Inspected rig':'Latest scratch build');
|
|
97
|
+
body.replaceChildren(state,make('div',{className:'row'},labelled('Kind ',kind),labelled('Animation ',animation)),fields,note,make('div',{className:'row'},name,add),list,
|
|
98
|
+
make('div',{className:'row'},labelled('Check on ',build),labelled('fps ',fps),check),status,refusals,summary);
|
|
99
|
+
let catalog:RequirementsCatalogView|null=null,declared:Requirement[]=[],job:RequirementsJobView|null=null,busy=false,shown:string[]=[];
|
|
100
|
+
let hashed:{text:string;sha256:string}|null=null;
|
|
101
|
+
const controls:Record<string,HTMLSelectElement|HTMLInputElement>={};
|
|
102
|
+
|
|
103
|
+
const sel=(id:string,values:readonly string[],label?:(v:string)=>string)=>{const s=make('select',{id:`requirements-${id}`});options(s,values,label);controls[id]=s;return s;};
|
|
104
|
+
const numberField=(id:string,value:number,min?:number)=>{const i=make('input',{type:'number',id:`requirements-${id}`,value:String(value),step:'any',...(min===undefined?{}:{min:String(min)})});controls[id]=i;return i;};
|
|
105
|
+
/** The subject inputs for the chosen kind, prefilled from the stage's slot (its bone, its mesh). */
|
|
106
|
+
function renderFields(){
|
|
107
|
+
fields.replaceChildren();for(const k of Object.keys(controls))delete controls[k];
|
|
108
|
+
if(!catalog)return;
|
|
109
|
+
const k=kind.value as RequirementKind,slot=deps.slot(),slotBone=slot?catalog.slotBones[slot]??null:null;
|
|
110
|
+
const bones=catalog.bones,points=['origin','tip'];
|
|
111
|
+
const pickBone=(id:string)=>{const s=sel(id,bones);if(slotBone&&bones.includes(slotBone))s.value=slotBone;return s;};
|
|
112
|
+
/** The subject and target selects, the target defaulting to another bone and moved off the subject when the subject changes to it. */
|
|
113
|
+
const pickPair=()=>{const subject=pickBone('bone'),target=sel('target-bone',bones);const away=()=>{const d=defaultTargetBone(bones,subject.value);if(d&&target.value===subject.value)target.value=d;};away();subject.addEventListener('change',away);return [subject,target] as const;};
|
|
114
|
+
if(k==='contact'){const [b,t]=pickPair();fields.append(make('div',{className:'row'},labelled('Bone ',b),labelled('point ',sel('point',points))),make('div',{className:'row'},labelled('Target bone ',t),labelled('point ',sel('target-point',points))));}
|
|
115
|
+
else if(k==='aim'){const [b,t]=pickPair();fields.append(make('div',{className:'row'},labelled('Bone ',b)),make('div',{className:'row'},labelled('Target bone ',t),labelled('point ',sel('target-point',points))));}
|
|
116
|
+
else if(k==='range'){fields.append(make('div',{className:'row'},labelled('Bone ',pickBone('bone'))));}
|
|
117
|
+
else if(k==='stretch'){
|
|
118
|
+
const meshSlots=catalog.slots.filter(s=>catalog!.meshes[s]?.length),slotSel=sel('slot',meshSlots);if(slot&&meshSlots.includes(slot))slotSel.value=slot;
|
|
119
|
+
const att=sel('attachment',catalog.meshes[slotSel.value]??[]);slotSel.onchange=()=>options(att as HTMLSelectElement,catalog!.meshes[slotSel.value]??[]);
|
|
120
|
+
fields.append(make('div',{className:'row'},labelled('Slot ',slotSel),labelled('mesh ',att)));
|
|
121
|
+
} else if(k==='seam'){
|
|
122
|
+
const part=sel('part',catalog.slots);if(slot&&catalog.slots.includes(slot))part.value=slot;const neighbour=sel('neighbour',catalog.slots.filter(s=>s!==part.value));
|
|
123
|
+
part.onchange=()=>options(neighbour as HTMLSelectElement,catalog!.slots.filter(s=>s!==part.value));
|
|
124
|
+
fields.append(make('div',{className:'row'},labelled('Part ',part),labelled('neighbour ',neighbour)));
|
|
125
|
+
} else {
|
|
126
|
+
const keys=catalog.constraints.map(c=>`${c.type}:${c.name}`),constraint=sel('constraint',keys,v=>v.replace(':',' '));
|
|
127
|
+
const pick=()=>catalog!.constraints[Math.max(0,keys.indexOf(constraint.value))];
|
|
128
|
+
const bone=sel('bone',pick()?.bones??[]),prop=sel('property',pick()?.drives??[]);
|
|
129
|
+
constraint.onchange=()=>{options(bone as HTMLSelectElement,pick()?.bones??[]);options(prop as HTMLSelectElement,pick()?.drives??[]);};
|
|
130
|
+
fields.append(make('div',{className:'row'},labelled('Constraint ',constraint)),make('div',{className:'row'},labelled('Bone ',bone),labelled('property ',prop)),
|
|
131
|
+
make('div',{className:'row'},labelled('tolerance ',numberField('tolerance',.05,0)),labelled('least drive ',numberField('least-drive',2,0))));
|
|
132
|
+
}
|
|
133
|
+
const why=kindUnavailable(k,catalog);note.textContent=why?`${k} is not offered here: ${why}.`:`The bar (${BAR_FIELDS[k].map(b=>b.field).join(', ')}) is set on the slider once added; the producer reads ${KIND_FIELDS[k].join(', ')}.`;note.classList.toggle('error',!!why);
|
|
134
|
+
add.disabled=!!why||busy;
|
|
135
|
+
name.placeholder=requirementName(k,subjectOf(k)??'',declared.map(r=>r.name));
|
|
136
|
+
}
|
|
137
|
+
const v=(id:string)=>controls[id]?.value??'';
|
|
138
|
+
function subjectOf(k:RequirementKind):string|null {return k==='stretch'?v('slot'):k==='seam'?`${v('part')}_${v('neighbour')}`:v('bone')||null;}
|
|
139
|
+
/** The requirement the inputs describe, with each bar at its initial value; a missing name says which. */
|
|
140
|
+
function fromInputs():Requirement|string {
|
|
141
|
+
if(!catalog)return 'REQUIREMENTS_UNAVAILABLE: no rig spec loaded';
|
|
142
|
+
const k=kind.value as RequirementKind,anim=animation.value;if(!anim)return 'an animation is required';
|
|
143
|
+
const why=kindUnavailable(k,catalog);if(why)return `${k} is not offered here: ${why}`;
|
|
144
|
+
const subject=subjectOf(k);if(!subject)return 'pick the subject';
|
|
145
|
+
const n=name.value.trim()||requirementName(k,subject,declared.map(r=>r.name));
|
|
146
|
+
if(!REQUIREMENT_NAME.test(n))return `name ${JSON.stringify(n)}: a letter, then letters, digits and underscores`;
|
|
147
|
+
if(declared.some(r=>r.name===n))return `name ${n} is declared already`;
|
|
148
|
+
const bar=Object.fromEntries(BAR_FIELDS[k].map(b=>[b.field,b.initial]));const common={name:n,animation:anim};
|
|
149
|
+
const target=()=>({bone:v('target-bone'),point:v('target-point') as BonePoint});
|
|
150
|
+
for(const id of Object.keys(controls))if(!v(id))return `${id.replace('-',' ')} is required`;
|
|
151
|
+
if(k==='contact'||k==='aim'){const self=selfTargetRefusal({kind:k,bone:v('bone'),point:v('point') as BonePoint,target:target()});if(self)return self;}
|
|
152
|
+
if(k==='contact')return {...common,kind:k,bone:v('bone'),point:v('point') as BonePoint,target:target(),within_px:bar.within_px!};
|
|
153
|
+
if(k==='aim')return {...common,kind:k,bone:v('bone'),target:target(),within_degrees:bar.within_degrees!};
|
|
154
|
+
if(k==='range')return {...common,kind:k,bone:v('bone'),lo_degrees:bar.lo_degrees!,hi_degrees:bar.hi_degrees!};
|
|
155
|
+
if(k==='stretch')return {...common,kind:k,slot:v('slot'),attachment:v('attachment'),within_ratio:bar.within_ratio!};
|
|
156
|
+
if(k==='seam')return {...common,kind:k,part:v('part'),neighbour:v('neighbour'),within_px:bar.within_px!};
|
|
157
|
+
const [type,...rest]=v('constraint').split(':'),tolerance=Number(v('tolerance')),least=Number(v('least-drive'));
|
|
158
|
+
if(!Number.isFinite(tolerance)||tolerance<0)return 'tolerance: a number 0 or more';if(!Number.isFinite(least)||least<=0)return 'least drive: a number above 0';
|
|
159
|
+
return {...common,kind:'follow',constraint:rest.join(':'),constraint_type:type as 'ik'|'transform',bone:v('bone'),property:v('property') as 'rotate'|'translate',fraction:bar.fraction!,tolerance,least_drive:least};
|
|
160
|
+
}
|
|
161
|
+
const fpsValue=()=>{const n=Number(fps.value);return Number.isFinite(n)&&n>0&&n<=240?n:null;};
|
|
162
|
+
function fileText():string|null {const f=fpsValue();return declared.length&&f!==null?requirementsFileText(requirementsDocument(f,declared.map(requirementEntry))):null;}
|
|
163
|
+
function rehash(){const text=fileText();if(text===null){hashed=null;return;}void deps.hash(text).then(sha256=>{if(fileText()===text)hashed={text,sha256};});}
|
|
164
|
+
function panelStatus(text:string,error:boolean){status.textContent=text;status.title=text;status.classList.toggle('error',error);}
|
|
165
|
+
/** One row per declared requirement: its bar slider(s) (the last measured value marked on the track), its line, a seek to its worst frame. */
|
|
166
|
+
function renderList(){
|
|
167
|
+
list.replaceChildren();shown=[];
|
|
168
|
+
const current=job&&job.file.text===fileText()?job:null;
|
|
169
|
+
for(const r of declared){
|
|
170
|
+
const row=job?.rows.find(x=>x.name===r.name),stale=!!row&&!current;
|
|
171
|
+
const item=make('li',{className:`requirements-item${row?` ${row.status==='PASS'?'pass':'fail'}`:''}`});item.dataset.name=r.name;
|
|
172
|
+
const head=make('div',{className:'row'},make('code',{textContent:`${r.name} · ${r.kind} · ${r.animation}`}));
|
|
173
|
+
const remove=make('button',{type:'button',textContent:'Remove',title:`Remove ${r.name}`});remove.onclick=()=>panel.remove(r.name);head.append(remove);item.append(head);
|
|
174
|
+
for(const b of BAR_FIELDS[r.kind]){
|
|
175
|
+
const value=(r as unknown as Record<string,number>)[b.field]!,measured=measuredFor(row,b.field);
|
|
176
|
+
const lo=Math.min(b.min,value,measured??b.min),hi=Math.max(b.max,value,measured===null?b.max:Math.ceil(measured*1.25*100)/100);
|
|
177
|
+
const slider=make('input',{type:'range',min:String(lo),max:String(hi),step:String(b.step),value:String(value)});slider.setAttribute('aria-label',`${r.name} ${b.field}`);
|
|
178
|
+
if(measured!==null){const marks=make('datalist',{id:`requirements-mark-${r.name}-${b.field}`},make('option',{value:String(measured)}));slider.setAttribute('list',marks.id);item.append(marks);}
|
|
179
|
+
const out=make('output',{textContent:`${b.field} ${value}`});
|
|
180
|
+
slider.oninput=()=>{out.textContent=`${b.field} ${slider.value}`;};slider.onchange=()=>panel.setBar(r.name,b.field,Number(slider.value));
|
|
181
|
+
item.append(make('div',{className:'row requirements-bar'},slider,out,make('span',{className:'subtle',textContent:measured===null?'not measured yet':`measured ${measured}${stale?' (before this change)':''}`})));
|
|
182
|
+
}
|
|
183
|
+
if(row){
|
|
184
|
+
const text=row.line,worst=row.worst;shown.push(text);
|
|
185
|
+
if(worst){const go=make('button',{type:'button',className:'readings-go',textContent:text,title:`Seek to ${worst.text}`});go.onclick=()=>deps.seek(row.animation||r.animation,worst.frame,worst.time,`${r.name} worst`);item.append(go);}
|
|
186
|
+
else item.append(make('code',{textContent:text,title:text}));
|
|
187
|
+
}
|
|
188
|
+
list.append(item);
|
|
189
|
+
}
|
|
190
|
+
check.disabled=busy||!declared.length||!catalog||catalog.status!=='ready';
|
|
191
|
+
}
|
|
192
|
+
function changed(){rehash();renderList();renderFields();}
|
|
193
|
+
|
|
194
|
+
kind.onchange=renderFields;
|
|
195
|
+
add.onclick=()=>{const r=fromInputs();if(typeof r==='string'){panelStatus(r,true);return;}declared.push(r);name.value='';panelStatus(`Added ${r.name}; set its bar, then Check.`,false);changed();};
|
|
196
|
+
fps.onchange=changed;
|
|
197
|
+
check.onclick=()=>{void (async()=>{
|
|
198
|
+
const f=fpsValue();if(f===null){panelStatus('fps: a number above 0, at most 240',true);return;}
|
|
199
|
+
busy=true;check.disabled=true;check.textContent='Checking…';panelStatus(`rig-parts check --requirements on the ${build.value==='inspected'?'inspected rig':'latest scratch build'}…`,false);
|
|
200
|
+
try{panel.checked(await deps.check({fps:f,build:build.value as RequirementsBuild,requirements:declared.map(requirementEntry)}));}
|
|
201
|
+
finally{busy=false;check.textContent='Check';renderList();renderFields();}
|
|
202
|
+
})();};
|
|
203
|
+
|
|
204
|
+
const panel:RequirementsPanel={
|
|
205
|
+
load(next,reason){
|
|
206
|
+
// Only a ready spec is a catalog: an unavailable one (no producer handoff, #150) leaves the form empty, so selecting a part never renders fields from it.
|
|
207
|
+
catalog=readyCatalog(next);
|
|
208
|
+
if(!next||next.status!=='ready'){state.textContent=reason??next?.reason??'REQUIREMENTS_UNAVAILABLE: no producer handoff';state.classList.add('error');kind.disabled=animation.disabled=true;add.disabled=check.disabled=true;fields.replaceChildren();return;}
|
|
209
|
+
state.textContent=`Rig spec: ${next.bones.length} bones, ${next.slots.length} slots, ${Object.keys(next.meshes).length} mesh slot(s), ${next.constraints.length} ik/transform constraint(s), ${next.animations.length} animation(s)${next.scratch?' · a scratch build exists':''}.`;state.classList.remove('error');
|
|
210
|
+
kind.disabled=animation.disabled=false;
|
|
211
|
+
options(kind,REQUIREMENT_KINDS,k=>{const why=kindUnavailable(k as RequirementKind,next);return why?`${k} — unavailable`:k;});
|
|
212
|
+
options(animation,next.animations.map(a=>a.name));const drawn=deps.animation();if(next.animations.some(a=>a.name===drawn))animation.value=drawn;
|
|
213
|
+
renderFields();renderList();
|
|
214
|
+
},
|
|
215
|
+
selectionChanged(){const drawn=deps.animation();if(catalog?.animations.some(a=>a.name===drawn))animation.value=drawn;renderFields();},
|
|
216
|
+
requirements:()=>declared.map(r=>JSON.parse(JSON.stringify(r)) as Requirement),
|
|
217
|
+
add(r){declared.push(JSON.parse(JSON.stringify(r)) as Requirement);changed();},
|
|
218
|
+
remove(n){declared=declared.filter(r=>r.name!==n);changed();},
|
|
219
|
+
setBar(n,field,value){const r=declared.find(x=>x.name===n) as unknown as Record<string,unknown>|undefined;if(!r||!(KIND_FIELDS[(r.kind as RequirementKind)]??[]).includes(field)||!Number.isFinite(value))return;r[field]=value;changed();},
|
|
220
|
+
text:fileText,
|
|
221
|
+
checked(result){
|
|
222
|
+
refusals.replaceChildren();summary.textContent='';
|
|
223
|
+
if(!result){panelStatus('REQUIREMENTS_FAILED: no answer from the check',true);return;}
|
|
224
|
+
if(!('id' in result)){panelStatus(result.error,true);return;}
|
|
225
|
+
job=result;
|
|
226
|
+
for(const line of [...result.refusals,...result.problems])refusals.append(make('li',{},make('code',{textContent:line,title:line})));
|
|
227
|
+
summary.textContent=[result.summary,result.checkLine].filter(Boolean).join(' · ');
|
|
228
|
+
if(result.status==='checked')panelStatus(`${result.producer.package} ${result.producer.version} check on the ${result.build==='inspected'?'inspected rig':'scratch build'}: ${result.rows.length} line(s)${result.wallMs===null?'':` · ${(result.wallMs/1000).toFixed(1)} s`}`,result.rows.some(r=>r.status!=='PASS'));
|
|
229
|
+
else panelStatus(result.error??`requirements check ${result.status}`,true);
|
|
230
|
+
renderList();
|
|
231
|
+
},
|
|
232
|
+
context(){
|
|
233
|
+
const text=fileText();if(text===null||!hashed||hashed.text!==text)return null;
|
|
234
|
+
const last=job&&job.file.text===text&&job.status!=='running'&&job.status!=='failed'?job:null;
|
|
235
|
+
return {schema:REQUIREMENTS_CONTEXT_SCHEMA,sha256:hashed.sha256,size:new TextEncoder().encode(text).length,text,
|
|
236
|
+
checked:last?{build:last.build,producer:{...last.producer},summary:last.summary,refusals:[...last.refusals],rows:last.rows.map(r=>({name:r.name,status:r.status,worst:r.worst?{...r.worst}:null}))}:null};
|
|
237
|
+
},
|
|
238
|
+
shownLines:()=>[...shown],
|
|
239
|
+
};
|
|
240
|
+
return panel;
|
|
241
|
+
}
|
|
@@ -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
|
+
}
|