@rig-c/review 0.13.0 → 0.14.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 +46 -708
- package/docs/DESIGN.md +11 -0
- package/docs/LIVE_FIXTURE.md +17 -4
- package/docs/PANELS.md +400 -0
- package/docs/REVIEW_PACKAGE.md +29 -5
- package/docs/WORKFLOWS.md +337 -0
- package/package.json +4 -1
- package/src/compare-session.ts +3 -1
- package/src/feedback-validation.ts +2 -0
- package/src/manifest.ts +2 -1
- package/src/producer.ts +11 -5
- package/src/session.ts +5 -3
- package/src/turn-pad.ts +60 -0
- package/web/compare.css +10 -0
- package/web/compare.ts +17 -5
- package/web/inspection-persistence.ts +4 -2
- package/web/main.ts +20 -7
- package/web/styles.css +10 -0
- package/web/turn-pad.ts +26 -0
package/src/turn-pad.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Inspection dials, not keys. Read the compiled slider's mapping; never invent a degree range. */
|
|
2
|
+
import type {BoneAdjust,BoneLocals} from '@rig-c/play';
|
|
3
|
+
export type TurnAxis='yaw'|'pitch'|'body';
|
|
4
|
+
export interface TurnDial {axis:TurnAxis;bone:string;slider:string;channel:keyof BoneLocals;min:number;max:number;provenance?:string}
|
|
5
|
+
export interface TurnSnapshot {schema:'rig-review-turn/1';values:Partial<Record<TurnAxis,number>>;held:boolean;smoothing:number}
|
|
6
|
+
const object=(v:unknown):Record<string,any>|null=>v!==null&&typeof v==='object'&&!Array.isArray(v)?v as Record<string,any>:null;
|
|
7
|
+
const finite=(v:unknown):v is number=>typeof v==='number'&&Number.isFinite(v);
|
|
8
|
+
const clamp=(v:number,min:number,max:number)=>Math.max(min,Math.min(max,v))||0;
|
|
9
|
+
const channels:Record<string,keyof BoneLocals>={rotate:'rotation',x:'x',y:'y',scaleX:'scaleX',scaleY:'scaleY',shearY:'shearY'};
|
|
10
|
+
/** `body_turn` is the producer's compiled body slider animation, including a custom turn.body.slider bone.
|
|
11
|
+
* This is compiled metadata only; no producer config or opaque Play document is read. */
|
|
12
|
+
export function discoverTurnDials(model:unknown,durations?:readonly {name:string;duration:number}[]):TurnDial[] {
|
|
13
|
+
const m=object(model);if(!m||!Array.isArray(m.bones)||!Array.isArray(m.constraints))return [];
|
|
14
|
+
const names=new Set(m.bones.map((b:any)=>b.name)),animations=durations??m.animations??[];
|
|
15
|
+
const result:TurnDial[]=[];
|
|
16
|
+
for(const axis of ['yaw','pitch','body'] as const){
|
|
17
|
+
const canonical=axis==='body'?'body_yaw_dial':axis+'_dial';
|
|
18
|
+
const candidates=m.constraints.filter((c:any)=>c&&c.kind==='slider'&&typeof c.name==='string'&&names.has(c.bone)&&(c.bone===canonical||axis==='body'&&c.animation==='body_turn'));
|
|
19
|
+
if(candidates.length!==1)continue;
|
|
20
|
+
const c=candidates[0],duration=animations.find((a:any)=>a.name===c.animation)?.duration,from=c.from??0,to=c.to??0,scale=c.scale??1,channel=channels[c.property??'rotate'];
|
|
21
|
+
if(!c.local||c.loop||!channel||![duration,from,to,scale].every(finite)||duration<=0||scale===0)continue;
|
|
22
|
+
// Same inverse slider mapping as the official head-turn demo; max is not a clamp in the runtime.
|
|
23
|
+
const a=from-to/scale,b=from+(duration-to)/scale;
|
|
24
|
+
if(!finite(a)||!finite(b)||a===b||Math.min(a,b)>0||Math.max(a,b)<0)continue;
|
|
25
|
+
if(result.some(d=>d.bone===c.bone&&d.channel===channel))continue;
|
|
26
|
+
result.push({axis,bone:c.bone,slider:c.name,channel,min:Math.min(a,b),max:Math.max(a,b),provenance:`model slider ${c.name} → bone ${c.bone}, ${c.property??'rotate'}, animation ${c.animation}${c.bone!==canonical?' (compiled body_turn binding)':''}`});
|
|
27
|
+
}return result;
|
|
28
|
+
}
|
|
29
|
+
export function sharedTurnDials(a:readonly TurnDial[],b:readonly TurnDial[]):TurnDial[]{
|
|
30
|
+
return a.flatMap(d=>{const other=b.find(e=>e.axis===d.axis);if(!other)return [];const min=Math.max(d.min,other.min),max=Math.min(d.max,other.max);return min<max?[{...d,min,max}]:[];});
|
|
31
|
+
}
|
|
32
|
+
export function pointerPosition(x:number,y:number,rect:{left:number;top:number;width:number;height:number}):[number,number]{
|
|
33
|
+
return rect.width>0&&rect.height>0?[clamp((x-rect.left)/rect.width*2-1,-1,1),clamp(1-(y-rect.top)/rect.height*2,-1,1)]:[0,0];
|
|
34
|
+
}
|
|
35
|
+
export function normalizedDial(n:number,range:{min:number;max:number}):number {const v=clamp(n,-1,1),zero=clamp(0,range.min,range.max);return clamp(zero+Math.abs(v)*((v<0?range.min:range.max)-zero),range.min,range.max);}
|
|
36
|
+
/** Exact critically damped solution from https://rig-c.org/demos/head-turn/bundle/app.js (HEAD_SPRING_OMEGA0=20). */
|
|
37
|
+
export function springStep(s:{x:number;v:number},target:number,dt:number,omega=20):void {
|
|
38
|
+
if(!(dt>0)||!finite(dt))return;const e=s.x-target,tau=omega*dt,exp=Math.exp(-tau),x=target+(e*(1+tau)+s.v*dt)*exp,v=(s.v*(1-tau)-omega*omega*dt*e)*exp;
|
|
39
|
+
s.x=Math.abs(x-target)<1e-12&&Math.abs(v)<1e-9?target:x;s.v=s.x===target?0:v;
|
|
40
|
+
}
|
|
41
|
+
export function parseTurnSnapshot(value:unknown):TurnSnapshot {
|
|
42
|
+
const o=object(value);if(!o||o.schema!=='rig-review-turn/1'||Object.keys(o).some(k=>!['schema','values','held','smoothing'].includes(k))||typeof o.held!=='boolean'||!finite(o.smoothing)||o.smoothing<0||o.smoothing>100)throw new Error('TURN_STATE_INVALID');
|
|
43
|
+
const values=object(o.values);if(!values||Object.keys(values).some(k=>!['yaw','pitch','body'].includes(k)||!finite(values[k])))throw new Error('TURN_VALUES_INVALID');
|
|
44
|
+
return {schema:o.schema,values:{...values},held:o.held,smoothing:o.smoothing};
|
|
45
|
+
}
|
|
46
|
+
const normalizedValue=(value:number,d:TurnDial)=>value<0?d.min<0?-value/d.min:0:d.max>0?value/d.max:0;
|
|
47
|
+
export class TurnState {
|
|
48
|
+
values:Partial<Record<TurnAxis,number>>={};held=false;smoothing=20;
|
|
49
|
+
readonly targets:Partial<Record<TurnAxis,number>>={};private springs=new Map<TurnAxis,{x:number;v:number}>();
|
|
50
|
+
constructor(readonly dials:readonly TurnDial[]){for(const d of dials){this.values[d.axis]=0;this.targets[d.axis]=0;this.springs.set(d.axis,{x:0,v:0});}}
|
|
51
|
+
pointer(x:number,y:number):void {if(this.held)return;for(const d of this.dials)this.targets[d.axis]=normalizedDial(d.axis==='pitch'?y:x,d);}
|
|
52
|
+
step(dt:number):boolean {let changed=false;if(this.held)return false;for(const d of this.dials){const s=this.springs.get(d.axis)!,target=this.targets[d.axis]!;if(this.smoothing===0){s.x=normalizedValue(target,d);s.v=0;}else springStep(s,normalizedValue(target,d),dt,this.smoothing);const next=this.smoothing===0?target:normalizedDial(s.x,d);changed||=next!==this.values[d.axis];this.values[d.axis]=next;}return changed;}
|
|
53
|
+
hold(value:boolean):void {this.held=value;for(const d of this.dials){const x=this.values[d.axis]!;this.targets[d.axis]=x;Object.assign(this.springs.get(d.axis)!,{x:normalizedValue(x,d),v:0});}}
|
|
54
|
+
exact(axis:TurnAxis,value:number):void {const d=this.dials.find(d=>d.axis===axis);if(!d||!finite(value)||value<d.min||value>d.max)throw new Error('TURN_VALUE_OUT_OF_RANGE');this.hold(true);this.values[axis]=value;this.targets[axis]=value;Object.assign(this.springs.get(axis)!,{x:normalizedValue(value,d),v:0});}
|
|
55
|
+
reset():void {this.hold(true);for(const d of this.dials)this.exact(d.axis,0);}
|
|
56
|
+
snapshot():TurnSnapshot{return {schema:'rig-review-turn/1',values:{...this.values},held:this.held,smoothing:this.smoothing};}
|
|
57
|
+
validate(value:unknown):TurnSnapshot {const v=parseTurnSnapshot(value);if(Object.keys(v.values).length!==this.dials.length||this.dials.some(d=>!finite(v.values[d.axis])||v.values[d.axis]!<d.min||v.values[d.axis]!>d.max))throw new Error('TURN_STATE_MODEL_MISMATCH');return v;}
|
|
58
|
+
restore(value:unknown):void {const v=this.validate(value);for(const d of this.dials)this.exact(d.axis,v.values[d.axis]!);this.smoothing=v.smoothing;this.hold(v.held);}
|
|
59
|
+
adjust(dials:readonly TurnDial[]=this.dials):BoneAdjust {return bones=>{for(const d of dials){const value=this.values[d.axis];if(value!==undefined)bones.bone(d.bone)[d.channel]=value;}};}
|
|
60
|
+
}
|
package/web/compare.css
CHANGED
|
@@ -121,3 +121,13 @@ body{display:grid;grid-template-rows:auto minmax(0,1fr) auto}.layout{grid-templa
|
|
|
121
121
|
|
|
122
122
|
@media(pointer:coarse){.workspace-separator::after{inset:0 -7px}.workspace-inspectorHeight::after{inset:-7px 0}.workspace-drawer-triggers button{min-height:38px}}
|
|
123
123
|
#stage-layout[data-panes="1"]{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
|
|
124
|
+
|
|
125
|
+
/* Model-bound inspection dials; stage and Inspector remain separate. */
|
|
126
|
+
#turn-pad-host{flex:none;max-height:180px;overflow:auto;border-bottom:1px solid var(--border);background:var(--bg)}
|
|
127
|
+
.turn-pad{display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;padding:8px 12px}
|
|
128
|
+
.turn-pad>span{color:var(--fg-muted);font-size:11px}
|
|
129
|
+
.turn-pad label{display:flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}
|
|
130
|
+
.turn-pad input[type=number]{width:78px;color:var(--fg);background:var(--rv-control);border:1px solid var(--border-strong);padding:4px}
|
|
131
|
+
.turn-pad output{font-variant-numeric:tabular-nums;color:var(--fg-strong)}
|
|
132
|
+
.turn-pad button[aria-pressed=true]{border-color:var(--pass);color:var(--pass)}
|
|
133
|
+
#turn-pad-host>p{margin:0 12px 6px}
|
package/web/compare.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {sharedTurnDials,type TurnDial} from '../src/turn-pad.ts';
|
|
2
|
+
import {bindTurnPad,type TurnPad} from './turn-pad.ts';
|
|
1
3
|
import {PartExplorer,loadPartCatalog} from './part-selection.ts';
|
|
2
4
|
import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,boundsFitSignature,dragSignature,zoomSignature,type CameraFrame,type CameraSignature,type PaneCamera} from './camera-view.ts';
|
|
3
5
|
import { loadModel, RigPlayer, type ModelAssets, type PoseBounds } from '@rig-c/play';
|
|
@@ -41,7 +43,7 @@ let cameraSync=true,activeCamera:CameraTarget='a',cameraRestoring=false,cameraLa
|
|
|
41
43
|
let idleHeld=true,loopRestarting=false,suspendedPreview=false;
|
|
42
44
|
let appearance:ReturnType<typeof bindAppearance>|null=null,controlDispose:(()=>void)|null=null,workspace:WorkspaceUI|null=null;
|
|
43
45
|
const viewDisposers:(()=>void)[]=[];
|
|
44
|
-
let parts:PartExplorer|null=null;
|
|
46
|
+
let parts:PartExplorer|null=null,turnPad:TurnPad|null=null;
|
|
45
47
|
const overlays:Partial<Record<Side,RigOverlays>>={};
|
|
46
48
|
function inspectionPlayer(side:Side):InspectionPlayer {const player=players[side];if(!player)throw new Error('Part player unavailable');return {get time(){return player.time;},pause:()=>player.pause(),seek:time=>player.seek(time),draw:()=>{player.draw();parts?.present(side);}};}
|
|
47
49
|
const layers={images:true,mesh:false,bones:false,meshOpacity:.65};
|
|
@@ -225,6 +227,14 @@ function fit(): void {
|
|
|
225
227
|
redrawOverlays(false);referenceFit();renderCameraControls();renderNumericView();
|
|
226
228
|
}
|
|
227
229
|
|
|
230
|
+
function configureTurnPad():void {
|
|
231
|
+
const a=data.a.feedbackCatalog.turnDials??[],b=data.b.feedbackCatalog.turnDials??[],dials=sharedTurnDials(a,b);
|
|
232
|
+
if(!dials.length)return;
|
|
233
|
+
const host=document.createElement('div');host.id='turn-pad-host';el('stage-layout').before(host);
|
|
234
|
+
turnPad=bindTurnPad({host,stages:[el('a-viewport'),el('b-viewport')],dials,blocked:()=>seeking||video.seeking||!!persistence?.restoring()||!ready,
|
|
235
|
+
draw:state=>{if(!players.a||!players.b)return;try{for(const side of ['a','b'] as const){const source=side==='a'?a:b;players[side]!.adjust=state.adjust(source.filter(d=>dials.some(shared=>shared.axis===d.axis)));}players.a.draw();parts?.present('a');players.b.draw();parts?.present('b');redrawOverlays(true);}catch(error){failPair(error);throw error;}},save:()=>persistence?.save(),error:value=>message(value,true)});
|
|
236
|
+
const note=document.createElement('p');note.className='hint';note.textContent='Both panes share the same values over the intersection of their declared ranges. Axes absent on either side are not offered.';host.append(note);
|
|
237
|
+
}
|
|
228
238
|
async function loadSide(side: Side, value: CompareSide): Promise<number> {
|
|
229
239
|
text(`${side}-title`, value.manifest.title);
|
|
230
240
|
if (!value.manifest.model) throw new Error(`${side.toUpperCase()}: compiled model Not supplied`);
|
|
@@ -370,7 +380,7 @@ function bind(): void {
|
|
|
370
380
|
if (!['bone', 'slot', 'rig'].includes(kind) || !['defect', 'quality'].includes(category) || !name || !summary || /[\r\n]/.test(summary) || summary.length > 1000) { message('Enter a target type, actual name, category and one-line summary of up to 1,000 characters.', true); return; }
|
|
371
381
|
const target: CompareFeedback['target'] = { kind: kind as CompareFeedback['target']['kind'], name };
|
|
372
382
|
try { validateFeedbackSelection(data[side].feedbackCatalog, { frameStart, frameEnd, target, animation: data.animation }); } catch (error) { message(describe(error), true); return; }
|
|
373
|
-
session.feedback.push({ id: crypto.randomUUID(), side, frameStart, frameEnd, target, category: category as CompareFeedback['category'], summary, context: { interval: { frameStart, frameEnd, inclusive: true }, animation: data.animation, mediaTime: inspected.reference, clockSource: inspected.source, frame: currentFrame(side), frames: { a: currentFrame('a'), b: currentFrame('b') }, playheads: { reference: inspected.reference, a: inspected.a, b: inspected.b },reviewFrames:{a:currentReviewFrame('a'),b:currentReviewFrame('b')},requestedProducer,part:parts?.feedback()??null,view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},decodedIndex:referenceFrames?referenceFrameAtTime(referenceFrames,inspected.reference)?.index??null:null } });
|
|
383
|
+
session.feedback.push({ id: crypto.randomUUID(), side, frameStart, frameEnd, target, category: category as CompareFeedback['category'], summary, context: { ...(turnPad?{turn:JSON.parse(JSON.stringify(turnPad.snapshot()))}:{}),interval: { frameStart, frameEnd, inclusive: true }, animation: data.animation, mediaTime: inspected.reference, clockSource: inspected.source, frame: currentFrame(side), frames: { a: currentFrame('a'), b: currentFrame('b') }, playheads: { reference: inspected.reference, a: inspected.a, b: inspected.b },reviewFrames:{a:currentReviewFrame('a'),b:currentReviewFrame('b')},requestedProducer,part:parts?.feedback()??null,view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},decodedIndex:referenceFrames?referenceFrameAtTime(referenceFrames,inspected.reference)?.index??null:null } });
|
|
374
384
|
input('summary').value = ''; markDirty(); renderFeedback(); message('Added feedback bound to the target build and both reviewed sources.');
|
|
375
385
|
};
|
|
376
386
|
button('submit').onclick = async () => { if(readOnly){message(readOnly,true);return;}if(draftConflict){message('Resolve the saved local draft conflict before submitting.',true);return;}if (session.feedbackWarnings.length) { message('Resolve validation warnings in existing feedback before submitting. Delete the marked entries and add valid replacements.', true); return; } saving = true; savedState(); const payload = JSON.stringify(session); try { const saved = await api<CompareSession>('/api/compare/session', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: payload }); if (JSON.stringify(session) === payload) { session = saved; dirty = false; } else session.updatedAt = saved.updatedAt;await persistence?.submitted(saved); message(dirty ? 'Changes were made during submission. Submit again.' : 'Submitted the selected review status and A/B/reference-video provenance.'); } catch (error) { message(`Submission failed: ${describe(error)}`, true); } finally { saving = false; savedState(); } };
|
|
@@ -390,7 +400,7 @@ function bind(): void {
|
|
|
390
400
|
document.addEventListener('visibilitychange',()=>{if(document.hidden){suspendedPreview=!video.paused;stop();}else if(suspendedPreview&&!idleHeld){suspendedPreview=false;void playComparison().catch(error=>message(describe(error),true));}});
|
|
391
401
|
const observer = new ResizeObserver(fit); observer.observe(el('a-viewport')); observer.observe(el('b-viewport'));observer.observe(el('reference-viewport'));
|
|
392
402
|
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
|
393
|
-
window.addEventListener('pagehide', () => { persistence?.dispose();workspace?.dispose();disposed = true; seekAbort?.abort();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());for(const overlay of Object.values(overlays))overlay.dispose();stop(); if (videoCallback !== null) video.cancelVideoFrameCallback(videoCallback); if (fallbackCallback !== null) cancelAnimationFrame(fallbackCallback); for (const side of ['a', 'b'] as const) { players[side]?.dispose(); assets[side]?.dispose(); } observer.disconnect(); });
|
|
403
|
+
window.addEventListener('pagehide', () => { persistence?.dispose();turnPad?.dispose();workspace?.dispose();disposed = true; seekAbort?.abort();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());for(const overlay of Object.values(overlays))overlay.dispose();stop(); if (videoCallback !== null) video.cancelVideoFrameCallback(videoCallback); if (fallbackCallback !== null) cancelAnimationFrame(fallbackCallback); for (const side of ['a', 'b'] as const) { players[side]?.dispose(); assets[side]?.dispose(); } observer.disconnect(); });
|
|
394
404
|
}
|
|
395
405
|
|
|
396
406
|
async function start(): Promise<void> {
|
|
@@ -411,7 +421,7 @@ async function start(): Promise<void> {
|
|
|
411
421
|
void loadFrameIndex();
|
|
412
422
|
input('scrub').max = String(domain.end); input('time-input').max = String(domain.end); text('domain', `Shared range 0–${domain.end} s · Neither side is independently clamped or looped`); ready = true;
|
|
413
423
|
input('scrub').disabled = false; input('time-input').disabled = false; button('seek').disabled = false; button('play').disabled = domain.end === 0;
|
|
414
|
-
await seekTo(0);activeCamera='a';if(cameraSync)alignCameras();fit();for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;renderIdle();const restored=await setupPersistence();if(!restored&&canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));
|
|
424
|
+
await seekTo(0);configureTurnPad();activeCamera='a';if(cameraSync)alignCameras();fit();for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;renderIdle();const restored=await setupPersistence();if(!restored&&canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));
|
|
415
425
|
if(!readOnly&&controlServed)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
416
426
|
} catch (error) { failPair(error); for (const side of ['a', 'b'] as const) if (!players[side]) text(`${side}-empty`, `${side.toUpperCase()} Could not open comparison rig.`); text('reference-empty', 'Check the reference video and comparison inputs.'); }
|
|
417
427
|
}
|
|
@@ -516,10 +526,11 @@ function numericView():ControlView {
|
|
|
516
526
|
}
|
|
517
527
|
function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
|
|
518
528
|
function inspectionBinding():InspectionBinding{return {mode:'compare',sourceHash:data.sourceHash,rigs:[data.a.reviewedSource.rig?.sha256??null,data.b.reviewedSource.rig?.sha256??null]};}
|
|
519
|
-
function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:data.animation,time:trace?.reference??presentation.snapshot().lastDrawn?.reference??null,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{part:parts?.value()??null,reference:data.reference.sha256,inspector:document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector??'view'}};}
|
|
529
|
+
function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:data.animation,time:trace?.reference??presentation.snapshot().lastDrawn?.reference??null,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{...(turnPad?{turn:turnPad.snapshot()}:{}),part:parts?.value()??null,reference:data.reference.sha256,inspector:document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector??'view'}};}
|
|
520
530
|
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
521
531
|
await restoreTransaction(async()=>{
|
|
522
532
|
|
|
533
|
+
if(bookmark.selection.turn){if(!turnPad)throw new Error('TURN_UNAVAILABLE');turnPad.state.validate(bookmark.selection.turn);}
|
|
523
534
|
parts?.validate(bookmark.selection.part);
|
|
524
535
|
if(bookmark.animation!==data.animation||bookmark.selection.reference!==data.reference.sha256)throw new Error('Saved comparison animation/reference requires a different source binding');
|
|
525
536
|
if(!domain)throw new Error('Comparison is not ready');if(bookmark.time!==null)requireCommonTime(bookmark.time,domain);
|
|
@@ -530,6 +541,7 @@ async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal)
|
|
|
530
541
|
}
|
|
531
542
|
async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
532
543
|
cameraRestoring=true;try{
|
|
544
|
+
if(turnPad){if(bookmark.selection.turn)turnPad.restore(bookmark.selection.turn);else{turnPad.state.reset();turnPad.restore(turnPad.snapshot());}}
|
|
533
545
|
parts?.select(bookmark.selection.part??null);
|
|
534
546
|
idleHeld=true;stop();if(bookmark.time!==null){await seekTo(bookmark.time);if(signal.aborted||!trace)throw new Error('Saved reference inspection could not settle');}
|
|
535
547
|
appearance?.set(bookmark.theme);Object.assign(layers,{images:bookmark.layers.images,mesh:bookmark.layers.meshes,bones:bookmark.layers.bones});input('layer-images').checked=layers.images;input('layer-mesh').checked=layers.mesh;input('layer-bones').checked=layers.bones;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {parseTurnSnapshot,type TurnSnapshot} from '../src/turn-pad.ts';
|
|
1
2
|
/** Versioned local drafts and public numeric view links. No capability or review decision is put in a URL. */
|
|
2
3
|
import {parseControlPart,parseControlView,type ControlPart,type ControlView} from '../src/control.ts';
|
|
3
4
|
import type {Json} from '../src/manifest.ts';
|
|
@@ -8,7 +9,7 @@ export function serializableView(view:ControlView):Record<string,Json> {
|
|
|
8
9
|
if(view.cameraSync!==undefined)value.cameraSync=view.cameraSync;if(view.activeCamera!==undefined)value.activeCamera=view.activeCamera;return value;
|
|
9
10
|
}
|
|
10
11
|
export interface InspectionBinding {mode:'single'|'compare';sourceHash:string;rigs:(string|null)[]}
|
|
11
|
-
export interface InspectionSelection {part?:ControlPart|null;workspace?:'modeling'|'motion';view?:'compare'|'reference'|'rig';checkpoint?:string|null;reference?:string|null;person?:string|null;inspector?:string;partSet?:string|null;axis?:{id:string;value:number}|null}
|
|
12
|
+
export interface InspectionSelection {turn?:TurnSnapshot;part?:ControlPart|null;workspace?:'modeling'|'motion';view?:'compare'|'reference'|'rig';checkpoint?:string|null;reference?:string|null;person?:string|null;inspector?:string;partSet?:string|null;axis?:{id:string;value:number}|null}
|
|
12
13
|
export interface InspectionBookmark {
|
|
13
14
|
schema:'rig-review-inspection/1';binding:InspectionBinding;animation:string|null;time:number|null;
|
|
14
15
|
theme:'system'|'dark'|'light';layers:{images:boolean;meshes:boolean;bones:boolean};view:ControlView;selection:InspectionSelection;
|
|
@@ -33,13 +34,14 @@ export function parseBookmark(value:unknown,expected?:InspectionBinding):Inspect
|
|
|
33
34
|
if(o.time!==null&&(typeof o.time!=='number'||!Number.isFinite(o.time)||o.time<0||o.time>Number.MAX_SAFE_INTEGER))throw new Error('View time requires finite nonnegative seconds');
|
|
34
35
|
if(!['system','dark','light'].includes(String(o.theme)))throw new Error('View appearance invalid');
|
|
35
36
|
const layers=record(o.layers,'layers');keys(layers,['images','meshes','bones'],'layers');for(const k of ['images','meshes','bones'])if(typeof layers[k]!=='boolean')throw new Error('View layers must be boolean');
|
|
36
|
-
const selection=record(o.selection,'selection');keys(selection,['workspace','view','checkpoint','reference','person','inspector','partSet','axis','part'],'selection');
|
|
37
|
+
const selection=record(o.selection,'selection');keys(selection,['workspace','view','checkpoint','reference','person','inspector','partSet','axis','part','turn'],'selection');
|
|
37
38
|
if(selection.workspace!==undefined&&!['modeling','motion'].includes(String(selection.workspace)))throw new Error('View workspace invalid');
|
|
38
39
|
if(selection.view!==undefined&&!['compare','reference','rig'].includes(String(selection.view)))throw new Error('View selection invalid');
|
|
39
40
|
for(const k of ['checkpoint','reference','person','inspector','partSet'])if(selection[k]!==undefined&&selection[k]!==null)text(selection[k],k);
|
|
40
41
|
if(selection.axis!==undefined&&selection.axis!==null){const axis=record(selection.axis,'axis');keys(axis,['id','value'],'axis');text(axis.id,'axis id');if(typeof axis.value!=='number'||!Number.isFinite(axis.value))throw new Error('View axis value invalid');}
|
|
41
42
|
if(selection.inspector!==undefined&&!['view','rig','playback','feedback'].includes(String(selection.inspector)))throw new Error('Inspector selection invalid');
|
|
42
43
|
if(selection.part!==undefined){const part=parseControlPart(selection.part);if(part&&(binding.mode==='single'?part.side!=='single':part.side==='single'))throw new Error('View part side unavailable in this review mode');selection.part=part;}
|
|
44
|
+
if(selection.turn!==undefined)selection.turn=parseTurnSnapshot(selection.turn);
|
|
43
45
|
const view=parseControlView(o.view);
|
|
44
46
|
if(view.activeCamera&&(binding.mode==='single'&&(view.activeCamera==='a'||view.activeCamera==='b')||binding.mode==='compare'&&view.activeCamera==='rig'))throw new Error('View active camera is unavailable in this review mode');
|
|
45
47
|
return {schema:o.schema,binding,animation,time:o.time as number|null,theme:o.theme as InspectionBookmark['theme'],layers:{images:layers.images as boolean,meshes:layers.meshes as boolean,bones:layers.bones as boolean},view,selection:{...selection} as InspectionSelection};
|
package/web/main.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {discoverTurnDials,TurnState} from '../src/turn-pad.ts';
|
|
2
|
+
import {bindTurnPad,type TurnPad} from './turn-pad.ts';
|
|
1
3
|
import {PartExplorer,loadPartModel,partBounds} from './part-selection.ts';
|
|
2
4
|
import {AnimationInspectionPanel} from './animation-inspection.ts';
|
|
3
5
|
import {PartThumbnails,pageRegionTable} from './part-thumbnails.ts';
|
|
@@ -155,7 +157,8 @@ let joltQueue:JoltStep[]=[],environmentNote='';
|
|
|
155
157
|
let idlePanel:IdlePanel|null=null;
|
|
156
158
|
/** Setup · Rig fields (#110): the panel; its turn dial and the idle preview share `player.adjust` (both run, idle first). */
|
|
157
159
|
let rigFieldsPanel:RigFieldsPanel|null=null,idleAdjust:BoneAdjust|null=null,dialAdjust:BoneAdjust|null=null;
|
|
158
|
-
|
|
160
|
+
let turnPad:TurnPad|null=null,turnAdjust:BoneAdjust|null=null;
|
|
161
|
+
function setAdjust():void {if(!player)return;player.adjust=composeAdjust(composeAdjust(idleAdjust,dialAdjust),turnAdjust);if(!playing)drawRig(()=>player!.draw());}
|
|
159
162
|
/** #106: the Setup Mesh panel, and the overlay's last snapshot (bar 1 reads its per-slot triangle count). */
|
|
160
163
|
let meshPanel:MeshPanel|null=null,lastOverlay:OverlaySnapshot|null=null,densityStrip:DensityStripPanel|null=null;
|
|
161
164
|
const physicsPreview=new PhysicsPreview();
|
|
@@ -639,6 +642,14 @@ function tick(now: number): void {
|
|
|
639
642
|
requestAnimationFrame(tick);
|
|
640
643
|
}
|
|
641
644
|
|
|
645
|
+
function configureTurnPad(model:unknown):void {
|
|
646
|
+
const previous=turnPad?.snapshot();turnPad?.dispose();turnPad=null;turnAdjust=null;document.getElementById('turn-pad-host')?.remove();
|
|
647
|
+
const dials=discoverTurnDials(model,assets?.animations);if(!dials.length){setAdjust();return;}
|
|
648
|
+
const host=document.createElement('div');host.id='turn-pad-host';el('stage-layout').before(host);
|
|
649
|
+
turnPad=bindTurnPad({host,stages:[el('rig-viewport')],dials,blocked:()=>!!persistence?.restoring()||!!document.querySelector('dialog[open]')||!!rebuildBusy,
|
|
650
|
+
draw:state=>{if(!player)return;turnAdjust=state.adjust();setAdjust();if(playing)drawRig(()=>player!.draw());},save:()=>persistence?.save(),error:value=>message(value,true)});
|
|
651
|
+
if(previous&&turnPad){try{turnPad.restore(previous);}catch{message('TURN_STATE_MODEL_MISMATCH: turn values reset for this build.',true);}}
|
|
652
|
+
}
|
|
642
653
|
async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
643
654
|
text('renderer-state',rendererStatus(null,'pending').label);
|
|
644
655
|
if (!data.manifest.model) { text('player-state', 'No rig supplied · reference review is available'); return; }
|
|
@@ -651,7 +662,7 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
|
651
662
|
} });
|
|
652
663
|
player = new RigPlayer(el('rig-root'), assets);
|
|
653
664
|
// Parts inspection is an add-on: a catalog refusal is shown in the explorer and never aborts the rig.
|
|
654
|
-
try{const catalog=await loadPartModel(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]);inspectionSourceState='ready';partModel=catalog.model;refreshAnimationInspection();weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){inspectionSourceState='failed';partModel=null;refreshAnimationInspection();parts?.refuseCatalog('single',error);}
|
|
665
|
+
try{const catalog=await loadPartModel(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]);inspectionSourceState='ready';partModel=catalog.model;configureTurnPad(catalog.model);refreshAnimationInspection();weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){inspectionSourceState='failed';partModel=null;refreshAnimationInspection();parts?.refuseCatalog('single',error);}
|
|
655
666
|
player.on('event', event => { text('event-log', `Runtime event: ${event.name} · ${event.time.toFixed(2)} s`); });
|
|
656
667
|
const animations = select('animation-select'); animations.replaceChildren();
|
|
657
668
|
for (const item of assets.animations) animations.append(option(item.name, data.manifest.animations.find(a => a.id === item.name)?.label ?? item.name));
|
|
@@ -668,7 +679,7 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
|
668
679
|
enableEnvironment(modelUrl);
|
|
669
680
|
describeLayerToggles(document,el('layer-state').textContent??'');
|
|
670
681
|
fitCamera();
|
|
671
|
-
} 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); }
|
|
682
|
+
} catch (error) { turnPad?.dispose();turnPad=null;document.getElementById('turn-pad-host')?.remove();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); }
|
|
672
683
|
}
|
|
673
684
|
|
|
674
685
|
function chooseAxis(): void {
|
|
@@ -850,7 +861,7 @@ function bind(): void {
|
|
|
850
861
|
try { validateFeedbackSelection(data.feedbackCatalog, { frameStart, frameEnd, target, animation: selectedAnimation }); } catch (error) { message(errorText(error), true); return; }
|
|
851
862
|
const box = assets?.stage ?? player?.bounds();
|
|
852
863
|
const center = cameraCenter ?? (box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : null);
|
|
853
|
-
session.feedback.push({ id: crypto.randomUUID(), frameStart, frameEnd, target, category: select('feedback-category').value as Feedback['category'], summary, context: {part:parts?.feedback()??null,
|
|
864
|
+
session.feedback.push({ id: crypto.randomUUID(), frameStart, frameEnd, target, category: select('feedback-category').value as Feedback['category'], summary, context: {...(turnPad?{turn:JSON.parse(JSON.stringify(turnPad.snapshot())) as Json}:{}),part:parts?.feedback()??null,
|
|
854
865
|
interval: { frameStart, frameEnd, inclusive: true },
|
|
855
866
|
checkpoint: checkpoint?.id ?? null, animation: selectedAnimation, time, frame: frameAtTime(activeFrameClock(), time),
|
|
856
867
|
reference: select('reference-select').value || null, person: selectedPerson || null,
|
|
@@ -941,7 +952,7 @@ function bind(): void {
|
|
|
941
952
|
document.addEventListener('visibilitychange',()=>{if(document.hidden){const resume=playing;pause();suspendedPreview=resume;}else if(suspendedPreview){suspendedPreview=false;void playPreview();}});
|
|
942
953
|
const resize = new ResizeObserver(fitCamera); resize.observe(el('rig-viewport')); resize.observe(el('reference-viewport'));
|
|
943
954
|
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
|
944
|
-
window.addEventListener('pagehide', () => { persistence?.dispose();workspace?.dispose();pause();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());overlays?.dispose(); player?.dispose(); assets?.dispose(); resize.disconnect(); });
|
|
955
|
+
window.addEventListener('pagehide', () => { persistence?.dispose();turnPad?.dispose();workspace?.dispose();pause();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());overlays?.dispose(); player?.dispose(); assets?.dispose(); resize.disconnect(); });
|
|
945
956
|
}
|
|
946
957
|
|
|
947
958
|
async function start(): Promise<void> {
|
|
@@ -1076,7 +1087,7 @@ function numericView():ControlView {
|
|
|
1076
1087
|
}
|
|
1077
1088
|
function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
|
|
1078
1089
|
function inspectionBinding():InspectionBinding{return {mode:'single',sourceHash:data.sourceHash,rigs:[data.reviewedSource.rig?.sha256??null]};}
|
|
1079
|
-
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}};}
|
|
1090
|
+
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:{...(turnPad?{turn:turnPad.snapshot()}:{}),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}};}
|
|
1080
1091
|
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
1081
1092
|
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();});
|
|
1082
1093
|
}
|
|
@@ -1092,6 +1103,7 @@ async function inspectionReferenceInfo(id:string|null,signal:AbortSignal):Promis
|
|
|
1092
1103
|
}
|
|
1093
1104
|
async function prepareInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
1094
1105
|
const selection=bookmark.selection;
|
|
1106
|
+
if(selection.turn){if(!turnPad)throw new Error('TURN_UNAVAILABLE');turnPad.state.validate(selection.turn);}
|
|
1095
1107
|
parts?.validate(selection.part);
|
|
1096
1108
|
if(player&&!bookmark.animation||bookmark.animation&&!assets?.animations.some(a=>a.name===bookmark.animation))throw new Error('Saved animation is absent from this rig');
|
|
1097
1109
|
if(selection.reference&&!data.manifest.references.some(r=>r.id===selection.reference))throw new Error('Saved reference is not declared');
|
|
@@ -1114,6 +1126,7 @@ async function prepareInspection(bookmark:InspectionBookmark,signal:AbortSignal)
|
|
|
1114
1126
|
async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
1115
1127
|
cameraRestoring=true;try{
|
|
1116
1128
|
const selection=bookmark.selection;
|
|
1129
|
+
if(turnPad){if(selection.turn)turnPad.restore(selection.turn);else{turnPad.state.reset();turnPad.restore(turnPad.snapshot());}}
|
|
1117
1130
|
parts?.select(selection.part??null);
|
|
1118
1131
|
pause();
|
|
1119
1132
|
if(selection.checkpoint!==undefined){checkpoint=data.manifest.checkpoints.find(c=>c.id===selection.checkpoint);renderCheckpoint();renderCheckpoints();}
|
|
@@ -1936,7 +1949,7 @@ async function swapRig(modelUrl:string,prefix:string,sha256:string,world:StageWo
|
|
|
1936
1949
|
// A scratch build may hold another set of animations (a retarget adds one, #114): the picker lists what this build has.
|
|
1937
1950
|
{const animations=select('animation-select');animations.replaceChildren(...assets.animations.map(item=>option(item.name,data.manifest.animations.find(a=>a.id===item.name)?.label??item.name)));animations.disabled=assets.animations.length===0;}
|
|
1938
1951
|
player.on('event',event=>{text('event-log',`Runtime event: ${event.name} · ${event.time.toFixed(2)} s`);});
|
|
1939
|
-
try{const catalog=await loadPartModel(modelUrl,sha256,data.feedbackCatalog.slots??[]);inspectionSourceState='ready';partModel=catalog.model;refreshAnimationInspection();weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){inspectionSourceState='failed';partModel=null;refreshAnimationInspection();parts?.refuseCatalog('single',error);}
|
|
1952
|
+
try{const catalog=await loadPartModel(modelUrl,sha256,data.feedbackCatalog.slots??[]);inspectionSourceState='ready';partModel=catalog.model;configureTurnPad(catalog.model);refreshAnimationInspection();weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){inspectionSourceState='failed';partModel=null;refreshAnimationInspection();parts?.refuseCatalog('single',error);}
|
|
1940
1953
|
if(keepSet){try{player.setSet(keepSet);}catch{select('part-set').value='';player.setSet(null);}}
|
|
1941
1954
|
// The same world before the first pose: a physics Apply resumes the wind (clock and pulse), the pending jolts, gravity and
|
|
1942
1955
|
// whether the controls are engaged as they stood when Apply was pressed; then the seek re-poses under that world.
|
package/web/styles.css
CHANGED
|
@@ -294,3 +294,13 @@ footer.status-bar{flex-wrap:nowrap}
|
|
|
294
294
|
.animation-bone-pick[aria-pressed=true]{outline:3px solid var(--rv-bone-selected);outline-offset:2px}
|
|
295
295
|
|
|
296
296
|
#motion-tools>.animation-inspection{flex-basis:100%;min-width:0}
|
|
297
|
+
|
|
298
|
+
/* Model-bound inspection dials; stage and Inspector remain separate. */
|
|
299
|
+
#turn-pad-host{flex:none;max-height:180px;overflow:auto;border-bottom:1px solid var(--border);background:var(--bg)}
|
|
300
|
+
.turn-pad{display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;padding:8px 12px}
|
|
301
|
+
.turn-pad>span{color:var(--fg-muted);font-size:11px}
|
|
302
|
+
.turn-pad label{display:flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}
|
|
303
|
+
.turn-pad input[type=number]{width:78px;color:var(--fg);background:var(--rv-control);border:1px solid var(--border-strong);padding:4px}
|
|
304
|
+
.turn-pad output{font-variant-numeric:tabular-nums;color:var(--fg-strong)}
|
|
305
|
+
.turn-pad button[aria-pressed=true]{border-color:var(--pass);color:var(--pass)}
|
|
306
|
+
#turn-pad-host>p{margin:0 12px 6px}
|
package/web/turn-pad.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {TurnState,type TurnDial,type TurnSnapshot,type TurnAxis,pointerPosition} from '../src/turn-pad.ts';
|
|
2
|
+
export interface TurnPad {state:TurnState;snapshot():TurnSnapshot;restore(value:unknown):void;dispose():void}
|
|
3
|
+
/** One pad owns the shared values. It never touches the model document or writes animation keys. */
|
|
4
|
+
export function bindTurnPad(options:{host:HTMLElement;stages:HTMLElement[];dials:readonly TurnDial[];draw:(state:TurnState)=>void;save:()=>void;blocked?:()=>boolean;error:(text:string)=>void}):TurnPad|null {
|
|
5
|
+
const {host,stages,dials}=options;if(!dials.length)return null;
|
|
6
|
+
const state=new TurnState(dials),panel=document.createElement('section');panel.id='turn-pad';panel.className='turn-pad';panel.setAttribute('aria-label','Turn pad');
|
|
7
|
+
const title=document.createElement('strong');title.textContent='Turn pad';panel.append(title);
|
|
8
|
+
const hint=document.createElement('span');hint.textContent='Move over the rig · left/right yaw and body · up/down pitch';panel.append(hint);
|
|
9
|
+
const hold=document.createElement('button');hold.type='button';hold.textContent='Hold';hold.setAttribute('aria-pressed','false');panel.append(hold);
|
|
10
|
+
const reset=document.createElement('button');reset.type='button';reset.textContent='Reset 0';panel.append(reset);
|
|
11
|
+
const smoothLabel=document.createElement('label'),smooth=document.createElement('input');smooth.type='checkbox';smooth.checked=true;smooth.setAttribute('aria-label','Turn smoothing');smoothLabel.append(smooth,' Smoothing ω₀ ');
|
|
12
|
+
const omega=document.createElement('input');omega.type='number';omega.min='1';omega.max='100';omega.step='1';omega.value='20';omega.setAttribute('aria-label','Turn smoothing omega');smoothLabel.append(omega,' s⁻¹');panel.append(smoothLabel);
|
|
13
|
+
const rows=new Map<TurnAxis,{number:HTMLInputElement;output:HTMLOutputElement;target:HTMLOutputElement}>();
|
|
14
|
+
for(const d of dials){const label=document.createElement('label');label.textContent=`${d.axis} · ${d.bone} [${d.min}, ${d.max}] ${d.channel==='rotation'?'°':d.channel}`;const number=document.createElement('input');number.type='number';number.min=String(d.min);number.max=String(d.max);number.step='any';number.value='0';number.setAttribute('aria-label',`Turn ${d.axis} value`);label.title=d.provenance??'';const output=document.createElement('output');output.dataset.turnAxis=d.axis;output.setAttribute('aria-label',`Current ${d.axis}`);const target=document.createElement('output');target.dataset.turnTarget=d.axis;label.append(number,' Applied ',output,' · target ',target);panel.append(label);rows.set(d.axis,{number,output,target});number.onchange=()=>{try{if(!number.value.trim())throw new Error('TURN_VALUE_REQUIRED');state.exact(d.axis,Number(number.value));commit();}catch(error){options.error(String(error));render();}};}
|
|
15
|
+
function render():void {hold.setAttribute('aria-pressed',String(state.held));smooth.checked=state.smoothing!==0;omega.disabled=!smooth.checked;if(state.smoothing)omega.value=String(state.smoothing);for(const [axis,row] of rows){const value=state.values[axis]!;row.output.value=String(Number(value.toFixed(6)));row.output.dataset.value=String(value);row.target.value=String(Number(state.targets[axis]!.toFixed(6)));if(document.activeElement!==row.number)row.number.value=String(state.targets[axis]);}}
|
|
16
|
+
let saveTimer:ReturnType<typeof setTimeout>|undefined;
|
|
17
|
+
function commit():void {options.draw(state);render();if(saveTimer)clearTimeout(saveTimer);saveTimer=setTimeout(options.save,150);}
|
|
18
|
+
hold.onclick=()=>{state.hold(!state.held);commit();};reset.onclick=()=>{state.reset();commit();};
|
|
19
|
+
smooth.onchange=()=>{state.smoothing=smooth.checked?Number(omega.value)||20:0;commit();};omega.onchange=()=>{const v=Number(omega.value);if(!Number.isFinite(v)||v<1||v>100){options.error('TURN_SMOOTHING_RANGE: use 1…100 s⁻¹');omega.value=String(state.smoothing||20);return;}state.smoothing=v;commit();};
|
|
20
|
+
const move=(event:PointerEvent)=>{if(event.buttons||options.blocked?.()||state.held)return;const stage=event.currentTarget as HTMLElement;const rect=stage.getBoundingClientRect();const [x,y]=pointerPosition(event.clientX,event.clientY,{left:rect.left,top:rect.top,width:Math.max(1,rect.width-1),height:Math.max(1,rect.height-1)});state.pointer(x,y);if(state.smoothing===0){if(state.step(0))commit();else render();}else render();};
|
|
21
|
+
for(const stage of stages)stage.addEventListener('pointermove',move);
|
|
22
|
+
let disposed=false,last=0,raf=0;
|
|
23
|
+
const step=(now:number)=>{if(disposed)return;if(!document.hidden&&!options.blocked?.()&&state.step(last?Math.min((now-last)/1000,.1):0))commit();last=now;raf=requestAnimationFrame(step);};
|
|
24
|
+
host.append(panel);commit();raf=requestAnimationFrame(step);
|
|
25
|
+
return {state,snapshot:()=>state.snapshot(),restore(value){state.restore(value);commit();},dispose(){disposed=true;if(saveTimer)clearTimeout(saveTimer);cancelAnimationFrame(raf);for(const stage of stages)stage.removeEventListener('pointermove',move);panel.remove();}};
|
|
26
|
+
}
|