@rig-c/review 0.11.1 → 0.12.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 CHANGED
@@ -606,6 +606,12 @@ Settings, panel sizes and drafts carry across reopens. `--direct` and foreground
606
606
  serving use per-port origins and keep them separate.
607
607
 
608
608
 
609
+ ## Inspect animation keys
610
+
611
+ Animate → **Transport** opens the read-only Timeline, Curves and Bone tree views of the build currently on stage. The timeline preserves every stored bone/slot track and key time; click a key to seek, or scrub the transport to move its playhead. Bone selection filters its direct tracks. Tree, Parts and stage selections stay synchronized; enable **Bone points** to pick a bone on stage. Selecting a bone highlights all directly attached Parts rows without inventing a selected slot.
612
+
613
+ Curves show the compiled skeleton’s exact linear, stepped or cubic Bézier segments and stored handles. The separately labeled **Runtime evaluation** cursor/readout uses Play’s ten-piece Float32 interpolation, which can differ from the cubic. Its local value includes setup rotation/translation and scale multiplication, in stored timeline order, before constraints and previews. Rotation is unwrapped local degrees. **Inspect time** accepts exact seconds; frame labels require the existing explicit clock. These views author no keys and change no package, motion, patch or review format. See [animation inspection](docs/ANIMATION_INSPECTION.md) for the numeric validation and reproduction commands.
614
+
609
615
  ## Inspect a part
610
616
 
611
617
  The Parts explorer (left column; right on the compare page) selects an actual model slot on a specific single/A/B side. All rows follow the public held snapshot's exact draw-order z, including hidden/null slots. Search matches the exact source slot names: a row is shown when its name contains the typed text, case-sensitively, with no case folding or normalization. Arrow keys/Home/End move between result buttons, Enter/Space selects, and Escape returns to search. Whole rig restores full drawing. Selecting a drawn row fits its conservative world bounds within the existing camera zoom range; Sync cameras applies the same normalized camera action to every visible pane, while independent mode changes only that selected pane. Hidden/empty geometry reports `PART_FIT_UNAVAILABLE` and keeps the camera.
@@ -0,0 +1,33 @@
1
+ # Read-only animation inspection
2
+
3
+ Animate → Transport offers a timeline, a stored-curve view and a bone tree for the current compiled build. These are inspection controls: no key authoring, motion.json writes, producer operations or output/patch schema changes. Two-build comparison is outside this feature.
4
+
5
+ The timeline lists bone groups followed by slot groups, one lane per stored timeline, with a mark per key including simultaneous keys. Key times remain exact seconds. Clicking a mark seeks through the existing transport; transport movement updates its playhead. No FPS is inferred. A selected bone filters its direct bone tracks; a selected slot filters that slot's tracks. Whole rig clears the filter.
6
+
7
+ Tree, Parts and stage share selection. Enable Bone points in Animate to pick bones on stage. A bone highlights its directly attached Parts rows, including an empty set for a bone without slots; it does not select an arbitrary child slot. Clicking drawn part geometry selects its slot. Picking uses the held drawn primitives and their resolved clip. Overlapping bone points pick the last active bone in compiled source order; the tree offers exact targets. Pan gestures remain pan; the existing Setup editors keep their own handles. Bone selection is page-local; existing slot identity/bookmark behavior is unchanged.
8
+
9
+ ## Curve values
10
+
11
+ Rotate, translation x/y and scale x/y read the compiled model's original numeric keys. Linear and stepped segments are drawn directly; cubic paths and handles use the stored absolute control points. Nothing refits, retimes or replaces the curve.
12
+
13
+ The **Runtime evaluation** cursor is separate. Play evaluates Bézier segments with ten pieces whose nine interior points use Float32 rounding. Its scalar can therefore sit off the exact stored cubic. Only the held scalar is drawn; there is no second generated curve. The evaluator is derived from [rig-c's implementation](https://github.com/rig-c/core/blob/v2.67.0/src/core/animation.ts), verified against the source map shipped in published Play 1.14.0. This helper is not imported from a private Play API.
14
+
15
+ The readout shows a selected track contribution and the final authored local for that property, including later overlapping timelines. The evaluator also recognizes shear channels, with the same exact numeric checks. Rotation, translation and shear add to setup; scale multiplies setup. Missing channels remain at setup. Local rotation is unwrapped: 350 → 10 passes through 180, not 0. These locals precede constraints, sliders that run later, physics and editor previews; they do not certify the final world pose or visible image. Zero setup scale is valid for forward multiplication and has no invertible channel factor. Inspect time seeks seconds only and never changes key timing.
16
+
17
+ ## Validation and reproduction
18
+
19
+ The focused suite uses the published Play loader and real player, capturing locals through its public read-only adjust hook. Evaluation compares Float64 bytes at zero tolerance at fixed interval fractions and raw/Float32 key boundaries, including adjacent representable times and non-Float32-exact key values. All ten recognized timeline kinds run with linear, stepped and Bézier controls; the supported-kind table must equal the independently declared test matrix. A recurrence-constant mutation must disagree with installed Play. It covers setup transforms, overlapping timelines, zero/negative scale, holds, linear and asymmetric Bézier controls. Dropped keys and swapped controls must be detected. The sine-preview sampling bound is not used as a keyed-curve tolerance.
20
+
21
+ ```sh
22
+ bun test tests/animation-inspection.test.ts tests/preview.test.ts tests/part-selection.test.ts tests/animation-selection.test.ts tests/theme.test.ts
23
+ ```
24
+
25
+ `scripts/animation-inspection-fixture.ts --prepare` downloads pinned public getting-started inputs, reads installed producer signatures and checks input paths without solving. Without `--prepare`, it bootstraps the guide's hand-placed-joint route through step 9b using published parts 1.64.0 and core 2.70.0. It records commands, exits, source pins and compiled-model SHA-256 beneath `.review-state/215/guide-fixture/`. Heavy bootstrap and full suites follow the contributor Nova policy. Intermediate render/build files are removed after the checked package and receipt are retained.
26
+
27
+ `scripts/animation-inspection-browser.ts` is an explicit browser acceptance runner, separate from `bun test`. It accepts `--playwright-module`, `--browser-executable`, `--out`, and optionally `--package`/`--animation`. Without a package it uses clearly synthetic original test art. With the bootstrap's public package it checks rendered key marks, drives the actual curve readout against an independent published Play instance, exercises selection, verifies source identity and captures each view. Local reports record the exact commands and screenshot paths; screenshots alone are not numeric validation. No older-runtime matrix is added.
28
+
29
+ Public guide pin: [ce926769373977558da470973afb52fe7c32d7fc](https://github.com/rig-c/getting-started/tree/ce926769373977558da470973afb52fe7c32d7fc). Public input pin and its license: [exam 01436ee3f02c189fe1a1e9656fe62068889837f5](https://github.com/rig-c/exam/tree/01436ee3f02c189fe1a1e9656fe62068889837f5). Keep that input's LICENSE and NOTICE beside the checked package and credit its artwork when sharing captures. Producer gate success, numeric inspection equivalence and browser acceptance are separate receipts.
30
+
31
+ ## Public capture receipt
32
+
33
+ [Public screenshots and the installed-package provenance receipt](https://github.com/rig-c/review/tree/main/docs/images/animation-inspection) use one checked getting-started model. The hello is linear-only; the curve capture selects its existing idle Bézier keys. Stepped interpolation is absent from that public build and is covered by explicit synthetic controls. No screenshot motion or keys were manufactured.
package/docs/DESIGN.md CHANGED
@@ -73,6 +73,12 @@ The single review page is one inspection workspace: a left column, the stage, th
73
73
 
74
74
  The compare page keeps its own markup and stylesheet: sources left, Parts explorer right, its four Inspector sections, its export buttons and its separator colour. It shares the workspace coordinator, so its separators are drawn at the same 4px.
75
75
 
76
+ ## Stored animation inspection
77
+
78
+ Animate’s read-only block under Transport reads the hash-verified compiled skeleton of the build on stage. Timeline, Curves and Bone tree share transport and page-local bone/slot selection. A bone selection highlights its directly attached Parts rows; it never picks an arbitrary child slot. Stage picking uses held drawn geometry and resolved clips. Source-bound slot identity and every existing output/patch format remain unchanged.
79
+
80
+ The curve path and handles are the stored numbers, without refitting or replacement keys. A separate runtime evaluation cursor/readout applies Play’s Float32 key conversion and ten-piece Bézier evaluation at held player time, then setup addition or scale multiplication in timeline order. Its authored locals precede constraints and previews, so it is not a final world pose. No second evaluated curve is drawn. See [the validation contract](ANIMATION_INSPECTION.md).
81
+
76
82
  ## Inspection strips
77
83
 
78
84
  An inclusive annotation interval can also become a derived frame strip. The single view selects one declared source video; compare retains its explicit shared video and chooses a producer clock basis for each interval. A batch preserves separate feedback intervals and side provenance. Source rows run down the sheet and requested producer frames run across it. A vertical batch repeats this grid rather than merging overlapping annotations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  "examples/demo",
36
36
  "docs/REVIEW_PACKAGE.md",
37
37
  "docs/DESIGN.md",
38
+ "docs/ANIMATION_INSPECTION.md",
38
39
  "docs/LIVE_FIXTURE.md",
39
40
  "README.md",
40
41
  "LICENSE"
@@ -0,0 +1,82 @@
1
+ /** Read-only projections of the hash-verified compiled skeleton. Stored numbers never change. */
2
+ export type InspectionTarget = {kind:'bone'|'slot';name:string}|null;
3
+ export interface StoredKey {time:number;curve?:number[]|'stepped';[field:string]:unknown}
4
+ export interface InspectionTrack {kind:'bone'|'slot';target:string;name:string;pointer:string;keys:readonly StoredKey[]}
5
+ export interface InspectionBone {name:string;parent?:string;rotation?:number;x?:number;y?:number;scaleX?:number;scaleY?:number;shearX?:number;shearY?:number}
6
+ export interface AnimationInspection {name:string;duration:number;bones:readonly InspectionBone[];slots:readonly {name:string;bone:string}[];tracks:readonly InspectionTrack[]}
7
+ export type LocalProperty='rotation'|'x'|'y'|'scaleX'|'scaleY'|'shearX'|'shearY';
8
+ export const CHANNELS:Readonly<Record<string,readonly {field:string;local:LocalProperty}[]>>={
9
+ rotate:[{field:'value',local:'rotation'}],translate:[{field:'x',local:'x'},{field:'y',local:'y'}],
10
+ translatex:[{field:'value',local:'x'}],translatey:[{field:'value',local:'y'}],
11
+ scale:[{field:'x',local:'scaleX'},{field:'y',local:'scaleY'}],scalex:[{field:'value',local:'scaleX'}],scaley:[{field:'value',local:'scaleY'}],
12
+ shear:[{field:'x',local:'shearX'},{field:'y',local:'shearY'}],shearx:[{field:'value',local:'shearX'}],sheary:[{field:'value',local:'shearY'}],
13
+ };
14
+ const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
15
+ const fail=(why:string):never=>{throw new Error('ANIMATION_INSPECTION_UNAVAILABLE: '+why);};
16
+ export function readAnimationInspection(document:unknown,name:string):AnimationInspection {
17
+ if(!object(document)||document.spec!=='rigc-compiled/3'||!Array.isArray(document.animations)||!Array.isArray(document.bones)||!Array.isArray(document.slots))return fail('a compiled skeleton is required');
18
+ const bones=document.bones as InspectionBone[],slots=document.slots as {name:string;bone:string}[];
19
+ if(bones.some(b=>!b||typeof b.name!=='string')||slots.some(s=>!s||typeof s.name!=='string'||!bones.some(b=>b.name===s.bone)))return fail('invalid bone/slot catalog');
20
+ const index=document.animations.findIndex(a=>object(a)&&a.name===name),a=document.animations[index];
21
+ if(!object(a)){if(!name)return {name:'',duration:0,bones,slots,tracks:[]};return fail('animation not in the drawn build');}
22
+ const tracks:InspectionTrack[]=[];
23
+ for(const [group,kind,catalog]of [['bones','bone',bones],['slots','slot',slots]] as const){
24
+ if(!Array.isArray(a[group]))return fail('missing '+group);
25
+ for(const [i,target]of a[group].entries()){
26
+ if(!object(target)||typeof target.name!=='string'||!catalog.some(c=>c.name===target.name)||!Array.isArray(target.timelines))return fail('invalid '+group+' target');
27
+ for(const [j,tl]of target.timelines.entries()){
28
+ if(!object(tl)||typeof tl.name!=='string'||!Array.isArray(tl.keys))return fail('invalid timeline');
29
+ const channels=kind==='bone'?CHANNELS[tl.name]:undefined;
30
+ for(const [k,key]of tl.keys.entries()){
31
+ if(!object(key)||typeof key.time!=='number'||!Number.isFinite(key.time)||key.time<0||k>0&&key.time<(tl.keys[k-1] as StoredKey).time)return fail('invalid key time');
32
+ if(channels&&channels.some(c=>typeof key[c.field]!=='number'||!Number.isFinite(key[c.field])))return fail('invalid channel value');
33
+ if(key.curve!==undefined&&key.curve!=='stepped'&&(!Array.isArray(key.curve)||!key.curve.every(n=>typeof n==='number'&&Number.isFinite(n))||channels&&key.curve.length!==4*channels.length))return fail('invalid curve');
34
+ }
35
+ tracks.push({kind,target:target.name,name:tl.name,pointer:`/animations/${index}/${group}/${i}/timelines/${j}`,keys:tl.keys as StoredKey[]});
36
+ }
37
+ }
38
+ }
39
+ return {name,duration:typeof a.duration==='number'?a.duration:0,bones,slots,tracks};
40
+ }
41
+ export function boneHierarchy(inspection:AnimationInspection){
42
+ const seen=new Set<string>();return inspection.bones.map(b=>{
43
+ if(seen.has(b.name)||b.parent&&!seen.has(b.parent))return fail('bone hierarchy is not parent-first');seen.add(b.name);
44
+ return {name:b.name,parent:b.parent??null,slots:inspection.slots.filter(s=>s.bone===b.name).map(s=>s.name)};
45
+ });
46
+ }
47
+ export function filterTracks(inspection:AnimationInspection,selected:InspectionTarget){return inspection.tracks.filter(t=>!selected||t.kind===selected.kind&&t.target===selected.name);}
48
+ export function channelSegments(track:InspectionTrack,channel:number){
49
+ const field=CHANNELS[track.name]?.[channel]?.field;if(track.kind!=='bone'||!field)return [];
50
+ return track.keys.slice(0,-1).map((key,i)=>({from:[key.time,key[field] as number] as const,to:[track.keys[i+1]!.time,track.keys[i+1]![field] as number] as const,curve:Array.isArray(key.curve)?key.curve.slice(channel*4,channel*4+4):key.curve??'linear'}));
51
+ }
52
+
53
+ /** Derived from rig-c v2.67.0 src/core/animation.ts::bezierPolyline/channelAt (MIT).
54
+ * Verified against the published Play 1.14.0 source map. These transient Float32 evaluation
55
+ * points are never stored/drawn as curve data. The stored SVG cubic uses the original numbers. */
56
+ function interior(p0:number,p1:number,p2:number,p3:number):number[]{
57
+ const second=(p0-2*p1+p2)*0.03,third=((p1-p2)*3-p0+p3)*0.006;
58
+ let first=(p1-p0)*0.3+second+third*0.16666667,d2=second*2+third,at=p0;const points:number[]=[];
59
+ for(let i=1;i<10;i++){at+=first;first+=d2;d2+=third;points.push(Math.fround(at));}return points;
60
+ }
61
+ const polylineCache=new WeakMap<StoredKey,Map<string,{end:StoredKey;points:number[]}>>();
62
+ export function evaluateStoredChannel(track:InspectionTrack,channel:number,t:number):number|null {
63
+ const field=CHANNELS[track.name]?.[channel]?.field;if(track.kind!=='bone'||!field||!Number.isFinite(t))return null;
64
+ let index=-1;for(let i=0;i<track.keys.length;i++){if(Math.fround(track.keys[i]!.time)<=t)index=i;else break;}
65
+ if(index<0)return null;const a=track.keys[index]!,b=track.keys[index+1],av=Math.fround(a[field] as number);
66
+ if(!b||a.curve==='stepped')return av;const bv=Math.fround(b[field] as number),at=Math.fround(a.time),bt=Math.fround(b.time);
67
+ if(!a.curve)return av+((t-at)/(bt-at))*(bv-av);
68
+ let cached=polylineCache.get(a);if(!cached){cached=new Map();polylineCache.set(a,cached);}let kept=cached.get(field);
69
+ if(!kept||kept.end!==b){
70
+ const c=a.curve.slice(channel*4,channel*4+4),xs=interior(a.time,c[0]!,c[2]!,b.time),ys=interior(a[field] as number,c[1]!,c[3]!,b[field] as number);
71
+ kept={end:b,points:[at,av,...xs.flatMap((x,i)=>[x,ys[i]!]),bt,bv]};cached.set(field,kept);
72
+ }
73
+ const points=kept.points;let i=2;while(i<points.length-2&&points[i]!<t)i+=2;
74
+ const [x0,y0,x1,y1]=[points[i-2]!,points[i-1]!,points[i]!,points[i+1]!];return y0+((t-x0)/(x1-x0))*(y1-y0);
75
+ }
76
+ export function evaluateBoneLocals(inspection:AnimationInspection,name:string,t:number):Record<LocalProperty,number>{
77
+ const setup=inspection.bones.find(b=>b.name===name);if(!setup)return fail('unknown bone '+name);
78
+ const locals={rotation:setup.rotation??0,x:setup.x??0,y:setup.y??0,scaleX:setup.scaleX??1,scaleY:setup.scaleY??1,shearX:setup.shearX??0,shearY:setup.shearY??0};
79
+ for(const track of inspection.tracks){if(track.kind!=='bone'||track.target!==name)continue;
80
+ CHANNELS[track.name]?.forEach((c,i)=>{const v=evaluateStoredChannel(track,i,t),scale=c.local==='scaleX'||c.local==='scaleY',base=setup[c.local]??(scale?1:0);locals[c.local]=scale?base*(v??1):base+(v??0);});
81
+ }return locals;
82
+ }
@@ -0,0 +1,136 @@
1
+ import type {RigPlayer,PosedFrame} from '@rig-c/play';
2
+ import {boneHierarchy,CHANNELS,channelSegments,evaluateBoneLocals,evaluateStoredChannel,filterTracks,readAnimationInspection,type AnimationInspection,type InspectionTarget,type InspectionTrack} from '../src/animation-inspection.ts';
3
+ import {InspectionSelection} from './inspection-selection.ts';
4
+ const NS='http://www.w3.org/2000/svg';
5
+ const svg=(tag:string,attrs:Record<string,string|number>)=>{const n=document.createElementNS(NS,tag);for(const [k,v]of Object.entries(attrs))n.setAttribute(k,String(v));return n;};
6
+ const numeric=(value:number)=>Object.is(value,-0)?'-0':String(value);
7
+ const button=(label:string,click:()=>void)=>{const b=document.createElement('button');b.type='button';b.textContent=label;b.onclick=click;return b;};
8
+ export interface AnimationPanelOptions {host:HTMLElement;stage:HTMLElement;select:(value:InspectionTarget)=>void;seek:(time:number)=>void;project:(x:number,y:number)=>{x:number;y:number};enabled:()=>boolean}
9
+
10
+ /** All picking reads the last drawn primitives, including their resolved clip; never a setup bounding box. */
11
+ export function pickedSlot(frame:PosedFrame,x:number,y:number):string|null {
12
+ const inside=(p:readonly number[])=>{let hit=false;for(let i=0,j=p.length-2;i<p.length;j=i,i+=2){const ax=p[i]!,ay=p[i+1]!,bx=p[j]!,by=p[j+1]!;if((ay>y)!==(by>y)&&x<(bx-ax)*(y-ay)/(by-ay)+ax)hit=!hit;}return hit;};
13
+ for(const row of [...frame.order].reverse()){
14
+ if(row.clip&&inside(row.clip.polygon)===row.clip.inverse)continue;
15
+ if(row.kind==='region'&&inside(row.corners))return row.slot;
16
+ if(row.kind==='mesh')for(let i=0;i<row.triangles.length;i+=3){const p=row.triangles.slice(i,i+3).flatMap(v=>[row.vertices[v*2]!,row.vertices[v*2+1]!]);if(inside(p))return row.slot;}
17
+ }return null;
18
+ }
19
+
20
+ export class AnimationInspectionPanel {
21
+ private inspection:AnimationInspection|null=null;private document:unknown=null;private animation='';private at=0;private runtimeDuration=0;private track:InspectionTrack|null=null;private channel=0;private view='timeline';private unavailableChannel='';
22
+ private selection:InspectionSelection;private panes=new Map<string,HTMLElement>();private views:HTMLButtonElement[]=[];private playhead:SVGElement|null=null;private cursor:SVGElement|null=null;private plot:((t:number,v:number)=>{x:number;y:number})|null=null;
23
+ private channelSelect:HTMLSelectElement;private readout:HTMLOutputElement;private status:HTMLElement;private picks:HTMLElement;private bones=new Map<string,{x:number;y:number}>();private frame:PosedFrame|null=null;private down:{x:number;y:number}|null=null;private space=false;
24
+ private disposers:(()=>void)[]=[];private boneButtons=new Map<string,HTMLButtonElement>();private bonePoints=false;
25
+ constructor(private options:AnimationPanelOptions){
26
+ const {host,stage}=options;host.classList.add('animation-inspection');host.innerHTML='<h3>Animation inspection · read only</h3><p data-inspection-status class="hint"></p><div class="animation-view-buttons" aria-label="Animation inspection views"></div><section data-animation-pane="timeline" aria-label="Timeline"></section><section data-animation-pane="curve" aria-label="Stored curves" hidden><label>Channel <select data-curve-channel aria-label="Stored animation channel"></select></label><label>Time (s) <input data-curve-time type="number" min="0" step="any" aria-label="Exact inspection seconds"></label><button data-curve-seek type="button">Inspect time</button><svg data-curve-plot role="img" aria-label="Exact stored curve and control points" viewBox="0 0 800 230"></svg><output data-curve-readout aria-live="off"></output><p class="hint">Stored curve and handles are exact. Runtime evaluation uses Play’s ten-piece Float32 interpolation; the cursor may sit off the cubic. Values are authored locals before constraints and previews.</p></section><section data-animation-pane="tree" aria-label="Bone tree" hidden></section>';
27
+ this.status=host.querySelector('[data-inspection-status]')!;this.channelSelect=host.querySelector('[data-curve-channel]')!;this.readout=host.querySelector('[data-curve-readout]')!;
28
+ for(const pane of host.querySelectorAll<HTMLElement>('[data-animation-pane]'))this.panes.set(pane.dataset.animationPane!,pane);
29
+ for(const [id,label]of [['timeline','Timeline'],['curve','Curves'],['tree','Bone tree']]){const b=button(label!,()=>this.show(id!));b.dataset.animationView=id;host.querySelector('.animation-view-buttons')!.append(b);this.views.push(b);}
30
+ this.selection=new InspectionSelection(value=>{options.select(value);this.renderSelection();});
31
+ host.querySelector<HTMLButtonElement>('[data-curve-seek]')!.onclick=()=>{const value=host.querySelector<HTMLInputElement>('[data-curve-time]')!.value.trim();options.seek(value?Number(value):NaN);};
32
+ this.channelSelect.onchange=()=>{this.unavailableChannel='';const [pointer,c]=JSON.parse(this.channelSelect.value) as [string,number];this.track=this.inspection?.tracks.find(t=>t.pointer===pointer)??null;this.channel=c;this.renderCurve();this.update(this.at,this.runtimeDuration);};
33
+ this.picks=document.createElement('div');this.picks.className='animation-stage-picks';stage.append(this.picks);
34
+ const down=(e:PointerEvent)=>{this.down=e.isPrimary&&e.button===0?{x:e.clientX,y:e.clientY}:null;};
35
+ const up=(e:PointerEvent)=>{const start=this.down;this.down=null;if(!start||Math.hypot(e.clientX-start.x,e.clientY-start.y)>3||this.space||!options.enabled()||e.target instanceof Element&&e.target.closest('button,input,select,textarea')||!this.frame)return;
36
+ const rect=stage.getBoundingClientRect(),p0=options.project(0,0),px=options.project(1,0),py=options.project(0,1),x=(e.clientX-rect.left-p0.x)/(px.x-p0.x),y=(e.clientY-rect.top-p0.y)/(py.y-p0.y),slot=pickedSlot(this.frame,x,y);if(slot)this.select({kind:'slot',name:slot});
37
+ };
38
+ const key=(e:KeyboardEvent)=>{if(e.code==='Space')this.space=e.type==='keydown';};const blur=()=>{this.space=false;this.down=null;};
39
+ stage.addEventListener('pointerdown',down);stage.addEventListener('pointerup',up);window.addEventListener('keydown',key);window.addEventListener('keyup',key);window.addEventListener('blur',blur);
40
+ this.disposers.push(()=>{stage.removeEventListener('pointerdown',down);stage.removeEventListener('pointerup',up);window.removeEventListener('keydown',key);window.removeEventListener('keyup',key);window.removeEventListener('blur',blur);});this.show('timeline');
41
+ }
42
+ model(doc:unknown,animation:string){
43
+ if(this.document===doc&&this.animation===animation)return;this.document=doc;this.animation=animation;this.inspection=null;this.track=null;this.unavailableChannel='';this.frame=null;this.bones.clear();this.boneButtons.clear();this.picks.replaceChildren();this.channelSelect.replaceChildren();this.playhead=null;
44
+ try{this.inspection=readAnimationInspection(doc,animation);boneHierarchy(this.inspection);this.status.textContent=`${animation||'Setup'} · ${this.inspection.tracks.length} stored tracks · key times in seconds; frames require a declared clock.`;}
45
+ catch(e){this.status.textContent=e instanceof Error?e.message:String(e);}
46
+ if(this.selection.value()&&!this.has(this.selection.value()))this.selection.select(null);
47
+ this.renderTree();this.renderSelection();
48
+ }
49
+ private has(value:InspectionTarget){return !value||!!this.inspection&&(value.kind==='bone'?this.inspection.bones:this.inspection.slots).some(x=>x.name===value.name);}
50
+ unavailable(reason:string){this.model(null,'');this.capture(null,undefined);this.status.textContent=reason;}
51
+ select(value:InspectionTarget){if(this.has(value))this.selection.select(value);}
52
+ fromParts(slot:string|null){this.selection.select(slot?{kind:'slot',name:slot}:null);}
53
+ value(){return this.selection.value();}
54
+ show(view:string){this.view=view;for(const [id,pane]of this.panes)pane.hidden=id!==view;for(const b of this.views)b.setAttribute('aria-pressed',String(b.dataset.animationView===view));}
55
+ private renderSelection(){
56
+ const selected=this.selection.value();this.options.host.dataset.selectedKind=selected?.kind??'';this.options.host.dataset.selectedName=selected?.name??'';
57
+ for(const b of this.options.host.querySelectorAll<HTMLButtonElement>('[data-tree-kind]'))b.setAttribute('aria-pressed',String(b.dataset.treeKind===selected?.kind&&b.dataset.treeName===selected?.name));
58
+ this.renderTimeline();this.renderChannels();this.redraw();
59
+ }
60
+ private renderTree(){
61
+ const pane=this.panes.get('tree')!;pane.replaceChildren();if(!this.inspection)return;
62
+ pane.append(button('Whole rig',()=>this.select(null)));const root=document.createElement('ul');root.className='animation-tree';pane.append(root);const parents=new Map<string,HTMLElement>();
63
+ for(const b of boneHierarchy(this.inspection)){
64
+ const li=document.createElement('li'),pick=button(b.name,()=>this.select({kind:'bone',name:b.name}));pick.dataset.treeKind='bone';pick.dataset.treeName=b.name;li.append(pick);(b.parent?parents.get(b.parent)??root:root).append(li);
65
+ const children=document.createElement('ul');li.append(children);parents.set(b.name,children);
66
+ for(const name of b.slots){const row=document.createElement('li'),slot=button('◇ '+name,()=>this.select({kind:'slot',name}));slot.dataset.treeKind='slot';slot.dataset.treeName=name;row.append(slot);children.append(row);}
67
+ }
68
+ const note=document.createElement('p');note.className='hint';note.textContent='Enable Bone points to pick bones on stage. Overlapping points pick the last active bone in source order; use the tree for an exact target. Attached Parts rows highlight together.';pane.prepend(note);
69
+ }
70
+ private renderTimeline(){
71
+ const pane=this.panes.get('timeline')!;pane.replaceChildren();this.playhead=null;if(!this.inspection)return;
72
+ const tracks=filterTracks(this.inspection,this.selection.value()),end=Math.max(this.runtimeDuration,...this.inspection.tracks.map(t=>t.keys.at(-1)?.time??0),Number.MIN_VALUE);
73
+ const list=document.createElement('div');list.className='animation-lanes';pane.append(list);
74
+ const axis=document.createElement('div');axis.className='animation-axis';axis.textContent=`0 s → ${end} s`;list.append(axis);
75
+ for(const kind of ['bone','slot']){
76
+ const group=document.createElement('h4');group.textContent=kind==='bone'?'Bones':'Slots';list.append(group);
77
+ for(const track of tracks.filter(t=>t.kind===kind)){
78
+ const row=document.createElement('div');row.className='animation-lane';row.dataset.track=track.pointer;
79
+ const label=button(`${track.target} · ${track.name}`,()=>{this.track=track;this.channel=0;this.renderChannels();this.show('curve');});label.className='animation-lane-label';row.append(label);
80
+ const marks=document.createElement('div');marks.className='animation-key-lane';marks.dataset.track=track.pointer;row.append(marks);
81
+ track.keys.forEach((key,index)=>{const mark=button('◆',()=>{this.track=track;this.channel=0;this.renderChannels();this.options.seek(key.time);});mark.className='animation-key';Object.assign(mark.dataset,{targetKind:kind,target:track.target,timeline:track.name,keyIndex:String(index),keyTime:String(key.time)});mark.style.left=`${key.time/end*100}%`;mark.title=`${track.target} · ${track.name} · key ${index} · ${key.time} s`;mark.setAttribute('aria-label',mark.title);marks.append(mark);});
82
+ const line=document.createElement('span');line.className='animation-playhead';line.dataset.playhead='';marks.append(line);list.append(row);
83
+ }
84
+ }
85
+ if(!tracks.length){const empty=document.createElement('p');empty.textContent='No stored tracks for this selection.';list.append(empty);}this.placePlayheads();
86
+ }
87
+ private renderChannels(){
88
+ const unsupported=this.track&&(this.track.kind!=='bone'||!CHANNELS[this.track.name])?`${this.track.target} · ${this.track.name} has no numeric curve evaluator`:'';this.unavailableChannel=unsupported;
89
+ this.channelSelect.replaceChildren();const tracks=this.inspection?filterTracks(this.inspection,this.selection.value()):[];
90
+ for(const t of tracks)if(t.kind==='bone')CHANNELS[t.name]?.forEach((c,i)=>{const o=document.createElement('option');o.value=JSON.stringify([t.pointer,i]);o.textContent=`${t.target} · ${t.name} · ${c.local}`;this.channelSelect.append(o);});
91
+ const value=this.track?JSON.stringify([this.track.pointer,this.channel]):'';
92
+ if(unsupported){this.track=null;this.channelSelect.selectedIndex=-1;}
93
+ else if([...this.channelSelect.options].some(o=>o.value===value))this.channelSelect.value=value;
94
+ else{const first=this.channelSelect.options[0];if(first){const [pointer,c]=JSON.parse(first.value);this.track=tracks.find(t=>t.pointer===pointer)??null;this.channel=c;}else this.track=null;}
95
+ this.channelSelect.disabled=this.channelSelect.options.length===0;this.renderCurve();this.update(this.at,this.runtimeDuration);
96
+ }
97
+ private renderCurve(){
98
+ const host=this.options.host.querySelector('[data-curve-plot]')!;host.replaceChildren();this.cursor=null;this.plot=null;
99
+ const t=this.track,field=t&&CHANNELS[t.name]?.[this.channel]?.field;if(!t||!field||!t.keys.length)return;
100
+ const segments=channelSegments(t,this.channel),values=t.keys.map(k=>k[field] as number);for(const s of segments)if(Array.isArray(s.curve))values.push(s.curve[1]!,s.curve[3]!);
101
+ const low=Math.min(...values),high=Math.max(...values),span=high-low||1,end=Math.max(this.runtimeDuration,t.keys.at(-1)!.time,Number.MIN_VALUE);
102
+ const plot=this.plot=(time:number,value:number)=>({x:48+time/end*728,y:195-(value-low)/span*160});
103
+ host.append(svg('path',{d:'M48 20 V195 H776',class:'animation-axes'}));
104
+ for(const [time,value]of [[0,low],[end,high]]){const n=svg('text',{x:time===0?48:650,y:time===0?220:15});n.textContent=`${time} s · ${value}`;host.append(n);}
105
+ for(const [index,s]of segments.entries()){
106
+ const a=plot(...s.from),b=plot(...s.to);let d=`M${a.x} ${a.y}`;
107
+ if(Array.isArray(s.curve)){const c=plot(s.curve[0]!,s.curve[1]!),e=plot(s.curve[2]!,s.curve[3]!);d+=` C${c.x} ${c.y} ${e.x} ${e.y} ${b.x} ${b.y}`;
108
+ host.append(svg('path',{d:`M${a.x} ${a.y} L${c.x} ${c.y} M${b.x} ${b.y} L${e.x} ${e.y}`,class:'animation-handles'}));for(const p of [c,e])host.append(svg('circle',{cx:p.x,cy:p.y,r:3,class:'animation-handle'}));
109
+ }else d+=s.curve==='stepped'?` H${b.x} V${b.y}`:` L${b.x} ${b.y}`;
110
+ const path=svg('path',{d,class:'animation-curve'});path.dataset.segment=String(index);path.dataset.stored=JSON.stringify(s);host.append(path);
111
+ }
112
+ for(const k of t.keys){const p=plot(k.time,k[field] as number);host.append(svg('circle',{cx:p.x,cy:p.y,r:3,class:'animation-curve-key'}));}
113
+ this.cursor=svg('circle',{r:5,class:'animation-runtime-cursor'});host.append(this.cursor);
114
+ }
115
+ update(time:number,duration:number){
116
+ this.at=time;this.runtimeDuration=duration;const timeInput=this.options.host.querySelector<HTMLInputElement>('[data-curve-time]')!;timeInput.max=String(duration);if(document.activeElement!==timeInput)timeInput.value=String(time);this.placePlayheads();const t=this.track,c=t&&CHANNELS[t.name]?.[this.channel];this.readout.textContent=`Runtime evaluation unavailable: ${this.unavailableChannel||'select a numeric stored channel'}.`;for(const key of ['time','channelValue','localValue','bone','property'])delete this.readout.dataset[key];
117
+ if(!t||!c||!this.inspection)return;
118
+ const value=evaluateStoredChannel(t,this.channel,time),local=evaluateBoneLocals(this.inspection,t.target,time)[c.local];
119
+ Object.assign(this.readout.dataset,{time:String(time),channelValue:value===null?'setup':numeric(value),localValue:numeric(local),bone:t.target,property:c.local});
120
+ this.readout.textContent=`Runtime evaluation · ${time} s · ${c.local} track: ${value===null?'before first key (setup)':numeric(value)} · authored local: ${numeric(local)}`;
121
+ if(this.cursor&&this.plot){this.cursor.setAttribute('visibility',value===null?'hidden':'visible');if(value!==null){const p=this.plot(time,value);this.cursor.setAttribute('cx',String(p.x));this.cursor.setAttribute('cy',String(p.y));}}
122
+ }
123
+ private placePlayheads(){const end=Math.max(this.runtimeDuration,...(this.inspection?.tracks??[]).map(t=>t.keys.at(-1)?.time??0),Number.MIN_VALUE);for(const n of this.options.host.querySelectorAll<HTMLElement>('[data-playhead]'))n.style.left=`${this.at/end*100}%`;}
124
+ capture(player:Pick<RigPlayer,'bone'>|null,frame:PosedFrame|undefined){
125
+ this.frame=frame??null;this.bones.clear();for(const b of player&&frame?this.inspection?.bones??[]:[]){try{const p=player!.bone(b.name);if(p.active)this.bones.set(b.name,{x:p.x,y:p.y});}catch{/* unavailable bones have no stage handle */}}this.redraw();
126
+ }
127
+ redraw(bonePoints=this.bonePoints){
128
+ this.bonePoints=bonePoints;
129
+ for(const [name,node]of this.boneButtons)if(!this.bones.has(name)){node.remove();this.boneButtons.delete(name);}
130
+ const {stage}=this.options,selected=this.selection.value();stage.dataset.heldFrame=this.frame?'available':'unavailable';stage.dataset.selectedKind=selected?.kind??'';stage.dataset.selectedName=selected?.name??'';this.picks.hidden=!this.options.enabled();
131
+ let order=0;for(const [name,p]of this.bones){let b=this.boneButtons.get(name);if(!b){b=button('',()=>this.select({kind:'bone',name}));b.className='animation-bone-pick';b.title=`Select bone ${name}`;b.setAttribute('aria-label',b.title);b.dataset.bone=name;this.boneButtons.set(name,b);this.picks.append(b);}
132
+ b.style.zIndex=String(order++);const at=this.options.project(p.x,p.y);b.style.left=`${at.x}px`;b.style.top=`${at.y}px`;b.hidden=!bonePoints&&!(selected?.kind==='bone'&&selected.name===name);b.setAttribute('aria-pressed',String(selected?.kind==='bone'&&selected.name===name));
133
+ }
134
+ }
135
+ dispose(){for(const dispose of this.disposers)dispose();this.picks.remove();}
136
+ }
package/web/index.html CHANGED
@@ -6,6 +6,6 @@
6
6
  <header class="topbar"><div class="brand"><span class="brand-mark">r</span>rig review</div><div><strong id="package-title">Review package</strong><span id="revision" class="subtle"></span></div><div class="mode-switch" role="group" aria-label="Mode"><button type="button" data-mode="rig" data-inspector="rig" aria-controls="inspector-rig" aria-pressed="true">Setup</button><button type="button" data-mode="playback" data-inspector="playback" aria-controls="inspector-playback" aria-pressed="false">Animate</button></div><div class="top-actions"><span id="dirty" class="subtle">Loading…</span><button id="write-review" type="button" class="primary" aria-haspopup="dialog" aria-controls="review-dialog">Write review</button></div></header>
7
7
  <div class="layout">
8
8
  <aside class="left-panel"><div class="left-header parts-header"><h2>PARTS</h2><input id="part-search" data-part-search type="search" autocomplete="off" aria-label="Find slot" placeholder="Find slot"></div><details id="package-context" class="package-context"><summary><span class="eyebrow">CHECKPOINTS</span><span id="checkpoint-title">Select a checkpoint</span></summary><nav id="checkpoints" aria-label="Checkpoints"></nav><section><label for="reference-select" class="label">Reference</label><select id="reference-select"></select><div id="reference-info" class="hint"></div><div id="person-control" hidden><label for="person-select" class="label space-top">Person in input pose</label><select id="person-select"></select></div></section></details><aside id="parts-explorer" class="parts-explorer" aria-label="Parts explorer"><button type="button" data-part-whole aria-pressed="true">Whole rig</button><label class="part-solo"><input id="part-solo" data-part-solo type="checkbox" disabled>Solo selected slot</label><p data-part-status class="hint" role="status">Loading actual slots…</p><ul data-part-list aria-label="Actual rig slots"></ul><p class="hint">Select in context or enable Solo. Thumbnails are each slot's declared page region; a type icon stands in where the model declares none. Atlas pages are not part thumbnails.</p></aside></aside>
9
- <main><div class="viewbar"><fieldset id="layer-controls" class="layer-toggles"><legend class="sr-only">Rig layers</legend><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="I" id="layer-images" type="checkbox" checked disabled><span>Images</span><kbd aria-hidden="true">I</kbd></label><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="M" id="layer-mesh" type="checkbox" disabled><span>Mesh edges</span><kbd aria-hidden="true">M</kbd></label><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="B" id="layer-bones" type="checkbox" disabled><span>Bone points</span><kbd aria-hidden="true">B</kbd></label></fieldset><div class="view-tools"><button id="chest-edit" type="button" class="view-tool" aria-pressed="false" aria-controls="chest-editor-bar" title="Place the two chest regions on the rig: drag a centre, the core edge or the band" hidden>Chest regions</button><button id="mesh-edit" type="button" class="view-tool" aria-pressed="false" aria-controls="mesh-editor-bar" title="Edit the selected slot's actual mesh as a typed delta: preview only, the producer retriangulates" hidden>Edit mesh</button></div><div class="zoom-controls"><label class="camera-sync"><input id="camera-sync" type="checkbox" checked>Sync cameras</label><button id="zoom-out" aria-label="Zoom out">−</button><output id="zoom-value">100%</output><button id="zoom-in" aria-label="Zoom in">+</button><button id="fit">Fit</button></div><details id="view-menu" class="view-menu"><summary aria-label="View layout">View: <span id="view-menu-label">Side by side</span></summary><div class="view-menu-list" role="group" aria-label="View"><button type="button" data-view="compare" aria-pressed="true">Side by side</button><button type="button" data-view="reference" aria-pressed="false">Reference</button><button type="button" data-view="rig" aria-pressed="false">Rig</button></div></details></div><div id="chest-editor-bar" class="chest-editor-bar" role="region" aria-label="Chest region editor" hidden><div class="bust-group" role="group" aria-label="Chest editor mode"><input id="bust-mode" type="hidden" value="circles"><button id="bust-mode-circles" type="button" data-bust-mode="circles" aria-pressed="true" title="Two circles, each centre, core and band set by hand (#99)">Circles</button><button id="bust-mode-landmarks" type="button" data-bust-mode="landmarks" aria-pressed="false" title="Three clicks (right apex, left apex, cleavage); the producer derives both circles (contour.bust)">Landmarks</button></div><div class="bust-group" data-chest-mode="circles"><span class="bust-tag bust-tag-r">● R</span><label>X<input id="bust-r-x" type="number" step="1" inputmode="decimal" aria-label="R region centre x"></label><label>Y<input id="bust-r-y" type="number" step="1" inputmode="decimal" aria-label="R region centre y"></label><label>R<input id="bust-r-r" type="number" min="6" max="120" step="1" inputmode="decimal" aria-label="R region core radius"></label><label>Band<input id="bust-r-band" type="number" min="4" max="120" step="1" inputmode="decimal" aria-label="R region band"></label></div><div class="bust-group" data-chest-mode="circles"><span class="bust-tag bust-tag-l">● L</span><label>X<input id="bust-l-x" type="number" step="1" inputmode="decimal" aria-label="L region centre x"></label><label>Y<input id="bust-l-y" type="number" step="1" inputmode="decimal" aria-label="L region centre y"></label><label>R<input id="bust-l-r" type="number" min="6" max="120" step="1" inputmode="decimal" aria-label="L region core radius"></label><label>Band<input id="bust-l-band" type="number" min="4" max="120" step="1" inputmode="decimal" aria-label="L region band"></label></div><div id="bust-landmarks" class="bust-group" data-chest-mode="landmarks" role="group" aria-label="Chest landmarks (rig px)" hidden><button id="lm-pick-apex_r" type="button" data-landmark="apex_r" aria-pressed="false" title="Click the part to place the apex r; drag its marker to move it">Apex R</button><label>X<input id="lm-apex_r-x" type="number" step="1" inputmode="decimal" aria-label="Apex R x (rig px)"></label><label>Y<input id="lm-apex_r-y" type="number" step="1" inputmode="decimal" aria-label="Apex R y (rig px)"></label><button id="lm-pick-apex_l" type="button" data-landmark="apex_l" aria-pressed="false" title="Click the part to place the apex l; drag its marker to move it">Apex L</button><label>X<input id="lm-apex_l-x" type="number" step="1" inputmode="decimal" aria-label="Apex L x (rig px)"></label><label>Y<input id="lm-apex_l-y" type="number" step="1" inputmode="decimal" aria-label="Apex L y (rig px)"></label><button id="lm-pick-cleavage" type="button" data-landmark="cleavage" aria-pressed="false" title="Click the part to place the cleavage; drag its marker to move it">Cleavage</button><label>X<input id="lm-cleavage-x" type="number" step="1" inputmode="decimal" aria-label="Cleavage x (rig px)"></label><label>Y<input id="lm-cleavage-y" type="number" step="1" inputmode="decimal" aria-label="Cleavage y (rig px)"></label></div><div id="bust-landmark-params" class="bust-group" data-chest-mode="landmarks" role="group" aria-label="Landmark constants" hidden><label title="Core ratio r / (r + band); the producer's default is 0.22">α<input id="lm-alpha" type="range" min="0.05" max="0.95" step="0.01" value="0.22" aria-label="Alpha, core ratio"><output id="lm-alpha-value" for="lm-alpha">0.22</output></label><label title="Cleavage clearance in rig px; the producer's default is 3.8">Clearance<input id="lm-clearance" type="range" min="0" max="40" step="0.1" value="3.8" aria-label="Clearance, rig px"><output id="lm-clearance-value" for="lm-clearance">3.8</output></label><label title="Control bone for bust_r; empty is the producer's default boob_r">Bone R<input id="lm-bone_r" type="text" list="lm-bones" placeholder="boob_r" spellcheck="false" autocomplete="off"></label><label title="Control bone for bust_l; empty is the producer's default boob_l">Bone L<input id="lm-bone_l" type="text" list="lm-bones" placeholder="boob_l" spellcheck="false" autocomplete="off"></label><datalist id="lm-bones"></datalist></div><span id="lm-derived" class="hint" data-chest-mode="landmarks" aria-live="polite" hidden></span><div class="bust-group" data-chest-mode="circles"><button id="bust-mirror" type="button" title="Copy R's y, core radius and band to L">Mirror R → L</button><button id="bust-download" type="button" title="Download chest-regions.json and its binding; evidence, not an approval">Download JSON</button></div><div class="bust-group"><button id="bust-apply" type="button" title="Run the installed producer (rig-parts remesh) on these regions or landmarks into a scratch build and load it here; the package is unchanged">Apply · Rebuild</button><button id="bust-reset" type="button" title="Return the stage to the inspected build" disabled>Reset</button></div><span id="handoff-line" class="hint handoff-line" aria-live="polite" title="Producer handoff: the config, parts and inspected rig an Apply rebuilds from"></span><span id="chest-editor-status" class="hint" aria-live="polite"></span><pre id="chest-editor-log" class="rebuild-log" aria-label="Producer output" hidden></pre><textarea id="rebuild-applied" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></div><div id="mesh-editor-bar" class="chest-editor-bar mesh-editor-bar" role="region" aria-label="Mesh edit" hidden><div class="bust-group" role="group" aria-label="Mesh edit sub-mode"><button id="mesh-mode-modify" type="button" data-mesh-mode="modify" aria-pressed="false" aria-keyshortcuts="V" title="Modify · key V">Modify<kbd aria-hidden="true">V</kbd></button><button id="mesh-mode-create" type="button" data-mesh-mode="create" aria-pressed="false" aria-keyshortcuts="C" title="Create · key C">Create<kbd aria-hidden="true">C</kbd></button><button id="mesh-mode-delete" type="button" data-mesh-mode="delete" aria-pressed="false" aria-keyshortcuts="X" title="Delete · key X">Delete<kbd aria-hidden="true">X</kbd></button><button id="mesh-mode-edges" type="button" data-mesh-mode="edges" aria-pressed="false" aria-keyshortcuts="E" title="Edges · key E">Edges<kbd aria-hidden="true">E</kbd></button></div><div class="bust-group"><button id="mesh-undo" type="button" aria-keyshortcuts="Control+Z Meta+Z" title="Undo the last action · Cmd/Ctrl-Z" disabled>Undo</button><button id="mesh-redo" type="button" aria-keyshortcuts="Control+Shift+Z Meta+Shift+Z" title="Redo · Shift-Cmd/Ctrl-Z" disabled>Redo</button><button id="mesh-reset" type="button" title="Drop the whole delta for this attachment (asks first)" disabled>Reset</button></div><div class="bust-group"><button id="mesh-download" type="button" title="Download mesh-edit.json and its binding; evidence, not an approval">Download JSON</button><button id="mesh-apply" type="button" title="Send the delta to the producer (rig-parts remesh --delta) and load the scratch revision in place; nothing is written into the package">Apply · Rebuild</button></div><textarea id="mesh-edit-draft" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea><span id="mesh-editor-status" class="hint" aria-live="polite"></span></div><div id="stage-layout" class="stages"><section id="reference-pane" class="stage-pane" aria-label="Reference"><span id="reference-label" class="pane-label">Reference</span><div id="reference-viewport" class="viewport"><div id="reference-content" class="reference-content"></div><div id="reference-empty" class="empty">No reference supplied.</div></div></section><section id="rig-pane" class="stage-pane" aria-label="Rig"><span id="rig-label" class="pane-label">Generated rig · read only</span><div id="rig-viewport" class="viewport"><div id="rig-root"></div><canvas id="rig-overlay" class="rig-overlay" aria-label="Actual rig mesh and bone overlay"></canvas><div id="idle-handles" class="idle-handles" hidden aria-label="Chain amplitude handles"></div><div id="rig-fields-stage" class="rig-fields-stage" hidden aria-label="Rig field handles"></div><div id="rig-empty" class="empty">No compiled model supplied.</div></div></section></div><div class="stage-footer"><span id="coordinates">Drag to pan · scroll to zoom · cameras synchronized</span><span>Source files remain unchanged.</span></div><section class="inspector" aria-label="Inspection controls"><div class="inspector-heading sticky-header"><strong>INSPECTOR</strong><span class="hint">Local view controls · source files stay unchanged</span></div><div class="inspector-body"><section id="applied-records" class="applied-records" aria-labelledby="applied-records-heading" hidden><div class="row"><span id="applied-records-heading" class="eyebrow">APPLIED RECORDS</span><button id="applied-undo" type="button" disabled>Undo last Apply (0 deep)</button></div><ol id="applied-list" class="applied-list"></ol><p id="applied-status" class="hint" aria-live="polite"></p></section><section id="inspector-rig" aria-label="Setup" data-inspector-panel="rig"><nav class="block-row" aria-label="Setup blocks"><button type="button" data-block-target="selected-part">Part</button><button type="button" data-block-target="modeling-tools">Modeling</button><button type="button" data-block-target="chest-regions">Chest</button><button type="button" data-block-target="mesh-panel">Mesh</button><button type="button" data-block-target="weights">Weights</button><button type="button" data-block-target="bones-panel">Bones</button><button type="button" data-block-target="rig-fields">Fields</button><button type="button" data-block-target="cuts-panel">Cuts</button><button type="button" data-block-target="cloth-panel">Cloth</button></nav><section id="selected-part" aria-label="Selected part" aria-live="polite"></section><section id="modeling-tools"><div class="eyebrow">MODELING REVIEW</div><button data-proposal="mesh" class="tool">Mesh density <small>Add feedback</small></button><button data-proposal="contour" class="tool">Contour curvature <small>Add feedback</small></button><button data-proposal="chest" class="tool">Chest position <small>Add feedback</small></button><label for="part-set" class="label space-top">Part presets</label><select id="part-set" disabled><option>No sets supplied</option></select><p class="hint">Preview only · does not create motion keys.</p></section><section id="chest-regions" class="chest-regions" aria-labelledby="chest-regions-heading"><div class="eyebrow" id="chest-regions-heading">CHEST REGIONS · DETAIL</div><p class="hint">Crop pixels, x screen-right and y down (<code>family-chest-regions/1</code>). Place the circles with <strong>Chest regions</strong> in the view bar; this block holds names, bones, semi-axes, clip and the contour. Equal semi-axes export as a circle, unequal ones (or a clip) as the generator's 32-sample polygon. Exporting writes nothing into the package and is not an approval.</p><p id="chest-mapping" class="hint"></p><div class="chest-grid"><label>Crop width<input id="chest-crop-w" type="number" step="any" inputmode="decimal"></label><label>Crop height<input id="chest-crop-h" type="number" step="any" inputmode="decimal"></label></div><fieldset class="chest-row"><legend>Region 1</legend><label>Name<input id="chest-0-name" type="text" autocomplete="off" spellcheck="false"></label><label>Bone<select id="chest-0-bone"><option value="">Select bone</option></select></label><label>Centre x<input id="chest-0-cx" type="number" step="any" inputmode="decimal"></label><label>Centre y<input id="chest-0-cy" type="number" step="any" inputmode="decimal"></label><label>Semi-axis x<input id="chest-0-ax" type="number" step="any" inputmode="decimal"></label><label>Semi-axis y<input id="chest-0-ay" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-0-spacing" type="number" step="any" inputmode="decimal"></label><label>Band<input id="chest-0-band" type="number" step="any" inputmode="decimal"></label><label>Medial clip x<input id="chest-0-clip" type="number" step="any" inputmode="decimal" placeholder="none"></label></fieldset><fieldset class="chest-row"><legend>Region 2</legend><label>Name<input id="chest-1-name" type="text" autocomplete="off" spellcheck="false"></label><label>Bone<select id="chest-1-bone"><option value="">Select bone</option></select></label><label>Centre x<input id="chest-1-cx" type="number" step="any" inputmode="decimal"></label><label>Centre y<input id="chest-1-cy" type="number" step="any" inputmode="decimal"></label><label>Semi-axis x<input id="chest-1-ax" type="number" step="any" inputmode="decimal"></label><label>Semi-axis y<input id="chest-1-ay" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-1-spacing" type="number" step="any" inputmode="decimal"></label><label>Band<input id="chest-1-band" type="number" step="any" inputmode="decimal"></label><label>Medial clip x<input id="chest-1-clip" type="number" step="any" inputmode="decimal" placeholder="none"></label></fieldset><fieldset class="chest-row"><legend>Contour</legend><label>Tolerance<input id="chest-tolerance" type="number" step="any" inputmode="decimal"></label><label>Margin<input id="chest-margin" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-spacing" type="number" step="any" inputmode="decimal"></label><label>Max edge<input id="chest-max-edge" type="number" step="any" inputmode="decimal"></label><label>Stray<input id="chest-stray" type="number" step="any" inputmode="decimal"></label><label class="chest-check"><input id="chest-bridges" type="checkbox">Bridges</label></fieldset><details class="chest-more"><summary>Generator and mesh fields</summary><label for="chest-generator" class="label space-top">Public generator</label><input id="chest-generator" type="text" autocomplete="off" spellcheck="false" placeholder="Generator that will read this file"><label for="chest-r" class="label space-top">Mesh r (optional)</label><input id="chest-r" type="number" step="any" inputmode="decimal"><label for="chest-segments" class="label space-top">Mesh segments JSON (optional)</label><textarea id="chest-segments" rows="3" spellcheck="false"></textarea><label for="chest-source-rule" class="label space-top">Source rule (optional)</label><input id="chest-source-rule" type="text" autocomplete="off"></details><p id="chest-status" class="hint" aria-live="polite"></p><button id="chest-export" type="button" class="outline block">Download chest-regions.json</button></section><section id="mesh-panel" class="physics-panel mesh-panel" aria-labelledby="mesh-panel-heading"><div class="row"><span id="mesh-panel-heading" class="eyebrow">MESH · APPLY REMESHES</span></div><p id="mesh-panel-state" class="hint">Select a mesh part in the parts list.</p><p class="hint">Edits the selected part's <code>meshes.&lt;part&gt;</code> entry from the handoff config (the grid and contour modes). Apply sends the whole pending entry to the installed producer's <code>remesh --mesh</code> (one patch op at <code>/meshes/&lt;part&gt;</code>), compiles and checks the revision, and reloads it with the mesh edges on; Reset returns to the inspected build and the config's entry. Slider ranges are review's; the number field takes any value, and the producer judges it.</p><div id="mesh-params" class="physics-controls mesh-params"></div><div class="row mesh-tools"><span class="eyebrow">REGIONS</span><button id="mesh-add-circle" type="button" disabled>Add circle</button><button id="mesh-add-polygon" type="button" aria-pressed="false" disabled>Draw polygon</button><button id="mesh-regions-edit" type="button" aria-pressed="false" disabled>Drag on stage</button></div><div id="mesh-regions" class="mesh-regions"></div><div class="row mesh-tools"><span class="eyebrow">SEGMENTS</span><select id="mesh-segment-bone" aria-label="Bone to add as a segment" disabled></select><button id="mesh-segment-add" type="button" disabled>Add</button></div><ol id="mesh-segments" class="physics-pending-list mesh-segments"></ol><div class="row"><span class="eyebrow">PENDING ENTRY · DIFF AGAINST THE CONFIG</span></div><ol id="mesh-diff" class="physics-pending-list"></ol><ul id="mesh-precheck" class="mesh-precheck" aria-label="Review pre-check in the config loader's names"></ul><div class="row"><button id="mesh-panel-apply" type="button" class="primary" disabled>Apply</button><button id="mesh-panel-reset" type="button" disabled>Reset</button><button id="mesh-folds" type="button" title="rigc render --folds of the drawn animation on the inspected build and on this revision (#108 readings)">Folds reading</button></div><output id="mesh-status" class="environment-readout"></output><div id="mesh-counts" class="physics-readout" aria-label="Vertex and triangle counts before and after"></div><div id="mesh-readout" class="physics-readout" aria-label="TEXTURE_STRETCH before and after Apply"></div><div id="density-strip" class="density-strip" aria-labelledby="density-strip-heading"><div class="row"><span id="density-strip-heading" class="eyebrow">DENSITY STRIP · NOTHING IS APPLIED</span></div><p id="density-strip-field" class="hint">Select a mesh part (grid or contour entry) to build a density strip.</p><p class="hint">Builds N values of the part's density field side by side, each as its own <code>replace /meshes/&lt;part&gt;</code> patch through remesh (into its own scratch folder). Rows are labels only: Load puts a row on the stage as pending, and the Mesh panel's Apply applies it. Closing or replacing the strip removes its scratch.</p><div class="row density-strip-form"><label>N<input id="density-strip-n" type="number" min="2" max="5" step="1" value="5" aria-label="Number of values"></label><label>from<input id="density-strip-from" type="number" step="any" aria-label="From value"></label><label>to<input id="density-strip-to" type="number" step="any" aria-label="To value"></label><button id="density-strip-build" type="button" disabled>Build strip</button><button id="density-strip-back" type="button" disabled title="Return the stage to the applied build it showed before a row was loaded">Back</button><button id="density-strip-close" type="button" disabled>Close strip</button></div><output id="density-strip-status" class="environment-readout"></output><div id="density-strip-table" class="density-strip-table"></div></div><textarea id="mesh-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="weights" class="physics-panel weights-block" aria-labelledby="weights-heading"><div class="row"><span id="weights-heading" class="eyebrow">WEIGHTS · SETUP POSE · WATCH ONLY</span></div><p class="hint">The selected slot's mesh at the setup pose, each vertex coloured by the weight the compiled model document stores for the picked bone (0 → 1), or by its dominant bone under <strong>Max influence</strong>. An unweighted mesh is its slot's bone at 1.0. Hover a vertex for its stored values. Nothing here edits, applies or records a weight.</p><div class="row weights-controls"><button id="weights-show" type="button" aria-pressed="false">Show weights</button><label>Colour by<select id="weights-mode"><option value="bone">Bone weight</option><option value="max">Max influence</option></select></label><label>Bone<select id="weights-bone" disabled></select></label></div><p id="weights-state" class="hint" aria-live="polite"></p><div id="weights-legend" class="weights-legend" aria-label="Weights legend"></div><output id="weights-hover" class="environment-readout"></output></section><section id="bones-panel" class="physics-panel bones-panel" aria-labelledby="bones-panel-heading"><div class="row"><span id="bones-panel-heading" class="eyebrow">BONES · CONFIG ANCHORS · APPLY REBUILDS</span></div><p id="bones-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's bones at their <code>at</code>, chain <code>points</code> and <code>tip</code> (rig px, the setup pose; the stage may be posed by an animation). Drag a handle on the stage or edit the numbers: each move is a config patch op at <code>/bones/&lt;i&gt;/at</code>, <code>/bones/&lt;i&gt;/points/&lt;k&gt;</code> or <code>/bones/&lt;i&gt;/tip</code> naming its bone, moved in whole rig px. <strong>Check</strong> runs <code>propose --from-config</code> on a scratch copy of the patched config (LINT and coverage lines; LINT blocks nothing) and <code>compare</code> against the handoff config. <strong>Apply</strong> rebuilds the whole rig (<code>rig --config</code>, then <code>check</code>) and needs the reproduced baseline; Reset returns to the inspected build and clears the pending ops. Adding, removing or re-parenting bones is out of scope.</p><div class="row bones-controls"><button id="bones-edit" type="button" aria-pressed="false" disabled>Edit bones on stage</button><label for="bones-select">Bone</label><select id="bones-select" disabled></select></div><div id="bones-anchor-editor" class="bones-anchor-editor"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="bones-pending-list" class="physics-pending-list"></ol><div class="row"><button id="bones-check" type="button" disabled>Check</button><button id="bones-apply" type="button" class="primary" disabled>Apply</button><button id="bones-reset-build" type="button" disabled>Reset</button></div><output id="bones-status" class="environment-readout"></output><div id="bones-compare" class="bones-lines" aria-label="compare: origin and tip distance per moved bone"></div><div id="bones-lint" class="bones-lines" aria-label="propose --from-config: LINT and coverage lines"></div><textarea id="bones-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="rig-fields" class="physics-panel rig-fields" aria-labelledby="rig-fields-heading"><div class="row"><span id="rig-fields-heading" class="eyebrow">RIG FIELDS · CONFIG · APPLY REBUILDS</span><span id="rig-fields-dial-label" class="idle-preview-label" hidden>preview — Apply decides</span></div><p id="rig-fields-state" class="hint">Reading the producer handoff…</p><p class="hint">Small producer config fields, each a config patch op: the bone a rigid part rides (<code>/regions/&lt;part&gt;</code>), a lash's blink still row (<code>/motion/blink/still/&lt;part&gt;</code>, dragged on the stage), a stop's inward / outward bounds (<code>/stops/&lt;i&gt;</code>, arcs dragged at the pivot), a bone's inherit mode (<code>/bones/&lt;i&gt;/inherit</code>; a chain's <code>link_inherit</code>) and <code>turn</code>'s pivot and per-part depth (<code>/turn/pivot</code>, <code>/turn/depths/&lt;part&gt;</code>). <strong>Apply</strong> rebuilds the whole rig (<code>rig --config</code>, then <code>check</code>) and needs the reproduced baseline; the producer's refusal is shown as printed. The dial only previews the drawn build's <code>turn</code> bone and is never recorded. Reset returns to the inspected build and clears the pending ops.</p><div id="rig-fields-region" class="rig-fields-group"></div><div id="rig-fields-still" class="rig-fields-group"></div><div id="rig-fields-stops" class="rig-fields-group"></div><div id="rig-fields-inherit" class="rig-fields-group"></div><div id="rig-fields-turn" class="rig-fields-group"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="rig-fields-pending-list" class="physics-pending-list"></ol><div class="row"><button id="rig-fields-apply" type="button" class="primary" disabled>Apply</button><button id="rig-fields-reset" type="button" disabled>Reset</button><button id="rig-fields-reading" type="button" disabled>Angle range</button></div><output id="rig-fields-status" class="environment-readout"></output><div id="rig-fields-readout" class="physics-readout" aria-label="BLINK_NO_HOLE and FEET_STAY_LEVEL before and after"></div><textarea id="rig-fields-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="cuts-panel" class="physics-panel bones-panel cuts-panel" aria-labelledby="cuts-panel-heading"><div class="row"><span id="cuts-panel-heading" class="eyebrow">CUTS · PATCHES · LIMBS · APPLY RUNS RIG-PARTS BUILD</span></div><p id="cuts-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's <code>assemble.cuts</code> polygons, <code>assemble.patches</code> boxes and each limb joint with its <code>range</code> arc, on the stage in rig px of the setup pose. Drag a vertex, a box corner or a joint, or edit the numbers: each change is a config patch op (<code>/assemble/cuts/&lt;i&gt;/polygon/&lt;k&gt;</code>, <code>/assemble/patches/&lt;i&gt;/box</code>, <code>/limbs/&lt;limb&gt;/segments/&lt;k&gt;/range</code>; a joint is its bone's origin, <code>/bones/…</code>). A new cut or patch adds its entry and a <code>regions</code> bone. <strong>Apply</strong> runs <code>rig-parts build</code> (assemble → rig → check) from the declared painting and See-through runs into scratch and needs the reproduced baseline; the producer's refusals (<code>ASSEMBLE_CUT_*</code>, <code>ASSEMBLE_UNDER_*</code>, <code>CONFIG_LIMB_*</code>, <code>CONFIG_PART_UNIQUE</code>) are shown as printed, and the recomposite error map, the <code>seam pair</code> lines and the build's wall clock come back with the build. Review paints no <code>under</code> or <code>cap</code> image. Reset returns to the inspected build and clears the pending ops.</p><div class="row bones-controls"><button id="cuts-edit" type="button" aria-pressed="false" disabled>Edit cuts on stage</button><label for="cuts-select">Entry</label><select id="cuts-select" disabled></select></div><div id="cuts-editor" class="bones-anchor-editor"></div><div id="cuts-add" class="row cuts-add"><span class="eyebrow">ADD</span><select data-cuts-add="from" aria-label="Plan part the new cut takes pixels from"></select><input data-cuts-add="name" type="text" autocomplete="off" placeholder="new part" aria-label="New part name"><input data-cuts-add="bone" type="text" list="lm-bones" autocomplete="off" placeholder="bone" aria-label="Bone the new part's region rides"><select data-cuts-add="draw" aria-label="Where the new part is drawn"></select><input data-cuts-add="overlap" type="number" min="0" step="1" value="0" inputmode="numeric" aria-label="Cut overlap band in rig px"><select data-cuts-add="alpha" aria-label="New patch alpha"><option value="silhouette">silhouette</option><option value="box">box</option></select><button type="button" data-cuts-add="place-cut">Place cut</button><button type="button" data-cuts-add="place-patch">Place patch</button><button type="button" data-cuts-add="finish">Finish</button><button type="button" data-cuts-add="cancel">Cancel</button></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="cuts-pending-list" class="physics-pending-list"></ol><div class="row"><button id="cuts-apply" type="button" class="primary" disabled>Apply</button><button id="cuts-reset" type="button" disabled>Reset</button></div><output id="cuts-status" class="environment-readout"></output><div id="cuts-readout" class="physics-readout" aria-label="The build's refusals, cut lines, recomposite, seam pairs and wall clock"></div><textarea id="cuts-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="cloth-panel" class="physics-panel bones-panel cloth-panel" aria-labelledby="cloth-panel-heading"><div class="row"><span id="cloth-panel-heading" class="eyebrow">CLOTH · RECIPE PER TAG · APPLY RUNS RIG-PARTS PROPOSE --CLOTH</span></div><p id="cloth-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's <code>cloth</code> block: one recipe per tag the installed reader accepts, with its fields as written. Each change is a config patch op at <code>/cloth/&lt;tag&gt;/…</code>. <strong>Show links</strong> draws the chains the tag's parts are meshed on, from the config's <code>points</code> and <code>tip</code> in rig px of the setup pose, and after an Apply the chains of the config the producer wrote. <strong>Apply</strong> runs <code>rig-parts propose --cloth --write-config</code> on the declared parts, painting and keypoints into scratch, then builds the written config (<code>rig-parts build</code> when the config declares <code>limbs</code>, else <code>rig --config</code>) and reloads it under the Environment's wind and gravity; the producer's refusals (<code>CONFIG_CLOTH_*</code>, <code>PROPOSE_CLOTH_DRAFTED</code>, <code>PROPOSE_CLOTH_PART</code>, <code>PROPOSE_WRITE_CONFIG_*</code>) are shown as printed. Review edits the recipe, never the drafted constraints. Reset returns to the inspected build and clears the pending ops.</p><div class="row bones-controls"><button id="cloth-links" type="button" aria-pressed="false" disabled>Show links on stage</button><label for="cloth-tag">Tag</label><select id="cloth-tag" disabled></select><label class="cloth-declared"><input id="cloth-declared" type="checkbox" disabled> recipe declared</label></div><div id="cloth-fields" class="cloth-fields"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="cloth-pending-list" class="physics-pending-list"></ol><div class="row"><button id="cloth-apply" type="button" class="primary" disabled>Apply</button><button id="cloth-reset" type="button" disabled>Reset</button></div><output id="cloth-status" class="environment-readout"></output><div id="cloth-readout" class="physics-readout" aria-label="Propose's refusals, the written config's diff, the route and check"></div><textarea id="cloth-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section></section><section id="inspector-playback" aria-label="Animate" data-inspector-panel="playback" hidden><nav class="block-row" aria-label="Animate blocks"><button type="button" data-block-target="transport" data-block-includes="frame-controls motion-tools angle-panel">Transport</button><button type="button" data-block-target="sound-panel">Sound</button><button type="button" data-block-target="environment">Environment</button><button type="button" data-block-target="physics-panel">Physics</button><button type="button" data-block-target="idle-tracks">Idle tracks</button><button type="button" data-block-target="readings">Readings</button><button type="button" data-block-target="requirements">Requirements</button><button type="button" data-block-target="retarget">Retarget</button><button type="button" data-block-target="report-panel">Report</button></nav><section id="transport" class="transport"><button id="play" aria-label="Play" disabled>▶</button><label for="animation-select" class="sr-only">Animation</label><select id="animation-select" disabled><option>No animations supplied</option></select><input id="scrub" type="range" min="0" max="1" step="0.001" value="0" aria-label="Playback time"><output id="time-label">0.0000 / 0.0000 s</output><output id="frame-label">rate unknown</output><label class="idle-repeat"><input id="idle-repeat" type="checkbox" checked>Repeat idle</label><div id="reading-ticks" class="reading-ticks" hidden aria-label="Reading ticks"></div><div id="idle-blink-strip" class="idle-blink-strip" hidden><button id="idle-blink-marker" type="button" class="idle-blink-marker" aria-label="Blink time">◆</button></div></section><section id="frame-controls" class="frame-controls" hidden aria-label="Frame navigation using the explicit clock"><label for="frame-number">Frame</label><input id="frame-number" type="number" min="0" step="1" inputmode="numeric" aria-label="Frame number to inspect"><button id="frame-jump" type="button">Go</button><input id="frame-scrub" type="range" min="0" max="1" step="1" value="0" aria-label="Scrub by frame"><span id="frame-clock-label" class="hint"></span><p id="frame-outside" class="hint" hidden>The current time is outside the explicit frame range. Enter a frame number to go to its exact time.</p></section><section id="motion-tools"><div class="eyebrow">MOTION · EDITS BUILD THROUGH THE PRODUCER</div><button data-proposal="pose" class="tool">Inspect pose <small>Add feedback</small></button><button data-proposal="timing" class="tool">Inspect interval <small>Add feedback</small></button><button data-proposal="audio" class="tool">Audio links <small>Add feedback</small></button><button data-proposal="angle" class="tool">Angle inspection <small id="axis-state">No inspection axes supplied</small></button></section><section id="angle-panel" class="angle-panel" hidden><label for="axis-select">Rig inspection axis</label><select id="axis-select"></select><label id="axis-label" for="axis-value"></label><input id="axis-value" type="range" disabled><output id="axis-value-label"></output><p class="hint">Maps one inspection axis to its animation time. This does not rotate the camera.</p></section><section id="sound-panel" class="sound-panel"><div class="row"><span class="eyebrow">SOUND CUES</span><label><input id="sync-audio" type="checkbox">Play cue audio in sync</label></div><div id="cues"></div><div class="row"><label for="audio-volume">Audio volume</label><input id="audio-volume" type="range" min="0" max="1" step="0.05" value="0.8"><output id="audio-volume-label">80%</output></div><div id="event-log" class="hint" aria-live="polite">Runtime events: none</div></section><section id="environment" class="environment-panel" aria-labelledby="environment-heading"><div class="row"><span id="environment-heading" class="eyebrow">ENVIRONMENT · INSPECTION ONLY</span></div><p class="hint">Wind, gravity and impulses go to the player while you inspect. Nothing is written to the model or motion; feedback records the state as context.</p><input id="environment-applied" type="checkbox" hidden aria-hidden="true" tabindex="-1"><div class="row"><label for="wind-base">Wind</label><input id="wind-base" type="range" min="-100" max="100" step="1" value="0" disabled><output id="wind-base-label">0</output></div><div class="row"><label><input id="wind-breeze" type="checkbox" disabled>Breeze</label><button id="wind-gust" type="button" disabled>Gust</button><label><input id="wind-repeat" type="checkbox" disabled>Repeat</label></div><div class="row"><label for="wind-gust-peak">Gust peak</label><input id="wind-gust-peak" type="number" min="0" max="200" step="1" value="48" inputmode="decimal" disabled><label for="wind-gust-duration">Duration (s)</label><input id="wind-gust-duration" type="number" min="0.1" max="20" step="0.1" value="2.4" inputmode="decimal" disabled></div><output id="wind-readout" class="environment-readout">No compiled model drawn · environment unavailable</output><div class="row"><label for="gravity">Gravity</label><input id="gravity" type="range" min="0" max="4" step="0.05" value="1" disabled><output id="gravity-label">1.00</output></div><div class="row"><button id="jolt" type="button" disabled>Jolt</button><label title="Dragging the rig pane moves the rig's physics instead of the camera. Hold Space to pan."><input id="drag-trail" type="checkbox" disabled>Drag to trail</label><button id="physics-reset" type="button" disabled>Reset physics</button></div><p class="hint">Drag to trail: dragging the rig pane tells the physics the rig moved; the camera stays. Hold Space to pan. Reset returns the runtime start values and re-poses from the current time, clearing the physics history.</p><div class="row"><label title="Readings and Apply's check read this wind and gravity as a rigc-environment/1 schedule; the wind clock restarts with the clip so you watch the same world"><input id="environment-use" type="checkbox" disabled>Use for readings</label><button id="environment-export" type="button" disabled>Export schedule</button><button id="environment-check" type="button" disabled>Check under this world</button></div><output id="environment-schedule" class="environment-readout">No schedule · the controls are not applied (the player stands at the runtime start values)</output><div id="environment-check-lines" class="environment-check-lines" aria-live="polite"></div><p class="hint">The schedule is the controls as a <code>rigc-environment/1</code> file from the clip's first frame: one step when the wind is steady, else one step per frame at 12 fps (between frames rigc holds the frame's value). A one-off Gust is not in it; Repeat puts gusts in it. Check under this world runs <code>rig-parts check --environment</code> on the build on the stage; the default-world line is that build's own check.</p></section><section id="physics-panel" class="physics-panel" aria-labelledby="physics-panel-heading"><div class="row"><span id="physics-panel-heading" class="eyebrow">PHYSICS TUNING · APPLY REBUILDS</span></div><p id="physics-panel-state" class="hint">No compiled model drawn.</p><p class="hint">Each edit is a config patch op at <code>/constraints/&lt;i&gt;/&lt;field&gt;</code> naming its constraint. Apply runs the installed producer (<code>rig --config</code>, then <code>check</code>) into a scratch build and reloads it under the same wind, gust and jolt state; Reset returns to the inspected build and clears the pending ops. Live preview changes strength, damping, inertia, wind, gravity and mix on the next evaluation; mass and component toggles require Apply. Clear resumes authored values without resetting integrated history.</p><div class="row"><label><input data-physics-preview type="checkbox" checked>Preview pending physics</label><button id="physics-preview-clear" type="button">Clear preview</button></div><output id="physics-preview-label" class="environment-readout">Preview off · Apply decides.</output><div class="row physics-chain-row"><label for="physics-chain">Chain</label><select id="physics-chain" disabled></select><button id="physics-chain-pick" type="button" aria-pressed="false" disabled>Pick on stage</button></div><div class="row physics-ramp-row"><label for="physics-ramp-field">Ramp</label><select id="physics-ramp-field" disabled></select><label for="physics-ramp-root">root</label><input id="physics-ramp-root" type="number" step="any" inputmode="decimal" disabled><label for="physics-ramp-tip">tip</label><input id="physics-ramp-tip" type="number" step="any" inputmode="decimal" disabled><button id="physics-ramp" type="button" disabled>Ramp</button></div><div class="physics-work"><div id="physics-rows" class="physics-table-wrap" role="region" aria-label="Physics constraints the config declares" tabindex="0"></div><div id="physics-row-editor" class="physics-row-editor"></div></div><div id="physics-locked" class="physics-table-wrap physics-locked" role="region" aria-label="Constraints that are not editable" tabindex="0" hidden></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="physics-pending-list" class="physics-pending-list"></ol><div class="row"><button id="physics-apply" type="button" class="primary" disabled>Apply</button><button id="physics-reset-build" type="button" disabled>Reset</button></div><output id="physics-apply-status" class="environment-readout"></output><div id="physics-readout" class="physics-readout" aria-label="Check readout before and after Apply"></div><textarea id="physics-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="idle-tracks" class="physics-panel idle-panel" aria-labelledby="idle-tracks-heading"><div class="row"><span id="idle-tracks-heading" class="eyebrow">IDLE TRACKS · APPLY REBUILDS</span><span id="idle-preview-label" class="idle-preview-label" aria-live="polite" hidden>preview — Apply decides</span></div><p id="idle-tracks-state" class="hint">No compiled model drawn.</p><p class="hint">Each edit is a config patch op at <code>/motion/tracks/&lt;i&gt;/…</code> or <code>/motion/blink/…</code>. Period is offered only among the divisors of <code>motion.duration</code> (the producer refuses any other, <code>CONFIG_PERIOD_DIVIDES_DURATION</code>). While edits are pending the preview drives the edited bones through the player's <code>adjust</code> with the producer's sine, <code>v(t) = base + amp·sin(2π(t/period − phase))</code>, on the bone the idle keys (<code>&lt;bone&gt;_ctl</code> under ctl keys); it is never recorded or submitted. Apply runs the installed producer (<code>rig --config</code>, then <code>check</code>) into a scratch build and replaces the preview with the built idle; Reset returns to the inspected build and clears the pending ops.</p><p id="idle-keys-line" class="hint"></p><div class="physics-work"><div id="idle-track-list" class="physics-table-wrap" role="region" aria-label="Idle tracks the config states" tabindex="0"></div><div id="idle-track-editor" class="physics-row-editor"></div></div><datalist id="idle-bones"></datalist><div class="row idle-add-row"><span class="eyebrow">ADD</span><input data-idle-add-bone type="text" list="idle-bones" autocomplete="off" placeholder="bone" aria-label="Bone for a new track" disabled><select data-idle-add-prop aria-label="Property for a new track" disabled></select><button data-idle-add-single type="button" disabled>Add track</button><select data-idle-add-chain aria-label="Chain for a new chain track" disabled></select><button data-idle-add-chain-button type="button" disabled>Add chain track</button></div><div id="idle-blink" class="idle-blink"></div><div class="row"><label class="idle-preview-toggle"><input data-idle-preview type="checkbox" checked>Preview edits (not recorded)</label></div><div id="idle-precheck" class="idle-precheck" aria-label="Review pre-check"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="idle-pending-list" class="physics-pending-list"></ol><div class="row"><button id="idle-apply" type="button" class="primary" disabled>Apply</button><button id="idle-reset" type="button" disabled>Reset</button></div><output id="idle-apply-status" class="environment-readout"></output><div id="idle-readout" class="physics-readout" aria-label="Check readout before and after Apply"></div><textarea id="idle-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="readings" class="readings-panel" aria-labelledby="readings-heading"><div class="row"><span id="readings-heading" class="eyebrow">READINGS · RIGC RENDER</span></div><p class="hint">Each reading is one <code>rigc render</code> of the drawn animation, on the inspected build and on the latest scratch build. Its frames become ticks under the timeline (<span class="tick-swatch inspected" aria-hidden="true"></span> inspected, <span class="tick-swatch scratch" aria-hidden="true"></span> scratch; click one to seek) and each pair gets its worst-frame line. A reading, not a gate: an unknown slot or bone is refused with rigc's own line.</p><datalist id="readings-slots"></datalist><datalist id="readings-bones"></datalist><div class="row"><label><input id="readings-folds" type="checkbox">Folds</label><label><input id="readings-overlaps" type="checkbox">Overlaps</label></div><div class="row readings-pair"><label><input id="readings-pass-on" type="checkbox">Pass-through</label><input id="readings-pass-cloth" type="text" list="readings-slots" autocomplete="off" placeholder="cloth slot" aria-label="Pass-through cloth slot"><span aria-hidden="true">:</span><input id="readings-pass-leg" type="text" list="readings-slots" autocomplete="off" placeholder="leg slot" aria-label="Pass-through leg slot"></div><div class="row readings-pair"><label><input id="readings-seam-on" type="checkbox">Seam</label><input id="readings-seam-a" type="text" list="readings-slots" autocomplete="off" placeholder="slot" aria-label="Seam first slot"><span aria-hidden="true">:</span><input id="readings-seam-b" type="text" list="readings-slots" autocomplete="off" placeholder="slot" aria-label="Seam second slot"></div><div class="row readings-pair"><label><input id="readings-angle-on" type="checkbox">Angle range</label><input id="readings-angle-bone" type="text" list="readings-bones" autocomplete="off" placeholder="bone" aria-label="Angle range bone"><span aria-hidden="true">vs</span><input id="readings-angle-relative" type="text" list="readings-bones" autocomplete="off" placeholder="relative bone" aria-label="Angle range relative-to bone"></div><div class="row"><button id="readings-run" type="button" disabled>Run readings</button></div><p id="readings-status" class="hint" aria-live="polite">No compiled model drawn.</p><ul id="readings-worst" class="readings-worst" aria-label="Worst frame per reading"></ul></section><section id="requirements" class="readings-panel requirements-panel" aria-labelledby="requirements-heading"><div class="row"><span id="requirements-heading" class="eyebrow">REQUIREMENTS · RIG-PARTS CHECK</span></div><p class="hint">Declare what the scene asks of the motion: a slot (picked on the stage), its mesh or a bone, an animation and a kind, with your own bar. Check writes a <code>spine-parts-requirements/1</code> file into scratch and runs the installed producer's <code>rig-parts check --requirements</code> on the inspected rig or the latest scratch build; each requirement gets one line with its worst frame (click to seek). A refusal is the producer's, by name. Waivers (<code>may_fold</code>), scene targets and the part exception lists are not written here. Submitted feedback carries the file by hash and content.</p><div id="requirements-body"></div></section><section id="retarget" class="readings-panel requirements-panel retarget-panel" aria-labelledby="retarget-heading"><div class="row"><span id="retarget-heading" class="eyebrow">RETARGET · RIG-POSE</span></div><p class="hint">Put a pose sequence the package declares onto this rig: edit the <code>rig-pose-map/1</code> (per chain, the bone that carries each segment), set the clean and retarget flags, and Apply. The installed <code>@rig-c/pose</code> runs <code>rig-pose clean</code> and <code>rig-pose retarget</code> onto the handoff rig, and its <code>rigc</code> compiles the result, all into scratch; the new animation plays here beside the reference (pick the sequence under Reference). Review writes no keys and never writes the package's <code>motion.json</code>. Refusals (<code>POSE_NOT_CLEANED</code>, <code>POSE_BEND_ONE_WAY</code>, <code>RAISED_HEAD</code>, <code>MOTION_NAME_FREE</code>, <code>MAP_*</code>) are the producer's, verbatim. FEET, STANCE, FLIP, SHORT, END-ON, LEG-SCALE and REACH lines seek to their frame.</p><div id="retarget-body"></div></section><section id="report-panel" class="report-panel" aria-labelledby="report-heading"><div class="report-header"><h2 id="report-heading">Report events</h2><span id="report-summary" class="subtle"></span><a id="report-source-link" target="_blank" rel="noopener" hidden>Source report</a></div><p id="report-unavailable" class="hint" hidden>No rig-pose-report/1 events supplied.</p><div id="report-browser"><div class="report-controls"><label for="report-category" class="sr-only">Report event category</label><select id="report-category"><option value="all">All events</option></select><button id="report-previous" type="button" aria-label="Previous event page">Previous</button><output id="report-page"></output><button id="report-next" type="button" aria-label="Next event page">Next</button></div><div id="report-empty-categories" class="report-empty-categories"></div><p id="report-missing" class="hint" hidden></p><ol id="report-events" class="report-event-list" aria-label="Read-only report events"></ol><section id="report-selection" class="report-selection" hidden aria-labelledby="report-selection-heading"><h3 id="report-selection-heading"></h3><p id="report-timing" class="hint"></p><div class="report-controls"><label id="report-time-label" for="report-time" hidden>Select explicit time</label><select id="report-time" hidden></select><button id="report-seek" type="button" disabled>Go to explicit time</button></div><div id="report-target-control" hidden><label for="report-target" class="label space-top">Select a target named in the report</label><select id="report-target"></select></div><p id="report-prefill-note" class="hint"></p><p id="report-provenance" class="hint"></p><details><summary>Source event details</summary><pre id="report-details"></pre></details></section></div></section></section></div></section></main></div><footer class="status-bar"><span id="message" class="status-message" role="status" aria-live="polite">Loading local review package…</span><div class="status-readouts" aria-label="Status"><span id="player-state" class="subtle">No rig supplied</span><span id="layout-state" class="subtle" role="status" aria-label="Effective layout">Loading inputs…</span><span id="renderer-state" class="subtle" aria-label="Last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span><span id="layer-state" class="hint">No rig supplied</span><span id="idle-state" class="hint" role="status">Idle preview unavailable</span><span id="control-state" class="hint" role="status"></span><div class="draft-notice"><span id="draft-state" role="status" aria-live="polite"></span><div id="draft-recovery"></div></div></div><div class="status-source"><details class="view-region"><summary>View region</summary><div class="view-region-body"><span class="hint">Numeric source region · opening this link restores a paused inspection</span><pre id="view-coordinates" class="numeric-view" aria-label="Current numeric inspection region"></pre><p class="hint">Sync cameras links fit-relative zoom and viewport-relative pan across visible panes. Turn it off to inspect each pane independently. Toolbar zoom and Fit use the last active pane. Hold Space while dragging a video to pan without taking over its native controls.</p></div></details><span id="source-hash" class="subtle"></span></div></footer><dialog id="review-dialog" class="review-dialog" aria-labelledby="review-dialog-title"><div class="review-dialog-header"><h2 id="review-dialog-title">Write review</h2><span class="hint">Drafts save locally as you type · nothing is submitted until you press Submit</span><button id="review-dialog-close" type="button">Close</button></div><section id="inspector-feedback" class="review-dialog-body" aria-label="Feedback"><div class="review-summary"><div class="eyebrow">REVIEW</div><label for="review-status" class="label">Review status</label><select id="review-status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="changes-requested">Changes requested</option></select><section class="provenance" aria-label="Reviewed source"><div class="eyebrow">REVIEWED SOURCE</div><div class="provenance-row"><span>Package revision</span><strong id="provenance-version">—</strong></div><div class="provenance-row"><span>rig SHA-256</span><code id="provenance-rig">—</code></div><div class="provenance-row"><span>motion SHA-256</span><code id="provenance-motion">—</code></div><details><summary>Full provenance</summary><pre id="provenance-full"></pre></details><div class="source-files"><div class="eyebrow">SOURCE FILES · READ ONLY</div><div id="source-files"></div></div><p class="hint">Submits server-verified file hashes and tool versions. Your selected review status is preserved.</p></section><div class="divider"></div></div><div id="checkpoint-detail"><div class="eyebrow">SELECTED CHECKPOINT</div><h1 id="detail-title">Checkpoint</h1><p id="detail-note" class="hint"></p><fieldset class="outcome"><legend>Checkpoint outcome</legend><label><input type="radio" name="outcome" value="pending" checked>Pending</label><label><input type="radio" name="outcome" value="pass">Pass</label><label><input type="radio" name="outcome" value="fail">Needs changes</label></fieldset><label for="checkpoint-note" class="label space-top">Review notes</label><textarea id="checkpoint-note" rows="5" placeholder="Describe the part, conditions and what looks wrong."></textarea></div><div class="divider"></div><section id="proposal-panel"><div class="eyebrow">STRUCTURED FEEDBACK</div><div class="label">Frame range · inclusive</div><div class="frame-range"><label>Start<input id="feedback-start" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 18"></label><label>End<input id="feedback-end" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 32"></label></div><p id="feedback-range-hint" class="hint">Checking frame bounds…</p><label for="feedback-target-kind" class="label space-top">Target type</label><select id="feedback-target-kind"><option value="">Select target</option><option value="rig">Entire rig</option><option value="bone">Bone</option><option value="slot">Slot</option></select><label for="feedback-target-name" class="label space-top">Target name</label><input id="feedback-target-name" type="text" list="feedback-target-names" autocomplete="off" placeholder="Actual bone or slot name"><datalist id="feedback-target-names"></datalist><p id="feedback-target-hint" class="hint"></p><label for="feedback-category" class="label space-top">Category</label><select id="feedback-category"><option value="">Select category</option><option value="defect">Defect</option><option value="quality">Quality</option></select><label for="feedback-summary" class="label space-top">One-line summary</label><input id="feedback-summary" type="text" maxlength="1000" placeholder="e.g. The left wrist moves outside the sleeve"><button id="add-proposal" class="outline block">Add feedback</button><p id="proposal-boundary" class="hint">Records the current checkpoint, time, reference, camera and inspection axis. Motion is read only: inspect, mark ranges and export feedback. No keys or source files are edited.</p><p id="feedback-warnings" class="hint" role="status" hidden></p><p id="feedback-applied-check" class="hint error" role="alert" hidden></p><div id="proposal-list"></div></section><details id="strip-panel" class="report-panel"><summary>Export frame strip</summary><p class="hint">Uses the feedback form start and end frames, inclusive. Inspection time and source files remain unchanged.</p><label for="strip-scope" class="label">Export range</label><select id="strip-scope"><option value="interval">Entered start–end frames</option><option value="feedback">All feedback ranges</option></select><label for="strip-reference" class="label space-top">Reference video</label><select id="strip-reference"><option value="">Select reference video</option></select><label for="strip-format" class="label space-top">Format</label><select id="strip-format"><option value="png">PNG strip</option><option value="json">JSON frames and provenance</option></select><p class="hint">PNG requires frameTimes in the selected report. Without it, JSON preserves requested frames with time: null. Batch export requires every feedback item to match the selected animation. Unsubmitted feedback is included; exporting does not submit the review.</p><button id="strip-export" type="button" disabled>Download strip</button><p id="strip-status" class="hint" role="status" aria-live="polite">Loading package…</p></details></section><div class="review-dialog-actions"><span id="review-message" class="review-message" role="status" aria-live="polite"></span><span id="review-draft-state" class="review-draft-state" role="status" aria-live="polite"></span><button id="save" class="primary" disabled>Submit review</button></div></dialog></div>
9
+ <main><div class="viewbar"><fieldset id="layer-controls" class="layer-toggles"><legend class="sr-only">Rig layers</legend><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="I" id="layer-images" type="checkbox" checked disabled><span>Images</span><kbd aria-hidden="true">I</kbd></label><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="M" id="layer-mesh" type="checkbox" disabled><span>Mesh edges</span><kbd aria-hidden="true">M</kbd></label><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="B" id="layer-bones" type="checkbox" disabled><span>Bone points</span><kbd aria-hidden="true">B</kbd></label></fieldset><div class="view-tools"><button id="chest-edit" type="button" class="view-tool" aria-pressed="false" aria-controls="chest-editor-bar" title="Place the two chest regions on the rig: drag a centre, the core edge or the band" hidden>Chest regions</button><button id="mesh-edit" type="button" class="view-tool" aria-pressed="false" aria-controls="mesh-editor-bar" title="Edit the selected slot's actual mesh as a typed delta: preview only, the producer retriangulates" hidden>Edit mesh</button></div><div class="zoom-controls"><label class="camera-sync"><input id="camera-sync" type="checkbox" checked>Sync cameras</label><button id="zoom-out" aria-label="Zoom out">−</button><output id="zoom-value">100%</output><button id="zoom-in" aria-label="Zoom in">+</button><button id="fit">Fit</button></div><details id="view-menu" class="view-menu"><summary aria-label="View layout">View: <span id="view-menu-label">Side by side</span></summary><div class="view-menu-list" role="group" aria-label="View"><button type="button" data-view="compare" aria-pressed="true">Side by side</button><button type="button" data-view="reference" aria-pressed="false">Reference</button><button type="button" data-view="rig" aria-pressed="false">Rig</button></div></details></div><div id="chest-editor-bar" class="chest-editor-bar" role="region" aria-label="Chest region editor" hidden><div class="bust-group" role="group" aria-label="Chest editor mode"><input id="bust-mode" type="hidden" value="circles"><button id="bust-mode-circles" type="button" data-bust-mode="circles" aria-pressed="true" title="Two circles, each centre, core and band set by hand (#99)">Circles</button><button id="bust-mode-landmarks" type="button" data-bust-mode="landmarks" aria-pressed="false" title="Three clicks (right apex, left apex, cleavage); the producer derives both circles (contour.bust)">Landmarks</button></div><div class="bust-group" data-chest-mode="circles"><span class="bust-tag bust-tag-r">● R</span><label>X<input id="bust-r-x" type="number" step="1" inputmode="decimal" aria-label="R region centre x"></label><label>Y<input id="bust-r-y" type="number" step="1" inputmode="decimal" aria-label="R region centre y"></label><label>R<input id="bust-r-r" type="number" min="6" max="120" step="1" inputmode="decimal" aria-label="R region core radius"></label><label>Band<input id="bust-r-band" type="number" min="4" max="120" step="1" inputmode="decimal" aria-label="R region band"></label></div><div class="bust-group" data-chest-mode="circles"><span class="bust-tag bust-tag-l">● L</span><label>X<input id="bust-l-x" type="number" step="1" inputmode="decimal" aria-label="L region centre x"></label><label>Y<input id="bust-l-y" type="number" step="1" inputmode="decimal" aria-label="L region centre y"></label><label>R<input id="bust-l-r" type="number" min="6" max="120" step="1" inputmode="decimal" aria-label="L region core radius"></label><label>Band<input id="bust-l-band" type="number" min="4" max="120" step="1" inputmode="decimal" aria-label="L region band"></label></div><div id="bust-landmarks" class="bust-group" data-chest-mode="landmarks" role="group" aria-label="Chest landmarks (rig px)" hidden><button id="lm-pick-apex_r" type="button" data-landmark="apex_r" aria-pressed="false" title="Click the part to place the apex r; drag its marker to move it">Apex R</button><label>X<input id="lm-apex_r-x" type="number" step="1" inputmode="decimal" aria-label="Apex R x (rig px)"></label><label>Y<input id="lm-apex_r-y" type="number" step="1" inputmode="decimal" aria-label="Apex R y (rig px)"></label><button id="lm-pick-apex_l" type="button" data-landmark="apex_l" aria-pressed="false" title="Click the part to place the apex l; drag its marker to move it">Apex L</button><label>X<input id="lm-apex_l-x" type="number" step="1" inputmode="decimal" aria-label="Apex L x (rig px)"></label><label>Y<input id="lm-apex_l-y" type="number" step="1" inputmode="decimal" aria-label="Apex L y (rig px)"></label><button id="lm-pick-cleavage" type="button" data-landmark="cleavage" aria-pressed="false" title="Click the part to place the cleavage; drag its marker to move it">Cleavage</button><label>X<input id="lm-cleavage-x" type="number" step="1" inputmode="decimal" aria-label="Cleavage x (rig px)"></label><label>Y<input id="lm-cleavage-y" type="number" step="1" inputmode="decimal" aria-label="Cleavage y (rig px)"></label></div><div id="bust-landmark-params" class="bust-group" data-chest-mode="landmarks" role="group" aria-label="Landmark constants" hidden><label title="Core ratio r / (r + band); the producer's default is 0.22">α<input id="lm-alpha" type="range" min="0.05" max="0.95" step="0.01" value="0.22" aria-label="Alpha, core ratio"><output id="lm-alpha-value" for="lm-alpha">0.22</output></label><label title="Cleavage clearance in rig px; the producer's default is 3.8">Clearance<input id="lm-clearance" type="range" min="0" max="40" step="0.1" value="3.8" aria-label="Clearance, rig px"><output id="lm-clearance-value" for="lm-clearance">3.8</output></label><label title="Control bone for bust_r; empty is the producer's default boob_r">Bone R<input id="lm-bone_r" type="text" list="lm-bones" placeholder="boob_r" spellcheck="false" autocomplete="off"></label><label title="Control bone for bust_l; empty is the producer's default boob_l">Bone L<input id="lm-bone_l" type="text" list="lm-bones" placeholder="boob_l" spellcheck="false" autocomplete="off"></label><datalist id="lm-bones"></datalist></div><span id="lm-derived" class="hint" data-chest-mode="landmarks" aria-live="polite" hidden></span><div class="bust-group" data-chest-mode="circles"><button id="bust-mirror" type="button" title="Copy R's y, core radius and band to L">Mirror R → L</button><button id="bust-download" type="button" title="Download chest-regions.json and its binding; evidence, not an approval">Download JSON</button></div><div class="bust-group"><button id="bust-apply" type="button" title="Run the installed producer (rig-parts remesh) on these regions or landmarks into a scratch build and load it here; the package is unchanged">Apply · Rebuild</button><button id="bust-reset" type="button" title="Return the stage to the inspected build" disabled>Reset</button></div><span id="handoff-line" class="hint handoff-line" aria-live="polite" title="Producer handoff: the config, parts and inspected rig an Apply rebuilds from"></span><span id="chest-editor-status" class="hint" aria-live="polite"></span><pre id="chest-editor-log" class="rebuild-log" aria-label="Producer output" hidden></pre><textarea id="rebuild-applied" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></div><div id="mesh-editor-bar" class="chest-editor-bar mesh-editor-bar" role="region" aria-label="Mesh edit" hidden><div class="bust-group" role="group" aria-label="Mesh edit sub-mode"><button id="mesh-mode-modify" type="button" data-mesh-mode="modify" aria-pressed="false" aria-keyshortcuts="V" title="Modify · key V">Modify<kbd aria-hidden="true">V</kbd></button><button id="mesh-mode-create" type="button" data-mesh-mode="create" aria-pressed="false" aria-keyshortcuts="C" title="Create · key C">Create<kbd aria-hidden="true">C</kbd></button><button id="mesh-mode-delete" type="button" data-mesh-mode="delete" aria-pressed="false" aria-keyshortcuts="X" title="Delete · key X">Delete<kbd aria-hidden="true">X</kbd></button><button id="mesh-mode-edges" type="button" data-mesh-mode="edges" aria-pressed="false" aria-keyshortcuts="E" title="Edges · key E">Edges<kbd aria-hidden="true">E</kbd></button></div><div class="bust-group"><button id="mesh-undo" type="button" aria-keyshortcuts="Control+Z Meta+Z" title="Undo the last action · Cmd/Ctrl-Z" disabled>Undo</button><button id="mesh-redo" type="button" aria-keyshortcuts="Control+Shift+Z Meta+Shift+Z" title="Redo · Shift-Cmd/Ctrl-Z" disabled>Redo</button><button id="mesh-reset" type="button" title="Drop the whole delta for this attachment (asks first)" disabled>Reset</button></div><div class="bust-group"><button id="mesh-download" type="button" title="Download mesh-edit.json and its binding; evidence, not an approval">Download JSON</button><button id="mesh-apply" type="button" title="Send the delta to the producer (rig-parts remesh --delta) and load the scratch revision in place; nothing is written into the package">Apply · Rebuild</button></div><textarea id="mesh-edit-draft" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea><span id="mesh-editor-status" class="hint" aria-live="polite"></span></div><div id="stage-layout" class="stages"><section id="reference-pane" class="stage-pane" aria-label="Reference"><span id="reference-label" class="pane-label">Reference</span><div id="reference-viewport" class="viewport"><div id="reference-content" class="reference-content"></div><div id="reference-empty" class="empty">No reference supplied.</div></div></section><section id="rig-pane" class="stage-pane" aria-label="Rig"><span id="rig-label" class="pane-label">Generated rig · read only</span><div id="rig-viewport" class="viewport"><div id="rig-root"></div><canvas id="rig-overlay" class="rig-overlay" aria-label="Actual rig mesh and bone overlay"></canvas><div id="idle-handles" class="idle-handles" hidden aria-label="Chain amplitude handles"></div><div id="rig-fields-stage" class="rig-fields-stage" hidden aria-label="Rig field handles"></div><div id="rig-empty" class="empty">No compiled model supplied.</div></div></section></div><div class="stage-footer"><span id="coordinates">Drag to pan · scroll to zoom · cameras synchronized</span><span>Source files remain unchanged.</span></div><section class="inspector" aria-label="Inspection controls"><div class="inspector-heading sticky-header"><strong>INSPECTOR</strong><span class="hint">Local view controls · source files stay unchanged</span></div><div class="inspector-body"><section id="applied-records" class="applied-records" aria-labelledby="applied-records-heading" hidden><div class="row"><span id="applied-records-heading" class="eyebrow">APPLIED RECORDS</span><button id="applied-undo" type="button" disabled>Undo last Apply (0 deep)</button></div><ol id="applied-list" class="applied-list"></ol><p id="applied-status" class="hint" aria-live="polite"></p></section><section id="inspector-rig" aria-label="Setup" data-inspector-panel="rig"><nav class="block-row" aria-label="Setup blocks"><button type="button" data-block-target="selected-part">Part</button><button type="button" data-block-target="modeling-tools">Modeling</button><button type="button" data-block-target="chest-regions">Chest</button><button type="button" data-block-target="mesh-panel">Mesh</button><button type="button" data-block-target="weights">Weights</button><button type="button" data-block-target="bones-panel">Bones</button><button type="button" data-block-target="rig-fields">Fields</button><button type="button" data-block-target="cuts-panel">Cuts</button><button type="button" data-block-target="cloth-panel">Cloth</button></nav><section id="selected-part" aria-label="Selected part" aria-live="polite"></section><section id="modeling-tools"><div class="eyebrow">MODELING REVIEW</div><button data-proposal="mesh" class="tool">Mesh density <small>Add feedback</small></button><button data-proposal="contour" class="tool">Contour curvature <small>Add feedback</small></button><button data-proposal="chest" class="tool">Chest position <small>Add feedback</small></button><label for="part-set" class="label space-top">Part presets</label><select id="part-set" disabled><option>No sets supplied</option></select><p class="hint">Preview only · does not create motion keys.</p></section><section id="chest-regions" class="chest-regions" aria-labelledby="chest-regions-heading"><div class="eyebrow" id="chest-regions-heading">CHEST REGIONS · DETAIL</div><p class="hint">Crop pixels, x screen-right and y down (<code>family-chest-regions/1</code>). Place the circles with <strong>Chest regions</strong> in the view bar; this block holds names, bones, semi-axes, clip and the contour. Equal semi-axes export as a circle, unequal ones (or a clip) as the generator's 32-sample polygon. Exporting writes nothing into the package and is not an approval.</p><p id="chest-mapping" class="hint"></p><div class="chest-grid"><label>Crop width<input id="chest-crop-w" type="number" step="any" inputmode="decimal"></label><label>Crop height<input id="chest-crop-h" type="number" step="any" inputmode="decimal"></label></div><fieldset class="chest-row"><legend>Region 1</legend><label>Name<input id="chest-0-name" type="text" autocomplete="off" spellcheck="false"></label><label>Bone<select id="chest-0-bone"><option value="">Select bone</option></select></label><label>Centre x<input id="chest-0-cx" type="number" step="any" inputmode="decimal"></label><label>Centre y<input id="chest-0-cy" type="number" step="any" inputmode="decimal"></label><label>Semi-axis x<input id="chest-0-ax" type="number" step="any" inputmode="decimal"></label><label>Semi-axis y<input id="chest-0-ay" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-0-spacing" type="number" step="any" inputmode="decimal"></label><label>Band<input id="chest-0-band" type="number" step="any" inputmode="decimal"></label><label>Medial clip x<input id="chest-0-clip" type="number" step="any" inputmode="decimal" placeholder="none"></label></fieldset><fieldset class="chest-row"><legend>Region 2</legend><label>Name<input id="chest-1-name" type="text" autocomplete="off" spellcheck="false"></label><label>Bone<select id="chest-1-bone"><option value="">Select bone</option></select></label><label>Centre x<input id="chest-1-cx" type="number" step="any" inputmode="decimal"></label><label>Centre y<input id="chest-1-cy" type="number" step="any" inputmode="decimal"></label><label>Semi-axis x<input id="chest-1-ax" type="number" step="any" inputmode="decimal"></label><label>Semi-axis y<input id="chest-1-ay" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-1-spacing" type="number" step="any" inputmode="decimal"></label><label>Band<input id="chest-1-band" type="number" step="any" inputmode="decimal"></label><label>Medial clip x<input id="chest-1-clip" type="number" step="any" inputmode="decimal" placeholder="none"></label></fieldset><fieldset class="chest-row"><legend>Contour</legend><label>Tolerance<input id="chest-tolerance" type="number" step="any" inputmode="decimal"></label><label>Margin<input id="chest-margin" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-spacing" type="number" step="any" inputmode="decimal"></label><label>Max edge<input id="chest-max-edge" type="number" step="any" inputmode="decimal"></label><label>Stray<input id="chest-stray" type="number" step="any" inputmode="decimal"></label><label class="chest-check"><input id="chest-bridges" type="checkbox">Bridges</label></fieldset><details class="chest-more"><summary>Generator and mesh fields</summary><label for="chest-generator" class="label space-top">Public generator</label><input id="chest-generator" type="text" autocomplete="off" spellcheck="false" placeholder="Generator that will read this file"><label for="chest-r" class="label space-top">Mesh r (optional)</label><input id="chest-r" type="number" step="any" inputmode="decimal"><label for="chest-segments" class="label space-top">Mesh segments JSON (optional)</label><textarea id="chest-segments" rows="3" spellcheck="false"></textarea><label for="chest-source-rule" class="label space-top">Source rule (optional)</label><input id="chest-source-rule" type="text" autocomplete="off"></details><p id="chest-status" class="hint" aria-live="polite"></p><button id="chest-export" type="button" class="outline block">Download chest-regions.json</button></section><section id="mesh-panel" class="physics-panel mesh-panel" aria-labelledby="mesh-panel-heading"><div class="row"><span id="mesh-panel-heading" class="eyebrow">MESH · APPLY REMESHES</span></div><p id="mesh-panel-state" class="hint">Select a mesh part in the parts list.</p><p class="hint">Edits the selected part's <code>meshes.&lt;part&gt;</code> entry from the handoff config (the grid and contour modes). Apply sends the whole pending entry to the installed producer's <code>remesh --mesh</code> (one patch op at <code>/meshes/&lt;part&gt;</code>), compiles and checks the revision, and reloads it with the mesh edges on; Reset returns to the inspected build and the config's entry. Slider ranges are review's; the number field takes any value, and the producer judges it.</p><div id="mesh-params" class="physics-controls mesh-params"></div><div class="row mesh-tools"><span class="eyebrow">REGIONS</span><button id="mesh-add-circle" type="button" disabled>Add circle</button><button id="mesh-add-polygon" type="button" aria-pressed="false" disabled>Draw polygon</button><button id="mesh-regions-edit" type="button" aria-pressed="false" disabled>Drag on stage</button></div><div id="mesh-regions" class="mesh-regions"></div><div class="row mesh-tools"><span class="eyebrow">SEGMENTS</span><select id="mesh-segment-bone" aria-label="Bone to add as a segment" disabled></select><button id="mesh-segment-add" type="button" disabled>Add</button></div><ol id="mesh-segments" class="physics-pending-list mesh-segments"></ol><div class="row"><span class="eyebrow">PENDING ENTRY · DIFF AGAINST THE CONFIG</span></div><ol id="mesh-diff" class="physics-pending-list"></ol><ul id="mesh-precheck" class="mesh-precheck" aria-label="Review pre-check in the config loader's names"></ul><div class="row"><button id="mesh-panel-apply" type="button" class="primary" disabled>Apply</button><button id="mesh-panel-reset" type="button" disabled>Reset</button><button id="mesh-folds" type="button" title="rigc render --folds of the drawn animation on the inspected build and on this revision (#108 readings)">Folds reading</button></div><output id="mesh-status" class="environment-readout"></output><div id="mesh-counts" class="physics-readout" aria-label="Vertex and triangle counts before and after"></div><div id="mesh-readout" class="physics-readout" aria-label="TEXTURE_STRETCH before and after Apply"></div><div id="density-strip" class="density-strip" aria-labelledby="density-strip-heading"><div class="row"><span id="density-strip-heading" class="eyebrow">DENSITY STRIP · NOTHING IS APPLIED</span></div><p id="density-strip-field" class="hint">Select a mesh part (grid or contour entry) to build a density strip.</p><p class="hint">Builds N values of the part's density field side by side, each as its own <code>replace /meshes/&lt;part&gt;</code> patch through remesh (into its own scratch folder). Rows are labels only: Load puts a row on the stage as pending, and the Mesh panel's Apply applies it. Closing or replacing the strip removes its scratch.</p><div class="row density-strip-form"><label>N<input id="density-strip-n" type="number" min="2" max="5" step="1" value="5" aria-label="Number of values"></label><label>from<input id="density-strip-from" type="number" step="any" aria-label="From value"></label><label>to<input id="density-strip-to" type="number" step="any" aria-label="To value"></label><button id="density-strip-build" type="button" disabled>Build strip</button><button id="density-strip-back" type="button" disabled title="Return the stage to the applied build it showed before a row was loaded">Back</button><button id="density-strip-close" type="button" disabled>Close strip</button></div><output id="density-strip-status" class="environment-readout"></output><div id="density-strip-table" class="density-strip-table"></div></div><textarea id="mesh-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="weights" class="physics-panel weights-block" aria-labelledby="weights-heading"><div class="row"><span id="weights-heading" class="eyebrow">WEIGHTS · SETUP POSE · WATCH ONLY</span></div><p class="hint">The selected slot's mesh at the setup pose, each vertex coloured by the weight the compiled model document stores for the picked bone (0 → 1), or by its dominant bone under <strong>Max influence</strong>. An unweighted mesh is its slot's bone at 1.0. Hover a vertex for its stored values. Nothing here edits, applies or records a weight.</p><div class="row weights-controls"><button id="weights-show" type="button" aria-pressed="false">Show weights</button><label>Colour by<select id="weights-mode"><option value="bone">Bone weight</option><option value="max">Max influence</option></select></label><label>Bone<select id="weights-bone" disabled></select></label></div><p id="weights-state" class="hint" aria-live="polite"></p><div id="weights-legend" class="weights-legend" aria-label="Weights legend"></div><output id="weights-hover" class="environment-readout"></output></section><section id="bones-panel" class="physics-panel bones-panel" aria-labelledby="bones-panel-heading"><div class="row"><span id="bones-panel-heading" class="eyebrow">BONES · CONFIG ANCHORS · APPLY REBUILDS</span></div><p id="bones-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's bones at their <code>at</code>, chain <code>points</code> and <code>tip</code> (rig px, the setup pose; the stage may be posed by an animation). Drag a handle on the stage or edit the numbers: each move is a config patch op at <code>/bones/&lt;i&gt;/at</code>, <code>/bones/&lt;i&gt;/points/&lt;k&gt;</code> or <code>/bones/&lt;i&gt;/tip</code> naming its bone, moved in whole rig px. <strong>Check</strong> runs <code>propose --from-config</code> on a scratch copy of the patched config (LINT and coverage lines; LINT blocks nothing) and <code>compare</code> against the handoff config. <strong>Apply</strong> rebuilds the whole rig (<code>rig --config</code>, then <code>check</code>) and needs the reproduced baseline; Reset returns to the inspected build and clears the pending ops. Adding, removing or re-parenting bones is out of scope.</p><div class="row bones-controls"><button id="bones-edit" type="button" aria-pressed="false" disabled>Edit bones on stage</button><label for="bones-select">Bone</label><select id="bones-select" disabled></select></div><div id="bones-anchor-editor" class="bones-anchor-editor"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="bones-pending-list" class="physics-pending-list"></ol><div class="row"><button id="bones-check" type="button" disabled>Check</button><button id="bones-apply" type="button" class="primary" disabled>Apply</button><button id="bones-reset-build" type="button" disabled>Reset</button></div><output id="bones-status" class="environment-readout"></output><div id="bones-compare" class="bones-lines" aria-label="compare: origin and tip distance per moved bone"></div><div id="bones-lint" class="bones-lines" aria-label="propose --from-config: LINT and coverage lines"></div><textarea id="bones-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="rig-fields" class="physics-panel rig-fields" aria-labelledby="rig-fields-heading"><div class="row"><span id="rig-fields-heading" class="eyebrow">RIG FIELDS · CONFIG · APPLY REBUILDS</span><span id="rig-fields-dial-label" class="idle-preview-label" hidden>preview — Apply decides</span></div><p id="rig-fields-state" class="hint">Reading the producer handoff…</p><p class="hint">Small producer config fields, each a config patch op: the bone a rigid part rides (<code>/regions/&lt;part&gt;</code>), a lash's blink still row (<code>/motion/blink/still/&lt;part&gt;</code>, dragged on the stage), a stop's inward / outward bounds (<code>/stops/&lt;i&gt;</code>, arcs dragged at the pivot), a bone's inherit mode (<code>/bones/&lt;i&gt;/inherit</code>; a chain's <code>link_inherit</code>) and <code>turn</code>'s pivot and per-part depth (<code>/turn/pivot</code>, <code>/turn/depths/&lt;part&gt;</code>). <strong>Apply</strong> rebuilds the whole rig (<code>rig --config</code>, then <code>check</code>) and needs the reproduced baseline; the producer's refusal is shown as printed. The dial only previews the drawn build's <code>turn</code> bone and is never recorded. Reset returns to the inspected build and clears the pending ops.</p><div id="rig-fields-region" class="rig-fields-group"></div><div id="rig-fields-still" class="rig-fields-group"></div><div id="rig-fields-stops" class="rig-fields-group"></div><div id="rig-fields-inherit" class="rig-fields-group"></div><div id="rig-fields-turn" class="rig-fields-group"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="rig-fields-pending-list" class="physics-pending-list"></ol><div class="row"><button id="rig-fields-apply" type="button" class="primary" disabled>Apply</button><button id="rig-fields-reset" type="button" disabled>Reset</button><button id="rig-fields-reading" type="button" disabled>Angle range</button></div><output id="rig-fields-status" class="environment-readout"></output><div id="rig-fields-readout" class="physics-readout" aria-label="BLINK_NO_HOLE and FEET_STAY_LEVEL before and after"></div><textarea id="rig-fields-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="cuts-panel" class="physics-panel bones-panel cuts-panel" aria-labelledby="cuts-panel-heading"><div class="row"><span id="cuts-panel-heading" class="eyebrow">CUTS · PATCHES · LIMBS · APPLY RUNS RIG-PARTS BUILD</span></div><p id="cuts-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's <code>assemble.cuts</code> polygons, <code>assemble.patches</code> boxes and each limb joint with its <code>range</code> arc, on the stage in rig px of the setup pose. Drag a vertex, a box corner or a joint, or edit the numbers: each change is a config patch op (<code>/assemble/cuts/&lt;i&gt;/polygon/&lt;k&gt;</code>, <code>/assemble/patches/&lt;i&gt;/box</code>, <code>/limbs/&lt;limb&gt;/segments/&lt;k&gt;/range</code>; a joint is its bone's origin, <code>/bones/…</code>). A new cut or patch adds its entry and a <code>regions</code> bone. <strong>Apply</strong> runs <code>rig-parts build</code> (assemble → rig → check) from the declared painting and See-through runs into scratch and needs the reproduced baseline; the producer's refusals (<code>ASSEMBLE_CUT_*</code>, <code>ASSEMBLE_UNDER_*</code>, <code>CONFIG_LIMB_*</code>, <code>CONFIG_PART_UNIQUE</code>) are shown as printed, and the recomposite error map, the <code>seam pair</code> lines and the build's wall clock come back with the build. Review paints no <code>under</code> or <code>cap</code> image. Reset returns to the inspected build and clears the pending ops.</p><div class="row bones-controls"><button id="cuts-edit" type="button" aria-pressed="false" disabled>Edit cuts on stage</button><label for="cuts-select">Entry</label><select id="cuts-select" disabled></select></div><div id="cuts-editor" class="bones-anchor-editor"></div><div id="cuts-add" class="row cuts-add"><span class="eyebrow">ADD</span><select data-cuts-add="from" aria-label="Plan part the new cut takes pixels from"></select><input data-cuts-add="name" type="text" autocomplete="off" placeholder="new part" aria-label="New part name"><input data-cuts-add="bone" type="text" list="lm-bones" autocomplete="off" placeholder="bone" aria-label="Bone the new part's region rides"><select data-cuts-add="draw" aria-label="Where the new part is drawn"></select><input data-cuts-add="overlap" type="number" min="0" step="1" value="0" inputmode="numeric" aria-label="Cut overlap band in rig px"><select data-cuts-add="alpha" aria-label="New patch alpha"><option value="silhouette">silhouette</option><option value="box">box</option></select><button type="button" data-cuts-add="place-cut">Place cut</button><button type="button" data-cuts-add="place-patch">Place patch</button><button type="button" data-cuts-add="finish">Finish</button><button type="button" data-cuts-add="cancel">Cancel</button></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="cuts-pending-list" class="physics-pending-list"></ol><div class="row"><button id="cuts-apply" type="button" class="primary" disabled>Apply</button><button id="cuts-reset" type="button" disabled>Reset</button></div><output id="cuts-status" class="environment-readout"></output><div id="cuts-readout" class="physics-readout" aria-label="The build's refusals, cut lines, recomposite, seam pairs and wall clock"></div><textarea id="cuts-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="cloth-panel" class="physics-panel bones-panel cloth-panel" aria-labelledby="cloth-panel-heading"><div class="row"><span id="cloth-panel-heading" class="eyebrow">CLOTH · RECIPE PER TAG · APPLY RUNS RIG-PARTS PROPOSE --CLOTH</span></div><p id="cloth-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's <code>cloth</code> block: one recipe per tag the installed reader accepts, with its fields as written. Each change is a config patch op at <code>/cloth/&lt;tag&gt;/…</code>. <strong>Show links</strong> draws the chains the tag's parts are meshed on, from the config's <code>points</code> and <code>tip</code> in rig px of the setup pose, and after an Apply the chains of the config the producer wrote. <strong>Apply</strong> runs <code>rig-parts propose --cloth --write-config</code> on the declared parts, painting and keypoints into scratch, then builds the written config (<code>rig-parts build</code> when the config declares <code>limbs</code>, else <code>rig --config</code>) and reloads it under the Environment's wind and gravity; the producer's refusals (<code>CONFIG_CLOTH_*</code>, <code>PROPOSE_CLOTH_DRAFTED</code>, <code>PROPOSE_CLOTH_PART</code>, <code>PROPOSE_WRITE_CONFIG_*</code>) are shown as printed. Review edits the recipe, never the drafted constraints. Reset returns to the inspected build and clears the pending ops.</p><div class="row bones-controls"><button id="cloth-links" type="button" aria-pressed="false" disabled>Show links on stage</button><label for="cloth-tag">Tag</label><select id="cloth-tag" disabled></select><label class="cloth-declared"><input id="cloth-declared" type="checkbox" disabled> recipe declared</label></div><div id="cloth-fields" class="cloth-fields"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="cloth-pending-list" class="physics-pending-list"></ol><div class="row"><button id="cloth-apply" type="button" class="primary" disabled>Apply</button><button id="cloth-reset" type="button" disabled>Reset</button></div><output id="cloth-status" class="environment-readout"></output><div id="cloth-readout" class="physics-readout" aria-label="Propose's refusals, the written config's diff, the route and check"></div><textarea id="cloth-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section></section><section id="inspector-playback" aria-label="Animate" data-inspector-panel="playback" hidden><nav class="block-row" aria-label="Animate blocks"><button type="button" data-block-target="transport" data-block-includes="frame-controls motion-tools angle-panel">Transport</button><button type="button" data-block-target="sound-panel">Sound</button><button type="button" data-block-target="environment">Environment</button><button type="button" data-block-target="physics-panel">Physics</button><button type="button" data-block-target="idle-tracks">Idle tracks</button><button type="button" data-block-target="readings">Readings</button><button type="button" data-block-target="requirements">Requirements</button><button type="button" data-block-target="retarget">Retarget</button><button type="button" data-block-target="report-panel">Report</button></nav><section id="transport" class="transport"><button id="play" aria-label="Play" disabled>▶</button><label for="animation-select" class="sr-only">Animation</label><select id="animation-select" disabled><option>No animations supplied</option></select><input id="scrub" type="range" min="0" max="1" step="0.001" value="0" aria-label="Playback time"><output id="time-label">0.0000 / 0.0000 s</output><output id="frame-label">rate unknown</output><label class="idle-repeat"><input id="idle-repeat" type="checkbox" checked>Repeat idle</label><div id="reading-ticks" class="reading-ticks" hidden aria-label="Reading ticks"></div><div id="idle-blink-strip" class="idle-blink-strip" hidden><button id="idle-blink-marker" type="button" class="idle-blink-marker" aria-label="Blink time">◆</button></div></section><section id="frame-controls" class="frame-controls" hidden aria-label="Frame navigation using the explicit clock"><label for="frame-number">Frame</label><input id="frame-number" type="number" min="0" step="1" inputmode="numeric" aria-label="Frame number to inspect"><button id="frame-jump" type="button">Go</button><input id="frame-scrub" type="range" min="0" max="1" step="1" value="0" aria-label="Scrub by frame"><span id="frame-clock-label" class="hint"></span><p id="frame-outside" class="hint" hidden>The current time is outside the explicit frame range. Enter a frame number to go to its exact time.</p></section><section id="motion-tools"><section id="animation-inspection" aria-label="Read-only animation inspection"></section><div class="eyebrow">MOTION · EDITS BUILD THROUGH THE PRODUCER</div><button data-proposal="pose" class="tool">Inspect pose <small>Add feedback</small></button><button data-proposal="timing" class="tool">Inspect interval <small>Add feedback</small></button><button data-proposal="audio" class="tool">Audio links <small>Add feedback</small></button><button data-proposal="angle" class="tool">Angle inspection <small id="axis-state">No inspection axes supplied</small></button></section><section id="angle-panel" class="angle-panel" hidden><label for="axis-select">Rig inspection axis</label><select id="axis-select"></select><label id="axis-label" for="axis-value"></label><input id="axis-value" type="range" disabled><output id="axis-value-label"></output><p class="hint">Maps one inspection axis to its animation time. This does not rotate the camera.</p></section><section id="sound-panel" class="sound-panel"><div class="row"><span class="eyebrow">SOUND CUES</span><label><input id="sync-audio" type="checkbox">Play cue audio in sync</label></div><div id="cues"></div><div class="row"><label for="audio-volume">Audio volume</label><input id="audio-volume" type="range" min="0" max="1" step="0.05" value="0.8"><output id="audio-volume-label">80%</output></div><div id="event-log" class="hint" aria-live="polite">Runtime events: none</div></section><section id="environment" class="environment-panel" aria-labelledby="environment-heading"><div class="row"><span id="environment-heading" class="eyebrow">ENVIRONMENT · INSPECTION ONLY</span></div><p class="hint">Wind, gravity and impulses go to the player while you inspect. Nothing is written to the model or motion; feedback records the state as context.</p><input id="environment-applied" type="checkbox" hidden aria-hidden="true" tabindex="-1"><div class="row"><label for="wind-base">Wind</label><input id="wind-base" type="range" min="-100" max="100" step="1" value="0" disabled><output id="wind-base-label">0</output></div><div class="row"><label><input id="wind-breeze" type="checkbox" disabled>Breeze</label><button id="wind-gust" type="button" disabled>Gust</button><label><input id="wind-repeat" type="checkbox" disabled>Repeat</label></div><div class="row"><label for="wind-gust-peak">Gust peak</label><input id="wind-gust-peak" type="number" min="0" max="200" step="1" value="48" inputmode="decimal" disabled><label for="wind-gust-duration">Duration (s)</label><input id="wind-gust-duration" type="number" min="0.1" max="20" step="0.1" value="2.4" inputmode="decimal" disabled></div><output id="wind-readout" class="environment-readout">No compiled model drawn · environment unavailable</output><div class="row"><label for="gravity">Gravity</label><input id="gravity" type="range" min="0" max="4" step="0.05" value="1" disabled><output id="gravity-label">1.00</output></div><div class="row"><button id="jolt" type="button" disabled>Jolt</button><label title="Dragging the rig pane moves the rig's physics instead of the camera. Hold Space to pan."><input id="drag-trail" type="checkbox" disabled>Drag to trail</label><button id="physics-reset" type="button" disabled>Reset physics</button></div><p class="hint">Drag to trail: dragging the rig pane tells the physics the rig moved; the camera stays. Hold Space to pan. Reset returns the runtime start values and re-poses from the current time, clearing the physics history.</p><div class="row"><label title="Readings and Apply's check read this wind and gravity as a rigc-environment/1 schedule; the wind clock restarts with the clip so you watch the same world"><input id="environment-use" type="checkbox" disabled>Use for readings</label><button id="environment-export" type="button" disabled>Export schedule</button><button id="environment-check" type="button" disabled>Check under this world</button></div><output id="environment-schedule" class="environment-readout">No schedule · the controls are not applied (the player stands at the runtime start values)</output><div id="environment-check-lines" class="environment-check-lines" aria-live="polite"></div><p class="hint">The schedule is the controls as a <code>rigc-environment/1</code> file from the clip's first frame: one step when the wind is steady, else one step per frame at 12 fps (between frames rigc holds the frame's value). A one-off Gust is not in it; Repeat puts gusts in it. Check under this world runs <code>rig-parts check --environment</code> on the build on the stage; the default-world line is that build's own check.</p></section><section id="physics-panel" class="physics-panel" aria-labelledby="physics-panel-heading"><div class="row"><span id="physics-panel-heading" class="eyebrow">PHYSICS TUNING · APPLY REBUILDS</span></div><p id="physics-panel-state" class="hint">No compiled model drawn.</p><p class="hint">Each edit is a config patch op at <code>/constraints/&lt;i&gt;/&lt;field&gt;</code> naming its constraint. Apply runs the installed producer (<code>rig --config</code>, then <code>check</code>) into a scratch build and reloads it under the same wind, gust and jolt state; Reset returns to the inspected build and clears the pending ops. Live preview changes strength, damping, inertia, wind, gravity and mix on the next evaluation; mass and component toggles require Apply. Clear resumes authored values without resetting integrated history.</p><div class="row"><label><input data-physics-preview type="checkbox" checked>Preview pending physics</label><button id="physics-preview-clear" type="button">Clear preview</button></div><output id="physics-preview-label" class="environment-readout">Preview off · Apply decides.</output><div class="row physics-chain-row"><label for="physics-chain">Chain</label><select id="physics-chain" disabled></select><button id="physics-chain-pick" type="button" aria-pressed="false" disabled>Pick on stage</button></div><div class="row physics-ramp-row"><label for="physics-ramp-field">Ramp</label><select id="physics-ramp-field" disabled></select><label for="physics-ramp-root">root</label><input id="physics-ramp-root" type="number" step="any" inputmode="decimal" disabled><label for="physics-ramp-tip">tip</label><input id="physics-ramp-tip" type="number" step="any" inputmode="decimal" disabled><button id="physics-ramp" type="button" disabled>Ramp</button></div><div class="physics-work"><div id="physics-rows" class="physics-table-wrap" role="region" aria-label="Physics constraints the config declares" tabindex="0"></div><div id="physics-row-editor" class="physics-row-editor"></div></div><div id="physics-locked" class="physics-table-wrap physics-locked" role="region" aria-label="Constraints that are not editable" tabindex="0" hidden></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="physics-pending-list" class="physics-pending-list"></ol><div class="row"><button id="physics-apply" type="button" class="primary" disabled>Apply</button><button id="physics-reset-build" type="button" disabled>Reset</button></div><output id="physics-apply-status" class="environment-readout"></output><div id="physics-readout" class="physics-readout" aria-label="Check readout before and after Apply"></div><textarea id="physics-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="idle-tracks" class="physics-panel idle-panel" aria-labelledby="idle-tracks-heading"><div class="row"><span id="idle-tracks-heading" class="eyebrow">IDLE TRACKS · APPLY REBUILDS</span><span id="idle-preview-label" class="idle-preview-label" aria-live="polite" hidden>preview — Apply decides</span></div><p id="idle-tracks-state" class="hint">No compiled model drawn.</p><p class="hint">Each edit is a config patch op at <code>/motion/tracks/&lt;i&gt;/…</code> or <code>/motion/blink/…</code>. Period is offered only among the divisors of <code>motion.duration</code> (the producer refuses any other, <code>CONFIG_PERIOD_DIVIDES_DURATION</code>). While edits are pending the preview drives the edited bones through the player's <code>adjust</code> with the producer's sine, <code>v(t) = base + amp·sin(2π(t/period − phase))</code>, on the bone the idle keys (<code>&lt;bone&gt;_ctl</code> under ctl keys); it is never recorded or submitted. Apply runs the installed producer (<code>rig --config</code>, then <code>check</code>) into a scratch build and replaces the preview with the built idle; Reset returns to the inspected build and clears the pending ops.</p><p id="idle-keys-line" class="hint"></p><div class="physics-work"><div id="idle-track-list" class="physics-table-wrap" role="region" aria-label="Idle tracks the config states" tabindex="0"></div><div id="idle-track-editor" class="physics-row-editor"></div></div><datalist id="idle-bones"></datalist><div class="row idle-add-row"><span class="eyebrow">ADD</span><input data-idle-add-bone type="text" list="idle-bones" autocomplete="off" placeholder="bone" aria-label="Bone for a new track" disabled><select data-idle-add-prop aria-label="Property for a new track" disabled></select><button data-idle-add-single type="button" disabled>Add track</button><select data-idle-add-chain aria-label="Chain for a new chain track" disabled></select><button data-idle-add-chain-button type="button" disabled>Add chain track</button></div><div id="idle-blink" class="idle-blink"></div><div class="row"><label class="idle-preview-toggle"><input data-idle-preview type="checkbox" checked>Preview edits (not recorded)</label></div><div id="idle-precheck" class="idle-precheck" aria-label="Review pre-check"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="idle-pending-list" class="physics-pending-list"></ol><div class="row"><button id="idle-apply" type="button" class="primary" disabled>Apply</button><button id="idle-reset" type="button" disabled>Reset</button></div><output id="idle-apply-status" class="environment-readout"></output><div id="idle-readout" class="physics-readout" aria-label="Check readout before and after Apply"></div><textarea id="idle-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="readings" class="readings-panel" aria-labelledby="readings-heading"><div class="row"><span id="readings-heading" class="eyebrow">READINGS · RIGC RENDER</span></div><p class="hint">Each reading is one <code>rigc render</code> of the drawn animation, on the inspected build and on the latest scratch build. Its frames become ticks under the timeline (<span class="tick-swatch inspected" aria-hidden="true"></span> inspected, <span class="tick-swatch scratch" aria-hidden="true"></span> scratch; click one to seek) and each pair gets its worst-frame line. A reading, not a gate: an unknown slot or bone is refused with rigc's own line.</p><datalist id="readings-slots"></datalist><datalist id="readings-bones"></datalist><div class="row"><label><input id="readings-folds" type="checkbox">Folds</label><label><input id="readings-overlaps" type="checkbox">Overlaps</label></div><div class="row readings-pair"><label><input id="readings-pass-on" type="checkbox">Pass-through</label><input id="readings-pass-cloth" type="text" list="readings-slots" autocomplete="off" placeholder="cloth slot" aria-label="Pass-through cloth slot"><span aria-hidden="true">:</span><input id="readings-pass-leg" type="text" list="readings-slots" autocomplete="off" placeholder="leg slot" aria-label="Pass-through leg slot"></div><div class="row readings-pair"><label><input id="readings-seam-on" type="checkbox">Seam</label><input id="readings-seam-a" type="text" list="readings-slots" autocomplete="off" placeholder="slot" aria-label="Seam first slot"><span aria-hidden="true">:</span><input id="readings-seam-b" type="text" list="readings-slots" autocomplete="off" placeholder="slot" aria-label="Seam second slot"></div><div class="row readings-pair"><label><input id="readings-angle-on" type="checkbox">Angle range</label><input id="readings-angle-bone" type="text" list="readings-bones" autocomplete="off" placeholder="bone" aria-label="Angle range bone"><span aria-hidden="true">vs</span><input id="readings-angle-relative" type="text" list="readings-bones" autocomplete="off" placeholder="relative bone" aria-label="Angle range relative-to bone"></div><div class="row"><button id="readings-run" type="button" disabled>Run readings</button></div><p id="readings-status" class="hint" aria-live="polite">No compiled model drawn.</p><ul id="readings-worst" class="readings-worst" aria-label="Worst frame per reading"></ul></section><section id="requirements" class="readings-panel requirements-panel" aria-labelledby="requirements-heading"><div class="row"><span id="requirements-heading" class="eyebrow">REQUIREMENTS · RIG-PARTS CHECK</span></div><p class="hint">Declare what the scene asks of the motion: a slot (picked on the stage), its mesh or a bone, an animation and a kind, with your own bar. Check writes a <code>spine-parts-requirements/1</code> file into scratch and runs the installed producer's <code>rig-parts check --requirements</code> on the inspected rig or the latest scratch build; each requirement gets one line with its worst frame (click to seek). A refusal is the producer's, by name. Waivers (<code>may_fold</code>), scene targets and the part exception lists are not written here. Submitted feedback carries the file by hash and content.</p><div id="requirements-body"></div></section><section id="retarget" class="readings-panel requirements-panel retarget-panel" aria-labelledby="retarget-heading"><div class="row"><span id="retarget-heading" class="eyebrow">RETARGET · RIG-POSE</span></div><p class="hint">Put a pose sequence the package declares onto this rig: edit the <code>rig-pose-map/1</code> (per chain, the bone that carries each segment), set the clean and retarget flags, and Apply. The installed <code>@rig-c/pose</code> runs <code>rig-pose clean</code> and <code>rig-pose retarget</code> onto the handoff rig, and its <code>rigc</code> compiles the result, all into scratch; the new animation plays here beside the reference (pick the sequence under Reference). Review writes no keys and never writes the package's <code>motion.json</code>. Refusals (<code>POSE_NOT_CLEANED</code>, <code>POSE_BEND_ONE_WAY</code>, <code>RAISED_HEAD</code>, <code>MOTION_NAME_FREE</code>, <code>MAP_*</code>) are the producer's, verbatim. FEET, STANCE, FLIP, SHORT, END-ON, LEG-SCALE and REACH lines seek to their frame.</p><div id="retarget-body"></div></section><section id="report-panel" class="report-panel" aria-labelledby="report-heading"><div class="report-header"><h2 id="report-heading">Report events</h2><span id="report-summary" class="subtle"></span><a id="report-source-link" target="_blank" rel="noopener" hidden>Source report</a></div><p id="report-unavailable" class="hint" hidden>No rig-pose-report/1 events supplied.</p><div id="report-browser"><div class="report-controls"><label for="report-category" class="sr-only">Report event category</label><select id="report-category"><option value="all">All events</option></select><button id="report-previous" type="button" aria-label="Previous event page">Previous</button><output id="report-page"></output><button id="report-next" type="button" aria-label="Next event page">Next</button></div><div id="report-empty-categories" class="report-empty-categories"></div><p id="report-missing" class="hint" hidden></p><ol id="report-events" class="report-event-list" aria-label="Read-only report events"></ol><section id="report-selection" class="report-selection" hidden aria-labelledby="report-selection-heading"><h3 id="report-selection-heading"></h3><p id="report-timing" class="hint"></p><div class="report-controls"><label id="report-time-label" for="report-time" hidden>Select explicit time</label><select id="report-time" hidden></select><button id="report-seek" type="button" disabled>Go to explicit time</button></div><div id="report-target-control" hidden><label for="report-target" class="label space-top">Select a target named in the report</label><select id="report-target"></select></div><p id="report-prefill-note" class="hint"></p><p id="report-provenance" class="hint"></p><details><summary>Source event details</summary><pre id="report-details"></pre></details></section></div></section></section></div></section></main></div><footer class="status-bar"><span id="message" class="status-message" role="status" aria-live="polite">Loading local review package…</span><div class="status-readouts" aria-label="Status"><span id="player-state" class="subtle">No rig supplied</span><span id="layout-state" class="subtle" role="status" aria-label="Effective layout">Loading inputs…</span><span id="renderer-state" class="subtle" aria-label="Last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span><span id="layer-state" class="hint">No rig supplied</span><span id="idle-state" class="hint" role="status">Idle preview unavailable</span><span id="control-state" class="hint" role="status"></span><div class="draft-notice"><span id="draft-state" role="status" aria-live="polite"></span><div id="draft-recovery"></div></div></div><div class="status-source"><details class="view-region"><summary>View region</summary><div class="view-region-body"><span class="hint">Numeric source region · opening this link restores a paused inspection</span><pre id="view-coordinates" class="numeric-view" aria-label="Current numeric inspection region"></pre><p class="hint">Sync cameras links fit-relative zoom and viewport-relative pan across visible panes. Turn it off to inspect each pane independently. Toolbar zoom and Fit use the last active pane. Hold Space while dragging a video to pan without taking over its native controls.</p></div></details><span id="source-hash" class="subtle"></span></div></footer><dialog id="review-dialog" class="review-dialog" aria-labelledby="review-dialog-title"><div class="review-dialog-header"><h2 id="review-dialog-title">Write review</h2><span class="hint">Drafts save locally as you type · nothing is submitted until you press Submit</span><button id="review-dialog-close" type="button">Close</button></div><section id="inspector-feedback" class="review-dialog-body" aria-label="Feedback"><div class="review-summary"><div class="eyebrow">REVIEW</div><label for="review-status" class="label">Review status</label><select id="review-status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="changes-requested">Changes requested</option></select><section class="provenance" aria-label="Reviewed source"><div class="eyebrow">REVIEWED SOURCE</div><div class="provenance-row"><span>Package revision</span><strong id="provenance-version">—</strong></div><div class="provenance-row"><span>rig SHA-256</span><code id="provenance-rig">—</code></div><div class="provenance-row"><span>motion SHA-256</span><code id="provenance-motion">—</code></div><details><summary>Full provenance</summary><pre id="provenance-full"></pre></details><div class="source-files"><div class="eyebrow">SOURCE FILES · READ ONLY</div><div id="source-files"></div></div><p class="hint">Submits server-verified file hashes and tool versions. Your selected review status is preserved.</p></section><div class="divider"></div></div><div id="checkpoint-detail"><div class="eyebrow">SELECTED CHECKPOINT</div><h1 id="detail-title">Checkpoint</h1><p id="detail-note" class="hint"></p><fieldset class="outcome"><legend>Checkpoint outcome</legend><label><input type="radio" name="outcome" value="pending" checked>Pending</label><label><input type="radio" name="outcome" value="pass">Pass</label><label><input type="radio" name="outcome" value="fail">Needs changes</label></fieldset><label for="checkpoint-note" class="label space-top">Review notes</label><textarea id="checkpoint-note" rows="5" placeholder="Describe the part, conditions and what looks wrong."></textarea></div><div class="divider"></div><section id="proposal-panel"><div class="eyebrow">STRUCTURED FEEDBACK</div><div class="label">Frame range · inclusive</div><div class="frame-range"><label>Start<input id="feedback-start" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 18"></label><label>End<input id="feedback-end" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 32"></label></div><p id="feedback-range-hint" class="hint">Checking frame bounds…</p><label for="feedback-target-kind" class="label space-top">Target type</label><select id="feedback-target-kind"><option value="">Select target</option><option value="rig">Entire rig</option><option value="bone">Bone</option><option value="slot">Slot</option></select><label for="feedback-target-name" class="label space-top">Target name</label><input id="feedback-target-name" type="text" list="feedback-target-names" autocomplete="off" placeholder="Actual bone or slot name"><datalist id="feedback-target-names"></datalist><p id="feedback-target-hint" class="hint"></p><label for="feedback-category" class="label space-top">Category</label><select id="feedback-category"><option value="">Select category</option><option value="defect">Defect</option><option value="quality">Quality</option></select><label for="feedback-summary" class="label space-top">One-line summary</label><input id="feedback-summary" type="text" maxlength="1000" placeholder="e.g. The left wrist moves outside the sleeve"><button id="add-proposal" class="outline block">Add feedback</button><p id="proposal-boundary" class="hint">Records the current checkpoint, time, reference, camera and inspection axis. Motion is read only: inspect, mark ranges and export feedback. No keys or source files are edited.</p><p id="feedback-warnings" class="hint" role="status" hidden></p><p id="feedback-applied-check" class="hint error" role="alert" hidden></p><div id="proposal-list"></div></section><details id="strip-panel" class="report-panel"><summary>Export frame strip</summary><p class="hint">Uses the feedback form start and end frames, inclusive. Inspection time and source files remain unchanged.</p><label for="strip-scope" class="label">Export range</label><select id="strip-scope"><option value="interval">Entered start–end frames</option><option value="feedback">All feedback ranges</option></select><label for="strip-reference" class="label space-top">Reference video</label><select id="strip-reference"><option value="">Select reference video</option></select><label for="strip-format" class="label space-top">Format</label><select id="strip-format"><option value="png">PNG strip</option><option value="json">JSON frames and provenance</option></select><p class="hint">PNG requires frameTimes in the selected report. Without it, JSON preserves requested frames with time: null. Batch export requires every feedback item to match the selected animation. Unsubmitted feedback is included; exporting does not submit the review.</p><button id="strip-export" type="button" disabled>Download strip</button><p id="strip-status" class="hint" role="status" aria-live="polite">Loading package…</p></details></section><div class="review-dialog-actions"><span id="review-message" class="review-message" role="status" aria-live="polite"></span><span id="review-draft-state" class="review-draft-state" role="status" aria-live="polite"></span><button id="save" class="primary" disabled>Submit review</button></div></dialog></div>
10
10
  <script type="module" src="main.js"></script>
11
11
  </body></html>
@@ -0,0 +1,13 @@
1
+ import type {InspectionTarget} from '../src/animation-inspection.ts';
2
+ /** Page-local bone selection. Existing slot identity, bookmarks and feedback formats stay unchanged. */
3
+ export class InspectionSelection {
4
+ private selected:InspectionTarget=null;private applying=false;
5
+ constructor(private changed:(value:InspectionTarget)=>void){}
6
+ value():InspectionTarget{return this.selected?{...this.selected}:null;}
7
+ select(value:InspectionTarget):void {
8
+ if(this.applying)return;
9
+ if(this.selected?.kind===value?.kind&&this.selected?.name===value?.name)return;
10
+ const previous=this.selected;this.selected=value?{...value}:null;this.applying=true;
11
+ try{this.changed(this.value());}catch(e){this.selected=previous;throw e;}finally{this.applying=false;}
12
+ }
13
+ }
package/web/main.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {PartExplorer,loadPartModel,partBounds} from './part-selection.ts';
2
+ import {AnimationInspectionPanel} from './animation-inspection.ts';
2
3
  import {PartThumbnails,pageRegionTable} from './part-thumbnails.ts';
3
4
  import { loadModel, RigPlayer, type BoneAdjust, type ModelAssets, type PoseBounds } from '@rig-c/play';
4
5
  import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,boundsFitSignature,dragSignature,zoomSignature,type CameraFrame,type CameraSignature} from './camera-view.ts';
@@ -106,6 +107,9 @@ let rigScale=1,referenceScale=1,referenceZoom=1;
106
107
  let displayedCenter:ViewPoint|null=null;
107
108
  let playbackIntent=0,desiredPlay=false,seekingControl=false;
108
109
  let parts:PartExplorer|null=null;
110
+ let animationPanel:AnimationInspectionPanel|null=null;
111
+ let inspectionSourceState:'ready'|'pending'|'failed'='ready';
112
+ function refreshAnimationInspection(){if(inspectionSourceState!=='ready'){animationPanel?.unavailable(`ANIMATION_INSPECTION_SOURCE_${inspectionSourceState.toUpperCase()}: current model source is unavailable`);return;}animationPanel?.update(player?.time??time,duration);animationPanel?.model(partModel,player?.current??'');animationPanel?.update(player?.time??time,duration);}
109
113
  let overlays:RigOverlays|null=null;
110
114
  let chestEditor:ChestEditor|null=null;
111
115
  let meshEditor:MeshEditor|null=null;
@@ -401,6 +405,7 @@ function fitCamera(): void {
401
405
  }
402
406
 
403
407
  function renderTransport(): void {
408
+ refreshAnimationInspection();
404
409
  const end = duration > 0 ? ` / ${duration.toFixed(4)}` : '';
405
410
  text('time-label', sequence ? `Source ${(time + sequence.frames[0]!.t).toFixed(4)} ↔ ${time.toFixed(4)}${end} s` : `${time.toFixed(4)}${end} s`);
406
411
  input('scrub').max = String(Math.max(duration, .001)); input('scrub').value = String(time); input('scrub').disabled = duration <= 0;
@@ -473,8 +478,8 @@ function seekFrame(frame: number): void {
473
478
  }
474
479
 
475
480
  function drawRig(action:()=>void):void {
476
- try{action();parts?.present('single');text('renderer-state',rendererStatus(player?.renderer??null,'succeeded').label);redrawOverlay(true);}
477
- catch(error){overlays?.clear();text('renderer-state',rendererStatus(null,'failed').label);throw error;}
481
+ try{action();parts?.present('single');if(player&&inspectionSourceState==='ready')animationPanel?.capture(player,parts?.frame('single'));else animationPanel?.capture(null,undefined);text('renderer-state',rendererStatus(player?.renderer??null,'succeeded').label);redrawOverlay(true);}
482
+ catch(error){animationPanel?.capture(null,undefined);overlays?.clear();text('renderer-state',rendererStatus(null,'failed').label);throw error;}
478
483
  }
479
484
 
480
485
  function chooseAnimation(name: string, move: ClipMove = { kind: 'choose' }): void {
@@ -484,7 +489,7 @@ function chooseAnimation(name: string, move: ClipMove = { kind: 'choose' }): voi
484
489
  select('animation-select').value = name;
485
490
  // #191: the wind restarts before the clip's first frame is drawn, so the paused stage shows the schedule's t=0 world.
486
491
  time = 0; clipMoved(move);
487
- if (player && runtimeAnimation) { drawRig(()=>{player!.play(name,{loop:false});player!.pause();player!.draw();}); }
492
+ if (player && runtimeAnimation) { drawRig(()=>{player!.play(name,{loop:false});player!.pause();refreshAnimationInspection();player!.draw();}); }
488
493
  else if (player && name){text('renderer-state',rendererStatus(null,'failed').label);message(`Animation "${name}" is not present in the rig.`, true);}
489
494
  renderTransport(); renderCues();renderIdle();
490
495
  }
@@ -646,7 +651,7 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
646
651
  } });
647
652
  player = new RigPlayer(el('rig-root'), assets);
648
653
  // Parts inspection is an add-on: a catalog refusal is shown in the explorer and never aborts the rig.
649
- try{const catalog=await loadPartModel(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]);partModel=catalog.model;weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){parts?.refuseCatalog('single',error);}
654
+ try{const catalog=await loadPartModel(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]);inspectionSourceState='ready';partModel=catalog.model;refreshAnimationInspection();weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){inspectionSourceState='failed';partModel=null;refreshAnimationInspection();parts?.refuseCatalog('single',error);}
650
655
  player.on('event', event => { text('event-log', `Runtime event: ${event.name} · ${event.time.toFixed(2)} s`); });
651
656
  const animations = select('animation-select'); animations.replaceChildren();
652
657
  for (const item of assets.animations) animations.append(option(item.name, data.manifest.animations.find(a => a.id === item.name)?.label ?? item.name));
@@ -952,7 +957,7 @@ async function start(): Promise<void> {
952
957
  // Checkpoints and reference fold away when the package supplies neither.
953
958
  (el('package-context') as HTMLDetailsElement).open = data.manifest.checkpoints.length > 0 || data.manifest.references.length > 0;
954
959
  const sourceFiles = el('source-files'); for (const file of data.manifest.authoring) { const a = document.createElement('a'); a.href = assetUrl(file.path); a.textContent = file.label; a.className = 'source-link'; a.target = '_blank'; a.rel = 'noopener'; sourceFiles.append(a); } if (!data.manifest.authoring.length) sourceFiles.textContent = 'No source files supplied';
955
- populateChestBones(); bind(); parts=new PartExplorer(el('workspace-left'),el('selected-part'),previous=>{weightsView?.refresh();redrawOverlay(true);meshEditor?.selectionChanged();const part=parts?.value();meshPanel?.select(part?.slot??null);requirementsPanel?.selectionChanged();if(part){activeCamera='rig';if(view==='reference')setView('rig');showInspector('rig');renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera(part?'rig':availableCamera()));persistence?.save();},part=>{select('feedback-target-kind').value='slot';renderFeedbackTargets(true);input('feedback-target-name').value=part.slot;showInspector('feedback');}); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackTargets(true); renderFeedbackRange(); renderCheckpoints(); renderProposals(); renderProvenance(); updateSavedState(); renderCues(); initReport();
960
+ populateChestBones(); bind(); parts=new PartExplorer(el('workspace-left'),el('selected-part'),previous=>{if(player&&inspectionSourceState==='ready')animationPanel?.capture(player,parts?.frame('single'));else animationPanel?.capture(null,undefined);weightsView?.refresh();redrawOverlay(true);meshEditor?.selectionChanged();const part=parts?.value();animationPanel?.fromParts(part?.slot??null);meshPanel?.select(part?.slot??null);requirementsPanel?.selectionChanged();if(part){activeCamera='rig';if(view==='reference')setView('rig');if(selectedInspector(document)!=='playback')showInspector('rig');renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera(part?'rig':availableCamera()));persistence?.save();},part=>{select('feedback-target-kind').value='slot';renderFeedbackTargets(true);input('feedback-target-name').value=part.slot;showInspector('feedback');}); animationPanel=new AnimationInspectionPanel({host:el('animation-inspection'),stage:el('rig-viewport'),project:(x,y)=>rigProject()(x,y),enabled:()=>inspectionSourceState==='ready'&&selectedInspector(document)==='playback',seek:exact=>{if(!Number.isFinite(exact)||exact<0||exact>duration){message('KEY_TIME_OUT_OF_DOMAIN: no substitute time was used.',true);return;}seekMarker(animation,exact,'Stored key');},select:value=>{const slot=value?.kind==='slot'?value.name:null;if((parts?.value()?.slot??null)!==slot)parts?.select(slot?{side:'single',slot,mode:parts?.value()?.mode??'context'}:null);parts?.highlightBone(value?.kind==='bone'?value.name:null);redrawOverlay(false);}});viewDisposers.push(()=>animationPanel?.dispose()); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackTargets(true); renderFeedbackRange(); renderCheckpoints(); renderProposals(); renderProvenance(); updateSavedState(); renderCues(); initReport();
956
961
  const requestedId = new URLSearchParams(location.search).get('checkpoint'),initial=initialReviewSelection(data.manifest,requestedId,data.explicitReference?.id,data.initialAnimation??undefined),first=initial.checkpoint;
957
962
  rigInput=data.manifest.model?{state:'pending'}:{state:'absent'};renderLayout();
958
963
  if(initial.referenceId){select('reference-select').value=initial.referenceId;await showReference(initial.referenceId);}
@@ -987,6 +992,7 @@ function bindReviewDialog():void {
987
992
  /** Rig world → rig-pane pixels for the current camera. */
988
993
  function rigProject(){const root=el('rig-root');return (x:number,y:number)=>({x:parseFloat(root.style.left||'0')+x*rigScale,y:parseFloat(root.style.top||'0')-y*rigScale});}
989
994
  function redrawOverlay(fresh:boolean):void {
995
+ animationPanel?.redraw(layers.bones);
990
996
  if(!overlays||!player)return;
991
997
  const project=rigProject();
992
998
  const regions=[...chestRegions(),...(meshPanel?.overlayRegions()??[]),...(cutsPanel?.overlayRegions()??[]),...(clothPanel?.overlayRegions()??[])],circles=[...chestCircles(),...(meshPanel?.overlayCircles()??[])],markers=[...chestMarkers(),...(cutsPanel?.overlayMarkers()??[])];
@@ -1923,13 +1929,14 @@ async function resetRebuild(world:StageWorld|null=null):Promise<void> {
1923
1929
  let stageSource:{url:string;prefix:string;hash:string}|null=null;
1924
1930
  async function swapRig(modelUrl:string,prefix:string,sha256:string,world:StageWorld|null=null):Promise<void> {
1925
1931
  const keepAnimation=animation,keepTime=player?.time??time,wasPlaying=playing,keepSet=select('part-set').disabled?'':select('part-set').value;
1926
- const next=await loadModel({modelUrl,setsUrl:null,resolvePage:(name,url)=>{const target=new URL(name,url);if(target.origin!==location.origin||!target.pathname.startsWith(prefix))throw new Error(`Model page escapes its build: ${name}`);return target.href;}});
1932
+ inspectionSourceState='pending';partModel=null;pause();refreshAnimationInspection();
1933
+ const next=await loadModel({modelUrl,setsUrl:null,resolvePage:(name,url)=>{const target=new URL(name,url);if(target.origin!==location.origin||!target.pathname.startsWith(prefix))throw new Error(`Model page escapes its build: ${name}`);return target.href;}}).catch(error=>{inspectionSourceState='failed';refreshAnimationInspection();throw error;});
1927
1934
  physicsPanel?.clearPreview();
1928
1935
  pause();player?.dispose();assets?.dispose();assets=next;player=new RigPlayer(el('rig-root'),assets);stageModelHash=sha256;stageSource={url:modelUrl,prefix,hash:sha256};
1929
1936
  // A scratch build may hold another set of animations (a retarget adds one, #114): the picker lists what this build has.
1930
1937
  {const animations=select('animation-select');animations.replaceChildren(...assets.animations.map(item=>option(item.name,data.manifest.animations.find(a=>a.id===item.name)?.label??item.name)));animations.disabled=assets.animations.length===0;}
1931
1938
  player.on('event',event=>{text('event-log',`Runtime event: ${event.name} · ${event.time.toFixed(2)} s`);});
1932
- try{const catalog=await loadPartModel(modelUrl,sha256,data.feedbackCatalog.slots??[]);partModel=catalog.model;weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){parts?.refuseCatalog('single',error);}
1939
+ try{const catalog=await loadPartModel(modelUrl,sha256,data.feedbackCatalog.slots??[]);inspectionSourceState='ready';partModel=catalog.model;refreshAnimationInspection();weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){inspectionSourceState='failed';partModel=null;refreshAnimationInspection();parts?.refuseCatalog('single',error);}
1933
1940
  if(keepSet){try{player.setSet(keepSet);}catch{select('part-set').value='';player.setSet(null);}}
1934
1941
  // The same world before the first pose: a physics Apply resumes the wind (clock and pulse), the pending jolts, gravity and
1935
1942
  // whether the controls are engaged as they stood when Apply was pressed; then the seek re-poses under that world.
@@ -97,6 +97,9 @@ export class PartInspection {
97
97
  /** Draws a row's thumbnail into its icon element, or leaves the type glyph; returns the tooltip note naming which and why. */
98
98
  export interface PartThumbnailSource {show(icon:HTMLElement,entry:PartEntry,identity:ControlPartIdentity|null,glyph:string):string}
99
99
  export class PartExplorer extends PartInspection {
100
+ private highlightedBone:string|null=null;
101
+ /** Page-local bone relation highlight; it never invents a selected slot or changes held identity. */
102
+ highlightBone(name:string|null):void {this.highlightedBone=name;for(const [row,entry]of this.rowEntries)row.dataset.boneSelected=String(name!==null&&entry.bone===name);}
100
103
  private thumbnails=new Map<ControlPart['side'],PartThumbnailSource>();private rowParts=new Map<HTMLButtonElement,{icon:HTMLElement;label:HTMLElement}>();
101
104
  private rows=new Map<string,HTMLButtonElement>();private rowEntries=new Map<HTMLButtonElement,PartEntry>();private detailKey='';private orderKey='';private selectionKey='';
102
105
  private detailNodes:{title:HTMLHeadingElement;info:HTMLParagraphElement;declarations:HTMLParagraphElement;bounds:HTMLParagraphElement;unavailable:HTMLParagraphElement;feedback:HTMLButtonElement;status:HTMLParagraphElement}|null=null;
@@ -117,7 +120,7 @@ export class PartExplorer extends PartInspection {
117
120
  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)}`;}}
118
121
  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
122
  // 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);}
123
+ 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);this.highlightBone(this.highlightedBone);}
121
124
  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';}
122
125
  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});}
123
126
  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;}
package/web/styles.css CHANGED
@@ -264,3 +264,33 @@ footer.status-bar{flex-wrap:nowrap}
264
264
  .cloth-fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:6px 10px;margin:0 0 10px;padding:9px 10px;border:1px solid var(--border);border-radius:6px}.cloth-field{display:flex;flex-direction:column;gap:2px;min-width:0;color:var(--fg-muted);font-size:11px}.cloth-field input,.cloth-field select{width:100%;min-width:0;padding:4px 6px;font:11px var(--font-mono)}.cloth-field code{font:10px var(--font-mono);color:var(--fg-soft);overflow-wrap:anywhere}.cloth-field.pending span{color:var(--warn)}.cloth-declared{display:inline-flex;align-items:center;gap:4px}#cloth-panel-state.error,#cloth-status.error{color:var(--fail)!important}#cloth-pending-list .error{color:var(--fail)}
265
265
  .applied-records{padding:12px 15px;border-bottom:1px solid var(--border);background:var(--rv-panel);font-size:11px}.applied-records .row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.applied-records .eyebrow{margin:0}.applied-records button{padding:4px 9px;font-size:11px}.applied-list{margin:0 0 6px;padding-left:0;list-style:none;display:flex;flex-direction:column;gap:3px}.applied-list li{font:11px var(--font-mono);color:var(--fg-muted);overflow-wrap:anywhere}.applied-list li.on-stage{color:var(--fg-strong)}#applied-status{margin:0}#applied-status.error{color:var(--fail)}
266
266
  .density-strip{margin-top:12px;padding-top:10px;border-top:1px solid var(--border)}.density-strip-form label{display:inline-flex;align-items:center;gap:4px}.density-strip-form input[type=number]{width:64px}.density-strip-table{overflow-x:auto;max-width:100%}.density-strip-rows{width:100%;border-collapse:collapse;font:11px var(--font-mono);font-variant-numeric:tabular-nums}.density-strip-rows th,.density-strip-rows td{padding:4px 6px;border-top:1px solid var(--border);text-align:left;vertical-align:top}.density-strip-rows th{color:var(--fg-muted);font-weight:500;white-space:nowrap}.density-strip-rows tr.pending td{background:var(--rv-pressed-bg)}.density-strip-rows tr.refused td{color:var(--fg-muted)}.density-strip-value strong{display:block;color:var(--fg-strong)}.density-strip-label{display:block;margin-top:3px;padding:1px 5px;border:1px solid var(--rv-pressed-border);border-radius:4px;color:var(--fg-strong);white-space:nowrap;font-family:var(--font-sans)}.density-strip-bars div{white-space:nowrap}.density-strip-verdict{min-width:140px;max-width:340px;overflow-wrap:anywhere}.density-strip-verdict.pass{color:var(--pass)}.density-strip-actions{white-space:nowrap}.density-strip-actions button{margin:0 4px 4px 0}
267
+ /* Key inspection reads the current compiled skeleton; it has no authoring controls. */
268
+ .animation-view-buttons{display:flex;gap:6px;margin:8px 0}
269
+ .animation-view-buttons button[aria-pressed=true],.animation-tree button[aria-pressed=true]{color:var(--pass);border-color:var(--pass)}
270
+ .animation-lanes{overflow:auto;max-height:280px;padding:4px 12px 8px 0}
271
+ .animation-lanes h4{position:sticky;left:0;margin:6px 0;color:var(--fg-soft)}
272
+ .animation-axis{padding-left:190px;color:var(--fg-muted);font-size:11px}
273
+ .animation-lane{display:grid;grid-template-columns:180px minmax(360px,1fr);gap:10px;min-height:28px;align-items:center;border-bottom:1px solid var(--border)}
274
+ .animation-lane-label{text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}
275
+ .animation-key-lane{position:relative;height:26px;margin:0 6px}
276
+ .animation-key{position:absolute;transform:translateX(-50%);padding:0;border:0;background:transparent;color:var(--link);font-size:12px;height:26px;min-width:6px}
277
+ .animation-key:hover,.animation-key:focus-visible{color:var(--pass);z-index:2}
278
+ .animation-playhead{position:absolute;top:0;bottom:0;width:1px;background:var(--warn);pointer-events:none}
279
+ [data-curve-plot]{display:block;width:100%;min-width:420px;max-height:260px;overflow:visible}
280
+ [data-curve-plot] text{fill:var(--fg-soft);font-size:11px}
281
+ .animation-axes{stroke:var(--border-strong);fill:none}
282
+ .animation-curve{stroke:var(--link);stroke-width:2;fill:none}
283
+ .animation-handles{stroke:var(--fg-muted);stroke-dasharray:3 3;fill:none}
284
+ .animation-handle{fill:var(--fg-soft)}
285
+ .animation-curve-key{fill:var(--link)}
286
+ .animation-runtime-cursor{fill:var(--warn);stroke:var(--fg-strong);stroke-width:1}
287
+ [data-curve-readout]{display:block;overflow-wrap:anywhere;font-family:monospace;font-size:12px}
288
+ .animation-tree{max-height:280px;overflow:auto;list-style:none;padding-left:0}
289
+ .animation-tree ul{list-style:none;padding-left:18px;border-left:1px solid var(--border)}
290
+ .animation-tree button{margin:2px;padding:3px 7px;font-size:12px}
291
+ [data-part-list] button[data-bone-selected=true]{outline:1px solid var(--pass);outline-offset:-2px}
292
+ .animation-stage-picks{position:absolute;inset:0;pointer-events:none;overflow:hidden;z-index:3}
293
+ .animation-bone-pick{position:absolute;width:10px;height:10px;min-width:0;padding:0;transform:translate(-50%,-50%);border-radius:50%;border:1px solid var(--rv-bone-line);background:var(--rv-bone-point);pointer-events:auto}
294
+ .animation-bone-pick[aria-pressed=true]{outline:3px solid var(--rv-bone-selected);outline-offset:2px}
295
+
296
+ #motion-tools>.animation-inspection{flex-basis:100%;min-width:0}