Add Three.js 3D pane below 2D simulator
This commit is contained in:
10
README.md
10
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
|
||||
|
||||
28
index.html
28
index.html
@@ -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>
|
||||
|
||||
48
src/main.js
48
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);
|
||||
|
||||
174
src/renderer3d.js
Normal file
174
src/renderer3d.js
Normal 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,
|
||||
};
|
||||
}
|
||||
12
src/ui.js
12
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);
|
||||
|
||||
45
styles.css
45
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user