Files
Presa-Mano/web/work_plane.js
T
Adriano Dal PastroandClaude Opus 4.7 76e0739620 fix(work_plane): allow negative offset_mm + readable validation errors
- Drop the ge=0 constraint on FitReq.offset_mm (negative values are a
  documented usage: aggressive noise cull above the plane).
- HTML input min lowered to -200 to match.
- Render FastAPI 422 validation arrays as a human-readable string in the
  status chip (was showing "[object Object]").

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

350 lines
12 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() {
// 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.
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 — 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 = '<div class="dim">nessun piano</div>';
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;
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(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 = '<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();