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'); }