@rig-c/review 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +242 -7
  2. package/docs/DESIGN.md +33 -5
  3. package/docs/REVIEW_PACKAGE.md +115 -0
  4. package/package.json +1 -1
  5. package/src/applied-context.ts +285 -0
  6. package/src/baseline.ts +175 -0
  7. package/src/bone-anchors.ts +123 -0
  8. package/src/bones-check.ts +105 -0
  9. package/src/build-route.ts +109 -0
  10. package/src/chest-landmarks.ts +122 -0
  11. package/src/chest-regions.ts +65 -6
  12. package/src/cli.ts +19 -8
  13. package/src/config-patch.ts +213 -0
  14. package/src/cut-edits.ts +184 -0
  15. package/src/environment.ts +226 -0
  16. package/src/feedback-export.ts +25 -1
  17. package/src/front.ts +11 -0
  18. package/src/handoff.ts +208 -0
  19. package/src/idle-tracks.ts +401 -0
  20. package/src/manifest.ts +15 -6
  21. package/src/mesh-edit.ts +317 -0
  22. package/src/mesh-entry.ts +351 -0
  23. package/src/open.ts +12 -5
  24. package/src/physics-tuning.ts +236 -0
  25. package/src/producer-files.ts +11 -0
  26. package/src/producer.ts +158 -0
  27. package/src/readings-run.ts +129 -0
  28. package/src/readings.ts +235 -0
  29. package/src/rebuild.ts +938 -0
  30. package/src/reference.ts +4 -3
  31. package/src/requirements-run.ts +160 -0
  32. package/src/requirements.ts +230 -0
  33. package/src/retarget.ts +211 -0
  34. package/src/retune-route.ts +52 -0
  35. package/src/rig-fields.ts +295 -0
  36. package/src/server.ts +59 -5
  37. package/src/session.ts +12 -0
  38. package/src/setup-mesh.ts +144 -0
  39. package/src/weights.ts +74 -0
  40. package/web/bones-panel.ts +230 -0
  41. package/web/chest-editor.ts +294 -0
  42. package/web/chest-regions-tool.ts +10 -4
  43. package/web/cuts-panel.ts +266 -0
  44. package/web/idle-panel.ts +280 -0
  45. package/web/index.html +2 -2
  46. package/web/inspector-blocks.ts +77 -0
  47. package/web/main.ts +813 -24
  48. package/web/mesh-editor.ts +303 -0
  49. package/web/mesh-panel.ts +273 -0
  50. package/web/part-selection.ts +13 -5
  51. package/web/part-thumbnails.ts +76 -0
  52. package/web/physics-panel.ts +151 -0
  53. package/web/readings-panel.ts +105 -0
  54. package/web/rebuild-client.ts +216 -0
  55. package/web/requirements-panel.ts +238 -0
  56. package/web/retarget-panel.ts +203 -0
  57. package/web/rig-fields-panel.ts +269 -0
  58. package/web/rig-overlays.ts +171 -14
  59. package/web/setup-pose.ts +30 -0
  60. package/web/styles.css +52 -5
  61. package/web/weights-view.ts +159 -0
  62. package/web/workspace-preferences.ts +17 -12
  63. package/web/workspace-ui.ts +5 -3
@@ -9,20 +9,78 @@ export interface RigOverlaySettings {images:boolean;mesh:boolean;bones:boolean;m
9
9
  export interface OverlayPlayer extends Pick<RigPlayer,'posedFrame'|'bone'> {}
10
10
  export interface OverlayLine {from:OverlayPoint;to:OverlayPoint;slot?:string}
11
11
  export interface OverlayBone {name:string;point:OverlayPoint;xAxis:OverlayPoint;yAxis:OverlayPoint}
12
- export interface RigOverlayGeometry {selection:OverlayLine[];mesh:OverlayLine[];bones:OverlayLine[];joints:OverlayBone[];warnings:string[];captured:{mesh:boolean;bones:boolean}}
12
+ /** `meshTriangles`: per slot, the triangles the mesh layer draws from the captured pose (#106 bar 1 reads it). */
13
+ export interface RigOverlayGeometry {selection:OverlayLine[];mesh:OverlayLine[];meshTriangles:Record<string,number>;bones:OverlayLine[];joints:OverlayBone[];warnings:string[];captured:{mesh:boolean;bones:boolean}}
13
14
  export interface OverlaySnapshot {
14
- available:boolean;needsCapture:boolean;meshEdges:number;boneCount:number;
15
+ available:boolean;needsCapture:boolean;meshEdges:number;meshTriangles:Record<string,number>;boneCount:number;
15
16
  capabilities:{images:boolean;mesh:boolean;bones:boolean};warnings:string[];
16
17
  }
17
18
  /** Designation polygons in rig world coordinates (Setup's Chest regions tool); colours are theme token values. */
18
- export interface OverlayRegion {name:string;points:OverlayPoint[];stroke:string;fill:string}
19
- export interface OverlayView {project:OverlayProject;settings:RigOverlaySettings;dpr?:number;regions?:readonly OverlayRegion[]}
19
+ /**
20
+ * `handles` (#106, the Mesh panel's polygon regions): a square on every vertex, the `active` one filled with `handle`,
21
+ * and the name as a label; `open` draws the outline unclosed (a polygon still being placed).
22
+ */
23
+ export interface OverlayRegion {name:string;points:OverlayPoint[];stroke:string;fill:string;handles?:boolean;active?:number|null;open?:boolean;colors?:{handle:string;ink:string;labelBg:string}}
24
+ /**
25
+ * A circle designation in rig world units (crop px = world px under the crop mapping): core r at full weight, dashed
26
+ * outer circle at r + band. With handles it is the chest editor's drawing (centre, core edge on the right, band on
27
+ * top, a label under the outer circle). Colours are theme token values.
28
+ */
29
+ export interface OverlayCircle {center:OverlayPoint;r:number;band:number;label?:string;/** Label rows below the outer circle, so two labels never overlap. */labelRow?:number;handles:boolean;flash?:boolean;colors:{stroke:string;fill:string;handle:string;ink:string;labelBg:string;flash:string}}
30
+ /**
31
+ * Mesh edit preview in rig world coordinates: the selected slot's original triangles (cut out of the veil that dims the
32
+ * rest of the rig), the edges after the delta by kind, moved-vertex ghosts and the vertices as handles. A box is in
33
+ * canvas px. It is drawn over the original triangulation; nothing here retriangulates. Colours are theme token values.
34
+ */
35
+ export interface OverlayMeshEdit {
36
+ triangles:readonly OverlayPoint[][];
37
+ edges:readonly {from:OverlayPoint;to:OverlayPoint;kind:'hull'|'interior'|'constraint'|'removed';hover?:boolean}[];
38
+ ghosts:readonly {from:OverlayPoint;to:OverlayPoint}[];
39
+ vertices:readonly {at:OverlayPoint;state:'source'|'moved'|'added'|'removed';selected:boolean;hover:boolean;pending:boolean}[];
40
+ box?:{from:OverlayPoint;to:OverlayPoint};
41
+ colors:{veil:string;edge:string;hull:string;constraint:string;removed:string;vertex:string;ink:string;selected:string;added:string;hover:string;box:string;boxFill:string};
42
+ }
43
+ /**
44
+ * A chest landmark (#105) in rig world units: a diamond with its tag, and a guide from the cleavage to each apex (the
45
+ * distance the producer derives a radius from). Colours are theme token values.
46
+ */
47
+ export interface OverlayMarker {at:OverlayPoint;tag:string;active:boolean;colors:{stroke:string;fill:string;ink:string;labelBg:string};guideTo?:OverlayPoint[]}
48
+ /**
49
+ * Setup weights (#107) in rig world coordinates: the selected slot's setup-pose mesh, each triangle filled with the mean
50
+ * of its vertices' ramp colours and each vertex a dot in its own colour, over a veil that dims the rest of the rig.
51
+ * `value` is the weight the colour was taken from (stored, never normalised); `hover` indexes `vertices` and draws its
52
+ * label. Colours are theme token values.
53
+ */
54
+ export interface OverlayWeights {
55
+ triangles:readonly {points:readonly [number,number,number];color:string}[];
56
+ vertices:readonly {at:OverlayPoint;index:number;value:number;color:string;label:string}[];
57
+ hover:number|null;
58
+ colors:{veil:string;edge:string;ink:string;labelBg:string;hover:string};
59
+ }
60
+ /**
61
+ * Setup bones (#109) in rig world coordinates: every config anchor as a handle — an origin a square, a chain point a
62
+ * circle, a tip a diamond — joined by its chain or bone-to-tip line; a pending anchor draws a dashed ghost from the
63
+ * config's value; the hovered or selected anchor is ringed and labelled. Colours are theme token values.
64
+ */
65
+ export interface OverlayAnchors {
66
+ lines:readonly {from:OverlayPoint;to:OverlayPoint}[];
67
+ anchors:readonly {at:OverlayPoint;from:OverlayPoint|null;kind:'at'|'point'|'tip';label:string;selected:boolean;hover:boolean}[];
68
+ colors:{line:string;origin:string;point:string;tip:string;pending:string;selected:string;ink:string;labelBg:string};
69
+ }
70
+ export interface OverlayView {project:OverlayProject;settings:RigOverlaySettings;dpr?:number;regions?:readonly OverlayRegion[];circles?:readonly OverlayCircle[];markers?:readonly OverlayMarker[];meshEdit?:OverlayMeshEdit|null;weights?:OverlayWeights|null;anchors?:OverlayAnchors|null}
71
+ /** Canvas-pixel geometry of a circle designation, the same numbers the editor hit-tests against. */
72
+ export function projectCircle(project:OverlayProject,circle:Pick<OverlayCircle,'center'|'r'|'band'>):{center:OverlayPoint;pxR:number;pxBand:number}|null {
73
+ const {x,y}=circle.center,center=project(x,y),edge=project(x+circle.r,y),top=project(x,y+circle.r+circle.band);
74
+ if(![center,edge,top].every(finite))return null;
75
+ const pxR=Math.max(2,Math.hypot(edge.x-center.x,edge.y-center.y));
76
+ return {center,pxR,pxBand:Math.max(pxR+2,Math.hypot(top.x-center.x,top.y-center.y))};
77
+ }
20
78
  export interface OverlayRefresh extends OverlayView {player:OverlayPlayer|null;bones:readonly BoneOverlaySpec[];frame?:PosedFrame;selectedSlot?:string|null}
21
79
  const point=(x:number,y:number):OverlayPoint=>({x,y});
22
80
  const finite=(p:OverlayPoint)=>Number.isFinite(p.x)&&Number.isFinite(p.y);
23
81
  const message=(error:unknown)=>error instanceof Error?error.message:String(error);
24
82
 
25
- function meshLines(frame:PosedFrame,warnings:string[]):OverlayLine[]{
83
+ function meshLines(frame:PosedFrame,warnings:string[],triangles:Record<string,number>={}):OverlayLine[]{
26
84
  const lines:OverlayLine[]=[];
27
85
  for(const slot of frame.order){
28
86
  if(slot.kind!=='mesh')continue;
@@ -33,6 +91,7 @@ function meshLines(frame:PosedFrame,warnings:string[]):OverlayLine[]{
33
91
  if(ids.some(index=>!Number.isSafeInteger(index)||index<0||index*2+1>=slot.vertices.length)){warnings.push(`Mesh overlay ${slot.slot}: triangle index outside posed vertices`);continue;}
34
92
  const points=ids.map(index=>point(slot.vertices[index*2]!,slot.vertices[index*2+1]!));
35
93
  if(points.some(p=>!finite(p))){warnings.push(`Mesh overlay ${slot.slot}: nonfinite posed vertex`);continue;}
94
+ triangles[slot.slot]=(triangles[slot.slot]??0)+1;
36
95
  for(let edge=0;edge<3;edge++){
37
96
  const a=ids[edge]!,b=ids[(edge+1)%3]!,key=a<b?`${a}:${b}`:`${b}:${a}`;
38
97
  if(edges.has(key))continue;edges.add(key);
@@ -45,10 +104,10 @@ function meshLines(frame:PosedFrame,warnings:string[]):OverlayLine[]{
45
104
 
46
105
  /** Takes copies; later seeks cannot turn an old overlay into a prepared pose. */
47
106
  export function captureRigGeometry(player:OverlayPlayer,specs:readonly BoneOverlaySpec[],layers:Pick<RigOverlaySettings,'mesh'|'bones'>,frame?:PosedFrame,selectedSlot?:string|null):RigOverlayGeometry{
48
- const geometry:RigOverlayGeometry={selection:[],mesh:[],bones:[],joints:[],warnings:[],captured:{mesh:false,bones:false}};
107
+ const geometry:RigOverlayGeometry={selection:[],mesh:[],meshTriangles:{},bones:[],joints:[],warnings:[],captured:{mesh:false,bones:false}};
49
108
  if(selectedSlot){try{const bounds=partBounds(frame??player.posedFrame(null),selectedSlot);if(bounds){const {x,y,width,height}=bounds,points=[point(x,y),point(x+width,y),point(x+width,y+height),point(x,y+height)];geometry.selection=points.map((from,i)=>({from,to:points[(i+1)%4]!,slot:selectedSlot}));}}catch(error){geometry.warnings.push(`Selection bounds unavailable: ${message(error)}`);}}
50
109
  if(layers.mesh){
51
- try{geometry.mesh=meshLines(frame??player.posedFrame(),geometry.warnings);geometry.captured.mesh=true;}
110
+ try{geometry.mesh=meshLines(frame??player.posedFrame(),geometry.warnings,geometry.meshTriangles);geometry.captured.mesh=true;}
52
111
  catch(error){geometry.warnings.push(`Mesh overlay unavailable: ${message(error)}`);}
53
112
  }
54
113
  if(layers.bones){
@@ -93,7 +152,7 @@ export class RigOverlays {
93
152
  this.geometry=input.player?captureRigGeometry(input.player,input.bones,input.settings,input.frame,input.selectedSlot):null;
94
153
  return this.redraw(input);
95
154
  }
96
- redraw({project,settings,dpr=globalThis.devicePixelRatio??1,regions=[]}:OverlayView):OverlaySnapshot{
155
+ redraw({project,settings,dpr=globalThis.devicePixelRatio??1,regions=[],circles=[],markers=[],meshEdit=null,weights=null,anchors=null}:OverlayView):OverlaySnapshot{
97
156
  if(this.disposed)return this.unavailable('Overlay has been disposed');
98
157
  this.host.rigRoot.style.visibility=settings.images?this.visibility:'hidden';
99
158
  const warnings=[...(this.geometry?.warnings??[])],ctx=this.host.canvas.getContext('2d');
@@ -136,14 +195,112 @@ export class RigOverlays {
136
195
  }
137
196
  // Designations are drawn over the drawn rig only; they are values the reviewer typed, not rig geometry.
138
197
  if(this.playerAvailable)for(const region of regions){
139
- const points=region.points.map(projected);if(points.length<3||points.some(p=>!p))continue;
140
- ctx.globalAlpha=1;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(points[0]!.x,points[0]!.y);for(const p of points.slice(1))ctx.lineTo(p!.x,p!.y);ctx.closePath();
141
- ctx.fillStyle=region.fill;ctx.fill();ctx.strokeStyle=region.stroke;ctx.lineWidth=1.6;ctx.stroke();
198
+ const points=region.points.map(projected);if(points.length<(region.open?1:3)||points.some(p=>!p))continue;
199
+ ctx.globalAlpha=1;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(points[0]!.x,points[0]!.y);for(const p of points.slice(1))ctx.lineTo(p!.x,p!.y);
200
+ if(!region.open){ctx.closePath();ctx.fillStyle=region.fill;ctx.fill();}
201
+ ctx.strokeStyle=region.stroke;ctx.lineWidth=1.6;ctx.stroke();
202
+ if(region.handles&&region.colors){
203
+ const c=region.colors;
204
+ points.forEach((p,i)=>{const size=region.active===i?5:3.5;ctx.fillStyle=region.active===i?c.handle:c.ink;ctx.strokeStyle=region.stroke;ctx.lineWidth=1.5;ctx.fillRect(p!.x-size,p!.y-size,size*2,size*2);ctx.strokeRect(p!.x-size,p!.y-size,size*2,size*2);});
205
+ const top=points.reduce((a,p)=>p!.y<a!.y?p:a,points[0]);
206
+ ctx.font='600 11px ui-monospace, monospace';const width=ctx.measureText(region.name).width+8;ctx.fillStyle=c.labelBg;ctx.fillRect(top!.x-width/2,top!.y-24,width,15);ctx.fillStyle=region.stroke;ctx.fillText(region.name,top!.x-width/2+4,top!.y-13);
207
+ }
208
+ }
209
+ if(this.playerAvailable)for(const circle of circles){
210
+ const at=projectCircle(project,circle);if(!at)continue;const {center,pxR,pxBand}=at,c=circle.colors;
211
+ ctx.globalAlpha=.95;
212
+ ctx.beginPath();ctx.setLineDash([5,4]);ctx.strokeStyle=c.stroke;ctx.lineWidth=1.6;ctx.arc(center.x,center.y,pxBand,0,Math.PI*2);ctx.stroke();
213
+ ctx.beginPath();ctx.setLineDash([]);ctx.fillStyle=c.fill;ctx.lineWidth=2.2;ctx.arc(center.x,center.y,pxR,0,Math.PI*2);ctx.fill();ctx.stroke();
214
+ if(!circle.handles)continue;
215
+ ctx.globalAlpha=1;
216
+ ctx.beginPath();ctx.moveTo(center.x-7,center.y);ctx.lineTo(center.x+7,center.y);ctx.moveTo(center.x,center.y-7);ctx.lineTo(center.x,center.y+7);ctx.stroke();
217
+ for(const [x,y,fill,stroke] of [[center.x,center.y,c.handle,c.stroke],[center.x+pxR,center.y,c.stroke,c.ink],[center.x,center.y-pxBand,c.ink,c.stroke]] as const){ctx.beginPath();ctx.fillStyle=fill;ctx.strokeStyle=stroke;ctx.lineWidth=2;ctx.arc(x,y,4.5,0,Math.PI*2);ctx.fill();ctx.stroke();}
218
+ if(circle.label){
219
+ ctx.font='600 11px ui-monospace, monospace';const width=ctx.measureText(circle.label).width+8,lx=center.x-width/2,ly=center.y+pxBand+14+(circle.labelRow??0)*17;
220
+ ctx.fillStyle=circle.flash?c.flash:c.labelBg;ctx.fillRect(lx,ly-11,width,15);ctx.fillStyle=circle.flash?c.ink:c.stroke;ctx.fillText(circle.label,lx+4,ly);
221
+ }
142
222
  }
143
- if(this.geometry?.selection.length){ctx.globalAlpha=1;ctx.strokeStyle='#ffbb47';ctx.lineWidth=2;ctx.setLineDash([5,3]);ctx.beginPath();for(const edge of this.geometry.selection)line(edge);ctx.stroke();ctx.setLineDash([]);}
223
+ if(this.playerAvailable)for(const marker of markers){
224
+ const at=projected(marker.at);if(!at)continue;const c=marker.colors;
225
+ ctx.globalAlpha=.9;ctx.setLineDash([3,3]);ctx.strokeStyle=c.stroke;ctx.lineWidth=1.2;
226
+ for(const to of marker.guideTo??[]){const end=projected(to);if(!end)continue;ctx.beginPath();ctx.moveTo(at.x,at.y);ctx.lineTo(end.x,end.y);ctx.stroke();}
227
+ ctx.setLineDash([]);ctx.globalAlpha=1;const size=marker.active?7:5.5;
228
+ ctx.beginPath();ctx.moveTo(at.x,at.y-size);ctx.lineTo(at.x+size,at.y);ctx.lineTo(at.x,at.y+size);ctx.lineTo(at.x-size,at.y);ctx.closePath();
229
+ ctx.fillStyle=c.fill;ctx.fill();ctx.strokeStyle=c.ink;ctx.lineWidth=marker.active?2.4:1.6;ctx.stroke();
230
+ ctx.font='600 11px ui-monospace, monospace';const width=ctx.measureText(marker.tag).width+8,lx=at.x+size+4,ly=at.y-size-2;
231
+ ctx.fillStyle=c.labelBg;ctx.fillRect(lx,ly-11,width,15);ctx.fillStyle=c.stroke;ctx.fillText(marker.tag,lx+4,ly);
232
+ }
233
+ ctx.globalAlpha=1;
234
+ if(this.playerAvailable&&meshEdit)this.drawMeshEdit(ctx,meshEdit,projected,width,height);
235
+ else if(this.playerAvailable&&weights)this.drawWeights(ctx,weights,projected,width,height);
236
+ if(this.playerAvailable&&anchors)this.drawAnchors(ctx,anchors,projected);
237
+ if(this.geometry?.selection.length&&!meshEdit&&!weights&&!anchors){ctx.globalAlpha=1;ctx.strokeStyle='#ffbb47';ctx.lineWidth=2;ctx.setLineDash([5,3]);ctx.beginPath();for(const edge of this.geometry.selection)line(edge);ctx.stroke();ctx.setLineDash([]);}
144
238
  ctx.restore();
145
239
  return this.snapshot(true,needsCapture,warnings);
146
240
  }
241
+ private drawMeshEdit(ctx:CanvasRenderingContext2D,edit:OverlayMeshEdit,projected:(p:OverlayPoint)=>OverlayPoint|null,width:number,height:number):void{
242
+ const c=edit.colors;ctx.save();ctx.globalAlpha=1;ctx.setLineDash([]);
243
+ // The rest of the rig dims; the edited slot's own triangles are cut out of the veil so its art stays at full opacity.
244
+ ctx.fillStyle=c.veil;ctx.fillRect(0,0,width,height);
245
+ ctx.globalCompositeOperation='destination-out';ctx.fillStyle=c.ink;ctx.beginPath();
246
+ for(const triangle of edit.triangles){const points=triangle.map(projected);if(points.length<3||points.some(p=>!p))continue;ctx.moveTo(points[0]!.x,points[0]!.y);for(const p of points.slice(1))ctx.lineTo(p!.x,p!.y);ctx.closePath();}
247
+ ctx.fill();ctx.globalCompositeOperation='source-over';
248
+ const segment=(from:OverlayPoint,to:OverlayPoint)=>{const a=projected(from),b=projected(to);if(a&&b){ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);}};
249
+ const stroke=(kind:OverlayMeshEdit['edges'][number]['kind'],color:string,width:number,dash:number[]=[])=>{ctx.beginPath();ctx.setLineDash(dash);ctx.strokeStyle=color;ctx.lineWidth=width;for(const e of edit.edges)if(e.kind===kind)segment(e.from,e.to);ctx.stroke();};
250
+ stroke('removed',c.removed,1.4,[4,3]);stroke('interior',c.edge,1);stroke('constraint',c.constraint,2.4);stroke('hull',c.hull,2.2);
251
+ ctx.beginPath();ctx.setLineDash([2,3]);ctx.strokeStyle=c.ink;ctx.lineWidth=1;for(const g of edit.ghosts)segment(g.from,g.to);ctx.stroke();
252
+ ctx.beginPath();ctx.setLineDash([]);ctx.strokeStyle=c.hover;ctx.lineWidth=3.2;for(const e of edit.edges)if(e.hover)segment(e.from,e.to);ctx.stroke();
253
+ for(const v of edit.vertices){
254
+ const p=projected(v.at);if(!p)continue;
255
+ if(v.state==='removed'){ctx.beginPath();ctx.strokeStyle=c.removed;ctx.lineWidth=1.6;ctx.moveTo(p.x-4,p.y-4);ctx.lineTo(p.x+4,p.y+4);ctx.moveTo(p.x+4,p.y-4);ctx.lineTo(p.x-4,p.y+4);ctx.stroke();continue;}
256
+ const r=v.selected?4.6:3.4;ctx.beginPath();ctx.fillStyle=v.selected?c.selected:v.state==='added'?c.added:c.vertex;ctx.strokeStyle=v.state==='moved'?c.selected:c.ink;ctx.lineWidth=v.state==='moved'?2:1.2;ctx.arc(p.x,p.y,r,0,Math.PI*2);ctx.fill();ctx.stroke();
257
+ if(v.hover||v.pending){ctx.beginPath();ctx.strokeStyle=v.pending?c.constraint:c.hover;ctx.lineWidth=2;ctx.arc(p.x,p.y,7.5,0,Math.PI*2);ctx.stroke();}
258
+ }
259
+ if(edit.box){const {from,to}=edit.box,x=Math.min(from.x,to.x),y=Math.min(from.y,to.y),w=Math.abs(to.x-from.x),h=Math.abs(to.y-from.y);ctx.fillStyle=c.boxFill;ctx.fillRect(x,y,w,h);ctx.setLineDash([4,3]);ctx.strokeStyle=c.box;ctx.lineWidth=1;ctx.strokeRect(x+.5,y+.5,w,h);}
260
+ ctx.restore();
261
+ }
262
+ private drawWeights(ctx:CanvasRenderingContext2D,w:OverlayWeights,projected:(p:OverlayPoint)=>OverlayPoint|null,width:number,height:number):void{
263
+ const c=w.colors,points=w.vertices.map(v=>projected(v.at));ctx.save();ctx.globalAlpha=1;ctx.setLineDash([]);
264
+ ctx.fillStyle=c.veil;ctx.fillRect(0,0,width,height);
265
+ for(const t of w.triangles){
266
+ const [a,b,d]=t.points.map(i=>points[i]);if(!a||!b||!d)continue;
267
+ ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.lineTo(d.x,d.y);ctx.closePath();ctx.globalAlpha=.72;ctx.fillStyle=t.color;ctx.fill();ctx.globalAlpha=.5;ctx.strokeStyle=c.edge;ctx.lineWidth=.8;ctx.stroke();
268
+ }
269
+ ctx.globalAlpha=1;
270
+ w.vertices.forEach((v,i)=>{const p=points[i];if(!p)return;ctx.beginPath();ctx.fillStyle=v.color;ctx.strokeStyle=c.ink;ctx.lineWidth=1.1;ctx.arc(p.x,p.y,3.4,0,Math.PI*2);ctx.fill();ctx.stroke();});
271
+ const h=w.hover===null?null:w.vertices[w.hover],hp=w.hover===null?null:points[w.hover];
272
+ if(h&&hp){
273
+ ctx.beginPath();ctx.strokeStyle=c.hover;ctx.lineWidth=2;ctx.arc(hp.x,hp.y,7.5,0,Math.PI*2);ctx.stroke();
274
+ ctx.font='600 11px ui-monospace, monospace';const tw=ctx.measureText(h.label).width+10,lx=Math.min(Math.max(2,hp.x+11),width-tw-2),ly=Math.max(14,hp.y-11);
275
+ ctx.fillStyle=c.labelBg;ctx.fillRect(lx,ly-12,tw,17);ctx.fillStyle=c.hover;ctx.fillText(h.label,lx+5,ly);
276
+ }
277
+ ctx.restore();
278
+ }
279
+ private drawAnchors(ctx:CanvasRenderingContext2D,b:OverlayAnchors,projected:(p:OverlayPoint)=>OverlayPoint|null):void{
280
+ const c=b.colors;ctx.save();ctx.globalAlpha=1;
281
+ ctx.setLineDash([]);ctx.lineWidth=1.6;ctx.strokeStyle=c.line;ctx.beginPath();
282
+ for(const l of b.lines){const from=projected(l.from),to=projected(l.to);if(from&&to){ctx.moveTo(from.x,from.y);ctx.lineTo(to.x,to.y);}}
283
+ ctx.stroke();
284
+ ctx.setLineDash([3,3]);ctx.strokeStyle=c.pending;ctx.lineWidth=1.2;ctx.beginPath();
285
+ for(const a of b.anchors){if(!a.from)continue;const from=projected(a.from),to=projected(a.at);if(from&&to){ctx.moveTo(from.x,from.y);ctx.lineTo(to.x,to.y);}}
286
+ ctx.stroke();ctx.setLineDash([]);
287
+ const labels:{at:OverlayPoint;text:string;color:string}[]=[];
288
+ for(const a of b.anchors){
289
+ const p=projected(a.at);if(!p)continue;
290
+ if(a.from){const g=projected(a.from);if(g){ctx.beginPath();ctx.strokeStyle=c.pending;ctx.lineWidth=1;ctx.arc(g.x,g.y,2.5,0,Math.PI*2);ctx.stroke();}}
291
+ const fill=a.from?c.pending:a.kind==='at'?c.origin:a.kind==='point'?c.point:c.tip,size=a.selected||a.hover?6:4.5;
292
+ ctx.beginPath();
293
+ if(a.kind==='at')ctx.rect(p.x-size,p.y-size,size*2,size*2);
294
+ else if(a.kind==='point')ctx.arc(p.x,p.y,size,0,Math.PI*2);
295
+ else{ctx.moveTo(p.x,p.y-size-1);ctx.lineTo(p.x+size+1,p.y);ctx.lineTo(p.x,p.y+size+1);ctx.lineTo(p.x-size-1,p.y);ctx.closePath();}
296
+ ctx.fillStyle=fill;ctx.fill();ctx.strokeStyle=c.ink;ctx.lineWidth=1.3;ctx.stroke();
297
+ if(a.selected||a.hover){ctx.beginPath();ctx.strokeStyle=c.selected;ctx.lineWidth=2;ctx.arc(p.x,p.y,size+4,0,Math.PI*2);ctx.stroke();}
298
+ if(a.selected||a.hover||a.from)labels.push({at:p,text:a.label,color:a.selected||a.hover?c.selected:c.pending});
299
+ }
300
+ ctx.font='600 11px ui-monospace, monospace';
301
+ for(const l of labels){const w=ctx.measureText(l.text).width+8,lx=l.at.x+10,ly=l.at.y-9;ctx.fillStyle=c.labelBg;ctx.fillRect(lx,ly-11,w,15);ctx.fillStyle=l.color;ctx.fillText(l.text,lx+4,ly);}
302
+ ctx.restore();
303
+ }
147
304
  clear():void{
148
305
  this.geometry=null;this.playerAvailable=false;
149
306
  const ctx=this.host.canvas.getContext('2d');if(ctx){ctx.save();ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,this.host.canvas.width,this.host.canvas.height);ctx.restore();}
@@ -152,7 +309,7 @@ export class RigOverlays {
152
309
  if(this.disposed)return;this.clear();this.host.rigRoot.style.visibility=this.visibility;this.host.canvas.style.pointerEvents=this.pointerEvents;this.disposed=true;
153
310
  }
154
311
  private snapshot(canvasAvailable:boolean,needsCapture:boolean,warnings:string[]):OverlaySnapshot{
155
- return {available:this.playerAvailable&&canvasAvailable,needsCapture,meshEdges:this.geometry?.mesh.length??0,boneCount:this.geometry?.joints.length??0,capabilities:{images:this.playerAvailable,mesh:this.playerAvailable&&canvasAvailable,bones:this.playerAvailable&&canvasAvailable},warnings};
312
+ return {available:this.playerAvailable&&canvasAvailable,needsCapture,meshEdges:this.geometry?.mesh.length??0,meshTriangles:{...(this.geometry?.meshTriangles??{})},boneCount:this.geometry?.joints.length??0,capabilities:{images:this.playerAvailable,mesh:this.playerAvailable&&canvasAvailable,bones:this.playerAvailable&&canvasAvailable},warnings};
156
313
  }
157
- private unavailable(warning:string):OverlaySnapshot{return {available:false,needsCapture:false,meshEdges:0,boneCount:0,capabilities:{images:false,mesh:false,bones:false},warnings:[warning]};}
314
+ private unavailable(warning:string):OverlaySnapshot{return {available:false,needsCapture:false,meshEdges:0,meshTriangles:{},boneCount:0,capabilities:{images:false,mesh:false,bones:false},warnings:[warning]};}
158
315
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The setup pose, read from the published player: a throwaway `RigPlayer` over the loaded assets that never plays, so
3
+ * its `bone()` and `slotIdentity()` answer the setup pose (the published `posedFrame()` is empty before `play`). Shared
4
+ * by Setup · Weights (#107) and Edit Mesh (#100). The stage player is never touched; the throwaway one is disposed
5
+ * before this returns, so every bone the caller may ask for is read here.
6
+ */
7
+ import {RigPlayer,type ModelAssets,type RenderMode} from '@rig-c/play';
8
+ import type {WorldTransform} from '../src/setup-mesh.ts';
9
+
10
+ export interface SetupPose {
11
+ /** The slot's attachment at the setup pose, or null when it shows none there. */
12
+ identity:{skin:string;attachment:string}|null;
13
+ /** A named bone's setup-pose world transform; throws for a bone that was not read. */
14
+ world:(bone:string)=>WorldTransform;
15
+ }
16
+ /** Reads `slot`'s setup-pose identity and every bone in `bones` (unknown names are skipped). Throws what the player throws. */
17
+ export function readSetupPose(assets:ModelAssets,slot:string,bones:readonly string[],root:HTMLElement,mode:RenderMode='dom'):SetupPose {
18
+ const player=new RigPlayer(root,assets,{mode,slots:[slot]});
19
+ try{
20
+ const row=player.slotIdentity().slots.find(r=>r.slot===slot),identity=row&&row.attachment&&row.skin?{skin:row.skin,attachment:row.attachment}:null;
21
+ const map=new Map<string,WorldTransform>();
22
+ for(const name of bones){try{const b=player.bone(name);map.set(name,{x:b.x,y:b.y,a:b.a,b:b.b,c:b.c,d:b.d});}catch{/* not a bone of this rig */}}
23
+ return {identity,world:name=>{const t=map.get(name);if(!t)throw new Error(`no setup-pose transform for bone ${name}`);return t;}};
24
+ }finally{player.dispose();}
25
+ }
26
+ /** The bone names a compiled model document declares, in stored order. */
27
+ export function modelBoneNames(model:unknown):string[] {
28
+ const list=(model as {bones?:unknown}|null)?.bones;
29
+ return Array.isArray(list)?list.map(b=>b&&typeof (b as {name?:unknown}).name==='string'?(b as {name:string}).name:'').filter(Boolean):[];
30
+ }
package/web/styles.css CHANGED
@@ -1,6 +1,6 @@
1
1
  /* Theme tokens: site values copied from rig-c.org styles.css (dark); light and --rv-* are review-local derivations. No literal colour outside these blocks. */
2
- :root{--rv-region-a:#ff9f43;--rv-region-a-fill:rgba(255,159,67,.18);--rv-region-b:#4dd6ff;--rv-region-b-fill:rgba(77,214,255,.18);--font-sans:Inter,system-ui,sans-serif;--font-display:Inter,system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace;--radius:10px;--bg:#070b18;--fg:#c9d4e8;--fg-strong:#f3f6fc;--fg-soft:#aebbd4;--fg-muted:#7f8ba6;--border:#18213a;--border-strong:#2a3657;--hover-bg:#0f1730;--active-bg:#142042;--link:#7cc8ff;--link-hover:#b3e0ff;--btn-fg:#08101f;--code-bg:#050814;--code-fg:#d6e2f5;--code-border:#18213a;--code-inline-bg:#121a30;--footer-bg:#050813;--pass:#30d158;--warn:#ffcc00;--fail:#ff453a;--rv-bar:#0b1124;--rv-panel:#0d152b;--rv-raised:var(--code-inline-bg);--rv-control:var(--code-inline-bg);--rv-control-hover:var(--active-bg);--rv-field:var(--code-bg);--rv-segmented:var(--code-bg);--rv-pressed-bg:#133733;--rv-pressed-border:#207c44;--rv-pressed-fg:var(--fg-strong);--rv-focus:var(--link-hover);--rv-backdrop:rgba(7,11,24,.82);--rv-pane-hi:#142042;--rv-pane-lo:#070b18;--rv-pane-label-bg:rgba(7,11,24,.85);--rv-stage-fg:#aebbd4;--rv-grid-dot:rgba(127,139,166,.2);--rv-media-bg:#0b1020;--rv-scrollbar-thumb:#2a3657;--rv-scrollbar-thumb-hover:#3d4c74;--rv-scrollbar-track:transparent;--rv-separator:#1a2747;--rv-separator-hover:#3f7fb8}
3
- :root[data-theme=light]{--rv-region-a:#ff9f43;--rv-region-a-fill:rgba(255,159,67,.18);--rv-region-b:#4dd6ff;--rv-region-b-fill:rgba(77,214,255,.18);--bg:#f5f7fb;--fg:#26324a;--fg-strong:#0c1324;--fg-soft:#3b4864;--fg-muted:#5d6982;--border:#d5dcea;--border-strong:#b3bed3;--hover-bg:#e8edf6;--active-bg:#dbe3f3;--link:#1a6fc0;--link-hover:#0f528f;--btn-fg:#ffffff;--code-bg:#eef1f7;--code-fg:#1d2940;--code-border:#d5dcea;--code-inline-bg:#e3e8f2;--footer-bg:#eceff6;--pass:#1e8a3c;--warn:#8a6400;--fail:#b3261e;--rv-bar:#edf1f7;--rv-panel:#e9eef6;--rv-raised:var(--code-inline-bg);--rv-control:var(--code-inline-bg);--rv-control-hover:#d5ddec;--rv-field:#ffffff;--rv-segmented:var(--code-inline-bg);--rv-pressed-bg:#c9ded8;--rv-pressed-border:#6fb286;--rv-pressed-fg:var(--fg-strong);--rv-focus:var(--link);--rv-backdrop:rgba(12,19,36,.45);--rv-pane-hi:#142042;--rv-pane-lo:#070b18;--rv-pane-label-bg:rgba(7,11,24,.85);--rv-stage-fg:#aebbd4;--rv-grid-dot:rgba(127,139,166,.2);--rv-media-bg:#0b1020;--rv-scrollbar-thumb:#b3bed3;--rv-scrollbar-thumb-hover:#8e9bb5;--rv-scrollbar-track:transparent;--rv-separator:#c9d6ec;--rv-separator-hover:#5b93cf}
2
+ :root{--rv-region-a:#ff9f43;--rv-region-a-fill:rgba(255,159,67,.18);--rv-region-b:#4dd6ff;--rv-region-b-fill:rgba(77,214,255,.18);--font-sans:Inter,system-ui,sans-serif;--font-display:Inter,system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace;--radius:10px;--bg:#070b18;--fg:#c9d4e8;--fg-strong:#f3f6fc;--fg-soft:#aebbd4;--fg-muted:#7f8ba6;--border:#18213a;--border-strong:#2a3657;--hover-bg:#0f1730;--active-bg:#142042;--link:#7cc8ff;--link-hover:#b3e0ff;--btn-fg:#08101f;--code-bg:#050814;--code-fg:#d6e2f5;--code-border:#18213a;--code-inline-bg:#121a30;--footer-bg:#050813;--pass:#30d158;--warn:#ffcc00;--fail:#ff453a;--rv-bar:#0b1124;--rv-panel:#0d152b;--rv-raised:var(--code-inline-bg);--rv-control:var(--code-inline-bg);--rv-control-hover:var(--active-bg);--rv-field:var(--code-bg);--rv-segmented:var(--code-bg);--rv-pressed-bg:#133733;--rv-pressed-border:#207c44;--rv-pressed-fg:var(--fg-strong);--rv-focus:var(--link-hover);--rv-backdrop:rgba(7,11,24,.82);--rv-pane-hi:#142042;--rv-pane-lo:#070b18;--rv-pane-label-bg:rgba(7,11,24,.85);--rv-stage-fg:#aebbd4;--rv-grid-dot:rgba(127,139,166,.2);--rv-media-bg:#0b1020;--rv-scrollbar-thumb:#2a3657;--rv-scrollbar-thumb-hover:#3d4c74;--rv-scrollbar-track:transparent;--rv-separator:#1a2747;--rv-separator-hover:#3f7fb8;--rv-tick-inspected:#7cc8ff;--rv-tick-scratch:#ff9f43}
3
+ :root[data-theme=light]{--rv-region-a:#ff9f43;--rv-region-a-fill:rgba(255,159,67,.18);--rv-region-b:#4dd6ff;--rv-region-b-fill:rgba(77,214,255,.18);--bg:#f5f7fb;--fg:#26324a;--fg-strong:#0c1324;--fg-soft:#3b4864;--fg-muted:#5d6982;--border:#d5dcea;--border-strong:#b3bed3;--hover-bg:#e8edf6;--active-bg:#dbe3f3;--link:#1a6fc0;--link-hover:#0f528f;--btn-fg:#ffffff;--code-bg:#eef1f7;--code-fg:#1d2940;--code-border:#d5dcea;--code-inline-bg:#e3e8f2;--footer-bg:#eceff6;--pass:#1e8a3c;--warn:#8a6400;--fail:#b3261e;--rv-bar:#edf1f7;--rv-panel:#e9eef6;--rv-raised:var(--code-inline-bg);--rv-control:var(--code-inline-bg);--rv-control-hover:#d5ddec;--rv-field:#ffffff;--rv-segmented:var(--code-inline-bg);--rv-pressed-bg:#c9ded8;--rv-pressed-border:#6fb286;--rv-pressed-fg:var(--fg-strong);--rv-focus:var(--link);--rv-backdrop:rgba(12,19,36,.45);--rv-pane-hi:#142042;--rv-pane-lo:#070b18;--rv-pane-label-bg:rgba(7,11,24,.85);--rv-stage-fg:#aebbd4;--rv-grid-dot:rgba(127,139,166,.2);--rv-media-bg:#0b1020;--rv-scrollbar-thumb:#b3bed3;--rv-scrollbar-thumb-hover:#8e9bb5;--rv-scrollbar-track:transparent;--rv-separator:#c9d6ec;--rv-separator-hover:#5b93cf;--rv-tick-inspected:#1a6fc0;--rv-tick-scratch:#c25e00}
4
4
  :root{font-family:var(--font-sans);color:var(--fg);background:var(--bg);font-size:13px;color-scheme:dark;font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{color:inherit;background:var(--rv-control);border:1px solid var(--border-strong);border-radius:6px;padding:7px 10px;cursor:pointer}button:hover{background:var(--rv-control-hover)}button:disabled{opacity:.45;cursor:default}button[aria-pressed=true]{background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border);color:var(--rv-pressed-fg)}button.primary{background:var(--link);border-color:var(--link);color:var(--btn-fg);font-weight:600}button.outline{background:transparent}button.block{width:100%;margin-top:10px}a{color:var(--link)}input,select,textarea{color:var(--fg);background:var(--rv-field);border:1px solid var(--border-strong);border-radius:6px;padding:8px;max-width:100%}select,input[type=text],textarea{width:100%}textarea{resize:vertical;line-height:1.6}input[type=range]{accent-color:var(--link);padding:0;min-width:0}input[type=checkbox],input[type=radio]{accent-color:var(--link)}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--rv-focus);outline-offset:3px}[hidden]{display:none!important}.topbar{min-height:72px;padding:15px 22px;display:flex;align-items:center;gap:25px;border-bottom:1px solid var(--border);flex-wrap:wrap;background:var(--rv-bar)}.brand{font-size:19px;letter-spacing:-.6px;white-space:nowrap}.brand-mark{display:inline-grid;place-items:center;width:24px;height:24px;border:1px solid var(--link);color:var(--link);border-radius:50%;margin-right:9px;font-size:14px}.topbar strong{font-weight:500;font-size:13px}.topbar #revision{margin-left:9px}.top-actions{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap}.subtle,.hint{color:var(--fg-muted);font-size:11px}.hint{line-height:1.6}.workspaces{display:flex;gap:10px;padding:0 18px;border-bottom:1px solid var(--border);background:var(--rv-bar)}.workspaces button{border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;padding:15px 13px;font-size:12px;letter-spacing:.06em;color:var(--fg-soft)}.workspaces button[aria-pressed=true]{border-bottom-color:var(--link);color:var(--fg-strong);background:transparent}.workspaces button span{font-size:11px;margin-left:8px;letter-spacing:0;color:var(--fg-muted)}.layout{display:grid;grid-template-columns:190px minmax(250px,1fr) 286px;min-height:calc(100vh - 160px)}aside{background:var(--rv-panel);padding:20px 15px}.left-panel{border-right:1px solid var(--border)}.right-panel{border-left:1px solid var(--border)}.eyebrow{font-size:11px;letter-spacing:.08em;color:var(--fg-muted);margin:0 0 11px}.label{display:block;color:var(--fg-muted);font-size:11px;margin:0 0 7px}.space-top{margin-top:15px}.left-panel section{margin-top:26px}.tool{width:100%;display:flex;justify-content:space-between;align-items:center;text-align:left;gap:5px;border-color:transparent;background:transparent;padding:9px 6px;margin:3px 0}.tool small{font-size:11px;color:var(--fg-muted)}.checkpoint{display:block;width:100%;background:transparent;border:1px solid transparent;text-align:left;margin:4px 0;padding:10px 9px;white-space:normal;line-height:1.5}.checkpoint[aria-current=true]{background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border)}.checkpoint small{display:block;font-size:11px;color:var(--fg-muted);margin-top:3px}.source-link{display:block;font-size:11px;overflow-wrap:anywhere;margin:8px 0}main{min-width:0;display:flex;flex-direction:column;background:var(--bg)}.viewbar{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:12px;border-bottom:1px solid var(--border)}.zoom-controls{display:flex;gap:4px;align-items:center}.zoom-controls button{padding:4px 8px;font-size:12px}.zoom-controls output{font:11px var(--font-mono);min-width:39px;text-align:center;color:var(--fg-muted)}.stages{display:flex;gap:10px;padding:12px 12px 0;flex:1;min-height:460px}.stage-pane{position:relative;min-width:0;flex:1;border:1px solid var(--border-strong);border-radius:8px;overflow:hidden;background:radial-gradient(ellipse at 50% 40%,var(--rv-pane-hi),var(--rv-pane-lo) 74%)}.pane-label{position:absolute;left:10px;top:10px;z-index:2;background:var(--rv-pane-label-bg);border-radius:4px;padding:4px 7px;color:var(--rv-stage-fg);font-size:11px;pointer-events:none}.viewport{position:relative;overflow:hidden;width:100%;height:100%;min-height:450px;touch-action:none;background-image:radial-gradient(var(--rv-grid-dot) .7px,transparent .7px);background-size:22px 22px}.empty{position:absolute;inset:65px 22px 30px;display:grid;place-items:center;text-align:center;color:var(--rv-stage-fg);font-size:12px;line-height:1.8}.reference-content,#rig-root{position:absolute;transform-origin:0 0}.reference-content img,.reference-content video{display:block;max-width:none}.reference-content svg{display:block;max-width:none}.reference-content video{object-fit:contain}.stage-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:11px 16px;color:var(--fg-muted);font-size:11px}.transport{display:flex;align-items:center;gap:11px;padding:13px;border-top:1px solid var(--border);background:var(--rv-panel)}.transport select{width:auto;max-width:150px;font-size:11px}.transport input{flex:1}.transport output{font:11px var(--font-mono);white-space:nowrap;color:var(--fg-muted)}.sound-panel,.angle-panel{padding:15px;border-top:1px solid var(--border);background:var(--rv-panel)}.row{display:flex;align-items:center;gap:11px;flex-wrap:wrap}.row .eyebrow{margin:0}.sound-panel .row{font-size:11px;margin-bottom:9px}.sound-panel input[type=range]{flex:1;max-width:160px}.cue{display:flex;gap:9px;align-items:center;justify-content:space-between;border-top:1px solid var(--border);padding:9px 0;font-size:11px;flex-wrap:wrap}.cue button{padding:4px 7px;font-size:11px}.angle-panel{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:11px}.angle-panel select{width:auto;max-width:150px}.angle-panel .hint{width:100%;margin:0}.angle-panel input{flex:1}.divider{height:1px;background:var(--border-strong);margin:22px 0}h1{font-size:15px;font-weight:500;line-height:1.6;margin:0 0 7px}.outcome{border:0;padding:0;margin:18px 0 0;display:flex;gap:11px;flex-wrap:wrap}.outcome legend{font-size:11px;color:var(--fg-muted);margin-bottom:8px;padding:0}.outcome label{display:flex;align-items:center;gap:3px;font-size:11px}.proposal-item{border-top:1px solid var(--border);padding:11px 0;margin-top:12px;font-size:11px;overflow-wrap:anywhere}.proposal-item strong{display:block;font-weight:500;margin-bottom:5px}.proposal-item button{font-size:11px;padding:3px 6px;margin-top:7px}footer{display:flex;justify-content:space-between;align-items:center;gap:6px 15px;flex-wrap:nowrap;height:40px;box-sizing:border-box;overflow:hidden;padding:0 18px;border-top:1px solid var(--border);background:var(--footer-bg);font-size:11px;color:var(--fg-soft)}.error{color:var(--fail)!important}.dirty{color:var(--warn)!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
5
5
  @media(min-width:1500px){.layout{grid-template-columns:218px minmax(250px,1fr) 310px}.stages{min-height:600px}}
6
6
  @media(max-width:1000px){.layout{grid-template-columns:158px minmax(250px,1fr) 250px}aside{padding:17px 12px}.topbar{gap:14px}.topbar #revision{display:block;margin-left:0}.stages{min-height:400px}.viewport{min-height:400px}.workspaces span{display:none}}
@@ -80,7 +80,12 @@ main{min-height:0;overflow:hidden;display:flex;flex-direction:column}
80
80
  #stage-layout:has(>.stage-pane[hidden]){grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
81
81
 
82
82
  /* Slot explorer shares the stage row; bottom Inspector remains in the main column. */
83
- .layout{grid-template-columns:260px minmax(0,1fr)!important}.parts-explorer{display:block;grid-template-columns:none;gap:normal;min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--border);background:var(--rv-panel);padding:12px;overscroll-behavior:contain}.parts-explorer h2{font-size:12px;margin:0 0 10px}.parts-explorer>label{display:block;font-size:11px;margin-top:12px}.parts-explorer input[type=search]{width:100%;min-width:0}.parts-explorer .part-solo{display:flex;gap:6px;align-items:center}.parts-explorer ul{list-style:none;margin:8px 0;padding:0;display:grid;gap:4px}.parts-explorer li{min-width:0}.parts-explorer li button{display:block;width:100%;text-align:left;overflow-wrap:anywhere;font-size:11px}.parts-explorer button[aria-pressed=true]{background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border)}.parts-explorer h2,#selected-part h3{font-weight:600}#selected-part{margin-bottom:12px}#selected-part h3{margin:0 0 8px;overflow-wrap:anywhere}#selected-part p{overflow-wrap:anywhere}
83
+ .layout{grid-template-columns:260px minmax(0,1fr)!important}.parts-explorer{display:block;grid-template-columns:none;gap:normal;min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--border);background:var(--rv-panel);padding:12px;overscroll-behavior:contain}.parts-explorer h2{font-size:12px;margin:0 0 10px}.parts-explorer>label{display:block;font-size:11px;margin-top:12px}.parts-explorer input[type=search]{width:100%;min-width:0}.parts-explorer .part-solo{display:flex;gap:6px;align-items:center}.parts-explorer ul{list-style:none;margin:8px 0;padding:0;display:grid;gap:4px}.parts-explorer li{min-width:0}.parts-explorer li button{display:flex;align-items:center;gap:6px;width:100%;text-align:left;overflow-wrap:anywhere;font-size:11px}
84
+ /* A slot's thumbnail: its declared page region in a 24px box, or the type icon when it has none. */
85
+ .part-thumb{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;overflow:hidden;border-radius:3px}
86
+ .part-thumb canvas{display:block;width:24px;height:24px}
87
+ .part-thumb[data-thumbnail=image]{background:var(--rv-field)}
88
+ .part-label{min-width:0;flex:1 1 auto}.parts-explorer button[aria-pressed=true]{background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border)}.parts-explorer h2,#selected-part h3{font-weight:600}#selected-part{margin-bottom:12px}#selected-part h3{margin:0 0 8px;overflow-wrap:anywhere}#selected-part p{overflow-wrap:anywhere}
84
89
  @media(max-width:1050px){.layout{grid-template-columns:200px minmax(0,1fr)!important}.parts-explorer{padding:8px}}
85
90
  @media(max-width:700px){.layout{grid-template-columns:minmax(0,1fr)!important}.left-panel{display:none!important}}
86
91
  @media(max-width:460px){.layout{grid-template-columns:minmax(0,1fr)!important}.parts-explorer{padding:6px}.parts-explorer li button{padding:6px}.parts-explorer>.hint{font-size:10px}}
@@ -165,7 +170,9 @@ footer.status-bar{flex-wrap:nowrap}
165
170
  .status-source{display:flex;align-items:center;gap:12px;margin-left:auto;flex:none;white-space:nowrap}
166
171
  .view-region{position:relative}
167
172
  .view-region>summary{cursor:pointer}
168
- .view-region-body{position:absolute;right:0;bottom:calc(100% + 10px);width:min(520px,90vw);max-height:60dvh;overflow:auto;padding:10px;z-index:10;background:var(--rv-panel);border:1px solid var(--border);border-radius:7px}
173
+ /* A popover above the 40px footer, clamped to the viewport: fixed so the footer's overflow never clips it
174
+ and opening it never scrolls the page; its own body scrolls. */
175
+ .view-region-body{position:fixed;right:8px;bottom:calc(40px + 6px);width:min(520px,calc(100vw - 16px));max-width:calc(100vw - 16px);max-height:calc(100dvh - 40px - 16px);box-sizing:border-box;overflow:auto;overscroll-behavior:contain;padding:10px;z-index:10;background:var(--rv-panel);border:1px solid var(--border);border-radius:7px}
169
176
  .view-region-body .numeric-view{margin:8px 0}
170
177
 
171
178
 
@@ -179,11 +186,18 @@ footer.status-bar{flex-wrap:nowrap}
179
186
  /* The left column's fixed header row: title and search never scroll and nothing sits above them. */
180
187
  .left-header{flex:none;padding:8px 10px;border-bottom:1px solid var(--border);background:var(--rv-panel)}
181
188
  .left-header h2{font-size:12px;font-weight:600;margin:0 0 6px}
182
- .left-header label{display:block;font-size:11px;margin:0 0 4px}
183
189
  .left-header input[type=search]{width:100%;min-width:0}
190
+ /* Parts header is one row: the title left, the search filling the rest. */
191
+ .parts-header{display:flex;flex-direction:row;flex-wrap:nowrap;align-items:center;gap:8px}
192
+ .parts-header h2{flex:none;margin:0}
193
+ .parts-header input[type=search]{flex:1 1 auto;width:auto;min-width:0}
184
194
  .workspace-sources{padding-top:0}
185
195
  .workspace-sources>.package-context{padding-top:10px}
186
196
  .workspace-settings>.sticky-header{margin:-10px -10px 8px;padding:10px 10px 6px}
197
+ .workspace-settings[data-collapsed=true]>.sticky-header{margin-bottom:-10px}
198
+ .settings-toggle{display:flex;align-items:center;gap:6px;width:100%;padding:0;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
199
+ .settings-toggle::before{content:'▸';flex:none;color:var(--fg-muted)}
200
+ .settings-toggle[aria-expanded=true]::before{content:'▾'}
187
201
  .workspace-drawer-header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex:none;font-size:12px;font-weight:600}
188
202
 
189
203
  /* One thin scrollbar for every scrolling region, from the theme tokens: the standard properties for
@@ -213,3 +227,36 @@ footer.status-bar{flex-wrap:nowrap}
213
227
  .review-dialog .review-summary>label{display:block;font-size:11px;margin:10px 0 5px}
214
228
  .review-dialog .report-panel{padding:10px 0}
215
229
  @media(max-width:540px){.review-dialog-header .hint{display:none}}
230
+ :root{--rv-mesh-region:#ff7ae0;--rv-mesh-region-fill:rgba(255,122,224,.12);--rv-mesh-handle:#ffffff;--rv-bust-r:#00e5ff;--rv-bust-r-fill:rgba(0,229,255,.13);--rv-bust-l:#52ff88;--rv-bust-l-fill:rgba(82,255,136,.13);--rv-bust-overlap:#ff5252;--rv-bust-overlap-fill:rgba(255,82,82,.13);--rv-bust-handle:#ffffff;--rv-bust-ink:#111318;--rv-bust-label-bg:rgba(12,14,18,.82);--rv-bust-flash:#ffcc00;--rv-landmark:#ffd34d;--rv-landmark-fill:#ffd34d;--rv-landmark-ink:#111318}
231
+ :root[data-theme=light]{--rv-mesh-region:#c2189b;--rv-mesh-region-fill:rgba(194,24,155,.12);--rv-mesh-handle:#ffffff;--rv-bust-r:#00e5ff;--rv-bust-r-fill:rgba(0,229,255,.13);--rv-bust-l:#52ff88;--rv-bust-l-fill:rgba(82,255,136,.13);--rv-bust-overlap:#ff5252;--rv-bust-overlap-fill:rgba(255,82,82,.13);--rv-bust-handle:#ffffff;--rv-bust-ink:#111318;--rv-bust-label-bg:rgba(12,14,18,.82);--rv-bust-flash:#ffcc00;--rv-landmark:#ffd34d;--rv-landmark-fill:#ffd34d;--rv-landmark-ink:#111318}
232
+ .view-tools{display:flex;gap:4px;flex:none}.view-tool{padding:5px 9px;font-size:11px;white-space:nowrap}
233
+ .chest-editor-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--rv-bar);font-size:11px}.chest-editor-bar[hidden]{display:none}.chest-editor-bar input[type=range]{width:84px}.chest-editor-bar input[type=text]{width:104px;padding:3px 5px;font-size:11px}.chest-editor-bar output{min-width:2.6em;font:10px/1 var(--font-mono);color:var(--fg)}.chest-editor-bar [data-landmark][aria-pressed=true]{outline:2px solid var(--rv-landmark);outline-offset:1px}#lm-derived{flex:1 1 100%;order:9;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font:10px/1.4 var(--font-mono)}#lm-derived.error{color:var(--fail)}.chest-editor-bar .bust-group{display:flex;align-items:center;gap:6px;padding-left:10px;border-left:1px solid var(--border)}.chest-editor-bar .bust-group:first-child{padding-left:0;border-left:0}.chest-editor-bar label{display:flex;align-items:center;gap:4px;color:var(--fg-muted)}.chest-editor-bar input[type=number]{width:58px;padding:3px 5px;font-size:11px}.chest-editor-bar button{padding:4px 8px;font-size:11px}.chest-editor-bar button[aria-disabled=true]{opacity:.6}.bust-tag{font-weight:700}.bust-tag-r{color:var(--rv-bust-r)}.bust-tag-l{color:var(--rv-bust-l)}#handoff-line{flex:1 1 100%;order:10;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font:10px/1.4 var(--font-mono)}#chest-editor-status{flex:1 1 220px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.chest-editor-bar{position:relative}.rebuild-log{position:absolute;top:100%;left:10px;right:10px;z-index:6;margin:4px 0 0;max-height:7.5em;overflow:auto;padding:5px 8px;font:10px/1.5 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere;background:var(--code-bg);color:var(--code-fg);border:1px solid var(--border);border-radius:5px}
234
+ :root{--rv-mesh-veil:rgba(8,10,14,.55);--rv-mesh-edge:#69f1ff;--rv-mesh-hull:#ffb020;--rv-mesh-constraint:#ff4fd8;--rv-mesh-removed:#ff5252;--rv-mesh-vertex:#ffffff;--rv-mesh-ink:#111318;--rv-mesh-selected:#ffe14d;--rv-mesh-added:#52ff88;--rv-mesh-hover:#ffffff;--rv-mesh-box:#ffe14d;--rv-mesh-box-fill:rgba(255,225,77,.12)}
235
+ :root[data-theme=light]{--rv-mesh-veil:rgba(245,247,251,.6);--rv-mesh-edge:#69f1ff;--rv-mesh-hull:#ffb020;--rv-mesh-constraint:#ff4fd8;--rv-mesh-removed:#ff5252;--rv-mesh-vertex:#ffffff;--rv-mesh-ink:#111318;--rv-mesh-selected:#ffe14d;--rv-mesh-added:#52ff88;--rv-mesh-hover:#ffffff;--rv-mesh-box:#ffe14d;--rv-mesh-box-fill:rgba(255,225,77,.12)}
236
+ .mesh-editor-bar kbd{margin-left:5px;font-size:10px;opacity:.75}#mesh-editor-status{flex:1 1 260px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
237
+ #rig-pane[data-mesh-mode] .viewport{cursor:crosshair}#rig-pane[data-mesh-mode=modify][data-mesh-hover] .viewport{cursor:move}#rig-pane[data-mesh-mode=create] .viewport{cursor:copy}#rig-pane[data-mesh-mode=delete][data-mesh-hover] .viewport{cursor:not-allowed}#rig-pane[data-mesh-mode=edges][data-mesh-hover=vertex] .viewport{cursor:cell}
238
+ #rig-pane[data-chest-hover=move] .viewport{cursor:move}#rig-pane[data-chest-hover=r] .viewport,#rig-pane[data-chest-hover=band] .viewport{cursor:ew-resize}#rig-pane[data-chest-hover=band] .viewport{cursor:ns-resize}
239
+ .environment-panel{padding:15px;border-top:1px solid var(--border);background:var(--rv-panel);font-size:11px}.environment-panel .row{margin-bottom:9px}.environment-panel .row label{display:flex;align-items:center;gap:4px;color:var(--fg-muted)}.environment-panel input[type=range]{flex:1;max-width:180px}.environment-panel input[type=number]{width:72px;padding:4px 6px;font-variant-numeric:tabular-nums}.environment-panel button{padding:4px 9px;font-size:11px}.environment-panel output{font:11px var(--font-mono);color:var(--fg-muted)}.environment-panel .hint{margin:0 0 9px}.environment-readout{display:block;margin:0 0 9px;color:var(--fg-strong)!important;overflow-wrap:anywhere}.physics-constraints summary{cursor:pointer;color:var(--fg-soft)}.physics-table-wrap{overflow-x:auto;max-height:260px;overflow-y:auto}.physics-table{width:100%;border-collapse:collapse;font:11px var(--font-mono);font-variant-numeric:tabular-nums}.physics-table th,.physics-table td{padding:3px 6px;border-top:1px solid var(--border);text-align:right;white-space:nowrap}.physics-table th{color:var(--fg-muted);font-weight:500}.physics-table th:first-child,.physics-table td:first-child,.physics-table th:nth-child(2),.physics-table td:nth-child(2){text-align:left}.physics-table .absent{color:var(--fg-muted)}
240
+ /* Animate · Physics tuning (#103): edits are config patch ops; tokens only. */
241
+ .physics-panel{padding:15px;border-top:1px solid var(--border);background:var(--rv-panel);font-size:11px}.physics-panel .row{margin-bottom:9px}.physics-panel .row label{color:var(--fg-muted)}.physics-panel select{width:auto;max-width:100%;padding:4px 6px;font-size:11px}.physics-panel input[type=number]{width:72px;padding:4px 6px;font-variant-numeric:tabular-nums}.physics-panel button{padding:4px 9px;font-size:11px}.physics-panel .hint{margin:0 0 9px}.physics-panel code{font:11px var(--font-mono)}.physics-table .pending{color:var(--warn)}.physics-table tr.selected th,.physics-table tr.selected td{background:var(--rv-pressed-bg)}.physics-row-pick{border:0;background:transparent;padding:0;font:inherit;color:var(--link);text-align:left}.physics-row-pick[aria-pressed=true]{background:transparent;color:var(--fg-strong);font-weight:600}.physics-row-editor{margin:10px 0;padding:9px 10px;border:1px solid var(--border);border-radius:6px}.physics-editor-title{margin:0 0 8px;overflow-wrap:anywhere}.physics-controls{display:flex;flex-direction:column;gap:6px}.physics-control{display:grid;grid-template-columns:58px minmax(0,1fr) 72px auto;align-items:center;gap:6px;color:var(--fg-muted)}.physics-control.pending span,.physics-toggle.pending{color:var(--warn)}.physics-control output{font:11px var(--font-mono);color:var(--fg-muted)}.physics-toggle{display:inline-flex;align-items:center;gap:4px;margin-right:10px;color:var(--fg-muted)}.physics-locked{margin-top:10px}.physics-locked .eyebrow{margin:0 0 6px}.physics-locked-table td.physics-reason,.physics-locked-table th{white-space:normal;text-align:left}.physics-reason{overflow-wrap:anywhere}.physics-pending-list{margin:0 0 9px;padding-left:18px}.physics-pending-list li{margin:3px 0;overflow-wrap:anywhere}.physics-pending-list li.applied{color:var(--fg-soft)}.physics-pending-list button{margin-left:6px;padding:1px 6px}.physics-readout{display:flex;flex-direction:column;gap:4px}.physics-readout-line{display:grid;grid-template-columns:52px minmax(0,1fr);gap:6px;align-items:baseline}.physics-readout-line .eyebrow{margin:0}.physics-readout-line code{overflow-wrap:anywhere;color:var(--fg-strong)}#rig-pane[data-physics-pick=true]{cursor:crosshair}.physics-work{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(260px,1fr);gap:12px;align-items:start}.physics-work .physics-row-editor{margin:0}@media (max-width:900px){.physics-work{grid-template-columns:minmax(0,1fr)}}
242
+
243
+ /* Readings (#108): the block, its worst-frame lines and the two-colour ticks under the transport scrub. */
244
+ .readings-panel{padding:15px;border-top:1px solid var(--border);background:var(--rv-panel);font-size:11px}.readings-panel .row{margin-bottom:9px}.readings-panel .row label{display:flex;align-items:center;gap:4px;color:var(--fg-muted)}.readings-panel .hint{margin:0 0 9px}.readings-panel code{font:11px var(--font-mono)}.readings-panel button{padding:4px 9px;font-size:11px}.readings-pair{flex-wrap:nowrap}.readings-pair label{flex:none;min-width:92px}.readings-pair input[type=text]{flex:1;min-width:0;width:auto;padding:4px 6px;font:11px var(--font-mono)}.readings-worst{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}.readings-line{display:grid;grid-template-columns:8px minmax(0,1fr);gap:6px;align-items:start}.readings-line .tick-swatch{margin-top:5px}.readings-line code,.readings-go{font:11px var(--font-mono);overflow-wrap:anywhere;text-align:left;color:var(--fg-strong)}.readings-go{border:0;background:transparent;padding:0;cursor:pointer}.readings-go:hover{background:transparent;color:var(--link-hover)}.readings-line.error code{color:var(--fail)}.readings-items{grid-column:2;color:var(--fg-muted)}.readings-items summary{cursor:pointer}.readings-items ul{margin:4px 0 0;padding-left:14px;display:flex;flex-direction:column;gap:3px}.tick-swatch{display:inline-block;width:8px;height:8px;border-radius:2px;vertical-align:baseline}.tick-swatch.inspected{background:var(--rv-tick-inspected)}.tick-swatch.scratch{background:var(--rv-tick-scratch)}
245
+ .transport{position:relative}.transport.has-reading-ticks{padding-bottom:24px}.inspector .transport.has-reading-ticks{padding-bottom:22px}.reading-ticks{position:absolute;height:14px;margin-top:2px;pointer-events:none}.reading-tick{position:absolute;width:3px;height:6px;margin-left:-1px;padding:0;border:0;border-radius:1px;pointer-events:auto;cursor:pointer}.reading-tick.inspected{top:0;background:var(--rv-tick-inspected)}.reading-tick.scratch{top:8px;background:var(--rv-tick-scratch)}.reading-tick:hover{background:var(--fg-strong)}.reading-tick:focus-visible{outline-offset:1px}
246
+ .idle-panel .idle-add-row input[type=text]{flex:1;min-width:90px;width:auto;padding:4px 6px;font:11px var(--font-mono)}.idle-preview-label{margin-left:auto;padding:2px 7px;border:1px solid var(--warn);border-radius:4px;color:var(--warn);font-size:11px;overflow-wrap:anywhere}.idle-preview-label.refused{border-style:dashed}.idle-preview-toggle{display:inline-flex;align-items:center;gap:4px;color:var(--fg-muted)}.idle-table tr.pending td,.idle-panel p.pending{color:var(--warn)}.idle-blink{margin:10px 0}.idle-blink .eyebrow{margin:0 0 6px}.idle-period select{width:100%;min-width:0}.idle-precheck{display:flex;flex-direction:column;gap:4px;margin-bottom:9px}.idle-precheck code{font:11px var(--font-mono);overflow-wrap:anywhere}.idle-precheck .idle-note{color:var(--fg-muted)}.transport.has-idle-blink{padding-top:22px}.idle-blink-strip{position:absolute;height:12px;pointer-events:none}.idle-blink-marker{position:absolute;top:0;width:14px;height:12px;margin-left:-7px;padding:0;border:0;background:transparent;color:var(--link);font-size:10px;line-height:12px;pointer-events:auto;cursor:ew-resize;touch-action:none}.idle-blink-marker.pending{color:var(--warn)}.idle-handles{position:absolute;inset:0;pointer-events:none;z-index:3}.idle-handle{position:absolute;width:14px;height:14px;margin:-7px 0 0 -7px;padding:0;border:2px solid var(--rv-tick-scratch);border-radius:50%;background:var(--rv-pane-label-bg);pointer-events:auto;cursor:ns-resize;touch-action:none}.idle-handle span{position:absolute;left:16px;top:-3px;white-space:nowrap;padding:1px 4px;border-radius:3px;background:var(--rv-pane-label-bg);color:var(--rv-stage-fg);font:10px var(--font-mono);pointer-events:none}
247
+ .mesh-params{margin:0 0 10px}.mesh-param{grid-template-columns:78px minmax(0,1fr) 72px 18px auto}.mesh-param select{max-width:140px}.mesh-param-toggle{margin-top:2px}.mesh-tools{display:flex;align-items:center;flex-wrap:wrap;gap:6px}.mesh-tools .eyebrow{margin:0 6px 0 0}.mesh-tools button[aria-pressed=true]{background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border);color:var(--rv-pressed-fg)}.mesh-regions{display:flex;flex-direction:column;gap:6px;margin:0 0 10px}.mesh-region{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;margin:0;padding:6px 8px;border:1px solid var(--border);border-radius:6px}.mesh-region.selected{border-color:var(--rv-mesh-region)}.mesh-region legend{padding:0 4px;font:600 11px var(--font-mono);color:var(--rv-mesh-region)}.mesh-region label{display:flex;align-items:center;gap:4px;color:var(--fg-muted)}.mesh-region input[type=number]{width:62px}.mesh-region input[type=text]{width:96px;padding:3px 5px;font-size:11px}.mesh-segments li{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.mesh-ribs{display:inline-flex;align-items:center;gap:4px;color:var(--fg-muted)}.mesh-ribs input{width:64px}.mesh-precheck{margin:0 0 9px;padding-left:18px}.mesh-precheck li{color:var(--fail);overflow-wrap:anywhere}#mesh-counts code.error,#mesh-status.error{color:var(--fail)}
248
+ /* Inspector block row (#124): sticky at the top of each section's scroll (the section pads 12px 15px), one line that scrolls sideways when narrow. */
249
+ .block-row{position:sticky;top:-12px;z-index:3;display:flex;flex-wrap:nowrap;gap:4px;margin:-12px -15px 10px;padding:6px 15px;overflow-x:auto;overscroll-behavior-x:contain;background:var(--rv-panel);border-bottom:1px solid var(--border)}.block-row button{flex:none;padding:3px 9px;font-size:11px;white-space:nowrap;color:var(--fg-muted);background:transparent;border-color:transparent}.block-row button:hover{color:var(--fg-strong);background:var(--rv-control-hover)}.block-row button[aria-current=true]{color:var(--rv-pressed-fg);background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border)}.block-row button:focus-visible{outline:2px solid var(--rv-focus);outline-offset:-2px}
250
+ /* Setup · Weights (#107): the weight ramp (low → half → high) and max influence's bone colours. Drawn on the stage, which stays dark, so both schemes carry the same values. */
251
+ :root{--rv-weight-0:#3d1a78;--rv-weight-half:#e0573a;--rv-weight-1:#fcef6c;--rv-weight-bone-1:#00e5ff;--rv-weight-bone-2:#ff7ae0;--rv-weight-bone-3:#52ff88;--rv-weight-bone-4:#ffb020;--rv-weight-bone-5:#a98bff;--rv-weight-bone-6:#ff5252}
252
+ :root[data-theme=light]{--rv-weight-0:#3d1a78;--rv-weight-half:#e0573a;--rv-weight-1:#fcef6c;--rv-weight-bone-1:#00e5ff;--rv-weight-bone-2:#ff7ae0;--rv-weight-bone-3:#52ff88;--rv-weight-bone-4:#ffb020;--rv-weight-bone-5:#a98bff;--rv-weight-bone-6:#ff5252}
253
+ .weights-controls{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px}.weights-controls label{display:inline-flex;align-items:center;gap:5px}.weights-controls select{width:auto;max-width:220px}#weights-state.error{color:var(--fail)}.weights-legend{display:flex;align-items:center;flex-wrap:wrap;gap:6px 10px;margin:0 0 8px;font:11px var(--font-mono);color:var(--fg-muted)}.weights-ramp{display:inline-block;width:140px;height:10px;border-radius:3px;background:linear-gradient(90deg,var(--rv-weight-0),var(--rv-weight-half),var(--rv-weight-1))}.weights-swatch{display:inline-flex;align-items:center;gap:5px}.weights-swatch i{display:inline-block;width:10px;height:10px;border-radius:50%}
254
+ :root{--rv-bone-line:#ffe26b;--rv-field-still:#69f1ff;--rv-field-inward:rgba(255,122,224,.28);--rv-field-inward-line:#ff7ae0;--rv-field-outward:rgba(105,241,255,.24);--rv-field-outward-line:#69f1ff;--rv-field-rest:#ffe26b;--rv-field-handle-bg:rgba(12,14,18,.82);--rv-field-ink:#f4f6fa;--rv-bone-origin:#ffe26b;--rv-bone-point:#69f1ff;--rv-bone-tip:#ff7ae0;--rv-bone-pending:#ffb020;--rv-bone-selected:#ffffff;--rv-bone-ink:#111318;--rv-bone-label-bg:rgba(12,14,18,.82)}
255
+ :root[data-theme=light]{--rv-bone-line:#ffe26b;--rv-field-still:#69f1ff;--rv-field-inward:rgba(255,122,224,.28);--rv-field-inward-line:#ff7ae0;--rv-field-outward:rgba(105,241,255,.24);--rv-field-outward-line:#69f1ff;--rv-field-rest:#ffe26b;--rv-field-handle-bg:rgba(12,14,18,.82);--rv-field-ink:#f4f6fa;--rv-bone-origin:#ffe26b;--rv-bone-point:#69f1ff;--rv-bone-tip:#ff7ae0;--rv-bone-pending:#ffb020;--rv-bone-selected:#ffffff;--rv-bone-ink:#111318;--rv-bone-label-bg:rgba(12,14,18,.82)}
256
+ .bones-controls{display:flex;align-items:center;flex-wrap:wrap;gap:6px 10px}.bones-controls button[aria-pressed=true]{background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border);color:var(--rv-pressed-fg)}.bones-controls select{max-width:260px}#bones-panel-state.error{color:var(--fail)}#bones-status.error{color:var(--fail)!important}.bones-anchor-editor{margin:0 0 10px;padding:9px 10px;border:1px solid var(--border);border-radius:6px}.bones-anchor{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1fr) 64px 64px auto;align-items:center;gap:6px;margin:3px 0;color:var(--fg-muted)}.bones-anchor code{overflow-wrap:anywhere}.bones-anchor.pending span,.bones-anchor.pending output{color:var(--warn)}.bones-anchor output{font:11px var(--font-mono)}.bones-lines{display:flex;flex-direction:column;gap:3px;margin-top:8px}.bones-lines .eyebrow{margin:0 0 2px}.bones-lines code{overflow-wrap:anywhere;color:var(--fg-strong)}.bones-lines code.lint{color:var(--warn)}.bones-lines code.error{color:var(--fail)}.bones-coverage summary{cursor:pointer;font:11px var(--font-mono);color:var(--fg-muted)}.bones-coverage code{display:block;margin:2px 0 2px 12px;color:var(--fg-muted)}#rig-pane[data-bones-hover=move]{cursor:move}
257
+
258
+ .rig-fields-group{margin:8px 0 10px}.rig-fields-group .eyebrow{margin:0 0 5px}.rig-fields-group .row{flex-wrap:wrap;gap:6px;align-items:center}.rig-fields-group select{min-width:0;max-width:100%}.rig-fields-group input[type=number]{width:76px}.rig-fields-group .physics-control{display:grid;grid-template-columns:minmax(64px,auto) minmax(0,1fr) 76px auto;gap:6px;align-items:center}
259
+ .rig-fields-stage{position:absolute;inset:0;pointer-events:none;z-index:3}.rig-fields-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.rig-fields-still-line{stroke:var(--rv-field-still);stroke-width:2;stroke-dasharray:6 3}.rig-fields-arc{stroke-width:1.5}.rig-fields-arc.inward{fill:var(--rv-field-inward);stroke:var(--rv-field-inward-line)}.rig-fields-arc.outward{fill:var(--rv-field-outward);stroke:var(--rv-field-outward-line)}.rig-fields-rest{stroke:var(--rv-field-rest);stroke-width:1.5;stroke-dasharray:3 3}
260
+ .rig-fields-handle{position:absolute;width:14px;height:14px;margin:-7px 0 0 -7px;padding:0;border:2px solid var(--rv-field-still);border-radius:50%;background:var(--rv-field-handle-bg);pointer-events:auto;cursor:grab;touch-action:none}.rig-fields-handle.still{cursor:ns-resize}.rig-fields-handle.inward{border-color:var(--rv-field-inward-line)}.rig-fields-handle.outward{border-color:var(--rv-field-outward-line)}.rig-fields-handle span{position:absolute;left:16px;top:-3px;white-space:nowrap;padding:1px 4px;border-radius:3px;background:var(--rv-field-handle-bg);color:var(--rv-field-ink);font:10px var(--font-mono);pointer-events:none}
261
+ .requirements-fields .row{margin-bottom:6px}.requirements-panel select{width:auto;max-width:170px;padding:4px 6px;font-size:11px}.requirements-panel input[type=text]{flex:1;min-width:0;width:auto;padding:4px 6px;font:11px var(--font-mono)}.requirements-panel input[type=number]{width:70px;padding:4px 6px;font-size:11px}.requirements-list{list-style:none;margin:0 0 9px;padding:0;display:flex;flex-direction:column;gap:8px}.requirements-item{border-left:2px solid var(--border-strong);padding-left:8px;display:flex;flex-direction:column;gap:4px}.requirements-item.pass{border-left-color:var(--pass,var(--link))}.requirements-item.fail{border-left-color:var(--fail)}.requirements-item code{font:11px var(--font-mono);overflow-wrap:anywhere}.requirements-bar input[type=range]{flex:1;min-width:90px}.requirements-bar output{font:11px var(--font-mono);color:var(--fg-strong)}.requirements-refusals{list-style:none;margin:6px 0;padding:0;display:flex;flex-direction:column;gap:3px}.requirements-refusals code{font:11px var(--font-mono);color:var(--fail);overflow-wrap:anywhere}
262
+ .retarget-chains{display:flex;flex-direction:column;gap:5px;margin-bottom:9px}.retarget-chain{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.retarget-chain.off{opacity:.55}.retarget-chain label{min-width:70px}.retarget-chain code,.retarget-translate span{font:11px var(--font-mono);color:var(--fg-muted)}.retarget-translate{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.retarget-json textarea{width:100%;font:11px var(--font-mono)}.retarget-panel .row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.retarget-log{max-height:160px;overflow:auto;font:10px var(--font-mono);white-space:pre-wrap;color:var(--fg-muted)}
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Setup · Weights (#107): a watch layer over the selected slot's setup-pose mesh. Pick a bone and every vertex is
3
+ * coloured by that bone's stored weight on the theme's ramp (0 → 1), or switch to max influence and every vertex takes
4
+ * its dominant bone's colour; hovering a vertex reads its stored value. Weights come from the compiled model document
5
+ * (`src/weights.ts` over the shared reader `src/setup-mesh.ts`); vertex positions come from the published player's
6
+ * setup pose (`web/setup-pose.ts`, a second `RigPlayer` over the same assets that never plays — the same placement
7
+ * Edit Mesh uses). Nothing is edited, applied, recorded or sent.
8
+ */
9
+ import type {ModelAssets,RenderMode} from '@rig-c/play';
10
+ import {placeSetupMesh} from '../src/setup-mesh.ts';
11
+ import {WEIGHTS_SELECT_SLOT,dominantInfluence,meanColor,meshWeights,rampColor,weightOf,weightText,weightsModel,type MeshWeights} from '../src/weights.ts';
12
+ import {modelBoneNames,readSetupPose} from './setup-pose.ts';
13
+ import type {OverlayPoint,OverlayProject,OverlayWeights} from './rig-overlays.ts';
14
+
15
+ export type WeightsMode='bone'|'max';
16
+ /** Ramp stops, low → high, and the categorical bone colours of max influence: theme token names. */
17
+ export const weightRampTokens=['--rv-weight-0','--rv-weight-half','--rv-weight-1'] as const;
18
+ export const weightBoneTokens=['--rv-weight-bone-1','--rv-weight-bone-2','--rv-weight-bone-3','--rv-weight-bone-4','--rv-weight-bone-5','--rv-weight-bone-6'] as const;
19
+ export const WEIGHTS_SOURCE_MISMATCH='WEIGHTS_SOURCE_MISMATCH';
20
+ export const WEIGHTS_RIG_UNAVAILABLE='WEIGHTS_RIG_UNAVAILABLE';
21
+
22
+ /** The selected slot's mesh at the setup pose: world vertices (one per stored vertex), triangles and identity. */
23
+ export interface SetupMesh {slot:string;vertices:number[];triangles:number[];identity:{skin:string;attachment:string}|null}
24
+ export type SetupMeshResult={ok:true;mesh:SetupMesh;weights:MeshWeights}|{ok:false;code:string};
25
+ /**
26
+ * The slot's attachment is the one the setup pose resolves (`readSetupPose`); its stored vertices are placed through
27
+ * the setup-pose bones (`placeSetupMesh`). The stage player is not touched.
28
+ */
29
+ export function setupPoseMesh(assets:ModelAssets,model:unknown,slot:string,root:HTMLElement,mode:RenderMode='dom'):SetupMeshResult {
30
+ const available=weightsModel(model);if(!available.ok)return available;
31
+ try{
32
+ const pose=readSetupPose(assets,slot,modelBoneNames(model),root,mode);
33
+ const result=meshWeights(model,slot,pose.identity);if(!result.ok)return result;
34
+ const vertices=placeSetupMesh(result.weights,pose.world);if(!vertices)return {ok:false,code:`${WEIGHTS_SOURCE_MISMATCH}: ${slot} has a non-finite setup-pose vertex`};
35
+ return {ok:true,weights:result.weights,mesh:{slot,vertices,triangles:result.weights.triangles,identity:pose.identity}};
36
+ }catch(error){return {ok:false,code:`${WEIGHTS_RIG_UNAVAILABLE}: ${error instanceof Error?error.message:String(error)}`};}
37
+ }
38
+
39
+ export interface WeightsOverlayInput {
40
+ weights:MeshWeights;mesh:SetupMesh;mode:WeightsMode;bone:string|null;hover:number|null;
41
+ /** Token values: the ramp stops low → high, the bone colours, and the overlay's own colours. */
42
+ ramp:readonly string[];boneColors:readonly string[];colors:OverlayWeights['colors'];
43
+ }
44
+ /** The overlay drawing: one entry per setup-pose vertex, its value and colour read from the stored weights. */
45
+ export function weightsOverlay(input:WeightsOverlayInput):{ok:true;overlay:OverlayWeights}|{ok:false;code:string} {
46
+ const {weights,mesh,mode,bone}=input,count=weights.vertices.length;
47
+ if(mesh.vertices.length!==count*2||mesh.triangles.length%3||mesh.triangles.some(i=>!Number.isSafeInteger(i)||i<0||i>=count))return {ok:false,code:`${WEIGHTS_SOURCE_MISMATCH}: ${mesh.slot} setup pose draws ${mesh.vertices.length/2} vertices; the model attachment stores ${count}`};
48
+ if(mode==='bone'&&!bone)return {ok:false,code:'WEIGHTS_SELECT_BONE: pick a bone'};
49
+ const vertices:OverlayWeights['vertices'][number][]=[];
50
+ for(let i=0;i<count;i++){
51
+ const influences=weights.vertices[i]!,at:OverlayPoint={x:mesh.vertices[i*2]!,y:mesh.vertices[i*2+1]!};
52
+ if(mode==='bone'){
53
+ const value=weightOf(influences,bone!),color=rampColor(input.ramp,value);if(!color)return {ok:false,code:'WEIGHTS_RAMP_UNAVAILABLE: the weight ramp tokens are not hex colours'};
54
+ vertices.push({at,index:i,value,color,label:`#${i} ${bone} ${weightText(value)}`});
55
+ }else{
56
+ const top=dominantInfluence(influences)!,color=input.boneColors[weights.bones.indexOf(top.bone)%input.boneColors.length];
57
+ if(!color)return {ok:false,code:'WEIGHTS_RAMP_UNAVAILABLE: no bone colour tokens'};
58
+ vertices.push({at,index:i,value:top.weight,color,label:`#${i} max ${top.bone} ${weightText(top.weight)}`});
59
+ }
60
+ }
61
+ const triangles:OverlayWeights['triangles'][number][]=[];
62
+ for(let t=0;t<mesh.triangles.length;t+=3){const points=[mesh.triangles[t]!,mesh.triangles[t+1]!,mesh.triangles[t+2]!] as const;triangles.push({points,color:meanColor(points.map(i=>vertices[i]!.color))??vertices[points[0]]!.color});}
63
+ const hover=input.hover!==null&&input.hover>=0&&input.hover<count?input.hover:null;
64
+ return {ok:true,overlay:{triangles,vertices,hover,colors:input.colors}};
65
+ }
66
+ /** Nearest projected vertex within `radius` canvas px, or null. */
67
+ export function hitVertex(overlay:OverlayWeights,project:OverlayProject,at:OverlayPoint,radius=10):number|null {
68
+ let best:number|null=null,distance=radius;
69
+ overlay.vertices.forEach((v,i)=>{const p=project(v.at.x,v.at.y),d=Math.hypot(p.x-at.x,p.y-at.y);if(Number.isFinite(d)&&d<=distance){distance=d;best=i;}});
70
+ return best;
71
+ }
72
+
73
+ export interface WeightsViewHost {
74
+ pane:HTMLElement;canvas:HTMLElement;
75
+ project:()=>OverlayProject|null;
76
+ inspector:()=>string|undefined;
77
+ slot:()=>string|null;
78
+ /** The hash-verified compiled model document the stage draws (null: the package has none; undefined: still loading). */
79
+ model:()=>unknown;
80
+ /** The selected slot's weights and setup-pose mesh (null: no rig loaded). */
81
+ setup:(slot:string)=>SetupMeshResult|null;
82
+ changed:()=>void;
83
+ token:(name:string)=>string;
84
+ }
85
+ export interface WeightsView {overlay:()=>OverlayWeights|null;refresh:()=>void;sync:()=>void;dispose:()=>void;state:()=>{show:boolean;mode:WeightsMode;bone:string|null;code:string|null;hover:number|null}}
86
+
87
+ export function bindWeightsView(host:WeightsViewHost):WeightsView {
88
+ const node=<T extends HTMLElement>(id:string)=>document.getElementById(id) as T;
89
+ const show=node<HTMLButtonElement>('weights-show'),modeSelect=node<HTMLSelectElement>('weights-mode'),boneSelect=node<HTMLSelectElement>('weights-bone');
90
+ const stateText=node<HTMLElement>('weights-state'),legend=node<HTMLElement>('weights-legend'),readout=node<HTMLElement>('weights-hover');
91
+ let on=false,hover:number|null=null,weights:MeshWeights|null=null,mesh:SetupMesh|null=null,loadCode:string|null=null,code:string|null=null,current:OverlayWeights|null=null,loaded:{slot:string|null;model:unknown}|null=null;
92
+ const mode=():WeightsMode=>modeSelect.value==='max'?'max':'bone';
93
+ const option=(value:string,label=value)=>{const o=document.createElement('option');o.value=value;o.textContent=label;return o;};
94
+ const allBones=():string[]=>modelBoneNames(host.model());
95
+ /** Re-read the slot's weights and setup mesh when the slot or the model changed. */
96
+ function load():void {
97
+ const slot=host.slot(),model=host.model();
98
+ if(loaded&&loaded.slot===slot&&loaded.model===model)return;loaded={slot,model};weights=null;mesh=null;hover=null;let code:string|null=null;
99
+ const done=()=>{loadCode=code;fillBones();};
100
+ if(model===undefined){code=`${WEIGHTS_RIG_UNAVAILABLE}: the compiled model document is still loading`;loaded=null;done();return;}
101
+ if(model===null){const result=meshWeights(model,slot,null);code=result.ok?null:result.code;done();return;}
102
+ if(!slot){code=`${WEIGHTS_SELECT_SLOT}: select one mesh slot in the parts list`;done();return;}
103
+ const setup=host.setup(slot);
104
+ if(!setup){code=`${WEIGHTS_RIG_UNAVAILABLE}: no rig loaded`;loaded=null;done();return;}
105
+ if(!setup.ok){code=setup.code;done();return;}
106
+ weights=setup.weights;mesh=setup.mesh;done();
107
+ }
108
+ function fillBones():void {
109
+ const before=boneSelect.value,influencing=weights?.bones??[],rest=allBones().filter(b=>!influencing.includes(b));
110
+ boneSelect.replaceChildren();
111
+ if(influencing.length){const g=document.createElement('optgroup');g.label=`Influencing ${weights!.slot}`;for(const b of influencing)g.append(option(b));boneSelect.append(g);}
112
+ if(rest.length){const g=document.createElement('optgroup');g.label='Other bones';for(const b of rest)g.append(option(b));boneSelect.append(g);}
113
+ const names=[...influencing,...rest];boneSelect.value=names.includes(before)?before:(influencing[0]??names[0]??'');
114
+ boneSelect.disabled=!names.length;
115
+ }
116
+ function build():void {
117
+ current=null;code=loadCode;
118
+ if(!on||!weights||!mesh){render();return;}
119
+ const token=host.token,result=weightsOverlay({weights,mesh,mode:mode(),bone:boneSelect.value||null,hover,ramp:weightRampTokens.map(token),boneColors:weightBoneTokens.map(token),
120
+ colors:{veil:token('--rv-mesh-veil'),edge:token('--rv-mesh-ink'),ink:token('--rv-mesh-ink'),labelBg:token('--rv-bust-label-bg'),hover:token('--rv-mesh-hover')}});
121
+ if(result.ok)current=result.overlay;else code=result.code;
122
+ render();
123
+ }
124
+ function render():void {
125
+ show.setAttribute('aria-pressed',String(on));show.textContent=on?'Hide weights':'Show weights';
126
+ show.disabled=!weights&&!on;boneSelect.disabled=!boneSelect.options.length||mode()==='max';
127
+ stateText.classList.toggle('error',!!code);
128
+ if(code)stateText.textContent=code;
129
+ else if(weights)stateText.textContent=`${weights.slot} / ${weights.skin} / ${weights.attachment}${weights.source.attachment!==weights.attachment||weights.source.slot!==weights.slot?` (linked to ${weights.source.slot} / ${weights.source.attachment})`:''} · ${weights.vertices.length} vertices · ${weights.weighted?`weighted over ${weights.bones.length} bone(s)`:`unweighted: every vertex is ${weights.bones[0]} at 1.0`} · setup pose${on?'':' · Show weights to draw them'}`;
130
+ else stateText.textContent='';
131
+ legend.replaceChildren();
132
+ if(weights&&mode()==='bone'){
133
+ const bar=document.createElement('span');bar.className='weights-ramp';bar.setAttribute('aria-hidden','true');
134
+ const low=document.createElement('span'),high=document.createElement('span');low.textContent='0.0';high.textContent=`1.0 · ${boneSelect.value||'no bone'}`;legend.append(low,bar,high);
135
+ }else if(weights){
136
+ weights.bones.forEach((b,i)=>{const item=document.createElement('span'),dot=document.createElement('i');item.className='weights-swatch';dot.style.background=`var(${weightBoneTokens[i%weightBoneTokens.length]})`;item.append(dot,b);legend.append(item);});
137
+ }
138
+ const h=current&&current.hover!==null?current.vertices[current.hover]:null;
139
+ readout.textContent=h&&weights?`#${h.index}: ${weights.vertices[h.index]!.map(i=>`${i.bone} ${weightText(i.weight)}`).join(' · ')}`:on&&weights?'Hover a vertex to read its stored weights':'';
140
+ }
141
+ const local=(event:PointerEvent)=>{const rect=host.canvas.getBoundingClientRect();return {x:event.clientX-rect.left,y:event.clientY-rect.top};};
142
+ const move=(event:PointerEvent)=>{
143
+ if(!on||!current)return;const project=host.project();if(!project)return;
144
+ const hit=hitVertex(current,project,local(event));if(hit===hover)return;hover=hit;build();host.changed();
145
+ };
146
+ const leave=()=>{if(hover===null)return;hover=null;build();host.changed();};
147
+ host.pane.addEventListener('pointermove',move);host.pane.addEventListener('pointerleave',leave);
148
+ const toggle=()=>{on=!on;hover=null;load();build();host.changed();};
149
+ const pick=()=>{hover=null;build();host.changed();};
150
+ show.addEventListener('click',toggle);modeSelect.addEventListener('change',pick);boneSelect.addEventListener('change',pick);
151
+ const visible=()=>host.inspector()==='rig';
152
+ return {
153
+ overlay:()=>on&&visible()?current:null,
154
+ refresh(){load();build();if(on)host.changed();},
155
+ sync(){if(!visible()&&hover!==null){hover=null;build();}},
156
+ state:()=>({show:on,mode:mode(),bone:boneSelect.value||null,code,hover}),
157
+ dispose(){host.pane.removeEventListener('pointermove',move);host.pane.removeEventListener('pointerleave',leave);show.removeEventListener('click',toggle);modeSelect.removeEventListener('change',pick);boneSelect.removeEventListener('change',pick);},
158
+ };
159
+ }