@rig-c/review 0.8.0 → 0.9.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
@@ -19,7 +19,7 @@ import {bindClothPanel,type ClothPanel} from './cloth-panel.ts';
19
19
  import {retargetAppliedRecord,retargetOutcomeLine,type AppliedRetarget,type RetargetJobView,type RetargetRequestBody} from './rebuild-client.ts';
20
20
  import {bindRetargetPanel,type PoseMap,type RetargetPanel} from './retarget-panel.ts';
21
21
  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';
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';
23
23
  import {meshEditBinding,meshEditText,type MeshEditDocument} from '../src/mesh-edit.ts';
24
24
  import {chestRegionsBinding,cropWorldMapping,type CropWorldMapping} from '../src/chest-regions.ts';
25
25
  import {bindWorkspaceUI,selectedInspector,type WorkspaceUI} from './workspace-ui.ts';
@@ -35,7 +35,7 @@ import type {ReferenceBinding} from '../src/reference.ts';
35
35
  import type { Checkpoint, Json, Reference, ReviewedSource, ReviewManifest } from '../src/manifest.ts';
36
36
  import type { Feedback, ReviewSession } from '../src/session.ts';
37
37
  import type { ReportEvent, ReportSeeds } from '../src/report.ts';
38
- import { clockForAnimation, frameAtTime, lastFrame as lastProducerFrame, timeAtFrame } from '../src/frame-clock.ts';
38
+ import { frameAtTime, lastFrame as lastProducerFrame, rigPaneLabel, samplesClock, timeAtFrame, transportClock, type StageClock } from '../src/frame-clock.ts';
39
39
  import { feedbackLastFrame, feedbackTargetNames, validateFeedbackSelection, type FeedbackCatalog } from '../src/feedback-validation.ts';
40
40
  import { eventCoordinates, eventSeekTime, eventSelection, eventTimingLabel, reportCounts, reportDraftForMarker, targetLabel } from './report-view.ts';
41
41
  import type { StripInterval, StripRequest } from '../src/strip.ts';
@@ -85,6 +85,8 @@ let player: RigPlayer | null = null;
85
85
  let animation = '';
86
86
  let playing = false;
87
87
  let time = 0;
88
+ /** The clock a scratch build on the stage states for its own animation (a retarget's report frameTimes, #164). */
89
+ let stageClock: StageClock | null = null;
88
90
  let duration = 0;
89
91
  let lastFrame = 0;
90
92
  type CameraTarget='rig'|'reference';
@@ -434,13 +436,14 @@ function renderFeedbackTargets(preserve = false): void {
434
436
  }
435
437
 
436
438
  function renderFrameTransport(): void {
437
- const clock = activeFrameClock();
439
+ const active = activeTransportClock(), clock = active?.clock ?? null;
438
440
  const last = lastProducerFrame(clock), frame = frameAtTime(clock, time);
441
+ text('rig-label', rigPaneLabel({ hasPlayer: !!player, animation: player?.current ?? animation, time, frame }));
439
442
  el('frame-controls').hidden = last === null;
440
443
  if (!clock) { text('frame-label', data.report?.clock ? data.report.animation ? 'Frame unknown · report and current clip do not match' : 'Frame unknown · report animation not supplied' : 'rate unknown'); return; }
441
444
  if (last === null) { text('frame-label', 'F? / ? · frame range unknown'); return; }
442
445
  text('frame-label', `F${frame === null ? '?' : frame} / ${last}${frame === null ? ' · Out of range' : ''}`);
443
- text('frame-clock-label', clock.kind === 'uniform' ? `${clock.fps} fps · frame0 ${clock.frame0} s` : `frameTimes · ${clock.frameTimes.length} explicit times`);
446
+ text('frame-clock-label', (clock.kind === 'uniform' ? `${clock.fps} fps · frame0 ${clock.frame0} s` : `frameTimes · ${clock.frameTimes.length} explicit times`) + (active?.source === 'stage' ? " · this build's report" : ''));
444
447
  input('frame-number').max = String(last);
445
448
  if (document.activeElement !== input('frame-number')) input('frame-number').value = frame === null ? '' : String(frame);
446
449
  input('frame-scrub').max = String(last); input('frame-scrub').hidden = frame === null;
@@ -449,10 +452,11 @@ function renderFrameTransport(): void {
449
452
  el('frame-outside').hidden = frame !== null;
450
453
  }
451
454
 
452
- function activeFrameClock() {
455
+ function activeTransportClock() {
453
456
  const active = player && player.current !== animation ? '' : animation;
454
- return clockForAnimation(data.report?.clock, data.report?.animation, active, !!player);
457
+ return transportClock({ clock: data.report?.clock, animation: data.report?.animation }, active, !!player, stageClock, stageModelHash);
455
458
  }
459
+ function activeFrameClock() { return activeTransportClock()?.clock ?? null; }
456
460
 
457
461
  function seekFrame(frame: number): void {
458
462
  const exact = timeAtFrame(activeFrameClock(), frame);
@@ -926,8 +930,8 @@ function bind(): void {
926
930
 
927
931
  async function start(): Promise<void> {
928
932
  try {
929
- let writes: { capabilityRequired: boolean };
930
- [data, session, writes] = await Promise.all([api<ReviewResponse>('/api/review'), api<ReviewSession>('/api/session'), api<{ capabilityRequired: boolean }>('/api/access')]);
933
+ let writes: { capabilityRequired: boolean; control?: boolean };
934
+ [data, session, writes] = await Promise.all([api<ReviewResponse>('/api/review'), api<ReviewSession>('/api/session'), api<{ capabilityRequired: boolean; control?: boolean }>('/api/access')]);
931
935
  readOnly = readOnlyReason(access, writes.capabilityRequired);
932
936
  submittedBaseline=structuredClone(session);
933
937
  text('package-title', data.manifest.title); text('revision', data.manifest.revision); document.title = `${data.manifest.title} · rig review`; text('source-hash', `source ${data.sourceHash.slice(0, 12)}`); select('review-status').value = session.status; select('feedback-target-kind').value = 'rig'; input('feedback-target-name').value = data.manifest.id;
@@ -952,7 +956,8 @@ async function start(): Promise<void> {
952
956
  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.');
953
957
  requestAnimationFrame(tick);
954
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();
955
- if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'single',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
959
+ // Only an instance that serves agent control is asked for its config: a plain open has no control endpoint (#164).
960
+ 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)});
956
961
  } catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
957
962
  }
958
963
  /** `feedback` is the review dialog, not an Inspector section: a user action opens it; a restored older bookmark that saved it selects nothing. */
@@ -1266,6 +1271,7 @@ function bindEnvironment():void {
1266
1271
  button('wind-gust').onclick=()=>engage(()=>wind.triggerGust());
1267
1272
  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.');};
1268
1273
  button('physics-reset').onclick=resetPhysics;
1274
+ button('applied-undo').onclick=()=>{void undoApply();};
1269
1275
  input('environment-use').onchange=()=>{
1270
1276
  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;}
1271
1277
  const world=worldFor(animation);
@@ -1377,6 +1383,8 @@ async function applyRetarget(body:{sequence:string;map:PoseMap;flags:RetargetFla
1377
1383
  retargetPanel?.result(result);retargetPanel?.status(outcome.text,outcome.error);
1378
1384
  if(next.id===null||!('id' in result)){message(outcome.text,true);return result;}
1379
1385
  await swapRig(new URL(access.route(next.url),location.href).href,access.route(`/rebuild/${next.id}/`),next.hash,world);
1386
+ // The new animation's own clock (the run's report frameTimes), bound to this build: the transport counter and the rig pane read it.
1387
+ const own=samplesClock(result.frameTimes);stageClock=own?{animation:result.request.record.flags.animation,modelHash:next.hash,clock:own}:null;
1380
1388
  appliedOnStage=retargetAppliedRecord(result);stageCheck=null;
1381
1389
  const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1382
1390
  // The new animation plays against the reference: choose it, and show the sequence it was made from beside it.
@@ -1434,7 +1442,60 @@ function renderHandoff(status:RebuildStatusView):void {
1434
1442
  if(handoffTimer)clearTimeout(handoffTimer);handoffTimer=null;
1435
1443
  if(status.baseline?.status==='running')handoffTimer=setTimeout(()=>{void api<RebuildStatusView>('/api/rebuild').then(next=>{rebuildStatus=next;renderHandoff(next);}).catch(()=>{});},2000);
1436
1444
  }
1437
- function rebuildState():void {chestEditor?.rebuildState({busy:rebuildBusy,applied:!!appliedOnStage});}
1445
+ function rebuildState():void {chestEditor?.rebuildState({busy:rebuildBusy,applied:!!appliedOnStage});renderApplied();if(!rebuildBusy)void refreshApplied();}
1446
+ /** #166: the session's applied records (the server's stack), the last one on the stage when the stage shows it. */
1447
+ let appliedHistory:AppliedHistoryView|null=null;
1448
+ async function refreshApplied():Promise<void> {
1449
+ if(!inspectedModel)return;
1450
+ try{appliedHistory=await api<AppliedHistoryView>('/api/rebuild/history');}catch{return;}
1451
+ renderApplied();
1452
+ }
1453
+ function renderApplied():void {
1454
+ const section=document.getElementById('applied-records');if(!section)return;
1455
+ const h=appliedHistory;section.hidden=!h||h.depth===0;
1456
+ 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;}));
1457
+ const control=undoControl(h,appliedOnStage?.buildId??null,rebuildBusy||!!readOnly),undo=button('applied-undo');
1458
+ undo.textContent=control.label;undo.disabled=!control.enabled;undo.title=control.title;
1459
+ }
1460
+ function appliedStatus(text:string,error=false):void {const node=el('applied-status');node.textContent=text;node.classList.toggle('error',error);}
1461
+ /**
1462
+ * #166: Undo last Apply — the last record leaves the server's stack and the stage returns to its predecessor's build
1463
+ * (kept on disk, or re-run from its record when evicted) or to the inspected build, under the same world. The feedback
1464
+ * context then carries the post-undo record (or null), never the reverted one. A refusal leaves the stage as it was.
1465
+ */
1466
+ async function undoApply():Promise<UndoView|null> {
1467
+ const from=appliedOnStage?.buildId;
1468
+ if(rebuildBusy||readOnly||!from||!inspectedModel)return null;
1469
+ const world=stageWorld();
1470
+ rebuildBusy=true;rebuildState();appliedStatus('Undoing the last Apply…');
1471
+ try{
1472
+ let result=await api<UndoView>('/api/rebuild/undo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from})});
1473
+ if(result.status==='running'){
1474
+ // An evicted predecessor re-runs its recorded step; its lines stream like an Apply's.
1475
+ let job=result.job as AnyJobView;const undone=result.undone;let seen=0;
1476
+ 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()||'…');}
1477
+ if(job.status!=='built'){const text=refusedText(job);appliedStatus(text,true);message(text,true);return {status:'refused',error:text,producerLine:job.producerLine};}
1478
+ result={status:'built',undone,job,history:await api<AppliedHistoryView>('/api/rebuild/history')};
1479
+ }
1480
+ if(result.status==='refused'){appliedStatus(result.error,true);message(result.error,true);return result;}
1481
+ let ops:import('../src/config-patch.ts').PatchOp[]|null=null;
1482
+ if(result.status==='inspected'){await swapRig(inspectedModel.url,inspectedModel.prefix,inspectedModel.sha256,world);appliedOnStage=null;stageCheck=null;}
1483
+ else{
1484
+ const job=result.job;if(!job.build)throw new Error('the restored job carries no build');
1485
+ await swapRig(new URL(access.route(job.build.modelUrl),location.href).href,access.route(`/rebuild/${job.id}/`),job.build.modelHash,world);
1486
+ appliedOnStage=recordFor(job);stageCheck=(job as PatchJobView).kind==='patch'?{lines:[...job.lines],check:(job as PatchJobView).check}:null;
1487
+ if((job as PatchJobView).kind==='patch')ops=(job as PatchJobView).request.patch.ops;
1488
+ }
1489
+ // The panels mark the ops the stage's build applied; the pending edits stay as they are.
1490
+ physicsPanel?.applied(ops);idlePanel?.applied(ops);rigFieldsPanel?.applied(ops);bonesPanel?.applied(ops);
1491
+ const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=appliedOnStage?JSON.stringify(appliedOnStage):'';draft.dispatchEvent(new Event('input',{bubbles:true}));
1492
+ appliedHistory=result.history;
1493
+ 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`;
1494
+ appliedStatus(text);message(text);redrawOverlay(true);
1495
+ return result;
1496
+ }catch(error){const text=`UNDO_FAILED: ${errorText(error)}`;appliedStatus(text,true);message(text,true);return {status:'refused',error:text,producerLine:null};}
1497
+ finally{rebuildBusy=false;rebuildState();}
1498
+ }
1438
1499
  /** Apply · Rebuild: the rows go to the installed producer; on success its scratch build replaces the drawn rig in place. */
1439
1500
  async function applyRebuild():Promise<void> {
1440
1501
  if(rebuildBusy)return;
@@ -1497,6 +1558,8 @@ async function applyPatch(patch:ConfigPatch,world:StageWorld|null=null):Promise<
1497
1558
  /** 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. */
1498
1559
  (window as unknown as {rigReviewEditor:unknown}).rigReviewEditor={
1499
1560
  applyPatch,reset:()=>resetRebuild(),
1561
+ /** #166: undo the last Apply, and the applied records as the server holds them. */
1562
+ undo:()=>undoApply(),history:()=>appliedHistory?JSON.parse(JSON.stringify(appliedHistory)):null,
1500
1563
  status:async()=>rebuildStatus??await loadRebuildStatus(),
1501
1564
  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()},
1502
1565
  /** Inspection only: one slot's posed world geometry on the drawn frame (what the stage drew), for comparing against `rigc render --geometry`. */
@@ -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,15 +118,15 @@ 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. */
129
- export interface RetargetJobView {id:string;kind:'retarget';op:'retarget';status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;refusals:string[];exitCode:number|null;wallMs:{clean:number|null;retarget:number|null;compile:number|null};files:FilesDiff|null;motion:MotionSummary|null;readings:RetargetLine[];build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{record:RetargetRecord;recordSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
129
+ export interface RetargetJobView {id:string;kind:'retarget';op:'retarget';status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;refusals:string[];exitCode:number|null;wallMs:{clean:number|null;retarget:number|null;compile:number|null};files:FilesDiff|null;motion:MotionSummary|null;readings:RetargetLine[];frameTimes?:number[]|null;build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{record:RetargetRecord;recordSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
130
130
  /** `{retarget: {sequence, map, flags, configHash}}`: a declared reference id, the map by content and typed flags — no path, no flag string. */
131
131
  export interface RetargetRequestBody {retarget:{sequence:string;map:Record<string,unknown>;flags:RetargetFlags;configHash:string}}
132
132
  /** What the draft keeps and `context.applied` carries for a retarget: the record, the producer's line, the motion summary, the diff and the 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,11 +186,13 @@ 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};
195
+ if(result.status!=='built')return {text:refusedText(result),error:true};
194
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';
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');
@@ -201,7 +203,7 @@ export function patchOutcomeLine(result:PatchJobView|RebuildRefusal):{text:strin
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
+ }
@@ -1,5 +1,6 @@
1
1
  /** Inspection graphics from the last successfully drawn public player pose. */
2
2
  import {partBounds} from './part-selection.ts';
3
+ import {placeLabels} from './label-placement.ts';
3
4
  import type {BoneWorld,PosedFrame,RigPlayer} from '@rig-c/play';
4
5
 
5
6
  export interface OverlayPoint {x:number;y:number}
@@ -221,15 +222,29 @@ export class RigOverlays {
221
222
  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);
222
223
  }
223
224
  }
224
- if(this.playerAvailable)for(const marker of markers){
225
- const at=projected(marker.at);if(!at)continue;const c=marker.colors;
226
- ctx.globalAlpha=.9;ctx.setLineDash([3,3]);ctx.strokeStyle=c.stroke;ctx.lineWidth=1.2;
227
- 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();}
228
- ctx.setLineDash([]);ctx.globalAlpha=1;const size=marker.active?7:5.5;
229
- 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();
230
- ctx.fillStyle=c.fill;ctx.fill();ctx.strokeStyle=c.ink;ctx.lineWidth=marker.active?2.4:1.6;ctx.stroke();
231
- 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;
232
- ctx.fillStyle=c.labelBg;ctx.fillRect(lx,ly-11,width,15);ctx.fillStyle=c.stroke;ctx.fillText(marker.tag,lx+4,ly);
225
+ if(this.playerAvailable){
226
+ const drawn:{marker:OverlayMarker;at:OverlayPoint}[]=[];
227
+ for(const marker of markers){
228
+ const at=projected(marker.at);if(!at)continue;const c=marker.colors;drawn.push({marker,at});
229
+ ctx.globalAlpha=.9;ctx.setLineDash([3,3]);ctx.strokeStyle=c.stroke;ctx.lineWidth=1.2;
230
+ 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();}
231
+ ctx.setLineDash([]);ctx.globalAlpha=1;const size=marker.active?7:5.5;
232
+ 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();
233
+ ctx.fillStyle=c.fill;ctx.fill();ctx.strokeStyle=c.ink;ctx.lineWidth=marker.active?2.4:1.6;ctx.stroke();
234
+ }
235
+ // Labels avoid each other (#147): a moved label keeps a leader to its point; one with no free slot shows on hover only.
236
+ ctx.font='600 11px ui-monospace, monospace';
237
+ const placed=placeLabels(drawn.map(({marker,at})=>({at,width:ctx.measureText(marker.tag).width+8,pinned:marker.active})),{gap:10,rise:3,bounds:{width,height},obstacles:drawn.map(({marker,at})=>{const r=marker.active?8:6.5;return {x:at.x-r,y:at.y-r,w:r*2,h:r*2};})});
238
+ // Leaders first, so no leader crosses another joint's label.
239
+ drawn.forEach(({marker,at},i)=>{
240
+ const {box,visible,moved}=placed[i]!;if(!visible||!moved)return;
241
+ const ex=Math.min(Math.max(at.x,box.x),box.x+box.w),ey=Math.min(Math.max(at.y,box.y),box.y+box.h);
242
+ ctx.globalAlpha=.85;ctx.strokeStyle=marker.colors.stroke;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(at.x,at.y);ctx.lineTo(ex,ey);ctx.stroke();ctx.globalAlpha=1;
243
+ });
244
+ drawn.forEach(({marker},i)=>{
245
+ const {box,visible}=placed[i]!,c=marker.colors;if(!visible)return;
246
+ ctx.fillStyle=c.labelBg;ctx.fillRect(box.x,box.y,box.w,box.h);ctx.fillStyle=c.stroke;ctx.fillText(marker.tag,box.x+4,box.y+11);
247
+ });
233
248
  }
234
249
  ctx.globalAlpha=1;
235
250
  if(this.playerAvailable&&meshEdit)this.drawMeshEdit(ctx,meshEdit,projected,width,height);
package/web/styles.css CHANGED
@@ -262,3 +262,4 @@ 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)}