Compare commits
40 Commits
4485803122
..
V3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dd4b0a20c | |||
| 271ce73d09 | |||
| 1bd6c6b011 | |||
| d8bca535ac | |||
| 14257304f8 | |||
| 5738920c9c | |||
| 452862e0f3 | |||
| 999f47092d | |||
| 6441a92062 | |||
| 8c361c14ae | |||
| 5897a9e5df | |||
| f44b578d5b | |||
| c5be9a2bb4 | |||
| 17ea010f7e | |||
| 1d6d74b2e4 | |||
| 2f0a5aaf1e | |||
| e96fbb73c2 | |||
| f3bacc8238 | |||
| e743363a62 | |||
| d48a8a1076 | |||
| 8ef61a1285 | |||
| 84fc1a08f7 | |||
| c904800e94 | |||
| 20c0a47c00 | |||
| 269bbc03ca | |||
| 815d49d699 | |||
| 43ef22079d | |||
| b01bba536a | |||
| 17344aae13 | |||
| fd3384f08c | |||
| 08aefa9888 | |||
| 06f308be6f | |||
| 93afd6187c | |||
| 8217f46d47 | |||
| d4802fd1a3 | |||
| ea64f3c524 | |||
| 58baed19e2 | |||
| 25b0741003 | |||
| df67a32a82 | |||
| 5723f055c4 |
@@ -76,3 +76,6 @@ docker-compose.override.yml
|
||||
|
||||
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
|
||||
packaging/windows/_strumenti.py
|
||||
|
||||
# #109: scritto da packaging/windows/sviluppo.ps1 (sha del submodule), non va versionato
|
||||
/vision-engine-version.txt
|
||||
|
||||
@@ -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`.
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
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):
|
||||
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;
|
||||
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).
|
||||
#>
|
||||
param(
|
||||
@@ -47,6 +48,24 @@ if (-not (Test-Path (Join-Path $gtk '*.dll'))) {
|
||||
}
|
||||
$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
|
||||
Set-Location $radice
|
||||
& $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox
|
||||
|
||||
@@ -124,6 +124,16 @@ def test_variabili_del_contratto(avvio, tmp_path):
|
||||
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):
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
primo = avvio.carica_segreti(dati)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
con la casella, «Calibrata» (/camera/anteprima/calibrata) affiancati; il ciclo anteprima
|
||||
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>
|
||||
@@ -66,9 +68,7 @@ PAGINA = r"""<!doctype html>
|
||||
/* anteprima */
|
||||
.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; }
|
||||
.cornice { position: relative; display: inline-block; line-height: 0; max-width: 100%; }
|
||||
.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 */
|
||||
.mirino { position: absolute; inset: 0; pointer-events: none; /* #92: classe, vale per i due riquadri; #122: sta dentro lo stage del tmf-viewer */
|
||||
background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
|
||||
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; }
|
||||
#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 span { font-weight: 400; font-size: .78rem; color: var(--secondario); margin-left: 6px; }
|
||||
.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 */
|
||||
.visore { position: relative; margin-top: 10px; }
|
||||
.riquadro .visore { margin-top: 4px; }
|
||||
.visore > .palco { margin-top: 0; }
|
||||
.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; }
|
||||
/* #122: i visori sono tmf-viewer (static/js/image_viewer.js servito su /visore/): barra, zoom e pan vengono dal componente.
|
||||
Altezza data dalla pagina (come l'altezza massima che avevano le immagini); [hidden] vale gia' sopra */
|
||||
.palco tmf-viewer { width: 100%; height: max(340px, calc(100vh - 330px)); }
|
||||
.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) */
|
||||
.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 select { width: 100%; }
|
||||
@@ -128,7 +117,7 @@ PAGINA = r"""<!doctype html>
|
||||
@media (max-width: 900px) {
|
||||
.griglia { grid-template-columns: minmax(0, 1fr); }
|
||||
.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) */
|
||||
main { padding: 8px; }
|
||||
@@ -179,28 +168,18 @@ PAGINA = r"""<!doctype html>
|
||||
<div class="riquadri" id="riquadri">
|
||||
<div class="riquadro">
|
||||
<div class="titolo">Immagine<span>grezza, senza correzione</span></div>
|
||||
<div class="visore"> <!-- #111 -->
|
||||
<div class="palco">
|
||||
<div class="palco"> <!-- #122: tmf-viewer al posto del visore #111; width/height = risoluzione della camera, messe da tornaAdatta() -->
|
||||
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
|
||||
<div class="cornice" id="cornice" hidden>
|
||||
<img id="immagine" alt="Anteprima grezza della sorgente">
|
||||
<div id="mirino" class="mirino" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="barraZoom"><button id="zoomMeno" class="secondario" type="button" title="Riduci">−</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>
|
||||
<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 -->
|
||||
<div id="mirino" class="mirino" hidden></div> <!-- #122: spostato nello stage del componente da creaVisore() -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="riquadro" id="riquadroCal" hidden>
|
||||
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
|
||||
<div class="visore"> <!-- #111 -->
|
||||
<div class="palco">
|
||||
<div class="palco"> <!-- #122 -->
|
||||
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
|
||||
<div class="cornice" id="corniceCal" hidden>
|
||||
<img id="immagineCal" alt="Anteprima della sorgente raddrizzata dalla calibrazione">
|
||||
<div id="mirinoCal" class="mirino" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="barraZoom"><button id="zoomMenoCal" class="secondario" type="button" title="Riduci">−</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>
|
||||
<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 -->
|
||||
<div id="mirinoCal" class="mirino" hidden></div>
|
||||
</div>
|
||||
<!-- #93: scelta della calibrazione da attivare sulla sorgente, con CONFERMA -->
|
||||
<div class="riga scelta-cal">
|
||||
@@ -260,14 +239,9 @@ PAGINA = r"""<!doctype html>
|
||||
<h2>Calibrazione</h2>
|
||||
<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 -->
|
||||
<div class="visore" style="margin-top:0"> <!-- #111 -->
|
||||
<div class="palco" style="margin-top:0">
|
||||
<div class="palco" style="margin-top:0"> <!-- #122 -->
|
||||
<div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
|
||||
<div class="cornice" id="corniceCalTab" hidden>
|
||||
<img id="immagineCalTab" alt="Immagine grezza della sorgente">
|
||||
</div>
|
||||
</div>
|
||||
<div class="barraZoom"><button id="zoomMenoCalTab" class="secondario" type="button" title="Riduci">−</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>
|
||||
<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 -->
|
||||
</div>
|
||||
<div class="riga" style="margin-top:8px">
|
||||
<button id="scattaCal" class="secondario" type="button">Scatta</button>
|
||||
@@ -294,7 +268,7 @@ PAGINA = r"""<!doctype html>
|
||||
<button id="salva">Salva</button>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -323,6 +297,10 @@ PAGINA = r"""<!doctype html>
|
||||
</div>
|
||||
</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>
|
||||
"use strict";
|
||||
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
|
||||
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
|
||||
// z = pixel CSS per pixel di camera (100 % = pixel della camera), limiti [zFit, ZMAX]; le dimensioni del frame pieno sono A.ris
|
||||
var ZMAX = 8, PASSO = 1.25;
|
||||
// ---- #122: visori = tmf-viewer (zoom, Adatta, Salva, pan e Ctrl+rotella vengono dal componente, come nel resto di TMF)
|
||||
// width/height del componente = risoluzione del frame pieno (A.ris): 100 % = pixel della camera, anche se l'anteprima scaricata e' piu' piccola
|
||||
var V = {};
|
||||
var sondaAlt = null;
|
||||
// #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
|
||||
// #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
|
||||
function tornaAdatta() {
|
||||
Object.keys(V).forEach(function (k) {
|
||||
var v = V[k];
|
||||
v.adatta = true;
|
||||
v.z = 0;
|
||||
v.palco.classList.remove("zoomata", "panning");
|
||||
v.palco.style.height = v.cornice.style.width = v.cornice.style.height = "";
|
||||
v.palco.scrollLeft = v.palco.scrollTop = 0;
|
||||
applica(v);
|
||||
var el = V[k].el;
|
||||
if (A.ris) { el.setAttribute("width", A.ris.w); el.setAttribute("height", A.ris.h); }
|
||||
else { el.removeAttribute("width"); el.removeAttribute("height"); }
|
||||
if (el.fit) el.fit();
|
||||
});
|
||||
}
|
||||
// #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() {
|
||||
var zm = 0;
|
||||
["acq", "cal"].forEach(function (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;
|
||||
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) {
|
||||
var cornice = $(idCornice);
|
||||
var v = { cornice: cornice, img: $(idImg), palco: cornice.parentNode, pct: $("zoomPct" + suf), z: 0, adatta: true, pan: null };
|
||||
function creaVisore(chiave, idViewer, idMirino) {
|
||||
var el = $(idViewer);
|
||||
var v = { el: el, z: 0, fit: 0 };
|
||||
V[chiave] = v;
|
||||
$("zoomMeno" + suf).onclick = function () { zoomBy(v, 1 / PASSO); };
|
||||
$("zoomPiu" + suf).onclick = function () { zoomBy(v, PASSO); };
|
||||
$("zoomAdatta" + suf).onclick = function () { adattaVisore(v); };
|
||||
// #111: Ctrl+rotella = zoom; senza Ctrl la rotella scorre la pagina
|
||||
v.palco.addEventListener("wheel", function (ev) {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
zoomBy(v, ev.deltaY < 0 ? PASSO : 1 / PASSO);
|
||||
}, { 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); });
|
||||
el.addEventListener("viewer-zoom", function (ev) { v.z = ev.detail.zoom; v.fit = ev.detail.fit; });
|
||||
// #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
|
||||
var monta = function () {
|
||||
var stage = el.querySelector(".tmfv-stage"), m = idMirino ? $(idMirino) : null;
|
||||
if (stage && m && m.parentNode !== stage) stage.appendChild(m);
|
||||
};
|
||||
monta();
|
||||
el.addEventListener("viewer-load", monta);
|
||||
}
|
||||
creaVisore("acq", "", "cornice", "immagine");
|
||||
creaVisore("cal", "Cal", "corniceCal", "immagineCal");
|
||||
creaVisore("tab", "CalTab", "corniceCalTab", "immagineCalTab");
|
||||
creaVisore("acq", "immagine", "mirino");
|
||||
creaVisore("cal", "immagineCal", "mirinoCal");
|
||||
creaVisore("tab", "immagineCalTab", "");
|
||||
|
||||
function azzeraIndicatori() {
|
||||
A.nitMax = 0;
|
||||
@@ -653,17 +545,17 @@ function pulsantiAnteprima() {
|
||||
function mostraImmagine(blob) {
|
||||
var vecchio = A.url;
|
||||
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
|
||||
$("cornice").hidden = false;
|
||||
$("immagine").hidden = false;
|
||||
$("vuoto").hidden = true;
|
||||
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)
|
||||
function mostraInTabCal(url) {
|
||||
$("immagineCalTab").src = url || "";
|
||||
$("corniceCalTab").hidden = !url;
|
||||
if (url) $("immagineCalTab").setAttribute("src", url); else $("immagineCalTab").removeAttribute("src"); // #122
|
||||
$("immagineCalTab").hidden = !url;
|
||||
$("vuotoCalTab").hidden = !!url;
|
||||
}
|
||||
|
||||
@@ -671,14 +563,14 @@ function mostraInTabCal(url) {
|
||||
function mostraCalibrata(blob, nome, soloScala) {
|
||||
var vecchio = A.urlCal;
|
||||
A.urlCal = URL.createObjectURL(blob);
|
||||
$("immagineCal").src = A.urlCal;
|
||||
$("immagineCal").setAttribute("src", A.urlCal); // #122
|
||||
if (vecchio) URL.revokeObjectURL(vecchio);
|
||||
$("corniceCal").hidden = false;
|
||||
$("immagineCal").hidden = false;
|
||||
$("vuotoCal").hidden = true;
|
||||
$("nomeCalibrata").textContent = (nome ? "«" + nome + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo
|
||||
}
|
||||
function messaggioCalibrata(testo) {
|
||||
$("corniceCal").hidden = true;
|
||||
$("immagineCal").hidden = true;
|
||||
$("vuotoCal").hidden = false;
|
||||
$("vuotoCal").textContent = testo;
|
||||
$("nomeCalibrata").textContent = "";
|
||||
@@ -686,7 +578,7 @@ function messaggioCalibrata(testo) {
|
||||
// #92: cambio sorgente: via i due frame, nessuna immagine di un'altra sorgente
|
||||
function svuotaRiquadri() {
|
||||
A.seqCal++; // una risposta calibrata ancora in volo non vale piu'
|
||||
$("cornice").hidden = true;
|
||||
$("immagine").hidden = true;
|
||||
$("vuoto").hidden = false;
|
||||
mostraInTabCal(null); // #94: svuotato anche il riquadro della tab Calibrazione
|
||||
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);
|
||||
$("bloccaImg").checked = false;
|
||||
$("scattaCal").disabled = $("scatta").disabled;
|
||||
mostraInTabCal($("cornice").hidden ? null : A.url);
|
||||
mostraInTabCal($("immagine").hidden ? null : A.url);
|
||||
}
|
||||
function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
|
||||
var s = A.sorgenteBloccata;
|
||||
@@ -843,7 +735,7 @@ function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
|
||||
$("bloccaImg").onchange = async function () {
|
||||
if (!this.checked) { rilasciaBlocco(); return; }
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente || $("cornice").hidden) {
|
||||
if (!sorgente || $("immagine").hidden) {
|
||||
$("bloccaImg").checked = false;
|
||||
dire("msgBlocco", "Nessuna immagine da bloccare: «Scatta» o avviare l'anteprima.", "errore");
|
||||
return;
|
||||
@@ -1514,7 +1406,7 @@ async function calcola() {
|
||||
var d = await r.json();
|
||||
ultimo = { dati: 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);
|
||||
$("origineCalcolo").textContent = "Origine: " + (d.bloccata ? "immagine bloccata — " : "") + descriviOrigine(d.origine); // #94
|
||||
$("risultato").hidden = false;
|
||||
|
||||
@@ -45,6 +45,7 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
|
||||
la libreria resta)
|
||||
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
|
||||
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:
|
||||
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
|
||||
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
|
||||
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",)
|
||||
_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
|
||||
@@ -780,6 +787,17 @@ def create_app(folder: Path | str | None = None,
|
||||
def calibrazione_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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,80 +1,87 @@
|
||||
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
def test_titolo_e_stile_scuro():
|
||||
assert "<title>Acquisizione camera</title>" in PAGINA
|
||||
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
|
||||
|
||||
|
||||
def test_rotte_usate():
|
||||
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
|
||||
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
|
||||
|
||||
def test_offline_e_senza_finestre_del_browser():
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
def test_id_principali():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
|
||||
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
|
||||
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
|
||||
assert attesi <= ids, attesi - ids
|
||||
# ogni id usato dal JS con $("...") esiste nel markup
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
assert usati <= ids, usati - ids
|
||||
|
||||
|
||||
def test_sezioni_e_testi_italiani():
|
||||
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
|
||||
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
|
||||
"Attiva calibrazione"): # #92 #93
|
||||
assert testo in PAGINA, testo
|
||||
assert "it-IT" in PAGINA # numeri con la virgola
|
||||
assert "Choose File" not in PAGINA
|
||||
|
||||
|
||||
def test_localstorage_protetto():
|
||||
# ogni uso di localStorage sta dentro un try
|
||||
for m in re.finditer(r"localStorage[.]", PAGINA):
|
||||
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
|
||||
|
||||
|
||||
def test_specchiatura_per_sorgente(): # #110
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
assert {"specchiaO", "specchiaV", "msgSpecchia"} <= ids
|
||||
assert '"/camera/orientamento' in PAGINA
|
||||
assert "Specchia orizzontale" in PAGINA and "Specchia verticale" in PAGINA
|
||||
|
||||
|
||||
def test_zoom_visori(): # #111
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
for suf in ("", "Cal", "CalTab"): # tre riquadri: grezza, calibrata, tab Calibrazione
|
||||
for base in ("zoomMeno", "zoomAdatta", "zoomPiu", "zoomPct"):
|
||||
assert base + suf in ids, base + suf
|
||||
assert "zoomata" in PAGINA
|
||||
assert "latoAnteprima()" in PAGINA and "unaAnteprima(1280)" not in PAGINA # lato_max segue lo zoom
|
||||
# la barra e' sorella del palco che scorre (non dentro): resta ferma durante lo scorrimento
|
||||
assert PAGINA.count('<div class="visore"') == 3
|
||||
assert PAGINA.count('<div class="barraZoom">') == 3
|
||||
|
||||
|
||||
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)
|
||||
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
|
||||
|
||||
import importlib.util
|
||||
|
||||
import re
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
|
||||
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_titolo_e_stile_scuro():
|
||||
|
||||
assert "<title>Acquisizione camera</title>" in PAGINA
|
||||
|
||||
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_rotte_usate():
|
||||
|
||||
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
|
||||
|
||||
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
|
||||
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_offline_e_senza_finestre_del_browser():
|
||||
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_id_principali():
|
||||
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
|
||||
|
||||
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||
|
||||
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
|
||||
|
||||
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
|
||||
|
||||
assert attesi <= ids, attesi - ids
|
||||
|
||||
# ogni id usato dal JS con $("...") esiste nel markup
|
||||
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
|
||||
assert usati <= ids, usati - ids
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_sezioni_e_testi_italiani():
|
||||
|
||||
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
|
||||
|
||||
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
|
||||
|
||||
@@ -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
|
||||
@@ -357,9 +357,13 @@ async def export_measurements_csv(
|
||||
# Rows
|
||||
for m in measurements:
|
||||
# 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 = (
|
||||
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
|
||||
else ""
|
||||
)
|
||||
|
||||
@@ -146,7 +146,8 @@ async def get_capability(
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
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)
|
||||
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,
|
||||
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]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
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,
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -162,7 +162,10 @@ async def execute(
|
||||
engine_version=result.engine_version,
|
||||
measurements=[
|
||||
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
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -43,7 +43,7 @@ class MeasurementResponse(BaseModel):
|
||||
subtask_id: int
|
||||
version_id: int
|
||||
measured_by: int
|
||||
value: float
|
||||
value: Optional[float] = None # #118: null = quota non prodotta dalla visione (NaN)
|
||||
pass_fail: str
|
||||
deviation: Optional[float] = None
|
||||
lot_number: Optional[str] = None
|
||||
@@ -108,7 +108,7 @@ class CycleMeasurementResponse(BaseModel):
|
||||
"""One quote already measured in the cycle under way."""
|
||||
|
||||
subtask_id: int
|
||||
value: float
|
||||
value: Optional[float] = None # #118: null = NaN
|
||||
pass_fail: str
|
||||
measured_at: UtcDateTime
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class MonitorSubtask(BaseModel):
|
||||
description: str
|
||||
unit: str
|
||||
count: int
|
||||
mean: float
|
||||
mean: Optional[float] = None # #118: None = solo quote NaN
|
||||
ltl: Optional[float] = None
|
||||
utl: Optional[float] = None
|
||||
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
||||
|
||||
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict
|
||||
class VisionMeasurementResult(BaseModel):
|
||||
"""One quote's outcome, as much of it as the client needs to show."""
|
||||
subtask_id: int
|
||||
value: float
|
||||
value: float | None # #118: null = quota non prodotta dal grafo (NaN), pass_fail "fail"
|
||||
pass_fail: str
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ class Measurement(Base):
|
||||
)
|
||||
|
||||
# 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(
|
||||
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
|
||||
)
|
||||
|
||||
@@ -302,7 +302,7 @@ async def current_cycle_measurements(
|
||||
return [
|
||||
{
|
||||
"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,
|
||||
"measured_at": m.measured_at,
|
||||
}
|
||||
@@ -355,7 +355,7 @@ async def save_measurement(
|
||||
subtask_id: int,
|
||||
version_id: int,
|
||||
measured_by: int,
|
||||
value: float,
|
||||
value: float | None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
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):
|
||||
the gate models "moving on" between measurements taken one after another,
|
||||
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
|
||||
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
|
||||
# above was computed on - calculate_pass_fail rounds internally too, but
|
||||
# matching it here keeps the persisted row from disagreeing with itself.
|
||||
value = round(value, 6)
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
if value is None:
|
||||
# #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(
|
||||
subtask_id=subtask_id,
|
||||
|
||||
@@ -96,14 +96,21 @@ async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
|
||||
per_sub[row.subtask_id].append(row)
|
||||
subtasks = []
|
||||
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]
|
||||
subtasks.append(MonitorSubtask(
|
||||
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,
|
||||
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)
|
||||
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 "",
|
||||
neutralise_csv_cell(subtask.description) if subtask else "",
|
||||
number(subtask.nominal) if subtask else "",
|
||||
number(measurement.value),
|
||||
number(measurement.deviation),
|
||||
number(measurement.value) if measurement.value is not None else "NaN", # #118
|
||||
"NaN" if measurement.value is None else number(measurement.deviation), # #124: quota NaN -> deviazione NaN
|
||||
measurement.pass_fail,
|
||||
number(subtask.ltl) if subtask else "",
|
||||
number(subtask.lwl) if subtask else "",
|
||||
|
||||
@@ -318,8 +318,10 @@ async def generate_spc_report(
|
||||
|
||||
# 2. Compute SPC data
|
||||
pass_fail_values = [m.pass_fail for m in measurements]
|
||||
values = [float(m.value) for m in measurements]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
# #118: le quote NaN (value None) contano nell'esito ma non nei calcoli
|
||||
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)
|
||||
|
||||
@@ -426,7 +428,7 @@ async def generate_measurement_report(
|
||||
rows.append({
|
||||
"num": i,
|
||||
"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"),
|
||||
"pass_fail": m.pass_fail,
|
||||
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
||||
|
||||
@@ -693,23 +693,13 @@ async def execute_task(
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
|
||||
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||
# than a piece not measured at all. A quote counts as produced only if its
|
||||
# output is present *and* a finite, non-bool number: the in-process runner
|
||||
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
|
||||
# is reached over HTTP - a different implementation of it, or a malformed
|
||||
# response, could still hand back None or a non-finite float for a mapped
|
||||
# key instead of omitting it outright.
|
||||
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 "")
|
||||
),
|
||||
)
|
||||
# #118: una quota che il grafo non produce non blocca piu' l'esecuzione
|
||||
# (prima 422 "the graph did not produce"): viene salvata con value NULL,
|
||||
# mostrata NaN, sempre `fail`; i motivi restano nei `failures` del risultato.
|
||||
# Una quota conta come prodotta solo se l'uscita c'e' *ed* e' un numero
|
||||
# finito non-bool: il worker risponde via HTTP, un'altra implementazione o
|
||||
# una risposta malformata potrebbe dare None o un non finito per una chiave
|
||||
# mappata invece di ometterla.
|
||||
|
||||
overlay = report.get("overlay") or []
|
||||
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,
|
||||
version_id=version_id,
|
||||
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,
|
||||
serial_number=serial_number,
|
||||
input_method="camera",
|
||||
@@ -762,8 +756,9 @@ async def check_task(
|
||||
VisionResult, niente file, niente misure. L'esito per quota usa
|
||||
calculate_pass_fail sulle quote con vision_output, nell'ordine di
|
||||
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
|
||||
`failures`: un'anteprima non deve dare 422 a ogni frame.
|
||||
ancora in posizione) entrano in `measurements` con `value` None (NaN) e
|
||||
`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:
|
||||
raise HTTPException(
|
||||
@@ -809,6 +804,7 @@ async def check_task(
|
||||
**worker_kwargs,
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
# #118: una voce per ogni quota, in ordine di marker_number; non prodotta -> NaN/fail
|
||||
measurements = [
|
||||
{
|
||||
"subtask_id": q.id,
|
||||
@@ -816,8 +812,10 @@ async def check_task(
|
||||
"pass_fail": measurement_service.calculate_pass_fail(
|
||||
float(outputs[q.vision_output]), q,
|
||||
)[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 {
|
||||
"outcome": report.get("outcome"),
|
||||
|
||||
@@ -360,3 +360,74 @@ class TestTaskAcquisitionMigration:
|
||||
assert "camera_url" in cols("stations") # la 020 resta
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
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()
|
||||
|
||||
@@ -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):
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -836,3 +836,31 @@ async def test_close_without_measurements_writes_no_file(
|
||||
)).json()
|
||||
assert closed["statistics_path"] is None
|
||||
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"
|
||||
|
||||
@@ -244,7 +244,7 @@ async def test_check_calibration_reasons(
|
||||
|
||||
|
||||
@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,
|
||||
):
|
||||
_, 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)
|
||||
resp = await _check(client, measurement_tec_user, task)
|
||||
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"}]
|
||||
|
||||
|
||||
|
||||
@@ -134,72 +134,134 @@ async def test_the_execution_records_which_engine_produced_it(
|
||||
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
|
||||
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,
|
||||
):
|
||||
"""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)
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
subtask.vision_output = "t1.diameter"
|
||||
await db_session.commit()
|
||||
worker_says({"t1.radius": 5.0})
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({"t1.radius": GOOD}, failures=[{"tool_name": "arco_tr"}])
|
||||
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||
|
||||
|
||||
@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(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
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 "t1.diameter" in response.json()["detail"]
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
assert response.status_code == 200
|
||||
assert [m["value"] for m in response.json()["measurements"]] == [None, None]
|
||||
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
|
||||
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,
|
||||
):
|
||||
"""A worker answering with the key present but null (not omitted) must not
|
||||
reach float(None) as a bare TypeError - it is "not produced", same as a
|
||||
missing key (#16)."""
|
||||
"""Chiave presente ma null (non omessa): NaN, non TypeError (#16, #118)."""
|
||||
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.diameter": None, "t1.radius": GOOD})
|
||||
|
||||
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() == []
|
||||
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||
|
||||
|
||||
@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,
|
||||
):
|
||||
"""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)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
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",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
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() == []
|
||||
|
||||
|
||||
|
||||
@@ -440,7 +440,8 @@ def task_complete(recipe_id: int):
|
||||
m["subtask"] = st
|
||||
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
|
||||
# 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:
|
||||
m["deviation"] = m["value"] - st["nominal"]
|
||||
except (TypeError, KeyError):
|
||||
|
||||
@@ -84,16 +84,18 @@ function csvExport() {
|
||||
const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl)
|
||||
? 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 = [
|
||||
m.id || '',
|
||||
m.subtask_id || '',
|
||||
this.escapeCsvValue(subtask.description || ''),
|
||||
this.formatNumber(m.value),
|
||||
notMeasured ? 'NaN' : this.formatNumber(m.value),
|
||||
this.escapeCsvValue(subtask.unit || 'mm'),
|
||||
this.formatNumber(nominal),
|
||||
this.formatNumber(tolerancePlus),
|
||||
this.formatNumber(toleranceMinus),
|
||||
this.formatNumber(m.deviation),
|
||||
notMeasured ? 'NaN' : this.formatNumber(m.deviation),
|
||||
m.pass_fail || '',
|
||||
this.escapeCsvValue(m.lot_number || ''),
|
||||
this.escapeCsvValue(m.serial_number || ''),
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
// 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.
|
||||
function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
// #121: i colori per ruolo stanno in VisionOverlay (vision_overlay.js)
|
||||
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'];
|
||||
|
||||
@@ -40,11 +39,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
|
||||
imageSource: '', // #104: '' | 'file' | 'camera'; il campo mm/px vale solo per 'file'
|
||||
tested: false, // #104: true dopo una Prova (anche fallita): mostra la sezione del risultato
|
||||
zoom: 1, // #104: scala dell'immagine di prova (1 = pixel nativi)
|
||||
fitZoom: 1, // #104: zoom di Adatta (sotto o pari a questo il viewport segue l'immagine)
|
||||
panning: false, // #104: trascinamento in corso
|
||||
_pan: null, // #104: {x, y, left, top} al pointerdown
|
||||
resPx: null, // #104: {w, h} in pixel dell'immagine di prova (per la dimensione del wrapper)
|
||||
// #122: zoom, Adatta e pan delle tre viste (immagine, DXF, Prova) ora stanno nel componente <tmf-viewer>
|
||||
runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione)
|
||||
runBandMm: null, // #104: banda usata nell'ultima Prova
|
||||
runMinPointsPct: null, // #104: soglia usata nell'ultima Prova
|
||||
@@ -57,11 +52,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
_srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta
|
||||
_dxfPickSeq: 0, // #105: sale a ogni scelta manuale del DXF; i file della ricetta arrivati dopo si scartano
|
||||
_imgPickSeq: 0, // #105: idem per l'immagine (_setImage)
|
||||
// #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)
|
||||
init() {
|
||||
@@ -205,25 +195,22 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
this.imageSrc = file ? URL.createObjectURL(file) : '';
|
||||
this.imagePx = null;
|
||||
this.imageBroken = false;
|
||||
this.vReset('img'); // #113: nuova immagine, vista da rifare
|
||||
this.result = null;
|
||||
this.tested = false; // #104
|
||||
this.resPx = null; // #104
|
||||
this.imageSource = file ? (source || '') : ''; // #104
|
||||
this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo
|
||||
},
|
||||
|
||||
// #102: l'<img> dell'anteprima e' caricata: legge i pixel; se non si carica il formato non e' mostrabile
|
||||
onImagePreviewLoad(el) {
|
||||
// #102: il <tmf-viewer> ha caricato l'immagine (evento viewer-load): legge i pixel dal detail
|
||||
// #122: lo zoom e' del componente; qui restano solo le dimensioni per il riepilogo
|
||||
onImagePreviewLoad(d) {
|
||||
this.imageBroken = false;
|
||||
this.imagePx = el && el.naturalWidth ? { w: el.naturalWidth, h: el.naturalHeight } : null;
|
||||
if (this.imagePx) this.vSetContent('img', this.imagePx.w, this.imagePx.h); // #113: Adatta al caricamento
|
||||
else this.vReset('img');
|
||||
this.imagePx = d && d.width ? { w: d.width, h: d.height } : null;
|
||||
},
|
||||
// #102: se il formato non e' mostrabile (sonda <img hidden> del template) compare l'avviso
|
||||
onImagePreviewError() {
|
||||
this.imageBroken = !!this.imageSrc;
|
||||
this.imagePx = null;
|
||||
this.vReset('img'); // #113
|
||||
},
|
||||
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
|
||||
get imagePreviewUrl() { return this.imageSrc; },
|
||||
@@ -256,7 +243,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
async loadDxfPreview() {
|
||||
const seq = ++this._dxfPreviewSeq;
|
||||
this.dxfPreview = null;
|
||||
this.vReset('dxf'); // #113: nuova anteprima in arrivo
|
||||
this.dxfPreviewError = '';
|
||||
this.dxfPreviewBusy = false;
|
||||
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);
|
||||
else {
|
||||
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) {
|
||||
if (seq !== this._dxfPreviewSeq) return;
|
||||
@@ -374,7 +358,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
this.error = this._errorText(body);
|
||||
} else {
|
||||
this.result = body;
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
if (save && body.saved) {
|
||||
this.notice = this.msgs.saved || 'Saved';
|
||||
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
|
||||
@@ -392,7 +375,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
this.busy = false;
|
||||
// #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra)
|
||||
this.tested = true;
|
||||
this.$nextTick(() => this.fit());
|
||||
},
|
||||
|
||||
// Le quattro uscite del confronto, nell'ordine fisso
|
||||
@@ -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')})`;
|
||||
},
|
||||
|
||||
// #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) {
|
||||
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
|
||||
{ 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.
|
||||
*/
|
||||
function visionExecute() {
|
||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
grabbing: false, // frame requested from the station camera (#72)
|
||||
@@ -35,21 +32,23 @@ function visionExecute() {
|
||||
preview: false, // result on screen is an analysis only, not saved
|
||||
saved: false, // «Registra» done: the saved result replaces the preview
|
||||
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
|
||||
registering: false, // «Registra» in progress: the loop stands still
|
||||
leaving: false, // page is being left: no more requests
|
||||
looping: false, // an in-line loop is running
|
||||
gen: 0, // bumped by «Registra»: answers of older requests are dropped
|
||||
|
||||
// #115: zoom del riquadro immagine (stesso schema del wizard DXF, #113). zoom = px schermo per px immagine;
|
||||
// fit = zoom di «Adatta»; w/h = dimensioni naturali; auto = la vista e' ad Adatta (segue il resize del riquadro)
|
||||
z: { zoom: 1, fit: 1, w: 0, h: 0, auto: true },
|
||||
zPanning: false, // #115: trascinamento in corso (cursore grabbing)
|
||||
_zPan: null,
|
||||
_zRO: null,
|
||||
|
||||
// #97: start on a frame already acquired and analysed; hook visibility and exit.
|
||||
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;
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
|
||||
const leave = () => { this.leaving = true; };
|
||||
@@ -62,7 +61,7 @@ function visionExecute() {
|
||||
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
|
||||
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
|
||||
// «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() {
|
||||
return this.inLinea && !this.paused && !this.saved && !this.registering
|
||||
@@ -127,7 +126,6 @@ function visionExecute() {
|
||||
this.result = body;
|
||||
this.preview = true;
|
||||
this.saved = false;
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
@@ -150,6 +148,8 @@ function visionExecute() {
|
||||
if (!this.error && this.result && this.result.vision_result_id) {
|
||||
this.preview = false;
|
||||
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 {
|
||||
this.registering = false;
|
||||
@@ -186,8 +186,8 @@ function visionExecute() {
|
||||
return;
|
||||
}
|
||||
this.result = body;
|
||||
// Draw once Alpine has rendered the canvas.
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
// #120: senza camera il file scelto e' salvato subito (nessun «Registra»): stesso evento
|
||||
if (!this.hasCamera && body.vision_result_id) this._emitRecorded();
|
||||
} catch (e) {
|
||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
} finally {
|
||||
@@ -195,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
|
||||
// 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
|
||||
@@ -276,131 +282,25 @@ function visionExecute() {
|
||||
});
|
||||
},
|
||||
|
||||
// #115: load dell'immagine (anche a ogni frame in linea): dimensioni naturali, fit solo se serve, poi overlay
|
||||
onImgLoad() {
|
||||
const img = this.$refs.img;
|
||||
if (img && img.naturalWidth) {
|
||||
const z = this.z;
|
||||
const changed = z.w !== img.naturalWidth || z.h !== img.naturalHeight;
|
||||
z.w = img.naturalWidth;
|
||||
z.h = img.naturalHeight;
|
||||
// dimensioni uguali e vista ingrandita: il frame nuovo resta ingrandito (in linea ogni frame ricarica l'immagine)
|
||||
if (changed || z.auto) this.zFit();
|
||||
this._zObserve();
|
||||
}
|
||||
this.drawOverlay();
|
||||
},
|
||||
// #122: zoom, pan, resize e Salva li gestisce <tmf-viewer> (static/js/image_viewer.js)
|
||||
|
||||
// #115: osservatore di resize sul viewport: se la vista e' ad Adatta (o il riquadro era nascosto) rifa il fit
|
||||
_zObserve() {
|
||||
const vp = this.$refs.vp;
|
||||
if (!vp || this._zRO || typeof ResizeObserver === 'undefined') return;
|
||||
this._zRO = new ResizeObserver(() => { if (this.z.w > 0 && this.z.auto) this.zFit(); });
|
||||
this._zRO.observe(vp);
|
||||
// #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;
|
||||
},
|
||||
|
||||
// #115: area utile del viewport; offset meno i bordi (client* escludono le barre di scorrimento che spariranno dopo il fit, come #113)
|
||||
_zArea() {
|
||||
const vp = this.$refs.vp;
|
||||
if (!vp) return { cw: 0, ch: 0 };
|
||||
let cw = vp.clientWidth, ch = vp.clientHeight;
|
||||
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);
|
||||
}
|
||||
return { cw, ch };
|
||||
},
|
||||
|
||||
// #115: Adatta = tutta l'immagine nel riquadro (come il fit di #96)
|
||||
zFit() {
|
||||
const vp = this.$refs.vp, z = this.z;
|
||||
if (!vp || !z.w || !z.h) return;
|
||||
const a = this._zArea();
|
||||
if (!(a.cw > 0) || !(a.ch > 0)) return; // riquadro nascosto o vuoto: niente, ci pensa l'osservatore
|
||||
const f = Math.min(a.cw / z.w, a.ch / z.h);
|
||||
z.fit = f;
|
||||
z.zoom = f;
|
||||
z.auto = true;
|
||||
vp.scrollLeft = 0;
|
||||
vp.scrollTop = 0;
|
||||
},
|
||||
|
||||
// #115: zoom di un fattore tenendo fermo il punto (cx, cy) del viewport (default: il centro)
|
||||
zoomBy(f, cx, cy) {
|
||||
const vp = this.$refs.vp, z = this.z;
|
||||
if (!vp || !z.w) return;
|
||||
const old = z.zoom;
|
||||
const nz = Math.min(Math.max(8, z.fit), Math.max(z.fit * 0.5, old * f));
|
||||
if (nz === old) return;
|
||||
if (cx == null) cx = vp.clientWidth / 2;
|
||||
if (cy == null) cy = vp.clientHeight / 2;
|
||||
const left = vp.scrollLeft, top = vp.scrollTop;
|
||||
z.zoom = nz;
|
||||
z.auto = false;
|
||||
const apply = () => {
|
||||
vp.scrollLeft = (left + cx) * nz / old - cx;
|
||||
vp.scrollTop = (top + cy) * nz / old - cy;
|
||||
};
|
||||
if (typeof this.$nextTick === 'function') this.$nextTick(apply); else apply();
|
||||
},
|
||||
|
||||
// #115: Ctrl+rotella = zoom sul punto del cursore; senza Ctrl la pagina scorre come sempre
|
||||
zWheel(ev) {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
const vp = this.$refs.vp;
|
||||
let cx, cy;
|
||||
if (vp && vp.getBoundingClientRect) {
|
||||
const r = vp.getBoundingClientRect();
|
||||
cx = ev.clientX - r.left;
|
||||
cy = ev.clientY - r.top;
|
||||
}
|
||||
this.zoomBy(ev.deltaY < 0 ? 1.25 : 1 / 1.25, cx, cy);
|
||||
},
|
||||
|
||||
zCanPan() { const z = this.z; return z.w > 0 && z.zoom > z.fit * 1.0001; },
|
||||
zCursor() { return this.zCanPan() ? (this.zPanning ? 'grabbing' : 'grab') : 'default'; },
|
||||
zPct() { return Math.round(this.z.zoom * 100) + '%'; },
|
||||
// #115: dimensione del wrapper: naturale x zoom; prima del load riempie il riquadro
|
||||
zSize() {
|
||||
const z = this.z;
|
||||
return z.w ? 'width:' + (z.w * z.zoom) + 'px;height:' + (z.h * z.zoom) + 'px' : 'width:100%;height:100%';
|
||||
},
|
||||
|
||||
// #115: trascinamento = scorrimento del viewport (solo se ingrandito)
|
||||
zPanDown(ev) {
|
||||
const vp = this.$refs.vp;
|
||||
if (!vp || !this.zCanPan() || (ev.pointerType === 'mouse' && ev.button)) return;
|
||||
if (ev.preventDefault) ev.preventDefault();
|
||||
this._zPan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
||||
this.zPanning = true;
|
||||
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
||||
},
|
||||
zPanMove(ev) {
|
||||
const vp = this.$refs.vp, p = this._zPan;
|
||||
if (!p || !vp) return;
|
||||
vp.scrollLeft = p.left - (ev.clientX - p.x);
|
||||
vp.scrollTop = p.top - (ev.clientY - p.y);
|
||||
},
|
||||
zPanEnd() { this._zPan = null; this.zPanning = false; },
|
||||
|
||||
// #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); },
|
||||
|
||||
// #116: etichette «nome valore unita» agganciate alla geometria dello strumento; posizione in % delle
|
||||
// dimensioni naturali dell'immagine, cosi' seguono lo zoom (#115) senza ingrandirsi
|
||||
get overlayLabels() {
|
||||
// #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 [];
|
||||
const img = this.$refs && this.$refs.img;
|
||||
const w = this.z.w || (img && img.naturalWidth) || 0;
|
||||
const h = this.z.h || (img && img.naturalHeight) || 0;
|
||||
if (!w || !h) return [];
|
||||
return VisionOverlay.labels(this.rows, this.result.overlay || []).map((l) => ({
|
||||
key: l.key,
|
||||
text: l.text,
|
||||
cls: this.labelClass(l.status),
|
||||
style: 'left:' + (l.x / w * 100) + '%;top:' + (l.y / h * 100) + '%;transform:translate(8px,-50%)',
|
||||
key: l.key, x: l.x, y: l.y, text: l.text, cls: this.labelClass(l.status),
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -419,33 +319,5 @@ function visionExecute() {
|
||||
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)]';
|
||||
},
|
||||
|
||||
// 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);
|
||||
const lw = Math.max(2, cv.width / 400);
|
||||
g.lineWidth = lw;
|
||||
g.lineJoin = 'round';
|
||||
// #116: tutte le forme (rect2, segment, circle, arc...) le disegna vision_overlay.js; il ciclo sotto resta come ripiego
|
||||
if (typeof VisionOverlay !== 'undefined') {
|
||||
VisionOverlay.draw(g, (this.result && this.result.overlay) || [], lw);
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* con require.
|
||||
*
|
||||
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
|
||||
* labels(rows, overlay) -> [{key, x, y, text, status}].
|
||||
* 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).
|
||||
@@ -129,6 +131,82 @@ var VisionOverlay = (function () {
|
||||
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) {
|
||||
@@ -172,20 +250,174 @@ var VisionOverlay = (function () {
|
||||
var pt = null;
|
||||
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
|
||||
if (!pt) return;
|
||||
var v = Number(r.value);
|
||||
if (!isFinite(v)) 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 + ' ' + v.toFixed(3) + ' ' + (r.unit || ''),
|
||||
text: r.name + ' ' + txt + ' ' + (r.unit || ''),
|
||||
status: r.status,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
return { draw: draw, anchor: anchor, labels: labels };
|
||||
// ---- #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;
|
||||
|
||||
@@ -730,58 +730,37 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
|
||||
<div x-show="imagePreviewUrl">
|
||||
<!-- #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) -->
|
||||
<div class="relative" x-show="!imageBroken">
|
||||
<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"
|
||||
style="background: rgba(0,0,0,.55); color: #fff;">
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('img', 1 / 1.25)" title="{{ _('Riduci') }}">−</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('img', 1.25)" title="{{ _('Ingrandisci') }}">+</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vFit('img')">{{ _('Adatta') }}</button>
|
||||
<span class="text-xs px-1" x-text="vPct('img')"></span>
|
||||
</div>
|
||||
<!-- #113: viewport fisso 240px; il wrapper (margin auto) e' largo w*zoom: centrato se piu' piccolo, scorrevole se piu' grande -->
|
||||
<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>
|
||||
{# #122: <tmf-viewer> standard (zoom, Adatta, Salva, pan) al posto della barra e del viewport scritti a mano (#113); altezza fissa 240px #}
|
||||
<div x-show="!imageBroken">{# #122: x-show sul contenitore, mai sul <tmf-viewer> #}
|
||||
{# #122: formato non mostrabile -> viewer-error del componente -> avviso imageBroken (al posto della sonda <img hidden>) #}
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer :src="imagePreviewUrl" save-name="immagine" no-layers
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
@viewer-load="onImagePreviewLoad($event.detail)" @viewer-error="onImagePreviewError()"
|
||||
class="rounded border border-[var(--border-color)]"
|
||||
style="height: 240px; background: var(--bg-primary);"></tmf-viewer>
|
||||
</div>
|
||||
<p x-show="imageBroken" class="text-xs text-amber-600">{{ _('Anteprima non disponibile per questo formato') }}</p>
|
||||
<p x-show="imagePreviewSummary" class="mt-1 text-xs text-[var(--text-muted)]" x-text="imagePreviewSummary"></p>
|
||||
</div>
|
||||
<div x-show="dxfPreview" class="sm:col-start-2">{# #104: resta a destra anche senza anteprima immagine #}
|
||||
<!-- #113: barra zoom come quella della Prova (#104), per l'anteprima 'dxf' -->
|
||||
<div class="relative">
|
||||
<div class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
|
||||
style="background: rgba(0,0,0,.55); color: #fff;">
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('dxf', 1 / 1.25)" title="{{ _('Riduci') }}">−</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('dxf', 1.25)" title="{{ _('Ingrandisci') }}">+</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vFit('dxf')">{{ _('Adatta') }}</button>
|
||||
<span class="text-xs px-1" x-text="vPct('dxf')"></span>
|
||||
</div>
|
||||
<!-- #113: stesso viewport dell'immagine; svg 100% del wrapper, viewBox in mm -->
|
||||
<div x-ref="vpDxf" @wheel="vWheel('dxf', $event)"
|
||||
@pointerdown="vPanDown('dxf', $event)" @pointermove="vPanMove('dxf', $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('dxf')">
|
||||
<div :style="'margin:auto;flex:none;width:' + vW('dxf') + 'px;height:' + vH('dxf') + 'px'">
|
||||
<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>
|
||||
{# #122: <tmf-viewer> in modo contenuto: lo <svg> (viewBox in mm) va nello stage; width/height = misura del viewBox, ora anche Salva (PNG) #}
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer save-name="dxf" no-layers
|
||||
:width="dxfPreviewViewBox().split(' ')[2]" :height="dxfPreviewViewBox().split(' ')[3]"
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
class="rounded border border-[var(--border-color)]"
|
||||
style="height: 240px; background: var(--bg-primary);">
|
||||
<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>
|
||||
</tmf-viewer>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -812,29 +791,15 @@
|
||||
<!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra -->
|
||||
<div x-show="tested" class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #}
|
||||
<div>
|
||||
<!-- #104: barra zoom in sovrimpressione, fuori dal contenuto scorrevole -->
|
||||
<div class="relative">
|
||||
<div class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
|
||||
style="background: rgba(0,0,0,.55); color: #fff;">
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1 / 1.25)" title="{{ _('Riduci') }}">−</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="fit()">{{ _('Adatta') }}</button>
|
||||
<span class="text-xs px-1" x-text="zoomPct"></span>
|
||||
</div>
|
||||
<!-- #104: pan col puntatore (mouse e dito), cursore grab; altezza che segue l'immagine finche' zoom <= Adatta -->
|
||||
<div x-ref="viewport" @wheel="onWheel($event)"
|
||||
@pointerdown="onPanDown($event)" @pointermove="onPanMove($event)"
|
||||
@pointerup="onPanEnd()" @pointercancel="onPanEnd()"
|
||||
class="rounded border border-[var(--border-color)]"
|
||||
:style="'overflow:auto;touch-action:none;user-select:none;background:var(--bg-primary);height:' + vpHeight + 'px;cursor:' + panCursor">
|
||||
<div class="relative" :style="zoomW ? ('width:' + zoomW + 'px;height:' + zoomH + 'px') : 'width:100%'">
|
||||
<img x-ref="img" :src="imageSrc" alt="" class="block" draggable="false" style="width: 100%; height: 100%; user-select: none;"
|
||||
@load="onResultImgLoad()">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{# #122: <tmf-viewer fit-height> al posto di barra, viewport, pan e overlay della Prova (#104 #121); overlay dalle forme del risultato #}
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer fit-height :src="imageSrc" save-name="prova" no-layers
|
||||
x-effect="$el.overlay = (result && result.result && result.result.overlay) || []"
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
class="rounded border border-[var(--border-color)]"
|
||||
style="background: var(--bg-primary);"></tmf-viewer>
|
||||
</div>{# #122: chiude solo la colonna dell'immagine (una chiusura in piu' chiudeva la griglia) #}
|
||||
<div class="space-y-2">
|
||||
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
|
||||
@@ -1516,6 +1481,9 @@
|
||||
<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/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/task_acquisition.js') }}"></script>{# #97 #}
|
||||
<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 }}
|
||||
</td>
|
||||
<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 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 %}">
|
||||
{{ "%+.3f"|format(m.deviation) }}
|
||||
<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 %}">
|
||||
{# #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 class="text-center">
|
||||
{% if m.pass_fail == 'pass' %}
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||
@numpad-rejected.window="onNumpadRejected($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
|
||||
@@ -471,7 +472,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,13 +541,14 @@
|
||||
|
||||
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
|
||||
<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' : ''">
|
||||
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
|
||||
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||
@change="onPick($event)" :disabled="busy">
|
||||
</label>
|
||||
{% if camera_enabled %}
|
||||
{% else %}
|
||||
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
|
||||
{% if camera_mode == 'in_linea' %}
|
||||
<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()"
|
||||
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
||||
:disabled="busy || grabbing">
|
||||
<span>{{ _('Acquisisci') }}</span>
|
||||
<span>{{ _('Acquisisci da camera') }}</span> {# #119 #}
|
||||
</button>
|
||||
{% endif %}
|
||||
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
|
||||
@@ -576,31 +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>
|
||||
|
||||
{# #96: riquadro flex-1 min-h-0; #115: dentro, barra zoom in overlay e viewport scorrevole con il wrapper (naturale x zoom) che contiene img e canvas; 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">
|
||||
{# #115: x-show sul contenitore della barra, mai sul viewport (con :style stringa Alpine lo pesterebbe) #}
|
||||
<div x-show="z.w" class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
|
||||
style="background: rgba(0,0,0,.55); color: #fff;">
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(0.8)" title="{{ _('Riduci') }}">−</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zFit()">{{ _('Adatta') }}</button>
|
||||
<span class="text-xs px-1" x-text="zPct()"></span>
|
||||
</div>
|
||||
{# #115: viewport = tutto il riquadro; solo :style stringa (uno style statico verrebbe cancellato da Alpine) #}
|
||||
<div x-ref="vp" class="absolute inset-0" @wheel="zWheel($event)"
|
||||
@pointerdown="zPanDown($event)" @pointermove="zPanMove($event)"
|
||||
@pointerup="zPanEnd()" @pointercancel="zPanEnd()"
|
||||
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;cursor:' + zCursor()">
|
||||
<div class="relative" :style="'margin:auto;flex:none;' + zSize()">
|
||||
<img x-ref="img" :src="shownImage" @load="onImgLoad()" alt="" draggable="false" class="absolute inset-0 w-full h-full object-contain">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
|
||||
{# #116: etichette quota in HTML sopra il canvas (posizione in %, dimensione costante a ogni zoom); solo :style stringa #}
|
||||
<template x-for="l in overlayLabels" :key="l.key">
|
||||
<div class="absolute pointer-events-none whitespace-nowrap text-xs font-bold px-1.5 py-0.5 rounded shadow"
|
||||
:class="l.cls" :style="l.style" x-text="l.text"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<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 x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||
@@ -619,7 +604,8 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
|
||||
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
|
||||
{# #118: quota non misurata (value null) = «NaN», esito fail #}
|
||||
<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)]">
|
||||
@@ -1209,12 +1195,17 @@
|
||||
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||
taskId: {{ task.id|tojson }},
|
||||
taskType: {{ (task.task_type or '')|tojson }},
|
||||
taskName: {{ (task.title or '')|tojson }}, {# #119: nome proposto per «Salva» #}
|
||||
subtasks: window.__taskSubtasks,
|
||||
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
|
||||
};
|
||||
</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>
|
||||
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
|
||||
<script>
|
||||
@@ -1256,6 +1247,7 @@ function taskExecute() {
|
||||
// ---- Measurement state ----
|
||||
currentIndex: 0,
|
||||
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,
|
||||
// The single reentrancy guard for handleMeasurement (#13): true for the
|
||||
// whole time a reading is being processed, including the 1s feedback
|
||||
@@ -1362,6 +1354,8 @@ function taskExecute() {
|
||||
},
|
||||
|
||||
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;
|
||||
return n === 1
|
||||
? {{ _('Manca 1 quota')|tojson }}
|
||||
@@ -1375,6 +1369,8 @@ function taskExecute() {
|
||||
ciclo misura», and the quotes it exists to collect would be optional. */
|
||||
get canLeaveTask() {
|
||||
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) {
|
||||
return this.cycleConfirmed;
|
||||
}
|
||||
@@ -1385,6 +1381,7 @@ function taskExecute() {
|
||||
if (this.blockedByTolerance) {
|
||||
return {{ _("Serve l'autorizzazione del capoturno")|tojson }};
|
||||
}
|
||||
if (this.isVisionTask && !this.visionRecorded) return this.missingQuotesLabel; // #120
|
||||
if (!this.isComplete) return this.missingQuotesLabel;
|
||||
return {{ _('Chiudi prima il ciclo di misura')|tojson }};
|
||||
},
|
||||
@@ -1468,9 +1465,10 @@ function taskExecute() {
|
||||
subtask_id: m.subtask_id,
|
||||
value: m.value,
|
||||
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(
|
||||
s => !this.measurements.some(m => m.subtask_id === s.id),
|
||||
);
|
||||
@@ -1951,6 +1949,23 @@ function taskExecute() {
|
||||
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.
|
||||
Local only: everything already measured is on the server, and the next
|
||||
reading is saved beside it rather than replacing it. */
|
||||
@@ -1958,6 +1973,7 @@ function taskExecute() {
|
||||
this.cycleConfirmed = false;
|
||||
this.showCompletionOverlay = false;
|
||||
this.measurements = [];
|
||||
this.visionRecorded = false; // #120: nuovo ciclo = pezzo da registrare di nuovo
|
||||
this.currentIndex = 0;
|
||||
this.currentValue = null;
|
||||
this.errorMessage = '';
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<span class="truncate" x-text="s.description"></span>
|
||||
<span class="font-mono whitespace-nowrap"
|
||||
: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>
|
||||
</template>
|
||||
</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 <tmf-viewer></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>
|
||||
@@ -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) {
|
||||
console.error(failures + ' test falliti su csv-export.js');
|
||||
process.exit(1);
|
||||
|
||||
@@ -92,7 +92,7 @@ const PREVIEW = {
|
||||
global.URL.revokeObjectURL = () => {};
|
||||
wi._setImage({ name: 'a.bmp' }, 'file'); // #104: la scala a mano vale solo per file
|
||||
check('#102 _setImage imposta URL e nome', wi.imagePreviewUrl === 'blob:x' && wi.imageName === 'a.bmp');
|
||||
wi.onImagePreviewLoad({ naturalWidth: 2000, naturalHeight: 1000 });
|
||||
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);
|
||||
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);
|
||||
@@ -142,30 +142,9 @@ const PREVIEW = {
|
||||
w4.tested = true; w4.stationId = ''; await w4.onStation();
|
||||
check('#104 tested falso dopo onStation', w4.tested === false);
|
||||
|
||||
// #104: zoomBy rispetta i limiti 0.05..8
|
||||
// #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', {});
|
||||
w5.$nextTick = f => f();
|
||||
w5.$refs = {};
|
||||
for (let i = 0; i < 40; i++) w5.zoomBy(1.25);
|
||||
check('#104 zoom massimo 8', w5.zoom === 8, String(w5.zoom));
|
||||
for (let i = 0; i < 80; i++) w5.zoomBy(1 / 1.25);
|
||||
check('#104 zoom minimo 0.05', w5.zoom === 0.05, String(w5.zoom));
|
||||
w5.zoom = 1;
|
||||
const vp = { scrollLeft: 100, scrollTop: 50, clientWidth: 200, clientHeight: 100 };
|
||||
w5.$refs = { viewport: vp };
|
||||
w5.zoomBy(2);
|
||||
check('#104 zoomBy tiene il centro', w5.zoom === 2 && vp.scrollLeft === 300 && vp.scrollTop === 150, vp.scrollLeft + ',' + vp.scrollTop);
|
||||
check('#104 zoomPct', w5.zoomPct === '200 %');
|
||||
|
||||
// #104: fit = min(viewport/immagine)
|
||||
w5.$refs = { viewport: { clientWidth: 500, clientHeight: 400, scrollLeft: 9, scrollTop: 9 }, img: { naturalWidth: 2000, naturalHeight: 1000 } };
|
||||
w5.fit();
|
||||
check('#104 fit limitato dalla larghezza', Math.abs(w5.zoom - 0.25) < 1e-9, String(w5.zoom));
|
||||
w5.$refs.img = { naturalWidth: 1000, naturalHeight: 2000 };
|
||||
w5.fit();
|
||||
check('#104 fit limitato dall altezza massima (520 senza window)', Math.abs(w5.zoom - 0.26) < 1e-9, String(w5.zoom));
|
||||
w5.$refs.viewport.clientWidth = 0; w5.zoom = 1; w5.fit();
|
||||
check('#104 fit con viewport nascosto non cambia', w5.zoom === 1);
|
||||
check('#122 zoom e Adatta tolti dal wizard', ['zoomBy', 'fit', 'onPanDown', 'onPanMove', 'onPanEnd'].every(k => typeof w5[k] !== 'function'));
|
||||
|
||||
// #105: ricetta esistente -> wizard compilato
|
||||
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);
|
||||
check('#105 dopo Salva: existing letto e non ricompilato', w11.existing && w11.existing.band_mm === 0.3 && w11._existingApplied === true);
|
||||
|
||||
// #104: pan col puntatore
|
||||
const wpan = dxfRecipeWizard(1, () => 'tok', {});
|
||||
const vppan = { scrollLeft: 100, scrollTop: 100, clientWidth: 500, clientHeight: 400, setPointerCapture() {} };
|
||||
wpan.$refs = { viewport: vppan };
|
||||
wpan.onPanDown({ clientX: 200, clientY: 200, pointerId: 1, pointerType: 'mouse', button: 0 });
|
||||
wpan.onPanMove({ clientX: 170, clientY: 180 });
|
||||
check('#104 pan: pointermove (-30,-20) -> scroll +30/+20', vppan.scrollLeft === 130 && vppan.scrollTop === 120, vppan.scrollLeft + ',' + vppan.scrollTop);
|
||||
check('#104 pan: grabbing durante il trascinamento', wpan.panning === true);
|
||||
wpan.onPanEnd();
|
||||
wpan.onPanMove({ clientX: 0, clientY: 0 });
|
||||
check('#104 pan: dopo pointerup non scrolla', vppan.scrollLeft === 130 && wpan.panning === false);
|
||||
wpan.onPanDown({ clientX: 0, clientY: 0, pointerId: 1, pointerType: 'mouse', button: 2 });
|
||||
check('#104 pan: il tasto destro non parte', wpan.panning === false);
|
||||
|
||||
// #104: altezza del viewport
|
||||
global.window = { innerHeight: 1000 }; // max = min(700, 520) = 520
|
||||
wpan.resPx = { w: 2000, h: 1000 };
|
||||
wpan.$refs = { viewport: { clientWidth: 500, scrollLeft: 0, scrollTop: 0 }, img: { naturalWidth: 2000, naturalHeight: 1000 } };
|
||||
wpan.fit(); // zoom = min(500/2000, 520/1000) = 0.25
|
||||
check('#104 fit usa l altezza massima', Math.abs(wpan.zoom - 0.25) < 1e-9 && wpan.fitZoom === wpan.zoom, String(wpan.zoom));
|
||||
check('#104 altezza a Fit = immagine scalata', wpan.vpHeight === 250 && wpan.canPan === false, String(wpan.vpHeight)); // zoomH = 250
|
||||
wpan.resPx = { w: 2000, h: 400 }; // zoomH = 100 -> minimo 200
|
||||
check('#104 altezza minima 200', wpan.vpHeight === 200, String(wpan.vpHeight));
|
||||
wpan.zoom = 1;
|
||||
check('#104 con zoom > Adatta altezza massima e pan attivo', wpan.vpHeight === 520 && wpan.canPan === true && wpan.panCursor === 'grab');
|
||||
global.window = { innerHeight: 500 }; // 70vh = 350
|
||||
check('#104 altezza massima = 70vh se minore di 520', wpan.vpHeight === 350, String(wpan.vpHeight));
|
||||
delete global.window;
|
||||
|
||||
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
|
||||
console.log('tutto ok');
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -18,109 +21,35 @@ function check(label, cond, extra) {
|
||||
if (cond) console.log('ok - ' + label);
|
||||
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
|
||||
function make(cw) {
|
||||
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;
|
||||
}
|
||||
const w = dxfRecipeWizard(1, () => 'tok', {});
|
||||
w.$nextTick = fn => fn();
|
||||
|
||||
// img: viewport 400 x 238 utili, immagine 2856 x 2848
|
||||
let w = make(400);
|
||||
w.vSetContent('img', 2856, 2848);
|
||||
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));
|
||||
check('#113 img: scroll azzerato dopo Adatta', w.$refs.vpImg.scrollLeft === 0 && w.$refs.vpImg.scrollTop === 0);
|
||||
check('#113 img: percentuale = zoom*100', w.vPct('img') === Math.round(238 / 2848 * 100) + ' %', w.vPct('img'));
|
||||
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));
|
||||
// #122: via tutto lo zoom/pan/fit/overlay del wizard
|
||||
const gone = ['views', 'viewPanKey', 'zoom', 'fitZoom', 'panning', 'resPx', 'vZoomBy', 'vFit', 'vWheel', 'vPanDown',
|
||||
'vPanMove', 'vPanEnd', 'vCursor', 'vPct', 'vW', 'vH', 'vReset', 'vSetContent', 'zoomBy', 'fit', 'onWheel',
|
||||
'onPanDown', 'onPanMove', 'onPanEnd', 'onResultImgLoad', 'drawOverlay', 'zoomPct', 'zoomW', 'zoomH', 'vpHeight'];
|
||||
for (const k of gone) check('#122 tolto dal wizard: ' + k, !(k in w));
|
||||
check('#122 sorgente senza $refs di zoom', !/\$refs\./.test(src));
|
||||
|
||||
// dxf: viewBox 20 x 10 mm, viewport 400 x 238 -> fit = min(20, 23.8) = 20
|
||||
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
|
||||
// onImagePreviewLoad riceve il detail di viewer-load e alimenta il riepilogo
|
||||
global.URL.createObjectURL = () => 'blob:x';
|
||||
global.URL.revokeObjectURL = () => {};
|
||||
w = make(400);
|
||||
w._setImage({ name: 'a.bmp' }, 'file');
|
||||
w.onImagePreviewLoad({ naturalWidth: 800, naturalHeight: 240 });
|
||||
check('#113 onImagePreviewLoad: Adatta all\'arrivo', near(w.views.img.zoom, 0.5) && w.views.img.w === 800, String(w.views.img.zoom));
|
||||
w._setImage({ name: 'b.bmp' }, 'file');
|
||||
check('#113 _setImage: vista azzerata', w.views.img.w === 0 && w.views.img.zoom === 1);
|
||||
w.onImagePreviewLoad({ naturalWidth: 800, naturalHeight: 240 });
|
||||
w.onImagePreviewLoad({ width: 800, height: 240 });
|
||||
check('#122 viewer-load: dimensioni nel riepilogo', w.imagePx.w === 800 && w.imagePx.h === 240 && w.imageBroken === false);
|
||||
check('#122 riepilogo «W × H px»', /800.*240 px/.test(w.imagePreviewSummary), w.imagePreviewSummary);
|
||||
w.onImagePreviewLoad({ width: 0, height: 0 });
|
||||
check('#122 viewer-load senza dimensioni: imagePx null', w.imagePx === null);
|
||||
w.onImagePreviewLoad({ width: 800, height: 240 });
|
||||
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
|
||||
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
|
||||
// Anteprima DXF: il viewBox resta quello per il <tmf-viewer> (width/height del template)
|
||||
(async () => {
|
||||
const PREVIEW = {
|
||||
contorni: [[[0, 0], [20, 0], [20, 10]]],
|
||||
@@ -129,16 +58,17 @@ delete global.getComputedStyle;
|
||||
};
|
||||
global.FormData = class { append() {} };
|
||||
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' };
|
||||
await wd.loadDxfPreview();
|
||||
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('#113 loadDxfPreview: Adatta = 100 %', wd.vPct('dxf') === '100 %' && wd.views.dxf.fit > 1);
|
||||
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('#122 DXF: anteprima caricata', wd.dxfPreview && wd.dxfPreviewD().startsWith('M 0 0'));
|
||||
wd.dxfFile = null;
|
||||
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); }
|
||||
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);
|
||||
})();
|
||||
@@ -224,5 +224,230 @@ const rows = fixture.measurements.map((m) => {
|
||||
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
|
||||
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);
|
||||
|
||||
let failures = 0;
|
||||
@@ -88,35 +93,113 @@ 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.overlayLabels.length === 0);
|
||||
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' }]; },
|
||||
};
|
||||
check('#116 senza immagine: nessuna etichetta', c3.overlayLabels.length === 0);
|
||||
c3.z.w = 2000; c3.z.h = 1000;
|
||||
const ls = c3.overlayLabels;
|
||||
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));
|
||||
check('#116 posizione in percentuale', ls[0].style === 'left:25%;top:25%;transform:translate(8px,-50%)', ls[0].style);
|
||||
// #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.overlayLabels.length === 0);
|
||||
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');
|
||||
|
||||
// drawOverlay usa VisionOverlay.draw con lw = max(2, w/400)
|
||||
let drawn = null;
|
||||
ctx.VisionOverlay = { draw(g, overlay, lw) { drawn = { g, overlay, lw }; return { drawn: 1, skipped: 0 }; } };
|
||||
const gfake = { clearRect() {}, set lineWidth(v) {}, set lineJoin(v) {} };
|
||||
const cv = { width: 0, height: 0, getContext() { return gfake; } };
|
||||
c3.$refs = { img: { naturalWidth: 2000, naturalHeight: 1000 }, overlay: cv };
|
||||
c3.result = { overlay: [{ type: 'circle' }] };
|
||||
c3.drawOverlay();
|
||||
check('#116 drawOverlay chiama VisionOverlay.draw', drawn && drawn.g === gfake && drawn.overlay.length === 1 && drawn.lw === 5, JSON.stringify(drawn && drawn.lw));
|
||||
check('#116 canvas alla risoluzione naturale', cv.width === 2000 && cv.height === 1000);
|
||||
// #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));
|
||||
|
||||
process.exit(failures ? 1 : 0);
|
||||
// #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);
|
||||
})();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* #115: zoom, Adatta e pan del riquadro immagine del pannello visione, con plain node.
|
||||
* #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
|
||||
*/
|
||||
@@ -11,7 +12,7 @@ const vm = require('vm');
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8',
|
||||
);
|
||||
const win = {};
|
||||
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);
|
||||
|
||||
@@ -20,92 +21,24 @@ function check(label, cond, extra) {
|
||||
if (cond) console.log('ok - ' + label);
|
||||
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||
}
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-9 * Math.max(1, Math.abs(b));
|
||||
|
||||
// Componente con viewport e immagine finti ($nextTick sincrono); getComputedStyle assente: si usano client*
|
||||
function make(cw, ch) {
|
||||
const c = ctx.visionExecute();
|
||||
const vp = { clientWidth: cw, clientHeight: ch, scrollLeft: 0, scrollTop: 0, setPointerCapture() {} };
|
||||
const img = { naturalWidth: 2000, naturalHeight: 1000 };
|
||||
c.$refs = { vp, img, overlay: null };
|
||||
c.$nextTick = (fn) => fn();
|
||||
return c;
|
||||
// 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));
|
||||
|
||||
// Prima del load: il wrapper riempie il riquadro
|
||||
let c = make(800, 400);
|
||||
check('#115 zSize prima del load = 100 %', c.zSize() === 'width:100%;height:100%', c.zSize());
|
||||
check('#115 zoomBy senza immagine: ignorato', (c.zoomBy(2), c.z.zoom === 1));
|
||||
|
||||
// Load: fit = min(800/2000, 400/1000) = 0.4
|
||||
c.onImgLoad();
|
||||
check('#115 load: Adatta = 0.4', near(c.z.zoom, 0.4) && near(c.z.fit, 0.4) && c.z.auto === true, String(c.z.zoom));
|
||||
check('#115 zSize = naturale x zoom', c.zSize() === 'width:800px;height:400px', c.zSize());
|
||||
check('#115 zPct = 40%', c.zPct() === '40%', c.zPct());
|
||||
check('#115 a Adatta non si trascina', c.zCanPan() === false && c.zCursor() === 'default');
|
||||
|
||||
// Zoom in/out e limiti
|
||||
c.zoomBy(1.25);
|
||||
check('#115 zoomBy moltiplica', near(c.z.zoom, 0.5) && c.z.auto === false);
|
||||
check('#115 dopo lo zoom si trascina', c.zCanPan() === true && c.zCursor() === 'grab');
|
||||
c.zoomBy(1e6);
|
||||
check('#115 limite alto 8', c.z.zoom === 8, String(c.z.zoom));
|
||||
c.zoomBy(1e-9);
|
||||
check('#115 limite basso fit*0.5', near(c.z.zoom, 0.2), String(c.z.zoom));
|
||||
c.zFit();
|
||||
check('#115 Adatta torna al fit e a auto', near(c.z.zoom, 0.4) && c.z.auto === true);
|
||||
|
||||
// Il punto sotto il cursore resta fermo: zoom da 0.4 a 0.8 col cursore a (100, 50), scroll 0
|
||||
c.zoomBy(2, 100, 50);
|
||||
check('#115 zoom al cursore: scrollLeft', near(c.$refs.vp.scrollLeft, 100) && near(c.$refs.vp.scrollTop, 50),
|
||||
c.$refs.vp.scrollLeft + ',' + c.$refs.vp.scrollTop);
|
||||
|
||||
// Frame nuovo con le stesse dimensioni: tiene lo zoom; dimensioni diverse: rifa il fit
|
||||
const z0 = c.z.zoom;
|
||||
c.onImgLoad();
|
||||
check('#115 frame con stesse dimensioni: zoom invariato', c.z.zoom === z0 && c.z.auto === false);
|
||||
c.$refs.img.naturalWidth = 1000; c.$refs.img.naturalHeight = 1000;
|
||||
c.onImgLoad();
|
||||
check('#115 dimensioni diverse: nuovo fit', near(c.z.fit, 0.4) && c.z.zoom === c.z.fit && c.z.auto === true);
|
||||
|
||||
// Riquadro nascosto (0 x 0): niente fit, stato invariato
|
||||
c = make(0, 0);
|
||||
c.onImgLoad();
|
||||
check('#115 riquadro nascosto: nessun fit', c.z.zoom === 1 && c.z.w === 2000);
|
||||
c.$refs.vp.clientWidth = 800; c.$refs.vp.clientHeight = 400;
|
||||
c.zFit();
|
||||
check('#115 riquadro visibile: il fit parte', near(c.z.zoom, 0.4));
|
||||
|
||||
// offset meno bordi (getComputedStyle disponibile)
|
||||
c = make(780, 380);
|
||||
c.$refs.vp.offsetWidth = 802; c.$refs.vp.offsetHeight = 402;
|
||||
ctx.getComputedStyle = () => ({ borderLeftWidth: '1px', borderRightWidth: '1px', borderTopWidth: '1px', borderBottomWidth: '1px' });
|
||||
c.onImgLoad();
|
||||
check('#115 area = offset meno bordi', near(c.z.fit, 0.4), String(c.z.fit));
|
||||
delete ctx.getComputedStyle;
|
||||
|
||||
// Rotella: solo con Ctrl
|
||||
c = make(800, 400); c.onImgLoad();
|
||||
let prevented = false;
|
||||
c.zWheel({ ctrlKey: false, deltaY: -1, preventDefault() { prevented = true; } });
|
||||
check('#115 rotella senza Ctrl: ignorata', !prevented && near(c.z.zoom, 0.4));
|
||||
c.zWheel({ ctrlKey: true, deltaY: -1, clientX: 0, clientY: 0, preventDefault() { prevented = true; } });
|
||||
check('#115 Ctrl+rotella su: zoom x1.25', prevented && near(c.z.zoom, 0.5));
|
||||
c.zWheel({ ctrlKey: true, deltaY: 1, preventDefault() {} });
|
||||
check('#115 Ctrl+rotella giu: zoom /1.25', near(c.z.zoom, 0.4));
|
||||
|
||||
// Pan: sposta lo scroll solo se ingrandito
|
||||
c = make(800, 400); c.onImgLoad();
|
||||
c.zPanDown({ clientX: 10, clientY: 10, pointerType: 'mouse', button: 0, pointerId: 1 });
|
||||
check('#115 pan a Adatta: ignorato', c._zPan === null && c.zPanning === false);
|
||||
c.zoomBy(2);
|
||||
c.$refs.vp.scrollLeft = 50; c.$refs.vp.scrollTop = 40;
|
||||
c.zPanDown({ clientX: 100, clientY: 100, pointerType: 'mouse', button: 0, pointerId: 1 });
|
||||
check('#115 pan avviato: cursore grabbing', c.zPanning === true && c.zCursor() === 'grabbing');
|
||||
c.zPanMove({ clientX: 80, clientY: 90 });
|
||||
check('#115 pan: scroll segue il puntatore', c.$refs.vp.scrollLeft === 70 && c.$refs.vp.scrollTop === 50,
|
||||
c.$refs.vp.scrollLeft + ',' + c.$refs.vp.scrollTop);
|
||||
c.zPanEnd();
|
||||
check('#115 pan finito', c._zPan === null && c.zPanning === false);
|
||||
// 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 'x-model="mmPx"' in html # #98: campo mm/px a mano
|
||||
assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF
|
||||
assert "imageSource === 'file'" in html and "zoomBy(" in html and "fit()" in html # #104: mm/px solo da file, zoom e Fit
|
||||
assert 'onPanDown($event)' in html # #104: pan col puntatore
|
||||
assert "imageSource === 'file'" in html # #104: mm/px solo da file
|
||||
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
|
||||
assert 'x-ref="vpImg"' in html and 'x-ref="vpDxf"' in html
|
||||
assert "vFit('img')" in html and "vFit('dxf')" in html
|
||||
assert "vZoomBy('img', 1.25)" in html and "vZoomBy('dxf', 1.25)" in html
|
||||
assert "vPanDown('img', $event)" in html and "vWheel('dxf', $event)" in html
|
||||
# #113: Alpine :style a stringa sostituisce l'attributo style: niente style statico sullo stesso tag
|
||||
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
|
||||
# #122: le tre viste (immagine, DXF, Prova) sono <tmf-viewer>; zoom, Adatta, pan e Salva stanno nel componente
|
||||
assert html.count("<tmf-viewer") == 3 and "js/image_viewer.js" in html
|
||||
assert '@viewer-load="onImagePreviewLoad($event.detail)"' in html # #122: dimensioni per il riepilogo
|
||||
assert "fit-height" in html and 'save-name="prova"' in html # #122: la Prova segue l'immagine
|
||||
for old in ('x-ref="vpImg"', 'x-ref="vpDxf"', "vZoomBy(", "vFit(", "vPanDown(", "onPanDown(", "zoomBy(", 'x-ref="viewport"'):
|
||||
assert old not in html, old # #122: zoom a mano tolto dal wizard
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
object-contain sovrapposti, nessun h-auto sull'immagine (era la causa dello scorrimento).
|
||||
Controllo sulle classi del template: pannello overflow-hidden, riquadro flex-1 min-h-0 che contiene
|
||||
<tmf-viewer> a tutta altezza (#122: zoom, img e overlay stanno dentro il componente).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -37,24 +37,22 @@ def test_panel_does_not_scroll():
|
||||
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()
|
||||
img = _tag_with(panel, 'x-ref="img"')
|
||||
assert "object-contain" in img
|
||||
assert "h-auto" not in img
|
||||
canvas = _tag_with(panel, 'x-ref="overlay"')
|
||||
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
|
||||
viewer = _tag_with(panel, "tmf-viewer class")
|
||||
assert "block" in viewer and "w-full" in viewer and "h-full" in viewer
|
||||
assert ':src="shownImage"' in viewer
|
||||
assert "x-show" not in viewer # mai x-show e :style stringa sullo stesso elemento: x-show sta sul riquadro
|
||||
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_zoom_viewport_scrolls_inside_the_box():
|
||||
# #115: il riquadro resta overflow-hidden (il pannello non scorre); lo scorrimento e' solo del viewport interno
|
||||
def test_no_own_zoom_viewport_in_template():
|
||||
# #122: niente piu' viewport/wrapper di zoom del pannello (#115): li fa il componente
|
||||
panel = _panel()
|
||||
vp = _tag_with(panel, 'x-ref="vp"')
|
||||
assert "absolute" in vp and "inset-0" in vp
|
||||
assert "overflow:auto" in vp # :style stringa del viewport
|
||||
assert 'style="' not in vp.replace(':style="', "") # niente style statico accanto a :style (Alpine lo cancella)
|
||||
assert "x-show" not in vp # mai x-show e :style stringa sullo stesso elemento
|
||||
assert "zSize()" in panel # wrapper naturale x zoom
|
||||
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():
|
||||
@@ -73,12 +71,11 @@ def test_quote_table_shows_nominal_and_tolerances():
|
||||
assert "text-measure-fail" in panel and "text-measure-warning" in panel
|
||||
|
||||
|
||||
def test_overlay_script_loaded_before_vision_execute_and_labels_layer():
|
||||
# #116: vision_overlay.js prima di vision_execute.js; strato etichette dentro il wrapper dello zoom, dopo il canvas
|
||||
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 panel.index('x-ref="overlay"') < panel.index('x-for="l in overlayLabels"')
|
||||
label = _tag_with(panel, 'x-text="l.text"')
|
||||
assert ':style="l.style"' in label and "pointer-events-none" in label
|
||||
assert 'style="' not in label.replace(':style="', "") # niente style statico accanto a :style
|
||||
assert "$el.labels = viewerLabels" in panel
|
||||
assert 'x-for="l in overlayLabels"' not in panel
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.
|
||||
Covers gitea:Adriano/TieMeasureFlow#115 #122 (dal #122 il test JS verifica che lo zoom non sia piu' nel componente).
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
@@ -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")
|
||||
@@ -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_minus": float(band_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
|
||||
"points_in_pct_nominal": float(min_points_pct),
|
||||
"points_in_pct_plus": float(100.0 - min_points_pct),
|
||||
|
||||
@@ -22,6 +22,7 @@ from src.vision.costruttori import (
|
||||
DXF_FILE,
|
||||
CostruttoreError,
|
||||
_candidate_anchors,
|
||||
_graph,
|
||||
_maschera_pezzo,
|
||||
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
|
||||
|
||||
|
||||
# #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():
|
||||
bianca = np.full((600, 800), 255, np.uint8)
|
||||
with pytest.raises(CostruttoreError, match="pezzo"):
|
||||
|
||||
@@ -2464,6 +2464,7 @@ dependencies = [
|
||||
{ name = "visionsuite" },
|
||||
{ name = "vs-measure" },
|
||||
{ name = "vs-task" },
|
||||
{ name = "zxing-cpp" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2485,6 +2486,7 @@ requires-dist = [
|
||||
{ name = "vs-pm2d", marker = "extra == 'locate'", editable = "vendor/visionsuite/packages/vs-pm2d" },
|
||||
{ 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 = "zxing-cpp", specifier = ">=3.1,<4" },
|
||||
]
|
||||
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/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" },
|
||||
]
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: 06b549a25d...9eb6f4cbce
Reference in New Issue
Block a user