feat(work_plane gui): show fit inliers as blue scatter in 3D, drop wrong plane mesh

The 0.4x0.4 PlaneGeometry square was a fixed-size proxy that did not
reflect the actual ROI extent — visually misleading. Replace it with
an explicit blue point cloud of the inliers fed to fit_plane_svd, so
the user can see *exactly* which depth pixels the plane was fit on.

- Backend: fit response now returns fit_points_camera (CV frame) instead
  of the (parallax-shifted) RGB pixel projection.
- Frontend: drop the purple plane mesh; new three.setFitInliers paints
  the inliers in blue. The RGB overlay no longer carries blue dots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal PastroandClaude Opus 4.7 committed 2026-05-13 11:20:03 +02:00
1 parent 4a83305b5e
commit 767d91acdc
3 files changed
+44 -53

No files matched your search

+40 -29
View File
@@ -10,7 +10,7 @@ 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 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) {
@@ -90,12 +90,6 @@ function startRgbLoop() {
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;
@@ -106,15 +100,14 @@ function startRgbLoop() {
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;
// 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');
@@ -143,7 +136,8 @@ function startRgbLoop() {
}
// ROI already set: start a new selection on the next click.
roi = null;
fitPointsPx = null;
fitPointsCv = null;
three.setFitInliers(null);
firstCorner = p;
drawOverlay();
setRoiInfo();
@@ -173,26 +167,19 @@ const three = (function setupThree() {
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);
// 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) { 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;
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();
}
@@ -231,7 +218,29 @@ const three = (function setupThree() {
scene.add(scatter);
}
return { setPlane, setScatter };
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 ----------------
@@ -289,10 +298,10 @@ async function doFit() {
if (r.detail) throw new Error(formatDetail(r.detail));
previewPlane = r.plane;
previewPoints = r.points_camera;
fitPointsPx = r.fit_points_px || null;
fitPointsCv = r.fit_points_camera || null;
three.setPlane(previewPlane);
three.setScatter(previewPoints, previewPlane, previewPlane.offset_mm);
if (window.__wpDrawOverlay) window.__wpDrawOverlay();
three.setFitInliers(fitPointsCv);
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');
@@ -332,8 +341,10 @@ async function doClear() {
savedPlane = null;
previewPlane = null;
previewPoints = null;
fitPointsCv = null;
three.setPlane(null);
three.setScatter(null);
three.setFitInliers(null);
$('wpStateBlock').innerHTML = '<div class="dim">nessun piano</div>';
$('wpDetails').textContent = '—';
setChip('nessun piano');