Compare commits

...

14 Commits

Author SHA1 Message Date
Adriano 08616aa1fa maker: pan dell'immagine di prova non si interrompe col mouse (Refs #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:46:57 +01:00
Adriano 02f1481fe5 maker: zoom in sovrimpressione, pan col puntatore e viewport che segue l'immagine (Refs #104)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:42:42 +01:00
Adriano 34e1b042f5 maker: risultati della Prova a destra dell'immagine da 640px (Refs #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:36:44 +01:00
Adriano bdef1f1cd8 maker: anteprima DXF sempre a destra, avviso immagine tolto dopo il salvataggio (Refs #104 #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:35:11 +01:00
Adriano 39b9ad77eb vision: condizione della quota su due righe leggibili (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:16 +01:00
Adriano 12632323e8 merge #105 105b: wizard Modifica ricetta da DXF (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:10 +01:00
Adriano 839a61e479 merge #105 105a: ricetta DXF riapribile, backend (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:10 +01:00
Adriano 9b8e729927 maker: Modifica ricetta da DXF, il wizard si riapre compilato (Refs #105)
Due proxy Flask (/source e /files, byte) e wizard che all'init legge la ricetta
esistente: etichetta del bottone dinamica, apertura con banda, soglia, polarita',
mm/px, DXF e immagine di riferimento; risposte tardive non sovrascrivono le scelte manuali.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:27:20 +01:00
Adriano 6080e6aebc vision: ricetta DXF riapribile, immagine salvata, rotte source e files, soglia segue la quota (Refs #105)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:25:37 +01:00
Adriano d5db5f3014 merge #102 #104: anteprima immagine, disposizione del wizard DXF, prova su due colonne con zoom (Refs #102 #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:21:43 +01:00
Adriano 1a19bb6679 maker: wizard DXF con anteprime sotto le scelte, mm/px solo da file, risultato su due colonne con zoom e Fit (Refs #104)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:21:19 +01:00
Adriano 20f33234a8 maker: anteprima immagine nel wizard Crea ricetta da DXF (Refs #102)
Anteprima dell'immagine scelta/acquisita accanto a quella del DXF, con
pixel e campo in mm (scala a mano o calibrazione stazione); fallback se
il formato non e' mostrabile. Test JS e di pagina, voci it/en.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:14:10 +01:00
Adriano 711abc96df merge #103: DXF a entità aperte unito in contorni chiusi (Refs #103)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:11:53 +01:00
Adriano 77419f9ebd vision: il DXF di confronto unisce le entita' aperte in contorni chiusi (Refs #103)
chiudi_contorni unisce LINE/ARC aperti in LWPOLYLINE chiuse prima di
costruire la ricetta e per l'anteprima: con banda larga cut_open_ends
non lasciava piu' punti in banda.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:11:18 +01:00
13 changed files with 1390 additions and 55 deletions
+43
View File
@@ -8,6 +8,7 @@ import base64
import binascii
import io
import json
import mimetypes
from typing import Literal
from fastapi.responses import FileResponse
@@ -447,8 +448,50 @@ async def create_dxf_compare_recipe(
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
if errors:
raise _422(errors)
# #105: l'immagine di riferimento resta nella cartella (non e' in meta, quindi
# /validate e l'esecuzione la ignorano) e il wizard puo' riaprirla; entra nel digest.
placed[f"riferimento.{_IMAGE_EXTENSIONS.get(image.content_type, 'png')}"] = image_bytes
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
await vision_service.ensure_points_quote(db, task, min_points_pct)
await db.commit()
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
return response
@router.get("/tasks/{task_id}/recipe/dxf-compare/source")
async def get_dxf_compare_source(
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
"""#105: i parametri della ricetta fatta con «Crea ricetta da DXF», per modificarla.
404 se il task non ha una ricetta del wizard (nessuna, o caricata a mano).
"""
task = await _task_or_404(db, task_id)
source = vision_service.dxf_compare_source(task.vision_json, task.vision_recipe_dir)
if source is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="nessuna ricetta da DXF su questo task",
)
return source
@router.get("/tasks/{task_id}/recipe/files/{path:path}")
async def get_recipe_file(
task_id: int,
path: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> FileResponse:
"""#105: un file della cartella ricetta del task (DXF, immagine di riferimento).
Percorso non sicuro, file assente o task senza ricetta: sempre 404.
"""
task = await _task_or_404(db, task_id)
target = vision_service.recipe_file_path(task.vision_recipe_dir, path)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
return FileResponse(target, media_type=media_type)
+98
View File
@@ -485,6 +485,84 @@ def list_recipe_files(recipe_dir: str | None) -> list[str]:
)
# #105: nomi e id della ricetta costruita da «Crea ricetta da DXF» (src/vision/costruttori.py).
_WIZARD_DXF_FILE = "disegno.dxf"
_WIZARD_CALIBRATION_FILE = "calibrazione.json"
_WIZARD_REFERENCE_STEM = "riferimento"
def _is_real_number(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def dxf_compare_source(graph: dict | None, recipe_dir: str | None) -> dict | None:
"""#105: i parametri con cui il wizard ha costruito la ricetta, per riaprirlo.
Legge i valori dal grafo (tool `pezzo` LocateBlob, tool `dxf` CompareDxf) e i
file dalla cartella ricetta. None se il grafo non e' uno del wizard (il task
non ha ricetta, o l'ha caricata a mano): la rotta risponde 404. Pura: niente DB.
"""
if not isinstance(graph, dict) or not isinstance(graph.get("tools"), list):
return None
tools = {t.get("id"): t for t in graph["tools"] if isinstance(t, dict)}
pezzo, confronto = tools.get("pezzo"), tools.get("dxf")
if not pezzo or pezzo.get("kind") != "LocateBlob":
return None
if not confronto or confronto.get("kind") != "CompareDxf":
return None
p_values = pezzo.get("values") if isinstance(pezzo.get("values"), dict) else {}
c_values = confronto.get("values") if isinstance(confronto.get("values"), dict) else {}
band, min_pct = c_values.get("band_plus"), c_values.get("points_in_pct_nominal")
if not (_is_real_number(band) and _is_real_number(min_pct)):
return None
polarity = p_values.get("polarity")
if polarity not in ("dark", "light"):
polarity = "dark" # default di LocateBlob e della rotta di creazione
folder = None
if recipe_dir:
try:
folder = _safe_join(settings.upload_path, recipe_dir)
except (ValueError, OSError):
folder = None
if folder is not None and not folder.is_dir():
folder = None
px_per_mm = None
dxf_name = None
image_name = None
if folder is not None:
try:
cal = json.loads((folder / _WIZARD_CALIBRATION_FILE).read_text(encoding="utf-8"))
value = cal.get("px_per_mm") if isinstance(cal, dict) else None
if _is_real_number(value) and value > 0:
px_per_mm = float(value)
except (OSError, ValueError):
px_per_mm = None # manca o illeggibile: il wizard chiedera' la scala
if (folder / _WIZARD_DXF_FILE).is_file():
dxf_name = _WIZARD_DXF_FILE
for candidate in sorted(folder.glob(f"{_WIZARD_REFERENCE_STEM}.*")):
if candidate.is_file():
image_name = candidate.name
break
return {
"band_mm": float(band), "min_points_pct": float(min_pct), "polarity": polarity,
"px_per_mm": px_per_mm, "dxf": dxf_name, "image": image_name,
}
def recipe_file_path(recipe_dir: str | None, relative: str) -> Path | None:
"""#105: percorso di un file della cartella ricetta, o None se non sicuro o assente."""
if not recipe_dir:
return None
try:
folder = _safe_join(settings.upload_path, recipe_dir)
target = _safe_join(folder, relative)
except (ValueError, OSError):
return None
return target if target.is_file() else None
def save_result_files(
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
) -> tuple[str, str]:
@@ -880,9 +958,29 @@ async def ensure_points_quote(
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
#105: unica eccezione, la quota nata da qui (`dxf.points_in_pct`, «Punti in
tolleranza») senza misure segue la soglia nuova: ltl = min_points_pct, e
ritorna True anche in quel caso.
"""
from src.backend.services import recipe_service # import locale: evita cicli
# #105: ricetta ripresa e salvata con un'altra soglia -> la quota la segue
own = (await db.execute(
select(RecipeSubtask)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output == "dxf.points_in_pct")
.where(RecipeSubtask.description == "Punti in tolleranza")
.limit(1)
)).scalar_one_or_none()
if own is not None:
# soglia invariata o versione con misure: la quota resta com'e'
same = own.ltl is not None and float(own.ltl) == float(min_points_pct)
if same or await recipe_service.version_has_measurements(db, task.version_id):
return False
own.ltl = min_points_pct
await db.flush()
return True
existing = (await db.execute(
select(RecipeSubtask.id)
.where(RecipeSubtask.task_id == task.id)
+226
View File
@@ -0,0 +1,226 @@
"""#105: la ricetta «Crea ricetta da DXF» si puo' riaprire e modificare.
Il worker non gira nei test: si finge come in test_vision_dxf_compare.
"""
import base64
import io
import json
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.services import vision_service
from src.backend.tests.conftest import auth_headers
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture)
redirect_uploads, validate_ok,
)
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
DXF = b"0\nEOF"
def _wizard_graph(band=0.3, pct=90.0, polarity="light") -> dict:
"""Grafo come lo costruisce src/vision/costruttori.py::_graph."""
return {
"schema_version": 2, "name": "Confronto DXF",
"meta": {"dxf": {"disegno": {"path": "disegno.dxf", "sha256": "x"}},
"calibration": {"path": "calibrazione.json", "sha256": "y"}},
"tools": [
{"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob",
"values": {"polarity": polarity}, "links": {}},
{"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf",
"values": {"band_plus": band, "band_minus": band, "points_in_pct_nominal": pct},
"links": {}},
],
}
@pytest.fixture
def w2(monkeypatch):
async def fake(image_bytes, dxf_bytes, **kwargs):
graph = _wizard_graph(kwargs["band_mm"], kwargs["min_points_pct"], kwargs["polarity"])
return {
"graph": graph,
"files": {
"disegno.dxf": base64.b64encode(dxf_bytes).decode(),
"calibrazione.json": base64.b64encode(
json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()).decode(),
},
"outputs": {"dxf.points_in_pct": 99.0}, "overlay": [], "outcome": "pass",
}
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
def _bmp() -> bytes:
buf = io.BytesIO()
Image.new("L", (32, 32)).save(buf, format="BMP")
return buf.getvalue()
def _save(client, user, task, *, image=None, ctype="image/png", **data):
return client.post(
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
headers=auth_headers(user),
files={"image": ("p", image if image is not None else an_image(), ctype),
"dxf": ("d.dxf", DXF, "application/octet-stream")},
data={"save": "true", "px_per_mm": "10", **{k: str(v) for k, v in data.items()}},
)
@pytest.mark.asyncio
async def test_save_keeps_the_reference_image_png(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
image = an_image()
resp = await _save(client, maker_user, task, image=image)
assert resp.status_code == 200, resp.text
folder = settings.upload_path / resp.json()["recipe_dir"]
assert (folder / "riferimento.png").read_bytes() == image
assert (folder / "disegno.dxf").read_bytes() == DXF # il resto della cartella non cambia
@pytest.mark.asyncio
async def test_save_keeps_the_reference_image_bmp(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
image = _bmp()
resp = await _save(client, maker_user, task, image=image, ctype="image/bmp")
assert resp.status_code == 200, resp.text
folder = settings.upload_path / resp.json()["recipe_dir"]
assert (folder / "riferimento.bmp").read_bytes() == image
@pytest.mark.asyncio
async def test_source_returns_what_the_wizard_saved(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
await _save(client, maker_user, task, band_mm=0.3, min_points_pct=90, polarity="light")
resp = await client.get(
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source", headers=auth_headers(maker_user),
)
assert resp.status_code == 200, resp.text
assert resp.json() == {
"band_mm": 0.3, "min_points_pct": 90.0, "polarity": "light", "px_per_mm": 10.0,
"dxf": "disegno.dxf", "image": "riferimento.png",
}
@pytest.mark.asyncio
async def test_source_404_without_recipe_or_with_a_hand_made_graph(client, db_session, maker_user):
task, _ = await make_task(db_session, maker_user)
url = f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source"
assert (await client.get(url, headers=auth_headers(maker_user))).status_code == 404
task.vision_json = {"schema_version": 2, "name": "a mano", "tools": [], "meta": {}}
await db_session.commit()
resp = await client.get(url, headers=auth_headers(maker_user))
assert resp.status_code == 404
assert resp.json()["detail"] == "nessuna ricetta da DXF su questo task"
@pytest.mark.asyncio
async def test_source_needs_maker(client, db_session, maker_user, measurement_tec_user):
task, _ = await make_task(db_session, maker_user)
resp = await client.get(
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 403
def test_source_of_an_old_recipe_has_no_image_and_no_unreadable_scale(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
folder = tmp_path / "vision" / "recipes" / "1" / "abc"
folder.mkdir(parents=True)
(folder / "disegno.dxf").write_bytes(DXF)
(folder / "calibrazione.json").write_text("{non json", encoding="utf-8")
source = vision_service.dxf_compare_source(_wizard_graph(), "vision/recipes/1/abc")
assert source["image"] is None and source["px_per_mm"] is None
assert source["dxf"] == "disegno.dxf"
assert vision_service.dxf_compare_source(None, None) is None
# senza cartella i valori del grafo restano
assert vision_service.dxf_compare_source(_wizard_graph(), None)["dxf"] is None
@pytest.mark.asyncio
async def test_files_returns_the_bytes(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
image = _bmp()
await _save(client, maker_user, task, image=image, ctype="image/bmp")
img = await client.get(
f"/api/vision/tasks/{task.id}/recipe/files/riferimento.bmp", headers=auth_headers(maker_user),
)
dxf = await client.get(
f"/api/vision/tasks/{task.id}/recipe/files/disegno.dxf", headers=auth_headers(maker_user),
)
assert img.status_code == 200 and img.content == image
assert img.headers["content-type"].startswith("image/")
assert dxf.status_code == 200 and dxf.content == DXF
assert dxf.headers["content-type"] # dal tipo dell'estensione, di ripiego octet-stream
@pytest.mark.asyncio
async def test_files_404_on_unsafe_missing_or_no_recipe(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
base = f"/api/vision/tasks/{task.id}/recipe/files/"
headers = auth_headers(maker_user)
assert (await client.get(base + "disegno.dxf", headers=headers)).status_code == 404 # nessuna ricetta
await _save(client, maker_user, task)
assert (await client.get(base + "manca.bmp", headers=headers)).status_code == 404
for unsafe in ("../x", "%2e%2e/x", "..%2f..%2fx", "sub/../../x"):
assert (await client.get(base + unsafe, headers=headers)).status_code == 404, unsafe
# la funzione non lascia uscire dalla cartella nemmeno con un assoluto
assert vision_service.recipe_file_path("vision/recipes/1/abc", "../x") is None
assert vision_service.recipe_file_path("vision/recipes/1/abc", "/etc/passwd") is None
assert vision_service.recipe_file_path("../fuori", "x") is None
@pytest.mark.asyncio
async def test_resave_follows_the_new_threshold(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
task_id = task.id
await _save(client, maker_user, task, min_points_pct=95)
resp = await _save(client, maker_user, task, min_points_pct=80)
assert resp.status_code == 200, resp.text
db_session.expire_all()
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
.where(RecipeSubtask.task_id == task_id))).scalars().all()
assert len(quotes) == 1 and float(quotes[0].ltl) == 80.0
@pytest.mark.asyncio
async def test_ensure_points_quote_leaves_quote_with_measurements(
client, db_session, maker_user, w2, validate_ok, monkeypatch,
):
task, _ = await make_task(db_session, maker_user)
task_id = task.id
await _save(client, maker_user, task, min_points_pct=95)
async def has_measurements(db, version_id):
return True
monkeypatch.setattr("src.backend.services.recipe_service.version_has_measurements", has_measurements)
task = await db_session.get(RecipeTask, task_id)
changed = await vision_service.ensure_points_quote(db_session, task, 80)
assert changed is False
quote = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
.where(RecipeSubtask.task_id == task_id))).scalar_one()
assert float(quote.ltl) == 95.0
@@ -5,6 +5,7 @@ import math
import requests as http_requests
from functools import wraps
from urllib.parse import quote # #105: percorso del file ricetta nell'URL del backend
from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
@@ -701,6 +702,46 @@ def api_dxf_compare_recipe(task_id: int):
return jsonify(resp), 200
# #105: parametri della ricetta DXF gia' salvata sul task, per riaprire il wizard compilato
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare/source", methods=["GET"])
@login_required
@role_required("Maker")
def api_dxf_compare_source(task_id: int):
"""Proxy: parametri e file della ricetta DXF del task (404 se non ce n'e' una) (#105)."""
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe/dxf-compare/source")
# Codice e corpo del backend arrivano com'e': il 404 vuol dire «nessuna ricetta da DXF»
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# #105: un file della cartella ricetta (DXF, immagine di riferimento) come byte, non JSON
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/files/<path:path>", methods=["GET"])
@login_required
@role_required("Maker")
def api_vision_recipe_file(task_id: int, path: str):
"""Proxy: byte di un file della cartella ricetta del task, con content-type del backend (#105)."""
# Niente segmenti '..' o backslash: urllib3 normalizzerebbe l'URL fuori da questa rotta del backend
if "\\" in path or ".." in path.split("/"):
return jsonify({"error": True, "detail": _("File non trovato")}), 404
try:
resp = http_requests.get(
f"{Config.API_SERVER_URL.rstrip('/')}/api/vision/tasks/{task_id}/recipe/files/{quote(path)}",
headers={"X-API-Key": session.get("api_key", "")},
timeout=30,
)
except http_requests.RequestException as exc:
return jsonify({
"error": True,
"detail": _("Servizio non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
return Response(
resp.content,
status=resp.status_code,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
# #101: anteprima del DXF appena scelto nel wizard (contorni letti dal vision worker)
@maker_bp.route("/api/vision/dxf-preview", methods=["POST"])
@login_required
@@ -6,6 +6,7 @@
function dxfRecipeWizard(taskId, csrf, msgs) {
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
const VALUE_UNITS = { 'dxf.points_in_pct': '%', 'dxf.distance_avg': 'mm', 'dxf.distance_min': 'mm', 'dxf.distance_max': 'mm' }; // #104
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
return {
@@ -20,6 +21,9 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
imageFile: null, // immagine caricata o acquisita
imageName: '',
imageSrc: '',
imagePx: null, // #102: {w, h} in pixel, letto dall'<img> dell'anteprima
imageBroken: false, // #102: il browser non sa mostrare il formato (il file resta valido per l'invio)
calPxMm: null, // #102: px/mm della calibrazione della stazione
dxfFile: null,
dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti}
dxfPreviewBusy: false,
@@ -34,10 +38,112 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
error: '',
notice: '',
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
imageSource: '', // #104: '' | 'file' | 'camera'; il campo mm/px vale solo per 'file'
tested: false, // #104: true dopo una Prova (anche fallita): mostra la sezione del risultato
zoom: 1, // #104: scala dell'immagine di prova (1 = pixel nativi)
fitZoom: 1, // #104: zoom di Adatta (sotto o pari a questo il viewport segue l'immagine)
panning: false, // #104: trascinamento in corso
_pan: null, // #104: {x, y, left, top} al pointerdown
resPx: null, // #104: {w, h} in pixel dell'immagine di prova (per la dimensione del wrapper)
runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione)
runBandMm: null, // #104: banda usata nell'ultima Prova
runMinPointsPct: null, // #104: soglia usata nell'ultima Prova
existing: null, // #105: risposta di /source (ricetta DXF gia' salvata sul task); null = nessuna
existingInfo: false, // #105: «Ricetta esistente caricata...» dopo aver compilato il wizard
existingWarn: '', // #105: avviso (immagine non salvata, file illeggibili)
dxfFromRecipe: false, // #105: il DXF viene dalla ricetta (l'<input type=file> non puo' mostrarne il nome)
_existingApplied: false, // #105: il wizard e' gia' stato compilato dalla ricetta (non sovrascrivere le modifiche)
_existingReady: null, // #105: promessa della prima lettura di /source
_srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta
_dxfPickSeq: 0, // #105: sale a ogni scelta manuale del DXF; i file della ricetta arrivati dopo si scartano
_imgPickSeq: 0, // #105: idem per l'immagine (_setImage)
// #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone)
init() {
if (this.taskId) this._existingReady = this.loadExisting();
},
async toggle() {
this.open = !this.open;
if (this.open && !this.stations.length) await this.loadStations();
if (this.open) {
this.openExisting(); // #105: compila il wizard dalla ricetta esistente, in parallelo alle stazioni
if (!this.stations.length) await this.loadStations();
}
},
// #105: GET /source -> this.existing (null se 404 o errore)
async loadExisting() {
const seq = ++this._srcSeq;
let found = null;
try {
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare/source`);
if (resp.ok) {
const body = await resp.json().catch(() => null);
if (body && typeof body === 'object' && !body.error) found = body;
}
} catch (e) { console.error('dxfRecipeWizard source', e); }
if (seq !== this._srcSeq) return; // lettura piu' recente in corso
this.existing = found;
},
// #105: all'apertura, una sola volta, riempie il wizard con la ricetta esistente
async openExisting() {
if (this._existingReady) await this._existingReady;
if (!this.existing || this._existingApplied) return;
await this.applyExisting();
},
// #105: mm/px da px/mm, fino a 6 cifre significative e virgola decimale; '' se non valido
_mmPxFrom(pxPerMm) {
const v = Number(pxPerMm);
if (!Number.isFinite(v) || v <= 0) return '';
return String(Number((1 / v).toPrecision(6))).replace('.', ',');
},
// #105: un file della cartella ricetta -> File; null se assente o illeggibile (in quel caso avvisa)
async _fetchRecipeFile(name) {
try {
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/files/${encodeURIComponent(name)}`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const blob = await resp.blob();
return new File([blob], name, { type: blob.type || '' });
} catch (e) {
console.error('dxfRecipeWizard file', name, e);
this.existingWarn = this.msgs.existingFilesFailed || 'Cannot read the recipe files';
return null;
}
},
// #105: valori salvati nei campi, poi DXF e immagine di riferimento dalla cartella ricetta
async applyExisting() {
const e = this.existing;
if (!e) return;
this._existingApplied = true;
this.error = '';
this.notice = '';
this.existingWarn = '';
const band = Number(e.band_mm), pct = Number(e.min_points_pct);
if (Number.isFinite(band) && band > 0) this.bandMm = band;
if (Number.isFinite(pct) && pct > 0) this.minPointsPct = pct;
this.dark = e.polarity === 'dark';
const mm = this._mmPxFrom(e.px_per_mm);
if (mm) this.mmPx = mm; // il campo compare quando l'immagine e' da file
this.existingInfo = true;
// Sequenze lette prima di scaricare: se l'utente sceglie un file a mano nel frattempo, vince lui
const dxfSeq = this._dxfPickSeq, imgSeq = this._imgPickSeq;
const [dxf, img] = await Promise.all([
e.dxf ? this._fetchRecipeFile(e.dxf) : null,
e.image ? this._fetchRecipeFile(e.image) : null,
]);
if (dxf && dxfSeq === this._dxfPickSeq) {
this.dxfFile = dxf;
this.dxfFromRecipe = true;
this.result = null;
this.tested = false;
this.loadDxfPreview();
}
if (img && imgSeq === this._imgPickSeq) this._setImage(img, 'file'); // 'file': il campo mm/px compare gia' compilato
if (!e.image && !this.existingWarn) this.existingWarn = this.msgs.noReferenceImage || 'Reference image not saved';
},
async loadStations() {
@@ -54,13 +160,18 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
async onStation() {
this.calState = ''; this.calLabel = ''; this.calUrl = '';
this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calPxMm = null;
this.result = null;
this.tested = false; // #104
if (!this.stationId) return;
const r = await fetchStationCalibration(this.stationId);
this.calState = r.state || 'unreachable';
this.calUrl = r.calibration_url || '';
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {});
if (this.calState === 'calibrated') {
this.calLabel = formatCalibration(r.calibration || {});
const c = Number((r.calibration || {}).px_per_mm); // #102: scala per il campo in mm dell'anteprima
this.calPxMm = Number.isFinite(c) && c > 0 ? c : null;
}
},
get calibrated() { return this.calState === 'calibrated'; },
@@ -74,26 +185,61 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
},
// #98: compilato ma non valido -> errore in pagina
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; },
// #104: la scala a mano vale solo per un'immagine da file; con la camera vale la calibrazione della stazione
get useManualScale() { return this.imageSource === 'file' && this.manualPxMm !== null; },
get canRun() {
// #98: la scala a mano sostituisce la calibrazione della stazione
return !!(this.stationId && (this.calibrated || this.manualPxMm !== null) && this.imageFile && this.dxfFile) && !this.busy;
// #98: la scala a mano sostituisce la calibrazione della stazione (#104: solo per immagini da file)
return !!(this.stationId && (this.calibrated || this.useManualScale) && this.imageFile && this.dxfFile) && !this.busy;
},
_setImage(file) {
_setImage(file, source) {
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageFile = file;
this.imageName = file.name || '';
this.imageSrc = URL.createObjectURL(file);
// #102: con file null (immagine tolta) si azzera anche l'anteprima
this.imageFile = file || null;
this.imageName = (file && file.name) || '';
this.imageSrc = file ? URL.createObjectURL(file) : '';
this.imagePx = null;
this.imageBroken = false;
this.result = null;
this.tested = false; // #104
this.resPx = null; // #104
this.imageSource = file ? (source || '') : ''; // #104
this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo
},
// #102: l'<img> dell'anteprima e' caricata: legge i pixel; se non si carica il formato non e' mostrabile
onImagePreviewLoad(el) {
this.imageBroken = false;
this.imagePx = el && el.naturalWidth ? { w: el.naturalWidth, h: el.naturalHeight } : null;
},
onImagePreviewError() {
this.imageBroken = !!this.imageSrc;
this.imagePx = null;
},
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
get imagePreviewUrl() { return this.imageSrc; },
// #102: «W × H px · campo ≈ L × H mm»; scala = mm/px a mano se valido, altrimenti calibrazione della stazione
get imagePreviewSummary() {
const p = this.imagePx;
if (!p) return '';
const lang = document.documentElement.lang || 'it';
const f = (v, d) => Number(v).toLocaleString(lang, { minimumFractionDigits: d, maximumFractionDigits: d });
let text = `${f(p.w, 0)} × ${f(p.h, 0)} px`;
const pxMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null); // #104
if (pxMm) text += ` · ${this.msgs.imageField || 'field'} ≈ ${f(p.w / pxMm, 2)} × ${f(p.h / pxMm, 2)} mm`;
return text;
},
onImage(ev) {
const f = ev.target.files && ev.target.files[0];
if (f) this._setImage(f);
if (f) this._setImage(f, 'file'); // #104
},
onDxf(ev) {
this.dxfFile = (ev.target.files && ev.target.files[0]) || null;
this._dxfPickSeq++; // #105: scelta a mano: il DXF della ricetta in arrivo si scarta
this.dxfFromRecipe = false; // #105
this.result = null;
this.tested = false; // #104
this.loadDxfPreview(); // #101: anteprima appena si sceglie il file
},
@@ -167,7 +313,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.error = body.detail || this.msgs.requestFailed || 'Error';
} else {
const blob = await resp.blob();
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }));
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }), 'camera'); // #104
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
@@ -191,13 +337,17 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.busy = true;
this.error = '';
this.notice = '';
// #104: parametri usati, mostrati accanto ai risultati
this.runPxPerMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null);
this.runBandMm = this.bandMm;
this.runMinPointsPct = this.minPointsPct;
try {
const fd = new FormData();
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
fd.append('dxf', this.dxfFile, this.dxfFile.name);
fd.append('station_id', this.stationId);
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione
if (this.manualPxMm !== null) fd.append('px_per_mm', String(this.manualPxMm));
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione (#104: solo con immagine da file)
if (this.useManualScale) fd.append('px_per_mm', String(this.manualPxMm));
fd.append('band_mm', String(this.bandMm));
fd.append('min_points_pct', String(this.minPointsPct));
fd.append('polarity', this.dark ? 'dark' : 'light');
@@ -215,18 +365,116 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.notice = this.msgs.saved || 'Saved';
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
// #105: il task ha ora una ricetta (etichetta «Modifica»); il wizard e' gia' allineato, non si ricompila
this._existingApplied = true;
this.existingInfo = false;
this.existingWarn = ''; // #105: l'immagine di riferimento ora e' salvata con la ricetta
await this.loadExisting();
}
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
}
this.busy = false;
// #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra)
this.tested = true;
this.$nextTick(() => this.fit());
},
// Le quattro uscite del confronto, nell'ordine fisso
get values() {
const out = (this.result && this.result.result && this.result.result.outputs) || {};
return VALUE_KEYS.map(k => ({ key: k, value: out[k] }));
return VALUE_KEYS.map(k => ({ key: k, value: out[k], label: this._valueLabel(k), unit: VALUE_UNITS[k] })); // #104
},
// #104: etichette leggibili (testi passati da msgs), chiave grezza come ripiego
_valueLabel(k) {
const m = { 'dxf.points_in_pct': this.msgs.pointsInBand, 'dxf.distance_avg': this.msgs.distAvg,
'dxf.distance_min': this.msgs.distMin, 'dxf.distance_max': this.msgs.distMax };
return m[k] || k;
},
// #104: esito della ricetta sul riferimento: pass / warning / fail (src/vision/runner.py); altro = valore grezzo
get outcome() { return (this.result && this.result.result && this.result.result.outcome) || ''; },
get outcomeLabel() {
const m = { pass: this.msgs.outcomePass, warning: this.msgs.outcomeWarning, fail: this.msgs.outcomeFail };
return m[this.outcome] || this.outcome;
},
get outcomeClass() {
return { pass: 'text-emerald-600', warning: 'text-amber-600', fail: 'text-red-500' }[this.outcome] || 'text-[var(--text-muted)]';
},
// #104: scala usata nell'ultima Prova, con la fonte
get scaleSummary() {
if (!this.runPxPerMm) return this.msgs.scaleStation || 'station calibration';
const lang = document.documentElement.lang || 'it';
const mm = (1 / this.runPxPerMm).toLocaleString(lang, { maximumFractionDigits: 6 });
return `${mm} mm/px (${this.imageSource === 'file' ? (this.msgs.scaleManual || 'manual') : (this.msgs.scaleStation || 'station calibration')})`;
},
// #104: zoom dell'immagine di prova. Il wrapper e' largo naturalWidth*zoom; il canvas resta in pixel immagine e lo scala il CSS
get zoomPct() { return Math.round(this.zoom * 100) + ' %'; },
get zoomW() { return this.resPx ? this.resPx.w * this.zoom : 0; },
get zoomH() { return this.resPx ? this.resPx.h * this.zoom : 0; },
// #104: altezza massima del viewport = min(70vh, 520px)
_maxVpH() { return Math.min(0.7 * (typeof window !== 'undefined' && window.innerHeight ? window.innerHeight : 800), 520); },
// #104: con zoom <= Adatta il viewport e' alto come l'immagine scalata (min 200px), altrimenti al massimo
get vpHeight() {
const max = this._maxVpH();
if (!this.resPx || this.zoom > this.fitZoom * 1.0001) return max;
return Math.min(max, Math.max(200, this.zoomH));
},
// #104: l'immagine e' piu' grande dello spazio: si puo' trascinare
get canPan() { return !!this.resPx && this.zoom > this.fitZoom * 1.0001; },
get panCursor() { return this.canPan ? (this.panning ? 'grabbing' : 'grab') : 'default'; },
// #104: pan col puntatore (mouse e touch): scroll del viewport di quanto si muove il puntatore
onPanDown(ev) {
const vp = this.$refs.viewport;
if (!vp || (ev.pointerType === 'mouse' && ev.button)) return;
// #104: senza preventDefault Chromium manda pointercancel al primo movimento del mouse e il pan si ferma
if (ev.preventDefault) ev.preventDefault();
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
this.panning = true;
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
},
onPanMove(ev) {
const vp = this.$refs.viewport;
if (!this._pan || !vp) return;
vp.scrollLeft = this._pan.left - (ev.clientX - this._pan.x);
vp.scrollTop = this._pan.top - (ev.clientY - this._pan.y);
},
onPanEnd() { this._pan = null; this.panning = false; },
zoomBy(f) {
const vp = this.$refs.viewport;
const old = this.zoom;
const z = Math.min(8, Math.max(0.05, old * f));
if (z === old) return;
// centro visibile invariato: coordinate immagine del centro prima, scroll dopo il nuovo layout
let cx = 0, cy = 0;
if (vp) { cx = (vp.scrollLeft + vp.clientWidth / 2) / old; cy = (vp.scrollTop + vp.clientHeight / 2) / old; }
this.zoom = z;
if (vp) this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; });
},
// #104: Fit = tutta l'immagine nel viewport (altezza massima, non quella corrente che segue l'immagine)
fit() {
const vp = this.$refs.viewport, img = this.$refs.img;
if (!vp || !img || !img.naturalWidth || !vp.clientWidth) return;
const z = Math.min(8, Math.max(0.05, Math.min(vp.clientWidth / img.naturalWidth, this._maxVpH() / img.naturalHeight)));
this.zoom = z;
this.fitZoom = z;
vp.scrollLeft = 0; vp.scrollTop = 0;
},
// #104: Ctrl+rotella = zoom
onWheel(ev) {
if (!ev.ctrlKey) return;
ev.preventDefault();
this.zoomBy(ev.deltaY < 0 ? 1.25 : 1 / 1.25);
},
// #104: load dell'immagine di prova: dimensioni, overlay e adattamento allo spazio
onResultImgLoad() {
const img = this.$refs.img;
this.resPx = img && img.naturalWidth ? { w: img.naturalWidth, h: img.naturalHeight } : null;
this.drawOverlay();
this.$nextTick(() => this.fit());
},
fmt(v) {
@@ -662,11 +662,16 @@
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
<template x-if="task.task_type === 'dxf_compare'">
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata'), 'dxfEntities': _('entità')}|tojson_attr }})">
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata'), 'dxfEntities': _('entità'), 'imageField': _('campo'), 'pointsInBand': _('Punti in banda'), 'distAvg': _('Distanza media'), 'distMin': _('Distanza minima'), 'distMax': _('Distanza massima'), 'outcomePass': _('Conforme'), 'outcomeWarning': _('Con avvisi'), 'outcomeFail': _('Non conforme'), 'scaleManual': _('mm/px a mano'), 'scaleStation': _('calibrazione stazione'), 'createLabel': _('Crea ricetta da DXF'), 'editLabel': _('Modifica ricetta da DXF'), 'noReferenceImage': _('Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala'), 'existingFilesFailed': _('Impossibile leggere i file della ricetta esistente')}|tojson_attr }})">{# #104: etichette del risultato della Prova; #105: etichette del bottone e avvisi della ricetta esistente #}
{# #105: «Modifica ricetta da DXF» se il task ha gia' una ricetta da DXF #}
<button type="button" class="btn btn-secondary text-xs" @click="toggle()"
x-text="existing ? msgs.editLabel : msgs.createLabel">
{{ _('Crea ricetta da DXF') }}
</button>
<div x-show="open" x-cloak class="mt-3 space-y-3">
<!-- #105: ricetta esistente caricata nel wizard -->
<p x-show="existingInfo" class="text-xs text-emerald-600">{{ _('Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla') }}</p>
<p x-show="existingWarn" class="text-xs text-amber-600" x-text="existingWarn"></p>
<!-- Stazione -->
<div>
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
@@ -680,24 +685,14 @@
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
</p>
<p x-show="stationId && calState && !calibrated && manualPxMm === null" class="mt-1 text-xs text-amber-600">{# #98: nascosto con mm/px valido #}
<p x-show="stationId && calState && !calibrated && !useManualScale" class="mt-1 text-xs text-amber-600">{# #98: nascosto con mm/px valido (#104: solo per immagine da file) #}
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
</p>
</div>
<!-- #98: mm/px a mano per immagini da file, sostituisce la calibrazione della stazione -->
<div>
<label class="tmf-label text-xs">{{ _('mm/px (immagine da file)') }}</label>
<input type="text" inputmode="decimal" class="tmf-input text-xs" x-model="mmPx" placeholder="0,01406">
<p x-show="manualPxMm !== null" class="mt-1 text-xs text-emerald-600">
= <span x-text="fmt(manualPxMm)"></span> px/mm
</p>
<p x-show="mmPxInvalid" class="mt-1 text-xs text-amber-600">{{ _('mm/px non valido') }}</p>
<p class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Se compilato sostituisce la calibrazione della stazione; la scala resta salvata nella ricetta.') }}</p>
</div>
<!-- Immagine: camera o file -->
<!-- Immagine: camera o file. #104: prima le scelte, poi le anteprime affiancate -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
@@ -710,24 +705,48 @@
</label>
</div>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
<!-- #98: mm/px a mano, #104: solo per un'immagine da file; sostituisce la calibrazione della stazione -->
<div x-show="imageSource === 'file'" class="mt-2">
<label class="tmf-label text-xs">{{ _('mm/px (immagine da file)') }}</label>
<input type="text" inputmode="decimal" class="tmf-input text-xs" x-model="mmPx" placeholder="0,01406">
<p x-show="manualPxMm !== null" class="mt-1 text-xs text-emerald-600">
= <span x-text="fmt(manualPxMm)"></span> px/mm
</p>
<p x-show="mmPxInvalid" class="mt-1 text-xs text-amber-600">{{ _('mm/px non valido') }}</p>
<p class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Se compilato sostituisce la calibrazione della stazione; la scala resta salvata nella ricetta.') }}</p>
</div>
</div>
<div>
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
<!-- #101: anteprima del disegno appena scelto il .dxf -->
<!-- #105: DXF preso dalla ricetta esistente (l'input file non puo' mostrarne il nome) -->
<p x-show="dxfFromRecipe && dxfFile" class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfFile ? dxfFile.name : ''"></p>
<!-- #101: lettura del disegno appena scelto il .dxf -->
<p x-show="dxfPreviewBusy" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Lettura DXF…') }}</p>
<p x-show="dxfPreviewError" class="mt-1 text-xs text-red-500" x-text="dxfPreviewError"></p>
<div x-show="dxfPreview" class="mt-2">
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
class="block w-full rounded border border-[var(--border-color)]"
style="max-height: 240px; background: var(--bg-primary);">
<!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox -->
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
stroke-width="1.5" vector-effect="non-scaling-stroke"
stroke-linejoin="round"></path>
</svg>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
</div>
</div>
</div>
<!-- #104: anteprime sotto le scelte, scatole alte uguali (240px) per stare allineate -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
<div x-show="imagePreviewUrl">
<!-- #102: anteprima dell'immagine -->
<img x-show="!imageBroken" :src="imagePreviewUrl" alt=""
@load="onImagePreviewLoad($event.target)" @error="onImagePreviewError()"
class="block w-full rounded border border-[var(--border-color)]"
style="height: 240px; object-fit: contain; background: var(--bg-primary);">
<p x-show="imageBroken" class="text-xs text-amber-600">{{ _('Anteprima non disponibile per questo formato') }}</p>
<p x-show="imagePreviewSummary" class="mt-1 text-xs text-[var(--text-muted)]" x-text="imagePreviewSummary"></p>
</div>
<div x-show="dxfPreview" class="sm:col-start-2">{# #104: resta a destra anche senza anteprima immagine #}
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
class="block w-full rounded border border-[var(--border-color)]"
style="height: 240px; background: var(--bg-primary);">
<!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox -->
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
stroke-width="1.5" vector-effect="non-scaling-stroke"
stroke-linejoin="round"></path>
</svg>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
</div>
</div>
<!-- Pochi parametri -->
@@ -752,21 +771,57 @@
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
</div>
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
<!-- Anteprima con overlay e quattro valori -->
<div x-show="imageSrc" class="relative">
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
@load="drawOverlay()">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
<!-- #104: errore di acquisizione prima di una Prova (dopo, sta nella colonna dei dati) -->
<p x-show="error && !tested" class="text-xs text-red-500" x-text="error"></p>
<!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra -->
<div x-show="tested" class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #}
<div>
<!-- #104: barra zoom in sovrimpressione, fuori dal contenuto scorrevole -->
<div class="relative">
<div class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
style="background: rgba(0,0,0,.55); color: #fff;">
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1 / 1.25)" title="{{ _('Riduci') }}">&minus;</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="fit()">{{ _('Adatta') }}</button>
<span class="text-xs px-1" x-text="zoomPct"></span>
</div>
<!-- #104: pan col puntatore (mouse e dito), cursore grab; altezza che segue l'immagine finche' zoom <= Adatta -->
<div x-ref="viewport" @wheel="onWheel($event)"
@pointerdown="onPanDown($event)" @pointermove="onPanMove($event)"
@pointerup="onPanEnd()" @pointercancel="onPanEnd()"
class="rounded border border-[var(--border-color)]"
:style="'overflow:auto;touch-action:none;user-select:none;background:var(--bg-primary);height:' + vpHeight + 'px;cursor:' + panCursor">
<div class="relative" :style="zoomW ? ('width:' + zoomW + 'px;height:' + zoomH + 'px') : 'width:100%'">
<img x-ref="img" :src="imageSrc" alt="" class="block" draggable="false" style="width: 100%; height: 100%; user-select: none;"
@load="onResultImgLoad()">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
<div x-show="result" class="space-y-2">
<p class="text-xs">{{ _('Esito') }}: <span class="font-semibold" :class="outcomeClass" x-text="outcomeLabel"></span></p>
<table class="tmf-table text-xs">
<tbody>
<template x-for="v in values" :key="v.key">
<tr>
<td :title="v.key" x-text="v.label"></td>
<td class="text-right"><span x-text="fmt(v.value)"></span> <span class="text-[var(--text-muted)]" x-text="v.unit"></span></td>
</tr>
</template>
</tbody>
</table>
<ul class="text-xs text-[var(--text-muted)] space-y-0.5">
<li>{{ _('Scala') }}: <span x-text="scaleSummary"></span></li>
<li>{{ _('Banda ± mm') }}: <span x-text="fmt(runBandMm)"></span></li>
<li>{{ _('Soglia punti in banda %%') }}: <span x-text="fmt(runMinPointsPct)"></span></li>
</ul>
</div>
</div>
</div>
<table x-show="result" class="tmf-table text-xs">
<tbody>
<template x-for="v in values" :key="v.key">
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
@@ -85,6 +85,217 @@ const PREVIEW = {
await w2.loadDxfPreview();
check('errore col detail', w2.dxfPreviewError === 'DXF illeggibile: x' && w2.dxfPreview === null, w2.dxfPreviewError);
// #102: anteprima dell'immagine
const wi = dxfRecipeWizard(1, () => 'tok', { imageField: 'campo' });
check('#102 senza immagine: summary vuoto', wi.imagePreviewSummary === '');
global.URL.createObjectURL = () => 'blob:x';
global.URL.revokeObjectURL = () => {};
wi._setImage({ name: 'a.bmp' }, 'file'); // #104: la scala a mano vale solo per file
check('#102 _setImage imposta URL e nome', wi.imagePreviewUrl === 'blob:x' && wi.imageName === 'a.bmp');
wi.onImagePreviewLoad({ naturalWidth: 2000, naturalHeight: 1000 });
check('#102 senza scala: solo pixel', /2[. ]?000 × 1[. ]?000 px$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
wi.mmPx = '0,01';
check('#102 con mm/px a mano: campo in mm', /px · campo ≈ 20,00 × 10,00 mm$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
wi.mmPx = ''; wi.calState = 'calibrated'; wi.calPxMm = 100;
check('#102 con calibrazione stazione: campo in mm', /campo ≈ 20,00 × 10,00 mm$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
wi.onImagePreviewError();
check('#102 errore <img>: imageBroken e file ancora valido', wi.imageBroken === true && wi.imageFile && wi.imagePx === null);
wi._setImage(null);
check('#102 _setImage(null) azzera', wi.imagePreviewUrl === '' && wi.imageFile === null && wi.imageName === ''
&& wi.imagePx === null && wi.imageBroken === false && wi.imagePreviewSummary === '');
// #104: scala a mano solo per immagini da file
const w4 = dxfRecipeWizard(1, () => 'tok', {});
w4._setImage({ name: 'a.bmp' }, 'file');
w4.mmPx = '0,01406';
check('#104 useManualScale vero con file', w4.useManualScale === true);
w4._setImage({ name: 'camera.png' }, 'camera');
check('#104 useManualScale falso con camera anche se mmPx valido', w4.manualPxMm !== null && w4.useManualScale === false);
w4.stationId = '1'; w4.dxfFile = { name: 'd.dxf' };
check('#104 canRun con camera richiede la calibrazione', w4.canRun === false);
w4.calState = 'calibrated'; w4.calPxMm = 50;
check('#104 canRun con camera e stazione calibrata', w4.canRun === true);
// #104: run() con la camera non invia px_per_mm; con il file si
const sent = [];
global.FormData = class { append(k, v) { sent.push(k); } };
global.fetch = async () => ({ ok: true, json: async () => ({ result: { outputs: {}, overlay: [], outcome: 'pass' } }) });
const tick = [];
w4.$nextTick = f => tick.push(f);
w4.$dispatch = () => {};
w4.$refs = {};
await w4.run(false);
check('#104 run con camera: niente px_per_mm', !sent.includes('px_per_mm') && sent.includes('station_id'), sent.join(','));
check('#104 run: tested true e scala = stazione', w4.tested === true && w4.runPxPerMm === 50);
sent.length = 0;
w4._setImage({ name: 'a.bmp' }, 'file');
w4.mmPx = '0,02';
await w4.run(false);
check('#104 run con file: px_per_mm inviato', sent.includes('px_per_mm') && Math.abs(w4.runPxPerMm - 50) < 1e-9, sent.join(','));
check('#104 esito pass', w4.outcome === 'pass' && w4.outcomeClass.includes('emerald'));
// #104: tested torna false dopo _setImage / onDxf / onStation
w4.tested = true; w4._setImage(null);
check('#104 tested falso dopo _setImage', w4.tested === false && w4.imageSource === '');
w4.tested = true; w4.onDxf({ target: { files: [] } });
check('#104 tested falso dopo onDxf', w4.tested === false);
w4.tested = true; w4.stationId = ''; await w4.onStation();
check('#104 tested falso dopo onStation', w4.tested === false);
// #104: zoomBy rispetta i limiti 0.05..8
const w5 = dxfRecipeWizard(1, () => 'tok', {});
w5.$nextTick = f => f();
w5.$refs = {};
for (let i = 0; i < 40; i++) w5.zoomBy(1.25);
check('#104 zoom massimo 8', w5.zoom === 8, String(w5.zoom));
for (let i = 0; i < 80; i++) w5.zoomBy(1 / 1.25);
check('#104 zoom minimo 0.05', w5.zoom === 0.05, String(w5.zoom));
w5.zoom = 1;
const vp = { scrollLeft: 100, scrollTop: 50, clientWidth: 200, clientHeight: 100 };
w5.$refs = { viewport: vp };
w5.zoomBy(2);
check('#104 zoomBy tiene il centro', w5.zoom === 2 && vp.scrollLeft === 300 && vp.scrollTop === 150, vp.scrollLeft + ',' + vp.scrollTop);
check('#104 zoomPct', w5.zoomPct === '200 %');
// #104: fit = min(viewport/immagine)
w5.$refs = { viewport: { clientWidth: 500, clientHeight: 400, scrollLeft: 9, scrollTop: 9 }, img: { naturalWidth: 2000, naturalHeight: 1000 } };
w5.fit();
check('#104 fit limitato dalla larghezza', Math.abs(w5.zoom - 0.25) < 1e-9, String(w5.zoom));
w5.$refs.img = { naturalWidth: 1000, naturalHeight: 2000 };
w5.fit();
check('#104 fit limitato dall altezza massima (520 senza window)', Math.abs(w5.zoom - 0.26) < 1e-9, String(w5.zoom));
w5.$refs.viewport.clientWidth = 0; w5.zoom = 1; w5.fit();
check('#104 fit con viewport nascosto non cambia', w5.zoom === 1);
// #105: ricetta esistente -> wizard compilato
global.File = class { constructor(parts, name, opts) { this.parts = parts; this.name = name; this.type = (opts && opts.type) || ''; } };
const SRC = { band_mm: 0.3, min_points_pct: 90, polarity: 'light', px_per_mm: 71.1237, dxf: 'disegno.dxf', image: 'riferimento.bmp' };
const blobOf = t => ({ type: t });
const mkFetch = (routes, calls) => async (url) => {
calls.push(url);
for (const k of Object.keys(routes)) if (url.endsWith(k)) return routes[k]();
return { ok: false, status: 404, json: async () => ({ error: true }) };
};
const okJson = body => () => ({ ok: true, json: async () => body });
const okBlob = t => () => ({ ok: true, blob: async () => blobOf(t) });
const w6 = dxfRecipeWizard(8, () => 'tok', { noReferenceImage: 'senza immagine', editLabel: 'Modifica', createLabel: 'Crea' });
w6.$nextTick = f => f();
const calls6 = [];
global.fetch = mkFetch({
'/recipe/dxf-compare/source': okJson(SRC),
'/recipe/files/disegno.dxf': okBlob('application/dxf'),
'/recipe/files/riferimento.bmp': okBlob('image/bmp'),
'/dxf-preview': okJson(PREVIEW),
}, calls6);
global.FormData = class { append() {} };
check('#105 senza ricetta: existing null', w6.existing === null);
w6.init();
await w6._existingReady;
check('#105 init legge /source', w6.existing && w6.existing.band_mm === 0.3 && calls6[0] === '/maker/api/vision/tasks/8/recipe/dxf-compare/source', calls6.join(','));
await w6.toggle();
await w6.openExisting();
check('#105 apertura: banda, soglia, polarita', w6.bandMm === 0.3 && w6.minPointsPct === 90 && w6.dark === false);
check('#105 mm/px = 1/px_per_mm con virgola', w6.mmPx === '0,01406', w6.mmPx);
check('#105 DXF caricato dalla cartella', w6.dxfFile && w6.dxfFile.name === 'disegno.dxf' && w6.dxfFromRecipe === true);
await new Promise(r => setTimeout(r, 0)); // l'anteprima parte senza essere attesa
check('#105 anteprima DXF richiesta', calls6.some(u => u.endsWith('/dxf-preview')) && w6.dxfPreview !== null);
check('#105 immagine con source file: il campo mm/px vale', w6.imageFile && w6.imageFile.name === 'riferimento.bmp'
&& w6.imageSource === 'file' && w6.imageSrc === 'blob:x');
check('#105 info mostrata, nessun avviso', w6.existingInfo === true && w6.existingWarn === '');
const nCalls = calls6.length;
w6.bandMm = 0.2;
await w6.openExisting();
check('#105 seconda apertura non ricompila', w6.bandMm === 0.2 && calls6.length === nCalls);
// #105: ricetta senza immagine salvata -> avviso
const w7 = dxfRecipeWizard(8, () => 'tok', { noReferenceImage: 'senza immagine' });
global.fetch = mkFetch({
'/recipe/dxf-compare/source': okJson({ ...SRC, image: null, px_per_mm: null }),
'/recipe/files/disegno.dxf': okBlob('application/dxf'),
'/dxf-preview': okJson(PREVIEW),
}, []);
w7.init(); await w7._existingReady; await w7.openExisting();
check('#105 senza immagine: avviso e nessun file', w7.existingWarn === 'senza immagine' && w7.imageFile === null && w7.imageSource === '');
check('#105 px_per_mm null: mm/px resta vuoto', w7.mmPx === '');
// #105: 404 -> existing null, etichetta «Crea»
const w8 = dxfRecipeWizard(9, () => 'tok', {});
global.fetch = mkFetch({}, []);
w8.init(); await w8._existingReady; await w8.openExisting();
check('#105 404: existing null e nessuna compilazione', w8.existing === null && w8.existingInfo === false && w8.dxfFile === null);
// #105: file scelti a mano durante il download della ricetta non si sovrascrivono
const w9 = dxfRecipeWizard(8, () => 'tok', {});
const gates = {};
const gate = k => () => new Promise(res => { gates[k] = () => res({ ok: true, blob: async () => blobOf(k) }); });
global.fetch = mkFetch({
'/recipe/dxf-compare/source': okJson(SRC),
'/recipe/files/disegno.dxf': gate('dxf'),
'/recipe/files/riferimento.bmp': gate('bmp'),
'/dxf-preview': okJson(PREVIEW),
}, []);
w9.init(); await w9._existingReady;
const pa = w9.openExisting();
await new Promise(r => setTimeout(r, 0));
w9.onDxf({ target: { files: [{ name: 'mio.dxf' }] } });
w9._setImage({ name: 'mia.bmp' }, 'file');
gates.dxf(); gates.bmp();
await pa;
check('#105 DXF scelto a mano non sovrascritto', w9.dxfFile.name === 'mio.dxf' && w9.dxfFromRecipe === false);
check('#105 immagine scelta a mano non sovrascritta', w9.imageFile.name === 'mia.bmp');
// #105: file della ricetta illeggibile -> avviso
const w10 = dxfRecipeWizard(8, () => 'tok', { existingFilesFailed: 'illeggibili' });
global.fetch = mkFetch({
'/recipe/dxf-compare/source': okJson(SRC),
'/recipe/files/disegno.dxf': () => ({ ok: false, status: 404 }),
'/recipe/files/riferimento.bmp': okBlob('image/bmp'),
}, []);
w10.init(); await w10._existingReady; await w10.openExisting();
check('#105 DXF illeggibile: avviso, immagine comunque caricata', w10.existingWarn === 'illeggibili' && w10.dxfFile === null && w10.imageFile !== null);
// #105: dopo Salva si rilegge /source e il wizard non si ricompila
const w11 = dxfRecipeWizard(8, () => 'tok', {});
w11.$nextTick = f => f(); w11.$dispatch = () => {}; w11.$refs = {};
w11.stationId = '1'; w11.calState = 'calibrated'; w11.calPxMm = 50;
w11.imageFile = { name: 'a.bmp' }; w11.dxfFile = { name: 'd.dxf' };
global.FormData = class { append() {} };
global.fetch = async (url, opts) => url.endsWith('/source')
? { ok: true, json: async () => SRC }
: { ok: true, json: async () => ({ saved: true, result: { outputs: {}, overlay: [], outcome: 'pass' } }) };
await w11.run(true);
check('#105 dopo Salva: existing letto e non ricompilato', w11.existing && w11.existing.band_mm === 0.3 && w11._existingApplied === true);
// #104: pan col puntatore
const wpan = dxfRecipeWizard(1, () => 'tok', {});
const vppan = { scrollLeft: 100, scrollTop: 100, clientWidth: 500, clientHeight: 400, setPointerCapture() {} };
wpan.$refs = { viewport: vppan };
wpan.onPanDown({ clientX: 200, clientY: 200, pointerId: 1, pointerType: 'mouse', button: 0 });
wpan.onPanMove({ clientX: 170, clientY: 180 });
check('#104 pan: pointermove (-30,-20) -> scroll +30/+20', vppan.scrollLeft === 130 && vppan.scrollTop === 120, vppan.scrollLeft + ',' + vppan.scrollTop);
check('#104 pan: grabbing durante il trascinamento', wpan.panning === true);
wpan.onPanEnd();
wpan.onPanMove({ clientX: 0, clientY: 0 });
check('#104 pan: dopo pointerup non scrolla', vppan.scrollLeft === 130 && wpan.panning === false);
wpan.onPanDown({ clientX: 0, clientY: 0, pointerId: 1, pointerType: 'mouse', button: 2 });
check('#104 pan: il tasto destro non parte', wpan.panning === false);
// #104: altezza del viewport
global.window = { innerHeight: 1000 }; // max = min(700, 520) = 520
wpan.resPx = { w: 2000, h: 1000 };
wpan.$refs = { viewport: { clientWidth: 500, scrollLeft: 0, scrollTop: 0 }, img: { naturalWidth: 2000, naturalHeight: 1000 } };
wpan.fit(); // zoom = min(500/2000, 520/1000) = 0.25
check('#104 fit usa l altezza massima', Math.abs(wpan.zoom - 0.25) < 1e-9 && wpan.fitZoom === wpan.zoom, String(wpan.zoom));
check('#104 altezza a Fit = immagine scalata', wpan.vpHeight === 250 && wpan.canPan === false, String(wpan.vpHeight)); // zoomH = 250
wpan.resPx = { w: 2000, h: 400 }; // zoomH = 100 -> minimo 200
check('#104 altezza minima 200', wpan.vpHeight === 200, String(wpan.vpHeight));
wpan.zoom = 1;
check('#104 con zoom > Adatta altezza massima e pan attivo', wpan.vpHeight === 520 && wpan.canPan === true && wpan.panCursor === 'grab');
global.window = { innerHeight: 500 }; // 70vh = 350
check('#104 altezza massima = 70vh se minore di 520', wpan.vpHeight === 350, String(wpan.vpHeight));
delete global.window;
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
console.log('tutto ok');
})();
@@ -253,6 +253,79 @@ class TestDxfPreviewProxy:
mock_api_client.post.assert_not_called()
class TestDxfSourceProxy:
"""#105: parametri della ricetta DXF gia' salvata (proxy verso il backend)."""
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare/source"
OK = {"band_mm": 0.3, "min_points_pct": 95.0, "polarity": "dark", "px_per_mm": 71.12,
"dxf": "disegno.dxf", "image": "riferimento.bmp"}
def test_forwards_body(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = self.OK
resp = logged_in_client.get(self.URL)
assert resp.status_code == 200
assert resp.get_json() == self.OK
assert mock_api_client.get.call_args.args[0] == "/api/vision/tasks/7/recipe/dxf-compare/source"
def test_404_propagates(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {
"error": True, "status_code": 404, "detail": "nessuna ricetta da DXF su questo task"}
resp = logged_in_client.get(self.URL)
assert resp.status_code == 404
assert resp.get_json()["detail"] == "nessuna ricetta da DXF su questo task"
def test_requires_maker_role(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
assert logged_in_client.get(self.URL).status_code == 403
mock_api_client.get.assert_not_called()
class TestRecipeFileProxy:
"""#105: file della cartella ricetta (DXF, immagine di riferimento) passati come byte."""
URL = "/maker/api/vision/tasks/7/recipe/files/riferimento.bmp"
BMP = b"BM 1 fake bmp"
def _fake_file(self, status=200, content=BMP, ctype="image/bmp"):
resp = MagicMock(status_code=status, content=content)
resp.headers = {"content-type": ctype}
return resp
def test_bytes_and_content_type_pass_through(self, logged_in_client, mock_api_client):
with patch("blueprints.maker.http_requests.get", return_value=self._fake_file()) as get:
resp = logged_in_client.get(self.URL)
assert resp.status_code == 200
assert resp.data == self.BMP
assert resp.mimetype == "image/bmp"
args, kwargs = get.call_args
assert args[0].endswith("/api/vision/tasks/7/recipe/files/riferimento.bmp")
assert kwargs["headers"]["X-API-Key"] == "test-api-key-12345"
def test_backend_404_propagates(self, logged_in_client, mock_api_client):
body = b'{"detail":"file non trovato"}'
with patch("blueprints.maker.http_requests.get",
return_value=self._fake_file(404, body, "application/json")):
resp = logged_in_client.get(self.URL)
assert resp.status_code == 404
assert resp.get_json()["detail"] == "file non trovato"
def test_unsafe_path_is_404_without_calling_backend(self, logged_in_client, mock_api_client):
with patch("blueprints.maker.http_requests.get") as get:
for path in ("a/../../x", "..%5Cx", "%2e%2e/x"):
assert logged_in_client.get(f"/maker/api/vision/tasks/7/recipe/files/{path}").status_code == 404
get.assert_not_called()
def test_service_down_is_502(self, logged_in_client, mock_api_client):
with patch("blueprints.maker.http_requests.get", side_effect=requests.ConnectionError("giu")):
assert logged_in_client.get(self.URL).status_code == 502
def test_requires_maker_role(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
with patch("blueprints.maker.http_requests.get") as get:
assert logged_in_client.get(self.URL).status_code == 403
get.assert_not_called()
class TestPages:
def test_stations_page_has_calibration_cell(self, logged_in_client):
with patch("blueprints.admin.api_client") as admin_api:
@@ -280,4 +353,8 @@ class TestPages:
assert "@dxf-recipe-saved" in html
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF
assert "imageSource === 'file'" in html and "zoomBy(" in html and "fit()" in html # #104: mm/px solo da file, zoom e Fit
assert 'onPanDown($event)' in html # #104: pan col puntatore
assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine
assert "existing ? msgs.editLabel : msgs.createLabel" in html and "Modifica ricetta da DXF" in html # #105: etichetta dinamica
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -3499,3 +3499,88 @@ msgstr "Reading DXF…"
msgid "entità"
msgstr "entities"
#: templates/maker/task_editor.html (#102)
msgid "campo"
msgstr "field"
#: templates/maker/task_editor.html (#102)
msgid "Anteprima non disponibile per questo formato"
msgstr "Preview not available for this format"
#: templates/maker/task_editor.html (#104)
msgid "Riduci"
msgstr "Zoom out"
#: templates/maker/task_editor.html (#104)
msgid "Ingrandisci"
msgstr "Zoom in"
#: templates/maker/task_editor.html (#104)
msgid "Adatta"
msgstr "Fit"
#: templates/maker/task_editor.html (#104)
msgid "Scala"
msgstr "Scale"
#: templates/maker/task_editor.html (#104)
msgid "Punti in banda"
msgstr "Points in band"
#: templates/maker/task_editor.html (#104)
msgid "Distanza media"
msgstr "Average distance"
#: templates/maker/task_editor.html (#104)
msgid "Distanza minima"
msgstr "Minimum distance"
#: templates/maker/task_editor.html (#104)
msgid "Distanza massima"
msgstr "Maximum distance"
#: templates/maker/task_editor.html (#104)
msgid "Conforme"
msgstr "Pass"
#: templates/maker/task_editor.html (#104)
msgid "Con avvisi"
msgstr "Warning"
#: templates/maker/task_editor.html (#104)
msgid "Non conforme"
msgstr "Fail"
#: templates/maker/task_editor.html (#104)
msgid "mm/px a mano"
msgstr "manual mm/px"
#: templates/maker/task_editor.html (#104)
msgid "calibrazione stazione"
msgstr "station calibration"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Modifica ricetta da DXF"
msgstr "Edit recipe from DXF"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
msgstr "Reference image not saved (recipe created earlier): capture or upload it"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Impossibile leggere i file della ricetta esistente"
msgstr "Cannot read the files of the existing recipe"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
msgstr "Existing recipe loaded: Test, then Save recipe to replace it"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "File non trovato"
msgstr "File not found"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
#, python-format
msgid "Servizio non raggiungibile: %(detail)s"
msgstr "Service not reachable: %(detail)s"
@@ -3483,3 +3483,88 @@ msgstr "Lettura DXF…"
msgid "entità"
msgstr "entità"
#: templates/maker/task_editor.html (#102)
msgid "campo"
msgstr "campo"
#: templates/maker/task_editor.html (#102)
msgid "Anteprima non disponibile per questo formato"
msgstr "Anteprima non disponibile per questo formato"
#: templates/maker/task_editor.html (#104)
msgid "Riduci"
msgstr "Riduci"
#: templates/maker/task_editor.html (#104)
msgid "Ingrandisci"
msgstr "Ingrandisci"
#: templates/maker/task_editor.html (#104)
msgid "Adatta"
msgstr "Adatta"
#: templates/maker/task_editor.html (#104)
msgid "Scala"
msgstr "Scala"
#: templates/maker/task_editor.html (#104)
msgid "Punti in banda"
msgstr "Punti in banda"
#: templates/maker/task_editor.html (#104)
msgid "Distanza media"
msgstr "Distanza media"
#: templates/maker/task_editor.html (#104)
msgid "Distanza minima"
msgstr "Distanza minima"
#: templates/maker/task_editor.html (#104)
msgid "Distanza massima"
msgstr "Distanza massima"
#: templates/maker/task_editor.html (#104)
msgid "Conforme"
msgstr "Conforme"
#: templates/maker/task_editor.html (#104)
msgid "Con avvisi"
msgstr "Con avvisi"
#: templates/maker/task_editor.html (#104)
msgid "Non conforme"
msgstr "Non conforme"
#: templates/maker/task_editor.html (#104)
msgid "mm/px a mano"
msgstr "mm/px a mano"
#: templates/maker/task_editor.html (#104)
msgid "calibrazione stazione"
msgstr "calibrazione stazione"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Modifica ricetta da DXF"
msgstr "Modifica ricetta da DXF"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
msgstr "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Impossibile leggere i file della ricetta esistente"
msgstr "Impossibile leggere i file della ricetta esistente"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
msgstr "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "File non trovato"
msgstr "File non trovato"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
#, python-format
msgid "Servizio non raggiungibile: %(detail)s"
msgstr "Servizio non raggiungibile: %(detail)s"
+63
View File
@@ -184,6 +184,66 @@ def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float,
return anchors
def chiudi_contorni(dxf_bytes: bytes) -> tuple[bytes, dict]:
"""#103: unisce le entita' aperte (LINE/ARC/polilinee aperte in fila) in contorni chiusi.
CompareDxf taglia il contorno misurato agli estremi di ogni entita' aperta
del disegno (cut_open_ends): con un profilo spezzato in LINE+ARC e banda
larga non resta nessun punto in banda e il confronto fallisce. Qui le catene
chiuse diventano una LWPOLYLINE chiusa (con bulge per gli archi).
Pura e senza eccezioni nuove: se non c'e' nulla da unire, o ezdxf non legge
il file, ritorna i byte originali (sha invariato, ricette esistenti stabili).
Ritorna (bytes, {"anelli", "entita_unite", "entita_libere"}).
"""
nulla = {"anelli": 0, "entita_unite": 0, "entita_libere": 0}
try:
import ezdxf
from ezdxf import edgeminer as em, edgesmith as es
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in.dxf"
src.write_bytes(dxf_bytes)
doc = ezdxf.readfile(str(src))
msp = doc.modelspace()
# edges_from_entities_2d salta le entita' gia' chiuse (restano dove sono)
edges = list(es.edges_from_entities_2d(msp))
if not edges:
return dxf_bytes, nulla
loops = em.find_all_loops(em.Deposit(edges, gap_tol=0.01)) # 0.01 mm: giunzioni CAD
usati: set = set()
unite: list = [] # (polilinea, entita' di partenza)
# loop piu' lungo per primo, ogni edge usato una sola volta
for loop in sorted(loops, key=lambda lp: -sum(e.length for e in lp)):
ids = {e.id for e in loop}
if ids & usati:
continue
usati |= ids
unite.append((loop, es.lwpolyline_from_chain(loop)))
if not unite:
return dxf_bytes, {**nulla, "entita_libere": len(edges)}
entita_unite = 0
for loop, pl in unite:
pl.closed = True
payload = getattr(loop[0], "payload", None)
layer = getattr(getattr(payload, "dxf", None), "layer", None)
if layer:
pl.dxf.layer = layer # stesso layer dell'entita' di partenza
for e in loop:
ent = getattr(e, "payload", None)
if ent is not None and ent.is_alive:
msp.delete_entity(ent) # sostituita dalla polilinea chiusa
msp.add_foreign_entity(pl)
entita_unite += len(loop)
dst = Path(tmp) / "out.dxf"
doc.saveas(str(dst))
info = {"anelli": len(unite), "entita_unite": entita_unite,
"entita_libere": len(edges) - entita_unite}
return dst.read_bytes(), info
except Exception: # noqa: BLE001 - DXF illeggibile: l'errore resta quello a valle
return dxf_bytes, nulla
def _run(gray, graph, px_per_mm, folder) -> RunOutcome:
return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder)
@@ -211,6 +271,9 @@ def build_dxf_compare(
raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}")
if not dxf_bytes:
raise CostruttoreError("DXF vuoto")
# #103: entita' aperte unite in contorni chiusi PRIMA dello sha: disegno.dxf,
# grafo e runtime usano tutti il DXF chiuso
dxf_bytes, _info_chiusura = chiudi_contorni(dxf_bytes)
frame = _gray_u8(gray)
calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8")
+3 -1
View File
@@ -18,7 +18,7 @@ import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83
from src.vision.costruttori import CostruttoreError, build_dxf_compare, chiudi_contorni # #83 #103
from src.vision.runner import (
GraphBuildError,
RunOutcome,
@@ -315,6 +315,8 @@ def _dxf_preview(dxf_bytes: bytes) -> dict:
if not dxf_bytes:
raise CostruttoreError("DXF illeggibile: file vuoto")
from visionsuite.io.dxf import read_xld_dxf
# #103: l'anteprima mostra cio' che il confronto usera' (contorni chiusi)
dxf_bytes, _ = chiudi_contorni(dxf_bytes)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "disegno.dxf"
path.write_bytes(dxf_bytes)
@@ -0,0 +1,101 @@
"""#103: chiudi_contorni unisce LINE/ARC aperti in contorni chiusi (test sincroni, no pytest-asyncio)."""
import hashlib
import io
import tempfile
from pathlib import Path
import cv2
import ezdxf
import numpy as np
import pytest
from src.vision.costruttori import CostruttoreError, build_dxf_compare, chiudi_contorni
from src.vision_worker.main import _dxf_preview
def _dxf(riempi) -> bytes:
doc = ezdxf.new("R2010")
riempi(doc.modelspace())
buf = io.StringIO()
doc.write(buf)
return buf.getvalue().encode("utf-8")
def _quattro_line(msp, w=20.0, h=10.0):
pts = [(0, 0), (w, 0), (w, h), (0, h)]
for i in range(4):
msp.add_line(pts[i], pts[(i + 1) % 4])
def _entita(dxf: bytes):
with tempfile.TemporaryDirectory() as tmp: # readfile gestisce da solo codifica e CRLF
path = Path(tmp) / "o.dxf"
path.write_bytes(dxf)
return list(ezdxf.readfile(str(path)).modelspace())
def test_chiudi_rettangolo_di_4_line_in_una_polilinea_chiusa():
out, info = chiudi_contorni(_dxf(_quattro_line))
assert info == {"anelli": 1, "entita_unite": 4, "entita_libere": 0}
ents = _entita(out)
assert [e.dxftype() for e in ents] == ["LWPOLYLINE"] and ents[0].closed
xs = [p[0] for p in ents[0].get_points()]
ys = [p[1] for p in ents[0].get_points()]
assert (min(xs), max(xs), min(ys), max(ys)) == (0, 20, 0, 10) # bbox invariato
def test_chiudi_asola_line_e_arc_ha_bulge():
def asola(msp): # asola 20x10: due LINE orizzontali + due semicerchi r=5
msp.add_line((5, 0), (15, 0))
msp.add_arc((15, 5), 5, -90, 90)
msp.add_line((15, 10), (5, 10))
msp.add_arc((5, 5), 5, 90, 270)
out, info = chiudi_contorni(_dxf(asola))
assert info["anelli"] == 1 and info["entita_unite"] == 4
ents = _entita(out)
assert len(ents) == 1 and ents[0].closed
assert any(abs(p[4]) > 1e-6 for p in ents[0].get_points()) # gli archi diventano bulge
def test_dxf_gia_chiuso_resta_identico():
def chiuso(msp):
msp.add_lwpolyline([(0, 0), (20, 0), (20, 10), (0, 10)], close=True)
msp.add_circle((10, 5), 2)
dxf = _dxf(chiuso)
out, info = chiudi_contorni(dxf)
assert out == dxf and info["anelli"] == 0
def test_line_isolata_resta_e_byte_identici():
dxf = _dxf(lambda msp: msp.add_line((0, 0), (10, 0)))
out, info = chiudi_contorni(dxf)
assert out == dxf and info["anelli"] == 0 and info["entita_libere"] == 1
def test_byte_non_dxf_ritornati_identici():
brutto = b"questo non e' un dxf"
out, info = chiudi_contorni(brutto)
assert out == brutto and info["anelli"] == 0
def test_preview_di_quattro_line_e_una_sola_entita():
resp = _dxf_preview(_dxf(_quattro_line))
assert resp["entita"] == 1 and resp["larghezza_mm"] == 20 and resp["altezza_mm"] == 10
@pytest.mark.parametrize("band", [0.1, 0.3])
def test_chiudi_build_dxf_compare_banda_larga_con_4_line(band):
# rettangolo scuro 20x10 mm su bianco, 20 px/mm; disegno = 4 LINE aperte
ppm = 20.0
img = np.full((int(40 * ppm), int(60 * ppm)), 255, np.uint8)
cx, cy = img.shape[1] // 2, img.shape[0] // 2
cv2.rectangle(img, (cx - int(10 * ppm), cy - int(5 * ppm)),
(cx + int(10 * ppm), cy + int(5 * ppm)), 0, -1)
dxf = _dxf(_quattro_line)
ris = build_dxf_compare(img, dxf, px_per_mm=ppm, band_mm=band)
assert ris["outputs"]["dxf.points_in_pct"] > 90.0
# il disegno nella ricetta e' quello chiuso, non l'originale
import base64
salvato = base64.b64decode(ris["files"]["disegno.dxf"])
assert salvato != dxf and hashlib.sha256(salvato).hexdigest() == \
ris["graph"]["meta"]["dxf"]["disegno"]["sha256"]