Files
Presa-Mano/web/work_plane.js
T
Adriano Dal PastroandClaude Opus 4.7 652fa4b912 fix(gui): two-click ROI on Calibrazione Piano (was shift+drag)
User asked for "2 punti" — replace the drag-to-rectangle interaction with
two discrete clicks: first click places corner A (cross marker), second
click defines the rectangle, third click starts a new selection.
Rectangles smaller than 10×10 px are rejected. Status chip guides the
flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:38:43 +02:00

316 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = "— · 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() {
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);
// 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.
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;
firstCorner = p;
drawOverlay();
setRoiInfo();
updateButtonStates();
setChip('punto 1/2 piazzato — clicca il secondo angolo');
});
})();
// ---------------- 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 = '<div class="dim">nessun piano</div>';
setChip('nessun piano');
}
updateButtonStates();
}
function renderDetails(plane) {
$('wpDetails').textContent = JSON.stringify(plane, null, 2);
}
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(r.detail);
previewPlane = r.plane;
previewPoints = r.points_camera;
three.setPlane(previewPlane);
three.setScatter(previewPoints, previewPlane, previewPlane.offset_mm);
renderDetails(previewPlane);
$('wpStateBlock').innerHTML = `<strong>preview</strong> · RMS ${previewPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${previewPlane.n_inliers} · offset ${previewPlane.offset_mm} mm · <span class="warn">non salvato</span>`;
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(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 = '<div class="dim">nessun piano</div>';
$('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 = `<strong>preview</strong> · RMS ${previewPlane.fit_rms_mm.toFixed(1)} mm · n_inliers ${previewPlane.n_inliers} · offset ${previewPlane.offset_mm.toFixed(1)} mm · <span class="warn">non salvato</span>`;
}
});
$('wpFitBtn').addEventListener('click', doFit);
$('wpSaveBtn').addEventListener('click', doSave);
$('wpClearBtn').addEventListener('click', doClear);
// Initial load.
startRgbLoop();
fetchSaved();