1196 lines
43 KiB
HTML
1196 lines
43 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>G1 3D Temperature Visualization</title>
|
||
<!-- Sanad: served from the dashboard static mount; socket.io dropped in
|
||
favour of a native WebSocket shim (see `const socket` below). -->
|
||
<script src="/static/temp3d/js/three.min.js"></script>
|
||
<script src="/static/temp3d/js/STLLoader.js"></script>
|
||
<script src="/static/temp3d/js/OrbitControls.js"></script>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
|
||
color: #ffffff;
|
||
overflow: hidden;
|
||
height: 100vh;
|
||
}
|
||
|
||
#canvas-container {
|
||
width: 100%;
|
||
height: 100vh;
|
||
position: relative;
|
||
}
|
||
|
||
/* Header */
|
||
.header {
|
||
position: absolute;
|
||
top: 20px;
|
||
left: 20px;
|
||
right: 20px;
|
||
z-index: 100;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
backdrop-filter: blur(10px);
|
||
border-radius: 15px;
|
||
padding: 15px 25px;
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 1.5em;
|
||
font-weight: 700;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
.status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.status-indicator {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: #ef4444;
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
.status-indicator.connected {
|
||
background: #10b981;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
|
||
50% {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
/* Stats Panel */
|
||
.stats-panel {
|
||
position: absolute;
|
||
top: 100px;
|
||
left: 20px;
|
||
z-index: 100;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
backdrop-filter: blur(10px);
|
||
border-radius: 15px;
|
||
padding: 20px;
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
min-width: 250px;
|
||
}
|
||
|
||
.stats-panel h2 {
|
||
font-size: 1.1em;
|
||
margin-bottom: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stat-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.stat-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.stat-label {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.stat-value {
|
||
font-weight: 600;
|
||
font-size: 1.1em;
|
||
}
|
||
|
||
/* Temperature Legend */
|
||
.temp-legend {
|
||
position: absolute;
|
||
bottom: 20px;
|
||
left: 20px;
|
||
z-index: 100;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
backdrop-filter: blur(10px);
|
||
border-radius: 15px;
|
||
padding: 20px;
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.temp-legend h3 {
|
||
font-size: 1em;
|
||
margin-bottom: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.gradient-bar {
|
||
width: 200px;
|
||
height: 20px;
|
||
background: linear-gradient(to right,
|
||
#3b82f6 0%,
|
||
#06b6d4 20%,
|
||
#10b981 40%,
|
||
#fbbf24 60%,
|
||
#f97316 80%,
|
||
#ef4444 100%);
|
||
border-radius: 10px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.gradient-labels {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 0.85em;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
/* Controls Panel */
|
||
.controls-panel {
|
||
position: absolute;
|
||
top: 100px;
|
||
right: 20px;
|
||
z-index: 100;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
backdrop-filter: blur(10px);
|
||
border-radius: 12px;
|
||
padding: 10px;
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
width: 250px;
|
||
}
|
||
|
||
.controls-panel h2 {
|
||
font-size: 1.1em;
|
||
margin-bottom: 8px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.control-btn {
|
||
width: 100%;
|
||
padding: 7px 6px;
|
||
margin-bottom: 5px;
|
||
background: rgba(102, 126, 234, 0.3);
|
||
border: 1px solid rgba(102, 126, 234, 0.5);
|
||
border-radius: 6px;
|
||
color: white;
|
||
font-size: 0.9em;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.control-btn:hover {
|
||
background: rgba(102, 126, 234, 0.5);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.control-btn.active {
|
||
background: rgba(102, 126, 234, 0.7);
|
||
border-color: rgba(102, 126, 234, 0.9);
|
||
}
|
||
|
||
/* Motor Info Panel */
|
||
.motor-info-panel {
|
||
position: absolute;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
z-index: 100;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
backdrop-filter: blur(10px);
|
||
border-radius: 15px;
|
||
padding: 20px;
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
min-width: 280px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
display: none;
|
||
}
|
||
|
||
.motor-info-panel.visible {
|
||
display: block;
|
||
}
|
||
|
||
.motor-info-panel h3 {
|
||
font-size: 1.1em;
|
||
margin-bottom: 12px;
|
||
font-weight: 600;
|
||
color: #667eea;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.close-btn {
|
||
background: rgba(239, 68, 68, 0.3);
|
||
border: 1px solid rgba(239, 68, 68, 0.5);
|
||
border-radius: 50%;
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
color: white;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.close-btn:hover {
|
||
background: rgba(239, 68, 68, 0.6);
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.motor-detail {
|
||
margin-bottom: 8px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.motor-detail:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.detail-label {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.detail-value {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.temp-hot {
|
||
color: #ef4444;
|
||
}
|
||
|
||
.temp-warm {
|
||
color: #fbbf24;
|
||
}
|
||
|
||
.temp-normal {
|
||
color: #10b981;
|
||
}
|
||
|
||
/* Loading Screen */
|
||
.loading-screen {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 1000;
|
||
transition: opacity 0.5s ease;
|
||
}
|
||
|
||
.loading-screen.hidden {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.loader {
|
||
width: 60px;
|
||
height: 60px;
|
||
border: 4px solid rgba(102, 126, 234, 0.3);
|
||
border-top-color: #667eea;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.loading-text {
|
||
margin-top: 20px;
|
||
font-size: 1.2em;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
.loading-progress {
|
||
margin-top: 10px;
|
||
font-size: 0.9em;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<!-- Loading Screen -->
|
||
<div class="loading-screen" id="loadingScreen">
|
||
<div class="loader"></div>
|
||
<div class="loading-text">Loading G1 Robot Model</div>
|
||
<div class="loading-progress" id="loadingProgress">0%</div>
|
||
</div>
|
||
|
||
<!-- Canvas Container -->
|
||
<div id="canvas-container"></div>
|
||
|
||
<!-- Header -->
|
||
<div class="header">
|
||
<h1>🤖 G1 3D Temperature Monitor</h1>
|
||
<div class="status">
|
||
<div class="status-indicator" id="statusIndicator"></div>
|
||
<span id="statusText">Connecting...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stats Panel -->
|
||
<div class="stats-panel">
|
||
<h2>📊 Statistics</h2>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Total Motors</span>
|
||
<span class="stat-value" id="totalMotors">0</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Avg Temp</span>
|
||
<span class="stat-value" id="avgTemp">--°C</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Max Temp</span>
|
||
<span class="stat-value" id="maxTemp">--°C</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Min Temp</span>
|
||
<span class="stat-value" id="minTemp">--°C</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Last Update</span>
|
||
<span class="stat-value" id="lastUpdate" style="font-size: 0.8em;">--</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Controls Panel -->
|
||
<div class="controls-panel">
|
||
<h2>🎮 Controls</h2>
|
||
<button class="control-btn" id="autoRotateBtn">Auto Rotate: OFF</button>
|
||
<button class="control-btn active" id="showPositionsBtn">Show Positions: ON</button>
|
||
<button class="control-btn" id="resetViewBtn">Reset Camera</button>
|
||
<button class="control-btn" id="wireframeBtn">Wireframe: OFF</button>
|
||
</div>
|
||
|
||
<!-- Temperature Legend -->
|
||
<div class="temp-legend">
|
||
<h3>🌡️ Temperature Scale</h3>
|
||
<div class="gradient-bar"></div>
|
||
<div class="gradient-labels">
|
||
<span>30°C</span>
|
||
<span>75°C</span>
|
||
<span>120°C</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Motor Info Panel -->
|
||
<div class="motor-info-panel" id="motorInfoPanel">
|
||
<h3 id="motorInfoTitle">
|
||
<span>Motor Information</span>
|
||
<button class="close-btn" id="closePanelBtn" title="Close">×</button>
|
||
</h3>
|
||
<div id="motorInfoContent"></div>
|
||
</div>
|
||
|
||
<script>
|
||
// Three.js Scene Setup
|
||
let scene, camera, renderer, controls;
|
||
let robotRoot; // Root object for the robot
|
||
let linkMeshes = {}; // Map of link names to meshes
|
||
let linkObjects = {}; // Map of link names to Three.js Group objects (for positioning)
|
||
let jointData = {}; // Map of joint names to joint information
|
||
let motorToJoint = {}; // Map of motor IDs to joint names
|
||
let motorData = {};
|
||
let selectedMesh = null; // Currently selected mesh
|
||
let autoRotate = false;
|
||
let wireframeMode = false;
|
||
let showPositions = true; // Enable position updates by default
|
||
// Sanad: native-WebSocket stand-in for socket.io. Exposes the same
|
||
// .on('connect'|'disconnect'|'motor_update', cb) API the rest of this
|
||
// file uses, backed by /ws/motor-temps (auto-reconnecting).
|
||
const socket = (function () {
|
||
const handlers = {};
|
||
const proto = (location.protocol === 'https:') ? 'wss' : 'ws';
|
||
let ws = null;
|
||
function fire(ev, arg) { (handlers[ev] || []).forEach(fn => fn(arg)); }
|
||
function connect() {
|
||
ws = new WebSocket(proto + '://' + location.host + '/ws/motor-temps');
|
||
ws.onopen = () => fire('connect');
|
||
ws.onclose = () => { fire('disconnect'); setTimeout(connect, 1500); };
|
||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||
ws.onmessage = (e) => {
|
||
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
|
||
fire('motor_update', d);
|
||
};
|
||
}
|
||
connect();
|
||
return { on: (ev, cb) => { (handlers[ev] = handlers[ev] || []).push(cb); } };
|
||
})();
|
||
|
||
// Temperature color mapping
|
||
function getTemperatureColor(temp) {
|
||
const minTemp = 30;
|
||
const maxTemp = 120;
|
||
const normalizedTemp = Math.max(0, Math.min(1, (temp - minTemp) / (maxTemp - minTemp)));
|
||
|
||
let r, g, b;
|
||
|
||
if (normalizedTemp < 0.2) {
|
||
const t = normalizedTemp / 0.2;
|
||
r = 59 + (6 - 59) * t;
|
||
g = 130 + (182 - 130) * t;
|
||
b = 246 + (212 - 246) * t;
|
||
} else if (normalizedTemp < 0.4) {
|
||
const t = (normalizedTemp - 0.2) / 0.2;
|
||
r = 6 + (16 - 6) * t;
|
||
g = 182 + (185 - 182) * t;
|
||
b = 212 + (129 - 212) * t;
|
||
} else if (normalizedTemp < 0.6) {
|
||
const t = (normalizedTemp - 0.4) / 0.2;
|
||
r = 16 + (251 - 16) * t;
|
||
g = 185 + (191 - 185) * t;
|
||
b = 129 + (36 - 129) * t;
|
||
} else if (normalizedTemp < 0.8) {
|
||
const t = (normalizedTemp - 0.6) / 0.2;
|
||
r = 251 + (249 - 251) * t;
|
||
g = 191 + (115 - 191) * t;
|
||
b = 36 + (22 - 36) * t;
|
||
} else {
|
||
const t = (normalizedTemp - 0.8) / 0.2;
|
||
r = 249 + (239 - 249) * t;
|
||
g = 115 + (68 - 115) * t;
|
||
b = 22 + (68 - 22) * t;
|
||
}
|
||
|
||
return new THREE.Color(r / 255, g / 255, b / 255);
|
||
}
|
||
|
||
// Parse URDF XML
|
||
function parseURDF(urdfText) {
|
||
const parser = new DOMParser();
|
||
const xmlDoc = parser.parseFromString(urdfText, "text/xml");
|
||
|
||
const links = {};
|
||
const joints = {};
|
||
|
||
// Parse links
|
||
const linkElements = xmlDoc.getElementsByTagName('link');
|
||
for (let i = 0; i < linkElements.length; i++) {
|
||
const link = linkElements[i];
|
||
const name = link.getAttribute('name');
|
||
links[name] = {
|
||
name: name,
|
||
visual: null
|
||
};
|
||
|
||
// Get visual mesh
|
||
const visual = link.getElementsByTagName('visual')[0];
|
||
if (visual) {
|
||
const geometry = visual.getElementsByTagName('geometry')[0];
|
||
if (geometry) {
|
||
const mesh = geometry.getElementsByTagName('mesh')[0];
|
||
if (mesh) {
|
||
const filename = mesh.getAttribute('filename');
|
||
links[name].visual = filename;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Parse joints
|
||
const jointElements = xmlDoc.getElementsByTagName('joint');
|
||
for (let i = 0; i < jointElements.length; i++) {
|
||
const joint = jointElements[i];
|
||
const name = joint.getAttribute('name');
|
||
const type = joint.getAttribute('type');
|
||
|
||
const parent = joint.getElementsByTagName('parent')[0].getAttribute('link');
|
||
const child = joint.getElementsByTagName('child')[0].getAttribute('link');
|
||
|
||
const origin = joint.getElementsByTagName('origin')[0];
|
||
let xyz = [0, 0, 0];
|
||
let rpy = [0, 0, 0];
|
||
|
||
if (origin) {
|
||
const xyzStr = origin.getAttribute('xyz');
|
||
const rpyStr = origin.getAttribute('rpy');
|
||
|
||
if (xyzStr) {
|
||
xyz = xyzStr.split(' ').map(parseFloat);
|
||
}
|
||
if (rpyStr) {
|
||
rpy = rpyStr.split(' ').map(parseFloat);
|
||
}
|
||
}
|
||
|
||
// Get joint axis for revolute joints
|
||
let axis = [0, 0, 1]; // default axis
|
||
const axisElement = joint.getElementsByTagName('axis')[0];
|
||
if (axisElement) {
|
||
const axisStr = axisElement.getAttribute('xyz');
|
||
if (axisStr) {
|
||
axis = axisStr.split(' ').map(parseFloat);
|
||
}
|
||
}
|
||
|
||
joints[name] = {
|
||
name: name,
|
||
type: type,
|
||
parent: parent,
|
||
child: child,
|
||
xyz: xyz,
|
||
rpy: rpy,
|
||
axis: axis
|
||
};
|
||
}
|
||
|
||
return { links, joints };
|
||
}
|
||
|
||
// Initialize Three.js scene
|
||
function initScene() {
|
||
scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x0f0c29);
|
||
scene.fog = new THREE.Fog(0x0f0c29, 10, 50);
|
||
|
||
camera = new THREE.PerspectiveCamera(
|
||
45,
|
||
window.innerWidth / window.innerHeight,
|
||
0.1,
|
||
1000
|
||
);
|
||
// Position camera closer and facing the front of the robot
|
||
camera.position.set(0, 1.2, 2.5);
|
||
camera.lookAt(0, 0.5, 0);
|
||
|
||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
renderer.setPixelRatio(window.devicePixelRatio);
|
||
renderer.shadowMap.enabled = true;
|
||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||
document.getElementById('canvas-container').appendChild(renderer.domElement);
|
||
|
||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||
controls.enableDamping = true;
|
||
controls.dampingFactor = 0.05;
|
||
controls.autoRotate = autoRotate;
|
||
controls.autoRotateSpeed = 1.0;
|
||
controls.target.set(0, 0.5, 0);
|
||
|
||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
|
||
scene.add(ambientLight);
|
||
|
||
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
|
||
directionalLight1.position.set(5, 10, 5);
|
||
directionalLight1.castShadow = true;
|
||
scene.add(directionalLight1);
|
||
|
||
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
|
||
directionalLight2.position.set(-5, 5, -5);
|
||
scene.add(directionalLight2);
|
||
|
||
const gridHelper = new THREE.GridHelper(10, 10, 0x667eea, 0x302b63);
|
||
gridHelper.position.y = -0.01;
|
||
scene.add(gridHelper);
|
||
|
||
window.addEventListener('resize', onWindowResize, false);
|
||
|
||
// Create robot root
|
||
robotRoot = new THREE.Group();
|
||
robotRoot.name = 'robot_root';
|
||
scene.add(robotRoot);
|
||
}
|
||
|
||
function onWindowResize() {
|
||
camera.aspect = window.innerWidth / window.innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
}
|
||
|
||
// Build robot from URDF
|
||
async function loadRobotModel() {
|
||
document.getElementById('loadingProgress').textContent = 'Fetching URDF...';
|
||
|
||
// Fetch URDF
|
||
const urdfText = await fetch('/static/temp3d/g1/g1_29dof_rev_1_0.urdf').then(r => r.text());
|
||
const { links, joints } = parseURDF(urdfText);
|
||
|
||
document.getElementById('loadingProgress').textContent = 'Loading meshes...';
|
||
|
||
// Load all STL meshes
|
||
const loader = new THREE.STLLoader();
|
||
const meshCache = {};
|
||
|
||
let loadedCount = 0;
|
||
const totalLinks = Object.keys(links).filter(name => links[name].visual).length;
|
||
|
||
// Load meshes for all links
|
||
for (const linkName in links) {
|
||
const link = links[linkName];
|
||
if (!link.visual) continue;
|
||
|
||
const meshPath = link.visual.replace('meshes/', '/static/temp3d/g1/meshes/');
|
||
|
||
try {
|
||
const geometry = await new Promise((resolve, reject) => {
|
||
loader.load(meshPath, resolve, undefined, reject);
|
||
});
|
||
|
||
const material = new THREE.MeshPhongMaterial({
|
||
color: 0x888888,
|
||
specular: 0x111111,
|
||
shininess: 30,
|
||
emissive: 0x000000,
|
||
emissiveIntensity: 0.3
|
||
});
|
||
|
||
const mesh = new THREE.Mesh(geometry, material);
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
mesh.name = linkName;
|
||
|
||
meshCache[linkName] = mesh;
|
||
linkMeshes[linkName] = mesh;
|
||
|
||
loadedCount++;
|
||
const progress = Math.round((loadedCount / totalLinks) * 100);
|
||
document.getElementById('loadingProgress').textContent = `${progress}%`;
|
||
|
||
} catch (error) {
|
||
console.warn(`Could not load mesh for ${linkName}:`, error);
|
||
}
|
||
}
|
||
|
||
document.getElementById('loadingProgress').textContent = 'Assembling robot...';
|
||
|
||
// Build kinematic tree
|
||
// Create Three.js objects for each link
|
||
for (const linkName in meshCache) {
|
||
const linkObj = new THREE.Group();
|
||
linkObj.name = linkName;
|
||
linkObj.add(meshCache[linkName]);
|
||
linkObjects[linkName] = linkObj;
|
||
}
|
||
|
||
// Find root link (pelvis)
|
||
const rootLinkName = 'pelvis';
|
||
if (linkObjects[rootLinkName]) {
|
||
robotRoot.add(linkObjects[rootLinkName]);
|
||
}
|
||
|
||
// Attach children according to joints and store joint info
|
||
for (const jointName in joints) {
|
||
const joint = joints[jointName];
|
||
const parentObj = linkObjects[joint.parent];
|
||
const childObj = linkObjects[joint.child];
|
||
|
||
if (parentObj && childObj) {
|
||
// Create a joint pivot group for rotation
|
||
const jointPivot = new THREE.Group();
|
||
jointPivot.name = `${jointName}_pivot`;
|
||
jointPivot.position.set(joint.xyz[0], joint.xyz[1], joint.xyz[2]);
|
||
jointPivot.rotation.set(joint.rpy[0], joint.rpy[1], joint.rpy[2]);
|
||
|
||
// Store original transform for reference
|
||
jointPivot.userData.originalPosition = new THREE.Vector3(joint.xyz[0], joint.xyz[1], joint.xyz[2]);
|
||
jointPivot.userData.originalRotation = new THREE.Euler(joint.rpy[0], joint.rpy[1], joint.rpy[2]);
|
||
jointPivot.userData.axis = new THREE.Vector3(joint.axis[0], joint.axis[1], joint.axis[2]);
|
||
jointPivot.userData.jointType = joint.type;
|
||
jointPivot.userData.childLink = joint.child;
|
||
|
||
// Store joint data
|
||
jointData[jointName] = {
|
||
pivot: jointPivot,
|
||
axis: joint.axis,
|
||
type: joint.type,
|
||
childLink: joint.child
|
||
};
|
||
|
||
// Attach pivot to parent
|
||
parentObj.add(jointPivot);
|
||
|
||
// Reset child transform and attach to pivot
|
||
childObj.position.set(0, 0, 0);
|
||
childObj.rotation.set(0, 0, 0);
|
||
jointPivot.add(childObj);
|
||
}
|
||
}
|
||
|
||
// Create motor to joint mapping based on link names
|
||
// This maps motor IDs to the joint that controls that link
|
||
await fetch('/api/temp/mapping')
|
||
.then(r => r.json())
|
||
.then(mapping => {
|
||
const motorToMesh = mapping.motor_to_mesh;
|
||
|
||
// For each motor, find the joint that has this link as a child
|
||
for (const motorId in motorToMesh) {
|
||
const linkName = motorToMesh[motorId];
|
||
|
||
// Find the joint that controls this link
|
||
for (const jointName in jointData) {
|
||
if (jointData[jointName].childLink === linkName) {
|
||
motorToJoint[motorId] = jointName;
|
||
console.log(`Motor ${motorId} -> Joint ${jointName} -> Link ${linkName}`);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Position robot at ground level and rotate to stand upright facing forward
|
||
robotRoot.position.y = 0.78; // Height adjustment to place feet on ground
|
||
robotRoot.rotation.x = -Math.PI / 2; // Rotate -90 degrees to stand upright
|
||
robotRoot.rotation.z = -Math.PI / 2; // Rotate -90 degrees to face forward (camera)
|
||
|
||
document.getElementById('loadingScreen').classList.add('hidden');
|
||
console.log(`Loaded ${Object.keys(linkMeshes).length} link meshes`);
|
||
console.log(`Created ${Object.keys(jointData).length} joints`);
|
||
}
|
||
|
||
// Update motor positions
|
||
function updateMotorPositions(positions) {
|
||
if (!positions || positions.length === 0) return;
|
||
if (!showPositions) return; // Skip if positions are disabled
|
||
|
||
positions.forEach(posData => {
|
||
const motorId = posData.motor_id;
|
||
const position = posData.position;
|
||
const jointName = motorToJoint[motorId];
|
||
|
||
if (jointName && jointData[jointName]) {
|
||
const joint = jointData[jointName];
|
||
const pivot = joint.pivot;
|
||
const axis = joint.axis;
|
||
|
||
// Apply rotation around the joint axis
|
||
// The position value is in radians
|
||
if (joint.type === 'revolute' || joint.type === 'continuous') {
|
||
// Create rotation quaternion around the axis
|
||
const rotationAxis = new THREE.Vector3(axis[0], axis[1], axis[2]).normalize();
|
||
const quaternion = new THREE.Quaternion();
|
||
quaternion.setFromAxisAngle(rotationAxis, position);
|
||
|
||
// Apply the rotation to the pivot
|
||
// First reset to original rotation
|
||
pivot.rotation.copy(pivot.userData.originalRotation);
|
||
// Then apply the motor position rotation
|
||
pivot.quaternion.multiply(quaternion);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Reset all joints to their original positions
|
||
function resetAllJointPositions() {
|
||
for (const jointName in jointData) {
|
||
const joint = jointData[jointName];
|
||
const pivot = joint.pivot;
|
||
if (pivot && pivot.userData.originalRotation) {
|
||
pivot.rotation.copy(pivot.userData.originalRotation);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update motor temperatures
|
||
function updateMotorTemperatures(data) {
|
||
if (!data.temperatures || data.temperatures.length === 0) return;
|
||
|
||
const temps = data.temperatures;
|
||
|
||
document.getElementById('totalMotors').textContent = temps.length;
|
||
|
||
const avgTemps = temps.map(m => m.avg);
|
||
const avgTemp = (avgTemps.reduce((a, b) => a + b, 0) / avgTemps.length).toFixed(1);
|
||
const maxTemp = Math.max(...avgTemps).toFixed(1);
|
||
const minTemp = Math.min(...avgTemps).toFixed(1);
|
||
|
||
document.getElementById('avgTemp').textContent = avgTemp + '°C';
|
||
document.getElementById('maxTemp').textContent = maxTemp + '°C';
|
||
document.getElementById('minTemp').textContent = minTemp + '°C';
|
||
|
||
const now = new Date();
|
||
document.getElementById('lastUpdate').textContent = now.toLocaleTimeString();
|
||
|
||
// Update mesh colors
|
||
temps.forEach(motor => {
|
||
const meshName = motor.mesh_name;
|
||
if (meshName && linkMeshes[meshName]) {
|
||
const linkGroup = linkMeshes[meshName].parent;
|
||
const mesh = linkMeshes[meshName];
|
||
const temp = motor.avg;
|
||
const color = getTemperatureColor(temp);
|
||
|
||
// Update motor data
|
||
mesh.userData.motorData = motor;
|
||
|
||
// Only update colors if this mesh is not currently selected
|
||
if (mesh !== selectedMesh) {
|
||
mesh.material.color = color;
|
||
mesh.material.emissive = color;
|
||
mesh.material.emissiveIntensity = 0.2 + (temp / 120) * 0.3;
|
||
}
|
||
}
|
||
});
|
||
|
||
motorData = data;
|
||
}
|
||
|
||
// Handle mesh clicks
|
||
const raycaster = new THREE.Raycaster();
|
||
const mouse = new THREE.Vector2();
|
||
|
||
function clearSelection() {
|
||
if (selectedMesh) {
|
||
// Restore original material properties based on temperature
|
||
const motorData = selectedMesh.userData.motorData;
|
||
if (motorData) {
|
||
const temp = motorData.avg;
|
||
const color = getTemperatureColor(temp);
|
||
selectedMesh.material.color = color;
|
||
selectedMesh.material.emissive = color;
|
||
selectedMesh.material.emissiveIntensity = 0.2 + (temp / 120) * 0.3;
|
||
}
|
||
selectedMesh = null;
|
||
}
|
||
}
|
||
|
||
function highlightMesh(mesh) {
|
||
// Clear previous selection
|
||
clearSelection();
|
||
|
||
// Highlight the new selection
|
||
selectedMesh = mesh;
|
||
|
||
// Get the temperature-based color and make it lighter/brighter
|
||
const motorData = mesh.userData.motorData;
|
||
if (motorData) {
|
||
const temp = motorData.avg;
|
||
const baseColor = getTemperatureColor(temp);
|
||
|
||
// Create a lighter version by lerping towards white
|
||
const lighterColor = baseColor.clone().lerp(new THREE.Color(1.0, 1.0, 1.0), 0.5);
|
||
|
||
// Apply the lighter color with high emissive intensity
|
||
selectedMesh.material.color = lighterColor;
|
||
selectedMesh.material.emissive = lighterColor;
|
||
selectedMesh.material.emissiveIntensity = 0.7;
|
||
}
|
||
}
|
||
|
||
function onMouseClick(event) {
|
||
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
||
|
||
raycaster.setFromCamera(mouse, camera);
|
||
const intersects = raycaster.intersectObjects(Object.values(linkMeshes));
|
||
|
||
if (intersects.length > 0) {
|
||
const mesh = intersects[0].object;
|
||
if (mesh.userData.motorData) {
|
||
// Clear hover effect if we had one
|
||
if (hoveredMesh && hoveredMesh !== mesh) {
|
||
const motorData = hoveredMesh.userData.motorData;
|
||
if (motorData) {
|
||
const temp = motorData.avg;
|
||
const color = getTemperatureColor(temp);
|
||
hoveredMesh.material.emissive = color;
|
||
hoveredMesh.material.emissiveIntensity = 0.2 + (temp / 120) * 0.3;
|
||
}
|
||
}
|
||
hoveredMesh = null;
|
||
|
||
highlightMesh(mesh);
|
||
showMotorInfo(mesh.userData.motorData);
|
||
}
|
||
} else {
|
||
// Clicked on empty space - clear selection and hide panel
|
||
clearSelection();
|
||
document.getElementById('motorInfoPanel').classList.remove('visible');
|
||
}
|
||
}
|
||
|
||
function showMotorInfo(motor) {
|
||
const panel = document.getElementById('motorInfoPanel');
|
||
const title = document.getElementById('motorInfoTitle');
|
||
const content = document.getElementById('motorInfoContent');
|
||
|
||
// Update title (keeping the close button)
|
||
title.querySelector('span').textContent = motor.motor_name;
|
||
|
||
const temp1Class = motor.temp1 > 60 ? 'temp-hot' : motor.temp1 > 45 ? 'temp-warm' : 'temp-normal';
|
||
const temp2Class = motor.temp2 > 60 ? 'temp-hot' : motor.temp2 > 45 ? 'temp-warm' : 'temp-normal';
|
||
const avgClass = motor.avg > 60 ? 'temp-hot' : motor.avg > 45 ? 'temp-warm' : 'temp-normal';
|
||
|
||
// Format position in degrees and radians
|
||
let positionHTML = '';
|
||
if (motor.position !== undefined) {
|
||
const positionDeg = (motor.position * 180 / Math.PI).toFixed(1);
|
||
const positionRad = motor.position.toFixed(3);
|
||
positionHTML = `
|
||
<div class="motor-detail">
|
||
<span class="detail-label">Position</span>
|
||
<span class="detail-value">${positionDeg}° (${positionRad} rad)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Format velocity if available
|
||
let velocityHTML = '';
|
||
if (motor.velocity !== undefined) {
|
||
const velocityDeg = (motor.velocity * 180 / Math.PI).toFixed(1);
|
||
velocityHTML = `
|
||
<div class="motor-detail">
|
||
<span class="detail-label">Velocity</span>
|
||
<span class="detail-value">${velocityDeg}°/s</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Format torque if available
|
||
let torqueHTML = '';
|
||
if (motor.torque !== undefined) {
|
||
torqueHTML = `
|
||
<div class="motor-detail">
|
||
<span class="detail-label">Torque</span>
|
||
<span class="detail-value">${motor.torque.toFixed(2)} Nm</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
content.innerHTML = `
|
||
<div class="motor-detail">
|
||
<span class="detail-label">Motor ID</span>
|
||
<span class="detail-value">${motor.motor_id}</span>
|
||
</div>
|
||
${positionHTML}
|
||
${velocityHTML}
|
||
${torqueHTML}
|
||
<div class="motor-detail">
|
||
<span class="detail-label">Surface Temp</span>
|
||
<span class="detail-value ${temp1Class}">${motor.temp1}°C</span>
|
||
</div>
|
||
<div class="motor-detail">
|
||
<span class="detail-label">Winding Temp</span>
|
||
<span class="detail-value ${temp2Class}">${motor.temp2}°C</span>
|
||
</div>
|
||
<div class="motor-detail">
|
||
<span class="detail-label">Average Temp</span>
|
||
<span class="detail-value ${avgClass}">${motor.avg.toFixed(1)}°C</span>
|
||
</div>
|
||
<div class="motor-detail">
|
||
<span class="detail-label">Mesh Part</span>
|
||
<span class="detail-value" style="font-size: 0.8em;">${motor.mesh_name}</span>
|
||
</div>
|
||
`;
|
||
|
||
panel.classList.add('visible');
|
||
}
|
||
|
||
window.addEventListener('click', onMouseClick, false);
|
||
|
||
// Close panel button
|
||
document.getElementById('closePanelBtn').addEventListener('click', function(event) {
|
||
event.stopPropagation(); // Prevent triggering the window click
|
||
|
||
// Clear hover effect if any
|
||
if (hoveredMesh) {
|
||
const motorData = hoveredMesh.userData.motorData;
|
||
if (motorData) {
|
||
const temp = motorData.avg;
|
||
const color = getTemperatureColor(temp);
|
||
hoveredMesh.material.emissive = color;
|
||
hoveredMesh.material.emissiveIntensity = 0.2 + (temp / 120) * 0.3;
|
||
}
|
||
hoveredMesh = null;
|
||
}
|
||
|
||
clearSelection();
|
||
document.getElementById('motorInfoPanel').classList.remove('visible');
|
||
});
|
||
|
||
// Add hover effect for meshes
|
||
let hoveredMesh = null;
|
||
function onMouseMove(event) {
|
||
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
||
|
||
raycaster.setFromCamera(mouse, camera);
|
||
const intersects = raycaster.intersectObjects(Object.values(linkMeshes));
|
||
|
||
let newHoveredMesh = null;
|
||
|
||
// Check if we're hovering over a mesh
|
||
if (intersects.length > 0) {
|
||
const mesh = intersects[0].object;
|
||
if (mesh.userData.motorData && mesh !== selectedMesh) {
|
||
newHoveredMesh = mesh;
|
||
}
|
||
}
|
||
|
||
// Only update if hover state changed
|
||
if (newHoveredMesh !== hoveredMesh) {
|
||
// Clear previous hover
|
||
if (hoveredMesh && hoveredMesh !== selectedMesh) {
|
||
const motorData = hoveredMesh.userData.motorData;
|
||
if (motorData) {
|
||
const temp = motorData.avg;
|
||
const color = getTemperatureColor(temp);
|
||
hoveredMesh.material.emissive = color;
|
||
hoveredMesh.material.emissiveIntensity = 0.2 + (temp / 120) * 0.3;
|
||
}
|
||
}
|
||
|
||
// Apply new hover
|
||
if (newHoveredMesh) {
|
||
const motorData = newHoveredMesh.userData.motorData;
|
||
if (motorData) {
|
||
const temp = motorData.avg;
|
||
const color = getTemperatureColor(temp);
|
||
// Make it brighter by increasing emissive intensity
|
||
newHoveredMesh.material.emissive = color;
|
||
newHoveredMesh.material.emissiveIntensity = 0.5;
|
||
}
|
||
document.body.style.cursor = 'pointer';
|
||
} else {
|
||
document.body.style.cursor = 'default';
|
||
}
|
||
|
||
hoveredMesh = newHoveredMesh;
|
||
} else if (newHoveredMesh) {
|
||
// Still hovering, keep cursor as pointer
|
||
document.body.style.cursor = 'pointer';
|
||
} else {
|
||
document.body.style.cursor = 'default';
|
||
}
|
||
}
|
||
|
||
window.addEventListener('mousemove', onMouseMove, false);
|
||
|
||
// Control buttons
|
||
document.getElementById('autoRotateBtn').addEventListener('click', function () {
|
||
autoRotate = !autoRotate;
|
||
controls.autoRotate = autoRotate;
|
||
this.textContent = `Auto Rotate: ${autoRotate ? 'ON' : 'OFF'}`;
|
||
this.classList.toggle('active', autoRotate);
|
||
});
|
||
|
||
document.getElementById('showPositionsBtn').addEventListener('click', function () {
|
||
showPositions = !showPositions;
|
||
this.textContent = `Show Positions: ${showPositions ? 'ON' : 'OFF'}`;
|
||
this.classList.toggle('active', showPositions);
|
||
|
||
if (!showPositions) {
|
||
// Reset all joints to original positions when disabled
|
||
resetAllJointPositions();
|
||
} else {
|
||
// Re-apply current positions when enabled
|
||
if (motorData.positions && motorData.positions.length > 0) {
|
||
updateMotorPositions(motorData.positions);
|
||
}
|
||
}
|
||
});
|
||
|
||
document.getElementById('resetViewBtn').addEventListener('click', function () {
|
||
camera.position.set(0, 1.2, 2.5);
|
||
controls.target.set(0, 0.5, 0);
|
||
controls.update();
|
||
});
|
||
|
||
document.getElementById('wireframeBtn').addEventListener('click', function () {
|
||
wireframeMode = !wireframeMode;
|
||
Object.values(linkMeshes).forEach(mesh => {
|
||
mesh.material.wireframe = wireframeMode;
|
||
});
|
||
this.textContent = `Wireframe: ${wireframeMode ? 'ON' : 'OFF'}`;
|
||
this.classList.toggle('active', wireframeMode);
|
||
});
|
||
|
||
// Socket.IO events
|
||
socket.on('connect', function () {
|
||
document.getElementById('statusIndicator').classList.add('connected');
|
||
document.getElementById('statusText').textContent = 'Connected';
|
||
});
|
||
|
||
socket.on('disconnect', function () {
|
||
document.getElementById('statusIndicator').classList.remove('connected');
|
||
document.getElementById('statusText').textContent = 'Disconnected';
|
||
});
|
||
|
||
socket.on('motor_update', function (data) {
|
||
updateMotorTemperatures(data);
|
||
if (data.positions && data.positions.length > 0) {
|
||
updateMotorPositions(data.positions);
|
||
}
|
||
});
|
||
|
||
// Animation loop
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
}
|
||
|
||
// Initialize
|
||
async function init() {
|
||
initScene();
|
||
await loadRobotModel();
|
||
animate();
|
||
|
||
// Fetch initial data
|
||
fetch('/api/temp/motors')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.temperatures && data.temperatures.length > 0) {
|
||
updateMotorTemperatures(data);
|
||
}
|
||
if (data.positions && data.positions.length > 0) {
|
||
updateMotorPositions(data.positions);
|
||
}
|
||
});
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
|
||
</html> |