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 binascii
import io import io
import json import json
import mimetypes
from typing import Literal from typing import Literal
from fastapi.responses import FileResponse 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) placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
if errors: if errors:
raise _422(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) recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
await vision_service.ensure_points_quote(db, task, min_points_pct) await vision_service.ensure_points_quote(db, task, min_points_pct)
await db.commit() await db.commit()
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"]) response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
return response 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( def save_result_files(
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png", task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
) -> tuple[str, str]: ) -> 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. 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 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. 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 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( existing = (await db.execute(
select(RecipeSubtask.id) select(RecipeSubtask.id)
.where(RecipeSubtask.task_id == task.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 import requests as http_requests
from functools import wraps 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 import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -701,6 +702,46 @@ def api_dxf_compare_recipe(task_id: int):
return jsonify(resp), 200 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) # #101: anteprima del DXF appena scelto nel wizard (contorni letti dal vision worker)
@maker_bp.route("/api/vision/dxf-preview", methods=["POST"]) @maker_bp.route("/api/vision/dxf-preview", methods=["POST"])
@login_required @login_required
@@ -6,6 +6,7 @@
function dxfRecipeWizard(taskId, csrf, msgs) { function dxfRecipeWizard(taskId, csrf, msgs) {
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found) // Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' }; 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']; const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
return { return {
@@ -20,6 +21,9 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
imageFile: null, // immagine caricata o acquisita imageFile: null, // immagine caricata o acquisita
imageName: '', imageName: '',
imageSrc: '', 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, dxfFile: null,
dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti} dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti}
dxfPreviewBusy: false, dxfPreviewBusy: false,
@@ -34,10 +38,112 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
error: '', error: '',
notice: '', notice: '',
result: null, // corpo B3 {result, saved, recipe_dir, outputs} 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() { async toggle() {
this.open = !this.open; 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() { async loadStations() {
@@ -54,13 +160,18 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore // Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
async onStation() { async onStation() {
this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calPxMm = null;
this.result = null; this.result = null;
this.tested = false; // #104
if (!this.stationId) return; if (!this.stationId) return;
const r = await fetchStationCalibration(this.stationId); const r = await fetchStationCalibration(this.stationId);
this.calState = r.state || 'unreachable'; this.calState = r.state || 'unreachable';
this.calUrl = r.calibration_url || ''; 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'; }, get calibrated() { return this.calState === 'calibrated'; },
@@ -74,26 +185,61 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
}, },
// #98: compilato ma non valido -> errore in pagina // #98: compilato ma non valido -> errore in pagina
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; }, 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() { get canRun() {
// #98: la scala a mano sostituisce la calibrazione della stazione // #98: la scala a mano sostituisce la calibrazione della stazione (#104: solo per immagini da file)
return !!(this.stationId && (this.calibrated || this.manualPxMm !== null) && this.imageFile && this.dxfFile) && !this.busy; 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); if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageFile = file; // #102: con file null (immagine tolta) si azzera anche l'anteprima
this.imageName = file.name || ''; this.imageFile = file || null;
this.imageSrc = URL.createObjectURL(file); this.imageName = (file && file.name) || '';
this.imageSrc = file ? URL.createObjectURL(file) : '';
this.imagePx = null;
this.imageBroken = false;
this.result = null; 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) { onImage(ev) {
const f = ev.target.files && ev.target.files[0]; const f = ev.target.files && ev.target.files[0];
if (f) this._setImage(f); if (f) this._setImage(f, 'file'); // #104
}, },
onDxf(ev) { onDxf(ev) {
this.dxfFile = (ev.target.files && ev.target.files[0]) || null; 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.result = null;
this.tested = false; // #104
this.loadDxfPreview(); // #101: anteprima appena si sceglie il file 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'; this.error = body.detail || this.msgs.requestFailed || 'Error';
} else { } else {
const blob = await resp.blob(); 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) { } catch (e) {
this.error = this.msgs.requestFailed || String(e); this.error = this.msgs.requestFailed || String(e);
@@ -191,13 +337,17 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.busy = true; this.busy = true;
this.error = ''; this.error = '';
this.notice = ''; 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 { try {
const fd = new FormData(); const fd = new FormData();
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png'); fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
fd.append('dxf', this.dxfFile, this.dxfFile.name); fd.append('dxf', this.dxfFile, this.dxfFile.name);
fd.append('station_id', this.stationId); fd.append('station_id', this.stationId);
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione // #98: scala a mano; il backend la preferisce alla calibrazione della stazione (#104: solo con immagine da file)
if (this.manualPxMm !== null) fd.append('px_per_mm', String(this.manualPxMm)); if (this.useManualScale) fd.append('px_per_mm', String(this.manualPxMm));
fd.append('band_mm', String(this.bandMm)); fd.append('band_mm', String(this.bandMm));
fd.append('min_points_pct', String(this.minPointsPct)); fd.append('min_points_pct', String(this.minPointsPct));
fd.append('polarity', this.dark ? 'dark' : 'light'); fd.append('polarity', this.dark ? 'dark' : 'light');
@@ -215,18 +365,116 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.notice = this.msgs.saved || 'Saved'; this.notice = this.msgs.saved || 'Saved';
// Il componente visionRecipe ricarica cartella, file e uscite delle quote // Il componente visionRecipe ricarica cartella, file e uscite delle quote
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId }); 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) { } catch (e) {
this.error = this.msgs.requestFailed || String(e); this.error = this.msgs.requestFailed || String(e);
} }
this.busy = false; 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 // Le quattro uscite del confronto, nell'ordine fisso
get values() { get values() {
const out = (this.result && this.result.result && this.result.result.outputs) || {}; 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) { fmt(v) {
@@ -662,11 +662,16 @@
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== --> <!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
<template x-if="task.task_type === 'dxf_compare'"> <template x-if="task.task_type === 'dxf_compare'">
<div class="mt-4 pt-3 border-t border-[var(--border-color)]" <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 }})"> 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 #}
<button type="button" class="btn btn-secondary text-xs" @click="toggle()"> {# #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') }} {{ _('Crea ricetta da DXF') }}
</button> </button>
<div x-show="open" x-cloak class="mt-3 space-y-3"> <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 --> <!-- Stazione -->
<div> <div>
<label class="tmf-label text-xs">{{ _('Stazione') }}</label> <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"> <p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
{{ _('Calibrazione') }}: <span x-text="calLabel"></span> {{ _('Calibrazione') }}: <span x-text="calLabel"></span>
</p> </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> <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" <a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a> class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span> <span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
</p> </p>
</div> </div>
<!-- #98: mm/px a mano per immagini da file, sostituisce la calibrazione della stazione --> <!-- Immagine: camera o file. #104: prima le scelte, poi le anteprime affiancate -->
<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 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label> <label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
@@ -710,17 +705,42 @@
</label> </label>
</div> </div>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p> <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>
<div> <div>
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label> <label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)"> <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="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> <p x-show="dxfPreviewError" class="mt-1 text-xs text-red-500" x-text="dxfPreviewError"></p>
<div x-show="dxfPreview" class="mt-2"> </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" <svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
class="block w-full rounded border border-[var(--border-color)]" class="block w-full rounded border border-[var(--border-color)]"
style="max-height: 240px; background: var(--bg-primary);"> 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 --> <!-- #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)" <path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
stroke-width="1.5" vector-effect="non-scaling-stroke" stroke-width="1.5" vector-effect="non-scaling-stroke"
@@ -729,7 +749,6 @@
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p> <p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
</div> </div>
</div> </div>
</div>
<!-- Pochi parametri --> <!-- Pochi parametri -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
@@ -752,21 +771,57 @@
<button type="button" class="btn btn-primary text-xs" @click="run(true)" <button type="button" class="btn btn-primary text-xs" @click="run(true)"
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button> :disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
</div> </div>
<p x-show="error" class="text-xs text-red-500" x-text="error"></p> <!-- #104: errore di acquisizione prima di una Prova (dopo, sta nella colonna dei dati) -->
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p> <p x-show="error && !tested" class="text-xs text-red-500" x-text="error"></p>
<!-- Anteprima con overlay e quattro valori --> <!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra -->
<div x-show="imageSrc" class="relative"> <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 #}
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]" <div>
@load="drawOverlay()"> <!-- #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> <canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div> </div>
<table x-show="result" class="tmf-table text-xs"> </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> <tbody>
<template x-for="v in values" :key="v.key"> <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> <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> </template>
</tbody> </tbody>
</table> </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>
</div> </div>
</div> </div>
</template> </template>
@@ -85,6 +85,217 @@ const PREVIEW = {
await w2.loadDxfPreview(); await w2.loadDxfPreview();
check('errore col detail', w2.dxfPreviewError === 'DXF illeggibile: x' && w2.dxfPreview === null, w2.dxfPreviewError); 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); } if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
console.log('tutto ok'); console.log('tutto ok');
})(); })();
@@ -253,6 +253,79 @@ class TestDxfPreviewProxy:
mock_api_client.post.assert_not_called() 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: class TestPages:
def test_stations_page_has_calibration_cell(self, logged_in_client): def test_stations_page_has_calibration_cell(self, logged_in_client):
with patch("blueprints.admin.api_client") as admin_api: with patch("blueprints.admin.api_client") as admin_api:
@@ -280,4 +353,8 @@ class TestPages:
assert "@dxf-recipe-saved" in html assert "@dxf-recipe-saved" in html
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano 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 "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 assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -3499,3 +3499,88 @@ msgstr "Reading DXF…"
msgid "entità" msgid "entità"
msgstr "entities" 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à" msgid "entità"
msgstr "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 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: def _run(gray, graph, px_per_mm, folder) -> RunOutcome:
return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder) 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)}") raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}")
if not dxf_bytes: if not dxf_bytes:
raise CostruttoreError("DXF vuoto") 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) frame = _gray_u8(gray)
calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8") 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 fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError 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 ( from src.vision.runner import (
GraphBuildError, GraphBuildError,
RunOutcome, RunOutcome,
@@ -315,6 +315,8 @@ def _dxf_preview(dxf_bytes: bytes) -> dict:
if not dxf_bytes: if not dxf_bytes:
raise CostruttoreError("DXF illeggibile: file vuoto") raise CostruttoreError("DXF illeggibile: file vuoto")
from visionsuite.io.dxf import read_xld_dxf 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: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "disegno.dxf" path = Path(tmp) / "disegno.dxf"
path.write_bytes(dxf_bytes) 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"]