@rig-c/review 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -12
- package/docs/DESIGN.md +26 -4
- package/docs/REVIEW_PACKAGE.md +113 -0
- package/package.json +1 -1
- package/src/applied-context.ts +285 -0
- package/src/baseline.ts +175 -0
- package/src/bone-anchors.ts +123 -0
- package/src/bones-check.ts +105 -0
- package/src/build-route.ts +109 -0
- package/src/chest-landmarks.ts +122 -0
- package/src/chest-regions.ts +10 -4
- package/src/cli.ts +19 -8
- package/src/config-patch.ts +213 -0
- package/src/cut-edits.ts +184 -0
- package/src/environment.ts +19 -6
- package/src/feedback-export.ts +17 -1
- package/src/front.ts +11 -0
- package/src/handoff.ts +208 -0
- package/src/idle-tracks.ts +401 -0
- package/src/manifest.ts +15 -6
- package/src/mesh-edit.ts +45 -15
- package/src/mesh-entry.ts +351 -0
- package/src/open.ts +12 -5
- package/src/physics-tuning.ts +236 -0
- package/src/producer-files.ts +11 -0
- package/src/producer.ts +158 -0
- package/src/readings-run.ts +129 -0
- package/src/readings.ts +235 -0
- package/src/rebuild.ts +938 -0
- package/src/reference.ts +4 -3
- package/src/requirements-run.ts +160 -0
- package/src/requirements.ts +230 -0
- package/src/retarget.ts +211 -0
- package/src/retune-route.ts +52 -0
- package/src/rig-fields.ts +295 -0
- package/src/server.ts +59 -5
- package/src/session.ts +9 -0
- package/src/setup-mesh.ts +144 -0
- package/src/weights.ts +74 -0
- package/web/bones-panel.ts +230 -0
- package/web/chest-editor.ts +128 -13
- package/web/chest-regions-tool.ts +2 -1
- package/web/cuts-panel.ts +266 -0
- package/web/idle-panel.ts +280 -0
- package/web/index.html +1 -1
- package/web/inspector-blocks.ts +77 -0
- package/web/main.ts +701 -49
- package/web/mesh-editor.ts +73 -38
- package/web/mesh-panel.ts +273 -0
- package/web/physics-panel.ts +151 -0
- package/web/readings-panel.ts +105 -0
- package/web/rebuild-client.ts +216 -0
- package/web/requirements-panel.ts +238 -0
- package/web/retarget-panel.ts +203 -0
- package/web/rig-fields-panel.ts +269 -0
- package/web/rig-overlays.ts +108 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +28 -5
- 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 {
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
853
|
-
|
|
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-
|
|
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){
|
|
1118
|
-
void
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
if(!
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
1138
|
-
function
|
|
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=
|
|
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();
|