410 lines
17 KiB
JavaScript
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');
|
|
}
|