@rig-c/review 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +242 -7
- package/docs/DESIGN.md +33 -5
- package/docs/REVIEW_PACKAGE.md +115 -0
- package/package.json +1 -1
- package/src/applied-context.ts +285 -0
- package/src/baseline.ts +175 -0
- package/src/bone-anchors.ts +123 -0
- package/src/bones-check.ts +105 -0
- package/src/build-route.ts +109 -0
- package/src/chest-landmarks.ts +122 -0
- package/src/chest-regions.ts +65 -6
- package/src/cli.ts +19 -8
- package/src/config-patch.ts +213 -0
- package/src/cut-edits.ts +184 -0
- package/src/environment.ts +226 -0
- package/src/feedback-export.ts +25 -1
- package/src/front.ts +11 -0
- package/src/handoff.ts +208 -0
- package/src/idle-tracks.ts +401 -0
- package/src/manifest.ts +15 -6
- package/src/mesh-edit.ts +317 -0
- package/src/mesh-entry.ts +351 -0
- package/src/open.ts +12 -5
- package/src/physics-tuning.ts +236 -0
- package/src/producer-files.ts +11 -0
- package/src/producer.ts +158 -0
- package/src/readings-run.ts +129 -0
- package/src/readings.ts +235 -0
- package/src/rebuild.ts +938 -0
- package/src/reference.ts +4 -3
- package/src/requirements-run.ts +160 -0
- package/src/requirements.ts +230 -0
- package/src/retarget.ts +211 -0
- package/src/retune-route.ts +52 -0
- package/src/rig-fields.ts +295 -0
- package/src/server.ts +59 -5
- package/src/session.ts +12 -0
- package/src/setup-mesh.ts +144 -0
- package/src/weights.ts +74 -0
- package/web/bones-panel.ts +230 -0
- package/web/chest-editor.ts +294 -0
- package/web/chest-regions-tool.ts +10 -4
- package/web/cuts-panel.ts +266 -0
- package/web/idle-panel.ts +280 -0
- package/web/index.html +2 -2
- package/web/inspector-blocks.ts +77 -0
- package/web/main.ts +813 -24
- package/web/mesh-editor.ts +303 -0
- package/web/mesh-panel.ts +273 -0
- package/web/part-selection.ts +13 -5
- package/web/part-thumbnails.ts +76 -0
- package/web/physics-panel.ts +151 -0
- package/web/readings-panel.ts +105 -0
- package/web/rebuild-client.ts +216 -0
- package/web/requirements-panel.ts +238 -0
- package/web/retarget-panel.ts +203 -0
- package/web/rig-fields-panel.ts +269 -0
- package/web/rig-overlays.ts +171 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +52 -5
- package/web/weights-view.ts +159 -0
- package/web/workspace-preferences.ts +17 -12
- package/web/workspace-ui.ts +5 -3
package/web/part-selection.ts
CHANGED
|
@@ -14,10 +14,12 @@ export function partCatalog(value:unknown,expected:readonly string[]):PartEntry[
|
|
|
14
14
|
let count=0;for(const [i,v]of model.skins.entries()){const skin=object(v,`skin ${i}`),skinName=name(skin.name,'skin.name');const slots=object(skin.attachments??{},'skin.attachments');for(const [slot,raw]of Object.entries(slots)){const entry=bySlot.get(slot);if(!entry)throw new Error(`Part catalog unknown slot ${slot}`);for(const [attachment,a]of Object.entries(object(raw,'slot attachments'))){if(++count>100000)throw new Error('Part declaration count exceeds 100000');const spec=object(a,'attachment');entry.declarations.push({skin:skinName,attachment:name(attachment,'attachment name'),kind:name(spec.kind,'attachment.kind'),path:typeof spec.path==='string'?name(spec.path,'attachment.path'):null});}}}
|
|
15
15
|
return entries;
|
|
16
16
|
}
|
|
17
|
-
export async function loadPartCatalog(url:string,sha256:string,slots:readonly string[],fetcher:typeof fetch=fetch):Promise<PartEntry[]>{
|
|
17
|
+
export async function loadPartCatalog(url:string,sha256:string,slots:readonly string[],fetcher:typeof fetch=fetch):Promise<PartEntry[]>{return (await loadPartModel(url,sha256,slots,fetcher)).entries;}
|
|
18
|
+
/** The hash-bound catalog plus the parsed model document it was read from (for the page regions thumbnails cut). */
|
|
19
|
+
export async function loadPartModel(url:string,sha256:string,slots:readonly string[],fetcher:typeof fetch=fetch):Promise<{entries:PartEntry[];model:unknown}>{
|
|
18
20
|
const response=await fetcher(url);if(!response.ok)throw new Error('Part catalog model unavailable');const bytes=await response.arrayBuffer();if(bytes.byteLength>128*1024*1024)throw new Error('Part catalog exceeds 128 MiB');
|
|
19
21
|
const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(v=>v.toString(16).padStart(2,'0')).join('');if(hash!==sha256)throw new Error('PART_SOURCE_CHANGED: model bytes differ from reviewed SHA-256');
|
|
20
|
-
|
|
22
|
+
const model:unknown=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes));return {entries:partCatalog(model,slots),model};
|
|
21
23
|
}
|
|
22
24
|
/** Resolve clips on the whole frame first. Filtering player.slots would drop clipping slots. */
|
|
23
25
|
export function selectedFrame(full:PosedFrame,part:ControlPart|null,side:ControlPart['side']):PosedFrame{return part?.side===side&&part.mode==='solo'?{...(full.generation===undefined?{}:{generation:full.generation}),order:full.order.filter(row=>row.slot===part.slot)}:full;}
|
|
@@ -92,7 +94,10 @@ export class PartInspection {
|
|
|
92
94
|
|
|
93
95
|
feedback():Record<string,Json>|null {const value=this.observation();return value?{...value,identity:value.identity?{...value.identity}:null,activeAttachment:value.activeAttachment?{...value.activeAttachment}:null,bounds:value.bounds?{...value.bounds}:null,declarations:value.declarations.map(item=>({...item}))}:null;}
|
|
94
96
|
}
|
|
97
|
+
/** Draws a row's thumbnail into its icon element, or leaves the type glyph; returns the tooltip note naming which and why. */
|
|
98
|
+
export interface PartThumbnailSource {show(icon:HTMLElement,entry:PartEntry,identity:ControlPartIdentity|null,glyph:string):string}
|
|
95
99
|
export class PartExplorer extends PartInspection {
|
|
100
|
+
private thumbnails=new Map<ControlPart['side'],PartThumbnailSource>();private rowParts=new Map<HTMLButtonElement,{icon:HTMLElement;label:HTMLElement}>();
|
|
96
101
|
private rows=new Map<string,HTMLButtonElement>();private rowEntries=new Map<HTMLButtonElement,PartEntry>();private detailKey='';private orderKey='';private selectionKey='';
|
|
97
102
|
private detailNodes:{title:HTMLHeadingElement;info:HTMLParagraphElement;declarations:HTMLParagraphElement;bounds:HTMLParagraphElement;unavailable:HTMLParagraphElement;feedback:HTMLButtonElement;status:HTMLParagraphElement}|null=null;
|
|
98
103
|
constructor(private host:HTMLElement,private detail:HTMLElement,private changed:(previous:ControlPart|null)=>void,private target:(part:ControlPart)=>void){
|
|
@@ -102,7 +107,7 @@ export class PartExplorer extends PartInspection {
|
|
|
102
107
|
this.whole.onclick=()=>this.action(()=>this.select(null));this.solo.onchange=()=>this.action(()=>{if(this.selected)this.select({...this.selected,mode:this.solo.checked?'solo':'context'});});this.renderList();this.renderDetail();
|
|
103
108
|
}
|
|
104
109
|
private get search(){return this.host.querySelector<HTMLInputElement>('[data-part-search]')!;}private get list(){return this.host.querySelector<HTMLElement>('[data-part-list]')!;}private get solo(){return this.host.querySelector<HTMLInputElement>('[data-part-solo]')!;}private get whole(){return this.host.querySelector<HTMLButtonElement>('[data-part-whole]')!;}
|
|
105
|
-
register(side:ControlPart['side'],entries:PartEntry[],player:PartPlayer){super.register(side,entries,player);this.renderList();}
|
|
110
|
+
register(side:ControlPart['side'],entries:PartEntry[],player:PartPlayer,thumbnails?:PartThumbnailSource){super.register(side,entries,player);if(thumbnails)this.thumbnails.set(side,thumbnails);else this.thumbnails.delete(side);this.renderList();}
|
|
106
111
|
refuseCatalog(side:ControlPart['side'],error:unknown):void {super.refuseCatalog(side,error);this.renderList();this.renderDetail();}
|
|
107
112
|
select(value:ControlPart|null):void {const previous=this.value();super.select(value);this.renderSelection();this.renderDetail();this.changed(previous);}
|
|
108
113
|
/** Runs on every drawn frame. The held copy and Solo draw are per frame; the list is rewritten only when what it shows changed. */
|
|
@@ -110,10 +115,13 @@ export class PartExplorer extends PartInspection {
|
|
|
110
115
|
/** Everything the rows and Solo control display: selection, per-side drawn slots in order, and identity. Generation is excluded. */
|
|
111
116
|
private viewKey():string {let key=JSON.stringify([this.selected,[...this.refused]]);for(const [side,state]of this.sides){key+='\u0001'+side+'\u0002'+(state.frame?state.frame.order.map(row=>row.slot).join('\u0000'):'\u0003');for(const row of state.identity?.slots??[])key+='\u0002'+row.slot+'\u0000'+row.z+'\u0000'+row.skin+'\u0000'+row.attachment+'\u0000'+row.reason;}return key;}
|
|
112
117
|
private action(run:()=>void){try{run();}catch(error){this.renderSelection(true);this.host.querySelector<HTMLElement>('[data-part-status]')!.textContent=`Part selection refused: ${error instanceof Error?error.message:String(error)}`;}}
|
|
113
|
-
private renderList(){this.rows.clear();this.rowEntries.clear();this.list.replaceChildren();const query=this.search.value;for(const [side,state]of this.sides)for(const entry of orderedParts(state.entries,state.frame,state.identity)){if(!entry.slot.includes(query))continue;const row=document.createElement('button');row.type='button';row.dataset.side=side;row.dataset.slot=entry.slot;row.title=`${entry.slot} · bone ${entry.bone}`;
|
|
118
|
+
private renderList(){this.rows.clear();this.rowEntries.clear();this.rowParts.clear();this.list.replaceChildren();const query=this.search.value;for(const [side,state]of this.sides)for(const entry of orderedParts(state.entries,state.frame,state.identity)){if(!entry.slot.includes(query))continue;const row=document.createElement('button');row.type='button';row.dataset.side=side;row.dataset.slot=entry.slot;row.title=`${entry.slot} · bone ${entry.bone}`;const icon=document.createElement('span'),label=document.createElement('span');icon.className='part-thumb';icon.setAttribute('aria-hidden','true');icon.textContent=entry.declarations.some(d=>d.kind==='mesh')?'◈':'◇';label.className='part-label';label.textContent=`${side==='single'?'':side.toUpperCase()+' · '}${entry.slot}`;row.append(icon,label);this.rowParts.set(row,{icon,label});
|
|
119
|
+
// Selecting the selected slot again (click, or Space/Enter on the focused row) returns to Whole rig.
|
|
120
|
+
row.onclick=()=>this.action(()=>{const live=this.live();this.select(live?.side===side&&live.slot===entry.slot?null:{side,slot:entry.slot,mode:this.selected?.mode??'context'});});const item=document.createElement('li');item.append(row);this.list.append(item);this.rows.set(JSON.stringify([side,entry.slot]),row);this.rowEntries.set(row,entry);}this.orderKey='';this.reorder();this.renderSelection(true);}
|
|
114
121
|
private statusText():string {const refused=[...this.refused].map(([side,r])=>`${side==='single'?'':side.toUpperCase()+' '}${r.code}`);if(refused.length)return `Parts unavailable · ${refused.join(' · ')}. The rig keeps drawing whole; selection, Solo and feedback targeting are off ${[...this.refused.values()].some(r=>r.catalog)?'until the package reloads with a matching catalog':'until a frame with a valid identity snapshot'}.`;return this.sides.size?`${[...this.sides.values()].reduce((n,s)=>n+s.entries.length,0)} declared slots · ${this.rows.size} results`:'No compiled rig catalog available';}
|
|
115
122
|
private reorder(){const keys=[...this.sides].flatMap(([side,state])=>orderedParts(state.entries,state.frame,state.identity).map(entry=>JSON.stringify([side,entry.slot]))).filter(key=>this.rows.has(key)),key=JSON.stringify(keys);if(key===this.orderKey)return;this.orderKey=key;const active=document.activeElement;for(const item of keys){const row=this.rows.get(item)!;this.list.append(row.parentElement!);}if(active instanceof HTMLElement&&this.list.contains(active)&&document.activeElement!==active)active.focus({preventScroll:true});}
|
|
116
|
-
private renderSelection(force=false){const key=this.viewKey();if(!force&&key===this.selectionKey)return;this.selectionKey=key;const status=this.host.querySelector<HTMLElement>('[data-part-status]')!;status.textContent=this.statusText();if(this.refused.size)this.host.dataset.parts='unavailable';else delete this.host.dataset.parts;const drawnBySide=new Map([...this.sides].map(([side,state])=>[side,new Set(state.frame?.order.map(row=>row.slot)??[])]));this.whole.setAttribute('aria-pressed',String(!this.selected));this.solo.disabled=!this.live();this.solo.checked=this.live()?.mode==='solo';for(const [key,row]of this.rows){row.setAttribute('aria-pressed',String(key===JSON.stringify([this.live()?.side,this.live()?.slot])));const refusal=this.refused.get(row.dataset.side as ControlPart['side']);row.disabled=!!refusal;if(refusal){const label=`${row.dataset.side==='single'?'':row.dataset.side!.toUpperCase()+' · '}${row.dataset.slot} · parts unavailable · ${refusal.code}`;if(
|
|
123
|
+
private renderSelection(force=false){const key=this.viewKey();if(!force&&key===this.selectionKey)return;this.selectionKey=key;const status=this.host.querySelector<HTMLElement>('[data-part-status]')!;status.textContent=this.statusText();if(this.refused.size)this.host.dataset.parts='unavailable';else delete this.host.dataset.parts;const drawnBySide=new Map([...this.sides].map(([side,state])=>[side,new Set(state.frame?.order.map(row=>row.slot)??[])]));this.whole.setAttribute('aria-pressed',String(!this.selected));this.solo.disabled=!this.live();this.solo.checked=this.live()?.mode==='solo';for(const [key,row]of this.rows){row.setAttribute('aria-pressed',String(key===JSON.stringify([this.live()?.side,this.live()?.slot])));const refusal=this.refused.get(row.dataset.side as ControlPart['side']);row.disabled=!!refusal;const parts=this.rowParts.get(row)!;if(refusal){const label=`${row.dataset.side==='single'?'':row.dataset.side!.toUpperCase()+' · '}${row.dataset.slot} · parts unavailable · ${refusal.code}`;if(parts.label.textContent!==label){parts.label.textContent=label;parts.icon.textContent='';delete parts.icon.dataset.thumbnailPlan;}row.title=label;row.setAttribute('aria-label',label);row.dataset.drawn='false';continue;}const state=this.sides.get(row.dataset.side as ControlPart['side']);const drawn=drawnBySide.get(row.dataset.side as ControlPart['side'])?.has(row.dataset.slot!);row.dataset.drawn=String(!!drawn);const entry=this.rowEntries.get(row),identity=entry?partIdentity(state?.identity??null,row.dataset.slot!):null;const glyph=entry?.declarations.some(d=>d.kind==='mesh')?'◈':'◇',source=this.thumbnails.get(row.dataset.side as ControlPart['side']),note=entry&&source?source.show(parts.icon,entry,identity,glyph):null;if(!note&&parts.icon.textContent!==glyph){parts.icon.textContent=glyph;delete parts.icon.dataset.thumbnailPlan;}
|
|
124
|
+
row.title=`${row.dataset.slot} · ${state?.frame?drawn?'drawn':'not drawn':'awaiting draw'}${identity?identity.reason?' · '+identity.reason:' · '+identity.skin+' / '+identity.attachment:' · awaiting identity snapshot'}${note?' · '+note:''}`;const label=`${row.dataset.side==='single'?'':row.dataset.side!.toUpperCase()+' · '}${row.dataset.slot} · ${state?.frame?drawn?'drawn':'not drawn':'awaiting draw'} · ${identity?identity.reason?'no active attachment · '+identity.reason:identity.skin+' / '+identity.attachment:'awaiting identity snapshot'}`;if(parts.label.textContent!==label)parts.label.textContent=label;row.setAttribute('aria-label',`${glyph} ${label} · ${state?.frame?drawn?'drawn':'not drawn':'awaiting draw'}`);}}
|
|
117
125
|
private renderDetail(){
|
|
118
126
|
const observation=this.observation(),refusal=this.selected?this.refused.get(this.selected.side):[...this.refused.values()][0],key=JSON.stringify([observation,refusal??null]);if(key===this.detailKey)return;this.detailKey=key;
|
|
119
127
|
// Keep focused controls connected while the selected pose's bounds animate.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Slot thumbnails cut from the actual page images by the region the compiled model declares for the drawn attachment.
|
|
2
|
+
* Nothing is inferred from the art: an attachment without a declared, unrotated page region shows its type icon and a
|
|
3
|
+
* named reason, and a whole page is never shown as a part. */
|
|
4
|
+
import type {ControlPartIdentity} from '../src/control.ts';
|
|
5
|
+
import type {PartEntry,PartThumbnailSource} from './part-selection.ts';
|
|
6
|
+
|
|
7
|
+
export interface PageRegion {page:string;name:string;x:number;y:number;width:number;height:number;degrees:number}
|
|
8
|
+
export interface PageRegionTable {pages:Map<string,{width:number;height:number}>;regions:Map<string,PageRegion[]>;absent:string|null}
|
|
9
|
+
/** The source rectangle on the page, as page UVs (the page spanning [0, 1]) and as page pixels derived from them. */
|
|
10
|
+
export interface ThumbnailCrop {kind:'crop';key:string;page:string;region:string;u0:number;v0:number;u1:number;v1:number;sx:number;sy:number;sw:number;sh:number}
|
|
11
|
+
export interface ThumbnailIcon {kind:'icon';key:string;reason:string}
|
|
12
|
+
export type ThumbnailPlan=ThumbnailCrop|ThumbnailIcon;
|
|
13
|
+
/** CSS box edge; the canvas backing store is twice that. */
|
|
14
|
+
export const thumbnailBox=24,thumbnailScale=2;
|
|
15
|
+
|
|
16
|
+
const record=(v:unknown):Record<string,unknown>|null=>v&&typeof v==='object'&&!Array.isArray(v)?v as Record<string,unknown>:null;
|
|
17
|
+
const finite=(v:unknown,min:number):v is number=>typeof v==='number'&&Number.isFinite(v)&&v>=min;
|
|
18
|
+
/** Reads `pages[].regions[]` of a `rigc-compiled/3` document. A malformed entry is dropped (its attachment shows the icon), never repaired. */
|
|
19
|
+
export function pageRegionTable(model:unknown):PageRegionTable {
|
|
20
|
+
const pages=new Map<string,{width:number;height:number}>(),regions=new Map<string,PageRegion[]>(),list=record(model)?.pages;
|
|
21
|
+
if(!Array.isArray(list))return {pages,regions,absent:'the model has no pages section'};
|
|
22
|
+
for(const raw of list.slice(0,256)){
|
|
23
|
+
const page=record(raw);if(!page||typeof page.name!=='string'||!finite(page.width,1)||!finite(page.height,1)||!Array.isArray(page.regions))continue;
|
|
24
|
+
pages.set(page.name,{width:page.width,height:page.height});
|
|
25
|
+
for(const value of page.regions.slice(0,100000)){const r=record(value);if(!r||typeof r.name!=='string'||!finite(r.x,0)||!finite(r.y,0)||!finite(r.width,1)||!finite(r.height,1))continue;const region:PageRegion={page:page.name,name:r.name,x:r.x,y:r.y,width:r.width,height:r.height,degrees:typeof r.degrees==='number'?r.degrees:0};regions.set(r.name,[...regions.get(r.name)??[],region]);}
|
|
26
|
+
}
|
|
27
|
+
return {pages,regions,absent:null};
|
|
28
|
+
}
|
|
29
|
+
/** What a row shows for the attachment the held public identity says is active. */
|
|
30
|
+
export function thumbnailPlan(table:PageRegionTable,entry:PartEntry,identity:ControlPartIdentity|null):ThumbnailPlan {
|
|
31
|
+
if(!identity)return {kind:'icon',key:'',reason:'awaiting identity snapshot'};
|
|
32
|
+
if(identity.reason!==null||identity.attachment===null||identity.skin===null)return {kind:'icon',key:'',reason:`no active attachment (${identity.reason??'none'})`};
|
|
33
|
+
const key=JSON.stringify([identity.skin,identity.attachment]),declaration=entry.declarations.find(d=>d.skin===identity.skin&&d.attachment===identity.attachment);
|
|
34
|
+
if(!declaration)return {kind:'icon',key,reason:`THUMBNAIL_UNDECLARED: ${identity.skin} / ${identity.attachment} is not in the catalog`};
|
|
35
|
+
if(declaration.kind!=='region'&&declaration.kind!=='mesh')return {kind:'icon',key,reason:`${declaration.kind} attachment draws no page image`};
|
|
36
|
+
if(table.absent)return {kind:'icon',key,reason:`THUMBNAIL_REGION_UNAVAILABLE: ${table.absent}`};
|
|
37
|
+
const name=declaration.path??declaration.attachment,found=table.regions.get(name)??[];
|
|
38
|
+
if(!found.length)return {kind:'icon',key,reason:`THUMBNAIL_NO_PAGE_REGION: the model declares no page region "${name}"`};
|
|
39
|
+
if(found.length>1)return {kind:'icon',key,reason:`THUMBNAIL_AMBIGUOUS_REGION: ${found.length} page regions are named "${name}" (sequence frames)`};
|
|
40
|
+
const region=found[0]!,page=table.pages.get(region.page)!;
|
|
41
|
+
if(region.degrees!==0)return {kind:'icon',key,reason:`THUMBNAIL_ROTATED_REGION: "${name}" is packed at ${region.degrees}°`};
|
|
42
|
+
if(region.x+region.width>page.width||region.y+region.height>page.height)return {kind:'icon',key,reason:`THUMBNAIL_REGION_OUTSIDE_PAGE: "${name}" exceeds ${region.page}`};
|
|
43
|
+
const u0=region.x/page.width,v0=region.y/page.height,u1=(region.x+region.width)/page.width,v1=(region.y+region.height)/page.height;
|
|
44
|
+
// Page pixels back from the UVs, rounded off float residue (a region edge sits on a whole or half texel).
|
|
45
|
+
const px=(value:number)=>Math.round(value*1e6)/1e6;
|
|
46
|
+
return {kind:'crop',key,page:region.page,region:name,u0,v0,u1,v1,sx:px(u0*page.width),sy:px(v0*page.height),sw:px((u1-u0)*page.width),sh:px((v1-v0)*page.height)};
|
|
47
|
+
}
|
|
48
|
+
/** Destination rectangle on the backing store: the crop fitted inside the box, aspect kept, centred. */
|
|
49
|
+
export function thumbnailFit(sw:number,sh:number,size=thumbnailBox*thumbnailScale):{dx:number;dy:number;dw:number;dh:number} {
|
|
50
|
+
const scale=Math.min(size/sw,size/sh),dw=sw*scale,dh=sh*scale;return {dx:(size-dw)/2,dy:(size-dh)/2,dw,dh};
|
|
51
|
+
}
|
|
52
|
+
type PageImage=CanvasImageSource&{complete?:boolean;naturalWidth?:number};
|
|
53
|
+
/** Lazy (IntersectionObserver) drawing, one cached bitmap per attachment key. */
|
|
54
|
+
export class PartThumbnails implements PartThumbnailSource {
|
|
55
|
+
private cache=new Map<string,HTMLCanvasElement>();private pending=new Map<Element,ThumbnailCrop>();private observer:IntersectionObserver|null=null;
|
|
56
|
+
constructor(private table:PageRegionTable,private images:ReadonlyMap<string,PageImage>){
|
|
57
|
+
if(typeof IntersectionObserver==='function')this.observer=new IntersectionObserver(items=>{for(const item of items)if(item.isIntersecting){const plan=this.pending.get(item.target);this.observer?.unobserve(item.target);this.pending.delete(item.target);if(plan)this.paint(item.target as HTMLCanvasElement,plan);}});
|
|
58
|
+
}
|
|
59
|
+
show(icon:HTMLElement,entry:PartEntry,identity:ControlPartIdentity|null,glyph:string):string {
|
|
60
|
+
const plan=thumbnailPlan(this.table,entry,identity),image=plan.kind==='crop'?this.images.get(plan.page):undefined;
|
|
61
|
+
const shown:ThumbnailPlan=plan.kind==='crop'&&!image?{kind:'icon',key:plan.key,reason:`THUMBNAIL_PAGE_UNAVAILABLE: page ${plan.page} is not loaded`}:plan;
|
|
62
|
+
const stamp=JSON.stringify(shown);
|
|
63
|
+
if(icon.dataset.thumbnailPlan!==stamp){
|
|
64
|
+
icon.dataset.thumbnailPlan=stamp;for(const child of icon.querySelectorAll('canvas')){this.observer?.unobserve(child);this.pending.delete(child);}
|
|
65
|
+
if(shown.kind==='crop'){const canvas=document.createElement('canvas');canvas.width=canvas.height=thumbnailBox*thumbnailScale;canvas.setAttribute('aria-hidden','true');icon.replaceChildren(canvas);icon.dataset.thumbnail='image';if(this.observer){this.pending.set(canvas,shown);this.observer.observe(canvas);}else this.paint(canvas,shown);}
|
|
66
|
+
else{icon.textContent=glyph;icon.dataset.thumbnail='icon';}
|
|
67
|
+
}
|
|
68
|
+
return shown.kind==='crop'?`thumbnail: region ${shown.region} on ${shown.page}`:`type icon: ${shown.reason}`;
|
|
69
|
+
}
|
|
70
|
+
private paint(canvas:HTMLCanvasElement,plan:ThumbnailCrop):void {
|
|
71
|
+
let source=this.cache.get(plan.key);
|
|
72
|
+
if(!source){const image=this.images.get(plan.page);if(!image)return;source=document.createElement('canvas');source.width=source.height=thumbnailBox*thumbnailScale;const fit=thumbnailFit(plan.sw,plan.sh);source.getContext('2d')?.drawImage(image,plan.sx,plan.sy,plan.sw,plan.sh,fit.dx,fit.dy,fit.dw,fit.dh);this.cache.set(plan.key,source);}
|
|
73
|
+
const context=canvas.getContext('2d');if(!context)return;context.clearRect(0,0,canvas.width,canvas.height);context.drawImage(source,0,0);
|
|
74
|
+
}
|
|
75
|
+
dispose():void {this.observer?.disconnect();this.pending.clear();this.cache.clear();}
|
|
76
|
+
}
|
|
@@ -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
|
+
}
|