803 lines
24 KiB
JavaScript
803 lines
24 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 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);
|