diff --git a/docs/source/hope_jr.mdx b/docs/source/hope_jr.mdx index 856febb95..00644c0ea 100644 --- a/docs/source/hope_jr.mdx +++ b/docs/source/hope_jr.mdx @@ -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 diff --git a/examples/hopejr/visualizer/index.html b/examples/hopejr/visualizer/index.html new file mode 100644 index 000000000..672cd9302 --- /dev/null +++ b/examples/hopejr/visualizer/index.html @@ -0,0 +1,182 @@ + + + + + + 3D Hand Joint Visualizer + + + +
+ + + + Status: Disconnected +
+ +
+
+ +
+ + + + +
+
+ + +
+ + + + + + + + \ No newline at end of file diff --git a/examples/hopejr/visualizer/script.js b/examples/hopejr/visualizer/script.js new file mode 100644 index 000000000..052899bbd --- /dev/null +++ b/examples/hopejr/visualizer/script.js @@ -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);