@rig-c/review 0.7.1 → 0.8.1

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
@@ -15,6 +15,7 @@ import {bindWeightsView,setupPoseMesh,type WeightsView} from './weights-view.ts'
15
15
  import {modelBoneNames,readSetupPose} from './setup-pose.ts';
16
16
  import {bindBonesPanel,type BonesPanel,type BonesCheckView} from './bones-panel.ts';
17
17
  import {bindCutsPanel,type CutsPanel} from './cuts-panel.ts';
18
+ import {bindClothPanel,type ClothPanel} from './cloth-panel.ts';
18
19
  import {retargetAppliedRecord,retargetOutcomeLine,type AppliedRetarget,type RetargetJobView,type RetargetRequestBody} from './rebuild-client.ts';
19
20
  import {bindRetargetPanel,type PoseMap,type RetargetPanel} from './retarget-panel.ts';
20
21
  import type {RetargetFlags} from '../src/retarget.ts';
@@ -34,7 +35,7 @@ import type {ReferenceBinding} from '../src/reference.ts';
34
35
  import type { Checkpoint, Json, Reference, ReviewedSource, ReviewManifest } from '../src/manifest.ts';
35
36
  import type { Feedback, ReviewSession } from '../src/session.ts';
36
37
  import type { ReportEvent, ReportSeeds } from '../src/report.ts';
37
- 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';
38
39
  import { feedbackLastFrame, feedbackTargetNames, validateFeedbackSelection, type FeedbackCatalog } from '../src/feedback-validation.ts';
39
40
  import { eventCoordinates, eventSeekTime, eventSelection, eventTimingLabel, reportCounts, reportDraftForMarker, targetLabel } from './report-view.ts';
40
41
  import type { StripInterval, StripRequest } from '../src/strip.ts';
@@ -42,6 +43,8 @@ import {createWindModel,environmentFor,windReadout,RUNTIME_START_READOUT,joltPac
42
43
  import {tuningRows,readoutRows,type InspectedCheckView} from '../src/physics-tuning.ts';
43
44
  import {bindReadingsPanel,EDGE as READING_EDGE,type ReadingsJobView,type ReadingsPanel,type ReadingsRefusal} from './readings-panel.ts';
44
45
  import {READINGS_SCHEMA,type ReadingsContext} from '../src/readings.ts';
46
+ import {environmentCheckRows,scheduleContext,worldExport,type EnvironmentCheckView,type WorldExport} from './environment-world.ts';
47
+ import {barLine,beforeLine} from '../src/physics-tuning.ts';
45
48
  import {bindRequirementsPanel,type RequirementsCatalogView,type RequirementsJobView,type RequirementsPanel,type RequirementsRefusal} from './requirements-panel.ts';
46
49
  import {patchRoute} from '../src/retune-route.ts';
47
50
  import {appliedCheckRefusal,type CheckSummary} from '../src/applied-context.ts';
@@ -82,6 +85,8 @@ let player: RigPlayer | null = null;
82
85
  let animation = '';
83
86
  let playing = false;
84
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;
85
90
  let duration = 0;
86
91
  let lastFrame = 0;
87
92
  type CameraTarget='rig'|'reference';
@@ -105,6 +110,7 @@ let weightsView:WeightsView|null=null;
105
110
  /** Setup · Bones (#109): the config's bone anchors on the stage, Check and a whole-rig Apply. */
106
111
  let bonesPanel:BonesPanel|null=null;
107
112
  let cutsPanel:CutsPanel|null=null;
113
+ let clothPanel:ClothPanel|null=null;
108
114
  /** The hash-verified compiled model document the parts catalog was read from (mesh edit reads attachments from it). */
109
115
  let partModel:unknown=null;
110
116
  /** Apply · Rebuild (#99): the inspected model's URL, the producer status, and the applied record while its scratch build is on the stage. */
@@ -430,13 +436,14 @@ function renderFeedbackTargets(preserve = false): void {
430
436
  }
431
437
 
432
438
  function renderFrameTransport(): void {
433
- const clock = activeFrameClock();
439
+ const active = activeTransportClock(), clock = active?.clock ?? null;
434
440
  const last = lastProducerFrame(clock), frame = frameAtTime(clock, time);
441
+ text('rig-label', rigPaneLabel({ hasPlayer: !!player, animation: player?.current ?? animation, time, frame }));
435
442
  el('frame-controls').hidden = last === null;
436
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; }
437
444
  if (last === null) { text('frame-label', 'F? / ? · frame range unknown'); return; }
438
445
  text('frame-label', `F${frame === null ? '?' : frame} / ${last}${frame === null ? ' · Out of range' : ''}`);
439
- 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" : ''));
440
447
  input('frame-number').max = String(last);
441
448
  if (document.activeElement !== input('frame-number')) input('frame-number').value = frame === null ? '' : String(frame);
442
449
  input('frame-scrub').max = String(last); input('frame-scrub').hidden = frame === null;
@@ -445,10 +452,11 @@ function renderFrameTransport(): void {
445
452
  el('frame-outside').hidden = frame !== null;
446
453
  }
447
454
 
448
- function activeFrameClock() {
455
+ function activeTransportClock() {
449
456
  const active = player && player.current !== animation ? '' : animation;
450
- 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);
451
458
  }
459
+ function activeFrameClock() { return activeTransportClock()?.clock ?? null; }
452
460
 
453
461
  function seekFrame(frame: number): void {
454
462
  const exact = timeAtFrame(activeFrameClock(), frame);
@@ -612,7 +620,7 @@ function tick(now: number): void {
612
620
  if (player && player.current) { try { stepEnvironment(dt); drawRig(()=>player!.frame(dt)); } catch (error) { pause(); message(errorText(error), true); } }
613
621
  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;
614
622
  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);
615
- renderSequenceFrame(); renderTransport(); if(time>=duration){if(canRepeatIdle()&&!idleHeld){stopAudio();seek(0);player?.resume();if(video)void video.play().catch(error=>{pause();message(errorText(error),true);});}else pause();} lastFrame = now;
623
+ 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;
616
624
  }
617
625
  requestAnimationFrame(tick);
618
626
  }
@@ -783,7 +791,7 @@ function bind(): void {
783
791
  button('chest-export').onclick = () => { void exportChest(); };
784
792
  for (const event of ['input', 'change']) el('chest-regions').addEventListener(event, renderChest);
785
793
  // A discarded local draft restores the form after its button's own handler; re-read the tool then.
786
- el('draft-recovery').addEventListener('click', () => { renderChest(); restoreEnvironment(); physicsPanel?.restore(); bonesPanel?.restore(); cutsPanel?.restore(); idlePanel?.restore(); rigFieldsPanel?.restore(); });
794
+ el('draft-recovery').addEventListener('click', () => { renderChest(); restoreEnvironment(); physicsPanel?.restore(); bonesPanel?.restore(); cutsPanel?.restore(); clothPanel?.restore(); idlePanel?.restore(); rigFieldsPanel?.restore(); });
787
795
  for (const id of ['strip-scope', 'strip-reference', 'strip-format']) select(id).onchange = () => stripStatus('Selection changed. Check the ranges, animation and reference video before downloading.');
788
796
  for (const item of document.querySelectorAll<HTMLButtonElement>('[data-view]')) item.onclick = () => {setView(item.dataset.view as typeof view);const menu=document.getElementById('view-menu') as HTMLDetailsElement|null;if(menu)menu.open=false;};
789
797
  for (const item of document.querySelectorAll<HTMLButtonElement>('[data-proposal]')) item.onclick = () => propose(item.dataset.proposal ?? '');
@@ -837,6 +845,8 @@ function bind(): void {
837
845
  view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},
838
846
  axis: axisInspection ? { id: select('axis-select').value, value: Number(input('axis-value').value) } : null,
839
847
  environment: feedbackEnvironment(),
848
+ // The exported schedule (#112): the drawn clip's rigc-environment/1 bytes and their sha256, and whether readings and Apply read them.
849
+ environmentSchedule: feedbackSchedule() as unknown as Json,
840
850
  // The exact last applied designation and the producer's line, only while that scratch build is what is drawn.
841
851
  applied: appliedOnStage ? JSON.parse(JSON.stringify(appliedOnStage)) as Json : null,
842
852
  // The readings chosen (#108): the pairs of the last finished run and the builds they ran on, not their results.
@@ -877,6 +887,10 @@ function bind(): void {
877
887
  cutsPanel=bindCutsPanel({pane:el('rig-pane'),canvas:el('rig-overlay'),project:()=>player?rigProject():null,mapping:()=>chestMapping,inspector:()=>selectedInspector(),pause,changed:()=>redrawOverlay(false),saved:()=>persistence?.save(),
878
888
  apply:applyCuts,reset:resetCutsBuild,token:name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim(),route:path=>access.route(path)});
879
889
  viewDisposers.push(()=>cutsPanel?.dispose());
890
+ // #111: the cloth recipe per tag; Apply runs rig-parts propose --cloth --write-config, then builds what it wrote.
891
+ clothPanel=bindClothPanel({project:()=>player?rigProject():null,mapping:()=>chestMapping,inspector:()=>selectedInspector(),pause,changed:()=>redrawOverlay(false),saved:()=>persistence?.save(),
892
+ apply:applyCloth,reset:resetClothBuild,token:name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim()});
893
+ viewDisposers.push(()=>clothPanel?.dispose());
880
894
  physicsPanel=bindPhysicsPanel({apply:async ops=>{await applyPhysics(ops);},reset:resetPhysicsBuild,pane:el('rig-pane'),saved:()=>persistence?.save(),
881
895
  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;}});
882
896
  viewDisposers.push(()=>physicsPanel?.dispose());
@@ -916,8 +930,8 @@ function bind(): void {
916
930
 
917
931
  async function start(): Promise<void> {
918
932
  try {
919
- let writes: { capabilityRequired: boolean };
920
- [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')]);
921
935
  readOnly = readOnlyReason(access, writes.capabilityRequired);
922
936
  submittedBaseline=structuredClone(session);
923
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;
@@ -941,8 +955,9 @@ async function start(): Promise<void> {
941
955
  else if (readOnly) message(readOnly, true);
942
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.');
943
957
  requestAnimationFrame(tick);
944
- const restored=await setupPersistence();await loadRebuildStatus();void loadRequirements();renderChest();restoreEnvironment();physicsPanel?.restore();bonesPanel?.restore();cutsPanel?.restore();idlePanel?.restore();rigFieldsPanel?.restore();meshPanel?.restore();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
945
- 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)});
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();
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)});
946
961
  } catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
947
962
  }
948
963
  /** `feedback` is the review dialog, not an Inspector section: a user action opens it; a restored older bookmark that saved it selects nothing. */
@@ -963,7 +978,7 @@ function rigProject(){const root=el('rig-root');return (x:number,y:number)=>({x:
963
978
  function redrawOverlay(fresh:boolean):void {
964
979
  if(!overlays||!player)return;
965
980
  const project=rigProject();
966
- const regions=[...chestRegions(),...(meshPanel?.overlayRegions()??[]),...(cutsPanel?.overlayRegions()??[])],circles=[...chestCircles(),...(meshPanel?.overlayCircles()??[])],markers=[...chestMarkers(),...(cutsPanel?.overlayMarkers()??[])];
981
+ const regions=[...chestRegions(),...(meshPanel?.overlayRegions()??[]),...(cutsPanel?.overlayRegions()??[]),...(clothPanel?.overlayRegions()??[])],circles=[...chestCircles(),...(meshPanel?.overlayCircles()??[])],markers=[...chestMarkers(),...(cutsPanel?.overlayMarkers()??[])];
967
982
  const value=fresh?overlays.refresh({player,frame:parts?.frame('single'),selectedSlot:parts?.value()?.slot,bones:(data.feedbackCatalog.bones??[]).map(name=>({name})),project,settings:layers,regions,circles,markers,meshEdit:meshEditor?.overlay(),weights:weightsView?.overlay(),anchors:bonesPanel?.overlay()}):overlays.redraw({project,settings:layers,regions,circles,markers,meshEdit:meshEditor?.overlay(),weights:weightsView?.overlay(),anchors:bonesPanel?.overlay()});
968
983
  lastOverlay=value;
969
984
  text('layer-state',value.warnings.join(' · ')||'Actual last-drawn mesh edges / bone points');idlePanel?.place(duration);rigFieldsPanel?.place();if(!fresh&&value.needsCapture&&!playing&&!seekingControl)drawRig(()=>player!.draw());
@@ -1160,7 +1175,7 @@ function renderChest():void {
1160
1175
  el('chest-mapping').classList.toggle('error',!chestMapping.available);
1161
1176
  const value=chestEvaluation();text('chest-status',value.ok?`${value.document.mesh.value.contour.regions.map(r=>`${r.name} → ${r.bone}`).join(' · ')} · valid for export`:value.error);
1162
1177
  el('chest-status').classList.toggle('error',!value.ok);
1163
- chestEditor?.sync();meshEditor?.sync();weightsView?.sync();bonesPanel?.sync();cutsPanel?.sync();redrawOverlay(false);
1178
+ chestEditor?.sync();meshEditor?.sync();weightsView?.sync();bonesPanel?.sync();cutsPanel?.sync();clothPanel?.sync();redrawOverlay(false);
1164
1179
  }
1165
1180
  async function exportChest():Promise<void> {
1166
1181
  const value=chestEvaluation();if(!value.ok){message(`Chest regions not exported: ${value.error}`,true);return;}
@@ -1170,7 +1185,7 @@ async function exportChest():Promise<void> {
1170
1185
  message('Downloaded chest-regions.json and chest-regions.binding.json. The regions are review evidence, not an approval; the package is unchanged.');
1171
1186
  }
1172
1187
  /** Animate · Environment. Inspection only: the player is the solver, nothing is written to the model or motion. */
1173
- const environmentInputs=['wind-base','wind-breeze','wind-gust','wind-repeat','wind-gust-peak','wind-gust-duration','gravity','jolt','drag-trail','physics-reset'];
1188
+ const environmentInputs=['wind-base','wind-breeze','wind-gust','wind-repeat','wind-gust-peak','wind-gust-duration','gravity','jolt','drag-trail','physics-reset','environment-use','environment-export','environment-check'];
1174
1189
  function environmentApplied():boolean{return input('environment-applied').checked;}
1175
1190
  function gravityValue():number{return Number(input('gravity').value);}
1176
1191
  /** Called from `tick` right before `player.frame(dt)`: the gust clock is the stepped time, so a paused player freezes it. */
@@ -1188,6 +1203,55 @@ function renderEnvironment():void {
1188
1203
  text('wind-readout',(environmentApplied()?windReadout(reading,gravityValue()):RUNTIME_START_READOUT)+environmentNote);
1189
1204
  const gust=button('wind-gust');gust.disabled=reading.gusting;gust.setAttribute('aria-pressed',String(reading.gusting));gust.textContent=reading.gusting?'Gusting…':'Gust';
1190
1205
  text('wind-base-label',input('wind-base').value);text('gravity-label',gravityValue().toFixed(2));
1206
+ renderSchedule();
1207
+ }
1208
+ /**
1209
+ * #112 · Use for readings: the controls as the exported `rigc-environment/1` schedule (src/environment-schedule.ts). While
1210
+ * on, readings send the drawn clip's schedule, Apply's check reads the checked clip's (`idle` when the rig has one, as
1211
+ * `rig-parts check` renders it), and the wind clock restarts with the clip so the person watches the world rigc reads.
1212
+ */
1213
+ function useWorld():boolean{return input('environment-use').checked&&environmentApplied();}
1214
+ function clipDuration(name:string):number{return assets?.animations.find(item=>item.name===name)?.duration??data.manifest.animations.find(item=>item.id===name)?.duration??0;}
1215
+ /** The clip `rig-parts check` poses its idle readings over: `idle` when the rig has one, else the drawn clip. */
1216
+ function checkAnimation():string{return assets?.animations.some(item=>item.name==='idle')?'idle':animation;}
1217
+ let worldCache:{key:string;value:WorldExport|{error:string}}|null=null;
1218
+ function worldFor(name:string):WorldExport|{error:string} {
1219
+ const key=JSON.stringify([wind.snapshot(),gravityValue(),environmentApplied(),name,clipDuration(name)]);
1220
+ if(worldCache?.key!==key)worldCache={key,value:worldExport(wind.snapshot(),gravityValue(),environmentApplied(),name,clipDuration(name))};
1221
+ return worldCache.value;
1222
+ }
1223
+ /** A fresh wind clock at the same settings (a repeating gust restarts at 0); pending jolts keep their distance in time. */
1224
+ function restartWindClock():void {const now=wind.read().time;wind.restore(wind.snapshot());joltQueue=joltQueue.map(step=>({...step,at:Math.max(0,step.at-now)}));}
1225
+ let scheduleShown='';
1226
+ function renderSchedule():void {
1227
+ const world=worldFor(animation),check=checkAnimation(),checked=check!==animation?worldFor(check):null;
1228
+ const shown='error' in world?world.error:`${world.summary}${useWorld()?' · used for readings and Apply':''}${checked&&!('error' in checked)&&checked.sha256!==world.sha256?` · check reads ${check}'s: ${checked.summary}`:''}`;
1229
+ if(shown!==scheduleShown){scheduleShown=shown;text('environment-schedule',shown);}
1230
+ }
1231
+ function feedbackSchedule(){if(!player||!environmentApplied())return null;const world=worldFor(animation);return 'error' in world?null:scheduleContext(world,useWorld());}
1232
+ let environmentCheckBusy=false;
1233
+ /** Check under this world: `rig-parts check --environment` on the build on the stage, beside that build's default-world check. */
1234
+ async function runEnvironmentCheck():Promise<EnvironmentCheckView|{status:'refused';error:string}|null> {
1235
+ if(environmentCheckBusy)return null;
1236
+ const out=el('environment-check-lines'),show=(rows:{kind:string;text:string}[])=>{out.replaceChildren(...rows.map(row=>{const line=document.createElement('div');line.className=`env-${row.kind}`;line.textContent=row.text;return line;}));};
1237
+ const name=checkAnimation(),world=worldFor(name);
1238
+ if('error' in world){show([{kind:'error',text:world.error}]);return {status:'refused',error:world.error};}
1239
+ const build=appliedOnStage?'scratch':'inspected',before=stageCheck;
1240
+ environmentCheckBusy=true;button('environment-check').disabled=true;show([{kind:'label',text:`Checking the ${build} build under ${name}'s schedule (${world.schedule.steps.length} step(s), sha256 ${world.sha256.slice(0,16)}) with rig-parts check --environment…`}]);
1241
+ try{
1242
+ let job=await api<EnvironmentCheckView|{status:'refused';error:string}>('/api/environment-check',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({build,schedule:world.schedule})}).catch(error=>({status:'refused' as const,error:errorText(error).replace(/^\d+: /,'')}));
1243
+ if(!('id' in job)){const text=(()=>{try{return (JSON.parse(job.error) as {error?:string}).error??job.error;}catch{return job.error;}})();show([{kind:'error',text}]);return {status:'refused',error:text};}
1244
+ const started=performance.now();
1245
+ while(job.status==='running'){show([{kind:'label',text:`check --environment running on the ${build} build · ${((performance.now()-started)/1000).toFixed(0)} s · ${job.lines.at(-1)?.trim()??''}`}]);await new Promise(resolve=>setTimeout(resolve,1500));job=await api<EnvironmentCheckView>(`/api/environment-check/${job.id}`);}
1246
+ const checked=job;
1247
+ const label=build==='inspected'?'default world (the inspected build\'s own check)':`the scratch build's Apply check${appliedOnStage&&'environment' in appliedOnStage&&appliedOnStage.environment?` (under schedule sha256 ${appliedOnStage.environment.sha256.slice(0,16)})`:' (default world)'}`;
1248
+ // No inspected check exists when the baseline did not reproduce the inspected rig; say why, verbatim (a schedule of wind [1, 0], gravity [0, 1] reads the default world).
1249
+ const baseline=rebuildStatus?.baseline,noDefault=build==='inspected'&&!inspectedCheckView()&&baseline&&baseline.status!=='running'&&baseline.status!=='reproduced';
1250
+ const draw=()=>show(environmentCheckRows(checked,name=>noDefault?`${name}: no default-world check — ${baseline!.line} (check a schedule of wind 1, gravity 1 for the runtime's world)`:build==='inspected'?beforeLine(name,null,inspectedCheckView()):barLine(name,before?.lines??null,before?.check??null),label));
1251
+ draw();if(build==='inspected'&&inspectedCheckView()?.status!=='checked')void followInspectedCheck(draw);
1252
+ return job;
1253
+ }catch(error){show([{kind:'error',text:`ENVIRONMENT_CHECK_FAILED: ${errorText(error)}`}]);return null;}
1254
+ finally{environmentCheckBusy=false;button('environment-check').disabled=!player;}
1191
1255
  }
1192
1256
  /** Every control change engages the environment; until then the player stands at the runtime start values. */
1193
1257
  function engage(change:()=>void):void {
@@ -1207,6 +1271,15 @@ function bindEnvironment():void {
1207
1271
  button('wind-gust').onclick=()=>engage(()=>wind.triggerGust());
1208
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.');};
1209
1273
  button('physics-reset').onclick=resetPhysics;
1274
+ input('environment-use').onchange=()=>{
1275
+ if(!input('environment-use').checked){message('Use for readings is off: readings send the standing wind and gravity, Apply\'s check reads the default world.');renderSchedule();return;}
1276
+ const world=worldFor(animation);
1277
+ if('error' in world){input('environment-use').checked=false;message(world.error,true);renderSchedule();return;}
1278
+ // The watched world starts where the schedule does: a fresh wind clock at the clip's first frame.
1279
+ restartWindClock();seek(0);applyEnvironment();message(`Use for readings: ${world.summary} · the wind clock restarts with the clip.`);
1280
+ };
1281
+ button('environment-export').onclick=()=>{const world=worldFor(animation);if('error' in world){message(world.error,true);return;}download('rigc-environment.json',world.text,'application/json');message(`Exported ${world.summary}. The download is the bytes readings, Apply and feedback carry; the package is unchanged.`);};
1282
+ button('environment-check').onclick=()=>{void runEnvironmentCheck();};
1210
1283
  input('drag-trail').onchange=()=>{el('rig-viewport').dataset.trail=String(input('drag-trail').checked);};
1211
1284
  // Drag to trail: the rig pane's pointer drag becomes `player.moved` before the pan handler sees it; Space still pans.
1212
1285
  let space=false,trail:{id:number;x:number;y:number}|null=null;
@@ -1309,6 +1382,8 @@ async function applyRetarget(body:{sequence:string;map:PoseMap;flags:RetargetFla
1309
1382
  retargetPanel?.result(result);retargetPanel?.status(outcome.text,outcome.error);
1310
1383
  if(next.id===null||!('id' in result)){message(outcome.text,true);return result;}
1311
1384
  await swapRig(new URL(access.route(next.url),location.href).href,access.route(`/rebuild/${next.id}/`),next.hash,world);
1385
+ // The new animation's own clock (the run's report frameTimes), bound to this build: the transport counter and the rig pane read it.
1386
+ const own=samplesClock(result.frameTimes);stageClock=own?{animation:result.request.record.flags.animation,modelHash:next.hash,clock:own}:null;
1312
1387
  appliedOnStage=retargetAppliedRecord(result);stageCheck=null;
1313
1388
  const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1314
1389
  // The new animation plays against the reference: choose it, and show the sequence it was made from beside it.
@@ -1354,6 +1429,7 @@ async function loadRebuildStatus():Promise<RebuildStatusView|null> {
1354
1429
  retargetPanel?.load(rebuildStatus.retarget??null,rebuildStatus.reason);
1355
1430
  bonesPanel?.load(rebuildStatus.inputs?rebuildStatus.bones??[]:null,rebuildStatus.inputs?null:rebuildStatus.reason??'BONES_UNAVAILABLE: no producer handoff');
1356
1431
  rigFieldsPanel?.load(rebuildStatus.inputs?rebuildStatus.fields??{}:null,rebuildStatus.inputs?null:rebuildStatus.reason??'RIG_FIELDS_UNAVAILABLE: no producer handoff');
1432
+ clothPanel?.load(rebuildStatus.inputs?rebuildStatus.cloth??null:null,rebuildStatus.inputs?null:rebuildStatus.reason??'CLOTH_UNAVAILABLE: no producer handoff');
1357
1433
  cutsPanel?.load(rebuildStatus.inputs&&rebuildStatus.cuts?rebuildStatus.cuts.config:null,rebuildStatus.bones??[],rebuildStatus.inputs?null:rebuildStatus.reason??'CUTS_UNAVAILABLE: no producer handoff',rebuildStatus.cuts?.build??null);
1358
1434
  meshPanel?.load(rebuildStatus.inputs?rebuildStatus.meshes??{}:null,rebuildStatus.inputs?null:rebuildStatus.reason??'MESH_PANEL_UNAVAILABLE: no producer handoff');meshPanel?.select(parts?.value()?.slot??null);
1359
1435
  return rebuildStatus;
@@ -1410,7 +1486,10 @@ async function applyPatch(patch:ConfigPatch,world:StageWorld|null=null):Promise<
1410
1486
  const lines:string[]=[];
1411
1487
  rebuildBusy=true;rebuildState();chestEditor?.notify(`Applying patch (${patch.op.cmd}, ${patch.ops.length} op(s))…`,false,[]);
1412
1488
  try{
1413
- const result=await runRebuild<{patch:ConfigPatch},PatchJobView>({patch},{post:value=>api<PatchJobView|RebuildRefusal>('/api/rebuild',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)}),get:id=>api<PatchJobView>(`/api/rebuild/${id}`),sleep:ms=>new Promise(resolve=>setTimeout(resolve,ms)),
1489
+ // #112: with "Use for readings" on, Apply's check reads the exported schedule of the clip check renders (its idle).
1490
+ const scheduled=useWorld()?worldFor(checkAnimation()):null;
1491
+ if(scheduled&&'error' in scheduled){chestEditor?.notify(scheduled.error,true);message(scheduled.error,true);return {status:'refused',error:scheduled.error,producerLine:scheduled.error};}
1492
+ const result=await runRebuild<{patch:ConfigPatch;environment?:WorldExport['schedule']},PatchJobView>({patch,...(scheduled?{environment:scheduled.schedule}:{})},{post:value=>api<PatchJobView|RebuildRefusal>('/api/rebuild',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)}),get:id=>api<PatchJobView>(`/api/rebuild/${id}`),sleep:ms=>new Promise(resolve=>setTimeout(resolve,ms)),
1414
1493
  onLine:line=>{lines.push(line);chestEditor?.notify(line.trim()||'…',false,lines.slice(-6));}});
1415
1494
  const outcome=patchOutcomeLine(result),next=stageModel(result,{url:'',hash:stageModelHash??''});
1416
1495
  if(next.id===null||!('id' in result)){chestEditor?.notify(outcome.text,true,[...lines.slice(-5),outcome.text]);message(outcome.text,true);return result;}
@@ -1454,6 +1533,8 @@ async function applyPatch(patch:ConfigPatch,world:StageWorld|null=null):Promise<
1454
1533
  const posedTime=player.time;if(steps.length){if(environmentApplied())player.environment(environmentFor(wind.read(),gravityValue()));else player.environment(null);}
1455
1534
  return posedTime;},
1456
1535
  /** Readings (#108): run (the pickers' choice, or the given readings), the last job, the ticks drawn and the worst lines shown. */
1536
+ /** #112: the exported schedule of a clip (the drawn one by default), Use for readings, and Check under this world. */
1537
+ world:{schedule:(name?:string)=>{const w=worldFor(name??animation);return 'error' in w?w:{text:w.text,sha256:w.sha256,summary:w.summary,form:w.export.form,tolerance:w.export.tolerance};},use:(on:boolean)=>{input('environment-use').checked=on;input('environment-use').dispatchEvent(new Event('change'));return useWorld();},check:()=>runEnvironmentCheck(),lines:()=>[...el('environment-check-lines').children].map(c=>c.textContent??'')},
1457
1538
  readings:{run:(readings?:import('../src/readings.ts').ReadingRequest[])=>runReadings(readings?{readings}:undefined),job:()=>readingsJob?JSON.parse(JSON.stringify(readingsJob)):null,ticks:()=>[...document.querySelectorAll<HTMLButtonElement>('#reading-ticks .reading-tick')].map(t=>({build:t.dataset.build,frame:Number(t.dataset.frame),left:t.style.left})),lines:()=>readingsPanel?.shownLines()??[],context:()=>readingsContext()},
1458
1539
  /** Idle tracks (#104): the panel's state and ops, its edits, Apply and Reset, the preview, and the inspection-only `sample`. */
1459
1540
  idle:{state:()=>idlePanel?.state()??null,ops:()=>idlePanel?.ops()??[],source:()=>idlePanel?.source()??null,keying:()=>idlePanel?.keying()??null,refusals:()=>idlePanel?.refusals()??[],
@@ -1471,6 +1552,9 @@ async function applyPatch(patch:ConfigPatch,world:StageWorld|null=null):Promise<
1471
1552
  cuts:{source:()=>cutsPanel?.source()??null,ops:()=>cutsPanel?.ops()??[],handles:()=>cutsPanel?.handles()??[],active:()=>cutsPanel?.active()??false,setActive:(on:boolean)=>cutsPanel?.setActive(on),
1472
1553
  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),
1473
1554
  apply:()=>cutsPanel?.apply()??Promise.resolve(null),reset:()=>resetCutsBuild()},
1555
+ /** #111: the Cloth block — the selected tag, its form values, the pending ops, the link preview, Apply and Reset. */
1556
+ cloth:{tag:()=>clothPanel?.tag()??'',select:(tag:string)=>clothPanel?.select(tag),values:(tag?:string)=>clothPanel?.values(tag)??{},set:(key:string,text:string,tag?:string)=>clothPanel?.set(key,text,tag),declare:(on:boolean,tag?:string)=>clothPanel?.declare(on,tag),
1557
+ ops:()=>clothPanel?.ops()??[],links:()=>clothPanel?.links()??null,active:()=>clothPanel?.active()??false,setActive:(on:boolean)=>clothPanel?.setActive(on),apply:()=>clothPanel?.apply()??Promise.resolve(null),reset:()=>resetClothBuild(),status:()=>el('cloth-status').textContent,readout:()=>[...document.querySelectorAll('#cloth-readout code')].map(c=>c.textContent)},
1474
1558
  bones:{anchors:()=>bonesPanel?.anchors()??[],edits:()=>bonesPanel?.edits()??[],set:(path:string,value:[number,number])=>bonesPanel?.set(path,value),select:(name:string|null)=>bonesPanel?.select(name),
1475
1559
  active:()=>bonesPanel?.active()??false,setActive:(on:boolean)=>bonesPanel?.setActive(on),handles:()=>bonesPanel?.handles()??[],check:()=>bonesPanel?.check()??Promise.resolve(null),
1476
1560
  apply:()=>applyBones((bonesPanel?.edits()??[]).map(e=>({op:'replace' as const,path:e.path,value:[...e.value],name:e.name}))),reset:()=>resetBonesBuild(),
@@ -1532,7 +1616,9 @@ async function runReadings(override?:{readings:import('../src/readings.ts').Read
1532
1616
  const chosen=override??readingsPanel.requests();
1533
1617
  if('error' in chosen){readingsPanel.status(chosen.error,true);return {status:'refused',error:chosen.error};}
1534
1618
  if(!animation){readingsPanel.status('READINGS_NO_ANIMATION: no animation is drawn',true);return null;}
1535
- const environment=player&&environmentApplied()?environmentFor(wind.read(),gravityValue()):null;
1619
+ // #112: with "Use for readings" on, the exported schedule of the drawn clip goes in place of the standing world.
1620
+ let environment:ReturnType<typeof environmentFor>|WorldExport['schedule']|null=player&&environmentApplied()?environmentFor(wind.read(),gravityValue()):null;
1621
+ if(environment&&useWorld()){const world=worldFor(animation);if('error' in world){readingsPanel.status(world.error,true);return {status:'refused',error:world.error};}environment=world.schedule;}
1536
1622
  const body={animation,readings:chosen.readings,environment};
1537
1623
  readingsBusy=true;readingsPanel.busy(true);readingsPanel.status(`Running ${chosen.readings.length} reading(s) of ${animation} with rigc render…`,false);
1538
1624
  const started=performance.now();
@@ -1549,7 +1635,8 @@ async function runReadings(override?:{readings:import('../src/readings.ts').Read
1549
1635
  readingsJob=job;readingsPanel.lines(job);renderReadingTicks(true);
1550
1636
  const ticks=job.runs.map(r=>`${r.build} ${r.results.reduce((n,x)=>n+x.frames.length,0)} tick(s)`).join(' · ');
1551
1637
  const refused=job.runs.flatMap(r=>r.results).find(r=>r.status!=='read');
1552
- const env=job.environment.requested?(job.environment.passed?' · wind and gravity passed as --environment':' · this rigc has no --environment; the environment was not passed'):'';
1638
+ const scheduled=job.request.environment&&'spec' in job.request.environment?job.request.environment:null;
1639
+ const env=job.environment.requested?(job.environment.passed?(scheduled?` · the exported schedule (${scheduled.steps.length} step(s)) passed as --environment`:' · wind and gravity passed as --environment'):' · this rigc has no --environment; the environment was not passed'):'';
1553
1640
  readingsPanel.status(`${refused?`${refused.refusal} · `:''}Readings of ${job.request.animation}: ${ticks}${job.runs.length<2?' · no scratch build yet (Apply builds one)':''}${env} · ${((performance.now()-started)/1000).toFixed(1)} s`,!!refused);
1554
1641
  return job;
1555
1642
  }catch(error){readingsPanel.status(`READINGS_FAILED: ${errorText(error)}`,true);return null;}
@@ -1617,6 +1704,29 @@ async function applyCuts(ops:PatchOp[]):Promise<PatchJobView|RebuildRefusal|null
1617
1704
  return result;
1618
1705
  }finally{cutsPanel?.busy(false);}
1619
1706
  }
1707
+ /**
1708
+ * Cloth Apply (#111): the pending recipe ops become one `rig` patch, which the server runs as `rig-parts propose --cloth
1709
+ * --write-config` and then builds the written config; the scratch build reloads in place under the stage's world.
1710
+ */
1711
+ async function applyCloth(ops:PatchOp[]):Promise<PatchJobView|RebuildRefusal|null> {
1712
+ const status=rebuildStatus??await loadRebuildStatus();
1713
+ const patch=status&&ops.length?makePatch(status,ops,{cmd:'rig'}):null;
1714
+ if(!patch){const reason=!ops.length?'CLOTH_NOTHING_PENDING: no pending ops':status?.reason??'REBUILD_STATUS_UNAVAILABLE';clothPanel?.status(reason,true);return null;}
1715
+ const route=status?.cloth?.route==='build'?'build (assemble → rig → check)':'rig --config, check';
1716
+ clothPanel?.busy(true);clothPanel?.status(`Applying ${ops.length} op(s) with ${patch.producer.name} ${patch.producer.version}: propose --cloth --write-config, then ${route}, compile…`,false);
1717
+ try{
1718
+ const result=await applyPatch(patch,stageWorld());
1719
+ const outcome=patchOutcomeLine(result);clothPanel?.status(outcome.text,outcome.error);
1720
+ if('id' in result&&result.status==='built')redrawOverlay(true);
1721
+ return result;
1722
+ }finally{clothPanel?.busy(false);}
1723
+ }
1724
+ /** Cloth Reset: the inspected build back on the stage (same world), and no pending op. */
1725
+ async function resetClothBuild():Promise<void> {
1726
+ clothPanel?.clear();clothPanel?.shown(null);
1727
+ if(appliedOnStage){clothPanel?.busy(true);try{await resetRebuild(stageWorld());}finally{clothPanel?.busy(false);}}
1728
+ clothPanel?.status('Reset: the inspected build is on the stage; no pending ops.',false);
1729
+ }
1620
1730
  /** Cuts Reset: the inspected build back on the stage (same world), and no pending op. */
1621
1731
  async function resetCutsBuild():Promise<void> {
1622
1732
  cutsPanel?.clear();cutsPanel?.shown(null);
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import {readingTicks,ticksOnClip,TICK_EDGE,worstLine,type ReadingBuild,type ReadingRequest,type ReadingResult,type ReadingTick} from '../src/readings.ts';
8
8
 
9
- export interface ReadingsJobView {id:string;status:'running'|'done'|'failed';error:string|null;request:{animation:string;readings:ReadingRequest[];environment:{wind:[number,number];gravity:[number,number]}|null};environment:{requested:boolean;flag:boolean;passed:boolean};compiler:{package:string;version:string};runs:{build:ReadingBuild;id:string;modelHash:string;results:ReadingResult[]}[]}
9
+ export interface ReadingsJobView {id:string;status:'running'|'done'|'failed';error:string|null;request:{animation:string;readings:ReadingRequest[];environment:import('../src/readings.ts').ReadingsRequest['environment']};environment:{requested:boolean;flag:boolean;passed:boolean};compiler:{package:string;version:string};runs:{build:ReadingBuild;id:string;modelHash:string;results:ReadingResult[]}[]}
10
10
  export interface ReadingsRefusal {status:'refused';error:string}
11
11
  export interface ReadingsPanel {
12
12
  catalog(slots:readonly string[],bones:readonly string[]):void;
@@ -19,7 +19,9 @@ export type RegionEntry=
19
19
  export interface HandoffView {how:'declared'|'discovered';via:string;outside:boolean;config:{path:string;sha256:string};parts:string|null;rig:string|null;files:{path:string;sha256:string}[];missing:string[]}
20
20
  export interface BaselineView {status:'running'|'reproduced'|'not-reproduced'|'refused'|'failed';firstDifference:string|null;hashes:{baseline:{rigJson:string|null;tree:string};inspected:{rigJson:string|null;tree:string}}|null;line:string;wallMs:number|null}
21
21
  export interface RetargetStatusView {available:boolean;reason:string|null;producer:{package:string;version:string}|null;compiler:{package:string;version:string}|null;rig:string|null;sequences:{id:string;label:string;path:string;cleaned:boolean;frames:number;people:string[]}[];bones:string[];animations:string[]}
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;producer:{package:string;version:string}|null;compiler:{package:string;version:string}|null;reason:string|null}
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
+ /** #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
+ 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}
23
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}}
24
26
  export interface RebuildRefusal {status:'refused';error:string;producerLine:string|null}
25
27
  /** #99 regions or #105 landmarks: exactly one of `regions` and `bust`. */
@@ -116,14 +118,15 @@ export function parseApplied(text:string):AppliedRecord|AppliedPatch|AppliedDelt
116
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. */
117
119
  /** #115: a build job's assemble readout (`AssembledView` in src/rebuild.ts). */
118
120
  export type AssembledJobView=import('../src/rebuild.ts').AssembledView;
119
- export interface PatchJobView {id:string;kind:'patch';op?:'rig'|'retune'|'remesh'|'build';assembled?:AssembledJobView;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}}
120
- export interface PatchRequestBody {patch:ConfigPatch}
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}}
122
+ /** #112: `environment` is the exported `rigc-environment/1` schedule Apply's check reads, when "Use for readings" is on. */
123
+ export interface PatchRequestBody {patch:ConfigPatch;environment?:import('../src/environment-schedule.ts').EnvironmentSchedule}
121
124
  /** Edit Mesh's delta Apply (#135): `remesh --delta`, the per-file diff and the part's counts; remesh's own gate holds check. */
122
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}}
123
126
  /** `{part, delta, configHash}`: the server owns every path and flag; the delta is the editor's export as it stands. */
124
127
  export interface DeltaRequestBody {part:string;delta:import('../src/mesh-edit.ts').MeshEditDocument;configHash:string}
125
128
  /** A retarget Apply's job (#114): clean, retarget, compile; every FAIL line of the step that refused, verbatim; the listed lines. */
126
- 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}}
127
130
  /** `{retarget: {sequence, map, flags, configHash}}`: a declared reference id, the map by content and typed flags — no path, no flag string. */
128
131
  export interface RetargetRequestBody {retarget:{sequence:string;map:Record<string,unknown>;flags:RetargetFlags;configHash:string}}
129
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. */
@@ -158,11 +161,11 @@ export function makePatch(status:Pick<RebuildStatusView,'inputs'|'producer'>,ops
158
161
  return {schema:CONFIG_PATCH_SCHEMA,config_sha256:status.inputs.configHash,ops:ops.map(o=>({...o})),producer:{name:status.producer.package,version:status.producer.version},op:{cmd:op.cmd,args:[...(op.args??[])]}};
159
162
  }
160
163
  /** What the draft keeps and `context.applied` carries for a patch: the patch, its hash, the producer's line and check's summary. */
161
- export const patchAppliedRecord=(job:PatchJobView):AppliedPatch=>({schema:APPLIED_PATCH_SCHEMA,buildId:job.id,patch:job.request.patch,patchSha256:job.request.patchSha256,configHash:job.request.configHash,producer:job.producer,compiler:job.compiler,line:job.producerLine,check:job.check,diff:job.diff,...(job.files?{files:job.files}:{}),modelHash:job.build!.modelHash,...(job.op?{op:job.op}:{}),...(job.remesh?{mesh:{part:job.remesh.part,before:{...job.remesh.before},after:{...job.remesh.after}}}:{}),...(job.op==='build'?{assembled:assembledRecord(job)}:{})});
164
+ export const patchAppliedRecord=(job:PatchJobView):AppliedPatch=>({schema:APPLIED_PATCH_SCHEMA,buildId:job.id,patch:job.request.patch,patchSha256:job.request.patchSha256,configHash:job.request.configHash,producer:job.producer,compiler:job.compiler,line:job.producerLine,check:job.check,diff:job.diff,...(job.files?{files:job.files}:{}),modelHash:job.build!.modelHash,...(job.op?{op:job.op}:{}),...(job.remesh?{mesh:{part:job.remesh.part,before:{...job.remesh.before},after:{...job.remesh.after}}}:{}),...(job.op==='cloth'&&job.cloth?{cloth:{route:job.cloth.route,keypoints:{...job.cloth.inputs.keypoints},written:{count:job.cloth.written?.count??0,paths:[...(job.cloth.written?.paths??[])]}}}:{}),...(job.op==='build'||(job.op==='cloth'&&job.cloth?.route==='build')?{assembled:assembledRecord(job)}:{}),...(job.environment?{environment:{...job.environment}}:{})});
162
165
  /** #115: what a build Apply records: the build's own wall clock, the recomposite figures, the parts it changed and its `seam pair` lines. */
163
166
  export function assembledRecord(job:PatchJobView):AppliedAssembled {
164
167
  const a=job.assembled,fig=(f:Record<string,number>|null|undefined)=>f?{...f}:null;
165
- return {wallMs:job.wallMs.op,recomposite:{before:fig(a?.recomposite.before as Record<string,number>|null),after:fig(a?.recomposite.after as Record<string,number>|null)},partsChanged:[...(a?.parts?.changed??[])],seamPairs:(a?.seamPairs??[]).slice(0,64)};
168
+ return {wallMs:job.op==='cloth'?job.cloth?.wallMs.build??null:job.wallMs.op,recomposite:{before:fig(a?.recomposite.before as Record<string,number>|null),after:fig(a?.recomposite.after as Record<string,number>|null)},partsChanged:[...(a?.parts?.changed??[])],seamPairs:(a?.seamPairs??[]).slice(0,64)};
166
169
  }
167
170
  /** What the draft keeps and `context.applied` carries for a delta: the document and its hash, the producer's line, op "remesh", the diff and the counts. */
168
171
  export const deltaAppliedRecord=(job:DeltaJobView):AppliedDelta=>({schema:APPLIED_DELTA_SCHEMA,buildId:job.id,part:job.request.part,delta:JSON.parse(JSON.stringify(job.request.delta)),deltaSha256:job.request.deltaSha256,configHash:job.request.configHash,producer:job.producer,compiler:job.compiler,line:job.producerLine,op:'remesh',files:job.files,...(job.remesh?{mesh:{part:job.remesh.part,before:{...job.remesh.before},after:{...job.remesh.after}}}:{}),modelHash:job.build!.modelHash});
@@ -188,10 +191,12 @@ const seconds=(ms:number|null)=>ms===null?'—':`${(ms/1000).toFixed(1)} s`;
188
191
  export function patchOutcomeLine(result:PatchJobView|RebuildRefusal):{text:string;error:boolean} {
189
192
  if(!('id' in result))return {text:result.producerLine??result.error,error:true};
190
193
  if(result.status!=='built')return {text:result.producerLine??`REBUILD_FAILED: exit ${result.exitCode}`,error:true};
191
- const check=result.check?(result.check.pass===null?`check refused: ${result.check.line??`exit ${result.check.exitCode}`}`:`check ${result.check.pass?'PASS':'FAIL'}`):'check not run';
194
+ const check=result.check?(result.check.pass===null?`check refused: ${result.check.line??`exit ${result.check.exitCode}`}`:`check ${result.check.pass?'PASS':'FAIL'}${result.check.source==='retune'?' (retune\'s own check of this revision, not run again)':''}`):'check not run';
192
195
  // #115: a build's own wall clock covers assemble, rig and check; the recorded figure is the same number.
193
- const wall=result.op==='build'?`${result.reused?'reused the scratch build of this exact patch · ':''}build ${seconds(result.wallMs.op)} (assemble → rig → check) + compile ${seconds(result.wallMs.compile)}`:result.reused?'reused the scratch build of this exact patch':`${result.op??result.request.patch.op.cmd} ${seconds(result.wallMs.op)} + compile ${seconds(result.wallMs.compile)} + check ${seconds(result.wallMs.check)}`;
194
- 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.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};
196
+ const building=result.op==='build'||(result.op==='cloth'&&result.cloth?.route==='build');
197
+ const propose=result.op==='cloth'&&result.cloth?`propose ${seconds(result.cloth.wallMs.propose)} (wrote a config differing at ${result.cloth.written?.count??'?'} path(s)) + `:'';
198
+ const wall=building?`${result.reused?'reused the scratch build of this exact patch · ':''}${propose}build ${seconds(result.op==='cloth'?result.cloth?.wallMs.build??null:result.wallMs.op)} (assemble → rig → check) + compile ${seconds(result.wallMs.compile)}`:result.reused?'reused the scratch build of this exact patch':`${propose}${result.op==='cloth'?'rig':result.op??result.request.patch.op.cmd} ${seconds(result.op==='cloth'?result.cloth?.wallMs.build??null:result.wallMs.op)} + compile ${seconds(result.wallMs.compile)}${result.check?.source==='retune'?'':` + check ${seconds(result.wallMs.check)}`}`;
199
+ 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};
195
200
  }
196
201
  /** The status line after a job: built (with the wall clock and whether the scratch build was reused) or the producer's line verbatim. */
197
202
  export function outcomeLine(result:RebuildJobView|RebuildRefusal):{text:string;error:boolean} {
@@ -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}
@@ -203,7 +204,8 @@ export class RigOverlays {
203
204
  const c=region.colors;
204
205
  points.forEach((p,i)=>{const size=region.active===i?5:3.5;ctx.fillStyle=region.active===i?c.handle:c.ink;ctx.strokeStyle=region.stroke;ctx.lineWidth=1.5;ctx.fillRect(p!.x-size,p!.y-size,size*2,size*2);ctx.strokeRect(p!.x-size,p!.y-size,size*2,size*2);});
205
206
  const top=points.reduce((a,p)=>p!.y<a!.y?p:a,points[0]);
206
- ctx.font='600 11px ui-monospace, monospace';const width=ctx.measureText(region.name).width+8;ctx.fillStyle=c.labelBg;ctx.fillRect(top!.x-width/2,top!.y-24,width,15);ctx.fillStyle=region.stroke;ctx.fillText(region.name,top!.x-width/2+4,top!.y-13);
207
+ // An empty name draws no label (#111: one label per chain set, not per chain).
208
+ if(region.name){ctx.font='600 11px ui-monospace, monospace';const width=ctx.measureText(region.name).width+8;ctx.fillStyle=c.labelBg;ctx.fillRect(top!.x-width/2,top!.y-24,width,15);ctx.fillStyle=region.stroke;ctx.fillText(region.name,top!.x-width/2+4,top!.y-13);}
207
209
  }
208
210
  }
209
211
  if(this.playerAvailable)for(const circle of circles){
@@ -220,15 +222,29 @@ export class RigOverlays {
220
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);
221
223
  }
222
224
  }
223
- if(this.playerAvailable)for(const marker of markers){
224
- const at=projected(marker.at);if(!at)continue;const c=marker.colors;
225
- ctx.globalAlpha=.9;ctx.setLineDash([3,3]);ctx.strokeStyle=c.stroke;ctx.lineWidth=1.2;
226
- for(const to of marker.guideTo??[]){const end=projected(to);if(!end)continue;ctx.beginPath();ctx.moveTo(at.x,at.y);ctx.lineTo(end.x,end.y);ctx.stroke();}
227
- ctx.setLineDash([]);ctx.globalAlpha=1;const size=marker.active?7:5.5;
228
- ctx.beginPath();ctx.moveTo(at.x,at.y-size);ctx.lineTo(at.x+size,at.y);ctx.lineTo(at.x,at.y+size);ctx.lineTo(at.x-size,at.y);ctx.closePath();
229
- ctx.fillStyle=c.fill;ctx.fill();ctx.strokeStyle=c.ink;ctx.lineWidth=marker.active?2.4:1.6;ctx.stroke();
230
- ctx.font='600 11px ui-monospace, monospace';const width=ctx.measureText(marker.tag).width+8,lx=at.x+size+4,ly=at.y-size-2;
231
- ctx.fillStyle=c.labelBg;ctx.fillRect(lx,ly-11,width,15);ctx.fillStyle=c.stroke;ctx.fillText(marker.tag,lx+4,ly);
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
+ });
232
248
  }
233
249
  ctx.globalAlpha=1;
234
250
  if(this.playerAvailable&&meshEdit)this.drawMeshEdit(ctx,meshEdit,projected,width,height);
package/web/styles.css CHANGED
@@ -260,3 +260,5 @@ footer.status-bar{flex-wrap:nowrap}
260
260
  .rig-fields-handle{position:absolute;width:14px;height:14px;margin:-7px 0 0 -7px;padding:0;border:2px solid var(--rv-field-still);border-radius:50%;background:var(--rv-field-handle-bg);pointer-events:auto;cursor:grab;touch-action:none}.rig-fields-handle.still{cursor:ns-resize}.rig-fields-handle.inward{border-color:var(--rv-field-inward-line)}.rig-fields-handle.outward{border-color:var(--rv-field-outward-line)}.rig-fields-handle span{position:absolute;left:16px;top:-3px;white-space:nowrap;padding:1px 4px;border-radius:3px;background:var(--rv-field-handle-bg);color:var(--rv-field-ink);font:10px var(--font-mono);pointer-events:none}
261
261
  .requirements-fields .row{margin-bottom:6px}.requirements-panel select{width:auto;max-width:170px;padding:4px 6px;font-size:11px}.requirements-panel input[type=text]{flex:1;min-width:0;width:auto;padding:4px 6px;font:11px var(--font-mono)}.requirements-panel input[type=number]{width:70px;padding:4px 6px;font-size:11px}.requirements-list{list-style:none;margin:0 0 9px;padding:0;display:flex;flex-direction:column;gap:8px}.requirements-item{border-left:2px solid var(--border-strong);padding-left:8px;display:flex;flex-direction:column;gap:4px}.requirements-item.pass{border-left-color:var(--pass,var(--link))}.requirements-item.fail{border-left-color:var(--fail)}.requirements-item code{font:11px var(--font-mono);overflow-wrap:anywhere}.requirements-bar input[type=range]{flex:1;min-width:90px}.requirements-bar output{font:11px var(--font-mono);color:var(--fg-strong)}.requirements-refusals{list-style:none;margin:6px 0;padding:0;display:flex;flex-direction:column;gap:3px}.requirements-refusals code{font:11px var(--font-mono);color:var(--fail);overflow-wrap:anywhere}
262
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
+ .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
+ .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)}