Files
walkersim/src/main.js
2026-02-19 10:35:04 +02:00

803 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
const PRESET_STORAGE_KEY = 'walkerSim.presets.v1';
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 BUILTIN_PRESET_NAME = 'Default 45cm leg';
const MUJOCO_TRACE_PRESETS = {
'MuJoCo 10cm trace': './sim/mujoco/model/mujoco_trace_10cm.json',
'MuJoCo 20cm trace': './sim/mujoco/model/mujoco_trace_20cm.json',
'MuJoCo 30cm trace': './sim/mujoco/model/mujoco_trace_30cm.json',
};
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 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,
},
};
const renderer3d = create3DRenderer(view3dEl);
state.canShow3d = renderer3d.available;
state.show3d = renderer3d.available;
let forceMobileLayout = false;
/* ── 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 sanitizePresetName(name) {
if (typeof name !== 'string') return '';
return name.trim().slice(0, 40);
}
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);
}
function loadPersistedPresets() {
const presets = {
[BUILTIN_PRESET_NAME]: { ...DEFAULT_LENGTH_CM },
};
let parsed = null;
try {
parsed = JSON.parse(localStorage.getItem(PRESET_STORAGE_KEY) ?? 'null');
} catch {
parsed = null;
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const [rawName, rawPreset] of Object.entries(parsed)) {
const name = sanitizePresetName(rawName);
if (!name || !rawPreset || typeof rawPreset !== 'object') continue;
const crank = Number(rawPreset.crank);
const leg = Number(rawPreset.leg);
const tendon = Number(rawPreset.tendon);
const body = Number(rawPreset.body);
if ([crank, leg, tendon, body].every(Number.isFinite)) {
presets[name] = { crank, leg, tendon, body };
}
}
}
return presets;
}
function persistPresets() {
const serializable = {};
for (const [name, preset] of Object.entries(state.presets)) {
if (name === BUILTIN_PRESET_NAME) continue;
serializable[name] = {
crank: preset.crank,
leg: preset.leg,
tendon: preset.tendon,
body: preset.body,
};
}
localStorage.setItem(PRESET_STORAGE_KEY, JSON.stringify(serializable));
}
state.presets = loadPersistedPresets();
refreshLayoutMode();
const ui = wireUI(state, {
setTheta,
reset() {
resetSimulation({ clearLengths: true });
},
refresh() {
resetSimulation({ clearLengths: false });
},
savePreset(name) {
const presetName = sanitizePresetName(name);
if (!presetName) return null;
state.presets[presetName] = { ...state.lengthCm };
persistPresets();
return presetName;
},
loadPreset(name) {
const presetName = sanitizePresetName(name);
const preset = state.presets[presetName];
if (!preset) return false;
state.lengthCm = { ...preset };
resetSimulation({ clearLengths: false });
return true;
},
listPresets() {
return Object.keys(state.presets);
},
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();
},
});
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();
/* ── 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);
if (state.show3d) renderer3d.render(state);
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();
});
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
refreshLayoutMode();
updateViewportHeightVar();
setSettingsSectionsExpanded(!isCompactViewport());
resizeViews();
});
}
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;
}
ui.syncAngle(state.theta);
ui.syncPlay();
renderScene(canvas, state);
if (state.show3d) renderer3d.render(state);
updateDiagnostics(diagEl, state);
syncMujocoPlaybackControls();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);