@rig-c/review 0.1.0 → 0.3.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/LICENSE +21 -0
- package/README.md +126 -7
- package/docs/DESIGN.md +29 -0
- package/docs/REVIEW_PACKAGE.md +24 -5
- package/package.json +5 -3
- package/src/animation-selection.ts +9 -5
- package/src/cli.ts +9 -6
- package/src/compare-server.ts +8 -5
- package/src/comparison-eligibility.ts +54 -0
- package/src/control.ts +32 -3
- package/src/front-worker.ts +2 -0
- package/src/front.ts +127 -0
- package/src/manifest.ts +5 -1
- package/src/open-protocol.ts +17 -0
- package/src/open.ts +67 -34
- package/src/server.ts +8 -7
- package/src/strip-render.ts +1 -1
- package/web/camera-view.ts +10 -0
- package/web/compare.css +44 -0
- package/web/compare.html +3 -3
- package/web/compare.ts +78 -34
- package/web/control.ts +6 -4
- package/web/index.html +3 -3
- package/web/inspection-persistence.ts +30 -11
- package/web/instance-access.ts +23 -0
- package/web/main.ts +85 -41
- package/web/part-selection.ts +136 -0
- package/web/rig-overlays.ts +9 -6
- package/web/styles.css +41 -0
- package/web/view-controls.ts +7 -5
- package/web/workspace-layout.ts +84 -0
- package/web/workspace-preferences.ts +95 -0
- package/web/workspace-ui.ts +91 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** Slot inspection over the public last-drawn frame. Never changes a track, skin or source. */
|
|
2
|
+
import type {Json} from '../src/manifest.ts';
|
|
3
|
+
import type {PosedFrame,RigPlayer,PoseBounds,SlotIdentitySnapshot} from '@rig-c/play';
|
|
4
|
+
import {parseControlPart,parsePartIdentity,type ControlPart,type ControlPartIdentity,type PartIdentityReason} from '../src/control.ts';
|
|
5
|
+
export interface PartDeclaration {skin:string;attachment:string;kind:string;path:string|null}
|
|
6
|
+
export interface PartEntry {slot:string;bone:string;setup:string|null;declarations:PartDeclaration[]}
|
|
7
|
+
export interface PartObservation {side:ControlPart['side'];slot:string;mode:ControlPart['mode'];identity:ControlPartIdentity|null;generation:number|null;reason:PartIdentityReason|null;drawn:boolean;kind:string|null;page:string|null;region:string|null;sequence:number|null;vertices:number|null;triangles:number|null;z:number|null;skin:string|null;set:string|null;activeAttachment:{skin:string;attachment:string}|null;identityUnavailable:string|null;bounds:PoseBounds|null;declarations:PartDeclaration[]}
|
|
8
|
+
const object=(v:unknown,name:string):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw new Error(`${name}: expected object`);return v as Record<string,unknown>;};
|
|
9
|
+
const name=(v:unknown,field:string):string=>{if(typeof v!=='string'||!v||v.length>512||/[\x00-\x1f]/.test(v))throw new Error(`${field}: expected nonempty name <= 512`);return v;};
|
|
10
|
+
export function partCatalog(value:unknown,expected:readonly string[]):PartEntry[]{
|
|
11
|
+
const model=object(value,'part model');if(!Array.isArray(model.slots)||!Array.isArray(model.skins)||model.slots.length>20000||model.skins.length>2000)throw new Error('Part catalog requires bounded model slots and skins');
|
|
12
|
+
const entries=model.slots.map((v,i)=>{const slot=object(v,`slot ${i}`);return {slot:name(slot.name,'slot.name'),bone:name(slot.bone,'slot.bone'),setup:slot.setup===undefined||slot.setup===null||slot.setup===''?null:name(slot.setup,'slot.setup'),declarations:[] as PartDeclaration[]};});
|
|
13
|
+
const bySlot=new Map(entries.map(e=>[e.slot,e]));if(bySlot.size!==entries.length||expected.length!==entries.length||new Set(expected).size!==expected.length||expected.some(s=>!bySlot.has(s)))throw new Error('PART_SOURCE_CHANGED: slot catalog differs from reviewed model');
|
|
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
|
+
return entries;
|
|
16
|
+
}
|
|
17
|
+
export async function loadPartCatalog(url:string,sha256:string,slots:readonly string[],fetcher:typeof fetch=fetch):Promise<PartEntry[]>{
|
|
18
|
+
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
|
+
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
|
+
return partCatalog(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes)),slots);
|
|
21
|
+
}
|
|
22
|
+
/** Resolve clips on the whole frame first. Filtering player.slots would drop clipping slots. */
|
|
23
|
+
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;}
|
|
24
|
+
/** Retained frames own geometry: a later prepared seek cannot change an earlier drawn pose. */
|
|
25
|
+
export function copyPartFrame(frame:PosedFrame):PosedFrame{return {...(frame.generation===undefined?{}:{generation:frame.generation}),order:frame.order.map(row=>{
|
|
26
|
+
const common={tint:[...row.tint] as [number,number,number,number],...(row.dark?{dark:[...row.dark] as [number,number,number]}:{}),...(row.clip?{clip:{...row.clip,polygon:[...row.clip.polygon]}}:{}),uvs:[...row.uvs]};
|
|
27
|
+
return row.kind==='mesh'?{...row,...common,vertices:[...row.vertices],triangles:[...row.triangles]}:{...row,...common,corners:[...row.corners]};
|
|
28
|
+
})};}
|
|
29
|
+
export function partGeometry(frame:PosedFrame,slot:string){
|
|
30
|
+
const row=frame.order.find(r=>r.slot===slot);if(!row)return null;const coords=row.kind==='mesh'?row.vertices:row.corners;
|
|
31
|
+
if(coords.length%2||coords.some(v=>!Number.isFinite(v))||row.kind==='mesh'&&(row.triangles.length%3||row.triangles.some(i=>!Number.isSafeInteger(i)||i<0||i>=coords.length/2)))throw new Error(`Part ${slot}: malformed posed geometry`);
|
|
32
|
+
return {kind:row.kind,page:row.page,region:row.kind==='region'?row.region:null,sequence:row.kind==='mesh'?row.sequence:null,vertices:row.kind==='mesh'?coords.length/2:null,triangles:row.kind==='mesh'?row.triangles.length/3:null,z:row.z};
|
|
33
|
+
}
|
|
34
|
+
/** Conservative world AABB of the last drawn geometry. Normal clips restrict its AABB;
|
|
35
|
+
* inverse clips remain conservative. This is not an alpha/pixel coverage measurement. */
|
|
36
|
+
export function partBounds(frame:PosedFrame,slot:string):PoseBounds|null {
|
|
37
|
+
const row=frame.order.find(r=>r.slot===slot);if(!row)return null;partGeometry(frame,slot);
|
|
38
|
+
const box=(coordinates:readonly number[])=>{let left=Infinity,bottom=Infinity,right=-Infinity,top=-Infinity;for(let i=0;i<coordinates.length;i+=2){const x=coordinates[i]!,y=coordinates[i+1]!;if(!Number.isFinite(x)||!Number.isFinite(y))throw new Error(`Part ${slot}: malformed posed bounds`);left=Math.min(left,x);right=Math.max(right,x);bottom=Math.min(bottom,y);top=Math.max(top,y);}return {left,right,bottom,top};};
|
|
39
|
+
const bounds=box(row.kind==='mesh'?row.vertices:row.corners);
|
|
40
|
+
if(row.clip&&!row.clip.inverse){if(row.clip.polygon.length<6||row.clip.polygon.length%2)throw new Error(`Part ${slot}: malformed clip bounds`);const clip=box(row.clip.polygon);bounds.left=Math.max(bounds.left,clip.left);bounds.right=Math.min(bounds.right,clip.right);bounds.bottom=Math.max(bounds.bottom,clip.bottom);bounds.top=Math.min(bounds.top,clip.top);}
|
|
41
|
+
const width=bounds.right-bounds.left,height=bounds.top-bounds.bottom;return Number.isFinite(width)&&Number.isFinite(height)&&width>0&&height>0?{x:bounds.left,y:bounds.bottom,width,height}:null;
|
|
42
|
+
}
|
|
43
|
+
/** Identity comes only from the held public snapshot, never catalog/UV/geometry inference. */
|
|
44
|
+
export function partIdentity(snapshot:SlotIdentitySnapshot|null,slot:string):ControlPartIdentity|null {
|
|
45
|
+
const row=snapshot?.slots.find(row=>row.slot===slot);return row?{attachment:row.attachment,skin:row.skin,reason:row.reason,set:snapshot!.set,activeSkin:snapshot!.skin}:null;
|
|
46
|
+
}
|
|
47
|
+
export function copyPartIdentity(snapshot:SlotIdentitySnapshot,frame:PosedFrame,entries:readonly PartEntry[],before:number,after:number):SlotIdentitySnapshot {
|
|
48
|
+
if(!Number.isSafeInteger(before)||before<0||before!==after||snapshot.generation!==before||frame.generation!==before)throw new Error('PART_IDENTITY_STALE: held frame and public slot identity generations differ');
|
|
49
|
+
if(snapshot.slots.length!==entries.length)throw new Error('PART_IDENTITY_SOURCE_CHANGED: snapshot slot count differs from reviewed catalog');
|
|
50
|
+
const names=new Set(entries.map(entry=>entry.slot)),seen=new Set<string>();
|
|
51
|
+
const slots=snapshot.slots.map((row,index)=>{
|
|
52
|
+
if(!names.has(row.slot)||seen.has(row.slot)||row.z!==index)throw new Error(`PART_IDENTITY_SOURCE_CHANGED: slot "${row.slot}" has unknown, repeated or noncontiguous z ${row.z}`);seen.add(row.slot);
|
|
53
|
+
parsePartIdentity({attachment:row.attachment,skin:row.skin,reason:row.reason,set:snapshot.set,activeSkin:snapshot.skin});return {...row};
|
|
54
|
+
});
|
|
55
|
+
const bySlot=new Map(slots.map(row=>[row.slot,row]));let lastZ=-1;
|
|
56
|
+
for(const row of frame.order){const identity=bySlot.get(row.slot);if(!identity||identity.reason!==null||identity.z!==row.z||row.z<=lastZ)throw new Error(`PART_IDENTITY_FRAME_MISMATCH: slot "${row.slot}" does not match held frame order`);lastZ=row.z;}
|
|
57
|
+
// Identity-known slots need not have a visible primitive (zero alpha/empty
|
|
58
|
+
// geometry/nonvisual setup). A known key is not a pixel or fit guarantee.
|
|
59
|
+
return {generation:snapshot.generation,set:snapshot.set,skin:snapshot.skin,slots};
|
|
60
|
+
}
|
|
61
|
+
export function orderedParts(entries:readonly PartEntry[],frame:PosedFrame|null,identity:SlotIdentitySnapshot|null=null):PartEntry[]{
|
|
62
|
+
const z=new Map(identity?.slots.map(row=>[row.slot,row.z])??frame?.order.map(row=>[row.slot,row.z])??[]),setup=new Map(entries.map((entry,i)=>[entry.slot,i]));
|
|
63
|
+
return [...entries].sort((a,b)=>{const az=z.get(a.slot),bz=z.get(b.slot);return az!==undefined&&bz!==undefined?az-bz:az!==undefined?-1:bz!==undefined?1:setup.get(a.slot)!-setup.get(b.slot)!;});
|
|
64
|
+
}
|
|
65
|
+
export interface PartPlayer extends Pick<RigPlayer,'posedFrame'|'slotIdentity'|'generation'|'skin'|'set'> {readonly renderer:Pick<RigPlayer['renderer'],'render'>}
|
|
66
|
+
interface SideState {entries:PartEntry[];player:PartPlayer;frame:PosedFrame|null;identity:SlotIdentitySnapshot|null}
|
|
67
|
+
/** Why a side's parts cannot be inspected. A catalog refusal lasts until that side registers again; an identity refusal until a later valid frame. */
|
|
68
|
+
export interface PartRefusal {code:string;message:string;catalog:boolean}
|
|
69
|
+
export function partRefusal(error:unknown,catalog:boolean):PartRefusal{const message=error instanceof Error?error.message:String(error),code=/^([A-Z][A-Z0-9_]+):/.exec(message)?.[1]??(catalog?'PART_CATALOG_UNAVAILABLE':'PART_IDENTITY_UNAVAILABLE');return {code,message,catalog};}
|
|
70
|
+
export class PartInspection {
|
|
71
|
+
protected sides=new Map<ControlPart['side'],SideState>();protected selected:ControlPart|null=null;protected refused=new Map<ControlPart['side'],PartRefusal>();
|
|
72
|
+
register(side:ControlPart['side'],entries:PartEntry[],player:PartPlayer){this.refused.delete(side);this.sides.set(side,{entries,player,frame:null,identity:null});}
|
|
73
|
+
/** The catalog for this side failed to load or hash: the rig still draws, the side offers no parts. */
|
|
74
|
+
refuseCatalog(side:ControlPart['side'],error:unknown):void {this.sides.delete(side);this.refused.set(side,partRefusal(error,true));}
|
|
75
|
+
refusal(side:ControlPart['side']):PartRefusal|null {return this.refused.get(side)??null;}
|
|
76
|
+
/** A selection on a refused side is retained for recovery but never exposed, drawn Solo or targeted. */
|
|
77
|
+
protected live():ControlPart|null {const part=this.selected;return part&&!this.refused.has(part.side)?part:null;}
|
|
78
|
+
value():ControlPart|null {const part=this.live();if(!part)return null;const state=this.sides.get(part.side),identity=partIdentity(state?.identity??null,part.slot);return {...part,...(identity?{identity}:{} )};}
|
|
79
|
+
assertIdentity(expected:ControlPart|null|undefined):void {if(!expected)return;this.validate(expected);const actual=partIdentity(this.sides.get(expected.side)?.identity??null,expected.slot);if(!actual)throw new Error(`PART_IDENTITY_UNAVAILABLE: ${expected.side} / ${expected.slot} has no held public snapshot`);if(expected.identity&&JSON.stringify(actual)!==JSON.stringify(parsePartIdentity(expected.identity)))throw new Error(`PART_IDENTITY_MISMATCH: ${expected.side} / ${expected.slot} no longer resolves the bookmarked skin/attachment/set/reason`);}
|
|
80
|
+
validate(value:ControlPart|null|undefined):void {if(!value)return;parseControlPart(value);const state=this.sides.get(value.side);if(!state||!state.entries.some(e=>e.slot===value.slot))throw new Error(`PART_UNAVAILABLE: ${value.side} / ${value.slot}`);}
|
|
81
|
+
select(value:ControlPart|null):void {this.validate(value);const refusal=value?this.refused.get(value.side):undefined;if(refusal)throw new Error(`PARTS_UNAVAILABLE: ${value!.side} / ${refusal.message}`);const next=value?{side:value.side,slot:value.slot,mode:value.mode}:null,previous=this.selected;try{for(const [side,state]of this.sides)if(state.frame)state.player.renderer.render(selectedFrame(state.frame,next,side));}catch(error){for(const [side,state]of this.sides)if(state.frame)state.player.renderer.render(selectedFrame(state.frame,previous,side));throw error;}this.selected=next;}
|
|
82
|
+
/** Called inside the successful draw boundary, before compare submission is measured complete. */
|
|
83
|
+
present(side:ControlPart['side']):void {const state=this.sides.get(side);if(!state)return;this.commit(side,state,this.capture(state));}
|
|
84
|
+
protected capture(state:SideState):{full:PosedFrame;identity:SlotIdentitySnapshot} {const before=state.player.generation,full=copyPartFrame(state.player.posedFrame());return {full,identity:copyPartIdentity(state.player.slotIdentity(),full,state.entries,before,state.player.generation)};}
|
|
85
|
+
/** Solo is drawn only from a frame whose identity was just captured; a renderer failure still propagates to the draw boundary. */
|
|
86
|
+
protected commit(side:ControlPart['side'],state:SideState,{full,identity}:{full:PosedFrame;identity:SlotIdentitySnapshot}):void {if(this.selected?.side===side&&this.selected.mode==='solo')state.player.renderer.render(selectedFrame(full,this.selected,side));state.frame=full;state.identity=identity;this.refused.delete(side);}
|
|
87
|
+
/** Drop the held frame and identity: nothing stale is shown as current, and the player's own whole draw stays on screen. */
|
|
88
|
+
protected refuseIdentity(side:ControlPart['side'],state:SideState,error:unknown):void {state.frame=null;state.identity=null;this.refused.set(side,partRefusal(error,false));}
|
|
89
|
+
bounds():PoseBounds|null {const part=this.live(),state=part?this.sides.get(part.side):null;return part&&state?.frame?partBounds(state.frame,part.slot):null;}
|
|
90
|
+
frame(side:ControlPart['side']):PosedFrame|undefined {const state=this.sides.get(side);return state?.frame?selectedFrame(state.frame,this.live(),side):undefined;}
|
|
91
|
+
observation():PartObservation|null {const part=this.live();if(!part)return null;const state=this.sides.get(part.side),entry=state?.entries.find(e=>e.slot===part.slot);if(!state||!entry)return null;const geometry=state.frame?partGeometry(state.frame,part.slot):null,identity=partIdentity(state.identity,part.slot),row=state.identity?.slots.find(row=>row.slot===part.slot);return {...part,identity,generation:state.identity?.generation??null,reason:identity?.reason??null,drawn:!!geometry,kind:geometry?.kind??null,page:geometry?.page??null,region:geometry?.region??null,sequence:geometry?.sequence??null,vertices:geometry?.vertices??null,triangles:geometry?.triangles??null,z:row?.z??geometry?.z??null,skin:state.identity?.skin??null,set:state.identity?.set??null,activeAttachment:identity?.reason===null&&identity.attachment!==null&&identity.skin!==null?{skin:identity.skin,attachment:identity.attachment}:null,identityUnavailable:identity?null:'PART_IDENTITY_UNAVAILABLE: no held public identity snapshot',bounds:this.bounds(),declarations:entry.declarations.map(d=>({...d}))};}
|
|
92
|
+
|
|
93
|
+
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
|
+
}
|
|
95
|
+
export class PartExplorer extends PartInspection {
|
|
96
|
+
private rows=new Map<string,HTMLButtonElement>();private rowEntries=new Map<HTMLButtonElement,PartEntry>();private detailKey='';private orderKey='';private selectionKey='';
|
|
97
|
+
private detailNodes:{title:HTMLHeadingElement;info:HTMLParagraphElement;declarations:HTMLParagraphElement;bounds:HTMLParagraphElement;unavailable:HTMLParagraphElement;feedback:HTMLButtonElement;status:HTMLParagraphElement}|null=null;
|
|
98
|
+
constructor(private host:HTMLElement,private detail:HTMLElement,private changed:(previous:ControlPart|null)=>void,private target:(part:ControlPart)=>void){
|
|
99
|
+
super();
|
|
100
|
+
this.search.addEventListener('input',()=>this.renderList());this.search.addEventListener('keydown',event=>{if(event.key==='ArrowDown'){event.preventDefault();this.list.querySelector<HTMLButtonElement>('button')?.focus();}if(event.key==='Escape'){this.search.value='';this.renderList();}});
|
|
101
|
+
this.list.addEventListener('keydown',event=>{const buttons=[...this.list.querySelectorAll<HTMLButtonElement>('button')],index=buttons.indexOf(document.activeElement as HTMLButtonElement);const next=event.key==='ArrowDown'?Math.min(index+1,buttons.length-1):event.key==='ArrowUp'?Math.max(0,index-1):event.key==='Home'?0:event.key==='End'?buttons.length-1:null;if(next!==null){event.preventDefault();buttons[next]?.focus();}if(event.key==='Escape')this.search.focus();});
|
|
102
|
+
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
|
+
}
|
|
104
|
+
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();}
|
|
106
|
+
refuseCatalog(side:ControlPart['side'],error:unknown):void {super.refuseCatalog(side,error);this.renderList();this.renderDetail();}
|
|
107
|
+
select(value:ControlPart|null):void {const previous=this.value();super.select(value);this.renderSelection();this.renderDetail();this.changed(previous);}
|
|
108
|
+
/** Runs on every drawn frame. The held copy and Solo draw are per frame; the list is rewritten only when what it shows changed. */
|
|
109
|
+
present(side:ControlPart['side']):void {const state=this.sides.get(side);if(!state)return;let held;try{held=this.capture(state);}catch(error){this.refuseIdentity(side,state,error);}if(held)this.commit(side,state,held);if(this.viewKey()!==this.selectionKey){this.reorder();this.renderSelection();}this.renderDetail();}
|
|
110
|
+
/** Everything the rows and Solo control display: selection, per-side drawn slots in order, and identity. Generation is excluded. */
|
|
111
|
+
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
|
+
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}`;row.textContent=`${entry.declarations.some(d=>d.kind==='mesh')?'◈':'◇'} ${side==='single'?'':side.toUpperCase()+' · '}${entry.slot}`;row.onclick=()=>this.action(()=>this.select({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
|
+
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
|
+
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(row.textContent!==label)row.textContent=label;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;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'}`;const label=`${entry?.declarations.some(d=>d.kind==='mesh')?'◈':'◇'} ${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(row.textContent!==label)row.textContent=label;row.setAttribute('aria-label',`${row.textContent} · ${state?.frame?drawn?'drawn':'not drawn':'awaiting draw'}`);}}
|
|
117
|
+
private renderDetail(){
|
|
118
|
+
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
|
+
// Keep focused controls connected while the selected pose's bounds animate.
|
|
120
|
+
const nodes=this.detailNodes??=(()=>{
|
|
121
|
+
const title=document.createElement('h3'),info=document.createElement('p'),declarations=document.createElement('p'),bounds=document.createElement('p'),unavailable=document.createElement('p'),feedback=document.createElement('button'),status=document.createElement('p');
|
|
122
|
+
for(const node of [info,declarations,bounds,unavailable,status])node.className='hint';
|
|
123
|
+
feedback.type='button';feedback.textContent='Use slot for feedback';feedback.onclick=()=>{const part=this.value();if(part)this.target(part);};
|
|
124
|
+
unavailable.textContent='Named vertex weights and original source alpha unavailable in this inspection. A packed page is not original part art.';
|
|
125
|
+
status.textContent='Mesh optimization requires an eligible producer handoff. This inspection changes no source art, geometry or motion.';
|
|
126
|
+
this.detail.replaceChildren(title,info,declarations,bounds,unavailable,feedback,status);
|
|
127
|
+
return {title,info,declarations,bounds,unavailable,feedback,status};
|
|
128
|
+
})();
|
|
129
|
+
nodes.title.textContent=observation?`${observation.side==='single'?'':observation.side.toUpperCase()+' · '}${observation.slot}`:'Whole rig';
|
|
130
|
+
nodes.info.textContent=observation?`${observation.mode==='solo'?'Solo':'In context'} · ${observation.drawn?observation.kind==='mesh'?`${observation.vertices} posed vertices · ${observation.triangles} triangles`:'Region · four posed corners':'Not drawn at current pose'} · Skin: ${observation.skin??'not set'} · Set: ${observation.set??'not set'}`:refusal?`Parts unavailable · ${refusal.message}. The whole rig stays drawn; no slot is selected, soloed or targeted from stale data.`:'Select an actual slot in the right explorer. Selection preserves animation, time and physics history.';
|
|
131
|
+
for(const node of [nodes.declarations,nodes.bounds,nodes.unavailable,nodes.feedback])node.hidden=!observation;nodes.feedback.disabled=!observation;
|
|
132
|
+
nodes.declarations.textContent=observation?`${observation.activeAttachment?'Resolved public attachment: '+observation.activeAttachment.skin+' / '+observation.activeAttachment.attachment+'.':(observation.identityUnavailable??'No active drawable attachment: '+observation.reason)+'.'} Declared: ${observation.declarations.map(d=>`${d.skin} / ${d.attachment} (${d.kind})`).join('; ')||'none'}. ${observation.page?'Drawn page: '+observation.page:''}${observation.region?' · region '+observation.region:''}${observation.sequence!==null?' · sequence '+observation.sequence:''}`:'';
|
|
133
|
+
nodes.bounds.textContent=observation?observation.bounds?`Selection bounds · world units / Y up: x ${observation.bounds.x}, y ${observation.bounds.y}, width ${observation.bounds.width}, height ${observation.bounds.height}. Conservative geometry/clip bounds; fit uses the existing 0.25–8× camera range.`:'PART_FIT_UNAVAILABLE: no positive drawn bounds at the held pose.':'';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
}
|
package/web/rig-overlays.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Inspection graphics from the last successfully drawn public player pose. */
|
|
2
|
+
import {partBounds} from './part-selection.ts';
|
|
2
3
|
import type {BoneWorld,PosedFrame,RigPlayer} from '@rig-c/play';
|
|
3
4
|
|
|
4
5
|
export interface OverlayPoint {x:number;y:number}
|
|
@@ -8,13 +9,13 @@ export interface RigOverlaySettings {images:boolean;mesh:boolean;bones:boolean;m
|
|
|
8
9
|
export interface OverlayPlayer extends Pick<RigPlayer,'posedFrame'|'bone'> {}
|
|
9
10
|
export interface OverlayLine {from:OverlayPoint;to:OverlayPoint;slot?:string}
|
|
10
11
|
export interface OverlayBone {name:string;point:OverlayPoint;xAxis:OverlayPoint;yAxis:OverlayPoint}
|
|
11
|
-
export interface RigOverlayGeometry {mesh:OverlayLine[];bones:OverlayLine[];joints:OverlayBone[];warnings:string[];captured:{mesh:boolean;bones:boolean}}
|
|
12
|
+
export interface RigOverlayGeometry {selection:OverlayLine[];mesh:OverlayLine[];bones:OverlayLine[];joints:OverlayBone[];warnings:string[];captured:{mesh:boolean;bones:boolean}}
|
|
12
13
|
export interface OverlaySnapshot {
|
|
13
14
|
available:boolean;needsCapture:boolean;meshEdges:number;boneCount:number;
|
|
14
15
|
capabilities:{images:boolean;mesh:boolean;bones:boolean};warnings:string[];
|
|
15
16
|
}
|
|
16
17
|
export interface OverlayView {project:OverlayProject;settings:RigOverlaySettings;dpr?:number}
|
|
17
|
-
export interface OverlayRefresh extends OverlayView {player:OverlayPlayer|null;bones:readonly BoneOverlaySpec[]}
|
|
18
|
+
export interface OverlayRefresh extends OverlayView {player:OverlayPlayer|null;bones:readonly BoneOverlaySpec[];frame?:PosedFrame;selectedSlot?:string|null}
|
|
18
19
|
const point=(x:number,y:number):OverlayPoint=>({x,y});
|
|
19
20
|
const finite=(p:OverlayPoint)=>Number.isFinite(p.x)&&Number.isFinite(p.y);
|
|
20
21
|
const message=(error:unknown)=>error instanceof Error?error.message:String(error);
|
|
@@ -41,10 +42,11 @@ function meshLines(frame:PosedFrame,warnings:string[]):OverlayLine[]{
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/** Takes copies; later seeks cannot turn an old overlay into a prepared pose. */
|
|
44
|
-
export function captureRigGeometry(player:OverlayPlayer,specs:readonly BoneOverlaySpec[],layers:Pick<RigOverlaySettings,'mesh'|'bones'
|
|
45
|
-
const geometry:RigOverlayGeometry={mesh:[],bones:[],joints:[],warnings:[],captured:{mesh:false,bones:false}};
|
|
45
|
+
export function captureRigGeometry(player:OverlayPlayer,specs:readonly BoneOverlaySpec[],layers:Pick<RigOverlaySettings,'mesh'|'bones'>,frame?:PosedFrame,selectedSlot?:string|null):RigOverlayGeometry{
|
|
46
|
+
const geometry:RigOverlayGeometry={selection:[],mesh:[],bones:[],joints:[],warnings:[],captured:{mesh:false,bones:false}};
|
|
47
|
+
if(selectedSlot){try{const bounds=partBounds(frame??player.posedFrame(null),selectedSlot);if(bounds){const {x,y,width,height}=bounds,points=[point(x,y),point(x+width,y),point(x+width,y+height),point(x,y+height)];geometry.selection=points.map((from,i)=>({from,to:points[(i+1)%4]!,slot:selectedSlot}));}}catch(error){geometry.warnings.push(`Selection bounds unavailable: ${message(error)}`);}}
|
|
46
48
|
if(layers.mesh){
|
|
47
|
-
try{geometry.mesh=meshLines(player.posedFrame(),geometry.warnings);geometry.captured.mesh=true;}
|
|
49
|
+
try{geometry.mesh=meshLines(frame??player.posedFrame(),geometry.warnings);geometry.captured.mesh=true;}
|
|
48
50
|
catch(error){geometry.warnings.push(`Mesh overlay unavailable: ${message(error)}`);}
|
|
49
51
|
}
|
|
50
52
|
if(layers.bones){
|
|
@@ -86,7 +88,7 @@ export class RigOverlays {
|
|
|
86
88
|
refresh(input:OverlayRefresh):OverlaySnapshot{
|
|
87
89
|
if(this.disposed)return this.unavailable('Overlay has been disposed');
|
|
88
90
|
this.playerAvailable=!!input.player;
|
|
89
|
-
this.geometry=input.player?captureRigGeometry(input.player,input.bones,input.settings):null;
|
|
91
|
+
this.geometry=input.player?captureRigGeometry(input.player,input.bones,input.settings,input.frame,input.selectedSlot):null;
|
|
90
92
|
return this.redraw(input);
|
|
91
93
|
}
|
|
92
94
|
redraw({project,settings,dpr=globalThis.devicePixelRatio??1}:OverlayView):OverlaySnapshot{
|
|
@@ -130,6 +132,7 @@ export class RigOverlays {
|
|
|
130
132
|
ctx.fillStyle='#ffe26b';ctx.strokeStyle='#44310d';ctx.lineWidth=1;ctx.beginPath();ctx.arc(center.x,center.y,2.8,0,Math.PI*2);ctx.fill();ctx.stroke();
|
|
131
133
|
}
|
|
132
134
|
}
|
|
135
|
+
if(this.geometry?.selection.length){ctx.globalAlpha=1;ctx.strokeStyle='#ffbb47';ctx.lineWidth=2;ctx.setLineDash([5,3]);ctx.beginPath();for(const edge of this.geometry.selection)line(edge);ctx.stroke();ctx.setLineDash([]);}
|
|
133
136
|
ctx.restore();
|
|
134
137
|
return this.snapshot(true,needsCapture,warnings);
|
|
135
138
|
}
|
package/web/styles.css
CHANGED
|
@@ -96,3 +96,44 @@ main{min-height:0;overflow:hidden;display:flex;flex-direction:column}
|
|
|
96
96
|
|
|
97
97
|
.camera-sync{display:inline-flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}.camera-sync input[type=checkbox]{width:auto;flex:none;margin:0}
|
|
98
98
|
#stage-layout:has(>.stage-pane[hidden]){grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
|
|
99
|
+
|
|
100
|
+
/* Slot explorer shares the stage row; bottom Inspector remains in the main column. */
|
|
101
|
+
.layout{grid-template-columns:150px minmax(0,1fr) 235px!important}.parts-explorer{display:block;grid-template-columns:none;gap:normal;min-width:0;min-height:0;overflow:auto;border-left:1px solid #49616d;background:#182d38;padding:12px;overscroll-behavior:contain}.parts-explorer h2{font-size:12px;margin:0 0 10px}.parts-explorer>label{display:block;font-size:11px;margin-top:12px}.parts-explorer input[type=search]{width:100%;min-width:0}.parts-explorer .part-solo{display:flex;gap:6px;align-items:center}.parts-explorer ul{list-style:none;margin:8px 0;padding:0;display:grid;gap:4px}.parts-explorer li{min-width:0}.parts-explorer li button{display:block;width:100%;text-align:left;overflow-wrap:anywhere;font-size:11px}.parts-explorer button[aria-pressed=true]{background:#355445;border-color:#9cd3b4}.parts-explorer h2,#selected-part h3{font-weight:600}#selected-part{margin-bottom:12px}#selected-part h3{margin:0 0 8px;overflow-wrap:anywhere}#selected-part p{overflow-wrap:anywhere}[data-theme=light] .parts-explorer{background:#edf3f5;border-color:#a6bec8;color:#294552}[data-theme=light] .parts-explorer button[aria-pressed=true]{background:#c5ddcd;color:#183c2c}
|
|
102
|
+
@media(max-width:1050px){.layout{grid-template-columns:110px minmax(0,1fr) 185px!important}.parts-explorer{padding:8px}}
|
|
103
|
+
@media(max-width:700px){.layout{grid-template-columns:minmax(0,1fr) 165px!important}.left-panel{display:none!important}}
|
|
104
|
+
@media(max-width:460px){.layout{grid-template-columns:minmax(0,1fr) 132px!important}.parts-explorer{padding:6px}.parts-explorer li button{padding:6px}.parts-explorer>.hint{font-size:10px}}
|
|
105
|
+
|
|
106
|
+
/* Resizable workspace: one measured shell; workspace-ui clamps the desired browser sizes. */
|
|
107
|
+
.layout.workspace-layout{display:grid;grid-template-columns:var(--workspace-columns)!important;min-height:0!important;overflow:hidden}
|
|
108
|
+
.workspace-layout>main{display:grid;min-width:0;min-height:0;overflow:hidden;grid-column:3;grid-row:1}
|
|
109
|
+
.workspace-stage-stack{grid-row:1;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
|
|
110
|
+
.workspace-stage-stack>#stage-layout{flex:1;min-height:0;height:auto;overflow:hidden}
|
|
111
|
+
.workspace-layout>.left-panel{grid-column:1;grid-row:1;display:flex!important;flex-direction:column;overflow:hidden;min-width:0;padding:0;border-right:1px solid #49616d}
|
|
112
|
+
.workspace-sources{flex:1;min-height:0;overflow:auto;padding:10px;overscroll-behavior:contain}
|
|
113
|
+
.workspace-settings{flex:none;max-height:65%;overflow:auto;padding:10px;border-top:1px solid #49616d}
|
|
114
|
+
.workspace-settings h2{font-size:12px;margin:0 0 8px}
|
|
115
|
+
.workspace-settings label{display:flex;align-items:center;gap:6px;font-size:11px;margin:7px 0}
|
|
116
|
+
.workspace-settings select{width:auto;min-width:0;flex:1}
|
|
117
|
+
.workspace-settings .hint{margin:6px 0}
|
|
118
|
+
.workspace-settings input[type=checkbox]{width:auto;flex:none}
|
|
119
|
+
.workspace-layout>.parts-explorer{display:block;grid-template-columns:none;grid-column:5;grid-row:1;width:auto;min-width:0;min-height:0;height:auto;overflow:auto}
|
|
120
|
+
.workspace-layout .inspector{grid-row:3;height:auto!important;min-height:0!important;max-height:none;overflow:hidden}
|
|
121
|
+
.workspace-separator{position:relative;z-index:3;background:#34515e;touch-action:none;cursor:col-resize;min-width:0;min-height:0}
|
|
122
|
+
.workspace-separator::after{content:'';position:absolute;inset:0 -4px}
|
|
123
|
+
.workspace-separator:focus-visible{outline:2px solid #c8e8d6;outline-offset:-2px;background:#719581}
|
|
124
|
+
.workspace-separator[data-resizing=true]{background:#8ac9a3}
|
|
125
|
+
.workspace-leftWidth{grid-column:2;grid-row:1}.workspace-rightWidth{grid-column:4;grid-row:1}
|
|
126
|
+
.workspace-inspectorHeight{grid-row:2;cursor:row-resize}.workspace-inspectorHeight::after{inset:-4px 0}
|
|
127
|
+
.workspace-drawer-triggers{display:flex;gap:6px;flex-wrap:wrap;flex:none;padding:4px 8px;min-height:0}
|
|
128
|
+
.workspace-drawer-triggers:not(:has(button:not([hidden]))){display:none}
|
|
129
|
+
.workspace-drawer-triggers button{padding:4px 7px;font-size:11px;min-height:28px}
|
|
130
|
+
.workspace-drawer{position:fixed;inset:0;width:min(94vw,950px);height:min(92dvh,850px);max-width:94vw;max-height:92dvh;margin:auto;padding:8px;border:1px solid #739383;border-radius:8px;background:#182d38;color:inherit;overflow:hidden;z-index:20}
|
|
131
|
+
.workspace-drawer:not([open]){display:none}.workspace-drawer[open]{display:flex;flex-direction:column;gap:8px}
|
|
132
|
+
.workspace-drawer::backdrop{background:#07141ed0}
|
|
133
|
+
.workspace-drawer-close{align-self:flex-end;flex:none}
|
|
134
|
+
.workspace-drawer>.left-panel{display:flex!important;flex-direction:column;flex:1;overflow:hidden;min-height:0;padding:0}
|
|
135
|
+
.workspace-drawer>.parts-explorer{display:block;grid-template-columns:none;flex:1;width:auto;min-width:0;min-height:0;height:auto;overflow:auto}
|
|
136
|
+
.workspace-drawer>.inspector{flex:1;height:auto!important;min-height:0!important;max-height:none;overflow:hidden}
|
|
137
|
+
[data-theme=light] .workspace-settings,[data-theme=light] .workspace-drawer{background:#edf3f5;color:#294552;border-color:#a6bec8}
|
|
138
|
+
[data-theme=light] .workspace-separator{background:#b2c7cf}[data-theme=light] .workspace-separator:focus-visible{outline-color:#24694d;background:#70a58c}
|
|
139
|
+
@media(pointer:coarse){.workspace-separator::after{inset:0 -7px}.workspace-inspectorHeight::after{inset:-7px 0}.workspace-drawer-triggers button{min-height:38px}}
|
package/web/view-controls.ts
CHANGED
|
@@ -14,11 +14,12 @@ export function automaticIdlePreview(names:readonly string[],selected:string,che
|
|
|
14
14
|
}
|
|
15
15
|
export function themePreference(value:unknown):ThemePreference {return value==='dark'||value==='light'?value:'system';}
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/** The inspection appearance is transient; workspace Settings owns the saved browser default. */
|
|
18
|
+
export function bindAppearance(control:HTMLSelectElement,initial:ThemePreference='system'):{get:()=>ThemePreference;set:(value:ThemePreference)=>void;dispose:()=>void} {
|
|
19
|
+
let current=themePreference(initial);
|
|
19
20
|
const system=matchMedia('(prefers-color-scheme: light)');
|
|
20
21
|
const render=()=>{document.documentElement.dataset.theme=current==='system'?(system.matches?'light':'dark'):current;control.value=current;};
|
|
21
|
-
const set=(value:ThemePreference)=>{current=value
|
|
22
|
+
const set=(value:ThemePreference)=>{current=themePreference(value);render();};
|
|
22
23
|
const change=()=>set(themePreference(control.value));control.addEventListener('change',change);system.addEventListener('change',render);render();
|
|
23
24
|
return {get:()=>current,set,dispose:()=>{control.removeEventListener('change',change);system.removeEventListener('change',render);}};
|
|
24
25
|
}
|
|
@@ -60,8 +61,9 @@ export function bindPan(viewport:HTMLElement,move:(dx:number,dy:number)=>void,en
|
|
|
60
61
|
return ()=>{blur();window.removeEventListener('blur',blur);document.removeEventListener('keydown',keyDown,true);window.removeEventListener('keyup',keyUp,true);viewport.removeEventListener('pointerdown',start);viewport.removeEventListener('pointermove',drag);viewport.removeEventListener('pointerup',end);viewport.removeEventListener('pointercancel',end);viewport.removeEventListener('lostpointercapture',end);viewport.removeEventListener('dragstart',native);};
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
/** beforeChange runs while the old panel is still visible; changed runs after the new one is shown. */
|
|
65
|
+
export function bindInspectorTabs(changed:()=>void,beforeChange:()=>void=()=>{}):void {
|
|
64
66
|
const tabs=[...document.querySelectorAll<HTMLButtonElement>('[data-inspector]')];
|
|
65
|
-
const activate=(tab:HTMLButtonElement)=>{for(const item of tabs){const active=item===tab;item.setAttribute('aria-selected',String(active));item.tabIndex=active?0:-1;}for(const panel of document.querySelectorAll<HTMLElement>('[data-inspector-panel]'))panel.hidden=panel.dataset.inspectorPanel!==tab.dataset.inspector;changed();};
|
|
67
|
+
const activate=(tab:HTMLButtonElement)=>{beforeChange();for(const item of tabs){const active=item===tab;item.setAttribute('aria-selected',String(active));item.tabIndex=active?0:-1;}for(const panel of document.querySelectorAll<HTMLElement>('[data-inspector-panel]'))panel.hidden=panel.dataset.inspectorPanel!==tab.dataset.inspector;changed();};
|
|
66
68
|
for(const [index,tab] of tabs.entries()){tab.onclick=()=>activate(tab);tab.onkeydown=event=>{const next=event.key==='ArrowRight'?(index+1)%tabs.length:event.key==='ArrowLeft'?(index+tabs.length-1)%tabs.length:event.key==='Home'?0:event.key==='End'?tabs.length-1:null;if(next===null)return;event.preventDefault();const target=tabs[next];if(target){activate(target);target.focus();}};}
|
|
67
69
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Effective workspace geometry and local DOM bindings. Never stores source or global preferences. */
|
|
2
|
+
import {parseWorkspacePanels,panelBounds,type WorkspacePanels} from './workspace-preferences.ts';
|
|
3
|
+
export type WorkspacePanel=keyof WorkspacePanels;
|
|
4
|
+
export const workspaceMinimums=Object.freeze({stageWidth:240,stageHeight:180,separator:8});
|
|
5
|
+
export interface SeparatorRange {min:number;max:number;value:number;enabled:boolean}
|
|
6
|
+
export interface EffectiveWorkspaceLayout {leftWidth:number;rightWidth:number;inspectorHeight:number;stageWidth:number;stageHeight:number;leftMode:'panel'|'drawer';rightMode:'panel'|'drawer';inspectorMode:'panel'|'drawer';stageMinimumSatisfied:boolean;separators:Record<WorkspacePanel,SeparatorRange>}
|
|
7
|
+
const clamp=(value:number,min:number,max:number)=>Math.max(min,Math.min(max,value));
|
|
8
|
+
/** Input rectangle excludes app chrome. Drawer triggers must remain outside collapsed panel content.
|
|
9
|
+
* Collapse left, then right when minima cannot coexist; Inspector becomes a drawer below its minimum.
|
|
10
|
+
* Returned clamps never replace the user's desired desktop sizes. */
|
|
11
|
+
export function allocateWorkspaceLayout(available:{width:number;height:number},desired:WorkspacePanels):EffectiveWorkspaceLayout {
|
|
12
|
+
const panels=parseWorkspacePanels(desired),{width,height}=available,{stageWidth:minWidth,stageHeight:minHeight,separator}=workspaceMinimums;
|
|
13
|
+
if(![width,height].every(n=>Number.isFinite(n)&&n>=0&&n<=65536))throw new Error('WORKSPACE_RECTANGLE: finite 0..65536 CSS pixels required');
|
|
14
|
+
let left=true,right=true;if(width<panelBounds.leftWidth.min+panelBounds.rightWidth.min+minWidth+2*separator)left=false;if(width<(left?panelBounds.leftWidth.min+separator:0)+panelBounds.rightWidth.min+minWidth+separator)right=false;
|
|
15
|
+
const count=Number(left)+Number(right),budget=Math.max(0,width-minWidth-count*separator),leftMin=left?panelBounds.leftWidth.min:0,rightMin=right?panelBounds.rightWidth.min:0,wantLeft=left?panels.leftWidth:0,wantRight=right?panels.rightWidth:0;
|
|
16
|
+
const slack=wantLeft+wantRight-leftMin-rightMin,ratio=slack>0?Math.min(1,Math.max(0,(budget-leftMin-rightMin)/slack)):1,leftWidth=leftMin+(wantLeft-leftMin)*ratio,rightWidth=rightMin+(wantRight-rightMin)*ratio;
|
|
17
|
+
const inspector=height>=minHeight+panelBounds.inspectorHeight.min+separator,inspectorHeight=inspector?Math.min(panels.inspectorHeight,height-minHeight-separator):0;
|
|
18
|
+
const stageWidth=Math.max(0,width-leftWidth-rightWidth-count*separator),stageHeight=Math.max(0,height-inspectorHeight-(inspector?separator:0));
|
|
19
|
+
const range=(visible:boolean,key:WorkspacePanel,value:number,max:number):SeparatorRange=>visible?{min:panelBounds[key].min,max:Math.max(panelBounds[key].min,Math.min(panelBounds[key].max,max)),value,enabled:true}:{min:0,max:0,value:0,enabled:false};
|
|
20
|
+
return {leftWidth,rightWidth,inspectorHeight,stageWidth,stageHeight,leftMode:left?'panel':'drawer',rightMode:right?'panel':'drawer',inspectorMode:inspector?'panel':'drawer',stageMinimumSatisfied:width>=minWidth&&height>=minHeight,separators:{leftWidth:range(left,'leftWidth',leftWidth,width-rightWidth-count*separator-minWidth),rightWidth:range(right,'rightWidth',rightWidth,width-leftWidth-count*separator-minWidth),inspectorHeight:range(inspector,'inspectorHeight',inspectorHeight,height-minHeight-separator)}};
|
|
21
|
+
}
|
|
22
|
+
export interface WorkspaceSeparatorOptions {element:HTMLElement;panel:WorkspacePanel;controls:string;label:string;range:()=>SeparatorRange;resize:(desired:number)=>void;desired?:()=>number;resolveDesired?:(effective:number,context:{range:SeparatorRange;desired:number})=>number;commit?:(desired:number)=>void;events?:Pick<EventTarget,'addEventListener'|'removeEventListener'>|null;step?:number;coarseStep?:number}
|
|
23
|
+
/** Arrow keys act only on the separator itself. Right/bottom boundaries grow inward.
|
|
24
|
+
* Pointer cancellation, capture loss, Escape and blur restore the prior desired size.
|
|
25
|
+
* Coupled allocators supply their reachable effective range and resolveDesired inverse;
|
|
26
|
+
* resize/commit receive desired sizes, including when effective geometry is clamped. */
|
|
27
|
+
export function bindWorkspaceSeparator(options:WorkspaceSeparatorOptions):{refresh():void;dispose():void} {
|
|
28
|
+
const {element,panel}=options,vertical=panel!=='inspectorHeight',direction=panel==='leftWidth'?1:-1,events=options.events===undefined?(typeof window==='undefined'?null:window):options.events;
|
|
29
|
+
if(!options.controls||!options.label||/[\x00-\x1f]/.test(options.controls+options.label))throw new Error('WORKSPACE_SEPARATOR: controls and accessible label required');
|
|
30
|
+
const step=options.step??8,coarse=options.coarseStep??40;if(![step,coarse].every(n=>Number.isFinite(n)&&n>0))throw new Error('WORKSPACE_SEPARATOR: positive finite keyboard steps required');
|
|
31
|
+
const attrs=['role','aria-orientation','aria-controls','aria-label','aria-valuemin','aria-valuemax','aria-valuenow','aria-valuetext','aria-disabled'],saved=new Map(attrs.map(key=>[key,element.getAttribute(key)])),tabIndex=element.tabIndex,touchAction=element.style.touchAction;
|
|
32
|
+
let drag:{id:number;coordinate:number;value:number;desired:number;last:number;changed:boolean}|null=null,disposed=false;
|
|
33
|
+
const current=()=>{const range=options.range();if(![range.min,range.max,range.value].every(Number.isFinite)||range.min>range.max)throw new Error('WORKSPACE_SEPARATOR_RANGE: finite ordered range required');return {...range,value:clamp(range.value,range.min,range.max)};};
|
|
34
|
+
const release=(cancel:boolean)=>{const previous=drag;if(!previous)return;drag=null;delete element.dataset.resizing;try{if(element.hasPointerCapture(previous.id))element.releasePointerCapture(previous.id);}finally{if(previous.changed){if(cancel)options.resize(previous.desired);else options.commit?.(previous.last);}}};
|
|
35
|
+
const refresh=()=>{if(disposed)return;let range=current(),enabled=range.enabled&&range.max>range.min;if(!enabled&&drag){release(true);range=current();enabled=range.enabled&&range.max>range.min;}element.setAttribute('role','separator');element.setAttribute('aria-orientation',vertical?'vertical':'horizontal');element.setAttribute('aria-controls',options.controls);element.setAttribute('aria-label',options.label);element.setAttribute('aria-valuemin',String(Math.round(range.min)));element.setAttribute('aria-valuemax',String(Math.round(range.max)));element.setAttribute('aria-valuenow',String(Math.round(range.value)));element.setAttribute('aria-valuetext',`${Math.round(range.value)} pixels`);element.setAttribute('aria-disabled',String(!enabled));element.tabIndex=enabled?0:-1;};
|
|
36
|
+
const apply=(value:number,beforeResize?:(desired:number)=>void)=>{const range=current(),next=clamp(value,range.min,range.max),desired=options.desired?.()??range.value;if(Math.abs(next-range.value)<=1e-7)return {desired,changed:false};const resolved=options.resolveDesired?.(next,{range,desired})??next;if(!Number.isFinite(resolved))throw new Error('WORKSPACE_SEPARATOR: finite resolved desired size required');beforeResize?.(resolved);options.resize(resolved);refresh();return {desired:resolved,changed:true};};
|
|
37
|
+
const down=(event:PointerEvent)=>{if(disposed||drag||!event.isPrimary||event.button!==0)return;const target=event.target as Element|null;if(target!==element&&target&&typeof target.closest==='function'&&target.closest('input,textarea,select,button,[contenteditable]'))return;const range=current();if(!range.enabled||range.min===range.max)return;const coordinate=vertical?event.clientX:event.clientY;if(!Number.isFinite(coordinate))return;const desired=options.desired?.()??range.value;if(!Number.isFinite(desired))throw new Error('WORKSPACE_SEPARATOR: finite desired start required');drag={id:event.pointerId,coordinate,value:range.value,desired,last:desired,changed:false};try{element.setPointerCapture(event.pointerId);}catch(error){drag=null;throw error;}element.dataset.resizing='true';element.focus({preventScroll:true});event.preventDefault();};
|
|
38
|
+
const move=(event:PointerEvent)=>{if(!drag||event.pointerId!==drag.id)return;const coordinate=vertical?event.clientX:event.clientY;if(!Number.isFinite(coordinate))return;const active=drag;apply(active.value+(coordinate-active.coordinate)*direction,desired=>{active.last=desired;active.changed=true;});event.preventDefault();};
|
|
39
|
+
const up=(event:PointerEvent)=>{if(drag?.id!==event.pointerId)return;release(false);refresh();};
|
|
40
|
+
const cancel=(event:PointerEvent)=>{if(drag?.id!==event.pointerId)return;release(true);refresh();};
|
|
41
|
+
const blur=()=>{release(true);refresh();};
|
|
42
|
+
const key=(event:KeyboardEvent)=>{if(event.target!==element||event.altKey||event.ctrlKey||event.metaKey)return;if(event.key==='Escape'&&drag){event.preventDefault();release(true);refresh();return;}const range=current();if(!range.enabled||range.min===range.max)return;const amount=event.shiftKey?coarse:step,delta=vertical?(event.key==='ArrowRight'?direction*amount:event.key==='ArrowLeft'?-direction*amount:null):(event.key==='ArrowDown'?-amount:event.key==='ArrowUp'?amount:null);const next=event.key==='Home'?range.min:event.key==='End'?range.max:delta===null?null:range.value+delta;if(next===null)return;event.preventDefault();const result=apply(next);if(result.changed)options.commit?.(result.desired);};
|
|
43
|
+
element.style.touchAction='none';element.addEventListener('pointerdown',down);element.addEventListener('pointermove',move);element.addEventListener('pointerup',up);element.addEventListener('pointercancel',cancel);element.addEventListener('lostpointercapture',cancel);element.addEventListener('keydown',key);events?.addEventListener('blur',blur);refresh();
|
|
44
|
+
return {refresh,dispose(){if(disposed)return;release(true);disposed=true;element.removeEventListener('pointerdown',down);element.removeEventListener('pointermove',move);element.removeEventListener('pointerup',up);element.removeEventListener('pointercancel',cancel);element.removeEventListener('lostpointercapture',cancel);element.removeEventListener('keydown',key);events?.removeEventListener('blur',blur);element.style.touchAction=touchAction;element.tabIndex=tabIndex;for(const [key,value]of saved)if(value===null)element.removeAttribute(key);else element.setAttribute(key,value);}};
|
|
45
|
+
}
|
|
46
|
+
/** Use a native modal dialog for collapsed panels: focus containment/Escape belong to the browser.
|
|
47
|
+
* The trigger remains outside the collapsed panel; closing returns focus to that trigger. */
|
|
48
|
+
export function bindWorkspaceDrawer(options:{trigger:HTMLButtonElement;drawer:HTMLDialogElement;label:string;close?:HTMLButtonElement}):()=>void {
|
|
49
|
+
const {trigger,drawer}=options;if(!drawer.id||!options.label)throw new Error('WORKSPACE_DRAWER: named dialog and trigger required');let opened=false;
|
|
50
|
+
trigger.setAttribute('aria-haspopup','dialog');trigger.setAttribute('aria-controls',drawer.id);trigger.setAttribute('aria-expanded',String(drawer.open));if(!drawer.getAttribute('aria-label')&&!drawer.getAttribute('aria-labelledby'))drawer.setAttribute('aria-label',options.label);
|
|
51
|
+
const close=()=>{if(drawer.open)drawer.close();closed();};const toggle=()=>{if(drawer.open){close();return;}drawer.showModal();opened=true;trigger.setAttribute('aria-expanded','true');};const closed=()=>{trigger.setAttribute('aria-expanded','false');if(opened){opened=false;trigger.focus({preventScroll:true});}};
|
|
52
|
+
trigger.addEventListener('click',toggle);drawer.addEventListener('close',closed);options.close?.addEventListener('click',close);
|
|
53
|
+
return ()=>{if(opened)close();trigger.removeEventListener('click',toggle);drawer.removeEventListener('close',closed);options.close?.removeEventListener('click',close);};
|
|
54
|
+
}
|
|
55
|
+
export const inspectorTabs=['view','rig','playback','feedback'] as const;
|
|
56
|
+
export type InspectorTab=typeof inspectorTabs[number];
|
|
57
|
+
export interface InspectorScrollOffset {x:number;y:number}
|
|
58
|
+
export type InspectorScrollSnapshot=Partial<Record<InspectorTab,InspectorScrollOffset>>;
|
|
59
|
+
export interface ScrollScheduler {request(callback:()=>void):number;cancel(handle:number):void}
|
|
60
|
+
const validTab=(tab:string):tab is InspectorTab=>inspectorTabs.includes(tab as InspectorTab);
|
|
61
|
+
export function parseInspectorScroll(value:unknown):InspectorScrollSnapshot {
|
|
62
|
+
if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('INSPECTOR_SCROLL: expected tab object');const result:InspectorScrollSnapshot={};
|
|
63
|
+
for(const [tab,raw]of Object.entries(value)){if(!validTab(tab)||!raw||typeof raw!=='object'||Array.isArray(raw))throw new Error('INSPECTOR_SCROLL: unknown tab or malformed offset');const o=raw as Record<string,unknown>;if(Object.keys(o).some(key=>key!=='x'&&key!=='y')||![o.x,o.y].every(n=>typeof n==='number'&&Number.isFinite(n)&&n>=0&&n<=10000000))throw new Error('INSPECTOR_SCROLL: finite nonnegative x/y <=10000000 required');result[tab]={x:o.x as number,y:o.y as number};}return result;
|
|
64
|
+
}
|
|
65
|
+
const scrollScheduler:ScrollScheduler={request:callback=>typeof requestAnimationFrame==='function'?requestAnimationFrame(callback):Number(setTimeout(callback,0)),cancel:handle=>typeof cancelAnimationFrame==='function'?cancelAnimationFrame(handle):clearTimeout(handle)};
|
|
66
|
+
/** One instance per source draft. Capture BEFORE hiding a tab; restore AFTER activation.
|
|
67
|
+
* Two frame boundaries settle layout, and later content can explicitly restore again.
|
|
68
|
+
* Clamping never overwrites the remembered desired offsets with a temporary empty extent.
|
|
69
|
+
* Programmatic scroll events are ignored through the following frame; explicit user input can capture with true. */
|
|
70
|
+
export class InspectorScrollMemory {
|
|
71
|
+
private offsets:InspectorScrollSnapshot;private pending=new Map<InspectorTab,number>();private applied=new Map<InspectorTab,InspectorScrollOffset>();private disposed=false;
|
|
72
|
+
constructor(initial:InspectorScrollSnapshot={},private readonly scheduler:ScrollScheduler=scrollScheduler){this.offsets=parseInspectorScroll(initial);}
|
|
73
|
+
snapshot():InspectorScrollSnapshot{return parseInspectorScroll(this.offsets);}
|
|
74
|
+
replace(value:unknown):void {const next=parseInspectorScroll(value);for(const handle of this.pending.values())this.scheduler.cancel(handle);this.pending.clear();this.applied.clear();this.offsets=next;}
|
|
75
|
+
restoring(tab:InspectorTab):boolean{return this.pending.has(tab);}
|
|
76
|
+
capture(tab:InspectorTab,panel:Pick<HTMLElement,'scrollLeft'|'scrollTop'>,userInitiated=false):void {if(this.disposed||this.pending.has(tab)&&!userInitiated)return;if(!validTab(tab))throw new Error('INSPECTOR_SCROLL: unknown tab');const offset={x:Math.max(0,panel.scrollLeft),y:Math.max(0,panel.scrollTop)},applied=this.applied.get(tab);if(!userInitiated&&applied&&offset.x===applied.x&&offset.y===applied.y)return;const handle=this.pending.get(tab);if(handle!==undefined)this.scheduler.cancel(handle);this.pending.delete(tab);this.applied.delete(tab);this.offsets={...this.offsets,...parseInspectorScroll({[tab]:offset})};}
|
|
77
|
+
restore(tab:InspectorTab,panel:Pick<HTMLElement,'scrollLeft'|'scrollTop'|'scrollWidth'|'scrollHeight'|'clientWidth'|'clientHeight'>):()=>void {
|
|
78
|
+
if(this.disposed)return ()=>{};if(!validTab(tab))throw new Error('INSPECTOR_SCROLL: unknown tab');const previous=this.pending.get(tab);if(previous!==undefined)this.scheduler.cancel(previous);let cancelled=false;
|
|
79
|
+
let handle=0;const apply=()=>{if(cancelled||this.disposed||this.pending.get(tab)!==handle)return;this.pending.delete(tab);if(![panel.scrollWidth,panel.scrollHeight,panel.clientWidth,panel.clientHeight].every(n=>Number.isFinite(n)&&n>=0))throw new Error('INSPECTOR_SCROLL_LAYOUT: finite nonnegative extents required');const offset=this.offsets[tab]??{x:0,y:0};panel.scrollLeft=clamp(offset.x,0,Math.max(0,panel.scrollWidth-panel.clientWidth));panel.scrollTop=clamp(offset.y,0,Math.max(0,panel.scrollHeight-panel.clientHeight));this.applied.set(tab,{x:panel.scrollLeft,y:panel.scrollTop});handle=this.scheduler.request(()=>{if(this.pending.get(tab)===handle)this.pending.delete(tab);});this.pending.set(tab,handle);};
|
|
80
|
+
handle=this.scheduler.request(()=>{if(cancelled||this.disposed||this.pending.get(tab)!==handle)return;handle=this.scheduler.request(apply);this.pending.set(tab,handle);});this.pending.set(tab,handle);
|
|
81
|
+
return ()=>{cancelled=true;this.scheduler.cancel(handle);if(this.pending.get(tab)===handle)this.pending.delete(tab);};
|
|
82
|
+
}
|
|
83
|
+
dispose():void {if(this.disposed)return;this.disposed=true;for(const handle of this.pending.values())this.scheduler.cancel(handle);this.pending.clear();this.applied.clear();}
|
|
84
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Browser-owned app defaults. Source inspection, review data and capabilities never belong here. */
|
|
2
|
+
export const workspacePreferenceKey='rig-review-preferences/1';
|
|
3
|
+
export const legacyAppearanceKey='rig-review-appearance/1';
|
|
4
|
+
export type WorkspaceTheme='system'|'dark'|'light';
|
|
5
|
+
export interface WorkspacePanels {leftWidth:number;rightWidth:number;inspectorHeight:number}
|
|
6
|
+
export const panelBounds=Object.freeze({leftWidth:Object.freeze({min:120,max:720}),rightWidth:Object.freeze({min:180,max:900}),inspectorHeight:Object.freeze({min:160,max:900})});
|
|
7
|
+
export const defaultWorkspacePanels:Readonly<WorkspacePanels>=Object.freeze({leftWidth:170,rightWidth:260,inspectorHeight:280});
|
|
8
|
+
type Field='theme'|'defaultCameraSync'|'defaultIdleRepeat'|keyof WorkspacePanels;
|
|
9
|
+
const fields:readonly Field[]=['theme','defaultCameraSync','defaultIdleRepeat','leftWidth','rightWidth','inspectorHeight'];
|
|
10
|
+
interface FieldStamp {revision:number;writer:string}
|
|
11
|
+
export interface WorkspacePreferences {schema:'rig-review-preferences/1';revision:number;theme:WorkspaceTheme;defaultCameraSync:boolean;defaultIdleRepeat:boolean;panels:WorkspacePanels;stamps:Record<Field,FieldStamp>}
|
|
12
|
+
export interface WorkspacePreferencePatch {theme?:WorkspaceTheme;defaultCameraSync?:boolean;defaultIdleRepeat?:boolean;panels?:Partial<WorkspacePanels>}
|
|
13
|
+
export interface PreferenceStorage {getItem(key:string):string|null;setItem(key:string,value:string):void}
|
|
14
|
+
export interface PreferenceStatus {state:'available'|'memory-only'|'corrupt';detail:string}
|
|
15
|
+
export interface PreferenceEventSource {addEventListener(type:string,listener:EventListener):void;removeEventListener(type:string,listener:EventListener):void}
|
|
16
|
+
export interface WorkspacePreferenceOptions {storage?:PreferenceStorage|null;events?:PreferenceEventSource|null;writer?:string;onStatus?:(status:PreferenceStatus)=>void}
|
|
17
|
+
const object=(v:unknown,label:string):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw new Error(`${label}: expected object`);return v as Record<string,unknown>;};
|
|
18
|
+
const closed=(v:Record<string,unknown>,allowed:readonly string[],label:string)=>{for(const key of Object.keys(v))if(!allowed.includes(key))throw new Error(`${label}.${key}: unknown field`);};
|
|
19
|
+
const theme=(v:unknown):WorkspaceTheme=>{if(v!=='system'&&v!=='dark'&&v!=='light')throw new Error('PREFERENCES_THEME: expected system, dark or light');return v;};
|
|
20
|
+
const boolean=(v:unknown,label:string):boolean=>{if(typeof v!=='boolean')throw new Error(`${label}: expected boolean`);return v;};
|
|
21
|
+
const panel=(v:unknown,key:keyof WorkspacePanels):number=>{const {min,max}=panelBounds[key];if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)throw new Error(`PREFERENCES_PANEL: ${key} requires finite ${min}..${max} CSS pixels`);return v;};
|
|
22
|
+
export function parseWorkspacePanels(value:unknown):WorkspacePanels {const o=object(value,'panels');closed(o,Object.keys(panelBounds),'panels');return {leftWidth:panel(o.leftWidth,'leftWidth'),rightWidth:panel(o.rightWidth,'rightWidth'),inspectorHeight:panel(o.inspectorHeight,'inspectorHeight')};}
|
|
23
|
+
export function defaultWorkspacePreferences():WorkspacePreferences {return {schema:workspacePreferenceKey,revision:0,theme:'system',defaultCameraSync:true,defaultIdleRepeat:true,panels:{...defaultWorkspacePanels},stamps:Object.fromEntries(fields.map(field=>[field,{revision:0,writer:''}])) as Record<Field,FieldStamp>};}
|
|
24
|
+
const clone=(v:WorkspacePreferences):WorkspacePreferences=>({...v,panels:{...v.panels},stamps:Object.fromEntries(fields.map(field=>[field,{...v.stamps[field]}])) as Record<Field,FieldStamp>});
|
|
25
|
+
function leaf(value:WorkspacePreferences,field:Field):WorkspaceTheme|boolean|number {return field in panelBounds?value.panels[field as keyof WorkspacePanels]:value[field as 'theme'|'defaultCameraSync'|'defaultIdleRepeat'];}
|
|
26
|
+
function put(value:WorkspacePreferences,field:Field,v:WorkspaceTheme|boolean|number):void {if(field==='theme')value.theme=theme(v);else if(field==='defaultCameraSync'||field==='defaultIdleRepeat')value[field]=boolean(v,field);else value.panels[field]=panel(v,field);}
|
|
27
|
+
export function parseWorkspacePreferences(value:unknown):WorkspacePreferences {
|
|
28
|
+
const o=object(value,'preferences');closed(o,['schema','revision','theme','defaultCameraSync','defaultIdleRepeat','panels','stamps'],'preferences');if(o.schema!==workspacePreferenceKey)throw new Error('PREFERENCES_VERSION: unsupported schema');
|
|
29
|
+
const result=defaultWorkspacePreferences();result.theme=theme(o.theme);result.defaultCameraSync=boolean(o.defaultCameraSync,'defaultCameraSync');result.defaultIdleRepeat=boolean(o.defaultIdleRepeat,'defaultIdleRepeat');result.panels=parseWorkspacePanels(o.panels);
|
|
30
|
+
const stamps=object(o.stamps,'stamps');closed(stamps,fields,'stamps');
|
|
31
|
+
for(const field of fields){const stamp=object(stamps[field],`stamps.${field}`);closed(stamp,['revision','writer'],`stamps.${field}`);if(typeof stamp.revision!=='number'||!Number.isSafeInteger(stamp.revision)||stamp.revision<0||typeof stamp.writer!=='string'||!(/^[A-Za-z0-9_-]{1,80}$/.test(stamp.writer)||stamp.revision===0&&stamp.writer===''))throw new Error(`PREFERENCES_STAMP: invalid ${field} revision/writer`);if(stamp.revision===0&&(stamp.writer!==''||leaf(result,field)!==leaf(defaultWorkspacePreferences(),field)))throw new Error(`PREFERENCES_STAMP: unstamped ${field} differs from defaults`);result.stamps[field]={revision:stamp.revision,writer:stamp.writer};}
|
|
32
|
+
const revision=Math.max(...fields.map(field=>result.stamps[field].revision));if(o.revision!==revision)throw new Error('PREFERENCES_REVISION: record revision must equal maximum field revision');result.revision=revision;return result;
|
|
33
|
+
}
|
|
34
|
+
function parseText(raw:string):WorkspacePreferences {if(raw.length>8192)throw new Error('PREFERENCES_SIZE: record exceeds 8192 characters');return parseWorkspacePreferences(JSON.parse(raw));}
|
|
35
|
+
function patches(value:unknown):Map<Field,WorkspaceTheme|boolean|number> {
|
|
36
|
+
const o=object(value,'preference patch');closed(o,['theme','defaultCameraSync','defaultIdleRepeat','panels'],'preference patch');const patch=new Map<Field,WorkspaceTheme|boolean|number>();
|
|
37
|
+
if('theme'in o)patch.set('theme',theme(o.theme));for(const field of ['defaultCameraSync','defaultIdleRepeat'] as const)if(field in o)patch.set(field,boolean(o[field],field));
|
|
38
|
+
if('panels'in o){const p=object(o.panels,'panel patch');closed(p,Object.keys(panelBounds),'panel patch');for(const key of Object.keys(p) as (keyof WorkspacePanels)[])patch.set(key,panel(p[key],key));}return patch;
|
|
39
|
+
}
|
|
40
|
+
/** Per-field Lamport stamps converge even when independent tabs wrote stale whole records.
|
|
41
|
+
* Equal-field concurrent writes use revision then writer ordering, never wall-clock time. */
|
|
42
|
+
export function mergeWorkspacePreferences(...records:WorkspacePreferences[]):WorkspacePreferences {
|
|
43
|
+
const result=defaultWorkspacePreferences();
|
|
44
|
+
for(const raw of records){const value=parseWorkspacePreferences(raw);for(const field of fields){const a=result.stamps[field],b=value.stamps[field],order=b.revision-a.revision|| (b.writer<a.writer?-1:b.writer>a.writer?1:0);if(order>0){put(result,field,leaf(value,field));result.stamps[field]={...b};}else if(order===0&&leaf(result,field)!==leaf(value,field))throw new Error(`PREFERENCES_STAMP_CONFLICT: ${field} has two values at one stamp`);}}
|
|
45
|
+
result.revision=Math.max(...fields.map(field=>result.stamps[field].revision));return result;
|
|
46
|
+
}
|
|
47
|
+
let fallbackWriter=0;
|
|
48
|
+
export class WorkspacePreferenceStore {
|
|
49
|
+
private value=defaultWorkspacePreferences();private persistence:PreferenceStatus={state:'available',detail:'Browser preferences use defaults.'};private readonly storage:PreferenceStorage|null;private readonly events:PreferenceEventSource|null;private readonly writer:string;private listeners=new Set<(value:WorkspacePreferences)=>void>();private conflicts=new Set<Field>();private closed=false;
|
|
50
|
+
constructor(private readonly options:WorkspacePreferenceOptions={}){
|
|
51
|
+
this.writer=options.writer??(globalThis.crypto?.randomUUID?.()??`local-${Date.now().toString(36)}-${++fallbackWriter}`);if(!/^[A-Za-z0-9_-]{1,80}$/.test(this.writer))throw new Error('PREFERENCES_WRITER: invalid tab writer');
|
|
52
|
+
let storage=options.storage??null;if(options.storage===undefined){try{storage=globalThis.localStorage;}catch{this.statusTo('memory-only','Browser storage access was denied; preferences remain usable in this page.');}}this.storage=storage;this.events=options.events===undefined?(typeof window==='undefined'?null:window):options.events;
|
|
53
|
+
const loaded=this.read();if(loaded.kind==='valid'){this.value=loaded.value;this.statusTo('available','Saved browser preferences loaded.');}else if(loaded.kind==='absent')this.migrateAppearance();this.events?.addEventListener('storage',this.storageEvent);
|
|
54
|
+
}
|
|
55
|
+
get():WorkspacePreferences{return clone(this.value);}
|
|
56
|
+
status():PreferenceStatus{return {...this.persistence};}
|
|
57
|
+
subscribe(listener:(value:WorkspacePreferences)=>void):()=>void {this.listeners.add(listener);return ()=>this.listeners.delete(listener);}
|
|
58
|
+
update(patch:WorkspacePreferencePatch):WorkspacePreferences {
|
|
59
|
+
if(this.closed)throw new Error('PREFERENCES_DISPOSED');const changes=patches(patch);if(!changes.size)return this.get();const loaded=this.read();if(loaded.kind==='valid')this.reconcile(loaded.value);const next=clone(this.value);
|
|
60
|
+
if(next.revision===Number.MAX_SAFE_INTEGER)throw new Error('PREFERENCES_REVISION_EXHAUSTED');const revision=next.revision+1;for(const [field,v]of changes)put(next,field,v);for(const field of new Set([...changes.keys(),...this.conflicts]))next.stamps[field]={revision,writer:this.writer};next.revision=revision;this.conflicts.clear();this.value=next;this.persist();this.notify();return this.get();
|
|
61
|
+
}
|
|
62
|
+
resetLayout():WorkspacePreferences{return this.update({panels:{...defaultWorkspacePanels}});}
|
|
63
|
+
/** Reconcile after resuming a suspended tab as well as on storage events. */
|
|
64
|
+
refresh():WorkspacePreferences {if(this.closed)return this.get();const loaded=this.read();if(loaded.kind==='valid'){this.reconcile(loaded.value);if(!this.conflicts.size)this.persist();this.notify();}return this.get();}
|
|
65
|
+
dispose():void {if(this.closed)return;this.closed=true;this.events?.removeEventListener('storage',this.storageEvent);this.listeners.clear();}
|
|
66
|
+
private statusTo(state:PreferenceStatus['state'],detail:string){this.persistence={state,detail};this.options.onStatus?.(this.status());}
|
|
67
|
+
/** Quarantine only equal-stamp conflicting leaves; retain every independent valid update.
|
|
68
|
+
* An explicit edit gives quarantined known values a new stamp before repairing storage. */
|
|
69
|
+
private reconcile(...records:WorkspacePreferences[]):void {
|
|
70
|
+
const next=clone(this.value);
|
|
71
|
+
for(const value of records)for(const field of fields){const a=next.stamps[field],b=value.stamps[field],order=b.revision-a.revision||(b.writer<a.writer?-1:b.writer>a.writer?1:0);if(order>0){put(next,field,leaf(value,field));next.stamps[field]={...b};this.conflicts.delete(field);}else if(order===0&&leaf(next,field)!==leaf(value,field))this.conflicts.add(field);}
|
|
72
|
+
next.revision=Math.max(...fields.map(field=>next.stamps[field].revision));this.value=next;if(this.conflicts.size)this.statusTo('corrupt',`Browser preference fields were refused because their stamps conflict: ${[...this.conflicts].join(', ')}. Known values remain usable; an explicit edit repairs them.`);
|
|
73
|
+
}
|
|
74
|
+
private read():{kind:'valid';value:WorkspacePreferences}|{kind:'absent'|'denied'|'corrupt'} {
|
|
75
|
+
if(!this.storage){this.statusTo('memory-only','Browser storage is unavailable; preferences remain usable in this page.');return {kind:'denied'};}
|
|
76
|
+
let raw:string|null;try{raw=this.storage.getItem(workspacePreferenceKey);}catch{this.statusTo('memory-only','Browser storage read was denied; preferences remain usable in this page.');return {kind:'denied'};}
|
|
77
|
+
if(raw===null)return {kind:'absent'};try{return {kind:'valid',value:parseText(raw)};}catch(error){this.statusTo('corrupt',`Saved browser preferences were refused: ${error instanceof Error?error.message:String(error)}. Current page defaults remain usable.`);return {kind:'corrupt'};}
|
|
78
|
+
}
|
|
79
|
+
private persist():void {
|
|
80
|
+
if(!this.storage)return;const latest=this.read();if(latest.kind==='valid')this.reconcile(latest.value);if(this.conflicts.size)return;
|
|
81
|
+
const text=JSON.stringify(this.value);if(latest.kind==='valid'&&JSON.stringify(latest.value)===text){this.statusTo('available','Browser preferences are current.');return;}
|
|
82
|
+
try{this.storage.setItem(workspacePreferenceKey,text);this.statusTo('available','Browser preferences saved.');}catch{this.statusTo('memory-only','Browser storage write was denied; changed preferences remain usable in this page.');}
|
|
83
|
+
}
|
|
84
|
+
private migrateAppearance():void {
|
|
85
|
+
if(!this.storage)return;let raw:string|null;try{raw=this.storage.getItem(legacyAppearanceKey);}catch{this.statusTo('memory-only','Legacy appearance storage read was denied; defaults remain usable.');return;}if(raw===null)return;
|
|
86
|
+
if(raw!=='system'&&raw!=='dark'&&raw!=='light'){this.statusTo('corrupt','Legacy appearance value was refused; defaults remain usable.');return;}
|
|
87
|
+
this.value.theme=raw;this.value.stamps.theme={revision:1,writer:this.writer};this.value.revision=1;this.persist();
|
|
88
|
+
}
|
|
89
|
+
private storageEvent:EventListener=(event)=>{
|
|
90
|
+
if(this.closed)return;const e=event as StorageEvent;if(e.key!==workspacePreferenceKey&&e.key!==null||e.storageArea&&e.storageArea!==this.storage)return;const latest=this.read();
|
|
91
|
+
if(latest.kind==='absent'){this.value=defaultWorkspacePreferences();this.conflicts.clear();this.statusTo('available','Browser preferences were cleared; defaults remain usable.');this.notify();return;}
|
|
92
|
+
try{const incoming=e.newValue===null||e.key===null?null:parseText(e.newValue);this.reconcile(...(incoming?[incoming]:[]),...(latest.kind==='valid'?[latest.value]:[]));if(latest.kind!=='corrupt'&&!this.conflicts.size)this.persist();this.notify();}catch(error){this.statusTo('corrupt',`Browser preference update was refused: ${error instanceof Error?error.message:String(error)}.`);}
|
|
93
|
+
};
|
|
94
|
+
private notify(){for(const listener of this.listeners)listener(this.get());}
|
|
95
|
+
}
|