Add Three.js 3D pane below 2D simulator

This commit is contained in:
2026-02-12 15:14:07 +02:00
parent 2d138c6b67
commit cc95b2a2e8
6 changed files with 309 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
# Walker Linkage Web Simulator
2D browser simulator of the walker linkage from [`linkage.svg`](linkage.svg), implemented with HTML Canvas and pure kinematics.
2D + 3D browser simulator of the walker linkage from [`linkage.svg`](linkage.svg), implemented with HTML Canvas for the top pane and Three.js for the lower pane.
## Features
@@ -22,17 +22,19 @@
- Presets save/load for named dimension setups (persisted in browser local storage).
- Foot trajectory traces for both sides.
- Optional linkage annotation overlay (joint and link labels).
- Optional real-time 3D viewport with orbit camera and camera reset.
- Diagnostics panel with closure error and fallback counters.
## File layout
- [`index.html`](index.html): Canvas + control panel shell
- [`styles.css`](styles.css): App layout and styling
- [`index.html`](index.html): 2D + 3D viewport shell and control panel
- [`styles.css`](styles.css): Stacked view layout and styling
- [`src/main.js`](src/main.js): bootstrap, animation loop, integration
- [`src/math.js`](src/math.js): vector math and circle intersection
- [`src/geometry.js`](src/geometry.js): normalized points and derived lengths
- [`src/kinematics.js`](src/kinematics.js): mechanism solve for near/far sides
- [`src/renderer.js`](src/renderer.js): Canvas rendering pipeline
- [`src/renderer3d.js`](src/renderer3d.js): Three.js scene, camera, and 3D linkage rendering
- [`src/trace.js`](src/trace.js): trajectory buffers
- [`src/ui.js`](src/ui.js): controls + diagnostics text
@@ -40,6 +42,8 @@
Any static server is enough.
No build step is required. Three.js is loaded via ESM URL imports.
Example with Python:
```bash

View File

@@ -5,11 +5,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Walker Linkage Simulator</title>
<link rel="stylesheet" href="./styles.css" />
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.161.0/build/three.module.js"
}
}
</script>
</head>
<body>
<main class="app">
<section class="canvas-wrap">
<canvas id="sim-canvas" width="1100" height="700"></canvas>
<section class="views-wrap">
<section class="view-panel" id="view-2d-panel">
<h2>2D View</h2>
<canvas id="sim-canvas" width="1100" height="700"></canvas>
</section>
<section class="view-panel" id="view-3d-panel">
<h2>3D View</h2>
<div id="sim-3d" class="sim-3d" aria-label="3D mechanism viewport"></div>
</section>
</section>
<aside class="panel" id="controls-panel">
@@ -92,6 +107,15 @@
<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>
<section class="diag">
<h2>Diagnostics</h2>
<pre id="diag">loading…</pre>

View File

@@ -1,6 +1,7 @@
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';
@@ -17,6 +18,9 @@ const DEFAULT_LENGTH_CM = {
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 = {
@@ -27,6 +31,7 @@ const state = {
showFar: true,
showTrace: true,
showAnnotations: false,
show3d: true,
lengthCm: { ...DEFAULT_LENGTH_CM },
fallbackCount: 0,
pose: null,
@@ -46,6 +51,35 @@ const state = {
},
};
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) {
@@ -247,12 +281,25 @@ const ui = wireUI(state, {
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;
@@ -292,6 +339,7 @@ function frame() {
ui.syncAngle(state.theta);
ui.syncPlay();
renderScene(canvas, state);
if (state.show3d) renderer3d.render(state);
updateDiagnostics(diagEl, state);
requestAnimationFrame(frame);

174
src/renderer3d.js Normal file
View File

@@ -0,0 +1,174 @@
import * as THREE from 'three';
import { OrbitControls } from 'https://unpkg.com/three@0.161.0/examples/jsm/controls/OrbitControls.js';
const SIDE_LINKS = [
['A', 'B', 0xea0000],
['O', 'C', 0x00ff00],
['A', 'D', 0x111111],
['C', 'D', 0x111111],
['C', 'F', 0x111111],
['B', 'E', 0x111111],
['C', 'E', 0x111111],
['C', 'G', 0x111111],
];
const JOINT_KEYS = ['A', 'B', 'O', 'C', 'D', 'E', 'F', 'G'];
function toVec3(point, z = 0) {
return new THREE.Vector3(point.x, -point.y, z);
}
function createLine(color) {
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(),
new THREE.Vector3(1, 0, 0),
]);
const material = new THREE.LineBasicMaterial({ color });
return new THREE.Line(geometry, material);
}
function setLinePoints(line, a, b) {
const pos = line.geometry.attributes.position;
pos.setXYZ(0, a.x, a.y, a.z);
pos.setXYZ(1, b.x, b.y, b.z);
pos.needsUpdate = true;
line.geometry.computeBoundingSphere();
}
export function create3DRenderer(mountEl) {
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f1116);
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 2000);
camera.position.set(0, 95, 170);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
mountEl.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.target.set(0, 0, 0);
controls.update();
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
const keyLight = new THREE.DirectionalLight(0xffffff, 0.55);
keyLight.position.set(90, 120, 90);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x9ab6ff, 0.25);
fillLight.position.set(-120, 80, -40);
scene.add(fillLight);
const grid = new THREE.GridHelper(320, 32, 0x2b3850, 0x1a2230);
grid.position.y = 0;
scene.add(grid);
const floorMat = new THREE.MeshStandardMaterial({
color: 0x232e40,
transparent: true,
opacity: 0.55,
roughness: 0.85,
metalness: 0.05,
});
const floor = new THREE.Mesh(new THREE.PlaneGeometry(360, 240), floorMat);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
const jointGeom = new THREE.SphereGeometry(1.35, 14, 10);
const nearJointMat = new THREE.MeshStandardMaterial({ color: 0xffff00 });
const farJointMat = new THREE.MeshStandardMaterial({ color: 0xffd95a });
const nearJoints = {};
const farJoints = {};
for (const key of JOINT_KEYS) {
nearJoints[key] = new THREE.Mesh(jointGeom, nearJointMat);
scene.add(nearJoints[key]);
farJoints[key] = new THREE.Mesh(jointGeom, farJointMat);
scene.add(farJoints[key]);
}
const nearGroup = [];
const farGroup = [];
for (const [from, to, color] of SIDE_LINKS) {
const nearLine = createLine(color);
nearLine.material.opacity = 1;
nearLine.material.transparent = false;
scene.add(nearLine);
nearGroup.push({ from, to, line: nearLine });
const farLine = createLine(color);
farLine.material.opacity = 0.55;
farLine.material.transparent = true;
scene.add(farLine);
farGroup.push({ from, to, line: farLine });
}
const sideOffset = 8;
function setVisible(visible) {
renderer.domElement.style.display = visible ? 'block' : 'none';
}
function resize() {
const width = Math.max(1, mountEl.clientWidth || 1);
const height = Math.max(1, mountEl.clientHeight || 1);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function resetCamera() {
camera.position.set(0, 95, 170);
controls.target.set(0, 0, 0);
controls.update();
}
function render(state) {
const near = state.pose?.near;
const far = state.pose?.far;
if (!near || !far) return;
for (const key of JOINT_KEYS) {
nearJoints[key].position.copy(toVec3(near[key], -sideOffset));
farJoints[key].position.copy(toVec3(far[key], sideOffset));
nearJoints[key].visible = state.showNear;
farJoints[key].visible = state.showFar;
}
for (const item of nearGroup) {
const a = toVec3(near[item.from], -sideOffset);
const b = toVec3(near[item.to], -sideOffset);
setLinePoints(item.line, a, b);
item.line.visible = state.showNear;
}
for (const item of farGroup) {
const a = toVec3(far[item.from], sideOffset);
const b = toVec3(far[item.to], sideOffset);
setLinePoints(item.line, a, b);
item.line.visible = state.showFar;
}
const floorY = state.ground?.floorY;
if (Number.isFinite(floorY)) {
floor.position.y = -floorY;
grid.position.y = -floorY;
}
controls.update();
renderer.render(scene, camera);
}
resize();
return {
resize,
render,
setVisible,
resetCamera,
};
}

View File

@@ -23,6 +23,8 @@ export function wireUI(state, hooks) {
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');
function formatCm(v) {
return `${Number(v).toFixed(1)} cm`;
@@ -75,6 +77,7 @@ export function wireUI(state, hooks) {
showFar.checked = state.showFar;
showTrace.checked = state.showTrace;
showAnnotations.checked = state.showAnnotations;
show3d.checked = state.show3d;
speed.addEventListener('input', () => {
const v = Number(speed.value);
@@ -164,6 +167,15 @@ export function wireUI(state, hooks) {
state.showAnnotations = showAnnotations.checked;
});
show3d.addEventListener('change', () => {
state.show3d = show3d.checked;
hooks.set3DVisible?.(state.show3d);
});
resetCameraBtn.addEventListener('click', () => {
hooks.reset3DCamera?.();
});
return {
syncAngle(theta) {
const deg = radToDeg(theta);

View File

@@ -24,16 +24,36 @@ body {
min-height: 100vh;
}
.canvas-wrap {
.views-wrap {
padding: 12px;
display: grid;
grid-template-rows: 1fr 1fr;
gap: 12px;
min-height: 0;
}
#sim-canvas {
.view-panel {
min-height: 0;
display: flex;
flex-direction: column;
}
.view-panel h2 {
margin: 0 0 8px;
}
#sim-canvas,
.sim-3d {
width: 100%;
height: calc(100vh - 24px);
height: 100%;
background: #0f1116;
border: 1px solid #2a3342;
border-radius: 8px;
min-height: 260px;
}
.sim-3d {
overflow: hidden;
}
.panel {
@@ -68,6 +88,10 @@ h2 {
grid-template-columns: 1fr 1fr;
}
.buttons.single {
grid-template-columns: 1fr;
}
button {
background: #2b3850;
border: 1px solid #3d5274;
@@ -113,3 +137,18 @@ select {
line-height: 1.3;
font-size: 12px;
}
@media (max-width: 1100px) {
.app {
grid-template-columns: 1fr;
}
.panel {
border-left: 0;
border-top: 1px solid #2a3342;
}
.views-wrap {
min-height: 70vh;
}
}