@rig-c/review 0.13.1 → 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.
@@ -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
+ }