Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2e3867370 | |||
| cab419bdc7 | |||
| 249999fa1e | |||
| a3e032d000 | |||
| 57376556d2 | |||
| 7cb877b11b | |||
| 08616aa1fa | |||
| 02f1481fe5 | |||
| 34e1b042f5 | |||
| bdef1f1cd8 | |||
| 39b9ad77eb | |||
| 12632323e8 | |||
| 839a61e479 | |||
| 9b8e729927 | |||
| 6080e6aebc | |||
| d5db5f3014 | |||
| 1a19bb6679 | |||
| 20f33234a8 | |||
| 711abc96df | |||
| 77419f9ebd | |||
| 4791c34205 | |||
| e582df7962 | |||
| a7b91b5508 | |||
| addf5a2598 | |||
| bdb1e8c8b9 | |||
| ebaafbe6e9 | |||
| 351153e78a | |||
| 842ceca007 | |||
| b78f2422d8 | |||
| 27d7c1e832 |
@@ -348,6 +348,69 @@ def _nessun_utente(db: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def camere_da_scaldare(db: Path) -> list[tuple[str, str]]:
|
||||||
|
"""Coppie (camera_url, camera_source) configurate nel db (stazioni e task di ricetta), senza doppioni.
|
||||||
|
Solo sorgenti "camera:" che aprono hardware (non "camera:folder"). Errore sqlite -> lista vuota (#108)."""
|
||||||
|
coppie: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def aggiungi(url, src) -> None:
|
||||||
|
# #108: valida la coppia e scarta duplicati e sorgenti che non aprono hardware
|
||||||
|
if not (isinstance(url, str) and isinstance(src, str) and url.strip() and src.strip()):
|
||||||
|
return
|
||||||
|
if not src.startswith("camera:") or src == "camera:folder":
|
||||||
|
return
|
||||||
|
if (url, src) not in coppie:
|
||||||
|
coppie.append((url, src))
|
||||||
|
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
|
||||||
|
try:
|
||||||
|
for url, src in con.execute("SELECT camera_url, camera_source FROM stations"):
|
||||||
|
aggiungi(url, src)
|
||||||
|
for (blob,) in con.execute("SELECT acquisition_json FROM recipe_tasks WHERE acquisition_json IS NOT NULL"):
|
||||||
|
try:
|
||||||
|
cfg = json.loads(blob)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue # #108: JSON non valido, si salta
|
||||||
|
if isinstance(cfg, dict):
|
||||||
|
aggiungi(cfg.get("camera_url"), cfg.get("camera_source"))
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return coppie
|
||||||
|
|
||||||
|
|
||||||
|
def scalda_camere(db: Path) -> None:
|
||||||
|
"""Apre ogni camera configurata con un grab a vuoto, cosi' il primo "Acquisisci" non aspetta ~25 s (#108).
|
||||||
|
Gira in un thread daemon: nessuna eccezione esce."""
|
||||||
|
try:
|
||||||
|
camere = camere_da_scaldare(db)
|
||||||
|
if not camere:
|
||||||
|
return
|
||||||
|
for _ in range(300): # aspetta il servizio camera (max ~60 s), come _apri_browser_quando_pronto
|
||||||
|
if _porta_aperta(PORTA_CAMERA):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
log.warning("scalda camere: servizio camera non raggiungibile, salto")
|
||||||
|
return
|
||||||
|
for url, src in camere:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
richiesta = urllib.request.Request(
|
||||||
|
f"{url.rstrip('/')}/camera/grab",
|
||||||
|
data=json.dumps({"source": src, "trigger": "continuo", "timeout_ms": 5000}).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(richiesta, timeout=90) as r:
|
||||||
|
esito = f"HTTP {r.status}"
|
||||||
|
log.info("scalda camera %s: %s in %.1f s", src, esito, time.monotonic() - t0)
|
||||||
|
except Exception as e: # #108: una camera che non risponde non ferma le altre
|
||||||
|
log.warning("scalda camera %s: %s dopo %.1f s", src, e, time.monotonic() - t0)
|
||||||
|
except Exception:
|
||||||
|
log.exception("scalda camere non riuscito")
|
||||||
|
|
||||||
|
|
||||||
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
|
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
|
||||||
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
||||||
for _ in range(300):
|
for _ in range(300):
|
||||||
@@ -509,6 +572,8 @@ def main(argv=None) -> int:
|
|||||||
|
|
||||||
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
|
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
|
||||||
thread = avvia_server(args.host, dati)
|
thread = avvia_server(args.host, dati)
|
||||||
|
# #108: apre le camere configurate con un grab a vuoto, con o senza browser
|
||||||
|
threading.Thread(target=scalda_camere, args=(dati / "tmf.db",), daemon=True).start()
|
||||||
if not args.senza_browser:
|
if not args.senza_browser:
|
||||||
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
||||||
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
|
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
|
||||||
|
|||||||
@@ -271,3 +271,77 @@ def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path,
|
|||||||
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
|
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
|
||||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
|
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- scalda camere (#108)
|
||||||
|
|
||||||
|
def _db_camere(percorso):
|
||||||
|
# #108: db minimo con le due tabelle lette da camere_da_scaldare
|
||||||
|
con = sqlite3.connect(percorso)
|
||||||
|
con.execute("CREATE TABLE stations (id INTEGER PRIMARY KEY, camera_url TEXT, camera_source TEXT)")
|
||||||
|
con.execute("CREATE TABLE recipe_tasks (id INTEGER PRIMARY KEY, acquisition_json TEXT)")
|
||||||
|
con.executemany("INSERT INTO stations (camera_url, camera_source) VALUES (?, ?)", [
|
||||||
|
("http://127.0.0.1:8765", "camera:601020"), # valida
|
||||||
|
(None, None), # stazione senza camera
|
||||||
|
("http://127.0.0.1:8765", "camera:folder"), # non apre hardware
|
||||||
|
])
|
||||||
|
con.executemany("INSERT INTO recipe_tasks (acquisition_json) VALUES (?)", [
|
||||||
|
(json.dumps({"camera_url": "http://127.0.0.1:8765", "camera_source": "camera:601020"}),), # duplicato
|
||||||
|
(json.dumps({"camera_url": "http://10.0.0.5:8765/", "camera_source": "camera:777"}),), # valida
|
||||||
|
("{rotto",), # JSON non valido
|
||||||
|
(None,),
|
||||||
|
])
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_camere_da_scaldare_filtra_e_deduplica(avvio, tmp_path):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
_db_camere(db)
|
||||||
|
assert avvio.camere_da_scaldare(db) == [
|
||||||
|
("http://127.0.0.1:8765", "camera:601020"),
|
||||||
|
("http://10.0.0.5:8765/", "camera:777"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_camere_da_scaldare_db_inesistente(avvio, tmp_path):
|
||||||
|
assert avvio.camere_da_scaldare(tmp_path / "non_esiste.db") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_scalda_camere_una_chiamata_per_coppia(avvio, tmp_path, monkeypatch):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
_db_camere(db)
|
||||||
|
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||||
|
chiamate = []
|
||||||
|
|
||||||
|
class Risposta:
|
||||||
|
status = 200
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): return False
|
||||||
|
|
||||||
|
def finto_urlopen(richiesta, timeout=None):
|
||||||
|
chiamate.append((richiesta.full_url, json.loads(richiesta.data), richiesta.get_method(), timeout))
|
||||||
|
return Risposta()
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio.urllib.request, "urlopen", finto_urlopen)
|
||||||
|
avvio.scalda_camere(db)
|
||||||
|
corpo = {"trigger": "continuo", "timeout_ms": 5000}
|
||||||
|
assert chiamate == [
|
||||||
|
("http://127.0.0.1:8765/camera/grab", {"source": "camera:601020", **corpo}, "POST", 90),
|
||||||
|
("http://10.0.0.5:8765/camera/grab", {"source": "camera:777", **corpo}, "POST", 90),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scalda_camere_eccezione_urlopen_non_esce(avvio, tmp_path, monkeypatch):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
_db_camere(db)
|
||||||
|
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||||
|
tentativi = []
|
||||||
|
|
||||||
|
def urlopen_rotto(richiesta, timeout=None):
|
||||||
|
tentativi.append(richiesta.full_url)
|
||||||
|
raise OSError("camera irraggiungibile")
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio.urllib.request, "urlopen", urlopen_rotto)
|
||||||
|
avvio.scalda_camere(db) # non solleva e passa alla camera successiva
|
||||||
|
assert len(tentativi) == 2
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -366,6 +367,20 @@ async def preview(
|
|||||||
return await vision_service.preview(db, task, await _read_image(image))
|
return await vision_service.preview(db, task, await _read_image(image))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dxf-preview")
|
||||||
|
async def dxf_preview(
|
||||||
|
dxf: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
|
||||||
|
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
|
||||||
|
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
|
||||||
|
dxf_bytes = await dxf.read()
|
||||||
|
if not validate_file_size(len(dxf_bytes)):
|
||||||
|
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
return await vision_service.dxf_preview(dxf_bytes)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
||||||
async def create_dxf_compare_recipe(
|
async def create_dxf_compare_recipe(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
@@ -433,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)
|
||||||
|
|||||||
@@ -224,6 +224,25 @@ async def dxf_compare_recipe(
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
async def dxf_preview(dxf_bytes: bytes, worker_url: str | None = None) -> dict:
|
||||||
|
"""#101: anteprima del DXF (contorni in mm + dimensioni) dal worker. Sostituita dai test."""
|
||||||
|
body = await call_worker_multipart(
|
||||||
|
"/dxf/preview",
|
||||||
|
files={"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream")},
|
||||||
|
data={},
|
||||||
|
worker_url=worker_url,
|
||||||
|
)
|
||||||
|
# forma minima del contratto: senza contorni e dimensioni non c'e' nulla da mostrare
|
||||||
|
required = {"contorni", "bbox", "larghezza_mm", "altezza_mm", "entita", "punti"}
|
||||||
|
if not required <= body.keys():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: missing "
|
||||||
|
+ ", ".join(sorted(required - body.keys())),
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
# Stati di fetch_station_calibration (#83)
|
# Stati di fetch_station_calibration (#83)
|
||||||
CAL_OK = "ok"
|
CAL_OK = "ok"
|
||||||
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||||
@@ -466,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]:
|
||||||
@@ -861,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)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""#101: POST /api/vision/dxf-preview, anteprima del DXF nel wizard.
|
||||||
|
|
||||||
|
Il worker non gira nei test: `vision_service.dxf_preview` si sostituisce.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
|
||||||
|
BODY = {
|
||||||
|
"contorni": [[[0.0, 0.0], [10.0, 0.0], [10.0, 5.0], [0.0, 5.0], [0.0, 0.0]]],
|
||||||
|
"bbox": {"xmin": 0.0, "ymin": 0.0, "xmax": 10.0, "ymax": 5.0},
|
||||||
|
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _post(client, user, content=b"0\nEOF"):
|
||||||
|
return client.post(
|
||||||
|
"/api/vision/dxf-preview",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"dxf": ("d.dxf", content, "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_forwards_the_file_and_returns_the_worker_body(
|
||||||
|
client, maker_user, monkeypatch,
|
||||||
|
):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
calls.append(dxf_bytes)
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, maker_user, b"DXF-BYTES")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == BODY
|
||||||
|
assert calls == [b"DXF-BYTES"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_forwards_the_worker_422(client, maker_user, monkeypatch):
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
raise HTTPException(status_code=422, detail="DXF illeggibile: file vuoto")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, maker_user, b"")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == "DXF illeggibile: file vuoto"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_requires_maker(client, measurement_tec_user, monkeypatch):
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, measurement_tec_user)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_too_big_is_422(client, maker_user, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
|
||||||
|
resp = await _post(client, maker_user, b"x" * 10)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_service_posts_to_worker_and_checks_shape(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_multipart(path, *, files, data, worker_url=None, timeout=120.0):
|
||||||
|
seen.update(path=path, files=files)
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr(vision_service, "call_worker_multipart", fake_multipart)
|
||||||
|
assert await vision_service.dxf_preview(b"abc") == BODY
|
||||||
|
assert seen["path"] == "/dxf/preview" and seen["files"]["dxf"][1] == b"abc"
|
||||||
|
|
||||||
|
async def malformed(path, *, files, data, worker_url=None, timeout=120.0):
|
||||||
|
return {"contorni": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr(vision_service, "call_worker_multipart", malformed)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await vision_service.dxf_preview(b"abc")
|
||||||
|
assert exc.value.status_code == 502
|
||||||
@@ -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
|
||||||
@@ -182,7 +182,7 @@ def api_camera_sources():
|
|||||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
return jsonify({"error": True, "detail": _("Indirizzo della camera non valido")}), 400
|
return jsonify({"error": True, "detail": _("Indirizzo della camera non valido")}), 400
|
||||||
try:
|
try:
|
||||||
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=5)
|
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=15) # #106: era 5, la prima enumerazione dopo l'avvio ha preso 3,7 s
|
||||||
except http_requests.RequestException as exc:
|
except http_requests.RequestException as exc:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"error": True,
|
"error": True,
|
||||||
|
|||||||
@@ -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 _
|
||||||
@@ -16,6 +17,9 @@ from services.file_proxy import proxy_file
|
|||||||
|
|
||||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||||
|
|
||||||
|
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
|
||||||
|
CAMERA_GRAB_TIMEOUT_S = 60
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PAGINE (GET)
|
# PAGINE (GET)
|
||||||
@@ -607,7 +611,7 @@ def api_station_camera_grab(station_id: int):
|
|||||||
resp = http_requests.post(
|
resp = http_requests.post(
|
||||||
f"{camera_url.rstrip('/')}/camera/grab",
|
f"{camera_url.rstrip('/')}/camera/grab",
|
||||||
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||||
timeout=15,
|
timeout=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
|
||||||
)
|
)
|
||||||
except http_requests.RequestException as exc:
|
except http_requests.RequestException as exc:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -701,6 +705,65 @@ 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)
|
||||||
|
@maker_bp.route("/api/vision/dxf-preview", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_dxf_preview():
|
||||||
|
"""Proxy: anteprima del DXF (contorni, bbox, L x H) prima di creare la ricetta (#101)."""
|
||||||
|
dxf = request.files.get("dxf")
|
||||||
|
if dxf is None or not dxf.filename:
|
||||||
|
return jsonify({"error": True, "detail": _("Serve il file DXF")}), 400
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
"/api/vision/dxf-preview",
|
||||||
|
files={"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream")},
|
||||||
|
)
|
||||||
|
if resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ from services.file_proxy import proxy_file
|
|||||||
|
|
||||||
measure_bp = Blueprint("measure", __name__)
|
measure_bp = Blueprint("measure", __name__)
|
||||||
|
|
||||||
|
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
|
||||||
|
CAMERA_GRAB_TIMEOUT_S = 60
|
||||||
|
|
||||||
# Session key holding a station chosen at runtime, overriding Config.STATION_CODE.
|
# Session key holding a station chosen at runtime, overriding Config.STATION_CODE.
|
||||||
STATION_OVERRIDE_KEY = "station_override"
|
STATION_OVERRIDE_KEY = "station_override"
|
||||||
|
|
||||||
@@ -1117,7 +1120,7 @@ def api_vision_grab():
|
|||||||
resp = http_requests.post(
|
resp = http_requests.post(
|
||||||
f"{camera_url.rstrip('/')}/camera/grab",
|
f"{camera_url.rstrip('/')}/camera/grab",
|
||||||
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||||
timeout=15,
|
timeout=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
|
||||||
)
|
)
|
||||||
except http_requests.RequestException as exc:
|
except http_requests.RequestException as exc:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
@@ -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,7 +21,15 @@ 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}
|
||||||
|
dxfPreviewBusy: false,
|
||||||
|
dxfPreviewError: '',
|
||||||
|
_dxfPreviewSeq: 0, // #101: una risposta arrivata dopo un cambio di file si scarta
|
||||||
|
mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto
|
||||||
bandMm: 0.1,
|
bandMm: 0.1,
|
||||||
minPointsPct: 95,
|
minPointsPct: 95,
|
||||||
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
||||||
@@ -29,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() {
|
||||||
@@ -49,33 +160,144 @@ 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'; },
|
||||||
|
// #98: mm/px inserito a mano -> px/mm; null se vuoto o fuori da 0.00001..100 (limiti di /camera/calibrazioni/manuale, #95)
|
||||||
|
get manualPxMm() {
|
||||||
|
const raw = String(this.mmPx || '').trim().replace(',', '.');
|
||||||
|
if (!raw) return null;
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isFinite(v) || v < 0.00001 || v > 100) return null;
|
||||||
|
return 1 / v;
|
||||||
|
},
|
||||||
|
// #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() {
|
get canRun() {
|
||||||
return !!(this.stationId && this.calibrated && 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);
|
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) {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: legge il DXF sul vision worker (via Flask e backend) e ne mostra i contorni
|
||||||
|
async loadDxfPreview() {
|
||||||
|
const seq = ++this._dxfPreviewSeq;
|
||||||
|
this.dxfPreview = null;
|
||||||
|
this.dxfPreviewError = '';
|
||||||
|
this.dxfPreviewBusy = false;
|
||||||
|
if (!this.dxfFile) return; // file tolto: tutto azzerato
|
||||||
|
this.dxfPreviewBusy = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||||||
|
const resp = await fetch('/maker/api/vision/dxf-preview', {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (seq !== this._dxfPreviewSeq) return; // file cambiato nel frattempo
|
||||||
|
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
|
||||||
|
else this.dxfPreview = body;
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== this._dxfPreviewSeq) return;
|
||||||
|
this.dxfPreviewError = this.msgs.requestFailed || String(e);
|
||||||
|
}
|
||||||
|
if (seq === this._dxfPreviewSeq) this.dxfPreviewBusy = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: una stringa `points` per ogni contorno, per <polyline>
|
||||||
|
dxfPreviewPaths() {
|
||||||
|
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||||||
|
return contorni.filter(c => c && c.length > 1).map(c => c.map(p => p[0] + ',' + p[1]).join(' '));
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: tutti i contorni in un solo attributo `d` ("M x y L x y ..."): x-for non funziona dentro <svg>
|
||||||
|
dxfPreviewD() {
|
||||||
|
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||||||
|
return contorni.filter(c => c && c.length > 1)
|
||||||
|
.map(c => c.map((p, i) => (i ? 'L ' : 'M ') + p[0] + ' ' + p[1]).join(' ')).join(' ');
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: viewBox sul bbox con margine 5% (minimo 0.5 mm per lato)
|
||||||
|
dxfPreviewViewBox() {
|
||||||
|
const b = this.dxfPreview && this.dxfPreview.bbox;
|
||||||
|
if (!b) return '0 0 1 1';
|
||||||
|
const w = b.xmax - b.xmin, h = b.ymax - b.ymin;
|
||||||
|
const m = Math.max(0.5, 0.05 * Math.max(w, h));
|
||||||
|
return [b.xmin - m, b.ymin - m, w + 2 * m, h + 2 * m].map(v => +v.toFixed(3)).join(' ');
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: «L × H mm · N entità», decimale con virgola (2 decimali) secondo la lingua pagina
|
||||||
|
get dxfPreviewSummary() {
|
||||||
|
const d = this.dxfPreview;
|
||||||
|
if (!d) return '';
|
||||||
|
const f = v => Number(v).toLocaleString(document.documentElement.lang || 'it',
|
||||||
|
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
return `${f(d.larghezza_mm)} × ${f(d.altezza_mm)} mm · ${d.entita} ${this.msgs.dxfEntities || 'entities'}`;
|
||||||
},
|
},
|
||||||
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
|
|
||||||
|
|
||||||
// Frame dalla camera della stazione scelta
|
// Frame dalla camera della stazione scelta
|
||||||
async grab() {
|
async grab() {
|
||||||
@@ -91,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);
|
||||||
@@ -115,11 +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 (#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('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');
|
||||||
@@ -137,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) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* image_to_png.js - #107: converte nel browser le immagini che il backend non ammette (es. BMP) in PNG.
|
||||||
|
*
|
||||||
|
* Cosa fa: tmfNeedsPng(type) dice se il MIME e' un'immagine fuori da PNG/JPEG/GIF/WebP;
|
||||||
|
* tmfImageToPng(file) la ridisegna su canvas e ritorna un File PNG (null se illeggibile, es. TIFF).
|
||||||
|
* Come si usa: await tmfImageToPng(file) prima del ritaglio e del caricamento.
|
||||||
|
* Dipende da: createImageBitmap e canvas del browser (non servono nei test: il caso "nessuna conversione" e' puro).
|
||||||
|
*/
|
||||||
|
// #107: tipi che il backend ammette gia': non vanno convertiti
|
||||||
|
const TMF_PNG_PASSTHROUGH = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
// #107: true per un'immagine (image/*) non ammessa dal backend, es. image/bmp
|
||||||
|
function tmfNeedsPng(type) {
|
||||||
|
return typeof type === 'string' && type.indexOf('image/') === 0 && TMF_PNG_PASSTHROUGH.indexOf(type) === -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #107: converte in PNG con canvas; ritorna il file invariato se non serve, null se il browser non sa decodificarlo
|
||||||
|
async function tmfImageToPng(file) {
|
||||||
|
if (!file || !tmfNeedsPng(file.type)) return file;
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||||
|
if (bitmap.close) bitmap.close();
|
||||||
|
const blob = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||||
|
if (!blob) return null;
|
||||||
|
const base = (file.name || 'immagine').replace(/\.[^.]+$/, '');
|
||||||
|
return new File([blob], base + '.png', { type: 'image/png' });
|
||||||
|
} catch (err) {
|
||||||
|
// #107: formato non decodificabile dal browser (es. TIFF)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #107: export per il test node (nel browser module non esiste)
|
||||||
|
if (typeof module !== 'undefined') module.exports = { tmfNeedsPng, tmfImageToPng };
|
||||||
@@ -504,6 +504,7 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
|
||||||
<script>
|
<script>
|
||||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||||
ImageCrop.configure({
|
ImageCrop.configure({
|
||||||
@@ -686,6 +687,13 @@ function recipeEditor() {
|
|||||||
async uploadFile(file) {
|
async uploadFile(file) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
|
||||||
|
file = await tmfImageToPng(file);
|
||||||
|
if (!file) {
|
||||||
|
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate file type
|
// Validate file type
|
||||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||||
if (!allowed.includes(file.type)) {
|
if (!allowed.includes(file.type)) {
|
||||||
|
|||||||
@@ -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')}|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,14 +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" class="mt-1 text-xs text-amber-600">
|
<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>
|
||||||
<!-- 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 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>
|
||||||
@@ -700,10 +705,48 @@
|
|||||||
</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)">
|
||||||
|
<!-- #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>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Pochi parametri -->
|
<!-- Pochi parametri -->
|
||||||
@@ -728,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 -->
|
||||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
<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') }}">−</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -800,6 +879,8 @@
|
|||||||
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
|
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- #107: errore di caricamento anche dentro il riquadro, non solo nel banner in cima -->
|
||||||
|
<p x-show="uploadErrorTask === task.id && errorMessage" x-text="errorMessage" class="text-xs text-red-600 dark:text-red-400 mt-2"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Directive / Description (read-only display) -->
|
<!-- Task Directive / Description (read-only display) -->
|
||||||
@@ -1405,6 +1486,7 @@
|
|||||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||||
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
|
||||||
<script>
|
<script>
|
||||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||||
ImageCrop.configure({
|
ImageCrop.configure({
|
||||||
@@ -1451,6 +1533,7 @@ function taskEditor() {
|
|||||||
showAddTask: false,
|
showAddTask: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
uploadErrorTask: null, // #107: id del task il cui upload e' fallito (errore mostrato nel riquadro Disegno Tecnico)
|
||||||
successMessage: '',
|
successMessage: '',
|
||||||
|
|
||||||
// ---- Task CRUD state ----
|
// ---- Task CRUD state ----
|
||||||
@@ -2336,14 +2419,24 @@ function taskEditor() {
|
|||||||
async uploadTaskFile(taskId, file, task) {
|
async uploadTaskFile(taskId, file, task) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
|
||||||
|
file = await tmfImageToPng(file);
|
||||||
|
if (!file) {
|
||||||
|
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf'];
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf'];
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!allowedTypes.includes(file.type)) {
|
||||||
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }};
|
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId; // #107
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > 20 * 1024 * 1024) {
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }};
|
this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId; // #107
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2353,6 +2446,7 @@ function taskEditor() {
|
|||||||
|
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
this.uploadErrorTask = null; // #107: nuovo tentativo, azzera l'errore del riquadro
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Upload file
|
// 1. Upload file
|
||||||
@@ -2369,6 +2463,7 @@ function taskEditor() {
|
|||||||
const uploadData = await uploadResp.json();
|
const uploadData = await uploadResp.json();
|
||||||
if (uploadData.error) {
|
if (uploadData.error) {
|
||||||
this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }};
|
this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId; // #107
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2393,6 +2488,7 @@ function taskEditor() {
|
|||||||
const updateData = await updateResp.json();
|
const updateData = await updateResp.json();
|
||||||
if (updateData.error) {
|
if (updateData.error) {
|
||||||
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId; // #107
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2408,6 +2504,7 @@ function taskEditor() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('uploadTaskFile error:', err);
|
console.error('uploadTaskFile error:', err);
|
||||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId; // #107
|
||||||
}
|
}
|
||||||
|
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
|
|||||||
@@ -51,7 +51,9 @@
|
|||||||
<!-- Subtask -->
|
<!-- Subtask -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
||||||
|
<!-- #100: il cambio di punto ricarica subito, senza «Applica filtri» -->
|
||||||
<select x-model="selectedSubtaskId"
|
<select x-model="selectedSubtaskId"
|
||||||
|
@change="loadData()"
|
||||||
:disabled="subtasks.length === 0"
|
:disabled="subtasks.length === 0"
|
||||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
||||||
<option value="">{{ _("Tutti i punti") }}</option>
|
<option value="">{{ _("Tutti i punti") }}</option>
|
||||||
@@ -287,20 +289,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!capability || capability.cp == null">
|
<!-- #100: con «Tutti i punti» tabella con una riga per punto di misura -->
|
||||||
|
<template x-if="!loadedSubtaskId && capabilityAll.length > 0">
|
||||||
|
<div>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs text-steel text-left">
|
||||||
|
<th class="py-1 font-medium">{{ _("Punto di misura") }}</th>
|
||||||
|
<th class="py-1 font-medium text-right">n</th>
|
||||||
|
<th class="py-1 font-medium text-right">Cp</th>
|
||||||
|
<th class="py-1 font-medium text-right">Cpk</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="row in capabilityAll" :key="row.subtask.id">
|
||||||
|
<tr class="cursor-pointer hover:bg-[var(--bg-primary)] border-t border-[var(--border-color)]"
|
||||||
|
@click="selectedSubtaskId = String(row.subtask.id); loadData()">
|
||||||
|
<td class="py-1.5 pr-2" x-text="'#' + row.subtask.marker_number + ' — ' + row.subtask.description"></td>
|
||||||
|
<td class="py-1.5 text-right font-mono" x-text="row.data && row.data.n != null ? row.data.n : '—'"></td>
|
||||||
|
<td class="py-1.5 text-right font-mono font-bold"
|
||||||
|
:class="row.data && row.data.cp != null ? capabilityColor(row.data.cp) : 'text-steel'"
|
||||||
|
x-text="row.data && row.data.cp != null ? row.data.cp : '—'"></td>
|
||||||
|
<td class="py-1.5 text-right font-mono font-bold"
|
||||||
|
:class="row.data && row.data.cpk != null ? capabilityColor(row.data.cpk) : 'text-steel'"
|
||||||
|
x-text="row.data && row.data.cpk != null ? row.data.cpk : '—'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="text-xs text-steel mt-3">{{ _("Scegli un punto per carta di controllo e istogramma") }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="(!capability || capability.cp == null) && (loadedSubtaskId || capabilityAll.length === 0)">
|
||||||
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
|
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 2: Carta di Controllo -->
|
<!-- Row 2: Carta di Controllo -->
|
||||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
<!-- #100: x-show (non x-if) perche' renderCharts() cerca gli id nel DOM; nascosta senza punto -->
|
||||||
|
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
||||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
|
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
|
||||||
<div id="control-chart" style="min-height: 350px;"></div>
|
<div id="control-chart" style="min-height: 350px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 3: Istogramma -->
|
<!-- Row 3: Istogramma -->
|
||||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
||||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
|
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
|
||||||
<div id="histogram-chart" style="min-height: 350px;"></div>
|
<div id="histogram-chart" style="min-height: 350px;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,6 +380,8 @@ function spcDashboard() {
|
|||||||
subtasks: [],
|
subtasks: [],
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
|
loadedSubtaskId: '', // #100 punto con cui sono stati caricati i dati (i pannelli dipendono da questo)
|
||||||
|
capabilityAll: [], // #100 con «Tutti i punti»: [{subtask, data}] una riga per punto
|
||||||
summary: null,
|
summary: null,
|
||||||
capability: null,
|
capability: null,
|
||||||
controlChart: null,
|
controlChart: null,
|
||||||
@@ -408,6 +444,8 @@ function spcDashboard() {
|
|||||||
|
|
||||||
async onRecipeChange() {
|
async onRecipeChange() {
|
||||||
this.selectedSubtaskId = '';
|
this.selectedSubtaskId = '';
|
||||||
|
this.loadedSubtaskId = ''; // #100
|
||||||
|
this.capabilityAll = []; // #100
|
||||||
this.selectedRunId = ''; // #58
|
this.selectedRunId = ''; // #58
|
||||||
this.runs = [];
|
this.runs = [];
|
||||||
this.subtasks = [];
|
this.subtasks = [];
|
||||||
@@ -459,10 +497,11 @@ function spcDashboard() {
|
|||||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
||||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||||
|
const requestedSubtaskId = this.selectedSubtaskId; // #100 punto richiesto da questa chiamata
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parallel fetch of all endpoints
|
// Parallel fetch of all endpoints
|
||||||
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([
|
const [summaryResp, capabilityResp, controlResp, histogramResp, allResp] = await Promise.allSettled([
|
||||||
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
|
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
|
||||||
this.selectedSubtaskId
|
this.selectedSubtaskId
|
||||||
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
|
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
|
||||||
@@ -473,6 +512,17 @@ function spcDashboard() {
|
|||||||
this.selectedSubtaskId
|
this.selectedSubtaskId
|
||||||
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
|
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
|
// #100: senza punto scelto, capability di ogni punto della ricetta
|
||||||
|
requestedSubtaskId
|
||||||
|
? Promise.resolve([])
|
||||||
|
: Promise.all(this.subtasks.map(st => {
|
||||||
|
const p = new URLSearchParams(params.toString());
|
||||||
|
p.set('subtask_id', st.id);
|
||||||
|
return fetch('/statistics/api/capability?' + p.toString())
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => ({ subtask: st, data: (d && !d.error) ? d : null }))
|
||||||
|
.catch(() => ({ subtask: st, data: null }));
|
||||||
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Summary (always available)
|
// Summary (always available)
|
||||||
@@ -505,6 +555,9 @@ function spcDashboard() {
|
|||||||
this.histogram = null;
|
this.histogram = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #100: lista per-punto solo con «Tutti i punti»; con un punto resta il flusso di prima
|
||||||
|
this.capabilityAll = (allResp.status === 'fulfilled' && Array.isArray(allResp.value)) ? allResp.value : [];
|
||||||
|
this.loadedSubtaskId = requestedSubtaskId;
|
||||||
this.hasData = true;
|
this.hasData = true;
|
||||||
|
|
||||||
// Render charts after DOM update
|
// Render charts after DOM update
|
||||||
@@ -522,7 +575,7 @@ function spcDashboard() {
|
|||||||
|
|
||||||
renderCharts() {
|
renderCharts() {
|
||||||
// Control chart
|
// Control chart
|
||||||
if (this.controlChart && this.selectedSubtaskId) {
|
if (this.controlChart && this.loadedSubtaskId) { // #100
|
||||||
renderControlChart('control-chart', this.controlChart);
|
renderControlChart('control-chart', this.controlChart);
|
||||||
} else {
|
} else {
|
||||||
const el = document.getElementById('control-chart');
|
const el = document.getElementById('control-chart');
|
||||||
@@ -530,7 +583,7 @@ function spcDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Histogram
|
// Histogram
|
||||||
if (this.histogram && this.selectedSubtaskId) {
|
if (this.histogram && this.loadedSubtaskId) { // #100
|
||||||
const tol = this.capability ? {
|
const tol = this.capability ? {
|
||||||
utl: this.capability.utl,
|
utl: this.capability.utl,
|
||||||
ltl: this.capability.ltl,
|
ltl: this.capability.ltl,
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* #101: anteprima DXF del wizard «Crea ricetta da DXF», con plain node.
|
||||||
|
* Copre dxfPreviewPaths, dxfPreviewViewBox, dxfPreviewSummary e lo scarto
|
||||||
|
* di una risposta arrivata dopo un cambio di file.
|
||||||
|
*
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_dxf_preview.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
global.document = { documentElement: { lang: 'it' } };
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'static', 'js', 'dxf_recipe_wizard.js'), 'utf8',
|
||||||
|
);
|
||||||
|
eval(src + ';global.dxfRecipeWizard = dxfRecipeWizard;');
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(label, cond, extra) {
|
||||||
|
if (cond) console.log('ok - ' + label);
|
||||||
|
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW = {
|
||||||
|
contorni: [[[0, -5], [12.3, -5], [12.3, 0], [0, 0], [0, -5]], [[1, -1]]],
|
||||||
|
bbox: { xmin: 0, ymin: -5.1, xmax: 12.3, ymax: 0 },
|
||||||
|
larghezza_mm: 12.3, altezza_mm: 5.1, entita: 2, punti: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const w = dxfRecipeWizard(1, () => 'tok', { dxfEntities: 'entità' });
|
||||||
|
|
||||||
|
check('senza anteprima: nessun path', w.dxfPreviewPaths().length === 0);
|
||||||
|
check('senza anteprima: summary vuoto', w.dxfPreviewSummary === '');
|
||||||
|
|
||||||
|
w.dxfPreview = PREVIEW;
|
||||||
|
const paths = w.dxfPreviewPaths();
|
||||||
|
check('un contorno con >1 punti diventa una polyline (quello da 1 punto no)', paths.length === 1);
|
||||||
|
check('formato points', paths[0] === '0,-5 12.3,-5 12.3,0 0,0 0,-5', paths[0]);
|
||||||
|
|
||||||
|
// #101: path unico per <path :d>
|
||||||
|
check('dxfPreviewD', w.dxfPreviewD() === 'M 0 -5 L 12.3 -5 L 12.3 0 L 0 0 L 0 -5', w.dxfPreviewD());
|
||||||
|
w.dxfPreview = null;
|
||||||
|
check('dxfPreviewD vuoto senza anteprima', w.dxfPreviewD() === '');
|
||||||
|
w.dxfPreview = PREVIEW;
|
||||||
|
|
||||||
|
const vb = w.dxfPreviewViewBox().split(' ').map(Number);
|
||||||
|
const m = Math.max(0.5, 0.05 * 12.3);
|
||||||
|
check('viewBox con margine 5%', Math.abs(vb[0] + m) < 1e-3 && Math.abs(vb[2] - (12.3 + 2 * m)) < 1e-3, vb.join(' '));
|
||||||
|
|
||||||
|
// Disegno minuscolo: margine minimo 0.5 mm
|
||||||
|
w.dxfPreview = { ...PREVIEW, bbox: { xmin: 0, ymin: 0, xmax: 1, ymax: 1 } };
|
||||||
|
check('margine minimo 0.5 mm', Math.abs(w.dxfPreviewViewBox().split(' ').map(Number)[0] + 0.5) < 1e-3);
|
||||||
|
|
||||||
|
w.dxfPreview = PREVIEW;
|
||||||
|
const s = w.dxfPreviewSummary;
|
||||||
|
check('summary L × H mm · N entità', s.includes('×') && s.includes('mm') && s.endsWith('2 entità')
|
||||||
|
&& /12[,.]30/.test(s) && /5[,.]10/.test(s), s);
|
||||||
|
|
||||||
|
// Risposta tardiva scartata: due scelte di file, la prima risponde per ultima
|
||||||
|
const resolvers = [];
|
||||||
|
global.fetch = () => new Promise(res => resolvers.push(res));
|
||||||
|
global.FormData = class { append() {} };
|
||||||
|
const w2 = dxfRecipeWizard(1, () => 'tok', {});
|
||||||
|
const reply = (body, ok = true) => ({ ok, json: async () => body });
|
||||||
|
w2.dxfFile = { name: 'a.dxf' };
|
||||||
|
const p1 = w2.loadDxfPreview();
|
||||||
|
w2.dxfFile = { name: 'b.dxf' };
|
||||||
|
const p2 = w2.loadDxfPreview();
|
||||||
|
resolvers[1](reply(PREVIEW));
|
||||||
|
await p2;
|
||||||
|
resolvers[0](reply({ ...PREVIEW, entita: 99 }));
|
||||||
|
await p1;
|
||||||
|
check('la risposta vecchia non sovrascrive la nuova', w2.dxfPreview && w2.dxfPreview.entita === 2);
|
||||||
|
check('busy spento', w2.dxfPreviewBusy === false);
|
||||||
|
|
||||||
|
// File tolto: tutto azzerato
|
||||||
|
w2.dxfFile = null;
|
||||||
|
await w2.loadDxfPreview();
|
||||||
|
check('file tolto azzera anteprima ed errore', w2.dxfPreview === null && w2.dxfPreviewError === '');
|
||||||
|
|
||||||
|
// Errore 422 con detail
|
||||||
|
global.fetch = async () => reply({ detail: 'DXF illeggibile: x' }, false);
|
||||||
|
w2.dxfFile = { name: 'c.dxf' };
|
||||||
|
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');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* image_to_png.js, run with plain node (#107): solo la parte pura (niente canvas qui).
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_image_to_png.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const path = require('path');
|
||||||
|
const { tmfNeedsPng, tmfImageToPng } = require(
|
||||||
|
path.join(__dirname, '..', '..', 'static', 'js', 'image_to_png.js'),
|
||||||
|
);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(label, cond) {
|
||||||
|
if (cond) { console.log('ok - ' + label); } else { console.error('FAIL - ' + label); failures++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
check('bmp va convertito', tmfNeedsPng('image/bmp') === true);
|
||||||
|
check('tiff va convertito', tmfNeedsPng('image/tiff') === true);
|
||||||
|
check('png no', tmfNeedsPng('image/png') === false);
|
||||||
|
check('jpeg no', tmfNeedsPng('image/jpeg') === false);
|
||||||
|
check('gif no', tmfNeedsPng('image/gif') === false);
|
||||||
|
check('webp no', tmfNeedsPng('image/webp') === false);
|
||||||
|
check('pdf no', tmfNeedsPng('application/pdf') === false);
|
||||||
|
check('tipo vuoto no', tmfNeedsPng('') === false);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const fake = { name: 'a.png', type: 'image/png' };
|
||||||
|
check('png ritorna lo stesso oggetto', (await tmfImageToPng(fake)) === fake);
|
||||||
|
const pdf = { name: 'a.pdf', type: 'application/pdf' };
|
||||||
|
check('pdf ritorna lo stesso oggetto', (await tmfImageToPng(pdf)) === pdf);
|
||||||
|
if (failures) { console.error(failures + ' failure(s)'); process.exit(1); }
|
||||||
|
console.log('all ok');
|
||||||
|
})();
|
||||||
@@ -278,7 +278,7 @@ def test_camera_sources_proxy_keeps_only_the_three_fields(logged_in_client, mock
|
|||||||
"name": "camera:folder", "label": "Cartella", "kind": "folder"}
|
"name": "camera:folder", "label": "Cartella", "kind": "folder"}
|
||||||
assert len(resp.get_json()["sources"]) == 2
|
assert len(resp.get_json()["sources"]) == 2
|
||||||
assert get.call_args.args[0] == "http://acq:8765/camera/sources"
|
assert get.call_args.args[0] == "http://acq:8765/camera/sources"
|
||||||
assert get.call_args.kwargs["timeout"] == 5
|
assert get.call_args.kwargs["timeout"] == 15 # #106
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("url", ["ftp://x", "", "http://", "acq:8765"])
|
@pytest.mark.parametrize("url", ["ftp://x", "", "http://", "acq:8765"])
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Runs the plain-node DXF preview behaviour test (tests/js/test_dxf_preview.js).
|
||||||
|
|
||||||
|
Same thin pytest wrapper pattern as test_numpad_js.py: shells out to ``node``
|
||||||
|
and asserts the script's own exit code. Skips (does not fail) when node is
|
||||||
|
not installed.
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#101.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_dxf_preview.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_js_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS di dxf_recipe_wizard.js saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_dxf_preview.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -72,6 +72,7 @@ class TestStationCameraGrab:
|
|||||||
args, kwargs = post.call_args
|
args, kwargs = post.call_args
|
||||||
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
|
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
|
||||||
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
|
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
|
||||||
|
assert kwargs["timeout"] == 60 # #106: margine per la prima apertura GenTL
|
||||||
|
|
||||||
def test_get_also_works(self, logged_in_client, mock_api_client):
|
def test_get_also_works(self, logged_in_client, mock_api_client):
|
||||||
_stations(mock_api_client)
|
_stations(mock_api_client)
|
||||||
@@ -178,6 +179,12 @@ class TestDxfCompareProxy:
|
|||||||
assert set(kwargs["files"]) == {"image", "dxf"}
|
assert set(kwargs["files"]) == {"image", "dxf"}
|
||||||
assert kwargs["files"]["image"][1] == PNG
|
assert kwargs["files"]["image"][1] == PNG
|
||||||
|
|
||||||
|
def test_manual_px_per_mm_forwarded(self, logged_in_client, mock_api_client):
|
||||||
|
# #98: scala a mano dal wizard (immagine da file): inoltrata al backend
|
||||||
|
mock_api_client.post.return_value = self.OK
|
||||||
|
self._post(logged_in_client, station_id="4", px_per_mm="71.1237")
|
||||||
|
assert mock_api_client.post.call_args.kwargs["data"]["px_per_mm"] == "71.1237"
|
||||||
|
|
||||||
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
|
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
|
||||||
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
|
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
|
||||||
resp = self._post(logged_in_client, save="true")
|
resp = self._post(logged_in_client, save="true")
|
||||||
@@ -204,6 +211,122 @@ class TestDxfCompareProxy:
|
|||||||
mock_api_client.post.assert_not_called()
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDxfPreviewProxy:
|
||||||
|
"""#101: anteprima del DXF scelto nel wizard (proxy verso il backend)."""
|
||||||
|
URL = "/maker/api/vision/dxf-preview"
|
||||||
|
OK = {
|
||||||
|
"contorni": [[[0.0, -5.0], [10.0, -5.0], [10.0, 0.0], [0.0, 0.0]]],
|
||||||
|
"bbox": {"xmin": 0.0, "ymin": -5.0, "xmax": 10.0, "ymax": 0.0},
|
||||||
|
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _post(self, client, with_file=True):
|
||||||
|
data = {"dxf": (io.BytesIO(b"0\nEOF"), "d.dxf")} if with_file else {}
|
||||||
|
return client.post(self.URL, data=data, content_type="multipart/form-data")
|
||||||
|
|
||||||
|
def test_forwards_file(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = self.OK
|
||||||
|
resp = self._post(logged_in_client)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["larghezza_mm"] == 10.0
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/vision/dxf-preview"
|
||||||
|
assert set(kwargs["files"]) == {"dxf"}
|
||||||
|
assert kwargs["files"]["dxf"][0] == "d.dxf"
|
||||||
|
assert kwargs["files"]["dxf"][1] == b"0\nEOF"
|
||||||
|
|
||||||
|
def test_missing_file_is_400(self, logged_in_client, mock_api_client):
|
||||||
|
resp = self._post(logged_in_client, with_file=False)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 422, "detail": "DXF illeggibile: x"}
|
||||||
|
resp = self._post(logged_in_client)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "DXF illeggibile: x"
|
||||||
|
|
||||||
|
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 self._post(logged_in_client).status_code == 403
|
||||||
|
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:
|
||||||
@@ -229,4 +352,10 @@ class TestPages:
|
|||||||
assert "js/dxf_recipe_wizard.js" in html
|
assert "js/dxf_recipe_wizard.js" in html
|
||||||
assert "dxfRecipeWizard(" in html
|
assert "dxfRecipeWizard(" in html
|
||||||
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 "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
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Runs the plain-node image_to_png.js test (tests/js/test_image_to_png.js).
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#107.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_image_to_png.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_to_png_js_pure_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS di image_to_png.js saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_image_to_png.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -313,6 +313,10 @@ class TestImageCropInclusion:
|
|||||||
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
||||||
assert "js/image-crop.js" in html
|
assert "js/image-crop.js" in html
|
||||||
assert "tmfCropBeforeUpload(file)" in html
|
assert "tmfCropBeforeUpload(file)" in html
|
||||||
|
# #107: conversione BMP->PNG e errore upload nel riquadro Disegno Tecnico
|
||||||
|
assert "js/image_to_png.js" in html
|
||||||
|
assert "tmfImageToPng(file)" in html
|
||||||
|
assert "uploadErrorTask" in html
|
||||||
|
|
||||||
def test_recipe_editor_includes_crop(self, logged_in_client, mock_api_client):
|
def test_recipe_editor_includes_crop(self, logged_in_client, mock_api_client):
|
||||||
mock_api_client.get.side_effect = [
|
mock_api_client.get.side_effect = [
|
||||||
@@ -322,3 +326,6 @@ class TestImageCropInclusion:
|
|||||||
html = logged_in_client.get("/maker/recipes/1/edit").get_data(as_text=True)
|
html = logged_in_client.get("/maker/recipes/1/edit").get_data(as_text=True)
|
||||||
assert "js/image-crop.js" in html
|
assert "js/image-crop.js" in html
|
||||||
assert "tmfCropBeforeUpload(file)" in html
|
assert "tmfCropBeforeUpload(file)" in html
|
||||||
|
# #107: conversione BMP->PNG prima dei controlli di formato
|
||||||
|
assert "js/image_to_png.js" in html
|
||||||
|
assert "tmfImageToPng(file)" in html
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def test_grab_passes_png_through(logged_in_client, mock_api_client, monkeypatch)
|
|||||||
assert kwargs["json"] == {
|
assert kwargs["json"] == {
|
||||||
"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000,
|
"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000,
|
||||||
}
|
}
|
||||||
assert kwargs["timeout"] == 15
|
assert kwargs["timeout"] == 60 # #106
|
||||||
|
|
||||||
|
|
||||||
def test_grab_service_503_is_502(logged_in_client, mock_api_client, monkeypatch):
|
def test_grab_service_503_is_502(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
|||||||
@@ -167,3 +167,11 @@ class TestRunTaskResultsProxy:
|
|||||||
assert 'id="run-task-results"' in html
|
assert 'id="run-task-results"' in html
|
||||||
assert "loadTaskResults" in html
|
assert "loadTaskResults" in html
|
||||||
assert "/statistics/api/runs/" in html
|
assert "/statistics/api/runs/" in html
|
||||||
|
|
||||||
|
def test_dashboard_all_points_overview(self, logged_in_client, mock_api_client):
|
||||||
|
# #100: con «Tutti i punti» la pagina ha tabella per-punto e select che ricarica da solo
|
||||||
|
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
|
||||||
|
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
|
||||||
|
assert "capabilityAll" in html
|
||||||
|
assert "loadedSubtaskId" in html
|
||||||
|
assert '@change="loadData()"' in html
|
||||||
|
|||||||
@@ -3485,3 +3485,106 @@ msgstr "Invalid camera address"
|
|||||||
#: templates/admin/stations.html blueprints/admin.py (#88)
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
msgid "Risposta non valida dal servizio camera"
|
msgid "Risposta non valida dal servizio camera"
|
||||||
msgstr "Invalid response from the camera service"
|
msgstr "Invalid response from the camera service"
|
||||||
|
|
||||||
|
#: templates/statistics/dashboard.html (#100)
|
||||||
|
msgid "Scegli un punto per carta di controllo e istogramma"
|
||||||
|
msgstr "Choose a point for the control chart and histogram"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html blueprints/maker.py (#101)
|
||||||
|
msgid "Serve il file DXF"
|
||||||
|
msgstr "The DXF file is required"
|
||||||
|
|
||||||
|
msgid "Lettura DXF…"
|
||||||
|
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"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/maker/recipe_editor.html (#107)
|
||||||
|
msgid "Formato immagine non leggibile. Usa PNG, JPG o PDF."
|
||||||
|
msgstr "Unreadable image format. Use PNG, JPG or PDF."
|
||||||
|
|||||||
@@ -3473,3 +3473,102 @@ msgstr "Indirizzo della camera non valido"
|
|||||||
#: templates/admin/stations.html blueprints/admin.py (#88)
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
msgid "Risposta non valida dal servizio camera"
|
msgid "Risposta non valida dal servizio camera"
|
||||||
msgstr "Risposta non valida dal servizio camera"
|
msgstr "Risposta non valida dal servizio camera"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html blueprints/maker.py (#101)
|
||||||
|
msgid "Serve il file DXF"
|
||||||
|
msgstr "Serve il file DXF"
|
||||||
|
|
||||||
|
msgid "Lettura DXF…"
|
||||||
|
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"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/maker/recipe_editor.html (#107)
|
||||||
|
msgid "Formato immagine non leggibile. Usa PNG, JPG o PDF."
|
||||||
|
msgstr "Formato immagine non leggibile. Usa PNG, JPG o PDF."
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile # #101
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
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,
|
||||||
@@ -288,3 +289,91 @@ async def recipes_dxf_compare(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
|
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# #101: tetto ai punti dell'anteprima DXF (un profilo fitto non deve gonfiare
|
||||||
|
# la risposta ne' rallentare il disegno SVG nel browser).
|
||||||
|
_PREVIEW_MAX_POINTS = 20000
|
||||||
|
|
||||||
|
|
||||||
|
def _decimate(points: list[list[float]], keep: int) -> list[list[float]]:
|
||||||
|
"""Decimazione uniforme a `keep` punti (>= 2), primo e ultimo sempre inclusi."""
|
||||||
|
n = len(points)
|
||||||
|
if n <= keep or keep < 2:
|
||||||
|
return points
|
||||||
|
idx = sorted({round(i * (n - 1) / (keep - 1)) for i in range(keep)})
|
||||||
|
return [points[i] for i in idx]
|
||||||
|
|
||||||
|
|
||||||
|
def _dxf_preview(dxf_bytes: bytes) -> dict:
|
||||||
|
"""#101: legge il DXF con lo stesso lettore di CompareDxf (read_xld_dxf) e ne
|
||||||
|
ritorna i contorni in mm, asse y verso il basso (come immagine e SVG).
|
||||||
|
|
||||||
|
Funzione sincrona e pura: la rotta la esegue in un thread. Solleva
|
||||||
|
CostruttoreError per DXF vuoto, illeggibile o senza geometria.
|
||||||
|
"""
|
||||||
|
if not dxf_bytes:
|
||||||
|
raise CostruttoreError("DXF illeggibile: file vuoto")
|
||||||
|
from visionsuite.io.dxf import read_xld_dxf
|
||||||
|
# #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)
|
||||||
|
try:
|
||||||
|
# flip_y=False + scale=1: coordinate native del DXF, in mm; il
|
||||||
|
# ribaltamento dell'asse y lo facciamo noi sotto (y = -y_dxf)
|
||||||
|
conts = list(read_xld_dxf(str(path), sampling_distance=0.05,
|
||||||
|
scale=1.0, flip_y=False))
|
||||||
|
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
|
||||||
|
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
|
||||||
|
|
||||||
|
contorni: list[list[list[float]]] = []
|
||||||
|
for cont in conts:
|
||||||
|
pts = np.asarray(cont.points, dtype=float) # (riga, colonna) = (y_dxf, x_dxf)
|
||||||
|
if pts.ndim != 2 or pts.shape[0] < 2:
|
||||||
|
continue
|
||||||
|
line = [[float(c), float(-r)] for r, c in pts]
|
||||||
|
if cont.closed and line[0] != line[-1]:
|
||||||
|
line.append(list(line[0])) # chiude il giro anche nella polilinea SVG
|
||||||
|
contorni.append(line)
|
||||||
|
if not contorni:
|
||||||
|
raise CostruttoreError("DXF senza geometria")
|
||||||
|
|
||||||
|
# #101: le dimensioni si misurano sui punti completi, prima della decimazione
|
||||||
|
xs = [p[0] for c in contorni for p in c]
|
||||||
|
ys = [p[1] for c in contorni for p in c]
|
||||||
|
xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys)
|
||||||
|
|
||||||
|
total = len(xs)
|
||||||
|
if total > _PREVIEW_MAX_POINTS:
|
||||||
|
# quota per contorno proporzionale, minimo 2 punti
|
||||||
|
contorni = [
|
||||||
|
_decimate(c, max(2, int(len(c) * _PREVIEW_MAX_POINTS / total))) for c in contorni
|
||||||
|
]
|
||||||
|
contorni = [[[round(x, 3), round(y, 3)] for x, y in c] for c in contorni]
|
||||||
|
xmin, xmax, ymin, ymax = (round(v, 3) for v in (xmin, xmax, ymin, ymax))
|
||||||
|
return {
|
||||||
|
"contorni": contorni,
|
||||||
|
"bbox": {"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax},
|
||||||
|
"larghezza_mm": round(xmax - xmin, 3),
|
||||||
|
"altezza_mm": round(ymax - ymin, 3),
|
||||||
|
"entita": len(contorni),
|
||||||
|
"punti": sum(len(c) for c in contorni),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/dxf/preview")
|
||||||
|
async def dxf_preview(dxf: UploadFile = File(...)) -> dict:
|
||||||
|
"""#101: anteprima del disegno DXF (contorni + dimensioni in mm) per il
|
||||||
|
wizard «Crea ricetta da DXF». Solo lettura, leggera: nessun run slot."""
|
||||||
|
dxf_bytes = await dxf.read()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_dxf_preview, dxf_bytes)
|
||||||
|
except CostruttoreError as exc:
|
||||||
|
raise _unprocessable(str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"dxf preview failed: {type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -80,3 +80,67 @@ async def test_dxf_compare_immagine_non_decodificabile_e_422():
|
|||||||
async def test_dxf_compare_parametro_fuori_range_e_422():
|
async def test_dxf_compare_parametro_fuori_range_e_422():
|
||||||
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
|
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# --- #101: POST /dxf/preview ---------------------------------------------------
|
||||||
|
|
||||||
|
def _rect_dxf(width: float, height: float, y0: float = 3.0) -> bytes:
|
||||||
|
"""DXF minimo con ezdxf: un rettangolo chiuso, y in alto rispetto a 0."""
|
||||||
|
import ezdxf
|
||||||
|
doc = ezdxf.new()
|
||||||
|
doc.modelspace().add_lwpolyline(
|
||||||
|
[(1, y0), (1 + width, y0), (1 + width, y0 + height), (1, y0 + height)], close=True,
|
||||||
|
)
|
||||||
|
buffer = io.StringIO()
|
||||||
|
doc.write(buffer)
|
||||||
|
return buffer.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _post_preview(dxf: bytes):
|
||||||
|
# TestClient sincrono: non serve pytest-asyncio (assente nel venv del worker)
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
return TestClient(app).post(
|
||||||
|
"/dxf/preview", files={"dxf": ("d.dxf", dxf, "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_rettangolo_10x5():
|
||||||
|
resp = _post_preview(_rect_dxf(10, 5))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["larghezza_mm"] == 10 and body["altezza_mm"] == 5
|
||||||
|
assert body["entita"] == 1 and body["punti"] == sum(len(c) for c in body["contorni"])
|
||||||
|
# y ribaltata: il rettangolo stava a y 3..8 nel DXF, ora a -8..-3 (asse verso il basso)
|
||||||
|
assert body["bbox"] == {"xmin": 1, "ymin": -8, "xmax": 11, "ymax": -3}
|
||||||
|
ring = body["contorni"][0]
|
||||||
|
assert ring[0] == ring[-1] # contorno chiuso: primo == ultimo
|
||||||
|
assert all(-8 <= y <= -3 for _, y in ring)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("content", [b"", b"questo non e' un dxf"])
|
||||||
|
def test_dxf_preview_illeggibile_e_422(content):
|
||||||
|
resp = _post_preview(content)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"].startswith("DXF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_senza_geometria_e_422():
|
||||||
|
import ezdxf
|
||||||
|
doc = ezdxf.new()
|
||||||
|
buffer = io.StringIO()
|
||||||
|
doc.write(buffer)
|
||||||
|
resp = _post_preview(buffer.getvalue().encode("utf-8"))
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "senza geometria" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_decima_oltre_il_tetto(monkeypatch):
|
||||||
|
from src.vision_worker import main
|
||||||
|
monkeypatch.setattr(main, "_PREVIEW_MAX_POINTS", 50)
|
||||||
|
import ezdxf
|
||||||
|
doc = ezdxf.new()
|
||||||
|
doc.modelspace().add_circle((0, 0), 20)
|
||||||
|
buffer = io.StringIO()
|
||||||
|
doc.write(buffer)
|
||||||
|
full = main._dxf_preview(buffer.getvalue().encode("utf-8"))
|
||||||
|
assert full["punti"] <= 50 + 1 and full["larghezza_mm"] == pytest.approx(40, abs=0.01) # dimensioni dai punti completi
|
||||||
|
|||||||
Reference in New Issue
Block a user