@rig-c/review 0.4.0 → 0.6.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 +87 -12
- package/docs/DESIGN.md +18 -8
- package/docs/REVIEW_PACKAGE.md +2 -0
- package/package.json +1 -1
- package/src/chest-regions.ts +306 -0
- package/src/environment.ts +213 -0
- package/src/feedback-export.ts +9 -1
- package/src/mesh-edit.ts +287 -0
- package/src/session.ts +3 -0
- package/web/chest-editor.ts +179 -0
- package/web/chest-regions-tool.ts +56 -0
- package/web/compare.css +1 -0
- package/web/index.html +3 -6
- package/web/main.ts +210 -42
- package/web/mesh-editor.ts +268 -0
- package/web/part-selection.ts +13 -5
- package/web/part-thumbnails.ts +76 -0
- package/web/rig-overlays.ts +74 -3
- package/web/styles.css +104 -23
- package/web/view-controls.ts +11 -5
- package/web/workspace-layout.ts +2 -1
- package/web/workspace-preferences.ts +17 -12
- package/web/workspace-ui.ts +26 -12
package/web/main.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import {PartExplorer,
|
|
1
|
+
import {PartExplorer,loadPartModel} from './part-selection.ts';
|
|
2
|
+
import {PartThumbnails,pageRegionTable} from './part-thumbnails.ts';
|
|
2
3
|
import { loadModel, RigPlayer, 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} from './rig-overlays.ts';
|
|
7
|
-
import {
|
|
7
|
+
import {RigOverlays,type OverlayCircle,type OverlayRegion} from './rig-overlays.ts';
|
|
8
|
+
import {chestOverlayCircles,chestOverlayPolygons,chestRegionRows,chestRowId,evaluateChestForm} from './chest-regions-tool.ts';
|
|
9
|
+
import {bindChestEditor,bustLabel,bustSides,rowCircle,type ChestEditor} from './chest-editor.ts';
|
|
10
|
+
import {bindMeshEditor,meshEditSource,sha256Hex,type MeshEditor} from './mesh-editor.ts';
|
|
11
|
+
import {meshEditBinding,type MeshEditDocument} from '../src/mesh-edit.ts';
|
|
12
|
+
import {chestRegionsBinding,cropWorldMapping,type CropWorldMapping} from '../src/chest-regions.ts';
|
|
13
|
+
import {bindWorkspaceUI,selectedInspector,type WorkspaceUI} from './workspace-ui.ts';
|
|
8
14
|
import {connectControl} from './control.ts';
|
|
9
15
|
import {pageInstanceAccess,readOnlyReason} from './instance-access.ts';
|
|
10
16
|
import {controlComparison,type ControlCommand,type ControlInspection,type ControlView} from '../src/control.ts';
|
|
@@ -20,6 +26,7 @@ import { clockForAnimation, frameAtTime, lastFrame as lastProducerFrame, timeAtF
|
|
|
20
26
|
import { feedbackLastFrame, feedbackTargetNames, validateFeedbackSelection, type FeedbackCatalog } from '../src/feedback-validation.ts';
|
|
21
27
|
import { eventCoordinates, eventSeekTime, eventSelection, eventTimingLabel, reportCounts, reportDraftForMarker, targetLabel } from './report-view.ts';
|
|
22
28
|
import type { StripInterval, StripRequest } from '../src/strip.ts';
|
|
29
|
+
import {createWindModel,environmentFor,windReadout,RUNTIME_START_READOUT,joltPacket,dueJolts,dragMoved,loadPhysicsConstraints,PHYSICS_FIELDS,CONSTRAINT_MIX_READONLY,environmentContext,environmentJson,type JoltStep,type WindSnapshot} from '../src/environment.ts';
|
|
23
30
|
|
|
24
31
|
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 } }
|
|
25
32
|
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 }[] }
|
|
@@ -67,6 +74,10 @@ let displayedCenter:ViewPoint|null=null;
|
|
|
67
74
|
let playbackIntent=0,desiredPlay=false,seekingControl=false;
|
|
68
75
|
let parts:PartExplorer|null=null;
|
|
69
76
|
let overlays:RigOverlays|null=null;
|
|
77
|
+
let chestEditor:ChestEditor|null=null;
|
|
78
|
+
let meshEditor:MeshEditor|null=null;
|
|
79
|
+
/** The hash-verified compiled model document the parts catalog was read from (mesh edit reads attachments from it). */
|
|
80
|
+
let partModel:unknown=null;
|
|
70
81
|
let appearance:ReturnType<typeof bindAppearance>|null=null;
|
|
71
82
|
let workspace:WorkspaceUI|null=null;
|
|
72
83
|
let idleHeld=false;
|
|
@@ -74,6 +85,7 @@ let suspendedPreview=false;
|
|
|
74
85
|
let controlDispose:(()=>void)|null=null;
|
|
75
86
|
const viewDisposers:(()=>void)[]=[];
|
|
76
87
|
const layers={images:true,mesh:false,bones:false,meshOpacity:.65};
|
|
88
|
+
let chestMapping:CropWorldMapping={available:false,reason:'CHEST_REGION_MAPPING_UNAVAILABLE: no compiled model drawn'};
|
|
77
89
|
let referenceSize: { width: number; height: number } | null = null;
|
|
78
90
|
let video: HTMLVideoElement | null = null;
|
|
79
91
|
let controlledVideoSample:{media:HTMLVideoElement;position:number;time:number}|null=null;
|
|
@@ -89,11 +101,16 @@ let reportPage = 0;
|
|
|
89
101
|
let selectedReportEvent: ReportEvent | null = null;
|
|
90
102
|
let selectedReportTime: number | undefined;
|
|
91
103
|
let selectedReportTarget: number | undefined;
|
|
104
|
+
// Animate's Environment block: the wind clock advances only with the steps the player takes.
|
|
105
|
+
const wind=createWindModel();
|
|
106
|
+
let joltQueue:JoltStep[]=[],environmentNote='';
|
|
92
107
|
|
|
93
108
|
function message(value: string, error = false): void {
|
|
94
109
|
text('message', value);
|
|
95
110
|
el('message').title = value; // the footer truncates long messages
|
|
96
111
|
el('message').classList.toggle('error', error);
|
|
112
|
+
// The modal makes the footer inert; the review dialog repeats the latest message for its reader.
|
|
113
|
+
text('review-message', value); el('review-message').classList.toggle('error', error);
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
|
@@ -183,6 +200,7 @@ function layoutEligibility(){return singleEligibility({rig:rigInput,reference:re
|
|
|
183
200
|
function renderLayout():void {
|
|
184
201
|
const state=layoutEligibility();effectiveView=state.effective;
|
|
185
202
|
for(const item of document.querySelectorAll<HTMLButtonElement>('[data-view]')){const layout=item.dataset.view as SingleLayout,reason=state.reasons[layout]??'';item.setAttribute('aria-pressed',String(layout===state.effective));item.disabled=!!reason;item.title=reason;if(reason)item.setAttribute('aria-description',reason);else item.removeAttribute('aria-description');}
|
|
203
|
+
const menuLabels={compare:'Side by side',reference:'Reference',rig:'Rig'};text('view-menu-label',menuLabels[state.effective??state.requested]);
|
|
186
204
|
el('reference-pane').hidden=state.effective!=='compare'&&state.effective!=='reference';el('rig-pane').hidden=state.effective!=='compare'&&state.effective!=='rig';
|
|
187
205
|
const shown={compare:'Side by side',reference:'Reference only',rig:'Rig only'};
|
|
188
206
|
text('layout-state',state.effective===null?`No input rendered · ${state.reasons.compare??''}`:state.effective===state.requested?shown[state.effective]:`${shown[state.effective]} · ${shown[state.requested]} unavailable: ${state.reasons[state.requested]??''}`);
|
|
@@ -521,7 +539,7 @@ function tick(now: number): void {
|
|
|
521
539
|
if (playing) {
|
|
522
540
|
const dt = lastFrame ? Math.min((now - lastFrame) / 1000, .1) : 0;
|
|
523
541
|
const previous = time; time = Math.min(duration, time + dt);
|
|
524
|
-
if (player && player.current) { try { drawRig(()=>player!.frame(dt)); } catch (error) { pause(); message(errorText(error), true); } }
|
|
542
|
+
if (player && player.current) { try { stepEnvironment(dt); drawRig(()=>player!.frame(dt)); } catch (error) { pause(); message(errorText(error), true); } }
|
|
525
543
|
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;
|
|
526
544
|
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);
|
|
527
545
|
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;
|
|
@@ -540,7 +558,7 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
|
540
558
|
} });
|
|
541
559
|
player = new RigPlayer(el('rig-root'), assets);
|
|
542
560
|
// Parts inspection is an add-on: a catalog refusal is shown in the explorer and never aborts the rig.
|
|
543
|
-
try{
|
|
561
|
+
try{const catalog=await loadPartModel(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]);partModel=catalog.model;parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){parts?.refuseCatalog('single',error);}
|
|
544
562
|
player.on('event', event => { text('event-log', `Runtime event: ${event.name} · ${event.time.toFixed(2)} s`); });
|
|
545
563
|
const animations = select('animation-select'); animations.replaceChildren();
|
|
546
564
|
for (const item of assets.animations) animations.append(option(item.name, data.manifest.animations.find(a => a.id === item.name)?.label ?? item.name));
|
|
@@ -554,6 +572,7 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
|
554
572
|
select('axis-select').replaceChildren(); for (const axis of validAxes) select('axis-select').append(option(axis.id, axis.label));
|
|
555
573
|
input('axis-value').disabled = !validAxes.length;
|
|
556
574
|
for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;
|
|
575
|
+
enableEnvironment(modelUrl);
|
|
557
576
|
describeLayerToggles(document,el('layer-state').textContent??'');
|
|
558
577
|
fitCamera();
|
|
559
578
|
} 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); }
|
|
@@ -575,7 +594,7 @@ function applyAxis(): void {
|
|
|
575
594
|
function renderProposals(): void {
|
|
576
595
|
const area = el('proposal-list'); area.replaceChildren();
|
|
577
596
|
const warnings = el('feedback-warnings'); warnings.hidden = session.feedbackWarnings.length === 0;
|
|
578
|
-
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.
|
|
597
|
+
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.` : '';
|
|
579
598
|
for (const feedback of session.feedback) {
|
|
580
599
|
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;
|
|
581
600
|
item.append(title, description);
|
|
@@ -588,7 +607,7 @@ function renderProposals(): void {
|
|
|
588
607
|
}
|
|
589
608
|
|
|
590
609
|
function propose(tool: string): void {
|
|
591
|
-
if (tool === 'angle' && !input('axis-value').disabled) { showInspector('
|
|
610
|
+
if (tool === 'angle' && !input('axis-value').disabled) { showInspector('playback');chooseAxis(); return; }
|
|
592
611
|
const choices: Record<string, { target: Feedback['target']['kind']; hint: string }> = {
|
|
593
612
|
mesh: { target: 'slot', hint: 'One-line feedback on mesh density' }, contour: { target: 'slot', hint: 'One-line feedback on contour curvature' }, chest: { target: 'rig', hint: 'One-line feedback on chest position' },
|
|
594
613
|
angle: { target: 'rig', hint: 'One-line feedback on face or torso angle deformation' }, pose: { target: 'bone', hint: 'One-line feedback on differences from the input pose' }, timing: { target: 'rig', hint: 'One-line feedback on timing in the selected range' }, audio: { target: 'rig', hint: 'One-line feedback on event or audio links' },
|
|
@@ -599,10 +618,10 @@ function propose(tool: string): void {
|
|
|
599
618
|
showInspector('feedback');input('feedback-summary').focus(); message('Specify a frame range and actual bone or slot name to add feedback. Motion keys are not edited.');
|
|
600
619
|
}
|
|
601
620
|
|
|
621
|
+
|
|
602
622
|
function download(name: string, content: string, mime: string): void {
|
|
603
623
|
const url = URL.createObjectURL(new Blob([content], { type: mime })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
604
624
|
}
|
|
605
|
-
|
|
606
625
|
function downloadStrip(name: string, content: Blob): void {
|
|
607
626
|
const url = URL.createObjectURL(content), link = document.createElement('a'); link.href = url; link.download = name; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
608
627
|
}
|
|
@@ -651,26 +670,6 @@ async function exportStrip(): Promise<void> {
|
|
|
651
670
|
finally { stripBusy = false; updateStripControls(); }
|
|
652
671
|
}
|
|
653
672
|
|
|
654
|
-
const markdown = (value: string) => value.replace(/[\\`*_{}\[\]<>]/g, '\\$&');
|
|
655
|
-
function issueFields(feedback: Feedback): { title: string; body: string; labels: string[] } {
|
|
656
|
-
const title = `[${feedback.category}] ${feedback.target.kind}:${feedback.target.name} F${feedback.frameStart}–${feedback.frameEnd} · ${feedback.summary}`;
|
|
657
|
-
const body = [
|
|
658
|
-
`- Package: ${markdown(session.id)} / ${markdown(session.revision)}`,
|
|
659
|
-
`- Source hash: ${session.sourceHash}`, ...(session.referenceBinding?[`- Reference binding: ${JSON.stringify(session.referenceBinding)}`]:[]),
|
|
660
|
-
`- Package revision: ${markdown(session.reviewedSource.packageRevision)}`,
|
|
661
|
-
`- Viewer version: ${markdown(session.reviewedSource.viewerVersion)}`,
|
|
662
|
-
`- Runtime version: ${markdown(session.reviewedSource.runtimeVersion)}`,
|
|
663
|
-
`- Rig: ${session.reviewedSource.rig ? `${markdown(session.reviewedSource.rig.path)} / SHA-256 ${session.reviewedSource.rig.sha256}` : 'not supplied'}`,
|
|
664
|
-
`- Motion: ${session.reviewedSource.motion ? `${markdown(session.reviewedSource.motion.path)} / SHA-256 ${session.reviewedSource.motion.sha256}` : 'not supplied'}`,
|
|
665
|
-
`- Frames: ${feedback.frameStart}–${feedback.frameEnd} (inclusive; producer frame numbers; no inferred FPS)`,
|
|
666
|
-
`- Target: ${feedback.target.kind} / ${markdown(feedback.target.name)}`,
|
|
667
|
-
`- Category: ${feedback.category}`, '', markdown(feedback.summary), '', 'Inspection context:',
|
|
668
|
-
...JSON.stringify(feedback.context ?? null, null, 2).split('\n').map(line => ' ' + line),
|
|
669
|
-
'', 'Validation warnings (must be corrected before submission):',
|
|
670
|
-
...JSON.stringify(session.feedbackWarnings.filter(warning => warning.id === feedback.id), null, 2).split('\n').map(line => ' ' + line),
|
|
671
|
-
].join('\n');
|
|
672
|
-
return { title, body, labels: [feedback.category] };
|
|
673
|
-
}
|
|
674
673
|
|
|
675
674
|
function paneFrame(target:CameraTarget,hidden=false):CameraFrame|null {
|
|
676
675
|
const viewport=el(target+'-viewport'),pane=el(target+'-pane');
|
|
@@ -708,8 +707,12 @@ function zoomViewport(target:CameraTarget,deltaY:number):void {zoomCamera(deltaY
|
|
|
708
707
|
|
|
709
708
|
function bind(): void {
|
|
710
709
|
button('strip-export').onclick = () => { void exportStrip(); };
|
|
710
|
+
button('chest-export').onclick = () => { void exportChest(); };
|
|
711
|
+
for (const event of ['input', 'change']) el('chest-regions').addEventListener(event, renderChest);
|
|
712
|
+
// A discarded local draft restores the form after its button's own handler; re-read the tool then.
|
|
713
|
+
el('draft-recovery').addEventListener('click', () => { renderChest(); restoreEnvironment(); });
|
|
711
714
|
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.');
|
|
712
|
-
for (const item of document.querySelectorAll<HTMLButtonElement>('[data-view]')) item.onclick = () => setView(item.dataset.view as typeof view);
|
|
715
|
+
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;};
|
|
713
716
|
for (const item of document.querySelectorAll<HTMLButtonElement>('[data-proposal]')) item.onclick = () => propose(item.dataset.proposal ?? '');
|
|
714
717
|
select('reference-select').onchange = () => { void showReference(select('reference-select').value); };
|
|
715
718
|
select('person-select').onchange = () => { selectedPerson = select('person-select').value; lastPoseFrame = -1; renderSequenceFrame(); };
|
|
@@ -760,6 +763,7 @@ function bind(): void {
|
|
|
760
763
|
camera: center ? { zoom, x: center.x, y: center.y, coordinateSystem: 'world-y-up' } : { zoom, coordinateSystem: 'reference-fit' },
|
|
761
764
|
view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},
|
|
762
765
|
axis: axisInspection ? { id: select('axis-select').value, value: Number(input('axis-value').value) } : null,
|
|
766
|
+
environment: feedbackEnvironment(),
|
|
763
767
|
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,
|
|
764
768
|
} });
|
|
765
769
|
input('feedback-summary').value = ''; markDirty(); renderProposals(); message('Added structured feedback. Submit the review to save it.');
|
|
@@ -775,20 +779,21 @@ function bind(): void {
|
|
|
775
779
|
message(dirty ? 'Changes were made during submission. Submit again.' : 'Submitted the selected review status, feedback and source provenance.');
|
|
776
780
|
} catch (error) { message(`Submission failed: ${errorText(error)}`, true); } finally { saving = false; updateSavedState(); }
|
|
777
781
|
};
|
|
778
|
-
button('export').onclick = () => { download(`${session.id}-feedback.json`, JSON.stringify({ schema: 'rig-review-feedback/1', id: session.id, revision: session.revision, sourceHash: session.sourceHash, referenceBinding:session.referenceBinding, reviewedSource: session.reviewedSource, status: session.status, checkpoints: session.checkpoints, feedback: session.feedback, feedbackWarnings: session.feedbackWarnings, issues: session.feedback.map(issueFields) }, null, 2) + '\n', 'application/json'); message('Exported source provenance, feedback, validation warnings and issue fields as JSON. Source files remain unchanged.'); };
|
|
779
|
-
button('export-markdown').onclick = () => { const content = [`# ${markdown(session.id)} review feedback`, '', 'Reviewed source:', ...JSON.stringify({ sourceHash: session.sourceHash, referenceBinding:session.referenceBinding, reviewedSource: session.reviewedSource }, null, 2).split('\n').map(line => ' ' + line), '', 'Validation warnings:', ...JSON.stringify(session.feedbackWarnings, null, 2).split('\n').map(line => ' ' + line), '', ...session.feedback.flatMap(item => { const issue = issueFields(item); return [`## ${markdown(issue.title)}`, '', issue.body, '', '---', '']; })].join('\n'); download(`${session.id}-issues.md`, content, 'text/markdown'); message('Exported source provenance, validation warnings and structured issue content as Markdown.'); };
|
|
780
782
|
// Saved browser defaults seed a new inspection; explicit links and source drafts restore over them.
|
|
781
|
-
workspace=bindWorkspaceUI({changed:fitCamera,defaultTheme:theme=>appearance?.set(theme)});
|
|
783
|
+
workspace=bindWorkspaceUI({changed:fitCamera,defaultTheme:theme=>appearance?.set(theme),currentTheme:()=>appearance?.get()});
|
|
782
784
|
cameraSync=workspace.defaults().defaultCameraSync;input('camera-sync').checked=cameraSync;input('idle-repeat').checked=workspace.defaults().defaultIdleRepeat;
|
|
783
|
-
appearance=bindAppearance(select('
|
|
785
|
+
appearance=bindAppearance(select('settings-theme'),workspace.defaults().theme);
|
|
784
786
|
overlays=new RigOverlays({canvas:el<HTMLCanvasElement>('rig-overlay'),rigRoot:el('rig-root')});
|
|
787
|
+
chestEditor=bindChestEditor({pane:el('rig-pane'),canvas:el('rig-overlay'),project:()=>player?rigProject():null,mapping:()=>chestMapping,changed:renderChest,download:()=>{void exportChest();},message,inspector:()=>selectedInspector()});viewDisposers.push(()=>chestEditor?.dispose());
|
|
788
|
+
meshEditor=bindMeshEditor({pane:el('rig-pane'),canvas:el('rig-overlay'),project:()=>player?rigProject():null,mapping:()=>chestMapping,inspector:()=>selectedInspector(),source:meshSource,basis:meshBasis,pause,changed:()=>redrawOverlay(false),download:doc=>{void exportMesh(doc);},message,confirm:text=>window.confirm(text),token:name=>getComputedStyle(document.documentElement).getPropertyValue(name).trim()});viewDisposers.push(()=>meshEditor?.dispose());
|
|
785
789
|
viewDisposers.push(bindStageLayout(el('stage-layout'),fitCamera));
|
|
786
790
|
for(const target of ['rig','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
|
|
787
791
|
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);};
|
|
788
792
|
viewDisposers.push(bindLayerShortcuts(document));describeLayerToggles(document,el('layer-state').textContent??'');
|
|
789
793
|
input('idle-repeat').onchange=()=>{renderIdle();if(canRepeatIdle())void playPreview();};
|
|
794
|
+
bindEnvironment();
|
|
790
795
|
for(const id of ['proposal-panel','checkpoint-detail'])el(id).addEventListener('focusin',pause);
|
|
791
|
-
bindInspector();
|
|
796
|
+
bindInspector();bindReviewDialog();
|
|
792
797
|
document.addEventListener('visibilitychange',()=>{if(document.hidden){const resume=playing;pause();suspendedPreview=resume;}else if(suspendedPreview){suspendedPreview=false;void playPreview();}});
|
|
793
798
|
const resize = new ResizeObserver(fitCamera); resize.observe(el('rig-viewport')); resize.observe(el('reference-viewport'));
|
|
794
799
|
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
|
@@ -808,12 +813,13 @@ async function start(): Promise<void> {
|
|
|
808
813
|
// Checkpoints and reference fold away when the package supplies neither.
|
|
809
814
|
(el('package-context') as HTMLDetailsElement).open = data.manifest.checkpoints.length > 0 || data.manifest.references.length > 0;
|
|
810
815
|
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';
|
|
811
|
-
bind(); parts=new PartExplorer(el('
|
|
816
|
+
populateChestBones(); bind(); parts=new PartExplorer(el('workspace-left'),el('selected-part'),previous=>{redrawOverlay(true);meshEditor?.selectionChanged();const part=parts?.value();if(part){activeCamera='rig';if(view==='reference')setView('rig');showInspector('rig');renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera(part?'rig':availableCamera()));persistence?.save();},part=>{select('feedback-target-kind').value='slot';renderFeedbackTargets(true);input('feedback-target-name').value=part.slot;showInspector('feedback');}); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackTargets(true); renderFeedbackRange(); renderCheckpoints(); renderProposals(); renderProvenance(); updateSavedState(); renderCues(); initReport();
|
|
812
817
|
const requestedId = new URLSearchParams(location.search).get('checkpoint'),initial=initialReviewSelection(data.manifest,requestedId,data.explicitReference?.id,data.initialAnimation??undefined),first=initial.checkpoint;
|
|
813
818
|
rigInput=data.manifest.model?{state:'pending'}:{state:'absent'};renderLayout();
|
|
814
819
|
if(initial.referenceId){select('reference-select').value=initial.referenceId;await showReference(initial.referenceId);}
|
|
815
820
|
await initPlayer(first);
|
|
816
821
|
// initPlayer keeps `player` only after its first draw succeeded; a declared model path alone never counts.
|
|
822
|
+
updateChestMapping();
|
|
817
823
|
rigInput=player?{state:'loaded'}:data.manifest.model?{state:'failed',reason:el('rig-empty').textContent||'compiled player unavailable'}:{state:'absent'};renderLayout();
|
|
818
824
|
if (first) await chooseCheckpoint(first,initial); else renderCheckpoint();
|
|
819
825
|
activeCamera=player?'rig':'reference';if(cameraSync)alignCameras();renderTransport();fitCamera();
|
|
@@ -821,17 +827,29 @@ async function start(): Promise<void> {
|
|
|
821
827
|
else if (readOnly) message(readOnly, true);
|
|
822
828
|
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.');
|
|
823
829
|
requestAnimationFrame(tick);
|
|
824
|
-
const restored=await setupPersistence();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
|
|
830
|
+
const restored=await setupPersistence();renderChest();restoreEnvironment();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
|
|
825
831
|
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)});
|
|
826
832
|
} catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
|
|
827
833
|
}
|
|
828
|
-
|
|
829
|
-
function
|
|
834
|
+
/** `feedback` is the review dialog, not an Inspector section: a user action opens it; a restored older bookmark that saved it selects nothing. */
|
|
835
|
+
function showInspector(name:string,reveal=true):void {if(name==='feedback'){if(reveal)openReview();return;}if(reveal)workspace?.revealInspector();document.querySelector<HTMLButtonElement>(`[data-inspector="${name}"]`)?.click();}
|
|
836
|
+
function bindInspector():void {bindInspectorTabs(()=>{workspace?.afterTab();fitCamera();},()=>workspace?.beforeTab());}
|
|
837
|
+
/** Opening the review pauses playback, as selecting Feedback did. Its inputs stay in the document whether or not it is open, so the draft keeps them. */
|
|
838
|
+
function openReview():void {const dialog=el<HTMLDialogElement>('review-dialog');for(const drawer of document.querySelectorAll<HTMLDialogElement>('dialog.workspace-drawer[open]'))drawer.close();pause();if(!dialog.open)dialog.showModal();}
|
|
839
|
+
function bindReviewDialog():void {
|
|
840
|
+
const dialog=el<HTMLDialogElement>('review-dialog');
|
|
841
|
+
button('write-review').onclick=openReview;button('review-dialog-close').onclick=()=>dialog.close();
|
|
842
|
+
// Escape closes the native modal; every close returns focus to the button that opens it.
|
|
843
|
+
dialog.addEventListener('close',()=>{button('write-review').setAttribute('aria-expanded','false');button('write-review').focus();});
|
|
844
|
+
dialog.addEventListener('toggle',()=>{if(dialog.open)button('write-review').setAttribute('aria-expanded','true');});
|
|
845
|
+
}
|
|
830
846
|
|
|
847
|
+
/** Rig world → rig-pane pixels for the current camera. */
|
|
848
|
+
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});}
|
|
831
849
|
function redrawOverlay(fresh:boolean):void {
|
|
832
850
|
if(!overlays||!player)return;
|
|
833
|
-
const
|
|
834
|
-
const value=fresh?overlays.refresh({player,frame:parts?.frame('single'),selectedSlot:parts?.value()?.slot,bones:(data.feedbackCatalog.bones??[]).map(name=>({name})),project,settings:layers}):overlays.redraw({project,settings:layers});
|
|
851
|
+
const project=rigProject();
|
|
852
|
+
const value=fresh?overlays.refresh({player,frame:parts?.frame('single'),selectedSlot:parts?.value()?.slot,bones:(data.feedbackCatalog.bones??[]).map(name=>({name})),project,settings:layers,regions:chestRegions(),circles:chestCircles(),meshEdit:meshEditor?.overlay()}):overlays.redraw({project,settings:layers,regions:chestRegions(),circles:chestCircles(),meshEdit:meshEditor?.overlay()});
|
|
835
853
|
text('layer-state',value.warnings.join(' · ')||'Actual last-drawn mesh edges / bone points');if(!fresh&&value.needsCapture&&!playing&&!seekingControl)drawRig(()=>player!.draw());
|
|
836
854
|
}
|
|
837
855
|
function canRepeatIdle():boolean{return !!player&&idleAvailable(assets?.animations.map(a=>a.name)??[],animation)&&!axisInspection&&input('idle-repeat').checked&&duration>0;}
|
|
@@ -908,7 +926,7 @@ function numericView():ControlView {
|
|
|
908
926
|
}
|
|
909
927
|
function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
|
|
910
928
|
function inspectionBinding():InspectionBinding{return {mode:'single',sourceHash:data.sourceHash,rigs:[data.reviewedSource.rig?.sha256??null]};}
|
|
911
|
-
function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:animation||null,time:player?.time??time,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{part:parts?.value()??null,view,checkpoint:checkpoint?.id??null,reference:select('reference-select').value||null,person:selectedPerson||null,inspector:
|
|
929
|
+
function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:animation||null,time:player?.time??time,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{part:parts?.value()??null,view,checkpoint:checkpoint?.id??null,reference:select('reference-select').value||null,person:selectedPerson||null,inspector:selectedInspector()??'view',partSet:select('part-set').disabled?null:select('part-set').value,axis:axisInspection?{id:select('axis-select').value,value:Number(input('axis-value').value)}:null}};}
|
|
912
930
|
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
913
931
|
await restoreTransaction(()=>prepareInspection(bookmark,signal),()=>({bookmark:inspectionBookmark(),inputs:captureInputs()}),()=>applyInspection(bookmark,signal),async previous=>{await applyInspection(previous.bookmark,new AbortController().signal);restoreInputs(previous.inputs);renderFeedbackRange();});
|
|
914
932
|
}
|
|
@@ -971,7 +989,157 @@ async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):P
|
|
|
971
989
|
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});});}
|
|
972
990
|
async function setupPersistence():Promise<boolean> {
|
|
973
991
|
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,
|
|
974
|
-
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;}});
|
|
992
|
+
ui:()=>({inspectorScroll:workspace?.scroll()??'{}',reportCategory:select('report-category').value,reportPage,reportEvent:selectedReportEvent?.id??null,reportTime:selectedReportTime??null,reportTarget:selectedReportTarget??null}),restoreUI:value=>{if(typeof value.reportCategory==='string'&&[...select('report-category').options].some(o=>o.value===value.reportCategory))select('report-category').value=value.reportCategory;if(typeof value.reportPage==='number'&&Number.isSafeInteger(value.reportPage)&&value.reportPage>=0)reportPage=value.reportPage;selectedReportEvent=typeof value.reportEvent==='string'?data.report?.categories.flatMap(c=>c.events).find(e=>e.id===value.reportEvent)??null:null;selectedReportTime=typeof value.reportTime==='number'?value.reportTime:undefined;selectedReportTarget=typeof value.reportTarget==='number'?value.reportTarget:undefined;renderReportEvents();renderReportSelection();workspace?.restoreScroll(value.inspectorScroll);},afterInputs:()=>{const target=input('feedback-target-name').value;renderFeedbackTargets(true);if(select('feedback-target-kind').value!=='rig')input('feedback-target-name').value=target;renderFeedbackRange();text('audio-volume-label',`${Math.round(Number(input('audio-volume').value)*100)}%`);},onConflict:value=>{draftConflict=value;updateSavedState();},onStatus:value=>{text('draft-state',value);el('draft-state').title=value;text('review-draft-state',value);}});
|
|
975
993
|
return persistence.restored;
|
|
976
994
|
}
|
|
995
|
+
/** Setup's Chest regions tool: designation values, an overlay and a two-file client download. Nothing reaches the session's feedback or the package. */
|
|
996
|
+
function chestField(id:string){const node=document.getElementById(id) as HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement|null;return node?{value:node.value,...(node instanceof HTMLInputElement&&node.type==='checkbox'?{checked:node.checked}:{})}:undefined;}
|
|
997
|
+
function chestEvaluation(){return evaluateChestForm(chestField,chestMapping,data.feedbackCatalog.bones);}
|
|
998
|
+
function chestRegions():OverlayRegion[]{
|
|
999
|
+
const style=getComputedStyle(document.documentElement),token=(name:string)=>style.getPropertyValue(name).trim();
|
|
1000
|
+
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')}));
|
|
1001
|
+
}
|
|
1002
|
+
/** Circle regions: in edit mode the editor's circles straight from the fields (handles and labels), else the valid designation's. */
|
|
1003
|
+
function chestCircles():OverlayCircle[]{
|
|
1004
|
+
const mapping=chestMapping;if(!mapping.available)return [];
|
|
1005
|
+
const style=getComputedStyle(document.documentElement),token=(name:string)=>style.getPropertyValue(name).trim();
|
|
1006
|
+
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')});
|
|
1007
|
+
if(chestEditor?.active()){
|
|
1008
|
+
const rows=bustSides.map(({side,row})=>({side,row,circle:rowCircle(chestField,row)})),[r,l]=[rows[0]!.circle,rows[1]!.circle];
|
|
1009
|
+
// As in the example, overlapping cores turn both circles to the overlap colour.
|
|
1010
|
+
const overlap=!!r&&!!l&&r.r+l.r>Math.hypot(l.cx-r.cx,l.cy-r.cy);
|
|
1011
|
+
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)}]:[]);
|
|
1012
|
+
}
|
|
1013
|
+
return chestOverlayCircles(chestEvaluation(),mapping).map(c=>({center:c.center,r:c.r,band:c.band,handles:false,colors:colors(c.row,false)}));
|
|
1014
|
+
}
|
|
1015
|
+
function populateChestBones():void {
|
|
1016
|
+
const bones=data.feedbackCatalog.bones;
|
|
1017
|
+
for(const row of chestRegionRows){const node=select(chestRowId(row,'bone'));node.replaceChildren(option('',bones?'Select bone':'No model bones'));for(const name of bones??[])node.append(option(name,name));node.disabled=!bones;}
|
|
1018
|
+
}
|
|
1019
|
+
function updateChestMapping():void {
|
|
1020
|
+
const root=data.feedbackCatalog.bones?.[0];let at:{x:number;y:number}|null=null;
|
|
1021
|
+
if(player&&root){try{const bone=player.bone(root);at={x:bone.x,y:bone.y};}catch{at=null;}}
|
|
1022
|
+
chestMapping=player?cropWorldMapping(assets?.stage,at):{available:false,reason:'CHEST_REGION_MAPPING_UNAVAILABLE: no compiled model drawn'};
|
|
1023
|
+
renderChest();
|
|
1024
|
+
}
|
|
1025
|
+
function renderChest():void {
|
|
1026
|
+
for(const id of ['chest-crop-w','chest-crop-h']){const node=input(id);node.readOnly=chestMapping.available;if(chestMapping.available)node.value=String(id==='chest-crop-w'?chestMapping.crop.w:chestMapping.crop.h);}
|
|
1027
|
+
text('chest-mapping',chestMapping.available?`Crop → rig world from the model: ${chestMapping.basis}.`:`${chestMapping.reason}. No overlay is drawn; enter the crop size to export.`);
|
|
1028
|
+
el('chest-mapping').classList.toggle('error',!chestMapping.available);
|
|
1029
|
+
const value=chestEvaluation();text('chest-status',value.ok?`${value.document.mesh.value.contour.regions.map(r=>`${r.name} → ${r.bone}`).join(' · ')} · valid for export`:value.error);
|
|
1030
|
+
el('chest-status').classList.toggle('error',!value.ok);
|
|
1031
|
+
chestEditor?.sync();meshEditor?.sync();redrawOverlay(false);
|
|
1032
|
+
}
|
|
1033
|
+
async function exportChest():Promise<void> {
|
|
1034
|
+
const value=chestEvaluation();if(!value.ok){message(`Chest regions not exported: ${value.error}`,true);return;}
|
|
1035
|
+
const digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value.text)))].map(b=>b.toString(16).padStart(2,'0')).join('');
|
|
1036
|
+
const binding=chestRegionsBinding({fileSha256:digest,sourceHash:data.sourceHash,reviewedSource:data.reviewedSource,exportedAt:new Date().toISOString(),mapping:chestMapping.available?chestMapping.basis:chestMapping.reason});
|
|
1037
|
+
download('chest-regions.json',value.text,'application/json');download('chest-regions.binding.json',JSON.stringify(binding,null,2)+'\n','application/json');
|
|
1038
|
+
message('Downloaded chest-regions.json and chest-regions.binding.json. The regions are review evidence, not an approval; the package is unchanged.');
|
|
1039
|
+
}
|
|
1040
|
+
/** Animate · Environment. Inspection only: the player is the solver, nothing is written to the model or motion. */
|
|
1041
|
+
const environmentInputs=['wind-base','wind-breeze','wind-gust','wind-repeat','wind-gust-peak','wind-gust-duration','gravity','jolt','drag-trail','physics-reset'];
|
|
1042
|
+
function environmentApplied():boolean{return input('environment-applied').checked;}
|
|
1043
|
+
function gravityValue():number{return Number(input('gravity').value);}
|
|
1044
|
+
/** Called from `tick` right before `player.frame(dt)`: the gust clock is the stepped time, so a paused player freezes it. */
|
|
1045
|
+
function stepEnvironment(dt:number):void {
|
|
1046
|
+
if(!player)return;
|
|
1047
|
+
wind.advance(dt);
|
|
1048
|
+
const {due,pending}=dueJolts(joltQueue,wind.read().time);joltQueue=pending;for(const step of due)player.moved({x:step.x,y:step.y});
|
|
1049
|
+
if(environmentApplied())player.environment(environmentFor(wind.read(),gravityValue()));
|
|
1050
|
+
renderEnvironment();
|
|
1051
|
+
}
|
|
1052
|
+
function applyEnvironment():void {if(player&&environmentApplied())player.environment(environmentFor(wind.read(),gravityValue()));renderEnvironment();}
|
|
1053
|
+
function renderEnvironment():void {
|
|
1054
|
+
if(!player)return;
|
|
1055
|
+
const reading=wind.read();
|
|
1056
|
+
text('wind-readout',(environmentApplied()?windReadout(reading,gravityValue()):RUNTIME_START_READOUT)+environmentNote);
|
|
1057
|
+
const gust=button('wind-gust');gust.disabled=reading.gusting;gust.setAttribute('aria-pressed',String(reading.gusting));gust.textContent=reading.gusting?'Gusting…':'Gust';
|
|
1058
|
+
text('wind-base-label',input('wind-base').value);text('gravity-label',gravityValue().toFixed(2));
|
|
1059
|
+
}
|
|
1060
|
+
/** Every control change engages the environment; until then the player stands at the runtime start values. */
|
|
1061
|
+
function engage(change:()=>void):void {
|
|
1062
|
+
try{change();input('environment-applied').checked=true;environmentNote='';applyEnvironment();persistence?.save();}
|
|
1063
|
+
catch(error){message(`Environment unchanged: ${errorText(error)}`,true);syncEnvironmentInputs();}
|
|
1064
|
+
}
|
|
1065
|
+
function syncEnvironmentInputs():void {
|
|
1066
|
+
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();
|
|
1067
|
+
}
|
|
1068
|
+
function bindEnvironment():void {
|
|
1069
|
+
input('wind-base').oninput=()=>engage(()=>wind.setBase(Number(input('wind-base').value)));
|
|
1070
|
+
input('wind-breeze').onchange=()=>engage(()=>wind.setBreeze(input('wind-breeze').checked));
|
|
1071
|
+
input('wind-repeat').onchange=()=>engage(()=>wind.setRepeat(input('wind-repeat').checked));
|
|
1072
|
+
input('wind-gust-peak').onchange=()=>engage(()=>wind.setGustPeak(Number(input('wind-gust-peak').value)));
|
|
1073
|
+
input('wind-gust-duration').onchange=()=>engage(()=>wind.setGustDuration(Number(input('wind-gust-duration').value)));
|
|
1074
|
+
input('gravity').oninput=()=>engage(()=>{environmentFor(wind.read(),gravityValue());});
|
|
1075
|
+
button('wind-gust').onclick=()=>engage(()=>wind.triggerGust());
|
|
1076
|
+
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.');};
|
|
1077
|
+
button('physics-reset').onclick=resetPhysics;
|
|
1078
|
+
input('drag-trail').onchange=()=>{el('rig-viewport').dataset.trail=String(input('drag-trail').checked);};
|
|
1079
|
+
// Drag to trail: the rig pane's pointer drag becomes `player.moved` before the pan handler sees it; Space still pans.
|
|
1080
|
+
let space=false,trail:{id:number;x:number;y:number}|null=null;
|
|
1081
|
+
const keys=(event:KeyboardEvent)=>{if(event.code==='Space')space=event.type==='keydown';};
|
|
1082
|
+
window.addEventListener('keydown',keys);window.addEventListener('keyup',keys);window.addEventListener('blur',()=>{space=false;});
|
|
1083
|
+
const pane=el('rig-pane'),viewport=el('rig-viewport');
|
|
1084
|
+
pane.addEventListener('pointerdown',event=>{
|
|
1085
|
+
if(!input('drag-trail').checked||space||!player||!event.isPrimary||event.button!==0)return;
|
|
1086
|
+
if(event.target instanceof Element&&event.target.closest('button,input,select,textarea,a'))return;
|
|
1087
|
+
event.stopPropagation();event.preventDefault();trail={id:event.pointerId,x:event.clientX,y:event.clientY};viewport.setPointerCapture(event.pointerId);
|
|
1088
|
+
},{capture:true});
|
|
1089
|
+
pane.addEventListener('pointermove',event=>{
|
|
1090
|
+
if(!trail||event.pointerId!==trail.id)return;event.stopPropagation();
|
|
1091
|
+
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);
|
|
1092
|
+
},{capture:true});
|
|
1093
|
+
const end=(event:PointerEvent)=>{if(!trail||event.pointerId!==trail.id)return;event.stopPropagation();if(viewport.hasPointerCapture(trail.id))viewport.releasePointerCapture(trail.id);trail=null;};
|
|
1094
|
+
pane.addEventListener('pointerup',end,{capture:true});pane.addEventListener('pointercancel',end,{capture:true});
|
|
1095
|
+
}
|
|
1096
|
+
/** Reset: the runtime's start values, then the player's reopen-then-step to the current time, so the physics history is cleared. */
|
|
1097
|
+
function resetPhysics():void {
|
|
1098
|
+
if(!player)return;
|
|
1099
|
+
joltQueue=[];wind.restore(null);input('environment-applied').checked=false;input('gravity').value='1';syncEnvironmentInputs();
|
|
1100
|
+
try{player.environment(null);if(player.current){player.seek(time);drawRig(()=>player!.draw());}environmentNote=` · physics history cleared; re-posed from ${time.toFixed(3)} s`;}
|
|
1101
|
+
catch(error){environmentNote='';message(`Physics reset refused: ${errorText(error)}`,true);}
|
|
1102
|
+
renderEnvironment();persistence?.save();
|
|
1103
|
+
}
|
|
1104
|
+
/** Draft restore: the inputs are already back; rebuild the wind model from them (a fresh gust clock). */
|
|
1105
|
+
function restoreEnvironment():void {
|
|
1106
|
+
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)};
|
|
1107
|
+
try{wind.restore(snapshot);environmentFor(wind.read(),gravityValue());}
|
|
1108
|
+
catch(error){wind.restore(null);input('environment-applied').checked=false;input('gravity').value='1';message(`Saved environment not restored: ${errorText(error)}`,true);}
|
|
1109
|
+
syncEnvironmentInputs();el('rig-viewport').dataset.trail=String(input('drag-trail').checked);
|
|
1110
|
+
if(player){if(environmentApplied())applyEnvironment();else player.environment(null);}
|
|
1111
|
+
}
|
|
1112
|
+
function feedbackEnvironment(){return player?environmentJson(environmentContext(wind.read(),gravityValue(),environmentApplied(),player.environment())):null;}
|
|
1113
|
+
function enableEnvironment(modelUrl:string):void {
|
|
1114
|
+
for(const id of environmentInputs)el<HTMLInputElement|HTMLButtonElement>(id).disabled=false;
|
|
1115
|
+
syncEnvironmentInputs();text('physics-mix-readonly',CONSTRAINT_MIX_READONLY);text('physics-constraints-state','Reading the compiled model…');
|
|
1116
|
+
const rig=data.reviewedSource.rig;
|
|
1117
|
+
if(!rig){renderConstraints({available:false,reason:'PHYSICS_CONSTRAINTS_UNAVAILABLE: no reviewed rig identity'});return;}
|
|
1118
|
+
void loadPhysicsConstraints(modelUrl,rig.sha256).then(renderConstraints,error=>renderConstraints({available:false,reason:`PHYSICS_CONSTRAINTS_UNAVAILABLE: ${errorText(error)}`}));
|
|
1119
|
+
}
|
|
1120
|
+
function renderConstraints(value:Awaited<ReturnType<typeof loadPhysicsConstraints>>):void {
|
|
1121
|
+
const area=el('physics-constraints');area.replaceChildren();
|
|
1122
|
+
if(!value.available){text('physics-constraints-state',value.reason);el('physics-constraints-state').classList.add('error');return;}
|
|
1123
|
+
el('physics-constraints-state').classList.remove('error');
|
|
1124
|
+
text('physics-constraints-state',`${value.rows.length} physics constraints as the compiled model declares them · — = not declared (no runtime default is shown)`);
|
|
1125
|
+
const table=document.createElement('table'),head=document.createElement('tr');table.className='physics-table';
|
|
1126
|
+
for(const label of ['Constraint','Bone',...PHYSICS_FIELDS]){const cell=document.createElement('th');cell.scope='col';cell.textContent=label;head.append(cell);}
|
|
1127
|
+
const thead=document.createElement('thead');thead.append(head);const body=document.createElement('tbody');
|
|
1128
|
+
for(const row of value.rows){
|
|
1129
|
+
const line=document.createElement('tr');
|
|
1130
|
+
const name=document.createElement('th');name.scope='row';name.textContent=row.name;const bone=document.createElement('td');bone.textContent=row.bone??'—';line.append(name,bone);
|
|
1131
|
+
for(const field of PHYSICS_FIELDS){const cell=document.createElement('td'),v=row.values[field];cell.textContent=v===undefined?'—':String(v);if(v===undefined){cell.className='absent';cell.title='Not declared';}line.append(cell);}
|
|
1132
|
+
body.append(line);
|
|
1133
|
+
}
|
|
1134
|
+
table.append(thead,body);area.append(table);
|
|
1135
|
+
}
|
|
1136
|
+
/** Setup's mesh edit: the selected slot's actual mesh as a typed delta. A two-file client download; nothing reaches the session or the package. */
|
|
1137
|
+
function meshSource(){const part=parts?.value()??null,frame=parts?.frame('single');return meshEditSource({slot:part?.slot??null,activeAttachment:parts?.observation()?.activeAttachment??null,row:part?frame?.order.find(row=>row.slot===part.slot):undefined,model:partModel,mapping:chestMapping});}
|
|
1138
|
+
function meshBasis():string {return `posed vertices of the last drawn frame (animation ${animation?JSON.stringify(animation):'none'} at ${(player?.time??time).toFixed(4)} s; playback pauses on entering mesh edit) mapped to crop px through ${chestMapping.available?chestMapping.basis:chestMapping.reason}`;}
|
|
1139
|
+
async function exportMesh(doc:MeshEditDocument):Promise<void> {
|
|
1140
|
+
const text=JSON.stringify(doc,null,2)+'\n',digest=await sha256Hex(text);
|
|
1141
|
+
const binding=meshEditBinding({fileSha256:digest,sourceHash:data.sourceHash,reviewedSource:data.reviewedSource,exportedAt:new Date().toISOString(),mapping:chestMapping.available?chestMapping.basis:chestMapping.reason,attachment:{slot:doc.slot,skin:doc.skin,name:doc.attachment,sha256:doc.source.hash}});
|
|
1142
|
+
download('mesh-edit.json',text,'application/json');download('mesh-edit.binding.json',JSON.stringify(binding,null,2)+'\n','application/json');
|
|
1143
|
+
message(`Downloaded mesh-edit.json (${doc.ops.length} ops) and mesh-edit.binding.json. The delta is review evidence, not an approval; the package is unchanged and the producer retriangulates.`);
|
|
1144
|
+
}
|
|
977
1145
|
void start();
|