Files
walkersim/src/ui.js

231 lines
7.8 KiB
JavaScript

import { degToRad, radToDeg } from './math.js';
import { Pane } from 'tweakpane';
export function wireUI(state, hooks) {
const paneHost = document.getElementById('tweakpane-root');
const pane = new Pane({
container: paneHost,
title: 'Controls',
expanded: true,
});
const params = {
speedRps: state.speedRps,
angleDeg: radToDeg(state.theta),
crankCm: state.lengthCm.crank,
legCm: state.lengthCm.leg,
tendonCm: state.lengthCm.tendon,
bodyCm: state.lengthCm.body,
playing: state.playing,
showNear: state.showNear,
showFar: state.showFar,
showTrace: state.showTrace,
showAnnotations: state.showAnnotations,
show3d: state.show3d,
canShow3d: state.canShow3d,
presetName: '',
selectedPreset: '',
};
function presetOptions() {
const names = hooks.listPresets();
if (names.length === 0) return { none: '' };
const options = {};
for (const name of names) {
options[name] = name;
}
return options;
}
let presetBinding = null;
function rebuildPresetList(preferredName = null) {
const options = presetOptions();
const optionValues = Object.values(options);
if (optionValues.length === 0) {
params.selectedPreset = '';
} else {
const next = preferredName && optionValues.includes(preferredName)
? preferredName
: optionValues[0];
params.selectedPreset = next;
}
if (presetBinding) {
presetBinding.dispose();
presetBinding = null;
}
presetBinding = presetsFolder.addBinding(params, 'selectedPreset', {
label: 'Saved preset',
options,
});
}
const motionFolder = pane.addFolder({ title: 'Motion' });
motionFolder.addBinding(params, 'speedRps', { label: 'Speed (rev/s)', min: 0, max: 2, step: 0.01 }).on('change', (ev) => {
state.speedRps = Number.isFinite(ev.value) ? ev.value : 0;
});
motionFolder.addBinding(params, 'angleDeg', { label: 'Angle (deg)', min: 0, max: 360, step: 0.1 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
hooks.setTheta(degToRad(ev.value));
});
motionFolder.addBinding(params, 'playing', { label: 'Playing' }).on('change', (ev) => {
state.playing = Boolean(ev.value);
});
motionFolder.addButton({ title: 'Reset' }).on('click', () => {
hooks.reset();
syncFromState();
rebuildPresetList(params.selectedPreset);
pane.refresh();
});
const geometryFolder = pane.addFolder({ title: 'Geometry (cm)' });
geometryFolder.addBinding(params, 'crankCm', { label: 'Crank (OC)', min: 1, max: 80, step: 0.1 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.lengthCm.crank = ev.value;
hooks.refresh();
});
geometryFolder.addBinding(params, 'legCm', { label: 'Leg (CG)', min: 10, max: 90, step: 0.1 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.lengthCm.leg = ev.value;
hooks.refresh();
});
geometryFolder.addBinding(params, 'tendonCm', { label: 'Tendon (BE)', min: 1, max: 80, step: 0.1 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.lengthCm.tendon = ev.value;
hooks.refresh();
});
geometryFolder.addBinding(params, 'bodyCm', { label: 'Body (AB)', min: 5, max: 110, step: 0.1 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.lengthCm.body = ev.value;
hooks.refresh();
});
const visibilityFolder = pane.addFolder({ title: 'Visibility' });
visibilityFolder.addBinding(params, 'showNear', { label: 'Show near side' }).on('change', (ev) => {
state.showNear = Boolean(ev.value);
});
visibilityFolder.addBinding(params, 'showFar', { label: 'Show far side (180°)' }).on('change', (ev) => {
state.showFar = Boolean(ev.value);
});
visibilityFolder.addBinding(params, 'showTrace', { label: 'Show foot traces' }).on('change', (ev) => {
state.showTrace = Boolean(ev.value);
});
visibilityFolder.addBinding(params, 'showAnnotations', { label: 'Overlay linkage annotations' }).on('change', (ev) => {
state.showAnnotations = Boolean(ev.value);
});
if (state.canShow3d) {
visibilityFolder.addBinding(params, 'show3d', { label: 'Show 3D pane' }).on('change', (ev) => {
state.show3d = Boolean(ev.value);
hooks.set3DVisible?.(state.show3d);
});
visibilityFolder.addButton({ title: 'Reset 3D camera' }).on('click', () => {
hooks.reset3DCamera?.();
});
} else {
const webglUnavailable = { status: 'WebGL unavailable in this browser/runtime' };
visibilityFolder.addBinding(webglUnavailable, 'status', {
label: '3D',
readonly: true,
});
}
const presetsFolder = pane.addFolder({ title: 'Presets' });
presetsFolder.addBinding(params, 'presetName', { label: 'Preset name' });
rebuildPresetList();
presetsFolder.addButton({ title: 'Save preset' }).on('click', () => {
const savedName = hooks.savePreset(params.presetName);
if (!savedName) return;
params.presetName = savedName;
rebuildPresetList(savedName);
pane.refresh();
});
presetsFolder.addButton({ title: 'Load selected preset' }).on('click', () => {
const target = params.selectedPreset;
if (!target) return;
const loaded = hooks.loadPreset(target);
if (!loaded) return;
syncFromState();
rebuildPresetList(target);
pane.refresh();
});
function syncFromState() {
params.speedRps = state.speedRps;
params.angleDeg = radToDeg(state.theta);
params.crankCm = state.lengthCm.crank;
params.legCm = state.lengthCm.leg;
params.tendonCm = state.lengthCm.tendon;
params.bodyCm = state.lengthCm.body;
params.playing = state.playing;
params.showNear = state.showNear;
params.showFar = state.showFar;
params.showTrace = state.showTrace;
params.showAnnotations = state.showAnnotations;
params.show3d = state.show3d;
params.canShow3d = state.canShow3d;
}
syncFromState();
pane.refresh();
return {
syncAngle(theta) {
params.angleDeg = radToDeg(theta);
pane.refresh();
},
syncPlay() {
params.playing = state.playing;
pane.refresh();
},
syncLengths() {
params.crankCm = state.lengthCm.crank;
params.legCm = state.lengthCm.leg;
params.tendonCm = state.lengthCm.tendon;
params.bodyCm = state.lengthCm.body;
pane.refresh();
},
};
}
export function updateDiagnostics(el, sim) {
const near = sim.pose.near;
const far = sim.pose.far;
const ground = sim.ground;
const lines = [
`theta near: ${(radToDeg(sim.pose.theta)).toFixed(2)} deg`,
`theta far: ${(radToDeg(sim.pose.thetaFar)).toFixed(2)} deg`,
'',
`near errD: ${near.errD.toExponential(3)}`,
`near errE: ${near.errE.toExponential(3)}`,
`far errD: ${far.errD.toExponential(3)}`,
`far errE: ${far.errE.toExponential(3)}`,
'',
`near valid: ${near.valid}`,
`far valid: ${far.valid}`,
`fallbacks: ${sim.fallbackCount}`,
'',
`grounded foot: ${ground.groundedFoot ?? 'none'}`,
`floorY: ${Number.isFinite(ground.floorY) ? `${ground.floorY.toFixed(3)} cm` : 'NaN'}`,
`bodyYOffset: ${ground.bodyYOffset.toFixed(3)} cm`,
`minFootY: ${Number.isFinite(ground.minFootY) ? `${ground.minFootY.toFixed(3)} cm` : 'NaN'}`,
`onFloor: ${ground.onFloor}`,
'',
`trace points nearF: ${sim.trace.nearF.points.length}`,
`trace points nearG: ${sim.trace.nearG.points.length}`,
`trace points farF: ${sim.trace.farF.points.length}`,
`trace points farG: ${sim.trace.farG.points.length}`,
'',
`mujoco overlay: ${sim.mujocoOverlay?.enabled ? 'on' : 'off'}`,
`mujoco nearF pts: ${sim.mujocoOverlay?.nearF?.length ?? 0}`,
`mujoco nearG pts: ${sim.mujocoOverlay?.nearG?.length ?? 0}`,
`mujoco farF pts: ${sim.mujocoOverlay?.farF?.length ?? 0}`,
`mujoco farG pts: ${sim.mujocoOverlay?.farG?.length ?? 0}`,
];
el.textContent = lines.join('\n');
}