@rig-c/review 0.7.1 → 0.8.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 +27 -7
- package/docs/DESIGN.md +2 -1
- package/docs/REVIEW_PACKAGE.md +17 -7
- package/package.json +1 -1
- package/src/applied-context.ts +35 -7
- package/src/cli.ts +1 -1
- package/src/cloth-recipe.ts +199 -0
- package/src/compare-server.ts +1 -1
- package/src/environment-check.ts +149 -0
- package/src/environment-schedule.ts +183 -0
- package/src/feedback-export.ts +8 -1
- package/src/frame-clock.ts +30 -0
- package/src/front.ts +4 -1
- package/src/handoff.ts +20 -13
- package/src/open-protocol.ts +4 -3
- package/src/open.ts +1 -1
- package/src/readings-run.ts +2 -1
- package/src/readings.ts +12 -6
- package/src/rebuild.ts +198 -34
- package/src/retune-check.ts +100 -0
- package/src/server.ts +12 -2
- package/src/session.ts +3 -0
- package/web/cloth-panel.ts +168 -0
- package/web/compare.ts +7 -5
- package/web/environment-world.ts +42 -0
- package/web/index.html +1 -1
- package/web/label-placement.ts +46 -0
- package/web/main.ts +127 -17
- package/web/readings-panel.ts +1 -1
- package/web/rebuild-client.ts +14 -9
- package/web/rig-overlays.ts +26 -10
- package/web/styles.css +2 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup · Cloth (#111), page side: one form per cloth tag the installed reader accepts, holding the recipe's fields as
|
|
3
|
+
* the producer config states them, and a link preview on the stage — each chain the tag's parts are meshed on, drawn
|
|
4
|
+
* from its config `points` and `tip` in rig px of the setup pose (the crop → world mapping the cuts, chest and bones
|
|
5
|
+
* views use). Every change is a config patch op at `/cloth/<tag>/…` (`src/cloth-recipe.ts`); Apply sends them as one
|
|
6
|
+
* `rig` patch, which the server runs as `rig-parts propose --cloth … --write-config` and then builds the written config
|
|
7
|
+
* (`build` when the config declares limbs, else `rig --config`); the scratch build reloads in place under the stage's
|
|
8
|
+
* Environment. After an Apply the preview adds the chains of the config the producer wrote. The producer's refusals
|
|
9
|
+
* (`CONFIG_CLOTH_*`, `PROPOSE_CLOTH_DRAFTED`, `PROPOSE_CLOTH_PART`, `PROPOSE_WRITE_CONFIG_*`) come back verbatim.
|
|
10
|
+
* Hand-editing the drafted constraints is the physics block's, after the draft. The draft keeps the pending edits
|
|
11
|
+
* (`#cloth-pending`).
|
|
12
|
+
*/
|
|
13
|
+
import {ClothFieldError,fieldText,recipeFromFields,recipeOps,RECIPE_FIELDS,type ClothTagView,type RecipeField} from '../src/cloth-recipe.ts';
|
|
14
|
+
import type {PatchOp} from '../src/config-patch.ts';
|
|
15
|
+
import type {CropWorldMapping} from '../src/chest-regions.ts';
|
|
16
|
+
import type {OverlayPoint,OverlayProject,OverlayRegion} from './rig-overlays.ts';
|
|
17
|
+
import type {ClothStatusView,PatchJobView,RebuildRefusal} from './rebuild-client.ts';
|
|
18
|
+
|
|
19
|
+
export interface ClothPanelDeps {
|
|
20
|
+
project:()=>OverlayProject|null;mapping:()=>CropWorldMapping;inspector:()=>string|undefined;
|
|
21
|
+
pause:()=>void;changed:()=>void;saved:()=>void;
|
|
22
|
+
apply:(ops:PatchOp[])=>Promise<PatchJobView|RebuildRefusal|null>;
|
|
23
|
+
reset:()=>Promise<void>;
|
|
24
|
+
token:(name:string)=>string;
|
|
25
|
+
}
|
|
26
|
+
export interface ClothPanel {
|
|
27
|
+
load(status:ClothStatusView|null,reason:string|null):void;
|
|
28
|
+
tag():string;select(tag:string):void;
|
|
29
|
+
values(tag?:string):Record<string,string>;set(key:string,text:string,tag?:string):void;declare(on:boolean,tag?:string):void;
|
|
30
|
+
ops():PatchOp[];
|
|
31
|
+
active():boolean;setActive(on:boolean):void;
|
|
32
|
+
overlayRegions():OverlayRegion[];
|
|
33
|
+
links():{tag:string;inspected:ClothTagView|null;drafted:ClothTagView|null};
|
|
34
|
+
apply():Promise<PatchJobView|RebuildRefusal|null>;
|
|
35
|
+
shown(job:PatchJobView|RebuildRefusal|null):void;
|
|
36
|
+
status(text:string,error:boolean):void;busy(value:boolean):void;
|
|
37
|
+
restore():void;clear():void;sync():void;dispose():void;
|
|
38
|
+
}
|
|
39
|
+
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;};
|
|
40
|
+
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;};
|
|
41
|
+
const isObject=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
|
|
42
|
+
|
|
43
|
+
export function bindClothPanel(deps:ClothPanelDeps):ClothPanel {
|
|
44
|
+
const state=node('cloth-panel-state'),toggle=node<HTMLButtonElement>('cloth-links'),picker=node<HTMLSelectElement>('cloth-tag'),declared=node<HTMLInputElement>('cloth-declared'),fields=node('cloth-fields'),pending=node('cloth-pending-list');
|
|
45
|
+
const applyButton=node<HTMLButtonElement>('cloth-apply'),resetButton=node<HTMLButtonElement>('cloth-reset'),statusOut=node('cloth-status'),readout=node('cloth-readout'),draft=node<HTMLTextAreaElement>('cloth-pending');
|
|
46
|
+
let status:ClothStatusView|null=null,reason:string|null=null,selected='',active=false,busy=false;
|
|
47
|
+
/** Per touched tag: every form field's text, and whether the tag's recipe is declared. */
|
|
48
|
+
let edits=new Map<string,{declared:boolean;values:Record<string,string>}>();
|
|
49
|
+
let drafted:ClothTagView[]=[];
|
|
50
|
+
|
|
51
|
+
const panelStatus=(text:string,error:boolean)=>{statusOut.textContent=text;statusOut.title=text;statusOut.classList.toggle('error',error);};
|
|
52
|
+
const recipe=(tag:string)=>isObject(status?.recipes)?status!.recipes![tag]:undefined;
|
|
53
|
+
const initial=(tag:string)=>({declared:recipe(tag)!==undefined,values:Object.fromEntries(RECIPE_FIELDS.map(f=>[f.key,fieldText(recipe(tag),f)]))});
|
|
54
|
+
const current=(tag:string)=>edits.get(tag)??initial(tag);
|
|
55
|
+
const touched=(tag:string)=>{const e=edits.get(tag),i=initial(tag);return !!e&&(e.declared!==i.declared||RECIPE_FIELDS.some(f=>(e.values[f.key]??'')!==(i.values[f.key]??'')));};
|
|
56
|
+
const save=()=>{draft.value=JSON.stringify({cloth:Object.fromEntries([...edits].filter(([tag])=>touched(tag)))});draft.dispatchEvent(new Event('input',{bubbles:true}));deps.saved();};
|
|
57
|
+
/** Every pending op, tag by tag in the reader's order; a field that does not read is named and stops the list. */
|
|
58
|
+
function allOps():PatchOp[] {
|
|
59
|
+
const config={cloth:status?.recipes??undefined},out:PatchOp[]=[];
|
|
60
|
+
for(const tag of status?.targets??[]){if(!touched(tag))continue;const e=current(tag);out.push(...recipeOps(config,tag,e.declared?recipeFromFields(e.values):null));}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
const change=()=>{save();render();deps.changed();};
|
|
64
|
+
|
|
65
|
+
function renderFields(){
|
|
66
|
+
const tag=selected,e=current(tag),pendingKeys=new Set(edits.has(tag)?RECIPE_FIELDS.filter(f=>(e.values[f.key]??'')!==(initial(tag).values[f.key]??'')).map(f=>f.key):[]);
|
|
67
|
+
declared.checked=e.declared;declared.disabled=busy||!tag;
|
|
68
|
+
const input=(f:RecipeField)=>{
|
|
69
|
+
const value=e.values[f.key]??'';let control:HTMLInputElement|HTMLSelectElement;
|
|
70
|
+
if(f.kind==='bool'||f.kind==='shape'){const options=f.kind==='bool'?['','true','false']:['','linear','smooth'];control=make('select',{},...options.map(o=>make('option',{value:o,textContent:o||'(absent)'})));control.value=value;}
|
|
71
|
+
else control=make('input',{type:'text',value,autocomplete:'off',inputMode:f.kind==='names'?'text':'decimal',placeholder:f.kind==='pair'?'root, tip':f.kind==='list'||f.kind==='names'?'a, b, …':'(absent)'});
|
|
72
|
+
control.disabled=busy||!e.declared;control.dataset.clothField=f.key;control.setAttribute('aria-label',`${tag} ${f.label}`);
|
|
73
|
+
control.onchange=()=>{panel.set(f.key,control.value);};
|
|
74
|
+
return make('label',{className:pendingKeys.has(f.key)?'cloth-field pending':'cloth-field'},make('span',{textContent:f.label+(f.required?' *':'')}),control,make('code',{textContent:`/cloth/${tag}/${f.path.join('/')}`}));
|
|
75
|
+
};
|
|
76
|
+
fields.replaceChildren(...(tag?RECIPE_FIELDS.map(input):[make('p',{className:'hint',textContent:'No tag to edit.'})]));
|
|
77
|
+
}
|
|
78
|
+
function renderPending(){
|
|
79
|
+
let ops:PatchOp[]=[],error:string|null=null;
|
|
80
|
+
try{ops=allOps();}catch(err){if(err instanceof ClothFieldError)error=err.message;else throw err;}
|
|
81
|
+
const items=ops.map(o=>make('li',{},make('code',{textContent:`${o.op} ${o.path}`}),o.op==='remove'?'':` → ${JSON.stringify(o.value)}`));
|
|
82
|
+
const revert=[...edits.keys()].filter(touched).map(tag=>{const b=make('button',{type:'button',textContent:`Revert ${tag}`,disabled:busy});b.onclick=()=>{edits.delete(tag);change();};return make('li',{},b);});
|
|
83
|
+
pending.replaceChildren(...(error?[make('li',{className:'error',textContent:error})]:[]),...(items.length?items:[make('li',{className:'hint',textContent:'No pending ops.'})]),...revert);
|
|
84
|
+
applyButton.disabled=busy||!items.length||!!error||!!reason||!!status?.reason;resetButton.disabled=busy;
|
|
85
|
+
toggle.disabled=!status;toggle.setAttribute('aria-pressed',String(active));
|
|
86
|
+
}
|
|
87
|
+
function renderPicker(){
|
|
88
|
+
const tags=status?.targets??[];
|
|
89
|
+
picker.replaceChildren(...tags.map(t=>make('option',{value:t,textContent:`${t}${recipe(t)===undefined?' (no recipe)':''}${touched(t)?' · pending':''}`})));
|
|
90
|
+
if(!tags.includes(selected))selected=tags[0]??'';picker.value=selected;picker.disabled=busy||!tags.length;
|
|
91
|
+
}
|
|
92
|
+
function renderState(){
|
|
93
|
+
if(!status){state.textContent=reason??'CLOTH_UNAVAILABLE: no producer handoff';state.classList.add('error');return;}
|
|
94
|
+
const view=status.tags.find(t=>t.tag===selected),mapping=deps.mapping();
|
|
95
|
+
state.classList.toggle('error',!!status.reason||!mapping.available);
|
|
96
|
+
state.textContent=`${status.targets.length} tag(s) the installed reader accepts${status.targetsRead?'':' (its list was not readable: the tags the config declares)'} · ${selected||'—'}: ${view?`${view.parts.length} part(s) (${view.parts.join(', ')||'none'}), ${view.chains.length} chain(s) in the config`:'—'} · built by ${status.route==='build'?'rig-parts build (the config declares limbs)':'rig --config'}${status.keypoints?` · keypoints ${status.keypoints.path}`:''}${status.reason?` · Apply: ${status.reason}`:''}${mapping.available?'':` · ${mapping.reason}: no links are drawn`}`;
|
|
97
|
+
}
|
|
98
|
+
function render(){renderPicker();renderFields();renderPending();renderState();}
|
|
99
|
+
|
|
100
|
+
const setActive=(on:boolean)=>{active=on&&!!status;if(active)deps.pause();render();deps.changed();};
|
|
101
|
+
toggle.onclick=()=>setActive(!active);
|
|
102
|
+
picker.onchange=()=>{selected=picker.value;render();deps.changed();};
|
|
103
|
+
declared.onchange=()=>panel.declare(declared.checked);
|
|
104
|
+
applyButton.onclick=()=>{void panel.apply();};
|
|
105
|
+
resetButton.onclick=()=>{if(!busy)void deps.reset();};
|
|
106
|
+
const observer=new MutationObserver(()=>panel.sync());
|
|
107
|
+
for(const selector of document.querySelectorAll('[data-inspector]'))observer.observe(selector,{attributes:true,attributeFilter:['aria-pressed','aria-selected']});
|
|
108
|
+
const colors=()=>({stroke:deps.token('--rv-mesh-region'),fill:'transparent',handle:deps.token('--rv-mesh-handle'),ink:deps.token('--rv-bone-ink'),labelBg:deps.token('--rv-bone-label-bg'),pending:deps.token('--rv-bone-pending')});
|
|
109
|
+
const world=(p:[number,number])=>{const m=deps.mapping();return m.available?m.toWorld(p[0],p[1]):null;};
|
|
110
|
+
const restore=()=>{
|
|
111
|
+
let doc:unknown=null;try{doc=JSON.parse(draft.value||'null');}catch{doc=null;}
|
|
112
|
+
edits=new Map();
|
|
113
|
+
const kept=isObject(doc)&&isObject(doc.cloth)?doc.cloth:{};
|
|
114
|
+
for(const [tag,v] of Object.entries(kept))if((status?.targets??[]).includes(tag)&&isObject(v)&&typeof v.declared==='boolean'&&isObject(v.values))edits.set(tag,{declared:v.declared,values:Object.fromEntries(RECIPE_FIELDS.map(f=>[f.key,typeof (v.values as Record<string,unknown>)[f.key]==='string'?(v.values as Record<string,string>)[f.key]!:'']))});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const panel:ClothPanel={
|
|
118
|
+
load(next,why){status=next;reason=next?null:why;drafted=[];if(!next)active=false;restore();render();deps.changed();},
|
|
119
|
+
tag:()=>selected,
|
|
120
|
+
select(tag){selected=tag;render();deps.changed();},
|
|
121
|
+
values:(tag=selected)=>({...current(tag).values}),
|
|
122
|
+
set(key,text,tag=selected){const f=RECIPE_FIELDS.find(x=>x.key===key);if(!f)throw new Error(`CLOTH_FIELD_UNKNOWN: ${key}`);const e=current(tag);edits.set(tag,{declared:e.declared,values:{...e.values,[key]:text}});change();},
|
|
123
|
+
declare(on,tag=selected){const e=current(tag);edits.set(tag,{declared:on,values:{...e.values}});change();},
|
|
124
|
+
ops:allOps,
|
|
125
|
+
active:()=>active,setActive,
|
|
126
|
+
links(){return {tag:selected,inspected:status?.tags.find(t=>t.tag===selected)??null,drafted:drafted.find(t=>t.tag===selected)??null};},
|
|
127
|
+
overlayRegions(){
|
|
128
|
+
if(!active||!status)return [];const c=colors(),out:OverlayRegion[]=[];
|
|
129
|
+
// One label per set (the first chain carries it); every link joint is a handle square.
|
|
130
|
+
const draw=(view:ClothTagView|undefined,stroke:string,label:string)=>{let first=true;for(const chain of view?.chains??[]){const w=chain.points.map(world);if(w.some(p=>!p))continue;out.push({name:first?label:'',points:w as OverlayPoint[],stroke,fill:c.fill,handles:true,open:true,active:null,colors:{handle:stroke,ink:c.ink,labelBg:c.labelBg}});first=false;}};
|
|
131
|
+
const inspected=status.tags.find(t=>t.tag===selected),written=drafted.find(t=>t.tag===selected);
|
|
132
|
+
// The written chains are drawn over the config's; when they are the same links, one label says so.
|
|
133
|
+
const same=!!written&&JSON.stringify(written.chains)===JSON.stringify(inspected?.chains??[]);
|
|
134
|
+
draw(inspected,c.stroke,`${selected} · ${inspected?.chains.length??0} chain(s) · config${same?' = written':''}`);
|
|
135
|
+
if(!same)draw(written,c.pending,`${selected} · ${written?.chains.length??0} chain(s) · written`);
|
|
136
|
+
return out;
|
|
137
|
+
},
|
|
138
|
+
async apply(){
|
|
139
|
+
let ops:PatchOp[];try{ops=allOps();}catch(err){if(err instanceof ClothFieldError){panelStatus(err.message,true);return null;}throw err;}
|
|
140
|
+
if(!ops.length||busy){panelStatus('CLOTH_NOTHING_PENDING: no pending ops',true);return null;}
|
|
141
|
+
const result=await deps.apply(ops);panel.shown(result);return result;
|
|
142
|
+
},
|
|
143
|
+
shown(job){
|
|
144
|
+
if(!job){readout.replaceChildren();drafted=[];deps.changed();return;}
|
|
145
|
+
const lines:HTMLElement[]=[],line=(label:string,text:string,error=false)=>lines.push(make('div',{className:'physics-readout-line'},make('span',{className:'eyebrow',textContent:label}),make('code',{textContent:text,title:text,className:error?'error':''})));
|
|
146
|
+
if(!('id' in job)){line('REFUSED',job.error,true);readout.replaceChildren(...lines);return;}
|
|
147
|
+
const c=job.cloth;
|
|
148
|
+
for(const r of c?.refusals??[])line('REFUSED',r,true);
|
|
149
|
+
if(c?.line)line('WROTE',c.line);
|
|
150
|
+
if(c?.written){line('WRITTEN DIFF',`${c.written.count} path(s) against cloth.json${c.written.sections.length?` · ${c.written.sections.join(', ')}`:''}${c.written.outside.length?` · outside the drafted sections: ${c.written.outside.slice(0,8).join(', ')}`:' · every one under the drafted sections'}`,c.written.outside.length>0);for(const p of c.written.paths.slice(0,12))line('PATH',p);}
|
|
151
|
+
if(c)line('ROUTE',c.route==='build'?'rig-parts build --config config.json (the config declares limbs)':'rig-parts rig --config config.json');
|
|
152
|
+
for(const r of job.assembled?.refusals??[])line('REFUSED',r,true);
|
|
153
|
+
for(const r of job.assembled?.checkFails??[])line('CHECK FAIL',r,true);
|
|
154
|
+
if(job.status!=='built'&&job.producerLine&&!(c?.refusals??[]).length)line('REFUSED',job.producerLine,true);
|
|
155
|
+
if(job.status==='built'&&job.check?.line)line('CHECK',job.check.line,job.check.pass!==true);
|
|
156
|
+
drafted=job.status==='built'&&c?c.links:[];
|
|
157
|
+
readout.replaceChildren(...lines);deps.changed();
|
|
158
|
+
},
|
|
159
|
+
status:panelStatus,
|
|
160
|
+
busy(value){busy=value;render();},
|
|
161
|
+
restore(){restore();render();deps.changed();},
|
|
162
|
+
clear(){edits=new Map();change();},
|
|
163
|
+
sync(){renderState();if(active&&deps.inspector()!=='rig')setActive(false);},
|
|
164
|
+
dispose(){observer.disconnect();},
|
|
165
|
+
};
|
|
166
|
+
render();
|
|
167
|
+
return panel;
|
|
168
|
+
}
|
package/web/compare.ts
CHANGED
|
@@ -49,6 +49,8 @@ let stripBusy = false;
|
|
|
49
49
|
let persistence:PersistenceConnection|null=null,submittedBaseline:CompareSession|null=null,draftConflict=false;
|
|
50
50
|
// First, before any URL use: take the `#cap=` handoff into memory/session storage and strip it.
|
|
51
51
|
const access=pageInstanceAccess();let readOnly:string|null=null;
|
|
52
|
+
/** Whether this instance serves agent control (`/api/access`); a plain open is never probed for it (#164). */
|
|
53
|
+
let controlServed=true;
|
|
52
54
|
let referenceFrames:VideoInfo|null=null;
|
|
53
55
|
const presentation=new ComparePresentation();
|
|
54
56
|
const rendererBadges={a:new RendererBadge(),b:new RendererBadge()};
|
|
@@ -393,9 +395,9 @@ function bind(): void {
|
|
|
393
395
|
|
|
394
396
|
async function start(): Promise<void> {
|
|
395
397
|
try {
|
|
396
|
-
let writes: { capabilityRequired: boolean };
|
|
397
|
-
[data, session, writes] = await Promise.all([api<CompareResponse>('/api/compare'), api<CompareSession>('/api/compare/session'), api<{ capabilityRequired: boolean }>('/api/access')]);
|
|
398
|
-
readOnly = readOnlyReason(access, writes.capabilityRequired); if (readOnly) message(readOnly, true);
|
|
398
|
+
let writes: { capabilityRequired: boolean; control?: boolean };
|
|
399
|
+
[data, session, writes] = await Promise.all([api<CompareResponse>('/api/compare'), api<CompareSession>('/api/compare/session'), api<{ capabilityRequired: boolean; control?: boolean }>('/api/access')]);
|
|
400
|
+
readOnly = readOnlyReason(access, writes.capabilityRequired); if (readOnly) message(readOnly, true); controlServed = writes.control !== false;
|
|
399
401
|
submittedBaseline=structuredClone(session);
|
|
400
402
|
if (data.id !== session.id || data.sourceHash !== session.sourceHash || JSON.stringify(data.binding) !== JSON.stringify(session.binding)) throw new Error('Comparison and submission session refer to different source identities. Reopen the review.');
|
|
401
403
|
text('title', data.title); text('animation', `${data.animation} · A/B order and reference video hash are fixed`); document.title = data.title + ' · rig review compare'; select('status').value = session.status; provenance(); bind(); parts=new PartExplorer(el('parts-explorer'),el('selected-part'),previous=>{redrawOverlays(true);const part=parts?.value();if(part){activeCamera=part.side==='b'?'b':'a';workspace?.revealInspector();document.querySelector<HTMLButtonElement>('[data-inspector=rig]')?.click();renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera());persistence?.save();},part=>{select('side').value=part.side;select('target-kind').value='slot';renderFeedbackSelection();input('target-name').value=part.slot;document.querySelector<HTMLButtonElement>('[data-inspector=feedback]')?.click();}); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackSelection(); renderFeedback(); savedState();
|
|
@@ -410,7 +412,7 @@ async function start(): Promise<void> {
|
|
|
410
412
|
input('scrub').max = String(domain.end); input('time-input').max = String(domain.end); text('domain', `Shared range 0–${domain.end} s · Neither side is independently clamped or looped`); ready = true;
|
|
411
413
|
input('scrub').disabled = false; input('time-input').disabled = false; button('seek').disabled = false; button('play').disabled = domain.end === 0;
|
|
412
414
|
await seekTo(0);activeCamera='a';if(cameraSync)alignCameras();fit();for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;renderIdle();const restored=await setupPersistence();if(!restored&&canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));
|
|
413
|
-
if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
415
|
+
if(!readOnly&&controlServed)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
414
416
|
} catch (error) { failPair(error); for (const side of ['a', 'b'] as const) if (!players[side]) text(`${side}-empty`, `${side.toUpperCase()} Could not open comparison rig.`); text('reference-empty', 'Check the reference video and comparison inputs.'); }
|
|
415
417
|
}
|
|
416
418
|
function sharedBox():PoseBounds|null {const a=assets.a?.stage??players.a?.bounds(),b=assets.b?.stage??players.b?.bounds();return a&&b?unionBounds(a,b):a??b??null;}
|
|
@@ -431,7 +433,7 @@ function showSurvivors():void {
|
|
|
431
433
|
updateFeedbackAvailability();
|
|
432
434
|
const state=renderPairLayout();activeCamera=state.effective==='b'?'b':state.effective==='reference'?'reference':'a';fit();
|
|
433
435
|
message(`Comparison unavailable · ${state.reasons.compare??''}${state.effective&&state.effective!=='compare'?` · showing ${state.effective==='reference'?'the reference':state.effective.toUpperCase()} alone`:''}`,true);
|
|
434
|
-
if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
436
|
+
if(!readOnly&&controlServed)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
435
437
|
}
|
|
436
438
|
function paneFrame(target:CameraTarget,hidden=false):CameraFrame|null {
|
|
437
439
|
const viewport=el(target+'-viewport');if(viewport.clientWidth<=0||viewport.clientHeight<=0)return hidden?cameraFrames[target]??null:null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Environment block's "Use for readings" (issue #112), the DOM-free half: the controls as the exported
|
|
3
|
+
* `rigc-environment/1` schedule for a clip, its bytes and their hash, the feedback context that carries them, and the
|
|
4
|
+
* "Check under this world" rows (each readout bar under the default world beside the same bar under this one).
|
|
5
|
+
*/
|
|
6
|
+
import { scheduleSummary, scheduleText, sha256Hex, SCHEDULE_CONTEXT_SCHEMA, windSchedule, ScheduleError, type EnvironmentSchedule, type ScheduleContext, type WindScheduleExport } from '../src/environment-schedule.ts';
|
|
7
|
+
import type { WindSnapshot } from '../src/environment.ts';
|
|
8
|
+
import type { CheckSummary } from '../src/applied-context.ts';
|
|
9
|
+
import { READOUT_BARS } from '../src/physics-tuning.ts';
|
|
10
|
+
|
|
11
|
+
export const ENVIRONMENT_NOT_APPLIED = 'ENVIRONMENT_NOT_APPLIED: the player stands at the runtime start values (wind [1, 0], gravity [0, 1]), which check and render read without --environment; move a control first';
|
|
12
|
+
export interface WorldExport { export: WindScheduleExport; schedule: EnvironmentSchedule; text: string; sha256: string; summary: string; animation: string }
|
|
13
|
+
/** The controls as a schedule over `animation` (`duration` s), or why there is none. */
|
|
14
|
+
export function worldExport(snapshot: WindSnapshot, gravity: number, applied: boolean, animation: string, duration: number): WorldExport | { error: string } {
|
|
15
|
+
if (!applied) return { error: ENVIRONMENT_NOT_APPLIED };
|
|
16
|
+
try {
|
|
17
|
+
const e = windSchedule(snapshot, gravity, duration), text = scheduleText(e.schedule), sha256 = sha256Hex(text);
|
|
18
|
+
return { export: e, schedule: e.schedule, text, sha256, summary: scheduleSummary(e, sha256), animation };
|
|
19
|
+
} catch (error) { if (error instanceof ScheduleError) return { error: error.message }; throw error; }
|
|
20
|
+
}
|
|
21
|
+
export const scheduleContext = (w: WorldExport, used: boolean): ScheduleContext => ({ schema: SCHEDULE_CONTEXT_SCHEMA, sha256: w.sha256, size: new TextEncoder().encode(w.text).length, text: w.text, usedForReadings: used });
|
|
22
|
+
|
|
23
|
+
/** `POST /api/environment-check`'s job as the page reads it. */
|
|
24
|
+
export interface EnvironmentCheckView {
|
|
25
|
+
id: string; status: 'running' | 'checked' | 'refused' | 'failed'; build: 'inspected' | 'scratch'; buildId: string | null;
|
|
26
|
+
schedule: { sha256: string; size: number; steps: number }; producer: { package: string; version: string };
|
|
27
|
+
exitCode: number | null; wallMs: number | null; check: CheckSummary | null;
|
|
28
|
+
environment: { path: string; sha256: string; steps: number } | null; environmentLine: string | null;
|
|
29
|
+
bars: { name: string; line: string }[]; error: string | null; lines: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface EnvironmentRow { kind: 'label' | 'bar' | 'error'; text: string }
|
|
32
|
+
/** The panel's lines: which schedule check read, then per bar the default world's line and this world's, or the refusal verbatim. */
|
|
33
|
+
export function environmentCheckRows(job: EnvironmentCheckView, defaultLine: (name: string) => string, defaultLabel: string): EnvironmentRow[] {
|
|
34
|
+
if (job.status !== 'checked') return [{ kind: 'error', text: job.error ?? `ENVIRONMENT_CHECK_FAILED: ${job.status}` }];
|
|
35
|
+
const rows: EnvironmentRow[] = [{ kind: 'label', text: `${job.build} build · ${job.producer.package} ${job.producer.version} check --environment · ${job.environmentLine ?? `check.json environment sha256 ${job.environment?.sha256 ?? '?'}`}${job.wallMs !== null ? ` · ${(job.wallMs / 1000).toFixed(1)} s` : ''}` }];
|
|
36
|
+
for (const name of READOUT_BARS) {
|
|
37
|
+
rows.push({ kind: 'label', text: `${defaultLabel}: ${defaultLine(name)}` });
|
|
38
|
+
rows.push({ kind: 'bar', text: `this world: ${job.bars.find(b => b.name === name)?.line ?? `${name}: not in check summary`}` });
|
|
39
|
+
}
|
|
40
|
+
if (job.check?.line) rows.push({ kind: 'label', text: `this world · ${job.check.line}` });
|
|
41
|
+
return rows;
|
|
42
|
+
}
|