752 lines
24 KiB
HTML
752 lines
24 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Robot FK Viewer</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--bg: #0d0f13;
|
||
--panel: #161920;
|
||
--border: #2a2d35;
|
||
--text: #c8cdd8;
|
||
--accent: #4a9eff;
|
||
--muted: #555b6e;
|
||
--ok: #3ecf6b;
|
||
--warn: #f59e0b;
|
||
--err: #ff4f4f;
|
||
--panel-w: 300px;
|
||
}
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font: 13px/1.5 'IBM Plex Mono', 'Cascadia Code', 'Courier New', monospace;
|
||
display: flex;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── sidebar ── */
|
||
#sidebar {
|
||
width: var(--panel-w);
|
||
flex-shrink: 0;
|
||
background: var(--panel);
|
||
border-right: 1px solid var(--border);
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
|
||
.section {
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 12px 14px;
|
||
}
|
||
|
||
.section h3 {
|
||
font-size: 10px;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
/* file inputs */
|
||
.file-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.file-row label {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
width: 58px;
|
||
flex-shrink: 0;
|
||
}
|
||
.file-row input[type=file] {
|
||
flex: 1;
|
||
font-size: 10px;
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
padding: 3px 6px;
|
||
cursor: pointer;
|
||
}
|
||
.file-row input[type=file]::file-selector-button {
|
||
background: var(--border);
|
||
color: var(--text);
|
||
border: none;
|
||
padding: 2px 8px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 10px;
|
||
border-radius: 2px;
|
||
margin-right: 6px;
|
||
}
|
||
|
||
/* joint sliders */
|
||
.slider-row {
|
||
display: grid;
|
||
grid-template-columns: 22px 1fr 48px;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 5px;
|
||
}
|
||
.slider-row span { color: var(--accent); font-size: 12px; }
|
||
input[type=range] {
|
||
-webkit-appearance: none;
|
||
height: 3px;
|
||
background: var(--border);
|
||
border-radius: 2px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
}
|
||
input[type=range]::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 12px; height: 12px;
|
||
background: var(--accent);
|
||
border-radius: 50%;
|
||
}
|
||
.val-box {
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
padding: 2px 4px;
|
||
font-size: 11px;
|
||
text-align: right;
|
||
color: var(--text);
|
||
width: 48px;
|
||
}
|
||
|
||
/* toggles */
|
||
.toggle-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 5px;
|
||
}
|
||
.toggle-label { font-size: 11px; color: var(--text); }
|
||
.toggle {
|
||
position: relative; width: 36px; height: 18px;
|
||
}
|
||
.toggle input { display: none; }
|
||
.slider-track {
|
||
position: absolute; inset: 0;
|
||
background: var(--border);
|
||
border-radius: 9px;
|
||
cursor: pointer;
|
||
transition: background .2s;
|
||
}
|
||
.slider-track::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 2px; top: 2px;
|
||
width: 14px; height: 14px;
|
||
background: var(--muted);
|
||
border-radius: 50%;
|
||
transition: transform .2s, background .2s;
|
||
}
|
||
.toggle input:checked + .slider-track { background: var(--accent); }
|
||
.toggle input:checked + .slider-track::after {
|
||
transform: translateX(18px);
|
||
background: #fff;
|
||
}
|
||
|
||
/* stats */
|
||
#stats-content {
|
||
font-size: 11px;
|
||
line-height: 1.8;
|
||
}
|
||
.stat-line { display: flex; justify-content: space-between; }
|
||
.stat-val { color: var(--accent); }
|
||
.stat-ok { color: var(--ok); }
|
||
.stat-warn { color: var(--warn); }
|
||
.stat-err { color: var(--err); }
|
||
|
||
#status-bar {
|
||
font-size: 10px;
|
||
color: var(--muted);
|
||
padding: 8px 14px;
|
||
border-top: 1px solid var(--border);
|
||
margin-top: auto;
|
||
}
|
||
|
||
/* canvas */
|
||
#canvas-wrap {
|
||
flex: 1;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
canvas { display: block; width: 100%; height: 100%; }
|
||
</style>
|
||
|
||
<script type="importmap">
|
||
{
|
||
"imports": {
|
||
"three": "https://unpkg.com/three@0.162.0/build/three.module.js",
|
||
"three/addons/": "https://unpkg.com/three@0.162.0/examples/jsm/"
|
||
}
|
||
}
|
||
</script>
|
||
</head>
|
||
|
||
<body>
|
||
<div id="sidebar">
|
||
|
||
<!-- files -->
|
||
<div class="section">
|
||
<h3>Data Files</h3>
|
||
<div class="file-row">
|
||
<label>robot.json</label>
|
||
<input type="file" id="fRobot" accept=".json">
|
||
</div>
|
||
<div class="file-row">
|
||
<label>aruco</label>
|
||
<input type="file" id="fAruco" accept=".json">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- joint sliders -->
|
||
<div class="section">
|
||
<h3>Joint Values</h3>
|
||
<div id="sliders"></div>
|
||
</div>
|
||
|
||
<!-- display toggles -->
|
||
<div class="section">
|
||
<h3>Display</h3>
|
||
<div class="toggle-row">
|
||
<span class="toggle-label">Skeleton</span>
|
||
<label class="toggle"><input type="checkbox" id="tSkeleton" checked><span class="slider-track"></span></label>
|
||
</div>
|
||
<div class="toggle-row">
|
||
<span class="toggle-label">Model markers</span>
|
||
<label class="toggle"><input type="checkbox" id="tModel" checked><span class="slider-track"></span></label>
|
||
</div>
|
||
<div class="toggle-row">
|
||
<span class="toggle-label">Observed markers</span>
|
||
<label class="toggle"><input type="checkbox" id="tObserved" checked><span class="slider-track"></span></label>
|
||
</div>
|
||
<div class="toggle-row">
|
||
<span class="toggle-label">Error lines</span>
|
||
<label class="toggle"><input type="checkbox" id="tErrors" checked><span class="slider-track"></span></label>
|
||
</div>
|
||
<div class="toggle-row">
|
||
<span class="toggle-label">Board plane</span>
|
||
<label class="toggle"><input type="checkbox" id="tBoard" checked><span class="slider-track"></span></label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- stats -->
|
||
<div class="section">
|
||
<h3>Error Statistics</h3>
|
||
<div id="stats-content"><span style="color:var(--muted)">Load both files…</span></div>
|
||
</div>
|
||
|
||
<div id="status-bar">Drag to orbit · Scroll to zoom · Right-drag to pan</div>
|
||
</div>
|
||
|
||
<div id="canvas-wrap">
|
||
<canvas id="cv"></canvas>
|
||
</div>
|
||
|
||
<script type="module">
|
||
import * as THREE from 'three';
|
||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// FK in plain JavaScript — mirrors robot_fk.py exactly
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
function norm(v) {
|
||
const n = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
|
||
return n < 1e-12 ? v.map(() => 0) : v.map(x => x / n);
|
||
}
|
||
|
||
function mm3(A, B) { // 3×3 row-major mat-mat
|
||
const C = new Array(9).fill(0);
|
||
for (let i = 0; i < 3; i++)
|
||
for (let j = 0; j < 3; j++)
|
||
for (let k = 0; k < 3; k++)
|
||
C[i*3+j] += A[i*3+k] * B[k*3+j];
|
||
return C;
|
||
}
|
||
|
||
function rotAA(axis, deg) { // Rodrigues
|
||
const [x, y, z] = norm(axis);
|
||
const r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r), t = 1 - c;
|
||
return [t*x*x+c, t*x*y-s*z, t*x*z+s*y,
|
||
t*x*y+s*z, t*y*y+c, t*y*z-s*x,
|
||
t*x*z-s*y, t*y*z+s*x, t*z*z+c];
|
||
}
|
||
|
||
function rotXYZ(rx, ry, rz) {
|
||
return mm3(mm3(rotAA([0,0,1],rz), rotAA([0,1,0],ry)), rotAA([1,0,0],rx));
|
||
}
|
||
|
||
// 4×4 row-major
|
||
function makeT(R3, tx, ty, tz) {
|
||
return [R3[0],R3[1],R3[2],tx, R3[3],R3[4],R3[5],ty,
|
||
R3[6],R3[7],R3[8],tz, 0,0,0,1];
|
||
}
|
||
function I4() { return [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; }
|
||
|
||
function mm4(A, B) {
|
||
const C = new Array(16).fill(0);
|
||
for (let i = 0; i < 4; i++)
|
||
for (let j = 0; j < 4; j++)
|
||
for (let k = 0; k < 4; k++)
|
||
C[i*4+j] += A[i*4+k] * B[k*4+j];
|
||
return C;
|
||
}
|
||
|
||
function applyT(T, p) {
|
||
return [T[0]*p[0]+T[1]*p[1]+T[2]*p[2]+T[3],
|
||
T[4]*p[0]+T[5]*p[1]+T[6]*p[2]+T[7],
|
||
T[8]*p[0]+T[9]*p[1]+T[10]*p[2]+T[11]];
|
||
}
|
||
|
||
function buildTopoOrder(links) {
|
||
const parent = Object.fromEntries(Object.entries(links).map(([n,d]) => [n, d.parent||null]));
|
||
const visited = new Set(), order = [];
|
||
const visit = n => {
|
||
if (visited.has(n)) return;
|
||
visited.add(n);
|
||
if (parent[n]) visit(parent[n]);
|
||
order.push(n);
|
||
};
|
||
Object.keys(links).forEach(visit);
|
||
return order;
|
||
}
|
||
|
||
function computeFK(robotData, joints) {
|
||
const links = robotData.links || {};
|
||
const state = {x:0,y:0,z:0,a:0,b:0,c:0,e:0, ...joints};
|
||
const T = {};
|
||
|
||
for (const lname of buildTopoOrder(links)) {
|
||
const ld = links[lname];
|
||
const par = ld.parent;
|
||
const Tp = T[par] || I4();
|
||
|
||
const mp = ld.mountPosition || [0,0,0];
|
||
const mr = ld.mountRotation || [0,0,0];
|
||
const Tm = makeT(rotXYZ(...mr), ...mp);
|
||
|
||
const jt = ld.jointToParent || {};
|
||
const jo = jt.origin || [0,0,0];
|
||
const jr = jt.rotation || [0,0,0];
|
||
const Tj = makeT(rotXYZ(...jr), ...jo);
|
||
|
||
const type = (jt.type||'').toLowerCase();
|
||
const axis = jt.axis || [1,0,0];
|
||
const v = state[jt.variable] || 0;
|
||
|
||
let Tmot = I4();
|
||
if (type === 'revolute') {
|
||
Tmot = makeT(rotAA(axis, v), 0, 0, 0);
|
||
} else if (type === 'linear') {
|
||
const [ax, ay, az] = norm(axis);
|
||
Tmot = makeT([1,0,0,0,1,0,0,0,1], ax*v, ay*v, az*v);
|
||
}
|
||
|
||
T[lname] = mm4(mm4(mm4(Tp, Tm), Tj), Tmot);
|
||
}
|
||
return T;
|
||
}
|
||
|
||
function markerWorld(T, link, local) {
|
||
return applyT(T[link] || I4(), local);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Coordinate conversion robot-mm → Three.js scene (metres)
|
||
// robot: x=right, y=backward, z=up
|
||
// Three.js: x=right, y=up, z=toward viewer
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const S = 1 / 1000;
|
||
function r2v(rx, ry, rz) { return new THREE.Vector3(rx*S, rz*S, -ry*S); }
|
||
function r2vArr(p) { return r2v(p[0], p[1], p[2]); }
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Three.js scene
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
const canvas = document.getElementById('cv');
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||
renderer.setPixelRatio(devicePixelRatio);
|
||
renderer.shadowMap.enabled = true;
|
||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||
renderer.toneMappingExposure = 0.9;
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x0d0f13);
|
||
scene.fog = new THREE.FogExp2(0x0d0f13, 0.35);
|
||
|
||
const camera = new THREE.PerspectiveCamera(45, 1, 0.001, 20);
|
||
camera.position.set(0.35, 0.55, 1.1);
|
||
camera.lookAt(0.2, 0.05, 0);
|
||
|
||
const controls = new OrbitControls(camera, canvas);
|
||
controls.target.set(0.2, 0.05, 0);
|
||
controls.enableDamping = true;
|
||
controls.dampingFactor = 0.08;
|
||
|
||
// Lighting
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
||
const sun = new THREE.DirectionalLight(0xfff4e0, 1.4);
|
||
sun.position.set(-0.8, 1.2, 0.9);
|
||
sun.castShadow = true;
|
||
scene.add(sun);
|
||
const fill = new THREE.DirectionalLight(0xc0d8ff, 0.4);
|
||
fill.position.set(0.6, 0.3, -0.5);
|
||
scene.add(fill);
|
||
|
||
// Grid
|
||
const grid = new THREE.GridHelper(3, 30, 0x1e2230, 0x1a1e28);
|
||
grid.position.y = -0.028;
|
||
scene.add(grid);
|
||
|
||
// Axes helper (world origin)
|
||
const axes = new THREE.AxesHelper(0.1);
|
||
axes.position.set(0, 0, 0);
|
||
scene.add(axes);
|
||
|
||
// ─── geometry helpers ─────────────────────────────────────────
|
||
|
||
function makeCylinder(p1, p2, radius, color, opacity=1) {
|
||
const dir = p2.clone().sub(p1);
|
||
const len = dir.length();
|
||
if (len < 0.0001) return null;
|
||
const geo = new THREE.CylinderGeometry(radius, radius, len, 8, 1);
|
||
const mat = new THREE.MeshPhongMaterial({
|
||
color, transparent: opacity < 1, opacity,
|
||
shininess: 60
|
||
});
|
||
const m = new THREE.Mesh(geo, mat);
|
||
m.castShadow = true;
|
||
m.position.copy(p1.clone().add(p2).multiplyScalar(0.5));
|
||
m.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir.normalize());
|
||
return m;
|
||
}
|
||
|
||
function makeSphere(pos, radius, color) {
|
||
const geo = new THREE.SphereGeometry(radius, 12, 8);
|
||
const mat = new THREE.MeshPhongMaterial({ color, shininess: 80 });
|
||
const m = new THREE.Mesh(geo, mat);
|
||
m.position.copy(pos);
|
||
m.castShadow = true;
|
||
return m;
|
||
}
|
||
|
||
function makeMarkerSquare(pos, normal, size, color) {
|
||
const geo = new THREE.BoxGeometry(size, size, size*0.1);
|
||
const mat = new THREE.MeshPhongMaterial({ color, shininess: 40 });
|
||
const m = new THREE.Mesh(geo, mat);
|
||
m.position.copy(pos);
|
||
// orient normal
|
||
const n = new THREE.Vector3(...normal).normalize();
|
||
m.quaternion.setFromUnitVectors(new THREE.Vector3(0,0,1), n);
|
||
return m;
|
||
}
|
||
|
||
function makeLine(p1, p2, color) {
|
||
const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
|
||
const mat = new THREE.LineBasicMaterial({ color });
|
||
return new THREE.Line(geo, mat);
|
||
}
|
||
|
||
// ─── link colours ─────────────────────────────────────────────
|
||
const LINK_COLORS = {
|
||
Board: 0x8b6528,
|
||
Base: 0xc8c8c8,
|
||
Arm1: 0x3355cc,
|
||
Ellbow: 0xcccccc,
|
||
Arm2: 0xbbbbbb,
|
||
Hand: 0xaaaaaa,
|
||
Palm: 0x999999,
|
||
FingerA: 0xdddddd,
|
||
FingerB: 0xdddddd,
|
||
};
|
||
function linkColor(name) { return LINK_COLORS[name] ?? 0x888888; }
|
||
|
||
// ─── scene groups ─────────────────────────────────────────────
|
||
const gSkeleton = new THREE.Group();
|
||
const gModel = new THREE.Group();
|
||
const gObserved = new THREE.Group();
|
||
const gErrors = new THREE.Group();
|
||
const gBoard = new THREE.Group();
|
||
scene.add(gSkeleton, gModel, gObserved, gErrors, gBoard);
|
||
|
||
// ─── toggle wiring ────────────────────────────────────────────
|
||
const toggles = {
|
||
tSkeleton: gSkeleton,
|
||
tModel: gModel,
|
||
tObserved: gObserved,
|
||
tErrors: gErrors,
|
||
tBoard: gBoard,
|
||
};
|
||
for (const [id, grp] of Object.entries(toggles)) {
|
||
document.getElementById(id).addEventListener('change', e => {
|
||
grp.visible = e.target.checked;
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// State
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
let robotData = null;
|
||
let arucoData = null;
|
||
const joints = { x:0, y:0, z:0, a:0, b:0, c:0, e:0 };
|
||
|
||
// ─── slider setup ─────────────────────────────────────────────
|
||
const JOINT_DEFS = [
|
||
{ key:'x', label:'x', min:-50, max:600, step:1, unit:'mm' },
|
||
{ key:'y', label:'y', min:-180, max:180, step:0.5,unit:'°' },
|
||
{ key:'z', label:'z', min:-180, max:180, step:0.5,unit:'°' },
|
||
{ key:'a', label:'a', min:-180, max:180, step:0.5,unit:'°' },
|
||
{ key:'b', label:'b', min:-180, max:180, step:0.5,unit:'°' },
|
||
{ key:'c', label:'c', min:-180, max:180, step:0.5,unit:'°' },
|
||
{ key:'e', label:'e', min:0, max:60, step:0.5,unit:'mm' },
|
||
];
|
||
|
||
const sliderEls = {}, valEls = {};
|
||
const slidersDiv = document.getElementById('sliders');
|
||
|
||
for (const d of JOINT_DEFS) {
|
||
const row = document.createElement('div');
|
||
row.className = 'slider-row';
|
||
|
||
const lbl = document.createElement('span');
|
||
lbl.textContent = d.key;
|
||
|
||
const sl = document.createElement('input');
|
||
sl.type = 'range';
|
||
sl.min = d.min; sl.max = d.max; sl.step = d.step; sl.value = 0;
|
||
|
||
const vb = document.createElement('input');
|
||
vb.type = 'text'; vb.className = 'val-box'; vb.value = '0';
|
||
vb.style.width = '58px';
|
||
|
||
sl.addEventListener('input', () => {
|
||
joints[d.key] = parseFloat(sl.value);
|
||
vb.value = parseFloat(sl.value).toFixed(d.step < 1 ? 1 : 0);
|
||
rebuild();
|
||
});
|
||
vb.addEventListener('change', () => {
|
||
const v = parseFloat(vb.value);
|
||
if (!isNaN(v)) {
|
||
joints[d.key] = v;
|
||
sl.value = v;
|
||
rebuild();
|
||
}
|
||
});
|
||
|
||
row.append(lbl, sl, vb);
|
||
slidersDiv.appendChild(row);
|
||
sliderEls[d.key] = sl;
|
||
valEls[d.key] = vb;
|
||
}
|
||
|
||
function setSlider(key, val) {
|
||
joints[key] = val;
|
||
sliderEls[key].value = val;
|
||
valEls[key].value = val.toFixed(JOINT_DEFS.find(d=>d.key===key)?.step < 1 ? 1 : 0);
|
||
}
|
||
|
||
// ─── file loading ──────────────────────────────────────────────
|
||
function readJSON(file) {
|
||
return new Promise((res, rej) => {
|
||
const r = new FileReader();
|
||
r.onload = e => { try { res(JSON.parse(e.target.result)); } catch(err) { rej(err); } };
|
||
r.onerror = rej;
|
||
r.readAsText(file);
|
||
});
|
||
}
|
||
|
||
document.getElementById('fRobot').addEventListener('change', async e => {
|
||
if (!e.target.files[0]) return;
|
||
robotData = await readJSON(e.target.files[0]);
|
||
// init sliders from defaultPosition if present
|
||
const dp = robotData.defaultPosition || {};
|
||
for (const k of Object.keys(joints)) {
|
||
if (dp[k] != null) setSlider(k, dp[k]);
|
||
}
|
||
setStatus('robot.json loaded');
|
||
rebuild();
|
||
});
|
||
|
||
document.getElementById('fAruco').addEventListener('change', async e => {
|
||
if (!e.target.files[0]) return;
|
||
arucoData = await readJSON(e.target.files[0]);
|
||
setStatus('aruco JSON loaded');
|
||
rebuild();
|
||
});
|
||
|
||
// ─── clear groups ──────────────────────────────────────────────
|
||
function clearGroup(g) {
|
||
while (g.children.length) {
|
||
const c = g.children[0];
|
||
c.geometry?.dispose?.();
|
||
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose?.());
|
||
else c.material?.dispose?.();
|
||
g.remove(c);
|
||
}
|
||
}
|
||
|
||
// ─── rebuild scene ─────────────────────────────────────────────
|
||
function rebuild() {
|
||
clearGroup(gSkeleton);
|
||
clearGroup(gModel);
|
||
clearGroup(gObserved);
|
||
clearGroup(gErrors);
|
||
clearGroup(gBoard);
|
||
|
||
if (!robotData) return;
|
||
|
||
const T = computeFK(robotData, joints);
|
||
|
||
// ── board plane ──
|
||
const boardSize = robotData.links?.Board?.size || [1000, 200, 25];
|
||
{
|
||
const w = boardSize[0]*S, d = boardSize[1]*S, h = boardSize[2]*S;
|
||
const geo = new THREE.BoxGeometry(w, h, d);
|
||
const mat = new THREE.MeshPhongMaterial({ color:0x7a5520, transparent:true, opacity:0.5 });
|
||
const m = new THREE.Mesh(geo, mat);
|
||
m.position.set(w/2, -h/2, -d/2); // board goes x→, y→backward
|
||
m.receiveShadow = true;
|
||
gBoard.add(m);
|
||
}
|
||
|
||
// ── skeleton ──
|
||
const links = robotData.links || {};
|
||
for (const [lname, ld] of Object.entries(links)) {
|
||
const sk = ld.skeleton;
|
||
if (!sk) continue;
|
||
const from = markerWorld(T, lname, sk.from || [0,0,0]);
|
||
const to = markerWorld(T, lname, sk.to || [0,0,0]);
|
||
const r = (sk.radius || 4) * S * 0.8;
|
||
const col = sk.color ? new THREE.Color(...sk.color) : new THREE.Color(linkColor(lname));
|
||
const cyl = makeCylinder(r2vArr(from), r2vArr(to), r, col);
|
||
if (cyl) gSkeleton.add(cyl);
|
||
}
|
||
|
||
// ── model markers ──
|
||
const modelPositions = {};
|
||
for (const [lname, ld] of Object.entries(links)) {
|
||
const col = linkColor(lname);
|
||
for (const m of (ld.markers||[])) {
|
||
if (!m.position) continue;
|
||
const mid = m.id;
|
||
const wp = markerWorld(T, lname, m.position);
|
||
modelPositions[mid] = wp;
|
||
const sq = makeMarkerSquare(r2vArr(wp), m.normal||[0,0,1], 0.022, col);
|
||
gModel.add(sq);
|
||
// ID label (not adding for perf, but could)
|
||
}
|
||
}
|
||
|
||
// ── observed markers + error lines ──
|
||
const obs = {};
|
||
if (arucoData) {
|
||
for (const m of (arucoData.markers||[])) {
|
||
const mid = m.marker_id ?? m.id;
|
||
if (mid == null) continue;
|
||
let pos;
|
||
if (m.position_mm) pos = m.position_mm;
|
||
else if (m.position_m) pos = m.position_m.map(v=>v*1000);
|
||
else continue;
|
||
obs[mid] = pos;
|
||
}
|
||
}
|
||
|
||
const errors = [];
|
||
for (const [midStr, opos] of Object.entries(obs)) {
|
||
const mid = parseInt(midStr);
|
||
const mpos = modelPositions[mid];
|
||
const op = r2vArr(opos);
|
||
|
||
// colour by error magnitude
|
||
const errMm = mpos
|
||
? Math.sqrt(opos.reduce((s,v,i) => s+(v-mpos[i])**2, 0))
|
||
: null;
|
||
const col = errMm == null ? 0x888888
|
||
: errMm < 5 ? 0x3ecf6b
|
||
: errMm < 15 ? 0xf59e0b
|
||
: 0xff4f4f;
|
||
|
||
gObserved.add(makeSphere(op, 0.006, col));
|
||
|
||
if (mpos) {
|
||
errors.push(errMm);
|
||
const mp = r2vArr(mpos);
|
||
gErrors.add(makeLine(mp, op, col));
|
||
}
|
||
}
|
||
|
||
// ── stats ──
|
||
updateStats(errors, Object.keys(obs).length, Object.keys(modelPositions).length);
|
||
}
|
||
|
||
// ─── statistics panel ──────────────────────────────────────────
|
||
function updateStats(errArr, nObs, nModel) {
|
||
const el = document.getElementById('stats-content');
|
||
if (errArr.length === 0) {
|
||
el.innerHTML = `<div class="stat-line"><span>Observed:</span><span class="stat-val">${nObs}</span></div>
|
||
<div class="stat-line"><span>Model:</span><span class="stat-val">${nModel}</span></div>
|
||
<div class="stat-line"><span style="color:var(--muted)">No matches found</span></div>`;
|
||
return;
|
||
}
|
||
const sorted = [...errArr].sort((a,b)=>a-b);
|
||
const mean = errArr.reduce((s,v)=>s+v,0) / errArr.length;
|
||
const rms = Math.sqrt(errArr.reduce((s,v)=>s+v*v,0) / errArr.length);
|
||
const p50 = sorted[Math.floor(sorted.length*0.5)];
|
||
const p90 = sorted[Math.floor(sorted.length*0.9)];
|
||
const max = sorted[sorted.length-1];
|
||
|
||
const cls = v => v < 5 ? 'stat-ok' : v < 15 ? 'stat-warn' : 'stat-err';
|
||
|
||
el.innerHTML = `
|
||
<div class="stat-line"><span>Observed:</span><span class="stat-val">${nObs}</span></div>
|
||
<div class="stat-line"><span>Matched:</span><span class="stat-val">${errArr.length}</span></div>
|
||
<div class="stat-line"><span>Mean error:</span><span class="${cls(mean)}">${mean.toFixed(1)} mm</span></div>
|
||
<div class="stat-line"><span>RMS error:</span><span class="${cls(rms)}">${rms.toFixed(1)} mm</span></div>
|
||
<div class="stat-line"><span>Median:</span><span class="${cls(p50)}">${p50.toFixed(1)} mm</span></div>
|
||
<div class="stat-line"><span>p90:</span><span class="${cls(p90)}">${p90.toFixed(1)} mm</span></div>
|
||
<div class="stat-line"><span>Max:</span><span class="${cls(max)}">${max.toFixed(1)} mm</span></div>
|
||
<div style="margin-top:6px;font-size:10px;color:var(--muted)">
|
||
🟢 <5mm 🟡 5–15mm 🔴 >15mm</div>`;
|
||
}
|
||
|
||
function setStatus(msg) {
|
||
document.getElementById('status-bar').textContent = msg + ' — Drag to orbit · Scroll to zoom';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Render loop
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function onResize() {
|
||
const w = canvas.parentElement.clientWidth;
|
||
const h = canvas.parentElement.clientHeight;
|
||
renderer.setSize(w, h);
|
||
camera.aspect = w / h;
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
window.addEventListener('resize', onResize);
|
||
onResize();
|
||
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
}
|
||
animate();
|
||
</script>
|
||
</body>
|
||
</html>
|