@rig-c/review 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -12
- package/docs/DESIGN.md +26 -4
- package/docs/REVIEW_PACKAGE.md +113 -0
- package/package.json +1 -1
- package/src/applied-context.ts +285 -0
- package/src/baseline.ts +175 -0
- package/src/bone-anchors.ts +123 -0
- package/src/bones-check.ts +105 -0
- package/src/build-route.ts +109 -0
- package/src/chest-landmarks.ts +122 -0
- package/src/chest-regions.ts +10 -4
- package/src/cli.ts +19 -8
- package/src/config-patch.ts +213 -0
- package/src/cut-edits.ts +184 -0
- package/src/environment.ts +19 -6
- package/src/feedback-export.ts +17 -1
- package/src/front.ts +11 -0
- package/src/handoff.ts +208 -0
- package/src/idle-tracks.ts +401 -0
- package/src/manifest.ts +15 -6
- package/src/mesh-edit.ts +45 -15
- package/src/mesh-entry.ts +351 -0
- package/src/open.ts +12 -5
- package/src/physics-tuning.ts +236 -0
- package/src/producer-files.ts +11 -0
- package/src/producer.ts +158 -0
- package/src/readings-run.ts +129 -0
- package/src/readings.ts +235 -0
- package/src/rebuild.ts +938 -0
- package/src/reference.ts +4 -3
- package/src/requirements-run.ts +160 -0
- package/src/requirements.ts +230 -0
- package/src/retarget.ts +211 -0
- package/src/retune-route.ts +52 -0
- package/src/rig-fields.ts +295 -0
- package/src/server.ts +59 -5
- package/src/session.ts +9 -0
- package/src/setup-mesh.ts +144 -0
- package/src/weights.ts +74 -0
- package/web/bones-panel.ts +230 -0
- package/web/chest-editor.ts +128 -13
- package/web/chest-regions-tool.ts +2 -1
- package/web/cuts-panel.ts +266 -0
- package/web/idle-panel.ts +280 -0
- package/web/index.html +1 -1
- package/web/inspector-blocks.ts +77 -0
- package/web/main.ts +701 -49
- package/web/mesh-editor.ts +73 -38
- package/web/mesh-panel.ts +273 -0
- package/web/physics-panel.ts +151 -0
- package/web/readings-panel.ts +105 -0
- package/web/rebuild-client.ts +216 -0
- package/web/requirements-panel.ts +238 -0
- package/web/retarget-panel.ts +203 -0
- package/web/rig-fields-panel.ts +269 -0
- package/web/rig-overlays.ts +108 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +28 -5
- package/web/weights-view.ts +159 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animate · Physics tuning panel (#103), page side: the rows `tuningRows` reads, one row editor (sliders, number fields
|
|
3
|
+
* and x / y / rotate toggles from `rowControls`), the chain ramp, the pending-ops list with per-op revert, Apply and
|
|
4
|
+
* Reset, the Apply status and the check readout. The panel's inputs carry no ids, so the source draft keeps only the
|
|
5
|
+
* pending-ops record (`#physics-pending`) and never counts a control per constraint.
|
|
6
|
+
*/
|
|
7
|
+
import {applyRamp,componentValue,draftEdits,draftText,nearestChain,patchOps,physicsChains,rowControls,setEdit,TUNING_FIELDS,COMPONENT_FIELDS,type PendingEdit,type PhysicsChain,type PhysicsField,type TuningRow,type TuningTable} from '../src/physics-tuning.ts';
|
|
8
|
+
import type {PatchOp} from '../src/config-patch.ts';
|
|
9
|
+
|
|
10
|
+
export interface PhysicsPanelDeps {
|
|
11
|
+
apply:(ops:PatchOp[])=>Promise<void>;
|
|
12
|
+
reset:()=>Promise<void>;
|
|
13
|
+
/** Link bones as pane pixels on the drawn frame, for picking a chain on stage; null when nothing is drawn. */
|
|
14
|
+
bonePoints:(bones:readonly string[])=>Map<string,{x:number;y:number}>|null;
|
|
15
|
+
pane:HTMLElement;
|
|
16
|
+
/** The draft record changed (the panel already wrote `#physics-pending`). */
|
|
17
|
+
saved:()=>void;
|
|
18
|
+
}
|
|
19
|
+
export interface PhysicsPanel {
|
|
20
|
+
load(table:TuningTable,bones:readonly {name:string;parent?:string|null}[]):void;
|
|
21
|
+
rows():TuningRow[];chains():PhysicsChain[];edits():PendingEdit[];
|
|
22
|
+
set(name:string,field:PhysicsField,value:number):void;
|
|
23
|
+
ramp(chainId:string,field:PhysicsField,root:number,tip:number):void;
|
|
24
|
+
select(name:string):void;
|
|
25
|
+
restore():void;clear():void;
|
|
26
|
+
applied(ops:readonly PatchOp[]|null):void;
|
|
27
|
+
status(text:string,error:boolean):void;
|
|
28
|
+
readout(lines:{name:string;before:string;after:string}[]):void;
|
|
29
|
+
busy(value:boolean):void;
|
|
30
|
+
dispose():void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
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;};
|
|
34
|
+
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;};
|
|
35
|
+
const shown=(value:number|undefined)=>value===undefined?'—':String(Number(value.toPrecision(6)));
|
|
36
|
+
|
|
37
|
+
export function bindPhysicsPanel(deps:PhysicsPanelDeps):PhysicsPanel {
|
|
38
|
+
const state=node('physics-panel-state'),rowsArea=node('physics-rows'),editor=node('physics-row-editor'),locked=node('physics-locked'),pending=node('physics-pending-list');
|
|
39
|
+
const chainSelect=node<HTMLSelectElement>('physics-chain'),pick=node<HTMLButtonElement>('physics-chain-pick'),rampField=node<HTMLSelectElement>('physics-ramp-field'),rampRoot=node<HTMLInputElement>('physics-ramp-root'),rampTip=node<HTMLInputElement>('physics-ramp-tip'),rampButton=node<HTMLButtonElement>('physics-ramp');
|
|
40
|
+
const applyButton=node<HTMLButtonElement>('physics-apply'),resetButton=node<HTMLButtonElement>('physics-reset-build'),status=node('physics-apply-status'),readoutArea=node('physics-readout'),draft=node<HTMLTextAreaElement>('physics-pending');
|
|
41
|
+
let table:TuningRow[]=[],chains:PhysicsChain[]=[],edits=new Map<string,PendingEdit>(),selected:string|null=null,appliedOps:string[]=[],busy=false,picking=false;
|
|
42
|
+
rampField.replaceChildren(...[...TUNING_FIELDS,...COMPONENT_FIELDS].map(f=>make('option',{value:f,textContent:f})));
|
|
43
|
+
rampField.value='damping';
|
|
44
|
+
|
|
45
|
+
const save=()=>{draft.value=draftText(edits);draft.dispatchEvent(new Event('input',{bubbles:true}));deps.saved();};
|
|
46
|
+
const change=(next:Map<string,PendingEdit>)=>{edits=next;save();render();};
|
|
47
|
+
const tryChange=(make:()=>Map<string,PendingEdit>)=>{try{change(make());}catch(error){panelStatus(error instanceof Error?error.message:String(error),true);}};
|
|
48
|
+
function panelStatus(text:string,error:boolean){status.textContent=text;status.title=text;status.classList.toggle('error',error);}
|
|
49
|
+
const row=(name:string)=>table.find(r=>r.name===name&&r.configIndex!==null)??table.find(r=>r.name===name);
|
|
50
|
+
|
|
51
|
+
function renderRows(){
|
|
52
|
+
const editable=table.filter(r=>r.editable);
|
|
53
|
+
const head=make('tr',{},...['Constraint','#',...TUNING_FIELDS,'x','y','rotate'].map(label=>make('th',{scope:'col',textContent:label})));
|
|
54
|
+
const body=make('tbody');
|
|
55
|
+
for(const r of editable){
|
|
56
|
+
const current=(field:PhysicsField)=>edits.get(`/constraints/${r.configIndex}/${field}`)?.value;
|
|
57
|
+
const name=make('button',{type:'button',textContent:r.name,className:'physics-row-pick',title:`config.constraints[${r.configIndex}] · bone ${r.bone??'—'}`});name.setAttribute('aria-pressed',String(selected===r.name));
|
|
58
|
+
name.onclick=()=>{selected=r.name;render();};
|
|
59
|
+
const cells=[...TUNING_FIELDS,...COMPONENT_FIELDS].map(field=>{const now=current(field),cell=make('td',{textContent:shown(now??r.values[field])});if(now!==undefined)cell.className='pending';else if(r.values[field]===undefined){cell.className='absent';cell.title='Not in the config (the producer default applies)';}return cell;});
|
|
60
|
+
const line=make('tr',{},make('th',{scope:'row'},name),make('td',{textContent:String(r.configIndex)}),...cells);if(selected===r.name)line.className='selected';body.append(line);
|
|
61
|
+
}
|
|
62
|
+
rowsArea.replaceChildren(make('table',{className:'physics-table'},make('thead',{},head),body));
|
|
63
|
+
const others=table.filter(r=>!r.editable);
|
|
64
|
+
const lockedBody=make('tbody');
|
|
65
|
+
for(const r of others){const line=make('tr',{},make('th',{scope:'row',textContent:r.name}),make('td',{textContent:r.kind}),make('td',{className:'physics-reason',textContent:r.reason??''}));line.dataset.notEditable='true';lockedBody.append(line);}
|
|
66
|
+
locked.replaceChildren(make('p',{className:'eyebrow',textContent:`NOT EDITABLE · ${others.length}`}),make('table',{className:'physics-table physics-locked-table'},make('thead',{},make('tr',{},...['Constraint','Kind','Reason'].map(l=>make('th',{scope:'col',textContent:l})))),lockedBody));
|
|
67
|
+
locked.hidden=!others.length;
|
|
68
|
+
}
|
|
69
|
+
function renderEditor(){
|
|
70
|
+
const r=selected?row(selected):undefined;
|
|
71
|
+
if(!r){editor.replaceChildren(make('p',{className:'hint',textContent:'Pick a constraint row or a chain to edit it.'}));return;}
|
|
72
|
+
const view=rowControls(r,edits),title=make('p',{className:'physics-editor-title'},make('strong',{textContent:r.name}),` · ${r.kind} · bone ${r.bone??'—'} · ${r.configIndex===null?'not in the config':`/constraints/${r.configIndex}`}`);
|
|
73
|
+
if(!view.editable){const reason=make('p',{className:'hint error physics-reason',textContent:view.reason});editor.replaceChildren(title,reason);return;}
|
|
74
|
+
const list=make('div',{className:'physics-controls'});
|
|
75
|
+
for(const control of view.controls){
|
|
76
|
+
if(control.kind==='slider'){
|
|
77
|
+
const label=`${r.name} ${control.field}`;
|
|
78
|
+
const range=make('input',{type:'range',min:String(control.min),max:String(control.max),step:String(control.step),value:String(control.value),disabled:busy});range.setAttribute('aria-label',label);range.dataset.field=control.field;
|
|
79
|
+
const number=make('input',{type:'number',step:'any',value:String(control.value),disabled:busy,inputMode:'decimal'});number.setAttribute('aria-label',`${label} value`);number.dataset.field=control.field;
|
|
80
|
+
const commit=(raw:string)=>{const value=Number(raw);if(raw.trim()===''||!Number.isFinite(value)){panelStatus(`PHYSICS_VALUE_INVALID: ${r.name}.${control.field} must be a finite number`,true);return;}tryChange(()=>setEdit(edits,r,control.field,value));};
|
|
81
|
+
range.onchange=()=>commit(range.value);range.oninput=()=>{number.value=range.value;};number.onchange=()=>commit(number.value);
|
|
82
|
+
list.append(make('label',{className:control.pending?'physics-control pending':'physics-control'},make('span',{textContent:control.field}),range,number,make('output',{textContent:control.pending?`was ${shown(control.base)}`:''})));
|
|
83
|
+
}else{
|
|
84
|
+
const box=make('input',{type:'checkbox',checked:control.on,disabled:busy});box.setAttribute('aria-label',`${r.name} ${control.field} component`);box.dataset.field=control.field;
|
|
85
|
+
box.onchange=()=>tryChange(()=>setEdit(edits,r,control.field,componentValue(r,control.field,box.checked)));
|
|
86
|
+
list.append(make('label',{className:control.pending?'physics-toggle pending':'physics-toggle'},box,`${control.field}${control.base===null?'':` (${shown(control.base)})`}`));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
editor.replaceChildren(title,list);
|
|
90
|
+
}
|
|
91
|
+
function renderPending(){
|
|
92
|
+
const items=[...edits.values()];
|
|
93
|
+
pending.replaceChildren(...items.map(e=>{
|
|
94
|
+
const revert=make('button',{type:'button',textContent:'Revert',disabled:busy});revert.setAttribute('aria-label',`Revert ${e.path}`);
|
|
95
|
+
revert.onclick=()=>{const next=new Map(edits);next.delete(e.path);change(next);};
|
|
96
|
+
const applied=appliedOps.includes(JSON.stringify({op:e.op,path:e.path,value:e.value,name:e.name}));
|
|
97
|
+
return make('li',{className:applied?'applied':''},make('code',{textContent:`${e.op} ${e.path}`}),` ${e.from===null?'(absent)':shown(e.from)} → ${shown(e.value)} · ${e.name}${applied?' · applied':''}`,revert);
|
|
98
|
+
}));
|
|
99
|
+
if(!items.length)pending.append(make('li',{className:'hint',textContent:'No pending ops.'}));
|
|
100
|
+
applyButton.disabled=busy||!items.length;resetButton.disabled=busy;
|
|
101
|
+
}
|
|
102
|
+
function renderChains(){
|
|
103
|
+
const keep=chainSelect.value;
|
|
104
|
+
chainSelect.replaceChildren(make('option',{value:'',textContent:chains.length?'Select a chain':'No chain of two or more links'}),...chains.map(c=>make('option',{value:c.id,textContent:c.label})));
|
|
105
|
+
if(chains.some(c=>c.id===keep))chainSelect.value=keep;
|
|
106
|
+
for(const control of [chainSelect,rampField,rampRoot,rampTip,rampButton,pick])control.disabled=busy||!chains.length;
|
|
107
|
+
}
|
|
108
|
+
function render(){renderRows();renderEditor();renderPending();renderChains();}
|
|
109
|
+
|
|
110
|
+
chainSelect.onchange=()=>{const chain=chains.find(c=>c.id===chainSelect.value);if(chain){selected=chain.links[0]!.name;seedRamp(chain);render();}};
|
|
111
|
+
rampField.onchange=()=>{const chain=chains.find(c=>c.id===chainSelect.value);if(chain)seedRamp(chain);};
|
|
112
|
+
function seedRamp(chain:PhysicsChain){const field=rampField.value as PhysicsField,value=(r:TuningRow)=>edits.get(`/constraints/${r.configIndex}/${field}`)?.value??r.values[field];const root=value(chain.links[0]!),tip=value(chain.links.at(-1)!);rampRoot.value=root===undefined?'':String(root);rampTip.value=tip===undefined?'':String(tip);}
|
|
113
|
+
rampButton.onclick=()=>{const chain=chains.find(c=>c.id===chainSelect.value);if(!chain){panelStatus('PHYSICS_CHAIN_NONE: select a chain first',true);return;}
|
|
114
|
+
const root=Number(rampRoot.value),tip=Number(rampTip.value);if(!rampRoot.value.trim()||!rampTip.value.trim()||!Number.isFinite(root)||!Number.isFinite(tip)){panelStatus('PHYSICS_VALUE_INVALID: the ramp needs a finite root and tip value',true);return;}
|
|
115
|
+
tryChange(()=>applyRamp(edits,chain,rampField.value as PhysicsField,root,tip));panelStatus(`Ramped ${rampField.value} over ${chain.links.length} links (${root} → ${tip}); Apply rebuilds.`,false);};
|
|
116
|
+
pick.onclick=()=>{picking=!picking;pick.setAttribute('aria-pressed',String(picking));deps.pane.dataset.physicsPick=String(picking);panelStatus(picking?'Click a chain on the stage (a link bone within 24 px).':'',false);};
|
|
117
|
+
const onPick=(event:PointerEvent)=>{
|
|
118
|
+
if(!picking||event.button!==0)return;
|
|
119
|
+
const points=deps.bonePoints(chains.flatMap(c=>c.links.map(l=>l.bone!)));if(!points)return;
|
|
120
|
+
const box=deps.pane.getBoundingClientRect(),chain=nearestChain(chains,points,{x:event.clientX-box.left,y:event.clientY-box.top});
|
|
121
|
+
event.stopPropagation();event.preventDefault();picking=false;pick.setAttribute('aria-pressed','false');deps.pane.dataset.physicsPick='false';
|
|
122
|
+
if(!chain){panelStatus('PHYSICS_CHAIN_NONE: no chain link bone within 24 px of the click',true);return;}
|
|
123
|
+
chainSelect.value=chain.id;chainSelect.dispatchEvent(new Event('change'));panelStatus(`Picked ${chain.label}.`,false);
|
|
124
|
+
};
|
|
125
|
+
deps.pane.addEventListener('pointerdown',onPick,{capture:true});
|
|
126
|
+
applyButton.onclick=()=>{if(!edits.size||busy)return;void deps.apply(patchOps(edits));};
|
|
127
|
+
resetButton.onclick=()=>{if(busy)return;void deps.reset();};
|
|
128
|
+
|
|
129
|
+
const panel:PhysicsPanel={
|
|
130
|
+
load(value,bones){
|
|
131
|
+
if(!value.available){table=[];chains=[];state.textContent=value.reason;state.classList.add('error');render();return;}
|
|
132
|
+
table=value.rows;chains=physicsChains(table,bones);
|
|
133
|
+
const editable=table.filter(r=>r.editable).length;
|
|
134
|
+
state.classList.remove('error');state.textContent=`${editable} physics constraints editable from the handoff config · ${table.length-editable} not editable (reasons below) · ${chains.length} chains from bone parentage`;
|
|
135
|
+
edits=draftEdits(draft.value,table);render();
|
|
136
|
+
},
|
|
137
|
+
rows:()=>table.map(r=>({...r,values:{...r.values}})),chains:()=>chains.map(c=>({...c,links:[...c.links]})),edits:()=>[...edits.values()].map(e=>({...e})),
|
|
138
|
+
set(name,field,value){const r=row(name);if(!r)throw new Error(`PHYSICS_ROW_UNKNOWN: ${name}`);change(setEdit(edits,r,field,value));},
|
|
139
|
+
ramp(chainId,field,root,tip){const chain=chains.find(c=>c.id===chainId);if(!chain)throw new Error(`PHYSICS_CHAIN_UNKNOWN: ${chainId}`);change(applyRamp(edits,chain,field,root,tip));},
|
|
140
|
+
select(name){selected=name;chainSelect.value='';render();},
|
|
141
|
+
restore(){edits=draftEdits(draft.value,table);render();},
|
|
142
|
+
clear(){change(new Map());},
|
|
143
|
+
applied(ops){appliedOps=(ops??[]).map(o=>JSON.stringify({op:o.op,path:o.path,value:o.value,name:o.name}));renderPending();},
|
|
144
|
+
status:panelStatus,
|
|
145
|
+
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}))]));},
|
|
146
|
+
busy(value){busy=value;render();},
|
|
147
|
+
dispose(){deps.pane.removeEventListener('pointerdown',onPick,{capture:true});},
|
|
148
|
+
};
|
|
149
|
+
render();
|
|
150
|
+
return panel;
|
|
151
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animate · Readings (#108), page side: the pickers (Folds and Overlaps toggles; a cloth:leg pass-through pair and a
|
|
3
|
+
* slot:slot seam pair from the model's slots; a bone and the bone it is read against), Run, the per-pair worst-frame
|
|
4
|
+
* lines and the timeline ticks on the transport scrub — two colours, the inspected build above the scratch build — each
|
|
5
|
+
* tick a button that seeks to its frame's time. The server runs `rigc render`; this draws what came back.
|
|
6
|
+
*/
|
|
7
|
+
import {readingTicks,ticksOnClip,TICK_EDGE,worstLine,type ReadingBuild,type ReadingRequest,type ReadingResult,type ReadingTick} from '../src/readings.ts';
|
|
8
|
+
|
|
9
|
+
export interface ReadingsJobView {id:string;status:'running'|'done'|'failed';error:string|null;request:{animation:string;readings:ReadingRequest[];environment:{wind:[number,number];gravity:[number,number]}|null};environment:{requested:boolean;flag:boolean;passed:boolean};compiler:{package:string;version:string};runs:{build:ReadingBuild;id:string;modelHash:string;results:ReadingResult[]}[]}
|
|
10
|
+
export interface ReadingsRefusal {status:'refused';error:string}
|
|
11
|
+
export interface ReadingsPanel {
|
|
12
|
+
catalog(slots:readonly string[],bones:readonly string[]):void;
|
|
13
|
+
/** The readings the pickers name, or the line saying what is missing. */
|
|
14
|
+
requests():{readings:ReadingRequest[]}|{error:string};
|
|
15
|
+
busy(value:boolean):void;
|
|
16
|
+
status(text:string,error:boolean):void;
|
|
17
|
+
/** Worst-frame lines for a finished job; each line seeks to its worst frame. */
|
|
18
|
+
lines(job:ReadingsJobView|null):void;
|
|
19
|
+
/** The ticks on the scrub for the drawn animation and its duration; hidden when the job's animation is not the one drawn. */
|
|
20
|
+
ticks(job:ReadingsJobView|null,animation:string,duration:number):ReadingTick[];
|
|
21
|
+
shownLines():string[];
|
|
22
|
+
}
|
|
23
|
+
export interface ReadingsPanelDeps {run:()=>void;seek:(animation:string,frame:number,time:number|null,label:string)=>void}
|
|
24
|
+
|
|
25
|
+
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;};
|
|
26
|
+
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;};
|
|
27
|
+
/** The range input's thumb travels inside the track by about its own width; ticks sit under the thumb's centre. */
|
|
28
|
+
const THUMB=14;
|
|
29
|
+
export const EDGE=TICK_EDGE;
|
|
30
|
+
|
|
31
|
+
export function bindReadingsPanel(deps:ReadingsPanelDeps):ReadingsPanel {
|
|
32
|
+
const folds=node<HTMLInputElement>('readings-folds'),overlaps=node<HTMLInputElement>('readings-overlaps');
|
|
33
|
+
const pairs={
|
|
34
|
+
pass:{on:node<HTMLInputElement>('readings-pass-on'),a:node<HTMLInputElement>('readings-pass-cloth'),b:node<HTMLInputElement>('readings-pass-leg')},
|
|
35
|
+
seam:{on:node<HTMLInputElement>('readings-seam-on'),a:node<HTMLInputElement>('readings-seam-a'),b:node<HTMLInputElement>('readings-seam-b')},
|
|
36
|
+
angle:{on:node<HTMLInputElement>('readings-angle-on'),a:node<HTMLInputElement>('readings-angle-bone'),b:node<HTMLInputElement>('readings-angle-relative')},
|
|
37
|
+
};
|
|
38
|
+
const run=node<HTMLButtonElement>('readings-run'),status=node('readings-status'),list=node('readings-worst'),strip=node('reading-ticks'),transport=node('transport'),scrub=node<HTMLInputElement>('scrub');
|
|
39
|
+
let shown:string[]=[],enabled=false,isBusy=false;
|
|
40
|
+
// Typing both names of a pair turns its picker on; clearing one turns it off.
|
|
41
|
+
for(const pair of Object.values(pairs)){const sync=()=>{pair.on.checked=!!pair.a.value.trim()&&!!pair.b.value.trim();};pair.a.addEventListener('input',sync);pair.b.addEventListener('input',sync);}
|
|
42
|
+
run.onclick=()=>deps.run();
|
|
43
|
+
const setRun=()=>{run.disabled=!enabled||isBusy;};
|
|
44
|
+
|
|
45
|
+
function requests(){
|
|
46
|
+
const readings:ReadingRequest[]=[];
|
|
47
|
+
if(folds.checked)readings.push({kind:'folds'});
|
|
48
|
+
const pair=(p:typeof pairs.pass,what:string)=>{const a=p.a.value.trim(),b=p.b.value.trim();if(!p.on.checked)return null;if(!a||!b)return `READINGS_PAIR_INCOMPLETE: ${what} needs both names`;return [a,b] as const;};
|
|
49
|
+
const pass=pair(pairs.pass,'pass-through');if(typeof pass==='string')return {error:pass};if(pass)readings.push({kind:'pass-through',cloth:pass[0],leg:pass[1]});
|
|
50
|
+
const seam=pair(pairs.seam,'seam');if(typeof seam==='string')return {error:seam};if(seam)readings.push({kind:'seam',a:seam[0],b:seam[1]});
|
|
51
|
+
if(overlaps.checked)readings.push({kind:'overlaps'});
|
|
52
|
+
const angle=pair(pairs.angle,'angle range');if(typeof angle==='string')return {error:angle};if(angle)readings.push({kind:'angle-range',bone:angle[0],relative:angle[1]});
|
|
53
|
+
return readings.length?{readings}:{error:'READINGS_NONE_CHOSEN: pick at least one reading'};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function lines(job:ReadingsJobView|null){
|
|
57
|
+
list.replaceChildren();shown=[];
|
|
58
|
+
if(!job)return;
|
|
59
|
+
for(const run of job.runs)for(const result of run.results){
|
|
60
|
+
const many=result.status==='read'&&result.items.length>1;
|
|
61
|
+
const line=worstLine(run.build,result),frame=result.worst?.frame??null;
|
|
62
|
+
const item=make('li',{className:`readings-line ${run.build}${result.status==='read'?'':' error'}`});
|
|
63
|
+
const swatch=make('span',{className:`tick-swatch ${run.build}`});swatch.setAttribute('aria-hidden','true');
|
|
64
|
+
if(frame!==null){const go=make('button',{type:'button',className:'readings-go',textContent:line,title:line});go.onclick=()=>deps.seek(job.request.animation,frame,result.fps?frame/result.fps:null,`${run.build} · ${result.label} worst`);item.append(swatch,go);}
|
|
65
|
+
else item.append(swatch,make('code',{textContent:line,title:line}));
|
|
66
|
+
shown.push(line);
|
|
67
|
+
if(many){
|
|
68
|
+
// Folds and overlaps read many meshes or pairs: each one's own worst line, folded away under the reading's.
|
|
69
|
+
const details=make('details',{className:'readings-items'}),summary=make('summary',{textContent:`${result.items.length} ${result.reading.kind==='folds'?'folding meshes':'moving pairs'}`});details.append(summary);
|
|
70
|
+
const inner=make('ul');for(const sub of result.items){const text=worstLine(run.build,result,sub),f=sub.worst?.frame??null;const li=make('li');if(f!==null){const go=make('button',{type:'button',className:'readings-go',textContent:text,title:text});go.onclick=()=>deps.seek(job.request.animation,f,result.fps?f/result.fps:null,`${run.build} · ${result.reading.kind} ${sub.pair} worst`);li.append(go);}else li.append(make('code',{textContent:text}));inner.append(li);}
|
|
71
|
+
details.append(inner);item.append(details);
|
|
72
|
+
}
|
|
73
|
+
list.append(item);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ticks(job:ReadingsJobView|null,animation:string,duration:number):ReadingTick[]{
|
|
78
|
+
strip.replaceChildren();
|
|
79
|
+
const all=job?readingTicks(job.runs):[],visible=job?ticksOnClip(all,job.request.animation,animation,duration):[];
|
|
80
|
+
strip.hidden=!visible.length;transport.classList.toggle('has-reading-ticks',!!visible.length);
|
|
81
|
+
if(!visible.length)return visible;
|
|
82
|
+
// Under the scrub's track: its own box inside the transport row.
|
|
83
|
+
strip.style.left=`${scrub.offsetLeft+THUMB/2}px`;strip.style.width=`${Math.max(0,scrub.offsetWidth-THUMB)}px`;strip.style.top=`${scrub.offsetTop+scrub.offsetHeight}px`;
|
|
84
|
+
// One button per (build, frame): the labels of every reading that lists the frame, so overlapping ticks stay one target.
|
|
85
|
+
const byFrame=new Map<string,ReadingTick[]>();for(const t of visible){const key=`${t.build}:${t.frame}`;byFrame.set(key,[...(byFrame.get(key)??[]),t]);}
|
|
86
|
+
for(const group of byFrame.values()){
|
|
87
|
+
const first=group[0]!,label=`${first.build} · F${first.frame} · ${first.time!.toFixed(4)} s · ${group.map(t=>t.label).join(', ')}`;
|
|
88
|
+
const tick=make('button',{type:'button',className:`reading-tick ${first.build}`,title:label});tick.setAttribute('aria-label',label);tick.dataset.frame=String(first.frame);tick.dataset.build=first.build;
|
|
89
|
+
const at=Math.min(first.time!,duration);tick.style.left=`${(at/duration)*100}%`;tick.onclick=()=>deps.seek(job!.request.animation,first.frame,first.time,label);
|
|
90
|
+
strip.append(tick);
|
|
91
|
+
}
|
|
92
|
+
return visible;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
catalog(slots,bones){
|
|
97
|
+
node('readings-slots').replaceChildren(...slots.map(s=>make('option',{value:s})));node('readings-bones').replaceChildren(...bones.map(b=>make('option',{value:b})));
|
|
98
|
+
enabled=true;setRun();
|
|
99
|
+
},
|
|
100
|
+
requests,
|
|
101
|
+
busy(value){isBusy=value;setRun();run.textContent=value?'Running…':'Run readings';},
|
|
102
|
+
status(text,error){status.textContent=text;status.title=text;status.classList.toggle('error',error);},
|
|
103
|
+
lines,ticks,shownLines:()=>[...shown],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply · Rebuild, page side (#99): the chest editor's rows become the producer's region entries, one typed request
|
|
3
|
+
* goes to `/api/rebuild`, the job's lines stream back by polling, and the applied state is recorded. No DOM here
|
|
4
|
+
* (main.ts owns the elements and the player); the server owns every path and the producer's flags.
|
|
5
|
+
*/
|
|
6
|
+
import {ellipsePolygon} from '../src/chest-regions.ts';
|
|
7
|
+
import type {BustSpec} from '../src/chest-landmarks.ts';
|
|
8
|
+
import {CONFIG_PATCH_SCHEMA,type ConfigPatch,type PatchCmd,type PatchOp} from '../src/config-patch.ts';
|
|
9
|
+
import {APPLIED_DELTA_SCHEMA,APPLIED_PATCH_SCHEMA,APPLIED_RETARGET_SCHEMA,filesDiffText,type AppliedAssembled,type AppliedDelta,type AppliedPatch,type AppliedRetarget,type CheckSummary,type FilesDiff} from '../src/applied-context.ts';
|
|
10
|
+
import type {MotionSummary,RetargetFlags,RetargetLine,RetargetRecord} from '../src/retarget.ts';
|
|
11
|
+
export type {ConfigPatch,PatchOp,AppliedPatch,AppliedDelta,AppliedRetarget,CheckSummary,BustSpec};
|
|
12
|
+
import {chestContourFields,chestRegionRows,chestRowId,type ChestFieldReader} from './chest-regions-tool.ts';
|
|
13
|
+
import type {InspectedCheckView} from '../src/physics-tuning.ts';
|
|
14
|
+
|
|
15
|
+
export type RegionEntry=
|
|
16
|
+
|{name:string;shape:'circle';cx:number;cy:number;r:number;bone:string;spacing:number;band:number}
|
|
17
|
+
|{name:string;bone:string;shape:'polygon';points:[number,number][];spacing:number;band:number};
|
|
18
|
+
/** The producer handoff (#101) as `/api/rebuild` reports it: paths relative to the package root, how it was found. */
|
|
19
|
+
export interface HandoffView {how:'declared'|'discovered';via:string;outside:boolean;config:{path:string;sha256:string};parts:string|null;rig:string|null;files:{path:string;sha256:string}[];missing:string[]}
|
|
20
|
+
export interface BaselineView {status:'running'|'reproduced'|'not-reproduced'|'refused'|'failed';firstDifference:string|null;hashes:{baseline:{rigJson:string|null;tree:string};inspected:{rigJson:string|null;tree:string}}|null;line:string;wallMs:number|null}
|
|
21
|
+
export interface RetargetStatusView {available:boolean;reason:string|null;producer:{package:string;version:string}|null;compiler:{package:string;version:string}|null;rig:string|null;sequences:{id:string;label:string;path:string;cleaned:boolean;frames:number;people:string[]}[];bones:string[];animations:string[]}
|
|
22
|
+
export interface RebuildStatusView {retarget?:RetargetStatusView;available:boolean;handoff:HandoffView|null;baseline:BaselineView|null;inputs:{config:string;parts:string|null;rig:string|null;configHash:string}|null;parts:{name:string;regions:unknown[]}[];contour:Record<string,Record<string,unknown>>;meshes?:Record<string,unknown>;constraints?:unknown[]|null;bones?:unknown[]|null;fields?:Record<string,unknown>|null;cuts?:{config:Record<string,unknown>;build:string|null}|null;inspectedCheck?:InspectedCheckView|null;idle?:import('../src/idle-tracks.ts').IdleStatus|null;producer:{package:string;version:string}|null;compiler:{package:string;version:string}|null;reason:string|null}
|
|
23
|
+
export interface RebuildJobView {id:string;status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;exitCode:number|null;wallMs:{remesh:number|null;compile:number|null};build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{part:string;regions:RegionEntry[];configHash:string;entrySha256:string;bust?:BustSpec};producer:{package:string;version:string};compiler:{package:string;version:string}}
|
|
24
|
+
export interface RebuildRefusal {status:'refused';error:string;producerLine:string|null}
|
|
25
|
+
/** #99 regions or #105 landmarks: exactly one of `regions` and `bust`. */
|
|
26
|
+
export type RebuildRequestBody={part:string;regions:RegionEntry[];configHash:string}|{part:string;bust:BustSpec;configHash:string};
|
|
27
|
+
/** What the draft keeps and the feedback context carries: the exact last applied inputs and the producer's line. */
|
|
28
|
+
export interface AppliedRecord {schema:'rig-review-applied/1';buildId:string;part:string;regions:RegionEntry[];bust?:BustSpec;configHash:string;entrySha256:string;producer:{package:string;version:string};compiler:{package:string;version:string};line:string|null;modelHash:string}
|
|
29
|
+
|
|
30
|
+
const number=(read:ChestFieldReader,id:string)=>{const raw=(read(id)?.value??'').trim();if(!raw)return null;const value=Number(raw);return Number.isFinite(value)?value:Number.NaN;};
|
|
31
|
+
|
|
32
|
+
/** Rows with a centre x are designated; a row with equal semi-axes and no clip is the config's circle, else the 32-sample polygon. */
|
|
33
|
+
export function designatedRegions(read:ChestFieldReader):{regions:RegionEntry[]}|{error:string} {
|
|
34
|
+
const regions:RegionEntry[]=[];
|
|
35
|
+
for(const row of chestRegionRows){
|
|
36
|
+
if(number(read,chestRowId(row,'cx'))===null)continue;
|
|
37
|
+
const name=(read(chestRowId(row,'name'))?.value??'').trim(),bone=(read(chestRowId(row,'bone'))?.value??'').trim(),tag=`row ${row+1}`;
|
|
38
|
+
if(!name)return {error:`REBUILD_REGION_INCOMPLETE: ${tag} name`};if(!bone)return {error:`REBUILD_REGION_INCOMPLETE: ${name} bone`};
|
|
39
|
+
const values:Record<string,number|null>={};
|
|
40
|
+
for(const field of ['cx','cy','ax','ay','spacing','band','clip'] as const){values[field]=number(read,chestRowId(row,field));if(Number.isNaN(values[field]))return {error:`REBUILD_REGION_INCOMPLETE: ${name} ${field}`};}
|
|
41
|
+
for(const field of ['cy','ax','spacing','band'] as const)if(values[field]===null)return {error:`REBUILD_REGION_INCOMPLETE: ${name} ${field}`};
|
|
42
|
+
const {cx,cy,ax,spacing,band,clip}=values as Record<string,number>,ay=values.ay??ax;
|
|
43
|
+
if(ax===ay&&values.clip===null)regions.push({name,shape:'circle',cx,cy,r:ax,bone,spacing,band});
|
|
44
|
+
else regions.push({name,bone,shape:'polygon',points:ellipsePolygon([cx,cy],[ax,ay],32,values.clip===null?undefined:clip),spacing,band});
|
|
45
|
+
}
|
|
46
|
+
return regions.length?{regions}:{error:'REBUILD_REGION_INCOMPLETE: no region has a centre'};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The contour part to rebuild: the one whose regions name the first designated bone, else the only one, else none. */
|
|
50
|
+
export function pickPart(status:Pick<RebuildStatusView,'parts'>,regions:readonly RegionEntry[]):string|null {
|
|
51
|
+
const bones=new Set(regions.map(r=>r.bone));
|
|
52
|
+
const named=status.parts.filter(p=>p.regions.some(r=>!!r&&typeof r==='object'&&bones.has(String((r as {bone?:unknown}).bone))));
|
|
53
|
+
if(named.length===1)return named[0]!.name;
|
|
54
|
+
return status.parts.length===1?status.parts[0]!.name:null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The part a landmarks Apply rebuilds: the contour part whose entry already states `contour.bust`, else as for regions. */
|
|
58
|
+
export function pickBustPart(status:Pick<RebuildStatusView,'parts'|'contour'>,derived:readonly RegionEntry[]):string|null {
|
|
59
|
+
const stated=status.parts.filter(p=>{const c=status.contour[p.name]?.contour;return !!c&&typeof c==='object'&&'bust' in (c as Record<string,unknown>);});
|
|
60
|
+
return stated.length===1?stated[0]!.name:pickPart(status,derived);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A landmarks Apply (#105): `{part, bust, configHash}`, or the line that refuses it before anything is sent — a point not
|
|
64
|
+
* placed, or review's pre-check of the producer's `CONFIG_BUST_CLEARANCE` on the same numbers. Bone names are the
|
|
65
|
+
* producer's to resolve (`CONFIG_NAME_RESOLVES` comes back verbatim).
|
|
66
|
+
*/
|
|
67
|
+
export function landmarksRequest(status:Pick<RebuildStatusView,'parts'|'contour'|'inputs'>,read:import('./chest-editor.ts').LandmarkRead):{body:RebuildRequestBody;part:string}|{error:string} {
|
|
68
|
+
if('error' in read)return {error:read.error};
|
|
69
|
+
if('missing' in read)return {error:`LANDMARKS_INCOMPLETE: place ${read.missing.join(', ')}`};
|
|
70
|
+
if(read.derived.refusal)return {error:read.derived.refusal};
|
|
71
|
+
if(!status.inputs)return {error:'REBUILD_STATUS_UNAVAILABLE'};
|
|
72
|
+
const part=pickBustPart(status,read.derived.regions);
|
|
73
|
+
if(!part)return {error:`REBUILD_PART_AMBIGUOUS: the config states ${status.parts.length} contour meshes and the landmark bones name none of them alone`};
|
|
74
|
+
return {part,body:{part,bust:read.spec,configHash:status.inputs.configHash}};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Field values that start the editor from the producer config: the first contour part's circle regions (at most two
|
|
79
|
+
* rows) and its contour numbers. Only for an untouched editor — `null` once any row has a centre.
|
|
80
|
+
*/
|
|
81
|
+
export function seedFields(status:Pick<RebuildStatusView,'parts'|'contour'>,read:ChestFieldReader):Record<string,string>|null {
|
|
82
|
+
if(chestRegionRows.some(row=>(read(chestRowId(row,'cx'))?.value??'').trim()))return null;
|
|
83
|
+
const part=status.parts.find(p=>p.regions.length),entry=part?status.contour[part.name]:undefined;if(!part||!entry)return null;
|
|
84
|
+
const out:Record<string,string>={},contour=(entry.contour??{}) as Record<string,unknown>;
|
|
85
|
+
part.regions.slice(0,chestRegionRows.length).forEach((value,row)=>{
|
|
86
|
+
const r=value as Record<string,unknown>;if(r.shape!=='circle')return;
|
|
87
|
+
for(const [field,key] of [['name','name'],['bone','bone'],['cx','cx'],['cy','cy'],['ax','r'],['ay','r'],['spacing','spacing'],['band','band']] as const)if(r[key]!==undefined)out[chestRowId(row,field)]=String(r[key]);
|
|
88
|
+
});
|
|
89
|
+
for(const [key,id] of chestContourFields)if(typeof contour[key]==='number')out[id]=String(contour[key]);
|
|
90
|
+
if(typeof contour.bridges==='boolean')out['chest-bridges']=String(contour.bridges);
|
|
91
|
+
if(typeof entry.r==='number')out['chest-r']=String(entry.r);
|
|
92
|
+
if(Array.isArray(entry.segments))out['chest-segments']=JSON.stringify(entry.segments);
|
|
93
|
+
return Object.keys(out).length?out:null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Landmark field values (#105) from a config entry that already states `contour.bust`, for an untouched Landmarks mode
|
|
98
|
+
* only (`null` once any landmark has a value). Absent alpha / clearance seed the producer's defaults.
|
|
99
|
+
*/
|
|
100
|
+
export function seedLandmarks(status:Pick<RebuildStatusView,'parts'|'contour'>,read:ChestFieldReader):Record<string,string>|null {
|
|
101
|
+
if(['apex_r','apex_l','cleavage'].some(k=>(read(`lm-${k}-x`)?.value??'').trim()||(read(`lm-${k}-y`)?.value??'').trim()))return null;
|
|
102
|
+
for(const p of status.parts){
|
|
103
|
+
const bust=(status.contour[p.name]?.contour as Record<string,unknown>|undefined)?.bust as Record<string,unknown>|undefined;if(!bust||typeof bust!=='object')continue;
|
|
104
|
+
const out:Record<string,string>={};
|
|
105
|
+
for(const k of ['apex_r','apex_l','cleavage'])if(Array.isArray(bust[k])&&(bust[k] as unknown[]).length===2){out[`lm-${k}-x`]=String((bust[k] as number[])[0]);out[`lm-${k}-y`]=String((bust[k] as number[])[1]);}
|
|
106
|
+
for(const k of ['alpha','clearance'])if(typeof bust[k]==='number')out[`lm-${k}`]=String(bust[k]);
|
|
107
|
+
for(const k of ['bone_r','bone_l'])if(typeof bust[k]==='string')out[`lm-${k}`]=bust[k] as string;
|
|
108
|
+
return Object.keys(out).length?out:null;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const appliedRecord=(job:RebuildJobView):AppliedRecord=>({schema:'rig-review-applied/1',buildId:job.id,part:job.request.part,regions:job.request.regions,...(job.request.bust?{bust:job.request.bust}:{}),configHash:job.request.configHash,entrySha256:job.request.entrySha256,producer:job.producer,compiler:job.compiler,line:job.producerLine,modelHash:job.build!.modelHash});
|
|
114
|
+
export function parseApplied(text:string):AppliedRecord|AppliedPatch|AppliedDelta|AppliedRetarget|null {try{const value=JSON.parse(text);return value?.schema==='rig-review-applied/1'||value?.schema===APPLIED_PATCH_SCHEMA||value?.schema===APPLIED_DELTA_SCHEMA||value?.schema===APPLIED_RETARGET_SCHEMA?value as AppliedRecord|AppliedPatch|AppliedDelta|AppliedRetarget:null;}catch{return null;}}
|
|
115
|
+
|
|
116
|
+
/** A patch Apply's job (#102): the patch and its hash, the op/compile/check wall clocks, check's summary and the rig.json diff. */
|
|
117
|
+
/** #115: a build job's assemble readout (`AssembledView` in src/rebuild.ts). */
|
|
118
|
+
export type AssembledJobView=import('../src/rebuild.ts').AssembledView;
|
|
119
|
+
export interface PatchJobView {id:string;kind:'patch';op?:'rig'|'retune'|'remesh'|'build';assembled?:AssembledJobView;status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;exitCode:number|null;wallMs:{op:number|null;compile:number|null;check:number|null};check:CheckSummary|null;diff:{count:number;paths:string[]}|null;files?:FilesDiff|null;remesh?:import('../src/mesh-entry.ts').RemeshCounts&{modelTriangles:number|null}|null;build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{patch:ConfigPatch;patchSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
|
|
120
|
+
export interface PatchRequestBody {patch:ConfigPatch}
|
|
121
|
+
/** Edit Mesh's delta Apply (#135): `remesh --delta`, the per-file diff and the part's counts; remesh's own gate holds check. */
|
|
122
|
+
export interface DeltaJobView {id:string;kind:'delta';op:'remesh';status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;exitCode:number|null;wallMs:{op:number|null;compile:number|null};files:FilesDiff|null;remesh:import('../src/mesh-entry.ts').RemeshCounts&{modelTriangles:number|null}|null;build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{part:string;delta:Record<string,unknown>;deltaSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
|
|
123
|
+
/** `{part, delta, configHash}`: the server owns every path and flag; the delta is the editor's export as it stands. */
|
|
124
|
+
export interface DeltaRequestBody {part:string;delta:import('../src/mesh-edit.ts').MeshEditDocument;configHash:string}
|
|
125
|
+
/** A retarget Apply's job (#114): clean, retarget, compile; every FAIL line of the step that refused, verbatim; the listed lines. */
|
|
126
|
+
export interface RetargetJobView {id:string;kind:'retarget';op:'retarget';status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;refusals:string[];exitCode:number|null;wallMs:{clean:number|null;retarget:number|null;compile:number|null};files:FilesDiff|null;motion:MotionSummary|null;readings:RetargetLine[];build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{record:RetargetRecord;recordSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
|
|
127
|
+
/** `{retarget: {sequence, map, flags, configHash}}`: a declared reference id, the map by content and typed flags — no path, no flag string. */
|
|
128
|
+
export interface RetargetRequestBody {retarget:{sequence:string;map:Record<string,unknown>;flags:RetargetFlags;configHash:string}}
|
|
129
|
+
/** What the draft keeps and `context.applied` carries for a retarget: the record, the producer's line, the motion summary, the diff and the lines. */
|
|
130
|
+
export const retargetAppliedRecord=(job:RetargetJobView):AppliedRetarget=>({schema:APPLIED_RETARGET_SCHEMA,buildId:job.id,retarget:JSON.parse(JSON.stringify(job.request.record)),recordSha256:job.request.recordSha256,configHash:job.request.configHash,producer:job.producer,compiler:job.compiler,line:job.producerLine,motion:JSON.parse(JSON.stringify(job.motion)),files:job.files,readings:job.readings.map(r=>({...r})),modelHash:job.build!.modelHash});
|
|
131
|
+
/** A retarget job's status line: built (wall clocks, the new animation, the diff) or the producer's line verbatim. */
|
|
132
|
+
export function retargetOutcomeLine(result:RetargetJobView|RebuildRefusal):{text:string;error:boolean} {
|
|
133
|
+
if(!('id' in result))return {text:result.producerLine??result.error,error:true};
|
|
134
|
+
if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
|
|
135
|
+
const r=result.request.record,wall=result.reused?'reused the scratch build of this exact retarget':`${r.op.clean?`clean ${seconds(result.wallMs.clean)} + `:''}retarget ${seconds(result.wallMs.retarget)} + compile ${seconds(result.wallMs.compile)}`;
|
|
136
|
+
const kept=result.motion?(result.motion.kept?'the rig\'s own animations unchanged':`changed: ${result.motion.changed.join(', ')}`):'';
|
|
137
|
+
return {text:`Retargeted "${r.flags.animation}" from ${r.sequence.reference} with ${result.producer.package} ${result.producer.version} · ${wall} · ${kept} · ${result.files?filesDiffText(result.files):'no diff'} · Reset returns to the inspected build`,error:!!result.motion&&!result.motion.kept};
|
|
138
|
+
}
|
|
139
|
+
export type AnyJobView=RebuildJobView|PatchJobView|DeltaJobView|RetargetJobView;
|
|
140
|
+
export const isPatchJob=(job:AnyJobView):job is PatchJobView=>(job as PatchJobView).kind==='patch';
|
|
141
|
+
|
|
142
|
+
export interface RebuildDeps<B=RebuildRequestBody|PatchRequestBody,J extends AnyJobView=AnyJobView> {post:(body:B)=>Promise<J|RebuildRefusal>;get:(id:string)=>Promise<J>;onLine:(line:string,index:number)=>void;sleep:(ms:number)=>Promise<void>;intervalMs?:number}
|
|
143
|
+
/** One Apply: post, then poll until the job leaves `running`, handing every new line over once, in order. */
|
|
144
|
+
export async function runRebuild<B,J extends AnyJobView>(body:B,deps:RebuildDeps<B,J>):Promise<J|RebuildRefusal> {
|
|
145
|
+
let job=await deps.post(body);if(job.status==='refused'&&!('id' in job))return job;
|
|
146
|
+
let seen=0;const deliver=(lines:string[])=>{for(;seen<lines.length;seen++)deps.onLine(lines[seen]!,seen);};
|
|
147
|
+
deliver((job as J).lines);
|
|
148
|
+
while(job.status==='running'){await deps.sleep(deps.intervalMs??700);job=await deps.get((job as J).id);deliver(job.lines);}
|
|
149
|
+
return job;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A patch from the config the status read: `config_sha256` is the handoff config's hash and the producer is the
|
|
154
|
+
* installed one, so an edit card only states its ops and the op. Null when Apply cannot run here.
|
|
155
|
+
*/
|
|
156
|
+
export function makePatch(status:Pick<RebuildStatusView,'inputs'|'producer'>,ops:PatchOp[],op:{cmd:PatchCmd;args?:string[]}={cmd:'rig'}):ConfigPatch|null {
|
|
157
|
+
if(!status.inputs||!status.producer)return null;
|
|
158
|
+
return {schema:CONFIG_PATCH_SCHEMA,config_sha256:status.inputs.configHash,ops:ops.map(o=>({...o})),producer:{name:status.producer.package,version:status.producer.version},op:{cmd:op.cmd,args:[...(op.args??[])]}};
|
|
159
|
+
}
|
|
160
|
+
/** What the draft keeps and `context.applied` carries for a patch: the patch, its hash, the producer's line and check's summary. */
|
|
161
|
+
export const patchAppliedRecord=(job:PatchJobView):AppliedPatch=>({schema:APPLIED_PATCH_SCHEMA,buildId:job.id,patch:job.request.patch,patchSha256:job.request.patchSha256,configHash:job.request.configHash,producer:job.producer,compiler:job.compiler,line:job.producerLine,check:job.check,diff:job.diff,...(job.files?{files:job.files}:{}),modelHash:job.build!.modelHash,...(job.op?{op:job.op}:{}),...(job.remesh?{mesh:{part:job.remesh.part,before:{...job.remesh.before},after:{...job.remesh.after}}}:{}),...(job.op==='build'?{assembled:assembledRecord(job)}:{})});
|
|
162
|
+
/** #115: what a build Apply records: the build's own wall clock, the recomposite figures, the parts it changed and its `seam pair` lines. */
|
|
163
|
+
export function assembledRecord(job:PatchJobView):AppliedAssembled {
|
|
164
|
+
const a=job.assembled,fig=(f:Record<string,number>|null|undefined)=>f?{...f}:null;
|
|
165
|
+
return {wallMs:job.wallMs.op,recomposite:{before:fig(a?.recomposite.before as Record<string,number>|null),after:fig(a?.recomposite.after as Record<string,number>|null)},partsChanged:[...(a?.parts?.changed??[])],seamPairs:(a?.seamPairs??[]).slice(0,64)};
|
|
166
|
+
}
|
|
167
|
+
/** What the draft keeps and `context.applied` carries for a delta: the document and its hash, the producer's line, op "remesh", the diff and the counts. */
|
|
168
|
+
export const deltaAppliedRecord=(job:DeltaJobView):AppliedDelta=>({schema:APPLIED_DELTA_SCHEMA,buildId:job.id,part:job.request.part,delta:JSON.parse(JSON.stringify(job.request.delta)),deltaSha256:job.request.deltaSha256,configHash:job.request.configHash,producer:job.producer,compiler:job.compiler,line:job.producerLine,op:'remesh',files:job.files,...(job.remesh?{mesh:{part:job.remesh.part,before:{...job.remesh.before},after:{...job.remesh.after}}}:{}),modelHash:job.build!.modelHash});
|
|
169
|
+
/** A delta job's status line: built (wall clocks, counts, the per-file diff) or the producer's line verbatim. */
|
|
170
|
+
export function deltaOutcomeLine(result:DeltaJobView|RebuildRefusal):{text:string;error:boolean} {
|
|
171
|
+
if(!('id' in result))return {text:result.producerLine??result.error,error:true};
|
|
172
|
+
if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
|
|
173
|
+
const ops=Array.isArray(result.request.delta.ops)?result.request.delta.ops.length:0;
|
|
174
|
+
const wall=result.reused?'reused the scratch build of this exact delta':`remesh --delta ${seconds(result.wallMs.op)} + compile ${seconds(result.wallMs.compile)}`;
|
|
175
|
+
const counts=result.remesh?` · ${result.remesh.part} v ${result.remesh.before.vertices} t ${result.remesh.before.triangles} → v ${result.remesh.after.vertices} t ${result.remesh.after.triangles}`:'';
|
|
176
|
+
return {text:`Applied mesh edit ${result.request.deltaSha256.slice(0,12)}… (${ops} op(s) on ${result.request.part}) with ${result.producer.package} ${result.producer.version} · ${wall}${counts} · ${result.files?filesDiffText(result.files):'no diff'} · Reset returns to the inspected build`,error:false};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* What the stage draws after an Apply: the scratch build when the job built one, else what it drew before — a
|
|
180
|
+
* refusal, a failure or a busy reply leaves the stage's model (and its hash) as it was.
|
|
181
|
+
*/
|
|
182
|
+
export function stageModel(result:AnyJobView|RebuildRefusal,current:{url:string;hash:string}):{url:string;hash:string;id:string|null} {
|
|
183
|
+
if('id' in result&&result.status==='built'&&result.build)return {url:result.build.modelUrl,hash:result.build.modelHash,id:result.id};
|
|
184
|
+
return {...current,id:null};
|
|
185
|
+
}
|
|
186
|
+
const seconds=(ms:number|null)=>ms===null?'—':`${(ms/1000).toFixed(1)} s`;
|
|
187
|
+
/** A patch job's status line: built (wall clocks, check's verdict, the per-file diff — #125) or the producer's line verbatim. */
|
|
188
|
+
export function patchOutcomeLine(result:PatchJobView|RebuildRefusal):{text:string;error:boolean} {
|
|
189
|
+
if(!('id' in result))return {text:result.producerLine??result.error,error:true};
|
|
190
|
+
if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
|
|
191
|
+
const check=result.check?(result.check.pass===null?`check refused: ${result.check.line??`exit ${result.check.exitCode}`}`:`check ${result.check.pass?'PASS':'FAIL'}`):'check not run';
|
|
192
|
+
// #115: a build's own wall clock covers assemble, rig and check; the recorded figure is the same number.
|
|
193
|
+
const wall=result.op==='build'?`${result.reused?'reused the scratch build of this exact patch · ':''}build ${seconds(result.wallMs.op)} (assemble → rig → check) + compile ${seconds(result.wallMs.compile)}`:result.reused?'reused the scratch build of this exact patch':`${result.op??result.request.patch.op.cmd} ${seconds(result.wallMs.op)} + compile ${seconds(result.wallMs.compile)} + check ${seconds(result.wallMs.check)}`;
|
|
194
|
+
return {text:`Applied patch ${result.request.patchSha256.slice(0,12)}… (${result.request.patch.ops.length} op(s)) with ${result.producer.package} ${result.producer.version} · ${wall} · ${check} · ${result.files?filesDiffText(result.files):`rig.json differs at ${result.diff?.count??'?'} path(s)`} · Reset returns to the inspected build`,error:!!result.check&&result.check.pass!==true};
|
|
195
|
+
}
|
|
196
|
+
/** The status line after a job: built (with the wall clock and whether the scratch build was reused) or the producer's line verbatim. */
|
|
197
|
+
export function outcomeLine(result:RebuildJobView|RebuildRefusal):{text:string;error:boolean} {
|
|
198
|
+
if(!('id' in result))return {text:result.producerLine??result.error,error:true};
|
|
199
|
+
if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
|
|
200
|
+
const wall=[result.wallMs.remesh,result.wallMs.compile].every(v=>v!==null)?` · remesh ${(result.wallMs.remesh!/1000).toFixed(1)} s + compile ${(result.wallMs.compile!/1000).toFixed(1)} s`:'';
|
|
201
|
+
return {text:`Applied ${result.request.part} with ${result.producer.package} ${result.producer.version}${result.reused?' · reused the scratch build of this exact designation':wall} · Reset returns to the inspected build`,error:false};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The editor bar's handoff line, shown before any Apply: how the config was found (declared, or discovered — and
|
|
206
|
+
* outside the opened folder when it is the `../config.json` beside an opened out/), its path and hash, then the
|
|
207
|
+
* baseline. Without a handoff it is the refusal naming where it was looked for and how to declare it.
|
|
208
|
+
*/
|
|
209
|
+
export function handoffText(status:Pick<RebuildStatusView,'handoff'|'baseline'|'reason'>):{text:string;error:boolean} {
|
|
210
|
+
const h=status.handoff;
|
|
211
|
+
if(!h)return {text:status.reason??'MODELING_HANDOFF_MISSING',error:true};
|
|
212
|
+
const how=h.how==='declared'?`declared (${h.via})`:h.outside?'discovered outside the opened folder':'discovered';
|
|
213
|
+
const parts=h.parts?` · parts ${h.parts==='.'?'(the opened folder)':h.parts}`:'';
|
|
214
|
+
const base=status.baseline?` · ${status.baseline.wallMs!==null&&status.baseline.status!=='running'?`${status.baseline.line} (${(status.baseline.wallMs/1000).toFixed(1)} s)`:status.baseline.line}`:' · baseline: waits for the producer';
|
|
215
|
+
return {text:`Handoff ${how}: ${h.config.path} sha256 ${h.config.sha256.slice(0,12)}…${parts}${base}`,error:!!status.baseline&&['not-reproduced','refused','failed'].includes(status.baseline.status)};
|
|
216
|
+
}
|