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 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 diagEl = document.getElementById('diag'); const state = { theta: 0, speedRps: 0.3, playing: true, showNear: true, showFar: true, showTrace: true, showAnnotations: false, show3d: true, 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), }, }; const renderer3d = create3DRenderer(view3dEl); function resize2DCanvasToDisplaySize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const width = Math.max(1, Math.floor(canvas.clientWidth * dpr)); const height = Math.max(1, Math.floor(canvas.clientHeight * dpr)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } } function apply3DVisibility() { if (view3dPanelEl) { view3dPanelEl.style.display = state.show3d ? '' : 'none'; } if (viewsWrapEl) { viewsWrapEl.style.gridTemplateRows = state.show3d ? '1fr 1fr' : '1fr'; } 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(); 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); }, set3DVisible(visible) { state.show3d = Boolean(visible); apply3DVisibility(); resizeViews(); }, reset3DCamera() { renderer3d.resetCamera(); }, }); state.pose = applyGrounding(solveWalker(state.theta, null, currentLengthScale())); apply3DVisibility(); resizeViews(); renderScene(canvas, state); if (state.show3d) renderer3d.render(state); updateDiagnostics(diagEl, state); window.addEventListener('resize', 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); } 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); requestAnimationFrame(frame); } requestAnimationFrame(frame);