@rig-c/review 0.9.0 → 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 {historyRow,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';
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'); }
@@ -1450,7 +1455,10 @@ async function refreshApplied():Promise<void> {
1450
1455
  try{appliedHistory=await api<AppliedHistoryView>('/api/rebuild/history');}catch{return;}
1451
1456
  renderApplied();
1452
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);}
1453
1460
  function renderApplied():void {
1461
+ renderAppliedBlocks();
1454
1462
  const section=document.getElementById('applied-records');if(!section)return;
1455
1463
  const h=appliedHistory;section.hidden=!h||h.depth===0;
1456
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;}));
@@ -1488,6 +1496,9 @@ async function undoApply():Promise<UndoView|null> {
1488
1496
  }
1489
1497
  // The panels mark the ops the stage's build applied; the pending edits stay as they are.
1490
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);
1491
1502
  const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=appliedOnStage?JSON.stringify(appliedOnStage):'';draft.dispatchEvent(new Event('input',{bubbles:true}));
1492
1503
  appliedHistory=result.history;
1493
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`;
@@ -1603,14 +1614,17 @@ async function applyPatch(patch:ConfigPatch,world:StageWorld|null=null):Promise<
1603
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),
1604
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,
1605
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(),
1606
- 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}},
1607
1621
  /** #115: the Cuts block — its handles as client pixels (for a real-pointer drag), its ops, Apply and Reset. */
1608
1622
  cuts:{source:()=>cutsPanel?.source()??null,ops:()=>cutsPanel?.ops()??[],handles:()=>cutsPanel?.handles()??[],active:()=>cutsPanel?.active()??false,setActive:(on:boolean)=>cutsPanel?.setActive(on),
1609
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),
1610
- apply:()=>cutsPanel?.apply()??Promise.resolve(null),reset:()=>resetCutsBuild()},
1624
+ apply:()=>cutsPanel?.apply()??Promise.resolve(null),reset:()=>resetCutsBuild(),applied:()=>cutsPanel?.appliedItems()??[]},
1611
1625
  /** #111: the Cloth block — the selected tag, its form values, the pending ops, the link preview, Apply and Reset. */
1612
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),
1613
- 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)},
1614
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),
1615
1629
  active:()=>bonesPanel?.active()??false,setActive:(on:boolean)=>bonesPanel?.setActive(on),handles:()=>bonesPanel?.handles()??[],check:()=>bonesPanel?.check()??Promise.resolve(null),
1616
1630
  apply:()=>applyBones((bonesPanel?.edits()??[]).map(e=>({op:'replace' as const,path:e.path,value:[...e.value],name:e.name}))),reset:()=>resetBonesBuild(),
@@ -1877,9 +1891,9 @@ function sampleLocals(bone:string,dt:number,n:number):{t:number;rotation:number;
1877
1891
  return [...rows.values()].sort((a,b)=>a.t-b.t);
1878
1892
  }
1879
1893
  async function resetRebuild(world:StageWorld|null=null):Promise<void> {
1880
- if(rebuildBusy||!appliedOnStage||!inspectedModel)return;
1894
+ if(rebuildBusy||(!appliedOnStage&&!stripPending)||!inspectedModel)return;
1881
1895
  rebuildBusy=true;rebuildState();
1882
- 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.');}
1883
1897
  catch(error){chestEditor?.notify(`REBUILD_RESET_FAILED: ${errorText(error)}`,true);}
1884
1898
  finally{rebuildBusy=false;rebuildState();}
1885
1899
  }
@@ -1887,10 +1901,11 @@ async function resetRebuild(world:StageWorld|null=null):Promise<void> {
1887
1901
  * Replace the drawn rig in place: the new model loads first (a failure leaves the stage as it was), then the player is
1888
1902
  * swapped and the same animation, time, set, camera and environment are restored; playback resumes when it was running.
1889
1903
  */
1904
+ let stageSource:{url:string;prefix:string;hash:string}|null=null;
1890
1905
  async function swapRig(modelUrl:string,prefix:string,sha256:string,world:StageWorld|null=null):Promise<void> {
1891
1906
  const keepAnimation=animation,keepTime=player?.time??time,wasPlaying=playing,keepSet=select('part-set').disabled?'':select('part-set').value;
1892
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;}});
1893
- 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};
1894
1909
  // A scratch build may hold another set of animations (a retarget adds one, #114): the picker lists what this build has.
1895
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;}
1896
1911
  player.on('event',event=>{text('event-log',`Runtime event: ${event.name} · ${event.time.toFixed(2)} s`);});
@@ -1929,6 +1944,8 @@ async function applyMesh(part:string,entry:MeshEntry):Promise<PatchJobView|Rebui
1929
1944
  const result=await applyPatch(patch,world);
1930
1945
  const outcome=meshApplyOutcome(result as Parameters<typeof meshApplyOutcome>[0]);meshPanel?.status(outcome.text,outcome.error);
1931
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();}
1932
1949
  // The new triangulation is drawn: the mesh edges go on and the overlay captures the reloaded pose.
1933
1950
  if(!layers.mesh){layers.mesh=true;input('layer-mesh').checked=true;}
1934
1951
  if(player)drawRig(()=>player!.draw());redrawOverlay(true);
@@ -1947,9 +1964,94 @@ async function applyMesh(part:string,entry:MeshEntry):Promise<PatchJobView|Rebui
1947
1964
  /** Mesh Reset: the inspected build back on the stage (same world); the panel already holds the config's entry again. */
1948
1965
  async function resetMeshBuild():Promise<void> {
1949
1966
  meshPanel?.counts([]);meshPanel?.readout([]);
1950
- 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);}}
1951
1968
  meshPanel?.status('Reset: the inspected build is on the stage; the pending entry is the config\'s.',false);
1952
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
+ }
1953
2055
  /** The folds reading (#108) of the drawn animation, on the inspected build and on the latest scratch build: one line per build. */
1954
2056
  async function meshFolds():Promise<string[]> {
1955
2057
  meshPanel?.status(`Running the folds reading of ${animation||'(no animation)'}…`,false);
@@ -193,11 +193,11 @@ const seconds=(ms:number|null)=>ms===null?'—':`${(ms/1000).toFixed(1)} s`;
193
193
  export function patchOutcomeLine(result:PatchJobView|RebuildRefusal):{text:string;error:boolean} {
194
194
  if(!('id' in result))return {text:result.producerLine??result.error,error:true};
195
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'?' (retune\'s own check of this revision, not run again)':''}`):'check not run';
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';
197
197
  // #115: a build's own wall clock covers assemble, rig and check; the recorded figure is the same number.
198
198
  const building=result.op==='build'||(result.op==='cloth'&&result.cloth?.route==='build');
199
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)) + `:'';
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'?'':` + 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)}`}`;
201
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};
202
202
  }
203
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. */
package/web/styles.css CHANGED
@@ -263,3 +263,4 @@ footer.status-bar{flex-wrap:nowrap}
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
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}