Files
walkersim/src/ui.js

410 lines
17 KiB
JavaScript

import { degToRad, radToDeg } from './math.js';
import { Pane } from 'tweakpane';
function detectCompactViewport() {
const viewportWidth = window.visualViewport?.width || window.innerWidth;
const ua = navigator.userAgent || '';
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
const touch = navigator.maxTouchPoints > 0;
const screenShortSide = Math.min(window.screen?.width || Infinity, window.screen?.height || Infinity);
return viewportWidth <= 1100 || mobileUA || screenShortSide <= 520 || (touch && screenShortSide <= 900);
}
export function wireUI(state, hooks) {
const isCompactViewport = detectCompactViewport();
const paneHost = document.getElementById('tweakpane-root');
const pane = new Pane({
container: paneHost,
title: 'Controls',
expanded: !isCompactViewport,
});
pane.expanded = !isCompactViewport;
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,
selectedMujocoPreset: '',
audioCameraEnabled: state.audioCamera.enabled,
audioMasterGain: state.audioCamera.masterGain,
audioVolumeInfluence: state.audioCamera.volumeInfluence,
audioLowInfluence: state.audioCamera.lowInfluence,
audioMidInfluence: state.audioCamera.midInfluence,
audioHighInfluence: state.audioCamera.highInfluence,
audioSmoothing: state.audioCamera.smoothing,
audioBeatSensitivity: state.audioCamera.beatSensitivity,
audioBeatDecay: state.audioCamera.beatDecay,
audioBeatPunch: state.audioCamera.beatPunch,
audioJumpAmount: state.audioCamera.jumpAmount,
audioShakeAmount: state.audioCamera.shakeAmount,
audioJitterAmount: state.audioCamera.jitterAmount,
audioChaos: state.audioCamera.chaos,
audioStatus: state.audioCamera.status,
audioDebugMode: state.audioDebug.mode,
audioDebugGain: state.audioDebug.gain,
audioDebugFreeze: state.audioDebug.freeze,
audioDetectedBpm: state.audioMetrics.bpm,
audioBeatImpulse: state.audioMetrics.beatImpulse,
};
function mujocoPresetOptions() {
const names = hooks.listMujocoTracePresets?.() ?? [];
if (names.length === 0) return { none: '' };
const options = {};
for (const name of names) {
options[name] = name;
}
return options;
}
let mujocoPresetBinding = null;
function rebuildMujocoPresetList(preferredName = null) {
const options = mujocoPresetOptions();
const optionValues = Object.values(options);
if (optionValues.length === 0) {
params.selectedMujocoPreset = '';
} else {
const next = preferredName && optionValues.includes(preferredName)
? preferredName
: optionValues[0];
params.selectedMujocoPreset = next;
}
if (mujocoPresetBinding) {
mujocoPresetBinding.dispose();
mujocoPresetBinding = null;
}
mujocoPresetBinding = presetsFolder.addBinding(params, 'selectedMujocoPreset', {
label: 'MuJoCo trace',
options,
});
}
const motionFolder = pane.addFolder({ title: 'Motion', expanded: !isCompactViewport });
motionFolder.expanded = !isCompactViewport;
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();
pane.refresh();
});
const geometryFolder = pane.addFolder({ title: 'Geometry (cm)', expanded: !isCompactViewport });
geometryFolder.expanded = !isCompactViewport;
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', expanded: !isCompactViewport });
visibilityFolder.expanded = !isCompactViewport;
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', expanded: !isCompactViewport });
presetsFolder.expanded = !isCompactViewport;
rebuildMujocoPresetList();
presetsFolder.addButton({ title: 'Load MuJoCo preset' }).on('click', async () => {
const target = params.selectedMujocoPreset;
if (!target) return;
await hooks.loadMujocoTracePreset?.(target);
});
const audioFolder = pane.addFolder({ title: 'Audio Camera', expanded: false });
audioFolder.addBinding(params, 'audioCameraEnabled', { label: 'Enabled' }).on('change', (ev) => {
state.audioCamera.enabled = Boolean(ev.value);
});
audioFolder.addBinding(params, 'audioMasterGain', { label: 'Master', min: 0, max: 2, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.masterGain = ev.value;
});
audioFolder.addBinding(params, 'audioVolumeInfluence', { label: 'Volume', min: 0, max: 2, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.volumeInfluence = ev.value;
});
audioFolder.addBinding(params, 'audioLowInfluence', { label: 'Low band', min: 0, max: 2, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.lowInfluence = ev.value;
});
audioFolder.addBinding(params, 'audioMidInfluence', { label: 'Mid band', min: 0, max: 2, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.midInfluence = ev.value;
});
audioFolder.addBinding(params, 'audioHighInfluence', { label: 'High band', min: 0, max: 2, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.highInfluence = ev.value;
});
audioFolder.addBinding(params, 'audioSmoothing', { label: 'Smoothing', min: 0, max: 0.98, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.smoothing = ev.value;
});
audioFolder.addBinding(params, 'audioBeatSensitivity', { label: 'Beat sense', min: 0.2, max: 3, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.beatSensitivity = ev.value;
});
audioFolder.addBinding(params, 'audioBeatDecay', { label: 'Beat decay', min: 0.5, max: 0.97, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.beatDecay = ev.value;
});
audioFolder.addBinding(params, 'audioBeatPunch', { label: 'Beat punch', min: 0, max: 3, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.beatPunch = ev.value;
});
audioFolder.addBinding(params, 'audioJumpAmount', { label: 'Jump', min: 0, max: 3, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.jumpAmount = ev.value;
});
audioFolder.addBinding(params, 'audioShakeAmount', { label: 'Shake', min: 0, max: 3, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.shakeAmount = ev.value;
});
audioFolder.addBinding(params, 'audioJitterAmount', { label: 'Jitter', min: 0, max: 3, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.jitterAmount = ev.value;
});
audioFolder.addBinding(params, 'audioChaos', { label: 'Chaos', min: 0, max: 1.5, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioCamera.chaos = ev.value;
});
audioFolder.addBinding(params, 'audioDebugMode', {
label: 'Debug view',
options: {
Off: 'off',
Waterfall: 'waterfall',
Spectrogram: 'spectrogram',
},
}).on('change', (ev) => {
state.audioDebug.mode = ev.value;
hooks.setAudioDebugMode?.(ev.value);
});
audioFolder.addBinding(params, 'audioDebugGain', { label: 'Debug gain', min: 0.5, max: 3, step: 0.01 }).on('change', (ev) => {
if (!Number.isFinite(ev.value)) return;
state.audioDebug.gain = ev.value;
hooks.setAudioDebugGain?.(ev.value);
});
audioFolder.addBinding(params, 'audioDebugFreeze', { label: 'Debug freeze' }).on('change', (ev) => {
state.audioDebug.freeze = Boolean(ev.value);
hooks.setAudioDebugFreeze?.(ev.value);
});
audioFolder.addButton({ title: 'Apply Frantic preset' }).on('click', () => {
state.audioCamera.profile = 'dance-frantic';
state.audioCamera.masterGain = 1.35;
state.audioCamera.volumeInfluence = 0.95;
state.audioCamera.lowInfluence = 1.6;
state.audioCamera.midInfluence = 1.35;
state.audioCamera.highInfluence = 1.25;
state.audioCamera.smoothing = 0.5;
state.audioCamera.beatSensitivity = 1.9;
state.audioCamera.beatDecay = 0.72;
state.audioCamera.beatPunch = 2.2;
state.audioCamera.jumpAmount = 1.9;
state.audioCamera.shakeAmount = 2.0;
state.audioCamera.jitterAmount = 1.5;
state.audioCamera.chaos = 0.95;
syncFromState();
pane.refresh();
});
audioFolder.addButton({ title: 'Start Mic' }).on('click', async () => {
await hooks.startAudioCamera?.();
syncFromState();
pane.refresh();
});
audioFolder.addButton({ title: 'Stop Mic' }).on('click', async () => {
await hooks.stopAudioCamera?.();
syncFromState();
pane.refresh();
});
audioFolder.addBinding(params, 'audioStatus', {
label: 'Mic',
readonly: true,
});
audioFolder.addBinding(params, 'audioDetectedBpm', {
label: 'Detected BPM',
readonly: true,
format: (v) => (Number(v) > 1 ? Math.round(v).toString() : '--'),
});
audioFolder.addBinding(params, 'audioBeatImpulse', {
label: 'Beat hit',
readonly: true,
format: (v) => Number(v).toFixed(2),
});
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;
params.audioCameraEnabled = state.audioCamera.enabled;
params.audioMasterGain = state.audioCamera.masterGain;
params.audioVolumeInfluence = state.audioCamera.volumeInfluence;
params.audioLowInfluence = state.audioCamera.lowInfluence;
params.audioMidInfluence = state.audioCamera.midInfluence;
params.audioHighInfluence = state.audioCamera.highInfluence;
params.audioSmoothing = state.audioCamera.smoothing;
params.audioBeatSensitivity = state.audioCamera.beatSensitivity;
params.audioBeatDecay = state.audioCamera.beatDecay;
params.audioBeatPunch = state.audioCamera.beatPunch;
params.audioJumpAmount = state.audioCamera.jumpAmount;
params.audioShakeAmount = state.audioCamera.shakeAmount;
params.audioJitterAmount = state.audioCamera.jitterAmount;
params.audioChaos = state.audioCamera.chaos;
params.audioStatus = state.audioCamera.status;
params.audioDebugMode = state.audioDebug.mode;
params.audioDebugGain = state.audioDebug.gain;
params.audioDebugFreeze = state.audioDebug.freeze;
params.audioDetectedBpm = state.audioMetrics.bpm;
params.audioBeatImpulse = state.audioMetrics.beatImpulse;
}
syncFromState();
pane.refresh();
return {
syncAngle(theta) {
params.angleDeg = radToDeg(theta);
pane.refresh();
},
syncPlay() {
params.playing = state.playing;
params.audioStatus = state.audioCamera.status;
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}`,
`mujoco playback: ${sim.mujocoPlayback?.enabled ? 'on' : 'off'}`,
`mujoco playback frame: ${sim.mujocoPlayback?.frame ?? 'n/a'}`,
`mujoco playback t: ${(sim.mujocoPlayback?.time ?? 0).toFixed(2)} s`,
`mujoco playback duration: ${(sim.mujocoPlayback?.duration ?? 0).toFixed(2)} s`,
`mujoco playback frames: ${sim.mujocoPlayback?.frames?.length ?? 0}`,
'',
`audio mic: ${sim.audioCamera?.status ?? 'idle'}`,
`audio enabled: ${Boolean(sim.audioCamera?.enabled)}`,
`audio level: ${(sim.audioMetrics?.level ?? 0).toFixed(3)}`,
`audio low: ${(sim.audioMetrics?.low ?? 0).toFixed(3)}`,
`audio mid: ${(sim.audioMetrics?.mid ?? 0).toFixed(3)}`,
`audio high: ${(sim.audioMetrics?.high ?? 0).toFixed(3)}`,
`audio kick: ${(sim.audioMetrics?.kick ?? 0).toFixed(3)}`,
`audio snare: ${(sim.audioMetrics?.snare ?? 0).toFixed(3)}`,
`audio hat: ${(sim.audioMetrics?.hat ?? 0).toFixed(3)}`,
`audio beat impulse: ${(sim.audioMetrics?.beatImpulse ?? 0).toFixed(3)}`,
`audio onset: ${(sim.audioMetrics?.onset ?? 0).toFixed(3)}`,
`audio bpm est: ${(sim.audioMetrics?.bpm ?? 0).toFixed(1)}`,
];
el.textContent = lines.join('\n');
}