@rig-c/review 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +242 -7
  2. package/docs/DESIGN.md +33 -5
  3. package/docs/REVIEW_PACKAGE.md +115 -0
  4. package/package.json +1 -1
  5. package/src/applied-context.ts +285 -0
  6. package/src/baseline.ts +175 -0
  7. package/src/bone-anchors.ts +123 -0
  8. package/src/bones-check.ts +105 -0
  9. package/src/build-route.ts +109 -0
  10. package/src/chest-landmarks.ts +122 -0
  11. package/src/chest-regions.ts +65 -6
  12. package/src/cli.ts +19 -8
  13. package/src/config-patch.ts +213 -0
  14. package/src/cut-edits.ts +184 -0
  15. package/src/environment.ts +226 -0
  16. package/src/feedback-export.ts +25 -1
  17. package/src/front.ts +11 -0
  18. package/src/handoff.ts +208 -0
  19. package/src/idle-tracks.ts +401 -0
  20. package/src/manifest.ts +15 -6
  21. package/src/mesh-edit.ts +317 -0
  22. package/src/mesh-entry.ts +351 -0
  23. package/src/open.ts +12 -5
  24. package/src/physics-tuning.ts +236 -0
  25. package/src/producer-files.ts +11 -0
  26. package/src/producer.ts +158 -0
  27. package/src/readings-run.ts +129 -0
  28. package/src/readings.ts +235 -0
  29. package/src/rebuild.ts +938 -0
  30. package/src/reference.ts +4 -3
  31. package/src/requirements-run.ts +160 -0
  32. package/src/requirements.ts +230 -0
  33. package/src/retarget.ts +211 -0
  34. package/src/retune-route.ts +52 -0
  35. package/src/rig-fields.ts +295 -0
  36. package/src/server.ts +59 -5
  37. package/src/session.ts +12 -0
  38. package/src/setup-mesh.ts +144 -0
  39. package/src/weights.ts +74 -0
  40. package/web/bones-panel.ts +230 -0
  41. package/web/chest-editor.ts +294 -0
  42. package/web/chest-regions-tool.ts +10 -4
  43. package/web/cuts-panel.ts +266 -0
  44. package/web/idle-panel.ts +280 -0
  45. package/web/index.html +2 -2
  46. package/web/inspector-blocks.ts +77 -0
  47. package/web/main.ts +813 -24
  48. package/web/mesh-editor.ts +303 -0
  49. package/web/mesh-panel.ts +273 -0
  50. package/web/part-selection.ts +13 -5
  51. package/web/part-thumbnails.ts +76 -0
  52. package/web/physics-panel.ts +151 -0
  53. package/web/readings-panel.ts +105 -0
  54. package/web/rebuild-client.ts +216 -0
  55. package/web/requirements-panel.ts +238 -0
  56. package/web/retarget-panel.ts +203 -0
  57. package/web/rig-fields-panel.ts +269 -0
  58. package/web/rig-overlays.ts +171 -14
  59. package/web/setup-pose.ts +30 -0
  60. package/web/styles.css +52 -5
  61. package/web/weights-view.ts +159 -0
  62. package/web/workspace-preferences.ts +17 -12
  63. package/web/workspace-ui.ts +5 -3
package/web/main.ts CHANGED
@@ -1,12 +1,28 @@
1
- import {PartExplorer,loadPartCatalog} from './part-selection.ts';
2
- import { loadModel, RigPlayer, type ModelAssets, type PoseBounds } from '@rig-c/play';
1
+ import {PartExplorer,loadPartModel,partBounds} from './part-selection.ts';
2
+ import {PartThumbnails,pageRegionTable} from './part-thumbnails.ts';
3
+ import { loadModel, RigPlayer, type BoneAdjust, type ModelAssets, type PoseBounds } from '@rig-c/play';
3
4
  import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,boundsFitSignature,dragSignature,zoomSignature,type CameraFrame,type CameraSignature} from './camera-view.ts';
4
5
  import {rendererStatus} from './renderer-status.ts';
5
6
  import {bindAppearance,bindPan,bindStageLayout,syncStageLayout,bindInspectorTabs,idleAvailable,initialIdleAnimation,automaticIdlePreview,bindLayerShortcuts,describeLayerToggles,type ViewPoint} from './view-controls.ts';
6
- import {RigOverlays,type OverlayRegion} from './rig-overlays.ts';
7
- import {chestOverlayPolygons,chestRegionRows,chestRowId,evaluateChestForm} from './chest-regions-tool.ts';
7
+ import {RigOverlays,type OverlayCircle,type OverlayMarker,type OverlayRegion,type OverlaySnapshot} from './rig-overlays.ts';
8
+ import {bindMeshPanel,type MeshPanel} from './mesh-panel.ts';
9
+ import {assertMeshPatch,countsText,meshApplyOutcome,meshPatchOps,triangleAgreement,type MeshEntry} from '../src/mesh-entry.ts';
10
+ import {worstLine} from '../src/readings.ts';
11
+ import {chestOverlayCircles,chestOverlayPolygons,chestRegionRows,chestRowId,evaluateChestForm} from './chest-regions-tool.ts';
12
+ import {bindChestEditor,canvasToCrop,bustLabel,bustSides,LANDMARKS,landmarkInputId,rowCircle,type ChestEditor,type LandmarkKey} from './chest-editor.ts';
13
+ import {bindMeshEditor,meshEditSource,sha256Hex,type MeshEditor} from './mesh-editor.ts';
14
+ import {bindWeightsView,setupPoseMesh,type WeightsView} from './weights-view.ts';
15
+ import {modelBoneNames,readSetupPose} from './setup-pose.ts';
16
+ import {bindBonesPanel,type BonesPanel,type BonesCheckView} from './bones-panel.ts';
17
+ import {bindCutsPanel,type CutsPanel} from './cuts-panel.ts';
18
+ import {retargetAppliedRecord,retargetOutcomeLine,type AppliedRetarget,type RetargetJobView,type RetargetRequestBody} from './rebuild-client.ts';
19
+ import {bindRetargetPanel,type PoseMap,type RetargetPanel} from './retarget-panel.ts';
20
+ import type {RetargetFlags} from '../src/retarget.ts';
21
+ import {appliedRecord,deltaAppliedRecord,deltaOutcomeLine,designatedRegions,handoffText,landmarksRequest,seedLandmarks,makePatch,outcomeLine,patchAppliedRecord,patchOutcomeLine,pickPart,runRebuild,seedFields,stageModel,type AppliedDelta,type AppliedPatch,type AppliedRecord,type DeltaJobView,type DeltaRequestBody,type ConfigPatch,type PatchJobView,type RebuildJobView,type RebuildRefusal,type RebuildRequestBody,type RebuildStatusView} from './rebuild-client.ts';
22
+ import {meshEditBinding,meshEditText,type MeshEditDocument} from '../src/mesh-edit.ts';
8
23
  import {chestRegionsBinding,cropWorldMapping,type CropWorldMapping} from '../src/chest-regions.ts';
9
24
  import {bindWorkspaceUI,selectedInspector,type WorkspaceUI} from './workspace-ui.ts';
25
+ import {bindInspectorBlocks,activeBlocksUI,restoreActiveBlocksUI,type InspectorBlocks} from './inspector-blocks.ts';
10
26
  import {connectControl} from './control.ts';
11
27
  import {pageInstanceAccess,readOnlyReason} from './instance-access.ts';
12
28
  import {controlComparison,type ControlCommand,type ControlInspection,type ControlView} from '../src/control.ts';
@@ -15,13 +31,26 @@ import {connectInspectionPersistence,serializableView,numericCamera,restoredZoom
15
31
  import {initialPlayerAnimation} from '../src/animation-selection.ts';
16
32
  import {initialReviewSelection} from '../src/open-selection.ts';
17
33
  import type {ReferenceBinding} from '../src/reference.ts';
18
- import type { Checkpoint, Reference, ReviewedSource, ReviewManifest } from '../src/manifest.ts';
34
+ import type { Checkpoint, Json, Reference, ReviewedSource, ReviewManifest } from '../src/manifest.ts';
19
35
  import type { Feedback, ReviewSession } from '../src/session.ts';
20
36
  import type { ReportEvent, ReportSeeds } from '../src/report.ts';
21
37
  import { clockForAnimation, frameAtTime, lastFrame as lastProducerFrame, timeAtFrame } from '../src/frame-clock.ts';
22
38
  import { feedbackLastFrame, feedbackTargetNames, validateFeedbackSelection, type FeedbackCatalog } from '../src/feedback-validation.ts';
23
39
  import { eventCoordinates, eventSeekTime, eventSelection, eventTimingLabel, reportCounts, reportDraftForMarker, targetLabel } from './report-view.ts';
24
40
  import type { StripInterval, StripRequest } from '../src/strip.ts';
41
+ import {createWindModel,environmentFor,windReadout,RUNTIME_START_READOUT,joltPacket,dueJolts,dragMoved,loadModelDocument,environmentContext,environmentJson,type JoltStep,type WindSnapshot,type WindState} from '../src/environment.ts';
42
+ import {tuningRows,readoutRows,type InspectedCheckView} from '../src/physics-tuning.ts';
43
+ import {bindReadingsPanel,EDGE as READING_EDGE,type ReadingsJobView,type ReadingsPanel,type ReadingsRefusal} from './readings-panel.ts';
44
+ import {READINGS_SCHEMA,type ReadingsContext} from '../src/readings.ts';
45
+ import {bindRequirementsPanel,type RequirementsCatalogView,type RequirementsJobView,type RequirementsPanel,type RequirementsRefusal} from './requirements-panel.ts';
46
+ import {patchRoute} from '../src/retune-route.ts';
47
+ import {appliedCheckRefusal,type CheckSummary} from '../src/applied-context.ts';
48
+ import {bindPhysicsPanel,type PhysicsPanel} from './physics-panel.ts';
49
+ import {bindIdlePanel,type BoneSetup,type IdlePanel} from './idle-panel.ts';
50
+ import {bindRigFieldsPanel,type RigFieldsPanel} from './rig-fields-panel.ts';
51
+ import {assertRigFieldOps,RIG_FIELDS_READOUT_BARS,TURN_BONE} from '../src/rig-fields.ts';
52
+ import {IDLE_READOUT_BARS,type LocalChannel} from '../src/idle-tracks.ts';
53
+ import type {PatchOp} from '../src/config-patch.ts';
25
54
 
26
55
  interface ReviewResponse { initialAnimation?:string|null; manifest: ReviewManifest; referenceBinding?:ReferenceBinding; explicitReference?:{id:string;name:string;sha256:string;url:string}; sourceHash: string; reviewedSource: ReviewedSource; report: ReportSeeds | null; feedbackCatalog: FeedbackCatalog; assetsBase: string; capabilities: { playback: boolean; modeling: false; motion: false; feedback: boolean; audio: boolean } }
27
56
  interface PoseReference { schema: 'rig-review-pose/1'; coordinateSystem: 'image-pixels-y-down'; width: number; height: number; joints: { id: string; x: number; y: number; parent?: string }[] }
@@ -69,8 +98,20 @@ let displayedCenter:ViewPoint|null=null;
69
98
  let playbackIntent=0,desiredPlay=false,seekingControl=false;
70
99
  let parts:PartExplorer|null=null;
71
100
  let overlays:RigOverlays|null=null;
101
+ let chestEditor:ChestEditor|null=null;
102
+ let meshEditor:MeshEditor|null=null;
103
+ /** Setup · Weights (#107): a watch layer over the selected slot's setup-pose mesh. */
104
+ let weightsView:WeightsView|null=null;
105
+ /** Setup · Bones (#109): the config's bone anchors on the stage, Check and a whole-rig Apply. */
106
+ let bonesPanel:BonesPanel|null=null;
107
+ let cutsPanel:CutsPanel|null=null;
108
+ /** The hash-verified compiled model document the parts catalog was read from (mesh edit reads attachments from it). */
109
+ let partModel:unknown=null;
110
+ /** Apply · Rebuild (#99): the inspected model's URL, the producer status, and the applied record while its scratch build is on the stage. */
111
+ let inspectedModel:{url:string;prefix:string;sha256:string}|null=null,rebuildStatus:RebuildStatusView|null=null,appliedOnStage:AppliedRecord|AppliedPatch|AppliedDelta|AppliedRetarget|null=null,rebuildBusy=false,stageModelHash:string|null=null;
72
112
  let appearance:ReturnType<typeof bindAppearance>|null=null;
73
113
  let workspace:WorkspaceUI|null=null;
114
+ let inspectorBlocks:InspectorBlocks|null=null;
74
115
  let idleHeld=false;
75
116
  let suspendedPreview=false;
76
117
  let controlDispose:(()=>void)|null=null;
@@ -92,6 +133,23 @@ let reportPage = 0;
92
133
  let selectedReportEvent: ReportEvent | null = null;
93
134
  let selectedReportTime: number | undefined;
94
135
  let selectedReportTarget: number | undefined;
136
+ // Animate's Environment block: the wind clock advances only with the steps the player takes.
137
+ const wind=createWindModel();
138
+ let joltQueue:JoltStep[]=[],environmentNote='';
139
+ /** Animate · Physics tuning (#103): the panel, the inspected model document it maps against, and the check lines of the build on the stage (null: the inspected build, which review has not checked). */
140
+ /** Animate · Idle tracks (#104): the panel; its preview drives `player.adjust`. */
141
+ let idlePanel:IdlePanel|null=null;
142
+ /** Setup · Rig fields (#110): the panel; its turn dial and the idle preview share `player.adjust` (both run, idle first). */
143
+ let rigFieldsPanel:RigFieldsPanel|null=null,idleAdjust:BoneAdjust|null=null,dialAdjust:BoneAdjust|null=null;
144
+ function setAdjust():void {if(!player)return;const a=idleAdjust,b=dialAdjust;player.adjust=a&&b?bones=>{a(bones);b(bones);}:a??b;if(!playing)drawRig(()=>player!.draw());}
145
+ /** #106: the Setup Mesh panel, and the overlay's last snapshot (bar 1 reads its per-slot triangle count). */
146
+ let meshPanel:MeshPanel|null=null,lastOverlay:OverlaySnapshot|null=null;
147
+ let physicsPanel:PhysicsPanel|null=null,inspectedDocument:unknown=null,inspectedDocumentReason:string|null=null,stageCheck:{lines:string[];check:CheckSummary|null}|null=null;
148
+ /** Animate · Readings (#108): the block, the last finished job (its ticks and worst lines), and the run in flight. */
149
+ let requirementsPanel:RequirementsPanel|null=null,retargetPanel:RetargetPanel|null=null;
150
+ let readingsPanel:ReadingsPanel|null=null,readingsJob:ReadingsJobView|null=null,readingsBusy=false,readingsTickKey='';
151
+ /** The world an Apply's reload resumes: wind (clock and active pulse), pending jolts, whether the controls are engaged, gravity. */
152
+ interface StageWorld {wind:WindState;jolts:JoltStep[];applied:boolean;gravity:number}
95
153
 
96
154
  function message(value: string, error = false): void {
97
155
  text('message', value);
@@ -338,6 +396,16 @@ function renderTransport(): void {
338
396
  button('play').disabled = (!player && !video && !sequence) || duration <= 0;
339
397
  renderFrameTransport();
340
398
  renderFeedbackRange();
399
+ renderReadingTicks();
400
+ idlePanel?.place(duration);
401
+ }
402
+
403
+ /** Re-lay the reading ticks only when what places them changes (renderTransport runs every played frame). */
404
+ function renderReadingTicks(force = false): void {
405
+ if (!readingsPanel) return;
406
+ const scrub = input('scrub'), key = `${readingsJob?.id ?? ''}|${readingsJob?.runs.map(r => r.results.length).join(',')}|${animation}|${duration}|${scrub.offsetLeft},${scrub.offsetTop},${scrub.offsetWidth}`;
407
+ if (!force && key === readingsTickKey) return;
408
+ readingsTickKey = key; readingsPanel.ticks(readingsJob, animation, duration);
341
409
  }
342
410
 
343
411
  function feedbackAnimation(): string | null { return player && player.current !== animation ? null : animation || null; }
@@ -408,7 +476,7 @@ function chooseAnimation(name: string): void {
408
476
 
409
477
  function seek(value: number): void {
410
478
  time = duration > 0 ? Math.min(Math.max(0, value), duration) : Math.max(0, value);
411
- if (player && player.current) { try { player.seek(time); drawRig(()=>player!.draw()); } catch (error) {text('renderer-state',rendererStatus(null,'failed').label); message(errorText(error), true); } }
479
+ 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); } }
412
480
  if (video && video.readyState >= 1) holdVideoReference(video, referenceGeneration);
413
481
  renderSequenceFrame(); renderTransport();
414
482
  }
@@ -511,23 +579,37 @@ function renderReportSelection(): void {
511
579
  text('report-details', JSON.stringify({ source: event.source, frames: event.frames, times: event.times, timeInterval: event.timeInterval, durationSeconds: event.durationSeconds, targets: event.targets, provenance: event.provenance, details: event.details }, null, 2));
512
580
  }
513
581
 
582
+ /**
583
+ * The timeline-marker seek: report events and reading ticks (#108) both land here — pause, switch to the marker's
584
+ * animation when the rig has it, refuse a time past the clip, else seek to the exact time.
585
+ */
586
+ function seekMarker(clip: string | undefined, exact: number, what: string): boolean {
587
+ pause();
588
+ if (clip && player && !assets?.animations.some(item => item.name === clip)) { message(`${what} animation "${clip}" is absent from the current rig; no seek was performed.`, true); return false; }
589
+ if (clip && assets?.animations.some(item => item.name === clip) && animation !== clip) chooseAnimation(clip);
590
+ if (duration > 0 && exact > duration) { message(`Explicit time ${exact} s is outside the current range of ${duration} s. No substitute time was used.`, true); return false; }
591
+ seek(exact);
592
+ return true;
593
+ }
514
594
  function seekReportEvent(): void {
515
595
  const event = selectedReportEvent; if (!event) return;
516
596
  const exact = eventSeekTime(event, selectedReportTime); if (exact === null) return;
517
- pause();
518
- const reportAnimation = data.report?.animation;
519
- if (reportAnimation && player && !assets?.animations.some(item => item.name === reportAnimation)) { message(`Report animation "${reportAnimation}" is absent from the current rig; no seek was performed.`, true); return; }
520
- if (reportAnimation && assets?.animations.some(item => item.name === reportAnimation) && animation !== reportAnimation) chooseAnimation(reportAnimation);
521
- if (duration > 0 && exact > duration) { message(`Explicit time ${exact} s is outside the current range of ${duration} s. No substitute time was used.`, true); return; }
522
- seek(exact);
597
+ if (!seekMarker(data.report?.animation, exact, 'Report')) return;
523
598
  message((player || video || sequence) ? `Selected report time ${exact} s.` : `Report time ${exact} s · no playback reference; time display only.`);
524
599
  }
600
+ /** A reading tick or worst line: its frame's time (frame / fps of the reading's set) in the reading's animation. */
601
+ function seekReading(clip: string, frame: number, exact: number | null, label: string): void {
602
+ if (exact === null) { message(`${label}: F${frame} has no time (the reading's set states no fps); no seek was performed.`, true); return; }
603
+ // The clip's last sample can sit a rounding hair past the runtime duration; that tick is the clip's end.
604
+ const at = duration > 0 && exact > duration && exact - duration <= READING_EDGE ? duration : exact;
605
+ if (seekMarker(clip, at, 'Reading')) message(`Reading · ${label} → F${frame} · ${exact.toFixed(4)} s`);
606
+ }
525
607
 
526
608
  function tick(now: number): void {
527
609
  if (playing) {
528
610
  const dt = lastFrame ? Math.min((now - lastFrame) / 1000, .1) : 0;
529
611
  const previous = time; time = Math.min(duration, time + dt);
530
- if (player && player.current) { try { drawRig(()=>player!.frame(dt)); } catch (error) { pause(); message(errorText(error), true); } }
612
+ if (player && player.current) { try { stepEnvironment(dt); drawRig(()=>player!.frame(dt)); } catch (error) { pause(); message(errorText(error), true); } }
531
613
  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;
532
614
  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);
533
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;
@@ -541,12 +623,13 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
541
623
  text('player-state', 'Loading compiled rig…');
542
624
  try {
543
625
  const modelUrl = new URL(assetUrl(data.manifest.model), location.href).href;
626
+ inspectedModel={url:modelUrl,prefix:access.route('/assets/'),sha256:data.reviewedSource.rig?.sha256??''};stageModelHash=inspectedModel.sha256||null;
544
627
  assets = await loadModel({ modelUrl, setsUrl: null, resolvePage: (name, url) => {
545
628
  const target = new URL(name, url); if (target.origin !== location.origin || !target.pathname.startsWith(access.route('/assets/'))) throw new Error(`Model page escapes local package: ${name}`); return target.href;
546
629
  } });
547
630
  player = new RigPlayer(el('rig-root'), assets);
548
631
  // Parts inspection is an add-on: a catalog refusal is shown in the explorer and never aborts the rig.
549
- try{parts?.register('single',await loadPartCatalog(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]),player);}catch(error){parts?.refuseCatalog('single',error);}
632
+ try{const catalog=await loadPartModel(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]);partModel=catalog.model;weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){parts?.refuseCatalog('single',error);}
550
633
  player.on('event', event => { text('event-log', `Runtime event: ${event.name} · ${event.time.toFixed(2)} s`); });
551
634
  const animations = select('animation-select'); animations.replaceChildren();
552
635
  for (const item of assets.animations) animations.append(option(item.name, data.manifest.animations.find(a => a.id === item.name)?.label ?? item.name));
@@ -560,6 +643,7 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
560
643
  select('axis-select').replaceChildren(); for (const axis of validAxes) select('axis-select').append(option(axis.id, axis.label));
561
644
  input('axis-value').disabled = !validAxes.length;
562
645
  for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;
646
+ enableEnvironment(modelUrl);
563
647
  describeLayerToggles(document,el('layer-state').textContent??'');
564
648
  fitCamera();
565
649
  } catch (error) { text('renderer-state',rendererStatus(null,'failed').label);player?.dispose(); player = null; assets?.dispose(); assets = null; text('player-state', 'Rig load failed'); text('rig-empty', `Could not open rig: ${errorText(error)}`); message(errorText(error), true); }
@@ -581,6 +665,8 @@ function applyAxis(): void {
581
665
  function renderProposals(): void {
582
666
  const area = el('proposal-list'); area.replaceChildren();
583
667
  const warnings = el('feedback-warnings'); warnings.hidden = session.feedbackWarnings.length === 0;
668
+ const unchecked = session.feedback.map(f => appliedCheckRefusal((f.context as Record<string, unknown> | undefined)?.applied)).find(Boolean) ?? null;
669
+ el('feedback-applied-check').hidden = !unchecked; text('feedback-applied-check', unchecked ? `${unchecked} · Submit refuses this review; delete the entry written on that build or Apply a build that passes check.` : '');
584
670
  warnings.textContent = session.feedbackWarnings.length ? `${session.feedbackWarnings.length} validation warnings in existing feedback · original records are preserved. Review the marked entries, delete them and add valid replacements before submitting. The saved session that rig-review feedback exports keeps both the original records and warnings.` : '';
585
671
  for (const feedback of session.feedback) {
586
672
  const item = document.createElement('div'); item.className = 'proposal-item'; const title = document.createElement('strong'); title.textContent = `${feedback.category} · ${feedback.target.kind}:${feedback.target.name} · F${feedback.frameStart}–${feedback.frameEnd}`; const description = document.createElement('span'); description.textContent = feedback.summary;
@@ -697,7 +783,7 @@ function bind(): void {
697
783
  button('chest-export').onclick = () => { void exportChest(); };
698
784
  for (const event of ['input', 'change']) el('chest-regions').addEventListener(event, renderChest);
699
785
  // A discarded local draft restores the form after its button's own handler; re-read the tool then.
700
- el('draft-recovery').addEventListener('click', () => renderChest());
786
+ el('draft-recovery').addEventListener('click', () => { renderChest(); restoreEnvironment(); physicsPanel?.restore(); bonesPanel?.restore(); cutsPanel?.restore(); idlePanel?.restore(); rigFieldsPanel?.restore(); });
701
787
  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.');
702
788
  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;};
703
789
  for (const item of document.querySelectorAll<HTMLButtonElement>('[data-proposal]')) item.onclick = () => propose(item.dataset.proposal ?? '');
@@ -750,6 +836,13 @@ function bind(): void {
750
836
  camera: center ? { zoom, x: center.x, y: center.y, coordinateSystem: 'world-y-up' } : { zoom, coordinateSystem: 'reference-fit' },
751
837
  view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},
752
838
  axis: axisInspection ? { id: select('axis-select').value, value: Number(input('axis-value').value) } : null,
839
+ environment: feedbackEnvironment(),
840
+ // The exact last applied designation and the producer's line, only while that scratch build is what is drawn.
841
+ applied: appliedOnStage ? JSON.parse(JSON.stringify(appliedOnStage)) as Json : null,
842
+ // The readings chosen (#108): the pairs of the last finished run and the builds they ran on, not their results.
843
+ readings: readingsContext() as unknown as Json,
844
+ // The declared requirements (#113): the file by hash and content, and its last check, or null when none are declared.
845
+ requirements: (requirementsPanel?.context() ?? null) as unknown as Json,
753
846
  reportEvent: selectedReportEvent ? { id: selectedReportEvent.id, field: selectedReportEvent.field, source: { ...selectedReportEvent.source }, frames: selectedReportEvent.frames ? [...selectedReportEvent.frames] : null, exactTime: eventSeekTime(selectedReportEvent, selectedReportTime), targets: selectedReportEvent.targets.map(target => ({ ...target })) } : null,
754
847
  } });
755
848
  input('feedback-summary').value = ''; markDirty(); renderProposals(); message('Added structured feedback. Submit the review to save it.');
@@ -769,12 +862,50 @@ function bind(): void {
769
862
  workspace=bindWorkspaceUI({changed:fitCamera,defaultTheme:theme=>appearance?.set(theme),currentTheme:()=>appearance?.get()});
770
863
  cameraSync=workspace.defaults().defaultCameraSync;input('camera-sync').checked=cameraSync;input('idle-repeat').checked=workspace.defaults().defaultIdleRepeat;
771
864
  appearance=bindAppearance(select('settings-theme'),workspace.defaults().theme);
865
+ inspectorBlocks=bindInspectorBlocks({sections:[el('inspector-rig'),el('inspector-playback')],changed:()=>persistence?.save()});
772
866
  overlays=new RigOverlays({canvas:el<HTMLCanvasElement>('rig-overlay'),rigRoot:el('rig-root')});
867
+ chestEditor=bindChestEditor({pane:el('rig-pane'),canvas:el('rig-overlay'),project:()=>player?rigProject():null,mapping:()=>chestMapping,changed:renderChest,download:()=>{void exportChest();},apply:()=>{void applyRebuild();},reset:()=>{void resetRebuild();},message,inspector:()=>selectedInspector()});viewDisposers.push(()=>chestEditor?.dispose());
868
+ meshEditor=bindMeshEditor({pane:el('rig-pane'),canvas:el('rig-overlay'),project:()=>player?rigProject():null,mapping:()=>chestMapping,inspector:()=>selectedInspector(),source:meshSource,pause,changed:()=>redrawOverlay(false),download:doc=>{void exportMesh(doc);},apply:(doc,line)=>applyMeshDelta(doc,line),applied:()=>appliedOnStage?.schema==='rig-review-applied/3',resetBuild:()=>resetRebuild(stageWorld()),message,confirm:text=>window.confirm(text),token:name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim()});viewDisposers.push(()=>meshEditor?.dispose());
869
+ weightsView=bindWeightsView({pane:el('rig-pane'),canvas:el('rig-overlay'),project:()=>player?rigProject():null,inspector:()=>selectedInspector(),slot:()=>parts?.value()?.slot??null,
870
+ model:()=>data.manifest.model?partModel??undefined:null,setup:slot=>assets?setupPoseMesh(assets,partModel,slot,document.createElement('div')):null,changed:()=>redrawOverlay(false),
871
+ token:name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim()});
872
+ viewDisposers.push(()=>weightsView?.dispose());weightsView.refresh();
873
+ bonesPanel=bindBonesPanel({pane:el('rig-pane'),canvas:el('rig-overlay'),project:()=>player?rigProject():null,mapping:()=>chestMapping,inspector:()=>selectedInspector(),pause,changed:()=>redrawOverlay(false),saved:()=>persistence?.save(),
874
+ check:checkBones,apply:async ops=>{await applyBones(ops);},reset:resetBonesBuild,token:name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim()});
875
+ viewDisposers.push(()=>bonesPanel?.dispose());
876
+ // #115: cuts, patches and limb joints on the stage; Apply runs rig-parts build through the patch runner.
877
+ 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
+ apply:applyCuts,reset:resetCutsBuild,token:name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim(),route:path=>access.route(path)});
879
+ viewDisposers.push(()=>cutsPanel?.dispose());
880
+ physicsPanel=bindPhysicsPanel({apply:async ops=>{await applyPhysics(ops);},reset:resetPhysicsBuild,pane:el('rig-pane'),saved:()=>persistence?.save(),
881
+ 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
+ viewDisposers.push(()=>physicsPanel?.dispose());
883
+ meshPanel=bindMeshPanel({pane:el('rig-pane'),project:()=>player?rigProject():null,mapping:()=>chestMapping,bones:()=>data.feedbackCatalog.bones??[],
884
+ apply:async(part,entry)=>{await applyMesh(part,entry);},reset:resetMeshBuild,folds:async()=>{await meshFolds();},changed:()=>redrawOverlay(false),saved:()=>persistence?.save(),
885
+ token:name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim()});
886
+ viewDisposers.push(()=>meshPanel?.dispose());
887
+ idlePanel=bindIdlePanel({apply:async(ops,keys)=>{await applyIdle(ops,keys);},reset:resetIdleBuild,pane:el('rig-pane'),saved:()=>persistence?.save(),clock:()=>player?.time??0,
888
+ preview:fn=>{idleAdjust=fn as BoneAdjust|null;setAdjust();},
889
+ bonePoints:bones=>{if(!player)return null;const project=rigProject(),out=new Map<string,{x:number;y:number}>();for(const name of bones){try{const b=player.bone(name);out.set(name,project(b.x,b.y));}catch{/* a bone this model lacks has no handle */}}return out;}});
890
+ viewDisposers.push(()=>idlePanel?.dispose());
891
+ rigFieldsPanel=bindRigFieldsPanel({apply:async ops=>{await applyRigFields(ops);},reset:resetRigFieldsBuild,reading:async(bone,relative)=>{await runReadings({readings:[{kind:'angle-range',bone,relative}]});},
892
+ part:()=>parts?.value()?.slot??null,inspector:()=>selectedInspector(),pause,saved:()=>persistence?.save(),
893
+ toPane:(x,y)=>{if(!player||!chestMapping.available)return null;const w=chestMapping.toWorld(x,y);return rigProject()(w.x,w.y);},
894
+ toRig:(x,y)=>player&&chestMapping.available?canvasToCrop(rigProject(),chestMapping.toWorld,{x,y}):null,
895
+ slotBox:slot=>{const frame=player?.posedFrame();if(!frame)return null;try{const b=partBounds(frame,slot);if(!b)return null;const project=rigProject(),a=project(b.x,b.y),c=project(b.x+b.width,b.y+b.height);return {left:Math.min(a.x,c.x),right:Math.max(a.x,c.x),top:Math.min(a.y,c.y),bottom:Math.max(a.y,c.y)};}catch{return null;}},
896
+ dial:fn=>{dialAdjust=fn as BoneAdjust|null;setAdjust();},
897
+ hasTurn:()=>{if(!player)return false;try{player.bone(TURN_BONE);return true;}catch{return false;}}});
898
+ viewDisposers.push(()=>rigFieldsPanel?.dispose());
899
+ readingsPanel=bindReadingsPanel({run:()=>{void runReadings();},seek:seekReading});
900
+ retargetPanel=bindRetargetPanel({apply:body=>applyRetarget(body),reset:()=>{void resetRebuild(stageWorld());},seek:seekReading});
901
+ requirementsPanel=bindRequirementsPanel({check:runRequirements,seek:seekReading,animation:()=>animation,slot:()=>parts?.value()?.slot??null,hash:sha256Hex});
902
+ const tickResize=new ResizeObserver(()=>renderReadingTicks());tickResize.observe(el('transport'));viewDisposers.push(()=>tickResize.disconnect());
773
903
  viewDisposers.push(bindStageLayout(el('stage-layout'),fitCamera));
774
904
  for(const target of ['rig','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
775
905
  for(const [id,key] of [['layer-images','images'],['layer-mesh','mesh'],['layer-bones','bones']] as const)input(id).onchange=()=>{layers[key]=input(id).checked;redrawOverlay(false);};
776
906
  viewDisposers.push(bindLayerShortcuts(document));describeLayerToggles(document,el('layer-state').textContent??'');
777
907
  input('idle-repeat').onchange=()=>{renderIdle();if(canRepeatIdle())void playPreview();};
908
+ bindEnvironment();
778
909
  for(const id of ['proposal-panel','checkpoint-detail'])el(id).addEventListener('focusin',pause);
779
910
  bindInspector();bindReviewDialog();
780
911
  document.addEventListener('visibilitychange',()=>{if(document.hidden){const resume=playing;pause();suspendedPreview=resume;}else if(suspendedPreview){suspendedPreview=false;void playPreview();}});
@@ -796,7 +927,7 @@ async function start(): Promise<void> {
796
927
  // Checkpoints and reference fold away when the package supplies neither.
797
928
  (el('package-context') as HTMLDetailsElement).open = data.manifest.checkpoints.length > 0 || data.manifest.references.length > 0;
798
929
  const sourceFiles = el('source-files'); for (const file of data.manifest.authoring) { const a = document.createElement('a'); a.href = assetUrl(file.path); a.textContent = file.label; a.className = 'source-link'; a.target = '_blank'; a.rel = 'noopener'; sourceFiles.append(a); } if (!data.manifest.authoring.length) sourceFiles.textContent = 'No source files supplied';
799
- populateChestBones(); bind(); parts=new PartExplorer(el('workspace-left'),el('selected-part'),previous=>{redrawOverlay(true);const part=parts?.value();if(part){activeCamera='rig';if(view==='reference')setView('rig');showInspector('rig');renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera(part?'rig':availableCamera()));persistence?.save();},part=>{select('feedback-target-kind').value='slot';renderFeedbackTargets(true);input('feedback-target-name').value=part.slot;showInspector('feedback');}); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackTargets(true); renderFeedbackRange(); renderCheckpoints(); renderProposals(); renderProvenance(); updateSavedState(); renderCues(); initReport();
930
+ populateChestBones(); bind(); parts=new PartExplorer(el('workspace-left'),el('selected-part'),previous=>{weightsView?.refresh();redrawOverlay(true);meshEditor?.selectionChanged();const part=parts?.value();meshPanel?.select(part?.slot??null);requirementsPanel?.selectionChanged();if(part){activeCamera='rig';if(view==='reference')setView('rig');showInspector('rig');renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera(part?'rig':availableCamera()));persistence?.save();},part=>{select('feedback-target-kind').value='slot';renderFeedbackTargets(true);input('feedback-target-name').value=part.slot;showInspector('feedback');}); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackTargets(true); renderFeedbackRange(); renderCheckpoints(); renderProposals(); renderProvenance(); updateSavedState(); renderCues(); initReport();
800
931
  const requestedId = new URLSearchParams(location.search).get('checkpoint'),initial=initialReviewSelection(data.manifest,requestedId,data.explicitReference?.id,data.initialAnimation??undefined),first=initial.checkpoint;
801
932
  rigInput=data.manifest.model?{state:'pending'}:{state:'absent'};renderLayout();
802
933
  if(initial.referenceId){select('reference-select').value=initial.referenceId;await showReference(initial.referenceId);}
@@ -810,7 +941,7 @@ async function start(): Promise<void> {
810
941
  else if (readOnly) message(readOnly, true);
811
942
  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.');
812
943
  requestAnimationFrame(tick);
813
- const restored=await setupPersistence();renderChest();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
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();
814
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)});
815
946
  } catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
816
947
  }
@@ -827,11 +958,15 @@ function bindReviewDialog():void {
827
958
  dialog.addEventListener('toggle',()=>{if(dialog.open)button('write-review').setAttribute('aria-expanded','true');});
828
959
  }
829
960
 
961
+ /** Rig world → rig-pane pixels for the current camera. */
962
+ function rigProject(){const root=el('rig-root');return (x:number,y:number)=>({x:parseFloat(root.style.left||'0')+x*rigScale,y:parseFloat(root.style.top||'0')-y*rigScale});}
830
963
  function redrawOverlay(fresh:boolean):void {
831
964
  if(!overlays||!player)return;
832
- const root=el('rig-root'),project=(x:number,y:number)=>({x:parseFloat(root.style.left||'0')+x*rigScale,y:parseFloat(root.style.top||'0')-y*rigScale});
833
- 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:chestRegions()}):overlays.redraw({project,settings:layers,regions:chestRegions()});
834
- text('layer-state',value.warnings.join(' · ')||'Actual last-drawn mesh edges / bone points');if(!fresh&&value.needsCapture&&!playing&&!seekingControl)drawRig(()=>player!.draw());
965
+ const project=rigProject();
966
+ const regions=[...chestRegions(),...(meshPanel?.overlayRegions()??[]),...(cutsPanel?.overlayRegions()??[])],circles=[...chestCircles(),...(meshPanel?.overlayCircles()??[])],markers=[...chestMarkers(),...(cutsPanel?.overlayMarkers()??[])];
967
+ 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
+ lastOverlay=value;
969
+ 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());
835
970
  }
836
971
  function canRepeatIdle():boolean{return !!player&&idleAvailable(assets?.animations.map(a=>a.name)??[],animation)&&!axisInspection&&input('idle-repeat').checked&&duration>0;}
837
972
  function renderIdle():void {text('idle-state',canRepeatIdle()?'Actual idle · repeat enabled':animation==='idle'?'Idle repeat paused':'Non-idle inspection · no automatic playback');}
@@ -868,13 +1003,13 @@ async function applyControl(command:ControlCommand,signal:AbortSignal):Promise<v
868
1003
  async function inspectControlTime(target:number,signal:AbortSignal):Promise<void> {
869
1004
  if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
870
1005
  const media=video;
871
- if(!media){if(player){player.seek(target);drawRig(()=>player!.draw());}time=target;renderSequenceFrame();renderTransport();return;}
1006
+ if(!media){if(player){player.seek(target);reposePreview();drawRig(()=>player!.draw());}time=target;renderSequenceFrame();renderTransport();return;}
872
1007
  if(media.readyState<1||target>media.duration)throw new Error('CONTROL_REFERENCE_UNAVAILABLE');
873
1008
  const previousPosition=media.currentTime;
874
1009
  const commit=(actual:number)=>{
875
1010
  if(signal.aborted||media!==video)throw new Error('CONTROL_SUPERSEDED');
876
1011
  if(!Number.isFinite(actual)||actual<0||actual>duration)throw new Error('CONTROL_TIME_OUTSIDE_DOMAIN');
877
- if(player){player.seek(actual);drawRig(()=>player!.draw());}time=actual;
1012
+ if(player){player.seek(actual);reposePreview();drawRig(()=>player!.draw());}time=actual;
878
1013
  controlledVideoSample={media,position:media.currentTime,time:actual};if(axisInspection&&duration>0){const axis=data.manifest.axes.find(item=>item.id===select('axis-select').value&&item.animation===animation);if(axis){const value=axis.min+actual/duration*(axis.max-axis.min);input('axis-value').value=String(value);text('axis-value-label',value.toFixed(3));}}renderSequenceFrame();renderTransport();
879
1014
  };
880
1015
  try{const actual=await decodedControlTime(media,target,signal);if(signal.aborted||media!==video)throw new Error('CONTROL_SUPERSEDED');commit(actual);if(Math.abs(actual-target)>1e-6)message(`Reference decoded ${actual.toFixed(4)} s · requested ${target.toFixed(4)} s. The rig and saved view use the decoded timestamp.`);}
@@ -970,7 +1105,7 @@ async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):P
970
1105
  async function waitReferenceMetadata(media:HTMLVideoElement,signal:AbortSignal):Promise<void> {if(media.readyState>=1)return;await new Promise<void>((resolve,reject)=>{const clean=()=>{clearTimeout(timer);media.removeEventListener('loadedmetadata',ready);media.removeEventListener('error',fail);signal.removeEventListener('abort',fail);};const ready=()=>{clean();resolve();},fail=()=>{clean();reject(new Error('Saved reference metadata unavailable'));};const timer=setTimeout(fail,10000);media.addEventListener('loadedmetadata',ready,{once:true});media.addEventListener('error',fail,{once:true});signal.addEventListener('abort',fail,{once:true});});}
971
1106
  async function setupPersistence():Promise<boolean> {
972
1107
  persistence=await connectInspectionPersistence({scope:'single:'+data.manifest.id+':'+data.sourceHash,binding:inspectionBinding(),baseline:submittedBaseline!,session:()=>session,reviewDirty:()=>dirty,restoreSession:(value,isDirty)=>{session=value;dirty=isDirty;select('review-status').value=session.status;renderProposals();renderCheckpoint();renderCheckpoints();updateSavedState();},bookmark:inspectionBookmark,restoreBookmark:restoreInspection,
973
- ui:()=>({inspectorScroll:workspace?.scroll()??'{}',reportCategory:select('report-category').value,reportPage,reportEvent:selectedReportEvent?.id??null,reportTime:selectedReportTime??null,reportTarget:selectedReportTarget??null}),restoreUI:value=>{if(typeof value.reportCategory==='string'&&[...select('report-category').options].some(o=>o.value===value.reportCategory))select('report-category').value=value.reportCategory;if(typeof value.reportPage==='number'&&Number.isSafeInteger(value.reportPage)&&value.reportPage>=0)reportPage=value.reportPage;selectedReportEvent=typeof value.reportEvent==='string'?data.report?.categories.flatMap(c=>c.events).find(e=>e.id===value.reportEvent)??null:null;selectedReportTime=typeof value.reportTime==='number'?value.reportTime:undefined;selectedReportTarget=typeof value.reportTarget==='number'?value.reportTarget:undefined;renderReportEvents();renderReportSelection();workspace?.restoreScroll(value.inspectorScroll);},afterInputs:()=>{const target=input('feedback-target-name').value;renderFeedbackTargets(true);if(select('feedback-target-kind').value!=='rig')input('feedback-target-name').value=target;renderFeedbackRange();text('audio-volume-label',`${Math.round(Number(input('audio-volume').value)*100)}%`);},onConflict:value=>{draftConflict=value;updateSavedState();},onStatus:value=>{text('draft-state',value);el('draft-state').title=value;text('review-draft-state',value);}});
1108
+ ui:()=>({inspectorScroll:workspace?.scroll()??'{}',...(inspectorBlocks?activeBlocksUI(inspectorBlocks.state()):{}),reportCategory:select('report-category').value,reportPage,reportEvent:selectedReportEvent?.id??null,reportTime:selectedReportTime??null,reportTarget:selectedReportTarget??null}),restoreUI:value=>{if(typeof value.reportCategory==='string'&&[...select('report-category').options].some(o=>o.value===value.reportCategory))select('report-category').value=value.reportCategory;if(typeof value.reportPage==='number'&&Number.isSafeInteger(value.reportPage)&&value.reportPage>=0)reportPage=value.reportPage;selectedReportEvent=typeof value.reportEvent==='string'?data.report?.categories.flatMap(c=>c.events).find(e=>e.id===value.reportEvent)??null:null;selectedReportTime=typeof value.reportTime==='number'?value.reportTime:undefined;selectedReportTarget=typeof value.reportTarget==='number'?value.reportTarget:undefined;renderReportEvents();renderReportSelection();workspace?.restoreScroll(value.inspectorScroll);inspectorBlocks?.restore(restoreActiveBlocksUI(value));},afterInputs:()=>{const target=input('feedback-target-name').value;renderFeedbackTargets(true);if(select('feedback-target-kind').value!=='rig')input('feedback-target-name').value=target;renderFeedbackRange();text('audio-volume-label',`${Math.round(Number(input('audio-volume').value)*100)}%`);},onConflict:value=>{draftConflict=value;updateSavedState();},onStatus:value=>{text('draft-state',value);el('draft-state').title=value;text('review-draft-state',value);}});
974
1109
  return persistence.restored;
975
1110
  }
976
1111
  /** Setup's Chest regions tool: designation values, an overlay and a two-file client download. Nothing reaches the session's feedback or the package. */
@@ -980,7 +1115,36 @@ function chestRegions():OverlayRegion[]{
980
1115
  const style=getComputedStyle(document.documentElement),token=(name:string)=>style.getPropertyValue(name).trim();
981
1116
  return chestOverlayPolygons(chestEvaluation(),chestMapping).map((region,i)=>({...region,stroke:token(i?'--rv-region-b':'--rv-region-a'),fill:token(i?'--rv-region-b-fill':'--rv-region-a-fill')}));
982
1117
  }
1118
+ /** Circle regions: in edit mode the editor's circles straight from the fields (handles and labels), else the valid designation's. */
1119
+ function chestCircles():OverlayCircle[]{
1120
+ const mapping=chestMapping;if(!mapping.available)return [];
1121
+ const style=getComputedStyle(document.documentElement),token=(name:string)=>style.getPropertyValue(name).trim();
1122
+ const colors=(row:number,overlap:boolean)=>({stroke:token(overlap?'--rv-bust-overlap':row?'--rv-bust-l':'--rv-bust-r'),fill:token(overlap?'--rv-bust-overlap-fill':row?'--rv-bust-l-fill':'--rv-bust-r-fill'),handle:token('--rv-bust-handle'),ink:token('--rv-bust-ink'),labelBg:token('--rv-bust-label-bg'),flash:token('--rv-bust-flash')});
1123
+ if(chestEditor?.active()&&chestEditor.mode()==='landmarks'){
1124
+ // #105: the producer's derivation drawn live; a CONFIG_BUST_CLEARANCE refusal turns both to the overlap colour.
1125
+ const read=chestEditor.landmarks();if(!('derived' in read))return [];
1126
+ const refused=!!read.derived.refusal;
1127
+ return read.derived.regions.filter(r=>r.r>0&&r.band>=0).map((r,row)=>({center:mapping.toWorld(r.cx,r.cy),r:r.r,band:r.band,label:`${r.name} r:${r.r} b:${r.band}`,labelRow:row,handles:false,colors:colors(row,refused)}));
1128
+ }
1129
+ if(chestEditor?.active()){
1130
+ const rows=bustSides.map(({side,row})=>({side,row,circle:rowCircle(chestField,row)})),[r,l]=[rows[0]!.circle,rows[1]!.circle];
1131
+ // As in the example, overlapping cores turn both circles to the overlap colour.
1132
+ const overlap=!!r&&!!l&&r.r+l.r>Math.hypot(l.cx-r.cx,l.cy-r.cy);
1133
+ return rows.flatMap(({side,row,circle})=>circle?[{center:mapping.toWorld(circle.cx,circle.cy),r:circle.r,band:circle.band,label:bustLabel(side,circle),labelRow:row,handles:true,flash:chestEditor!.flashing(side),colors:colors(row,overlap)}]:[]);
1134
+ }
1135
+ return chestOverlayCircles(chestEvaluation(),mapping).map(c=>({center:c.center,r:c.r,band:c.band,handles:false,colors:colors(c.row,false)}));
1136
+ }
1137
+ /** Landmark markers (#105) in Landmarks mode: apexes in the R / L colours, the cleavage with guides to both apexes. */
1138
+ function chestMarkers():OverlayMarker[]{
1139
+ const mapping=chestMapping;if(!mapping.available||!chestEditor?.active()||chestEditor.mode()!=='landmarks')return [];
1140
+ const style=getComputedStyle(document.documentElement),token=(name:string)=>style.getPropertyValue(name).trim(),armed=chestEditor.armed();
1141
+ const at=(key:LandmarkKey)=>{const x=Number(input(landmarkInputId(key,'x')).value.trim()||NaN),y=Number(input(landmarkInputId(key,'y')).value.trim()||NaN);return Number.isFinite(x)&&Number.isFinite(y)?mapping.toWorld(x,y):null;};
1142
+ const apexes=[at('apex_r'),at('apex_l')].filter((p):p is {x:number;y:number}=>!!p);
1143
+ return LANDMARKS.flatMap(({key,tag})=>{const p=at(key);if(!p)return [];const stroke=token(key==='apex_r'?'--rv-bust-r':key==='apex_l'?'--rv-bust-l':'--rv-landmark');
1144
+ return [{at:p,tag,active:armed===key,colors:{stroke,fill:stroke,ink:token('--rv-landmark-ink'),labelBg:token('--rv-bust-label-bg')},...(key==='cleavage'?{guideTo:apexes}:{})}];});
1145
+ }
983
1146
  function populateChestBones():void {
1147
+ const list=document.getElementById('lm-bones');if(list){list.replaceChildren();for(const name of data.feedbackCatalog.bones??[])list.append(option(name,name));}
984
1148
  const bones=data.feedbackCatalog.bones;
985
1149
  for(const row of chestRegionRows){const node=select(chestRowId(row,'bone'));node.replaceChildren(option('',bones?'Select bone':'No model bones'));for(const name of bones??[])node.append(option(name,name));node.disabled=!bones;}
986
1150
  }
@@ -996,7 +1160,7 @@ function renderChest():void {
996
1160
  el('chest-mapping').classList.toggle('error',!chestMapping.available);
997
1161
  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);
998
1162
  el('chest-status').classList.toggle('error',!value.ok);
999
- redrawOverlay(false);
1163
+ chestEditor?.sync();meshEditor?.sync();weightsView?.sync();bonesPanel?.sync();cutsPanel?.sync();redrawOverlay(false);
1000
1164
  }
1001
1165
  async function exportChest():Promise<void> {
1002
1166
  const value=chestEvaluation();if(!value.ok){message(`Chest regions not exported: ${value.error}`,true);return;}
@@ -1005,4 +1169,629 @@ async function exportChest():Promise<void> {
1005
1169
  download('chest-regions.json',value.text,'application/json');download('chest-regions.binding.json',JSON.stringify(binding,null,2)+'\n','application/json');
1006
1170
  message('Downloaded chest-regions.json and chest-regions.binding.json. The regions are review evidence, not an approval; the package is unchanged.');
1007
1171
  }
1172
+ /** 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'];
1174
+ function environmentApplied():boolean{return input('environment-applied').checked;}
1175
+ function gravityValue():number{return Number(input('gravity').value);}
1176
+ /** Called from `tick` right before `player.frame(dt)`: the gust clock is the stepped time, so a paused player freezes it. */
1177
+ function stepEnvironment(dt:number):void {
1178
+ if(!player)return;
1179
+ wind.advance(dt);
1180
+ const {due,pending}=dueJolts(joltQueue,wind.read().time);joltQueue=pending;for(const step of due)player.moved({x:step.x,y:step.y});
1181
+ if(environmentApplied())player.environment(environmentFor(wind.read(),gravityValue()));
1182
+ renderEnvironment();
1183
+ }
1184
+ function applyEnvironment():void {if(player&&environmentApplied())player.environment(environmentFor(wind.read(),gravityValue()));renderEnvironment();}
1185
+ function renderEnvironment():void {
1186
+ if(!player)return;
1187
+ const reading=wind.read();
1188
+ text('wind-readout',(environmentApplied()?windReadout(reading,gravityValue()):RUNTIME_START_READOUT)+environmentNote);
1189
+ const gust=button('wind-gust');gust.disabled=reading.gusting;gust.setAttribute('aria-pressed',String(reading.gusting));gust.textContent=reading.gusting?'Gusting…':'Gust';
1190
+ text('wind-base-label',input('wind-base').value);text('gravity-label',gravityValue().toFixed(2));
1191
+ }
1192
+ /** Every control change engages the environment; until then the player stands at the runtime start values. */
1193
+ function engage(change:()=>void):void {
1194
+ try{change();input('environment-applied').checked=true;environmentNote='';applyEnvironment();persistence?.save();}
1195
+ catch(error){message(`Environment unchanged: ${errorText(error)}`,true);syncEnvironmentInputs();}
1196
+ }
1197
+ function syncEnvironmentInputs():void {
1198
+ const s=wind.snapshot();input('wind-base').value=String(s.base);input('wind-breeze').checked=s.breeze;input('wind-repeat').checked=s.repeat;input('wind-gust-peak').value=String(s.gustPeak);input('wind-gust-duration').value=String(s.gustDuration);renderEnvironment();
1199
+ }
1200
+ function bindEnvironment():void {
1201
+ input('wind-base').oninput=()=>engage(()=>wind.setBase(Number(input('wind-base').value)));
1202
+ input('wind-breeze').onchange=()=>engage(()=>wind.setBreeze(input('wind-breeze').checked));
1203
+ input('wind-repeat').onchange=()=>engage(()=>wind.setRepeat(input('wind-repeat').checked));
1204
+ input('wind-gust-peak').onchange=()=>engage(()=>wind.setGustPeak(Number(input('wind-gust-peak').value)));
1205
+ input('wind-gust-duration').onchange=()=>engage(()=>wind.setGustDuration(Number(input('wind-gust-duration').value)));
1206
+ input('gravity').oninput=()=>engage(()=>{environmentFor(wind.read(),gravityValue());});
1207
+ button('wind-gust').onclick=()=>engage(()=>wind.triggerGust());
1208
+ 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
+ button('physics-reset').onclick=resetPhysics;
1210
+ input('drag-trail').onchange=()=>{el('rig-viewport').dataset.trail=String(input('drag-trail').checked);};
1211
+ // Drag to trail: the rig pane's pointer drag becomes `player.moved` before the pan handler sees it; Space still pans.
1212
+ let space=false,trail:{id:number;x:number;y:number}|null=null;
1213
+ const keys=(event:KeyboardEvent)=>{if(event.code==='Space')space=event.type==='keydown';};
1214
+ window.addEventListener('keydown',keys);window.addEventListener('keyup',keys);window.addEventListener('blur',()=>{space=false;});
1215
+ const pane=el('rig-pane'),viewport=el('rig-viewport');
1216
+ pane.addEventListener('pointerdown',event=>{
1217
+ if(!input('drag-trail').checked||space||!player||!event.isPrimary||event.button!==0)return;
1218
+ if(event.target instanceof Element&&event.target.closest('button,input,select,textarea,a'))return;
1219
+ event.stopPropagation();event.preventDefault();trail={id:event.pointerId,x:event.clientX,y:event.clientY};viewport.setPointerCapture(event.pointerId);
1220
+ },{capture:true});
1221
+ pane.addEventListener('pointermove',event=>{
1222
+ if(!trail||event.pointerId!==trail.id)return;event.stopPropagation();
1223
+ const motion=dragMoved(event.clientX-trail.x,event.clientY-trail.y,rigScale);trail.x=event.clientX;trail.y=event.clientY;if(motion&&player)player.moved(motion);
1224
+ },{capture:true});
1225
+ const end=(event:PointerEvent)=>{if(!trail||event.pointerId!==trail.id)return;event.stopPropagation();if(viewport.hasPointerCapture(trail.id))viewport.releasePointerCapture(trail.id);trail=null;};
1226
+ pane.addEventListener('pointerup',end,{capture:true});pane.addEventListener('pointercancel',end,{capture:true});
1227
+ }
1228
+ /** Reset: the runtime's start values, then the player's reopen-then-step to the current time, so the physics history is cleared. */
1229
+ function resetPhysics():void {
1230
+ if(!player)return;
1231
+ joltQueue=[];wind.restore(null);input('environment-applied').checked=false;input('gravity').value='1';syncEnvironmentInputs();
1232
+ try{player.environment(null);if(player.current){player.seek(time);drawRig(()=>player!.draw());}environmentNote=` · physics history cleared; re-posed from ${time.toFixed(3)} s`;}
1233
+ catch(error){environmentNote='';message(`Physics reset refused: ${errorText(error)}`,true);}
1234
+ renderEnvironment();persistence?.save();
1235
+ }
1236
+ /** Draft restore: the inputs are already back; rebuild the wind model from them (a fresh gust clock). */
1237
+ function restoreEnvironment():void {
1238
+ const snapshot:WindSnapshot={version:1,base:Number(input('wind-base').value),breeze:input('wind-breeze').checked,repeat:input('wind-repeat').checked,gustPeak:Number(input('wind-gust-peak').value),gustDuration:Number(input('wind-gust-duration').value)};
1239
+ try{wind.restore(snapshot);environmentFor(wind.read(),gravityValue());}
1240
+ catch(error){wind.restore(null);input('environment-applied').checked=false;input('gravity').value='1';message(`Saved environment not restored: ${errorText(error)}`,true);}
1241
+ syncEnvironmentInputs();el('rig-viewport').dataset.trail=String(input('drag-trail').checked);
1242
+ if(player){if(environmentApplied())applyEnvironment();else player.environment(null);}
1243
+ }
1244
+ function feedbackEnvironment(){return player?environmentJson(environmentContext(wind.read(),gravityValue(),environmentApplied(),player.environment())):null;}
1245
+ function enableEnvironment(modelUrl:string):void {
1246
+ for(const id of environmentInputs)el<HTMLInputElement|HTMLButtonElement>(id).disabled=false;
1247
+ syncEnvironmentInputs();text('physics-panel-state','Reading the compiled model…');
1248
+ readingsPanel?.catalog(data.feedbackCatalog.slots??[],data.feedbackCatalog.bones??[]);readingsPanel?.status('Pick readings, then Run: each runs on the inspected build and on the latest scratch build.',false);
1249
+ const rig=data.reviewedSource.rig;
1250
+ if(!rig){inspectedDocumentReason='PHYSICS_TUNING_UNAVAILABLE: no reviewed rig identity';refreshPhysics();return;}
1251
+ void loadModelDocument(modelUrl,rig.sha256).then(loaded=>{if(loaded.ok)inspectedDocument=loaded.document;else inspectedDocumentReason=`PHYSICS_TUNING_UNAVAILABLE: ${loaded.reason}`;refreshPhysics();},error=>{inspectedDocumentReason=`PHYSICS_TUNING_UNAVAILABLE: ${errorText(error)}`;refreshPhysics();});
1252
+ }
1253
+ /** The panel's rows: the inspected model's constraints against the handoff config's (both must be read). */
1254
+ function refreshPhysics():void {
1255
+ if(!physicsPanel)return;
1256
+ if(inspectedDocumentReason){physicsPanel.load({available:false,reason:inspectedDocumentReason},[]);return;}
1257
+ if(!inspectedDocument)return;
1258
+ if(!rebuildStatus){text('physics-panel-state','Reading the producer handoff…');return;}
1259
+ if(!rebuildStatus.inputs||!rebuildStatus.producer){physicsPanel.load({available:false,reason:rebuildStatus.reason??'PHYSICS_TUNING_UNAVAILABLE: no producer handoff'},[]);return;}
1260
+ const bones=(inspectedDocument as {bones?:unknown}).bones,list=Array.isArray(bones)?bones.filter((b):b is {name:string;parent?:string}=>!!b&&typeof (b as {name?:unknown}).name==='string'):[];
1261
+ physicsPanel.load(tuningRows(rebuildStatus.constraints??null,inspectedDocument),list);
1262
+ refreshIdle();
1263
+ }
1264
+ /** The Idle tracks panel (#104): the config's idle and its keying from `GET /api/rebuild`, the drawn model's bone setups. */
1265
+ function refreshIdle():void {
1266
+ if(!idlePanel||!rebuildStatus)return;
1267
+ idlePanel.load(rebuildStatus.idle??null,rebuildStatus.inputs?null:rebuildStatus.reason??'IDLE_TRACKS_UNAVAILABLE: no producer handoff');
1268
+ if(!stageDocumentSet&&inspectedDocument)idlePanel.model(boneSetup(inspectedDocument));
1269
+ }
1270
+ let stageDocumentSet=false;
1271
+ /** A model document's bone setups as play poses them before the timelines (absent channels are play's defaults). */
1272
+ function boneSetup(document:unknown):BoneSetup {
1273
+ const list=(document as {bones?:unknown}).bones,map=new Map<string,Partial<Record<LocalChannel,number>>>();
1274
+ for(const b of Array.isArray(list)?list:[]){if(!b||typeof (b as {name?:unknown}).name!=='string')continue;const o=b as Record<string,unknown>,v:Partial<Record<LocalChannel,number>>={};for(const c of ['rotation','x','y','scaleX','scaleY','shearX','shearY'] as const)if(typeof o[c]==='number')v[c]=o[c] as number;map.set(o.name as string,v);}
1275
+ return {names:[...map.keys()],get:bone=>map.get(bone)};
1276
+ }
1277
+ /** After a seek the held pose is re-posed through the preview's adjust at the restored track time (a seek's replay steps a track the player's time does not read). */
1278
+ function reposePreview():void {if(player?.adjust)player.repose();}
1279
+ /** Setup's mesh edit: the selected slot's actual mesh as a typed delta. A two-file client download; nothing reaches the session or the package. */
1280
+ /** Edit Mesh's source (#100): the selected slot's mesh in the model document at the setup pose (a never-played player's bones). */
1281
+ function meshSource(){
1282
+ if(data.manifest.model&&!partModel)return {ok:false as const,code:'MESH_EDIT_RIG_UNAVAILABLE: the compiled model document is still loading'};
1283
+ return meshEditSource({slot:parts?.value()?.slot??null,model:data.manifest.model?partModel:null,pose:slot=>assets?readSetupPose(assets,slot,modelBoneNames(partModel),document.createElement('div')):null,mapping:chestMapping});
1284
+ }
1285
+ async function exportMesh(doc:MeshEditDocument):Promise<void> {
1286
+ const text=meshEditText(doc),digest=await sha256Hex(text);
1287
+ const binding=meshEditBinding({fileSha256:digest,sourceHash:data.sourceHash,reviewedSource:data.reviewedSource,exportedAt:new Date().toISOString(),mapping:chestMapping.available?chestMapping.basis:chestMapping.reason,attachment:{slot:doc.slot,skin:doc.skin,name:doc.attachment,sha256:doc.source.hash}});
1288
+ download('mesh-edit.json',text,'application/json');download('mesh-edit.binding.json',JSON.stringify(binding,null,2)+'\n','application/json');
1289
+ message(`Downloaded mesh-edit.json (${doc.ops.length} ops) and mesh-edit.binding.json. The delta is review evidence, not an approval; the package is unchanged and the producer retriangulates.`);
1290
+ }
1291
+ /**
1292
+ * Retarget Apply (#114): `{retarget: {sequence, map, flags, configHash}}` to `/api/rebuild`, where the server runs the
1293
+ * installed `rig-pose clean` and `rig-pose retarget` and the shared tail into scratch. On a built job the scratch build
1294
+ * replaces the drawn rig in place, the new animation is chosen and the sequence is shown as the reference beside it; the
1295
+ * draft keeps the record (map by content, flags and their argv), the producer's line, the motion summary and the lines.
1296
+ * A refusal leaves the stage as it was and shows every FAIL line verbatim.
1297
+ */
1298
+ async function applyRetarget(body:{sequence:string;map:PoseMap;flags:RetargetFlags}):Promise<RetargetJobView|RebuildRefusal|null> {
1299
+ if(rebuildBusy){retargetPanel?.status('REBUILD_BUSY: an Apply is still running',true);return null;}
1300
+ if(readOnly){retargetPanel?.status(readOnly,true);return null;}
1301
+ const status=rebuildStatus??await loadRebuildStatus();
1302
+ if(!status?.inputs){retargetPanel?.status(status?.reason??'REBUILD_STATUS_UNAVAILABLE',true);return null;}
1303
+ const request:RetargetRequestBody={retarget:{...body,configHash:status.inputs.configHash}},world=stageWorld();
1304
+ rebuildBusy=true;rebuildState();retargetPanel?.busy(true);retargetPanel?.result(null);
1305
+ retargetPanel?.status(`Retargeting ${body.sequence} as "${body.flags.animation}" with ${status.retarget?.producer?.package??'@rig-c/pose'} ${status.retarget?.producer?.version??''}: ${body.flags.clean?'clean, ':''}retarget, compile…`,false);
1306
+ try{
1307
+ const result=await runRebuild<RetargetRequestBody,RetargetJobView>(request,{post:value=>api<RetargetJobView|RebuildRefusal>('/api/rebuild',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)}),get:id=>api<RetargetJobView>(`/api/rebuild/${id}`),sleep:ms=>new Promise(resolve=>setTimeout(resolve,ms)),onLine:text=>retargetPanel?.line(text)});
1308
+ const outcome=retargetOutcomeLine(result),next=stageModel(result,{url:'',hash:stageModelHash??''});
1309
+ retargetPanel?.result(result);retargetPanel?.status(outcome.text,outcome.error);
1310
+ if(next.id===null||!('id' in result)){message(outcome.text,true);return result;}
1311
+ await swapRig(new URL(access.route(next.url),location.href).href,access.route(`/rebuild/${next.id}/`),next.hash,world);
1312
+ appliedOnStage=retargetAppliedRecord(result);stageCheck=null;
1313
+ const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1314
+ // The new animation plays against the reference: choose it, and show the sequence it was made from beside it.
1315
+ const name=result.request.record.flags.animation;if(assets?.animations.some(clip=>clip.name===name))chooseAnimation(name);
1316
+ const ref=result.request.record.sequence.reference;if(data.manifest.references.some(r=>r.id===ref)&&select('reference-select').value!==ref){select('reference-select').value=ref;await showReference(ref);}
1317
+ message(outcome.text,outcome.error);
1318
+ return result;
1319
+ }catch(error){const text=`REBUILD_FAILED: ${errorText(error)}`;retargetPanel?.status(text,true);message(text,true);return {status:'refused',error:text,producerLine:null};}
1320
+ finally{rebuildBusy=false;rebuildState();retargetPanel?.busy(false);}
1321
+ }
1322
+ /**
1323
+ * Edit Mesh's Apply · Rebuild (#135): `{part, delta, configHash}` to `/api/rebuild`, where the server runs
1324
+ * `rig-parts remesh --delta` and the shared tail; on a built job the revision replaces the drawn rig in place under the
1325
+ * same world, and the draft keeps the applied delta (by content and hash), the producer's line, op "remesh", the
1326
+ * per-file diff and the part's counts. A refusal leaves the stage as it was and returns the producer's line verbatim.
1327
+ */
1328
+ async function applyMeshDelta(doc:MeshEditDocument,line:(text:string)=>void):Promise<{built:boolean;text:string;error:boolean}> {
1329
+ if(rebuildBusy)return {built:false,text:'REBUILD_BUSY: an Apply is still running',error:true};
1330
+ if(readOnly)return {built:false,text:readOnly,error:true};
1331
+ const status=rebuildStatus??await loadRebuildStatus();
1332
+ if(!status?.inputs)return {built:false,text:status?.reason??'REBUILD_STATUS_UNAVAILABLE',error:true};
1333
+ const body:DeltaRequestBody={part:doc.slot,delta:doc,configHash:status.inputs.configHash},world=stageWorld();
1334
+ rebuildBusy=true;rebuildState();
1335
+ try{
1336
+ const result=await runRebuild<DeltaRequestBody,DeltaJobView>(body,{post:value=>api<DeltaJobView|RebuildRefusal>('/api/rebuild',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)}),get:id=>api<DeltaJobView>(`/api/rebuild/${id}`),sleep:ms=>new Promise(resolve=>setTimeout(resolve,ms)),onLine:text=>line(text)});
1337
+ const outcome=deltaOutcomeLine(result),next=stageModel(result,{url:'',hash:stageModelHash??''});
1338
+ if(next.id===null||!('id' in result)){message(outcome.text,true);return {built:false,text:outcome.text,error:true};}
1339
+ await swapRig(new URL(access.route(next.url),location.href).href,access.route(`/rebuild/${next.id}/`),next.hash,world);
1340
+ appliedOnStage=deltaAppliedRecord(result);stageCheck=null;
1341
+ const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1342
+ message(outcome.text);
1343
+ return {built:true,text:outcome.text,error:false};
1344
+ }catch(error){const text=`REBUILD_FAILED: ${errorText(error)}`;message(text,true);return {built:false,text,error:true};}
1345
+ finally{rebuildBusy=false;rebuildState();}
1346
+ }
1347
+ /** `GET /api/rebuild`: whether Apply can run, and the producer config's regions to start an untouched editor from. */
1348
+ async function loadRebuildStatus():Promise<RebuildStatusView|null> {
1349
+ try{rebuildStatus=await api<RebuildStatusView>('/api/rebuild');}catch(error){rebuildStatus=null;chestEditor?.notify(`REBUILD_STATUS_UNAVAILABLE: ${errorText(error)}`,true);return null;}
1350
+ const seed={...(seedFields(rebuildStatus,chestField)??{}),...(seedLandmarks(rebuildStatus,chestField)??{})};
1351
+ if(Object.keys(seed).length){for(const [id,value] of Object.entries(seed)){const node=document.getElementById(id) as HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement|null;if(!node)continue;if(node instanceof HTMLInputElement&&node.type==='checkbox')node.checked=value==='true';else node.value=value;}el('chest-regions').dispatchEvent(new Event('input',{bubbles:true}));}
1352
+ if(!rebuildStatus.available)chestEditor?.notify(rebuildStatus.reason,true);
1353
+ renderHandoff(rebuildStatus);refreshPhysics();
1354
+ retargetPanel?.load(rebuildStatus.retarget??null,rebuildStatus.reason);
1355
+ bonesPanel?.load(rebuildStatus.inputs?rebuildStatus.bones??[]:null,rebuildStatus.inputs?null:rebuildStatus.reason??'BONES_UNAVAILABLE: no producer handoff');
1356
+ rigFieldsPanel?.load(rebuildStatus.inputs?rebuildStatus.fields??{}:null,rebuildStatus.inputs?null:rebuildStatus.reason??'RIG_FIELDS_UNAVAILABLE: no producer handoff');
1357
+ 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
+ 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
+ return rebuildStatus;
1360
+ }
1361
+ /** The handoff line (#101): path, hash and how it was found, then the baseline; re-read while the baseline runs. */
1362
+ let handoffTimer:ReturnType<typeof setTimeout>|null=null;
1363
+ function renderHandoff(status:RebuildStatusView):void {
1364
+ const line=el('handoff-line'),shown=handoffText(status);line.textContent=shown.text;line.title=shown.text;line.classList.toggle('error',shown.error);
1365
+ if(handoffTimer)clearTimeout(handoffTimer);handoffTimer=null;
1366
+ if(status.baseline?.status==='running')handoffTimer=setTimeout(()=>{void api<RebuildStatusView>('/api/rebuild').then(next=>{rebuildStatus=next;renderHandoff(next);}).catch(()=>{});},2000);
1367
+ }
1368
+ function rebuildState():void {chestEditor?.rebuildState({busy:rebuildBusy,applied:!!appliedOnStage});}
1369
+ /** Apply · Rebuild: the rows go to the installed producer; on success its scratch build replaces the drawn rig in place. */
1370
+ async function applyRebuild():Promise<void> {
1371
+ if(rebuildBusy)return;
1372
+ if(readOnly){chestEditor?.notify(readOnly,true);return;}
1373
+ const status=rebuildStatus??await loadRebuildStatus();
1374
+ if(!status||!status.available||!status.inputs){const reason=status?.reason??'REBUILD_STATUS_UNAVAILABLE';chestEditor?.notify(reason,true,[reason]);message(reason,true);return;}
1375
+ let part:string,body:RebuildRequestBody;const lines:string[]=[];
1376
+ if(chestEditor?.mode()==='landmarks'){
1377
+ // #105: refused here, before anything is sent, when a point is missing or the circles would not clear.
1378
+ const request=landmarksRequest(status,chestEditor.landmarks());if('error' in request){chestEditor.notify(request.error,true,[request.error]);message(request.error,true);return;}
1379
+ ({part,body}=request);
1380
+ }else{
1381
+ const designated=designatedRegions(chestField);if('error' in designated){chestEditor?.notify(designated.error,true);return;}
1382
+ const picked=pickPart(status,designated.regions);if(!picked){chestEditor?.notify(`REBUILD_PART_AMBIGUOUS: the config states ${status.parts.length} contour meshes and the designated bones name none of them alone`,true);return;}
1383
+ part=picked;body={part,regions:designated.regions,configHash:status.inputs.configHash};
1384
+ }
1385
+ rebuildBusy=true;rebuildState();chestEditor?.notify(`Rebuilding ${part} with ${status.producer?.package} ${status.producer?.version}…`,false,[]);
1386
+ try{
1387
+ const result=await runRebuild(body,{post:value=>api<RebuildJobView|RebuildRefusal>('/api/rebuild',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)}),get:id=>api<RebuildJobView>(`/api/rebuild/${id}`),sleep:ms=>new Promise(resolve=>setTimeout(resolve,ms)),
1388
+ onLine:line=>{lines.push(line);chestEditor?.notify(line.trim()||'…',false,lines.slice(-6));}});
1389
+ const outcome=outcomeLine(result);
1390
+ // A refusal stays readable in full: the bar's status line is one line, so the log ends with the producer's line verbatim.
1391
+ if(!('id' in result)||result.status!=='built'||!result.build){chestEditor?.notify(outcome.text,true,[...lines.slice(-5),outcome.text]);message(outcome.text,true);return;}
1392
+ await swapRig(new URL(access.route(result.build.modelUrl),location.href).href,access.route(`/rebuild/${result.id}/`),result.build.modelHash);
1393
+ appliedOnStage=appliedRecord(result);stageCheck=null;
1394
+ const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1395
+ // The log closes on success so nothing floats over the rebuilt stage; a refusal keeps it open.
1396
+ chestEditor?.notify(outcome.text,false,[]);message(outcome.text);
1397
+ }catch(error){chestEditor?.notify(`REBUILD_FAILED: ${errorText(error)}`,true);message(`REBUILD_FAILED: ${errorText(error)}`,true);}
1398
+ finally{rebuildBusy=false;rebuildState();}
1399
+ }
1400
+ /**
1401
+ * Apply a config patch (#102): the same stream, in-place reload and Reset as a region Apply. The patch goes to the
1402
+ * installed producer through `POST /api/rebuild {patch}`; on a built job the scratch build replaces the drawn rig with
1403
+ * the same camera, animation, time, set and environment, and the draft keeps the patch, its hash, the producer's line
1404
+ * and check's summary. A refusal leaves the stage as it was and shows the producer's line verbatim. No editing UI calls
1405
+ * this yet (#103+); `window.rigReviewEditor.applyPatch` is its entry point.
1406
+ */
1407
+ async function applyPatch(patch:ConfigPatch,world:StageWorld|null=null):Promise<PatchJobView|RebuildRefusal> {
1408
+ if(rebuildBusy)return {status:'refused',error:'REBUILD_BUSY: an Apply is still running',producerLine:null};
1409
+ if(readOnly){chestEditor?.notify(readOnly,true);return {status:'refused',error:readOnly,producerLine:null};}
1410
+ const lines:string[]=[];
1411
+ rebuildBusy=true;rebuildState();chestEditor?.notify(`Applying patch (${patch.op.cmd}, ${patch.ops.length} op(s))…`,false,[]);
1412
+ 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)),
1414
+ onLine:line=>{lines.push(line);chestEditor?.notify(line.trim()||'…',false,lines.slice(-6));}});
1415
+ const outcome=patchOutcomeLine(result),next=stageModel(result,{url:'',hash:stageModelHash??''});
1416
+ if(next.id===null||!('id' in result)){chestEditor?.notify(outcome.text,true,[...lines.slice(-5),outcome.text]);message(outcome.text,true);return result;}
1417
+ await swapRig(new URL(access.route(next.url),location.href).href,access.route(`/rebuild/${next.id}/`),next.hash,world);
1418
+ appliedOnStage=patchAppliedRecord(result);stageCheck={lines:[...result.lines],check:result.check};
1419
+ const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1420
+ chestEditor?.notify(outcome.text,outcome.error,[]);message(outcome.text,outcome.error);
1421
+ return result;
1422
+ }catch(error){const text=`REBUILD_FAILED: ${errorText(error)}`;chestEditor?.notify(text,true);message(text,true);return {status:'refused',error:text,producerLine:null};}
1423
+ finally{rebuildBusy=false;rebuildState();}
1424
+ }
1425
+ /** The client's edit entry point for automation and the edit cards to come: apply a patch, return to the inspected build, read the stage. */
1426
+ (window as unknown as {rigReviewEditor:unknown}).rigReviewEditor={
1427
+ applyPatch,reset:()=>resetRebuild(),
1428
+ status:async()=>rebuildStatus??await loadRebuildStatus(),
1429
+ physics:{rows:()=>physicsPanel?.rows()??[],chains:()=>physicsPanel?.chains()??[],edits:()=>physicsPanel?.edits()??[],set:(name:string,field:Parameters<PhysicsPanel['set']>[1],value:number)=>physicsPanel?.set(name,field,value),ramp:(id:string,field:Parameters<PhysicsPanel['set']>[1],root:number,tip:number)=>physicsPanel?.ramp(id,field,root,tip),select:(name:string)=>physicsPanel?.select(name),apply:()=>applyPhysics(physicsPanel?.edits().map(e=>({op:e.op,path:e.path,value:e.value,name:e.name}))??[]),reset:()=>resetPhysicsBuild()},
1430
+ /** Inspection only: one slot's posed world geometry on the drawn frame (what the stage drew), for comparing against `rigc render --geometry`. */
1431
+ posed:(slot:string)=>{const frame=player?.posedFrame();const found=frame?.order.find(item=>item.slot===slot);return found?JSON.parse(JSON.stringify(found,(key,value)=>key==='bitmap'||key==='image'||key==='page'?undefined:value)):null;},
1432
+ bone:(name:string)=>player?JSON.parse(JSON.stringify(player.bone(name))):null,
1433
+ /** Inspection only (#100): Edit Mesh's setup-space source (crop px per vertex, unrounded) and its drawn vertices as page client pixels. */
1434
+ meshEdit:()=>{
1435
+ const session=meshEditor?.session(),overlay=meshEditor?.overlay();if(!session||!overlay)return null;
1436
+ const s=session.source,project=rigProject(),view=el('rig-root').parentElement!.getBoundingClientRect();
1437
+ return {slot:s.slot,skin:s.skin,attachment:s.attachment,hash:s.hash,hull:s.hull,triangles:s.triangles.length/3,vertices:s.vertices,ops:session.ops(),
1438
+ drawn:overlay.vertices.map(v=>{const p=project(v.at.x,v.at.y);return {x:p.x+view.left,y:p.y+view.top,state:v.state};})};
1439
+ },
1440
+ /** Inspection only: a world point (y up) as page client pixels on the rig pane, the projection the overlays draw with. */
1441
+ screen:(x:number,y:number)=>{const p=rigProject()(x,y),view=el('rig-root').parentElement!.getBoundingClientRect();return {x:p.x+view.left,y:p.y+view.top};},
1442
+ /**
1443
+ * Inspection only, a fixed schedule: pause, reopen the track at 0 and step it `round(t/dt)` times by `dt`. With `steps`
1444
+ * (`rigc-environment/1` steps: x right, y down) each step is set before the step that poses the first frame at or after
1445
+ * its `t`, the way `rigc render --environment` steps a build; without, the environment as it stands. The page's own
1446
+ * environment is put back afterwards; no jolt is replayed and the wind clock is untouched.
1447
+ */
1448
+ replay:(t:number,dt:number,steps:readonly {t:number;wind?:[number,number];gravity?:[number,number]}[]=[])=>{
1449
+ if(!player?.current||!(dt>0)||!(t>=0))return null;
1450
+ const at=(k:number)=>{for(const step of steps)if(Math.max(0,Math.ceil(step.t/dt-1e-9))===k)player!.environment({...(step.wind?{wind:step.wind}:{}),...(step.gravity?{gravity:step.gravity}:{})});};
1451
+ pause();if(steps.length)player.environment(null);at(0);player.seek(0);reposePreview();player.resume();
1452
+ try{for(let k=1,n=Math.round(t/dt);k<=n;k++){at(k);player.frame(dt);}}finally{player.pause();}
1453
+ drawRig(()=>player!.draw());time=player.time;renderTransport();
1454
+ const posedTime=player.time;if(steps.length){if(environmentApplied())player.environment(environmentFor(wind.read(),gravityValue()));else player.environment(null);}
1455
+ return posedTime;},
1456
+ /** Readings (#108): run (the pickers' choice, or the given readings), the last job, the ticks drawn and the worst lines shown. */
1457
+ 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
+ /** Idle tracks (#104): the panel's state and ops, its edits, Apply and Reset, the preview, and the inspection-only `sample`. */
1459
+ idle:{state:()=>idlePanel?.state()??null,ops:()=>idlePanel?.ops()??[],source:()=>idlePanel?.source()??null,keying:()=>idlePanel?.keying()??null,refusals:()=>idlePanel?.refusals()??[],
1460
+ select:(key:string)=>idlePanel?.select(key),field:(key:string,field:Parameters<IdlePanel['field']>[1],value:number|null)=>idlePanel?.field(key,field,value),linkAmp:(key:string,link:number,value:number)=>idlePanel?.linkAmp(key,link,value),
1461
+ add:(spec:Parameters<IdlePanel['add']>[0])=>idlePanel?.add(spec),remove:(key:string)=>idlePanel?.remove(key),blink:(field:Parameters<IdlePanel['blink']>[0],value:number)=>idlePanel?.blink(field,value),
1462
+ preview:()=>idlePanel?.previewing()??null,setPreview:(on:boolean)=>idlePanel?.setPreview(on),
1463
+ apply:()=>applyIdle(idlePanel?.ops()??[],idlePanel?.keying()?.keys??null),reset:()=>resetIdleBuild(),
1464
+ sample:(bone:string,dt:number,n:number)=>sampleLocals(bone,dt,n),adjustSet:()=>!!player?.adjust},
1465
+ /** Mesh panel (#106): the selected part's pending entry, its edits, Apply, Reset, the folds reading and the overlay's triangle count per slot. */
1466
+ mesh:{part:()=>meshPanel?.part()??null,select:(part:string)=>meshPanel?.select(part),entry:()=>meshPanel?.entry()??null,configEntry:()=>meshPanel?.configEntry()??null,set:(path:string[],value:import('../src/config-patch.ts').JsonValue|undefined)=>meshPanel?.set(path,value),setEntry:(entry:MeshEntry)=>meshPanel?.setEntry(entry),
1467
+ regions:()=>meshPanel?.regions.list()??[],addRegion:(region:import('../src/mesh-entry.ts').MeshRegion)=>meshPanel?.regions.add(region),moveRegion:(region:number,vertex:number,to:[number,number])=>meshPanel?.regions.move(region,vertex,to),drawRegions:(on:boolean)=>meshPanel?.regions.draw(on),placing:()=>meshPanel?.regions.placing()??null,
1468
+ precheck:()=>meshPanel?.precheck()??[],apply:()=>{const part=meshPanel?.part(),entry=meshPanel?.entry();return part&&entry?applyMesh(part,entry):Promise.resolve(null);},reset:()=>resetMeshBuild(),folds:()=>meshFolds(),
1469
+ overlayTriangles:(slot:string)=>lastOverlay?.meshTriangles[slot]??null,status:()=>el('mesh-status').textContent,counts:()=>[...document.querySelectorAll('#mesh-counts code')].map(c=>c.textContent),readout:()=>[...document.querySelectorAll('#mesh-readout code')].map(c=>c.textContent),pending:()=>input('mesh-pending').value,lastPatch:()=>lastMeshPatch?JSON.parse(JSON.stringify(lastMeshPatch)):null},
1470
+ /** #115: the Cuts block — its handles as client pixels (for a real-pointer drag), its ops, Apply and Reset. */
1471
+ cuts:{source:()=>cutsPanel?.source()??null,ops:()=>cutsPanel?.ops()??[],handles:()=>cutsPanel?.handles()??[],active:()=>cutsPanel?.active()??false,setActive:(on:boolean)=>cutsPanel?.setActive(on),
1472
+ 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
+ apply:()=>cutsPanel?.apply()??Promise.resolve(null),reset:()=>resetCutsBuild()},
1474
+ 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
+ active:()=>bonesPanel?.active()??false,setActive:(on:boolean)=>bonesPanel?.setActive(on),handles:()=>bonesPanel?.handles()??[],check:()=>bonesPanel?.check()??Promise.resolve(null),
1476
+ apply:()=>applyBones((bonesPanel?.edits()??[]).map(e=>({op:'replace' as const,path:e.path,value:[...e.value],name:e.name}))),reset:()=>resetBonesBuild(),
1477
+ status:()=>el('bones-status').textContent,lines:()=>[...document.querySelectorAll('#bones-lint code, #bones-lint summary')].map(c=>c.textContent),compare:()=>[...document.querySelectorAll('#bones-compare code')].map(c=>c.textContent),pending:()=>input('bones-pending').value},
1478
+ /** Rig fields (#110): the block's source, edits and ops, each field's setter, the dial, the stage handles, Apply and Reset. */
1479
+ fields:{source:()=>rigFieldsPanel?.source()??null,edits:()=>rigFieldsPanel?.edits()??[],ops:()=>rigFieldsPanel?.ops()??[],
1480
+ region:(part:string,bone:string)=>rigFieldsPanel?.region(part,bone),still:(part:string,entry:{row:number;bone:string}|null)=>rigFieldsPanel?.still(part,entry),stop:(index:number,bounds:{inward?:number;outward?:number})=>rigFieldsPanel?.stop(index,bounds),
1481
+ inherit:(index:number,mode:string|null,link?:string)=>rigFieldsPanel?.inherit(index,mode,link),turn:(target:{pivot:number}|{part:string;depth:number})=>rigFieldsPanel?.turn(target),
1482
+ select:(kind:'part'|'still'|'stop'|'bone',value:string|number|null)=>rigFieldsPanel?.select(kind,value),setDial:(x:number)=>rigFieldsPanel?.setDial(x),dial:()=>rigFieldsPanel?.dialState()??null,handles:()=>rigFieldsPanel?.handles()??[],
1483
+ apply:()=>applyRigFields(rigFieldsPanel?.ops()??[]),reset:()=>resetRigFieldsBuild(),status:()=>el('rig-fields-status').textContent,readout:()=>[...document.querySelectorAll('#rig-fields-readout code')].map(c=>c.textContent),pending:()=>input('rig-fields-pending').value},
1484
+ /** Retarget (#114): the editor's map and flags, Apply (through the panel's own pre-check) and the listed lines shown. */
1485
+ retarget:{map:()=>retargetPanel?.map()??null,setMap:(map:PoseMap)=>retargetPanel?.setMap(map),include:(chain:string,on:boolean)=>retargetPanel?.include(chain,on),pick:(chain:string,index:number,bone:string)=>retargetPanel?.pick(chain,index,bone),flags:()=>retargetPanel?.flags()??null,setFlags:(flags:Partial<RetargetFlags>)=>retargetPanel?.setFlags(flags),apply:()=>{const button=document.getElementById('retarget-apply') as HTMLButtonElement|null;button?.click();},status:()=>el('retarget-status').textContent,lines:()=>retargetPanel?.shownLines()??[],refusals:()=>[...document.querySelectorAll('#retarget-refusals code')].map(c=>c.textContent)},
1486
+ stage:()=>({modelHash:stageModelHash,inspectedHash:inspectedModel?.sha256??null,applied:appliedOnStage?JSON.parse(JSON.stringify(appliedOnStage)):null,animation,time:player?.time??time,playing,busy:rebuildBusy}),
1487
+ };
1488
+ /** The world as it stands now: what a physics Apply's reload resumes. */
1489
+ function stageWorld():StageWorld {return {wind:wind.state(),jolts:joltQueue.map(step=>({...step})),applied:environmentApplied(),gravity:gravityValue()};}
1490
+ /**
1491
+ * Physics Apply (#103): the pending ops become one config patch (`rig`, then check) against the handoff config; the
1492
+ * scratch build reloads under the world Apply was pressed in, and the readout shows the two check lines before (the
1493
+ * build that was on the stage) and after. A refusal shows the producer's line verbatim and leaves the stage as it was.
1494
+ */
1495
+ async function applyPhysics(ops:PatchOp[]):Promise<PatchJobView|RebuildRefusal|null> {
1496
+ const status=rebuildStatus??await loadRebuildStatus();
1497
+ const patch=status&&ops.length?makePatch(status,ops,{cmd:'rig'}):null;
1498
+ if(!patch){const reason=!ops.length?'PHYSICS_NOTHING_PENDING: no pending ops':status?.reason??'REBUILD_STATUS_UNAVAILABLE';physicsPanel?.status(reason,true);return null;}
1499
+ const world=stageWorld(),before=stageCheck;
1500
+ physicsPanel?.busy(true);physicsPanel?.status(`Applying ${ops.length} op(s) with ${patch.producer.name} ${patch.producer.version}: ${patchRoute(patch.ops,patch.producer.version)} --config, compile, check…`,false);
1501
+ try{
1502
+ const result=await applyPatch(patch,world);
1503
+ const outcome=patchOutcomeLine(result);physicsPanel?.status(outcome.text,outcome.error);
1504
+ if('id' in result&&result.status==='built'){
1505
+ physicsPanel?.applied(result.request.patch.ops);
1506
+ // BEFORE for the inspected build is its stored background check (#108, HQ ruling); re-read while it still runs.
1507
+ const after={lines:[...result.lines],check:result.check};
1508
+ const show=()=>physicsPanel?.readout(readoutRows(before,before?null:inspectedCheckView(),after));
1509
+ show();if(!before)void followInspectedCheck(show);
1510
+ }
1511
+ return result;
1512
+ }finally{physicsPanel?.busy(false);}
1513
+ }
1514
+ /** The inspected build's check as the last `GET /api/rebuild` returned it. */
1515
+ function inspectedCheckView():InspectedCheckView|null {return rebuildStatus?.inspectedCheck??null;}
1516
+ /** While the inspected check runs, re-read the status and redraw the readout once it settles. */
1517
+ async function followInspectedCheck(show:()=>void):Promise<void> {
1518
+ for(let i=0;i<120&&(!rebuildStatus?.inspectedCheck||rebuildStatus.inspectedCheck.status==='running');i++){
1519
+ try{rebuildStatus=await api<RebuildStatusView>('/api/rebuild');}catch{return;}
1520
+ show();if(rebuildStatus.inspectedCheck&&rebuildStatus.inspectedCheck.status!=='running')return;
1521
+ if(!rebuildStatus.inspectedCheck&&rebuildStatus.baseline?.status!=='running')return;
1522
+ await new Promise(resolve=>setTimeout(resolve,3000));
1523
+ }
1524
+ }
1525
+ /**
1526
+ * Readings (#108): the pickers' readings of the drawn animation go to `POST /api/readings`, which runs `rigc render`
1527
+ * once per reading on the inspected build and on the latest scratch build; the finished job's frames become ticks and
1528
+ * its worst frames lines. The Environment block's wind and gravity go along only while its controls are applied.
1529
+ */
1530
+ async function runReadings(override?:{readings:import('../src/readings.ts').ReadingRequest[]}):Promise<ReadingsJobView|ReadingsRefusal|null> {
1531
+ if(!readingsPanel||readingsBusy)return null;
1532
+ const chosen=override??readingsPanel.requests();
1533
+ if('error' in chosen){readingsPanel.status(chosen.error,true);return {status:'refused',error:chosen.error};}
1534
+ if(!animation){readingsPanel.status('READINGS_NO_ANIMATION: no animation is drawn',true);return null;}
1535
+ const environment=player&&environmentApplied()?environmentFor(wind.read(),gravityValue()):null;
1536
+ const body={animation,readings:chosen.readings,environment};
1537
+ readingsBusy=true;readingsPanel.busy(true);readingsPanel.status(`Running ${chosen.readings.length} reading(s) of ${animation} with rigc render…`,false);
1538
+ const started=performance.now();
1539
+ try{
1540
+ let job=await api<ReadingsJobView|ReadingsRefusal>('/api/readings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).catch(error=>({status:'refused' as const,error:errorText(error).replace(/^\d+: /,'')}));
1541
+ if(!('id' in job)){const text=(()=>{try{return (JSON.parse(job.error) as {error?:string}).error??job.error;}catch{return job.error;}})();readingsPanel.status(text,true);return {status:'refused',error:text};}
1542
+ while(job.status==='running'){
1543
+ const done=job.runs.reduce((n,r)=>n+r.results.length,0),total=job.runs.length*job.request.readings.length;
1544
+ readingsPanel.status(`Running readings of ${animation}: ${done} of ${total} done…`,false);
1545
+ await new Promise(resolve=>setTimeout(resolve,800));
1546
+ job=await api<ReadingsJobView>(`/api/readings/${job.id}`);
1547
+ }
1548
+ if(job.status==='failed'){readingsPanel.status(job.error??'READINGS_FAILED',true);return job;}
1549
+ readingsJob=job;readingsPanel.lines(job);renderReadingTicks(true);
1550
+ const ticks=job.runs.map(r=>`${r.build} ${r.results.reduce((n,x)=>n+x.frames.length,0)} tick(s)`).join(' · ');
1551
+ 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'):'';
1553
+ 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
+ return job;
1555
+ }catch(error){readingsPanel.status(`READINGS_FAILED: ${errorText(error)}`,true);return null;}
1556
+ finally{readingsBusy=false;readingsPanel.busy(false);}
1557
+ }
1558
+ /** `context.readings`: the last finished run's pairs and builds, or null when none ran. */
1559
+ function readingsContext():ReadingsContext|null {
1560
+ const job=readingsJob;if(!job)return null;
1561
+ const ticks=(build:'inspected'|'scratch')=>{const run=job.runs.find(r=>r.build===build);return run?run.results.reduce((n,r)=>n+r.frames.length,0):null;};
1562
+ return {schema:READINGS_SCHEMA,animation:job.request.animation,requests:job.request.readings,environment:job.request.environment,environmentPassed:job.environment.passed,builds:{inspected:job.runs.find(r=>r.build==='inspected')?.modelHash??'',scratch:job.runs.find(r=>r.build==='scratch')?.id??null},compiler:job.compiler,ticks:{inspected:ticks('inspected')??0,scratch:ticks('scratch')}};
1563
+ }
1564
+ /** Requirements (#113): what the handoff's rig spec offers the panel, or why nothing does. */
1565
+ async function loadRequirements():Promise<void> {
1566
+ try{const catalog=await api<RequirementsCatalogView>('/api/requirements');requirementsPanel?.load(catalog,catalog.reason);}
1567
+ catch(error){requirementsPanel?.load(null,`REQUIREMENTS_UNAVAILABLE: ${errorText(error)}`);}
1568
+ }
1569
+ /** Requirements Check (#113): typed requirements to `POST /api/requirements`, then its job until it settles; a refusal is shown verbatim. */
1570
+ async function runRequirements(body:{fps:number;build:'inspected'|'scratch';requirements:Record<string,unknown>[]}):Promise<RequirementsJobView|RequirementsRefusal|null> {
1571
+ const unwrap=(error:unknown)=>{const text=errorText(error).replace(/^\d+: /,'');try{return (JSON.parse(text) as {error?:string}).error??text;}catch{return text;}};
1572
+ try{
1573
+ let job=await api<RequirementsJobView|RequirementsRefusal>('/api/requirements',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
1574
+ while('id' in job&&job.status==='running'){await new Promise(resolve=>setTimeout(resolve,800));job=await api<RequirementsJobView>(`/api/requirements/${job.id}`);}
1575
+ return job;
1576
+ }catch(error){return {status:'refused',error:unwrap(error)};}
1577
+ finally{void loadRequirements();}
1578
+ }
1579
+ /** Bones Check (#109): the pending ops go to `POST /api/bones-check` with the handoff config's hash; a refusal is shown verbatim. */
1580
+ async function checkBones(ops:PatchOp[]):Promise<BonesCheckView> {
1581
+ const status=rebuildStatus??await loadRebuildStatus();
1582
+ if(!status?.inputs)return {status:'refused',error:status?.reason??'REBUILD_STATUS_UNAVAILABLE'};
1583
+ try{return await api<BonesCheckView>('/api/bones-check',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ops,configHash:status.inputs.configHash})});}
1584
+ catch(error){const text=errorText(error).replace(/^\d+: /,'');let shown=text;try{shown=(JSON.parse(text) as {error?:string}).error??text;}catch{/* not JSON */}return {status:'refused',error:shown};}
1585
+ }
1586
+ /**
1587
+ * Bones Apply (#109): the pending ops become one config patch (`rig`, then check) against the handoff config — a
1588
+ * whole-rig op, so the server refuses it (`EDIT_BASELINE_MISMATCH`) unless the baseline reproduced the inspected rig.
1589
+ * On a build the re-weighted rig reloads in place (the Weights view reads the revision); a refusal is shown verbatim.
1590
+ */
1591
+ async function applyBones(ops:PatchOp[]):Promise<PatchJobView|RebuildRefusal|null> {
1592
+ const status=rebuildStatus??await loadRebuildStatus();
1593
+ const patch=status&&ops.length?makePatch(status,ops,{cmd:'rig'}):null;
1594
+ if(!patch){const reason=!ops.length?'BONES_NOTHING_PENDING: no pending ops':status?.reason??'REBUILD_STATUS_UNAVAILABLE';bonesPanel?.status(reason,true);return null;}
1595
+ bonesPanel?.busy(true);bonesPanel?.status(`Applying ${ops.length} op(s) with ${patch.producer.name} ${patch.producer.version}: ${patchRoute(patch.ops,patch.producer.version)} --config, compile, check…`,false);
1596
+ try{
1597
+ const result=await applyPatch(patch,stageWorld());
1598
+ const outcome=patchOutcomeLine(result);bonesPanel?.status(outcome.text,outcome.error);
1599
+ if('id' in result&&result.status==='built'){bonesPanel?.applied(result.request.patch.ops);redrawOverlay(true);}
1600
+ return result;
1601
+ }finally{bonesPanel?.busy(false);}
1602
+ }
1603
+ /**
1604
+ * Cuts Apply (#115): the pending cut / patch / range / joint ops become one `rig` patch, which the server runs as
1605
+ * `rig-parts build` (assemble → rig → check) from the handoff's painting and runs; the scratch build reloads in place
1606
+ * and the panel shows the refusals, the recomposite, the seam pairs and the build's wall clock.
1607
+ */
1608
+ async function applyCuts(ops:PatchOp[]):Promise<PatchJobView|RebuildRefusal|null> {
1609
+ const status=rebuildStatus??await loadRebuildStatus();
1610
+ const patch=status&&ops.length?makePatch(status,ops,{cmd:'rig'}):null;
1611
+ if(!patch){const reason=!ops.length?'CUTS_NOTHING_PENDING: no pending ops':status?.reason??'REBUILD_STATUS_UNAVAILABLE';cutsPanel?.status(reason,true);return null;}
1612
+ cutsPanel?.busy(true);cutsPanel?.status(`Applying ${ops.length} op(s) with ${patch.producer.name} ${patch.producer.version}: build (assemble → rig → check), compile… (a full build takes minutes)`,false);
1613
+ try{
1614
+ const result=await applyPatch(patch,stageWorld());
1615
+ const outcome=patchOutcomeLine(result);cutsPanel?.status(outcome.text,outcome.error);
1616
+ if('id' in result&&result.status==='built')redrawOverlay(true);
1617
+ return result;
1618
+ }finally{cutsPanel?.busy(false);}
1619
+ }
1620
+ /** Cuts Reset: the inspected build back on the stage (same world), and no pending op. */
1621
+ async function resetCutsBuild():Promise<void> {
1622
+ cutsPanel?.clear();cutsPanel?.shown(null);
1623
+ if(appliedOnStage){cutsPanel?.busy(true);try{await resetRebuild(stageWorld());}finally{cutsPanel?.busy(false);}}
1624
+ cutsPanel?.status('Reset: the inspected build is on the stage; no pending ops.',false);
1625
+ }
1626
+ /** Bones Reset: the inspected build back on the stage (same world), and no pending op. */
1627
+ async function resetBonesBuild():Promise<void> {
1628
+ bonesPanel?.clear();bonesPanel?.applied(null);
1629
+ if(appliedOnStage){bonesPanel?.busy(true);try{await resetRebuild(stageWorld());}finally{bonesPanel?.busy(false);}}
1630
+ bonesPanel?.status('Reset: the inspected build is on the stage; no pending ops.',false);
1631
+ }
1632
+ /**
1633
+ * Rig fields Apply (#110): the pending ops (in the block's scope, `RIG_FIELDS_OP_OUT_OF_SCOPE` otherwise) become one
1634
+ * config patch (`rig`, then check) against the handoff config — a whole-rig op behind the baseline guard (`/stops`,
1635
+ * `/bones`, `/regions`, `/turn` and the blink still are outside retune's scope, #136). On a build the revision reloads in place and the readout shows BLINK_NO_HOLE and FEET_STAY_LEVEL
1636
+ * before and after; a refusal shows the producer's line verbatim and leaves the stage as it was.
1637
+ */
1638
+ async function applyRigFields(ops:PatchOp[]):Promise<PatchJobView|RebuildRefusal|null> {
1639
+ const status=rebuildStatus??await loadRebuildStatus();
1640
+ try{assertRigFieldOps(ops);}catch(error){rigFieldsPanel?.status(errorText(error),true);return null;}
1641
+ const patch=status&&ops.length?makePatch(status,ops,{cmd:'rig'}):null;
1642
+ if(!patch){const reason=!ops.length?'RIG_FIELDS_NOTHING_PENDING: no pending ops':status?.reason??'REBUILD_STATUS_UNAVAILABLE';rigFieldsPanel?.status(reason,true);return null;}
1643
+ const world=stageWorld(),before=stageCheck;
1644
+ rigFieldsPanel?.busy(true);rigFieldsPanel?.status(`Applying ${ops.length} op(s) with ${patch.producer.name} ${patch.producer.version}: ${patchRoute(patch.ops,patch.producer.version)} --config, compile, check…`,false);
1645
+ try{
1646
+ const result=await applyPatch(patch,world);
1647
+ const outcome=patchOutcomeLine(result);rigFieldsPanel?.status(outcome.text,outcome.error);
1648
+ if('id' in result&&result.status==='built'){
1649
+ rigFieldsPanel?.applied(result.request.patch.ops);
1650
+ const after={lines:[...result.lines],check:result.check};
1651
+ const show=()=>rigFieldsPanel?.readout(readoutRows(before,before?null:inspectedCheckView(),after,RIG_FIELDS_READOUT_BARS));
1652
+ show();if(!before)void followInspectedCheck(show);
1653
+ }
1654
+ return result;
1655
+ }finally{rigFieldsPanel?.busy(false);}
1656
+ }
1657
+ /** Rig fields Reset: the dial off, no pending op, and the inspected build back under the same world. */
1658
+ async function resetRigFieldsBuild():Promise<void> {
1659
+ rigFieldsPanel?.setDial(0);rigFieldsPanel?.clear();rigFieldsPanel?.applied(null);rigFieldsPanel?.readout([]);
1660
+ if(appliedOnStage){rigFieldsPanel?.busy(true);try{await resetRebuild(stageWorld());}finally{rigFieldsPanel?.busy(false);}}
1661
+ rigFieldsPanel?.status('Reset: the inspected build is on the stage; no pending ops; the dial is at 0.',false);
1662
+ }
1663
+ /** Physics Reset: the inspected build back under the same world, and no pending op. */
1664
+ async function resetPhysicsBuild():Promise<void> {
1665
+ physicsPanel?.clear();physicsPanel?.applied(null);physicsPanel?.readout([]);
1666
+ if(appliedOnStage){physicsPanel?.busy(true);try{await resetRebuild(stageWorld());}finally{physicsPanel?.busy(false);}}
1667
+ physicsPanel?.status('Reset: the inspected build is on the stage; no pending ops.',false);
1668
+ }
1669
+ /**
1670
+ * Idle tracks Apply (#104): the pending ops become one config patch (`rig --idle-keys <the inspected rig's keys>`, no
1671
+ * flag when they could not be read, then check) against the handoff config. The preview was cleared when Apply was pressed; on a built job the scratch build
1672
+ * reloads under the same world and its built idle plays, and the readout shows CHAIN_LAG, TIP_OVER_ROOT, BREATH_VISIBLE
1673
+ * and the loop line before and after. A refusal shows the producer's line verbatim and leaves the stage as it was.
1674
+ */
1675
+ async function applyIdle(ops:PatchOp[],keys:'ctl'|'direct'|null):Promise<PatchJobView|RebuildRefusal|null> {
1676
+ const status=rebuildStatus??await loadRebuildStatus();
1677
+ const patch=status&&ops.length?makePatch(status,ops,{cmd:'rig',args:keys?['--idle-keys',keys]:[]}):null;
1678
+ if(!patch){const reason=!ops.length?'IDLE_NOTHING_PENDING: no pending ops':status?.reason??'REBUILD_STATUS_UNAVAILABLE';idlePanel?.status(reason,true);return null;}
1679
+ const world=stageWorld(),before=stageCheck;
1680
+ idlePanel?.busy(true);idlePanel?.status(`Applying ${ops.length} op(s) with ${patch.producer.name} ${patch.producer.version}: ${patchRoute(patch.ops,patch.producer.version)} --config${keys?` --idle-keys ${keys}`:''}, compile, check…`,false);
1681
+ try{
1682
+ const result=await applyPatch(patch,world);
1683
+ const outcome=patchOutcomeLine(result);idlePanel?.status(outcome.text,outcome.error);
1684
+ if('id' in result&&result.status==='built'){
1685
+ idlePanel?.applied(result.request.patch.ops);
1686
+ const after={lines:[...result.lines],check:result.check};
1687
+ const show=()=>idlePanel?.readout(readoutRows(before,before?null:inspectedCheckView(),after,IDLE_READOUT_BARS));
1688
+ show();if(!before)void followInspectedCheck(show);
1689
+ }
1690
+ return result;
1691
+ }finally{idlePanel?.busy(false);}
1692
+ }
1693
+ /** Idle Reset: the preview cleared, no pending op, and the inspected build back under the same world. */
1694
+ async function resetIdleBuild():Promise<void> {
1695
+ idlePanel?.clear();idlePanel?.applied(null);idlePanel?.readout([]);
1696
+ if(appliedOnStage){idlePanel?.busy(true);try{await resetRebuild(stageWorld());}finally{idlePanel?.busy(false);}}
1697
+ idlePanel?.status('Reset: the inspected build is on the stage; no pending ops; the preview is cleared.',false);
1698
+ }
1699
+ /**
1700
+ * Inspection only (#104 bar 1): pause, reopen the track at 0 and step it `n` times by `dt`, reading `bone`'s locals on
1701
+ * every pose after the timelines and the preview's adjust (when one is set) — what the pose holds before constraints.
1702
+ */
1703
+ function sampleLocals(bone:string,dt:number,n:number):{t:number;rotation:number;x:number;y:number;scaleX:number;scaleY:number;shearX:number;shearY:number}[]|null {
1704
+ if(!player?.current||!(dt>0)||!Number.isInteger(n)||n<0)return null;
1705
+ const p=player,inner=p.adjust,rows=new Map<number,{t:number;rotation:number;x:number;y:number;scaleX:number;scaleY:number;shearX:number;shearY:number}>();
1706
+ let recording=false;
1707
+ const read:BoneAdjust=bones=>{inner?.(bones);if(!recording)return;const l=bones.bone(bone);rows.set(p.time,{t:p.time,rotation:l.rotation,x:l.x,y:l.y,scaleX:l.scaleX,scaleY:l.scaleY,shearX:l.shearX,shearY:l.shearY});};
1708
+ pause();p.adjust=read;
1709
+ try{p.seek(0);recording=true;p.repose();p.resume();for(let k=1;k<=n;k++)p.frame(dt);}
1710
+ finally{recording=false;p.pause();p.adjust=inner;drawRig(()=>p.draw());time=p.time;renderTransport();}
1711
+ return [...rows.values()].sort((a,b)=>a.t-b.t);
1712
+ }
1713
+ async function resetRebuild(world:StageWorld|null=null):Promise<void> {
1714
+ if(rebuildBusy||!appliedOnStage||!inspectedModel)return;
1715
+ rebuildBusy=true;rebuildState();
1716
+ try{await swapRig(inspectedModel.url,inspectedModel.prefix,inspectedModel.sha256,world);appliedOnStage=null;stageCheck=null;chestEditor?.notify('Reset: the inspected build is on the stage. The draft keeps the last applied state.',false);message('Reset to the inspected build.');}
1717
+ catch(error){chestEditor?.notify(`REBUILD_RESET_FAILED: ${errorText(error)}`,true);}
1718
+ finally{rebuildBusy=false;rebuildState();}
1719
+ }
1720
+ /**
1721
+ * Replace the drawn rig in place: the new model loads first (a failure leaves the stage as it was), then the player is
1722
+ * swapped and the same animation, time, set, camera and environment are restored; playback resumes when it was running.
1723
+ */
1724
+ async function swapRig(modelUrl:string,prefix:string,sha256:string,world:StageWorld|null=null):Promise<void> {
1725
+ const keepAnimation=animation,keepTime=player?.time??time,wasPlaying=playing,keepSet=select('part-set').disabled?'':select('part-set').value;
1726
+ const next=await loadModel({modelUrl,setsUrl:null,resolvePage:(name,url)=>{const target=new URL(name,url);if(target.origin!==location.origin||!target.pathname.startsWith(prefix))throw new Error(`Model page escapes its build: ${name}`);return target.href;}});
1727
+ pause();player?.dispose();assets?.dispose();assets=next;player=new RigPlayer(el('rig-root'),assets);stageModelHash=sha256;
1728
+ // A scratch build may hold another set of animations (a retarget adds one, #114): the picker lists what this build has.
1729
+ {const animations=select('animation-select');animations.replaceChildren(...assets.animations.map(item=>option(item.name,data.manifest.animations.find(a=>a.id===item.name)?.label??item.name)));animations.disabled=assets.animations.length===0;}
1730
+ player.on('event',event=>{text('event-log',`Runtime event: ${event.name} · ${event.time.toFixed(2)} s`);});
1731
+ try{const catalog=await loadPartModel(modelUrl,sha256,data.feedbackCatalog.slots??[]);partModel=catalog.model;weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){parts?.refuseCatalog('single',error);}
1732
+ if(keepSet){try{player.setSet(keepSet);}catch{select('part-set').value='';player.setSet(null);}}
1733
+ // The same world before the first pose: a physics Apply resumes the wind (clock and pulse), the pending jolts, gravity and
1734
+ // whether the controls are engaged as they stood when Apply was pressed; then the seek re-poses under that world.
1735
+ 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();}
1736
+ if(environmentApplied())player.environment(environmentFor(wind.read(),gravityValue()));
1737
+ if(keepAnimation&&assets.animations.some(clip=>clip.name===keepAnimation)){chooseAnimation(keepAnimation);seek(keepTime);}else drawRig(()=>player!.draw());
1738
+ if(environmentApplied())applyEnvironment();
1739
+ updateChestMapping();fitCamera();
1740
+ // The preview (#104) drives the new player once its bone setups are read (an idle Apply cleared it first).
1741
+ stageDocumentSet=true;void loadModelDocument(modelUrl,sha256).then(loaded=>{if(loaded.ok&&stageModelHash===sha256)idlePanel?.model(boneSetup(loaded.document));});
1742
+ if(wasPlaying)void playPreview();
1743
+ }
1744
+ /**
1745
+ * Mesh Apply (#106): the pending `meshes.<part>` entry becomes one config patch — `replace /meshes/<part>` and nothing
1746
+ * else (bar 3) — run by `remesh --part <part>` through the patch runner (compile, check). On a build the revision
1747
+ * reloads with the mesh edges on, and the panel shows remesh.json's counts, the revision model's and the overlay's
1748
+ * triangle counts (bar 1) and TEXTURE_STRETCH before and after. A refusal leaves the stage and the pending entry as
1749
+ * they were and shows the producer's line verbatim (bar 2).
1750
+ */
1751
+ let lastMeshPatch:ConfigPatch|null=null;
1752
+ async function applyMesh(part:string,entry:MeshEntry):Promise<PatchJobView|RebuildRefusal|null> {
1753
+ const status=rebuildStatus??await loadRebuildStatus();
1754
+ const problems=meshPanel?.precheck()??[];
1755
+ if(problems.length){meshPanel?.status(`${problems[0]} (review pre-check; nothing sent)`,true);return null;}
1756
+ const patch=status?makePatch(status,meshPatchOps(part,entry),{cmd:'remesh',args:['--part',part]}):null;
1757
+ if(!patch){meshPanel?.status(status?.reason??'REBUILD_STATUS_UNAVAILABLE',true);return null;}
1758
+ try{assertMeshPatch(patch,part);}catch(error){meshPanel?.status(errorText(error),true);return null;}
1759
+ lastMeshPatch=patch;
1760
+ const world=stageWorld(),before=stageCheck;
1761
+ meshPanel?.busy(true);meshPanel?.status(`Applying meshes.${part} with ${patch.producer.name} ${patch.producer.version}: remesh --mesh, compile, check…`,false);
1762
+ try{
1763
+ const result=await applyPatch(patch,world);
1764
+ const outcome=meshApplyOutcome(result as Parameters<typeof meshApplyOutcome>[0]);meshPanel?.status(outcome.text,outcome.error);
1765
+ if('id' in result&&result.status==='built'){
1766
+ // The new triangulation is drawn: the mesh edges go on and the overlay captures the reloaded pose.
1767
+ if(!layers.mesh){layers.mesh=true;input('layer-mesh').checked=true;}
1768
+ if(player)drawRig(()=>player!.draw());redrawOverlay(true);
1769
+ const counts=result.remesh??null,overlay=lastOverlay?.meshTriangles[part]??null,agree=triangleAgreement(counts,counts?.modelTriangles??null,overlay);
1770
+ meshPanel?.counts([
1771
+ ...(counts?[{label:'BEFORE',text:`${counts.part} ${countsText(counts.before)} (remesh.json)`},{label:'AFTER',text:`${counts.part} ${countsText(counts.after)} (remesh.json)`}]:[]),
1772
+ {label:agree.ok?'AGREE':'DIFFER',text:agree.line,error:!agree.ok},
1773
+ ]);
1774
+ const after={lines:[...result.lines],check:result.check};
1775
+ const show=()=>meshPanel?.readout(readoutRows(before,before?null:inspectedCheckView(),after,['TEXTURE_STRETCH']));
1776
+ show();if(!before)void followInspectedCheck(show);
1777
+ }
1778
+ return result;
1779
+ }finally{meshPanel?.busy(false);}
1780
+ }
1781
+ /** Mesh Reset: the inspected build back on the stage (same world); the panel already holds the config's entry again. */
1782
+ async function resetMeshBuild():Promise<void> {
1783
+ meshPanel?.counts([]);meshPanel?.readout([]);
1784
+ if(appliedOnStage){meshPanel?.busy(true);try{await resetRebuild(stageWorld());}finally{meshPanel?.busy(false);}}
1785
+ meshPanel?.status('Reset: the inspected build is on the stage; the pending entry is the config\'s.',false);
1786
+ }
1787
+ /** The folds reading (#108) of the drawn animation, on the inspected build and on the latest scratch build: one line per build. */
1788
+ async function meshFolds():Promise<string[]> {
1789
+ meshPanel?.status(`Running the folds reading of ${animation||'(no animation)'}…`,false);
1790
+ const job=await runReadings({readings:[{kind:'folds'}]});
1791
+ if(!job||!('runs' in job)){const text=job&&'error' in job&&job.error?job.error:'READINGS_UNAVAILABLE: no reading ran';meshPanel?.status(text,true);return [text];}
1792
+ const lines=job.runs.map(run=>{const r=run.results[0];return r?worstLine(run.build,r):`${run.build} · folds: not run`;});
1793
+ if(job.runs.length<2)lines.push('scratch · folds: no scratch build yet (Apply builds one)');
1794
+ meshPanel?.status(lines.join(' | '),job.runs.some(run=>run.results.some(r=>r.status!=='read')));
1795
+ return lines;
1796
+ }
1008
1797
  void start();