@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.
@@ -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
- function setAdjust():void {if(!player)return;player.adjust=composeAdjust(idleAdjust,dialAdjust);if(!playing)drawRig(()=>player!.draw());}
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}
@@ -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
+ }