// Calibrazione Piano tab: shift+drag ROI on RGB, fit preview, offset // slider, save / clear, and a 3D scatter with the plane mesh. import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const $ = (id) => document.getElementById(id); const RGB_REFRESH_MS = 1000; let roi = null; // {u0,v0,u1,v1} in image pixel coords let previewPlane = null; // last response.plane (preview, not saved) let previewPoints = null; // list of [x,y,z] in CV camera frame, meters let savedPlane = null; // last GET /api/work_plane response.plane function setChip(text, level) { const el = $('wpStatusChip'); if (!el) return; el.textContent = text; el.className = 'post-health ' + (level || ''); } function setRoiInfo() { const el = $('wpRoiInfo'); if (!roi) { el.textContent = '— · disegna con shift+drag'; el.classList.add('dim'); return; } el.classList.remove('dim'); el.textContent = `u=${roi.u0}…${roi.u1} v=${roi.v0}…${roi.v1} (${roi.u1 - roi.u0}×${roi.v1 - roi.v0} px)`; } function updateButtonStates() { $('wpFitBtn').disabled = !roi; $('wpSaveBtn').disabled = !previewPlane; $('wpClearBtn').disabled = !savedPlane; } function rgbUrl() { // Same-origin RGB proxy exposed by presa-mano (no CORS issues). return `/api/calibration/rgb.jpg?t=${Date.now()}`; } function startRgbLoop() { const tick = () => { $('wpImg').src = rgbUrl(); }; tick(); setInterval(tick, RGB_REFRESH_MS); } // ---------------- ROI drawing on the RGB image ---------------- (function setupRoiDrawing() { const viewport = $('wpViewport'); const img = $('wpImg'); const overlay = $('wpOverlay'); let drawing = null; function imgPixelFromEvent(e) { const rect = img.getBoundingClientRect(); const sx = img.naturalWidth / rect.width; const sy = img.naturalHeight / rect.height; return { u: Math.round((e.clientX - rect.left) * sx), v: Math.round((e.clientY - rect.top) * sy), }; } function syncOverlaySize() { overlay.width = img.naturalWidth || 640; overlay.height = img.naturalHeight || 480; overlay.style.width = img.clientWidth + 'px'; overlay.style.height = img.clientHeight + 'px'; } img.addEventListener('load', syncOverlaySize); function drawOverlay() { const ctx = overlay.getContext('2d'); ctx.clearRect(0, 0, overlay.width, overlay.height); if (roi) { ctx.strokeStyle = '#9b6dff'; ctx.lineWidth = 2; ctx.strokeRect(roi.u0, roi.v0, roi.u1 - roi.u0, roi.v1 - roi.v0); } } viewport.addEventListener('mousedown', (e) => { if (!e.shiftKey) return; // shift+drag = ROI; plain drag = pan (no-op here) const p = imgPixelFromEvent(e); drawing = { u0: p.u, v0: p.v, u1: p.u, v1: p.v }; e.preventDefault(); }); viewport.addEventListener('mousemove', (e) => { if (!drawing) return; const p = imgPixelFromEvent(e); drawing.u1 = p.u; drawing.v1 = p.v; roi = { u0: Math.min(drawing.u0, drawing.u1), v0: Math.min(drawing.v0, drawing.v1), u1: Math.max(drawing.u0, drawing.u1), v1: Math.max(drawing.v0, drawing.v1), }; drawOverlay(); setRoiInfo(); updateButtonStates(); }); viewport.addEventListener('mouseup', () => { drawing = null; }); viewport.addEventListener('mouseleave', () => { drawing = null; }); })(); // ---------------- Three.js 3D viewer ---------------- const three = (function setupThree() { const canvas = $('wp3dCanvas'); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, canvas.width / canvas.height, 0.01, 20); camera.position.set(0.5, -0.5, -1.2); camera.lookAt(0, 0, 1); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setSize(canvas.width, canvas.height); const controls = new OrbitControls(camera, canvas); scene.add(new THREE.AxesHelper(0.1)); const planeMesh = new THREE.Mesh( new THREE.PlaneGeometry(0.4, 0.4), new THREE.MeshBasicMaterial({ color: 0x9b6dff, transparent: true, opacity: 0.3, side: THREE.DoubleSide }), ); planeMesh.visible = false; scene.add(planeMesh); let scatter = null; function render() { renderer.render(scene, camera); } (function loop() { controls.update(); render(); requestAnimationFrame(loop); })(); function setPlane(plane) { if (!plane) { planeMesh.visible = false; return; } const c = plane.centroid; const n = plane.normal; planeMesh.position.set(c[0], c[1], c[2]); // Align plane to its normal. planeMesh.lookAt(c[0] + n[0], c[1] + n[1], c[2] + n[2]); planeMesh.visible = true; } function setScatter(pointsCv, plane, offsetMm) { if (scatter) { scene.remove(scatter); scatter.geometry.dispose(); scatter.material.dispose(); scatter = null; } if (!pointsCv || pointsCv.length === 0) return; const n = pointsCv.length; const positions = new Float32Array(n * 3); const colors = new Float32Array(n * 3); const nx = plane ? plane.normal[0] : 0; const ny = plane ? plane.normal[1] : 0; const nz = plane ? plane.normal[2] : 0; const d = plane ? plane.offset : 0; const offsetM = (offsetMm || 0) / 1000.0; const rmsM = plane ? (plane.fit_rms_mm / 1000.0) * 2.0 : 0.01; for (let i = 0; i < n; i++) { const x = pointsCv[i][0], y = pointsCv[i][1], z = pointsCv[i][2]; positions[3 * i] = x; positions[3 * i + 1] = y; positions[3 * i + 2] = z; const signed = plane ? (nx * x + ny * y + nz * z + d) : 0; let r = 0.5, g = 0.5, b = 0.5; if (plane) { if (signed > offsetM) { r = 1.0; g = 0.83; b = 0.31; } // yellow: above plane+offset else if (signed < -offsetM) { r = 0.93; g = 0.27; b = 0.27; } // red: below plane-offset else if (Math.abs(signed) <= rmsM) { r = 0.30; g = 0.78; b = 0.40; } // green: inlier else { r = 0.55; g = 0.55; b = 0.55; } // grey: buffer } colors[3 * i] = r; colors[3 * i + 1] = g; colors[3 * i + 2] = b; } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geom.setAttribute('color', new THREE.BufferAttribute(colors, 3)); const mat = new THREE.PointsMaterial({ size: 0.005, vertexColors: true }); scatter = new THREE.Points(geom, mat); scene.add(scatter); } return { setPlane, setScatter }; })(); // ---------------- State + button handlers ---------------- async function fetchSaved() { try { const r = await fetch('/api/work_plane').then((r) => r.json()); savedPlane = r.plane || null; } catch (e) { savedPlane = null; } if (savedPlane && !previewPlane) { three.setPlane(savedPlane); renderDetails(savedPlane); $('wpStateBlock').innerHTML = `piano salvato · RMS ${savedPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${savedPlane.n_inliers} · offset ${savedPlane.offset_mm} mm`; setChip('piano salvato', 'ok'); } else if (!savedPlane && !previewPlane) { $('wpStateBlock').innerHTML = '