349 lines
8.8 KiB
JavaScript
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);
|