diff --git a/src/backend/api/routers/vision.py b/src/backend/api/routers/vision.py index 9dcfab3..9b4de33 100644 --- a/src/backend/api/routers/vision.py +++ b/src/backend/api/routers/vision.py @@ -366,6 +366,20 @@ async def preview( return await vision_service.preview(db, task, await _read_image(image)) +@router.post("/dxf-preview") +async def dxf_preview( + dxf: UploadFile = File(...), + current_user: User = Depends(require_maker), +) -> dict: + """#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni + in mm e dimensioni), letto dal worker con lo stesso lettore del confronto. + Non scrive nulla; stesso limite di dimensione della rotta dxf-compare.""" + dxf_bytes = await dxf.read() + if not validate_file_size(len(dxf_bytes)): + raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"]) + return await vision_service.dxf_preview(dxf_bytes) + + @router.post("/tasks/{task_id}/recipe/dxf-compare") async def create_dxf_compare_recipe( task_id: int, diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 5f7f332..8b05f53 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -224,6 +224,25 @@ async def dxf_compare_recipe( return body +async def dxf_preview(dxf_bytes: bytes, worker_url: str | None = None) -> dict: + """#101: anteprima del DXF (contorni in mm + dimensioni) dal worker. Sostituita dai test.""" + body = await call_worker_multipart( + "/dxf/preview", + files={"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream")}, + data={}, + worker_url=worker_url, + ) + # forma minima del contratto: senza contorni e dimensioni non c'e' nulla da mostrare + required = {"contorni", "bbox", "larghezza_mm", "altezza_mm", "entita", "punti"} + if not required <= body.keys(): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="the vision worker's answer was malformed: missing " + + ", ".join(sorted(required - body.keys())), + ) + return body + + # Stati di fetch_station_calibration (#83) CAL_OK = "ok" CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source diff --git a/src/backend/tests/test_vision_dxf_preview.py b/src/backend/tests/test_vision_dxf_preview.py new file mode 100644 index 0000000..9a6a408 --- /dev/null +++ b/src/backend/tests/test_vision_dxf_preview.py @@ -0,0 +1,90 @@ +"""#101: POST /api/vision/dxf-preview, anteprima del DXF nel wizard. + +Il worker non gira nei test: `vision_service.dxf_preview` si sostituisce. +""" +import pytest +from fastapi import HTTPException + +from src.backend.config import settings +from src.backend.services import vision_service +from src.backend.tests.conftest import auth_headers + +BODY = { + "contorni": [[[0.0, 0.0], [10.0, 0.0], [10.0, 5.0], [0.0, 5.0], [0.0, 0.0]]], + "bbox": {"xmin": 0.0, "ymin": 0.0, "xmax": 10.0, "ymax": 5.0}, + "larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 5, +} + + +def _post(client, user, content=b"0\nEOF"): + return client.post( + "/api/vision/dxf-preview", + headers=auth_headers(user), + files={"dxf": ("d.dxf", content, "application/octet-stream")}, + ) + + +@pytest.mark.asyncio +async def test_dxf_preview_forwards_the_file_and_returns_the_worker_body( + client, maker_user, monkeypatch, +): + calls = [] + + async def fake(dxf_bytes, worker_url=None): + calls.append(dxf_bytes) + return BODY + + monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake) + resp = await _post(client, maker_user, b"DXF-BYTES") + assert resp.status_code == 200, resp.text + assert resp.json() == BODY + assert calls == [b"DXF-BYTES"] + + +@pytest.mark.asyncio +async def test_dxf_preview_forwards_the_worker_422(client, maker_user, monkeypatch): + async def fake(dxf_bytes, worker_url=None): + raise HTTPException(status_code=422, detail="DXF illeggibile: file vuoto") + + monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake) + resp = await _post(client, maker_user, b"") + assert resp.status_code == 422 + assert resp.json()["detail"] == "DXF illeggibile: file vuoto" + + +@pytest.mark.asyncio +async def test_dxf_preview_requires_maker(client, measurement_tec_user, monkeypatch): + async def fake(dxf_bytes, worker_url=None): + return BODY + + monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake) + resp = await _post(client, measurement_tec_user) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_dxf_preview_too_big_is_422(client, maker_user, monkeypatch): + monkeypatch.setattr(settings, "max_upload_size_mb", 0) + resp = await _post(client, maker_user, b"x" * 10) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_dxf_preview_service_posts_to_worker_and_checks_shape(monkeypatch): + seen = {} + + async def fake_multipart(path, *, files, data, worker_url=None, timeout=120.0): + seen.update(path=path, files=files) + return BODY + + monkeypatch.setattr(vision_service, "call_worker_multipart", fake_multipart) + assert await vision_service.dxf_preview(b"abc") == BODY + assert seen["path"] == "/dxf/preview" and seen["files"]["dxf"][1] == b"abc" + + async def malformed(path, *, files, data, worker_url=None, timeout=120.0): + return {"contorni": []} + + monkeypatch.setattr(vision_service, "call_worker_multipart", malformed) + with pytest.raises(HTTPException) as exc: + await vision_service.dxf_preview(b"abc") + assert exc.value.status_code == 502 diff --git a/src/vision_worker/main.py b/src/vision_worker/main.py index ef805ab..3e5d9eb 100644 --- a/src/vision_worker/main.py +++ b/src/vision_worker/main.py @@ -11,6 +11,7 @@ import io import json import os import re +import tempfile # #101 from pathlib import Path import numpy as np @@ -288,3 +289,89 @@ async def recipes_dxf_compare( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"recipe build failed: {type(exc).__name__}: {exc}", ) from exc + + +# #101: tetto ai punti dell'anteprima DXF (un profilo fitto non deve gonfiare +# la risposta ne' rallentare il disegno SVG nel browser). +_PREVIEW_MAX_POINTS = 20000 + + +def _decimate(points: list[list[float]], keep: int) -> list[list[float]]: + """Decimazione uniforme a `keep` punti (>= 2), primo e ultimo sempre inclusi.""" + n = len(points) + if n <= keep or keep < 2: + return points + idx = sorted({round(i * (n - 1) / (keep - 1)) for i in range(keep)}) + return [points[i] for i in idx] + + +def _dxf_preview(dxf_bytes: bytes) -> dict: + """#101: legge il DXF con lo stesso lettore di CompareDxf (read_xld_dxf) e ne + ritorna i contorni in mm, asse y verso il basso (come immagine e SVG). + + Funzione sincrona e pura: la rotta la esegue in un thread. Solleva + CostruttoreError per DXF vuoto, illeggibile o senza geometria. + """ + if not dxf_bytes: + raise CostruttoreError("DXF illeggibile: file vuoto") + from visionsuite.io.dxf import read_xld_dxf + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "disegno.dxf" + path.write_bytes(dxf_bytes) + try: + # flip_y=False + scale=1: coordinate native del DXF, in mm; il + # ribaltamento dell'asse y lo facciamo noi sotto (y = -y_dxf) + conts = list(read_xld_dxf(str(path), sampling_distance=0.05, + scale=1.0, flip_y=False)) + except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile + raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc + + contorni: list[list[list[float]]] = [] + for cont in conts: + pts = np.asarray(cont.points, dtype=float) # (riga, colonna) = (y_dxf, x_dxf) + if pts.ndim != 2 or pts.shape[0] < 2: + continue + line = [[float(c), float(-r)] for r, c in pts] + if cont.closed and line[0] != line[-1]: + line.append(list(line[0])) # chiude il giro anche nella polilinea SVG + contorni.append(line) + if not contorni: + raise CostruttoreError("DXF senza geometria") + + # #101: le dimensioni si misurano sui punti completi, prima della decimazione + xs = [p[0] for c in contorni for p in c] + ys = [p[1] for c in contorni for p in c] + xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys) + + total = len(xs) + if total > _PREVIEW_MAX_POINTS: + # quota per contorno proporzionale, minimo 2 punti + contorni = [ + _decimate(c, max(2, int(len(c) * _PREVIEW_MAX_POINTS / total))) for c in contorni + ] + contorni = [[[round(x, 3), round(y, 3)] for x, y in c] for c in contorni] + xmin, xmax, ymin, ymax = (round(v, 3) for v in (xmin, xmax, ymin, ymax)) + return { + "contorni": contorni, + "bbox": {"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax}, + "larghezza_mm": round(xmax - xmin, 3), + "altezza_mm": round(ymax - ymin, 3), + "entita": len(contorni), + "punti": sum(len(c) for c in contorni), + } + + +@app.post("/dxf/preview") +async def dxf_preview(dxf: UploadFile = File(...)) -> dict: + """#101: anteprima del disegno DXF (contorni + dimensioni in mm) per il + wizard «Crea ricetta da DXF». Solo lettura, leggera: nessun run slot.""" + dxf_bytes = await dxf.read() + try: + return await asyncio.to_thread(_dxf_preview, dxf_bytes) + except CostruttoreError as exc: + raise _unprocessable(str(exc)) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"dxf preview failed: {type(exc).__name__}: {exc}", + ) from exc diff --git a/src/vision_worker/tests/test_dxf_compare.py b/src/vision_worker/tests/test_dxf_compare.py index 877311c..429cc76 100644 --- a/src/vision_worker/tests/test_dxf_compare.py +++ b/src/vision_worker/tests/test_dxf_compare.py @@ -80,3 +80,67 @@ async def test_dxf_compare_immagine_non_decodificabile_e_422(): async def test_dxf_compare_parametro_fuori_range_e_422(): response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio") assert response.status_code == 422 + + +# --- #101: POST /dxf/preview --------------------------------------------------- + +def _rect_dxf(width: float, height: float, y0: float = 3.0) -> bytes: + """DXF minimo con ezdxf: un rettangolo chiuso, y in alto rispetto a 0.""" + import ezdxf + doc = ezdxf.new() + doc.modelspace().add_lwpolyline( + [(1, y0), (1 + width, y0), (1 + width, y0 + height), (1, y0 + height)], close=True, + ) + buffer = io.StringIO() + doc.write(buffer) + return buffer.getvalue().encode("utf-8") + + +def _post_preview(dxf: bytes): + # TestClient sincrono: non serve pytest-asyncio (assente nel venv del worker) + from fastapi.testclient import TestClient + return TestClient(app).post( + "/dxf/preview", files={"dxf": ("d.dxf", dxf, "application/octet-stream")}, + ) + + +def test_dxf_preview_rettangolo_10x5(): + resp = _post_preview(_rect_dxf(10, 5)) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["larghezza_mm"] == 10 and body["altezza_mm"] == 5 + assert body["entita"] == 1 and body["punti"] == sum(len(c) for c in body["contorni"]) + # y ribaltata: il rettangolo stava a y 3..8 nel DXF, ora a -8..-3 (asse verso il basso) + assert body["bbox"] == {"xmin": 1, "ymin": -8, "xmax": 11, "ymax": -3} + ring = body["contorni"][0] + assert ring[0] == ring[-1] # contorno chiuso: primo == ultimo + assert all(-8 <= y <= -3 for _, y in ring) + + +@pytest.mark.parametrize("content", [b"", b"questo non e' un dxf"]) +def test_dxf_preview_illeggibile_e_422(content): + resp = _post_preview(content) + assert resp.status_code == 422 + assert resp.json()["detail"].startswith("DXF") + + +def test_dxf_preview_senza_geometria_e_422(): + import ezdxf + doc = ezdxf.new() + buffer = io.StringIO() + doc.write(buffer) + resp = _post_preview(buffer.getvalue().encode("utf-8")) + assert resp.status_code == 422 + assert "senza geometria" in resp.json()["detail"] + + +def test_dxf_preview_decima_oltre_il_tetto(monkeypatch): + from src.vision_worker import main + monkeypatch.setattr(main, "_PREVIEW_MAX_POINTS", 50) + import ezdxf + doc = ezdxf.new() + doc.modelspace().add_circle((0, 0), 20) + buffer = io.StringIO() + doc.write(buffer) + full = main._dxf_preview(buffer.getvalue().encode("utf-8")) + assert full["punti"] <= 50 + 1 and full["larghezza_mm"] == pytest.approx(40, abs=0.01) # dimensioni dai punti completi