@rig-c/review 0.3.0 → 0.5.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 +58 -11
- package/docs/DESIGN.md +33 -5
- package/package.json +1 -1
- package/src/chest-regions.ts +253 -0
- package/web/chest-regions-tool.ts +51 -0
- package/web/compare.css +27 -44
- package/web/index.html +4 -6
- package/web/main.ts +75 -39
- package/web/part-selection.ts +1 -1
- package/web/rig-overlays.ts +10 -2
- package/web/styles.css +138 -62
- package/web/view-controls.ts +43 -4
- package/web/workspace-layout.ts +33 -12
- package/web/workspace-preferences.ts +3 -0
- package/web/workspace-ui.ts +51 -31
package/web/main.ts
CHANGED
|
@@ -2,9 +2,11 @@ import {PartExplorer,loadPartCatalog} from './part-selection.ts';
|
|
|
2
2
|
import { loadModel, RigPlayer, type ModelAssets, type PoseBounds } from '@rig-c/play';
|
|
3
3
|
import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,boundsFitSignature,dragSignature,zoomSignature,type CameraFrame,type CameraSignature} from './camera-view.ts';
|
|
4
4
|
import {rendererStatus} from './renderer-status.ts';
|
|
5
|
-
import {bindAppearance,bindPan,bindStageLayout,syncStageLayout,bindInspectorTabs,idleAvailable,initialIdleAnimation,automaticIdlePreview,type ViewPoint} from './view-controls.ts';
|
|
6
|
-
import {RigOverlays} from './rig-overlays.ts';
|
|
7
|
-
import {
|
|
5
|
+
import {bindAppearance,bindPan,bindStageLayout,syncStageLayout,bindInspectorTabs,idleAvailable,initialIdleAnimation,automaticIdlePreview,bindLayerShortcuts,describeLayerToggles,type ViewPoint} from './view-controls.ts';
|
|
6
|
+
import {RigOverlays,type OverlayRegion} from './rig-overlays.ts';
|
|
7
|
+
import {chestOverlayPolygons,chestRegionRows,chestRowId,evaluateChestForm} from './chest-regions-tool.ts';
|
|
8
|
+
import {chestRegionsBinding,cropWorldMapping,type CropWorldMapping} from '../src/chest-regions.ts';
|
|
9
|
+
import {bindWorkspaceUI,selectedInspector,type WorkspaceUI} from './workspace-ui.ts';
|
|
8
10
|
import {connectControl} from './control.ts';
|
|
9
11
|
import {pageInstanceAccess,readOnlyReason} from './instance-access.ts';
|
|
10
12
|
import {controlComparison,type ControlCommand,type ControlInspection,type ControlView} from '../src/control.ts';
|
|
@@ -74,6 +76,7 @@ let suspendedPreview=false;
|
|
|
74
76
|
let controlDispose:(()=>void)|null=null;
|
|
75
77
|
const viewDisposers:(()=>void)[]=[];
|
|
76
78
|
const layers={images:true,mesh:false,bones:false,meshOpacity:.65};
|
|
79
|
+
let chestMapping:CropWorldMapping={available:false,reason:'CHEST_REGION_MAPPING_UNAVAILABLE: no compiled model drawn'};
|
|
77
80
|
let referenceSize: { width: number; height: number } | null = null;
|
|
78
81
|
let video: HTMLVideoElement | null = null;
|
|
79
82
|
let controlledVideoSample:{media:HTMLVideoElement;position:number;time:number}|null=null;
|
|
@@ -92,7 +95,10 @@ let selectedReportTarget: number | undefined;
|
|
|
92
95
|
|
|
93
96
|
function message(value: string, error = false): void {
|
|
94
97
|
text('message', value);
|
|
98
|
+
el('message').title = value; // the footer truncates long messages
|
|
95
99
|
el('message').classList.toggle('error', error);
|
|
100
|
+
// The modal makes the footer inert; the review dialog repeats the latest message for its reader.
|
|
101
|
+
text('review-message', value); el('review-message').classList.toggle('error', error);
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
|
@@ -182,6 +188,7 @@ function layoutEligibility(){return singleEligibility({rig:rigInput,reference:re
|
|
|
182
188
|
function renderLayout():void {
|
|
183
189
|
const state=layoutEligibility();effectiveView=state.effective;
|
|
184
190
|
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');}
|
|
191
|
+
const menuLabels={compare:'Side by side',reference:'Reference',rig:'Rig'};text('view-menu-label',menuLabels[state.effective??state.requested]);
|
|
185
192
|
el('reference-pane').hidden=state.effective!=='compare'&&state.effective!=='reference';el('rig-pane').hidden=state.effective!=='compare'&&state.effective!=='rig';
|
|
186
193
|
const shown={compare:'Side by side',reference:'Reference only',rig:'Rig only'};
|
|
187
194
|
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]??''}`);
|
|
@@ -553,6 +560,7 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
|
553
560
|
select('axis-select').replaceChildren(); for (const axis of validAxes) select('axis-select').append(option(axis.id, axis.label));
|
|
554
561
|
input('axis-value').disabled = !validAxes.length;
|
|
555
562
|
for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;
|
|
563
|
+
describeLayerToggles(document,el('layer-state').textContent??'');
|
|
556
564
|
fitCamera();
|
|
557
565
|
} 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); }
|
|
558
566
|
}
|
|
@@ -573,7 +581,7 @@ function applyAxis(): void {
|
|
|
573
581
|
function renderProposals(): void {
|
|
574
582
|
const area = el('proposal-list'); area.replaceChildren();
|
|
575
583
|
const warnings = el('feedback-warnings'); warnings.hidden = session.feedbackWarnings.length === 0;
|
|
576
|
-
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.
|
|
584
|
+
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.` : '';
|
|
577
585
|
for (const feedback of session.feedback) {
|
|
578
586
|
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;
|
|
579
587
|
item.append(title, description);
|
|
@@ -586,7 +594,7 @@ function renderProposals(): void {
|
|
|
586
594
|
}
|
|
587
595
|
|
|
588
596
|
function propose(tool: string): void {
|
|
589
|
-
if (tool === 'angle' && !input('axis-value').disabled) { showInspector('
|
|
597
|
+
if (tool === 'angle' && !input('axis-value').disabled) { showInspector('playback');chooseAxis(); return; }
|
|
590
598
|
const choices: Record<string, { target: Feedback['target']['kind']; hint: string }> = {
|
|
591
599
|
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' },
|
|
592
600
|
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' },
|
|
@@ -597,10 +605,10 @@ function propose(tool: string): void {
|
|
|
597
605
|
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.');
|
|
598
606
|
}
|
|
599
607
|
|
|
608
|
+
|
|
600
609
|
function download(name: string, content: string, mime: string): void {
|
|
601
610
|
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);
|
|
602
611
|
}
|
|
603
|
-
|
|
604
612
|
function downloadStrip(name: string, content: Blob): void {
|
|
605
613
|
const url = URL.createObjectURL(content), link = document.createElement('a'); link.href = url; link.download = name; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
606
614
|
}
|
|
@@ -649,26 +657,6 @@ async function exportStrip(): Promise<void> {
|
|
|
649
657
|
finally { stripBusy = false; updateStripControls(); }
|
|
650
658
|
}
|
|
651
659
|
|
|
652
|
-
const markdown = (value: string) => value.replace(/[\\`*_{}\[\]<>]/g, '\\$&');
|
|
653
|
-
function issueFields(feedback: Feedback): { title: string; body: string; labels: string[] } {
|
|
654
|
-
const title = `[${feedback.category}] ${feedback.target.kind}:${feedback.target.name} F${feedback.frameStart}–${feedback.frameEnd} · ${feedback.summary}`;
|
|
655
|
-
const body = [
|
|
656
|
-
`- Package: ${markdown(session.id)} / ${markdown(session.revision)}`,
|
|
657
|
-
`- Source hash: ${session.sourceHash}`, ...(session.referenceBinding?[`- Reference binding: ${JSON.stringify(session.referenceBinding)}`]:[]),
|
|
658
|
-
`- Package revision: ${markdown(session.reviewedSource.packageRevision)}`,
|
|
659
|
-
`- Viewer version: ${markdown(session.reviewedSource.viewerVersion)}`,
|
|
660
|
-
`- Runtime version: ${markdown(session.reviewedSource.runtimeVersion)}`,
|
|
661
|
-
`- Rig: ${session.reviewedSource.rig ? `${markdown(session.reviewedSource.rig.path)} / SHA-256 ${session.reviewedSource.rig.sha256}` : 'not supplied'}`,
|
|
662
|
-
`- Motion: ${session.reviewedSource.motion ? `${markdown(session.reviewedSource.motion.path)} / SHA-256 ${session.reviewedSource.motion.sha256}` : 'not supplied'}`,
|
|
663
|
-
`- Frames: ${feedback.frameStart}–${feedback.frameEnd} (inclusive; producer frame numbers; no inferred FPS)`,
|
|
664
|
-
`- Target: ${feedback.target.kind} / ${markdown(feedback.target.name)}`,
|
|
665
|
-
`- Category: ${feedback.category}`, '', markdown(feedback.summary), '', 'Inspection context:',
|
|
666
|
-
...JSON.stringify(feedback.context ?? null, null, 2).split('\n').map(line => ' ' + line),
|
|
667
|
-
'', 'Validation warnings (must be corrected before submission):',
|
|
668
|
-
...JSON.stringify(session.feedbackWarnings.filter(warning => warning.id === feedback.id), null, 2).split('\n').map(line => ' ' + line),
|
|
669
|
-
].join('\n');
|
|
670
|
-
return { title, body, labels: [feedback.category] };
|
|
671
|
-
}
|
|
672
660
|
|
|
673
661
|
function paneFrame(target:CameraTarget,hidden=false):CameraFrame|null {
|
|
674
662
|
const viewport=el(target+'-viewport'),pane=el(target+'-pane');
|
|
@@ -706,8 +694,12 @@ function zoomViewport(target:CameraTarget,deltaY:number):void {zoomCamera(deltaY
|
|
|
706
694
|
|
|
707
695
|
function bind(): void {
|
|
708
696
|
button('strip-export').onclick = () => { void exportStrip(); };
|
|
697
|
+
button('chest-export').onclick = () => { void exportChest(); };
|
|
698
|
+
for (const event of ['input', 'change']) el('chest-regions').addEventListener(event, renderChest);
|
|
699
|
+
// 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());
|
|
709
701
|
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.');
|
|
710
|
-
for (const item of document.querySelectorAll<HTMLButtonElement>('[data-view]')) item.onclick = () => setView(item.dataset.view as typeof view);
|
|
702
|
+
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;};
|
|
711
703
|
for (const item of document.querySelectorAll<HTMLButtonElement>('[data-proposal]')) item.onclick = () => propose(item.dataset.proposal ?? '');
|
|
712
704
|
select('reference-select').onchange = () => { void showReference(select('reference-select').value); };
|
|
713
705
|
select('person-select').onchange = () => { selectedPerson = select('person-select').value; lastPoseFrame = -1; renderSequenceFrame(); };
|
|
@@ -773,19 +765,18 @@ function bind(): void {
|
|
|
773
765
|
message(dirty ? 'Changes were made during submission. Submit again.' : 'Submitted the selected review status, feedback and source provenance.');
|
|
774
766
|
} catch (error) { message(`Submission failed: ${errorText(error)}`, true); } finally { saving = false; updateSavedState(); }
|
|
775
767
|
};
|
|
776
|
-
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.'); };
|
|
777
|
-
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.'); };
|
|
778
768
|
// Saved browser defaults seed a new inspection; explicit links and source drafts restore over them.
|
|
779
|
-
workspace=bindWorkspaceUI({changed:fitCamera,defaultTheme:theme=>appearance?.set(theme)});
|
|
769
|
+
workspace=bindWorkspaceUI({changed:fitCamera,defaultTheme:theme=>appearance?.set(theme),currentTheme:()=>appearance?.get()});
|
|
780
770
|
cameraSync=workspace.defaults().defaultCameraSync;input('camera-sync').checked=cameraSync;input('idle-repeat').checked=workspace.defaults().defaultIdleRepeat;
|
|
781
|
-
appearance=bindAppearance(select('
|
|
771
|
+
appearance=bindAppearance(select('settings-theme'),workspace.defaults().theme);
|
|
782
772
|
overlays=new RigOverlays({canvas:el<HTMLCanvasElement>('rig-overlay'),rigRoot:el('rig-root')});
|
|
783
773
|
viewDisposers.push(bindStageLayout(el('stage-layout'),fitCamera));
|
|
784
774
|
for(const target of ['rig','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
|
|
785
775
|
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
|
+
viewDisposers.push(bindLayerShortcuts(document));describeLayerToggles(document,el('layer-state').textContent??'');
|
|
786
777
|
input('idle-repeat').onchange=()=>{renderIdle();if(canRepeatIdle())void playPreview();};
|
|
787
778
|
for(const id of ['proposal-panel','checkpoint-detail'])el(id).addEventListener('focusin',pause);
|
|
788
|
-
bindInspector();
|
|
779
|
+
bindInspector();bindReviewDialog();
|
|
789
780
|
document.addEventListener('visibilitychange',()=>{if(document.hidden){const resume=playing;pause();suspendedPreview=resume;}else if(suspendedPreview){suspendedPreview=false;void playPreview();}});
|
|
790
781
|
const resize = new ResizeObserver(fitCamera); resize.observe(el('rig-viewport')); resize.observe(el('reference-viewport'));
|
|
791
782
|
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
|
@@ -802,13 +793,16 @@ async function start(): Promise<void> {
|
|
|
802
793
|
select('reference-select').replaceChildren(); for (const reference of data.manifest.references) select('reference-select').append(option(reference.id, `${reference.label} · ${reference.type}`));
|
|
803
794
|
for (const reference of data.manifest.references.filter(item => item.type === 'video')) select('strip-reference').append(option(reference.id, reference.label));
|
|
804
795
|
if (!data.manifest.references.length) { select('reference-select').append(option('', 'No references supplied')); select('reference-select').disabled = true; }
|
|
796
|
+
// Checkpoints and reference fold away when the package supplies neither.
|
|
797
|
+
(el('package-context') as HTMLDetailsElement).open = data.manifest.checkpoints.length > 0 || data.manifest.references.length > 0;
|
|
805
798
|
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';
|
|
806
|
-
bind(); parts=new PartExplorer(el('
|
|
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();
|
|
807
800
|
const requestedId = new URLSearchParams(location.search).get('checkpoint'),initial=initialReviewSelection(data.manifest,requestedId,data.explicitReference?.id,data.initialAnimation??undefined),first=initial.checkpoint;
|
|
808
801
|
rigInput=data.manifest.model?{state:'pending'}:{state:'absent'};renderLayout();
|
|
809
802
|
if(initial.referenceId){select('reference-select').value=initial.referenceId;await showReference(initial.referenceId);}
|
|
810
803
|
await initPlayer(first);
|
|
811
804
|
// initPlayer keeps `player` only after its first draw succeeded; a declared model path alone never counts.
|
|
805
|
+
updateChestMapping();
|
|
812
806
|
rigInput=player?{state:'loaded'}:data.manifest.model?{state:'failed',reason:el('rig-empty').textContent||'compiled player unavailable'}:{state:'absent'};renderLayout();
|
|
813
807
|
if (first) await chooseCheckpoint(first,initial); else renderCheckpoint();
|
|
814
808
|
activeCamera=player?'rig':'reference';if(cameraSync)alignCameras();renderTransport();fitCamera();
|
|
@@ -816,17 +810,27 @@ async function start(): Promise<void> {
|
|
|
816
810
|
else if (readOnly) message(readOnly, true);
|
|
817
811
|
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.');
|
|
818
812
|
requestAnimationFrame(tick);
|
|
819
|
-
const restored=await setupPersistence();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
|
|
813
|
+
const restored=await setupPersistence();renderChest();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
|
|
820
814
|
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)});
|
|
821
815
|
} catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
|
|
822
816
|
}
|
|
823
|
-
|
|
824
|
-
function
|
|
817
|
+
/** `feedback` is the review dialog, not an Inspector section: a user action opens it; a restored older bookmark that saved it selects nothing. */
|
|
818
|
+
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();}
|
|
819
|
+
function bindInspector():void {bindInspectorTabs(()=>{workspace?.afterTab();fitCamera();},()=>workspace?.beforeTab());}
|
|
820
|
+
/** 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. */
|
|
821
|
+
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();}
|
|
822
|
+
function bindReviewDialog():void {
|
|
823
|
+
const dialog=el<HTMLDialogElement>('review-dialog');
|
|
824
|
+
button('write-review').onclick=openReview;button('review-dialog-close').onclick=()=>dialog.close();
|
|
825
|
+
// Escape closes the native modal; every close returns focus to the button that opens it.
|
|
826
|
+
dialog.addEventListener('close',()=>{button('write-review').setAttribute('aria-expanded','false');button('write-review').focus();});
|
|
827
|
+
dialog.addEventListener('toggle',()=>{if(dialog.open)button('write-review').setAttribute('aria-expanded','true');});
|
|
828
|
+
}
|
|
825
829
|
|
|
826
830
|
function redrawOverlay(fresh:boolean):void {
|
|
827
831
|
if(!overlays||!player)return;
|
|
828
832
|
const root=el('rig-root'),project=(x:number,y:number)=>({x:parseFloat(root.style.left||'0')+x*rigScale,y:parseFloat(root.style.top||'0')-y*rigScale});
|
|
829
|
-
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});
|
|
833
|
+
const value=fresh?overlays.refresh({player,frame:parts?.frame('single'),selectedSlot:parts?.value()?.slot,bones:(data.feedbackCatalog.bones??[]).map(name=>({name})),project,settings:layers,regions:chestRegions()}):overlays.redraw({project,settings:layers,regions:chestRegions()});
|
|
830
834
|
text('layer-state',value.warnings.join(' · ')||'Actual last-drawn mesh edges / bone points');if(!fresh&&value.needsCapture&&!playing&&!seekingControl)drawRig(()=>player!.draw());
|
|
831
835
|
}
|
|
832
836
|
function canRepeatIdle():boolean{return !!player&&idleAvailable(assets?.animations.map(a=>a.name)??[],animation)&&!axisInspection&&input('idle-repeat').checked&&duration>0;}
|
|
@@ -903,7 +907,7 @@ function numericView():ControlView {
|
|
|
903
907
|
}
|
|
904
908
|
function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
|
|
905
909
|
function inspectionBinding():InspectionBinding{return {mode:'single',sourceHash:data.sourceHash,rigs:[data.reviewedSource.rig?.sha256??null]};}
|
|
906
|
-
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:
|
|
910
|
+
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}};}
|
|
907
911
|
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
908
912
|
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();});
|
|
909
913
|
}
|
|
@@ -966,7 +970,39 @@ async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):P
|
|
|
966
970
|
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});});}
|
|
967
971
|
async function setupPersistence():Promise<boolean> {
|
|
968
972
|
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,
|
|
969
|
-
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;}});
|
|
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);}});
|
|
970
974
|
return persistence.restored;
|
|
971
975
|
}
|
|
976
|
+
/** Setup's Chest regions tool: designation values, an overlay and a two-file client download. Nothing reaches the session's feedback or the package. */
|
|
977
|
+
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;}
|
|
978
|
+
function chestEvaluation(){return evaluateChestForm(chestField,chestMapping,data.feedbackCatalog.bones);}
|
|
979
|
+
function chestRegions():OverlayRegion[]{
|
|
980
|
+
const style=getComputedStyle(document.documentElement),token=(name:string)=>style.getPropertyValue(name).trim();
|
|
981
|
+
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
|
+
}
|
|
983
|
+
function populateChestBones():void {
|
|
984
|
+
const bones=data.feedbackCatalog.bones;
|
|
985
|
+
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
|
+
}
|
|
987
|
+
function updateChestMapping():void {
|
|
988
|
+
const root=data.feedbackCatalog.bones?.[0];let at:{x:number;y:number}|null=null;
|
|
989
|
+
if(player&&root){try{const bone=player.bone(root);at={x:bone.x,y:bone.y};}catch{at=null;}}
|
|
990
|
+
chestMapping=player?cropWorldMapping(assets?.stage,at):{available:false,reason:'CHEST_REGION_MAPPING_UNAVAILABLE: no compiled model drawn'};
|
|
991
|
+
renderChest();
|
|
992
|
+
}
|
|
993
|
+
function renderChest():void {
|
|
994
|
+
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);}
|
|
995
|
+
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.`);
|
|
996
|
+
el('chest-mapping').classList.toggle('error',!chestMapping.available);
|
|
997
|
+
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
|
+
el('chest-status').classList.toggle('error',!value.ok);
|
|
999
|
+
redrawOverlay(false);
|
|
1000
|
+
}
|
|
1001
|
+
async function exportChest():Promise<void> {
|
|
1002
|
+
const value=chestEvaluation();if(!value.ok){message(`Chest regions not exported: ${value.error}`,true);return;}
|
|
1003
|
+
const digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value.text)))].map(b=>b.toString(16).padStart(2,'0')).join('');
|
|
1004
|
+
const binding=chestRegionsBinding({fileSha256:digest,sourceHash:data.sourceHash,reviewedSource:data.reviewedSource,exportedAt:new Date().toISOString(),mapping:chestMapping.available?chestMapping.basis:chestMapping.reason});
|
|
1005
|
+
download('chest-regions.json',value.text,'application/json');download('chest-regions.binding.json',JSON.stringify(binding,null,2)+'\n','application/json');
|
|
1006
|
+
message('Downloaded chest-regions.json and chest-regions.binding.json. The regions are review evidence, not an approval; the package is unchanged.');
|
|
1007
|
+
}
|
|
972
1008
|
void start();
|
package/web/part-selection.ts
CHANGED
|
@@ -127,7 +127,7 @@ export class PartExplorer extends PartInspection {
|
|
|
127
127
|
return {title,info,declarations,bounds,unavailable,feedback,status};
|
|
128
128
|
})();
|
|
129
129
|
nodes.title.textContent=observation?`${observation.side==='single'?'':observation.side.toUpperCase()+' · '}${observation.slot}`:'Whole rig';
|
|
130
|
-
nodes.info.textContent=observation?`${observation.mode==='solo'?'Solo':'In context'} · ${observation.drawn?observation.kind==='mesh'?`${observation.vertices} posed vertices · ${observation.triangles} triangles`:'Region · four posed corners':'Not drawn at current pose'} · Skin: ${observation.skin??'not set'} · Set: ${observation.set??'not set'}`:refusal?`Parts unavailable · ${refusal.message}. The whole rig stays drawn; no slot is selected, soloed or targeted from stale data.`:'Select an actual slot in the
|
|
130
|
+
nodes.info.textContent=observation?`${observation.mode==='solo'?'Solo':'In context'} · ${observation.drawn?observation.kind==='mesh'?`${observation.vertices} posed vertices · ${observation.triangles} triangles`:'Region · four posed corners':'Not drawn at current pose'} · Skin: ${observation.skin??'not set'} · Set: ${observation.set??'not set'}`:refusal?`Parts unavailable · ${refusal.message}. The whole rig stays drawn; no slot is selected, soloed or targeted from stale data.`:'Select an actual slot in the parts explorer. Selection preserves animation, time and physics history.';
|
|
131
131
|
for(const node of [nodes.declarations,nodes.bounds,nodes.unavailable,nodes.feedback])node.hidden=!observation;nodes.feedback.disabled=!observation;
|
|
132
132
|
nodes.declarations.textContent=observation?`${observation.activeAttachment?'Resolved public attachment: '+observation.activeAttachment.skin+' / '+observation.activeAttachment.attachment+'.':(observation.identityUnavailable??'No active drawable attachment: '+observation.reason)+'.'} Declared: ${observation.declarations.map(d=>`${d.skin} / ${d.attachment} (${d.kind})`).join('; ')||'none'}. ${observation.page?'Drawn page: '+observation.page:''}${observation.region?' · region '+observation.region:''}${observation.sequence!==null?' · sequence '+observation.sequence:''}`:'';
|
|
133
133
|
nodes.bounds.textContent=observation?observation.bounds?`Selection bounds · world units / Y up: x ${observation.bounds.x}, y ${observation.bounds.y}, width ${observation.bounds.width}, height ${observation.bounds.height}. Conservative geometry/clip bounds; fit uses the existing 0.25–8× camera range.`:'PART_FIT_UNAVAILABLE: no positive drawn bounds at the held pose.':'';
|
package/web/rig-overlays.ts
CHANGED
|
@@ -14,7 +14,9 @@ export interface OverlaySnapshot {
|
|
|
14
14
|
available:boolean;needsCapture:boolean;meshEdges:number;boneCount:number;
|
|
15
15
|
capabilities:{images:boolean;mesh:boolean;bones:boolean};warnings:string[];
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
/** Designation polygons in rig world coordinates (Setup's Chest regions tool); colours are theme token values. */
|
|
18
|
+
export interface OverlayRegion {name:string;points:OverlayPoint[];stroke:string;fill:string}
|
|
19
|
+
export interface OverlayView {project:OverlayProject;settings:RigOverlaySettings;dpr?:number;regions?:readonly OverlayRegion[]}
|
|
18
20
|
export interface OverlayRefresh extends OverlayView {player:OverlayPlayer|null;bones:readonly BoneOverlaySpec[];frame?:PosedFrame;selectedSlot?:string|null}
|
|
19
21
|
const point=(x:number,y:number):OverlayPoint=>({x,y});
|
|
20
22
|
const finite=(p:OverlayPoint)=>Number.isFinite(p.x)&&Number.isFinite(p.y);
|
|
@@ -91,7 +93,7 @@ export class RigOverlays {
|
|
|
91
93
|
this.geometry=input.player?captureRigGeometry(input.player,input.bones,input.settings,input.frame,input.selectedSlot):null;
|
|
92
94
|
return this.redraw(input);
|
|
93
95
|
}
|
|
94
|
-
redraw({project,settings,dpr=globalThis.devicePixelRatio??1}:OverlayView):OverlaySnapshot{
|
|
96
|
+
redraw({project,settings,dpr=globalThis.devicePixelRatio??1,regions=[]}:OverlayView):OverlaySnapshot{
|
|
95
97
|
if(this.disposed)return this.unavailable('Overlay has been disposed');
|
|
96
98
|
this.host.rigRoot.style.visibility=settings.images?this.visibility:'hidden';
|
|
97
99
|
const warnings=[...(this.geometry?.warnings??[])],ctx=this.host.canvas.getContext('2d');
|
|
@@ -132,6 +134,12 @@ export class RigOverlays {
|
|
|
132
134
|
ctx.fillStyle='#ffe26b';ctx.strokeStyle='#44310d';ctx.lineWidth=1;ctx.beginPath();ctx.arc(center.x,center.y,2.8,0,Math.PI*2);ctx.fill();ctx.stroke();
|
|
133
135
|
}
|
|
134
136
|
}
|
|
137
|
+
// Designations are drawn over the drawn rig only; they are values the reviewer typed, not rig geometry.
|
|
138
|
+
if(this.playerAvailable)for(const region of regions){
|
|
139
|
+
const points=region.points.map(projected);if(points.length<3||points.some(p=>!p))continue;
|
|
140
|
+
ctx.globalAlpha=1;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(points[0]!.x,points[0]!.y);for(const p of points.slice(1))ctx.lineTo(p!.x,p!.y);ctx.closePath();
|
|
141
|
+
ctx.fillStyle=region.fill;ctx.fill();ctx.strokeStyle=region.stroke;ctx.lineWidth=1.6;ctx.stroke();
|
|
142
|
+
}
|
|
135
143
|
if(this.geometry?.selection.length){ctx.globalAlpha=1;ctx.strokeStyle='#ffbb47';ctx.lineWidth=2;ctx.setLineDash([5,3]);ctx.beginPath();for(const edge of this.geometry.selection)line(edge);ctx.stroke();ctx.setLineDash([]);}
|
|
136
144
|
ctx.restore();
|
|
137
145
|
return this.snapshot(true,needsCapture,warnings);
|