@rig-c/review 0.8.1 → 0.10.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/web/main.ts CHANGED
@@ -6,7 +6,9 @@ import {rendererStatus} from './renderer-status.ts';
6
6
  import {bindAppearance,bindPan,bindStageLayout,syncStageLayout,bindInspectorTabs,idleAvailable,initialIdleAnimation,automaticIdlePreview,bindLayerShortcuts,describeLayerToggles,type ViewPoint} from './view-controls.ts';
7
7
  import {RigOverlays,type OverlayCircle,type OverlayMarker,type OverlayRegion,type OverlaySnapshot} from './rig-overlays.ts';
8
8
  import {bindMeshPanel,type MeshPanel} from './mesh-panel.ts';
9
- import {assertMeshPatch,countsText,meshApplyOutcome,meshPatchOps,triangleAgreement,type MeshEntry} from '../src/mesh-entry.ts';
9
+ import {bindDensityStrip,type DensityStripPanel,type DensityStripViewLike} from './density-strip-panel.ts';
10
+ import {densityField,densityPath} from '../src/density-strip.ts';
11
+ import {assertMeshPatch,countsText,meshApplyOutcome,meshPatchOps,setEntryValue,triangleAgreement,type MeshEntry} from '../src/mesh-entry.ts';
10
12
  import {worstLine} from '../src/readings.ts';
11
13
  import {chestOverlayCircles,chestOverlayPolygons,chestRegionRows,chestRowId,evaluateChestForm} from './chest-regions-tool.ts';
12
14
  import {bindChestEditor,canvasToCrop,bustLabel,bustSides,LANDMARKS,landmarkInputId,rowCircle,type ChestEditor,type LandmarkKey} from './chest-editor.ts';
@@ -19,7 +21,7 @@ import {bindClothPanel,type ClothPanel} from './cloth-panel.ts';
19
21
  import {retargetAppliedRecord,retargetOutcomeLine,type AppliedRetarget,type RetargetJobView,type RetargetRequestBody} from './rebuild-client.ts';
20
22
  import {bindRetargetPanel,type PoseMap,type RetargetPanel} from './retarget-panel.ts';
21
23
  import type {RetargetFlags} from '../src/retarget.ts';
22
- import {appliedRecord,deltaAppliedRecord,deltaOutcomeLine,designatedRegions,handoffText,landmarksRequest,seedLandmarks,makePatch,outcomeLine,patchAppliedRecord,patchOutcomeLine,pickPart,runRebuild,seedFields,stageModel,type AppliedDelta,type AppliedPatch,type AppliedRecord,type DeltaJobView,type DeltaRequestBody,type ConfigPatch,type PatchJobView,type RebuildJobView,type RebuildRefusal,type RebuildRequestBody,type RebuildStatusView} from './rebuild-client.ts';
24
+ import {historyRow,isPatchJob,recordFor,refusedText,undoControl,type AppliedHistoryView,type UndoView,type AnyJobView,appliedRecord,deltaAppliedRecord,deltaOutcomeLine,designatedRegions,handoffText,landmarksRequest,seedLandmarks,makePatch,outcomeLine,patchAppliedRecord,patchOutcomeLine,pickPart,runRebuild,seedFields,stageModel,type AppliedDelta,type AppliedPatch,type AppliedRecord,type DeltaJobView,type DeltaRequestBody,type ConfigPatch,type PatchJobView,type RebuildJobView,type RebuildRefusal,type RebuildRequestBody,type RebuildStatusView} from './rebuild-client.ts';
23
25
  import {meshEditBinding,meshEditText,type MeshEditDocument} from '../src/mesh-edit.ts';
24
26
  import {chestRegionsBinding,cropWorldMapping,type CropWorldMapping} from '../src/chest-regions.ts';
25
27
  import {bindWorkspaceUI,selectedInspector,type WorkspaceUI} from './workspace-ui.ts';
@@ -48,6 +50,7 @@ import {barLine,beforeLine} from '../src/physics-tuning.ts';
48
50
  import {bindRequirementsPanel,type RequirementsCatalogView,type RequirementsJobView,type RequirementsPanel,type RequirementsRefusal} from './requirements-panel.ts';
49
51
  import {patchRoute} from '../src/retune-route.ts';
50
52
  import {appliedCheckRefusal,type CheckSummary} from '../src/applied-context.ts';
53
+ import {appliedBlockOps,recordBlock} from '../src/applied-blocks.ts';
51
54
  import {bindPhysicsPanel,type PhysicsPanel} from './physics-panel.ts';
52
55
  import {bindIdlePanel,type BoneSetup,type IdlePanel} from './idle-panel.ts';
53
56
  import {bindRigFieldsPanel,type RigFieldsPanel} from './rig-fields-panel.ts';
@@ -149,7 +152,7 @@ let idlePanel:IdlePanel|null=null;
149
152
  let rigFieldsPanel:RigFieldsPanel|null=null,idleAdjust:BoneAdjust|null=null,dialAdjust:BoneAdjust|null=null;
150
153
  function setAdjust():void {if(!player)return;const a=idleAdjust,b=dialAdjust;player.adjust=a&&b?bones=>{a(bones);b(bones);}:a??b;if(!playing)drawRig(()=>player!.draw());}
151
154
  /** #106: the Setup Mesh panel, and the overlay's last snapshot (bar 1 reads its per-slot triangle count). */
152
- let meshPanel:MeshPanel|null=null,lastOverlay:OverlaySnapshot|null=null;
155
+ let meshPanel:MeshPanel|null=null,lastOverlay:OverlaySnapshot|null=null,densityStrip:DensityStripPanel|null=null;
153
156
  let physicsPanel:PhysicsPanel|null=null,inspectedDocument:unknown=null,inspectedDocumentReason:string|null=null,stageCheck:{lines:string[];check:CheckSummary|null}|null=null;
154
157
  /** Animate · Readings (#108): the block, the last finished job (its ticks and worst lines), and the run in flight. */
155
158
  let requirementsPanel:RequirementsPanel|null=null,retargetPanel:RetargetPanel|null=null;
@@ -895,9 +898,11 @@ function bind(): void {
895
898
  bonePoints:bones=>{if(!player)return null;const project=rigProject(),pane=el('rig-pane').getBoundingClientRect(),view=el('rig-root').parentElement!.getBoundingClientRect(),out=new Map<string,{x:number;y:number}>();for(const name of bones){try{const b=player.bone(name),p=project(b.x,b.y);out.set(name,{x:p.x+view.left-pane.left,y:p.y+view.top-pane.top});}catch{/* a bone this model lacks is not pickable */}}return out;}});
896
899
  viewDisposers.push(()=>physicsPanel?.dispose());
897
900
  meshPanel=bindMeshPanel({pane:el('rig-pane'),project:()=>player?rigProject():null,mapping:()=>chestMapping,bones:()=>data.feedbackCatalog.bones??[],
898
- apply:async(part,entry)=>{await applyMesh(part,entry);},reset:resetMeshBuild,folds:async()=>{await meshFolds();},changed:()=>redrawOverlay(false),saved:()=>persistence?.save(),
901
+ apply:async(part,entry)=>{await applyMesh(part,entry);},reset:resetMeshBuild,folds:async()=>{await meshFolds();},changed:()=>{redrawOverlay(false);densityStrip?.changed();},saved:()=>persistence?.save(),
899
902
  token:name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim()});
900
903
  viewDisposers.push(()=>meshPanel?.dispose());
904
+ // #173: the density strip beside the Mesh panel's density field.
905
+ densityStrip=bindDensityStrip({part:()=>meshPanel?.part()??null,entry:()=>meshPanel?.entry()??null,build:async values=>{await buildDensityStrip(values);},load:async i=>{await loadStripRow(i);},back:async()=>{await stripBack();},close:async()=>{await closeDensityStrip();}});
901
906
  idlePanel=bindIdlePanel({apply:async(ops,keys)=>{await applyIdle(ops,keys);},reset:resetIdleBuild,pane:el('rig-pane'),saved:()=>persistence?.save(),clock:()=>player?.time??0,
902
907
  preview:fn=>{idleAdjust=fn as BoneAdjust|null;setAdjust();},
903
908
  bonePoints:bones=>{if(!player)return null;const project=rigProject(),out=new Map<string,{x:number;y:number}>();for(const name of bones){try{const b=player.bone(name);out.set(name,project(b.x,b.y));}catch{/* a bone this model lacks has no handle */}}return out;}});
@@ -955,7 +960,7 @@ async function start(): Promise<void> {
955
960
  else if (readOnly) message(readOnly, true);
956
961
  else message(data.manifest.model ? 'Record review status and structured feedback while preserving sources. Motion is read only.' : 'No rig supplied. Review the available references and checkpoints.');
957
962
  requestAnimationFrame(tick);
958
- const restored=await setupPersistence();await loadRebuildStatus();void loadRequirements();renderChest();restoreEnvironment();physicsPanel?.restore();bonesPanel?.restore();cutsPanel?.restore();clothPanel?.restore();idlePanel?.restore();rigFieldsPanel?.restore();meshPanel?.restore();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
963
+ const restored=await setupPersistence();await loadRebuildStatus();void loadRequirements();renderChest();restoreEnvironment();physicsPanel?.restore();bonesPanel?.restore();cutsPanel?.restore();clothPanel?.restore();idlePanel?.restore();rigFieldsPanel?.restore();meshPanel?.restore();void refreshDensityStrip();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
959
964
  // Only an instance that serves agent control is asked for its config: a plain open has no control endpoint (#164).
960
965
  if(!readOnly&&writes.control!==false)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'single',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
961
966
  } catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
@@ -1271,6 +1276,7 @@ function bindEnvironment():void {
1271
1276
  button('wind-gust').onclick=()=>engage(()=>wind.triggerGust());
1272
1277
  button('jolt').onclick=()=>{if(!player)return;joltQueue.push(...joltPacket(wind.read().time));if(!playing)message('Jolt queued · it reaches the physics on the next steps; press Play.');};
1273
1278
  button('physics-reset').onclick=resetPhysics;
1279
+ button('applied-undo').onclick=()=>{void undoApply();};
1274
1280
  input('environment-use').onchange=()=>{
1275
1281
  if(!input('environment-use').checked){message('Use for readings is off: readings send the standing wind and gravity, Apply\'s check reads the default world.');renderSchedule();return;}
1276
1282
  const world=worldFor(animation);
@@ -1441,7 +1447,66 @@ function renderHandoff(status:RebuildStatusView):void {
1441
1447
  if(handoffTimer)clearTimeout(handoffTimer);handoffTimer=null;
1442
1448
  if(status.baseline?.status==='running')handoffTimer=setTimeout(()=>{void api<RebuildStatusView>('/api/rebuild').then(next=>{rebuildStatus=next;renderHandoff(next);}).catch(()=>{});},2000);
1443
1449
  }
1444
- function rebuildState():void {chestEditor?.rebuildState({busy:rebuildBusy,applied:!!appliedOnStage});}
1450
+ function rebuildState():void {chestEditor?.rebuildState({busy:rebuildBusy,applied:!!appliedOnStage});renderApplied();if(!rebuildBusy)void refreshApplied();}
1451
+ /** #166: the session's applied records (the server's stack), the last one on the stage when the stage shows it. */
1452
+ let appliedHistory:AppliedHistoryView|null=null;
1453
+ async function refreshApplied():Promise<void> {
1454
+ if(!inspectedModel)return;
1455
+ try{appliedHistory=await api<AppliedHistoryView>('/api/rebuild/history');}catch{return;}
1456
+ renderApplied();
1457
+ }
1458
+ /** #172: the Cuts and Cloth blocks mark as applied only the ops the stage's applied record holds (none on the inspected build). */
1459
+ function renderAppliedBlocks():void {const on=appliedBlockOps(appliedOnStage);cutsPanel?.applied(on.cuts);clothPanel?.applied(on.cloth);}
1460
+ function renderApplied():void {
1461
+ renderAppliedBlocks();
1462
+ const section=document.getElementById('applied-records');if(!section)return;
1463
+ const h=appliedHistory;section.hidden=!h||h.depth===0;
1464
+ const list=el('applied-list');list.replaceChildren(...(h?.records??[]).map((r,i)=>{const li=document.createElement('li');li.textContent=historyRow(h!,i);li.dataset.buildId=r.id;li.classList.toggle('on-stage',i===h!.records.length-1&&appliedOnStage?.buildId===r.id);return li;}));
1465
+ const control=undoControl(h,appliedOnStage?.buildId??null,rebuildBusy||!!readOnly),undo=button('applied-undo');
1466
+ undo.textContent=control.label;undo.disabled=!control.enabled;undo.title=control.title;
1467
+ }
1468
+ function appliedStatus(text:string,error=false):void {const node=el('applied-status');node.textContent=text;node.classList.toggle('error',error);}
1469
+ /**
1470
+ * #166: Undo last Apply — the last record leaves the server's stack and the stage returns to its predecessor's build
1471
+ * (kept on disk, or re-run from its record when evicted) or to the inspected build, under the same world. The feedback
1472
+ * context then carries the post-undo record (or null), never the reverted one. A refusal leaves the stage as it was.
1473
+ */
1474
+ async function undoApply():Promise<UndoView|null> {
1475
+ const from=appliedOnStage?.buildId;
1476
+ if(rebuildBusy||readOnly||!from||!inspectedModel)return null;
1477
+ const world=stageWorld();
1478
+ rebuildBusy=true;rebuildState();appliedStatus('Undoing the last Apply…');
1479
+ try{
1480
+ let result=await api<UndoView>('/api/rebuild/undo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from})});
1481
+ if(result.status==='running'){
1482
+ // An evicted predecessor re-runs its recorded step; its lines stream like an Apply's.
1483
+ let job=result.job as AnyJobView;const undone=result.undone;let seen=0;
1484
+ while(job.status==='running'){await new Promise(resolve=>setTimeout(resolve,700));job=await api<AnyJobView>(`/api/rebuild/${job.id}`);for(;seen<job.lines.length;seen++)appliedStatus(job.lines[seen]!.trim()||'…');}
1485
+ if(job.status!=='built'){const text=refusedText(job);appliedStatus(text,true);message(text,true);return {status:'refused',error:text,producerLine:job.producerLine};}
1486
+ result={status:'built',undone,job,history:await api<AppliedHistoryView>('/api/rebuild/history')};
1487
+ }
1488
+ if(result.status==='refused'){appliedStatus(result.error,true);message(result.error,true);return result;}
1489
+ let ops:import('../src/config-patch.ts').PatchOp[]|null=null;
1490
+ if(result.status==='inspected'){await swapRig(inspectedModel.url,inspectedModel.prefix,inspectedModel.sha256,world);appliedOnStage=null;stageCheck=null;}
1491
+ else{
1492
+ const job=result.job;if(!job.build)throw new Error('the restored job carries no build');
1493
+ await swapRig(new URL(access.route(job.build.modelUrl),location.href).href,access.route(`/rebuild/${job.id}/`),job.build.modelHash,world);
1494
+ appliedOnStage=recordFor(job);stageCheck=(job as PatchJobView).kind==='patch'?{lines:[...job.lines],check:(job as PatchJobView).check}:null;
1495
+ if((job as PatchJobView).kind==='patch')ops=(job as PatchJobView).request.patch.ops;
1496
+ }
1497
+ // The panels mark the ops the stage's build applied; the pending edits stay as they are.
1498
+ physicsPanel?.applied(ops);idlePanel?.applied(ops);rigFieldsPanel?.applied(ops);bonesPanel?.applied(ops);
1499
+ // #172: the Cuts and Cloth readouts show the restored build's job when it is theirs, else nothing (never the reverted one).
1500
+ const restored=result.status==='built'&&isPatchJob(result.job)?result.job:null,block=recordBlock(appliedOnStage);
1501
+ cutsPanel?.shown(block==='cuts'?restored:null);clothPanel?.shown(block==='cloth'?restored:null);
1502
+ const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=appliedOnStage?JSON.stringify(appliedOnStage):'';draft.dispatchEvent(new Event('input',{bubbles:true}));
1503
+ appliedHistory=result.history;
1504
+ const text=`Undid ${result.undone.slice(0,12)}… · the stage is ${result.status==='inspected'?`the inspected build (model ${inspectedModel.sha256.slice(0,12)}…)`:`record ${result.history.depth} (model ${stageModelHash?.slice(0,12)}…)`} · ${result.history.depth} applied record(s) left`;
1505
+ appliedStatus(text);message(text);redrawOverlay(true);
1506
+ return result;
1507
+ }catch(error){const text=`UNDO_FAILED: ${errorText(error)}`;appliedStatus(text,true);message(text,true);return {status:'refused',error:text,producerLine:null};}
1508
+ finally{rebuildBusy=false;rebuildState();}
1509
+ }
1445
1510
  /** Apply · Rebuild: the rows go to the installed producer; on success its scratch build replaces the drawn rig in place. */
1446
1511
  async function applyRebuild():Promise<void> {
1447
1512
  if(rebuildBusy)return;
@@ -1504,6 +1569,8 @@ async function applyPatch(patch:ConfigPatch,world:StageWorld|null=null):Promise<
1504
1569
  /** The client's edit entry point for automation and the edit cards to come: apply a patch, return to the inspected build, read the stage. */
1505
1570
  (window as unknown as {rigReviewEditor:unknown}).rigReviewEditor={
1506
1571
  applyPatch,reset:()=>resetRebuild(),
1572
+ /** #166: undo the last Apply, and the applied records as the server holds them. */
1573
+ undo:()=>undoApply(),history:()=>appliedHistory?JSON.parse(JSON.stringify(appliedHistory)):null,
1507
1574
  status:async()=>rebuildStatus??await loadRebuildStatus(),
1508
1575
  physics:{rows:()=>physicsPanel?.rows()??[],chains:()=>physicsPanel?.chains()??[],edits:()=>physicsPanel?.edits()??[],set:(name:string,field:Parameters<PhysicsPanel['set']>[1],value:number)=>physicsPanel?.set(name,field,value),ramp:(id:string,field:Parameters<PhysicsPanel['set']>[1],root:number,tip:number)=>physicsPanel?.ramp(id,field,root,tip),select:(name:string)=>physicsPanel?.select(name),apply:()=>applyPhysics(physicsPanel?.edits().map(e=>({op:e.op,path:e.path,value:e.value,name:e.name}))??[]),reset:()=>resetPhysicsBuild()},
1509
1576
  /** Inspection only: one slot's posed world geometry on the drawn frame (what the stage drew), for comparing against `rigc render --geometry`. */
@@ -1547,14 +1614,17 @@ async function applyPatch(patch:ConfigPatch,world:StageWorld|null=null):Promise<
1547
1614
  mesh:{part:()=>meshPanel?.part()??null,select:(part:string)=>meshPanel?.select(part),entry:()=>meshPanel?.entry()??null,configEntry:()=>meshPanel?.configEntry()??null,set:(path:string[],value:import('../src/config-patch.ts').JsonValue|undefined)=>meshPanel?.set(path,value),setEntry:(entry:MeshEntry)=>meshPanel?.setEntry(entry),
1548
1615
  regions:()=>meshPanel?.regions.list()??[],addRegion:(region:import('../src/mesh-entry.ts').MeshRegion)=>meshPanel?.regions.add(region),moveRegion:(region:number,vertex:number,to:[number,number])=>meshPanel?.regions.move(region,vertex,to),drawRegions:(on:boolean)=>meshPanel?.regions.draw(on),placing:()=>meshPanel?.regions.placing()??null,
1549
1616
  precheck:()=>meshPanel?.precheck()??[],apply:()=>{const part=meshPanel?.part(),entry=meshPanel?.entry();return part&&entry?applyMesh(part,entry):Promise.resolve(null);},reset:()=>resetMeshBuild(),folds:()=>meshFolds(),
1550
- overlayTriangles:(slot:string)=>lastOverlay?.meshTriangles[slot]??null,status:()=>el('mesh-status').textContent,counts:()=>[...document.querySelectorAll('#mesh-counts code')].map(c=>c.textContent),readout:()=>[...document.querySelectorAll('#mesh-readout code')].map(c=>c.textContent),pending:()=>input('mesh-pending').value,lastPatch:()=>lastMeshPatch?JSON.parse(JSON.stringify(lastMeshPatch)):null},
1617
+ overlayTriangles:(slot:string)=>lastOverlay?.meshTriangles[slot]??null,status:()=>el('mesh-status').textContent,counts:()=>[...document.querySelectorAll('#mesh-counts code')].map(c=>c.textContent),readout:()=>[...document.querySelectorAll('#mesh-readout code')].map(c=>c.textContent),pending:()=>input('mesh-pending').value,lastPatch:()=>lastMeshPatch?JSON.parse(JSON.stringify(lastMeshPatch)):null,
1618
+ /** #173: the density strip — build N values, the view, load a row as pending, back, close. */
1619
+ strip:{build:(values:number[])=>buildDensityStrip(values),view:()=>stripView?JSON.parse(JSON.stringify(stripView)):null,load:(i:number)=>loadStripRow(i),back:()=>stripBack(),close:()=>closeDensityStrip(),pending:()=>stripPending?{index:stripPending.index,candidate:stripPending.candidate}:null,
1620
+ applied:()=>appliedOnStage?JSON.parse(JSON.stringify(appliedOnStage)):null,stageHash:()=>stageModelHash}},
1551
1621
  /** #115: the Cuts block — its handles as client pixels (for a real-pointer drag), its ops, Apply and Reset. */
1552
1622
  cuts:{source:()=>cutsPanel?.source()??null,ops:()=>cutsPanel?.ops()??[],handles:()=>cutsPanel?.handles()??[],active:()=>cutsPanel?.active()??false,setActive:(on:boolean)=>cutsPanel?.setActive(on),
1553
1623
  setVertex:(cut:number,k:number,value:[number,number])=>cutsPanel?.setVertex(cut,k,value),setBox:(i:number,value:[number,number,number,number])=>cutsPanel?.setBox(i,value),setRange:(limb:string,k:number,value:[number,number]|null)=>cutsPanel?.setRange(limb,k,value),
1554
- apply:()=>cutsPanel?.apply()??Promise.resolve(null),reset:()=>resetCutsBuild()},
1624
+ apply:()=>cutsPanel?.apply()??Promise.resolve(null),reset:()=>resetCutsBuild(),applied:()=>cutsPanel?.appliedItems()??[]},
1555
1625
  /** #111: the Cloth block — the selected tag, its form values, the pending ops, the link preview, Apply and Reset. */
1556
1626
  cloth:{tag:()=>clothPanel?.tag()??'',select:(tag:string)=>clothPanel?.select(tag),values:(tag?:string)=>clothPanel?.values(tag)??{},set:(key:string,text:string,tag?:string)=>clothPanel?.set(key,text,tag),declare:(on:boolean,tag?:string)=>clothPanel?.declare(on,tag),
1557
- ops:()=>clothPanel?.ops()??[],links:()=>clothPanel?.links()??null,active:()=>clothPanel?.active()??false,setActive:(on:boolean)=>clothPanel?.setActive(on),apply:()=>clothPanel?.apply()??Promise.resolve(null),reset:()=>resetClothBuild(),status:()=>el('cloth-status').textContent,readout:()=>[...document.querySelectorAll('#cloth-readout code')].map(c=>c.textContent)},
1627
+ ops:()=>clothPanel?.ops()??[],links:()=>clothPanel?.links()??null,active:()=>clothPanel?.active()??false,setActive:(on:boolean)=>clothPanel?.setActive(on),apply:()=>clothPanel?.apply()??Promise.resolve(null),reset:()=>resetClothBuild(),applied:()=>clothPanel?.appliedItems()??[],status:()=>el('cloth-status').textContent,readout:()=>[...document.querySelectorAll('#cloth-readout code')].map(c=>c.textContent)},
1558
1628
  bones:{anchors:()=>bonesPanel?.anchors()??[],edits:()=>bonesPanel?.edits()??[],set:(path:string,value:[number,number])=>bonesPanel?.set(path,value),select:(name:string|null)=>bonesPanel?.select(name),
1559
1629
  active:()=>bonesPanel?.active()??false,setActive:(on:boolean)=>bonesPanel?.setActive(on),handles:()=>bonesPanel?.handles()??[],check:()=>bonesPanel?.check()??Promise.resolve(null),
1560
1630
  apply:()=>applyBones((bonesPanel?.edits()??[]).map(e=>({op:'replace' as const,path:e.path,value:[...e.value],name:e.name}))),reset:()=>resetBonesBuild(),
@@ -1821,9 +1891,9 @@ function sampleLocals(bone:string,dt:number,n:number):{t:number;rotation:number;
1821
1891
  return [...rows.values()].sort((a,b)=>a.t-b.t);
1822
1892
  }
1823
1893
  async function resetRebuild(world:StageWorld|null=null):Promise<void> {
1824
- if(rebuildBusy||!appliedOnStage||!inspectedModel)return;
1894
+ if(rebuildBusy||(!appliedOnStage&&!stripPending)||!inspectedModel)return;
1825
1895
  rebuildBusy=true;rebuildState();
1826
- try{await swapRig(inspectedModel.url,inspectedModel.prefix,inspectedModel.sha256,world);appliedOnStage=null;stageCheck=null;chestEditor?.notify('Reset: the inspected build is on the stage. The draft keeps the last applied state.',false);message('Reset to the inspected build.');}
1896
+ try{await swapRig(inspectedModel.url,inspectedModel.prefix,inspectedModel.sha256,world);appliedOnStage=null;stageCheck=null;if(stripPending){stripPending=null;renderStrip();}chestEditor?.notify('Reset: the inspected build is on the stage. The draft keeps the last applied state.',false);message('Reset to the inspected build.');}
1827
1897
  catch(error){chestEditor?.notify(`REBUILD_RESET_FAILED: ${errorText(error)}`,true);}
1828
1898
  finally{rebuildBusy=false;rebuildState();}
1829
1899
  }
@@ -1831,10 +1901,11 @@ async function resetRebuild(world:StageWorld|null=null):Promise<void> {
1831
1901
  * Replace the drawn rig in place: the new model loads first (a failure leaves the stage as it was), then the player is
1832
1902
  * swapped and the same animation, time, set, camera and environment are restored; playback resumes when it was running.
1833
1903
  */
1904
+ let stageSource:{url:string;prefix:string;hash:string}|null=null;
1834
1905
  async function swapRig(modelUrl:string,prefix:string,sha256:string,world:StageWorld|null=null):Promise<void> {
1835
1906
  const keepAnimation=animation,keepTime=player?.time??time,wasPlaying=playing,keepSet=select('part-set').disabled?'':select('part-set').value;
1836
1907
  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;}});
1837
- pause();player?.dispose();assets?.dispose();assets=next;player=new RigPlayer(el('rig-root'),assets);stageModelHash=sha256;
1908
+ pause();player?.dispose();assets?.dispose();assets=next;player=new RigPlayer(el('rig-root'),assets);stageModelHash=sha256;stageSource={url:modelUrl,prefix,hash:sha256};
1838
1909
  // A scratch build may hold another set of animations (a retarget adds one, #114): the picker lists what this build has.
1839
1910
  {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;}
1840
1911
  player.on('event',event=>{text('event-log',`Runtime event: ${event.name} · ${event.time.toFixed(2)} s`);});
@@ -1873,6 +1944,8 @@ async function applyMesh(part:string,entry:MeshEntry):Promise<PatchJobView|Rebui
1873
1944
  const result=await applyPatch(patch,world);
1874
1945
  const outcome=meshApplyOutcome(result as Parameters<typeof meshApplyOutcome>[0]);meshPanel?.status(outcome.text,outcome.error);
1875
1946
  if('id' in result&&result.status==='built'){
1947
+ // #173: a strip row on the stage as pending is now (or was replaced by) an applied record; Back has nothing to return to.
1948
+ if(stripPending){stripPending=null;void refreshDensityStrip();}
1876
1949
  // The new triangulation is drawn: the mesh edges go on and the overlay captures the reloaded pose.
1877
1950
  if(!layers.mesh){layers.mesh=true;input('layer-mesh').checked=true;}
1878
1951
  if(player)drawRig(()=>player!.draw());redrawOverlay(true);
@@ -1891,9 +1964,94 @@ async function applyMesh(part:string,entry:MeshEntry):Promise<PatchJobView|Rebui
1891
1964
  /** Mesh Reset: the inspected build back on the stage (same world); the panel already holds the config's entry again. */
1892
1965
  async function resetMeshBuild():Promise<void> {
1893
1966
  meshPanel?.counts([]);meshPanel?.readout([]);
1894
- if(appliedOnStage){meshPanel?.busy(true);try{await resetRebuild(stageWorld());}finally{meshPanel?.busy(false);}}
1967
+ if(appliedOnStage||stripPending){meshPanel?.busy(true);try{await resetRebuild(stageWorld());}finally{meshPanel?.busy(false);}}
1895
1968
  meshPanel?.status('Reset: the inspected build is on the stage; the pending entry is the config\'s.',false);
1896
1969
  }
1970
+ /**
1971
+ * #173 · Density strip. Build: one Mesh-panel patch per value (the pending entry with its density field at that value),
1972
+ * sent together to `POST /api/density-strip` and polled until every candidate settles. Load: a built row's revision on
1973
+ * the stage as pending — the applied record, the undo stack and the feedback context's applied state are left alone
1974
+ * (the context reads null while a pending row is on the stage: nothing applied is on it), and Undo is unavailable until
1975
+ * Back returns the stage to what it showed or the Mesh panel's Apply applies the row (reusing its build). Close removes
1976
+ * the strip's scratch.
1977
+ */
1978
+ let stripView:DensityStripViewLike|null=null,densityBusy=false;
1979
+ let stripPending:{index:number;id:string;candidate:string;back:{url:string;prefix:string;hash:string;applied:AppliedRecord|AppliedPatch|AppliedDelta|AppliedRetarget|null;check:{lines:string[];check:CheckSummary|null}|null}}|null=null;
1980
+ const densityLine=(text:string,error=false)=>{densityStrip?.status(text,error);if(error)message(text,true);};
1981
+ const renderStrip=()=>densityStrip?.show(stripView,stripPending&&stripView?.id===stripPending.id?stripPending.index:null,densityBusy||rebuildBusy||!!readOnly);
1982
+ async function refreshDensityStrip():Promise<void>{try{const r=await api<{strip:DensityStripViewLike|null}>('/api/density-strip');stripView=r.strip;}catch{/* keep the last view */}renderStrip();}
1983
+ async function buildDensityStrip(values:number[]):Promise<DensityStripViewLike|{error:string}>{
1984
+ if(densityBusy)return {error:'DENSITY_STRIP_BUSY: a strip is still building'};
1985
+ if(readOnly){densityLine(readOnly,true);return {error:readOnly};}
1986
+ const status=rebuildStatus??await loadRebuildStatus(),part=meshPanel?.part()??null,entry=meshPanel?.entry()??null;
1987
+ if(!status||!part||!entry){const t=status?.reason??'MESH_PANEL_NO_PART: select a mesh part';densityLine(t,true);return {error:t};}
1988
+ const problems=meshPanel?.precheck()??[];if(problems.length){const t=`${problems[0]} (review pre-check; nothing sent)`;densityLine(t,true);return {error:t};}
1989
+ let patches:ConfigPatch[];
1990
+ try{const field=densityField(entry,part);patches=values.map(v=>{const patch=makePatch(status,meshPatchOps(part,setEntryValue(entry,densityPath(field),v)),{cmd:'remesh',args:['--part',part]});if(!patch)throw new Error(status.reason??'REBUILD_STATUS_UNAVAILABLE');assertMeshPatch(patch,part);return patch;});}
1991
+ catch(error){const t=errorText(error);densityLine(t,true);return {error:t};}
1992
+ // A pending row goes back first: the strip it came from is about to be replaced.
1993
+ if(stripPending)await stripBack();
1994
+ densityBusy=true;renderStrip();densityLine(`Building a strip of ${values.length} on ${part}: ${values.join(', ')} — remesh, compile and remesh's own check per value…`);
1995
+ const t0=performance.now();
1996
+ try{
1997
+ let view:DensityStripViewLike|{status:string;error:string};
1998
+ try{view=await api<DensityStripViewLike|{status:string;error:string}>('/api/density-strip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({patches})});}
1999
+ catch(error){const t=errorText(error);densityLine(t,true);return {error:t};}
2000
+ if('error' in view){densityLine(view.error,true);return {error:view.error};}
2001
+ stripView=view;renderStrip();
2002
+ while(stripView.status==='running'){await new Promise(resolve=>setTimeout(resolve,1500));stripView=await api<DensityStripViewLike>(`/api/density-strip/${stripView.id}`);renderStrip();
2003
+ const settled=stripView.rows.filter(r=>r.status!=='running').length;densityLine(`Strip ${stripView.id.slice(0,12)}…: ${settled} of ${stripView.rows.length} settled · ${((performance.now()-t0)/1000).toFixed(0)} s`);}
2004
+ const passing=stripView.rows.filter(r=>r.verdict==='PASS').length;
2005
+ densityLine(`Strip of ${stripView.rows.length} on ${stripView.part} settled in ${((stripView.wallMs??0)/1000).toFixed(1)} s (at most ${stripView.concurrency} at once): ${passing} passing, ${stripView.rows.length-passing} refused or failing. Labels only — nothing is applied; Load puts a row on the stage as pending.`);
2006
+ return stripView;
2007
+ }finally{densityBusy=false;renderStrip();}
2008
+ }
2009
+ async function loadStripRow(index:number):Promise<{line:string;ok:boolean}|{error:string}>{
2010
+ const view=stripView,row=view?.rows[index];
2011
+ if(!view||!row)return {error:'DENSITY_STRIP_NOT_FOUND'};
2012
+ if(!row.modelUrl||!row.modelHash){const t=`DENSITY_STRIP_ROW_NOT_LOADABLE: ${row.value} — ${row.applied?'applied already':row.verdict}`;densityLine(t,true);return {error:t};}
2013
+ if(rebuildBusy||densityBusy)return {error:'REBUILD_BUSY'};
2014
+ const back=stripPending?.back??{url:stageSource?.url??inspectedModel!.url,prefix:stageSource?.prefix??inspectedModel!.prefix,hash:stageSource?.hash??inspectedModel!.sha256,applied:appliedOnStage,check:stageCheck};
2015
+ rebuildBusy=true;rebuildState();
2016
+ try{
2017
+ await swapRig(new URL(access.route(row.modelUrl),location.href).href,access.route(`/density-strip/${view.id}/${row.id}/`),row.modelHash,stageWorld());
2018
+ stripPending={index,id:view.id,candidate:row.id,back};
2019
+ // Pending, not applied: no record, no undo entry; the feedback context names no applied state while it is on the stage.
2020
+ appliedOnStage=null;stageCheck=null;
2021
+ const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value='';draft.dispatchEvent(new Event('input',{bubbles:true}));
2022
+ meshPanel?.setEntry(row.entry as MeshEntry);
2023
+ if(!layers.mesh){layers.mesh=true;input('layer-mesh').checked=true;}
2024
+ if(player)drawRig(()=>player!.draw());redrawOverlay(true);
2025
+ const c=row.counts?{vertices:row.counts.vertices,triangles:row.counts.triangles,hull:0}:null;
2026
+ const agree=triangleAgreement(c?{part:view.part,before:c,after:c}:null,row.modelTriangles,lastOverlay?.meshTriangles[view.part]??null);
2027
+ meshPanel?.counts([{label:'PENDING',text:`${view.field} ${row.value} · ${row.counts?`v ${row.counts.vertices} · t ${row.counts.triangles}`:'no counts'} (remesh.json) — not applied`},{label:agree.ok?'AGREE':'DIFFER',text:agree.line,error:!agree.ok}]);
2028
+ const line=`Pending on the stage: ${view.part} ${view.field} ${row.value} (strip row ${index+1}) — not applied. The Mesh panel's Apply applies it; Back returns the stage. ${agree.line}`;
2029
+ densityLine(line,!agree.ok);
2030
+ return {line:agree.line,ok:agree.ok};
2031
+ }catch(error){const t=`DENSITY_STRIP_LOAD_FAILED: ${errorText(error)}`;densityLine(t,true);return {error:t};}
2032
+ finally{rebuildBusy=false;rebuildState();renderStrip();}
2033
+ }
2034
+ async function stripBack():Promise<void>{
2035
+ const pending=stripPending;if(!pending||rebuildBusy)return;
2036
+ rebuildBusy=true;rebuildState();
2037
+ try{
2038
+ await swapRig(pending.back.url,pending.back.prefix,pending.back.hash,stageWorld());
2039
+ appliedOnStage=pending.back.applied;stageCheck=pending.back.check;stripPending=null;
2040
+ const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=appliedOnStage?JSON.stringify(appliedOnStage):'';draft.dispatchEvent(new Event('input',{bubbles:true}));
2041
+ meshPanel?.counts([]);redrawOverlay(true);
2042
+ densityLine(`Back: the stage shows ${appliedOnStage?`the last applied record (model ${pending.back.hash.slice(0,12)}…)`:`the inspected build (model ${pending.back.hash.slice(0,12)}…)`}.`);
2043
+ }catch(error){densityLine(`DENSITY_STRIP_BACK_FAILED: ${errorText(error)}`,true);}
2044
+ finally{rebuildBusy=false;rebuildState();renderStrip();}
2045
+ }
2046
+ async function closeDensityStrip():Promise<{status:string;error?:string}>{
2047
+ const view=stripView;if(!view)return {status:'closed'};
2048
+ if(stripPending&&stripPending.id===view.id)await stripBack();
2049
+ try{
2050
+ const r=await api<{status:string;error?:string}>('/api/density-strip/close',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:view.id})});
2051
+ if(r.status!=='closed'){densityLine(r.error??'DENSITY_STRIP_CLOSE_REFUSED',true);return r;}
2052
+ stripView=null;renderStrip();densityLine('Strip closed; its scratch is removed.');return r;
2053
+ }catch(error){const t=errorText(error);densityLine(t,true);return {status:'refused',error:t};}
2054
+ }
1897
2055
  /** The folds reading (#108) of the drawn animation, on the inspected build and on the latest scratch build: one line per build. */
1898
2056
  async function meshFolds():Promise<string[]> {
1899
2057
  meshPanel?.status(`Running the folds reading of ${animation||'(no animation)'}…`,false);
@@ -22,7 +22,7 @@ export interface RetargetStatusView {available:boolean;reason:string|null;produc
22
22
  export interface RebuildStatusView {retarget?:RetargetStatusView;available:boolean;handoff:HandoffView|null;baseline:BaselineView|null;inputs:{config:string;parts:string|null;rig:string|null;configHash:string}|null;parts:{name:string;regions:unknown[]}[];contour:Record<string,Record<string,unknown>>;meshes?:Record<string,unknown>;constraints?:unknown[]|null;bones?:unknown[]|null;fields?:Record<string,unknown>|null;cuts?:{config:Record<string,unknown>;build:string|null}|null;inspectedCheck?:InspectedCheckView|null;idle?:import('../src/idle-tracks.ts').IdleStatus|null;cloth?:ClothStatusView|null;producer:{package:string;version:string}|null;compiler:{package:string;version:string}|null;reason:string|null}
23
23
  /** #111: the cloth recipe form's status — the reader's tags, the recipes as written, each tag's parts and chains, the keypoints, the route and why Apply cannot run. */
24
24
  export interface ClothStatusView {targets:string[];targetsRead:boolean;recipes:Record<string,unknown>|null;tags:import('../src/cloth-recipe.ts').ClothTagView[];keypoints:{path:string;sha256:string}|null;route:'build'|'rig';reason:string|null}
25
- export interface RebuildJobView {id:string;status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;exitCode:number|null;wallMs:{remesh:number|null;compile:number|null};build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{part:string;regions:RegionEntry[];configHash:string;entrySha256:string;bust?:BustSpec};producer:{package:string;version:string};compiler:{package:string;version:string}}
25
+ export interface RebuildJobView {id:string;status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;hint?:string;exitCode:number|null;wallMs:{remesh:number|null;compile:number|null};build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{part:string;regions:RegionEntry[];configHash:string;entrySha256:string;bust?:BustSpec};producer:{package:string;version:string};compiler:{package:string;version:string}}
26
26
  export interface RebuildRefusal {status:'refused';error:string;producerLine:string|null}
27
27
  /** #99 regions or #105 landmarks: exactly one of `regions` and `bust`. */
28
28
  export type RebuildRequestBody={part:string;regions:RegionEntry[];configHash:string}|{part:string;bust:BustSpec;configHash:string};
@@ -118,11 +118,11 @@ export function parseApplied(text:string):AppliedRecord|AppliedPatch|AppliedDelt
118
118
  /** A patch Apply's job (#102): the patch and its hash, the op/compile/check wall clocks, check's summary and the rig.json diff. */
119
119
  /** #115: a build job's assemble readout (`AssembledView` in src/rebuild.ts). */
120
120
  export type AssembledJobView=import('../src/rebuild.ts').AssembledView;
121
- export interface PatchJobView {id:string;kind:'patch';op?:'rig'|'retune'|'remesh'|'build'|'cloth';assembled?:AssembledJobView;cloth?:import('../src/rebuild.ts').ClothJob;status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;exitCode:number|null;wallMs:{op:number|null;compile:number|null;check:number|null};check:CheckSummary|null;diff:{count:number;paths:string[]}|null;files?:FilesDiff|null;remesh?:import('../src/mesh-entry.ts').RemeshCounts&{modelTriangles:number|null}|null;build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{patch:ConfigPatch;patchSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string};environment?:{sha256:string;steps:number}}
121
+ export interface PatchJobView {id:string;kind:'patch';op?:'rig'|'retune'|'remesh'|'build'|'cloth';assembled?:AssembledJobView;cloth?:import('../src/rebuild.ts').ClothJob;status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;hint?:string;exitCode:number|null;wallMs:{op:number|null;compile:number|null;check:number|null};check:CheckSummary|null;diff:{count:number;paths:string[]}|null;files?:FilesDiff|null;remesh?:import('../src/mesh-entry.ts').RemeshCounts&{modelTriangles:number|null}|null;build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{patch:ConfigPatch;patchSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string};environment?:{sha256:string;steps:number}}
122
122
  /** #112: `environment` is the exported `rigc-environment/1` schedule Apply's check reads, when "Use for readings" is on. */
123
123
  export interface PatchRequestBody {patch:ConfigPatch;environment?:import('../src/environment-schedule.ts').EnvironmentSchedule}
124
124
  /** Edit Mesh's delta Apply (#135): `remesh --delta`, the per-file diff and the part's counts; remesh's own gate holds check. */
125
- export interface DeltaJobView {id:string;kind:'delta';op:'remesh';status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;exitCode:number|null;wallMs:{op:number|null;compile:number|null};files:FilesDiff|null;remesh:import('../src/mesh-entry.ts').RemeshCounts&{modelTriangles:number|null}|null;build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{part:string;delta:Record<string,unknown>;deltaSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
125
+ export interface DeltaJobView {id:string;kind:'delta';op:'remesh';status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;hint?:string;exitCode:number|null;wallMs:{op:number|null;compile:number|null};files:FilesDiff|null;remesh:import('../src/mesh-entry.ts').RemeshCounts&{modelTriangles:number|null}|null;build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{part:string;delta:Record<string,unknown>;deltaSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
126
126
  /** `{part, delta, configHash}`: the server owns every path and flag; the delta is the editor's export as it stands. */
127
127
  export interface DeltaRequestBody {part:string;delta:import('../src/mesh-edit.ts').MeshEditDocument;configHash:string}
128
128
  /** A retarget Apply's job (#114): clean, retarget, compile; every FAIL line of the step that refused, verbatim; the listed lines. */
@@ -172,7 +172,7 @@ export const deltaAppliedRecord=(job:DeltaJobView):AppliedDelta=>({schema:APPLIE
172
172
  /** A delta job's status line: built (wall clocks, counts, the per-file diff) or the producer's line verbatim. */
173
173
  export function deltaOutcomeLine(result:DeltaJobView|RebuildRefusal):{text:string;error:boolean} {
174
174
  if(!('id' in result))return {text:result.producerLine??result.error,error:true};
175
- if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
175
+ if(result.status!=='built')return {text:refusedText(result),error:true};
176
176
  const ops=Array.isArray(result.request.delta.ops)?result.request.delta.ops.length:0;
177
177
  const wall=result.reused?'reused the scratch build of this exact delta':`remesh --delta ${seconds(result.wallMs.op)} + compile ${seconds(result.wallMs.compile)}`;
178
178
  const counts=result.remesh?` · ${result.remesh.part} v ${result.remesh.before.vertices} t ${result.remesh.before.triangles} → v ${result.remesh.after.vertices} t ${result.remesh.after.triangles}`:'';
@@ -186,22 +186,24 @@ export function stageModel(result:AnyJobView|RebuildRefusal,current:{url:string;
186
186
  if('id' in result&&result.status==='built'&&result.build)return {url:result.build.modelUrl,hash:result.build.modelHash,id:result.id};
187
187
  return {...current,id:null};
188
188
  }
189
+ /** #148: a refused job's line — the producer's line as printed, then review's reading of it when the server added one. */
190
+ export const refusedText=(job:{producerLine:string|null;exitCode:number|null;hint?:string}):string=>`${job.producerLine??`REBUILD_FAILED: exit ${job.exitCode}`}${job.hint?` · review: ${job.hint}`:''}`;
189
191
  const seconds=(ms:number|null)=>ms===null?'—':`${(ms/1000).toFixed(1)} s`;
190
192
  /** A patch job's status line: built (wall clocks, check's verdict, the per-file diff — #125) or the producer's line verbatim. */
191
193
  export function patchOutcomeLine(result:PatchJobView|RebuildRefusal):{text:string;error:boolean} {
192
194
  if(!('id' in result))return {text:result.producerLine??result.error,error:true};
193
- if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
194
- const check=result.check?(result.check.pass===null?`check refused: ${result.check.line??`exit ${result.check.exitCode}`}`:`check ${result.check.pass?'PASS':'FAIL'}${result.check.source==='retune'?' (retune\'s own check of this revision, not run again)':''}`):'check not run';
195
+ if(result.status!=='built')return {text:refusedText(result),error:true};
196
+ const check=result.check?(result.check.pass===null?`check refused: ${result.check.line??`exit ${result.check.exitCode}`}`:`check ${result.check.pass?'PASS':'FAIL'}${result.check.source==='retune'||result.check.source==='remesh'?` (${result.check.source}'s own check of this revision, not run again)`:''}`):'check not run';
195
197
  // #115: a build's own wall clock covers assemble, rig and check; the recorded figure is the same number.
196
198
  const building=result.op==='build'||(result.op==='cloth'&&result.cloth?.route==='build');
197
199
  const propose=result.op==='cloth'&&result.cloth?`propose ${seconds(result.cloth.wallMs.propose)} (wrote a config differing at ${result.cloth.written?.count??'?'} path(s)) + `:'';
198
- const wall=building?`${result.reused?'reused the scratch build of this exact patch · ':''}${propose}build ${seconds(result.op==='cloth'?result.cloth?.wallMs.build??null:result.wallMs.op)} (assemble → rig → check) + compile ${seconds(result.wallMs.compile)}`:result.reused?'reused the scratch build of this exact patch':`${propose}${result.op==='cloth'?'rig':result.op??result.request.patch.op.cmd} ${seconds(result.op==='cloth'?result.cloth?.wallMs.build??null:result.wallMs.op)} + compile ${seconds(result.wallMs.compile)}${result.check?.source==='retune'?'':` + check ${seconds(result.wallMs.check)}`}`;
200
+ const wall=building?`${result.reused?'reused the scratch build of this exact patch · ':''}${propose}build ${seconds(result.op==='cloth'?result.cloth?.wallMs.build??null:result.wallMs.op)} (assemble → rig → check) + compile ${seconds(result.wallMs.compile)}`:result.reused?'reused the scratch build of this exact patch':`${propose}${result.op==='cloth'?'rig':result.op??result.request.patch.op.cmd} ${seconds(result.op==='cloth'?result.cloth?.wallMs.build??null:result.wallMs.op)} + compile ${seconds(result.wallMs.compile)}${result.check?.source==='retune'||result.check?.source==='remesh'?'':` + check ${seconds(result.wallMs.check)}`}`;
199
201
  return {text:`Applied patch ${result.request.patchSha256.slice(0,12)}… (${result.request.patch.ops.length} op(s)) with ${result.producer.package} ${result.producer.version} · ${wall} · ${check}${result.environment?` under the exported world (${result.environment.steps} step(s), sha256 ${result.environment.sha256.slice(0,16)})`:''} · ${result.files?filesDiffText(result.files):`rig.json differs at ${result.diff?.count??'?'} path(s)`} · Reset returns to the inspected build`,error:!!result.check&&result.check.pass!==true};
200
202
  }
201
203
  /** The status line after a job: built (with the wall clock and whether the scratch build was reused) or the producer's line verbatim. */
202
204
  export function outcomeLine(result:RebuildJobView|RebuildRefusal):{text:string;error:boolean} {
203
205
  if(!('id' in result))return {text:result.producerLine??result.error,error:true};
204
- if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
206
+ if(result.status!=='built')return {text:refusedText(result),error:true};
205
207
  const wall=[result.wallMs.remesh,result.wallMs.compile].every(v=>v!==null)?` · remesh ${(result.wallMs.remesh!/1000).toFixed(1)} s + compile ${(result.wallMs.compile!/1000).toFixed(1)} s`:'';
206
208
  return {text:`Applied ${result.request.part} with ${result.producer.package} ${result.producer.version}${result.reused?' · reused the scratch build of this exact designation':wall} · Reset returns to the inspected build`,error:false};
207
209
  }
@@ -219,3 +221,31 @@ export function handoffText(status:Pick<RebuildStatusView,'handoff'|'baseline'|'
219
221
  const base=status.baseline?` · ${status.baseline.wallMs!==null&&status.baseline.status!=='running'?`${status.baseline.line} (${(status.baseline.wallMs/1000).toFixed(1)} s)`:status.baseline.line}`:' · baseline: waits for the producer';
220
222
  return {text:`Handoff ${how}: ${h.config.path} sha256 ${h.config.sha256.slice(0,12)}…${parts}${base}`,error:!!status.baseline&&['not-reproduced','refused','failed'].includes(status.baseline.status)};
221
223
  }
224
+
225
+ /** #166: `GET /api/rebuild/history` — the session's applied records in order; the last is on the stage; `kept`: its build is on disk. */
226
+ export interface AppliedHistoryView {schema:'rig-review-applied-history/1';depth:number;records:{id:string;kind:'regions'|'patch'|'delta'|'retarget';op:string;what:string;modelHash:string;kept:boolean}[]}
227
+ /** #166: `POST /api/rebuild/undo {from}` — the predecessor's build (kept, or re-run and still running), the inspected build, or a refusal. */
228
+ export type UndoView=
229
+ |{status:'inspected';undone:string;history:AppliedHistoryView}
230
+ |{status:'built';undone:string;job:AnyJobView;history:AppliedHistoryView}
231
+ |{status:'running';undone:string;job:AnyJobView}
232
+ |RebuildRefusal;
233
+ /** #166: the applied record a job carries into `context.applied` (the same shapes each Apply records). */
234
+ export function recordFor(job:AnyJobView):AppliedRecord|AppliedPatch|AppliedDelta|AppliedRetarget {
235
+ const kind=(job as {kind?:string}).kind;
236
+ return kind==='patch'?patchAppliedRecord(job as PatchJobView):kind==='delta'?deltaAppliedRecord(job as DeltaJobView):kind==='retarget'?retargetAppliedRecord(job as RetargetJobView):appliedRecord(job as RebuildJobView);
237
+ }
238
+ /** #166: one record's line in the list: its place, what it applied, the op and the model; the stage's last is marked. */
239
+ export function historyRow(history:AppliedHistoryView,index:number):string {
240
+ const r=history.records[index]!,last=index===history.records.length-1;
241
+ return `${index+1}. ${r.kind} (${r.op}) · ${r.what} · model ${r.modelHash.slice(0,12)}…${last?' · on the stage':r.kept?'':' · build evicted, an undo to it re-runs its step'}`;
242
+ }
243
+ /** #166: the Undo control's label and whether it can run: the depth, and what the stage returns to. */
244
+ export function undoControl(history:AppliedHistoryView|null,stageId:string|null,busy:boolean):{label:string;enabled:boolean;title:string} {
245
+ const depth=history?.depth??0,top=history?.records.at(-1)??null;
246
+ const label=`Undo last Apply (${depth} deep)`;
247
+ if(!top)return {label,enabled:false,title:'No Apply is on the stage'};
248
+ if(stageId!==top.id)return {label,enabled:false,title:'The stage does not show the last Apply (Reset shows the inspected build); undo acts on the last Apply only'};
249
+ const back=depth>1?`record ${depth-1} (model ${history!.records[depth-2]!.modelHash.slice(0,12)}…)`:'the inspected build';
250
+ return {label,enabled:!busy,title:`Return the stage to ${back}; the package is unchanged`};
251
+ }
package/web/styles.css CHANGED
@@ -262,3 +262,5 @@ footer.status-bar{flex-wrap:nowrap}
262
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)}
263
263
  .environment-check-lines{display:grid;gap:3px;margin:0 0 9px;font:11px var(--font-mono);color:var(--fg-muted);overflow-wrap:anywhere}.environment-check-lines .env-bar{color:var(--fg-strong)}.environment-check-lines .env-label{color:var(--fg-soft)}.environment-check-lines .env-error{color:var(--fail)}
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
+ .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
+ .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}