Compare commits

..

10 Commits

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-29 13:49:39 +01:00
17 changed files with 898 additions and 38 deletions
+3
View File
@@ -76,3 +76,6 @@ docker-compose.override.yml
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
packaging/windows/_strumenti.py
# #109: scritto da packaging/windows/sviluppo.ps1 (sha del submodule), non va versionato
/vision-engine-version.txt
+1 -1
View File
@@ -289,7 +289,7 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
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:
+21 -2
View File
@@ -4,14 +4,15 @@ Nuitka (che richiede ~20 minuti): stessi quattro server e stesse porte dell'exe
8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow.
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
+10
View File
@@ -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)
+108
View File
@@ -0,0 +1,108 @@
"""Archivio sorgente di TieMeasureFlow per un PC senza git (TieMeasureFlow#109).
Cosa fa
Crea uno zip del repo a un commit, con dentro anche il submodule vendor/visionsuite (che
`git archive` da solo NON include) e il file vision-engine-version.txt nella radice dello zip.
Quel file contiene lo sha del submodule REGISTRATO nel commit (non la HEAD del checkout del
submodule): packaging/windows/avvio.py lo legge e lo passa al worker di visione come
VISION_ENGINE_VERSION; senza, il worker rifiuta ogni job. Formato: 40 caratteri ASCII,
senza BOM e senza a capo (avvio.py usa .strip(), che non toglie un BOM).
Come si usa (dalla radice del repo, con il submodule inizializzato)
uv run python scripts/archivio_sorgente.py --out C:/tmp/tmf.zip [--ref HEAD]
Esce 0 e stampa lo sha; esce 1 con un messaggio e senza lasciare lo zip se il submodule
non e' inizializzato o non ha il commit registrato (git submodule update --init vendor/visionsuite).
Dipende da: git nel PATH, solo libreria standard.
"""
from __future__ import annotations
import argparse
import io
import os
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
SUBMODULE = "vendor/visionsuite"
FILE_VERSIONE = "vision-engine-version.txt"
class ErroreArchivio(RuntimeError):
"""Errore chiaro da mostrare all'operatore (niente traceback)."""
def _git(cartella: Path, *args: str, binario: bool = False):
"""Esegue git in `cartella`; solleva ErroreArchivio con lo stderr se fallisce."""
r = subprocess.run(["git", "-C", str(cartella), *args], capture_output=True)
if r.returncode != 0:
raise ErroreArchivio(f"git {' '.join(args)} fallito in {cartella}: {r.stderr.decode(errors='replace').strip()}")
return r.stdout if binario else r.stdout.decode().strip()
def sha_registrato(repo: Path, ref: str) -> str:
"""Sha del submodule registrato nel commit `ref` (gitlink, modo 160000), non la HEAD del checkout."""
campi = _git(repo, "ls-tree", ref, SUBMODULE).split()
if len(campi) < 3 or campi[0] != "160000":
raise ErroreArchivio(f"{SUBMODULE} non e' un submodule registrato in {ref}")
return campi[2]
def crea_archivio(repo: Path, out: Path, ref: str = "HEAD") -> str:
"""Scrive lo zip in `out` e ritorna lo sha del submodule; in caso di errore non lascia nulla."""
repo, out = Path(repo), Path(out)
sha = sha_registrato(repo, ref)
sub = repo / SUBMODULE
# submodule non inizializzato o senza il commit registrato: errore prima di scrivere lo zip
if not (sub / ".git").exists():
raise ErroreArchivio(f"{SUBMODULE} non e' inizializzato: git submodule update --init {SUBMODULE}")
if subprocess.run(["git", "-C", str(sub), "cat-file", "-e", f"{sha}^{{commit}}"], capture_output=True).returncode != 0:
raise ErroreArchivio(f"{SUBMODULE} non ha il commit {sha}: git submodule update --init {SUBMODULE}")
zip_repo = _git(repo, "archive", "--format=zip", ref, binario=True)
zip_sub = _git(sub, "archive", "--format=zip", f"--prefix={SUBMODULE}/", sha, binario=True)
out.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(suffix=".zip.tmp", dir=out.parent) # scrittura atomica: o c'e' tutto o niente
os.close(fd)
try:
viste: set[str] = set()
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
for blob in (zip_repo, zip_sub):
with zipfile.ZipFile(io.BytesIO(blob)) as sorgente:
for info in sorgente.infolist():
if info.filename in viste:
continue # es. la cartella vendor/visionsuite/ del gitlink, gia' nel primo zip
viste.add(info.filename)
z.writestr(info, sorgente.read(info.filename), compress_type=zipfile.ZIP_DEFLATED)
if FILE_VERSIONE in viste:
raise ErroreArchivio(f"{FILE_VERSIONE} e' gia' nel repo a {ref}: va tolto dal versionamento")
# 40 caratteri ASCII, senza BOM e senza a capo (avvio.py fa .strip(), che non toglie un BOM)
z.writestr(FILE_VERSIONE, sha.encode("ascii"))
os.replace(tmp, out)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
return sha
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Zip del sorgente con submodule e vision-engine-version.txt (#109)")
p.add_argument("--out", required=True, type=Path, help="file .zip da creare")
p.add_argument("--ref", default="HEAD", help="commit da archiviare (default HEAD)")
a = p.parse_args(argv)
radice = Path(__file__).resolve().parents[1]
try:
sha = crea_archivio(radice, a.out, a.ref)
except ErroreArchivio as e:
print(f"ERRORE: {e}", file=sys.stderr)
return 1
print(f"{a.out} creato; {FILE_VERSIONE} = {sha}")
return 0
if __name__ == "__main__":
sys.exit(main())
+4 -4
View File
@@ -170,7 +170,7 @@ PAGINA = r"""<!doctype html>
<div class="titolo">Immagine<span>grezza, senza correzione</span></div>
<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>
<tmf-viewer id="immagine" save-name="grezza" title-save="Salva l'immagine grezza" hidden></tmf-viewer>
<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>
@@ -178,7 +178,7 @@ PAGINA = r"""<!doctype html>
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
<div class="palco"> <!-- #122 -->
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
<tmf-viewer id="immagineCal" save-name="calibrata" title-save="Salva l'immagine calibrata" hidden></tmf-viewer>
<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 -->
@@ -241,7 +241,7 @@ PAGINA = r"""<!doctype html>
<!-- #94: immagine viva (stesso frame dell'anteprima) e «Blocca immagine» per calcolare su quella -->
<div class="palco" style="margin-top:0"> <!-- #122 -->
<div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
<tmf-viewer id="immagineCalTab" save-name="calibrazione" title-save="Salva l'immagine della calibrazione" hidden></tmf-viewer>
<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>
@@ -268,7 +268,7 @@ PAGINA = r"""<!doctype html>
<button id="salva">Salva</button>
</div>
<div id="msgSalva" class="messaggio"></div>
<tmf-viewer id="overlay" class="angoli" fit-height save-name="angoli" title-save="Salva l'immagine degli angoli"></tmf-viewer> <!-- #122: era un <img> senza zoom -->
<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>
+92
View File
@@ -0,0 +1,92 @@
"""Test di scripts/archivio_sorgente.py (TieMeasureFlow#109): repo temporaneo con un submodule finto.
Uso: uv run --no-project --python 3.13 --with pytest pytest -q scripts/tests/test_archivio_sorgente.py
"""
import importlib.util
import subprocess
import zipfile
from pathlib import Path
import pytest
_spec = importlib.util.spec_from_file_location(
"archivio_sorgente", Path(__file__).resolve().parents[1] / "archivio_sorgente.py")
archivio = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(archivio)
GIT = ["git", "-c", "core.autocrlf=false", "-c", "protocol.file.allow=always",
"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]
def _g(cartella, *args):
r = subprocess.run([*GIT, "-C", str(cartella), *args], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return r.stdout.strip()
def _repo(tmp_path, nome):
d = tmp_path / nome
d.mkdir()
_g(d, "init", "-q", "-b", "main")
return d
@pytest.fixture
def padre(tmp_path):
"""Repo padre con vendor/visionsuite registrato (submodule finto da un repo locale)."""
origine = _repo(tmp_path, "origine")
(origine / "motore.py").write_text("x = 1\n")
_g(origine, "add", ".")
_g(origine, "commit", "-q", "-m", "motore")
p = _repo(tmp_path, "padre")
(p / "app.py").write_text("print('tmf')\n")
_g(p, "add", ".")
_g(p, "commit", "-q", "-m", "app")
_g(p, "submodule", "add", "-q", str(origine), "vendor/visionsuite")
_g(p, "commit", "-q", "-m", "submodule")
return p
def test_zip_con_repo_submodule_e_versione(padre, tmp_path):
atteso = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
out = tmp_path / "o" / "tmf.zip"
assert archivio.crea_archivio(padre, out) == atteso
with zipfile.ZipFile(out) as z:
nomi = z.namelist()
assert "app.py" in nomi and "vendor/visionsuite/motore.py" in nomi
dati = z.read("vision-engine-version.txt")
assert dati == atteso.encode("ascii") and len(dati) == 40
assert not dati.startswith(b"\xef\xbb\xbf") and b"\n" not in dati # niente BOM, niente a capo
def test_sha_resta_quello_registrato_se_il_submodule_avanza(padre, tmp_path):
registrato = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
sub = padre / "vendor" / "visionsuite"
(sub / "nuovo.py").write_text("y = 2\n")
_g(sub, "add", ".")
_g(sub, "commit", "-q", "-m", "avanti non registrato")
assert _g(sub, "rev-parse", "HEAD") != registrato
out = tmp_path / "tmf.zip"
assert archivio.crea_archivio(padre, out) == registrato
with zipfile.ZipFile(out) as z:
assert z.read("vision-engine-version.txt").decode() == registrato
assert "vendor/visionsuite/nuovo.py" not in z.namelist() # contenuto del commit registrato
def test_submodule_non_inizializzato_errore_e_nessuno_zip(padre, tmp_path):
clone = tmp_path / "clone"
_g(tmp_path, "clone", "-q", str(padre), str(clone)) # senza --recurse-submodules
out = tmp_path / "tmf.zip"
with pytest.raises(archivio.ErroreArchivio, match="inizializzato"):
archivio.crea_archivio(clone, out)
assert not out.exists() and not list(tmp_path.glob("*.tmp")) # nessuno zip ne' temporaneo
def test_cli_esce_1_con_messaggio(padre, tmp_path, capsys, monkeypatch):
clone = tmp_path / "clone"
_g(tmp_path, "clone", "-q", str(padre), str(clone))
originale = archivio.crea_archivio
# la CLI lavora sul repo che contiene lo script: la si dirotta sul clone senza submodule
monkeypatch.setattr(archivio, "crea_archivio", lambda r, o, ref="HEAD": originale(clone, o, ref))
assert archivio.main(["--out", str(tmp_path / "x.zip")]) == 1
assert "ERRORE" in capsys.readouterr().err
+229 -21
View File
@@ -2,7 +2,8 @@
* 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,
* Ctrl+rotella intorno al puntatore, pan col puntatore, overlay SVG a tratto costante ed etichette.
* 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
@@ -12,6 +13,16 @@
* 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.
@@ -21,6 +32,12 @@
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 = {
@@ -66,6 +83,14 @@
'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>
@@ -90,7 +115,7 @@
function defineViewer() {
var OBSERVED = ['src', 'width', 'height', 'fit-height', 'no-save', 'save-name',
'label-fit', 'label-save', 'title-in', 'title-out', 'title-save'];
'label-fit', 'label-save', 'title-in', 'title-out', 'title-save', 'no-layers'];
class TmfViewer extends HTMLElement {
static get observedAttributes() { return OBSERVED; }
@@ -105,6 +130,8 @@
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() {
@@ -113,7 +140,8 @@
this._built = true;
// proprieta' impostate prima dell'upgrade dell'elemento: le riporto sugli accessor
var self = this;
['overlay', 'labels'].forEach(function (k) {
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);
@@ -123,6 +151,17 @@
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;
@@ -131,8 +170,8 @@
var labels = document.createElement('div'); labels.className = 'tmfv-labels';
stage.appendChild(img); stage.appendChild(ov); stage.appendChild(labels);
vp.appendChild(stage);
this._bar = bar; this._vp = vp; this._stage = stage; this._img = img; this._ovEl = ov; this._lbEl = labels;
this.appendChild(bar); this.appendChild(vp);
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); };
@@ -181,9 +220,13 @@
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;
@@ -203,7 +246,7 @@
// #122: adotta un figlio <svg> come contenuto (modo contenuto, DXF)
_take(el) {
if (!el || el === this._bar || el === this._vp || el.parentNode !== this) return;
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;
@@ -213,7 +256,7 @@
_adopt() {
var kids = Array.prototype.slice.call(this.children), self = this, found = false;
kids.forEach(function (k) { if (k !== self._bar && k !== self._vp) { self._take(k); found = true; } });
kids.forEach(function (k) { if (k !== self._bar && k !== self._layBar && k !== self._vp) { self._take(k); found = true; } });
if (found) this._refresh();
}
@@ -315,17 +358,30 @@
}
// #122: zoom di un fattore f tenendo fermo il punto (px, py) del viewport (default: il centro)
zoomBy(f, px, py) {
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;
var s = TmfViewerMath.zoomAround(z0, z1, vp.scrollLeft, vp.scrollTop, px, py);
// #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);
}
@@ -341,6 +397,7 @@
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;
@@ -361,17 +418,26 @@
this._vp.style.touchAction = can ? 'none' : 'auto'; // il dito trascina solo se c'e' qualcosa da scorrere
}
_wheel(ev) {
if (!ev.ctrlKey) return;
_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();
this.zoomBy(ev.deltaY < 0 ? STEP : 1 / STEP, ev.clientX - r.left - this._vp.clientLeft, ev.clientY - r.top - this._vp.clientTop);
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 */ }
@@ -386,36 +452,178 @@
_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
// #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);
ov.innerHTML = VO && this._ov && this._ov.length ? VO.svg(this._ov, { zoom: this._z || 1 }) : '';
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;
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 = (l.x / self._w * 100) + '%';
d.style.top = (l.y / self._h * 100) + '%';
d.textContent = l.text == null ? '' : String(l.text);
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(); }
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._drawLabels(); }
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() {
@@ -8,7 +8,8 @@
*
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
* labels(rows, overlay) -> [{key, x, y, text, status}];
* svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto).
* 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).
@@ -264,7 +265,159 @@ var VisionOverlay = (function () {
return out;
}
return { draw: draw, anchor: anchor, labels: labels, svg: svg };
// ---- #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;
@@ -733,7 +733,8 @@
{# #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>) #}
<tmf-viewer :src="imagePreviewUrl" save-name="immagine"
{# #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()"
@@ -745,7 +746,8 @@
</div>
<div x-show="dxfPreview" class="sm:col-start-2">{# #104: resta a destra anche senza anteprima immagine #}
{# #122: <tmf-viewer> in modo contenuto: lo <svg> (viewBox in mm) va nello stage; width/height = misura del viewBox, ora anche Salva (PNG) #}
<tmf-viewer save-name="dxf"
{# #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') }}"
@@ -790,7 +792,8 @@
<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>
{# #122: <tmf-viewer fit-height> al posto di barra, viewport, pan e overlay della Prova (#104 #121); overlay dalle forme del risultato #}
<tmf-viewer fit-height :src="imageSrc" save-name="prova"
{# #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') }}"
@@ -581,7 +581,8 @@
{# #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">
<tmf-viewer class="block w-full h-full" :src="shownImage" :save-name="saveName"
{# #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>
@@ -25,8 +25,17 @@
<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>
@@ -35,6 +44,7 @@ window.onerror = function (m, u, l) { document.getElementById('log').textContent
(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);
@@ -75,7 +85,7 @@ window.onerror = function (m, u, l) { document.getElementById('log').textContent
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 non intercettata', we.defaultPrevented === false);
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);
@@ -108,7 +118,7 @@ window.onerror = function (m, u, l) { document.getElementById('log').textContent
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.querySelector('.tmfv-ov').children.length === 1 && e.querySelectorAll('.tmfv-label').length === 1);
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');
@@ -131,5 +141,64 @@ window.onerror = function (m, u, l) { document.getElementById('log').textContent
}, 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>
@@ -361,5 +361,93 @@ const SHAPE_RE = /<(polygon|line|circle|path|polyline) /g;
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);
+3
View File
@@ -80,6 +80,9 @@ def _graph(dxf_sha: str, cal_sha: str, band_mm: float, min_points_pct: float, po
"band_plus": float(band_mm),
"band_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),
+69
View File
@@ -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"):
Generated
+34
View File
@@ -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" },
]