@rig-c/review 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +206 -12
  2. package/docs/DESIGN.md +26 -4
  3. package/docs/REVIEW_PACKAGE.md +113 -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 +10 -4
  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 +19 -6
  16. package/src/feedback-export.ts +17 -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 +45 -15
  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 +9 -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 +128 -13
  42. package/web/chest-regions-tool.ts +2 -1
  43. package/web/cuts-panel.ts +266 -0
  44. package/web/idle-panel.ts +280 -0
  45. package/web/index.html +1 -1
  46. package/web/inspector-blocks.ts +77 -0
  47. package/web/main.ts +701 -49
  48. package/web/mesh-editor.ts +73 -38
  49. package/web/mesh-panel.ts +273 -0
  50. package/web/physics-panel.ts +151 -0
  51. package/web/readings-panel.ts +105 -0
  52. package/web/rebuild-client.ts +216 -0
  53. package/web/requirements-panel.ts +241 -0
  54. package/web/retarget-panel.ts +203 -0
  55. package/web/rig-fields-panel.ts +269 -0
  56. package/web/rig-overlays.ts +108 -14
  57. package/web/setup-pose.ts +30 -0
  58. package/web/styles.css +28 -5
  59. package/web/weights-view.ts +159 -0
package/web/main.ts CHANGED
@@ -1,16 +1,28 @@
1
- import {PartExplorer,loadPartModel} from './part-selection.ts';
1
+ import {PartExplorer,loadPartModel,partBounds} from './part-selection.ts';
2
2
  import {PartThumbnails,pageRegionTable} from './part-thumbnails.ts';
3
- import { loadModel, RigPlayer, type ModelAssets, type PoseBounds } from '@rig-c/play';
3
+ import { loadModel, RigPlayer, type BoneAdjust, type ModelAssets, type PoseBounds } from '@rig-c/play';
4
4
  import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,boundsFitSignature,dragSignature,zoomSignature,type CameraFrame,type CameraSignature} from './camera-view.ts';
5
5
  import {rendererStatus} from './renderer-status.ts';
6
6
  import {bindAppearance,bindPan,bindStageLayout,syncStageLayout,bindInspectorTabs,idleAvailable,initialIdleAnimation,automaticIdlePreview,bindLayerShortcuts,describeLayerToggles,type ViewPoint} from './view-controls.ts';
7
- import {RigOverlays,type OverlayCircle,type OverlayRegion} from './rig-overlays.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';
8
11
  import {chestOverlayCircles,chestOverlayPolygons,chestRegionRows,chestRowId,evaluateChestForm} from './chest-regions-tool.ts';
9
- import {bindChestEditor,bustLabel,bustSides,rowCircle,type ChestEditor} from './chest-editor.ts';
12
+ import {bindChestEditor,canvasToCrop,bustLabel,bustSides,LANDMARKS,landmarkInputId,rowCircle,type ChestEditor,type LandmarkKey} from './chest-editor.ts';
10
13
  import {bindMeshEditor,meshEditSource,sha256Hex,type MeshEditor} from './mesh-editor.ts';
11
- import {meshEditBinding,type MeshEditDocument} from '../src/mesh-edit.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';
12
23
  import {chestRegionsBinding,cropWorldMapping,type CropWorldMapping} from '../src/chest-regions.ts';
13
24
  import {bindWorkspaceUI,selectedInspector,type WorkspaceUI} from './workspace-ui.ts';
25
+ import {bindInspectorBlocks,activeBlocksUI,restoreActiveBlocksUI,type InspectorBlocks} from './inspector-blocks.ts';
14
26
  import {connectControl} from './control.ts';
15
27
  import {pageInstanceAccess,readOnlyReason} from './instance-access.ts';
16
28
  import {controlComparison,type ControlCommand,type ControlInspection,type ControlView} from '../src/control.ts';
@@ -19,14 +31,26 @@ import {connectInspectionPersistence,serializableView,numericCamera,restoredZoom
19
31
  import {initialPlayerAnimation} from '../src/animation-selection.ts';
20
32
  import {initialReviewSelection} from '../src/open-selection.ts';
21
33
  import type {ReferenceBinding} from '../src/reference.ts';
22
- import type { Checkpoint, Reference, ReviewedSource, ReviewManifest } from '../src/manifest.ts';
34
+ import type { Checkpoint, Json, Reference, ReviewedSource, ReviewManifest } from '../src/manifest.ts';
23
35
  import type { Feedback, ReviewSession } from '../src/session.ts';
24
36
  import type { ReportEvent, ReportSeeds } from '../src/report.ts';
25
37
  import { clockForAnimation, frameAtTime, lastFrame as lastProducerFrame, timeAtFrame } from '../src/frame-clock.ts';
26
38
  import { feedbackLastFrame, feedbackTargetNames, validateFeedbackSelection, type FeedbackCatalog } from '../src/feedback-validation.ts';
27
39
  import { eventCoordinates, eventSeekTime, eventSelection, eventTimingLabel, reportCounts, reportDraftForMarker, targetLabel } from './report-view.ts';
28
40
  import type { StripInterval, StripRequest } from '../src/strip.ts';
29
- import {createWindModel,environmentFor,windReadout,RUNTIME_START_READOUT,joltPacket,dueJolts,dragMoved,loadPhysicsConstraints,PHYSICS_FIELDS,CONSTRAINT_MIX_READONLY,environmentContext,environmentJson,type JoltStep,type WindSnapshot} from '../src/environment.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';
30
54
 
31
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 } }
32
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 }[] }
@@ -76,10 +100,18 @@ let parts:PartExplorer|null=null;
76
100
  let overlays:RigOverlays|null=null;
77
101
  let chestEditor:ChestEditor|null=null;
78
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;
79
108
  /** The hash-verified compiled model document the parts catalog was read from (mesh edit reads attachments from it). */
80
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;
81
112
  let appearance:ReturnType<typeof bindAppearance>|null=null;
82
113
  let workspace:WorkspaceUI|null=null;
114
+ let inspectorBlocks:InspectorBlocks|null=null;
83
115
  let idleHeld=false;
84
116
  let suspendedPreview=false;
85
117
  let controlDispose:(()=>void)|null=null;
@@ -104,6 +136,20 @@ let selectedReportTarget: number | undefined;
104
136
  // Animate's Environment block: the wind clock advances only with the steps the player takes.
105
137
  const wind=createWindModel();
106
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}
107
153
 
108
154
  function message(value: string, error = false): void {
109
155
  text('message', value);
@@ -350,6 +396,16 @@ function renderTransport(): void {
350
396
  button('play').disabled = (!player && !video && !sequence) || duration <= 0;
351
397
  renderFrameTransport();
352
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);
353
409
  }
354
410
 
355
411
  function feedbackAnimation(): string | null { return player && player.current !== animation ? null : animation || null; }
@@ -420,7 +476,7 @@ function chooseAnimation(name: string): void {
420
476
 
421
477
  function seek(value: number): void {
422
478
  time = duration > 0 ? Math.min(Math.max(0, value), duration) : Math.max(0, value);
423
- 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); } }
424
480
  if (video && video.readyState >= 1) holdVideoReference(video, referenceGeneration);
425
481
  renderSequenceFrame(); renderTransport();
426
482
  }
@@ -523,17 +579,31 @@ function renderReportSelection(): void {
523
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));
524
580
  }
525
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
+ }
526
594
  function seekReportEvent(): void {
527
595
  const event = selectedReportEvent; if (!event) return;
528
596
  const exact = eventSeekTime(event, selectedReportTime); if (exact === null) return;
529
- pause();
530
- const reportAnimation = data.report?.animation;
531
- 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; }
532
- if (reportAnimation && assets?.animations.some(item => item.name === reportAnimation) && animation !== reportAnimation) chooseAnimation(reportAnimation);
533
- 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; }
534
- seek(exact);
597
+ if (!seekMarker(data.report?.animation, exact, 'Report')) return;
535
598
  message((player || video || sequence) ? `Selected report time ${exact} s.` : `Report time ${exact} s · no playback reference; time display only.`);
536
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
+ }
537
607
 
538
608
  function tick(now: number): void {
539
609
  if (playing) {
@@ -553,12 +623,13 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
553
623
  text('player-state', 'Loading compiled rig…');
554
624
  try {
555
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;
556
627
  assets = await loadModel({ modelUrl, setsUrl: null, resolvePage: (name, url) => {
557
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;
558
629
  } });
559
630
  player = new RigPlayer(el('rig-root'), assets);
560
631
  // Parts inspection is an add-on: a catalog refusal is shown in the explorer and never aborts the rig.
561
- try{const catalog=await loadPartModel(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]);partModel=catalog.model;parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}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);}
562
633
  player.on('event', event => { text('event-log', `Runtime event: ${event.name} · ${event.time.toFixed(2)} s`); });
563
634
  const animations = select('animation-select'); animations.replaceChildren();
564
635
  for (const item of assets.animations) animations.append(option(item.name, data.manifest.animations.find(a => a.id === item.name)?.label ?? item.name));
@@ -594,6 +665,8 @@ function applyAxis(): void {
594
665
  function renderProposals(): void {
595
666
  const area = el('proposal-list'); area.replaceChildren();
596
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.` : '');
597
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.` : '';
598
671
  for (const feedback of session.feedback) {
599
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;
@@ -710,7 +783,7 @@ function bind(): void {
710
783
  button('chest-export').onclick = () => { void exportChest(); };
711
784
  for (const event of ['input', 'change']) el('chest-regions').addEventListener(event, renderChest);
712
785
  // A discarded local draft restores the form after its button's own handler; re-read the tool then.
713
- el('draft-recovery').addEventListener('click', () => { renderChest(); restoreEnvironment(); });
786
+ el('draft-recovery').addEventListener('click', () => { renderChest(); restoreEnvironment(); physicsPanel?.restore(); bonesPanel?.restore(); cutsPanel?.restore(); idlePanel?.restore(); rigFieldsPanel?.restore(); });
714
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.');
715
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;};
716
789
  for (const item of document.querySelectorAll<HTMLButtonElement>('[data-proposal]')) item.onclick = () => propose(item.dataset.proposal ?? '');
@@ -764,6 +837,12 @@ function bind(): void {
764
837
  view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},
765
838
  axis: axisInspection ? { id: select('axis-select').value, value: Number(input('axis-value').value) } : null,
766
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,
767
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,
768
847
  } });
769
848
  input('feedback-summary').value = ''; markDirty(); renderProposals(); message('Added structured feedback. Submit the review to save it.');
@@ -783,9 +862,44 @@ function bind(): void {
783
862
  workspace=bindWorkspaceUI({changed:fitCamera,defaultTheme:theme=>appearance?.set(theme),currentTheme:()=>appearance?.get()});
784
863
  cameraSync=workspace.defaults().defaultCameraSync;input('camera-sync').checked=cameraSync;input('idle-repeat').checked=workspace.defaults().defaultIdleRepeat;
785
864
  appearance=bindAppearance(select('settings-theme'),workspace.defaults().theme);
865
+ inspectorBlocks=bindInspectorBlocks({sections:[el('inspector-rig'),el('inspector-playback')],changed:()=>persistence?.save()});
786
866
  overlays=new RigOverlays({canvas:el<HTMLCanvasElement>('rig-overlay'),rigRoot:el('rig-root')});
787
- chestEditor=bindChestEditor({pane:el('rig-pane'),canvas:el('rig-overlay'),project:()=>player?rigProject():null,mapping:()=>chestMapping,changed:renderChest,download:()=>{void exportChest();},message,inspector:()=>selectedInspector()});viewDisposers.push(()=>chestEditor?.dispose());
788
- meshEditor=bindMeshEditor({pane:el('rig-pane'),canvas:el('rig-overlay'),project:()=>player?rigProject():null,mapping:()=>chestMapping,inspector:()=>selectedInspector(),source:meshSource,basis:meshBasis,pause,changed:()=>redrawOverlay(false),download:doc=>{void exportMesh(doc);},message,confirm:text=>window.confirm(text),token:name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim()});viewDisposers.push(()=>meshEditor?.dispose());
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());
789
903
  viewDisposers.push(bindStageLayout(el('stage-layout'),fitCamera));
790
904
  for(const target of ['rig','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
791
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);};
@@ -813,7 +927,7 @@ async function start(): Promise<void> {
813
927
  // Checkpoints and reference fold away when the package supplies neither.
814
928
  (el('package-context') as HTMLDetailsElement).open = data.manifest.checkpoints.length > 0 || data.manifest.references.length > 0;
815
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';
816
- populateChestBones(); bind(); parts=new PartExplorer(el('workspace-left'),el('selected-part'),previous=>{redrawOverlay(true);meshEditor?.selectionChanged();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();
817
931
  const requestedId = new URLSearchParams(location.search).get('checkpoint'),initial=initialReviewSelection(data.manifest,requestedId,data.explicitReference?.id,data.initialAnimation??undefined),first=initial.checkpoint;
818
932
  rigInput=data.manifest.model?{state:'pending'}:{state:'absent'};renderLayout();
819
933
  if(initial.referenceId){select('reference-select').value=initial.referenceId;await showReference(initial.referenceId);}
@@ -827,7 +941,7 @@ async function start(): Promise<void> {
827
941
  else if (readOnly) message(readOnly, true);
828
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.');
829
943
  requestAnimationFrame(tick);
830
- const restored=await setupPersistence();renderChest();restoreEnvironment();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();
831
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)});
832
946
  } catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
833
947
  }
@@ -849,8 +963,10 @@ function rigProject(){const root=el('rig-root');return (x:number,y:number)=>({x:
849
963
  function redrawOverlay(fresh:boolean):void {
850
964
  if(!overlays||!player)return;
851
965
  const project=rigProject();
852
- 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(),circles:chestCircles(),meshEdit:meshEditor?.overlay()}):overlays.redraw({project,settings:layers,regions:chestRegions(),circles:chestCircles(),meshEdit:meshEditor?.overlay()});
853
- text('layer-state',value.warnings.join(' · ')||'Actual last-drawn mesh edges / bone points');if(!fresh&&value.needsCapture&&!playing&&!seekingControl)drawRig(()=>player!.draw());
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());
854
970
  }
855
971
  function canRepeatIdle():boolean{return !!player&&idleAvailable(assets?.animations.map(a=>a.name)??[],animation)&&!axisInspection&&input('idle-repeat').checked&&duration>0;}
856
972
  function renderIdle():void {text('idle-state',canRepeatIdle()?'Actual idle · repeat enabled':animation==='idle'?'Idle repeat paused':'Non-idle inspection · no automatic playback');}
@@ -887,13 +1003,13 @@ async function applyControl(command:ControlCommand,signal:AbortSignal):Promise<v
887
1003
  async function inspectControlTime(target:number,signal:AbortSignal):Promise<void> {
888
1004
  if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
889
1005
  const media=video;
890
- 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;}
891
1007
  if(media.readyState<1||target>media.duration)throw new Error('CONTROL_REFERENCE_UNAVAILABLE');
892
1008
  const previousPosition=media.currentTime;
893
1009
  const commit=(actual:number)=>{
894
1010
  if(signal.aborted||media!==video)throw new Error('CONTROL_SUPERSEDED');
895
1011
  if(!Number.isFinite(actual)||actual<0||actual>duration)throw new Error('CONTROL_TIME_OUTSIDE_DOMAIN');
896
- if(player){player.seek(actual);drawRig(()=>player!.draw());}time=actual;
1012
+ if(player){player.seek(actual);reposePreview();drawRig(()=>player!.draw());}time=actual;
897
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();
898
1014
  };
899
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.`);}
@@ -989,7 +1105,7 @@ async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):P
989
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});});}
990
1106
  async function setupPersistence():Promise<boolean> {
991
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,
992
- 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);}});
993
1109
  return persistence.restored;
994
1110
  }
995
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. */
@@ -1004,6 +1120,12 @@ function chestCircles():OverlayCircle[]{
1004
1120
  const mapping=chestMapping;if(!mapping.available)return [];
1005
1121
  const style=getComputedStyle(document.documentElement),token=(name:string)=>style.getPropertyValue(name).trim();
1006
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
+ }
1007
1129
  if(chestEditor?.active()){
1008
1130
  const rows=bustSides.map(({side,row})=>({side,row,circle:rowCircle(chestField,row)})),[r,l]=[rows[0]!.circle,rows[1]!.circle];
1009
1131
  // As in the example, overlapping cores turn both circles to the overlap colour.
@@ -1012,7 +1134,17 @@ function chestCircles():OverlayCircle[]{
1012
1134
  }
1013
1135
  return chestOverlayCircles(chestEvaluation(),mapping).map(c=>({center:c.center,r:c.r,band:c.band,handles:false,colors:colors(c.row,false)}));
1014
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
+ }
1015
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));}
1016
1148
  const bones=data.feedbackCatalog.bones;
1017
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;}
1018
1150
  }
@@ -1028,7 +1160,7 @@ function renderChest():void {
1028
1160
  el('chest-mapping').classList.toggle('error',!chestMapping.available);
1029
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);
1030
1162
  el('chest-status').classList.toggle('error',!value.ok);
1031
- chestEditor?.sync();meshEditor?.sync();redrawOverlay(false);
1163
+ chestEditor?.sync();meshEditor?.sync();weightsView?.sync();bonesPanel?.sync();cutsPanel?.sync();redrawOverlay(false);
1032
1164
  }
1033
1165
  async function exportChest():Promise<void> {
1034
1166
  const value=chestEvaluation();if(!value.ok){message(`Chest regions not exported: ${value.error}`,true);return;}
@@ -1112,34 +1244,554 @@ function restoreEnvironment():void {
1112
1244
  function feedbackEnvironment(){return player?environmentJson(environmentContext(wind.read(),gravityValue(),environmentApplied(),player.environment())):null;}
1113
1245
  function enableEnvironment(modelUrl:string):void {
1114
1246
  for(const id of environmentInputs)el<HTMLInputElement|HTMLButtonElement>(id).disabled=false;
1115
- syncEnvironmentInputs();text('physics-mix-readonly',CONSTRAINT_MIX_READONLY);text('physics-constraints-state','Reading the compiled model…');
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);
1116
1249
  const rig=data.reviewedSource.rig;
1117
- if(!rig){renderConstraints({available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: no reviewed rig identity'});return;}
1118
- void loadPhysicsConstraints(modelUrl,rig.sha256).then(renderConstraints,error=>renderConstraints({available:false,reason:`PHYSICS_CONSTRAINTS_UNAVAILABLE: ${errorText(error)}`}));
1119
- }
1120
- function renderConstraints(value:Awaited<ReturnType<typeof loadPhysicsConstraints>>):void {
1121
- const area=el('physics-constraints');area.replaceChildren();
1122
- if(!value.available){text('physics-constraints-state',value.reason);el('physics-constraints-state').classList.add('error');return;}
1123
- el('physics-constraints-state').classList.remove('error');
1124
- text('physics-constraints-state',`${value.rows.length} physics constraints as the compiled model declares them · — = not declared (no runtime default is shown)`);
1125
- const table=document.createElement('table'),head=document.createElement('tr');table.className='physics-table';
1126
- for(const label of ['Constraint','Bone',...PHYSICS_FIELDS]){const cell=document.createElement('th');cell.scope='col';cell.textContent=label;head.append(cell);}
1127
- const thead=document.createElement('thead');thead.append(head);const body=document.createElement('tbody');
1128
- for(const row of value.rows){
1129
- const line=document.createElement('tr');
1130
- const name=document.createElement('th');name.scope='row';name.textContent=row.name;const bone=document.createElement('td');bone.textContent=row.bone??'—';line.append(name,bone);
1131
- for(const field of PHYSICS_FIELDS){const cell=document.createElement('td'),v=row.values[field];cell.textContent=v===undefined?'—':String(v);if(v===undefined){cell.className='absent';cell.title='Not declared';}line.append(cell);}
1132
- body.append(line);
1133
- }
1134
- table.append(thead,body);area.append(table);
1135
- }
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();}
1136
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. */
1137
- function meshSource(){const part=parts?.value()??null,frame=parts?.frame('single');return meshEditSource({slot:part?.slot??null,activeAttachment:parts?.observation()?.activeAttachment??null,row:part?frame?.order.find(row=>row.slot===part.slot):undefined,model:partModel,mapping:chestMapping});}
1138
- function meshBasis():string {return `posed vertices of the last drawn frame (animation ${animation?JSON.stringify(animation):'none'} at ${(player?.time??time).toFixed(4)} s; playback pauses on entering mesh edit) mapped to crop px through ${chestMapping.available?chestMapping.basis:chestMapping.reason}`;}
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
+ }
1139
1285
  async function exportMesh(doc:MeshEditDocument):Promise<void> {
1140
- const text=JSON.stringify(doc,null,2)+'\n',digest=await sha256Hex(text);
1286
+ const text=meshEditText(doc),digest=await sha256Hex(text);
1141
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}});
1142
1288
  download('mesh-edit.json',text,'application/json');download('mesh-edit.binding.json',JSON.stringify(binding,null,2)+'\n','application/json');
1143
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.`);
1144
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
+ }
1145
1797
  void start();