Files
walkersim/src/main.js

349 lines
8.8 KiB
JavaScript

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