Replace controls with Tweakpane and show 2D/3D side-by-side
This commit is contained in:
91
index.html
91
index.html
@@ -4,11 +4,13 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Walker Linkage Simulator</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tweakpane@4.0.4/dist/tweakpane.min.css" />
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.161.0/build/three.module.js"
|
||||
"three": "https://unpkg.com/three@0.161.0/build/three.module.js",
|
||||
"tweakpane": "https://esm.sh/tweakpane@4.0.4"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -29,92 +31,7 @@
|
||||
|
||||
<aside class="panel" id="controls-panel">
|
||||
<h1>Walker Sim (Kinematic)</h1>
|
||||
|
||||
<label class="row">
|
||||
<span>Speed (rev/s)</span>
|
||||
<input id="speed" type="range" min="0" max="2" step="0.01" value="0.3" />
|
||||
<output id="speed-out">0.30</output>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span>Angle (deg)</span>
|
||||
<input id="angle" type="range" min="0" max="360" step="0.1" value="0" />
|
||||
<output id="angle-out">0.0</output>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span>Crank (OC)</span>
|
||||
<input id="crank-len" type="range" min="1" max="80" step="0.1" value="4.6" />
|
||||
<output id="crank-len-out">4.6 cm</output>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span>Leg (CG)</span>
|
||||
<input id="leg-len" type="range" min="10" max="90" step="0.1" value="45" />
|
||||
<output id="leg-len-out">45.0 cm</output>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span>Tendon (BE)</span>
|
||||
<input id="tendon-len" type="range" min="1" max="80" step="0.1" value="24.8" />
|
||||
<output id="tendon-len-out">24.8 cm</output>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span>Body (AB)</span>
|
||||
<input id="body-len" type="range" min="5" max="110" step="0.1" value="54.6" />
|
||||
<output id="body-len-out">54.6 cm</output>
|
||||
</label>
|
||||
|
||||
<section class="presets">
|
||||
<h2>Presets</h2>
|
||||
<label class="row">
|
||||
<span>Preset name</span>
|
||||
<input id="preset-name" type="text" maxlength="40" placeholder="e.g. Fast Gait" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span>Saved presets</span>
|
||||
<select id="preset-select"></select>
|
||||
</label>
|
||||
<div class="row buttons">
|
||||
<button id="preset-save">Save</button>
|
||||
<button id="preset-load">Load</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="row buttons">
|
||||
<button id="play">Pause</button>
|
||||
<button id="reset">Reset</button>
|
||||
</div>
|
||||
|
||||
<label class="check">
|
||||
<input id="show-near" type="checkbox" checked />
|
||||
<span>Show near side</span>
|
||||
</label>
|
||||
|
||||
<label class="check">
|
||||
<input id="show-far" type="checkbox" checked />
|
||||
<span>Show far side (180°)</span>
|
||||
</label>
|
||||
|
||||
<label class="check">
|
||||
<input id="show-trace" type="checkbox" checked />
|
||||
<span>Show foot traces</span>
|
||||
</label>
|
||||
|
||||
<label class="check">
|
||||
<input id="show-annotations" type="checkbox" />
|
||||
<span>Overlay linkage annotations</span>
|
||||
</label>
|
||||
|
||||
<label class="check">
|
||||
<input id="show-3d" type="checkbox" checked />
|
||||
<span>Show 3D pane</span>
|
||||
</label>
|
||||
|
||||
<div class="row buttons single">
|
||||
<button id="reset-camera">Reset 3D camera</button>
|
||||
</div>
|
||||
<div id="tweakpane-root"></div>
|
||||
|
||||
<section class="diag">
|
||||
<h2>Diagnostics</h2>
|
||||
|
||||
@@ -68,7 +68,7 @@ function apply3DVisibility() {
|
||||
view3dPanelEl.style.display = state.show3d ? '' : 'none';
|
||||
}
|
||||
if (viewsWrapEl) {
|
||||
viewsWrapEl.style.gridTemplateRows = state.show3d ? '1fr 1fr' : '1fr';
|
||||
viewsWrapEl.style.gridTemplateColumns = state.show3d ? '1fr 1fr' : '1fr';
|
||||
}
|
||||
renderer3d.setVisible(state.show3d);
|
||||
}
|
||||
|
||||
295
src/ui.js
295
src/ui.js
@@ -1,192 +1,181 @@
|
||||
import { degToRad, radToDeg } from './math.js';
|
||||
import { Pane } from 'tweakpane';
|
||||
|
||||
export function wireUI(state, hooks) {
|
||||
const speed = document.getElementById('speed');
|
||||
const speedOut = document.getElementById('speed-out');
|
||||
const angle = document.getElementById('angle');
|
||||
const angleOut = document.getElementById('angle-out');
|
||||
const crankLen = document.getElementById('crank-len');
|
||||
const crankLenOut = document.getElementById('crank-len-out');
|
||||
const legLen = document.getElementById('leg-len');
|
||||
const legLenOut = document.getElementById('leg-len-out');
|
||||
const tendonLen = document.getElementById('tendon-len');
|
||||
const tendonLenOut = document.getElementById('tendon-len-out');
|
||||
const bodyLen = document.getElementById('body-len');
|
||||
const bodyLenOut = document.getElementById('body-len-out');
|
||||
const presetName = document.getElementById('preset-name');
|
||||
const presetSelect = document.getElementById('preset-select');
|
||||
const presetSaveBtn = document.getElementById('preset-save');
|
||||
const presetLoadBtn = document.getElementById('preset-load');
|
||||
const playBtn = document.getElementById('play');
|
||||
const resetBtn = document.getElementById('reset');
|
||||
const showNear = document.getElementById('show-near');
|
||||
const showFar = document.getElementById('show-far');
|
||||
const showTrace = document.getElementById('show-trace');
|
||||
const showAnnotations = document.getElementById('show-annotations');
|
||||
const show3d = document.getElementById('show-3d');
|
||||
const resetCameraBtn = document.getElementById('reset-camera');
|
||||
const paneHost = document.getElementById('tweakpane-root');
|
||||
const pane = new Pane({
|
||||
container: paneHost,
|
||||
title: 'Controls',
|
||||
expanded: true,
|
||||
});
|
||||
|
||||
function formatCm(v) {
|
||||
return `${Number(v).toFixed(1)} cm`;
|
||||
const params = {
|
||||
speedRps: state.speedRps,
|
||||
angleDeg: radToDeg(state.theta),
|
||||
crankCm: state.lengthCm.crank,
|
||||
legCm: state.lengthCm.leg,
|
||||
tendonCm: state.lengthCm.tendon,
|
||||
bodyCm: state.lengthCm.body,
|
||||
playing: state.playing,
|
||||
showNear: state.showNear,
|
||||
showFar: state.showFar,
|
||||
showTrace: state.showTrace,
|
||||
showAnnotations: state.showAnnotations,
|
||||
show3d: state.show3d,
|
||||
presetName: '',
|
||||
selectedPreset: '',
|
||||
};
|
||||
|
||||
function presetOptions() {
|
||||
const names = hooks.listPresets();
|
||||
if (names.length === 0) return { none: '' };
|
||||
|
||||
const options = {};
|
||||
for (const name of names) {
|
||||
options[name] = name;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function syncLengthInputs() {
|
||||
crankLen.value = String(state.lengthCm.crank);
|
||||
crankLenOut.value = formatCm(state.lengthCm.crank);
|
||||
legLen.value = String(state.lengthCm.leg);
|
||||
legLenOut.value = formatCm(state.lengthCm.leg);
|
||||
tendonLen.value = String(state.lengthCm.tendon);
|
||||
tendonLenOut.value = formatCm(state.lengthCm.tendon);
|
||||
bodyLen.value = String(state.lengthCm.body);
|
||||
bodyLenOut.value = formatCm(state.lengthCm.body);
|
||||
}
|
||||
|
||||
function selectedPresetName() {
|
||||
return presetSelect.value;
|
||||
}
|
||||
let presetBinding = null;
|
||||
|
||||
function rebuildPresetList(preferredName = null) {
|
||||
const names = hooks.listPresets();
|
||||
const previous = preferredName ?? selectedPresetName();
|
||||
|
||||
presetSelect.innerHTML = '';
|
||||
for (const name of names) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = name;
|
||||
presetSelect.appendChild(opt);
|
||||
const options = presetOptions();
|
||||
const optionValues = Object.values(options);
|
||||
if (optionValues.length === 0) {
|
||||
params.selectedPreset = '';
|
||||
} else {
|
||||
const next = preferredName && optionValues.includes(preferredName)
|
||||
? preferredName
|
||||
: optionValues[0];
|
||||
params.selectedPreset = next;
|
||||
}
|
||||
|
||||
if (previous && names.includes(previous)) {
|
||||
presetSelect.value = previous;
|
||||
} else if (names.length > 0) {
|
||||
presetSelect.value = names[0];
|
||||
if (presetBinding) {
|
||||
presetBinding.dispose();
|
||||
presetBinding = null;
|
||||
}
|
||||
|
||||
presetBinding = presetsFolder.addBinding(params, 'selectedPreset', {
|
||||
label: 'Saved preset',
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
speed.value = String(state.speedRps);
|
||||
speedOut.value = Number(state.speedRps).toFixed(2);
|
||||
|
||||
angle.value = String(radToDeg(state.theta));
|
||||
angleOut.value = Number(radToDeg(state.theta)).toFixed(1);
|
||||
|
||||
syncLengthInputs();
|
||||
rebuildPresetList();
|
||||
|
||||
showNear.checked = state.showNear;
|
||||
showFar.checked = state.showFar;
|
||||
showTrace.checked = state.showTrace;
|
||||
showAnnotations.checked = state.showAnnotations;
|
||||
show3d.checked = state.show3d;
|
||||
|
||||
speed.addEventListener('input', () => {
|
||||
const v = Number(speed.value);
|
||||
state.speedRps = Number.isFinite(v) ? v : 0;
|
||||
speedOut.value = state.speedRps.toFixed(2);
|
||||
const motionFolder = pane.addFolder({ title: 'Motion' });
|
||||
motionFolder.addBinding(params, 'speedRps', { label: 'Speed (rev/s)', min: 0, max: 2, step: 0.01 }).on('change', (ev) => {
|
||||
state.speedRps = Number.isFinite(ev.value) ? ev.value : 0;
|
||||
});
|
||||
|
||||
angle.addEventListener('input', () => {
|
||||
const v = Number(angle.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
hooks.setTheta(degToRad(v));
|
||||
angleOut.value = v.toFixed(1);
|
||||
motionFolder.addBinding(params, 'angleDeg', { label: 'Angle (deg)', min: 0, max: 360, step: 0.1 }).on('change', (ev) => {
|
||||
if (!Number.isFinite(ev.value)) return;
|
||||
hooks.setTheta(degToRad(ev.value));
|
||||
});
|
||||
|
||||
crankLen.addEventListener('input', () => {
|
||||
const v = Number(crankLen.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
state.lengthCm.crank = v;
|
||||
crankLenOut.value = formatCm(v);
|
||||
hooks.refresh();
|
||||
motionFolder.addBinding(params, 'playing', { label: 'Playing' }).on('change', (ev) => {
|
||||
state.playing = Boolean(ev.value);
|
||||
});
|
||||
|
||||
legLen.addEventListener('input', () => {
|
||||
const v = Number(legLen.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
state.lengthCm.leg = v;
|
||||
legLenOut.value = formatCm(v);
|
||||
hooks.refresh();
|
||||
});
|
||||
|
||||
tendonLen.addEventListener('input', () => {
|
||||
const v = Number(tendonLen.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
state.lengthCm.tendon = v;
|
||||
tendonLenOut.value = formatCm(v);
|
||||
hooks.refresh();
|
||||
});
|
||||
|
||||
bodyLen.addEventListener('input', () => {
|
||||
const v = Number(bodyLen.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
state.lengthCm.body = v;
|
||||
bodyLenOut.value = formatCm(v);
|
||||
hooks.refresh();
|
||||
});
|
||||
|
||||
playBtn.addEventListener('click', () => {
|
||||
state.playing = !state.playing;
|
||||
playBtn.textContent = state.playing ? 'Pause' : 'Play';
|
||||
});
|
||||
|
||||
resetBtn.addEventListener('click', () => {
|
||||
motionFolder.addButton({ title: 'Reset' }).on('click', () => {
|
||||
hooks.reset();
|
||||
syncLengthInputs();
|
||||
rebuildPresetList();
|
||||
syncFromState();
|
||||
rebuildPresetList(params.selectedPreset);
|
||||
pane.refresh();
|
||||
});
|
||||
|
||||
presetSaveBtn.addEventListener('click', () => {
|
||||
const savedName = hooks.savePreset(presetName.value);
|
||||
if (!savedName) return;
|
||||
presetName.value = savedName;
|
||||
rebuildPresetList(savedName);
|
||||
const geometryFolder = pane.addFolder({ title: 'Geometry (cm)' });
|
||||
geometryFolder.addBinding(params, 'crankCm', { label: 'Crank (OC)', min: 1, max: 80, step: 0.1 }).on('change', (ev) => {
|
||||
if (!Number.isFinite(ev.value)) return;
|
||||
state.lengthCm.crank = ev.value;
|
||||
hooks.refresh();
|
||||
});
|
||||
geometryFolder.addBinding(params, 'legCm', { label: 'Leg (CG)', min: 10, max: 90, step: 0.1 }).on('change', (ev) => {
|
||||
if (!Number.isFinite(ev.value)) return;
|
||||
state.lengthCm.leg = ev.value;
|
||||
hooks.refresh();
|
||||
});
|
||||
geometryFolder.addBinding(params, 'tendonCm', { label: 'Tendon (BE)', min: 1, max: 80, step: 0.1 }).on('change', (ev) => {
|
||||
if (!Number.isFinite(ev.value)) return;
|
||||
state.lengthCm.tendon = ev.value;
|
||||
hooks.refresh();
|
||||
});
|
||||
geometryFolder.addBinding(params, 'bodyCm', { label: 'Body (AB)', min: 5, max: 110, step: 0.1 }).on('change', (ev) => {
|
||||
if (!Number.isFinite(ev.value)) return;
|
||||
state.lengthCm.body = ev.value;
|
||||
hooks.refresh();
|
||||
});
|
||||
|
||||
presetLoadBtn.addEventListener('click', () => {
|
||||
const target = selectedPresetName();
|
||||
if (!target) return;
|
||||
const loaded = hooks.loadPreset(target);
|
||||
if (!loaded) return;
|
||||
syncLengthInputs();
|
||||
rebuildPresetList(target);
|
||||
const visibilityFolder = pane.addFolder({ title: 'Visibility' });
|
||||
visibilityFolder.addBinding(params, 'showNear', { label: 'Show near side' }).on('change', (ev) => {
|
||||
state.showNear = Boolean(ev.value);
|
||||
});
|
||||
|
||||
showNear.addEventListener('change', () => {
|
||||
state.showNear = showNear.checked;
|
||||
visibilityFolder.addBinding(params, 'showFar', { label: 'Show far side (180°)' }).on('change', (ev) => {
|
||||
state.showFar = Boolean(ev.value);
|
||||
});
|
||||
|
||||
showFar.addEventListener('change', () => {
|
||||
state.showFar = showFar.checked;
|
||||
visibilityFolder.addBinding(params, 'showTrace', { label: 'Show foot traces' }).on('change', (ev) => {
|
||||
state.showTrace = Boolean(ev.value);
|
||||
});
|
||||
|
||||
showTrace.addEventListener('change', () => {
|
||||
state.showTrace = showTrace.checked;
|
||||
visibilityFolder.addBinding(params, 'showAnnotations', { label: 'Overlay linkage annotations' }).on('change', (ev) => {
|
||||
state.showAnnotations = Boolean(ev.value);
|
||||
});
|
||||
|
||||
showAnnotations.addEventListener('change', () => {
|
||||
state.showAnnotations = showAnnotations.checked;
|
||||
});
|
||||
|
||||
show3d.addEventListener('change', () => {
|
||||
state.show3d = show3d.checked;
|
||||
visibilityFolder.addBinding(params, 'show3d', { label: 'Show 3D pane' }).on('change', (ev) => {
|
||||
state.show3d = Boolean(ev.value);
|
||||
hooks.set3DVisible?.(state.show3d);
|
||||
});
|
||||
|
||||
resetCameraBtn.addEventListener('click', () => {
|
||||
visibilityFolder.addButton({ title: 'Reset 3D camera' }).on('click', () => {
|
||||
hooks.reset3DCamera?.();
|
||||
});
|
||||
|
||||
const presetsFolder = pane.addFolder({ title: 'Presets' });
|
||||
presetsFolder.addBinding(params, 'presetName', { label: 'Preset name' });
|
||||
rebuildPresetList();
|
||||
presetsFolder.addButton({ title: 'Save preset' }).on('click', () => {
|
||||
const savedName = hooks.savePreset(params.presetName);
|
||||
if (!savedName) return;
|
||||
params.presetName = savedName;
|
||||
rebuildPresetList(savedName);
|
||||
pane.refresh();
|
||||
});
|
||||
presetsFolder.addButton({ title: 'Load selected preset' }).on('click', () => {
|
||||
const target = params.selectedPreset;
|
||||
if (!target) return;
|
||||
const loaded = hooks.loadPreset(target);
|
||||
if (!loaded) return;
|
||||
syncFromState();
|
||||
rebuildPresetList(target);
|
||||
pane.refresh();
|
||||
});
|
||||
|
||||
function syncFromState() {
|
||||
params.speedRps = state.speedRps;
|
||||
params.angleDeg = radToDeg(state.theta);
|
||||
params.crankCm = state.lengthCm.crank;
|
||||
params.legCm = state.lengthCm.leg;
|
||||
params.tendonCm = state.lengthCm.tendon;
|
||||
params.bodyCm = state.lengthCm.body;
|
||||
params.playing = state.playing;
|
||||
params.showNear = state.showNear;
|
||||
params.showFar = state.showFar;
|
||||
params.showTrace = state.showTrace;
|
||||
params.showAnnotations = state.showAnnotations;
|
||||
params.show3d = state.show3d;
|
||||
}
|
||||
|
||||
syncFromState();
|
||||
pane.refresh();
|
||||
|
||||
return {
|
||||
syncAngle(theta) {
|
||||
const deg = radToDeg(theta);
|
||||
angle.value = String(deg);
|
||||
angleOut.value = deg.toFixed(1);
|
||||
params.angleDeg = radToDeg(theta);
|
||||
pane.refresh();
|
||||
},
|
||||
syncPlay() {
|
||||
playBtn.textContent = state.playing ? 'Pause' : 'Play';
|
||||
params.playing = state.playing;
|
||||
pane.refresh();
|
||||
},
|
||||
syncLengths() {
|
||||
syncLengthInputs();
|
||||
params.crankCm = state.lengthCm.crank;
|
||||
params.legCm = state.lengthCm.leg;
|
||||
params.tendonCm = state.lengthCm.tendon;
|
||||
params.bodyCm = state.lengthCm.body;
|
||||
pane.refresh();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
11
styles.css
11
styles.css
@@ -27,7 +27,7 @@ body {
|
||||
.views-wrap {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -62,6 +62,14 @@ body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#tweakpane-root {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#tweakpane-root .tp-dfwv {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 1.1rem;
|
||||
@@ -149,6 +157,7 @@ select {
|
||||
}
|
||||
|
||||
.views-wrap {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user