diff --git a/README.md b/README.md
index 9109951..c63d568 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/index.html b/index.html
index cfab39e..e3b9807 100644
--- a/index.html
+++ b/index.html
@@ -5,11 +5,26 @@
Walker Linkage Simulator
+
-
-
+
@@ -92,6 +107,15 @@
Overlay linkage annotations
+
+
+ Show 3D pane
+
+
+
+ Reset 3D camera
+
+
Diagnostics
loading…
diff --git a/src/main.js b/src/main.js
index 6cddbe6..519f027 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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);
diff --git a/src/renderer3d.js b/src/renderer3d.js
new file mode 100644
index 0000000..4ce7ec4
--- /dev/null
+++ b/src/renderer3d.js
@@ -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,
+ };
+}
diff --git a/src/ui.js b/src/ui.js
index 59d4659..c1cd909 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -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);
diff --git a/styles.css b/styles.css
index b3545af..a750dc6 100644
--- a/styles.css
+++ b/styles.css
@@ -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;
+ }
+}