Compare commits

..

5 Commits

Author SHA1 Message Date
Martino Russi dfb47b91a5 Merge branch 'main' into mrussi/glove_visualizer 2026-07-05 17:35:05 +02:00
pre-commit-ci[bot] 4c17a7be2b [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-09-01 09:48:07 +00:00
nepyope c8c37bd339 added visualizer 2025-09-01 11:47:45 +02:00
pre-commit-ci[bot] 0ccc08e347 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-08-12 12:16:07 +00:00
nepyope 7b207d44a0 added glove visualizer 2025-08-12 14:12:13 +02:00
8 changed files with 1710 additions and 390 deletions
+8
View File
@@ -117,6 +117,14 @@ middle_dip | 1484 | 1500 | 1547
Once calibration is complete, the system will save the calibration to `/Users/your_username/.cache/huggingface/lerobot/calibration/teleoperators/homunculus_glove/red.json`
#### Visualizing Teleoperator Glove
After calibration, you can visualize the glove movements in real-time. Open the visualizer by navigating to the visualizer directory and opening the HTML file in your browser:
```bash
open examples/hopejr/visualizer/index.html
```
### 1.3 Calibrate Robot Arm
```bash
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Hand Joint Visualizer</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100vh;
}
.controls {
padding: 15px;
background-color: #f5f5f5;
z-index: 100;
}
.status {
padding: 10px;
border-radius: 5px;
margin: 10px 0;
}
.connected {
background-color: #d4edda;
color: #155724;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
}
button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
button:hover {
background-color: #45a049;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.container {
display: flex;
flex: 1;
overflow: hidden;
}
#canvas-container {
flex: 3;
position: relative;
}
#sidebar {
flex: 1;
padding: 15px;
background-color: #f8f9fa;
overflow-y: auto;
max-width: 300px;
border-left: 1px solid #ddd;
}
.joint-info {
margin-bottom: 10px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.joint-name {
font-weight: bold;
}
.joint-value {
font-family: monospace;
}
.bar-container {
width: 100%;
background-color: #e0e0e0;
height: 10px;
border-radius: 5px;
overflow: hidden;
margin-top: 5px;
}
.bar {
height: 100%;
background-color: #4CAF50;
width: 0%;
transition: width 0.2s ease-in-out;
}
.log-container {
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
height: 150px;
overflow-y: auto;
font-family: monospace;
background-color: #f8f9fa;
}
.view-controls {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 10;
}
.view-button {
background-color: rgba(0, 0, 0, 0.5);
color: white;
border: none;
padding: 5px 10px;
margin-right: 5px;
border-radius: 3px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="controls">
<button id="connectButton">Connect to Device</button>
<button id="disconnectButton" disabled>Disconnect</button>
<select id="baudRate">
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="115200" selected>115200</option>
</select>
<span id="statusIndicator" class="status disconnected">Status: Disconnected</span>
</div>
<div class="container">
<div id="canvas-container">
<!-- 3D canvas will be inserted here -->
<div class="view-controls">
<button class="view-button" id="frontView">Front</button>
<button class="view-button" id="sideView">Side</button>
<button class="view-button" id="topView">Top</button>
<button class="view-button" id="resetView">Reset</button>
</div>
</div>
<div id="sidebar">
<h3>Joint Values</h3>
<div id="jointsContainer">
<!-- Joint info will be added here -->
</div>
<div class="log-container" id="logContainer">
<!-- Log messages will be added here -->
</div>
</div>
</div>
<!-- Import Three.js -->
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/OrbitControls.js"></script>
<script src="script.js"></script>
</body>
</html>
+669
View File
@@ -0,0 +1,669 @@
// === Hand Visualizer with Pre-Connect Sliders + Per-Joint Angle Limits ===
// Assumes your HTML already has elements with the following IDs:
// connectButton, disconnectButton, baudRate, statusIndicator, jointsContainer, logContainer,
// canvas-container, frontView, sideView, topView, resetView
// Requires Three.js + OrbitControls loaded on the page.
// -------------------- Config --------------------
const MAX_JOINTS = 16;
const RAW_MIN = 0, RAW_MAX = 4096;
const RAW_CENTER = (RAW_MIN + RAW_MAX) / 2;
const DEG = Math.PI / 180;
const UI_DEG_MIN = -90, UI_DEG_MAX = 90; // UI sliders for angle limits
// -------------------- State --------------------
let port;
let reader;
let keepReading = false;
let isConnected = false;
const decoder = new TextDecoder();
let inputBuffer = '';
let jointValues = new Array(MAX_JOINTS).fill(RAW_CENTER);
// Auto-calibration: track observed min/max per joint
let observedMin = new Array(MAX_JOINTS).fill(Infinity);
let observedMax = new Array(MAX_JOINTS).fill(-Infinity);
let calibrationEnabled = true;
// Three.js
let scene, camera, renderer, controls;
let hand = { palm: null, fingers: [] };
// DOM
const connectButton = document.getElementById('connectButton');
const disconnectButton = document.getElementById('disconnectButton');
const baudRateSelect = document.getElementById('baudRate');
const statusIndicator = document.getElementById('statusIndicator');
const jointsContainer = document.getElementById('jointsContainer');
const logContainer = document.getElementById('logContainer');
const canvasContainer = document.getElementById('canvas-container');
const frontViewBtn = document.getElementById('frontView');
const sideViewBtn = document.getElementById('sideView');
const topViewBtn = document.getElementById('topView');
const resetViewBtn = document.getElementById('resetView');
// Helpers
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const invLerp = (a, b, x) => clamp((x - a) / (b - a), 0, 1);
// -------------------- Joint Map with per-joint angle limits --------------------
const fingerJointMap = [
// Thumb (4)
{ finger:0, joint:0, type:'CMC_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:0, joint:1, type:'CMC_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:0, joint:2, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
{ finger:0, joint:3, type:'IP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Index (3)
{ finger:1, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:1, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:1, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Middle (3)
{ finger:2, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:2, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:2, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Ring (3)
{ finger:3, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:3, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:3, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false }, // +45° only
// Pinky (3)
{ finger:4, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:4, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:4, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false } // +45° only
];
// Assign angle limits (radians) per joint (default ±45°, exceptions: +45° only)
for (const j of fingerJointMap) {
const isThumb = j.finger === 0;
const isPIP = j.type === 'PIP_FLEXION';
let minA = -45 * DEG, maxA = +45 * DEG;
if ((isThumb && (j.type === 'MCP_FLEXION' || j.type === 'IP_FLEXION')) || (!isThumb && isPIP)) {
minA = 0;
maxA = +45 * DEG;
}
j.angleMin = minA;
j.angleMax = maxA;
}
// -------------------- UI: Joint Panel --------------------
const uiRefs = []; // per joint: { valueLabel, bar, barWrap, slider, invertChk, minDeg, maxDeg }
function initializeJointElements() {
jointsContainer.innerHTML = '';
uiRefs.length = 0;
for (let i = 0; i < MAX_JOINTS; i++) {
const wrap = document.createElement('div');
wrap.className = 'joint-info';
const fingerIndex = i < 4 ? 0 : Math.floor((i - 4) / 3) + 1;
const jointInfo = fingerJointMap[i];
const jointType = jointInfo?.type || 'Unknown';
const fingerName = ['Thumb', 'Index', 'Middle', 'Ring', 'Pinky'][fingerIndex];
// Header
const nameEl = document.createElement('div');
nameEl.className = 'joint-name';
nameEl.textContent = `${fingerName} ${jointType}`;
// Value + bar
const valueEl = document.createElement('div');
valueEl.className = 'joint-value';
valueEl.textContent = `Value: ${jointValues[i]}`;
const barWrap = document.createElement('div');
barWrap.className = 'bar-container';
const barEl = document.createElement('div');
barEl.className = 'bar';
barWrap.appendChild(barEl);
// Slider for pre-connect manual control
const slider = document.createElement('input');
slider.type = 'range';
slider.min = String(RAW_MIN);
slider.max = String(RAW_MAX);
slider.value = String(jointValues[i]);
slider.step = '1';
slider.className = 'joint-slider';
slider.addEventListener('input', () => {
if (isConnected) return; // ignore while connected
let v = parseInt(slider.value, 10);
if (jointInfo?.inverted) v = (jointInfo.min + jointInfo.max) - v;
jointValues[i] = clamp(jointInfo ? v : 0, RAW_MIN, RAW_MAX);
updateJointDisplay(i, jointValues[i]);
updateHandModel();
});
// Invert checkbox
const invertLbl = document.createElement('label');
invertLbl.className = 'invert-toggle';
const invertChk = document.createElement('input');
invertChk.type = 'checkbox';
invertChk.checked = !!jointInfo?.inverted;
invertChk.addEventListener('change', () => {
if (jointInfo) jointInfo.inverted = invertChk.checked;
addLogMessage(`${fingerName} ${jointType} inversion ${invertChk.checked ? 'enabled' : 'disabled'}`);
});
invertLbl.appendChild(invertChk);
invertLbl.appendChild(document.createTextNode('Invert Values'));
// Angle limits (deg) controls
const limitsRow = document.createElement('div');
limitsRow.className = 'limits-row';
const minDeg = document.createElement('input');
minDeg.type = 'number';
minDeg.min = String(UI_DEG_MIN);
minDeg.max = String(UI_DEG_MAX);
minDeg.step = '1';
minDeg.value = String(Math.round((jointInfo.angleMin || 0) / DEG));
minDeg.className = 'limit-num';
const maxDeg = document.createElement('input');
maxDeg.type = 'number';
maxDeg.min = String(UI_DEG_MIN);
maxDeg.max = String(UI_DEG_MAX);
maxDeg.step = '1';
maxDeg.value = String(Math.round((jointInfo.angleMax || 0) / DEG));
maxDeg.className = 'limit-num';
const minLbl = document.createElement('span'); minLbl.textContent = 'min°';
const maxLbl = document.createElement('span'); maxLbl.textContent = 'max°';
minLbl.className = 'limit-label'; maxLbl.className = 'limit-label';
function syncLimits() {
let mn = parseFloat(minDeg.value);
let mx = parseFloat(maxDeg.value);
if (isNaN(mn)) mn = -45;
if (isNaN(mx)) mx = +45;
if (mn > mx) [mn, mx] = [mx, mn];
jointInfo.angleMin = clamp(mn, UI_DEG_MIN, UI_DEG_MAX) * DEG;
jointInfo.angleMax = clamp(mx, UI_DEG_MIN, UI_DEG_MAX) * DEG;
minDeg.value = String(Math.round(jointInfo.angleMin / DEG));
maxDeg.value = String(Math.round(jointInfo.angleMax / DEG));
updateHandModel();
}
minDeg.addEventListener('change', syncLimits);
maxDeg.addEventListener('change', syncLimits);
limitsRow.appendChild(minLbl);
limitsRow.appendChild(minDeg);
limitsRow.appendChild(maxLbl);
limitsRow.appendChild(maxDeg);
// Calibration controls
const calibRow = document.createElement('div');
calibRow.className = 'calib-row';
const resetCalibBtn = document.createElement('button');
resetCalibBtn.textContent = 'Reset Calib';
resetCalibBtn.className = 'calib-btn';
resetCalibBtn.addEventListener('click', () => {
observedMin[i] = Infinity;
observedMax[i] = -Infinity;
addLogMessage(`Reset calibration for ${fingerName} ${jointType}`);
});
const calibStatus = document.createElement('span');
calibStatus.className = 'calib-status';
calibStatus.textContent = `Range: --`;
calibRow.appendChild(resetCalibBtn);
calibRow.appendChild(calibStatus);
// Compose
wrap.appendChild(nameEl);
wrap.appendChild(valueEl);
wrap.appendChild(barWrap);
wrap.appendChild(slider);
wrap.appendChild(invertLbl);
wrap.appendChild(limitsRow);
wrap.appendChild(calibRow);
jointsContainer.appendChild(wrap);
uiRefs[i] = { valueLabel: valueEl, bar: barEl, barWrap, slider, invertChk, minDeg, maxDeg, nameEl, calibStatus };
}
setConnectedUI(false); // initial state: sliders active
}
// Toggle UI between pre-connect SLIDERS vs post-connect BARS
function setConnectedUI(connected) {
isConnected = connected;
for (let i = 0; i < uiRefs.length; i++) {
const ui = uiRefs[i];
if (!ui) continue;
// Show bars when connected; sliders disabled/hidden
ui.barWrap.style.display = connected ? '' : 'none';
ui.slider.disabled = connected;
ui.slider.style.display = connected ? 'none' : '';
}
// Reset calibration when connecting
if (connected) {
observedMin.fill(Infinity);
observedMax.fill(-Infinity);
addLogMessage('Calibration reset - move joints through full range for best results');
}
}
// Update joint display (value text + bar color/width + slider position if needed)
function updateJointDisplay(jointIndex, value) {
const ui = uiRefs[jointIndex];
const info = fingerJointMap[jointIndex];
if (!ui || !info) return;
ui.valueLabel.textContent = `Value: ${value}`;
// bar
const min = info.min, max = info.max;
const pct = clamp((value - min) / (max - min), 0, 1) * 100;
ui.bar.style.width = `${pct}%`;
const hue = Math.floor(pct * 1.2); // 0..120
ui.bar.style.backgroundColor = `hsl(${hue}, 80%, 50%)`;
// slider (only meaningful when not connected; keep in sync anyway)
const rawForSlider = info.inverted ? (info.min + info.max) - value : value;
if (!isConnected) ui.slider.value = String(clamp(Math.round(rawForSlider), RAW_MIN, RAW_MAX));
}
// -------------------- Serial I/O --------------------
async function readSerialData() {
while (port?.readable && keepReading) {
reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) processData(decoder.decode(value));
}
} catch (err) {
console.error('Error reading:', err);
addLogMessage(`Error: ${err.message}`);
break;
} finally {
reader.releaseLock();
}
}
}
function processData(chunk) {
inputBuffer += chunk;
let idx;
while ((idx = inputBuffer.indexOf('\n')) !== -1) {
const line = inputBuffer.slice(0, idx).trim();
inputBuffer = inputBuffer.slice(idx + 1);
const vals = line.split(/\s+/).map(v => parseInt(v, 10));
if (vals.length === MAX_JOINTS && vals.every(v => Number.isFinite(v))) {
for (let i = 0; i < MAX_JOINTS; i++) {
const info = fingerJointMap[i];
if (!info) continue;
let rawValue = vals[i];
// Update calibration tracking
if (calibrationEnabled) {
observedMin[i] = Math.min(observedMin[i], rawValue);
observedMax[i] = Math.max(observedMax[i], rawValue);
// Update calibration display
const ui = uiRefs[i];
if (ui && ui.calibStatus) {
if (observedMin[i] !== Infinity && observedMax[i] !== -Infinity) {
ui.calibStatus.textContent = `Range: ${observedMin[i]}-${observedMax[i]}`;
}
}
// Remap observed range to target range
if (observedMin[i] !== Infinity && observedMax[i] !== -Infinity && observedMax[i] > observedMin[i]) {
const observedRange = observedMax[i] - observedMin[i];
const targetRange = info.max - info.min;
const normalizedValue = (rawValue - observedMin[i]) / observedRange;
rawValue = info.min + (normalizedValue * targetRange);
}
}
let v = clamp(rawValue, info.min, info.max);
if (info.inverted) v = (info.min + info.max) - v;
jointValues[i] = v;
updateJointDisplay(i, v);
}
updateHandModel();
} else {
addLogMessage(`Received: ${line}`);
}
}
}
async function connectToDevice() {
try {
port = await navigator.serial.requestPort();
const baudRate = parseInt(baudRateSelect.value, 10) || 115200;
await port.open({ baudRate });
keepReading = true;
setConnectedUI(true);
statusIndicator.textContent = 'Status: Connected';
statusIndicator.className = 'status connected';
connectButton.disabled = true;
disconnectButton.disabled = false;
baudRateSelect.disabled = true;
addLogMessage(`Connected at ${baudRate} baud`);
readSerialData();
} catch (e) {
console.error('Connect error:', e);
addLogMessage(`Connection error: ${e.message}`);
}
}
async function disconnectFromDevice() {
try {
keepReading = false;
if (reader) {
try { reader.cancel(); } catch {}
}
if (port) {
await port.close();
port = null;
}
} catch (e) {
console.error('Disconnect error:', e);
addLogMessage(`Disconnection error: ${e.message}`);
} finally {
setConnectedUI(false);
statusIndicator.textContent = 'Status: Disconnected';
statusIndicator.className = 'status disconnected';
connectButton.disabled = false;
disconnectButton.disabled = true;
baudRateSelect.disabled = false;
addLogMessage('Disconnected');
}
}
// -------------------- Three.js Scene --------------------
function initThreeJS() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f0f0);
camera = new THREE.PerspectiveCamera(
75,
canvasContainer.clientWidth / canvasContainer.clientHeight,
0.1, 1000
);
camera.position.set(0, 15, 15);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
canvasContainer.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.25;
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
const dir1 = new THREE.DirectionalLight(0xffffff, 0.5);
dir1.position.set(1, 1, 1);
scene.add(dir1);
const dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
dir2.position.set(-1, 1, -1);
scene.add(dir2);
const gridHelper = new THREE.GridHelper(20, 20);
scene.add(gridHelper);
createHandModel();
window.addEventListener('resize', onWindowResize);
animate();
}
function createHandModel() {
const palmMaterial = new THREE.MeshPhongMaterial({ color: 0xf5c396 });
const fingerMaterial = new THREE.MeshPhongMaterial({ color: 0xf5c396 });
const jointMaterial = new THREE.MeshPhongMaterial({ color: 0xe3a977 });
const palmGeometry = new THREE.BoxGeometry(7, 1, 8);
hand.palm = new THREE.Mesh(palmGeometry, palmMaterial);
hand.palm.position.set(0, 0, 0);
hand.palm.rotation.x = Math.PI / 2; // hand vertical, palm facing forward
scene.add(hand.palm);
const fingerWidth = 1, fingerHeight = 0.8;
const fingerSegmentLengths = [3, 2, 1.5];
const thumbSegmentLengths = [2, 2, 1.5];
const fingerBasePositions = [
[ 3, 0, -2], // Thumb
[ 1.5,-0.5,-4], // Index
[ 0, -0.5,-4], // Middle
[-1.5,-0.5,-4], // Ring
[-3, -0.5,-4], // Pinky
];
const fingerBaseRot = [
{ x:0, y:-Math.PI/3, z: Math.PI/3 }, // Thumb
{ x:0, y:-Math.PI/48, z: 0 },
{ x:0, y: Math.PI/48, z: 0 },
{ x:0, y: Math.PI/32, z: 0 },
{ x:0, y: Math.PI/24, z: 0 }
];
for (let fIdx = 0; fIdx < 5; fIdx++) {
const finger = { name:['Thumb','Index','Middle','Ring','Pinky'][fIdx], segments:[], joints:[] };
const isThumb = fIdx === 0;
const segLens = isThumb ? thumbSegmentLengths : fingerSegmentLengths;
finger.group = new THREE.Group();
finger.group.position.set(...fingerBasePositions[fIdx]);
finger.group.rotation.x = fingerBaseRot[fIdx].x;
finger.group.rotation.y = fingerBaseRot[fIdx].y;
finger.group.rotation.z = fingerBaseRot[fIdx].z;
finger.group.userData.baseRot = {
x:finger.group.rotation.x,
y:finger.group.rotation.y,
z:finger.group.rotation.z
};
hand.palm.add(finger.group);
let parent = finger.group;
for (let s = 0; s < segLens.length; s++) {
const segGroup = new THREE.Group();
const jGeom = new THREE.SphereGeometry(fingerWidth * 0.6, 8, 8);
const joint = new THREE.Mesh(jGeom, jointMaterial);
segGroup.add(joint);
const segGeom = new THREE.BoxGeometry(fingerWidth, fingerHeight, segLens[s]);
const seg = new THREE.Mesh(segGeom, fingerMaterial);
seg.position.z = -segLens[s] / 2;
segGroup.add(seg);
parent.add(segGroup);
finger.segments.push(segGroup);
finger.joints.push(joint);
if (s < segLens.length - 1) {
const connector = new THREE.Group();
connector.position.z = -segLens[s];
segGroup.add(connector);
parent = connector;
}
}
hand.fingers.push(finger);
}
addFingerLabels();
addHandLabel();
}
function addFingerLabels() {
const names = ['Thumb','Index','Middle','Ring','Pinky'];
for (let i = 0; i < hand.fingers.length; i++) {
const finger = hand.fingers[i];
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 128; canvas.height = 32;
ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.font = 'bold 16px Arial';
ctx.fillStyle = '#000000';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(names[i], canvas.width/2, canvas.height/2);
const texture = new THREE.CanvasTexture(canvas);
const geom = new THREE.PlaneGeometry(2, 0.5);
const mat = new THREE.MeshBasicMaterial({ map:texture, transparent:true, side:THREE.DoubleSide });
const label = new THREE.Mesh(geom, mat);
label.position.set(0, -1.5, -2);
label.rotation.x = Math.PI / 2;
finger.group.add(label);
}
}
function addHandLabel() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 256; canvas.height = 64;
ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.font = 'bold 24px Arial';
ctx.fillStyle = '#000000';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('RIGHT HAND (VERTICAL)', canvas.width/2, canvas.height/2);
const texture = new THREE.CanvasTexture(canvas);
const geom = new THREE.PlaneGeometry(7, 1.75);
const mat = new THREE.MeshBasicMaterial({ map:texture, transparent:true, side:THREE.DoubleSide });
const label = new THREE.Mesh(geom, mat);
label.position.set(0, -2, 0);
label.rotation.x = Math.PI / 2;
scene.add(label);
}
function updateHandModel() {
for (let i = 0; i < MAX_JOINTS; i++) {
const info = fingerJointMap[i];
if (!info) continue;
const { finger, joint, type, min, max, angleMin, angleMax } = info;
const raw = jointValues[i];
const f = hand.fingers[finger];
if (!f) continue;
const center = (min + max) / 2;
let angle = 0;
if (type.includes('ABDUCTION')) {
// symmetric around neutral
const k = clamp((raw - center) / ((max - min) / 2), -1, 1);
angle = angleMin + (k + 1) * 0.5 * (angleMax - angleMin);
const base = f.group.userData.baseRot || {x:0,y:0,z:0};
if (finger === 0 && joint === 0) {
// Thumb: abduction about Z (toward/away from palm)
f.group.rotation.z = base.z + angle;
} else {
// Other fingers: side-to-side about Y
f.group.rotation.y = base.y + angle;
}
} else if (type.includes('FLEXION')) {
const isThumb = finger === 0;
const isMCP = type === 'MCP_FLEXION';
const isPIP = type === 'PIP_FLEXION';
const positiveOnly = (isThumb && (type === 'MCP_FLEXION' || type === 'IP_FLEXION')) || (!isThumb && isPIP);
if (positiveOnly) {
const t = raw <= center ? 0 : invLerp(center, max, raw); // 0..1
angle = angleMin + t * (angleMax - angleMin); // 0..+limit
} else {
const k = clamp((raw - center) / ((max - min) / 2), -1, 1);
angle = angleMin + (k + 1) * 0.5 * (angleMax - angleMin);
}
if (isMCP) {
// MCP flexion applies to the finger base group (same as abduction)
const base = f.group.userData.baseRot || {x:0,y:0,z:0};
f.group.rotation.x = base.x + angle;
} else if (f.segments[joint]) {
// PIP/DIP/IP flexion applies to individual segments
f.segments[joint].rotation.x = angle;
}
}
}
}
// -------------------- Render Loop --------------------
function onWindowResize() {
camera.aspect = canvasContainer.clientWidth / canvasContainer.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
}
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
// -------------------- Misc UI --------------------
function addLogMessage(msg) {
const el = document.createElement('div');
el.textContent = msg;
logContainer.appendChild(el);
logContainer.scrollTop = logContainer.scrollHeight;
while (logContainer.children.length > 100) {
logContainer.removeChild(logContainer.firstChild);
}
}
// Camera view controls
frontViewBtn?.addEventListener('click', () => { camera.position.set(0, 0, 20); camera.lookAt(0,0,0); controls.update(); });
sideViewBtn?.addEventListener('click', () => { camera.position.set(20, 0, 0); camera.lookAt(0,0,0); controls.update(); });
topViewBtn?.addEventListener('click', () => { camera.position.set(0, 20, 0); camera.lookAt(0,0,0); controls.update(); });
resetViewBtn?.addEventListener('click', () => { camera.position.set(10,10,10); camera.lookAt(0,0,0); controls.update(); });
// Serial connect buttons
connectButton?.addEventListener('click', connectToDevice);
disconnectButton?.addEventListener('click', disconnectFromDevice);
// Web Serial support check
if (!navigator.serial) {
statusIndicator.textContent = 'Status: Web Serial API not supported in this browser';
connectButton.disabled = true;
addLogMessage('ERROR: Web Serial API is not supported in this browser. Try Chrome or Edge.');
}
// -------------------- Boot --------------------
initThreeJS();
initializeJointElements();
// -------------------- Styles (inline) --------------------
const styleElement = document.createElement('style');
styleElement.textContent = `
.joint-info { border-bottom: 1px solid #eee; padding: 8px 0; }
.joint-name { font-weight: 600; margin-bottom: 4px; }
.joint-value { font-size: 12px; color: #333; margin-bottom: 4px; }
.bar-container { width: 100%; height: 8px; background: #ddd; border-radius: 4px; overflow: hidden; }
.bar { height: 100%; width: 0%; background: #4caf50; }
.joint-slider { width: 100%; margin: 6px 0; }
.invert-toggle { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; color: #555; }
.limits-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.limit-label { font-size: 11px; color: #666; }
.limit-num { width: 60px; }
.calib-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.calib-btn { padding: 2px 6px; font-size: 11px; background: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer; }
.calib-btn:hover { background: #d32f2f; }
.calib-status { font-size: 11px; color: #666; }
.status.connected { color: #0a0; }
.status.disconnected { color: #a00; }
`;
document.head.appendChild(styleElement);
@@ -1,79 +0,0 @@
#!/usr/bin/env python
"""Convert a legacy LeRobot checkpoint to the current processor-pipeline format.
Older hub checkpoints (e.g. ``lerobot/act_aloha_sim_insertion_human``) bake
normalization stats into the model weights and do not ship
``policy_preprocessor.json`` / ``policy_postprocessor.json``. Current ``main``
loads those processor configs from the checkpoint, so eval/rollout fail with
``FileNotFoundError: Could not find 'policy_preprocessor.json'``.
This script rebuilds the processors from the training dataset's stats and saves
a pipeline-format checkpoint locally that ``lerobot-eval`` can consume directly.
Usage:
python examples/onnx/convert_legacy_checkpoint.py \
--policy-path=lerobot/act_aloha_sim_insertion_human \
--dataset-repo-id=lerobot/aloha_sim_insertion_human \
--output-dir=outputs/converted/act_aloha_sim_insertion_human
Then:
lerobot-eval \
--policy.path=outputs/converted/act_aloha_sim_insertion_human \
--env.type=aloha --env.task=AlohaInsertion-v0 \
--eval.batch_size=10 --eval.n_episodes=50 \
--eval.use_async_envs=false --policy.device=cuda
"""
import argparse
from pathlib import Path
from lerobot.configs.policies import PreTrainedConfig
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
from lerobot.policies.factory import make_policy, make_pre_post_processors
from lerobot.utils.constants import (
POLICY_POSTPROCESSOR_DEFAULT_NAME,
POLICY_PREPROCESSOR_DEFAULT_NAME,
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--policy-path", required=True, help="Legacy checkpoint repo id or local dir")
parser.add_argument(
"--dataset-repo-id",
required=True,
help="Training dataset repo id, used only for normalization stats",
)
parser.add_argument("--output-dir", required=True, help="Where to save the converted checkpoint")
parser.add_argument("--device", default="cpu", help="Device for building the policy (cpu is fine)")
args = parser.parse_args()
out = Path(args.output_dir)
out.mkdir(parents=True, exist_ok=True)
print(f"[1/4] Loading dataset stats from '{args.dataset_repo_id}' (metadata only)...")
ds_meta = LeRobotDatasetMetadata(args.dataset_repo_id)
print(f"[2/4] Loading policy weights from '{args.policy_path}'...")
cfg = PreTrainedConfig.from_pretrained(args.policy_path)
cfg.pretrained_path = args.policy_path
cfg.device = args.device
policy = make_policy(cfg, ds_meta=ds_meta)
print("[3/4] Building processors from dataset stats...")
preprocessor, postprocessor = make_pre_post_processors(
policy_cfg=policy.config,
dataset_stats=ds_meta.stats,
)
print(f"[4/4] Saving pipeline-format checkpoint to '{out}'...")
policy.save_pretrained(out)
preprocessor.save_pretrained(out, config_filename=f"{POLICY_PREPROCESSOR_DEFAULT_NAME}.json")
postprocessor.save_pretrained(out, config_filename=f"{POLICY_POSTPROCESSOR_DEFAULT_NAME}.json")
print(f"\nDone. Converted checkpoint at: {out}")
print("Eval it with --policy.path=" + str(out))
if __name__ == "__main__":
main()
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env python
"""Evaluate an ACT policy in sim with either the PyTorch or ONNX network.
The ONNX backend swaps only ``policy.model`` (ResNet + transformer + action head)
with an onnxruntime session. Everything else - the LeRobot processor pipeline
(normalization), the action queue, and the gym env - is identical, so any
difference in success rate is attributable to the network backend alone.
Run both backends with the same seed to compare:
python examples/onnx/eval_act_onnx.py \
--policy-path=lerobot/act_aloha_sim_transfer_cube_human \
--task=AlohaTransferCube-v0 \
--backend=torch --n-episodes=50 --batch-size=10 --device=cuda
python examples/onnx/eval_act_onnx.py \
--policy-path=lerobot/act_aloha_sim_transfer_cube_human \
--task=AlohaTransferCube-v0 \
--onnx=outputs/onnx/act_transfer_cube.onnx \
--backend=onnx --n-episodes=50 --batch-size=10 --device=cuda
"""
import argparse
from pathlib import Path
import numpy as np
import torch
from torch import nn
from lerobot.envs.factory import make_env, make_env_config, make_env_pre_post_processors
from lerobot.policies.act.modeling_act import ACTPolicy
from lerobot.policies.factory import make_pre_post_processors
from lerobot.scripts.lerobot_eval import eval_policy
from lerobot.utils.constants import OBS_ENV_STATE, OBS_IMAGES, OBS_STATE
from lerobot.utils.random_utils import set_seed
class ONNXACTModel(nn.Module):
"""Drop-in replacement for ``ACTPolicy.model`` backed by onnxruntime."""
def __init__(
self, onnx_path: str, image_keys: list[str], has_state: bool, has_env_state: bool, device: str
):
super().__init__()
import onnxruntime as ort
providers = (
["CUDAExecutionProvider", "CPUExecutionProvider"]
if str(device).startswith("cuda")
else ["CPUExecutionProvider"]
)
so = ort.SessionOptions()
so.log_severity_level = 3
self.sess = ort.InferenceSession(onnx_path, sess_options=so, providers=providers)
self.image_keys = image_keys
self.has_state = has_state
self.has_env_state = has_env_state
print(f"[onnx] providers in use: {self.sess.get_providers()}")
def forward(self, batch: dict):
state = batch[OBS_STATE] if self.has_state else batch[OBS_ENV_STATE]
ref = state
ort_inputs = {"state": state.detach().cpu().numpy().astype(np.float32)}
images = batch[OBS_IMAGES]
for i, img in enumerate(images):
ort_inputs[f"image_{i}"] = img.detach().cpu().numpy().astype(np.float32)
out = self.sess.run(None, ort_inputs)[0]
actions = torch.from_numpy(out).to(ref.device, dtype=ref.dtype)
return actions, None
def load_stats_from_checkpoint(policy_path: str, input_features, output_features) -> dict:
"""Recover MEAN_STD stats baked into a legacy ACT checkpoint's safetensors buffers.
Legacy checkpoints store normalization as buffers like
``normalize_inputs.buffer_observation_state.{mean,std}``. We map those back to
feature names so we can rebuild the processor pipeline without the dataset.
"""
from safetensors.torch import load_file
p = Path(policy_path)
if p.is_dir():
st_path = p / "model.safetensors"
else:
from huggingface_hub import hf_hub_download
st_path = Path(hf_hub_download(policy_path, "model.safetensors"))
sd = load_file(str(st_path))
stats: dict = {}
for feat in list(input_features) + list(output_features):
buf = "buffer_" + feat.replace(".", "_")
for prefix in ("normalize_inputs", "normalize_targets", "unnormalize_outputs"):
mkey, skey = f"{prefix}.{buf}.mean", f"{prefix}.{buf}.std"
if mkey in sd and skey in sd:
stats[feat] = {"mean": sd[mkey].numpy(), "std": sd[skey].numpy()}
break
return stats
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--policy-path", required=True)
parser.add_argument("--task", required=True, help="e.g. AlohaTransferCube-v0")
parser.add_argument("--env-type", default="aloha")
parser.add_argument("--backend", choices=["torch", "onnx"], default="torch")
parser.add_argument("--onnx", default=None, help="Path to .onnx (required for --backend=onnx)")
parser.add_argument("--n-episodes", type=int, default=50)
parser.add_argument("--batch-size", type=int, default=10)
parser.add_argument("--device", default="cuda")
parser.add_argument("--seed", type=int, default=1000)
args = parser.parse_args()
if args.backend == "onnx" and not args.onnx:
raise SystemExit("--backend=onnx requires --onnx=<path>")
device = "cuda" if (args.device == "cuda" and torch.cuda.is_available()) else "cpu"
set_seed(args.seed)
print(f"[1/4] Loading ACT policy from '{args.policy_path}'...")
policy = ACTPolicy.from_pretrained(args.policy_path)
policy.config.device = device
policy.eval()
policy.to(device)
cfg = policy.config
if args.backend == "onnx":
image_keys = list(cfg.image_features)
has_state = cfg.robot_state_feature is not None
has_env_state = cfg.env_state_feature is not None
print(f"[2/4] Swapping policy.model with ONNX backend ({args.onnx})")
policy.model = ONNXACTModel(args.onnx, image_keys, has_state, has_env_state, device)
policy.to(device)
else:
print("[2/4] Using PyTorch backend")
print("[3/4] Building processors and environment...")
stats = load_stats_from_checkpoint(args.policy_path, cfg.input_features, cfg.output_features)
preprocessor, postprocessor = make_pre_post_processors(
policy_cfg=cfg,
dataset_stats=stats,
preprocessor_overrides={"device_processor": {"device": device}},
)
env_cfg = make_env_config(args.env_type, task=args.task)
env_preprocessor, env_postprocessor = make_env_pre_post_processors(env_cfg=env_cfg, policy_cfg=cfg)
env_groups = make_env(env_cfg, n_envs=args.batch_size, use_async_envs=False)
# make_env returns {task_group: {idx: VectorEnv}}; grab the single env.
first_group = next(iter(env_groups.values()))
env = next(iter(first_group.values()))
print(f"[4/4] Evaluating backend='{args.backend}' for {args.n_episodes} episodes (seed={args.seed})...")
with torch.no_grad():
info = eval_policy(
env=env,
policy=policy,
env_preprocessor=env_preprocessor,
env_postprocessor=env_postprocessor,
preprocessor=preprocessor,
postprocessor=postprocessor,
n_episodes=args.n_episodes,
start_seed=args.seed,
)
agg = info["aggregated"]
print("\n==== RESULT ====")
print(f"backend : {args.backend}")
print(f"task : {args.task}")
print(f"n_episodes : {args.n_episodes}")
print(f"pc_success : {agg['pc_success']:.1f}%")
print(f"avg_max_reward: {agg['avg_max_reward']:.4f}")
print(f"eval_ep_s : {agg['eval_ep_s']:.2f}s")
env.close()
if __name__ == "__main__":
main()
-133
View File
@@ -1,133 +0,0 @@
#!/usr/bin/env python
"""Export an ACT policy's network to ONNX and verify numerical parity.
Only the inference network is exported (ResNet backbone + transformer enc/dec +
action head). The VAE encoder is training-only and the inference latent is zeros,
so the exported graph is a pure function of (state, images) -> action_chunk.
Normalization stays in the LeRobot processor pipeline (outside ONNX).
Usage:
python examples/onnx/export_act.py \
--policy-path=outputs/converted/act_aloha_sim_transfer_cube_human \
--output=outputs/onnx/act_transfer_cube.onnx
"""
import argparse
from pathlib import Path
import numpy as np
import torch
from torch import nn
from lerobot.policies.act.modeling_act import ACTPolicy
from lerobot.utils.constants import OBS_ENV_STATE, OBS_IMAGES, OBS_STATE
class ACTExportWrapper(nn.Module):
"""Tensor-in/tensor-out wrapper around ACT's inference network."""
def __init__(self, model: nn.Module, image_keys: list[str], has_state: bool, has_env_state: bool):
super().__init__()
self.model = model
self.image_keys = image_keys
self.has_state = has_state
self.has_env_state = has_env_state
def forward(self, state: torch.Tensor, *images: torch.Tensor) -> torch.Tensor:
batch: dict = {}
if self.has_state:
batch[OBS_STATE] = state
if self.has_env_state:
# Convention: when env_state is used it is passed as `state`.
batch[OBS_ENV_STATE] = state
batch[OBS_IMAGES] = list(images)
actions, _ = self.model(batch)
return actions
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--policy-path", required=True, help="Converted ACT checkpoint dir or repo id")
parser.add_argument("--output", required=True, help="Output .onnx path")
parser.add_argument("--opset", type=int, default=17)
parser.add_argument("--atol", type=float, default=1e-3)
parser.add_argument("--device", default="cpu")
args = parser.parse_args()
out = Path(args.output)
out.parent.mkdir(parents=True, exist_ok=True)
print(f"[1/4] Loading ACT policy from '{args.policy_path}'...")
policy = ACTPolicy.from_pretrained(args.policy_path)
policy.eval()
policy.to(args.device)
cfg = policy.config
image_keys = list(cfg.image_features)
has_state = cfg.robot_state_feature is not None
has_env_state = cfg.env_state_feature is not None
state_dim = (cfg.robot_state_feature or cfg.env_state_feature).shape[0]
print(
f" image_keys={image_keys} state_dim={state_dim} "
f"chunk_size={cfg.chunk_size} action_dim={cfg.action_feature.shape[0]}"
)
wrapper = ACTExportWrapper(policy.model, image_keys, has_state, has_env_state).eval().to(args.device)
# Build example inputs (batch size 1) from the config feature shapes.
state_example = torch.randn(1, state_dim, device=args.device)
image_examples = [torch.rand(1, *cfg.image_features[k].shape, device=args.device) for k in image_keys]
example_inputs = (state_example, *image_examples)
input_names = ["state"] + [f"image_{i}" for i in range(len(image_keys))]
output_names = ["action_chunk"]
dynamic_axes = {name: {0: "batch"} for name in input_names + output_names}
print(f"[2/4] Exporting to ONNX (opset {args.opset}) -> {out}")
torch.onnx.export(
wrapper,
example_inputs,
str(out),
input_names=input_names,
output_names=output_names,
dynamic_axes=dynamic_axes,
opset_version=args.opset,
do_constant_folding=True,
dynamo=False,
)
print("[3/4] Running parity check (torch vs onnxruntime)...")
import onnxruntime as ort
providers = ["CPUExecutionProvider"]
so = ort.SessionOptions()
so.log_severity_level = 3
sess = ort.InferenceSession(str(out), sess_options=so, providers=providers)
# Fresh random inputs for the check.
state_check = torch.randn(2, state_dim, device=args.device)
image_check = [torch.rand(2, *cfg.image_features[k].shape, device=args.device) for k in image_keys]
with torch.no_grad():
torch_out = wrapper(state_check, *image_check).cpu().numpy()
ort_inputs = {"state": state_check.cpu().numpy()}
for i, img in enumerate(image_check):
ort_inputs[f"image_{i}"] = img.cpu().numpy()
ort_out = sess.run(None, ort_inputs)[0]
max_abs = float(np.max(np.abs(torch_out - ort_out)))
mean_abs = float(np.mean(np.abs(torch_out - ort_out)))
print(f" shapes: torch={torch_out.shape} onnx={ort_out.shape}")
print(f" max_abs_diff={max_abs:.3e} mean_abs_diff={mean_abs:.3e} (atol={args.atol:.0e})")
ok = max_abs <= args.atol
print(f"[4/4] Parity: {'PASS' if ok else 'FAIL'}")
if not ok:
raise SystemExit(f"Parity check failed: max_abs_diff {max_abs:.3e} > atol {args.atol:.0e}")
print(f"\nDone. ONNX model at: {out}")
if __name__ == "__main__":
main()
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Hand Joint Visualizer</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100vh;
}
.controls {
padding: 15px;
background-color: #f5f5f5;
z-index: 100;
}
.status {
padding: 10px;
border-radius: 5px;
margin: 10px 0;
}
.connected {
background-color: #d4edda;
color: #155724;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
}
button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
button:hover {
background-color: #45a049;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.container {
display: flex;
flex: 1;
overflow: hidden;
}
#canvas-container {
flex: 3;
position: relative;
}
#sidebar {
flex: 1;
padding: 15px;
background-color: #f8f9fa;
overflow-y: auto;
max-width: 300px;
border-left: 1px solid #ddd;
}
.joint-info {
margin-bottom: 10px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.joint-name {
font-weight: bold;
}
.joint-value {
font-family: monospace;
}
.bar-container {
width: 100%;
background-color: #e0e0e0;
height: 10px;
border-radius: 5px;
overflow: hidden;
margin-top: 5px;
}
.bar {
height: 100%;
background-color: #4CAF50;
width: 0%;
transition: width 0.2s ease-in-out;
}
.log-container {
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
height: 150px;
overflow-y: auto;
font-family: monospace;
background-color: #f8f9fa;
}
.view-controls {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 10;
}
.view-button {
background-color: rgba(0, 0, 0, 0.5);
color: white;
border: none;
padding: 5px 10px;
margin-right: 5px;
border-radius: 3px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="controls">
<button id="connectButton">Connect to Device</button>
<button id="disconnectButton" disabled>Disconnect</button>
<select id="baudRate">
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="115200" selected>115200</option>
</select>
<span id="statusIndicator" class="status disconnected">Status: Disconnected</span>
</div>
<div class="container">
<div id="canvas-container">
<!-- 3D canvas will be inserted here -->
<div class="view-controls">
<button class="view-button" id="frontView">Front</button>
<button class="view-button" id="sideView">Side</button>
<button class="view-button" id="topView">Top</button>
<button class="view-button" id="resetView">Reset</button>
</div>
</div>
<div id="sidebar">
<h3>Joint Values</h3>
<div id="jointsContainer">
<!-- Joint info will be added here -->
</div>
<div class="log-container" id="logContainer">
<!-- Log messages will be added here -->
</div>
</div>
</div>
<!-- Import Three.js -->
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/OrbitControls.js"></script>
<script src="script.js"></script>
</body>
</html>
+669
View File
@@ -0,0 +1,669 @@
// === Hand Visualizer with Pre-Connect Sliders + Per-Joint Angle Limits ===
// Assumes your HTML already has elements with the following IDs:
// connectButton, disconnectButton, baudRate, statusIndicator, jointsContainer, logContainer,
// canvas-container, frontView, sideView, topView, resetView
// Requires Three.js + OrbitControls loaded on the page.
// -------------------- Config --------------------
const MAX_JOINTS = 16;
const RAW_MIN = 0, RAW_MAX = 4096;
const RAW_CENTER = (RAW_MIN + RAW_MAX) / 2;
const DEG = Math.PI / 180;
const UI_DEG_MIN = -90, UI_DEG_MAX = 90; // UI sliders for angle limits
// -------------------- State --------------------
let port;
let reader;
let keepReading = false;
let isConnected = false;
const decoder = new TextDecoder();
let inputBuffer = '';
let jointValues = new Array(MAX_JOINTS).fill(RAW_CENTER);
// Auto-calibration: track observed min/max per joint
let observedMin = new Array(MAX_JOINTS).fill(Infinity);
let observedMax = new Array(MAX_JOINTS).fill(-Infinity);
let calibrationEnabled = true;
// Three.js
let scene, camera, renderer, controls;
let hand = { palm: null, fingers: [] };
// DOM
const connectButton = document.getElementById('connectButton');
const disconnectButton = document.getElementById('disconnectButton');
const baudRateSelect = document.getElementById('baudRate');
const statusIndicator = document.getElementById('statusIndicator');
const jointsContainer = document.getElementById('jointsContainer');
const logContainer = document.getElementById('logContainer');
const canvasContainer = document.getElementById('canvas-container');
const frontViewBtn = document.getElementById('frontView');
const sideViewBtn = document.getElementById('sideView');
const topViewBtn = document.getElementById('topView');
const resetViewBtn = document.getElementById('resetView');
// Helpers
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const invLerp = (a, b, x) => clamp((x - a) / (b - a), 0, 1);
// -------------------- Joint Map with per-joint angle limits --------------------
const fingerJointMap = [
// Thumb (4)
{ finger:0, joint:0, type:'CMC_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:0, joint:1, type:'CMC_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:0, joint:2, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
{ finger:0, joint:3, type:'IP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Index (3)
{ finger:1, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:1, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:1, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Middle (3)
{ finger:2, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:2, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:2, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Ring (3)
{ finger:3, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:3, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:3, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false }, // +45° only
// Pinky (3)
{ finger:4, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:4, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:4, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false } // +45° only
];
// Assign angle limits (radians) per joint (default ±45°, exceptions: +45° only)
for (const j of fingerJointMap) {
const isThumb = j.finger === 0;
const isPIP = j.type === 'PIP_FLEXION';
let minA = -45 * DEG, maxA = +45 * DEG;
if ((isThumb && (j.type === 'MCP_FLEXION' || j.type === 'IP_FLEXION')) || (!isThumb && isPIP)) {
minA = 0;
maxA = +45 * DEG;
}
j.angleMin = minA;
j.angleMax = maxA;
}
// -------------------- UI: Joint Panel --------------------
const uiRefs = []; // per joint: { valueLabel, bar, barWrap, slider, invertChk, minDeg, maxDeg }
function initializeJointElements() {
jointsContainer.innerHTML = '';
uiRefs.length = 0;
for (let i = 0; i < MAX_JOINTS; i++) {
const wrap = document.createElement('div');
wrap.className = 'joint-info';
const fingerIndex = i < 4 ? 0 : Math.floor((i - 4) / 3) + 1;
const jointInfo = fingerJointMap[i];
const jointType = jointInfo?.type || 'Unknown';
const fingerName = ['Thumb', 'Index', 'Middle', 'Ring', 'Pinky'][fingerIndex];
// Header
const nameEl = document.createElement('div');
nameEl.className = 'joint-name';
nameEl.textContent = `${fingerName} ${jointType}`;
// Value + bar
const valueEl = document.createElement('div');
valueEl.className = 'joint-value';
valueEl.textContent = `Value: ${jointValues[i]}`;
const barWrap = document.createElement('div');
barWrap.className = 'bar-container';
const barEl = document.createElement('div');
barEl.className = 'bar';
barWrap.appendChild(barEl);
// Slider for pre-connect manual control
const slider = document.createElement('input');
slider.type = 'range';
slider.min = String(RAW_MIN);
slider.max = String(RAW_MAX);
slider.value = String(jointValues[i]);
slider.step = '1';
slider.className = 'joint-slider';
slider.addEventListener('input', () => {
if (isConnected) return; // ignore while connected
let v = parseInt(slider.value, 10);
if (jointInfo?.inverted) v = (jointInfo.min + jointInfo.max) - v;
jointValues[i] = clamp(jointInfo ? v : 0, RAW_MIN, RAW_MAX);
updateJointDisplay(i, jointValues[i]);
updateHandModel();
});
// Invert checkbox
const invertLbl = document.createElement('label');
invertLbl.className = 'invert-toggle';
const invertChk = document.createElement('input');
invertChk.type = 'checkbox';
invertChk.checked = !!jointInfo?.inverted;
invertChk.addEventListener('change', () => {
if (jointInfo) jointInfo.inverted = invertChk.checked;
addLogMessage(`${fingerName} ${jointType} inversion ${invertChk.checked ? 'enabled' : 'disabled'}`);
});
invertLbl.appendChild(invertChk);
invertLbl.appendChild(document.createTextNode('Invert Values'));
// Angle limits (deg) controls
const limitsRow = document.createElement('div');
limitsRow.className = 'limits-row';
const minDeg = document.createElement('input');
minDeg.type = 'number';
minDeg.min = String(UI_DEG_MIN);
minDeg.max = String(UI_DEG_MAX);
minDeg.step = '1';
minDeg.value = String(Math.round((jointInfo.angleMin || 0) / DEG));
minDeg.className = 'limit-num';
const maxDeg = document.createElement('input');
maxDeg.type = 'number';
maxDeg.min = String(UI_DEG_MIN);
maxDeg.max = String(UI_DEG_MAX);
maxDeg.step = '1';
maxDeg.value = String(Math.round((jointInfo.angleMax || 0) / DEG));
maxDeg.className = 'limit-num';
const minLbl = document.createElement('span'); minLbl.textContent = 'min°';
const maxLbl = document.createElement('span'); maxLbl.textContent = 'max°';
minLbl.className = 'limit-label'; maxLbl.className = 'limit-label';
function syncLimits() {
let mn = parseFloat(minDeg.value);
let mx = parseFloat(maxDeg.value);
if (isNaN(mn)) mn = -45;
if (isNaN(mx)) mx = +45;
if (mn > mx) [mn, mx] = [mx, mn];
jointInfo.angleMin = clamp(mn, UI_DEG_MIN, UI_DEG_MAX) * DEG;
jointInfo.angleMax = clamp(mx, UI_DEG_MIN, UI_DEG_MAX) * DEG;
minDeg.value = String(Math.round(jointInfo.angleMin / DEG));
maxDeg.value = String(Math.round(jointInfo.angleMax / DEG));
updateHandModel();
}
minDeg.addEventListener('change', syncLimits);
maxDeg.addEventListener('change', syncLimits);
limitsRow.appendChild(minLbl);
limitsRow.appendChild(minDeg);
limitsRow.appendChild(maxLbl);
limitsRow.appendChild(maxDeg);
// Calibration controls
const calibRow = document.createElement('div');
calibRow.className = 'calib-row';
const resetCalibBtn = document.createElement('button');
resetCalibBtn.textContent = 'Reset Calib';
resetCalibBtn.className = 'calib-btn';
resetCalibBtn.addEventListener('click', () => {
observedMin[i] = Infinity;
observedMax[i] = -Infinity;
addLogMessage(`Reset calibration for ${fingerName} ${jointType}`);
});
const calibStatus = document.createElement('span');
calibStatus.className = 'calib-status';
calibStatus.textContent = `Range: --`;
calibRow.appendChild(resetCalibBtn);
calibRow.appendChild(calibStatus);
// Compose
wrap.appendChild(nameEl);
wrap.appendChild(valueEl);
wrap.appendChild(barWrap);
wrap.appendChild(slider);
wrap.appendChild(invertLbl);
wrap.appendChild(limitsRow);
wrap.appendChild(calibRow);
jointsContainer.appendChild(wrap);
uiRefs[i] = { valueLabel: valueEl, bar: barEl, barWrap, slider, invertChk, minDeg, maxDeg, nameEl, calibStatus };
}
setConnectedUI(false); // initial state: sliders active
}
// Toggle UI between pre-connect SLIDERS vs post-connect BARS
function setConnectedUI(connected) {
isConnected = connected;
for (let i = 0; i < uiRefs.length; i++) {
const ui = uiRefs[i];
if (!ui) continue;
// Show bars when connected; sliders disabled/hidden
ui.barWrap.style.display = connected ? '' : 'none';
ui.slider.disabled = connected;
ui.slider.style.display = connected ? 'none' : '';
}
// Reset calibration when connecting
if (connected) {
observedMin.fill(Infinity);
observedMax.fill(-Infinity);
addLogMessage('Calibration reset - move joints through full range for best results');
}
}
// Update joint display (value text + bar color/width + slider position if needed)
function updateJointDisplay(jointIndex, value) {
const ui = uiRefs[jointIndex];
const info = fingerJointMap[jointIndex];
if (!ui || !info) return;
ui.valueLabel.textContent = `Value: ${value}`;
// bar
const min = info.min, max = info.max;
const pct = clamp((value - min) / (max - min), 0, 1) * 100;
ui.bar.style.width = `${pct}%`;
const hue = Math.floor(pct * 1.2); // 0..120
ui.bar.style.backgroundColor = `hsl(${hue}, 80%, 50%)`;
// slider (only meaningful when not connected; keep in sync anyway)
const rawForSlider = info.inverted ? (info.min + info.max) - value : value;
if (!isConnected) ui.slider.value = String(clamp(Math.round(rawForSlider), RAW_MIN, RAW_MAX));
}
// -------------------- Serial I/O --------------------
async function readSerialData() {
while (port?.readable && keepReading) {
reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) processData(decoder.decode(value));
}
} catch (err) {
console.error('Error reading:', err);
addLogMessage(`Error: ${err.message}`);
break;
} finally {
reader.releaseLock();
}
}
}
function processData(chunk) {
inputBuffer += chunk;
let idx;
while ((idx = inputBuffer.indexOf('\n')) !== -1) {
const line = inputBuffer.slice(0, idx).trim();
inputBuffer = inputBuffer.slice(idx + 1);
const vals = line.split(/\s+/).map(v => parseInt(v, 10));
if (vals.length === MAX_JOINTS && vals.every(v => Number.isFinite(v))) {
for (let i = 0; i < MAX_JOINTS; i++) {
const info = fingerJointMap[i];
if (!info) continue;
let rawValue = vals[i];
// Update calibration tracking
if (calibrationEnabled) {
observedMin[i] = Math.min(observedMin[i], rawValue);
observedMax[i] = Math.max(observedMax[i], rawValue);
// Update calibration display
const ui = uiRefs[i];
if (ui && ui.calibStatus) {
if (observedMin[i] !== Infinity && observedMax[i] !== -Infinity) {
ui.calibStatus.textContent = `Range: ${observedMin[i]}-${observedMax[i]}`;
}
}
// Remap observed range to target range
if (observedMin[i] !== Infinity && observedMax[i] !== -Infinity && observedMax[i] > observedMin[i]) {
const observedRange = observedMax[i] - observedMin[i];
const targetRange = info.max - info.min;
const normalizedValue = (rawValue - observedMin[i]) / observedRange;
rawValue = info.min + (normalizedValue * targetRange);
}
}
let v = clamp(rawValue, info.min, info.max);
if (info.inverted) v = (info.min + info.max) - v;
jointValues[i] = v;
updateJointDisplay(i, v);
}
updateHandModel();
} else {
addLogMessage(`Received: ${line}`);
}
}
}
async function connectToDevice() {
try {
port = await navigator.serial.requestPort();
const baudRate = parseInt(baudRateSelect.value, 10) || 115200;
await port.open({ baudRate });
keepReading = true;
setConnectedUI(true);
statusIndicator.textContent = 'Status: Connected';
statusIndicator.className = 'status connected';
connectButton.disabled = true;
disconnectButton.disabled = false;
baudRateSelect.disabled = true;
addLogMessage(`Connected at ${baudRate} baud`);
readSerialData();
} catch (e) {
console.error('Connect error:', e);
addLogMessage(`Connection error: ${e.message}`);
}
}
async function disconnectFromDevice() {
try {
keepReading = false;
if (reader) {
try { reader.cancel(); } catch {}
}
if (port) {
await port.close();
port = null;
}
} catch (e) {
console.error('Disconnect error:', e);
addLogMessage(`Disconnection error: ${e.message}`);
} finally {
setConnectedUI(false);
statusIndicator.textContent = 'Status: Disconnected';
statusIndicator.className = 'status disconnected';
connectButton.disabled = false;
disconnectButton.disabled = true;
baudRateSelect.disabled = false;
addLogMessage('Disconnected');
}
}
// -------------------- Three.js Scene --------------------
function initThreeJS() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f0f0);
camera = new THREE.PerspectiveCamera(
75,
canvasContainer.clientWidth / canvasContainer.clientHeight,
0.1, 1000
);
camera.position.set(0, 15, 15);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
canvasContainer.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.25;
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
const dir1 = new THREE.DirectionalLight(0xffffff, 0.5);
dir1.position.set(1, 1, 1);
scene.add(dir1);
const dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
dir2.position.set(-1, 1, -1);
scene.add(dir2);
const gridHelper = new THREE.GridHelper(20, 20);
scene.add(gridHelper);
createHandModel();
window.addEventListener('resize', onWindowResize);
animate();
}
function createHandModel() {
const palmMaterial = new THREE.MeshPhongMaterial({ color: 0xf5c396 });
const fingerMaterial = new THREE.MeshPhongMaterial({ color: 0xf5c396 });
const jointMaterial = new THREE.MeshPhongMaterial({ color: 0xe3a977 });
const palmGeometry = new THREE.BoxGeometry(7, 1, 8);
hand.palm = new THREE.Mesh(palmGeometry, palmMaterial);
hand.palm.position.set(0, 0, 0);
hand.palm.rotation.x = Math.PI / 2; // hand vertical, palm facing forward
scene.add(hand.palm);
const fingerWidth = 1, fingerHeight = 0.8;
const fingerSegmentLengths = [3, 2, 1.5];
const thumbSegmentLengths = [2, 2, 1.5];
const fingerBasePositions = [
[ 3, 0, -2], // Thumb
[ 1.5,-0.5,-4], // Index
[ 0, -0.5,-4], // Middle
[-1.5,-0.5,-4], // Ring
[-3, -0.5,-4], // Pinky
];
const fingerBaseRot = [
{ x:0, y:-Math.PI/3, z: Math.PI/3 }, // Thumb
{ x:0, y:-Math.PI/48, z: 0 },
{ x:0, y: Math.PI/48, z: 0 },
{ x:0, y: Math.PI/32, z: 0 },
{ x:0, y: Math.PI/24, z: 0 }
];
for (let fIdx = 0; fIdx < 5; fIdx++) {
const finger = { name:['Thumb','Index','Middle','Ring','Pinky'][fIdx], segments:[], joints:[] };
const isThumb = fIdx === 0;
const segLens = isThumb ? thumbSegmentLengths : fingerSegmentLengths;
finger.group = new THREE.Group();
finger.group.position.set(...fingerBasePositions[fIdx]);
finger.group.rotation.x = fingerBaseRot[fIdx].x;
finger.group.rotation.y = fingerBaseRot[fIdx].y;
finger.group.rotation.z = fingerBaseRot[fIdx].z;
finger.group.userData.baseRot = {
x:finger.group.rotation.x,
y:finger.group.rotation.y,
z:finger.group.rotation.z
};
hand.palm.add(finger.group);
let parent = finger.group;
for (let s = 0; s < segLens.length; s++) {
const segGroup = new THREE.Group();
const jGeom = new THREE.SphereGeometry(fingerWidth * 0.6, 8, 8);
const joint = new THREE.Mesh(jGeom, jointMaterial);
segGroup.add(joint);
const segGeom = new THREE.BoxGeometry(fingerWidth, fingerHeight, segLens[s]);
const seg = new THREE.Mesh(segGeom, fingerMaterial);
seg.position.z = -segLens[s] / 2;
segGroup.add(seg);
parent.add(segGroup);
finger.segments.push(segGroup);
finger.joints.push(joint);
if (s < segLens.length - 1) {
const connector = new THREE.Group();
connector.position.z = -segLens[s];
segGroup.add(connector);
parent = connector;
}
}
hand.fingers.push(finger);
}
addFingerLabels();
addHandLabel();
}
function addFingerLabels() {
const names = ['Thumb','Index','Middle','Ring','Pinky'];
for (let i = 0; i < hand.fingers.length; i++) {
const finger = hand.fingers[i];
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 128; canvas.height = 32;
ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.font = 'bold 16px Arial';
ctx.fillStyle = '#000000';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(names[i], canvas.width/2, canvas.height/2);
const texture = new THREE.CanvasTexture(canvas);
const geom = new THREE.PlaneGeometry(2, 0.5);
const mat = new THREE.MeshBasicMaterial({ map:texture, transparent:true, side:THREE.DoubleSide });
const label = new THREE.Mesh(geom, mat);
label.position.set(0, -1.5, -2);
label.rotation.x = Math.PI / 2;
finger.group.add(label);
}
}
function addHandLabel() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 256; canvas.height = 64;
ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.font = 'bold 24px Arial';
ctx.fillStyle = '#000000';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('RIGHT HAND (VERTICAL)', canvas.width/2, canvas.height/2);
const texture = new THREE.CanvasTexture(canvas);
const geom = new THREE.PlaneGeometry(7, 1.75);
const mat = new THREE.MeshBasicMaterial({ map:texture, transparent:true, side:THREE.DoubleSide });
const label = new THREE.Mesh(geom, mat);
label.position.set(0, -2, 0);
label.rotation.x = Math.PI / 2;
scene.add(label);
}
function updateHandModel() {
for (let i = 0; i < MAX_JOINTS; i++) {
const info = fingerJointMap[i];
if (!info) continue;
const { finger, joint, type, min, max, angleMin, angleMax } = info;
const raw = jointValues[i];
const f = hand.fingers[finger];
if (!f) continue;
const center = (min + max) / 2;
let angle = 0;
if (type.includes('ABDUCTION')) {
// symmetric around neutral
const k = clamp((raw - center) / ((max - min) / 2), -1, 1);
angle = angleMin + (k + 1) * 0.5 * (angleMax - angleMin);
const base = f.group.userData.baseRot || {x:0,y:0,z:0};
if (finger === 0 && joint === 0) {
// Thumb: abduction about Z (toward/away from palm)
f.group.rotation.z = base.z + angle;
} else {
// Other fingers: side-to-side about Y
f.group.rotation.y = base.y + angle;
}
} else if (type.includes('FLEXION')) {
const isThumb = finger === 0;
const isMCP = type === 'MCP_FLEXION';
const isPIP = type === 'PIP_FLEXION';
const positiveOnly = (isThumb && (type === 'MCP_FLEXION' || type === 'IP_FLEXION')) || (!isThumb && isPIP);
if (positiveOnly) {
const t = raw <= center ? 0 : invLerp(center, max, raw); // 0..1
angle = angleMin + t * (angleMax - angleMin); // 0..+limit
} else {
const k = clamp((raw - center) / ((max - min) / 2), -1, 1);
angle = angleMin + (k + 1) * 0.5 * (angleMax - angleMin);
}
if (isMCP) {
// MCP flexion applies to the finger base group (same as abduction)
const base = f.group.userData.baseRot || {x:0,y:0,z:0};
f.group.rotation.x = base.x + angle;
} else if (f.segments[joint]) {
// PIP/DIP/IP flexion applies to individual segments
f.segments[joint].rotation.x = angle;
}
}
}
}
// -------------------- Render Loop --------------------
function onWindowResize() {
camera.aspect = canvasContainer.clientWidth / canvasContainer.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
}
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
// -------------------- Misc UI --------------------
function addLogMessage(msg) {
const el = document.createElement('div');
el.textContent = msg;
logContainer.appendChild(el);
logContainer.scrollTop = logContainer.scrollHeight;
while (logContainer.children.length > 100) {
logContainer.removeChild(logContainer.firstChild);
}
}
// Camera view controls
frontViewBtn?.addEventListener('click', () => { camera.position.set(0, 0, 20); camera.lookAt(0,0,0); controls.update(); });
sideViewBtn?.addEventListener('click', () => { camera.position.set(20, 0, 0); camera.lookAt(0,0,0); controls.update(); });
topViewBtn?.addEventListener('click', () => { camera.position.set(0, 20, 0); camera.lookAt(0,0,0); controls.update(); });
resetViewBtn?.addEventListener('click', () => { camera.position.set(10,10,10); camera.lookAt(0,0,0); controls.update(); });
// Serial connect buttons
connectButton?.addEventListener('click', connectToDevice);
disconnectButton?.addEventListener('click', disconnectFromDevice);
// Web Serial support check
if (!navigator.serial) {
statusIndicator.textContent = 'Status: Web Serial API not supported in this browser';
connectButton.disabled = true;
addLogMessage('ERROR: Web Serial API is not supported in this browser. Try Chrome or Edge.');
}
// -------------------- Boot --------------------
initThreeJS();
initializeJointElements();
// -------------------- Styles (inline) --------------------
const styleElement = document.createElement('style');
styleElement.textContent = `
.joint-info { border-bottom: 1px solid #eee; padding: 8px 0; }
.joint-name { font-weight: 600; margin-bottom: 4px; }
.joint-value { font-size: 12px; color: #333; margin-bottom: 4px; }
.bar-container { width: 100%; height: 8px; background: #ddd; border-radius: 4px; overflow: hidden; }
.bar { height: 100%; width: 0%; background: #4caf50; }
.joint-slider { width: 100%; margin: 6px 0; }
.invert-toggle { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; color: #555; }
.limits-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.limit-label { font-size: 11px; color: #666; }
.limit-num { width: 60px; }
.calib-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.calib-btn { padding: 2px 6px; font-size: 11px; background: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer; }
.calib-btn:hover { background: #d32f2f; }
.calib-status { font-size: 11px; color: #666; }
.status.connected { color: #0a0; }
.status.disconnected { color: #a00; }
`;
document.head.appendChild(styleElement);