Compare commits

...

45 Commits

Author SHA1 Message Date
Adriano 1dd4b0a20c vendor/visionsuite a TieVision-Core 9eb6f4c (solo vs-demo-api, #370-#372); pin in CLAUDE.md, refs #127
Test visione: 63 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
2026-11-06 14:23:01 +01:00
Adriano 271ce73d09 costruttori: CompareDxf con pose_search=quarter, il pezzo capovolto di 180 gradi torna in banda, refs #127
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
2026-11-06 14:14:05 +01:00
Adriano 1bd6c6b011 test: pezzo a L (sample.dxf e il rettangolo con foro non distinguono il capovolgimento), refs #127
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
2026-11-06 14:13:46 +01:00
Adriano d8bca535ac test: ricetta DXF su pezzo asimmetrico capovolto di 180 gradi e pose_search nel grafo (rossi prima di U2), refs #127
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
2026-11-06 14:11:02 +01:00
Adriano 14257304f8 vendor/visionsuite a TieVision-Core ffb93b4 (pose_search, #343); pin in CLAUDE.md e uv.lock (zxing-cpp), refs #127
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
2026-11-06 14:09:59 +01:00
Adriano 5738920c9c sviluppo.ps1: git solo con submodule inizializzato; comando di test corretto (#109)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 12:07:23 +01:00
Adriano 452862e0f3 sviluppo.ps1: ripristinati i backslash dei percorsi (#109)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 11:55:50 +01:00
Adriano 999f47092d sviluppo.ps1 scrive vision-engine-version.txt, gitignore (#109)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 11:54:59 +01:00
Adriano 6441a92062 Archivio sorgente con submodule e vision-engine-version.txt (#109)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 11:54:15 +01:00
Adriano 8c361c14ae Visore <tmf-viewer> di TieVision-Station con no-layers su ogni immagine di TMF (#126)
Rotella senza Ctrl = zoom sul punto del mouse su tutte le immagini. image_viewer.js e
vision_overlay.js copiati byte per byte da TieVision-Station (lavoro/daitarn-nolayers b1af911);
attributo no-layers su ogni <tmf-viewer> (task_editor 3, task_execute 1, calibrazione_pagina 4).
Test JS e pagina demo allineati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-29 13:49:39 +01:00
Adriano 5897a9e5df Wizard: chiusura in più nella Prova tolta, percentuale del DXF rispetto ad Adatta (Refs #122)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 22:15:55 +01:00
Adriano f44b578d5b Unisce lavoro/daitarn-tmf-rev118: correzioni della revisione Fable (Refs #118 #123 #124 #125)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 22:07:44 +01:00
Adriano c5be9a2bb4 tmf-viewer: host [hidden] nascosto, wizard usa viewer-error al posto della sonda, test_dxf_preview senza zoom del wizard (Refs #122)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 22:07:36 +01:00
Adriano 17ea010f7e Test: quota NaN chiede il capoturno, migrazione 022 conserva le righe
Refs #125

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:07:18 +01:00
Adriano 1d6d74b2e4 Unisce lavoro/daitarn-tmf-v-camera: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:44 +01:00
Adriano 2f0a5aaf1e Deviazione NaN nelle esportazioni CSV per le quote non misurate
Refs #124

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:06:43 +01:00
Adriano e96fbb73c2 Unisce lavoro/daitarn-tmf-v-wizard: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:43 +01:00
Adriano f3bacc8238 Unisce lavoro/daitarn-tmf-v-operatore: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:43 +01:00
Adriano e743363a62 Unisce lavoro/daitarn-tmf-visore: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:43 +01:00
Adriano d48a8a1076 <tmf-viewer>: clamp fit*8, viewer-error, etichette autonome, pagina di prova
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:05:54 +01:00
Adriano 8ef61a1285 Registra spento sul frame gia' registrato, nuovo grab dopo il reset
Refs #123

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:04:20 +01:00
Adriano 84fc1a08f7 Pagina camera 8765: i quattro visori diventano tmf-viewer, JS serviti su /visore/
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:03:58 +01:00
Adriano c904800e94 Etichetta NaN sull'immagine per le quote non misurate
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:02:26 +01:00
Adriano 20c0a47c00 <tmf-viewer>: visualizzatore immagine standard, tmfSaveBlob e tmfSvgToPngBlob
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:59:01 +01:00
Adriano 269bbc03ca Wizard DXF: anteprima immagine, anteprima DXF e Prova usano <tmf-viewer>
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:58:27 +01:00
Adriano 815d49d699 Pannello visione operatore: zoom, overlay e Salva passano a <tmf-viewer>
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:58:18 +01:00
Adriano 43ef22079d Unione lavoro/daitarn-tmf-nan (Refs #118)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:41:43 +01:00
Adriano b01bba536a Test check: la quota non prodotta entra come NaN/fail
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:41:22 +01:00
Adriano 17344aae13 Monitor: quota con soli NaN resta in elenco con media null e fuori tolleranza
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:30:11 +01:00
Adriano fd3384f08c Monitor e quota registrata mostrano NaN invece di andare in errore (Refs #118)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:28:53 +01:00
Adriano 08aefa9888 Salva apre il «Salva con nome» prima di scaricare; riepilogo senza deviazione 0 per quota NaN (Refs #119 #118)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:23:55 +01:00
Adriano 06f308be6f Visione: quota non prodotta salvata come NaN (value NULL, fail) invece del 422; migrazione 022
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:21:54 +01:00
Adriano 93afd6187c Unione lavoro/daitarn-tmf-wizard (Refs #118 #119 #120 #121)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:21:33 +01:00
Adriano 8217f46d47 Unione lavoro/daitarn-tmf-operatore (Refs #118 #119 #120 #121)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:21:33 +01:00
Adriano d4802fd1a3 Unione lavoro/daitarn-tmf-svg (Refs #118 #119 #120 #121)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:21:33 +01:00
Adriano ea64f3c524 Wizard DXF e riepilogo: overlay SVG, Salva immagine, NaN per quota non misurata
Refs #118 #119 #121

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:20:26 +01:00
Adriano 58baed19e2 Pannello visione operatore: barra senza input file con camera, Salva, Registra conta le quote, svg al posto del canvas, NaN
Refs #118 #119 #120 #121

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:20:21 +01:00
Adriano 25b0741003 Test del helper di salvataggio immagine e fix pulizia backslash nel nome
Refs #119

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:18:20 +01:00
Adriano df67a32a82 Test dell'overlay SVG e delle etichette senza valore
Refs #121 #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:17:29 +01:00
Adriano 5723f055c4 Overlay SVG e helper salva immagine per il pannello visione
Refs #121 #119 #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:16:11 +01:00
Adriano 4485803122 Unione vision_overlay.js nel pannello visione (Refs #116)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 17:01:28 +01:00
Adriano acd94a9d33 Pannello visione: integrazione overlay ed etichette quota (rows, drawOverlay, overlayLabels)
Refs #116

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:00:48 +01:00
Adriano 88b3f202b1 Pannello visione: nominale e tolleranze nella tabella quote
Refs #117

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:57:27 +01:00
Adriano 4a11d2595f Pannello visione: zoom, Adatta e trascinamento sull'immagine
Refs #115

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:54:13 +01:00
Adriano b46ccba1ee Pannello visione: modulo vision_overlay.js (forme overlay, ancore, etichette) con test node e fixture task 14
Refs #116

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:52:50 +01:00
63 changed files with 4524 additions and 822 deletions
+3
View File
@@ -76,3 +76,6 @@ docker-compose.override.yml
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point # Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
packaging/windows/_strumenti.py packaging/windows/_strumenti.py
# #109: scritto da packaging/windows/sviluppo.ps1 (sha del submodule), non va versionato
/vision-engine-version.txt
+1 -1
View File
@@ -289,7 +289,7 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`. Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691. Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 9eb6f4c.
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata: Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
+21 -2
View File
@@ -4,14 +4,15 @@ Nuitka (che richiede ~20 minuti): stessi quattro server e stesse porte dell'exe
8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow. 8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow.
Prima dell'avvio: ambiente Python 3.13 in build\venv313 (uv sync, solo con -Sincronizza o se manca), Prima dell'avvio: ambiente Python 3.13 in build\venv313 (uv sync, solo con -Sincronizza o se manca),
tailwind.css e traduzioni .mo ricompilati, DLL di WeasyPrint in build\gtk (scaricate con tailwind.css e traduzioni .mo ricompilati, DLL di WeasyPrint in build\gtk (scaricate con
pdf\prendi-gtk.ps1 se mancano). pdf\prendi-gtk.ps1 se mancano). Scrive vision-engine-version.txt nella radice (sha del submodule
vendor/visionsuite) se git c'e'; senza git e senza il file si ferma con un errore chiaro (#109).
Come si usa (dalla radice del repo): Come si usa (dalla radice del repo):
powershell -ExecutionPolicy Bypass -File packaging\windows\sviluppo.ps1 [-Sincronizza] [-Ascolto 0.0.0.0] [-EscludiCamere OECS] powershell -ExecutionPolicy Bypass -File packaging\windows\sviluppo.ps1 [-Sincronizza] [-Ascolto 0.0.0.0] [-EscludiCamere OECS]
Dopo una modifica: Ctrl+C e rilancio (pochi secondi). Template e JS non richiedono altro; Dopo una modifica: Ctrl+C e rilancio (pochi secondi). Template e JS non richiedono altro;
classi Tailwind nuove e msgid nuovi vengono ricompilati a ogni avvio. classi Tailwind nuove e msgid nuovi vengono ricompilati a ogni avvio.
Dipende da: uv, npx (Node), la licenza tiemeasureflow di QUESTA macchina in Dipende da: uv, npx (Node), git (opzionale: senza, serve vision-engine-version.txt gia' nella radice), la licenza tiemeasureflow di QUESTA macchina in
%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json (impronta: avvio.py --impronta). %PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json (impronta: avvio.py --impronta).
#> #>
param( param(
@@ -47,6 +48,24 @@ if (-not (Test-Path (Join-Path $gtk '*.dll'))) {
} }
$env:WEASYPRINT_DLL_DIRECTORIES = $gtk $env:WEASYPRINT_DLL_DIRECTORIES = $gtk
# #109: il worker di visione rifiuta ogni job senza VISION_ENGINE_VERSION, che avvio.py legge da
# <radice>\vision-engine-version.txt (come nel pacchetto, build.ps1). Da sorgente lo scrive questo script.
$versione = Join-Path $radice 'vision-engine-version.txt'
$shaMotore = $null
# #109: solo se il submodule e' inizializzato (ha .git): altrimenti git risale al repo padre e darebbe lo sha di TMF
if ((Get-Command git -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $radice 'vendor\visionsuite\.git'))) {
$shaMotore = (git -C (Join-Path $radice 'vendor\visionsuite') rev-parse HEAD 2>$null)
if ($LASTEXITCODE -ne 0) { $shaMotore = $null }
}
if ($shaMotore) {
# 40 caratteri ASCII, UTF8 senza BOM e senza a capo (avvio.py fa .strip(), che non toglie il BOM)
[System.IO.File]::WriteAllText($versione, $shaMotore.Trim(), (New-Object System.Text.UTF8Encoding $false))
} elseif (Test-Path $versione) {
Write-Host "git non disponibile: uso $versione = $((Get-Content -Raw $versione).Trim())"
} else {
throw "Manca vision-engine-version.txt e git non puo' leggere vendor\visionsuite. Rimedio: crea l'archivio con 'uv run python scripts/archivio_sorgente.py --out tmf.zip' da un clone con git, oppure scrivi in $versione lo sha del submodule (40 caratteri, ASCII, senza BOM e senza a capo)."
}
if ($EscludiCamere) { $env:VS_CAMERA_ESCLUDI = $EscludiCamere } # #339: stesso percorso camera del PC fiera if ($EscludiCamere) { $env:VS_CAMERA_ESCLUDI = $EscludiCamere } # #339: stesso percorso camera del PC fiera
Set-Location $radice Set-Location $radice
& $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox & $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox
+10
View File
@@ -124,6 +124,16 @@ def test_variabili_del_contratto(avvio, tmp_path):
assert len(e[k]) >= 32 assert len(e[k]) >= 32
def test_versione_motore_visione_letta_dalla_radice_sorgente(avvio, tmp_path, monkeypatch):
# #109: da sorgente il file vision-engine-version.txt sta nella radice del repo e arriva al worker
prog = tmp_path / "prog"
prog.mkdir()
(prog / "vision-engine-version.txt").write_bytes(b"a" * 40)
monkeypatch.setattr(avvio, "cartella_programma", lambda: prog)
avvio.imposta_ambiente(tmp_path / "dati", "127.0.0.1", {k: "x" * 32 for k in avvio.SEGRETI})
assert os.environ["VISION_ENGINE_VERSION"] == "a" * 40
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path): def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
dati = tmp_path / "Tielogic" / "TieMeasureFlow" dati = tmp_path / "Tielogic" / "TieMeasureFlow"
primo = avvio.carica_segreti(dati) primo = avvio.carica_segreti(dati)
+108
View File
@@ -0,0 +1,108 @@
"""Archivio sorgente di TieMeasureFlow per un PC senza git (TieMeasureFlow#109).
Cosa fa
Crea uno zip del repo a un commit, con dentro anche il submodule vendor/visionsuite (che
`git archive` da solo NON include) e il file vision-engine-version.txt nella radice dello zip.
Quel file contiene lo sha del submodule REGISTRATO nel commit (non la HEAD del checkout del
submodule): packaging/windows/avvio.py lo legge e lo passa al worker di visione come
VISION_ENGINE_VERSION; senza, il worker rifiuta ogni job. Formato: 40 caratteri ASCII,
senza BOM e senza a capo (avvio.py usa .strip(), che non toglie un BOM).
Come si usa (dalla radice del repo, con il submodule inizializzato)
uv run python scripts/archivio_sorgente.py --out C:/tmp/tmf.zip [--ref HEAD]
Esce 0 e stampa lo sha; esce 1 con un messaggio e senza lasciare lo zip se il submodule
non e' inizializzato o non ha il commit registrato (git submodule update --init vendor/visionsuite).
Dipende da: git nel PATH, solo libreria standard.
"""
from __future__ import annotations
import argparse
import io
import os
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
SUBMODULE = "vendor/visionsuite"
FILE_VERSIONE = "vision-engine-version.txt"
class ErroreArchivio(RuntimeError):
"""Errore chiaro da mostrare all'operatore (niente traceback)."""
def _git(cartella: Path, *args: str, binario: bool = False):
"""Esegue git in `cartella`; solleva ErroreArchivio con lo stderr se fallisce."""
r = subprocess.run(["git", "-C", str(cartella), *args], capture_output=True)
if r.returncode != 0:
raise ErroreArchivio(f"git {' '.join(args)} fallito in {cartella}: {r.stderr.decode(errors='replace').strip()}")
return r.stdout if binario else r.stdout.decode().strip()
def sha_registrato(repo: Path, ref: str) -> str:
"""Sha del submodule registrato nel commit `ref` (gitlink, modo 160000), non la HEAD del checkout."""
campi = _git(repo, "ls-tree", ref, SUBMODULE).split()
if len(campi) < 3 or campi[0] != "160000":
raise ErroreArchivio(f"{SUBMODULE} non e' un submodule registrato in {ref}")
return campi[2]
def crea_archivio(repo: Path, out: Path, ref: str = "HEAD") -> str:
"""Scrive lo zip in `out` e ritorna lo sha del submodule; in caso di errore non lascia nulla."""
repo, out = Path(repo), Path(out)
sha = sha_registrato(repo, ref)
sub = repo / SUBMODULE
# submodule non inizializzato o senza il commit registrato: errore prima di scrivere lo zip
if not (sub / ".git").exists():
raise ErroreArchivio(f"{SUBMODULE} non e' inizializzato: git submodule update --init {SUBMODULE}")
if subprocess.run(["git", "-C", str(sub), "cat-file", "-e", f"{sha}^{{commit}}"], capture_output=True).returncode != 0:
raise ErroreArchivio(f"{SUBMODULE} non ha il commit {sha}: git submodule update --init {SUBMODULE}")
zip_repo = _git(repo, "archive", "--format=zip", ref, binario=True)
zip_sub = _git(sub, "archive", "--format=zip", f"--prefix={SUBMODULE}/", sha, binario=True)
out.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(suffix=".zip.tmp", dir=out.parent) # scrittura atomica: o c'e' tutto o niente
os.close(fd)
try:
viste: set[str] = set()
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
for blob in (zip_repo, zip_sub):
with zipfile.ZipFile(io.BytesIO(blob)) as sorgente:
for info in sorgente.infolist():
if info.filename in viste:
continue # es. la cartella vendor/visionsuite/ del gitlink, gia' nel primo zip
viste.add(info.filename)
z.writestr(info, sorgente.read(info.filename), compress_type=zipfile.ZIP_DEFLATED)
if FILE_VERSIONE in viste:
raise ErroreArchivio(f"{FILE_VERSIONE} e' gia' nel repo a {ref}: va tolto dal versionamento")
# 40 caratteri ASCII, senza BOM e senza a capo (avvio.py fa .strip(), che non toglie un BOM)
z.writestr(FILE_VERSIONE, sha.encode("ascii"))
os.replace(tmp, out)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
return sha
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Zip del sorgente con submodule e vision-engine-version.txt (#109)")
p.add_argument("--out", required=True, type=Path, help="file .zip da creare")
p.add_argument("--ref", default="HEAD", help="commit da archiviare (default HEAD)")
a = p.parse_args(argv)
radice = Path(__file__).resolve().parents[1]
try:
sha = crea_archivio(radice, a.out, a.ref)
except ErroreArchivio as e:
print(f"ERRORE: {e}", file=sys.stderr)
return 1
print(f"{a.out} creato; {FILE_VERSIONE} = {sha}")
return 0
if __name__ == "__main__":
sys.exit(main())
+56 -164
View File
@@ -17,7 +17,9 @@ un'opzione della calibrazione: la calibrazione usa sempre il frame grezzo della
scelta nella tab Acquisizione. Sorgente in cima alla tab; riquadri «Immagine» (grezza) e, scelta nella tab Acquisizione. Sorgente in cima alla tab; riquadri «Immagine» (grezza) e,
con la casella, «Calibrata» (/camera/anteprima/calibrata) affiancati; il ciclo anteprima con la casella, «Calibrata» (/camera/anteprima/calibrata) affiancati; il ciclo anteprima
si sospende mentre la tab Calibrazione e' aperta. si sospende mentre la tab Calibrazione e' aperta.
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati. #122: i quattro visori sono tmf-viewer; i tre file JS del frontend (vision_overlay, save_image, image_viewer)
li serve camera_service su /visore/.
Dipende da: i tre file JS su /visore/ (camera_service). E' una stringa, cosi' il pacchetto compilato non ha file dati.
""" """
PAGINA = r"""<!doctype html> PAGINA = r"""<!doctype html>
@@ -66,9 +68,7 @@ PAGINA = r"""<!doctype html>
/* anteprima */ /* anteprima */
.palco { background: #020617; border: 1px solid var(--bordo); border-radius: 6px; min-height: 340px; .palco { background: #020617; border: 1px solid var(--bordo); border-radius: 6px; min-height: 340px;
display: flex; align-items: center; justify-content: center; margin-top: 10px; overflow: hidden; } display: flex; align-items: center; justify-content: center; margin-top: 10px; overflow: hidden; }
.cornice { position: relative; display: inline-block; line-height: 0; max-width: 100%; } .mirino { position: absolute; inset: 0; pointer-events: none; /* #92: classe, vale per i due riquadri; #122: sta dentro lo stage del tmf-viewer */
.cornice img { max-width: 100%; max-height: calc(100vh - 330px); min-height: 200px; border: 0; margin: 0; }
.mirino { position: absolute; inset: 0; pointer-events: none; /* #92: classe, vale per i due riquadri */
background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat, background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; } linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; }
#vuoto, #vuotoCal, #vuotoCalTab { padding: 30px 16px; color: var(--secondario); text-align: center; } #vuoto, #vuotoCal, #vuotoCalTab { padding: 30px 16px; color: var(--secondario); text-align: center; }
@@ -78,21 +78,10 @@ PAGINA = r"""<!doctype html>
.riquadro .titolo { font-size: .88rem; font-weight: 600; } .riquadro .titolo { font-size: .88rem; font-weight: 600; }
.riquadro .titolo span { font-weight: 400; font-size: .78rem; color: var(--secondario); margin-left: 6px; } .riquadro .titolo span { font-weight: 400; font-size: .78rem; color: var(--secondario); margin-left: 6px; }
.riquadro .palco { margin-top: 4px; } .riquadro .palco { margin-top: 4px; }
/* #111: visore zoom. .visore (position:relative) NON scorre: la barra e' sua figlia e resta ferma; a scorrere e' il .palco da zoomato */ /* #122: i visori sono tmf-viewer (static/js/image_viewer.js servito su /visore/): barra, zoom e pan vengono dal componente.
.visore { position: relative; margin-top: 10px; } Altezza data dalla pagina (come l'altezza massima che avevano le immagini); [hidden] vale gia' sopra */
.riquadro .visore { margin-top: 4px; } .palco tmf-viewer { width: 100%; height: max(340px, calc(100vh - 330px)); }
.visore > .palco { margin-top: 0; } .angoli { display: block; width: 100%; margin-top: 8px; border: 1px solid var(--bordo); border-radius: 6px; } /* #122: immagine degli angoli, alta quanto serve (fit-height) */
.barraZoom { position: absolute; top: 8px; right: 8px; z-index: 3; display: flex; align-items: center; gap: 4px;
padding: 3px 5px; border-radius: 6px; background: rgba(0, 0, 0, .6); color: #fff; font-size: .8rem; }
.barraZoom button { padding: 2px 9px; font-size: .85rem; background: rgba(255, 255, 255, .15); color: #fff; }
.barraZoom span { min-width: 3.4em; text-align: right; font-variant-numeric: tabular-nums; }
.palco.zoomata + .barraZoom { right: 26px; } /* #111: lascia libera la barra di scorrimento verticale */
.visore:has(.cornice[hidden]) .barraZoom { display: none; } /* #111: barra nascosta finche' la cornice e' nascosta */
/* #111: stato zoomato: palco ad altezza fissa che scorre, cornice a dimensioni esplicite (impostate da JS); centrata con margin:auto solo se piu' piccola */
.palco.zoomata { display: flex; align-items: flex-start; justify-content: flex-start; overflow: auto; cursor: grab; touch-action: none; }
.palco.zoomata.panning { cursor: grabbing; }
.palco.zoomata .cornice { display: block; flex: none; margin: auto; max-width: none; }
.palco.zoomata .cornice img { display: block; width: 100%; height: 100%; max-width: none; max-height: none; min-height: 0; }
.sorgente-riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; } .sorgente-riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.sorgente-riga .campo-sorgente { flex: 1 1 260px; min-width: 0; } .sorgente-riga .campo-sorgente { flex: 1 1 260px; min-width: 0; }
.sorgente-riga select { width: 100%; } .sorgente-riga select { width: 100%; }
@@ -128,7 +117,7 @@ PAGINA = r"""<!doctype html>
@media (max-width: 900px) { @media (max-width: 900px) {
.griglia { grid-template-columns: minmax(0, 1fr); } .griglia { grid-template-columns: minmax(0, 1fr); }
.riquadri.doppio { grid-template-columns: minmax(0, 1fr); } /* #92: in colonna */ .riquadri.doppio { grid-template-columns: minmax(0, 1fr); } /* #92: in colonna */
.cornice img { max-height: 60vh; } .palco tmf-viewer { height: max(340px, 60vh); } /* #122: era max-height delle immagini */
} }
@media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */ @media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */
main { padding: 8px; } main { padding: 8px; }
@@ -179,28 +168,18 @@ PAGINA = r"""<!doctype html>
<div class="riquadri" id="riquadri"> <div class="riquadri" id="riquadri">
<div class="riquadro"> <div class="riquadro">
<div class="titolo">Immagine<span>grezza, senza correzione</span></div> <div class="titolo">Immagine<span>grezza, senza correzione</span></div>
<div class="visore"> <!-- #111 --> <div class="palco"> <!-- #122: tmf-viewer al posto del visore #111; width/height = risoluzione della camera, messe da tornaAdatta() -->
<div class="palco">
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div> <div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="cornice" hidden> <tmf-viewer id="immagine" save-name="grezza" title-save="Salva l'immagine grezza" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
<img id="immagine" alt="Anteprima grezza della sorgente"> <div id="mirino" class="mirino" hidden></div> <!-- #122: spostato nello stage del componente da creaVisore() -->
<div id="mirino" class="mirino" hidden></div>
</div>
</div>
<div class="barraZoom"><button id="zoomMeno" class="secondario" type="button" title="Riduci">&minus;</button><button id="zoomAdatta" class="secondario" type="button" title="Adatta allo spazio">Adatta</button><button id="zoomPiu" class="secondario" type="button" title="Ingrandisci">+</button><span id="zoomPct">100 %</span></div>
</div> </div>
</div> </div>
<div class="riquadro" id="riquadroCal" hidden> <div class="riquadro" id="riquadroCal" hidden>
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div> <div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
<div class="visore"> <!-- #111 --> <div class="palco"> <!-- #122 -->
<div class="palco">
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div> <div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="corniceCal" hidden> <tmf-viewer id="immagineCal" save-name="calibrata" title-save="Salva l'immagine calibrata" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
<img id="immagineCal" alt="Anteprima della sorgente raddrizzata dalla calibrazione"> <div id="mirinoCal" class="mirino" hidden></div>
<div id="mirinoCal" class="mirino" hidden></div>
</div>
</div>
<div class="barraZoom"><button id="zoomMenoCal" class="secondario" type="button" title="Riduci">&minus;</button><button id="zoomAdattaCal" class="secondario" type="button" title="Adatta allo spazio">Adatta</button><button id="zoomPiuCal" class="secondario" type="button" title="Ingrandisci">+</button><span id="zoomPctCal">100 %</span></div>
</div> </div>
<!-- #93: scelta della calibrazione da attivare sulla sorgente, con CONFERMA --> <!-- #93: scelta della calibrazione da attivare sulla sorgente, con CONFERMA -->
<div class="riga scelta-cal"> <div class="riga scelta-cal">
@@ -260,14 +239,9 @@ PAGINA = r"""<!doctype html>
<h2>Calibrazione</h2> <h2>Calibrazione</h2>
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> — immagine grezza dalla tab Acquisizione</div> <div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> — immagine grezza dalla tab Acquisizione</div>
<!-- #94: immagine viva (stesso frame dell'anteprima) e «Blocca immagine» per calcolare su quella --> <!-- #94: immagine viva (stesso frame dell'anteprima) e «Blocca immagine» per calcolare su quella -->
<div class="visore" style="margin-top:0"> <!-- #111 --> <div class="palco" style="margin-top:0"> <!-- #122 -->
<div class="palco" style="margin-top:0">
<div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div> <div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
<div class="cornice" id="corniceCalTab" hidden> <tmf-viewer id="immagineCalTab" save-name="calibrazione" title-save="Salva l'immagine della calibrazione" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
<img id="immagineCalTab" alt="Immagine grezza della sorgente">
</div>
</div>
<div class="barraZoom"><button id="zoomMenoCalTab" class="secondario" type="button" title="Riduci">&minus;</button><button id="zoomAdattaCalTab" class="secondario" type="button" title="Adatta allo spazio">Adatta</button><button id="zoomPiuCalTab" class="secondario" type="button" title="Ingrandisci">+</button><span id="zoomPctCalTab">100 %</span></div>
</div> </div>
<div class="riga" style="margin-top:8px"> <div class="riga" style="margin-top:8px">
<button id="scattaCal" class="secondario" type="button">Scatta</button> <button id="scattaCal" class="secondario" type="button">Scatta</button>
@@ -294,7 +268,7 @@ PAGINA = r"""<!doctype html>
<button id="salva">Salva</button> <button id="salva">Salva</button>
</div> </div>
<div id="msgSalva" class="messaggio"></div> <div id="msgSalva" class="messaggio"></div>
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px"> <tmf-viewer id="overlay" class="angoli" fit-height no-layers save-name="angoli" title-save="Salva l'immagine degli angoli"></tmf-viewer> <!-- #122: era un <img> senza zoom; #126: no-layers -->
</div> </div>
</section> </section>
@@ -323,6 +297,10 @@ PAGINA = r"""<!doctype html>
</div> </div>
</main> </main>
<!-- #122: file del frontend serviti da camera_service su /visore/ (locali: la pagina resta offline); l'ordine conta -->
<script src="/visore/vision_overlay.js"></script>
<script src="/visore/save_image.js"></script>
<script src="/visore/image_viewer.js"></script>
<script> <script>
"use strict"; "use strict";
var $ = function (id) { return document.getElementById(id); }; var $ = function (id) { return document.getElementById(id); };
@@ -510,130 +488,44 @@ function cambioSorgente(salva) {
// #91: ris = {w, h} dell'ultimo frame; #92: calAttiva = la sorgente ha una calibrazione attiva (null = non nota), bloccoCal = un 409 ha fermato le richieste calibrata; urlCal/seqCal = riquadro calibrata, bloccata/sorgenteBloccata/urlBlocco/seqBlocco = #94 immagine bloccata per il calcolo (il riquadro della tab Calibrazione non segue piu' il ciclo), lato = ultimo lato_max // #91: ris = {w, h} dell'ultimo frame; #92: calAttiva = la sorgente ha una calibrazione attiva (null = non nota), bloccoCal = un 409 ha fermato le richieste calibrata; urlCal/seqCal = riquadro calibrata, bloccata/sorgenteBloccata/urlBlocco/seqBlocco = #94 immagine bloccata per il calcolo (il riquadro della tab Calibrazione non segue piu' il ciclo), lato = ultimo lato_max
var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, bloccata: false, bloccando: 0, sorgenteBloccata: null, urlBlocco: null, seqBlocco: 0, lato: 1280, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null }; var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, bloccata: false, bloccando: 0, sorgenteBloccata: null, urlBlocco: null, seqBlocco: 0, lato: 1280, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null };
// ---- #111: visori con zoom +/-, Adatta e pan (come il wizard DXF #104), uno per riquadro immagine; ognuno ha il suo zoom // ---- #122: visori = tmf-viewer (zoom, Adatta, Salva, pan e Ctrl+rotella vengono dal componente, come nel resto di TMF)
// z = pixel CSS per pixel di camera (100 % = pixel della camera), limiti [zFit, ZMAX]; le dimensioni del frame pieno sono A.ris // width/height del componente = risoluzione del frame pieno (A.ris): 100 % = pixel della camera, anche se l'anteprima scaricata e' piu' piccola
var ZMAX = 8, PASSO = 1.25;
var V = {}; var V = {};
var sondaAlt = null; // #122: tutti i visori dell'anteprima tornano ad Adatta con le dimensioni di A.ris (cambio sorgente, risoluzione del frame diversa); A.ris ignoto = dimensioni naturali
// #111: altezza massima del palco = max-height che il CSS da' alle immagini (letta da una sonda nascosta: nessun numero duplicato, segue la media query)
function altezzaMax() {
if (!sondaAlt) {
sondaAlt = document.createElement("div");
sondaAlt.className = "cornice";
sondaAlt.hidden = true;
sondaAlt.appendChild(document.createElement("img"));
document.body.appendChild(sondaAlt);
}
var h = parseFloat(getComputedStyle(sondaAlt.firstChild).maxHeight);
return isFinite(h) && h > 0 ? h : 400;
}
// #111: zoom che fa stare tutto il frame nel palco (0 = non calcolabile: palco nascosto o risoluzione ignota)
function zFit(v) {
var larg = v.palco.offsetWidth - 2 * v.palco.clientLeft; // larghezza utile senza la barra di scorrimento
if (!A.ris || larg <= 0) return 0;
return Math.min(larg / A.ris.w, altezzaMax() / A.ris.h);
}
function zoomata(v) { return v.palco.classList.contains("zoomata"); }
// #111: applica lo stato al DOM. Adatta (z == zFit): nessuna regola nuova, layout di prima. Zoomato: palco ad altezza fissa che scorre, cornice W*z x H*z
function applica(v) {
var zf = zFit(v);
if (!zf) return;
var z = v.adatta ? zf : Math.max(zf, Math.min(ZMAX, v.z));
v.z = z;
if (z > zf * 1.0001) {
v.palco.classList.add("zoomata");
v.palco.style.height = altezzaMax() + "px";
v.cornice.style.width = (A.ris.w * z) + "px";
v.cornice.style.height = (A.ris.h * z) + "px";
} else {
v.palco.classList.remove("zoomata", "panning");
v.palco.style.height = v.cornice.style.width = v.cornice.style.height = "";
}
v.pct.textContent = Math.round(z * 100) + " %";
}
// #111: zoom che tiene fermo il centro visibile (stessa idea del wizard), centro misurato in pixel di camera
function zoomBy(v, f) {
var zf = zFit(v);
if (!zf) return;
var vecchio = v.z || zf;
var z = Math.max(zf, Math.min(ZMAX, vecchio * f));
if (z === vecchio) return;
var p = v.palco, pr = p.getBoundingClientRect(), cr = v.cornice.getBoundingClientRect();
var cx = A.ris.w / 2, cy = A.ris.h / 2;
if (cr.width > 0 && cr.height > 0) {
cx = Math.max(0, Math.min(A.ris.w, (pr.left + p.clientLeft + p.clientWidth / 2 - cr.left) / (cr.width / A.ris.w)));
cy = Math.max(0, Math.min(A.ris.h, (pr.top + p.clientTop + p.clientHeight / 2 - cr.top) / (cr.height / A.ris.h)));
}
v.z = z;
v.adatta = z <= zf * 1.0001;
applica(v);
if (zoomata(v)) { p.scrollLeft = cx * z - p.clientWidth / 2; p.scrollTop = cy * z - p.clientHeight / 2; }
}
function adattaVisore(v) { // #111: nome distinto da adatta(d, v) dei parametri camera
v.adatta = true;
v.z = 0;
applica(v);
v.palco.scrollLeft = 0;
v.palco.scrollTop = 0;
}
// #111: tutti i visori tornano ad Adatta (cambio sorgente, risoluzione del frame diversa); con A.ris ignoto si toglie anche lo stato zoomato
function tornaAdatta() { function tornaAdatta() {
Object.keys(V).forEach(function (k) { Object.keys(V).forEach(function (k) {
var v = V[k]; var el = V[k].el;
v.adatta = true; if (A.ris) { el.setAttribute("width", A.ris.w); el.setAttribute("height", A.ris.h); }
v.z = 0; else { el.removeAttribute("width"); el.removeAttribute("height"); }
v.palco.classList.remove("zoomata", "panning"); if (el.fit) el.fit();
v.palco.style.height = v.cornice.style.width = v.cornice.style.height = "";
v.palco.scrollLeft = v.palco.scrollTop = 0;
applica(v);
}); });
} }
// #111: lato_max dell'anteprima: 1280 come prima se nessun visore dell'Acquisizione e' zoomato, altrimenti abbastanza pixel per lo zoom piu' alto // #122: lato_max dell'anteprima: 1280 come prima se nessun visore dell'Acquisizione e' ingrandito, altrimenti abbastanza pixel per lo zoom piu' alto (zoom dall'evento viewer-zoom)
function latoAnteprima() { function latoAnteprima() {
var zm = 0; var zm = 0;
["acq", "cal"].forEach(function (k) { ["acq", "cal"].forEach(function (k) {
var v = V[k]; var v = V[k];
if (v.palco.offsetWidth > 0 && zoomata(v) && v.z > zm) zm = v.z; if (v.el.offsetWidth > 0 && v.fit && v.z > v.fit * 1.0001 && v.z > zm) zm = v.z;
}); });
if (!zm || !A.ris) return 1280; if (!zm || !A.ris) return 1280;
return Math.min(4096, Math.max(1280, Math.ceil(Math.max(A.ris.w, A.ris.h) * zm * (window.devicePixelRatio || 1)))); return Math.min(4096, Math.max(1280, Math.ceil(Math.max(A.ris.w, A.ris.h) * zm * (window.devicePixelRatio || 1))));
} }
function creaVisore(chiave, suf, idCornice, idImg) { function creaVisore(chiave, idViewer, idMirino) {
var cornice = $(idCornice); var el = $(idViewer);
var v = { cornice: cornice, img: $(idImg), palco: cornice.parentNode, pct: $("zoomPct" + suf), z: 0, adatta: true, pan: null }; var v = { el: el, z: 0, fit: 0 };
V[chiave] = v; V[chiave] = v;
$("zoomMeno" + suf).onclick = function () { zoomBy(v, 1 / PASSO); }; el.addEventListener("viewer-zoom", function (ev) { v.z = ev.detail.zoom; v.fit = ev.detail.fit; });
$("zoomPiu" + suf).onclick = function () { zoomBy(v, PASSO); }; // #122: il mirino sta dentro lo stage del componente (segue immagine e zoom); lo stage esiste dopo il connectedCallback, si riprova a ogni viewer-load
$("zoomAdatta" + suf).onclick = function () { adattaVisore(v); }; var monta = function () {
// #111: Ctrl+rotella = zoom; senza Ctrl la rotella scorre la pagina var stage = el.querySelector(".tmfv-stage"), m = idMirino ? $(idMirino) : null;
v.palco.addEventListener("wheel", function (ev) { if (stage && m && m.parentNode !== stage) stage.appendChild(m);
if (!ev.ctrlKey) return; };
ev.preventDefault(); monta();
zoomBy(v, ev.deltaY < 0 ? PASSO : 1 / PASSO); el.addEventListener("viewer-load", monta);
}, { passive: false });
// #111: pan col puntatore (solo zoomato): scroll del palco di quanto si muove il puntatore
v.palco.addEventListener("pointerdown", function (ev) {
if (!zoomata(v) || (ev.pointerType === "mouse" && ev.button)) return;
ev.preventDefault(); // senza, Chromium manda pointercancel al primo movimento e il pan si ferma
v.pan = { x: ev.clientX, y: ev.clientY, left: v.palco.scrollLeft, top: v.palco.scrollTop };
v.palco.classList.add("panning");
try { v.palco.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
});
v.palco.addEventListener("pointermove", function (ev) {
if (!v.pan) return;
v.palco.scrollLeft = v.pan.left - (ev.clientX - v.pan.x);
v.palco.scrollTop = v.pan.top - (ev.clientY - v.pan.y);
});
var fine = function () { v.pan = null; v.palco.classList.remove("panning"); };
v.palco.addEventListener("pointerup", fine);
v.palco.addEventListener("pointercancel", fine);
// #111: palco ridimensionato (finestra, griglia a due riquadri, tab mostrata): ricalcola zFit e riapplica
if (window.ResizeObserver) new ResizeObserver(function () { applica(v); }).observe(v.palco);
else window.addEventListener("resize", function () { applica(v); });
} }
creaVisore("acq", "", "cornice", "immagine"); creaVisore("acq", "immagine", "mirino");
creaVisore("cal", "Cal", "corniceCal", "immagineCal"); creaVisore("cal", "immagineCal", "mirinoCal");
creaVisore("tab", "CalTab", "corniceCalTab", "immagineCalTab"); creaVisore("tab", "immagineCalTab", "");
function azzeraIndicatori() { function azzeraIndicatori() {
A.nitMax = 0; A.nitMax = 0;
@@ -653,17 +545,17 @@ function pulsantiAnteprima() {
function mostraImmagine(blob) { function mostraImmagine(blob) {
var vecchio = A.url; var vecchio = A.url;
A.url = URL.createObjectURL(blob); A.url = URL.createObjectURL(blob);
$("immagine").src = A.url; $("immagine").setAttribute("src", A.url); // #122
if (vecchio && vecchio !== A.urlBlocco) URL.revokeObjectURL(vecchio); // niente memoria che cresce a ogni frame; #94: non quello bloccato if (vecchio && vecchio !== A.urlBlocco) URL.revokeObjectURL(vecchio); // niente memoria che cresce a ogni frame; #94: non quello bloccato
$("cornice").hidden = false; $("immagine").hidden = false;
$("vuoto").hidden = true; $("vuoto").hidden = true;
if (!A.bloccata) mostraInTabCal(A.url); // #94: lo stesso blob URL va anche nella tab Calibrazione, salvo immagine bloccata if (!A.bloccata) mostraInTabCal(A.url); // #94: lo stesso blob URL va anche nella tab Calibrazione, salvo immagine bloccata
} }
// #94: riquadro «immagine viva» della tab Calibrazione (url null = vuoto) // #94: riquadro «immagine viva» della tab Calibrazione (url null = vuoto)
function mostraInTabCal(url) { function mostraInTabCal(url) {
$("immagineCalTab").src = url || ""; if (url) $("immagineCalTab").setAttribute("src", url); else $("immagineCalTab").removeAttribute("src"); // #122
$("corniceCalTab").hidden = !url; $("immagineCalTab").hidden = !url;
$("vuotoCalTab").hidden = !!url; $("vuotoCalTab").hidden = !!url;
} }
@@ -671,14 +563,14 @@ function mostraInTabCal(url) {
function mostraCalibrata(blob, nome, soloScala) { function mostraCalibrata(blob, nome, soloScala) {
var vecchio = A.urlCal; var vecchio = A.urlCal;
A.urlCal = URL.createObjectURL(blob); A.urlCal = URL.createObjectURL(blob);
$("immagineCal").src = A.urlCal; $("immagineCal").setAttribute("src", A.urlCal); // #122
if (vecchio) URL.revokeObjectURL(vecchio); if (vecchio) URL.revokeObjectURL(vecchio);
$("corniceCal").hidden = false; $("immagineCal").hidden = false;
$("vuotoCal").hidden = true; $("vuotoCal").hidden = true;
$("nomeCalibrata").textContent = (nome ? "«" + nome + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo $("nomeCalibrata").textContent = (nome ? "«" + nome + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo
} }
function messaggioCalibrata(testo) { function messaggioCalibrata(testo) {
$("corniceCal").hidden = true; $("immagineCal").hidden = true;
$("vuotoCal").hidden = false; $("vuotoCal").hidden = false;
$("vuotoCal").textContent = testo; $("vuotoCal").textContent = testo;
$("nomeCalibrata").textContent = ""; $("nomeCalibrata").textContent = "";
@@ -686,7 +578,7 @@ function messaggioCalibrata(testo) {
// #92: cambio sorgente: via i due frame, nessuna immagine di un'altra sorgente // #92: cambio sorgente: via i due frame, nessuna immagine di un'altra sorgente
function svuotaRiquadri() { function svuotaRiquadri() {
A.seqCal++; // una risposta calibrata ancora in volo non vale piu' A.seqCal++; // una risposta calibrata ancora in volo non vale piu'
$("cornice").hidden = true; $("immagine").hidden = true;
$("vuoto").hidden = false; $("vuoto").hidden = false;
mostraInTabCal(null); // #94: svuotato anche il riquadro della tab Calibrazione mostraInTabCal(null); // #94: svuotato anche il riquadro della tab Calibrazione
messaggioCalibrata("Premere «Avvia anteprima» o «Scatta»."); messaggioCalibrata("Premere «Avvia anteprima» o «Scatta».");
@@ -832,7 +724,7 @@ function sbloccaLocale() { // casella spenta, il riquadro della tab Calibrazion
if (b && b !== A.url) URL.revokeObjectURL(b); if (b && b !== A.url) URL.revokeObjectURL(b);
$("bloccaImg").checked = false; $("bloccaImg").checked = false;
$("scattaCal").disabled = $("scatta").disabled; $("scattaCal").disabled = $("scatta").disabled;
mostraInTabCal($("cornice").hidden ? null : A.url); mostraInTabCal($("immagine").hidden ? null : A.url);
} }
function rilasciaBlocco() { // DELETE idempotente: l'esito non conta function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
var s = A.sorgenteBloccata; var s = A.sorgenteBloccata;
@@ -843,7 +735,7 @@ function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
$("bloccaImg").onchange = async function () { $("bloccaImg").onchange = async function () {
if (!this.checked) { rilasciaBlocco(); return; } if (!this.checked) { rilasciaBlocco(); return; }
var sorgente = $("sorgente").value; var sorgente = $("sorgente").value;
if (!sorgente || $("cornice").hidden) { if (!sorgente || $("immagine").hidden) {
$("bloccaImg").checked = false; $("bloccaImg").checked = false;
dire("msgBlocco", "Nessuna immagine da bloccare: «Scatta» o avviare l'anteprima.", "errore"); dire("msgBlocco", "Nessuna immagine da bloccare: «Scatta» o avviare l'anteprima.", "errore");
return; return;
@@ -1514,7 +1406,7 @@ async function calcola() {
var d = await r.json(); var d = await r.json();
ultimo = { dati: d }; ultimo = { dati: d };
tabella($("tabella"), d); tabella($("tabella"), d);
$("overlay").src = "data:image/png;base64," + d.overlay_png; $("overlay").setAttribute("src", "data:image/png;base64," + d.overlay_png); // #122
$("avviso").textContent = avvisi(d); $("avviso").textContent = avvisi(d);
$("origineCalcolo").textContent = "Origine: " + (d.bloccata ? "immagine bloccata — " : "") + descriviOrigine(d.origine); // #94 $("origineCalcolo").textContent = "Origine: " + (d.bloccata ? "immagine bloccata — " : "") + descriviOrigine(d.origine); // #94
$("risultato").hidden = false; $("risultato").hidden = false;
+18
View File
@@ -45,6 +45,7 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
la libreria resta) la libreria resta)
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
GET /acquisizione -> stessa pagina (#87) GET /acquisizione -> stessa pagina (#87)
GET /visore/{image_viewer.js|save_image.js|vision_overlay.js} -> i JS del <tmf-viewer> del frontend (#122), altri nomi 404
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default: Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096), POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito). timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
@@ -125,6 +126,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import calibrazione # noqa: E402 import calibrazione # noqa: E402
from calibrazione_pagina import PAGINA # noqa: E402 from calibrazione_pagina import PAGINA # noqa: E402
# #122: i tre file JS del frontend che la pagina carica su /visore/<nome> (solo questi nomi: nessun percorso arbitrario dall'URL).
# Cartelle cercate nell'ordine: pacchetto Nuitka (static/ accanto al modulo, copiata da build.ps1) e sorgenti (src/frontend/flask_app/static/js)
_FILE_VISORE = ("image_viewer.js", "save_image.js", "vision_overlay.js")
_QUI = Path(__file__).resolve().parent
_CARTELLE_VISORE = [_QUI / "static" / "js", _QUI.parent / "src" / "frontend" / "flask_app" / "static" / "js"]
_TRIGGER = ("continuo",) _TRIGGER = ("continuo",)
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome _MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere _CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
@@ -780,6 +787,17 @@ def create_app(folder: Path | str | None = None,
def calibrazione_pagina(): def calibrazione_pagina():
return HTMLResponse(PAGINA) return HTMLResponse(PAGINA)
@app.get("/visore/{nome}")
def visore_file(nome: str):
"""#122: i file JS del <tmf-viewer> letti dal frontend Flask; la pagina resta offline."""
if nome not in _FILE_VISORE: # lista chiusa: niente percorsi dall'URL
raise HTTPException(404, "file non trovato")
for cartella in _CARTELLE_VISORE:
f = cartella / nome
if f.is_file():
return Response(f.read_bytes(), media_type="application/javascript", headers={"Cache-Control": "no-cache"})
raise HTTPException(404, "file non trovato")
return app return app
+92
View File
@@ -0,0 +1,92 @@
"""Test di scripts/archivio_sorgente.py (TieMeasureFlow#109): repo temporaneo con un submodule finto.
Uso: uv run --no-project --python 3.13 --with pytest pytest -q scripts/tests/test_archivio_sorgente.py
"""
import importlib.util
import subprocess
import zipfile
from pathlib import Path
import pytest
_spec = importlib.util.spec_from_file_location(
"archivio_sorgente", Path(__file__).resolve().parents[1] / "archivio_sorgente.py")
archivio = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(archivio)
GIT = ["git", "-c", "core.autocrlf=false", "-c", "protocol.file.allow=always",
"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]
def _g(cartella, *args):
r = subprocess.run([*GIT, "-C", str(cartella), *args], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return r.stdout.strip()
def _repo(tmp_path, nome):
d = tmp_path / nome
d.mkdir()
_g(d, "init", "-q", "-b", "main")
return d
@pytest.fixture
def padre(tmp_path):
"""Repo padre con vendor/visionsuite registrato (submodule finto da un repo locale)."""
origine = _repo(tmp_path, "origine")
(origine / "motore.py").write_text("x = 1\n")
_g(origine, "add", ".")
_g(origine, "commit", "-q", "-m", "motore")
p = _repo(tmp_path, "padre")
(p / "app.py").write_text("print('tmf')\n")
_g(p, "add", ".")
_g(p, "commit", "-q", "-m", "app")
_g(p, "submodule", "add", "-q", str(origine), "vendor/visionsuite")
_g(p, "commit", "-q", "-m", "submodule")
return p
def test_zip_con_repo_submodule_e_versione(padre, tmp_path):
atteso = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
out = tmp_path / "o" / "tmf.zip"
assert archivio.crea_archivio(padre, out) == atteso
with zipfile.ZipFile(out) as z:
nomi = z.namelist()
assert "app.py" in nomi and "vendor/visionsuite/motore.py" in nomi
dati = z.read("vision-engine-version.txt")
assert dati == atteso.encode("ascii") and len(dati) == 40
assert not dati.startswith(b"\xef\xbb\xbf") and b"\n" not in dati # niente BOM, niente a capo
def test_sha_resta_quello_registrato_se_il_submodule_avanza(padre, tmp_path):
registrato = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
sub = padre / "vendor" / "visionsuite"
(sub / "nuovo.py").write_text("y = 2\n")
_g(sub, "add", ".")
_g(sub, "commit", "-q", "-m", "avanti non registrato")
assert _g(sub, "rev-parse", "HEAD") != registrato
out = tmp_path / "tmf.zip"
assert archivio.crea_archivio(padre, out) == registrato
with zipfile.ZipFile(out) as z:
assert z.read("vision-engine-version.txt").decode() == registrato
assert "vendor/visionsuite/nuovo.py" not in z.namelist() # contenuto del commit registrato
def test_submodule_non_inizializzato_errore_e_nessuno_zip(padre, tmp_path):
clone = tmp_path / "clone"
_g(tmp_path, "clone", "-q", str(padre), str(clone)) # senza --recurse-submodules
out = tmp_path / "tmf.zip"
with pytest.raises(archivio.ErroreArchivio, match="inizializzato"):
archivio.crea_archivio(clone, out)
assert not out.exists() and not list(tmp_path.glob("*.tmp")) # nessuno zip ne' temporaneo
def test_cli_esce_1_con_messaggio(padre, tmp_path, capsys, monkeypatch):
clone = tmp_path / "clone"
_g(tmp_path, "clone", "-q", str(padre), str(clone))
originale = archivio.crea_archivio
# la CLI lavora sul repo che contiene lo script: la si dirotta sul clone senza submodule
monkeypatch.setattr(archivio, "crea_archivio", lambda r, o, ref="HEAD": originale(clone, o, ref))
assert archivio.main(["--out", str(tmp_path / "x.zip")]) == 1
assert "ERRORE" in capsys.readouterr().err
+87 -80
View File
@@ -1,80 +1,87 @@
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio.""" """Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
import importlib.util import importlib.util
import re import re
from pathlib import Path from pathlib import Path
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py" _FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE) _spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
_mod = importlib.util.module_from_spec(_spec) _mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod) _spec.loader.exec_module(_mod)
PAGINA = _mod.PAGINA PAGINA = _mod.PAGINA
def test_titolo_e_stile_scuro(): def test_titolo_e_stile_scuro():
assert "<title>Acquisizione camera</title>" in PAGINA assert "<title>Acquisizione camera</title>" in PAGINA
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
def test_rotte_usate(): def test_rotte_usate():
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri", for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"): "/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
assert '"' + rotta in PAGINA, rotta assert '"' + rotta in PAGINA, rotta
def test_offline_e_senza_finestre_del_browser(): def test_offline_e_senza_finestre_del_browser():
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"): for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
assert vietato not in PAGINA, vietato assert vietato not in PAGINA, vietato
def test_id_principali(): def test_id_principali():
ids = set(re.findall(r'id="([^"]+)"', PAGINA)) ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine", attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni", "nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine", "parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione "mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
assert attesi <= ids, attesi - ids assert attesi <= ids, attesi - ids
# ogni id usato dal JS con $("...") esiste nel markup # ogni id usato dal JS con $("...") esiste nel markup
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA)) usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids assert usati <= ids, usati - ids
def test_sezioni_e_testi_italiani(): def test_sezioni_e_testi_italiani():
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione", for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili", "Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
"Attiva calibrazione"): # #92 #93 "Attiva calibrazione"): # #92 #93
assert testo in PAGINA, testo assert testo in PAGINA, testo
assert "it-IT" in PAGINA # numeri con la virgola assert "it-IT" in PAGINA # numeri con la virgola
assert "Choose File" not in PAGINA assert "Choose File" not in PAGINA
def test_localstorage_protetto(): def test_localstorage_protetto():
# ogni uso di localStorage sta dentro un try # ogni uso di localStorage sta dentro un try
for m in re.finditer(r"localStorage[.]", PAGINA): for m in re.finditer(r"localStorage[.]", PAGINA):
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()] assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
def test_specchiatura_per_sorgente(): # #110 def test_specchiatura_per_sorgente(): # #110
ids = set(re.findall(r'id="([^"]+)"', PAGINA)) ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"specchiaO", "specchiaV", "msgSpecchia"} <= ids assert {"specchiaO", "specchiaV", "msgSpecchia"} <= ids
assert '"/camera/orientamento' in PAGINA assert '"/camera/orientamento' in PAGINA
assert "Specchia orizzontale" in PAGINA and "Specchia verticale" in PAGINA assert "Specchia orizzontale" in PAGINA and "Specchia verticale" in PAGINA
def test_zoom_visori(): # #111 def test_zoom_visori(): # #111, #122: i visori sono <tmf-viewer>, la barra e lo zoom vengono dal componente
ids = set(re.findall(r'id="([^"]+)"', PAGINA)) ids = set(re.findall(r'id="([^"]+)"', PAGINA))
for suf in ("", "Cal", "CalTab"): # tre riquadri: grezza, calibrata, tab Calibrazione for i in ("immagine", "immagineCal", "immagineCalTab", "overlay"): # grezza, calibrata, tab Calibrazione, angoli
for base in ("zoomMeno", "zoomAdatta", "zoomPiu", "zoomPct"): assert re.search(r'<tmf-viewer id="%s"' % i, PAGINA), i
assert base + suf in ids, base + suf assert PAGINA.count("<tmf-viewer") == 4
assert "zoomata" in PAGINA for nome in ("grezza", "calibrata", "calibrazione", "angoli"):
assert "latoAnteprima()" in PAGINA and "unaAnteprima(1280)" not in PAGINA # lato_max segue lo zoom assert 'save-name="%s"' % nome in PAGINA, nome
# la barra e' sorella del palco che scorre (non dentro): resta ferma durante lo scorrimento assert "zoomAdatta" not in PAGINA and "barraZoom" not in PAGINA and "creaVisore(" in PAGINA
assert PAGINA.count('<div class="visore"') == 3 assert not {"zoomMeno", "zoomPiu", "zoomPct"} & ids
assert PAGINA.count('<div class="barraZoom">') == 3 assert "latoAnteprima()" in PAGINA and "unaAnteprima(1280)" not in PAGINA # lato_max segue lo zoom
assert '"viewer-zoom"' in PAGINA # lo zoom si legge dall'evento del componente
def test_nessuna_funzione_dichiarata_due_volte(): # #111: in JS vince l'ultima dichiarazione, in silenzio
script = re.search(r"<script>(.*?)</script>", PAGINA, re.S).group(1) def test_script_del_visore_in_ordine(): # #122: i JS arrivano da /visore/ (offline), prima dello script di pagina
nomi = re.findall(r"function\s+([A-Za-z0-9_$]+)\s*\(", script) src = re.findall(r'<script src="([^"]+)"', PAGINA)
doppi = sorted({n for n in nomi if nomi.count(n) > 1}) assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
assert not doppi, "funzioni dichiarate piu' volte: " + ", ".join(doppi) assert PAGINA.index("/visore/image_viewer.js") < PAGINA.index("<script>")
def test_nessuna_funzione_dichiarata_due_volte(): # #111: in JS vince l'ultima dichiarazione, in silenzio
script = re.search(r"<script>(.*?)</script>", PAGINA, re.S).group(1)
nomi = re.findall(r"function\s+([A-Za-z0-9_$]+)\s*\(", script)
doppi = sorted({n for n in nomi if nomi.count(n) > 1})
assert not doppi, "funzioni dichiarate piu' volte: " + ", ".join(doppi)
+84
View File
@@ -0,0 +1,84 @@
"""Test delle rotte /visore/*.js di camera_service e della pagina che le usa (#122).
Uso: python -m pytest scripts/tests/test_visore_rotte.py -q
Dipende da: venv con fastapi, httpx, opencv (come test_camera_service.py).
image_viewer.js lo scrive un altro piano (V1): se manca nei sorgenti il test della rotta usa una cartella temporanea con file finti.
"""
import importlib.util
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
NOMI = ("image_viewer.js", "save_image.js", "vision_overlay.js")
def _carica():
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def client(tmp_path):
mod = _carica()
with TestClient(mod.create_app(tmp_path)) as c:
c.mod = mod
yield c
@pytest.fixture
def cartella_finta(client, tmp_path):
# file finti in una cartella temporanea: la rotta si prova senza dipendere da image_viewer.js del frontend
d = tmp_path / "js"
d.mkdir()
for n in NOMI:
(d / n).write_text("/* finto " + n + " */\n", encoding="utf-8")
client.mod._CARTELLE_VISORE[:] = [d]
return d
@pytest.mark.parametrize("nome", NOMI)
def test_rotta_serve_il_file(client, cartella_finta, nome):
r = client.get("/visore/" + nome)
assert r.status_code == 200
assert r.headers["content-type"].startswith("application/javascript")
assert r.content == (cartella_finta / nome).read_bytes()
@pytest.mark.parametrize("nome", ["altro.js", "camera_service.py", "..%2Fcamera_service.py", "image_viewer.JS", "image_viewer.js.map"])
def test_altri_nomi_danno_404(client, cartella_finta, nome):
assert client.get("/visore/" + nome).status_code == 404
def test_file_mancante_nella_cartella_da_404(client, tmp_path):
client.mod._CARTELLE_VISORE[:] = [tmp_path / "vuota"]
assert client.get("/visore/save_image.js").status_code == 404
def test_cartelle_di_default_sono_static_js_del_frontend(client):
# sviluppo: sorgenti del frontend; pacchetto Nuitka: static/ accanto al modulo (build.ps1 la include)
cartelle = _carica()._CARTELLE_VISORE
assert any(c.parts[-5:] == ("src", "frontend", "flask_app", "static", "js") for c in cartelle)
assert any(c.parts[-2:] == ("static", "js") and c.parent.parent == MODULO.parent for c in cartelle)
@pytest.mark.parametrize("nome", ("save_image.js", "vision_overlay.js", "image_viewer.js"))
def test_file_reali_del_frontend(client, nome):
reale = next((c / nome for c in client.mod._CARTELLE_VISORE if (c / nome).is_file()), None)
if reale is None:
pytest.skip(nome + " non e' ancora nei sorgenti del frontend (lo scrive il piano V1)")
r = client.get("/visore/" + nome)
assert r.status_code == 200 and r.content == reale.read_bytes()
@pytest.mark.parametrize("rotta", ("/acquisizione", "/calibrazione"))
def test_pagina_usa_quattro_visori_e_i_tre_script(client, rotta):
html = client.get(rotta).text
assert html.count("<tmf-viewer") == 4
src = re.findall(r'<script src="(/visore/[^"]+)"', html)
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
assert "zoomAdatta" not in html
+6 -2
View File
@@ -357,9 +357,13 @@ async def export_measurements_csv(
# Rows # Rows
for m in measurements: for m in measurements:
# Format decimals with configured separator # Format decimals with configured separator
value_str = str(m.value).replace(".", decimal_separator) # #118: quota non misurata dalla visione -> NaN
value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator)
# #124: quota NaN (value None) -> deviazione NaN, non vuota
deviation_str = ( deviation_str = (
str(m.deviation).replace(".", decimal_separator) "NaN"
if m.value is None
else str(m.deviation).replace(".", decimal_separator)
if m.deviation is not None if m.deviation is not None
else "" else ""
) )
+6 -2
View File
@@ -146,7 +146,8 @@ async def get_capability(
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58 production_run_id=production_run_id, # #58
) )
values = [float(m.value) for m in measurements] # #118: le quote NaN (value None) non entrano nei calcoli
values = [float(m.value) for m in measurements if m.value is not None]
tol = await _get_subtask_tolerances(db, subtask_id) tol = await _get_subtask_tolerances(db, subtask_id)
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"]) return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"])
@@ -171,6 +172,8 @@ async def get_control_chart(
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58 production_run_id=production_run_id, # #58
) )
# #118: le quote NaN (value None) si saltano, valori e istanti restano allineati
measurements = [m for m in measurements if m.value is not None]
values = [float(m.value) for m in measurements] values = [float(m.value) for m in measurements]
timestamps = [m.measured_at for m in measurements] timestamps = [m.measured_at for m in measurements]
tol = await _get_subtask_tolerances(db, subtask_id) tol = await _get_subtask_tolerances(db, subtask_id)
@@ -201,7 +204,8 @@ async def get_histogram(
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58 production_run_id=production_run_id, # #58
) )
values = [float(m.value) for m in measurements] # #118: le quote NaN (value None) non entrano nell'istogramma
values = [float(m.value) for m in measurements if m.value is not None]
return compute_histogram(values, n_bins) return compute_histogram(values, n_bins)
+4 -1
View File
@@ -162,7 +162,10 @@ async def execute(
engine_version=result.engine_version, engine_version=result.engine_version,
measurements=[ measurements=[
VisionMeasurementResult( VisionMeasurementResult(
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail, subtask_id=m.subtask_id,
# #118: None = quota non prodotta, il frontend la mostra NaN
value=float(m.value) if m.value is not None else None,
pass_fail=m.pass_fail,
) )
for m in saved for m in saved
], ],
@@ -0,0 +1,39 @@
"""measurements.value nullable (#118)
measurements.value: NULL = quota che il grafo di visione non ha prodotto
(l'operatore la vede come NaN, esito sempre `fail`, deviazione NULL). Prima
execute rifiutava con 422 l'intera acquisizione. measurements.deviation era
gia' nullable.
Downgrade: torna NOT NULL; FALLISCE se esistono righe con value NULL (vanno
cancellate o valorizzate prima).
Revision ID: 022_measurement_value_nullable
Revises: 021_task_acquisition
Create Date: 2026-10-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '022_measurement_value_nullable'
down_revision: Union[str, None] = '021_task_acquisition'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'value', existing_type=sa.DECIMAL(12, 6), nullable=True,
)
def downgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'value', existing_type=sa.DECIMAL(12, 6), nullable=False,
)
+2 -2
View File
@@ -43,7 +43,7 @@ class MeasurementResponse(BaseModel):
subtask_id: int subtask_id: int
version_id: int version_id: int
measured_by: int measured_by: int
value: float value: Optional[float] = None # #118: null = quota non prodotta dalla visione (NaN)
pass_fail: str pass_fail: str
deviation: Optional[float] = None deviation: Optional[float] = None
lot_number: Optional[str] = None lot_number: Optional[str] = None
@@ -108,7 +108,7 @@ class CycleMeasurementResponse(BaseModel):
"""One quote already measured in the cycle under way.""" """One quote already measured in the cycle under way."""
subtask_id: int subtask_id: int
value: float value: Optional[float] = None # #118: null = NaN
pass_fail: str pass_fail: str
measured_at: UtcDateTime measured_at: UtcDateTime
+1 -1
View File
@@ -12,7 +12,7 @@ class MonitorSubtask(BaseModel):
description: str description: str
unit: str unit: str
count: int count: int
mean: float mean: Optional[float] = None # #118: None = solo quote NaN
ltl: Optional[float] = None ltl: Optional[float] = None
utl: Optional[float] = None utl: Optional[float] = None
# True when ltl <= mean <= utl (a missing limit does not constrain). # True when ltl <= mean <= utl (a missing limit does not constrain).
+1 -1
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict
class VisionMeasurementResult(BaseModel): class VisionMeasurementResult(BaseModel):
"""One quote's outcome, as much of it as the client needs to show.""" """One quote's outcome, as much of it as the client needs to show."""
subtask_id: int subtask_id: int
value: float value: float | None # #118: null = quota non prodotta dal grafo (NaN), pass_fail "fail"
pass_fail: str pass_fail: str
+2 -1
View File
@@ -26,7 +26,8 @@ class Measurement(Base):
) )
# Measurement data # Measurement data
value: Mapped[float] = mapped_column(DECIMAL(12, 6), nullable=False) # #118: NULL = quota che il grafo di visione non ha prodotto (mostrata NaN, sempre fail)
value: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
pass_fail: Mapped[str] = mapped_column( pass_fail: Mapped[str] = mapped_column(
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
) )
+13 -5
View File
@@ -302,7 +302,7 @@ async def current_cycle_measurements(
return [ return [
{ {
"subtask_id": m.subtask_id, "subtask_id": m.subtask_id,
"value": float(m.value), "value": float(m.value) if m.value is not None else None, # #118: NaN
"pass_fail": m.pass_fail, "pass_fail": m.pass_fail,
"measured_at": m.measured_at, "measured_at": m.measured_at,
} }
@@ -355,7 +355,7 @@ async def save_measurement(
subtask_id: int, subtask_id: int,
version_id: int, version_id: int,
measured_by: int, measured_by: int,
value: float, value: float | None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
input_method: str = "manual", input_method: str = "manual",
@@ -372,8 +372,12 @@ async def save_measurement(
batch of quotes taken at the same instant (see Ruling R10 / vision_service): batch of quotes taken at the same instant (see Ruling R10 / vision_service):
the gate models "moving on" between measurements taken one after another, the gate models "moving on" between measurements taken one after another,
which does not exist inside a single camera acquisition. which does not exist inside a single camera acquisition.
#118: ``value=None`` is a quote the vision graph did not produce (shown as
NaN): saved as a ``fail`` with no deviation. Only the vision path passes it.
""" """
_validate_measured_value(value) if value is not None: # #118: una quota non misurata non ha un valore da validare
_validate_measured_value(value)
# Get subtask for tolerance values # Get subtask for tolerance values
result = await db.execute( result = await db.execute(
@@ -421,8 +425,12 @@ async def save_measurement(
# Rounded once, here, so the stored value is exactly the one the verdict # Rounded once, here, so the stored value is exactly the one the verdict
# above was computed on - calculate_pass_fail rounds internally too, but # above was computed on - calculate_pass_fail rounds internally too, but
# matching it here keeps the persisted row from disagreeing with itself. # matching it here keeps the persisted row from disagreeing with itself.
value = round(value, 6) if value is None:
pass_fail, deviation = calculate_pass_fail(value, subtask) # #118: quota non prodotta dal grafo -> NaN: fail, nessuna deviazione
pass_fail, deviation = "fail", None
else:
value = round(value, 6)
pass_fail, deviation = calculate_pass_fail(value, subtask)
measurement = Measurement( measurement = Measurement(
subtask_id=subtask_id, subtask_id=subtask_id,
+10 -3
View File
@@ -96,14 +96,21 @@ async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
per_sub[row.subtask_id].append(row) per_sub[row.subtask_id].append(row)
subtasks = [] subtasks = []
for sid, group in sorted(per_sub.items()): for sid, group in sorted(per_sub.items()):
mean = sum(float(g.value) for g in group) / len(group) # #118: le quote NaN (value None) non entrano nella media; con soli
# NaN la quota resta in elenco con mean None ed e' fuori tolleranza
numeric = [float(g.value) for g in group if g.value is not None]
mean = sum(numeric) / len(numeric) if numeric else None
first = group[0] first = group[0]
subtasks.append(MonitorSubtask( subtasks.append(MonitorSubtask(
subtask_id=sid, description=first.description, unit=first.unit, subtask_id=sid, description=first.description, unit=first.unit,
count=len(group), mean=round(mean, 6), count=len(group),
mean=round(mean, 6) if mean is not None else None, # #118
ltl=float(first.ltl) if first.ltl is not None else None, ltl=float(first.ltl) if first.ltl is not None else None,
utl=float(first.utl) if first.utl is not None else None, utl=float(first.utl) if first.utl is not None else None,
in_tolerance=_in_tolerance(mean, first.ltl, first.utl), in_tolerance=(
_in_tolerance(mean, first.ltl, first.utl)
if mean is not None else False # #118: tutte NaN = fail
),
)) ))
timer = production_service.describe(run) timer = production_service.describe(run)
ev = last_event.get(run.id) ev = last_event.get(run.id)
@@ -159,8 +159,8 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
subtask.marker_number if subtask else "", subtask.marker_number if subtask else "",
neutralise_csv_cell(subtask.description) if subtask else "", neutralise_csv_cell(subtask.description) if subtask else "",
number(subtask.nominal) if subtask else "", number(subtask.nominal) if subtask else "",
number(measurement.value), number(measurement.value) if measurement.value is not None else "NaN", # #118
number(measurement.deviation), "NaN" if measurement.value is None else number(measurement.deviation), # #124: quota NaN -> deviazione NaN
measurement.pass_fail, measurement.pass_fail,
number(subtask.ltl) if subtask else "", number(subtask.ltl) if subtask else "",
number(subtask.lwl) if subtask else "", number(subtask.lwl) if subtask else "",
+5 -3
View File
@@ -318,8 +318,10 @@ async def generate_spc_report(
# 2. Compute SPC data # 2. Compute SPC data
pass_fail_values = [m.pass_fail for m in measurements] pass_fail_values = [m.pass_fail for m in measurements]
values = [float(m.value) for m in measurements] # #118: le quote NaN (value None) contano nell'esito ma non nei calcoli
timestamps = [m.measured_at for m in measurements] numeric = [m for m in measurements if m.value is not None]
values = [float(m.value) for m in numeric]
timestamps = [m.measured_at for m in numeric]
summary = compute_summary(pass_fail_values) summary = compute_summary(pass_fail_values)
@@ -426,7 +428,7 @@ async def generate_measurement_report(
rows.append({ rows.append({
"num": i, "num": i,
"subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}", "subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}",
"value": f"{float(m.value):.4f}", "value": f"{float(m.value):.4f}" if m.value is not None else "NaN", # #118
"unit": st_info.get("unit", "mm"), "unit": st_info.get("unit", "mm"),
"pass_fail": m.pass_fail, "pass_fail": m.pass_fail,
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"), "measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
+19 -21
View File
@@ -693,23 +693,13 @@ async def execute_task(
) )
outputs = report["outputs"] outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse # #118: una quota che il grafo non produce non blocca piu' l'esecuzione
# than a piece not measured at all. A quote counts as produced only if its # (prima 422 "the graph did not produce"): viene salvata con value NULL,
# output is present *and* a finite, non-bool number: the in-process runner # mostrata NaN, sempre `fail`; i motivi restano nei `failures` del risultato.
# already drops NaN/inf itself (see src/vision/runner.py), but the worker # Una quota conta come prodotta solo se l'uscita c'e' *ed* e' un numero
# is reached over HTTP - a different implementation of it, or a malformed # finito non-bool: il worker risponde via HTTP, un'altra implementazione o
# response, could still hand back None or a non-finite float for a mapped # una risposta malformata potrebbe dare None o un non finito per una chiave
# key instead of omitting it outright. # mappata invece di ometterla.
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
if missing:
failed = ", ".join(f["tool_name"] for f in report["failures"])
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"the graph did not produce {', '.join(missing)}"
+ (f" (tools that failed: {failed})" if failed else "")
),
)
overlay = report.get("overlay") or [] overlay = report.get("overlay") or []
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext) image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
@@ -737,7 +727,11 @@ async def execute_task(
subtask_id=quote.id, subtask_id=quote.id,
version_id=version_id, version_id=version_id,
measured_by=measured_by, measured_by=measured_by,
value=float(outputs[quote.vision_output]), # #118: None = quota non prodotta (NaN), salvata come fail
value=(
float(outputs[quote.vision_output])
if _is_measurable(outputs.get(quote.vision_output)) else None
),
lot_number=lot_number, lot_number=lot_number,
serial_number=serial_number, serial_number=serial_number,
input_method="camera", input_method="camera",
@@ -762,8 +756,9 @@ async def check_task(
VisionResult, niente file, niente misure. L'esito per quota usa VisionResult, niente file, niente misure. L'esito per quota usa
calculate_pass_fail sulle quote con vision_output, nell'ordine di calculate_pass_fail sulle quote con vision_output, nell'ordine di
marker_number come execute. Le quote che il grafo non produce (pezzo non marker_number come execute. Le quote che il grafo non produce (pezzo non
ancora in posizione) restano fuori da `measurements` e il perche' sta in ancora in posizione) entrano in `measurements` con `value` None (NaN) e
`failures`: un'anteprima non deve dare 422 a ogni frame. `pass_fail` "fail" (#118, come execute); il perche' sta in `failures`:
un'anteprima non deve dare 422 a ogni frame.
""" """
if task.task_type not in VISION_TASK_TYPES: if task.task_type not in VISION_TASK_TYPES:
raise HTTPException( raise HTTPException(
@@ -809,6 +804,7 @@ async def check_task(
**worker_kwargs, **worker_kwargs,
) )
outputs = report["outputs"] outputs = report["outputs"]
# #118: una voce per ogni quota, in ordine di marker_number; non prodotta -> NaN/fail
measurements = [ measurements = [
{ {
"subtask_id": q.id, "subtask_id": q.id,
@@ -816,8 +812,10 @@ async def check_task(
"pass_fail": measurement_service.calculate_pass_fail( "pass_fail": measurement_service.calculate_pass_fail(
float(outputs[q.vision_output]), q, float(outputs[q.vision_output]), q,
)[0], )[0],
} if _is_measurable(outputs.get(q.vision_output)) else {
"subtask_id": q.id, "value": None, "pass_fail": "fail",
} }
for q in quotes if _is_measurable(outputs.get(q.vision_output)) for q in quotes
] ]
return { return {
"outcome": report.get("outcome"), "outcome": report.get("outcome"),
+71
View File
@@ -360,3 +360,74 @@ class TestTaskAcquisitionMigration:
assert "camera_url" in cols("stations") # la 020 resta assert "camera_url" in cols("stations") # la 020 resta
command.upgrade(cfg, "head") # e di nuovo su command.upgrade(cfg, "head") # e di nuovo su
assert "acquisition_json" in cols("recipe_tasks") assert "acquisition_json" in cols("recipe_tasks")
class TestMeasurementValueNullableMigration:
"""022_measurement_value_nullable: measurements.value accetta NULL, su e giu (#118)."""
def test_022_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def value_nullable():
cols = _inspect(db_path).get_columns("measurements")
return {c["name"]: c["nullable"] for c in cols}["value"]
command.upgrade(cfg, "head")
assert value_nullable() is True
command.downgrade(cfg, "021_task_acquisition")
assert value_nullable() is False
command.upgrade(cfg, "head") # e di nuovo su
assert value_nullable() is True
def test_022_keeps_rows(self, sqlite_database_url):
"""#125: la ricostruzione della tabella conserva righe, somma dei value e indici."""
import sqlite3
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "021_task_acquisition")
def indexes(con):
return sorted(r[0] for r in con.execute(
"select name from sqlite_master where type='index' and tbl_name='measurements'"
))
con = sqlite3.connect(db_path)
# Colonne NOT NULL senza default riempite per tipo (stessa idea di mig_check.py)
vals = {}
for _cid, name, typ, notnull, dflt, pk in con.execute("pragma table_info(measurements)").fetchall():
if pk or not notnull or dflt is not None:
continue
t = typ.upper()
vals[name] = (
1 if "INT" in t
else 1.5 if ("DEC" in t or "FLOAT" in t or "NUM" in t)
else "2026-01-01 00:00:00" if ("DATE" in t or "TIME" in t)
else "x"
)
vals["pass_fail"] = "pass"
vals["value"] = 12.5
sql = f"insert into measurements ({','.join(vals)}) values ({','.join('?' * len(vals))})"
for _ in range(3):
con.execute(sql, list(vals.values()))
con.commit()
before_idx = indexes(con)
before = con.execute("select count(*), sum(value) from measurements").fetchone()
con.close()
assert before == (3, 37.5)
command.upgrade(cfg, "head")
con = sqlite3.connect(db_path)
try:
assert con.execute("select count(*), sum(value) from measurements").fetchone() == before
assert indexes(con) == before_idx
assert con.execute("pragma integrity_check").fetchone()[0] == "ok"
vals["value"] = None
vals["pass_fail"] = "fail"
con.execute(sql, list(vals.values())) # una riga NaN (value NULL) ora e' accettata
con.commit()
assert con.execute("select count(*) from measurements where value is null").fetchone()[0] == 1
finally:
con.close()
+26
View File
@@ -81,3 +81,29 @@ async def test_open_lists_only_open_runs_with_means(client, db_session, admin_us
async def test_open_empty_for_admin(client, admin_user): async def test_open_empty_for_admin(client, admin_user):
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user)) resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
assert resp.status_code == 200 and resp.json() == [] assert resp.status_code == 200 and resp.json() == []
async def test_nan_quotes_in_the_monitor(client, db_session, admin_user):
"""#118: solo NaN -> la quota resta, mean null e fuori tolleranza; mista -> media dei numeri."""
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
r1, s1, v1 = await _run(db_session, admin_user, "N1")
r2, s2, v2 = await _run(db_session, admin_user, "N2")
for i in range(2):
db_session.add(_m(r1, s1, v1, admin_user, None, "fail", i))
for i, (val, pf) in enumerate([(None, "fail"), (10.0, "pass"), (10.2, "pass")]):
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
await db_session.commit()
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
assert resp.status_code == 200
body = {r["station_code"]: r for r in resp.json()}
only_nan = body["ST-N1"]["subtasks"]
assert len(only_nan) == 1
assert only_nan[0]["mean"] is None
assert only_nan[0]["count"] == 2
assert only_nan[0]["in_tolerance"] is False
assert body["ST-N1"]["fail_count"] == 2
mixed = body["ST-N2"]["subtasks"][0]
assert mixed["count"] == 3
assert round(mixed["mean"], 6) == 10.1
assert mixed["in_tolerance"] is True
+114
View File
@@ -0,0 +1,114 @@
"""#118: una quota non misurata dalla visione e' una misura con value NULL (NaN), sempre fail.
Il valore NULL non deve far cadere statistiche, report, CSV e liste, e non entra
nei calcoli numerici (medie, indici, istogramma); conta solo nell'esito.
"""
import csv
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.user import User
from src.backend.services import measurement_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_statistics import _seed_measurements
async def _recipe_with_nan(client, db_session, tec_user):
"""Una ricetta con tre misure numeriche e una NaN (value NULL, fail)."""
recipe = await create_test_recipe(db_session, tec_user.id)
subtask_id, version_id = await _seed_measurements(
client, tec_user, recipe.id, [10.0, 10.1, 9.9],
)
nan = await measurement_service.save_measurement(
db_session, subtask_id=subtask_id, version_id=version_id,
measured_by=tec_user.id, value=None, input_method="camera",
enforce_tolerance_gate=False,
)
await db_session.commit()
return recipe, subtask_id, version_id, nan
async def test_save_measurement_none_is_fail_without_deviation(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
assert nan.value is None
assert nan.pass_fail == "fail"
assert nan.deviation is None
async def test_manual_create_still_requires_a_value(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
"""Solo il percorso visione puo' produrre NaN: la creazione manuale resta float."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _seed_measurements(
client, measurement_tec_user, recipe.id, [],
)
resp = await client.post(
"/api/measurements/", headers=auth_headers(measurement_tec_user),
json={"subtask_id": subtask_id, "version_id": version_id, "value": None},
)
assert resp.status_code == 422
async def test_statistics_skip_the_nan_measurement(
client: AsyncClient, db_session: AsyncSession,
measurement_tec_user: User, metrologist_user: User,
):
recipe, subtask_id, _, _ = await _recipe_with_nan(
client, db_session, measurement_tec_user,
)
params = {"recipe_id": recipe.id, "subtask_id": subtask_id}
headers = auth_headers(metrologist_user)
cap = await client.get("/api/statistics/capability", headers=headers, params=params)
assert cap.status_code == 200
assert cap.json()["n"] == 3
assert abs(cap.json()["mean"] - 10.0) < 1e-9
chart = await client.get("/api/statistics/control-chart", headers=headers, params=params)
assert chart.status_code == 200
assert len(chart.json()["values"]) == 3
hist = await client.get("/api/statistics/histogram", headers=headers, params=params)
assert hist.status_code == 200
summary = await client.get(
"/api/statistics/summary", headers=headers, params={"recipe_id": recipe.id},
)
assert summary.status_code == 200
async def test_measurement_list_returns_null_value(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
_, _, version_id, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
resp = await client.get(
"/api/measurements/", headers=auth_headers(measurement_tec_user),
params={"version_id": version_id, "per_page": 50},
)
assert resp.status_code == 200
rows = {m["id"]: m for m in resp.json()["items"]}
assert rows[nan.id]["value"] is None
assert rows[nan.id]["pass_fail"] == "fail"
assert rows[nan.id]["deviation"] is None
async def test_csv_export_writes_nan(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
resp = await client.get(
"/api/measurements/export/csv", headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 200
rows = list(csv.reader(resp.text.splitlines()))
header = rows[0]
by_id = {r[header.index("id")]: r for r in rows[1:]}
row = by_id[str(nan.id)]
assert row[header.index("value")] == "NaN"
assert row[header.index("pass_fail")] == "fail"
assert row[header.index("deviation")] == "NaN" # #124
+28
View File
@@ -836,3 +836,31 @@ async def test_close_without_measurements_writes_no_file(
)).json() )).json()
assert closed["statistics_path"] is None assert closed["statistics_path"] is None
assert not (tmp_path / "statistics").exists() assert not (tmp_path / "statistics").exists()
async def test_statistics_csv_writes_nan_value_and_deviation(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""#124: una quota NaN (value NULL) esce NaN sia nel valore sia nella deviazione."""
from src.backend.services import measurement_service, production_export_service
station = await _station(db_session, admin_user.id, code="ST-NAN")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-NAN")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
subtask = await _first_subtask(db_session, recipe.id)
await measurement_service.save_measurement(
db_session, subtask_id=subtask.id, version_id=opened["version_id"],
measured_by=measurement_tec_user.id, value=None, input_method="camera",
production_run_id=opened["id"], enforce_tolerance_gate=False,
)
await db_session.commit()
run = (await db_session.execute(
select(ProductionRun).where(ProductionRun.id == opened["id"])
)).scalar_one()
text = await production_export_service.build_statistics_csv(db_session, run)
lines = [line for line in text.lstrip("").splitlines() if line.strip()]
header = lines[0].split(",")
row = lines[1].split(",")
assert row[header.index("value")] == "NaN"
assert row[header.index("deviation")] == "NaN"
+5 -2
View File
@@ -244,7 +244,7 @@ async def test_check_calibration_reasons(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_check_omits_quotes_the_graph_did_not_produce( async def test_check_lists_quotes_the_graph_did_not_produce_as_nan(
client, db_session, measurement_tec_user, monkeypatch, client, db_session, measurement_tec_user, monkeypatch,
): ):
_, task = await _camera_task(db_session, measurement_tec_user.id) _, task = await _camera_task(db_session, measurement_tec_user.id)
@@ -255,7 +255,10 @@ async def test_check_omits_quotes_the_graph_did_not_produce(
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call) monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await _check(client, measurement_tec_user, task) resp = await _check(client, measurement_tec_user, task)
assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame
assert resp.json()["measurements"] == [] # #118: la quota non prodotta c'e', value null (NaN) e fail
measurements = resp.json()["measurements"]
assert len(measurements) == 1
assert measurements[0]["value"] is None and measurements[0]["pass_fail"] == "fail"
assert resp.json()["failures"] == [{"tool_name": "blob"}] assert resp.json()["failures"] == [{"tool_name": "blob"}]
+94 -32
View File
@@ -134,72 +134,134 @@ async def test_the_execution_records_which_engine_produced_it(
assert result.graph_snapshot == task.vision_json assert result.graph_snapshot == task.vision_json
# #118: una quota che il grafo non produce non e' piu' un 422: e' NaN (value
# NULL), `fail`, salvata con le altre.
async def _assert_nan_saved(client, db_session, user, task, first, second):
response = await client.post(
"/api/vision/execute",
headers=auth_headers(user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200
body = response.json()
assert [m["subtask_id"] for m in body["measurements"]] == [first.id, second.id]
assert body["measurements"][0]["value"] is None
assert body["measurements"][0]["pass_fail"] == "fail"
assert body["measurements"][1]["value"] == GOOD
rows = (await db_session.execute(
select(Measurement).order_by(Measurement.subtask_id)
)).scalars().all()
assert len(rows) == 2
assert rows[0].value is None
assert rows[0].pass_fail == "fail"
assert rows[0].deviation is None
assert rows[0].vision_result_id is not None
assert rows[1].value is not None and rows[1].pass_fail == "pass"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_a_missing_output_names_itself( async def test_a_missing_output_is_saved_as_nan(
client, db_session, measurement_tec_user, worker_says, client, db_session, measurement_tec_user, worker_says,
): ):
"""No half measurement: if an output is missing, the error names it.""" """#118: output assente -> 200, misura NULL/fail, le altre quote salvate."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id) recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id) version, task, first, second = await two_vision_quotes(db_session, recipe.id)
task.task_type = "camera_measure" worker_says({"t1.radius": GOOD}, failures=[{"tool_name": "arco_tr"}])
task.vision_json = {"schema_version": 1, "name": "p", "tools": []} await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
subtask.vision_output = "t1.diameter"
await db_session.commit()
worker_says({"t1.radius": 5.0}) @pytest.mark.asyncio
async def test_all_outputs_missing_still_saves_every_quote(
client, db_session, measurement_tec_user, worker_says,
):
"""#118: anche senza nessuna uscita, una misura NaN per quota."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({})
response = await client.post( response = await client.post(
"/api/vision/execute", "/api/vision/execute",
headers=auth_headers(measurement_tec_user), headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")}, files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id), "lot_number": "L1"}, data={"task_id": str(task.id)},
) )
assert response.status_code == 422 assert response.status_code == 200
assert "t1.diameter" in response.json()["detail"] assert [m["value"] for m in response.json()["measurements"]] == [None, None]
assert (await db_session.execute(select(Measurement))).scalars().all() == [] rows = (await db_session.execute(select(Measurement))).scalars().all()
assert len(rows) == 2 and all(r.value is None and r.pass_fail == "fail" for r in rows)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_a_null_output_counts_as_not_produced( async def test_a_null_output_is_saved_as_nan(
client, db_session, measurement_tec_user, worker_says, client, db_session, measurement_tec_user, worker_says,
): ):
"""A worker answering with the key present but null (not omitted) must not """Chiave presente ma null (non omessa): NaN, non TypeError (#16, #118)."""
reach float(None) as a bare TypeError - it is "not produced", same as a
missing key (#16)."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id) recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id) version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.diameter": None, "t1.radius": GOOD}) worker_says({"t1.diameter": None, "t1.radius": GOOD})
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
assert "t1.diameter" in response.json()["detail"]
assert (await db_session.execute(select(Measurement))).scalars().all() == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_a_non_finite_output_counts_as_not_produced( async def test_a_non_finite_output_is_saved_as_nan(
client, db_session, measurement_tec_user, worker_says, client, db_session, measurement_tec_user, worker_says,
): ):
"""NaN/inf must not become a saved measurement (#16).""" """NaN/inf non diventano un valore salvato: diventano NaN (NULL) (#16, #118)."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id) recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id) version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD}) worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD})
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
response = await client.post(
@pytest.mark.asyncio
async def test_a_nan_quote_needs_supervisor_authorisation(
client, db_session, measurement_tec_user, worker_says,
):
"""#125: la quota NaN e' un fail come gli altri e chiede il capoturno (pending_authorisation);
la prossima acquisizione non e' bloccata."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.radius": GOOD})
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
blocking = await measurement_service.pending_authorisation(
db_session, version.id, measurement_tec_user.id,
)
assert blocking is not None and blocking.subtask_id == first.id and blocking.value is None
# #125: una seconda acquisizione sullo stesso task risponde 200, non e' bloccata
again = await client.post(
"/api/vision/execute", "/api/vision/execute",
headers=auth_headers(measurement_tec_user), headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")}, files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)}, data={"task_id": str(task.id)},
) )
assert again.status_code == 200, again.text
assert response.status_code == 422
assert "t1.diameter" in response.json()["detail"] @pytest.mark.asyncio
async def test_check_lists_a_missing_quote_as_nan(
client, db_session, measurement_tec_user, worker_says,
):
"""#118: check -> una voce per quota, la non prodotta e' value null / fail."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.radius": GOOD})
response = await client.post(
"/api/vision/check",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200
assert response.json()["measurements"] == [
{"subtask_id": first.id, "value": None, "pass_fail": "fail"},
{"subtask_id": second.id, "value": GOOD, "pass_fail": "pass"},
]
assert (await db_session.execute(select(Measurement))).scalars().all() == [] assert (await db_session.execute(select(Measurement))).scalars().all() == []
+2 -1
View File
@@ -440,7 +440,8 @@ def task_complete(recipe_id: int):
m["subtask"] = st m["subtask"] = st
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {}) m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
# Compute deviation if not present # Compute deviation if not present
if m.get("deviation") is None and st.get("nominal") is not None: # #118: quota non misurata (value None): la deviazione resta None (il template mostra NaN)
if m.get("deviation") is None and st.get("nominal") is not None and m.get("value") is not None:
try: try:
m["deviation"] = m["value"] - st["nominal"] m["deviation"] = m["value"] - st["nominal"]
except (TypeError, KeyError): except (TypeError, KeyError):
@@ -84,16 +84,18 @@ function csvExport() {
const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl) const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl)
? nominal - subtask.ltl : null; ? nominal - subtask.ltl : null;
// #118: quota non misurata (value null) -> NaN nel valore e nella deviazione; le tolleranze nulle restano vuote
const notMeasured = m.value === null || m.value === undefined;
const row = [ const row = [
m.id || '', m.id || '',
m.subtask_id || '', m.subtask_id || '',
this.escapeCsvValue(subtask.description || ''), this.escapeCsvValue(subtask.description || ''),
this.formatNumber(m.value), notMeasured ? 'NaN' : this.formatNumber(m.value),
this.escapeCsvValue(subtask.unit || 'mm'), this.escapeCsvValue(subtask.unit || 'mm'),
this.formatNumber(nominal), this.formatNumber(nominal),
this.formatNumber(tolerancePlus), this.formatNumber(tolerancePlus),
this.formatNumber(toleranceMinus), this.formatNumber(toleranceMinus),
this.formatNumber(m.deviation), notMeasured ? 'NaN' : this.formatNumber(m.deviation),
m.pass_fail || '', m.pass_fail || '',
this.escapeCsvValue(m.lot_number || ''), this.escapeCsvValue(m.lot_number || ''),
this.escapeCsvValue(m.serial_number || ''), this.escapeCsvValue(m.serial_number || ''),
@@ -4,8 +4,7 @@
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'. // la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js. // Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
function dxfRecipeWizard(taskId, csrf, msgs) { function dxfRecipeWizard(taskId, csrf, msgs) {
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found) // #121: i colori per ruolo stanno in VisionOverlay (vision_overlay.js)
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_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'];
@@ -40,11 +39,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
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' 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 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) // #122: zoom, Adatta e pan delle tre viste (immagine, DXF, Prova) ora stanno nel componente <tmf-viewer>
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) runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione)
runBandMm: null, // #104: banda usata nell'ultima Prova runBandMm: null, // #104: banda usata nell'ultima Prova
runMinPointsPct: null, // #104: soglia usata nell'ultima Prova runMinPointsPct: null, // #104: soglia usata nell'ultima Prova
@@ -57,11 +52,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
_srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta _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 _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) _imgPickSeq: 0, // #105: idem per l'immagine (_setImage)
// #113: zoom delle due anteprime (img = immagine caricata/acquisita, dxf = disegno); w,h = misura del contenuto (px immagine; mm del viewBox per il DXF)
views: { img: { zoom: 1, fit: 1, w: 0, h: 0 }, dxf: { zoom: 1, fit: 1, w: 0, h: 0 } },
viewPanKey: '', // #113: anteprima in trascinamento ('' = nessuna)
_viewPan: null, // #113: {k, x, y, left, top} al pointerdown
_viewRO: {}, // #113: ResizeObserver per chiave, rifanno Adatta se il viewport cambia larghezza
// #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone) // #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone)
init() { init() {
@@ -205,25 +195,22 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.imageSrc = file ? URL.createObjectURL(file) : ''; this.imageSrc = file ? URL.createObjectURL(file) : '';
this.imagePx = null; this.imagePx = null;
this.imageBroken = false; this.imageBroken = false;
this.vReset('img'); // #113: nuova immagine, vista da rifare
this.result = null; this.result = null;
this.tested = false; // #104 this.tested = false; // #104
this.resPx = null; // #104
this.imageSource = file ? (source || '') : ''; // #104 this.imageSource = file ? (source || '') : ''; // #104
this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo 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 // #102: il <tmf-viewer> ha caricato l'immagine (evento viewer-load): legge i pixel dal detail
onImagePreviewLoad(el) { // #122: lo zoom e' del componente; qui restano solo le dimensioni per il riepilogo
onImagePreviewLoad(d) {
this.imageBroken = false; this.imageBroken = false;
this.imagePx = el && el.naturalWidth ? { w: el.naturalWidth, h: el.naturalHeight } : null; this.imagePx = d && d.width ? { w: d.width, h: d.height } : null;
if (this.imagePx) this.vSetContent('img', this.imagePx.w, this.imagePx.h); // #113: Adatta al caricamento
else this.vReset('img');
}, },
// #102: se il formato non e' mostrabile (sonda <img hidden> del template) compare l'avviso
onImagePreviewError() { onImagePreviewError() {
this.imageBroken = !!this.imageSrc; this.imageBroken = !!this.imageSrc;
this.imagePx = null; this.imagePx = null;
this.vReset('img'); // #113
}, },
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati) // #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
get imagePreviewUrl() { return this.imageSrc; }, get imagePreviewUrl() { return this.imageSrc; },
@@ -256,7 +243,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
async loadDxfPreview() { async loadDxfPreview() {
const seq = ++this._dxfPreviewSeq; const seq = ++this._dxfPreviewSeq;
this.dxfPreview = null; this.dxfPreview = null;
this.vReset('dxf'); // #113: nuova anteprima in arrivo
this.dxfPreviewError = ''; this.dxfPreviewError = '';
this.dxfPreviewBusy = false; this.dxfPreviewBusy = false;
if (!this.dxfFile) return; // file tolto: tutto azzerato if (!this.dxfFile) return; // file tolto: tutto azzerato
@@ -272,8 +258,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body); if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
else { else {
this.dxfPreview = body; this.dxfPreview = body;
const vb = this.dxfPreviewViewBox().split(' ').map(Number); // #113: misura del viewBox (mm) per lo zoom
this.vSetContent('dxf', vb[2], vb[3]);
} }
} catch (e) { } catch (e) {
if (seq !== this._dxfPreviewSeq) return; if (seq !== this._dxfPreviewSeq) return;
@@ -374,7 +358,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.error = this._errorText(body); this.error = this._errorText(body);
} else { } else {
this.result = body; this.result = body;
this.$nextTick(() => this.drawOverlay());
if (save && body.saved) { if (save && body.saved) {
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
@@ -392,7 +375,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.busy = false; this.busy = false;
// #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra) // #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra)
this.tested = true; this.tested = true;
this.$nextTick(() => this.fit());
}, },
// Le quattro uscite del confronto, nell'ordine fisso // Le quattro uscite del confronto, nell'ordine fisso
@@ -425,182 +407,9 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
return `${mm} mm/px (${this.imageSource === 'file' ? (this.msgs.scaleManual || 'manual') : (this.msgs.scaleStation || 'station calibration')})`; 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;
},
// #113: zoom per chiave ('img' | 'dxf'), stessa logica di #104 ma con stato in views[k]. Viewport fisso di 240px.
_vpEl(k) { return this.$refs ? this.$refs[k === 'img' ? 'vpImg' : 'vpDxf'] : null; },
_vLimits(k) {
const v = this.views[k];
// img: pixel nativi 5 %..800 %; dxf: da meta' a 50 volte Adatta
return k === 'img' ? [0.05, 8] : [v.fit * 0.5, v.fit * 50];
},
vPct(k) {
const v = this.views[k];
return Math.round((k === 'img' ? v.zoom : (v.fit ? v.zoom / v.fit : 1)) * 100) + ' %';
},
vW(k) { const v = this.views[k]; return v.w * v.zoom; },
vH(k) { const v = this.views[k]; return v.h * v.zoom; },
vCanPan(k) { const v = this.views[k]; return v.w > 0 && v.zoom > v.fit * 1.0001; },
vCursor(k) { return this.vCanPan(k) ? (this.viewPanKey === k ? 'grabbing' : 'grab') : 'default'; },
// #113: nessun contenuto: zoom di nuovo a 1 e misura azzerata (il wrapper sparisce)
vReset(k) { this.views[k] = { zoom: 1, fit: 1, w: 0, h: 0 }; },
// #113: nuovo contenuto: vista da Adatta (appena il viewport ha una larghezza) e osservatore di resize
vSetContent(k, w, h) {
if (!(w > 0) || !(h > 0)) { this.vReset(k); return; }
this.views[k] = { zoom: 1, fit: 1, w, h };
const run = () => { this._vObserve(k); this.vFit(k); };
if (typeof this.$nextTick === 'function') this.$nextTick(run); else run();
},
// #113: se il viewport era nascosto (larghezza 0) o cambia larghezza, rifa Adatta finche' la vista e' ad Adatta
_vObserve(k) {
const vp = this._vpEl(k);
if (!vp || this._viewRO[k] || typeof ResizeObserver === 'undefined') return;
this._viewRO[k] = new ResizeObserver(() => {
const v = this.views[k];
if (v.w > 0 && Math.abs(v.zoom - v.fit) < 1e-9 * Math.max(1, v.fit)) this.vFit(k);
});
this._viewRO[k].observe(vp);
},
vFit(k) {
const vp = this._vpEl(k), v = this.views[k];
if (!vp || !v.w || !v.h || !vp.clientWidth) return; // nascosto o vuoto: niente
// altezza utile del viewport (senza bordo), ripiego 240; con 240 secco il contenuto sborda e compare lo scroll
let cw = vp.clientWidth, ch = vp.clientHeight || 240;
// #113: Adatta premuto da ingrandito: client* escludono le barre di scorrimento che spariranno dopo il fit;
// l'area vera e' offset* meno i bordi (nei test senza DOM offsetWidth manca e restano client*)
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
const cs = getComputedStyle(vp);
cw = vp.offsetWidth - (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth) || 0);
ch = vp.offsetHeight - (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) || 0) || 240;
}
let z = Math.min(cw / v.w, ch / v.h);
if (k === 'img') z = Math.min(8, Math.max(0.05, z));
v.zoom = z;
v.fit = z;
vp.scrollLeft = 0; vp.scrollTop = 0;
},
vZoomBy(k, f) {
const vp = this._vpEl(k), v = this.views[k];
if (!v.w) return;
const old = v.zoom, lim = this._vLimits(k);
const z = Math.min(lim[1], Math.max(lim[0], old * f));
if (z === old) return;
// centro visibile invariato, come zoomBy (#104)
let cx = 0, cy = 0;
if (vp) { cx = (vp.scrollLeft + vp.clientWidth / 2) / old; cy = (vp.scrollTop + vp.clientHeight / 2) / old; }
v.zoom = z;
if (vp && typeof this.$nextTick === 'function') this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; });
},
vWheel(k, ev) {
if (!ev.ctrlKey) return;
ev.preventDefault();
this.vZoomBy(k, ev.deltaY < 0 ? 1.25 : 1 / 1.25);
},
vPanDown(k, ev) {
const vp = this._vpEl(k);
if (!vp || (ev.pointerType === 'mouse' && ev.button)) return;
if (ev.preventDefault) ev.preventDefault();
this._viewPan = { k, x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
this.viewPanKey = k;
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
},
vPanMove(k, ev) {
const vp = this._vpEl(k), p = this._viewPan;
if (!p || p.k !== k || !vp) return;
vp.scrollLeft = p.left - (ev.clientX - p.x);
vp.scrollTop = p.top - (ev.clientY - p.y);
},
vPanEnd() { this._viewPan = null; this.viewPanKey = ''; },
// #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) {
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it', return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
{ maximumFractionDigits: 3 }) : '-'; { maximumFractionDigits: 3 }) : '-';
}, },
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
for (const item of shapes) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
}; };
} }
@@ -0,0 +1,657 @@
/**
* image_viewer.js - #122: <tmf-viewer>, il visualizzatore immagine standard di TieMeasureFlow.
*
* Cosa fa: Custom Element in JS puro (senza Alpine ne' Tailwind) con zoom -/+, Adatta, Salva, percentuale,
* rotella (e Ctrl+rotella) intorno al puntatore, pan col puntatore, overlay SVG a tratto costante ed etichette,
* menu dei livelli in alto a sinistra (#12).
* Sostituisce le quattro implementazioni di zoom di prima (pannello operatore #115, wizard DXF #113 e #104,
* pagina camera #111). Light DOM, stile iniettato una volta nel <head> (<style id="tmf-viewer-css">).
* Come si usa: <script> vision_overlay.js e save_image.js, poi image_viewer.js; poi
* <tmf-viewer src="..." width="1920" height="1080" save-name="task_14" fit-height></tmf-viewer>
* Attributi: src, width, height (px logici), save-name, save-src, no-save, fit-height,
* label-fit, label-save, title-in, title-out, title-save.
* Senza src, un figlio <svg> (DXF) va nello stage al 100 % (modo contenuto; Salva lo rasterizza in PNG).
* Proprieta': overlay (forme vs-tools -> VisionOverlay.svg), labels ([{key,x,y,text,cls}] in px logici),
* zoom (sola lettura); metodi fit(), zoomBy(f). Eventi (bubbling): viewer-load {width,height}, viewer-zoom {zoom,fit}.
* #12 livelli: la rotella senza Ctrl zooma sul punto del mouse. L'overlay sta in tre gruppi SVG
* (<g data-layer>): ricerca, costruzioni (opacity 0.5), quote; un menu a caselle (.tmfv-layers) accende
* Immagine, Costruzioni, Ricerca (spenta di default), Quote, Nomi. #126 attributo no-layers = viewer semplice, come
* prima dei livelli: niente menu, un solo disegno (VisionOverlay.svg di tutte le forme, poi le quote), nessun <g data-layer>
* ne' opacity, forme e immagine sempre visibili, etichette sempre accese (text) e non trascinabili (niente quota-spostata);
* zoom con rotella, pan, Adatta e Salva restano. Lo stato dei livelli non conta con no-layers. Proprieta'
* layers get/set {immagine,costruzioni,ricerca,quote,nomi}, evento viewer-layers {layers}; stato in localStorage
* (chiave tmf-viewer-layers[:save-name]). Etichette estese: {key,x,y,text,breve,cls,nome,camera,quota,testo};
* con quota stanno sul punto della scritta (VisionOverlay.quotaPunto) e si trascinano: evento quota-spostata
* {key,nome,camera,testo:[du,dv]|null} (doppio clic = testo null, posizione di default).
* In Alpine: :src="...", x-effect="$el.overlay = ...; $el.labels = ...".
* Dipende da: window.VisionOverlay (facoltativo, per l'overlay), tmfSaveImage/tmfSaveBlob/tmfSvgToPngBlob di save_image.js
* (per Salva). Il modulo si carica anche in node (require): esporta solo TmfViewerMath, funzioni pure.
*/
(function () {
'use strict';
var TAG = 'tmf-viewer';
var STEP = 1.25; // #122: passo dei pulsanti +/- e della rotella (come #111 e #115)
// #12: livelli del menu, titoli, valori di default e colore delle quote per esito (classi cls di stazione.js)
var LAYER_NAMES = ['immagine', 'costruzioni', 'ricerca', 'quote', 'nomi'];
var LAYER_TITLES = { immagine: 'Immagine', costruzioni: 'Costruzioni', ricerca: 'Ricerca', quote: 'Quote', nomi: 'Nomi' };
var LAYER_DEFAULT = { immagine: true, costruzioni: true, ricerca: false, quote: true, nomi: true };
var QUOTA_COLORS = { 'stz-ok': '#16a34a', 'stz-avviso': '#d97706', 'stz-ko': '#dc2626' };
var QUOTA_COLOR_DEFAULT = '#ef4444';
// #122: matematica pura dello zoom, testata in node senza browser
var TmfViewerMath = {
// zoom che fa stare tutta l'immagine (w x h) nel box (boxW x boxH); 0 se non calcolabile. boxH puo' essere Infinity
fitZoom: function (boxW, boxH, w, h) {
if (!(boxW > 0) || !(boxH > 0) || !(w > 0) || !(h > 0)) return 0;
var f = Math.min(boxW / w, boxH / h);
return isFinite(f) && f > 0 ? f : 0;
},
// zoom ristretto a [fit * 0.5, max(8, fit * 8)]: il massimo segue il fit, cosi' un DXF in mm (fit ~10) si ingrandisce ancora
clampZoom: function (z, fit) {
var lo = fit * 0.5, hi = Math.max(8, fit * 8);
return Math.min(hi, Math.max(lo, z));
},
// nuovo scroll che tiene fermo il punto (px, py) del viewport passando dallo zoom z0 a z1
zoomAround: function (z0, z1, scrollL, scrollT, px, py) {
var k = z1 / z0;
return { scrollLeft: (scrollL + px) * k - px, scrollTop: (scrollT + py) * k - py };
},
pct: function (z) { return Math.round(z * 100) + ' %'; },
};
var CSS = [
'tmf-viewer{display:block;position:relative;overflow:hidden;background:#1f2937;min-width:0}',
'tmf-viewer *{box-sizing:border-box}',
'tmf-viewer [hidden]{display:none!important}',
// #122: anche l'host con l'attributo hidden si nasconde (display:block sopra vincerebbe sul [hidden] del browser)
'tmf-viewer[hidden]{display:none!important}',
'tmf-viewer .tmfv-vp{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;display:flex}',
'tmf-viewer[fit-height] .tmfv-vp{position:relative;width:100%}',
'tmf-viewer .tmfv-stage{position:relative;flex:none;margin:auto;user-select:none;-webkit-user-select:none}',
'tmf-viewer .tmfv-img{display:block;width:100%;height:100%;max-width:none;-webkit-user-drag:none}',
'tmf-viewer .tmfv-stage>svg:not(.tmfv-ov){display:block;width:100%;height:100%}',
'tmf-viewer .tmfv-ov{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:visible}',
'tmf-viewer .tmfv-labels{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}',
// #122: aspetto completo dell'etichetta; le classi `cls` della pagina (es. bg-red-600 text-white) cambiano solo i colori
'tmf-viewer .tmfv-label{position:absolute;transform:translate(8px,-50%);white-space:nowrap;font:700 12px/1.3 system-ui,sans-serif;',
'padding:2px 6px;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.4);pointer-events:none}',
':where(tmf-viewer .tmfv-label){color:#fff;background:rgba(0,0,0,.6)}',
'tmf-viewer .tmfv-bar{position:absolute;top:6px;right:6px;z-index:3;display:flex;align-items:center;gap:2px;padding:2px;',
'background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
'tmf-viewer .tmfv-bar button{appearance:none;border:0;border-radius:4px;background:transparent;color:#fff;',
'padding:2px 8px;font:inherit;cursor:pointer;min-width:26px}',
'tmf-viewer .tmfv-bar button:hover{background:rgba(255,255,255,.22)}',
'tmf-viewer .tmfv-pct{min-width:46px;text-align:center;font-variant-numeric:tabular-nums}',
// #12: etichette con key trascinabili; menu dei livelli in alto a sinistra, stile della barra
'tmf-viewer .tmfv-label[data-key]{pointer-events:auto;cursor:move;touch-action:none}',
// #126: con no-layers le etichette non si trascinano (stessa specificita' della regola sopra, viene dopo)
'tmf-viewer[no-layers] .tmfv-label,tmf-viewer[no-layers] .tmfv-label[data-key]{pointer-events:none;cursor:default}',
'tmf-viewer .tmfv-layers{position:absolute;top:6px;left:6px;z-index:3;display:flex;flex-wrap:wrap;align-items:center;gap:2px 10px;',
'padding:3px 8px;max-width:calc(100% - 12px);background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
'tmf-viewer .tmfv-layers label{display:inline-flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}',
'tmf-viewer .tmfv-layers input{margin:0;cursor:pointer}',
].join('\n');
// #122: stile iniettato una volta sola nel <head>
function injectCss() {
if (typeof document === 'undefined' || document.getElementById('tmf-viewer-css')) return;
var st = document.createElement('style');
st.id = 'tmf-viewer-css';
st.textContent = CSS;
(document.head || document.documentElement).appendChild(st);
}
function num(v) { var n = parseFloat(v); return n > 0 && isFinite(n) ? n : 0; }
// #122: dimensioni logiche di un <svg> dal viewBox, altrimenti da width/height
function svgDims(svg) {
var vb = svg.viewBox && svg.viewBox.baseVal;
if (vb && vb.width > 0 && vb.height > 0) return { w: vb.width, h: vb.height };
var a = (svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
if (a.length === 4 && a[2] > 0 && a[3] > 0) return { w: a[2], h: a[3] };
return { w: num(svg.getAttribute('width')), h: num(svg.getAttribute('height')) };
}
function defineViewer() {
var OBSERVED = ['src', 'width', 'height', 'fit-height', 'no-save', 'save-name',
'label-fit', 'label-save', 'title-in', 'title-out', 'title-save', 'no-layers'];
class TmfViewer extends HTMLElement {
static get observedAttributes() { return OBSERVED; }
constructor() {
super();
this._built = false;
this._w = 0; this._h = 0; // dimensioni logiche correnti
this._nw = 0; this._nh = 0; // dimensioni naturali dell'immagine
this._z = 0; this._fit = 0; this._auto = true;
this._ov = []; this._lb = [];
this._pan = null;
this._content = null; // modo contenuto: l'<svg> adottato
this._lastEvt = '';
this._lay = Object.assign({}, LAYER_DEFAULT); // #12: livelli visibili
this._ldrag = null; // #12: trascinamento di un'etichetta in corso
}
connectedCallback() {
injectCss();
if (this._built) return;
this._built = true;
// proprieta' impostate prima dell'upgrade dell'elemento: le riporto sugli accessor
var self = this;
this._layersLoad(); // #12: stato salvato, prima di eventuali proprieta' impostate dalla pagina
['overlay', 'labels', 'layers'].forEach(function (k) {
if (Object.prototype.hasOwnProperty.call(self, k)) { var v = self[k]; delete self[k]; self[k] = v; }
});
var kids = Array.prototype.slice.call(this.children);
var bar = document.createElement('div'); bar.className = 'tmfv-bar'; bar.hidden = true;
this._bOut = this._btn('−'); this._bIn = this._btn('+'); this._bFit = this._btn(''); this._bSave = this._btn('');
this._pctEl = document.createElement('span'); this._pctEl.className = 'tmfv-pct';
bar.appendChild(this._bOut); bar.appendChild(this._bIn); bar.appendChild(this._bFit);
bar.appendChild(this._bSave); bar.appendChild(this._pctEl);
// #12: menu dei livelli (caselle) in alto a sinistra
var lay = document.createElement('div'); lay.className = 'tmfv-layers'; lay.hidden = true;
this._layBoxes = {};
LAYER_NAMES.forEach(function (name) {
var lb = document.createElement('label'), cb = document.createElement('input'), tx = document.createElement('span');
cb.type = 'checkbox'; cb.setAttribute('data-layer', name); cb.checked = self._lay[name];
tx.textContent = LAYER_TITLES[name];
cb.addEventListener('change', function () { var o = {}; o[name] = cb.checked; self.layers = o; });
lb.appendChild(cb); lb.appendChild(tx); lay.appendChild(lb);
self._layBoxes[name] = cb;
});
var vp = document.createElement('div'); vp.className = 'tmfv-vp';
var stage = document.createElement('div'); stage.className = 'tmfv-stage';
var img = document.createElement('img'); img.className = 'tmfv-img'; img.draggable = false; img.alt = ''; img.hidden = true;
var ov = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
ov.setAttribute('class', 'tmfv-ov'); ov.setAttribute('preserveAspectRatio', 'none');
var labels = document.createElement('div'); labels.className = 'tmfv-labels';
stage.appendChild(img); stage.appendChild(ov); stage.appendChild(labels);
vp.appendChild(stage);
this._layBar = lay; this._bar = bar; this._vp = vp; this._stage = stage; this._img = img; this._ovEl = ov; this._lbEl = labels;
this.appendChild(bar); this.appendChild(lay); this.appendChild(vp);
this._bOut.onclick = function () { self.zoomBy(1 / STEP); };
this._bIn.onclick = function () { self.zoomBy(STEP); };
this._bFit.onclick = function () { self.fit(); };
this._bSave.onclick = function () { self._save(); };
img.addEventListener('load', function () { self._onLoad(); });
// #122: errore di caricamento -> evento viewer-error (bubbling) con l'URL che non si e' caricato
img.addEventListener('error', function () {
if (!self._hasSrc()) return; // l'errore di un src appena tolto non conta
self.dispatchEvent(new CustomEvent('viewer-error', { bubbles: true, detail: { src: self.getAttribute('src') } }));
});
// Ctrl+rotella = zoom intorno al puntatore; senza Ctrl la pagina scorre come sempre
vp.addEventListener('wheel', function (ev) { self._wheel(ev); }, { passive: false });
vp.addEventListener('pointerdown', function (ev) { self._panDown(ev); });
vp.addEventListener('pointermove', function (ev) { self._panMove(ev); });
vp.addEventListener('pointerup', function () { self._panEnd(); });
vp.addEventListener('pointercancel', function () { self._panEnd(); });
// resize del viewport: in stato «adatta» rifa Adatta; altrimenti aggiorna solo il fit
if (typeof ResizeObserver !== 'undefined') {
// #122: la callback cambia l'altezza del viewport (fit-height): rimando a un task dopo, senza «ResizeObserver loop»
this._ro = new ResizeObserver(function () {
if (self._raf) return;
var run = function () { self._raf = 0; self._onResize(); };
self._raf = setTimeout(run, 0);
});
this._ro.observe(vp);
}
// contenuto aggiunto dopo (x-html, template): lo adotto
if (typeof MutationObserver !== 'undefined') {
this._mo = new MutationObserver(function () { self._adopt(); });
this._mo.observe(this, { childList: true });
this._mo2 = new MutationObserver(function () { self._refresh(); });
}
kids.forEach(function (k) { self._take(k); });
this._texts();
this._loadSrc();
this._refresh();
}
disconnectedCallback() {
// gli osservatori restano: se l'elemento viene ricollegato il viewport e' lo stesso
}
attributeChangedCallback(name, oldV, newV) {
if (!this._built || oldV === newV) return;
if (name === 'src') this._loadSrc();
else if (name === 'width' || name === 'height') this._refresh();
else if (name === 'fit-height') { if (!this.hasAttribute('fit-height')) this._vp.style.height = ''; this._onResize(true); }
else if (name === 'no-layers') { this._syncLayers(); this._drawOverlay(); this._drawLabels(); } // #12 #126: ridisegna al cambio
else this._texts();
}
// #126: viewer senza livelli (attributo no-layers): disegno semplice, etichette fisse
_noLay() { return this.hasAttribute('no-layers'); }
_btn(txt) {
var b = document.createElement('button');
b.type = 'button'; b.textContent = txt;
return b;
}
// #122: testi e titoli dei pulsanti dagli attributi
_texts() {
var g = function (el, n, d) { var v = el.getAttribute(n); return v === null || v === '' ? d : v; };
this._bFit.textContent = g(this, 'label-fit', 'Adatta');
this._bSave.textContent = g(this, 'label-save', 'Salva');
this._bIn.title = g(this, 'title-in', 'Ingrandisci');
this._bOut.title = g(this, 'title-out', 'Riduci');
this._bSave.title = g(this, 'title-save', 'Salva immagine');
this._bSave.hidden = this.hasAttribute('no-save');
}
// #122: adotta un figlio <svg> come contenuto (modo contenuto, DXF)
_take(el) {
if (!el || el === this._bar || el === this._layBar || el === this._vp || el.parentNode !== this) return;
if (String(el.tagName).toLowerCase() !== 'svg') return;
if (this._content && this._content !== el && this._content.parentNode) this._content.parentNode.removeChild(this._content);
this._content = el;
this._stage.insertBefore(el, this._ovEl);
if (this._mo2) this._mo2.observe(el, { attributes: true, attributeFilter: ['viewBox', 'width', 'height'] });
}
_adopt() {
var kids = Array.prototype.slice.call(this.children), self = this, found = false;
kids.forEach(function (k) { if (k !== self._bar && k !== self._layBar && k !== self._vp) { self._take(k); found = true; } });
if (found) this._refresh();
}
// #122: (ri)carica l'immagine dall'attributo src
_loadSrc() {
var src = this.getAttribute('src');
if (src) {
this._img.hidden = false;
this._img.src = src;
} else {
this._img.removeAttribute('src');
this._img.hidden = true;
this._nw = 0; this._nh = 0;
this._refresh();
}
}
_onLoad() {
var img = this._img;
this._nw = img.naturalWidth; this._nh = img.naturalHeight;
this._refresh();
this.dispatchEvent(new CustomEvent('viewer-load', { bubbles: true, detail: { width: this._w, height: this._h } }));
}
_hasSrc() { return !!this.getAttribute('src'); }
// #122: ricalcola le dimensioni logiche; Adatta se cambiano (o se la vista era ad Adatta), zoom mantenuto altrimenti
_refresh() {
if (!this._built) return;
var w = num(this.getAttribute('width')), h = num(this.getAttribute('height'));
if (this._hasSrc()) {
if (!w) w = this._nw;
if (!h) h = this._nh;
if (!this._nw || !this._nh) { if (!(w && h)) { w = 0; h = 0; } }
} else if (this._content) {
var d = svgDims(this._content);
if (!w) w = d.w;
if (!h) h = d.h;
} else { w = 0; h = 0; }
if (!(w > 0 && h > 0)) { w = 0; h = 0; }
var changed = w !== this._w || h !== this._h;
this._w = w; this._h = h;
this._bar.hidden = !w;
this._img.hidden = !this._hasSrc();
if (this._content) this._content.style.display = this._hasSrc() ? 'none' : '';
if (!w) { this._z = 0; this._fit = 0; this._auto = true; this._apply(); return; }
if (changed || this._auto || !this._z) this.fit(); else this._apply();
if (!this._z) this._apply(true); // riquadro nascosto: Adatta rimandata, ma overlay ed etichette (in %) si disegnano gia'
}
// #122: area utile del viewport: offset meno i bordi (client* escludono le barre che spariranno dopo l'Adatta)
_area() {
var vp = this._vp, cw = vp.clientWidth, ch = vp.clientHeight, bx = 0, by = 0;
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
var cs = getComputedStyle(vp);
bx = (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)) || 0;
by = (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)) || 0;
cw = vp.offsetWidth - bx;
ch = vp.offsetHeight - by;
}
return { cw: cw, ch: ch, by: by };
}
// zoom di Adatta per l'area corrente; 0 se non calcolabile (riquadro nascosto o vuoto)
_calcFit() {
if (!this._w) return 0;
var a = this._area();
if (this.hasAttribute('fit-height')) return TmfViewerMath.fitZoom(a.cw, Infinity, this._w, this._h);
return TmfViewerMath.fitZoom(a.cw, a.ch, this._w, this._h);
}
// #122: altezza del viewport in fit-height: segue l'immagine adattata alla larghezza
_fitHeightBox(fit) {
if (!this.hasAttribute('fit-height')) return;
this._vp.style.height = fit > 0 ? (this._h * fit + this._area().by) + 'px' : '';
}
fit() {
if (!this._built || !this._w) return;
var f = this._calcFit();
if (!(f > 0)) return; // riquadro nascosto: ci pensa l'osservatore
this._fit = f; this._z = f; this._auto = true;
this._fitHeightBox(f);
this._vp.scrollLeft = 0; this._vp.scrollTop = 0;
this._apply();
}
// resize del viewport: Adatta se la vista non e' stata zoomata, altrimenti fit aggiornato e zoom nei limiti
_onResize(force) {
if (!this._built || !this._w) return;
var f = this._calcFit();
if (!(f > 0)) return;
if (!force && Math.abs(f - this._fit) < 1e-9 && this._z) return;
if (this._auto) { this.fit(); return; }
this._fit = f;
this._z = TmfViewerMath.clampZoom(this._z, f);
this._fitHeightBox(f);
this._apply();
}
// #122: zoom di un fattore f tenendo fermo il punto (px, py) del viewport (default: il centro)
zoomBy(f, px, py, ancora) { // `ancora` {ix, iy} (interna, #12): punto immagine da tenere sotto (px, py)
if (!this._built || !this._w || !this._z) return;
var vp = this._vp, z0 = this._z;
var z1 = TmfViewerMath.clampZoom(z0 * f, this._fit);
if (z1 === z0) return;
if (px == null) px = vp.clientWidth / 2;
if (py == null) py = vp.clientHeight / 2;
// #12: punto immagine sotto (px, py) misurato sullo stage reale, cosi' resta fermo anche quando lo stage e' centrato
// dal margin:auto (immagine piu' piccola del viewport prima dello zoom); zoomAround copre il caso senza geometria
var sr = this._stage.getBoundingClientRect(), vr = vp.getBoundingClientRect();
var ix = (px - (sr.left - vr.left - vp.clientLeft)) / z0, iy = (py - (sr.top - vr.top - vp.clientTop)) / z0;
// #12: con l'ancora di una sequenza di rotella si riporta sotto il mouse il punto di partenza, non quello corrente
if (ancora && isFinite(ancora.ix) && isFinite(ancora.iy)) { ix = ancora.ix; iy = ancora.iy; }
this._zpt = { ix: ix, iy: iy };
this._z = z1; this._auto = false;
this._apply(); // la dimensione dello stage cambia subito, poi si puo' scorrere
var sr2 = this._stage.getBoundingClientRect(), vr2 = vp.getBoundingClientRect();
if (sr.width > 0 && isFinite(ix) && isFinite(iy)) {
vp.scrollLeft += (sr2.left - vr2.left - vp.clientLeft + ix * z1) - px;
vp.scrollTop += (sr2.top - vr2.top - vp.clientTop + iy * z1) - py;
} else {
var s = TmfViewerMath.zoomAround(z0, z1, vp.scrollLeft, vp.scrollTop, px, py);
vp.scrollLeft = s.scrollLeft; vp.scrollTop = s.scrollTop;
}
this._apply(true);
}
get zoom() { return this._z; }
// #122: applica zoom allo stage, ridisegna overlay ed etichette, aggiorna barra e cursore, avvisa con viewer-zoom
_apply(quiet) {
if (!this._built) return;
var z = this._z;
this._stage.style.width = this._w && z ? (this._w * z) + 'px' : '0px';
this._stage.style.height = this._w && z ? (this._h * z) + 'px' : '0px';
// #122: modo contenuto (DXF in mm) = percentuale rispetto ad Adatta; immagine = 100 % un pixel per pixel
this._pctEl.textContent = TmfViewerMath.pct(!this._hasSrc() && this._content && this._fit ? z / this._fit : z);
this._drawOverlay();
this._drawLabels();
this._syncLayers(); // #12
this._cursor();
if (quiet) return;
var key = z + '|' + this._fit;
if (key !== this._lastEvt) {
this._lastEvt = key;
this.dispatchEvent(new CustomEvent('viewer-zoom', { bubbles: true, detail: { zoom: z, fit: this._fit } }));
}
}
_canPan() {
var vp = this._vp;
return this._w > 0 && (vp.scrollWidth > vp.clientWidth + 1 || vp.scrollHeight > vp.clientHeight + 1);
}
_cursor() {
var can = this._canPan();
this._vp.style.cursor = can ? (this._pan ? 'grabbing' : 'grab') : 'default';
this._vp.style.touchAction = can ? 'none' : 'auto'; // il dito trascina solo se c'e' qualcosa da scorrere
}
_wheel(ev) { // Ctrl+rotella (anche il pinch del trackpad) fa lo stesso
// #12: la rotella zooma sempre sul punto del mouse (prima solo con Ctrl); senza immagine la pagina scorre
if (!this._w) return;
ev.preventDefault();
var r = this._vp.getBoundingClientRect(), now = Date.now(), wa = this._wa;
// #12: sequenza di scatti a mouse fermo (<= 2 px, < 1500 ms) = stessa ancora; altrimenti la nuova ancora si misura in zoomBy.
// Cosi' lo scarto accumulato mentre lo stage e' piu' stretto del viewport (centrato, scroll impossibile) si recupera dopo.
var riusa = wa && Math.abs(ev.clientX - wa.cx) <= 2 && Math.abs(ev.clientY - wa.cy) <= 2 && now - wa.t < 1500;
this._zpt = null;
this.zoomBy(ev.deltaY < 0 ? STEP : 1 / STEP, ev.clientX - r.left - this._vp.clientLeft, ev.clientY - r.top - this._vp.clientTop,
riusa ? wa : null);
var pt = this._zpt || (riusa ? wa : null);
this._wa = pt ? { cx: ev.clientX, cy: ev.clientY, t: now, ix: pt.ix, iy: pt.iy } : null;
}
_panDown(ev) {
var vp = this._vp;
if (!this._canPan() || (ev.pointerType === 'mouse' && ev.button)) return;
ev.preventDefault(); // senza, Chromium manda pointercancel al primo movimento
this._wa = null; // #12: il pan cambia il punto sotto il mouse: niente ancora della rotella
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
this._cursor();
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
}
_panMove(ev) {
var p = this._pan;
if (!p) return;
this._vp.scrollLeft = p.left - (ev.clientX - p.x);
this._vp.scrollTop = p.top - (ev.clientY - p.y);
}
_panEnd() { this._pan = null; this._cursor(); }
// #122: overlay SVG a tratto costante; ridisegnato a ogni zoom perche' le croci dei points restano a dimensione fissa.
// #12: tre gruppi <g data-layer> (ricerca, costruzioni a opacity 0.5, quote); un livello spento ha display none.
_drawOverlay() {
var ov = this._ovEl, VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
if (!this._w) { ov.innerHTML = ''; return; }
ov.setAttribute('viewBox', '0 0 ' + this._w + ' ' + this._h);
if (!VO) { ov.innerHTML = ''; return; }
var z = this._z || 1, lay = this._lay, noLay = this._noLay(); // #126
var by = { ricerca: [], costruzioni: [], quote: [] };
(this._ov || []).forEach(function (o) {
var k = VO.layerOf ? VO.layerOf(o) : (o && o.role === 'found' ? 'costruzioni' : 'ricerca');
by[k].push(o);
});
var quote = noLay ? '' : VO.svg(by.quote, { zoom: z }); // #126: con no-layers le forme vanno tutte nel disegno unico sotto
(this._lb || []).forEach(function (l) {
if (!l) return;
var col = QUOTA_COLOR_DEFAULT;
String(l.cls || '').split(/\s+/).forEach(function (c) { if (QUOTA_COLORS[c]) col = QUOTA_COLORS[c]; });
if (l.quota && VO.quotaSvg) { quote += VO.quotaSvg(l.quota, { zoom: z, color: col }); return; }
var t = l.testo;
if (Array.isArray(t) && t.length === 2 && isFinite(t[0]) && isFinite(t[1]) && isFinite(l.x) && isFinite(l.y)) {
// senza quota ma spostata: linea sottile dal punto originale alla scritta
var r2 = function (v) { return String(Math.round(v * 100) / 100); };
quote += '<line x1="' + r2(l.x) + '" y1="' + r2(l.y) + '" x2="' + r2(l.x + t[0]) + '" y2="' + r2(l.y + t[1]) +
'" stroke="' + col + '" stroke-width="1" vector-effect="non-scaling-stroke"/>';
}
});
// #126: no-layers = disegno di prima dei livelli: tutte le forme nell'ordine dato, poi le quote; niente <g>, niente opacity
if (noLay) { ov.innerHTML = VO.svg(this._ov || [], { zoom: z }) + quote; return; }
var g = function (name, body, extra) {
return '<g data-layer="' + name + '"' + (extra || '') + (lay[name] ? '' : ' display="none"') + '>' + body + '</g>';
};
ov.innerHTML = g('ricerca', VO.svg(by.ricerca, { zoom: z })) +
g('costruzioni', VO.svg(by.costruzioni, { zoom: z }), ' opacity="0.5"') + g('quote', quote);
}
// #12: punto della scritta di un'etichetta in px logici: sulla quota, oppure (x,y) + testo [dx,dy], oppure (x,y)
_labelPos(l) {
var VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
if (l.quota && VO && VO.quotaPunto) { var q = VO.quotaPunto(l.quota); if (q) return { x: q.x, y: q.y, quota: true }; }
var t = l.testo;
if (Array.isArray(t) && t.length === 2 && isFinite(t[0]) && isFinite(t[1])) return { x: l.x + t[0], y: l.y + t[1], quota: false };
return { x: l.x, y: l.y, quota: false };
}
// #122: etichette in px logici -> % dello stage, cosi' seguono lo zoom senza ingrandirsi
// #12: testo per «Nomi» (text) o solo valore (breve); il livello Quote nasconde tutte le etichette
_drawLabels() {
var box = this._lbEl;
while (box.firstChild) box.removeChild(box.firstChild);
var noLay = this._noLay(); // #126
box.style.display = noLay || this._lay.quote ? '' : 'none'; // #126: con no-layers le etichette sono sempre visibili
if (!this._w) return;
var self = this, nomi = noLay || this._lay.nomi; // #126: con no-layers testo = text
(this._lb || []).forEach(function (l) {
if (!l || !isFinite(l.x) || !isFinite(l.y)) return;
var d = document.createElement('div');
d.className = 'tmfv-label' + (l.cls ? ' ' + l.cls : '');
var p = self._labelPos(l);
if (l.key != null) d.setAttribute('data-key', l.key);
d.style.left = (p.x / self._w * 100) + '%';
d.style.top = (p.y / self._h * 100) + '%';
if (p.quota) d.style.transform = 'translate(-50%,-50%)'; // la scritta di una quota e' centrata sul suo punto
var txt = nomi ? l.text : (l.breve != null ? l.breve : l.text);
d.textContent = txt == null ? '' : String(txt);
if (l.key != null && !noLay) { // #126: con no-layers nessun trascinamento
d.addEventListener('pointerdown', function (ev) { self._labelDown(ev, l); });
d.addEventListener('dblclick', function (ev) { self._labelReset(ev, l); });
}
box.appendChild(d);
});
}
// #12: sposta la scritta dell'etichetta nel punto (x, y) in px logici (testo [du,dv] per le quote, [dx,dy] per le altre)
_labelSet(l, x, y) {
var VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
var t = null;
if (l.quota && VO && VO.quotaTesto) t = VO.quotaTesto(l.quota, x, y);
if (t) l.quota = Object.assign({}, l.quota, { testo: t });
else t = [x - l.x, y - l.y];
l.testo = t;
}
_labelEmit(l, testo) {
this.dispatchEvent(new CustomEvent('quota-spostata', { bubbles: true,
detail: { key: l.key, nome: l.nome == null ? null : l.nome, camera: l.camera == null ? null : l.camera, testo: testo } }));
}
// #12: trascinamento di un'etichetta: niente pan (la propagazione si ferma), movimento sul documento
_labelDown(ev, l) {
if (ev.pointerType === 'mouse' && ev.button) return;
ev.stopPropagation(); ev.preventDefault();
var self = this, z = this._z || 1, p0 = this._labelPos(l);
var st = { cx: ev.clientX, cy: ev.clientY, moved: false };
var move = function (e) {
if (!st.moved && Math.abs(e.clientX - st.cx) + Math.abs(e.clientY - st.cy) < 3) return; // soglia: un clic non sposta
st.moved = true;
self._labelSet(l, p0.x + (e.clientX - st.cx) / z, p0.y + (e.clientY - st.cy) / z);
self._drawOverlay(); self._drawLabels();
};
var up = function () {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
document.removeEventListener('pointercancel', up);
self._ldrag = null;
if (!st.moved) return;
var t = [Math.round(l.testo[0] * 10) / 10, Math.round(l.testo[1] * 10) / 10]; // 0.1 px, come il contratto
if (l.quota) l.quota = Object.assign({}, l.quota, { testo: t });
l.testo = t;
self._drawOverlay(); self._drawLabels();
self._labelEmit(l, t);
};
this._ldrag = st;
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
document.addEventListener('pointercancel', up);
}
// #12: doppio clic = la scritta torna alla posizione di default (testo null)
_labelReset(ev, l) {
ev.stopPropagation(); ev.preventDefault();
if (l.quota) l.quota = Object.assign({}, l.quota, { testo: null });
l.testo = null;
this._drawOverlay(); this._drawLabels();
this._labelEmit(l, null);
}
// #12: livelli. Stato in localStorage (try/catch: puo' essere bloccato), chiave per save-name
_layKey() {
var n = this.getAttribute('save-name');
return 'tmf-viewer-layers' + (n ? ':' + n : '');
}
_layersLoad() {
try {
var raw = window.localStorage.getItem(this._layKey()), o = raw ? JSON.parse(raw) : null, lay = this._lay;
if (o && typeof o === 'object') LAYER_NAMES.forEach(function (k) { if (typeof o[k] === 'boolean') lay[k] = o[k]; });
} catch (e) { /* storage non disponibile: restano i default */ }
}
_layersSave() {
try { window.localStorage.setItem(this._layKey(), JSON.stringify(this._lay)); } catch (e) { /* storage non disponibile */ }
}
// #12: menu visibile con immagine o contenuto e almeno una forma o un'etichetta; immagine nascosta ma stage invariato
_syncLayers() {
if (!this._built || !this._layBar) return;
var has = (this._ov && this._ov.length) || (this._lb && this._lb.length);
this._layBar.hidden = this.hasAttribute('no-layers') || !this._w || !has;
this._img.style.visibility = this._noLay() || this._lay.immagine ? '' : 'hidden'; // #126: con no-layers l'immagine resta visibile
var self = this;
LAYER_NAMES.forEach(function (k) { self._layBoxes[k].checked = !!self._lay[k]; });
}
get layers() { return Object.assign({}, this._lay); }
set layers(v) {
var changed = false, lay = this._lay;
if (v && typeof v === 'object') {
LAYER_NAMES.forEach(function (k) {
if (k in v && !!v[k] !== lay[k]) { lay[k] = !!v[k]; changed = true; }
});
}
if (!changed || !this._layBar) return; // prima della costruzione del DOM resta solo lo stato
this._syncLayers(); this._drawOverlay(); this._drawLabels();
this._layersSave();
this.dispatchEvent(new CustomEvent('viewer-layers', { bubbles: true, detail: { layers: this.layers } }));
}
get overlay() { return this._ov; }
set overlay(v) { this._ov = Array.isArray(v) ? v : []; if (this._built) { this._drawOverlay(); this._syncLayers(); } }
get labels() { return this._lb; }
set labels(v) { this._lb = Array.isArray(v) ? v : []; if (this._built) { this._drawOverlay(); this._drawLabels(); this._syncLayers(); } } // #12: le quote stanno nell'overlay
// #122: Salva = immagine grezza (src o save-src) o, in modo contenuto, l'SVG rasterizzato
_save() {
var name = this.getAttribute('save-name') || 'immagine';
if (this._hasSrc()) {
if (typeof tmfSaveImage === 'function') return tmfSaveImage(this.getAttribute('save-src') || this.getAttribute('src'), name);
return Promise.resolve(false);
}
if (!this._content || typeof tmfSvgToPngBlob !== 'function' || typeof tmfSaveBlob !== 'function') return Promise.resolve(false);
var self = this;
return tmfSvgToPngBlob(this._content, this._w, this._h).then(function (blob) {
return tmfSaveBlob(blob, name);
}).catch(function (err) {
if (typeof console !== 'undefined') console.error('tmf-viewer salva:', err);
return false;
});
}
}
return TmfViewer;
}
// customElements.define una sola volta, anche se lo script e' caricato due volte
if (typeof customElements !== 'undefined' && typeof HTMLElement !== 'undefined' &&
!(typeof customElements.get === 'function' && customElements.get(TAG))) {
customElements.define(TAG, defineViewer());
if (typeof document !== 'undefined') injectCss();
}
if (typeof window !== 'undefined') window.TmfViewerMath = TmfViewerMath;
if (typeof module !== 'undefined' && module.exports) module.exports = { TmfViewerMath: TmfViewerMath };
})();
@@ -0,0 +1,158 @@
/**
* save_image.js - #119: salva il frame grezzo mostrato nel pannello visione come PNG, senza disegni.
*
* Cosa fa: tmfSaveImage(src, baseName) scarica l'immagine `src` (blob: o http), la converte in PNG se serve
* e la salva con nome <baseName>_<AAAAMMGG-hhmmss>.png: «Salva con nome» (showSaveFilePicker)
* dove c'e', altrimenti <a download>. Ritorna una Promise<boolean> (false se annullato o fallito).
* tmfSaveImageName(baseName, date) e' la funzione pura del nome, esportata per i test.
* Come si usa: <script src=".../save_image.js"></script> poi @click="tmfSaveImage(url, 'task_14')".
* Dipende da: fetch, canvas, createImageBitmap e window del browser, toccati solo dentro le funzioni
* (il modulo si carica anche in node, con require, senza questi globali).
* #122: tmfSaveBlob(blob, baseName) salva un blob PNG gia' pronto; tmfSvgToPngBlob(svgEl, w, h) rasterizza un <svg>
* (sfondo bianco, lato lungo 1600-4096 px). Entrambi usati da <tmf-viewer> (image_viewer.js).
* Idea di conversione da image_to_png.js (#107).
*/
// #119: nome file proposto <baseName>_<AAAAMMGG-hhmmss>.png; baseName ripulito dai caratteri non validi
function tmfSaveImageName(baseName, date) {
var base = String(baseName === undefined || baseName === null ? '' : baseName)
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_') // caratteri vietati nei nomi file Windows
.replace(/\s+/g, '_')
.replace(/^[._]+|[._]+$/g, ''); // niente punti o underscore ai bordi
if (!base) base = 'immagine';
var d = date instanceof Date && !isNaN(date.getTime()) ? date : new Date();
var p = function (n) { return (n < 10 ? '0' : '') + n; };
var stamp = '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' +
p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
return base + '_' + stamp + '.png';
}
// #119: ridisegna su canvas un'immagine non PNG (es. JPEG, BMP) e ritorna il blob PNG
async function tmfBlobToPng(blob) {
var bitmap = await createImageBitmap(blob);
var canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
if (bitmap.close) bitmap.close();
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
if (!png) throw new Error('conversione PNG fallita');
return png;
}
// #122: «Salva con nome» (showSaveFilePicker) dove c'e'; null se manca (si ripiega su <a download>). Puo' lanciare AbortError
async function tmfPickPng(name) {
if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') {
return window.showSaveFilePicker({
suggestedName: name,
types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }],
});
}
return null;
}
// #122: scrive il blob sul file scelto (handle) oppure, senza handle, lo scarica con <a download>
async function tmfWritePng(handle, blob, name) {
if (handle) {
var w = await handle.createWritable();
await w.write(blob);
await w.close();
return;
}
// #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): <a download>
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
// #122: gestione comune dell'errore di salvataggio: l'annullo dell'utente non e' un errore
function tmfSaveFailed(err, tag) {
if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore
if (typeof console !== 'undefined') console.error(tag + ':', err);
return false;
}
// #122: salva un blob PNG gia' pronto (es. l'SVG del DXF rasterizzato) come <baseName>_<data>.png; true = salvato
async function tmfSaveBlob(blob, baseName) {
if (!blob) return false;
try {
var name = tmfSaveImageName(baseName, new Date());
var handle = await tmfPickPng(name);
await tmfWritePng(handle, blob, name);
return true;
} catch (err) {
return tmfSaveFailed(err, 'tmfSaveBlob');
}
}
// #122: rasterizza un <svg> in PNG (sfondo bianco). w,h = dimensioni logiche (default dal viewBox o dagli attributi);
// lato lungo portato fra 1600 e 4096 px. Ritorna una Promise<Blob> o lancia se il browser non riesce a disegnarlo.
async function tmfSvgToPngBlob(svgEl, w, h) {
var vb = svgEl.getAttribute && svgEl.getAttribute('viewBox');
var vbp = vb ? vb.trim().split(/[\s,]+/).map(Number) : [];
if (!(w > 0)) w = vbp.length === 4 ? vbp[2] : parseFloat(svgEl.getAttribute('width'));
if (!(h > 0)) h = vbp.length === 4 ? vbp[3] : parseFloat(svgEl.getAttribute('height'));
if (!(w > 0) || !(h > 0)) throw new Error('dimensioni SVG non valide');
var scale = Math.min(4096, Math.max(1600, Math.max(w, h))) / Math.max(w, h);
var cw = Math.max(1, Math.round(w * scale));
var ch = Math.max(1, Math.round(h * scale));
var clone = svgEl.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (!vb) clone.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
clone.setAttribute('width', cw); // dimensione in pixel del PNG: l'SVG isolato non ha il CSS della pagina
clone.setAttribute('height', ch);
if (clone.style) { clone.style.width = ''; clone.style.height = ''; }
var xml = new XMLSerializer().serializeToString(clone);
var url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
try {
var img = new Image();
await new Promise(function (resolve, reject) {
img.onload = resolve;
img.onerror = function () { reject(new Error('SVG non disegnabile')); };
img.src = url;
});
var canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
var g = canvas.getContext('2d');
g.fillStyle = '#ffffff';
g.fillRect(0, 0, cw, ch);
g.drawImage(img, 0, 0, cw, ch);
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
if (!png) throw new Error('conversione PNG fallita');
return png;
} finally {
URL.revokeObjectURL(url);
}
}
// #119: scarica `src`, lo porta a PNG e lo salva; true = salvato, false = annullato dall'utente o errore
async function tmfSaveImage(src, baseName) {
if (!src) return false; // #119: nessuna immagine mostrata
try {
var name = tmfSaveImageName(baseName, new Date());
// #119: «Salva con nome» per primo, finche' vale il gesto dell'utente (Chrome lo rifiuta dopo ~5 s di fetch e conversione)
var handle = await tmfPickPng(name);
var resp = await fetch(src);
if (!resp.ok) return false;
var blob = await resp.blob();
if (blob.type !== 'image/png') blob = await tmfBlobToPng(blob);
await tmfWritePng(handle, blob, name);
return true;
} catch (err) {
return tmfSaveFailed(err, 'tmfSaveImage');
}
}
// #119: globale nel browser, export per il test node
// #122: esportati anche tmfSaveBlob e tmfSvgToPngBlob (usati da <tmf-viewer>)
if (typeof window !== 'undefined') {
window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName;
window.tmfSaveBlob = tmfSaveBlob; window.tmfSvgToPngBlob = tmfSvgToPngBlob;
}
if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob };
@@ -20,9 +20,6 @@
* Extra config: cameraAvailable, modo, checkUrl. * Extra config: cameraAvailable, modo, checkUrl.
*/ */
function visionExecute() { function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found).
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
return { return {
busy: false, busy: false,
grabbing: false, // frame requested from the station camera (#72) grabbing: false, // frame requested from the station camera (#72)
@@ -35,6 +32,7 @@ function visionExecute() {
preview: false, // result on screen is an analysis only, not saved preview: false, // result on screen is an analysis only, not saved
saved: false, // «Registra» done: the saved result replaces the preview saved: false, // «Registra» done: the saved result replaces the preview
lastBlob: null, // last frame shown, the one «Registra» saves lastBlob: null, // last frame shown, the one «Registra» saves
recordedBlob: null, // #123: frame saved by the last successful «Registra»: not recordable twice
paused: false, // operator paused the in-line loop paused: false, // operator paused the in-line loop
registering: false, // «Registra» in progress: the loop stands still registering: false, // «Registra» in progress: the loop stands still
leaving: false, // page is being left: no more requests leaving: false, // page is being left: no more requests
@@ -43,6 +41,14 @@ function visionExecute() {
// #97: start on a frame already acquired and analysed; hook visibility and exit. // #97: start on a frame already acquired and analysed; hook visibility and exit.
init() { init() {
// #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera
window.addEventListener('task-result-reset', () => {
this.saved = false;
// #123: il frame gia' registrato non e' piu' un'anteprima ne' si registra due volte
this.preview = !!this.result && this.lastBlob !== this.recordedBlob;
// #123: camera impulsiva = frame nuovo subito (come all'apertura); in linea riparte il ciclo
if (this.hasCamera && !this.inLinea) this.grab(); else this.kick();
});
if (!this.hasCamera) return; if (!this.hasCamera) return;
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
const leave = () => { this.leaving = true; }; const leave = () => { this.leaving = true; };
@@ -55,7 +61,7 @@ function visionExecute() {
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; }, get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; }, get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
// «Registra» needs a frame on screen that is not saved yet // «Registra» needs a frame on screen that is not saved yet
get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; }, get canRecord() { return !!this.lastBlob && this.lastBlob !== this.recordedBlob && !this.saved && !this.registering; }, // #123: non sul frame gia' registrato
shouldRun() { shouldRun() {
return this.inLinea && !this.paused && !this.saved && !this.registering return this.inLinea && !this.paused && !this.saved && !this.registering
@@ -120,7 +126,6 @@ function visionExecute() {
this.result = body; this.result = body;
this.preview = true; this.preview = true;
this.saved = false; this.saved = false;
this.$nextTick(() => this.drawOverlay());
return true; return true;
} catch (e) { } catch (e) {
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
@@ -143,6 +148,8 @@ function visionExecute() {
if (!this.error && this.result && this.result.vision_result_id) { if (!this.error && this.result && this.result.vision_result_id) {
this.preview = false; this.preview = false;
this.saved = true; this.saved = true;
this.recordedBlob = blob; // #123: questo frame e' registrato, Registra resta spento finche' non ne arriva uno nuovo
this._emitRecorded(); // #120: la pagina conta le quote registrate
} }
} finally { } finally {
this.registering = false; this.registering = false;
@@ -179,8 +186,8 @@ function visionExecute() {
return; return;
} }
this.result = body; this.result = body;
// Draw once Alpine has rendered the canvas. // #120: senza camera il file scelto e' salvato subito (nessun «Registra»): stesso evento
this.$nextTick(() => this.drawOverlay()); if (!this.hasCamera && body.vision_result_id) this._emitRecorded();
} catch (e) { } catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
} finally { } finally {
@@ -188,6 +195,12 @@ function visionExecute() {
} }
}, },
// #120: misure salvate (risposta di execute) alla pagina: conteggio quote e «Completato»
_emitRecorded() {
const ms = (this.result && this.result.measurements) || [];
window.dispatchEvent(new CustomEvent('vision-recorded', { detail: { measurements: ms } }));
},
// Station camera (#72): the Flask proxy answers a PNG, which goes through // Station camera (#72): the Flask proxy answers a PNG, which goes through
// acquire() like a picked file. Errors show as the execute ones do. // acquire() like a picked file. Errors show as the execute ones do.
// #97: sends task_id (the task's camera wins over the station's); with a camera // #97: sends task_id (the task's camera wins over the station's); with a camera
@@ -254,15 +267,51 @@ function visionExecute() {
return ((this.result && this.result.measurements) || []).map((m) => { return ((this.result && this.result.measurements) || []).map((m) => {
const st = subs.find((s) => s.id === m.subtask_id) || {}; const st = subs.find((s) => s.id === m.subtask_id) || {};
return { return {
// #116: chiave stabile della quota, nome senza numero di marker e uscita ricetta, per le etichette sull'immagine
key: m.subtask_id,
name: st.description || st.measurement_type || '#' + m.subtask_id,
vision_output: st.vision_output,
// #114: il nome della quota e' description (prima: measurement_type/name, che per le quote DXF e' vuoto -> '#15') // #114: il nome della quota e' description (prima: measurement_type/name, che per le quote DXF e' vuoto -> '#15')
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.description || st.measurement_type || '#' + m.subtask_id), label: (st.marker_number ? st.marker_number + '. ' : '') + (st.description || st.measurement_type || '#' + m.subtask_id),
value: m.value, value: m.value,
unit: st.unit || 'mm', unit: st.unit || 'mm',
status: m.pass_fail, status: m.pass_fail,
// #117: nominale e tolleranze della quota (come la schermata dei calibri); null se mancano
nominal: st.nominal, ltl: st.ltl, lwl: st.lwl, uwl: st.uwl, utl: st.utl,
}; };
}); });
}, },
// #122: zoom, pan, resize e Salva li gestisce <tmf-viewer> (static/js/image_viewer.js)
// #118: valore della cella: una quota non misurata (null) e' «NaN», mai Number(null) = 0
fmtValue(v, unit) {
return (v == null || !Number.isFinite(Number(v)) ? 'NaN' : Number(v).toFixed(3)) + ' ' + unit;
},
// #117: tolleranza a 3 decimali, trattino se manca (come x-text="...?.toFixed(3) || '—'" dei calibri, ma 0 resta 0.000)
fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); },
// #119 #122: nome proposto da «Salva» (titolo del task) per <tmf-viewer save-name>
get saveName() { return (window.__visionCfg || {}).taskName || 'immagine'; },
// #116 #122: etichette «nome valore unita» per <tmf-viewer>: x/y in px logici dell'immagine
// (il componente le posiziona in % e le tiene a dimensione costante a ogni zoom)
get viewerLabels() {
if (typeof VisionOverlay === 'undefined' || !this.result) return [];
return VisionOverlay.labels(this.rows, this.result.overlay || []).map((l) => ({
key: l.key, x: l.x, y: l.y, text: l.text, cls: this.labelClass(l.status),
}));
},
// #116: colori dell'etichetta per esito
labelClass(s) {
if (s === 'pass') return 'bg-green-600 text-white';
if (s === 'warning') return 'bg-amber-400 text-black';
if (s === 'fail') return 'bg-red-600 text-white';
return 'bg-gray-700 text-white';
},
statusClass(s) { statusClass(s) {
// Same palette as the measurement markers of the manual flow. // Same palette as the measurement markers of the manual flow.
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'; if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
@@ -270,27 +319,5 @@ function visionExecute() {
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'; if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'; return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
}, },
// Canvas is sized to the natural image, so overlay coordinates (image px)
// are used as they are and CSS scales the whole thing to the displayed size.
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
for (const item of ((this.result && this.result.overlay) || [])) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
}; };
} }
@@ -0,0 +1,424 @@
/**
* #116: disegno dell'overlay del check visione sul canvas del pannello operatore e
* ancoraggio delle etichette delle quote alla geometria del loro strumento.
*
* Script classico (niente import/export): lo carica task_execute.html prima di
* vision_execute.js, che lo usa via window.VisionOverlay. In node (test) si carica
* con require.
*
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
* labels(rows, overlay) -> [{key, x, y, text, status}];
* svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto);
* layerOf(o), quotaPunto(q), quotaTesto(q, x, y), quotaSvg(q, opts): livelli e quote CAD (#12).
* Convenzioni (vs-tools): row = y, col = x; angoli in gradi positivi antiorari a video.
* Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma)
* e frontend/src/editor/misure.js (ancoraOverlay).
*/
var VisionOverlay = (function () {
'use strict';
// #116: colore per ruolo, uguale a quello dello Studio
var COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
var COLOR_OTHER = '#ef4444';
function rad(d) { return (d * Math.PI) / 180; }
function num(v) { return typeof v === 'number' && isFinite(v); }
function allNum(o) {
for (var i = 1; i < arguments.length; i++) {
if (!o || !num(o[arguments[i]])) return false;
}
return true;
}
// #116: serie rows/cols valida = entrambe array, stessa lunghezza, non vuote, tutti numeri finiti
function validSeries(o) {
if (!o || !Array.isArray(o.rows) || !Array.isArray(o.cols)) return false;
if (o.rows.length === 0 || o.rows.length !== o.cols.length) return false;
for (var i = 0; i < o.rows.length; i++) {
if (!num(o.rows[i]) || !num(o.cols[i])) return false;
}
return true;
}
// #116: i 4 vertici del rect2 (x = col, y = row), come verticiRect2 dello Studio
function rect2Vertices(o) {
var a = rad(o.phi_deg);
var u = [Math.cos(a), -Math.sin(a)];
var v = [-Math.sin(a), -Math.cos(a)]; // u ruotato di +90 antiorario
return [[1, 1], [1, -1], [-1, -1], [-1, 1]].map(function (st) {
return [
o.col + st[0] * o.length1 * u[0] + st[1] * o.length2 * v[0],
o.row + st[0] * o.length1 * u[1] + st[1] * o.length2 * v[1],
];
});
}
// #116: disegna una forma; true se disegnata, false se saltata (tipo ignoto o dati malformati)
function drawShape(g, o, lw) {
var type = o && o.type;
var i;
switch (type) {
case 'rect2': {
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return false;
var vs = rect2Vertices(o);
g.beginPath();
g.moveTo(vs[0][0], vs[0][1]);
for (i = 1; i < vs.length; i++) g.lineTo(vs[i][0], vs[i][1]);
g.closePath();
g.stroke();
return true;
}
case 'segment':
case 'line':
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return false;
g.beginPath();
g.moveTo(o.col1, o.row1);
g.lineTo(o.col2, o.row2);
g.stroke();
return true;
case 'circle':
if (!allNum(o, 'row', 'col', 'radius')) return false;
g.beginPath();
g.arc(o.col, o.row, o.radius, 0, 2 * Math.PI);
g.stroke();
return true;
case 'arc':
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return false;
g.beginPath();
// #116: y canvas verso il basso, quindi angoli negati; extent > 0 = antiorario a video
g.arc(o.col, o.row, o.radius, -rad(o.start_deg), -rad(o.start_deg + o.extent_deg), o.extent_deg > 0);
g.stroke();
return true;
case 'points': {
if (!validSeries(o)) return false;
var m = Math.max(3, lw * 2); // semi-lato della croce
g.beginPath();
for (i = 0; i < o.rows.length; i++) {
g.moveTo(o.cols[i] - m, o.rows[i]);
g.lineTo(o.cols[i] + m, o.rows[i]);
g.moveTo(o.cols[i], o.rows[i] - m);
g.lineTo(o.cols[i], o.rows[i] + m);
}
g.stroke();
return true;
}
case 'contour':
if (!validSeries(o)) return false;
g.beginPath();
g.moveTo(o.cols[0], o.rows[0]);
for (i = 1; i < o.rows.length; i++) g.lineTo(o.cols[i], o.rows[i]);
g.stroke(); // polilinea aperta, come nello Studio
return true;
default:
return false;
}
}
function draw(g, overlay, lw) {
var res = { drawn: 0, skipped: 0 };
var items = Array.isArray(overlay) ? overlay : [];
for (var k = 0; k < items.length; k++) {
var o = items[k];
var role = o && o.role;
var isRoi = role === 'roi';
g.strokeStyle = COLORS[role] || COLOR_OTHER;
g.lineWidth = isRoi ? lw * 0.75 : lw;
g.setLineDash(isRoi ? [lw * 3, lw * 2] : []); // roi tratteggiata
var ok = false;
try { ok = drawShape(g, o, lw); } catch (e) { ok = false; } // un elemento rotto non ferma gli altri
if (ok) res.drawn++; else res.skipped++;
}
g.setLineDash([]);
return res;
}
// #121: numero per il markup SVG, al piu' 2 decimali (niente stringhe dei dati nel markup)
function n2(v) { return String(Math.round(v * 100) / 100); }
function pos(v, dflt) { return num(v) && v > 0 ? v : dflt; }
// #121: path SVG di un arco, porting di percorsoArco dello Studio; extent > 0 = antiorario a video
function arcPath(o) {
var ext = Math.max(-359.99, Math.min(359.99, o.extent_deg)); // 360 pieni darebbero start == end
var a0 = rad(o.start_deg);
var a1 = rad(o.start_deg + ext);
var x0 = o.col + o.radius * Math.cos(a0), y0 = o.row - o.radius * Math.sin(a0);
var x1 = o.col + o.radius * Math.cos(a1), y1 = o.row - o.radius * Math.sin(a1);
var large = Math.abs(ext) > 180 ? 1 : 0;
var sweep = ext > 0 ? 0 : 1; // y verso il basso: antiorario a video = sweep-flag 0
return 'M ' + n2(x0) + ' ' + n2(y0) + ' A ' + n2(o.radius) + ' ' + n2(o.radius) +
' 0 ' + large + ' ' + sweep + ' ' + n2(x1) + ' ' + n2(y1);
}
// #121: [tag, attributi geometrici] di una forma, o null se tipo ignoto o dati malformati (stessi criteri di drawShape)
function shapeSvg(o, cross) {
var i, pts;
switch (o && o.type) {
case 'rect2':
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return null;
pts = rect2Vertices(o).map(function (v) { return n2(v[0]) + ',' + n2(v[1]); }).join(' ');
return ['polygon', 'points="' + pts + '"'];
case 'segment':
case 'line':
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return null;
return ['line', 'x1="' + n2(o.col1) + '" y1="' + n2(o.row1) + '" x2="' + n2(o.col2) + '" y2="' + n2(o.row2) + '"'];
case 'circle':
if (!allNum(o, 'row', 'col', 'radius')) return null;
return ['circle', 'cx="' + n2(o.col) + '" cy="' + n2(o.row) + '" r="' + n2(o.radius) + '"'];
case 'arc':
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
return ['path', 'd="' + arcPath(o) + '"'];
case 'points': {
if (!validSeries(o)) return null;
var d = [];
for (i = 0; i < o.rows.length; i++) {
var x = o.cols[i], y = o.rows[i];
d.push('M ' + n2(x - cross) + ' ' + n2(y) + ' L ' + n2(x + cross) + ' ' + n2(y) +
' M ' + n2(x) + ' ' + n2(y - cross) + ' L ' + n2(x) + ' ' + n2(y + cross));
}
return ['path', 'd="' + d.join(' ') + '"'];
}
case 'contour':
if (!validSeries(o)) return null;
pts = o.rows.map(function (r, k) { return n2(o.cols[k]) + ',' + n2(r); }).join(' ');
return ['polyline', 'points="' + pts + '"']; // aperta, come draw
default:
return null;
}
}
// #121: overlay -> figli SVG (senza <svg> esterno) in px immagine. Tratto a spessore costante a video
// (vector-effect non-scaling-stroke), croci dei points a semi-braccio costante a video (cross / zoom).
function svg(overlay, opts) {
opts = opts || {};
var zoom = pos(opts.zoom, 1), lw = pos(opts.lw, 2), cross = pos(opts.cross, 6);
var items = Array.isArray(overlay) ? overlay : [];
var out = [];
for (var k = 0; k < items.length; k++) {
var o = items[k];
var sh = null;
try { sh = shapeSvg(o, cross / zoom); } catch (e) { sh = null; } // un elemento rotto non ferma gli altri
if (!sh) continue;
var role = o.role;
var isRoi = role === 'roi';
// solo colori fissi e numeri: nessuna stringa dei dati finisce nel markup
out.push('<' + sh[0] + ' ' + sh[1] + ' fill="none" stroke="' + (COLORS[role] || COLOR_OTHER) +
'" stroke-width="' + n2(isRoi ? lw * 0.75 : lw) + '"' +
(isRoi ? ' stroke-dasharray="6 4"' : '') + ' vector-effect="non-scaling-stroke"/>');
}
return out.join('');
}
// #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio
function anchor(o) {
switch (o && o.type) {
case 'segment':
case 'line':
return allNum(o, 'row1', 'col1', 'row2', 'col2')
? { x: (o.col1 + o.col2) / 2, y: (o.row1 + o.row2) / 2 } : null;
case 'circle':
return allNum(o, 'row', 'col', 'radius') ? { x: o.col + o.radius, y: o.row } : null; // punto a destra
case 'arc': {
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
var a = rad(o.start_deg + o.extent_deg / 2); // punto medio dell'arco
return { x: o.col + o.radius * Math.cos(a), y: o.row - o.radius * Math.sin(a) };
}
case 'points':
return Array.isArray(o.rows) && Array.isArray(o.cols) && num(o.rows[0]) && num(o.cols[0])
? { x: o.cols[0], y: o.rows[0] } : null;
case 'rect2':
return allNum(o, 'row', 'col') ? { x: o.col, y: o.row } : null;
case 'contour': {
if (!Array.isArray(o.rows) || !Array.isArray(o.cols)) return null;
var i = Math.floor(Math.min(o.rows.length, o.cols.length) / 2);
return num(o.rows[i]) && num(o.cols[i]) ? { x: o.cols[i], y: o.rows[i] } : null;
}
default:
return null;
}
}
// #116: un'etichetta per riga con vision_output 'tool_id.proprieta' che ha un overlay ancorabile
function labels(rows, overlay) {
var out = [];
var items = Array.isArray(overlay) ? overlay : [];
(rows || []).forEach(function (r) {
if (!r || typeof r.vision_output !== 'string' || r.vision_output.indexOf('.') < 0) return;
var toolId = r.vision_output.split('.')[0];
var mine = items.filter(function (o) { return o && o.tool_id === toolId; });
// prima gli elementi 'found', poi qualsiasi altro
var order = mine.filter(function (o) { return o.role === 'found'; })
.concat(mine.filter(function (o) { return o.role !== 'found'; }));
var pt = null;
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
if (!pt) return;
// #118: valore null/undefined/vuoto/non finito = quota non misurata (Number(null) e' 0): etichetta NaN, status della riga (fail)
var v = (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean')
? NaN : Number(r.value);
var txt = isFinite(v) ? v.toFixed(3) : 'NaN';
out.push({
key: r.key,
x: pt.x,
y: pt.y,
text: r.name + ' ' + txt + ' ' + (r.unit || ''),
status: r.status,
});
});
return out;
}
// ---- #12: livelli e quote stile CAD ------------------------------------------------------------
// Coordinate in px immagine; angoli in gradi antiorari a video, versore u(a) = (cos a, -sin a),
// normale n = (-u.y, u.x). Contratto: gitea:Adriano/TieVision-Station#12.
// #12: colori ammessi per le quote (esiti del visore + rosso di default); mai una stringa dei dati nel markup
var QUOTA_COLORS = ['#16a34a', '#d97706', '#dc2626', '#ef4444'];
var QUOTA_ARROW = 9, QUOTA_GAP = 3, QUOTA_OVER = 6, QUOTA_LW = 1.5; // px a video
// #12: livello di una forma: campo "layer" se valido, altrimenti found = costruzioni, il resto = ricerca
function layerOf(o) {
var l = o && o.layer;
if (l === 'costruzioni' || l === 'ricerca' || l === 'quote') return l;
return o && o.role === 'found' ? 'costruzioni' : 'ricerca';
}
function pt2(p) { return Array.isArray(p) && p.length === 2 && num(p[0]) && num(p[1]); }
function uVec(deg) { var a = rad(deg); return [Math.cos(a), -Math.sin(a)]; }
// #12: riferimento locale della quota {b, u, n, td, ...} o null se malformata
function quotaFrame(q) {
if (!q || typeof q !== 'object') return null;
var f = { tipo: q.tipo };
switch (q.tipo) {
case 'lineare': {
if (!pt2(q.p1) || !pt2(q.p2)) return null;
var dx = q.p2[0] - q.p1[0], dy = q.p2[1] - q.p1[1], L = Math.sqrt(dx * dx + dy * dy);
f.u = L > 1e-12 ? [dx / L, dy / L] : [1, 0];
f.b = [(q.p1[0] + q.p2[0]) / 2, (q.p1[1] + q.p2[1]) / 2];
f.L = L; f.dflt = [0, 0];
break;
}
case 'raggio':
if (!pt2(q.centro) || !num(q.r) || !num(q.dir_deg)) return null;
f.u = uVec(q.dir_deg); f.b = [q.centro[0], q.centro[1]]; f.dflt = [2 * q.r, 0];
break;
case 'angolo':
if (!pt2(q.centro) || !num(q.r) || !num(q.start_deg) || !num(q.extent_deg)) return null;
f.u = uVec(q.start_deg + q.extent_deg / 2); f.b = [q.centro[0], q.centro[1]]; f.dflt = [1.5 * q.r, 0];
break;
default:
return null;
}
f.n = [-f.u[1], f.u[0]];
f.td = pt2(q.testo) ? [q.testo[0], q.testo[1]] : f.dflt; // testo null o malformato = posizione di default
return f;
}
// #12: punto T della scritta (px immagine) = B + du*u + dv*n; null se la quota e' malformata
function quotaPunto(q) {
var f = quotaFrame(q);
if (!f) return null;
return { x: f.b[0] + f.td[0] * f.u[0] + f.td[1] * f.n[0], y: f.b[1] + f.td[0] * f.u[1] + f.td[1] * f.n[1] };
}
// #12: inverso di quotaPunto: [du, dv] che porta la scritta nel punto (x, y); serve al trascinamento
function quotaTesto(q, x, y) {
var f = quotaFrame(q);
if (!f || !num(x) || !num(y)) return null;
var rx = x - f.b[0], ry = y - f.b[1];
return [rx * f.u[0] + ry * f.u[1], rx * f.n[0] + ry * f.n[1]];
}
function qLine(x1, y1, x2, y2, color) {
return '<line x1="' + n2(x1) + '" y1="' + n2(y1) + '" x2="' + n2(x2) + '" y2="' + n2(y2) + '" stroke="' + color +
'" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>';
}
// #12: freccia piena con la punta in (x, y) rivolta verso (dx, dy) (versore); lunghezza fissa a video
function qArrow(x, y, dx, dy, zoom, color) {
var len = QUOTA_ARROW / zoom, w = len / 3;
var bx = x - dx * len, by = y - dy * len;
return '<polygon points="' + n2(x) + ',' + n2(y) + ' ' + n2(bx - dy * w) + ',' + n2(by + dx * w) + ' ' +
n2(bx + dy * w) + ',' + n2(by - dx * w) + '" fill="' + color + '" stroke="none"/>';
}
function quotaSvgLineare(q, f, zoom, color) {
var out = [], u = f.u, n = f.n, dv = f.td[1], du = f.td[0], i;
var sg = dv >= 0 ? 1 : -1, gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
var ps = [q.p1, q.p2];
if (Math.abs(dv) * zoom >= 3) { // linee di estensione: assenti se la linea di misura sta sul pezzo
for (i = 0; i < 2; i++) {
out.push(qLine(ps[i][0] + sg * gap * n[0], ps[i][1] + sg * gap * n[1],
ps[i][0] + (dv + sg * over) * n[0], ps[i][1] + (dv + sg * over) * n[1], color));
}
}
var m0 = [f.b[0] + dv * n[0], f.b[1] + dv * n[1]]; // punto medio della linea di misura
var h = f.L / 2, inside = f.L * zoom >= 2.5 * QUOTA_ARROW;
var lo = Math.min(-h, du), hi = Math.max(h, du); // la linea arriva fino alla scritta se sta oltre A o B
if (!inside) { lo = Math.min(lo, -h - 12 / zoom); hi = Math.max(hi, h + 12 / zoom); }
out.push(qLine(m0[0] + lo * u[0], m0[1] + lo * u[1], m0[0] + hi * u[0], m0[1] + hi * u[1], color));
var k = inside ? 1 : -1; // frecce corte: fuori, rivolte verso l'interno
out.push(qArrow(m0[0] - h * u[0], m0[1] - h * u[1], -k * u[0], -k * u[1], zoom, color));
out.push(qArrow(m0[0] + h * u[0], m0[1] + h * u[1], k * u[0], k * u[1], zoom, color));
return out.join('');
}
function quotaSvgRaggio(q, f, zoom, color) {
var out = [], c = q.centro, T = quotaPunto(q);
var vx = T.x - c[0], vy = T.y - c[1], d = Math.sqrt(vx * vx + vy * vy);
var w = d > 1e-9 ? [vx / d, vy / d] : uVec(q.dir_deg);
var px = c[0] + q.r * w[0], py = c[1] + q.r * w[1], cr = 4 / zoom;
out.push(qLine(c[0] - cr, c[1], c[0] + cr, c[1], color)); // crocetta sul centro
out.push(qLine(c[0], c[1] - cr, c[0], c[1] + cr, color));
if (d > q.r) { // scritta fuori dal cerchio: da P a T, freccia in P verso il centro
out.push(qLine(px, py, T.x, T.y, color));
out.push(qArrow(px, py, -w[0], -w[1], zoom, color));
} else { // scritta dentro: dal centro a P, freccia in P verso fuori
out.push(qLine(c[0], c[1], px, py, color));
out.push(qArrow(px, py, w[0], w[1], zoom, color));
}
return out.join('');
}
function quotaSvgAngolo(q, f, zoom, color) {
var out = [], c = q.centro, T = quotaPunto(q), i;
var rho = Math.sqrt((T.x - c[0]) * (T.x - c[0]) + (T.y - c[1]) * (T.y - c[1]));
if (!(rho > 1e-9)) rho = q.r > 0 ? 1.5 * q.r : 1;
var gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
var ends = [q.start_deg, q.start_deg + q.extent_deg];
var sgn = q.extent_deg >= 0 ? 1 : -1;
var inside = rho * rad(Math.abs(q.extent_deg)) * zoom >= 2.5 * QUOTA_ARROW;
for (i = 0; i < 2; i++) {
var u = uVec(ends[i]);
var a = rho >= q.r ? q.r + gap : rho - over, b = rho >= q.r ? rho + over : q.r - gap;
out.push(qLine(c[0] + a * u[0], c[1] + a * u[1], c[0] + b * u[0], c[1] + b * u[1], color)); // estensione radiale
}
out.push('<path d="' + arcPath({ col: c[0], row: c[1], radius: rho, start_deg: q.start_deg, extent_deg: q.extent_deg }) +
'" fill="none" stroke="' + color + '" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>');
for (i = 0; i < 2; i++) {
var ang = rad(ends[i]), uu = uVec(ends[i]);
var tg = [-Math.sin(ang), -Math.cos(ang)]; // tangente nel verso degli angoli crescenti
var dir = (i === 0 ? -1 : 1) * sgn * (inside ? 1 : -1); // dentro: verso fuori dall'arco; corte: verso l'interno
out.push(qArrow(c[0] + rho * uu[0], c[1] + rho * uu[1], dir * tg[0], dir * tg[1], zoom, color));
}
return out.join('');
}
// #12: quota -> figli SVG (px immagine). opts = {zoom, color}; misure fisse a video divise per lo zoom.
// Solo numeri e colori da lista fissa nel markup.
function quotaSvg(q, opts) {
opts = opts || {};
var zoom = pos(opts.zoom, 1);
var color = QUOTA_COLORS.indexOf(opts.color) >= 0 ? opts.color : COLOR_OTHER;
try {
var f = quotaFrame(q);
if (!f) return '';
if (f.tipo === 'lineare') return quotaSvgLineare(q, f, zoom, color);
if (f.tipo === 'raggio') return quotaSvgRaggio(q, f, zoom, color);
return quotaSvgAngolo(q, f, zoom, color);
} catch (e) { return ''; } // una quota rotta non ferma le altre
}
return { draw: draw, anchor: anchor, labels: labels, svg: svg,
layerOf: layerOf, quotaPunto: quotaPunto, quotaTesto: quotaTesto, quotaSvg: quotaSvg };
})();
if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay;
if (typeof module !== 'undefined' && module.exports) module.exports = VisionOverlay;
@@ -730,58 +730,37 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
<div x-show="imagePreviewUrl"> <div x-show="imagePreviewUrl">
<!-- #102: anteprima dell'immagine --> <!-- #102: anteprima dell'immagine -->
<!-- #113: barra zoom come quella della Prova (#104), per l'anteprima 'img'; x-show sul contenitore (sul viewport :style lo pesterebbe) --> {# #122: <tmf-viewer> standard (zoom, Adatta, Salva, pan) al posto della barra e del viewport scritti a mano (#113); altezza fissa 240px #}
<div class="relative" x-show="!imageBroken"> <div x-show="!imageBroken">{# #122: x-show sul contenitore, mai sul <tmf-viewer> #}
<div x-show="!imageBroken && views.img.w" class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5" {# #122: formato non mostrabile -> viewer-error del componente -> avviso imageBroken (al posto della sonda <img hidden>) #}
style="background: rgba(0,0,0,.55); color: #fff;"> {# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('img', 1 / 1.25)" title="{{ _('Riduci') }}">&minus;</button> <tmf-viewer :src="imagePreviewUrl" save-name="immagine" no-layers
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('img', 1.25)" title="{{ _('Ingrandisci') }}">+</button> label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vFit('img')">{{ _('Adatta') }}</button> title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
<span class="text-xs px-1" x-text="vPct('img')"></span> @viewer-load="onImagePreviewLoad($event.detail)" @viewer-error="onImagePreviewError()"
</div> class="rounded border border-[var(--border-color)]"
<!-- #113: viewport fisso 240px; il wrapper (margin auto) e' largo w*zoom: centrato se piu' piccolo, scorrevole se piu' grande --> style="height: 240px; background: var(--bg-primary);"></tmf-viewer>
<div x-ref="vpImg" @wheel="vWheel('img', $event)"
@pointerdown="vPanDown('img', $event)" @pointermove="vPanMove('img', $event)"
@pointerup="vPanEnd()" @pointercancel="vPanEnd()"
class="rounded border border-[var(--border-color)]"
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;height:240px;background:var(--bg-primary);cursor:' + vCursor('img')">
<div :style="'margin:auto;flex:none;' + (views.img.w ? ('width:' + vW('img') + 'px;height:' + vH('img') + 'px') : 'width:100%;height:240px')">{# #113: tutto in :style, Alpine con stringa sostituisce l'attributo style #}
<img :src="imagePreviewUrl" alt="" draggable="false"
@load="onImagePreviewLoad($event.target)" @error="onImagePreviewError()"
class="block" style="width: 100%; height: 100%; object-fit: contain; user-select: none;">
</div>
</div>
</div> </div>
<p x-show="imageBroken" class="text-xs text-amber-600">{{ _('Anteprima non disponibile per questo formato') }}</p> <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> <p x-show="imagePreviewSummary" class="mt-1 text-xs text-[var(--text-muted)]" x-text="imagePreviewSummary"></p>
</div> </div>
<div x-show="dxfPreview" class="sm:col-start-2">{# #104: resta a destra anche senza anteprima immagine #} <div x-show="dxfPreview" class="sm:col-start-2">{# #104: resta a destra anche senza anteprima immagine #}
<!-- #113: barra zoom come quella della Prova (#104), per l'anteprima 'dxf' --> {# #122: <tmf-viewer> in modo contenuto: lo <svg> (viewBox in mm) va nello stage; width/height = misura del viewBox, ora anche Salva (PNG) #}
<div class="relative"> {# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
<div class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5" <tmf-viewer save-name="dxf" no-layers
style="background: rgba(0,0,0,.55); color: #fff;"> :width="dxfPreviewViewBox().split(' ')[2]" :height="dxfPreviewViewBox().split(' ')[3]"
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('dxf', 1 / 1.25)" title="{{ _('Riduci') }}">&minus;</button> label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('dxf', 1.25)" title="{{ _('Ingrandisci') }}">+</button> title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vFit('dxf')">{{ _('Adatta') }}</button> class="rounded border border-[var(--border-color)]"
<span class="text-xs px-1" x-text="vPct('dxf')"></span> style="height: 240px; background: var(--bg-primary);">
</div> <svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
<!-- #113: stesso viewport dell'immagine; svg 100% del wrapper, viewBox in mm --> class="block rounded" style="width: 100%; height: 100%;">
<div x-ref="vpDxf" @wheel="vWheel('dxf', $event)" <!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox -->
@pointerdown="vPanDown('dxf', $event)" @pointermove="vPanMove('dxf', $event)" <path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
@pointerup="vPanEnd()" @pointercancel="vPanEnd()" stroke-width="1.5" vector-effect="non-scaling-stroke"
class="rounded border border-[var(--border-color)]" stroke-linejoin="round"></path>
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;height:240px;background:var(--bg-primary);cursor:' + vCursor('dxf')"> </svg>
<div :style="'margin:auto;flex:none;width:' + vW('dxf') + 'px;height:' + vH('dxf') + 'px'"> </tmf-viewer>
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
class="block rounded" style="width: 100%; height: 100%;">
<!-- #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>
</div>
</div>
</div>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p> <p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
</div> </div>
</div> </div>
@@ -812,29 +791,15 @@
<!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra --> <!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra -->
<div x-show="tested" class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #} <div x-show="tested" class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #}
<div> <div>
<!-- #104: barra zoom in sovrimpressione, fuori dal contenuto scorrevole --> {# #122: <tmf-viewer fit-height> al posto di barra, viewport, pan e overlay della Prova (#104 #121); overlay dalle forme del risultato #}
<div class="relative"> {# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
<div class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5" <tmf-viewer fit-height :src="imageSrc" save-name="prova" no-layers
style="background: rgba(0,0,0,.55); color: #fff;"> x-effect="$el.overlay = (result && result.result && result.result.overlay) || []"
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1 / 1.25)" title="{{ _('Riduci') }}">&minus;</button> label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button> title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="fit()">{{ _('Adatta') }}</button> class="rounded border border-[var(--border-color)]"
<span class="text-xs px-1" x-text="zoomPct"></span> style="background: var(--bg-primary);"></tmf-viewer>
</div> </div>{# #122: chiude solo la colonna dell'immagine (una chiusura in piu' chiudeva la griglia) #}
<!-- #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"> <div class="space-y-2">
<p x-show="error" class="text-xs text-red-500" x-text="error"></p> <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> <p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
@@ -1516,6 +1481,9 @@
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script> <script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script> <script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script> <script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_overlay.js') }}"></script>{# #121 #}
<script src="{{ url_for('static', filename='js/save_image.js') }}"></script>{# #119 #}
<script src="{{ url_for('static', filename='js/image_viewer.js') }}"></script>{# #122: <tmf-viewer>, dopo vision_overlay.js e save_image.js #}
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script> <script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
<script src="{{ url_for('static', filename='js/task_acquisition.js') }}"></script>{# #97 #} <script src="{{ url_for('static', filename='js/task_acquisition.js') }}"></script>{# #97 #}
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script> <script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
@@ -195,10 +195,12 @@
{{ "%.3f"|format(m.subtask.nominal) }} {{ m.subtask.unit }} {{ "%.3f"|format(m.subtask.nominal) }} {{ m.subtask.unit }}
</td> </td>
<td class="text-right measure-value font-semibold" style="color: var(--text-primary);"> <td class="text-right measure-value font-semibold" style="color: var(--text-primary);">
{{ "%.3f"|format(m.value) }} {{ m.subtask.unit }} {# #118: quota non misurata (value None) -> NaN, senza unita' #}
{% if m.value is none %}NaN{% else %}{{ "%.3f"|format(m.value) }} {{ m.subtask.unit }}{% endif %}
</td> </td>
<td class="text-right measure-value {% if m.deviation > 0 %}text-measure-fail dark:text-red-400{% elif m.deviation < 0 %}text-measure-pass dark:text-green-400{% else %}{% endif %}"> <td class="text-right measure-value {% if m.value is not none and m.deviation is not none and m.deviation > 0 %}text-measure-fail dark:text-red-400{% elif m.value is not none and m.deviation is not none and m.deviation < 0 %}text-measure-pass dark:text-green-400{% else %}{% endif %}">
{{ "%+.3f"|format(m.deviation) }} {# #118: deviazione None, o valore None (il blueprint la mette a 0.0) -> NaN #}
{% if m.value is none or m.deviation is none %}NaN{% else %}{{ "%+.3f"|format(m.deviation) }}{% endif %}
</td> </td>
<td class="text-center"> <td class="text-center">
{% if m.pass_fail == 'pass' %} {% if m.pass_fail == 'pass' %}
@@ -69,7 +69,8 @@
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)" @numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
@numpad-rejected.window="onNumpadRejected($event.detail)" @numpad-rejected.window="onNumpadRejected($event.detail)"
@numpad-error.window="onNumpadError($event.detail)" @numpad-error.window="onNumpadError($event.detail)"
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"> @marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"
@vision-recorded.window="onVisionRecorded($event.detail)">
{# ================================================================ {# ================================================================
HEADER — Compact task info bar HEADER — Compact task info bar
@@ -471,7 +472,8 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg> </svg>
<span>{{ _('Registrata') }}:</span> <span>{{ _('Registrata') }}:</span>
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span> {# #118: quota registrata senza valore (visione, NaN) #}
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) ?? 'NaN'"></span>
<span x-text="currentSubtask?.unit || 'mm'"></span> <span x-text="currentSubtask?.unit || 'mm'"></span>
</div> </div>
</div> </div>
@@ -539,13 +541,14 @@
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2"> <div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #} {% if not camera_enabled %}
{# #119: l'input file solo senza camera. On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''"> <label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span> <span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
<input type="file" accept="image/*" capture="environment" class="hidden" <input type="file" accept="image/*" capture="environment" class="hidden"
@change="onPick($event)" :disabled="busy"> @change="onPick($event)" :disabled="busy">
</label> </label>
{% if camera_enabled %} {% else %}
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #} {# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
{% if camera_mode == 'in_linea' %} {% if camera_mode == 'in_linea' %}
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()" <button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
@@ -558,7 +561,7 @@
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()" <button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''" :class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
:disabled="busy || grabbing"> :disabled="busy || grabbing">
<span>{{ _('Acquisisci') }}</span> <span>{{ _('Acquisisci da camera') }}</span> {# #119 #}
</button> </button>
{% endif %} {% endif %}
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #} {# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
@@ -576,10 +579,13 @@
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div> <div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
{# #96: riquadro flex-1 min-h-0; img e canvas object-contain sovrapposti (il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato) #} {# #96: riquadro flex-1 min-h-0; #122: dentro, <tmf-viewer> (zoom, pan, Salva, overlay SVG a tratto costante ed etichette quota) riempie il riquadro; overlay e labels arrivano da x-effect (px dell'immagine, #121 #116); x-show sul contenitore, mai sul viewer #}
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5"> <div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="absolute inset-0 w-full h-full object-contain"> {# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas> <tmf-viewer class="block w-full h-full" no-layers :src="shownImage" :save-name="saveName"
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
x-effect="$el.overlay = (result && result.overlay) || []; $el.labels = viewerLabels"></tmf-viewer>
</div> </div>
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]"> <div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }} {{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
@@ -593,12 +599,29 @@
</div> </div>
<table class="w-full text-sm" x-show="rows.length"> <table class="w-full text-sm" x-show="rows.length">
{# #117: due righe per quota (esito, poi nominale e tolleranze come nella schermata dei calibri); un solo tbody per quota perche' x-for vuole un'unica radice #}
<template x-for="r in rows" :key="r.label"> <template x-for="r in rows" :key="r.label">
<tr> <tbody>
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td> <tr>
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td> <td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td> {# #118: quota non misurata (value null) = «NaN», esito fail #}
</tr> <td class="py-1 pr-2 font-mono text-right" x-text="fmtValue(r.value, r.unit)"></td>
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
</tr>
<tr class="border-b border-[var(--border-color)]">
<td colspan="3" class="pb-1.5 text-[11px] font-mono">
<span class="text-primary font-semibold">{{ _('Nom') }} <span x-text="fmtTol(r.nominal)"></span></span>
<span class="text-measure-fail ml-2">LTL <span x-text="fmtTol(r.ltl)"></span></span>
<span class="text-measure-fail ml-1">UTL <span x-text="fmtTol(r.utl)"></span></span>
<template x-if="r.lwl != null || r.uwl != null">
<span>
<span class="text-measure-warning ml-2">LWL <span x-text="fmtTol(r.lwl)"></span></span>
<span class="text-measure-warning ml-1">UWL <span x-text="fmtTol(r.uwl)"></span></span>
</span>
</template>
</td>
</tr>
</tbody>
</template> </template>
</table> </table>
@@ -1172,10 +1195,17 @@
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}', imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }}, taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }}, taskType: {{ (task.task_type or '')|tojson }},
taskName: {{ (task.title or '')|tojson }}, {# #119: nome proposto per «Salva» #}
subtasks: window.__taskSubtasks, subtasks: window.__taskSubtasks,
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} }, labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
}; };
</script> </script>
{# #116: disegno overlay ed etichette quote; prima di vision_execute.js, che lo usa se presente #}
<script src="{{ url_for('static', filename='js/vision_overlay.js') }}"></script>
{# #119: «Salva» immagine (frame grezzo PNG) #}
<script src="{{ url_for('static', filename='js/save_image.js') }}"></script>
{# #122: visualizzatore standard <tmf-viewer>, dopo vision_overlay.js e save_image.js #}
<script src="{{ url_for('static', filename='js/image_viewer.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script> <script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #} {# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
<script> <script>
@@ -1217,6 +1247,7 @@ function taskExecute() {
// ---- Measurement state ---- // ---- Measurement state ----
currentIndex: 0, currentIndex: 0,
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...] measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
visionRecorded: false, // #120: task visione: il pezzo e' stato registrato («Registra»), le quote sono in measurements
saving: false, saving: false,
// The single reentrancy guard for handleMeasurement (#13): true for the // The single reentrancy guard for handleMeasurement (#13): true for the
// whole time a reading is being processed, including the 1s feedback // whole time a reading is being processed, including the 1s feedback
@@ -1323,6 +1354,8 @@ function taskExecute() {
}, },
get missingQuotesLabel() { get missingQuotesLabel() {
// #120: task visione non registrato: le quote arrivano tutte col «Registra», non una a una
if (this.isVisionTask && !this.visionRecorded) return {{ _('Registra il pezzo')|tojson }};
const n = this.missingQuotes; const n = this.missingQuotes;
return n === 1 return n === 1
? {{ _('Manca 1 quota')|tojson }} ? {{ _('Manca 1 quota')|tojson }}
@@ -1336,6 +1369,8 @@ function taskExecute() {
ciclo misura», and the quotes it exists to collect would be optional. */ ciclo misura», and the quotes it exists to collect would be optional. */
get canLeaveTask() { get canLeaveTask() {
if (this.blockedByTolerance) return false; if (this.blockedByTolerance) return false;
// #120: un task visione si lascia solo dopo «Registra» (anche dxf_compare, prima sempre attivo)
if (this.isVisionTask && !this.visionRecorded) return false;
if (this.isMeasureTask && this.subtasks.length > 0) { if (this.isMeasureTask && this.subtasks.length > 0) {
return this.cycleConfirmed; return this.cycleConfirmed;
} }
@@ -1346,6 +1381,7 @@ function taskExecute() {
if (this.blockedByTolerance) { if (this.blockedByTolerance) {
return {{ _("Serve l'autorizzazione del capoturno")|tojson }}; return {{ _("Serve l'autorizzazione del capoturno")|tojson }};
} }
if (this.isVisionTask && !this.visionRecorded) return this.missingQuotesLabel; // #120
if (!this.isComplete) return this.missingQuotesLabel; if (!this.isComplete) return this.missingQuotesLabel;
return {{ _('Chiudi prima il ciclo di misura')|tojson }}; return {{ _('Chiudi prima il ciclo di misura')|tojson }};
}, },
@@ -1429,9 +1465,10 @@ function taskExecute() {
subtask_id: m.subtask_id, subtask_id: m.subtask_id,
value: m.value, value: m.value,
pass_fail: m.pass_fail, pass_fail: m.pass_fail,
deviation: m.value - nominal, deviation: m.value == null ? null : m.value - nominal, // #118: quota non misurata = NaN, nessuna deviazione
}; };
}); });
if (this.isVisionTask) this.visionRecorded = true; // #120: misure ripristinate = pezzo gia' registrato
const next = this.subtasks.findIndex( const next = this.subtasks.findIndex(
s => !this.measurements.some(m => m.subtask_id === s.id), s => !this.measurements.some(m => m.subtask_id === s.id),
); );
@@ -1912,6 +1949,23 @@ function taskExecute() {
this.redirectIn = this.measurementTaskUrl ? 5 : 0; this.redirectIn = this.measurementTaskUrl ? 5 : 0;
}, },
/* #120: «Registra» del pannello visione: le misure salvate dal server diventano le misure del
ciclo (conteggio quote, «Fine ciclo misura»/«Completato»). value null = quota non misurata. */
onVisionRecorded(detail) {
const ms = (detail && detail.measurements) || [];
this.measurements = ms.map(m => {
const st = this.subtasks.find(s => s.id === m.subtask_id);
const nominal = st ? (st.nominal || 0) : 0;
return {
subtask_id: m.subtask_id,
value: m.value == null ? null : m.value,
pass_fail: m.pass_fail,
deviation: m.value == null ? null : m.value - nominal,
};
});
this.visionRecorded = true;
},
/* Clear the screen for another pass over the same quotes. /* Clear the screen for another pass over the same quotes.
Local only: everything already measured is on the server, and the next Local only: everything already measured is on the server, and the next
reading is saved beside it rather than replacing it. */ reading is saved beside it rather than replacing it. */
@@ -1919,6 +1973,7 @@ function taskExecute() {
this.cycleConfirmed = false; this.cycleConfirmed = false;
this.showCompletionOverlay = false; this.showCompletionOverlay = false;
this.measurements = []; this.measurements = [];
this.visionRecorded = false; // #120: nuovo ciclo = pezzo da registrare di nuovo
this.currentIndex = 0; this.currentIndex = 0;
this.currentValue = null; this.currentValue = null;
this.errorMessage = ''; this.errorMessage = '';
@@ -56,7 +56,7 @@
<span class="truncate" x-text="s.description"></span> <span class="truncate" x-text="s.description"></span>
<span class="font-mono whitespace-nowrap" <span class="font-mono whitespace-nowrap"
:class="s.in_tolerance ? 'text-green-700' : 'text-red-700 font-bold'" :class="s.in_tolerance ? 'text-green-700' : 'text-red-700 font-bold'"
x-text="s.mean.toFixed(3) + ' ' + s.unit"></span> x-text="(s.mean == null ? 'NaN' : s.mean.toFixed(3)) + ' ' + s.unit"></span>{# #118: quota con soli valori NaN: media null #}
</li> </li>
</template> </template>
</ul> </ul>
@@ -0,0 +1,204 @@
<!doctype html>
<!-- #122: pagina di prova di <tmf-viewer> da aprire in Chrome (file:// va bene): immagine generata col canvas,
overlay vs-tools finto, etichette e modo contenuto (svg). Il riquadro #log riporta i controlli automatici. -->
<html lang="it">
<head>
<meta charset="utf-8">
<title>Prova tmf-viewer</title>
<style>
body { font: 14px system-ui, sans-serif; margin: 16px; background: #f3f4f6; }
.griglia { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.box { height: 320px; }
h2 { font-size: 13px; margin: 12px 0 4px; }
#log { white-space: pre; font: 12px ui-monospace, monospace; background: #fff; padding: 8px; margin-top: 12px; }
</style>
</head>
<body>
<h1>Prova di &lt;tmf-viewer&gt;</h1>
<div class="griglia">
<div><h2>Immagine 800x500 + overlay + etichette (box fisso, Adatta nel box)</h2>
<tmf-viewer id="a" class="box" save-name="demo_immagine" width="800" height="500"></tmf-viewer></div>
<div><h2>fit-height, no-save: alto quanto l'immagine</h2>
<tmf-viewer id="b" fit-height no-save label-fit="Intera"></tmf-viewer></div>
<div><h2>Modo contenuto: svg (DXF) senza src</h2>
<tmf-viewer id="c" class="box" save-name="demo_dxf">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect x="5" y="5" width="190" height="90" fill="none" stroke="#fff" stroke-width="1"/><circle cx="100" cy="50" r="30" fill="none" stroke="#0ff" stroke-width="1"/></svg>
</tmf-viewer></div>
<div><h2>Vuoto: barra nascosta</h2><tmf-viewer id="d" class="box"></tmf-viewer></div>
<!-- #126: no-layers (overlay misto, etichette con key) e, per confronto, lo stesso senza no-layers -->
<div><h2>#126 no-layers: overlay misto, etichette fisse</h2>
<tmf-viewer id="f" class="box" no-layers width="400" height="250" save-name="demo_nolayers"></tmf-viewer></div>
<div><h2>#126 senza no-layers (confronto): livelli e trascinamento</h2>
<tmf-viewer id="g" class="box" width="400" height="250" save-name="demo_conlayers"></tmf-viewer></div>
</div>
<div id="log">in corso...</div>
<script>
// #126: stato dei livelli salvato prima del caricamento del visore (ricerca spenta): con no-layers non deve contare
try { localStorage.setItem('tmf-viewer-layers', JSON.stringify({ ricerca: false })); } catch (e) { /* storage bloccato */ }
</script>
<script src="../../../static/js/vision_overlay.js"></script>
<script src="../../../static/js/save_image.js"></script>
<script src="../../../static/js/image_viewer.js"></script>
<script>
window.onerror = function (m, u, l) { document.getElementById('log').textContent += '\nERRORE ' + m + ' riga ' + l; }; // errori visibili nel riquadro
(function () {
var out = [], fails = 0;
function check(l, c, x) { if (!c) fails++; out.push((c ? 'ok - ' : 'FAIL - ') + l + (c || x === undefined ? '' : ' :: ' + x)); document.getElementById('log').textContent = out.join('\n'); } // log aggiornato a ogni controllo
window.tmfCheck = check; // #126: il blocco no-layers in fondo alla pagina scrive nello stesso #log
function img(w, h, col) {
var c = document.createElement('canvas'); c.width = w; c.height = h;
var g = c.getContext('2d'); g.fillStyle = col; g.fillRect(0, 0, w, h);
g.fillStyle = '#fff'; for (var x = 0; x < w; x += 100) g.fillRect(x, 0, 2, h);
for (var y = 0; y < h; y += 100) g.fillRect(0, y, w, 2);
return c.toDataURL('image/png');
}
var a = document.getElementById('a'), b = document.getElementById('b'), c = document.getElementById('c'), d = document.getElementById('d');
var ev = { load: 0, zoom: 0 };
a.addEventListener('viewer-load', function (e) { ev.load++; ev.det = e.detail; });
a.addEventListener('viewer-zoom', function () { ev.zoom++; });
a.overlay = [{ type: 'segment', role: 'found', row1: 50, col1: 50, row2: 450, col2: 750 },
{ type: 'points', role: 'reference', rows: [100, 200], cols: [100, 300] }];
a.labels = [{ key: 'q1', x: 400, y: 250, text: 'Quota 1 12.345 mm', cls: 'ok' }];
a.setAttribute('src', img(800, 500, '#334155'));
b.setAttribute('src', img(1000, 400, '#475569'));
function btn(v, i) { return v.querySelectorAll('.tmfv-bar button')[i]; }
setTimeout(function () {
var vpa = a.querySelector('.tmfv-vp'), st = a.querySelector('.tmfv-stage');
var fitA = function () { return Math.min(vpa.clientWidth / 800, vpa.clientHeight / 500); };
check('a: viewer-load una volta con 800x500', ev.load === 1 && ev.det && ev.det.width === 800 && ev.det.height === 500, JSON.stringify(ev));
check('a: Adatta = min(box/img), stage entra nel box', Math.abs(a.zoom - fitA()) < 0.01 && st.offsetWidth <= vpa.clientWidth + 1 && st.offsetHeight <= vpa.clientHeight + 1,
a.zoom + ' ' + st.offsetWidth + 'x' + st.offsetHeight + ' vp ' + vpa.clientWidth + 'x' + vpa.clientHeight);
check('a: percentuale mostrata', /^\d+ %$/.test(a.querySelector('.tmfv-pct').textContent), a.querySelector('.tmfv-pct').textContent);
check('a: overlay con 2 forme e viewBox 0 0 800 500', a.querySelector('.tmfv-ov').children.length >= 2 && a.querySelector('.tmfv-ov').getAttribute('viewBox') === '0 0 800 500', a.querySelector('.tmfv-ov').innerHTML.slice(0, 80));
var lab = a.querySelector('.tmfv-label');
check('a: etichetta al 50 % / 50 %, testo, classe', lab && lab.style.left === '50%' && lab.style.top === '50%' && lab.textContent === 'Quota 1 12.345 mm' && lab.classList.contains('ok'));
check('a: barra visibile con -, +, Adatta, Salva', !a.querySelector('.tmfv-bar').hidden && a.querySelectorAll('.tmfv-bar button').length === 4 && btn(a, 3).textContent === 'Salva');
var z0 = a.zoom; btn(a, 1).click();
check('a: + ingrandisce di 1.25', Math.abs(a.zoom / z0 - 1.25) < 1e-9, a.zoom + '/' + z0);
for (var i = 0; i < 6; i++) a.zoomBy(1.25);
check('a: ingrandito -> scorrimento possibile e cursore grab', vpa.scrollWidth > vpa.clientWidth && vpa.style.cursor === 'grab', vpa.style.cursor);
var rect = vpa.getBoundingClientRect(), px = 120, py = 90;
var zb = a.zoom, lx = (vpa.scrollLeft + px) / zb, ly = (vpa.scrollTop + py) / zb;
vpa.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true, deltaY: -100, clientX: rect.left + vpa.clientLeft + px, clientY: rect.top + vpa.clientTop + py, bubbles: true, cancelable: true }));
var za = a.zoom, lx2 = (vpa.scrollLeft + px) / za, ly2 = (vpa.scrollTop + py) / za;
check('a: Ctrl+rotella zooma e tiene fermo il punto', za > zb && Math.abs(lx - lx2) < 1 && Math.abs(ly - ly2) < 1, [zb, za, lx, lx2, ly, ly2].join(' '));
for (i = 0; i < 40; i++) a.zoomBy(1.25);
check('a: zoom limitato a max(8, fit)', a.zoom <= 8.0000001, a.zoom);
var we = new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true }); vpa.dispatchEvent(we);
check('a: rotella senza Ctrl zooma sul punto del mouse (intercettata, #12 #126)', we.defaultPrevented === true); // #126: controllo aggiornato al contratto #12
btn(a, 2).click();
check('a: Adatta torna al fit e scroll a 0', Math.abs(a.zoom - fitA()) < 0.01 && vpa.scrollLeft === 0, a.zoom);
check('a: viewer-zoom emesso a ogni cambio', ev.zoom > 3, ev.zoom);
a.zoomBy(1.25); var zz = a.zoom; ev.load = 0;
a.setAttribute('src', img(800, 500, '#7f1d1d'));
setTimeout(function () {
check('a: nuovo src, stesse dimensioni -> zoom mantenuto', ev.load === 1 && Math.abs(a.zoom - zz) < 1e-9, a.zoom + ' vs ' + zz);
var vpb = b.querySelector('.tmfv-vp');
check('b: fit-height: viewport alto quanto l\'immagine adattata alla larghezza', Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5 && Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01,
vpb.offsetHeight + ' vs ' + 400 * b.zoom + ' zoom ' + b.zoom);
check('b: no-save nasconde Salva, label-fit "Intera"', btn(b, 3).hidden && btn(b, 2).textContent === 'Intera');
b.style.width = '300px';
// il resize e' consegnato dal ResizeObserver al prossimo frame: in una scheda in background puo' tardare, quindi attendo (max 5 s)
var t0 = Date.now();
(function attendi() { if (b.zoom < 0.5 || Date.now() - t0 > 5000) dopo(); else setTimeout(attendi, 100); })();
function dopo() {
check('b: resize in stato adatta -> rifa Adatta (300 px)', Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01 && Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5, b.zoom + ' ' + vpb.offsetHeight);
var stc = c.querySelector('.tmfv-stage');
check('c: svg nello stage, dimensioni dal viewBox 200x100, Adatta', stc.querySelector('svg:not(.tmfv-ov)') && !c.querySelector(':scope > svg') && c.zoom > 0 && Math.abs(stc.offsetWidth / stc.offsetHeight - 2) < 0.05, c.zoom + ' ' + stc.offsetWidth + 'x' + stc.offsetHeight);
// #122: DXF in mm (fit ~ 10): si ingrandisce oltre Adatta; width/height cambiati dopo il connect rifanno Adatta
c.setAttribute('width', '50'); c.setAttribute('height', '30'); // contenuto 50 x 30 mm nel box ~ 560 x 320
var zc = c.zoom; c.zoomBy(1.25);
check('c: width/height 50x30 -> fit > 8 e zoom oltre Adatta', zc > 8 && c.zoom > zc * 1.2, zc + ' -> ' + c.zoom);
// #122: viewer-error su immagine non caricabile; src tolto -> componente vuoto senza errori
var e = document.createElement('tmf-viewer'); e.style.height = '100px'; document.body.appendChild(e);
var gotErr = null; e.addEventListener('viewer-error', function (x) { gotErr = x.detail; });
e.overlay = [{ type: 'segment', role: 'found', row1: 1, col1: 1, row2: 5, col2: 9 }]; e.labels = [{ x: 5, y: 5, text: 'x' }]; // prima del load
e.setAttribute('src', 'inesistente.png');
setTimeout(function () {
check('e: viewer-error con src', gotErr && gotErr.src === 'inesistente.png', JSON.stringify(gotErr));
e.setAttribute('src', img(100, 50, '#000'));
setTimeout(function () {
check('e: overlay ed etichette impostati prima del load disegnati dopo', e.querySelectorAll('.tmfv-ov g[data-layer="costruzioni"] > *').length === 1 && e.querySelectorAll('.tmfv-label').length === 1);
var lb = getComputedStyle(e.querySelector('.tmfv-label'));
check('e: etichetta con aspetto proprio (absolute, bold, padding)', lb.position === 'absolute' && lb.fontWeight === '700' && lb.paddingLeft === '6px' && lb.pointerEvents === 'none');
e.removeAttribute('src');
check('e: src tolto -> vuoto, barra nascosta', e.querySelector('.tmfv-bar').hidden === true && e.zoom === 0);
}, 300);
}, 300);
check('d: vuoto -> barra nascosta', d.querySelector('.tmfv-bar').hidden === true);
check('stile iniettato una sola volta', document.querySelectorAll('#tmf-viewer-css').length === 1);
tmfSvgToPngBlob(c.querySelector('.tmfv-stage > svg:not(.tmfv-ov)'), 200, 100).then(function (bl) {
check('c: tmfSvgToPngBlob produce un PNG', bl && bl.type === 'image/png' && bl.size > 100, bl && (bl.type + ' ' + bl.size));
return createImageBitmap(bl);
}).then(function (bm) {
check('c: PNG 1600x800', bm.width === 1600 && bm.height === 800, bm.width + 'x' + bm.height);
}).catch(function (er) { check('c: rasterizzazione', false, String(er)); }).then(function () { return new Promise(function (r) { setTimeout(r, 900); }); }).then(function () {
document.getElementById('log').textContent = out.join('\n') + '\n\n' + (out.length - fails) + '/' + out.length + ' controlli ok';
document.title = fails ? 'FALLITO' : 'TUTTO OK';
});
}
}, 400);
}, 600);
})();
</script>
<script>
// #126: controlli del viewer no-layers (f) contro lo stesso overlay senza no-layers (g)
(function () {
var check = window.tmfCheck;
function img(w, h, col) {
var c = document.createElement('canvas'); c.width = w; c.height = h;
var g = c.getContext('2d'); g.fillStyle = col; g.fillRect(0, 0, w, h);
return c.toDataURL('image/png');
}
var f = document.getElementById('f'), g = document.getElementById('g');
function dati() { // overlay misto: found (costruzioni), non found (ricerca), layer 'ricerca' esplicito; due etichette con key
return {
ov: [{ type: 'segment', role: 'found', row1: 20, col1: 20, row2: 220, col2: 380 },
{ type: 'segment', role: 'reference', row1: 20, col1: 380, row2: 220, col2: 20 },
{ type: 'points', role: 'reference', layer: 'ricerca', rows: [60, 120], cols: [100, 300] }],
lb: [{ key: 'q1', nome: 'Larghezza', camera: 1, x: 200, y: 120, text: 'Larghezza 12.345', breve: '12.345', cls: 'stz-ok',
quota: { tipo: 'lineare', p1: [60, 200], p2: [340, 200], testo: [0, 20] } },
{ key: 'q2', nome: 'Punto', camera: 1, x: 100, y: 60, text: 'Punto', breve: 'P' }]
};
}
var df = dati(), dg = dati(), movf = 0, movg = 0;
f.addEventListener('quota-spostata', function () { movf++; });
g.addEventListener('quota-spostata', function () { movg++; });
f.overlay = df.ov; f.labels = df.lb; g.overlay = dg.ov; g.labels = dg.lb;
f.setAttribute('src', img(400, 250, '#1e293b')); g.setAttribute('src', img(400, 250, '#1e293b'));
function drag(el) { // trascinamento simulato di un'etichetta: down sull'etichetta, move e up sul documento
var r = el.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
var o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 };
el.dispatchEvent(new PointerEvent('pointerdown', Object.assign({ clientX: x, clientY: y }, o)));
document.dispatchEvent(new PointerEvent('pointermove', Object.assign({ clientX: x + 30, clientY: y + 20 }, o)));
document.dispatchEvent(new PointerEvent('pointerup', Object.assign({ clientX: x + 30, clientY: y + 20 }, o)));
}
setTimeout(function () {
var ovf = f.querySelector('.tmfv-ov'), ovg = g.querySelector('.tmfv-ov');
check('f no-layers: localStorage ricerca=false, ma nessun g[data-layer], nessun opacity, nessun display none', ovf.querySelectorAll('g[data-layer]').length === 0 && ovf.querySelectorAll('[opacity]').length === 0 && ovf.innerHTML.indexOf('display="none"') < 0, ovf.innerHTML.slice(0, 120));
check('f no-layers: forme found, non found e layer ricerca tutte nel disegno (>= 5 elementi)', ovf.children.length >= 5, ovf.children.length);
check('f no-layers: menu dei livelli nascosto', f.querySelector('.tmfv-layers').hidden === true);
check('f no-layers: immagine visibile, etichette visibili con text', f.querySelector('.tmfv-img').style.visibility === '' && f.querySelector('.tmfv-labels').style.display !== 'none' &&
f.querySelectorAll('.tmfv-label')[0].textContent === 'Larghezza 12.345' && f.querySelectorAll('.tmfv-label')[1].textContent === 'Punto');
check('g senza no-layers (confronto): tre gruppi g[data-layer] e menu visibile', ovg.querySelectorAll('g[data-layer]').length === 3 && g.querySelector('.tmfv-layers').hidden === false);
var lf = f.querySelector('.tmfv-label[data-key="q1"]');
var pf = lf.style.left + '|' + lf.style.top;
check('f no-layers: etichetta con pointer-events none e cursore default', getComputedStyle(lf).pointerEvents === 'none' && getComputedStyle(lf).cursor === 'default', getComputedStyle(lf).pointerEvents);
drag(lf); drag(g.querySelector('.tmfv-label[data-key="q1"]'));
var lf2 = f.querySelector('.tmfv-label[data-key="q1"]');
check('f no-layers: trascinamento simulato -> nessun quota-spostata, posizione invariata', movf === 0 && lf2.style.left + '|' + lf2.style.top === pf, movf + ' ' + lf2.style.left);
check('g senza no-layers (controllo del test): lo stesso trascinamento emette quota-spostata', movg === 1, movg);
var z0 = f.zoom, r = f.querySelector('.tmfv-vp').getBoundingClientRect();
var we = new WheelEvent('wheel', { deltaY: -100, clientX: r.left + 100, clientY: r.top + 100, bubbles: true, cancelable: true });
f.querySelector('.tmfv-vp').dispatchEvent(we);
check('f no-layers: rotella senza Ctrl -> zoom cambiato', f.zoom > z0 && we.defaultPrevented === true, z0 + ' -> ' + f.zoom);
f.removeAttribute('no-layers');
check('f: no-layers tolto a runtime -> gruppi g[data-layer], menu e trascinamento', ovf.querySelectorAll('g[data-layer]').length === 3 && f.querySelector('.tmfv-layers').hidden === false &&
getComputedStyle(f.querySelector('.tmfv-label[data-key="q1"]')).pointerEvents === 'auto');
f.setAttribute('no-layers', '');
check('f: no-layers rimesso a runtime -> di nuovo disegno unico, menu nascosto', ovf.querySelectorAll('g[data-layer]').length === 0 && f.querySelector('.tmfv-layers').hidden === true && ovf.children.length >= 5);
}, 1000);
})();
</script>
</body>
</html>
@@ -0,0 +1,448 @@
{
"outcome": "pass",
"overlay": [
{
"type": "points",
"role": "found",
"rows": [
2218.926540676052
],
"cols": [
2371.08496101821
],
"tool_id": "blob"
},
{
"type": "segment",
"role": "found",
"row1": 2218.926540676052,
"col1": 2371.08496101821,
"row2": 2268.925168990387,
"col2": 2371.4553212279457,
"tool_id": "blob"
},
{
"type": "contour",
"role": "found",
"rows": [
2469.0093822012514,
2467.8907055854274,
1969.1995500932737,
1970.3182267090972,
2469.0093822012514
],
"cols": [
2297.7334028311207,
2448.7547459672996,
2445.0607374080982,
2294.0393942719193,
2297.7334028311207
],
"tool_id": "blob"
},
{
"type": "rect2",
"role": "roi",
"row": 2250.0000476834334,
"col": 2296.999963957319,
"phi_deg": -89.99999458063853,
"length1": 159.9999999999998,
"length2": 20.0,
"tool_id": "lato_sx"
},
{
"type": "segment",
"role": "found",
"row1": 2409.997184964675,
"col1": 2297.344751969946,
"row2": 2089.9983738509072,
"col2": 2295.147617847124,
"tool_id": "lato_sx"
},
{
"type": "rect2",
"role": "roi",
"row": 2250.000033590164,
"col": 2445.999963957318,
"phi_deg": -89.99999458063853,
"length1": 159.9999999999998,
"length2": 20.0,
"tool_id": "lato_dx"
},
{
"type": "segment",
"role": "found",
"row1": 2410.000338325413,
"col1": 2446.9546542059475,
"row2": 2090.0001187672883,
"col2": 2444.590522414638,
"tool_id": "lato_dx"
},
{
"type": "rect2",
"role": "roi",
"row": 1970.0000406368001,
"col": 2371.4999374733225,
"phi_deg": 5.419361327409989e-06,
"length1": 36.499999999999936,
"length2": 15.0,
"tool_id": "lato_alto"
},
{
"type": "segment",
"role": "found",
"row1": 1969.7270815449738,
"col1": 2335.0012415021283,
"row2": 1969.9815342357629,
"col2": 2408.0075386065682,
"tool_id": "lato_alto"
},
{
"type": "rect2",
"role": "roi",
"row": 2466.000040636798,
"col": 2371.4999843878295,
"phi_deg": 5.419361327409989e-06,
"length1": 36.499999999999936,
"length2": 15.0,
"tool_id": "lato_basso"
},
{
"type": "segment",
"role": "found",
"row1": 2466.9468485672455,
"col1": 2335.0088484836465,
"row2": 2465.293164536675,
"col2": 2407.9979613716796,
"tool_id": "lato_basso"
},
{
"type": "arc",
"role": "roi",
"row": 2015.6000435878736,
"col": 2340.2999417864303,
"radius": 46.0,
"start_deg": 95.00000541936147,
"extent_deg": 80.0,
"tool_id": "arco_tl"
},
{
"type": "arc",
"role": "found",
"row": 2014.0729365101115,
"col": 2338.325339664551,
"radius": 43.69800114451398,
"start_deg": 92.61763130401864,
"extent_deg": 84.15103825789168,
"tool_id": "arco_tl"
},
{
"type": "arc",
"role": "roi",
"row": 2016.1000381586543,
"col": 2397.699941833723,
"radius": 46.0,
"start_deg": 5.000005419361472,
"extent_deg": 80.0,
"tool_id": "arco_tr"
},
{
"type": "arc",
"role": "found",
"row": 2014.7155277666257,
"col": 2400.767078642102,
"radius": 43.26606749223183,
"start_deg": 3.5348692687035217,
"extent_deg": 85.33536916618716,
"tool_id": "arco_tr"
},
{
"type": "arc",
"role": "roi",
"row": 2418.4000378086853,
"col": 2401.3999798855507,
"radius": 46.0,
"start_deg": 275.00000541936146,
"extent_deg": 80.0,
"tool_id": "arco_br"
},
{
"type": "arc",
"role": "found",
"row": 2420.300713032017,
"col": 2404.4253040181898,
"radius": 42.604285715934104,
"start_deg": -76.0934883340429,
"extent_deg": 73.28101449433917,
"tool_id": "arco_br"
},
{
"type": "arc",
"role": "roi",
"row": 2421.8000432662807,
"col": 2343.699980207142,
"radius": 46.0,
"start_deg": 185.00000541936146,
"extent_deg": 80.0,
"tool_id": "arco_bl"
},
{
"type": "arc",
"role": "found",
"row": 2423.3935764712314,
"col": 2338.8006257209418,
"radius": 41.362918221221754,
"start_deg": -176.60049573516702,
"extent_deg": 77.4552373110766,
"tool_id": "arco_bl"
},
{
"type": "segment",
"role": "found",
"row1": 2250.0002285463506,
"col1": 2445.772588310293,
"row2": 2251.0268387327387,
"col2": 2296.253250501527,
"tool_id": "larghezza"
},
{
"type": "segment",
"role": "found",
"row1": 2466.12000655196,
"col1": 2371.503404927663,
"row2": 1969.8603328620893,
"col2": 2373.2330448944704,
"tool_id": "lunghezza"
},
{
"type": "circle",
"role": "found",
"row": 2014.0729365101115,
"col": 2338.325339664551,
"radius": 43.69800114451398,
"tool_id": "R_tl"
},
{
"type": "circle",
"role": "found",
"row": 2014.7155277666257,
"col": 2400.767078642102,
"radius": 43.26606749223183,
"tool_id": "R_tr"
},
{
"type": "circle",
"role": "found",
"row": 2420.300713032017,
"col": 2404.4253040181898,
"radius": 42.604285715934104,
"tool_id": "R_br"
},
{
"type": "circle",
"role": "found",
"row": 2423.3935764712314,
"col": 2338.8006257209418,
"radius": 41.362918221221754,
"tool_id": "R_bl"
}
],
"failures": [],
"measurements": [
{
"subtask_id": 15,
"value": 1.6682265730716714,
"pass_fail": "pass"
},
{
"subtask_id": 16,
"value": 5.536802808714362,
"pass_fail": "pass"
},
{
"subtask_id": 17,
"value": 0.48753859876934247,
"pass_fail": "pass"
},
{
"subtask_id": 18,
"value": 0.4827195150108305,
"pass_fail": "pass"
},
{
"subtask_id": 19,
"value": 0.4753360157326768,
"pass_fail": "pass"
},
{
"subtask_id": 20,
"value": 0.4614860785941711,
"pass_fail": "pass"
},
{
"subtask_id": 21,
"value": 0.004819083758511997,
"pass_fail": "pass"
},
{
"subtask_id": 22,
"value": 0.012202583036665687,
"pass_fail": "pass"
},
{
"subtask_id": 23,
"value": 0.026052520175171373,
"pass_fail": "pass"
},
{
"subtask_id": 24,
"value": 0.00738349927815369,
"pass_fail": "pass"
},
{
"subtask_id": 25,
"value": 0.021233436416659375,
"pass_fail": "pass"
},
{
"subtask_id": 26,
"value": 0.013849937138505686,
"pass_fail": "pass"
}
],
"calibration": "ok",
"_subtasks": [
{
"id": 15,
"marker_number": 1,
"description": "Larghezza",
"measurement_type": null,
"unit": "mm",
"vision_output": "larghezza.value",
"nominal": 1.668,
"ltl": null,
"utl": null
},
{
"id": 16,
"marker_number": 2,
"description": "Lunghezza",
"measurement_type": null,
"unit": "mm",
"vision_output": "lunghezza.value",
"nominal": 5.537,
"ltl": null,
"utl": null
},
{
"id": 17,
"marker_number": 3,
"description": "R alto-sinistra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_tl.value",
"nominal": 0.488,
"ltl": null,
"utl": null
},
{
"id": 18,
"marker_number": 4,
"description": "R alto-destra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_tr.value",
"nominal": 0.483,
"ltl": null,
"utl": null
},
{
"id": 19,
"marker_number": 5,
"description": "R basso-destra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_br.value",
"nominal": 0.475,
"ltl": null,
"utl": null
},
{
"id": 20,
"marker_number": 6,
"description": "R basso-sinistra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_bl.value",
"nominal": 0.464,
"ltl": null,
"utl": null
},
{
"id": 21,
"marker_number": 7,
"description": "Diff. R alto-sx / alto-dx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tl_tr.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 22,
"marker_number": 8,
"description": "Diff. R alto-sx / basso-dx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tl_br.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 23,
"marker_number": 9,
"description": "Diff. R alto-sx / basso-sx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tl_bl.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 24,
"marker_number": 10,
"description": "Diff. R alto-dx / basso-dx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tr_br.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 25,
"marker_number": 11,
"description": "Diff. R alto-dx / basso-sx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tr_bl.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 26,
"marker_number": 12,
"description": "Diff. R basso-dx / basso-sx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_br_bl.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
}
]
}
@@ -153,6 +153,20 @@ const MEASUREMENT = {
); );
} }
// #118: quota non misurata (value null) -> NaN nel valore e nella deviazione; tolleranze nulle restano vuote
{
const e = csvExport();
const nan = { ...MEASUREMENT, value: null, deviation: null, pass_fail: 'fail',
subtask: { ...MEASUREMENT.subtask, utl: null, ltl: null } };
const row = e.buildMeasurementCSV([nan]).split('\n')[1].split(';');
check('#118 value null -> NaN', row[3] === 'NaN', row.join('|'));
check('#118 deviazione null -> NaN', row[8] === 'NaN', row.join('|'));
check('#118 tolleranze nulle restano vuote', row[6] === '' && row[7] === '', row.join('|'));
check('#118 esito fail', row[9] === 'fail', row.join('|'));
const ok = e.buildMeasurementCSV([MEASUREMENT]).split('\n')[1].split(';');
check('#118 valore presente invariato', ok[3] !== 'NaN' && ok[8] !== 'NaN', ok.join('|'));
}
if (failures > 0) { if (failures > 0) {
console.error(failures + ' test falliti su csv-export.js'); console.error(failures + ' test falliti su csv-export.js');
process.exit(1); process.exit(1);
@@ -92,7 +92,7 @@ const PREVIEW = {
global.URL.revokeObjectURL = () => {}; global.URL.revokeObjectURL = () => {};
wi._setImage({ name: 'a.bmp' }, 'file'); // #104: la scala a mano vale solo per file 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'); check('#102 _setImage imposta URL e nome', wi.imagePreviewUrl === 'blob:x' && wi.imageName === 'a.bmp');
wi.onImagePreviewLoad({ naturalWidth: 2000, naturalHeight: 1000 }); wi.onImagePreviewLoad({ width: 2000, height: 1000 }); // #122: detail di viewer-load
check('#102 senza scala: solo pixel', /2[. ]?000 × 1[. ]?000 px$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary); check('#102 senza scala: solo pixel', /2[. ]?000 × 1[. ]?000 px$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
wi.mmPx = '0,01'; 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); check('#102 con mm/px a mano: campo in mm', /px · campo ≈ 20,00 × 10,00 mm$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
@@ -142,30 +142,9 @@ const PREVIEW = {
w4.tested = true; w4.stationId = ''; await w4.onStation(); w4.tested = true; w4.stationId = ''; await w4.onStation();
check('#104 tested falso dopo onStation', w4.tested === false); check('#104 tested falso dopo onStation', w4.tested === false);
// #104: zoomBy rispetta i limiti 0.05..8 // #122: zoom, Adatta e pan della Prova stanno in <tmf-viewer> (tests/js/test_image_viewer.js), non piu' nel wizard
const w5 = dxfRecipeWizard(1, () => 'tok', {}); const w5 = dxfRecipeWizard(1, () => 'tok', {});
w5.$nextTick = f => f(); check('#122 zoom e Adatta tolti dal wizard', ['zoomBy', 'fit', 'onPanDown', 'onPanMove', 'onPanEnd'].every(k => typeof w5[k] !== 'function'));
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 // #105: ricetta esistente -> wizard compilato
global.File = class { constructor(parts, name, opts) { this.parts = parts; this.name = name; this.type = (opts && opts.type) || ''; } }; global.File = class { constructor(parts, name, opts) { this.parts = parts; this.name = name; this.type = (opts && opts.type) || ''; } };
@@ -267,35 +246,6 @@ const PREVIEW = {
await w11.run(true); await w11.run(true);
check('#105 dopo Salva: existing letto e non ricompilato', w11.existing && w11.existing.band_mm === 0.3 && w11._existingApplied === true); check('#105 dopo Salva: existing letto e non ricompilato', w11.existing && w11.existing.band_mm === 0.3 && w11._existingApplied === true);
// #104: pan col puntatore
const wpan = dxfRecipeWizard(1, () => 'tok', {});
const vppan = { scrollLeft: 100, scrollTop: 100, clientWidth: 500, clientHeight: 400, setPointerCapture() {} };
wpan.$refs = { viewport: vppan };
wpan.onPanDown({ clientX: 200, clientY: 200, pointerId: 1, pointerType: 'mouse', button: 0 });
wpan.onPanMove({ clientX: 170, clientY: 180 });
check('#104 pan: pointermove (-30,-20) -> scroll +30/+20', vppan.scrollLeft === 130 && vppan.scrollTop === 120, vppan.scrollLeft + ',' + vppan.scrollTop);
check('#104 pan: grabbing durante il trascinamento', wpan.panning === true);
wpan.onPanEnd();
wpan.onPanMove({ clientX: 0, clientY: 0 });
check('#104 pan: dopo pointerup non scrolla', vppan.scrollLeft === 130 && wpan.panning === false);
wpan.onPanDown({ clientX: 0, clientY: 0, pointerId: 1, pointerType: 'mouse', button: 2 });
check('#104 pan: il tasto destro non parte', wpan.panning === false);
// #104: altezza del viewport
global.window = { innerHeight: 1000 }; // max = min(700, 520) = 520
wpan.resPx = { w: 2000, h: 1000 };
wpan.$refs = { viewport: { clientWidth: 500, scrollLeft: 0, scrollTop: 0 }, img: { naturalWidth: 2000, naturalHeight: 1000 } };
wpan.fit(); // zoom = min(500/2000, 520/1000) = 0.25
check('#104 fit usa l altezza massima', Math.abs(wpan.zoom - 0.25) < 1e-9 && wpan.fitZoom === wpan.zoom, String(wpan.zoom));
check('#104 altezza a Fit = immagine scalata', wpan.vpHeight === 250 && wpan.canPan === false, String(wpan.vpHeight)); // zoomH = 250
wpan.resPx = { w: 2000, h: 400 }; // zoomH = 100 -> minimo 200
check('#104 altezza minima 200', wpan.vpHeight === 200, String(wpan.vpHeight));
wpan.zoom = 1;
check('#104 con zoom > Adatta altezza massima e pan attivo', wpan.vpHeight === 520 && wpan.canPan === true && wpan.panCursor === 'grab');
global.window = { innerHeight: 500 }; // 70vh = 350
check('#104 altezza massima = 70vh se minore di 520', wpan.vpHeight === 350, String(wpan.vpHeight));
delete global.window;
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); } if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
console.log('tutto ok'); console.log('tutto ok');
})(); })();
+31 -101
View File
@@ -1,5 +1,8 @@
/** /**
* #113: zoom, Adatta e pan sulle anteprime del wizard (immagine e DXF), con plain node. * #122: lo zoom delle tre viste del wizard (immagine, DXF, Prova) e' nel componente <tmf-viewer>.
* Qui si prova cio' che resta nel wizard (dimensioni dal detail di viewer-load, anteprima DXF)
* e che le funzioni di zoom/pan/fit/overlay non ci siano piu'.
* (Il nome del file resta quello di #113 per non rompere i riferimenti.)
* *
* Run from src/frontend/flask_app: node tests/js/test_dxf_zoom.js * Run from src/frontend/flask_app: node tests/js/test_dxf_zoom.js
*/ */
@@ -18,109 +21,35 @@ function check(label, cond, extra) {
if (cond) console.log('ok - ' + label); if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); } else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
} }
const near = (a, b) => Math.abs(a - b) < 1e-9 * Math.max(1, Math.abs(b));
// Wizard con viewport finti e $nextTick sincrono const w = dxfRecipeWizard(1, () => 'tok', {});
function make(cw) { w.$nextTick = fn => fn();
const w = dxfRecipeWizard(1, () => 'tok', {});
const vp = () => ({ clientWidth: cw, clientHeight: 238, scrollLeft: 5, scrollTop: 5 });
w.$refs = { vpImg: vp(), vpDxf: vp() };
w.$nextTick = fn => fn();
return w;
}
// img: viewport 400 x 238 utili, immagine 2856 x 2848 // #122: via tutto lo zoom/pan/fit/overlay del wizard
let w = make(400); const gone = ['views', 'viewPanKey', 'zoom', 'fitZoom', 'panning', 'resPx', 'vZoomBy', 'vFit', 'vWheel', 'vPanDown',
w.vSetContent('img', 2856, 2848); 'vPanMove', 'vPanEnd', 'vCursor', 'vPct', 'vW', 'vH', 'vReset', 'vSetContent', 'zoomBy', 'fit', 'onWheel',
check('#113 img: Adatta = clientHeight/2848', near(w.views.img.zoom, 238 / 2848) && near(w.views.img.fit, 238 / 2848), String(w.views.img.zoom)); 'onPanDown', 'onPanMove', 'onPanEnd', 'onResultImgLoad', 'drawOverlay', 'zoomPct', 'zoomW', 'zoomH', 'vpHeight'];
check('#113 img: scroll azzerato dopo Adatta', w.$refs.vpImg.scrollLeft === 0 && w.$refs.vpImg.scrollTop === 0); for (const k of gone) check('#122 tolto dal wizard: ' + k, !(k in w));
check('#113 img: percentuale = zoom*100', w.vPct('img') === Math.round(238 / 2848 * 100) + ' %', w.vPct('img')); check('#122 sorgente senza $refs di zoom', !/\$refs\./.test(src));
const z0 = w.views.img.zoom;
w.vZoomBy('img', 1.25);
check('#113 img: zoomBy moltiplica', near(w.views.img.zoom, z0 * 1.25));
check('#113 img: dopo zoom si puo trascinare', w.vCanPan('img') === true && w.vCursor('img') === 'grab');
w.vZoomBy('img', 1e6);
check('#113 img: limite alto 8', w.views.img.zoom === 8);
w.vZoomBy('img', 1e-9);
check('#113 img: limite basso 0.05', w.views.img.zoom === 0.05);
w.vFit('img');
check('#113 img: Adatta torna al fit', near(w.views.img.zoom, z0) && w.vCanPan('img') === false);
// clientHeight 0 (non misurabile): ripiego 240
w = make(400); w.$refs.vpImg.clientHeight = 0; w.vSetContent('img', 2856, 2848);
check('#113 img: clientHeight 0 usa 240', near(w.views.img.fit, 240 / 2848));
w = make(400); w.vSetContent('img', 2856, 2848);
check('#113 img: l\'altra vista non si muove', w.views.dxf.w === 0 && w.views.dxf.zoom === 1);
check('#113 img: wrapper w*zoom', near(w.vW('img'), 2856 * z0) && near(w.vH('img'), 2848 * z0));
// dxf: viewBox 20 x 10 mm, viewport 400 x 238 -> fit = min(20, 23.8) = 20 // onImagePreviewLoad riceve il detail di viewer-load e alimenta il riepilogo
w = make(400);
w.vSetContent('dxf', 20, 10);
check('#113 dxf: fit = min(vpW/w, clientHeight/h)', near(w.views.dxf.fit, 20), String(w.views.dxf.fit));
check('#113 dxf: dopo Adatta 100 %', w.vPct('dxf') === '100 %', w.vPct('dxf'));
w.vZoomBy('dxf', 2);
check('#113 dxf: zoom 2x = 200 %', w.vPct('dxf') === '200 %', w.vPct('dxf'));
w.vZoomBy('dxf', 1e9);
check('#113 dxf: si ferma a fit*50', near(w.views.dxf.zoom, 20 * 50) && w.vPct('dxf') === '5000 %', String(w.views.dxf.zoom));
w.vZoomBy('dxf', 1e-9);
check('#113 dxf: si ferma a fit*0.5', near(w.views.dxf.zoom, 10) && w.vPct('dxf') === '50 %', String(w.views.dxf.zoom));
// vFit con viewport largo 0 (nascosto): stato invariato
w = make(0);
w.vSetContent('img', 1000, 500);
check('#113 clientWidth 0: nessun fit, stato invariato', w.views.img.zoom === 1 && w.views.img.fit === 1 && w.views.img.w === 1000);
w.$refs.vpImg.clientWidth = 500;
w.vFit('img');
check('#113 viewport visibile: il fit parte', near(w.views.img.fit, 238 / 500), String(w.views.img.fit));
// Reset: nuova immagine / DXF tolto
w.vReset('img');
check('#113 reset: contenuto e zoom azzerati', w.views.img.w === 0 && w.views.img.zoom === 1 && w.vCanPan('img') === false);
w.vZoomBy('img', 2);
check('#113 zoomBy senza contenuto: ignorato', w.views.img.zoom === 1);
// Integrazione: onImagePreviewLoad e _setImage
global.URL.createObjectURL = () => 'blob:x'; global.URL.createObjectURL = () => 'blob:x';
global.URL.revokeObjectURL = () => {}; global.URL.revokeObjectURL = () => {};
w = make(400);
w._setImage({ name: 'a.bmp' }, 'file'); w._setImage({ name: 'a.bmp' }, 'file');
w.onImagePreviewLoad({ naturalWidth: 800, naturalHeight: 240 }); w.onImagePreviewLoad({ width: 800, height: 240 });
check('#113 onImagePreviewLoad: Adatta all\'arrivo', near(w.views.img.zoom, 0.5) && w.views.img.w === 800, String(w.views.img.zoom)); check('#122 viewer-load: dimensioni nel riepilogo', w.imagePx.w === 800 && w.imagePx.h === 240 && w.imageBroken === false);
w._setImage({ name: 'b.bmp' }, 'file'); check('#122 riepilogo «W × H px»', /800.*240 px/.test(w.imagePreviewSummary), w.imagePreviewSummary);
check('#113 _setImage: vista azzerata', w.views.img.w === 0 && w.views.img.zoom === 1); w.onImagePreviewLoad({ width: 0, height: 0 });
w.onImagePreviewLoad({ naturalWidth: 800, naturalHeight: 240 }); check('#122 viewer-load senza dimensioni: imagePx null', w.imagePx === null);
w.onImagePreviewLoad({ width: 800, height: 240 });
w.onImagePreviewError(); w.onImagePreviewError();
check('#113 errore <img>: vista azzerata', w.views.img.w === 0); check('#122 errore immagine: avviso e dimensioni azzerate', w.imageBroken === true && w.imagePx === null);
w._setImage({ name: 'b.bmp' }, 'file');
check('#122 nuova immagine: avviso tolto', w.imageBroken === false && w.imagePx === null);
w._setImage(null);
check('#122 immagine tolta: src vuoto', w.imageSrc === '' && w.imagePreviewUrl === '');
// Wheel: solo con Ctrl // Anteprima DXF: il viewBox resta quello per il <tmf-viewer> (width/height del template)
w = make(400);
w.vSetContent('dxf', 20, 10);
let prevented = false;
w.vWheel('dxf', { ctrlKey: false, deltaY: -1, preventDefault() { prevented = true; } });
check('#113 rotella senza Ctrl: niente zoom', w.views.dxf.zoom === 20 && !prevented);
w.vWheel('dxf', { ctrlKey: true, deltaY: -1, preventDefault() { prevented = true; } });
check('#113 Ctrl+rotella: zoom avanti', near(w.views.dxf.zoom, 25) && prevented);
// Pan
w = make(400);
w.vSetContent('dxf', 20, 10);
w.vZoomBy('dxf', 2);
const vp = w.$refs.vpDxf;
vp.scrollLeft = 100; vp.scrollTop = 50; vp.setPointerCapture = () => {};
w.vPanDown('dxf', { pointerType: 'mouse', button: 0, clientX: 200, clientY: 100, pointerId: 1 });
w.vPanMove('dxf', { clientX: 150, clientY: 90 });
check('#113 pan: scroll di quanto si muove il puntatore', vp.scrollLeft === 150 && vp.scrollTop === 60, vp.scrollLeft + ',' + vp.scrollTop);
check('#113 pan: cursore grabbing', w.vCursor('dxf') === 'grabbing');
w.vPanEnd();
check('#113 pan finito', w.viewPanKey === '' && w._viewPan === null);
// #113: Adatta da ingrandito, con barre di scorrimento presenti: area = offset* meno i bordi, non client*
global.getComputedStyle = () => ({ borderLeftWidth: '1px', borderRightWidth: '1px', borderTopWidth: '1px', borderBottomWidth: '1px' });
w = make(432); w.$refs.vpImg.clientHeight = 228; w.$refs.vpImg.offsetWidth = 444; w.$refs.vpImg.offsetHeight = 240;
w.vSetContent('img', 2464, 2056);
check('#113 Adatta con barre: area senza barre (238/2056)', near(w.views.img.fit, 238 / 2056), String(w.views.img.fit));
delete global.getComputedStyle;
// Integrazione DXF: loadDxfPreview imposta la misura del viewBox e fa Adatta; file tolto azzera
(async () => { (async () => {
const PREVIEW = { const PREVIEW = {
contorni: [[[0, 0], [20, 0], [20, 10]]], contorni: [[[0, 0], [20, 0], [20, 10]]],
@@ -129,16 +58,17 @@ delete global.getComputedStyle;
}; };
global.FormData = class { append() {} }; global.FormData = class { append() {} };
global.fetch = async () => ({ ok: true, json: async () => PREVIEW }); global.fetch = async () => ({ ok: true, json: async () => PREVIEW });
const wd = make(400); const wd = dxfRecipeWizard(1, () => 'tok', {});
wd.$nextTick = fn => fn();
wd.dxfFile = { name: 'a.dxf' }; wd.dxfFile = { name: 'a.dxf' };
await wd.loadDxfPreview(); await wd.loadDxfPreview();
const vb = wd.dxfPreviewViewBox().split(' ').map(Number); const vb = wd.dxfPreviewViewBox().split(' ').map(Number);
check('#113 loadDxfPreview: misura = viewBox', near(wd.views.dxf.w, vb[2]) && near(wd.views.dxf.h, vb[3]), JSON.stringify(wd.views.dxf)); check('#122 DXF: viewBox con margine 5 % (w 22 x h 12)', vb.length === 4 && Math.abs(vb[2] - 22) < 1e-6 && Math.abs(vb[3] - 12) < 1e-6, vb.join(' '));
check('#113 loadDxfPreview: Adatta = 100 %', wd.vPct('dxf') === '100 %' && wd.views.dxf.fit > 1); check('#122 DXF: anteprima caricata', wd.dxfPreview && wd.dxfPreviewD().startsWith('M 0 0'));
wd.dxfFile = null; wd.dxfFile = null;
await wd.loadDxfPreview(); await wd.loadDxfPreview();
check('#113 DXF tolto: vista azzerata', wd.views.dxf.w === 0 && wd.views.dxf.zoom === 1); check('#122 DXF tolto: anteprima azzerata', wd.dxfPreview === null && wd.dxfPreviewViewBox() === '0 0 1 1');
if (failures) { console.log(failures + ' test falliti'); process.exit(1); } if (failures) { console.log(failures + ' test falliti'); process.exit(1); }
console.log('tutti i test #113 ok'); console.log('tutti i test #122 ok');
})(); })();
@@ -0,0 +1,99 @@
/**
* #122: static/js/image_viewer.js (<tmf-viewer>) con plain node: matematica pura (TmfViewerMath) e
* registrazione del Custom Element una sola volta. Il DOM vero si prova in Chrome (fixtures/image_viewer_demo.html).
*
* Il modulo si carica senza window/document/customElements.
* Run from src/frontend/flask_app: node tests/js/test_image_viewer.js
*/
'use strict';
const path = require('path');
const MOD = path.join(__dirname, '..', '..', 'static', 'js', 'image_viewer.js');
let failures = 0;
let total = 0;
function check(label, cond, extra) {
total++;
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const near = (a, b) => Math.abs(a - b) < 1e-9;
// --- caricamento in node senza globali del browser ---
check('node senza window/document/customElements/HTMLElement',
typeof window === 'undefined' && typeof document === 'undefined' &&
typeof customElements === 'undefined' && typeof HTMLElement === 'undefined');
const { TmfViewerMath: M } = require(MOD);
check('esporta TmfViewerMath con le quattro funzioni',
M && ['fitZoom', 'clampZoom', 'zoomAround', 'pct'].every((k) => typeof M[k] === 'function'));
// --- fitZoom ---
check('fitZoom: box largo, immagine alta -> limita l\'altezza', near(M.fitZoom(800, 400, 1000, 1000), 0.4));
check('fitZoom: box alto, immagine larga -> limita la larghezza', near(M.fitZoom(400, 800, 1000, 500), 0.4));
check('fitZoom: immagine piu\' piccola del box -> zoom > 1', near(M.fitZoom(800, 600, 400, 300), 2));
check('fitZoom: box esatto -> 1', near(M.fitZoom(640, 480, 640, 480), 1));
check('fitZoom: boxH Infinity (fit-height) -> solo larghezza', near(M.fitZoom(500, Infinity, 1000, 4000), 0.5));
check('fitZoom: 0 se box nascosto o immagine senza dimensioni',
M.fitZoom(0, 400, 10, 10) === 0 && M.fitZoom(400, 0, 10, 10) === 0 &&
M.fitZoom(400, 400, 0, 10) === 0 && M.fitZoom(400, 400, 10, 0) === 0);
check('fitZoom: 0 con NaN, negativi, undefined',
M.fitZoom(NaN, 400, 10, 10) === 0 && M.fitZoom(-5, 400, 10, 10) === 0 && M.fitZoom(400, 400, undefined, 10) === 0 &&
M.fitZoom(400, 400, 10, null) === 0);
// --- clampZoom ---
check('clampZoom: dentro i limiti resta uguale', near(M.clampZoom(1, 0.5), 1));
check('clampZoom: sotto fit*0.5 -> fit*0.5', near(M.clampZoom(0.01, 0.5), 0.25));
check('clampZoom: sopra 8 -> 8', near(M.clampZoom(20, 0.5), 8));
check('clampZoom: fit sopra 1 alza il massimo a fit*8 (DXF in mm, fit ~10)', near(M.clampZoom(20, 12), 20) && near(M.clampZoom(200, 12), 96) && near(M.clampZoom(11, 12), 11));
check('clampZoom: con fit piccolo il massimo resta 8', near(M.clampZoom(20, 0.5), 8) && near(M.clampZoom(20, 1), 8) && near(M.clampZoom(100, 2), 16));
check('clampZoom: limiti esatti', near(M.clampZoom(0.25, 0.5), 0.25) && near(M.clampZoom(8, 0.5), 8));
// --- zoomAround ---
{
// il punto del contenuto sotto (px,py) prima e dopo lo zoom deve essere lo stesso
const z0 = 0.5, z1 = 1.25, sl = 120, st = 40, px = 300, py = 200;
const r = M.zoomAround(z0, z1, sl, st, px, py);
const before = { x: (sl + px) / z0, y: (st + py) / z0 }; // coordinate logiche sotto il puntatore
const after = { x: (r.scrollLeft + px) / z1, y: (r.scrollTop + py) / z1 };
check('zoomAround: il punto sotto il puntatore resta fermo', near(before.x, after.x) && near(before.y, after.y),
JSON.stringify([before, after]));
const same = M.zoomAround(2, 2, 50, 60, 10, 10);
check('zoomAround: z1 == z0 non sposta lo scroll', near(same.scrollLeft, 50) && near(same.scrollTop, 60));
const c = M.zoomAround(1, 2, 0, 0, 100, 50);
check('zoomAround: da scroll 0 raddoppiando (100,50) -> scroll (100,50)', near(c.scrollLeft, 100) && near(c.scrollTop, 50));
const o = M.zoomAround(2, 1, 100, 100, 0, 0);
check('zoomAround: puntatore all\'origine, riduzione -> scroll dimezzato', near(o.scrollLeft, 50) && near(o.scrollTop, 50));
}
// --- pct ---
check('pct: 1 -> "100 %"', M.pct(1) === '100 %', M.pct(1));
check('pct: arrotonda 0.4567 -> "46 %"', M.pct(0.4567) === '46 %', M.pct(0.4567));
check('pct: 8 -> "800 %", 0 -> "0 %"', M.pct(8) === '800 %' && M.pct(0) === '0 %');
// --- define una sola volta, anche caricando due volte ---
{
const defined = {};
let defineCalls = 0;
globalThis.HTMLElement = class {};
globalThis.customElements = {
get: (n) => defined[n],
define: (n, c) => { defineCalls++; defined[n] = c; },
};
delete require.cache[require.resolve(MOD)];
require(MOD);
delete require.cache[require.resolve(MOD)];
require(MOD);
check('customElements.define("tmf-viewer") una sola volta con due caricamenti',
defineCalls === 1 && typeof defined['tmf-viewer'] === 'function', 'chiamate: ' + defineCalls);
const C = defined['tmf-viewer'];
check('osserva src, width, height, fit-height, no-save, save-name',
['src', 'width', 'height', 'fit-height', 'no-save', 'save-name'].every((a) => C.observedAttributes.includes(a)));
check('espone fit, zoomBy, overlay, labels, zoom sul prototipo',
typeof C.prototype.fit === 'function' && typeof C.prototype.zoomBy === 'function' &&
['overlay', 'labels', 'zoom'].every((k) => Object.getOwnPropertyDescriptor(C.prototype, k)));
delete globalThis.customElements;
delete globalThis.HTMLElement;
}
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
process.exit(failures ? 1 : 0);
@@ -0,0 +1,240 @@
/**
* #119: helper static/js/save_image.js (salva il frame grezzo come PNG) con plain node.
*
* Fetch, window e document sono finti: il modulo li tocca solo dentro le funzioni.
* Run from src/frontend/flask_app: node tests/js/test_save_image.js
*/
'use strict';
const path = require('path');
const mod = require(path.join(__dirname, '..', '..', 'static', 'js', 'save_image.js'));
const { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob } = mod; // #122: + tmfSaveBlob, tmfSvgToPngBlob
let failures = 0;
let total = 0;
function check(label, cond, extra) {
total++;
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
// --- nome proposto (funzione pura) ---
const d = new Date(2026, 9, 28, 9, 5, 7); // 28/10/2026 09:05:07 ora locale
check('nome: baseName + AAAAMMGG-hhmmss + .png', tmfSaveImageName('task_14', d) === 'task_14_20261028-090507.png',
tmfSaveImageName('task_14', d));
check('nome: caratteri non validi -> _', tmfSaveImageName('a/b\\c:d*e?f"g<h>i|j', d) === 'a_b_c_d_e_f_g_h_i_j_20261028-090507.png',
tmfSaveImageName('a/b\\c:d*e?f"g<h>i|j', d));
check('nome: spazi -> _, punti ai bordi tolti', tmfSaveImageName(' ..Ricetta test 1.. ', d) === 'Ricetta_test_1_20261028-090507.png',
tmfSaveImageName(' ..Ricetta test 1.. ', d));
check('nome: vuoto/null/undefined -> immagine',
tmfSaveImageName('', d).startsWith('immagine_') && tmfSaveImageName(null, d).startsWith('immagine_') &&
tmfSaveImageName(undefined, d).startsWith('immagine_'));
check('nome: solo caratteri vietati -> immagine', tmfSaveImageName('///', d) === 'immagine_20261028-090507.png',
tmfSaveImageName('///', d));
check('nome: senza data usa ora corrente e ha il formato giusto',
/^x_\d{8}-\d{6}\.png$/.test(tmfSaveImageName('x')), tmfSaveImageName('x'));
check('nome: data non valida -> ora corrente', /^x_\d{8}-\d{6}\.png$/.test(tmfSaveImageName('x', new Date('boh'))));
check('nome: non esiste doppia estensione', !/\.png\.png$/.test(tmfSaveImageName('foto.png', d)));
// --- finti del browser ---
const globals = ['fetch', 'window', 'document', 'URL', 'createImageBitmap', 'setTimeout', 'Image', 'XMLSerializer']; // #122: + Image, XMLSerializer
const saved = {};
globals.forEach((k) => { saved[k] = Object.getOwnPropertyDescriptor(globalThis, k); });
function restore() {
globals.forEach((k) => {
if (saved[k]) Object.defineProperty(globalThis, k, saved[k]); else delete globalThis[k];
});
}
function setGlobal(k, v) { Object.defineProperty(globalThis, k, { value: v, configurable: true, writable: true }); }
function fakeEnv({ blobType = 'image/png', ok = true, picker, pickerErr, fetchErr } = {}) {
const log = { fetched: [], written: [], closed: 0, clicks: [], appended: 0, removed: 0, revoked: [], pickerOpts: null, canvases: [] };
const blob = { type: blobType, size: 3, tag: 'blob-originale' };
setGlobal('fetch', async (src) => {
log.fetched.push(src);
if (fetchErr) throw fetchErr;
return { ok, blob: async () => blob };
});
const win = {};
if (picker) {
win.showSaveFilePicker = async (opts) => {
log.pickerOpts = opts;
if (pickerErr) throw pickerErr;
return { createWritable: async () => ({ write: async (b) => { log.written.push(b); }, close: async () => { log.closed++; } }) };
};
}
setGlobal('window', win);
setGlobal('document', {
body: { appendChild() { log.appended++; }, removeChild() { log.removed++; } },
createElement(tag) {
if (tag === 'canvas') {
const c = {
width: 0, height: 0,
getContext() { return { drawImage() { c.drawn = true; } }; },
toBlob(cb, type) { cb({ type, size: 9, tag: 'blob-png' }); },
};
log.canvases.push(c);
return c;
}
const a = { click() { log.clicks.push({ href: a.href, download: a.download }); } };
return a;
},
});
setGlobal('URL', { createObjectURL: (b) => 'blob:fake/' + b.tag, revokeObjectURL: (u) => { log.revoked.push(u); } });
setGlobal('createImageBitmap', async () => ({ width: 20, height: 10, close() {} }));
setGlobal('setTimeout', (fn) => { fn(); return 0; }); // il revoke differito scatta subito
return { log, blob };
}
(async () => {
const origErr = console.error;
try {
// --- ramo showSaveFilePicker, blob gia' PNG ---
{
const { log, blob } = fakeEnv({ picker: true });
const r = await tmfSaveImage('blob:http://x/abc', 'task 14');
check('picker: risolve a true', r === true);
check('picker: fetch dell\'src', log.fetched.length === 1 && log.fetched[0] === 'blob:http://x/abc');
check('picker: suggestedName del formato atteso',
log.pickerOpts && /^task_14_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName), JSON.stringify(log.pickerOpts));
check('picker: tipo PNG', log.pickerOpts && log.pickerOpts.types[0].accept['image/png'][0] === '.png');
check('picker: scritto il blob originale (gia\' PNG, nessuna conversione) e chiuso',
log.written.length === 1 && log.written[0] === blob && log.closed === 1 && log.canvases.length === 0);
check('picker: nessun <a download>', log.clicks.length === 0);
}
// --- picker, blob JPEG -> convertito su canvas ---
{
const { log } = fakeEnv({ picker: true, blobType: 'image/jpeg' });
const r = await tmfSaveImage('http://x/frame.jpg', 'f');
check('picker + JPEG: risolve a true', r === true);
check('picker + JPEG: ridisegnato su canvas 20x10 e scritto il PNG',
log.canvases.length === 1 && log.canvases[0].width === 20 && log.canvases[0].height === 10 && log.canvases[0].drawn &&
log.written.length === 1 && log.written[0].tag === 'blob-png' && log.written[0].type === 'image/png');
}
// --- ramo <a download> (nessun showSaveFilePicker) ---
{
const { log, blob } = fakeEnv({ picker: false });
const r = await tmfSaveImage('blob:http://x/abc', 'task_14');
check('a download: risolve a true', r === true);
check('a download: un click con href del blob e nome atteso',
log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/' + blob.tag &&
/^task_14_\d{8}-\d{6}\.png$/.test(log.clicks[0].download), JSON.stringify(log.clicks));
check('a download: elemento aggiunto, rimosso e URL liberato',
log.appended === 1 && log.removed === 1 && log.revoked.length === 1);
}
// --- AbortError: annullo dell'utente, nessun errore ---
{
const abort = Object.assign(new Error('The user aborted a request.'), { name: 'AbortError' });
const errs = [];
console.error = (...a) => errs.push(a);
const { log } = fakeEnv({ picker: true, pickerErr: abort });
let thrown = null, r = null;
try { r = await tmfSaveImage('blob:x', 'a'); } catch (e) { thrown = e; }
check('AbortError: nessuna eccezione, risolve a false', thrown === null && r === false, String(thrown));
check('AbortError: nessun errore in console, niente scritto, niente <a download>',
errs.length === 0 && log.written.length === 0 && log.clicks.length === 0);
}
// --- errori veri: fetch fallisce, risposta non ok, picker con errore diverso ---
{
console.error = () => {};
let thrown = null, r1 = null, r2 = null, r3 = null;
try {
fakeEnv({ picker: true, fetchErr: new TypeError('rete') });
r1 = await tmfSaveImage('blob:x', 'a');
fakeEnv({ picker: true, ok: false });
r2 = await tmfSaveImage('blob:x', 'a');
fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) });
r3 = await tmfSaveImage('blob:x', 'a');
} catch (e) { thrown = e; }
check('errori: fetch rotto, risposta non ok, picker che fallisce -> false senza eccezioni',
thrown === null && r1 === false && r2 === false && r3 === false, String(thrown) + [r1, r2, r3]);
}
// --- #122: tmfSaveBlob con picker ---
{
const { log } = fakeEnv({ picker: true });
const png = { type: 'image/png', size: 5, tag: 'blob-dxf' };
const r = await tmfSaveBlob(png, 'dxf ricetta');
check('saveBlob picker: true, scrive il blob dato senza fetch ne conversioni',
r === true && log.written.length === 1 && log.written[0] === png && log.closed === 1 &&
log.fetched.length === 0 && log.canvases.length === 0);
check('saveBlob picker: nome e tipo PNG proposti',
/^dxf_ricetta_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName) && log.pickerOpts.types[0].accept['image/png'][0] === '.png');
}
// --- #122: tmfSaveBlob senza picker -> <a download> ---
{
const { log } = fakeEnv({ picker: false });
const png = { type: 'image/png', size: 5, tag: 'blob-dxf' };
const r = await tmfSaveBlob(png, 'dxf');
check('saveBlob a download: true, un click con href del blob e nome atteso, URL liberato',
r === true && log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/blob-dxf' &&
/^dxf_\d{8}-\d{6}\.png$/.test(log.clicks[0].download) && log.revoked.length === 1);
}
// --- #122: tmfSaveBlob: blob mancante, annullo, errore ---
{
const errs = [];
console.error = (...a) => errs.push(a);
fakeEnv({ picker: true });
const rNull = await tmfSaveBlob(null, 'x');
const abort = Object.assign(new Error('annullato'), { name: 'AbortError' });
fakeEnv({ picker: true, pickerErr: abort });
const rAbort = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x');
const nAbort = errs.length;
fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) });
const rErr = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x');
check('saveBlob: null -> false; AbortError -> false senza log; altro errore -> false con log',
rNull === false && rAbort === false && nAbort === 0 && rErr === false && errs.length === 1);
console.error = () => {};
}
// --- #122: tmfSvgToPngBlob ---
{
const { log } = fakeEnv({ picker: false });
const svgs = [];
setGlobal('XMLSerializer', class { serializeToString(el) { svgs.push(el); return '<svg/>'; } });
setGlobal('Image', class { set src(v) { this._src = v; setImmediate(() => this.onload()); } });
const fills = [];
const cv = document.createElement;
document.createElement = (tag) => {
const e = cv(tag);
if (tag === 'canvas') e.getContext = () => ({ set fillStyle(v) { fills.push(v); }, fillRect() { fills.push('rect'); }, drawImage() { e.drawn = true; } });
return e;
};
const made = [];
const svgEl = {
attrs: { viewBox: '0 0 300 200' }, style: { width: '100%', height: '100%' },
getAttribute(k) { return this.attrs[k] === undefined ? null : this.attrs[k]; },
setAttribute(k, v) { this.attrs[k] = String(v); },
cloneNode() { const c = Object.assign({}, this, { attrs: Object.assign({}, this.attrs), style: Object.assign({}, this.style) }); made.push(c); return c; },
};
const png = await tmfSvgToPngBlob(svgEl, 300, 200);
const c = log.canvases[0];
check('svgToPng: ritorna il PNG del canvas, sfondo bianco, lato lungo portato a 1600 (300x200 -> 1600x1067)',
png && png.tag === 'blob-png' && c.width === 1600 && c.height === 1067 && fills[0] === '#ffffff' && fills[1] === 'rect' && c.drawn,
JSON.stringify([c.width, c.height, fills]));
check('svgToPng: clone con xmlns, width/height in pixel del PNG e stile 100% tolto; originale intatto',
made.length === 1 && made[0].attrs.xmlns === 'http://www.w3.org/2000/svg' && made[0].attrs.width === '1600' &&
made[0].attrs.height === '1067' && made[0].style.width === '' && svgEl.attrs.width === undefined && svgs[0] === made[0]);
check('svgToPng: URL del blob SVG liberato', log.revoked.length === 1);
// lato lungo oltre 4096 -> ridotto
const big = await tmfSvgToPngBlob(svgEl, 10000, 5000);
check('svgToPng: 10000x5000 -> 4096x2048', log.canvases[1].width === 4096 && log.canvases[1].height === 2048 && big);
// senza w/h usa il viewBox; senza dimensioni lancia
await tmfSvgToPngBlob(svgEl);
check('svgToPng: senza w/h usa il viewBox', log.canvases[2].width === 1600 && log.canvases[2].height === 1067);
let thrown = null;
try { await tmfSvgToPngBlob({ getAttribute: () => null }); } catch (e) { thrown = e; }
check('svgToPng: senza dimensioni lancia', thrown !== null);
document.createElement = cv;
}
// --- esposizione ---
{
fakeEnv({ picker: false });
check('esposto come funzione e export', typeof tmfSaveImage === 'function' && typeof tmfSaveImageName === 'function' &&
typeof tmfSaveBlob === 'function' && typeof tmfSvgToPngBlob === 'function');
}
} finally {
console.error = origErr;
restore();
}
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
process.exit(failures ? 1 : 0);
})();
@@ -0,0 +1,453 @@
/**
* #116: overlay del check visione (static/js/vision_overlay.js) con plain node.
*
* Run from src/frontend/flask_app: node tests/js/test_vision_overlay.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const VisionOverlay = require(path.join(__dirname, '..', '..', 'static', 'js', 'vision_overlay.js'));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, 'fixtures', 'vision_check_task14.json'), 'utf8'),
);
let failures = 0;
let total = 0;
function check(label, cond, extra) {
total++;
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const near = (a, b) => Math.abs(a - b) < 1e-9;
// Canvas finto: registra le chiamate e le proprieta' al momento dello stroke
function fakeCanvas() {
const calls = [];
const g = {
calls,
strokes: [],
dash: [],
strokeStyle: '',
lineWidth: 0,
beginPath() { calls.push(['beginPath']); },
moveTo(x, y) { calls.push(['moveTo', x, y]); },
lineTo(x, y) { calls.push(['lineTo', x, y]); },
arc(...a) { calls.push(['arc', ...a]); },
closePath() { calls.push(['closePath']); },
stroke() {
calls.push(['stroke']);
g.strokes.push({ style: g.strokeStyle, width: g.lineWidth, dash: g.dash.slice() });
},
setLineDash(d) { g.dash = d.slice(); calls.push(['setLineDash', d.slice()]); },
};
return g;
}
const onlyCalls = (g, name) => g.calls.filter((c) => c[0] === name);
// --- fixture vero: task 14 TEST1 ---
const overlay = fixture.overlay;
check('fixture: overlay con 25 elementi', overlay.length === 25, String(overlay.length));
{
const g = fakeCanvas();
const res = VisionOverlay.draw(g, overlay, 2);
check('draw sul fixture: drawn 25, skipped 0', res.drawn === 25 && res.skipped === 0, JSON.stringify(res));
const lastDash = onlyCalls(g, 'setLineDash').pop();
check('draw: ultimo setLineDash e\' []', lastDash && lastDash[1].length === 0);
check('draw: uno stroke per elemento', g.strokes.length === 25, String(g.strokes.length));
const roiIdx = overlay.findIndex((o) => o.role === 'roi');
const foundIdx = overlay.findIndex((o) => o.role === 'found');
check('roi: arancione, tratteggiata, spessore lw*0.75',
g.strokes[roiIdx].style === '#f59e0b' && g.strokes[roiIdx].width === 1.5 &&
JSON.stringify(g.strokes[roiIdx].dash) === '[6,4]', JSON.stringify(g.strokes[roiIdx]));
check('found: verde, piena, spessore lw',
g.strokes[foundIdx].style === '#16a34a' && g.strokes[foundIdx].width === 2 &&
g.strokes[foundIdx].dash.length === 0, JSON.stringify(g.strokes[foundIdx]));
}
// --- colori: reference e ruolo ignoto ---
{
const g = fakeCanvas();
VisionOverlay.draw(g, [
{ type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 },
{ type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 },
], 1);
check('reference blu', g.strokes[0].style === '#2563eb');
check('ruolo ignoto rosso', g.strokes[1].style === '#ef4444');
}
// --- rect2 ---
function xs(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[1]); }
function ys(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[2]); }
{
const g = fakeCanvas();
const r = VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }], 1);
const X = xs(g), Y = ys(g);
check('rect2 phi 0: 4 vertici chiusi', r.drawn === 1 && X.length === 4 && onlyCalls(g, 'closePath').length === 1);
check('rect2 phi 0: col 90/110', X.every((x) => near(x, 90) || near(x, 110)) && X.filter((x) => near(x, 90)).length === 2, String(X));
check('rect2 phi 0: row 46/54', Y.every((y) => near(y, 46) || near(y, 54)) && Y.filter((y) => near(y, 46)).length === 2, String(Y));
}
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 90, length1: 10, length2: 4 }], 1);
const X = xs(g), Y = ys(g);
check('rect2 phi 90: lati scambiati, col 96/104', X.every((x) => near(x, 96) || near(x, 104)), String(X));
check('rect2 phi 90: lati scambiati, row 40/60', Y.every((y) => near(y, 40) || near(y, 60)), String(Y));
}
// --- arc: argomenti come da contratto ---
{
const g = fakeCanvas();
const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 };
VisionOverlay.draw(g, [a], 1);
const c = onlyCalls(g, 'arc')[0];
check('arc: g.arc(col, row, r, -rad(start), -rad(start+extent), extent>0)',
c && near(c[1], 40) && near(c[2], 30) && near(c[3], 12) &&
near(c[4], -Math.PI / 2) && near(c[5], -Math.PI) && c[6] === true, JSON.stringify(c));
const g2 = fakeCanvas();
VisionOverlay.draw(g2, [{ ...a, start_deg: 0, extent_deg: -45 }], 1);
const c2 = onlyCalls(g2, 'arc')[0];
check('arc: extent negativo = orario (anticlockwise false)',
c2 && near(c2[4], 0) && near(c2[5], Math.PI / 4) && c2[6] === false, JSON.stringify(c2));
}
// --- circle, points, contour ---
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 }], 1);
const c = onlyCalls(g, 'arc')[0];
check('circle: cerchio pieno', c && c[1] === 7 && c[2] === 5 && c[3] === 3 && c[4] === 0 && near(c[5], 2 * Math.PI));
}
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 1);
const m = onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo'));
// lw 1 -> semi-lato max(3, 2) = 3
check('points: croce di semi-lato 3', m.length === 4 && m.some((c) => c[1] === 17 && c[2] === 10) &&
m.some((c) => c[1] === 23 && c[2] === 10) && m.some((c) => c[1] === 20 && c[2] === 7) &&
m.some((c) => c[1] === 20 && c[2] === 13), JSON.stringify(m));
const g2 = fakeCanvas();
VisionOverlay.draw(g2, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 5);
check('points: semi-lato lw*2 per lw grande', onlyCalls(g2, 'moveTo').some((c) => c[1] === 10 && c[2] === 10));
}
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] }], 1);
check('contour: polilinea aperta di 3 punti',
onlyCalls(g, 'moveTo').length === 1 && onlyCalls(g, 'lineTo').length === 2 && onlyCalls(g, 'closePath').length === 0);
}
// --- elementi malformati: saltati, nessuna eccezione ---
{
const g = fakeCanvas();
let thrown = null;
let res = null;
try {
res = VisionOverlay.draw(g, [
{ type: 'circle', role: 'found', row: 1, col: 1 }, // manca radius
{ type: 'points', role: 'found', rows: [1, 2], cols: [1] }, // lunghezze diverse
{ type: 'contour', role: 'found', rows: [], cols: [] }, // vuoto
{ type: 'boh', role: 'found' }, // tipo ignoto
{ type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 }, // non finito
null, // elemento nullo
{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, // valido
], 1);
} catch (e) { thrown = e; }
check('malformati: nessuna eccezione', thrown === null, String(thrown));
check('malformati: 6 saltati, 1 disegnato', res && res.drawn === 1 && res.skipped === 6, JSON.stringify(res));
check('malformati: draw con overlay non array', (() => {
try { const r = VisionOverlay.draw(fakeCanvas(), null, 1); return r.drawn === 0 && r.skipped === 0; } catch (e) { return false; }
})());
}
// --- anchor, valori calcolati a mano ---
{
const A = VisionOverlay.anchor;
const seg = A({ type: 'segment', row1: 10, col1: 0, row2: 20, col2: 40 });
check('anchor segment: punto medio', seg && seg.x === 20 && seg.y === 15);
const lin = A({ type: 'line', row1: 0, col1: 2, row2: 0, col2: 4 });
check('anchor line: punto medio', lin && lin.x === 3 && lin.y === 0);
const cir = A({ type: 'circle', row: 50, col: 100, radius: 8 });
check('anchor circle: centro + raggio a destra', cir && cir.x === 108 && cir.y === 50);
const arc = A({ type: 'arc', row: 50, col: 100, radius: 10, start_deg: 0, extent_deg: 180 });
check('anchor arc: punto medio (90 gradi = in alto a video)', arc && near(arc.x, 100) && near(arc.y, 40), JSON.stringify(arc));
const pts = A({ type: 'points', rows: [7, 8], cols: [3, 4] });
check('anchor points: primo punto', pts && pts.x === 3 && pts.y === 7);
const r2 = A({ type: 'rect2', row: 9, col: 11, phi_deg: 30, length1: 5, length2: 2 });
check('anchor rect2: centro', r2 && r2.x === 11 && r2.y === 9);
const con = A({ type: 'contour', rows: [0, 1, 2, 3], cols: [10, 11, 12, 13] });
check('anchor contour: punto a meta\' (indice 2)', con && con.x === 12 && con.y === 2);
check('anchor: tipo ignoto -> null', A({ type: 'boh' }) === null);
check('anchor: dati mancanti -> null', A({ type: 'circle', row: 1, col: 1 }) === null && A(null) === null);
}
// --- labels sul fixture, righe costruite come fara' vision_execute.js ---
const subById = {};
fixture._subtasks.forEach((s) => { subById[s.id] = s; });
const rows = fixture.measurements.map((m) => {
const s = subById[m.subtask_id];
return {
key: m.subtask_id, name: s.description, value: m.value, unit: s.unit,
status: m.pass_fail, vision_output: s.vision_output,
};
});
{
const L = VisionOverlay.labels(rows, overlay);
check('labels fixture: 6 etichette', L.length === 6, String(L.length));
const names = L.map((l) => l.text.split(' ')[0]).join(',');
check('labels fixture: larghezza, lunghezza e 4 raggi',
L[0].text.startsWith('Larghezza') && L[1].text.startsWith('Lunghezza') &&
L.slice(2).every((l) => l.text.startsWith('R ')), names);
check('labels fixture: nessuna per le differenze', L.every((l) => !/^Diff/.test(l.text)));
const l15 = L.find((l) => l.key === 15);
check('quota 15: testo con Larghezza e 1.668', l15 && l15.text.includes('Larghezza') && l15.text.includes('1.668'), l15 && l15.text);
check('quota 15: status riportato', l15 && l15.status === fixture.measurements[0].pass_fail);
const seg = overlay.find((o) => o.tool_id === 'larghezza' && o.role === 'found');
check('quota 15: ancorata al punto medio del segmento', l15 && near(l15.x, (seg.col1 + seg.col2) / 2) && near(l15.y, (seg.row1 + seg.row2) / 2));
}
{
const nuda = rows.map((r) => ({ ...r, vision_output: undefined }));
check('labels: righe senza vision_output -> nessuna etichetta', VisionOverlay.labels(nuda, overlay).length === 0);
const sola = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass' }], overlay);
check('labels: riga senza vision_output (assente) -> nessuna', sola.length === 0);
const noTool = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass', vision_output: 'nonesiste.value' }], overlay);
check('labels: tool senza overlay -> nessuna', noTool.length === 0);
// found preferito: la roi (per prima) ha un'altra ancora, ma vince il found
const ov = [
{ type: 'rect2', role: 'roi', tool_id: 't', row: 1, col: 1, phi_deg: 0, length1: 1, length2: 1 },
{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 },
];
const f = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'warning', vision_output: 't.value' }], ov);
check('labels: preferisce l\'ancora del found', f.length === 1 && f[0].x === 15 && f[0].y === 10 && f[0].status === 'warning', JSON.stringify(f));
const solo = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'fail', vision_output: 't.value' }], [ov[0]]);
check('labels: senza found ripiega sul primo elemento', solo.length === 1 && solo[0].x === 1 && solo[0].y === 1);
check('labels: testo name + valore a 3 decimali + unit', f[0].text === 'T 1.500 mm', f[0].text);
}
// --- #118: labels, valore null/undefined/non finito -> etichetta NaN con status della riga ---
{
const ov = [{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 }];
const mk = (value) => ({ key: 1, name: 'T', value, unit: 'mm', status: 'fail', vision_output: 't.value' });
const nan = (v) => { const l = VisionOverlay.labels([mk(v)], ov); return l.length === 1 && l[0].text === 'T NaN mm' && l[0].status === 'fail'; };
check('labels: value null -> etichetta NaN, status fail (Number(null) e 0)', nan(null));
check('labels: value undefined / stringa vuota -> etichetta NaN', nan(undefined) && nan(''));
check('labels: value NaN/Infinity -> etichetta NaN', nan(NaN) && nan(Infinity) && nan(-Infinity));
check('labels: value 0 resta un valore valido', VisionOverlay.labels([mk(0)], ov)[0].text === 'T 0.000 mm');
const misto = VisionOverlay.labels([{ ...mk(null), key: 1 }, { ...mk(2.5), key: 2 }], ov);
check('labels: null e valida entrambe presenti', misto.length === 2 && misto[0].text === 'T NaN mm' && misto[1].key === 2);
check('labels: riga NaN senza ancoraggio -> nessuna etichetta', VisionOverlay.labels([mk(null)], []).length === 0);
}
// --- #121: svg(overlay, opts) ---
const count = (str, re) => (str.match(re) || []).length;
const SHAPE_RE = /<(polygon|line|circle|path|polyline) /g;
{
const out = VisionOverlay.svg(overlay);
// il fixture vero ha 25 forme: 7 segment, 1 contour, 1 points, 4 rect2 roi, 4 arc roi, 4 arc found, 4 circle
check('svg fixture: 25 forme', count(out, SHAPE_RE) === 25, String(count(out, SHAPE_RE)));
check('svg fixture: ogni forma con vector-effect non-scaling-stroke',
count(out, /vector-effect="non-scaling-stroke"/g) === 25);
check('svg fixture: ogni forma fill none', count(out, /fill="none"/g) === 25);
check('svg fixture: 8 roi (4 rect2 + 4 arc) con stroke-dasharray 6 4', count(out, /stroke-dasharray="6 4"/g) === 8,
String(count(out, /stroke-dasharray="6 4"/g)));
check('svg fixture: nessun <svg> esterno', !/<svg/.test(out));
check('svg fixture: nessun NaN/undefined/null nel markup', !/NaN|undefined|null|Infinity/.test(out));
check('svg fixture: numeri con al piu\' 2 decimali', !/\d\.\d{3,}/.test(out));
}
{
const one = (o, opts) => VisionOverlay.svg([o], opts);
const roi = one({ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 });
check('svg roi: polygon 4 vertici, colore roi, dash 6 4, spessore lw*0.75',
roi.startsWith('<polygon ') && roi.includes('points="110,46 110,54 90,54 90,46"') && roi.includes('stroke="#f59e0b"') &&
roi.includes('stroke-dasharray="6 4"') && roi.includes('stroke-width="1.5"'), roi);
const roi4 = one({ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }, { lw: 4 });
check('svg roi: lw 4 -> stroke-width 3', roi4.includes('stroke-width="3"'), roi4);
const fnd = one({ type: 'segment', role: 'found', row1: 1, col1: 2, row2: 3, col2: 4 });
check('svg found: line verde piena, spessore lw 2 predefinito',
fnd === '<line x1="2" y1="1" x2="4" y2="3" fill="none" stroke="#16a34a" stroke-width="2" vector-effect="non-scaling-stroke"/>', fnd);
check('svg reference blu, ruolo ignoto rosso',
one({ type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 }).includes('stroke="#2563eb"') &&
one({ type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 }).includes('stroke="#ef4444"'));
check('svg: spessore costante, non dipende dallo zoom',
one({ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, { zoom: 4 }).includes('stroke-width="2"'));
const cir = one({ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 });
check('svg circle: cx cy r', cir.startsWith('<circle cx="7" cy="5" r="3" '), cir);
const con = one({ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] });
check('svg contour: polyline aperta', con.startsWith('<polyline points="4,1 5,2 6,3" ') && !/ Z/.test(con), con);
// points: croce di semi-braccio cross/zoom
const p1 = one({ type: 'points', role: 'found', rows: [10], cols: [20] }, { zoom: 1 });
check('svg points zoom 1: semi-braccio 6', p1.includes('d="M 14 10 L 26 10 M 20 4 L 20 16"'), p1);
const p2 = one({ type: 'points', role: 'found', rows: [10], cols: [20] }, { zoom: 2 });
check('svg points zoom 2: semi-braccio 3', p2.includes('d="M 17 10 L 23 10 M 20 7 L 20 13"'), p2);
const p3 = one({ type: 'points', role: 'found', rows: [10, 30], cols: [20, 40] }, { zoom: 1, cross: 2 });
check('svg points: cross personalizzato, 2 croci in un solo path',
count(p3, /<path /g) === 1 && count(p3, /M /g) === 4 && p3.includes('M 18 10 L 22 10'), p3);
// arc: estremi calcolati (y verso il basso, angoli antiorari)
const arcD = (o) => /d="([^"]+)"/.exec(one({ type: 'arc', role: 'found', ...o }))[1];
const parse = (d) => d.match(/-?\d+(\.\d+)?/g).map(Number); // [x0, y0, rx, ry, rot, large, sweep, x1, y1]
{
// start 90, extent 90 su centro (40,30) r 12: da (40,18) a (28,30), antiorario (sweep 0), arco piccolo
const d = arcD({ row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 });
const v = parse(d);
check('svg arc 90+90: M 40 18 A 12 12 0 0 0 28 30',
near(v[0], 40) && near(v[1], 18) && v[2] === 12 && v[3] === 12 && v[4] === 0 && v[5] === 0 && v[6] === 0 &&
near(v[7], 28) && near(v[8], 30), d);
}
{
// start 0, extent -90 (orario): da (10,0) a (0,10) a video, sweep 1
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: -90 }));
check('svg arc extent negativo: sweep 1, fine in basso a video',
near(v[0], 10) && near(v[1], 0) && v[5] === 0 && v[6] === 1 && near(v[7], 0) && near(v[8], 10), JSON.stringify(v));
}
{
// 270 gradi antiorari da 0: fine a 270 = in basso a video (0, +10)
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: 270 }));
check('svg arc 270: large-arc 1, sweep 0, fine (0,10) a video',
v[5] === 1 && v[6] === 0 && near(v[7], 0) && near(v[8], 10), JSON.stringify(v));
}
{
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: 360 }));
check('svg arc 360: estensione limitata a 359.99 (large-arc, fine quasi sullo start)',
v[5] === 1 && Math.abs(v[8]) < 0.01 && v[7] > 9.99, JSON.stringify(v));
}
{
// coincide con draw: punto finale del g.arc del canvas (angolo -rad(start+extent))
const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 30, extent_deg: 100 };
const g = fakeCanvas();
VisionOverlay.draw(g, [a], 1);
const c = onlyCalls(g, 'arc')[0];
const ex = c[1] + c[3] * Math.cos(c[5]), ey = c[2] + c[3] * Math.sin(c[5]);
const sx = c[1] + c[3] * Math.cos(c[4]), sy = c[2] + c[3] * Math.sin(c[4]);
const v = parse(arcD(a));
check('svg arc: estremi uguali a quelli di draw su canvas',
Math.abs(v[7] - ex) < 0.01 && Math.abs(v[8] - ey) < 0.01 && Math.abs(v[0] - sx) < 0.01 && Math.abs(v[1] - sy) < 0.01,
JSON.stringify([v, ex, ey]));
}
// malformati e ignoti: saltati, nessuna eccezione, la forma valida resta
let thrown = null, out = '';
try {
out = VisionOverlay.svg([
{ type: 'circle', role: 'found', row: 1, col: 1 },
{ type: 'points', role: 'found', rows: [1, 2], cols: [1] },
{ type: 'contour', role: 'found', rows: [], cols: [] },
{ type: 'boh', role: 'found' },
{ type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 },
{ type: 'arc', role: 'found', row: 0, col: 0, radius: 1, start_deg: 0 },
{ type: 'rect2', role: 'roi', row: 0, col: 0, phi_deg: 0, length1: Infinity, length2: 1 },
null,
{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 },
], { zoom: 1 });
} catch (e) { thrown = e; }
check('svg malformati: nessuna eccezione, una sola forma valida',
thrown === null && count(out, /<line /g) === 1 && count(out, /vector-effect/g) === 1, String(thrown) + out);
check('svg: overlay non array o vuoto -> stringa vuota',
VisionOverlay.svg(null) === '' && VisionOverlay.svg(undefined) === '' && VisionOverlay.svg([]) === '');
check('svg: opts non validi -> predefiniti',
VisionOverlay.svg([{ type: 'points', role: 'found', rows: [10], cols: [20] }], { zoom: 0, lw: -1, cross: NaN })
.includes('d="M 14 10 L 26 10 M 20 4 L 20 16"'));
// nessuna stringa dei dati nel markup
const evil = '<script>alert(1)</script>"onload="x';
const sicuro = VisionOverlay.svg([
{ type: 'segment', role: evil, tool_id: evil, name: evil, row1: 0, col1: 0, row2: 1, col2: 1 },
{ type: 'circle', role: 'found', tool_id: evil, row: 1, col: 1, radius: 2 },
]);
check('svg: tool_id e role dei dati non compaiono nel markup',
!/script|onload|alert/.test(sicuro) && count(sicuro, /vector-effect/g) === 2, sicuro);
const strNum = VisionOverlay.svg([{ type: 'segment', role: 'found', row1: '<b>', col1: 0, row2: 1, col2: 1 }]);
check('svg: coordinata stringa -> forma saltata', strNum === '');
}
// --- #12: livelli e quote CAD ---
{
const VO = VisionOverlay;
check('layerOf: layer esplicito vince sul ruolo',
VO.layerOf({ role: 'found', layer: 'quote' }) === 'quote' && VO.layerOf({ role: 'roi', layer: 'costruzioni' }) === 'costruzioni');
check('layerOf: senza campo found = costruzioni, altro = ricerca',
VO.layerOf({ role: 'found' }) === 'costruzioni' && VO.layerOf({ role: 'roi' }) === 'ricerca' &&
VO.layerOf({ role: 'reference' }) === 'ricerca' && VO.layerOf({}) === 'ricerca' && VO.layerOf(null) === 'ricerca');
check('layerOf: layer ignoto -> secondo il ruolo', VO.layerOf({ role: 'found', layer: 'boh' }) === 'costruzioni');
const lin = { tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: null };
const rag = { tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 90, testo: null };
const ang = { tipo: 'angolo', centro: [50, 50], r: 20, start_deg: 0, extent_deg: 90, testo: null };
// default
let p = VO.quotaPunto(lin);
check('quotaPunto lineare default = punto medio', near(p.x, 50) && near(p.y, 0), JSON.stringify(p));
p = VO.quotaPunto(rag);
check('quotaPunto raggio default = centro + 2r lungo dir (90 gradi = verso l\'alto a video)', near(p.x, 50) && near(p.y, 30), JSON.stringify(p));
p = VO.quotaPunto(ang);
{ // u a 45 gradi = (cos45, -sin45); T = centro + 1.5r u
const e = 30 * Math.SQRT1_2;
check('quotaPunto angolo default = centro + 1.5r a meta arco', near(p.x, 50 + e) && near(p.y, 50 - e), JSON.stringify(p));
}
// con testo: lineare p1->p2 lungo +x, n = (0, 1): dv positivo = verso il basso a video
p = VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [10, 20] });
check('quotaPunto lineare con testo [du,dv]', near(p.x, 60) && near(p.y, 20), JSON.stringify(p));
// inverse
[lin, rag, ang].forEach((q) => {
[[3, -7], [-12.5, 40], [0, 0]].forEach((t) => {
const q2 = Object.assign({}, q, { testo: t });
const pt = VO.quotaPunto(q2);
const back = VO.quotaTesto(q2, pt.x, pt.y);
check('quotaTesto inverso di quotaPunto (' + q.tipo + ' ' + t + ')', near(back[0], t[0]) && near(back[1], t[1]), JSON.stringify(back));
});
// dal punto di default si torna a testo di default
const d = VO.quotaPunto(q), back = VO.quotaTesto(q, d.x, d.y), p2 = VO.quotaPunto(Object.assign({}, q, { testo: back }));
check('quotaTesto dal punto di default riproduce il default (' + q.tipo + ')', near(p2.x, d.x) && near(p2.y, d.y));
});
check('quota malformata: quotaPunto/quotaTesto null, quotaSvg stringa vuota',
VO.quotaPunto(null) === null && VO.quotaPunto({ tipo: 'boh' }) === null &&
VO.quotaPunto({ tipo: 'lineare', p1: [0, 'a'], p2: [1, 1] }) === null &&
VO.quotaTesto({ tipo: 'raggio' }, 1, 1) === null &&
VO.quotaSvg({ tipo: 'angolo', centro: [0, 0] }) === '' && VO.quotaSvg(undefined) === '');
check('quotaPunto: testo malformato = default', near(VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: ['x', 1] }).x, 5));
// quotaSvg lineare: linee di estensione solo con dv != 0
const conDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 1, color: '#16a34a' });
const senzaDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 0] }, { zoom: 1, color: '#16a34a' });
check('quotaSvg lineare dv=30: 2 estensioni + 1 linea di misura, 2 frecce', count(conDv, /<line /g) === 3 && count(conDv, /<polygon /g) === 2, conDv);
check('quotaSvg lineare dv=0: nessuna estensione (solo linea di misura), 2 frecce', count(senzaDv, /<line /g) === 1 && count(senzaDv, /<polygon /g) === 2, senzaDv);
check('quotaSvg: tratto non-scaling 1.5 e colore passato', /stroke-width="1.5" vector-effect="non-scaling-stroke"/.test(conDv) && conDv.includes('#16a34a'));
// frecce dentro/fuori: lineare lunga 200 a zoom 1 -> la punta sta in A (0, 30): primo vertice del poligono
{ // dentro: punta in A, coda verso +u (x crescente) -> il secondo vertice ha x > 0? no: dentro la freccia punta verso fuori, coda verso l'interno (x>0)
const m = conDv.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
check('freccia dentro: punta in A, coda verso l\'interno della misura', near(m[0][0], 0) && near(m[0][1], 30) && m[1][0] > 0, m.join('|'));
const corta = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: [0, 30] }, { zoom: 1 });
const mc = corta.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
check('freccia fuori (misura corta): punta in A, coda fuori (x < 0)', near(mc[0][0], 0) && mc[1][0] < 0, mc.join('|'));
const zz = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 4 });
const mz = zz.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
check('freccia: lunghezza fissa a video (9 px / zoom)', near(mz[1][0], 9 / 4) && count(zz, /<line /g) === 3, mz.join('|'));
}
// T oltre B: la linea di misura si allunga
const oltre = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [150, 30] }, { zoom: 1 });
check('quotaSvg lineare: scritta oltre B allunga la linea di misura fino a lei', oltre.includes('x2="200"'), oltre);
// raggio
const rFuori = VO.quotaSvg({ tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 0, testo: [30, 0] }, { zoom: 1 });
const rDentro = VO.quotaSvg({ tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 0, testo: [5, 0] }, { zoom: 1 });
check('quotaSvg raggio: crocetta + linea + freccia (fuori)', count(rFuori, /<line /g) === 3 && count(rFuori, /<polygon /g) === 1, rFuori);
{ // fuori: P = (60, 50), freccia verso il centro: coda a x > 60
const m = rFuori.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
check('raggio fuori: punta in P sul cerchio, coda verso fuori', near(m[0][0], 60) && near(m[0][1], 50) && m[1][0] > 60, m.join('|'));
const d = rDentro.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
check('raggio dentro: punta in P, coda verso il centro (x < 60)', near(d[0][0], 60) && d[1][0] < 60, d.join('|'));
}
// angolo
const a1 = VO.quotaSvg(ang, { zoom: 1 });
check('quotaSvg angolo: 2 estensioni radiali + arco + 2 frecce', count(a1, /<line /g) === 2 && count(a1, /<path /g) === 1 && count(a1, /<polygon /g) === 2, a1);
// markup senza stringhe dei dati
const evil = '<script>alert(1)</script>"onload="x';
const s1 = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [0, 20], nome: evil, extra: evil }, { zoom: 1, color: evil });
const s2 = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [0, evil] }, { zoom: 1 });
check('quotaSvg: nessuna stringa dei dati nel markup, colore non in lista -> rosso',
!/script|onload|alert/.test(s1) && s1.includes('#ef4444') && !/script|onload|alert/.test(s2), s1);
check('quotaSvg: colori ammessi (esiti)', ['#16a34a', '#d97706', '#dc2626'].every((c) => VO.quotaSvg(lin, { color: c }).includes(c)));
check('svg() invariato: uscita di un segmento', VO.svg([{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }]).startsWith('<line x1="0"'));
}
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
process.exit(failures ? 1 : 0);
@@ -13,7 +13,12 @@ const src = fs.readFileSync(
); );
// Contesto vm con window e document finti; il file definisce visionExecute() globale // Contesto vm con window e document finti; il file definisce visionExecute() globale
const win = {}; const win = {};
const ctx = vm.createContext({ window: win, document: { addEventListener() {}, querySelector() { return null; } } }); // #120: CustomEvent finto (la pagina emette vision-recorded con window.dispatchEvent)
const ctx = vm.createContext({
window: win,
document: { addEventListener() {}, querySelector() { return null; } },
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
});
vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx); vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
let failures = 0; let failures = 0;
@@ -46,4 +51,155 @@ check('#114 riga 99: subtask sconosciuta', rows[3].label === '#99', rows[3].labe
check('#114 unit riga 17 = mm', rows[2].unit === 'mm', rows[2].unit); check('#114 unit riga 17 = mm', rows[2].unit === 'mm', rows[2].unit);
check('#114 unit riga 99 = mm', rows[3].unit === 'mm', rows[3].unit); check('#114 unit riga 99 = mm', rows[3].unit === 'mm', rows[3].unit);
process.exit(failures ? 1 : 0); // #117: nominale e tolleranze portati dalla quota; fmtTol
win.__visionCfg = {
subtasks: [
{ id: 20, description: 'Largh', unit: 'mm', nominal: 12.5, ltl: 12.4, utl: 12.6, lwl: 12.45, uwl: 12.55 },
{ id: 21, description: 'Diam', unit: 'mm', nominal: 0, ltl: null, utl: 0.05 },
{ id: 22, description: 'Nuda', unit: 'mm' },
],
};
const c2 = ctx.visionExecute();
c2.result = { measurements: [
{ subtask_id: 20, value: 12.5, pass_fail: 'pass' },
{ subtask_id: 21, value: 0, pass_fail: 'pass' },
{ subtask_id: 22, value: 1, pass_fail: 'pass' },
] };
const r2 = c2.rows;
check('#117 riga 20: nominale e tolleranze',
r2[0].nominal === 12.5 && r2[0].ltl === 12.4 && r2[0].utl === 12.6 && r2[0].lwl === 12.45 && r2[0].uwl === 12.55);
check('#117 riga 21: nominale 0 resta 0, ltl null', r2[1].nominal === 0 && r2[1].ltl == null && r2[1].utl === 0.05);
check('#117 riga 22: senza tolleranze sono undefined/null', r2[2].nominal == null && r2[2].utl == null && r2[2].lwl == null);
check('#117 label invariato', r2[0].label === 'Largh', r2[0].label);
check('#117 fmtTol(null) = trattino', c2.fmtTol(null) === '\u2014', c2.fmtTol(null));
check('#117 fmtTol(undefined) = trattino', c2.fmtTol(undefined) === '\u2014');
check('#117 fmtTol(0.05) = 0.050', c2.fmtTol(0.05) === '0.050', c2.fmtTol(0.05));
check('#117 fmtTol(0) = 0.000', c2.fmtTol(0) === '0.000', c2.fmtTol(0));
// #116: righe con key/name/vision_output, etichette in % con un VisionOverlay finto, labelClass
win.__visionCfg = {
subtasks: [
{ id: 30, marker_number: 1, description: 'Larghezza', unit: 'mm', vision_output: 'larg.value' },
{ id: 31, description: '', measurement_type: 'Diametro', unit: 'mm' },
],
};
const c3 = ctx.visionExecute();
c3.result = { overlay: [{ type: 'circle', tool_id: 'larg' }], measurements: [
{ subtask_id: 30, value: 1.5, pass_fail: 'pass' },
{ subtask_id: 31, value: 2, pass_fail: 'fail' },
] };
const r3 = c3.rows;
check('#116 riga: key = subtask_id', r3[0].key === 30 && r3[1].key === 31);
check('#116 riga: name senza marker', r3[0].name === 'Larghezza' && r3[1].name === 'Diametro', r3[0].name + '/' + r3[1].name);
check('#116 riga: vision_output', r3[0].vision_output === 'larg.value' && r3[1].vision_output == null);
check('#116 label invariato (#114)', r3[0].label === '1. Larghezza', r3[0].label);
check('#116 senza VisionOverlay: nessuna etichetta', c3.viewerLabels.length === 0);
let seen = null;
ctx.VisionOverlay = {
labels(rows, overlay) { seen = { rows, overlay }; return [{ key: 30, x: 500, y: 250, text: 'Larghezza 1.500 mm', status: 'pass' }]; },
};
const ls = c3.viewerLabels;
check('#116 una etichetta', ls.length === 1 && ls[0].key === 30 && ls[0].text === 'Larghezza 1.500 mm', JSON.stringify(ls));
// #122: posizione in px logici dell'immagine (la percentuale la calcola <tmf-viewer>), niente style
check('#122 posizione in px logici', ls[0].x === 500 && ls[0].y === 250 && ls[0].style === undefined, JSON.stringify(ls[0]));
check('#116 classe da esito', ls[0].cls === 'bg-green-600 text-white', ls[0].cls);
check('#116 labels riceve rows e overlay', seen.rows.length === 2 && seen.overlay.length === 1);
c3.result = null;
check('#116 senza risultato: nessuna etichetta', c3.viewerLabels.length === 0);
check('#116 labelClass pass', c3.labelClass('pass') === 'bg-green-600 text-white');
check('#116 labelClass warning', c3.labelClass('warning') === 'bg-amber-400 text-black');
check('#116 labelClass fail', c3.labelClass('fail') === 'bg-red-600 text-white');
check('#116 labelClass altro', c3.labelClass('boh') === 'bg-gray-700 text-white' && c3.labelClass(undefined) === 'bg-gray-700 text-white');
// #122: zoom, overlay SVG e Salva sono di <tmf-viewer>: il componente non li ha piu'
check('#122 niente drawOverlay/saveImage/overlayLabels/z', c3.drawOverlay === undefined && c3.saveImage === undefined && c3.overlayLabels === undefined && c3.z === undefined);
check('#122 niente codice canvas', !/getContext|clearRect/.test(src));
// #118: quota non misurata (value null) = NaN, mai Number(null) = 0
check('#118 fmtValue(null) = NaN', c3.fmtValue(null, 'mm') === 'NaN mm', c3.fmtValue(null, 'mm'));
check('#118 fmtValue(undefined) = NaN', c3.fmtValue(undefined, 'mm') === 'NaN mm');
check('#118 fmtValue(0) = 0.000', c3.fmtValue(0, 'mm') === '0.000 mm', c3.fmtValue(0, 'mm'));
check('#118 fmtValue(1.5) = 1.500', c3.fmtValue(1.5, 'mm') === '1.500 mm');
win.__visionCfg = { subtasks: [{ id: 40, description: 'Persa', unit: 'mm', nominal: 5, ltl: 4, utl: 6 }] };
const c4 = ctx.visionExecute();
c4.result = { measurements: [{ subtask_id: 40, value: null, pass_fail: 'fail' }] };
check('#118 riga con value null: valore null, esito fail, tolleranze come prima',
c4.rows[0].value === null && c4.rows[0].status === 'fail' && c4.rows[0].nominal === 5 && c4.rows[0].utl === 6);
// #120: evento vision-recorded dopo un record riuscito, non dopo uno fallito
const events = [];
win.dispatchEvent = (e) => { events.push(e); return true; };
const listeners = {};
win.addEventListener = (t, f) => { (listeners[t] = listeners[t] || []).push(f); };
win.__visionCfg = { cameraAvailable: true, modo: 'impulsiva', subtasks: [{ id: 40, description: 'Persa', unit: 'mm' }] };
const c5 = ctx.visionExecute();
c5.lastBlob = { name: 'x' };
const okMs = [{ subtask_id: 40, value: null, pass_fail: 'fail' }, { subtask_id: 41, value: 2, pass_fail: 'pass' }];
c5.acquire = async function () { this.result = { vision_result_id: 7, measurements: okMs }; };
(async () => {
await c5.record();
const ev = events.filter((e) => e.type === 'vision-recorded');
check('#120 record riuscito: un evento vision-recorded', ev.length === 1, String(ev.length));
check('#120 evento: misure di execute', ev[0].detail.measurements === okMs);
check('#120 record riuscito: saved', c5.saved === true && c5.preview === false);
events.length = 0;
const c6 = ctx.visionExecute();
c6.lastBlob = { name: 'x' };
c6.acquire = async function () { this.error = 'boom'; this.result = null; };
await c6.record();
check('#120 record fallito (errore): nessun evento', events.length === 0 && c6.saved === false);
const c7 = ctx.visionExecute();
c7.lastBlob = { name: 'x' };
c7.acquire = async function () { this.result = { measurements: okMs }; }; // niente vision_result_id
await c7.record();
check('#120 record senza vision_result_id: nessun evento', events.length === 0 && c7.saved === false);
// senza camera il file e' salvato subito da acquire(): stesso evento
events.length = 0;
win.__visionCfg = { cameraAvailable: false, executeUrl: '/e', taskId: 1, subtasks: [] };
const c8 = ctx.visionExecute();
c8.$nextTick = (fn) => fn();
ctx.URL = { createObjectURL: () => 'blob:x', revokeObjectURL() {} };
ctx.FormData = class { append() {} };
ctx.fetch = async () => ({ ok: true, json: async () => ({ vision_result_id: 9, measurements: okMs }) });
await c8.acquire({ name: 'a.png' });
check('#120 senza camera: acquire riuscito emette vision-recorded', events.length === 1 && events[0].type === 'vision-recorded', String(events.length));
// task-result-reset: saved torna false (anche senza camera, init registra l'ascolto)
c5.init();
const h = (listeners['task-result-reset'] || [])[0];
check('#120 init ascolta task-result-reset', typeof h === 'function');
c5.saved = true;
h();
check('#120 task-result-reset: saved = false', c5.saved === false);
// #123: dopo Registra + task-result-reset Registra e' spento sul frame registrato, anteprima spenta, impulsiva = grab
win.__visionCfg = { cameraAvailable: true, modo: 'impulsiva', subtasks: [] };
const c9 = ctx.visionExecute();
let grabs = 0;
c9.grab = async function () { grabs++; };
c9.lastBlob = { name: 'f1' };
c9.acquire = async function () { this.result = { vision_result_id: 11, measurements: okMs }; };
check('#123 prima di Registra: canRecord', c9.canRecord === true);
await c9.record();
check('#123 dopo Registra: canRecord falso (saved)', c9.canRecord === false && c9.recordedBlob === c9.lastBlob);
c9.$nextTick = () => {}; // #123: nel test nessun grab di apertura
c9.init(); // registra l'ascolto di task-result-reset
grabs = 0;
(listeners['task-result-reset'] || []).slice(-1)[0]();
check('#123 reset sul frame registrato: canRecord falso', c9.canRecord === false && c9.saved === false);
check('#123 reset sul frame registrato: niente badge anteprima', c9.preview === false);
check('#123 reset in impulsiva: un solo grab', grabs === 1, String(grabs));
c9.lastBlob = { name: 'f2' };
check('#123 frame nuovo: canRecord vero', c9.canRecord === true);
// #119 #122: saveName = titolo del task (nome proposto da <tmf-viewer save-name>), 'immagine' se manca
win.__visionCfg = { taskName: 'Roto3', subtasks: [] };
check('#122 saveName = nome del task', ctx.visionExecute().saveName === 'Roto3');
win.__visionCfg = { subtasks: [] };
check('#122 saveName di riserva', ctx.visionExecute().saveName === 'immagine');
process.exit(failures ? 1 : 0);
})();
@@ -0,0 +1,44 @@
/**
* #122: lo zoom del pannello visione non e' piu' in vision_execute.js ma in <tmf-viewer> (image_viewer.js).
* Qui: il componente passa al viewer solo le etichette in px logici (viewerLabels) e non ha piu' codice di zoom.
*
* Run from src/frontend/flask_app: node tests/js/test_vision_zoom.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8',
);
const win = { __visionCfg: { subtasks: [{ id: 1, description: 'Larg', unit: 'mm' }] } };
const ctx = vm.createContext({ window: win, document: { addEventListener() {}, querySelector() { return null; } } });
vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
let failures = 0;
function check(label, cond, extra) {
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
// Niente piu' stato ne' funzioni di zoom/pan/resize/Salva/overlay nel componente
const c = ctx.visionExecute();
for (const k of ['z', 'zoomBy', 'zFit', 'zWheel', 'zCanPan', 'zCursor', 'zPct', 'zSize', 'zPanDown', 'zPanMove', 'zPanEnd',
'onImgLoad', 'drawOverlay', 'saveImage', 'overlayLabels']) {
check('#122 componente senza ' + k, c[k] === undefined);
}
check('#122 sorgente senza $watch di zoom ne ResizeObserver', !/z\.zoom|ResizeObserver|tmfSaveImage/.test(src));
// viewerLabels: {key, x, y, text, cls} in px logici, classe dall'esito; vuote senza risultato o senza VisionOverlay
check('#122 senza VisionOverlay: nessuna etichetta', (c.result = { measurements: [] }, c.viewerLabels.length === 0));
win.VisionOverlay = ctx.VisionOverlay = {
labels() { return [{ key: 1, x: 500, y: 250, text: 'Larg 1.500 mm', status: 'fail' }]; },
};
const ls = c.viewerLabels;
check('#122 viewerLabels in px logici', ls.length === 1 && ls[0].x === 500 && ls[0].y === 250 && ls[0].key === 1 && ls[0].text === 'Larg 1.500 mm', JSON.stringify(ls));
check('#122 viewerLabels: classe da esito', ls[0].cls === 'bg-red-600 text-white', ls[0].cls);
c.result = null;
check('#122 senza risultato: nessuna etichetta', c.viewerLabels.length === 0);
process.exit(failures ? 1 : 0);
@@ -354,19 +354,13 @@ class TestPages:
assert "@dxf-recipe-saved" in html assert "@dxf-recipe-saved" in html
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF
assert "imageSource === 'file'" in html and "zoomBy(" in html and "fit()" in html # #104: mm/px solo da file, zoom e Fit assert "imageSource === 'file'" in html # #104: mm/px solo da file
assert 'onPanDown($event)' in html # #104: pan col puntatore
assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine
# #113: zoom, Adatta e pan anche sulle due anteprime (immagine e DXF), per chiave # #122: le tre viste (immagine, DXF, Prova) sono <tmf-viewer>; zoom, Adatta, pan e Salva stanno nel componente
assert 'x-ref="vpImg"' in html and 'x-ref="vpDxf"' in html assert html.count("<tmf-viewer") == 3 and "js/image_viewer.js" in html
assert "vFit('img')" in html and "vFit('dxf')" in html assert '@viewer-load="onImagePreviewLoad($event.detail)"' in html # #122: dimensioni per il riepilogo
assert "vZoomBy('img', 1.25)" in html and "vZoomBy('dxf', 1.25)" in html assert "fit-height" in html and 'save-name="prova"' in html # #122: la Prova segue l'immagine
assert "vPanDown('img', $event)" in html and "vWheel('dxf', $event)" in html for old in ('x-ref="vpImg"', 'x-ref="vpDxf"', "vZoomBy(", "vFit(", "vPanDown(", "onPanDown(", "zoomBy(", 'x-ref="viewport"'):
# #113: Alpine :style a stringa sostituisce l'attributo style: niente style statico sullo stesso tag assert old not in html, old # #122: zoom a mano tolto dal wizard
assert 'style="margin:auto;flex:none;"' not in html
assert html.count("margin:auto;flex:none;") == 2 and html.count(":style=\"'margin:auto;flex:none;") == 2
# #113: x-show e :style (display:flex) si pestano: x-ref="vpImg" non sta sul tag con x-show
tag = html[html.rindex("<div", 0, html.index('x-ref="vpImg"')):html.index('x-ref="vpImg"')]
assert "x-show" not in tag
assert "existing ? msgs.editLabel : msgs.createLabel" in html and "Modifica ricetta da DXF" in html # #105: etichetta dinamica 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,35 @@
"""Runs the plain-node image viewer (TmfViewerMath) test (tests/js/test_image_viewer.js).
Same thin pytest wrapper pattern as test_vision_overlay_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#122.
"""
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_viewer.js"
def test_image_viewer_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS del visualizzatore immagine 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_viewer.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -0,0 +1,35 @@
"""Runs the plain-node save-image helper test (tests/js/test_save_image.js).
Same thin pytest wrapper pattern as test_vision_overlay_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#119.
"""
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_save_image.js"
def test_save_image_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS del salvataggio immagine 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_save_image.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -0,0 +1,30 @@
"""#118: il riepilogo task mostra NaN per una quota non misurata (value None)."""
import pytest
@pytest.fixture(autouse=True)
def _station(monkeypatch):
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_task_complete_renders_nan_for_null_value(logged_in_client, mock_api_client):
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
{"id": 1, "marker_number": 1, "description": "quota mancante", "nominal": 1.0,
"unit": "mm"},
{"id": 2, "marker_number": 2, "description": "quota ok", "nominal": 2.0,
"unit": "mm"}]}],
None,
{"items": [
{"id": 1, "subtask_id": 1, "value": None, "deviation": None, "pass_fail": "fail",
"measured_at": "2026-10-06T08:00:00+00:00"},
{"id": 2, "subtask_id": 2, "value": 2.5, "pass_fail": "pass",
"measured_at": "2026-10-06T08:00:00+00:00"},
]},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
assert "NaN" in html
assert "2.500" in html # la quota misurata resta formattata
@@ -0,0 +1,97 @@
"""gitea:Adriano/TieMeasureFlow#118 #119 #120 #121 - pannello visione dell'operatore (task_execute.html).
Controlli sul testo del template: barra in alto con/senza camera, pulsante Salva,
conteggio quote e «Completato» dopo «Registra», cella NaN, svg al posto del canvas.
"""
import re
from pathlib import Path
TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "measure" / "task_execute.html"
SRC = TEMPLATE.read_text(encoding="utf-8")
def _panel():
start = SRC.rindex("<div", 0, SRC.index('x-data="visionExecute()"'))
return SRC[start:SRC.index("MEASUREMENT TIMER BANNER")]
def _bar(panel):
# barra in alto: dall'apertura fino al blocco di errore
return panel[:panel.index('<div x-show="error"')]
def test_file_input_only_without_camera():
# #119: l'input file «Acquisisci immagine/Riprova» sta nel ramo `not camera_enabled`
bar = _bar(_panel())
no_cam = bar[bar.index("{% if not camera_enabled %}"):bar.index("{% else %}")]
assert 'type="file"' in no_cam
cam = bar[bar.index("{% else %}"):]
assert 'type="file"' not in cam
assert bar.count('type="file"') == 1
def test_camera_bar_has_grab_and_record_only():
# #119: con camera «Acquisisci da camera» (grab) e «Registra»; in_linea resta Pausa/Riprendi
cam = _bar(_panel())
cam = cam[cam.index("{% else %}"):]
assert "{{ _('Acquisisci da camera') }}" in cam and '@click="grab()"' in cam
assert '@click="record()"' in cam and "{{ _('Registra') }}" in cam
assert "togglePause()" in cam and "{{ _('Pausa') }}" in cam and "{{ _('Riprendi') }}" in cam
assert "onPick(" not in cam
def test_save_button_is_tmf_viewer_with_texts():
# #119 #122: «Salva» e' del componente <tmf-viewer> (barra: Riduci, Ingrandisci, Adatta, Salva, %); il template gli passa nome e testi
panel = _panel()
assert "<tmf-viewer" in panel and ':src="shownImage"' in panel and ':save-name="saveName"' in panel
for t in ("Adatta", "Salva", "Ingrandisci", "Riduci", "Salva immagine"):
assert "{{ _('%s') }}" % t in panel, t
assert "saveImage()" not in panel and "zPct()" not in panel
assert "taskName:" in SRC
def test_viewer_scripts_order():
# #122: image_viewer.js dopo vision_overlay.js e save_image.js (e prima di vision_execute.js non serve: e' un Custom Element)
assert SRC.index("js/vision_overlay.js") < SRC.index("js/image_viewer.js")
assert SRC.index("js/save_image.js") < SRC.index("js/image_viewer.js")
def test_overlay_and_labels_passed_to_viewer():
# #121 #122: overlay e etichette arrivano al viewer con x-effect (proprieta'), niente canvas
panel = _panel()
assert "<canvas" not in panel
assert "$el.overlay = (result && result.overlay) || []" in panel and "$el.labels = viewerLabels" in panel
assert "overlayLabels" not in panel
def test_value_cell_shows_nan_for_null():
# #118: la cella passa da fmtValue (null -> NaN), non da Number(r.value).toFixed
panel = _panel()
assert 'fmtValue(r.value, r.unit)' in panel
assert "Number(r.value)" not in panel
def test_can_leave_task_depends_on_vision_recorded():
# #120: «Completato» e «Fine ciclo misura» richiedono visionRecorded per i task visione
m = re.search(r"get canLeaveTask\(\) \{(.*?)\n \},", SRC, re.S)
assert m and "isVisionTask && !this.visionRecorded" in m.group(1)
assert m.group(1).index("visionRecorded") < m.group(1).index("cycleConfirmed")
assert "visionRecorded: false" in SRC
def test_missing_label_says_record_the_piece():
# #120: non registrato -> «Registra il pezzo», non «Mancano N quote»
m = re.search(r"get missingQuotesLabel\(\) \{(.*?)\n \},", SRC, re.S)
assert m and "Registra il pezzo" in m.group(1)
assert m.group(1).index("Registra il pezzo") < m.group(1).index("Mancano")
def test_page_listens_to_vision_recorded_and_resets():
# #120: la radice ascolta l'evento; resetForNewCycle azzera visionRecorded; il ripristino mette deviation null
assert '@vision-recorded.window="onVisionRecorded($event.detail)"' in SRC
m = re.search(r"resetForNewCycle\(\) \{(.*?)\n \},", SRC, re.S)
assert m and "visionRecorded = false" in m.group(1)
m = re.search(r"onVisionRecorded\(detail\) \{(.*?)\n \},", SRC, re.S)
assert m and "visionRecorded = true" in m.group(1) and "this.measurements = " in m.group(1)
assert "deviation: m.value == null ? null" in SRC
assert "if (this.isVisionTask) this.visionRecorded = true" in SRC
@@ -0,0 +1,35 @@
"""Runs the plain-node vision overlay draw/anchor/labels test (tests/js/test_vision_overlay.js).
Same thin pytest wrapper pattern as test_vision_rows_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#116.
"""
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_vision_overlay.js"
def test_vision_overlay_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS dell'overlay 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_vision_overlay.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -1,7 +1,7 @@
"""gitea:Adriano/TieMeasureFlow#96 - l'immagine del task visione sta in fit, senza scorrimento. """gitea:Adriano/TieMeasureFlow#96 #122 - l'immagine del task visione sta in fit, senza scorrimento.
Controllo sulle classi del template: pannello overflow-hidden, img/canvas Controllo sulle classi del template: pannello overflow-hidden, riquadro flex-1 min-h-0 che contiene
object-contain sovrapposti, nessun h-auto sull'immagine (era la causa dello scorrimento). <tmf-viewer> a tutta altezza (#122: zoom, img e overlay stanno dentro il componente).
""" """
from pathlib import Path from pathlib import Path
@@ -37,13 +37,22 @@ def test_panel_does_not_scroll():
assert "overflow-auto" not in tag assert "overflow-auto" not in tag
def test_image_is_object_contain_without_h_auto(): def test_image_is_inside_tmf_viewer():
# #122: img, svg overlay, barra zoom e pan sono di <tmf-viewer>: il template non ha piu' img/svg/viewport propri
panel = _panel() panel = _panel()
img = _tag_with(panel, 'x-ref="img"') viewer = _tag_with(panel, "tmf-viewer class")
assert "object-contain" in img assert "block" in viewer and "w-full" in viewer and "h-full" in viewer
assert "h-auto" not in img assert ':src="shownImage"' in viewer
canvas = _tag_with(panel, 'x-ref="overlay"') assert "x-show" not in viewer # mai x-show e :style stringa sullo stesso elemento: x-show sta sul riquadro
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato assert 'x-ref="img"' not in panel and 'x-ref="overlay"' not in panel and "<canvas" not in panel
assert "<img" not in panel and "<svg" not in panel
def test_no_own_zoom_viewport_in_template():
# #122: niente piu' viewport/wrapper di zoom del pannello (#115): li fa il componente
panel = _panel()
assert 'x-ref="vp"' not in panel
assert "zSize()" not in panel and "zoomBy(" not in panel and "zFit()" not in panel
def test_image_box_and_outcome_column_can_shrink(): def test_image_box_and_outcome_column_can_shrink():
@@ -52,3 +61,21 @@ def test_image_box_and_outcome_column_can_shrink():
assert "flex-1" in box and "min-h-0" in box assert "flex-1" in box and "min-h-0" in box
outcome = _tag_with(panel, 'x-show="result">') outcome = _tag_with(panel, 'x-show="result">')
assert "overflow-y-auto" in outcome and "min-h-0" in outcome assert "overflow-y-auto" in outcome and "min-h-0" in outcome
def test_quote_table_shows_nominal_and_tolerances():
# #117: la tabella quote del pannello mostra nominale e tolleranze come la schermata dei calibri
panel = _panel()
for marker in ("fmtTol(r.nominal)", "fmtTol(r.ltl)", "fmtTol(r.utl)", "fmtTol(r.lwl)", "fmtTol(r.uwl)"):
assert marker in panel, marker
assert "text-measure-fail" in panel and "text-measure-warning" in panel
def test_overlay_script_loaded_before_vision_execute_and_viewer():
# #116 #122: vision_overlay.js prima di vision_execute.js; le etichette le disegna <tmf-viewer> (viewerLabels via x-effect)
src = TEMPLATE.read_text(encoding="utf-8")
assert src.index("js/vision_overlay.js") < src.index("js/vision_execute.js")
assert src.index("js/vision_overlay.js") < src.index("js/image_viewer.js")
panel = _panel()
assert "$el.labels = viewerLabels" in panel
assert 'x-for="l in overlayLabels"' not in panel
@@ -0,0 +1,35 @@
"""Runs the plain-node vision panel zoom test (tests/js/test_vision_zoom.js).
Same thin pytest wrapper pattern as test_vision_rows_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#115 #122 (dal #122 il test JS verifica che lo zoom non sia piu' nel componente).
"""
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_vision_zoom.js"
def test_vision_zoom_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS dello zoom del pannello 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_vision_zoom.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -0,0 +1,64 @@
"""Controllo del template del wizard DXF: le tre viste sono <tmf-viewer> (#122, prima #113 #119 #121).
Legge task_editor.html come testo (come test_vision_panel_fit.py), senza renderlo.
"""
import re
from pathlib import Path
TEMPLATE = Path(__file__).resolve().parent.parent / "templates" / "maker" / "task_editor.html"
def _src() -> str:
# senza commenti Jinja e HTML, che citano <tmf-viewer> e i vecchi nomi
text = TEMPLATE.read_text(encoding="utf-8")
text = re.sub(r"\{#.*?#\}", "", text, flags=re.S)
return re.sub(r"<!--.*?-->", "", text, flags=re.S)
def _tag_of(src: str, marker: str) -> str:
"""Tag di apertura <tmf-viewer ...> che contiene il marker."""
i = src.index(marker)
start = src.rfind("<tmf-viewer", 0, i)
return src[start:src.index(">", i) + 1]
def test_three_viewers_image_dxf_prova():
# #122: tre <tmf-viewer>: immagine (src), DXF (modo contenuto, con <svg>), Prova (fit-height)
src = _src()
assert src.count("<tmf-viewer") == 3
img = _tag_of(src, 'save-name="immagine"')
assert ':src="imagePreviewUrl"' in img
dxf = _tag_of(src, 'save-name="dxf"')
assert ":src" not in dxf and ":width=" in dxf and ":height=" in dxf
prova = _tag_of(src, 'save-name="prova"')
assert "fit-height" in prova and ':src="imageSrc"' in prova
assert "result.result.overlay" in prova # overlay delle forme del risultato
# il DXF contiene lo <svg> da disegnare, dentro il componente
i = src.index('save-name="dxf"')
assert src.index("<svg", i) < src.index("</tmf-viewer>", i)
assert "dxfPreviewD()" in src[i:src.index("</tmf-viewer>", i)]
def test_old_viewports_gone():
# #122: niente piu' viewport, barre e overlay scritti a mano
src = _src()
for old in ('x-ref="vpImg"', 'x-ref="vpDxf"', 'x-ref="overlay"', 'x-ref="viewport"', "tmfSaveImage(",
"vZoomBy", "vFit(", "zoomPct", "vPct("):
assert old not in src, old
def test_no_style_string_on_viewers():
# #122: un :style a stringa sul <tmf-viewer> cancella lo style statico; x-show non sta sul componente
src = _src()
for m in re.finditer(r"<tmf-viewer[^>]*>", src):
tag = m.group(0)
assert ":style" not in tag and "x-show" not in tag, tag
def test_scripts_loaded_before_wizard():
# #119 #121 #122: vision_overlay.js e save_image.js prima di image_viewer.js, che sta prima di dxf_recipe_wizard.js
src = _src()
viewer = src.index("js/image_viewer.js")
assert src.index("js/vision_overlay.js") < viewer
assert src.index("js/save_image.js") < viewer
assert viewer < src.index("js/dxf_recipe_wizard.js")
+3
View File
@@ -80,6 +80,9 @@ def _graph(dxf_sha: str, cal_sha: str, band_mm: float, min_points_pct: float, po
"band_plus": float(band_mm), "band_plus": float(band_mm),
"band_minus": float(band_mm), "band_minus": float(band_mm),
"nominal_unit": "mm", "nominal_unit": "mm",
# #127 (Adriano, 06/11/2026): LocateBlob normalizza l'angolo in (-90, 90], il pezzo a 180 o +-120 gradi
# verrebbe capovolto; "quarter" prova le pose ruotate (Core#343). Lo specchiato resta fuori.
"pose_search": "quarter",
# tolleranza di points_in_pct: nominale = limite, plus = 100 - limite # tolleranza di points_in_pct: nominale = limite, plus = 100 - limite
"points_in_pct_nominal": float(min_points_pct), "points_in_pct_nominal": float(min_points_pct),
"points_in_pct_plus": float(100.0 - min_points_pct), "points_in_pct_plus": float(100.0 - min_points_pct),
+69
View File
@@ -22,6 +22,7 @@ from src.vision.costruttori import (
DXF_FILE, DXF_FILE,
CostruttoreError, CostruttoreError,
_candidate_anchors, _candidate_anchors,
_graph,
_maschera_pezzo, _maschera_pezzo,
build_dxf_compare, build_dxf_compare,
) )
@@ -94,6 +95,74 @@ def test_ricetta_dxf_con_riferimento_gia_ruotato(tmp_path):
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0 assert risultato["outputs"]["dxf.points_in_pct"] > 95.0
# #127: pezzo a L (asimmetrico a 180 gradi: sample.dxf e' simmetrico e non distingue il
# capovolgimento). Un rettangolo con foro fuori centro NON basta: l'ICP di Core non lo risolve.
L_PROFILE_MM = [(-100, -50), (100, -50), (100, 50), (-20, 50), (-20, 10), (-100, 10)]
def _l_dxf(path: Path) -> bytes:
import ezdxf
doc = ezdxf.new()
doc.modelspace().add_lwpolyline(L_PROFILE_MM, close=True)
doc.saveas(str(path))
return path.read_bytes()
def _l_scene(angle_deg: float = 0.0) -> np.ndarray:
"""Il pezzo a L (scuro su bianco) a 4 px/mm; l'asse y del DXF e' verso l'alto."""
w, h = int(320 * PX_PER_MM), int(220 * PX_PER_MM)
cx, cy = w // 2, h // 2
img = np.full((h, w), 255, np.uint8)
pts = np.array([(cx + x * PX_PER_MM, cy - y * PX_PER_MM) for x, y in L_PROFILE_MM], np.int32)
cv2.fillPoly(img, [pts], 0)
if angle_deg:
m = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0)
img = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR, borderValue=255)
return img
def _pct_a_180(tmp_path, togli_quarter: bool) -> tuple[float, float]:
"""points_in_pct della ricetta costruita a 0 gradi: (sulla scena dritta, a 180 gradi)."""
risultato = build_dxf_compare(_l_scene(0.0), _l_dxf(tmp_path / "l.dxf"), PX_PER_MM, band_mm=2.0)
graph = risultato["graph"]
if togli_quarter: # misura di controllo: senza pose_search il pezzo a 180 va capovolto
for tool in graph["tools"]:
tool["values"].pop("pose_search", None)
for nome, b64 in risultato["files"].items():
(tmp_path / nome).write_bytes(base64.b64decode(b64))
def pct(img):
esito = run_graph(img, graph, calibration={"px_per_mm": PX_PER_MM}, recipe_dir=tmp_path)
assert not esito.failures, esito.failures
return esito.outputs["dxf.points_in_pct"]
return pct(_l_scene(0.0)), pct(_l_scene(180.0))
def test_ricetta_dxf_su_pezzo_capovolto_di_180(tmp_path):
# #127: con pose_search=quarter il pezzo a 180 gradi vale quanto quello dritto
pct_0, pct_180 = _pct_a_180(tmp_path, togli_quarter=False)
assert pct_0 >= 95.0
assert pct_180 >= 95.0 and abs(pct_180 - pct_0) <= 2.0
def test_ricetta_dxf_senza_quarter_il_pezzo_a_180_e_capovolto(tmp_path):
# #127: controllo del test sopra - senza pose_search il disegno finisce capovolto
_, pct_180 = _pct_a_180(tmp_path, togli_quarter=True)
assert pct_180 < 20.0 # misurato ~2,8% (Core#343 riporta 3,3% su PR165)
def test_graph_scrive_pose_search_quarter_nel_compare_dxf():
# #127: il CompareDxf porta pose_search=quarter solo se c'e' l'ancora
con = _graph("a" * 64, "b" * 64, 1.0, 95.0, "dark",
{"row": 0.0, "col": 0.0, "angle_deg": 0.0}, (1.0, 2.0, 3.0))
dxf_tool = next(t for t in con["tools"] if t["kind"] == "CompareDxf")
assert dxf_tool["values"]["pose_search"] == "quarter"
senza = _graph("a" * 64, "b" * 64, 1.0, 95.0, "dark",
{"row": 0.0, "col": 0.0, "angle_deg": 0.0}, None)
assert [t["kind"] for t in senza["tools"]] == ["LocateBlob"]
def test_immagine_senza_pezzo_solleva(): def test_immagine_senza_pezzo_solleva():
bianca = np.full((600, 800), 255, np.uint8) bianca = np.full((600, 800), 255, np.uint8)
with pytest.raises(CostruttoreError, match="pezzo"): with pytest.raises(CostruttoreError, match="pezzo"):
Generated
+34
View File
@@ -2464,6 +2464,7 @@ dependencies = [
{ name = "visionsuite" }, { name = "visionsuite" },
{ name = "vs-measure" }, { name = "vs-measure" },
{ name = "vs-task" }, { name = "vs-task" },
{ name = "zxing-cpp" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -2485,6 +2486,7 @@ requires-dist = [
{ name = "vs-pm2d", marker = "extra == 'locate'", editable = "vendor/visionsuite/packages/vs-pm2d" }, { name = "vs-pm2d", marker = "extra == 'locate'", editable = "vendor/visionsuite/packages/vs-pm2d" },
{ name = "vs-task", editable = "vendor/visionsuite/packages/vs-task" }, { name = "vs-task", editable = "vendor/visionsuite/packages/vs-task" },
{ name = "vs-tools", extras = ["locate", "dxf"], marker = "extra == 'dev'", editable = "vendor/visionsuite/packages/vs-tools" }, { name = "vs-tools", extras = ["locate", "dxf"], marker = "extra == 'dev'", editable = "vendor/visionsuite/packages/vs-tools" },
{ name = "zxing-cpp", specifier = ">=3.1,<4" },
] ]
provides-extras = ["locate", "dxf", "dev"] provides-extras = ["locate", "dxf", "dev"]
@@ -2787,3 +2789,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
] ]
[[package]]
name = "zxing-cpp"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/30/ad0e0352c593712ebb47143571ff11b130812e2852d7540e7c80cdf23340/zxing_cpp-3.1.1.tar.gz", hash = "sha256:1051a521b21a9fe206702ad4186aeb195154e3e1badcd99576d030723f36382b", size = 1437030, upload-time = "2026-07-29T08:50:59.019Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/c4/d64c1b751561eee75706def600041e4c72642403864ac6c52588fdb54bb3/zxing_cpp-3.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:9e558cf4d6d0dd0ae1199541bc8fd01e8fb67e18673faa7ca96e50440fdd6f93", size = 912350, upload-time = "2026-07-29T08:50:23.952Z" },
{ url = "https://files.pythonhosted.org/packages/01/1b/94067d5a5d324a30cd9862296171ec50cda58c9e31317eca53286aeab832/zxing_cpp-3.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ec41a833dc1697e5360b5d9e2620fab1f3e92892b890c31fe85b50a10ca05217", size = 865032, upload-time = "2026-07-29T08:50:25.304Z" },
{ url = "https://files.pythonhosted.org/packages/12/ee/4ab8cf9594959e1dc8f3c0e234d225fd1080cecc349c99cac4850005055a/zxing_cpp-3.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07ac611267b7220b769c182ae33473ee95aca1cc6c57e597755288b557848935", size = 1028402, upload-time = "2026-07-29T08:50:26.935Z" },
{ url = "https://files.pythonhosted.org/packages/12/83/5af471c7ad3fbb11d3efba64b41aba9f209d5dcc2945ca6b0afb29a9fed0/zxing_cpp-3.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a5b32d719a5448f1b2f474e04d2db6ce41cc6973fb5c705d47dbe899361e5f9", size = 1102966, upload-time = "2026-07-29T08:50:28.428Z" },
{ url = "https://files.pythonhosted.org/packages/dd/f4/8b75505b3b2110146769006a0087e1517675af057bb1eaa7709ef8dd507a/zxing_cpp-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:0343458a0fdf3f99c9dcff74dd09be6bae5d0d87a2f99de7876014317b939996", size = 1005571, upload-time = "2026-07-29T08:50:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/20/e8/05b134e41abda4bb3aca00ea2bc16898c9c3641afce5ad4637fd4b1166f0/zxing_cpp-3.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:73a26e6e7c5fa411bfd690c374e3d4a7fdcb41ca57a047839365b0c985e325ad", size = 933099, upload-time = "2026-07-29T08:50:31.309Z" },
{ url = "https://files.pythonhosted.org/packages/56/57/ac717270db6888973eba83e9832fe800808b555df0ebe34e37b6a6e07545/zxing_cpp-3.1.1-cp312-abi3-macosx_10_13_x86_64.whl", hash = "sha256:09dea611a7c9dc7c713a82303b15b733dc71abb1a77454b26b779e33671cef05", size = 911430, upload-time = "2026-07-29T08:50:32.625Z" },
{ url = "https://files.pythonhosted.org/packages/12/70/f14831dd92d5c844a39c03ebe9ba185e073d4467d50b48dcf2a816cae0c5/zxing_cpp-3.1.1-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:037cbcaeb0cb12497fc15ced23f6b778fce8a6a1d1bbffddbffd004c6225744d", size = 863740, upload-time = "2026-07-29T08:50:34.23Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f3/3fb2c6c48e6f58382fbbd31965c7caafd81f75b7e6707b011bdb940adb5f/zxing_cpp-3.1.1-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4dae01111f323f46736fc21f05c14dcaaac06cea5fdc8fd994ba19f6f918c6e", size = 1024253, upload-time = "2026-07-29T08:50:35.599Z" },
{ url = "https://files.pythonhosted.org/packages/0c/30/79683cf7139ee5325fbc68169eb8dc1cb2033ec43339b5f39de990f909a7/zxing_cpp-3.1.1-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cf67341949946307d086b302cefd453fb47bc6d6ddc7d088839e9481982757b", size = 1096795, upload-time = "2026-07-29T08:50:36.896Z" },
{ url = "https://files.pythonhosted.org/packages/7d/14/055c5a68a50bdde8378ced94e63f9ce340311e51c87b947f9b95fe69f51a/zxing_cpp-3.1.1-cp312-abi3-win_amd64.whl", hash = "sha256:29f98a91148171460b47a942d137ecc90c4b8097636f23cca65263a56bb025d3", size = 1003677, upload-time = "2026-07-29T08:50:38.328Z" },
{ url = "https://files.pythonhosted.org/packages/5d/32/a827a99fa5e0aee382b5d464cbd2075e1911a69500116705f6695a6accd8/zxing_cpp-3.1.1-cp312-abi3-win_arm64.whl", hash = "sha256:04a8f8b78779ab9b637853a0329770791cfc3095d232c768dc4824b63901ebd0", size = 931324, upload-time = "2026-07-29T08:50:39.632Z" },
{ url = "https://files.pythonhosted.org/packages/b0/30/e98ce9c56bd1f1fe0a1fd0e5c39202da49baa3620031cb80ac7a04759ffb/zxing_cpp-3.1.1-cp313-cp313t-macosx_10_15_x86_64.whl", hash = "sha256:9d291fd958c26066aca97c4a416a9f15475a99c97b253cd4d2c6754a485b01e6", size = 915582, upload-time = "2026-07-29T08:50:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/3d/d8/ab1db4571348e8756c2019425c72b3cb936f72c4a7c2af35687396381c36/zxing_cpp-3.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:670e2946232128b1ebba5b1f623e016ac8f8ad743ae3a0fb2e50b33180f216a2", size = 867699, upload-time = "2026-07-29T08:50:42.815Z" },
{ url = "https://files.pythonhosted.org/packages/6a/09/78a038367fd3d4fc00fa1f696672bfff002b4771814c3b20b1c392872043/zxing_cpp-3.1.1-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efc7ed301846a8c060720f09bed8a29fefccef54b5106c291e4136ffe87d089", size = 1030204, upload-time = "2026-07-29T08:50:44.356Z" },
{ url = "https://files.pythonhosted.org/packages/90/7b/0fc91d2d0463164268d06dd3e9b97520f9fe5c79dc6a954c92cd9ac92fbf/zxing_cpp-3.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f37e714ad4fd0ae4dd759b19fef25bd524a2865bc3ca8730b4e318c0cc7800e", size = 1104920, upload-time = "2026-07-29T08:50:45.639Z" },
{ url = "https://files.pythonhosted.org/packages/3b/9d/2adb3c88894b1e018739aae9bd2725733b55c14e3df090a0e01b2bffff14/zxing_cpp-3.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:93918148c1ed7ec60ff172b183ddc9dddfcb59e40867b0e98d79cc2d62a2b41d", size = 1009395, upload-time = "2026-07-29T08:50:47.118Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/c7c93c2123701c12cda01ef02662ff010a79d31e86f67e9080d10d19013b/zxing_cpp-3.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:68b8cbd6797228eb983ab616b876cc744db319c64a9491a4806324afd04a8c48", size = 934267, upload-time = "2026-07-29T08:50:48.463Z" },
{ url = "https://files.pythonhosted.org/packages/d2/a8/8c005a5251734f57a30f1e85fa2a8965d53cd0df99d1abf642956153410e/zxing_cpp-3.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b4bd34f71868af0e34b000da4fc885c85a7f0ef37eecc0ec433ff27b263a5b7", size = 915637, upload-time = "2026-07-29T08:50:50.129Z" },
{ url = "https://files.pythonhosted.org/packages/5d/31/a2e693c9771b88e45dd7e52b56c85c169649123cf0eebfb32151efdfb356/zxing_cpp-3.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:94e342d390933b9678f71bf6005cf2125cdb27c2355c21fa194e3a672502aac6", size = 867750, upload-time = "2026-07-29T08:50:51.788Z" },
{ url = "https://files.pythonhosted.org/packages/f0/30/d2f7e626b4216bbb47783d7431cd27b151cfe5abeb22aa06f0b130095841/zxing_cpp-3.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71df8523deb2fb40b834238e6fa739e210e3a6e27c5b94a99b4106c08e339b9b", size = 1030274, upload-time = "2026-07-29T08:50:53.535Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b9/c4b6db45a3a9f7e34a3faadcce78c2084f0bc2ce0ee8344d61f1149d2318/zxing_cpp-3.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:388626ac8df24f63c2bb17dcd42fd21daeeea6fd6759bd9b1c064b71142da07e", size = 1104873, upload-time = "2026-07-29T08:50:54.941Z" },
{ url = "https://files.pythonhosted.org/packages/c8/8e/8dbf8fcf4d466c7d9b5023ae4cf17da22f328efabd4d7107109ac7737155/zxing_cpp-3.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fe8172f3c9b17f8fd40fba2ae0ba9728228caeff3587eabf5d99888891d02e62", size = 1032030, upload-time = "2026-07-29T08:50:56.169Z" },
{ url = "https://files.pythonhosted.org/packages/47/38/e547ea4f9a7c8c24a1d3a59869540029e4bad467f9544084c3cee94eb6e0/zxing_cpp-3.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:1992231c161c3eaf5857f7bc35193b8ca621eba0debc51e752403657b88d542a", size = 956213, upload-time = "2026-07-29T08:50:57.524Z" },
]