import { solveWalker } from './kinematics.js'; import { TAU, normalizeAngleRad } from './math.js'; import { renderScene } from './renderer.js'; import { create3DRenderer } from './renderer3d.js'; import { clearTrace, createTraceBuffer, pushTrace } from './trace.js'; import { updateDiagnostics, wireUI } from './ui.js'; import { BASE_DIMENSIONS_CM } from './geometry.js'; import { createAudioCameraController } from './audioCamera.js'; import { createAudioDebugViz } from './audioDebug.js'; const DEFAULT_LENGTH_CM = { crank: BASE_DIMENSIONS_CM.crank, leg: BASE_DIMENSIONS_CM.leg, tendon: BASE_DIMENSIONS_CM.tendon, body: BASE_DIMENSIONS_CM.body, }; const MUJOCO_TRACE_PRESETS = { 'MuJoCo 30cm trace': './sim/mujoco/model/mujoco_trace_30cm.json', 'MuJoCo 20cm trace': './sim/mujoco/model/mujoco_trace_20cm.json', 'MuJoCo 10cm trace': './sim/mujoco/model/mujoco_trace_10cm.json', }; const DEFAULT_MUJOCO_TRACE_PRESET = 'MuJoCo 30cm trace'; const canvas = document.getElementById('sim-canvas'); const view3dPanelEl = document.getElementById('view-3d-panel'); const view3dEl = document.getElementById('sim-3d'); const viewsWrapEl = document.querySelector('.views-wrap'); const mobileToggleEl = document.getElementById('mobile-view-toggle'); const mujocoTraceInputEl = document.getElementById('mujoco-trace-file'); const clearMujocoTraceEl = document.getElementById('clear-mujoco-trace'); const mujocoPlaybackControlsEl = document.getElementById('mujoco-playback-controls'); const mujocoPlaybackToggleEl = document.getElementById('mujoco-playback-toggle'); const mujocoPlaybackTimeEl = document.getElementById('mujoco-playback-time'); const mujocoPlaybackLoopEl = document.getElementById('mujoco-playback-loop'); const mujocoPlaybackLabelEl = document.getElementById('mujoco-playback-label'); const mujocoImportSectionEl = document.getElementById('mujoco-import-section'); const diagnosticsSectionEl = document.getElementById('diagnostics-section'); const diagEl = document.getElementById('diag'); const audioDebugSectionEl = document.getElementById('audio-debug-section'); const audioDebugCanvasEl = document.getElementById('audio-debug-canvas'); const audioBeatBpmEl = document.getElementById('audio-beat-bpm'); const audioBeatIntensityEl = document.getElementById('audio-beat-intensity'); const audioBeatFlashEl = document.getElementById('audio-beat-flash'); const statusLightEl = document.querySelector('.status-light'); const state = { theta: 0, speedRps: 0.3, playing: true, showNear: true, showFar: true, showTrace: true, showAnnotations: false, show3d: true, canShow3d: true, mobileView: '3d', // which view is active on mobile: '3d' | '2d' lengthCm: { ...DEFAULT_LENGTH_CM }, fallbackCount: 0, pose: null, ground: { floorY: null, bodyYOffset: 0, groundedFoot: null, minFootY: NaN, onFloor: false, verticalVel: 0, }, trace: { nearF: createTraceBuffer(1200), nearG: createTraceBuffer(1200), farF: createTraceBuffer(1200), farG: createTraceBuffer(1200), }, mujocoOverlay: { enabled: false, nearF: [], nearG: [], farF: [], farG: [], torso: [], }, mujocoPlayback: { enabled: false, frame: 'world', units: 'meters', frames: [], currentPose: null, playing: true, loop: true, speed: 1, time: 0, duration: 0, }, audioCamera: { enabled: true, profile: 'dance-frantic', status: 'idle', running: false, masterGain: 1.35, volumeInfluence: 0.95, lowInfluence: 1.6, midInfluence: 1.35, highInfluence: 1.25, smoothing: 0.5, beatSensitivity: 1.9, beatDecay: 0.72, beatPunch: 2.2, jumpAmount: 1.9, shakeAmount: 2.0, jitterAmount: 1.5, chaos: 0.95, }, audioMetrics: { level: 0, low: 0, mid: 0, high: 0, kick: 0, snare: 0, hat: 0, beatImpulse: 0, onset: 0, bpm: 0, spectrum: new Float32Array(128), }, audioDebug: { mode: 'off', gain: 1.25, freeze: false, }, }; const renderer3d = create3DRenderer(view3dEl); const audioCamera = createAudioCameraController(); const audioDebugViz = audioDebugCanvasEl ? createAudioDebugViz(audioDebugCanvasEl) : null; let prevBeatImpulse = 0; let beatFlashUntil = 0; state.canShow3d = renderer3d.available; state.show3d = renderer3d.available; let forceMobileLayout = false; function clamp01(value) { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(1, value)); } function updateBeatDebugUi(nowSec) { const bpm = Number(state.audioMetrics?.bpm) || 0; const beatImpulse = Number(state.audioMetrics?.beatImpulse) || 0; const high = Number(state.audioMetrics?.high) || 0; if (audioBeatBpmEl) { audioBeatBpmEl.textContent = bpm > 1 ? `BPM: ${Math.round(bpm)}` : 'BPM: --'; } if (audioBeatIntensityEl) { audioBeatIntensityEl.textContent = `Beat: ${beatImpulse.toFixed(2)}`; } const hit = beatImpulse > 0.58 && beatImpulse > prevBeatImpulse + 0.1; if (hit) beatFlashUntil = nowSec + 0.12; prevBeatImpulse = beatImpulse; const isFlashOn = nowSec < beatFlashUntil; if (audioBeatFlashEl) { audioBeatFlashEl.classList.toggle('active', isFlashOn); } if (statusLightEl) { const level = clamp01(beatImpulse / 1.9 + high * 0.18); const scale = 1 + level * 0.52; const opacity = 0.66 + level * 0.34; statusLightEl.style.setProperty('--beat-scale', scale.toFixed(3)); statusLightEl.style.setProperty('--beat-opacity', opacity.toFixed(3)); } } /* ── Mobile helpers ── */ function detectMobileLayout() { 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); const narrowViewport = viewportWidth <= 820; const phoneSizedScreen = screenShortSide <= 520; const handsetLikeScreen = screenShortSide <= 900; return narrowViewport || mobileUA || phoneSizedScreen || (touch && handsetLikeScreen); } function refreshLayoutMode() { forceMobileLayout = detectMobileLayout(); document.documentElement.setAttribute('data-mobile-layout', forceMobileLayout ? '1' : '0'); return forceMobileLayout; } function isMobileViewport() { return forceMobileLayout; } function isCompactViewport() { const viewportWidth = window.visualViewport?.width || window.innerWidth; return forceMobileLayout || viewportWidth <= 1100; } function updateViewportHeightVar() { const viewportHeight = window.visualViewport?.height || window.innerHeight; document.documentElement.style.setProperty('--viewport-height', `${Math.max(1, Math.floor(viewportHeight))}px`); } function setSettingsSectionsExpanded(expanded) { if (mujocoImportSectionEl) mujocoImportSectionEl.open = expanded; if (diagnosticsSectionEl) diagnosticsSectionEl.open = expanded; } function setMobileView(mode) { state.mobileView = mode; // '3d' | '2d' if (viewsWrapEl) viewsWrapEl.setAttribute('data-mobile-view', mode); if (mobileToggleEl) { for (const btn of mobileToggleEl.querySelectorAll('.toggle-btn')) { btn.classList.toggle('active', btn.dataset.view === mode); } } // On mobile, CSS handles panel display via data-mobile-view. // Clear any inline display overrides so CSS can work. if (view3dPanelEl) view3dPanelEl.style.display = ''; // Let the 3D renderer know if it should actually render const render3d = mode === '3d' && state.canShow3d; state.show3d = render3d; renderer3d.setVisible(render3d); resizeViews(); } function resize2DCanvasToDisplaySize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const container = canvas.parentElement; const width = Math.max(1, Math.floor(container.clientWidth * dpr)); const height = Math.max(1, Math.floor(container.clientHeight * dpr)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } } /** Desktop-only: controls 3D panel visibility via inline styles */ function apply3DVisibility() { if (isMobileViewport()) return; // mobile handled by setMobileView if (view3dPanelEl) { view3dPanelEl.style.display = state.show3d ? '' : 'none'; } if (viewsWrapEl) { viewsWrapEl.classList.toggle('single-view', !state.show3d); } renderer3d.setVisible(state.show3d); } function resizeViews() { resize2DCanvasToDisplaySize(); if (state.show3d) { renderer3d.resize(); } } const FOOT_KEYS = ['nearF', 'nearG', 'farF', 'farG']; function mapSideWithYOffset(side, yOffset) { return { ...side, A: { x: side.A.x, y: side.A.y + yOffset }, B: { x: side.B.x, y: side.B.y + yOffset }, O: { x: side.O.x, y: side.O.y + yOffset }, C: { x: side.C.x, y: side.C.y + yOffset }, D: { x: side.D.x, y: side.D.y + yOffset }, E: { x: side.E.x, y: side.E.y + yOffset }, F: { x: side.F.x, y: side.F.y + yOffset }, G: { x: side.G.x, y: side.G.y + yOffset }, }; } function getFootPoint(pose, key) { switch (key) { case 'nearF': return pose.near.F; case 'nearG': return pose.near.G; case 'farF': return pose.far.F; case 'farG': return pose.far.G; default: return null; } } function footCandidates(pose) { return [ { key: 'nearF', point: pose.near.F }, { key: 'nearG', point: pose.near.G }, { key: 'farF', point: pose.far.F }, { key: 'farG', point: pose.far.G }, ]; } function lowestFootCandidate(pose) { const feet = footCandidates(pose); let best = feet[0]; for (let i = 1; i < feet.length; i += 1) { if (feet[i].point.y > best.point.y) { best = feet[i]; } } return best; } function applyGrounding(rawPose) { const best = lowestFootCandidate(rawPose); if (state.ground.floorY == null) { state.ground.floorY = best.point.y; } let groundedFoot = state.ground.groundedFoot; if (!FOOT_KEYS.includes(groundedFoot)) { groundedFoot = best.key; } groundedFoot = best.key; const contact = getFootPoint(rawPose, groundedFoot) ?? best.point; const bodyYOffset = state.ground.floorY - contact.y; const groundedPose = { ...rawPose, near: mapSideWithYOffset(rawPose.near, bodyYOffset), far: mapSideWithYOffset(rawPose.far, bodyYOffset), }; state.ground.groundedFoot = groundedFoot; state.ground.bodyYOffset = bodyYOffset; const lowestShifted = lowestFootCandidate(groundedPose); state.ground.minFootY = lowestShifted.point.y; state.ground.onFloor = Math.abs(lowestShifted.point.y - state.ground.floorY) < 1e-4; return groundedPose; } function clearAllTraces() { clearTrace(state.trace.nearF); clearTrace(state.trace.nearG); clearTrace(state.trace.farF); clearTrace(state.trace.farG); } function setTheta(theta) { state.theta = normalizeAngleRad(theta); } function currentLengthScale() { return { crank: state.lengthCm.crank / BASE_DIMENSIONS_CM.crank, leg: state.lengthCm.leg / BASE_DIMENSIONS_CM.leg, tendon: state.lengthCm.tendon / BASE_DIMENSIONS_CM.tendon, body: state.lengthCm.body / BASE_DIMENSIONS_CM.body, }; } function syncAudioStatus() { const status = audioCamera.getStatus(); state.audioCamera.running = Boolean(status.running); state.audioCamera.status = status.status || 'idle'; } function resetSimulation({ clearLengths = false } = {}) { if (clearLengths) { state.lengthCm = { ...DEFAULT_LENGTH_CM }; } setTheta(0); state.fallbackCount = 0; state.ground.floorY = null; state.ground.groundedFoot = null; state.ground.bodyYOffset = 0; state.ground.minFootY = NaN; state.ground.onFloor = false; state.ground.verticalVel = 0; clearAllTraces(); const raw = solveWalker(state.theta, null, currentLengthScale()); state.pose = applyGrounding(raw); } refreshLayoutMode(); const ui = wireUI(state, { setTheta, reset() { resetSimulation({ clearLengths: true }); }, refresh() { resetSimulation({ clearLengths: false }); }, listMujocoTracePresets() { return Object.keys(MUJOCO_TRACE_PRESETS); }, async loadMujocoTracePreset(name) { const presetName = typeof name === 'string' ? name : ''; const url = MUJOCO_TRACE_PRESETS[presetName]; if (!url) return false; try { await loadMujocoTraceFromUrl(url); return true; } catch (err) { console.error('[MuJoCo overlay] Failed to load preset', err); alert(`Failed to load MuJoCo preset: ${err.message}`); return false; } }, set3DVisible(visible) { state.show3d = state.canShow3d && Boolean(visible); apply3DVisibility(); resizeViews(); }, reset3DCamera() { renderer3d.resetCamera(); }, async startAudioCamera() { const status = await audioCamera.start(); state.audioCamera.running = Boolean(status.running); state.audioCamera.status = status.status || 'idle'; }, async stopAudioCamera() { const status = await audioCamera.stop(); state.audioCamera.running = Boolean(status.running); state.audioCamera.status = status.status || 'stopped'; }, setAudioDebugMode(mode) { state.audioDebug.mode = mode; }, setAudioDebugGain(gain) { state.audioDebug.gain = gain; }, setAudioDebugFreeze(freeze) { state.audioDebug.freeze = freeze; }, }); state.pose = applyGrounding(solveWalker(state.theta, null, currentLengthScale())); function sanitizeTracePointArray(value) { if (!Array.isArray(value)) return []; const out = []; for (const p of value) { if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; out.push({ x: Number(p.x), y: Number(p.y) }); } return out; } function sanitizePlaybackPoint(value) { if (!value || typeof value !== 'object') return null; const x = Number(value.x); const y = Number(value.y); const z = Number(value.z); if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null; return { x, y, z }; } function sanitizePlaybackSide(value) { if (!value || typeof value !== 'object') return null; const keys = ['A', 'B', 'O', 'C', 'D', 'E', 'F', 'G']; const side = {}; for (const key of keys) { const p = sanitizePlaybackPoint(value[key]); if (p) side[key] = p; } if (!side.C || !side.F || !side.G) return null; return side; } function setMujocoOverlayFromArrays({ nearF = [], nearG = [], farF = [], farG = [], torso = [] }) { state.mujocoOverlay.nearF = nearF; state.mujocoOverlay.nearG = nearG; state.mujocoOverlay.farF = farF; state.mujocoOverlay.farG = farG; state.mujocoOverlay.torso = torso; state.mujocoOverlay.enabled = nearF.length > 0 || nearG.length > 0 || farF.length > 0 || farG.length > 0; } function clearMujocoPlayback() { state.mujocoPlayback.enabled = false; state.mujocoPlayback.frames = []; state.mujocoPlayback.currentPose = null; state.mujocoPlayback.playing = true; state.mujocoPlayback.loop = true; state.mujocoPlayback.speed = 1; state.mujocoPlayback.time = 0; state.mujocoPlayback.duration = 0; } function setMujocoPlaybackControlsVisible(visible) { if (mujocoPlaybackControlsEl) { mujocoPlaybackControlsEl.hidden = !visible; } } function syncMujocoPlaybackControls() { const playback = state.mujocoPlayback; const duration = Math.max(0, playback.duration); const frames = playback.frames; const start = frames.length > 0 ? frames[0].t : 0; const end = frames.length > 0 ? frames[frames.length - 1].t : 0; const clampedTime = Math.min(Math.max(playback.time, start), end); const elapsed = Math.max(0, clampedTime - start); const ratio = duration > 0 ? elapsed / duration : 0; if (mujocoPlaybackToggleEl) { mujocoPlaybackToggleEl.textContent = playback.playing ? 'Pause playback' : 'Play playback'; mujocoPlaybackToggleEl.disabled = !playback.enabled; } if (mujocoPlaybackTimeEl) { mujocoPlaybackTimeEl.disabled = !playback.enabled; mujocoPlaybackTimeEl.value = String(ratio); } if (mujocoPlaybackLoopEl) { mujocoPlaybackLoopEl.checked = playback.loop; mujocoPlaybackLoopEl.disabled = !playback.enabled; } if (mujocoPlaybackLabelEl) { mujocoPlaybackLabelEl.textContent = `${elapsed.toFixed(2)}s / ${duration.toFixed(2)}s`; } } function samplePlaybackPose(frames, t) { if (!Array.isArray(frames) || frames.length === 0) return null; if (frames.length === 1) { return { near: frames[0].near, far: frames[0].far, torso: frames[0].torso, }; } const clamped = Math.min(Math.max(t, frames[0].t), frames[frames.length - 1].t); let hi = 1; while (hi < frames.length && frames[hi].t < clamped) hi += 1; const lo = Math.max(0, hi - 1); const a = frames[lo]; const b = frames[Math.min(hi, frames.length - 1)]; if (!a || !b) return null; if (a.t === b.t) { return { near: a.near, far: a.far, torso: a.torso }; } const alpha = (clamped - a.t) / (b.t - a.t); const lerpPoint = (p0, p1) => { if (!p0 || !p1) return p0 || p1 || null; return { x: p0.x + (p1.x - p0.x) * alpha, y: p0.y + (p1.y - p0.y) * alpha, z: p0.z + (p1.z - p0.z) * alpha, }; }; const keys = ['A', 'B', 'O', 'C', 'D', 'E', 'F', 'G']; const near = {}; const far = {}; for (const key of keys) { near[key] = lerpPoint(a.near?.[key], b.near?.[key]); far[key] = lerpPoint(a.far?.[key], b.far?.[key]); } return { near, far, torso: lerpPoint(a.torso, b.torso), }; } function loadMujocoTraceObject(obj) { if (!obj || typeof obj !== 'object') { throw new Error('Expected JSON object payload.'); } if (obj.format === 'walkersim-mujoco-trace-v1') { const nearF = sanitizeTracePointArray(obj.nearF ?? obj.leftFoot); const nearG = sanitizeTracePointArray(obj.nearG); const farF = sanitizeTracePointArray(obj.farF ?? obj.rightFoot); const farG = sanitizeTracePointArray(obj.farG); const torso = sanitizeTracePointArray(obj.torso); if (nearF.length === 0 && nearG.length === 0 && farF.length === 0 && farG.length === 0) { throw new Error('Trace has no valid foot points.'); } clearMujocoPlayback(); setMujocoOverlayFromArrays({ nearF, nearG, farF, farG, torso }); setMujocoPlaybackControlsVisible(false); syncMujocoPlaybackControls(); return; } if (obj.format === 'walkersim-mujoco-playback-v2') { const rawFrames = Array.isArray(obj.frames) ? obj.frames : []; const frames = []; for (const item of rawFrames) { if (!item || typeof item !== 'object') continue; const t = Number(item.t); if (!Number.isFinite(t)) continue; const near = sanitizePlaybackSide(item.near); const far = sanitizePlaybackSide(item.far); if (!near || !far) continue; const torso = sanitizePlaybackPoint(item.torso) ?? { x: 0, y: 0, z: 0 }; frames.push({ t, near, far, torso }); } if (frames.length === 0) { throw new Error('Playback has no valid frames.'); } frames.sort((a, b) => a.t - b.t); const overlay = obj.overlay2d ?? obj; const nearF = sanitizeTracePointArray(overlay.nearF); const nearG = sanitizeTracePointArray(overlay.nearG); const farF = sanitizeTracePointArray(overlay.farF); const farG = sanitizeTracePointArray(overlay.farG); const torso = sanitizeTracePointArray(overlay.torso); setMujocoOverlayFromArrays({ nearF, nearG, farF, farG, torso }); state.mujocoPlayback.enabled = true; state.mujocoPlayback.frame = obj.frame === 'body' ? 'body' : 'world'; state.mujocoPlayback.units = typeof obj.units === 'string' ? obj.units : 'meters'; state.mujocoPlayback.frames = frames; state.mujocoPlayback.currentPose = samplePlaybackPose(frames, frames[0].t); state.mujocoPlayback.playing = true; state.mujocoPlayback.loop = true; state.mujocoPlayback.speed = 1; state.mujocoPlayback.time = frames[0].t; state.mujocoPlayback.duration = Math.max(0, frames[frames.length - 1].t - frames[0].t); setMujocoPlaybackControlsVisible(true); syncMujocoPlaybackControls(); return; } throw new Error('Unsupported trace format.'); } async function loadMujocoTraceFromUrl(url) { const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) { throw new Error(`Failed to fetch preset (${res.status})`); } const payload = await res.json(); loadMujocoTraceObject(payload); if (mujocoTraceInputEl) mujocoTraceInputEl.value = ''; updateDiagnostics(diagEl, state); } if (mujocoTraceInputEl) { mujocoTraceInputEl.addEventListener('change', async () => { const file = mujocoTraceInputEl.files?.[0]; if (!file) return; try { const text = await file.text(); const payload = JSON.parse(text); loadMujocoTraceObject(payload); updateDiagnostics(diagEl, state); } catch (err) { console.error('[MuJoCo overlay] Failed to load file', err); alert(`Failed to load MuJoCo trace: ${err.message}`); } }); } if (clearMujocoTraceEl) { clearMujocoTraceEl.addEventListener('click', () => { setMujocoOverlayFromArrays({}); clearMujocoPlayback(); setMujocoPlaybackControlsVisible(false); syncMujocoPlaybackControls(); if (mujocoTraceInputEl) mujocoTraceInputEl.value = ''; }); } if (mujocoPlaybackToggleEl) { mujocoPlaybackToggleEl.addEventListener('click', () => { if (!state.mujocoPlayback.enabled) return; state.mujocoPlayback.playing = !state.mujocoPlayback.playing; syncMujocoPlaybackControls(); }); } if (mujocoPlaybackLoopEl) { mujocoPlaybackLoopEl.addEventListener('change', () => { state.mujocoPlayback.loop = Boolean(mujocoPlaybackLoopEl.checked); syncMujocoPlaybackControls(); }); } if (mujocoPlaybackTimeEl) { mujocoPlaybackTimeEl.addEventListener('input', () => { if (!state.mujocoPlayback.enabled) return; const t = Number(mujocoPlaybackTimeEl.value); if (!Number.isFinite(t)) return; const frames = state.mujocoPlayback.frames; if (frames.length === 0) return; const start = frames[0].t; const end = frames[frames.length - 1].t; const nextTime = start + (end - start) * Math.min(Math.max(t, 0), 1); state.mujocoPlayback.time = nextTime; state.mujocoPlayback.currentPose = samplePlaybackPose(frames, nextTime); syncMujocoPlaybackControls(); }); } setMujocoPlaybackControlsVisible(false); syncMujocoPlaybackControls(); void loadMujocoTraceFromUrl(MUJOCO_TRACE_PRESETS[DEFAULT_MUJOCO_TRACE_PRESET]).catch((err) => { console.error('[MuJoCo overlay] Failed to load default preset', err); }); /* ── Mobile toggle button wiring ── */ if (mobileToggleEl) { mobileToggleEl.addEventListener('click', (e) => { const btn = e.target.closest('.toggle-btn'); if (!btn) return; const view = btn.dataset.view; if (view === '3d' || view === '2d') setMobileView(view); }); } // Initialize: on mobile start with 3D, on desktop show both if (isCompactViewport()) { setSettingsSectionsExpanded(false); } else { setSettingsSectionsExpanded(true); } if (isMobileViewport()) { if (state.canShow3d) { setMobileView('3d'); } else { // WebGL unavailable – hide the toggle, force 2D if (mobileToggleEl) mobileToggleEl.style.display = 'none'; setMobileView('2d'); } } else { apply3DVisibility(); } updateViewportHeightVar(); resizeViews(); renderScene(canvas, state); renderer3d.setAudioReactiveState({ enabled: state.audioCamera.enabled && state.audioCamera.running, metrics: state.audioMetrics, config: state.audioCamera, }); if (state.show3d) renderer3d.render(state); if (audioDebugSectionEl) audioDebugSectionEl.hidden = state.audioDebug.mode === 'off'; audioDebugViz?.render({ mode: state.audioDebug.mode, spectrum: state.audioMetrics.spectrum, gain: state.audioDebug.gain, freeze: state.audioDebug.freeze }); updateBeatDebugUi(performance.now() / 1000); updateDiagnostics(diagEl, state); window.addEventListener('resize', () => { refreshLayoutMode(); updateViewportHeightVar(); setSettingsSectionsExpanded(!isCompactViewport()); if (isMobileViewport()) { // Reapply current mobile view mode setMobileView(state.mobileView); } else { // Desktop: show both panels, remove mobile data attr if (viewsWrapEl) viewsWrapEl.removeAttribute('data-mobile-view'); state.show3d = state.canShow3d; apply3DVisibility(); } resizeViews(); audioDebugViz?.resize(); }); if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { refreshLayoutMode(); updateViewportHeightVar(); setSettingsSectionsExpanded(!isCompactViewport()); resizeViews(); audioDebugViz?.resize(); }); } window.addEventListener('beforeunload', () => { void audioCamera.stop(); }); const fixedDt = 1 / 240; let acc = 0; let lastT = performance.now() / 1000; function step(dt) { if (state.playing) { state.theta = normalizeAngleRad(state.theta + TAU * state.speedRps * dt); } const prev = state.pose; const nextRaw = solveWalker(state.theta, prev, currentLengthScale()); const next = applyGrounding(nextRaw); state.pose = next; if (nextRaw.near.fallbackUsed || nextRaw.far.fallbackUsed) { state.fallbackCount += 1; } pushTrace(state.trace.nearF, next.near.F); pushTrace(state.trace.nearG, next.near.G); pushTrace(state.trace.farF, next.far.F); pushTrace(state.trace.farG, next.far.G); if (state.mujocoPlayback.enabled && state.mujocoPlayback.frames.length > 0) { const frames = state.mujocoPlayback.frames; const start = frames[0].t; const end = frames[frames.length - 1].t; let nextTime = state.mujocoPlayback.time; if (state.mujocoPlayback.playing) { nextTime += dt * state.mujocoPlayback.speed; if (nextTime > end) { if (state.mujocoPlayback.loop) { const span = Math.max(end - start, 1e-6); const wrapped = ((nextTime - start) % span + span) % span; nextTime = start + wrapped; } else { nextTime = end; state.mujocoPlayback.playing = false; } } } state.mujocoPlayback.time = nextTime; state.mujocoPlayback.currentPose = samplePlaybackPose(frames, nextTime); } } function frame() { const now = performance.now() / 1000; let dt = now - lastT; lastT = now; if (dt > 0.1) dt = 0.1; acc += dt; while (acc >= fixedDt) { step(fixedDt); acc -= fixedDt; } syncAudioStatus(); state.audioMetrics = audioCamera.sample({ smoothing: state.audioCamera.smoothing, beatSensitivity: state.audioCamera.beatSensitivity, beatDecay: state.audioCamera.beatDecay, }); renderer3d.setAudioReactiveState({ enabled: state.audioCamera.enabled && state.audioCamera.running, metrics: state.audioMetrics, config: state.audioCamera, }); if (audioDebugSectionEl) { audioDebugSectionEl.hidden = state.audioDebug.mode === 'off'; } if (audioDebugViz) { audioDebugViz.render({ mode: state.audioDebug.mode, spectrum: state.audioMetrics.spectrum, gain: state.audioDebug.gain, freeze: state.audioDebug.freeze, }); } updateBeatDebugUi(now); ui.syncAngle(state.theta); ui.syncPlay(); renderScene(canvas, state); if (state.show3d) renderer3d.render(state); updateDiagnostics(diagEl, state); syncMujocoPlaybackControls(); requestAnimationFrame(frame); } requestAnimationFrame(frame);