// 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 fitPointsPx = null; // list of [u,v] pixels of inliers used for the fit 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 = "— · clicca 2 angoli sull'RGB"; 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() { // Setting width/height on a canvas wipes its content. Only re-set when // the image resolution actually changes (rare) so the ROI overlay survives // each RGB live-refresh tick. const w = img.naturalWidth || 640; const h = img.naturalHeight || 480; if (overlay.width !== w) overlay.width = w; if (overlay.height !== h) overlay.height = h; overlay.style.width = img.clientWidth + 'px'; overlay.style.height = img.clientHeight + 'px'; } img.addEventListener('load', () => { syncOverlaySize(); drawOverlay(); }); // Two-click ROI: 1st click = corner A, 2nd click = corner B (rectangle // defined). 3rd click starts a new selection. let firstCorner = null; function drawCross(ctx, u, v) { ctx.strokeStyle = '#9b6dff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(u - 8, v); ctx.lineTo(u + 8, v); ctx.moveTo(u, v - 8); ctx.lineTo(u, v + 8); ctx.stroke(); } function drawOverlay() { const ctx = overlay.getContext('2d'); ctx.clearRect(0, 0, overlay.width, overlay.height); // Inliers used for the last fit, drawn first so the ROI rectangle stays // on top. if (fitPointsPx && fitPointsPx.length) { ctx.fillStyle = '#4da3ff'; for (const [u, v] of fitPointsPx) ctx.fillRect(u, v, 2, 2); } if (roi) { ctx.strokeStyle = '#9b6dff'; ctx.lineWidth = 2; ctx.strokeRect(roi.u0, roi.v0, roi.u1 - roi.u0, roi.v1 - roi.v0); drawCross(ctx, roi.u0, roi.v0); drawCross(ctx, roi.u1, roi.v1); } else if (firstCorner) { drawCross(ctx, firstCorner.u, firstCorner.v); } } // Re-export so doFit can refresh the overlay after a successful fit. window.__wpDrawOverlay = drawOverlay; viewport.addEventListener('click', (e) => { const p = imgPixelFromEvent(e); if (firstCorner === null && roi === null) { // First click: place corner A. Drop any stale fit-points overlay so we // don't suggest the new ROI is being analyzed yet. fitPointsPx = null; firstCorner = p; drawOverlay(); setChip('punto 1/2 piazzato — clicca il secondo angolo'); return; } if (firstCorner !== null) { // Second click: complete the rectangle. roi = { u0: Math.min(firstCorner.u, p.u), v0: Math.min(firstCorner.v, p.v), u1: Math.max(firstCorner.u, p.u), v1: Math.max(firstCorner.v, p.v), }; firstCorner = null; // Reject degenerate rectangles (<10 px on either side). if (roi.u1 - roi.u0 < 10 || roi.v1 - roi.v0 < 10) { roi = null; setChip('rettangolo troppo piccolo, riprova', 'err'); } else { setChip('ROI pronto — clicca "Calcola piano"', 'ok'); } drawOverlay(); setRoiInfo(); updateButtonStates(); return; } // ROI already set: start a new selection on the next click. roi = null; fitPointsPx = null; firstCorner = p; drawOverlay(); setRoiInfo(); updateButtonStates(); setChip('punto 1/2 piazzato — clicca il secondo angolo'); }); })(); // ---------------- Three.js 3D viewer — camera-POV ---------------- // // CV camera frame: X right, Y down, Z forward. // We render the scene so the user sees it from the camera's vantage point: // camera at the origin, looking at +Z, with screen-up = CV -Y. OrbitControls // can rotate around the scene centroid once it's defined; before any fit the // view stays at camera-POV. 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-POV: stand just behind the camera origin so OrbitControls has a // non-zero distance to its target. Screen-up = CV -Y. camera.up.set(0, -1, 0); camera.position.set(0, 0, -0.001); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setSize(canvas.width, canvas.height); const controls = new OrbitControls(camera, canvas); controls.target.set(0, 0, 1); controls.update(); 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; // Move the orbit target onto the plane centroid so rotation feels natural. controls.target.set(c[0], c[1], c[2]); controls.update(); } 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; // Two-tone colouring: filter-excluded points (signed < -offset_m) red, // everything else cyan-white. This matches the backend filter contract // (keep == signed >= -offset_m). 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, g, b; if (plane && signed < -offsetM) { r = 0.93; g = 0.27; b = 0.27; // red: excluded by below-plane filter } else { r = 0.75; g = 0.92; b = 1.00; // cyan-white: kept } 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 = '
nessun piano
'; setChip('nessun piano'); } updateButtonStates(); } function renderDetails(plane) { $('wpDetails').textContent = JSON.stringify(plane, null, 2); } // FastAPI 422 returns `detail` as an array of validation errors: // [{loc, msg, type, ...}, ...] // Plain HTTPException returns it as a string. Render either case usefully so // the status chip never says "[object Object]". function formatDetail(detail) { if (typeof detail === 'string') return detail; if (Array.isArray(detail)) { return detail.map(e => { const loc = Array.isArray(e.loc) ? e.loc.join('.') : e.loc; return loc ? `${loc}: ${e.msg}` : (e.msg || JSON.stringify(e)); }).join('; '); } return JSON.stringify(detail); } async function doFit() { if (!roi) return; setChip('fit…'); $('wpFitBtn').disabled = true; try { const body = { u_min: roi.u0, v_min: roi.v0, u_max: roi.u1, v_max: roi.v1, offset_mm: parseFloat($('wpOffset').value || '5'), }; const r = await fetch('/api/work_plane/fit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then((r) => r.json()); if (r.detail) throw new Error(formatDetail(r.detail)); previewPlane = r.plane; previewPoints = r.points_camera; fitPointsPx = r.fit_points_px || null; three.setPlane(previewPlane); three.setScatter(previewPoints, previewPlane, previewPlane.offset_mm); if (window.__wpDrawOverlay) window.__wpDrawOverlay(); renderDetails(previewPlane); $('wpStateBlock').innerHTML = `preview · RMS ${previewPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${previewPlane.n_inliers} · offset ${previewPlane.offset_mm} mm · non salvato`; setChip('preview pronto', 'ok'); } catch (e) { setChip('fit failed: ' + e.message, 'err'); } finally { updateButtonStates(); } } async function doSave() { if (!previewPlane) return; setChip('save…'); try { const r = await fetch('/api/work_plane/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plane: previewPlane }), }).then((r) => r.json()); if (r.detail) throw new Error(formatDetail(r.detail)); savedPlane = r.plane; previewPlane = null; $('wpStateBlock').innerHTML = `piano salvato · RMS ${savedPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${savedPlane.n_inliers} · offset ${savedPlane.offset_mm} mm`; renderDetails(savedPlane); setChip('piano salvato', 'ok'); } catch (e) { setChip('save failed: ' + e.message, 'err'); } finally { updateButtonStates(); } } async function doClear() { if (!confirm('Rimuovere il piano salvato?')) return; setChip('clear…'); try { await fetch('/api/work_plane', { method: 'DELETE' }).then((r) => r.json()); savedPlane = null; previewPlane = null; previewPoints = null; three.setPlane(null); three.setScatter(null); $('wpStateBlock').innerHTML = '
nessun piano
'; $('wpDetails').textContent = '—'; setChip('nessun piano'); } catch (e) { setChip('clear failed: ' + e.message, 'err'); } finally { updateButtonStates(); } } // ---------------- Offset slider: re-color in real time ---------------- $('wpOffset').addEventListener('input', () => { if (previewPlane && previewPoints) { const off = parseFloat($('wpOffset').value || '0'); previewPlane.offset_mm = off; three.setScatter(previewPoints, previewPlane, off); $('wpStateBlock').innerHTML = `preview · RMS ${previewPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${previewPlane.n_inliers} · offset ${previewPlane.offset_mm.toFixed(1)} mm · non salvato`; } }); $('wpFitBtn').addEventListener('click', doFit); $('wpSaveBtn').addEventListener('click', doSave); $('wpClearBtn').addEventListener('click', doClear); // Initial load. startRgbLoop(); fetchSaved();