feat: initial commit — orchestratore presa-mano con AI assistant + dual-robot
Pipeline completa per calibrazione hand-eye e grasp planning su robot Unitree G1/H1 con camera depth Nuwa-HP60C. Esposta come FastAPI + GUI vanilla JS, con assistente AI (OpenRouter + tool calling curato) per pilotare il sistema in linguaggio naturale. Architettura: - FastAPI orchestrator (porta da .env, default 8890) che coordina 4 microservizi upstream: Nuwa-HP60C, marker-finder, YOLO, robot bridge. - Dual-robot: services.g1 e services.h1 in config.yaml; switchabili a runtime via PUT /api/home/robot. - Calibrazione hand-eye eye-to-hand split per-side (right/left). - Marker pose: marker-finder riceve gli intrinseci RGB della Nuwa al boot, PnP server-side con quei dati. Nessun re-PnP locale richiesto. - Depth-to-RGB: registrazione iterativa IR↔RGB per back-projection 3D corretta (Nuwa /api/measure usa coords IR; il nostro /api/perception/ measure_rgb prende pixel RGB e ritorna 3D nel frame RGB OpenCV). - Correzione affine sul depth bias (calibrabile via marker come ground-truth) — riduce errore Z da ~6% a ~1.5% nei test. - AI assistant: 16 tool curati (move_wrist_*, hand_action, get_marker[_3d], detect_object, calibration_*, set_active_robot…), system prompt dinamico che riflette robot attivo + marker config + addendum customizzabile da GUI. Temperature di default 0.1 per evitare invenzioni. GUI (7 tab): - Home: stato comunicazione device + selettore robot attivo G1/H1 - Assistente AI: chat con OpenRouter, history persistita lato browser, task riutilizzabili (prompt salvati), trace tool calls - Impostazioni AI: modello da combo curata, temperature, max iterations, prompt addendum, anteprima system prompt - Calibrazione, Auto-Calibrazione, TrovaUtensile, Servo Visuale, Vista 3D, Movimento EE — invariate dalle versioni precedenti Setup: - Variabili in presa-mano/.env (template in .env.example) - OPENROUTER_API_KEY richiesta solo per il tab Assistente AI - PRESA_MANO_PORT configurabile Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
ac573505b7
121 files changed
+67199
No files matched your search
@@ -0,0 +1,484 @@
|
||||
/* G1 3D diagnostic viewer.
|
||||
*
|
||||
* Loads the URDF, builds the scene graph with two-level groups per joint
|
||||
* (outer = <origin>, inner = motion driven by diagnostic q), renders meshes
|
||||
* tinted by per-motor temperature and colored spheres at joint origins.
|
||||
* Exposes a tiny global API consumed by app.js powerPollOnce:
|
||||
* window.g1Diag3d.update(diagData)
|
||||
*
|
||||
* Deliberately avoids depending on any URDF library — the URDF subset we
|
||||
* need (fixed/revolute/continuous/prismatic joints, single mesh per visual,
|
||||
* relative mesh paths) is small enough to parse in ~50 lines.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
||||
|
||||
const URDF_URL = '/static/assets/g1/g1_body29_hand14.urdf';
|
||||
const MESH_BASE = '/static/assets/g1/';
|
||||
|
||||
const WARN_C_DEFAULT = 75;
|
||||
const CRIT_C_DEFAULT = 85;
|
||||
|
||||
function parseOrigin(el) {
|
||||
if (!el) return { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||
const parse = (s) => s ? s.trim().split(/\s+/).map(Number) : [0, 0, 0];
|
||||
return {
|
||||
xyz: parse(el.getAttribute('xyz')),
|
||||
rpy: parse(el.getAttribute('rpy')),
|
||||
};
|
||||
}
|
||||
function parseAxis(el) {
|
||||
if (!el) return [1, 0, 0];
|
||||
const s = el.getAttribute('xyz');
|
||||
return s ? s.trim().split(/\s+/).map(Number) : [1, 0, 0];
|
||||
}
|
||||
|
||||
async function parseURDF(xmlText) {
|
||||
const doc = new DOMParser().parseFromString(xmlText, 'application/xml');
|
||||
const links = {};
|
||||
for (const el of doc.querySelectorAll('link')) {
|
||||
const name = el.getAttribute('name');
|
||||
if (!name) continue;
|
||||
const v = el.querySelector('visual');
|
||||
let mesh = null, visualOrigin = { xyz: [0,0,0], rpy: [0,0,0] };
|
||||
if (v) {
|
||||
visualOrigin = parseOrigin(v.querySelector('origin'));
|
||||
const me = v.querySelector('geometry mesh');
|
||||
if (me) mesh = me.getAttribute('filename');
|
||||
}
|
||||
links[name] = { name, mesh, visualOrigin };
|
||||
}
|
||||
const joints = [];
|
||||
for (const el of doc.querySelectorAll('joint')) {
|
||||
const parent = el.querySelector('parent');
|
||||
const child = el.querySelector('child');
|
||||
if (!parent || !child) continue;
|
||||
joints.push({
|
||||
name: el.getAttribute('name'),
|
||||
type: el.getAttribute('type') || 'fixed',
|
||||
parent: parent.getAttribute('link'),
|
||||
child: child.getAttribute('link'),
|
||||
origin: parseOrigin(el.querySelector('origin')),
|
||||
axis: parseAxis(el.querySelector('axis')),
|
||||
});
|
||||
}
|
||||
return { links, joints };
|
||||
}
|
||||
|
||||
function applyTransform(obj, o) {
|
||||
obj.position.set(o.xyz[0], o.xyz[1], o.xyz[2]);
|
||||
obj.rotation.order = 'ZYX'; // URDF RPY: intrinsic rotations yaw * pitch * roll
|
||||
obj.rotation.set(o.rpy[0], o.rpy[1], o.rpy[2]);
|
||||
}
|
||||
|
||||
function tempToColor(t, warn, crit) {
|
||||
if (!Number.isFinite(t)) return 0x888888;
|
||||
if (t >= crit) return 0xe74c3c;
|
||||
if (t >= warn) return 0xf0ad4e;
|
||||
// green->yellow interpolation below warn
|
||||
const u = Math.max(0, Math.min(1, (t - 30) / (warn - 30)));
|
||||
const r = Math.round(0x3e + u * (0xf0 - 0x3e));
|
||||
const g = Math.round(0xcf - u * (0xcf - 0xad));
|
||||
const b = Math.round(0x8e - u * (0x8e - 0x4e));
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
class G1Viewer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio || 1);
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x0d1014);
|
||||
|
||||
// URDF is Z-up. Keep Z-up throughout for coherence with the robot base frame.
|
||||
this.camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.05, 20);
|
||||
this.camera.up.set(0, 0, 1);
|
||||
this.camera.position.set(1.8, -1.8, 1.2);
|
||||
this.camera.lookAt(0, 0, 0.4);
|
||||
|
||||
this.controls = new OrbitControls(this.camera, canvas);
|
||||
this.controls.target.set(0, 0, 0.4);
|
||||
this.controls.update();
|
||||
|
||||
const hemi = new THREE.HemisphereLight(0xffffff, 0x222233, 0.6);
|
||||
this.scene.add(hemi);
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dir.position.set(2, 2, 4);
|
||||
this.scene.add(dir);
|
||||
const dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||
dir2.position.set(-2, -1, 2);
|
||||
this.scene.add(dir2);
|
||||
|
||||
this.grid = new THREE.GridHelper(2, 20, 0x444444, 0x222233);
|
||||
this.grid.rotation.x = Math.PI / 2; // grid is XY plane in Z-up
|
||||
this.scene.add(this.grid);
|
||||
|
||||
this.robotRoot = new THREE.Group();
|
||||
this.scene.add(this.robotRoot);
|
||||
|
||||
this.linkGroups = {};
|
||||
this.linkMeshes = {};
|
||||
this.jointGroups = {}; // static <origin> holders
|
||||
this.jointMotions = {}; // inner motion-driven holders
|
||||
this.jointIndicators = {}; // sphere at joint origin
|
||||
this.motorByJointName = {}; // joint_name -> motor dict (from /diagnostics)
|
||||
this.warnC = WARN_C_DEFAULT;
|
||||
this.critC = CRIT_C_DEFAULT;
|
||||
this.showMeshTint = true;
|
||||
this.showSpheres = true;
|
||||
this.pinnedJoint = null;
|
||||
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.mouseNDC = new THREE.Vector2();
|
||||
|
||||
this._onResize = () => this.resize();
|
||||
window.addEventListener('resize', this._onResize);
|
||||
this.canvas.addEventListener('pointermove', (ev) => this._onHover(ev));
|
||||
this.canvas.addEventListener('click', (ev) => this._onClick(ev));
|
||||
|
||||
this._animate = this._animate.bind(this);
|
||||
this.resize();
|
||||
this._animate();
|
||||
}
|
||||
|
||||
resize() {
|
||||
const w = this.canvas.clientWidth || 720;
|
||||
const h = this.canvas.clientHeight || 560;
|
||||
this.renderer.setSize(w, h, false);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
_animate() {
|
||||
this._raf = requestAnimationFrame(this._animate);
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
async load() {
|
||||
const res = await fetch(URDF_URL);
|
||||
if (!res.ok) throw new Error(`URDF fetch ${res.status}`);
|
||||
const xml = await res.text();
|
||||
const urdf = await parseURDF(xml);
|
||||
this._urdf = urdf;
|
||||
this._build(urdf);
|
||||
await this._loadMeshes(urdf);
|
||||
this._placeFloor();
|
||||
}
|
||||
|
||||
_build(urdf) {
|
||||
const childSet = new Set(urdf.joints.map(j => j.child));
|
||||
const rootName = Object.keys(urdf.links).find(n => !childSet.has(n));
|
||||
const buildLink = (name) => {
|
||||
const link = urdf.links[name];
|
||||
const g = new THREE.Group();
|
||||
g.name = `link:${name}`;
|
||||
g.userData.linkName = name;
|
||||
this.linkGroups[name] = g;
|
||||
// child joints
|
||||
for (const j of urdf.joints.filter(x => x.parent === name)) {
|
||||
const origin = new THREE.Group();
|
||||
applyTransform(origin, j.origin);
|
||||
origin.userData.joint = j;
|
||||
this.jointGroups[j.name] = origin;
|
||||
const motion = new THREE.Group();
|
||||
origin.add(motion);
|
||||
this.jointMotions[j.name] = motion;
|
||||
// sphere indicator at joint origin (in origin's local frame → origin(0,0,0))
|
||||
const sphere = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.012, 16, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0x888888, depthTest: false, transparent: true, opacity: 0.9 }),
|
||||
);
|
||||
sphere.renderOrder = 999;
|
||||
sphere.userData.jointName = j.name;
|
||||
origin.add(sphere);
|
||||
this.jointIndicators[j.name] = sphere;
|
||||
motion.add(buildLink(j.child));
|
||||
g.add(origin);
|
||||
}
|
||||
return g;
|
||||
};
|
||||
this.robotRoot.add(buildLink(rootName));
|
||||
}
|
||||
|
||||
async _loadMeshes(urdf) {
|
||||
const loader = new STLLoader();
|
||||
const tasks = [];
|
||||
for (const name of Object.keys(urdf.links)) {
|
||||
const link = urdf.links[name];
|
||||
if (!link.mesh) continue;
|
||||
const url = MESH_BASE + link.mesh;
|
||||
tasks.push(new Promise((resolve) => {
|
||||
loader.load(url, (geom) => {
|
||||
geom.computeVertexNormals();
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0x888888, metalness: 0.1, roughness: 0.6,
|
||||
flatShading: false,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geom, mat);
|
||||
mesh.userData.linkName = name;
|
||||
applyTransform(mesh, link.visualOrigin);
|
||||
this.linkMeshes[name] = mesh;
|
||||
this.linkGroups[name].add(mesh);
|
||||
resolve();
|
||||
}, undefined, (err) => {
|
||||
console.warn('STL load failed:', url, err);
|
||||
resolve();
|
||||
});
|
||||
}));
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
_placeFloor() {
|
||||
// position grid at model's feet (min z of bounding box).
|
||||
const box = new THREE.Box3().setFromObject(this.robotRoot);
|
||||
if (isFinite(box.min.z)) this.grid.position.z = box.min.z;
|
||||
}
|
||||
|
||||
setShowGrid(v) { this.grid.visible = v; }
|
||||
setShowSpheres(v) {
|
||||
this.showSpheres = v;
|
||||
for (const s of Object.values(this.jointIndicators)) s.visible = v;
|
||||
}
|
||||
setShowMeshTint(v) {
|
||||
this.showMeshTint = v;
|
||||
this._applyColors();
|
||||
}
|
||||
|
||||
fitView() {
|
||||
const box = new THREE.Box3().setFromObject(this.robotRoot);
|
||||
if (!isFinite(box.min.z)) return;
|
||||
const c = new THREE.Vector3();
|
||||
box.getCenter(c);
|
||||
const s = new THREE.Vector3();
|
||||
box.getSize(s);
|
||||
const d = Math.max(s.x, s.y, s.z) * 1.8;
|
||||
this.controls.target.copy(c);
|
||||
const dir = new THREE.Vector3(1, -1, 0.4).normalize();
|
||||
this.camera.position.copy(c).addScaledVector(dir, d);
|
||||
this.controls.update();
|
||||
}
|
||||
resetView() {
|
||||
this.controls.target.set(0, 0, 0.4);
|
||||
this.camera.position.set(1.8, -1.8, 1.2);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
update(diagData) {
|
||||
if (!diagData || !Array.isArray(diagData.motors)) return;
|
||||
this.warnC = Number(diagData.warn_temp_c ?? WARN_C_DEFAULT);
|
||||
this.critC = Number(diagData.crit_temp_c ?? CRIT_C_DEFAULT);
|
||||
this.motorByJointName = {};
|
||||
for (const m of diagData.motors) {
|
||||
const jn = m.name + '_joint';
|
||||
this.motorByJointName[jn] = m;
|
||||
// Apply joint angle.
|
||||
const jg = this.jointMotions[jn];
|
||||
if (jg) {
|
||||
const joint = this.jointGroups[jn].userData.joint;
|
||||
const ax = new THREE.Vector3(...joint.axis).normalize();
|
||||
if (joint.type === 'revolute' || joint.type === 'continuous') {
|
||||
jg.quaternion.setFromAxisAngle(ax, Number(m.q) || 0);
|
||||
} else if (joint.type === 'prismatic') {
|
||||
jg.position.copy(ax.multiplyScalar(Number(m.q) || 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
this._applyColors();
|
||||
if (this.pinnedJoint) this._renderPinned();
|
||||
this._renderSummary(diagData);
|
||||
}
|
||||
|
||||
_applyColors() {
|
||||
for (const [jointName, motor] of Object.entries(this.motorByJointName)) {
|
||||
const joint = this.jointGroups[jointName];
|
||||
if (!joint) continue;
|
||||
const temp = motor.temperature_motor;
|
||||
const faulted = !!motor.faulted;
|
||||
const enabled = motor.enabled !== false;
|
||||
let color = tempToColor(temp, this.warnC, this.critC);
|
||||
if (!enabled) color = 0x333333;
|
||||
// sphere
|
||||
const sph = this.jointIndicators[jointName];
|
||||
if (sph) {
|
||||
sph.material.color.setHex(color);
|
||||
if (faulted) sph.material.color.setHex(0xe74c3c);
|
||||
}
|
||||
// mesh tint on child link
|
||||
const childLink = joint.userData.joint.child;
|
||||
const mesh = this.linkMeshes[childLink];
|
||||
if (mesh) {
|
||||
if (this.showMeshTint) {
|
||||
mesh.material.color.setHex(color);
|
||||
mesh.material.emissive.setHex(faulted ? 0x330000 : 0x000000);
|
||||
mesh.material.emissiveIntensity = faulted ? 0.8 : 0;
|
||||
} else {
|
||||
mesh.material.color.setHex(0x888888);
|
||||
mesh.material.emissiveIntensity = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_pickJointAt(clientX, clientY) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.mouseNDC.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||
this.mouseNDC.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||||
this.raycaster.setFromCamera(this.mouseNDC, this.camera);
|
||||
// Prefer hits on joint spheres first (small but always on top).
|
||||
const sphHits = this.raycaster.intersectObjects(Object.values(this.jointIndicators), false);
|
||||
if (sphHits.length) return sphHits[0].object.userData.jointName;
|
||||
// Fall back to mesh hits (walk up the tree to find the joint whose motion group is the ancestor).
|
||||
const meshHits = this.raycaster.intersectObjects(Object.values(this.linkMeshes), false);
|
||||
if (!meshHits.length) return null;
|
||||
const linkName = meshHits[0].object.userData.linkName;
|
||||
// Find the joint whose child == linkName.
|
||||
const joint = this._urdf.joints.find(j => j.child === linkName && this.motorByJointName[j.name]);
|
||||
return joint ? joint.name : null;
|
||||
}
|
||||
|
||||
_onHover(ev) {
|
||||
const tt = document.getElementById('diag3dTooltip');
|
||||
const joint = this._pickJointAt(ev.clientX, ev.clientY);
|
||||
if (!joint) { tt.classList.add('hidden'); return; }
|
||||
const m = this.motorByJointName[joint];
|
||||
if (!m) { tt.classList.add('hidden'); return; }
|
||||
const cls = m.faulted ? 'err' : (m.temperature_motor >= this.critC ? 'err' :
|
||||
m.temperature_motor >= this.warnC ? 'warn' : '');
|
||||
tt.className = 'diag3d-tooltip ' + cls;
|
||||
tt.innerHTML = this._tooltipHTML(m);
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
tt.style.left = (ev.clientX - rect.left + 14) + 'px';
|
||||
tt.style.top = (ev.clientY - rect.top + 14) + 'px';
|
||||
}
|
||||
|
||||
_onClick(ev) {
|
||||
const joint = this._pickJointAt(ev.clientX, ev.clientY);
|
||||
if (!joint) return;
|
||||
this.pinnedJoint = joint;
|
||||
this._renderPinned();
|
||||
}
|
||||
|
||||
_tooltipHTML(m) {
|
||||
const rows = [
|
||||
['T motore', `${m.temperature_motor}°C`],
|
||||
['T ctrl', `${m.temperature_ctrl}°C`],
|
||||
['q', Number(m.q).toFixed(3) + ' rad'],
|
||||
['dq', Number(m.dq).toFixed(3) + ' rad/s'],
|
||||
['tau_est', Number(m.tau_est).toFixed(2) + ' Nm'],
|
||||
['V', Number(m.voltage).toFixed(2) + ' V'],
|
||||
['mode', m.mode + (m.enabled === false ? ' (OFF)' : '')],
|
||||
['fault', m.faulted ? ('0x' + Number(m.motorstate).toString(16)) : 'no'],
|
||||
];
|
||||
const body = rows.map(([k, v]) => `<div class="tt-row"><span class="k">${k}</span><span>${v}</span></div>`).join('');
|
||||
return `<div class="tt-title">#${m.index} · ${m.name}</div>${body}`;
|
||||
}
|
||||
|
||||
_renderPinned() {
|
||||
const host = document.getElementById('diag3dPinned');
|
||||
if (!host) return;
|
||||
if (!this.pinnedJoint) return;
|
||||
const m = this.motorByJointName[this.pinnedJoint];
|
||||
if (!m) {
|
||||
host.innerHTML = `<div class="dim">giunto ${this.pinnedJoint}: nessun motore associato</div>`;
|
||||
return;
|
||||
}
|
||||
const statusCls = m.faulted ? 'err' :
|
||||
m.temperature_motor >= this.critC ? 'err' :
|
||||
m.temperature_motor >= this.warnC ? 'warn' : '';
|
||||
const rows = [
|
||||
['nome', m.name],
|
||||
['indice', m.index],
|
||||
['T motore (°C)', m.temperature_motor],
|
||||
['T ctrl (°C)', m.temperature_ctrl],
|
||||
['q (rad)', Number(m.q).toFixed(4)],
|
||||
['dq (rad/s)', Number(m.dq).toFixed(4)],
|
||||
['tau_est (Nm)', Number(m.tau_est).toFixed(3)],
|
||||
['voltaggio (V)', Number(m.voltage).toFixed(3)],
|
||||
['mode', m.mode],
|
||||
['enabled', m.enabled ? 'sì' : 'no'],
|
||||
['motorstate raw', '0x' + Number(m.motorstate).toString(16)],
|
||||
['faulted', m.faulted ? 'SÌ' : 'no'],
|
||||
['derating', m.derating ? 'sì' : 'no'],
|
||||
];
|
||||
host.innerHTML =
|
||||
`<div><strong>#${m.index} · ${m.name}</strong> <span class="dim">(giunto: ${this.pinnedJoint})</span></div>` +
|
||||
`<table><tbody>${rows.map(([k,v]) =>
|
||||
`<tr class="${statusCls}"><td>${k}</td><td>${v}</td></tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
_renderSummary(d) {
|
||||
const host = document.getElementById('diag3dSummary');
|
||||
if (!host) return;
|
||||
const s = d.summary || {};
|
||||
const fmt = (arr) => (arr && arr.length) ? arr.join(', ') : '—';
|
||||
const rows = [
|
||||
['lowstate', d.stale ? 'STALE' : (d.tick != null ? `tick ${d.tick}` : 'ok')],
|
||||
['max temp (°C)', s.max_motor_temp_c != null ? `${s.max_motor_temp_c} @ ${s.max_motor_temp_joint}` : '—'],
|
||||
['warn / crit (°C)', `${d.warn_temp_c} / ${d.crit_temp_c}`],
|
||||
['derating', fmt(s.derating_joints)],
|
||||
['critical', fmt(s.critical_joints)],
|
||||
['faulted', fmt(s.faulted_joints)],
|
||||
['disabled', fmt(s.disabled_joints)],
|
||||
['min V', s.min_voltage != null ? Number(s.min_voltage).toFixed(2) + ' V' : '—'],
|
||||
];
|
||||
host.innerHTML = `<table><tbody>${rows.map(([k,v]) =>
|
||||
`<tr><td>${k}</td><td>${v}</td></tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose the viewer class so other modules (Auto-Calibrazione) can
|
||||
// instantiate a second canvas over the same URDF pipeline.
|
||||
export { G1Viewer };
|
||||
|
||||
|
||||
// ---- Boot and expose global hook. ----
|
||||
|
||||
const canvas = document.getElementById('diag3dCanvas');
|
||||
const statusEl = document.getElementById('diag3dStatus');
|
||||
let viewer = null;
|
||||
|
||||
async function boot() {
|
||||
if (!canvas) return;
|
||||
try {
|
||||
statusEl.textContent = 'caricamento URDF + mesh…';
|
||||
viewer = new G1Viewer(canvas);
|
||||
await viewer.load();
|
||||
statusEl.textContent = 'URDF caricato · in attesa di /diagnostics…';
|
||||
// Wire up toolbar controls.
|
||||
const by = (id) => document.getElementById(id);
|
||||
by('diag3dMeshTint').addEventListener('change', (e) => viewer.setShowMeshTint(e.target.checked));
|
||||
by('diag3dSpheres').addEventListener('change', (e) => viewer.setShowSpheres(e.target.checked));
|
||||
by('diag3dGrid').addEventListener('change', (e) => viewer.setShowGrid(e.target.checked));
|
||||
by('diag3dResetBtn').addEventListener('click', () => viewer.resetView());
|
||||
by('diag3dFitBtn').addEventListener('click', () => viewer.fitView());
|
||||
// Re-size when the diagnostic tab becomes active (canvas may have 0 size while hidden).
|
||||
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
|
||||
if (btn.dataset.tab === 'diagnostic') setTimeout(() => viewer && viewer.resize(), 50);
|
||||
}));
|
||||
// Global hook for app.js to push diagnostic updates.
|
||||
window.g1Diag3d = {
|
||||
update: (d) => {
|
||||
try { viewer.update(d); statusEl.textContent =
|
||||
d.stale ? 'lowstate STALE' :
|
||||
d.available === false ? (d.reason || '/diagnostics non disponibile') :
|
||||
`motori aggiornati · tick ${d.tick ?? '?'}`; }
|
||||
catch (e) { console.warn('g1Diag3d.update:', e); }
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('diag3d boot failed:', e);
|
||||
statusEl.textContent = 'errore caricamento: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Defer boot until the tab is first opened — avoids eager CDN fetch and STL load
|
||||
// for users who never visit this tab.
|
||||
let booted = false;
|
||||
document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => {
|
||||
if (btn.dataset.tab === 'diagnostic' && !booted) { booted = true; boot(); }
|
||||
}));
|
||||
Reference in new issue
Block a user