// 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 fitPointsCv = null; // list of [x,y,z] inliers used for the fit (CV frame) 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); 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); } } viewport.addEventListener('click', (e) => { const p = imgPixelFromEvent(e); if (firstCorner === null && roi === null) { // First click: place corner A. Drop any stale fit-inliers from the 3D // viewer so we don't suggest the new ROI is being analyzed yet. fitPointsCv = null; three.setFitInliers(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; fitPointsCv = null; three.setFitInliers(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)); // The fitted plane is implied by the blue inlier cloud; no extra mesh is // drawn (the previous PlaneGeometry was a fixed 0.4x0.4 square that did // not reflect the actual ROI extent and was misleading). let scatter = null; let fitInliersScatter = null; function render() { renderer.render(scene, camera); } (function loop() { controls.update(); render(); requestAnimationFrame(loop); })(); function setPlane(plane) { if (!plane) return; // Move the orbit target onto the plane centroid so rotation feels natural. const c = plane.centroid; 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); } function setFitInliers(pointsCv) { if (fitInliersScatter) { scene.remove(fitInliersScatter); fitInliersScatter.geometry.dispose(); fitInliersScatter.material.dispose(); fitInliersScatter = null; } if (!pointsCv || pointsCv.length === 0) return; const n = pointsCv.length; const positions = new Float32Array(n * 3); for (let i = 0; i < n; i++) { positions[3 * i] = pointsCv[i][0]; positions[3 * i + 1] = pointsCv[i][1]; positions[3 * i + 2] = pointsCv[i][2]; } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const mat = new THREE.PointsMaterial({ color: 0x4da3ff, size: 0.006 }); fitInliersScatter = new THREE.Points(geom, mat); scene.add(fitInliersScatter); } return { setPlane, setScatter, setFitInliers }; })(); // ---------------- 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 = '