@rig-c/review 0.10.1 → 0.10.2

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
@@ -18,10 +18,10 @@ import {modelBoneNames,readSetupPose} from './setup-pose.ts';
18
18
  import {bindBonesPanel,type BonesPanel,type BonesCheckView} from './bones-panel.ts';
19
19
  import {bindCutsPanel,type CutsPanel} from './cuts-panel.ts';
20
20
  import {bindClothPanel,type ClothPanel} from './cloth-panel.ts';
21
- import {retargetAppliedRecord,retargetOutcomeLine,type AppliedRetarget,type RetargetJobView,type RetargetRequestBody} from './rebuild-client.ts';
21
+ import {retargetAppliedRecord,retargetOutcomeLine,stripPendingAfter,type StageChange,type AppliedRetarget,type RetargetJobView,type RetargetRequestBody} from './rebuild-client.ts';
22
22
  import {bindRetargetPanel,type PoseMap,type RetargetPanel} from './retarget-panel.ts';
23
23
  import type {RetargetFlags} from '../src/retarget.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';
24
+ import {historyRow,isPatchJob,recordFor,refusedText,retargetStage,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';
25
25
  import {meshEditBinding,meshEditText,type MeshEditDocument} from '../src/mesh-edit.ts';
26
26
  import {chestRegionsBinding,cropWorldMapping,type CropWorldMapping} from '../src/chest-regions.ts';
27
27
  import {bindWorkspaceUI,selectedInspector,type WorkspaceUI} from './workspace-ui.ts';
@@ -31,26 +31,26 @@ import {pageInstanceAccess,readOnlyReason} from './instance-access.ts';
31
31
  import {controlComparison,type ControlCommand,type ControlInspection,type ControlView} from '../src/control.ts';
32
32
  import {singleEligibility,videoReferenceAt,type InputState,type SingleLayout} from '../src/comparison-eligibility.ts';
33
33
  import {connectInspectionPersistence,serializableView,numericCamera,restoredZoom,restoreTransaction,captureInputs,restoreInputs,type InspectionBookmark,type InspectionBinding,type PersistenceConnection} from './inspection-persistence.ts';
34
- import {initialPlayerAnimation} from '../src/animation-selection.ts';
34
+ import {initialPlayerAnimation,keptAnimation} from '../src/animation-selection.ts';
35
35
  import {initialReviewSelection} from '../src/open-selection.ts';
36
36
  import type {ReferenceBinding} from '../src/reference.ts';
37
37
  import type { Checkpoint, Json, Reference, ReviewedSource, ReviewManifest } from '../src/manifest.ts';
38
38
  import type { Feedback, ReviewSession } from '../src/session.ts';
39
39
  import type { ReportEvent, ReportSeeds } from '../src/report.ts';
40
- import { frameAtTime, lastFrame as lastProducerFrame, rigPaneLabel, samplesClock, timeAtFrame, transportClock, type StageClock } from '../src/frame-clock.ts';
40
+ import { frameAtTime, lastFrame as lastProducerFrame, rigPaneLabel, timeAtFrame, transportClock, type StageClock } from '../src/frame-clock.ts';
41
41
  import { feedbackLastFrame, feedbackTargetNames, validateFeedbackSelection, type FeedbackCatalog } from '../src/feedback-validation.ts';
42
42
  import { eventCoordinates, eventSeekTime, eventSelection, eventTimingLabel, reportCounts, reportDraftForMarker, targetLabel } from './report-view.ts';
43
43
  import type { StripInterval, StripRequest } from '../src/strip.ts';
44
44
  import {createWindModel,environmentFor,windReadout,RUNTIME_START_READOUT,joltPacket,dueJolts,dragMoved,loadModelDocument,environmentContext,environmentJson,type JoltStep,type WindSnapshot,type WindState} from '../src/environment.ts';
45
45
  import {tuningRows,readoutRows,type InspectedCheckView} from '../src/physics-tuning.ts';
46
- import {bindReadingsPanel,EDGE as READING_EDGE,type ReadingsJobView,type ReadingsPanel,type ReadingsRefusal} from './readings-panel.ts';
47
- import {READINGS_SCHEMA,type ReadingsContext} from '../src/readings.ts';
48
- import {environmentCheckRows,scheduleContext,worldExport,type EnvironmentCheckView,type WorldExport} from './environment-world.ts';
46
+ import {bindReadingsPanel,type ReadingsJobView,type ReadingsPanel,type ReadingsRefusal} from './readings-panel.ts';
47
+ import {READINGS_SCHEMA,readingSeekTime,type ReadingsContext} from '../src/readings.ts';
48
+ import {restartsWindClock,type ClipMove,environmentCheckRows,scheduleContext,worldExport,type EnvironmentCheckView,type WorldExport} from './environment-world.ts';
49
49
  import {barLine,beforeLine} from '../src/physics-tuning.ts';
50
50
  import {bindRequirementsPanel,type RequirementsCatalogView,type RequirementsJobView,type RequirementsPanel,type RequirementsRefusal} from './requirements-panel.ts';
51
51
  import {patchRoute} from '../src/retune-route.ts';
52
52
  import {appliedCheckRefusal,type CheckSummary} from '../src/applied-context.ts';
53
- import {appliedBlockOps,recordBlock} from '../src/applied-blocks.ts';
53
+ import {appliedBlockOps,recordBlock,staleReadouts} from '../src/applied-blocks.ts';
54
54
  import {bindPhysicsPanel,type PhysicsPanel} from './physics-panel.ts';
55
55
  import {bindIdlePanel,type BoneSetup,type IdlePanel} from './idle-panel.ts';
56
56
  import {bindRigFieldsPanel,type RigFieldsPanel} from './rig-fields-panel.ts';
@@ -475,18 +475,21 @@ function drawRig(action:()=>void):void {
475
475
  catch(error){overlays?.clear();text('renderer-state',rendererStatus(null,'failed').label);throw error;}
476
476
  }
477
477
 
478
- function chooseAnimation(name: string): void {
478
+ function chooseAnimation(name: string, move: ClipMove = { kind: 'choose' }): void {
479
479
  pause(); animation = name; axisInspection = false; el('angle-panel').hidden = true;
480
480
  const runtimeAnimation = assets?.animations.find(item => item.name === name);
481
- duration = runtimeAnimation?.duration ?? data.manifest.animations.find(item => item.id === name)?.duration ?? 0;
481
+ duration = clipDuration(name);
482
482
  select('animation-select').value = name;
483
+ // #191: the wind restarts before the clip's first frame is drawn, so the paused stage shows the schedule's t=0 world.
484
+ time = 0; clipMoved(move);
483
485
  if (player && runtimeAnimation) { drawRig(()=>{player!.play(name,{loop:false});player!.pause();player!.draw();}); }
484
486
  else if (player && name){text('renderer-state',rendererStatus(null,'failed').label);message(`Animation "${name}" is not present in the rig.`, true);}
485
- time = 0; renderTransport(); renderCues();renderIdle();
487
+ renderTransport(); renderCues();renderIdle();
486
488
  }
487
489
 
488
- function seek(value: number): void {
490
+ function seek(value: number, move?: ClipMove): void {
489
491
  time = duration > 0 ? Math.min(Math.max(0, value), duration) : Math.max(0, value);
492
+ clipMoved(move ?? { kind: 'seek', to: time });
490
493
  if (player && player.current) { try { player.seek(time); reposePreview(); drawRig(()=>player!.draw()); } catch (error) {text('renderer-state',rendererStatus(null,'failed').label); message(errorText(error), true); } }
491
494
  if (video && video.readyState >= 1) holdVideoReference(video, referenceGeneration);
492
495
  renderSequenceFrame(); renderTransport();
@@ -611,8 +614,9 @@ function seekReportEvent(): void {
611
614
  /** A reading tick or worst line: its frame's time (frame / fps of the reading's set) in the reading's animation. */
612
615
  function seekReading(clip: string, frame: number, exact: number | null, label: string): void {
613
616
  if (exact === null) { message(`${label}: F${frame} has no time (the reading's set states no fps); no seek was performed.`, true); return; }
614
- // The clip's last sample can sit a rounding hair past the runtime duration; that tick is the clip's end.
615
- const at = duration > 0 && exact > duration && exact - duration <= READING_EDGE ? duration : exact;
617
+ // The clip's last sample can sit a rounding hair past the runtime duration; that tick is the clip's end — the end of
618
+ // the reading's clip, the one the seek switches to (#186), not the clip drawn now.
619
+ const at = readingSeekTime(exact, clipDuration(clip));
616
620
  if (seekMarker(clip, at, 'Reading')) message(`Reading · ${label} → F${frame} · ${exact.toFixed(4)} s`);
617
621
  }
618
622
 
@@ -623,7 +627,7 @@ function tick(now: number): void {
623
627
  if (player && player.current) { try { stepEnvironment(dt); drawRig(()=>player!.frame(dt)); } catch (error) { pause(); message(errorText(error), true); } }
624
628
  if (video && video.readyState >= 1 && time > video.duration) { if (referenceInput.state !== 'out-of-domain') holdVideoReference(video, referenceGeneration); } else if (video && referenceInput.state === 'loaded' && Math.abs(video.currentTime - time) > .15) video.currentTime = time;
625
629
  if (input('sync-audio').checked) for (const cue of activeCues()) if ((previous < cue.time && cue.time <= time) || (previous === 0 && cue.time === 0 && dt > 0)) void audition(cue.clip);
626
- renderSequenceFrame(); renderTransport(); if(time>=duration){if(canRepeatIdle()&&!idleHeld){stopAudio();if(useWorld())restartWindClock();seek(0);player?.resume();if(video)void video.play().catch(error=>{pause();message(errorText(error),true);});}else pause();} lastFrame = now;
630
+ renderSequenceFrame(); renderTransport(); if(time>=duration){if(canRepeatIdle()&&!idleHeld){stopAudio();seek(0,{kind:'replay'});player?.resume();if(video)void video.play().catch(error=>{pause();message(errorText(error),true);});}else pause();} lastFrame = now;
627
631
  }
628
632
  requestAnimationFrame(tick);
629
633
  }
@@ -992,7 +996,7 @@ function canRepeatIdle():boolean{return !!player&&idleAvailable(assets?.animatio
992
996
  function renderIdle():void {text('idle-state',canRepeatIdle()?'Actual idle · repeat enabled':animation==='idle'?'Idle repeat paused':'Non-idle inspection · no automatic playback');}
993
997
  async function playPreview():Promise<void> {
994
998
  if((!player&&!video&&!sequence)||duration<=0)return;
995
- const intent=++playbackIntent;desiredPlay=true;idleHeld=false;if(time>=duration)seek(0);
999
+ const intent=++playbackIntent;desiredPlay=true;idleHeld=false;if(time>=duration)seek(0,{kind:'replay'});
996
1000
  if(video&&referenceInput.state==='loaded'){try{await video.play();}catch(error){pause();message(`Playback is paused: ${errorText(error)}. Use Play to retry.`,true);return;}}
997
1001
  if(intent!==playbackIntent||!desiredPlay){if(!desiredPlay)video?.pause();return;}playing=true;lastFrame=0;player?.resume();button('play').textContent='Ⅱ';button('play').setAttribute('aria-label','Pause');
998
1002
  }
@@ -1022,6 +1026,8 @@ async function applyControl(command:ControlCommand,signal:AbortSignal):Promise<v
1022
1026
  }
1023
1027
  async function inspectControlTime(target:number,signal:AbortSignal):Promise<void> {
1024
1028
  if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
1029
+ // #191: a control seek is a clip move like the transport's: a seek to 0 restarts the wind clock while the exported world is in use.
1030
+ clipMoved({kind:'seek',to:target});
1025
1031
  const media=video;
1026
1032
  if(!media){if(player){player.seek(target);reposePreview();drawRig(()=>player!.draw());}time=target;renderSequenceFrame();renderTransport();return;}
1027
1033
  if(media.readyState<1||target>media.duration)throw new Error('CONTROL_REFERENCE_UNAVAILABLE');
@@ -1227,6 +1233,8 @@ function worldFor(name:string):WorldExport|{error:string} {
1227
1233
  }
1228
1234
  /** A fresh wind clock at the same settings (a repeating gust restarts at 0); pending jolts keep their distance in time. */
1229
1235
  function restartWindClock():void {const now=wind.read().time;wind.restore(wind.snapshot());joltQueue=joltQueue.map(step=>({...step,at:Math.max(0,step.at-now)}));}
1236
+ /** #183: every clip restart (`restartsWindClock`) restarts the wind clock while the schedule is watched, not only the checkbox. */
1237
+ function clipMoved(move:ClipMove):void {if(player&&restartsWindClock(move,useWorld())){restartWindClock();applyEnvironment();}}
1230
1238
  let scheduleShown='';
1231
1239
  function renderSchedule():void {
1232
1240
  const world=worldFor(animation),check=checkAnimation(),checked=check!==animation?worldFor(check):null;
@@ -1389,8 +1397,8 @@ async function applyRetarget(body:{sequence:string;map:PoseMap;flags:RetargetFla
1389
1397
  if(next.id===null||!('id' in result)){message(outcome.text,true);return result;}
1390
1398
  await swapRig(new URL(access.route(next.url),location.href).href,access.route(`/rebuild/${next.id}/`),next.hash,world);
1391
1399
  // The new animation's own clock (the run's report frameTimes), bound to this build: the transport counter and the rig pane read it.
1392
- const own=samplesClock(result.frameTimes);stageClock=own?{animation:result.request.record.flags.animation,modelHash:next.hash,clock:own}:null;
1393
- appliedOnStage=retargetAppliedRecord(result);stageCheck=null;
1400
+ stageClock=retargetStage(result,next.hash)?.clock??null;
1401
+ appliedOnStage=retargetAppliedRecord(result);stageCheck=null;stageChanged({kind:'apply',via:'retarget'});
1394
1402
  const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1395
1403
  // The new animation plays against the reference: choose it, and show the sequence it was made from beside it.
1396
1404
  const name=result.request.record.flags.animation;if(assets?.animations.some(clip=>clip.name===name))chooseAnimation(name);
@@ -1418,7 +1426,7 @@ async function applyMeshDelta(doc:MeshEditDocument,line:(text:string)=>void):Pro
1418
1426
  const outcome=deltaOutcomeLine(result),next=stageModel(result,{url:'',hash:stageModelHash??''});
1419
1427
  if(next.id===null||!('id' in result)){message(outcome.text,true);return {built:false,text:outcome.text,error:true};}
1420
1428
  await swapRig(new URL(access.route(next.url),location.href).href,access.route(`/rebuild/${next.id}/`),next.hash,world);
1421
- appliedOnStage=deltaAppliedRecord(result);stageCheck=null;
1429
+ appliedOnStage=deltaAppliedRecord(result);stageCheck=null;stageChanged({kind:'apply',via:'delta'});
1422
1430
  const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1423
1431
  message(outcome.text);
1424
1432
  return {built:true,text:outcome.text,error:false};
@@ -1456,7 +1464,13 @@ async function refreshApplied():Promise<void> {
1456
1464
  renderApplied();
1457
1465
  }
1458
1466
  /** #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);}
1467
+ let readoutStage:AppliedRecord|AppliedPatch|AppliedDelta|AppliedRetarget|null=null;
1468
+ function renderAppliedBlocks():void {
1469
+ const on=appliedBlockOps(appliedOnStage);cutsPanel?.applied(on.cuts);clothPanel?.applied(on.cloth);
1470
+ // #186: when the stage record changes, a Cuts / Cloth readout the new record is not that block's names an older build.
1471
+ for(const block of staleReadouts(readoutStage,appliedOnStage))(block==='cuts'?cutsPanel:clothPanel)?.shown(null);
1472
+ readoutStage=appliedOnStage;
1473
+ }
1460
1474
  function renderApplied():void {
1461
1475
  renderAppliedBlocks();
1462
1476
  const section=document.getElementById('applied-records');if(!section)return;
@@ -1493,7 +1507,11 @@ async function undoApply():Promise<UndoView|null> {
1493
1507
  await swapRig(new URL(access.route(job.build.modelUrl),location.href).href,access.route(`/rebuild/${job.id}/`),job.build.modelHash,world);
1494
1508
  appliedOnStage=recordFor(job);stageCheck=(job as PatchJobView).kind==='patch'?{lines:[...job.lines],check:(job as PatchJobView).check}:null;
1495
1509
  if((job as PatchJobView).kind==='patch')ops=(job as PatchJobView).request.patch.ops;
1510
+ // #186: a restored retarget build gets what its Apply set — its own clock, then its animation selected.
1511
+ const retarget=retargetStage(job,job.build.modelHash);stageClock=retarget?.clock??null;
1512
+ if(retarget&&assets?.animations.some(clip=>clip.name===retarget.animation))chooseAnimation(retarget.animation);
1496
1513
  }
1514
+ stageChanged({kind:'undo'});
1497
1515
  // The panels mark the ops the stage's build applied; the pending edits stay as they are.
1498
1516
  physicsPanel?.applied(ops);idlePanel?.applied(ops);rigFieldsPanel?.applied(ops);bonesPanel?.applied(ops);
1499
1517
  // #172: the Cuts and Cloth readouts show the restored build's job when it is theirs, else nothing (never the reverted one).
@@ -1531,7 +1549,7 @@ async function applyRebuild():Promise<void> {
1531
1549
  // A refusal stays readable in full: the bar's status line is one line, so the log ends with the producer's line verbatim.
1532
1550
  if(!('id' in result)||result.status!=='built'||!result.build){chestEditor?.notify(outcome.text,true,[...lines.slice(-5),outcome.text]);message(outcome.text,true);return;}
1533
1551
  await swapRig(new URL(access.route(result.build.modelUrl),location.href).href,access.route(`/rebuild/${result.id}/`),result.build.modelHash);
1534
- appliedOnStage=appliedRecord(result);stageCheck=null;
1552
+ appliedOnStage=appliedRecord(result);stageCheck=null;stageChanged({kind:'apply',via:'regions'});
1535
1553
  const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1536
1554
  // The log closes on success so nothing floats over the rebuilt stage; a refusal keeps it open.
1537
1555
  chestEditor?.notify(outcome.text,false,[]);message(outcome.text);
@@ -1559,7 +1577,7 @@ async function applyPatch(patch:ConfigPatch,world:StageWorld|null=null):Promise<
1559
1577
  const outcome=patchOutcomeLine(result),next=stageModel(result,{url:'',hash:stageModelHash??''});
1560
1578
  if(next.id===null||!('id' in result)){chestEditor?.notify(outcome.text,true,[...lines.slice(-5),outcome.text]);message(outcome.text,true);return result;}
1561
1579
  await swapRig(new URL(access.route(next.url),location.href).href,access.route(`/rebuild/${next.id}/`),next.hash,world);
1562
- appliedOnStage=patchAppliedRecord(result);stageCheck={lines:[...result.lines],check:result.check};
1580
+ appliedOnStage=patchAppliedRecord(result);stageCheck={lines:[...result.lines],check:result.check};stageChanged({kind:'apply',via:'patch'});
1563
1581
  const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1564
1582
  chestEditor?.notify(outcome.text,outcome.error,[]);message(outcome.text,outcome.error);
1565
1583
  return result;
@@ -1893,7 +1911,7 @@ function sampleLocals(bone:string,dt:number,n:number):{t:number;rotation:number;
1893
1911
  async function resetRebuild(world:StageWorld|null=null):Promise<void> {
1894
1912
  if(rebuildBusy||(!appliedOnStage&&!stripPending)||!inspectedModel)return;
1895
1913
  rebuildBusy=true;rebuildState();
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.');}
1914
+ try{await swapRig(inspectedModel.url,inspectedModel.prefix,inspectedModel.sha256,world);appliedOnStage=null;stageCheck=null;stageChanged({kind:'reset'});chestEditor?.notify('Reset: the inspected build is on the stage. The draft keeps the last applied state.',false);message('Reset to the inspected build.');}
1897
1915
  catch(error){chestEditor?.notify(`REBUILD_RESET_FAILED: ${errorText(error)}`,true);}
1898
1916
  finally{rebuildBusy=false;rebuildState();}
1899
1917
  }
@@ -1915,7 +1933,12 @@ async function swapRig(modelUrl:string,prefix:string,sha256:string,world:StageWo
1915
1933
  // whether the controls are engaged as they stood when Apply was pressed; then the seek re-poses under that world.
1916
1934
  if(world){wind.resume(world.wind);joltQueue=world.jolts.map(step=>({...step}));input('environment-applied').checked=world.applied;input('gravity').value=String(world.gravity);syncEnvironmentInputs();}
1917
1935
  if(environmentApplied())player.environment(environmentFor(wind.read(),gravityValue()));
1918
- if(keepAnimation&&assets.animations.some(clip=>clip.name===keepAnimation)){chooseAnimation(keepAnimation);seek(keepTime);}else drawRig(()=>player!.draw());
1936
+ // #186: a model without the staged animation (Reset after a retarget, a rebuild without that clip) clears it, so the
1937
+ // rig pane and the transport name the setup pose drawn, not the removed clip.
1938
+ if(keptAnimation(keepAnimation,assets.animations.map(clip=>clip.name))){chooseAnimation(keepAnimation,{kind:'reload'});seek(keepTime,{kind:'reload'});}
1939
+ // #197: the picker was just refilled (which selects its first option), so it is cleared whenever no clip is kept — even
1940
+ // when none was staged — and nothing claims a clip the stage is not drawing.
1941
+ else{drawRig(()=>player!.draw());animation='';duration=0;time=0;select('animation-select').value='';renderTransport();renderCues();renderIdle();}
1919
1942
  if(environmentApplied())applyEnvironment();
1920
1943
  updateChestMapping();fitCamera();
1921
1944
  // The preview (#104) drives the new player once its bone setups are read (an idle Apply cleared it first).
@@ -1944,8 +1967,7 @@ async function applyMesh(part:string,entry:MeshEntry):Promise<PatchJobView|Rebui
1944
1967
  const result=await applyPatch(patch,world);
1945
1968
  const outcome=meshApplyOutcome(result as Parameters<typeof meshApplyOutcome>[0]);meshPanel?.status(outcome.text,outcome.error);
1946
1969
  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();}
1970
+ // #173 / #183: the pending strip row (if any) was cleared by applyPatch's landing, as after every Apply.
1949
1971
  // The new triangulation is drawn: the mesh edges go on and the overlay captures the reloaded pose.
1950
1972
  if(!layers.mesh){layers.mesh=true;input('layer-mesh').checked=true;}
1951
1973
  if(player)drawRig(()=>player!.draw());redrawOverlay(true);
@@ -1972,11 +1994,13 @@ async function resetMeshBuild():Promise<void> {
1972
1994
  * sent together to `POST /api/density-strip` and polled until every candidate settles. Load: a built row's revision on
1973
1995
  * the stage as pending — the applied record, the undo stack and the feedback context's applied state are left alone
1974
1996
  * (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
1997
+ * Back returns the stage to what it showed or an Apply lands (the Mesh panel's reuses the row's build; any Apply, Reset or Undo clears the pending row, #183). Close removes
1976
1998
  * the strip's scratch.
1977
1999
  */
1978
2000
  let stripView:DensityStripViewLike|null=null,densityBusy=false;
1979
2001
  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;
2002
+ /** #183: after every landed Apply, Reset or Undo the pending row and Back go (`stripPendingAfter`); the strip re-reads (a row Applied is marked). */
2003
+ function stageChanged(change:StageChange<NonNullable<typeof stripPending>>):void {const next=stripPendingAfter(stripPending,change);if(next===stripPending)return;stripPending=next;void refreshDensityStrip();}
1980
2004
  const densityLine=(text:string,error=false)=>{densityStrip?.status(text,error);if(error)message(text,true);};
1981
2005
  const renderStrip=()=>densityStrip?.show(stripView,stripPending&&stripView?.id===stripPending.id?stripPending.index:null,densityBusy||rebuildBusy||!!readOnly);
1982
2006
  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();}
@@ -11,6 +11,7 @@ import type {MotionSummary,RetargetFlags,RetargetLine,RetargetRecord} from '../s
11
11
  export type {ConfigPatch,PatchOp,AppliedPatch,AppliedDelta,AppliedRetarget,CheckSummary,BustSpec};
12
12
  import {chestContourFields,chestRegionRows,chestRowId,type ChestFieldReader} from './chest-regions-tool.ts';
13
13
  import type {InspectedCheckView} from '../src/physics-tuning.ts';
14
+ import {samplesClock,type StageClock} from '../src/frame-clock.ts';
14
15
 
15
16
  export type RegionEntry=
16
17
  |{name:string;shape:'circle';cx:number;cy:number;r:number;bone:string;spacing:number;band:number}
@@ -235,6 +236,16 @@ export function recordFor(job:AnyJobView):AppliedRecord|AppliedPatch|AppliedDelt
235
236
  const kind=(job as {kind?:string}).kind;
236
237
  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
  }
239
+ /**
240
+ * What a retarget build on the stage sets beside its model (#164, #186): its new animation, to select, and that
241
+ * animation's own clock (the run's report frameTimes) bound to the build's model hash. The same for an Apply and for an
242
+ * Undo that restores a retarget build; null for any other job (it restores no retarget selection or clock).
243
+ */
244
+ export function retargetStage(job:AnyJobView,modelHash:string):{animation:string;clock:StageClock|null}|null {
245
+ if((job as {kind?:string}).kind!=='retarget')return null;
246
+ const r=job as RetargetJobView,animation=r.request.record.flags.animation,own=samplesClock(r.frameTimes);
247
+ return {animation,clock:own?{animation,modelHash,clock:own}:null};
248
+ }
238
249
  /** #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
250
  export function historyRow(history:AppliedHistoryView,index:number):string {
240
251
  const r=history.records[index]!,last=index===history.records.length-1;
@@ -249,3 +260,19 @@ export function undoControl(history:AppliedHistoryView|null,stageId:string|null,
249
260
  const back=depth>1?`record ${depth-1} (model ${history!.records[depth-2]!.modelHash.slice(0,12)}…)`:'the inspected build';
250
261
  return {label,enabled:!busy,title:`Return the stage to ${back}; the package is unchanged`};
251
262
  }
263
+ /**
264
+ * #183: a density-strip row pending on the stage after a stage change. Every landed Apply — whichever block sent it
265
+ * (regions, any patch block, Edit Mesh's delta, retarget) — and every Reset and Undo replaces the stage the row was
266
+ * loaded over and moves the server's stack, so the row stops being pending and Back has nothing to return to (returning
267
+ * would restore a stale stage over the applied record). A refused or failed step leaves the stage, and the row, as they
268
+ * were; only Load puts a row on the stage.
269
+ */
270
+ export type StageChange<P>=
271
+ |{kind:'apply';via:'regions'|'mesh'|'physics'|'idle'|'cuts'|'cloth'|'rig-fields'|'bones'|'patch'|'delta'|'retarget'}
272
+ |{kind:'reset'}|{kind:'undo'}|{kind:'refused'}
273
+ |{kind:'load';row:P};
274
+ export function stripPendingAfter<P>(pending:P|null,change:StageChange<P>):P|null {
275
+ if(change.kind==='load')return change.row;
276
+ if(change.kind==='refused')return pending;
277
+ return null;
278
+ }
@@ -132,7 +132,12 @@ export function bindRetargetPanel(deps:RetargetPanelDeps):RetargetPanel {
132
132
  template.onclick=()=>setMap(templateMap());
133
133
  file.onchange=()=>{const f=file.files?.[0];if(!f)return;void f.text().then(text=>{const read=readMap(text);if('error' in read)say(read.error,true);else{setMap(read.map);say(`Map loaded from ${f.name} (by content; nothing is sent until Apply).`,false);}file.value='';});};
134
134
  useJson.onclick=()=>{const read=readMap(json.value);if('error' in read)say(read.error,true);else{setMap(read.map);say('Map taken from the JSON field.',false);}};
135
- sequence.onchange=()=>{people();syncApply();};
135
+ // #186: `clean first` follows the selected sequence whenever the selection changes — on for a raw one (the producer
136
+ // refuses an uncleaned sequence, POSE_NOT_CLEANED), off for one already cleaned. A reload that keeps the selection keeps
137
+ // the reviewer's own choice.
138
+ let cleanFor:string|null=null;
139
+ function deriveClean(){const id=sequence.value;if(id===cleanFor)return;cleanFor=id;const s=status?.sequences.find(x=>x.id===id);if(s)clean.checked=!s.cleaned;}
140
+ sequence.onchange=()=>{deriveClean();people();syncApply();};
136
141
  reset.onclick=()=>deps.reset();
137
142
 
138
143
  function flags():RetargetFlags|{error:string}{
@@ -176,7 +181,7 @@ export function bindRetargetPanel(deps:RetargetPanelDeps):RetargetPanel {
176
181
  status=next;const prior=sequence.value;
177
182
  sequence.replaceChildren(...(next?.sequences??[]).map(s=>make('option',{value:s.id,textContent:`${s.label} · ${s.frames} frame(s) · ${s.cleaned?'cleaned':'raw'}`})));
178
183
  if(prior&&next?.sequences.some(s=>s.id===prior))sequence.value=prior;
179
- const s=next?.sequences.find(x=>x.id===sequence.value);if(s?.cleaned&&!prior)clean.checked=false;
184
+ deriveClean();
180
185
  people();if(!entries.length)setMap(templateMap());else render();
181
186
  say(next?.available?`${next.producer?.package} ${next.producer?.version} with ${next.compiler?.package} ${next.compiler?.version} on the handoff rig${next.rig?` (${next.rig})`:''} · ${next.bones.length} bone(s), animations ${next.animations.join(', ')||'none'}`:(next?.reason??reason??'RETARGET_UNAVAILABLE'),!next?.available);
182
187
  syncApply();