231 lines
7.8 KiB
JavaScript
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');
|
|
}
|