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