Compare commits

..

88 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:52:50 +01:00
Adriano 7a1df0bca4 Pannello visione: la tabella quote mostra description invece di #id
Refs #114

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 16:33:09 +01:00
Adriano 198c6c63c4 Wizard DXF: Adatta misura l'area senza barre di scorrimento (offset meno bordi). Refs #113
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 12:44:26 +01:00
Adriano 04b81ad2f2 Wizard DXF: correzioni revisione zoom anteprime (style statico, x-show, altezza utile). Refs #113
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:39:06 +01:00
Adriano 45c9e6c3f3 Wizard DXF: zoom, Adatta e pan sulle anteprime immagine e DXF. Refs #113
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:35:10 +01:00
Adriano ca6cd0cab1 Etichette uscite visione: count/n_points/area non ereditano la quantità del tool, ratio in %. Refs #112
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:17:13 +01:00
Adriano 34c251aa90 Test blocco: ciclo con latoAnteprima() al posto di 1280. Refs #111
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:59:25 +01:00
Adriano c70d29d6d7 Acquisizione: visore zoom, adatta() rinominata adattaVisore (collideva con quella dei parametri camera)
Aggiunto test che vieta funzioni dichiarate due volte nello script della pagina.

Refs #111

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:55:09 +01:00
Adriano 96aca975d0 Merge branch 'lavoro/daitarn-tmf-flip' into lavoro/daitarn-tmf-zoom 2026-10-28 10:53:00 +01:00
Adriano b3c56ef5b5 Acquisizione: zoom +/-, Adatta e pan sui tre riquadri immagine, lato anteprima che segue lo zoom
Barra in sovrimpressione fuori dal palco che scorre, Ctrl+rotella, pan col puntatore.

Refs #111

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:52:30 +01:00
Adriano 9111eb2741 Orientamento: se il salvataggio fallisce si ripristina lo stato di prima in memoria. Refs #110
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:49:46 +01:00
Adriano b46a70347f Merge branch 'lavoro/daitarn-tmf-flip-pagina' into lavoro/daitarn-tmf-flip-unione 2026-10-28 10:43:39 +01:00
Adriano 155abfd15c Acquisizione: caselle di specchiatura orizzontale e verticale per sorgente
Refs #110

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:39:49 +01:00
Adriano 2c2d601495 Specchiatura orizzontale/verticale per sorgente nel servizio camera, salvata su disco. Refs #110
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:38:49 +01:00
Adriano b2e3867370 Test scalda camere. Refs #108
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:16:40 +01:00
Adriano cab419bdc7 Avvio: scalda le camere configurate con un grab a vuoto. Refs #108
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:16:31 +01:00
Adriano 249999fa1e Test pagina e JS per conversione BMP->PNG. Refs #107
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:10:21 +01:00
Adriano a3e032d000 Disegno Tecnico: BMP convertiti in PNG nel browser, errore upload nel riquadro. Refs #107
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:07:52 +01:00
Adriano 57376556d2 camera: commento admin preciso, test timeout grab maker (Refs #106)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 17:57:43 +01:00
Adriano 7cb877b11b Timeout grab camera 60 s e sorgenti admin 15 s (prima apertura GenTL lenta). Refs #106
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 17:54:56 +01:00
Adriano 08616aa1fa maker: pan dell'immagine di prova non si interrompe col mouse (Refs #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:46:57 +01:00
Adriano 02f1481fe5 maker: zoom in sovrimpressione, pan col puntatore e viewport che segue l'immagine (Refs #104)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:42:42 +01:00
Adriano 34e1b042f5 maker: risultati della Prova a destra dell'immagine da 640px (Refs #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:36:44 +01:00
Adriano bdef1f1cd8 maker: anteprima DXF sempre a destra, avviso immagine tolto dopo il salvataggio (Refs #104 #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:35:11 +01:00
Adriano 39b9ad77eb vision: condizione della quota su due righe leggibili (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:16 +01:00
Adriano 12632323e8 merge #105 105b: wizard Modifica ricetta da DXF (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:10 +01:00
Adriano 839a61e479 merge #105 105a: ricetta DXF riapribile, backend (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:10 +01:00
Adriano 9b8e729927 maker: Modifica ricetta da DXF, il wizard si riapre compilato (Refs #105)
Due proxy Flask (/source e /files, byte) e wizard che all'init legge la ricetta
esistente: etichetta del bottone dinamica, apertura con banda, soglia, polarita',
mm/px, DXF e immagine di riferimento; risposte tardive non sovrascrivono le scelte manuali.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:27:20 +01:00
Adriano 6080e6aebc vision: ricetta DXF riapribile, immagine salvata, rotte source e files, soglia segue la quota (Refs #105)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:25:37 +01:00
Adriano d5db5f3014 merge #102 #104: anteprima immagine, disposizione del wizard DXF, prova su due colonne con zoom (Refs #102 #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:21:43 +01:00
Adriano 1a19bb6679 maker: wizard DXF con anteprime sotto le scelte, mm/px solo da file, risultato su due colonne con zoom e Fit (Refs #104)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:21:19 +01:00
Adriano 20f33234a8 maker: anteprima immagine nel wizard Crea ricetta da DXF (Refs #102)
Anteprima dell'immagine scelta/acquisita accanto a quella del DXF, con
pixel e campo in mm (scala a mano o calibrazione stazione); fallback se
il formato non e' mostrabile. Test JS e di pagina, voci it/en.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:14:10 +01:00
Adriano 711abc96df merge #103: DXF a entità aperte unito in contorni chiusi (Refs #103)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:11:53 +01:00
Adriano 77419f9ebd vision: il DXF di confronto unisce le entita' aperte in contorni chiusi (Refs #103)
chiudi_contorni unisce LINE/ARC aperti in LWPOLYLINE chiuse prima di
costruire la ricetta e per l'anteprima: con banda larga cut_open_ends
non lasciava piu' punti in banda.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:11:18 +01:00
Adriano 4791c34205 merge #101 101b: anteprima DXF disegnata con path (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:06:54 +01:00
Adriano e582df7962 maker: anteprima DXF disegnata con path e viewBox camel (Refs #101)
Un solo <path :d="dxfPreviewD()"> al posto di template x-for + polyline
(x-for non funziona nel namespace SVG) e x-bind:view-box.camel al posto
di :viewBox (il parser HTML abbassa il nome dell'attributo).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:02:04 +01:00
Adriano a7b91b5508 merge #101 101b: anteprima DXF nel wizard (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:59:25 +01:00
Adriano addf5a2598 merge #101 101a: anteprima DXF nel wizard (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:59:14 +01:00
Adriano bdb1e8c8b9 maker: anteprima del DXF nel wizard «Crea ricetta da DXF» (Refs #101)
Proxy Flask /maker/api/vision/dxf-preview (piano B), stato e funzioni
dxfPreview* nel wizard, riquadro SVG con L x H sotto il campo .dxf,
traduzioni e test (proxy, JS con node, pagina).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:56:45 +01:00
Adriano ebaafbe6e9 vision: anteprima DXF nel worker e nel backend (Refs #101)
Worker: POST /dxf/preview legge il DXF con read_xld_dxf (lo stesso lettore di
CompareDxf) e ritorna contorni in mm con y verso il basso, bbox, dimensioni,
entita e punti (tetto 20000, decimazione uniforme). Backend: POST
/api/vision/dxf-preview (Maker) che inoltra al worker; 422 inoltrato.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:55:43 +01:00
Adriano 351153e78a merge #100: statistiche con «Tutti i punti» (Refs #100)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:52:17 +01:00
Adriano 842ceca007 statistiche: con Tutti i punti tabella capability per punto, carta e istogramma solo con punto scelto, il select ricarica da solo (Refs #100)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:51:38 +01:00
Adriano b78f2422d8 merge #99: BMP nelle rotte visione (Refs #99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:47:21 +01:00
Adriano 27d7c1e832 maker: mm/px a mano nel wizard DXF per immagini da file (Refs #98)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:37:40 +01:00
89 changed files with 7268 additions and 301 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:
+65
View File
@@ -348,6 +348,69 @@ def _nessun_utente(db: Path) -> bool:
return False
def camere_da_scaldare(db: Path) -> list[tuple[str, str]]:
"""Coppie (camera_url, camera_source) configurate nel db (stazioni e task di ricetta), senza doppioni.
Solo sorgenti "camera:" che aprono hardware (non "camera:folder"). Errore sqlite -> lista vuota (#108)."""
coppie: list[tuple[str, str]] = []
def aggiungi(url, src) -> None:
# #108: valida la coppia e scarta duplicati e sorgenti che non aprono hardware
if not (isinstance(url, str) and isinstance(src, str) and url.strip() and src.strip()):
return
if not src.startswith("camera:") or src == "camera:folder":
return
if (url, src) not in coppie:
coppie.append((url, src))
try:
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
try:
for url, src in con.execute("SELECT camera_url, camera_source FROM stations"):
aggiungi(url, src)
for (blob,) in con.execute("SELECT acquisition_json FROM recipe_tasks WHERE acquisition_json IS NOT NULL"):
try:
cfg = json.loads(blob)
except (TypeError, ValueError):
continue # #108: JSON non valido, si salta
if isinstance(cfg, dict):
aggiungi(cfg.get("camera_url"), cfg.get("camera_source"))
finally:
con.close()
except Exception:
return []
return coppie
def scalda_camere(db: Path) -> None:
"""Apre ogni camera configurata con un grab a vuoto, cosi' il primo "Acquisisci" non aspetta ~25 s (#108).
Gira in un thread daemon: nessuna eccezione esce."""
try:
camere = camere_da_scaldare(db)
if not camere:
return
for _ in range(300): # aspetta il servizio camera (max ~60 s), come _apri_browser_quando_pronto
if _porta_aperta(PORTA_CAMERA):
break
time.sleep(0.2)
else:
log.warning("scalda camere: servizio camera non raggiungibile, salto")
return
for url, src in camere:
t0 = time.monotonic()
try:
richiesta = urllib.request.Request(
f"{url.rstrip('/')}/camera/grab",
data=json.dumps({"source": src, "trigger": "continuo", "timeout_ms": 5000}).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(richiesta, timeout=90) as r:
esito = f"HTTP {r.status}"
log.info("scalda camera %s: %s in %.1f s", src, esito, time.monotonic() - t0)
except Exception as e: # #108: una camera che non risponde non ferma le altre
log.warning("scalda camera %s: %s dopo %.1f s", src, e, time.monotonic() - t0)
except Exception:
log.exception("scalda camere non riuscito")
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
for _ in range(300):
@@ -509,6 +572,8 @@ def main(argv=None) -> int:
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
thread = avvia_server(args.host, dati)
# #108: apre le camere configurate con un grab a vuoto, con o senza browser
threading.Thread(target=scalda_camere, args=(dati / "tmf.db",), daemon=True).start()
if not args.senza_browser:
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
+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
+84
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)
@@ -271,3 +281,77 @@ def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path,
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
# ---------------------------------------------------------------- scalda camere (#108)
def _db_camere(percorso):
# #108: db minimo con le due tabelle lette da camere_da_scaldare
con = sqlite3.connect(percorso)
con.execute("CREATE TABLE stations (id INTEGER PRIMARY KEY, camera_url TEXT, camera_source TEXT)")
con.execute("CREATE TABLE recipe_tasks (id INTEGER PRIMARY KEY, acquisition_json TEXT)")
con.executemany("INSERT INTO stations (camera_url, camera_source) VALUES (?, ?)", [
("http://127.0.0.1:8765", "camera:601020"), # valida
(None, None), # stazione senza camera
("http://127.0.0.1:8765", "camera:folder"), # non apre hardware
])
con.executemany("INSERT INTO recipe_tasks (acquisition_json) VALUES (?)", [
(json.dumps({"camera_url": "http://127.0.0.1:8765", "camera_source": "camera:601020"}),), # duplicato
(json.dumps({"camera_url": "http://10.0.0.5:8765/", "camera_source": "camera:777"}),), # valida
("{rotto",), # JSON non valido
(None,),
])
con.commit()
con.close()
def test_camere_da_scaldare_filtra_e_deduplica(avvio, tmp_path):
db = tmp_path / "tmf.db"
_db_camere(db)
assert avvio.camere_da_scaldare(db) == [
("http://127.0.0.1:8765", "camera:601020"),
("http://10.0.0.5:8765/", "camera:777"),
]
def test_camere_da_scaldare_db_inesistente(avvio, tmp_path):
assert avvio.camere_da_scaldare(tmp_path / "non_esiste.db") == []
def test_scalda_camere_una_chiamata_per_coppia(avvio, tmp_path, monkeypatch):
db = tmp_path / "tmf.db"
_db_camere(db)
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
chiamate = []
class Risposta:
status = 200
def __enter__(self): return self
def __exit__(self, *a): return False
def finto_urlopen(richiesta, timeout=None):
chiamate.append((richiesta.full_url, json.loads(richiesta.data), richiesta.get_method(), timeout))
return Risposta()
monkeypatch.setattr(avvio.urllib.request, "urlopen", finto_urlopen)
avvio.scalda_camere(db)
corpo = {"trigger": "continuo", "timeout_ms": 5000}
assert chiamate == [
("http://127.0.0.1:8765/camera/grab", {"source": "camera:601020", **corpo}, "POST", 90),
("http://10.0.0.5:8765/camera/grab", {"source": "camera:777", **corpo}, "POST", 90),
]
def test_scalda_camere_eccezione_urlopen_non_esce(avvio, tmp_path, monkeypatch):
db = tmp_path / "tmf.db"
_db_camere(db)
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
tentativi = []
def urlopen_rotto(richiesta, timeout=None):
tentativi.append(richiesta.full_url)
raise OSError("camera irraggiungibile")
monkeypatch.setattr(avvio.urllib.request, "urlopen", urlopen_rotto)
avvio.scalda_camere(db) # non solleva e passa alla camera successiva
assert len(tentativi) == 2
+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())
+157 -33
View File
@@ -17,7 +17,9 @@ un'opzione della calibrazione: la calibrazione usa sempre il frame grezzo della
scelta nella tab Acquisizione. Sorgente in cima alla tab; riquadri «Immagine» (grezza) e,
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,6 +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; }
/* #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%; }
@@ -113,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; }
@@ -164,22 +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="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>
<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="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>
<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">
@@ -220,6 +220,12 @@ PAGINA = r"""<!doctype html>
<section class="scheda" id="sezioneParametri">
<h2>Parametri camera</h2>
<div id="parametri"><div class="nota">-</div></div>
<!-- #110: specchiatura per sorgente, applicata dal servizio a ogni frame -->
<div class="riga" id="specchiaRiga" style="margin-top:10px">
<label style="margin:0 0 6px"><input id="specchiaO" type="checkbox" disabled> Specchia orizzontale</label>
<label style="margin:0 0 6px"><input id="specchiaV" type="checkbox" disabled> Specchia verticale</label>
</div>
<div id="msgSpecchia" class="messaggio"></div>
<div class="riga" id="salvaParametriRiga" hidden>
<button id="salvaParametri" class="secondario">Salva come predefiniti</button>
</div>
@@ -233,11 +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="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>
<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>
@@ -264,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>
@@ -293,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); };
@@ -463,6 +471,7 @@ function cambioSorgente(salva) {
if (A.bloccata) rilasciaBlocco(); // #94: casella spenta e DELETE sulla sorgente vecchia (prima di svuotare i riquadri)
azzeraIndicatori();
svuotaRiquadri(); // #92: il frame vecchio non e' della nuova sorgente
tornaAdatta(); // #111: nuova sorgente = visori ad Adatta
A.calAttiva = null; A.bloccoCal = false; // #92: stato calibrazione della nuova sorgente ancora da leggere
annullaRimuovi();
$("rimuoviImmagine").hidden = !sorgenteImmagine();
@@ -470,6 +479,7 @@ function cambioSorgente(salva) {
dire("msgElenco", ""); // #91
aggiornaStato();
caricaParametri();
caricaOrientamento(); // #110: specchiatura della nuova sorgente
if (era) avviaAnteprima();
}
@@ -478,6 +488,45 @@ 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 };
// ---- #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 = {};
// #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 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();
});
}
// #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.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, idViewer, idMirino) {
var el = $(idViewer);
var v = { el: el, z: 0, fit: 0 };
V[chiave] = 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", "immagine", "mirino");
creaVisore("cal", "immagineCal", "mirinoCal");
creaVisore("tab", "immagineCalTab", "");
function azzeraIndicatori() {
A.nitMax = 0;
["nitidezza", "saturati", "media", "dimensioni", "nitidezzaMax"].forEach(function (i) { $(i).textContent = "-"; });
@@ -496,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;
}
@@ -514,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 = "";
@@ -529,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».");
@@ -587,7 +636,7 @@ function aggiornaIndicatori(h) {
$("dimensioni").textContent = fmt(w, 0) + " x " + fmt(a, 0);
// #91: risoluzione nota del frame, per segnare le calibrazioni che non combaciano
var nw = parseInt(w, 10), na = parseInt(a, 10);
if (!A.ris || A.ris.w !== nw || A.ris.h !== na) { A.ris = { w: nw, h: na }; segnaRisoluzioni(); }
if (!A.ris || A.ris.w !== nw || A.ris.h !== na) { A.ris = { w: nw, h: na }; segnaRisoluzioni(); tornaAdatta(); } // #111: risoluzione diversa = Adatta
}
$("raddrizzato").textContent = "frame grezzo"; // #92: l'indicatore viene sempre dal frame grezzo
}
@@ -632,7 +681,7 @@ async function ciclo(g) {
while (A.bloccando) await pausa(20); // #94: durante «Blocca immagine» nessuna nuova anteprima
if (!A.attiva || g !== A.gen) return;
var t0 = Date.now();
var esito = await unaAnteprima(1280);
var esito = await unaAnteprima(latoAnteprima()); // #111: 1280 salvo zoom sull'Acquisizione
if (g !== A.gen) return;
if (esito === "fatale") { fermaAnteprima(); return; }
await pausa(esito === "errore" ? 1500 : Math.max(100 - (Date.now() - t0), 0));
@@ -675,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;
@@ -686,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;
@@ -793,6 +842,80 @@ async function caricaParametri() {
}
}
// #110: specchiatura per sorgente; gen scarta le risposte di una sorgente ormai lasciata, dati = ultimo stato noto del servizio
var O = { gen: 0, dati: { orizzontale: false, verticale: false } };
function caselleSpecchia(attive) { // #110: abilita o disabilita le due caselle
$("specchiaO").disabled = $("specchiaV").disabled = !attive;
}
async function caricaOrientamento() { // #110
var sorgente = $("sorgente").value;
var g = ++O.gen;
dire("msgSpecchia", "");
O.dati = { orizzontale: false, verticale: false };
$("specchiaO").checked = $("specchiaV").checked = false;
caselleSpecchia(false); // spente finche' non si sa lo stato (e senza sorgente)
if (!sorgente) return;
try {
var r = await fetch("/camera/orientamento?source=" + encodeURIComponent(sorgente));
if (g !== O.gen) return;
if (!r.ok) { dire("msgSpecchia", await dettaglio(r), "errore"); return; }
var j = await r.json();
if (g !== O.gen) return;
O.dati = { orizzontale: !!j.orizzontale, verticale: !!j.verticale };
$("specchiaO").checked = O.dati.orizzontale;
$("specchiaV").checked = O.dati.verticale;
caselleSpecchia(true);
} catch (e) {
if (g === O.gen) dire("msgSpecchia", "Servizio non raggiungibile.", "errore");
}
}
async function salvaOrientamento() { // #110: onchange di una casella -> PUT
var sorgente = $("sorgente").value;
if (!sorgente) return;
var g = ++O.gen;
var nuovo = { orizzontale: $("specchiaO").checked, verticale: $("specchiaV").checked };
caselleSpecchia(false); // durante la PUT niente altri cambi
dire("msgSpecchia", "");
var rimetti = function () { // #110: casella com'era prima del clic
$("specchiaO").checked = O.dati.orizzontale;
$("specchiaV").checked = O.dati.verticale;
};
try {
var r = await fetch("/camera/orientamento", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: sorgente, orizzontale: nuovo.orizzontale, verticale: nuovo.verticale })
});
if (g !== O.gen) return; // sorgente cambiata: ci pensa caricaOrientamento
if (!r.ok) {
var d = await dettaglio(r);
if (g !== O.gen) return;
rimetti();
caselleSpecchia(true);
dire("msgSpecchia", d, "errore");
return;
}
var j = await r.json();
if (g !== O.gen) return;
O.dati = nuovo;
caselleSpecchia(true);
if (j.avviso) dire("msgSpecchia", j.avviso, "avviso"); // es. calibrazione da rifare
else dire("msgSpecchia", "Specchiatura salvata.", "ok");
if (!A.attiva) { // anteprima ferma: un frame solo per vedere l'effetto (se e' attiva, il prossimo esce gia' specchiato)
while (A.inVolo) await pausa(50);
if (g === O.gen && !A.attiva) unaAnteprima(A.lato);
}
} catch (e) {
if (g !== O.gen) return;
rimetti();
caselleSpecchia(true);
dire("msgSpecchia", "Servizio non raggiungibile.", "errore");
}
}
function costruisciParametri(j) {
var box = $("parametri");
box.textContent = "";
@@ -1283,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;
@@ -1393,6 +1516,7 @@ $("acquisisci").onclick = calcola;
$("salva").onclick = salvaNome;
$("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); };
$("sorgente").onchange = function () { dire("msgSorgente", ""); cambioSorgente(true); }; // #92
$("specchiaO").onchange = $("specchiaV").onchange = salvaOrientamento; // #110
scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata
caricaSorgenti();
</script>
+110 -3
View File
@@ -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).
@@ -61,7 +62,17 @@ Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
POST /camera/parametri/salva json {source} -> {source, salvati, file}
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
della camera nel servizio il file, se c'e', viene ricaricato.
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json (default
#110: specchiatura per sorgente, in software (cv2.flip) su OGNI frame letto dalla
sorgente, prima di raddrizzatura/calibrazione (anteprima, grab, calibrazione da camera,
copia bloccata); non tocca l'immagine caricata a mano in /camera/calibrate.
GET /camera/orientamento?source=S -> {source, orizzontale, verticale} (default false/false);
400 se manca source
PUT /camera/orientamento json {source, orizzontale: bool, verticale: bool} -> come GET +
"avviso" (stringa o null: la calibrazione attiva ha una mappa fatta con l'orientamento
di prima e va rifatta). 400 se source manca o i due campi non sono bool. Salva subito
su disco; scarta i frame tenuti (anteprima/blocca).
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json, l'orientamento
per sorgente in orientamento.json (#110) (default
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
@@ -87,6 +98,7 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
from __future__ import annotations
import argparse
import json # #110: orientamento.json
import logging
import math # #95: mm/px finito nella calibrazione manuale
import os
@@ -114,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
@@ -167,6 +185,36 @@ def create_app(folder: Path | str | None = None,
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
lock_calcoli = threading.Lock()
file_orientamento = archivio.cartella / "orientamento.json" # #110: {sorgente: {orizzontale, verticale}}
def _carica_orientamenti() -> dict:
"""#110: orientamenti salvati; file assente o rotto -> {} (mai eccezione)."""
if not file_orientamento.is_file():
return {}
try:
dati = json.loads(file_orientamento.read_text(encoding="utf-8"))
if not isinstance(dati, dict):
raise ValueError("non e' un oggetto JSON")
return {n: {"orizzontale": v.get("orizzontale") is True, "verticale": v.get("verticale") is True}
for n, v in dati.items() if isinstance(n, str) and isinstance(v, dict)}
except (OSError, ValueError) as e:
_log.warning("orientamento non caricato (%s): %s", file_orientamento, e)
return {}
orientamenti: dict = _carica_orientamenti() # #110: sorgente -> {"orizzontale": bool, "verticale": bool}; protetto da `lock`
def _specchia(nome: str, frame):
"""#110: applica l'orientamento della sorgente al frame letto; nessuno -> lo stesso oggetto, senza copia."""
with lock:
o = orientamenti.get(nome) or {}
orizz, vert = o.get("orizzontale", False), o.get("verticale", False)
if orizz and vert:
return cv2.flip(frame, -1) # entrambi = rotazione di 180 gradi
if orizz:
return cv2.flip(frame, 1)
if vert:
return cv2.flip(frame, 0)
return frame
def _config() -> dict:
if folder is None:
@@ -317,7 +365,7 @@ def create_app(folder: Path | str | None = None,
def _grab(nome: str, timeout: int):
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
return _con_sorgente(nome, lambda s: _specchia(nome, s.grab(timeout))) # #87: corpo in _con_sorgente; #110: specchiato prima di tutto il resto
@app.post("/camera/grab")
def camera_grab(corpo: dict = Body(default=None)):
@@ -544,7 +592,7 @@ def create_app(folder: Path | str | None = None,
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
f = s.grab(timeout)
f = _specchia(nome, s.grab(timeout)) # #110: specchiato PRIMA di tenerlo, cosi' blocca/calibrata lo ricevono gia' specchiato
with lock:
grezzi[nome] = f.copy()
return f
@@ -686,11 +734,70 @@ def create_app(folder: Path | str | None = None,
return _con_sorgente(nome, salva)
# ---- #110: specchiatura orizzontale/verticale per sorgente
def _orientamento_di(nome: str) -> dict:
with lock:
o = orientamenti.get(nome) or {}
return {"source": nome, "orizzontale": bool(o.get("orizzontale")), "verticale": bool(o.get("verticale"))}
@app.get("/camera/orientamento")
def orientamento_leggi(source: str = ""):
return _orientamento_di(_sorgente(source))
@app.put("/camera/orientamento")
def orientamento_scrivi(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
orizz, vert = corpo.get("orizzontale"), corpo.get("verticale")
if not isinstance(orizz, bool) or not isinstance(vert, bool):
raise HTTPException(400, "«orizzontale» e «verticale» devono essere true o false")
with lock:
prima = orientamenti.get(nome) or {}
prima_voce = orientamenti.get(nome) # #110: voce di prima (None = non c'era), per il ripristino se il salvataggio fallisce
cambiato = (bool(prima.get("orizzontale")), bool(prima.get("verticale"))) != (orizz, vert)
if orizz or vert:
orientamenti[nome] = {"orizzontale": orizz, "verticale": vert}
else:
orientamenti.pop(nome, None) # tutti false: la sorgente esce dal file
# scrittura atomica di tutto il dizionario (tmp + replace)
try:
file_orientamento.parent.mkdir(parents=True, exist_ok=True)
tmp = file_orientamento.with_name(file_orientamento.name + ".tmp")
tmp.write_text(json.dumps(orientamenti, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, file_orientamento)
except OSError as e:
_log.warning("orientamento non salvato (%s): %s", file_orientamento, e)
if prima_voce is None: # #110: salvataggio fallito -> in memoria resta l'orientamento di prima
orientamenti.pop(nome, None)
else:
orientamenti[nome] = prima_voce
raise HTTPException(500, "salvataggio dell'orientamento non riuscito")
# i frame tenuti hanno l'orientamento di prima
grezzi.pop(nome, None)
bloccati.pop(nome, None)
avviso = None
if cambiato and archivio.leggi_mappa(nome) is not None:
avviso = (f"la calibrazione attiva «{archivio.attiva_di(nome)}» corregge la distorsione "
"con l'orientamento di prima: va rifatta")
return {**_orientamento_di(nome), "avviso": avviso}
@app.get("/calibrazione", response_class=HTMLResponse)
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
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
+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
+2 -1
View File
@@ -151,6 +151,7 @@ def test_pagina_blocco_attende_l_anteprima_in_volo():
assert "k < 150" in blocco and "pausa(20)" in blocco
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(1280)")
# #111: il lato dell'anteprima segue lo zoom (latoAnteprima(), 1280 senza zoom)
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(latoAnteprima())")
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
+186
View File
@@ -0,0 +1,186 @@
"""Test della specchiatura orizzontale/verticale per sorgente (#110): scripts/camera_service.py.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Copre: GET/PUT /camera/orientamento, effetto su /camera/grab e /camera/anteprima,
persistenza al riavvio, file rotto, validazione, scarto dei frame tenuti, avviso
sulla calibrazione densa. Sorgente: camera:folder su un'immagine 4x3 asimmetrica.
"""
import importlib.util
from pathlib import Path
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
from test_calibrazione import ( # calibrazione densa reale (#84), per l'avviso
SORGENTE as SORGENTE_DENSA, cartella_densa, carica_su, client_denso, salva_con_nome,
)
__all__ = ["cartella_densa", "client_denso"] # fixture importate, restano usate
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
SORGENTE = "camera:folder"
def _carica():
spec = importlib.util.spec_from_file_location("camera_service_orient", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def cartella(tmp_path):
# #110: 4x3 tutta nera con UN pixel acceso in alto a sinistra (riga 0, colonna 0)
img = tmp_path / "foto"
img.mkdir()
f = np.zeros((3, 4, 3), np.uint8)
f[0, 0] = 255
cv2.imwrite(str(img / "a.png"), f)
return img
@pytest.fixture
def config(tmp_path):
return tmp_path / "cfg"
@pytest.fixture
def client(cartella, config):
mod = _carica()
with TestClient(mod.create_app(cartella, config)) as c:
yield c
def _acceso(png: bytes):
"""(riga, colonna) del pixel acceso nel PNG; controlla che sia uno solo."""
f = cv2.imdecode(np.frombuffer(png, np.uint8), cv2.IMREAD_COLOR)
assert f.shape[:2] == (3, 4)
r, c = np.nonzero(f.max(axis=2) > 128)
assert len(r) == 1
return int(r[0]), int(c[0])
def _imposta(c, orizz, vert, source=SORGENTE):
return c.put("/camera/orientamento", json={"source": source, "orizzontale": orizz, "verticale": vert})
def _grab(c):
r = c.post("/camera/grab", json={"source": SORGENTE})
assert r.status_code == 200, r.text
return _acceso(r.content)
def test_get_senza_file_false_false(client):
r = client.get("/camera/orientamento", params={"source": SORGENTE})
assert r.status_code == 200
assert r.json() == {"source": SORGENTE, "orizzontale": False, "verticale": False}
def test_get_senza_source_400(client):
assert client.get("/camera/orientamento").status_code == 400
@pytest.mark.parametrize("orizz,vert,atteso", [
(False, False, (0, 0)), # originale: alto a sinistra
(True, False, (0, 3)), # orizzontale: alto a destra
(False, True, (2, 0)), # verticale: basso a sinistra
(True, True, (2, 3)), # entrambi: basso a destra (180 gradi)
])
def test_grab_specchiato(client, orizz, vert, atteso):
r = _imposta(client, orizz, vert)
assert r.status_code == 200, r.text
assert (r.json()["orizzontale"], r.json()["verticale"]) == (orizz, vert)
assert client.get("/camera/orientamento", params={"source": SORGENTE}).json()["orizzontale"] is orizz
assert _grab(client) == atteso
def test_tutti_false_toglie_la_sorgente_dal_file(client, config):
_imposta(client, True, False)
assert SORGENTE in (config / "orientamento.json").read_text(encoding="utf-8")
_imposta(client, False, False)
assert SORGENTE not in (config / "orientamento.json").read_text(encoding="utf-8")
assert _grab(client) == (0, 0)
def test_anteprima_specchiata_dimensioni_invariate(client):
_imposta(client, True, True)
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 4096})
assert r.status_code == 200, r.text
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("4", "3")
f = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
assert f.shape == (3, 4)
# JPEG con perdite: il pixel acceso e' il piu' chiaro, in basso a destra
assert np.unravel_index(int(np.argmax(f)), f.shape) == (2, 3)
def test_persistenza_al_riavvio(cartella, config):
mod = _carica()
with TestClient(mod.create_app(cartella, config)) as c1:
assert _imposta(c1, True, False).status_code == 200
with TestClient(_carica().create_app(cartella, config)) as c2:
j = c2.get("/camera/orientamento", params={"source": SORGENTE}).json()
assert (j["orizzontale"], j["verticale"]) == (True, False)
assert _grab(c2) == (0, 3)
def test_file_rotto_non_blocca_l_avvio(cartella, config):
config.mkdir(parents=True)
(config / "orientamento.json").write_text("{non e json", encoding="utf-8")
with TestClient(_carica().create_app(cartella, config)) as c:
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
assert (j["orizzontale"], j["verticale"]) == (False, False)
assert _imposta(c, False, True).status_code == 200 # e si puo' riscrivere
def test_put_campi_non_bool_400(client):
for corpo in ({"source": SORGENTE, "orizzontale": 1, "verticale": False},
{"source": SORGENTE, "orizzontale": True, "verticale": "si"},
{"source": SORGENTE, "orizzontale": True},
{"orizzontale": True, "verticale": True}):
assert client.put("/camera/orientamento", json=corpo).status_code == 400, corpo
def test_put_scarta_i_frame_tenuti(client):
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 200
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 200
_imposta(client, True, False)
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 404
# col frame tenuto scartato anche l'ultimo grezzo dell'anteprima e' sparito
assert client.post("/camera/anteprima/calibrata", json={"source": SORGENTE}).status_code == 404
def test_avviso_senza_mappa_e_null(client):
j = _imposta(client, True, False).json()
assert j["avviso"] is None
def test_avviso_con_mappa_attiva(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE_DENSA, "square_mm": "2"}).json()
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
assert carica_su(client_denso, SORGENTE_DENSA, "Densa").status_code == 200
r = _imposta(client_denso, True, False, source=SORGENTE_DENSA)
assert r.status_code == 200, r.text
assert "«Densa»" in r.json()["avviso"] and "va rifatta" in r.json()["avviso"]
# stesso valore di nuovo: nulla e' cambiato, nessun avviso
assert _imposta(client_denso, True, False, source=SORGENTE_DENSA).json()["avviso"] is None
def test_scrittura_fallita_500_e_orientamento_di_prima(cartella, config, monkeypatch):
# #110: se orientamento.json non si scrive, in memoria resta l'orientamento di prima
mod = _carica()
with TestClient(mod.create_app(cartella, config)) as c:
assert _imposta(c, True, False).status_code == 200 # stato di partenza: orizzontale
def rotto(*a, **k):
raise OSError("disco pieno")
monkeypatch.setattr(mod.os, "replace", rotto)
assert _imposta(c, False, True).status_code == 500
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
assert (j["orizzontale"], j["verticale"]) == (True, False)
assert _grab(c) == (0, 3) # ancora solo orizzontale
# sorgente che prima non c'era: la voce non resta
assert c.put("/camera/orientamento", json={"source": "altra", "orizzontale": True, "verticale": True}).status_code == 500
j = c.get("/camera/orientamento", params={"source": "altra"}).json()
assert (j["orizzontale"], j["verticale"]) == (False, False)
+87 -54
View File
@@ -1,54 +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()]
"""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, #122: i visori sono <tmf-viewer>, la barra e lo zoom vengono dal componente
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
for i in ("immagine", "immagineCal", "immagineCalTab", "overlay"): # grezza, calibrata, tab Calibrazione, angoli
assert re.search(r'<tmf-viewer id="%s"' % i, PAGINA), i
assert PAGINA.count("<tmf-viewer") == 4
for nome in ("grezza", "calibrata", "calibrazione", "angoli"):
assert 'save-name="%s"' % nome in PAGINA, nome
assert "zoomAdatta" not in PAGINA and "barraZoom" not in PAGINA and "creaVisore(" in PAGINA
assert not {"zoomMeno", "zoomPiu", "zoomPct"} & ids
assert "latoAnteprima()" in PAGINA and "unaAnteprima(1280)" not in PAGINA # lato_max segue lo zoom
assert '"viewer-zoom"' in PAGINA # lo zoom si legge dall'evento del componente
def test_script_del_visore_in_ordine(): # #122: i JS arrivano da /visore/ (offline), prima dello script di pagina
src = re.findall(r'<script src="([^"]+)"', PAGINA)
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
assert PAGINA.index("/visore/image_viewer.js") < PAGINA.index("<script>")
def test_nessuna_funzione_dichiarata_due_volte(): # #111: in JS vince l'ultima dichiarazione, in silenzio
script = re.search(r"<script>(.*?)</script>", PAGINA, re.S).group(1)
nomi = re.findall(r"function\s+([A-Za-z0-9_$]+)\s*\(", script)
doppi = sorted({n for n in nomi if nomi.count(n) > 1})
assert not doppi, "funzioni dichiarate piu' volte: " + ", ".join(doppi)
+84
View File
@@ -0,0 +1,84 @@
"""Test delle rotte /visore/*.js di camera_service e della pagina che le usa (#122).
Uso: python -m pytest scripts/tests/test_visore_rotte.py -q
Dipende da: venv con fastapi, httpx, opencv (come test_camera_service.py).
image_viewer.js lo scrive un altro piano (V1): se manca nei sorgenti il test della rotta usa una cartella temporanea con file finti.
"""
import importlib.util
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
NOMI = ("image_viewer.js", "save_image.js", "vision_overlay.js")
def _carica():
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def client(tmp_path):
mod = _carica()
with TestClient(mod.create_app(tmp_path)) as c:
c.mod = mod
yield c
@pytest.fixture
def cartella_finta(client, tmp_path):
# file finti in una cartella temporanea: la rotta si prova senza dipendere da image_viewer.js del frontend
d = tmp_path / "js"
d.mkdir()
for n in NOMI:
(d / n).write_text("/* finto " + n + " */\n", encoding="utf-8")
client.mod._CARTELLE_VISORE[:] = [d]
return d
@pytest.mark.parametrize("nome", NOMI)
def test_rotta_serve_il_file(client, cartella_finta, nome):
r = client.get("/visore/" + nome)
assert r.status_code == 200
assert r.headers["content-type"].startswith("application/javascript")
assert r.content == (cartella_finta / nome).read_bytes()
@pytest.mark.parametrize("nome", ["altro.js", "camera_service.py", "..%2Fcamera_service.py", "image_viewer.JS", "image_viewer.js.map"])
def test_altri_nomi_danno_404(client, cartella_finta, nome):
assert client.get("/visore/" + nome).status_code == 404
def test_file_mancante_nella_cartella_da_404(client, tmp_path):
client.mod._CARTELLE_VISORE[:] = [tmp_path / "vuota"]
assert client.get("/visore/save_image.js").status_code == 404
def test_cartelle_di_default_sono_static_js_del_frontend(client):
# sviluppo: sorgenti del frontend; pacchetto Nuitka: static/ accanto al modulo (build.ps1 la include)
cartelle = _carica()._CARTELLE_VISORE
assert any(c.parts[-5:] == ("src", "frontend", "flask_app", "static", "js") for c in cartelle)
assert any(c.parts[-2:] == ("static", "js") and c.parent.parent == MODULO.parent for c in cartelle)
@pytest.mark.parametrize("nome", ("save_image.js", "vision_overlay.js", "image_viewer.js"))
def test_file_reali_del_frontend(client, nome):
reale = next((c / nome for c in client.mod._CARTELLE_VISORE if (c / nome).is_file()), None)
if reale is None:
pytest.skip(nome + " non e' ancora nei sorgenti del frontend (lo scrive il piano V1)")
r = client.get("/visore/" + nome)
assert r.status_code == 200 and r.content == reale.read_bytes()
@pytest.mark.parametrize("rotta", ("/acquisizione", "/calibrazione"))
def test_pagina_usa_quattro_visori_e_i_tre_script(client, rotta):
html = client.get(rotta).text
assert html.count("<tmf-viewer") == 4
src = re.findall(r'<script src="(/visore/[^"]+)"', html)
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
assert "zoomAdatta" not in html
+6 -2
View File
@@ -357,9 +357,13 @@ async def export_measurements_csv(
# Rows
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 ""
)
+6 -2
View File
@@ -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)
+61 -1
View File
@@ -8,6 +8,7 @@ import base64
import binascii
import io
import json
import mimetypes
from typing import Literal
from fastapi.responses import FileResponse
@@ -161,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
],
@@ -366,6 +370,20 @@ async def preview(
return await vision_service.preview(db, task, await _read_image(image))
@router.post("/dxf-preview")
async def dxf_preview(
dxf: UploadFile = File(...),
current_user: User = Depends(require_maker),
) -> dict:
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
dxf_bytes = await dxf.read()
if not validate_file_size(len(dxf_bytes)):
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
return await vision_service.dxf_preview(dxf_bytes)
@router.post("/tasks/{task_id}/recipe/dxf-compare")
async def create_dxf_compare_recipe(
task_id: int,
@@ -433,8 +451,50 @@ async def create_dxf_compare_recipe(
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
if errors:
raise _422(errors)
# #105: l'immagine di riferimento resta nella cartella (non e' in meta, quindi
# /validate e l'esecuzione la ignorano) e il wizard puo' riaprirla; entra nel digest.
placed[f"riferimento.{_IMAGE_EXTENSIONS.get(image.content_type, 'png')}"] = image_bytes
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
await vision_service.ensure_points_quote(db, task, min_points_pct)
await db.commit()
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
return response
@router.get("/tasks/{task_id}/recipe/dxf-compare/source")
async def get_dxf_compare_source(
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
"""#105: i parametri della ricetta fatta con «Crea ricetta da DXF», per modificarla.
404 se il task non ha una ricetta del wizard (nessuna, o caricata a mano).
"""
task = await _task_or_404(db, task_id)
source = vision_service.dxf_compare_source(task.vision_json, task.vision_recipe_dir)
if source is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="nessuna ricetta da DXF su questo task",
)
return source
@router.get("/tasks/{task_id}/recipe/files/{path:path}")
async def get_recipe_file(
task_id: int,
path: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> FileResponse:
"""#105: un file della cartella ricetta del task (DXF, immagine di riferimento).
Percorso non sicuro, file assente o task senza ricetta: sempre 404.
"""
task = await _task_or_404(db, task_id)
target = vision_service.recipe_file_path(task.vision_recipe_dir, path)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
return FileResponse(target, media_type=media_type)
@@ -0,0 +1,39 @@
"""measurements.value nullable (#118)
measurements.value: NULL = quota che il grafo di visione non ha prodotto
(l'operatore la vede come NaN, esito sempre `fail`, deviazione NULL). Prima
execute rifiutava con 422 l'intera acquisizione. measurements.deviation era
gia' nullable.
Downgrade: torna NOT NULL; FALLISCE se esistono righe con value NULL (vanno
cancellate o valorizzate prima).
Revision ID: 022_measurement_value_nullable
Revises: 021_task_acquisition
Create Date: 2026-10-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '022_measurement_value_nullable'
down_revision: Union[str, None] = '021_task_acquisition'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'value', existing_type=sa.DECIMAL(12, 6), nullable=True,
)
def downgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'value', existing_type=sa.DECIMAL(12, 6), nullable=False,
)
+2 -2
View File
@@ -43,7 +43,7 @@ class MeasurementResponse(BaseModel):
subtask_id: int
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
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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
)
+13 -5
View File
@@ -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,
+10 -3
View File
@@ -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 "",
+5 -3
View File
@@ -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"),
+136 -21
View File
@@ -224,6 +224,25 @@ async def dxf_compare_recipe(
return body
async def dxf_preview(dxf_bytes: bytes, worker_url: str | None = None) -> dict:
"""#101: anteprima del DXF (contorni in mm + dimensioni) dal worker. Sostituita dai test."""
body = await call_worker_multipart(
"/dxf/preview",
files={"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream")},
data={},
worker_url=worker_url,
)
# forma minima del contratto: senza contorni e dimensioni non c'e' nulla da mostrare
required = {"contorni", "bbox", "larghezza_mm", "altezza_mm", "entita", "punti"}
if not required <= body.keys():
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: missing "
+ ", ".join(sorted(required - body.keys())),
)
return body
# Stati di fetch_station_calibration (#83)
CAL_OK = "ok"
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
@@ -466,6 +485,84 @@ def list_recipe_files(recipe_dir: str | None) -> list[str]:
)
# #105: nomi e id della ricetta costruita da «Crea ricetta da DXF» (src/vision/costruttori.py).
_WIZARD_DXF_FILE = "disegno.dxf"
_WIZARD_CALIBRATION_FILE = "calibrazione.json"
_WIZARD_REFERENCE_STEM = "riferimento"
def _is_real_number(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def dxf_compare_source(graph: dict | None, recipe_dir: str | None) -> dict | None:
"""#105: i parametri con cui il wizard ha costruito la ricetta, per riaprirlo.
Legge i valori dal grafo (tool `pezzo` LocateBlob, tool `dxf` CompareDxf) e i
file dalla cartella ricetta. None se il grafo non e' uno del wizard (il task
non ha ricetta, o l'ha caricata a mano): la rotta risponde 404. Pura: niente DB.
"""
if not isinstance(graph, dict) or not isinstance(graph.get("tools"), list):
return None
tools = {t.get("id"): t for t in graph["tools"] if isinstance(t, dict)}
pezzo, confronto = tools.get("pezzo"), tools.get("dxf")
if not pezzo or pezzo.get("kind") != "LocateBlob":
return None
if not confronto or confronto.get("kind") != "CompareDxf":
return None
p_values = pezzo.get("values") if isinstance(pezzo.get("values"), dict) else {}
c_values = confronto.get("values") if isinstance(confronto.get("values"), dict) else {}
band, min_pct = c_values.get("band_plus"), c_values.get("points_in_pct_nominal")
if not (_is_real_number(band) and _is_real_number(min_pct)):
return None
polarity = p_values.get("polarity")
if polarity not in ("dark", "light"):
polarity = "dark" # default di LocateBlob e della rotta di creazione
folder = None
if recipe_dir:
try:
folder = _safe_join(settings.upload_path, recipe_dir)
except (ValueError, OSError):
folder = None
if folder is not None and not folder.is_dir():
folder = None
px_per_mm = None
dxf_name = None
image_name = None
if folder is not None:
try:
cal = json.loads((folder / _WIZARD_CALIBRATION_FILE).read_text(encoding="utf-8"))
value = cal.get("px_per_mm") if isinstance(cal, dict) else None
if _is_real_number(value) and value > 0:
px_per_mm = float(value)
except (OSError, ValueError):
px_per_mm = None # manca o illeggibile: il wizard chiedera' la scala
if (folder / _WIZARD_DXF_FILE).is_file():
dxf_name = _WIZARD_DXF_FILE
for candidate in sorted(folder.glob(f"{_WIZARD_REFERENCE_STEM}.*")):
if candidate.is_file():
image_name = candidate.name
break
return {
"band_mm": float(band), "min_points_pct": float(min_pct), "polarity": polarity,
"px_per_mm": px_per_mm, "dxf": dxf_name, "image": image_name,
}
def recipe_file_path(recipe_dir: str | None, relative: str) -> Path | None:
"""#105: percorso di un file della cartella ricetta, o None se non sicuro o assente."""
if not recipe_dir:
return None
try:
folder = _safe_join(settings.upload_path, recipe_dir)
target = _safe_join(folder, relative)
except (ValueError, OSError):
return None
return target if target.is_file() else None
def save_result_files(
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
) -> tuple[str, str]:
@@ -596,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)
@@ -640,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",
@@ -665,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(
@@ -712,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,
@@ -719,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"),
@@ -861,9 +956,29 @@ async def ensure_points_quote(
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
#105: unica eccezione, la quota nata da qui (`dxf.points_in_pct`, «Punti in
tolleranza») senza misure segue la soglia nuova: ltl = min_points_pct, e
ritorna True anche in quel caso.
"""
from src.backend.services import recipe_service # import locale: evita cicli
# #105: ricetta ripresa e salvata con un'altra soglia -> la quota la segue
own = (await db.execute(
select(RecipeSubtask)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output == "dxf.points_in_pct")
.where(RecipeSubtask.description == "Punti in tolleranza")
.limit(1)
)).scalar_one_or_none()
if own is not None:
# soglia invariata o versione con misure: la quota resta com'e'
same = own.ltl is not None and float(own.ltl) == float(min_points_pct)
if same or await recipe_service.version_has_measurements(db, task.version_id):
return False
own.ltl = min_points_pct
await db.flush()
return True
existing = (await db.execute(
select(RecipeSubtask.id)
.where(RecipeSubtask.task_id == task.id)
+71
View File
@@ -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()
+26
View File
@@ -81,3 +81,29 @@ async def test_open_lists_only_open_runs_with_means(client, db_session, admin_us
async def test_open_empty_for_admin(client, admin_user):
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
+114
View File
@@ -0,0 +1,114 @@
"""#118: una quota non misurata dalla visione e' una misura con value NULL (NaN), sempre fail.
Il valore NULL non deve far cadere statistiche, report, CSV e liste, e non entra
nei calcoli numerici (medie, indici, istogramma); conta solo nell'esito.
"""
import csv
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.user import User
from src.backend.services import measurement_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_statistics import _seed_measurements
async def _recipe_with_nan(client, db_session, tec_user):
"""Una ricetta con tre misure numeriche e una NaN (value NULL, fail)."""
recipe = await create_test_recipe(db_session, tec_user.id)
subtask_id, version_id = await _seed_measurements(
client, tec_user, recipe.id, [10.0, 10.1, 9.9],
)
nan = await measurement_service.save_measurement(
db_session, subtask_id=subtask_id, version_id=version_id,
measured_by=tec_user.id, value=None, input_method="camera",
enforce_tolerance_gate=False,
)
await db_session.commit()
return recipe, subtask_id, version_id, nan
async def test_save_measurement_none_is_fail_without_deviation(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
assert nan.value is None
assert nan.pass_fail == "fail"
assert nan.deviation is None
async def test_manual_create_still_requires_a_value(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
"""Solo il percorso visione puo' produrre NaN: la creazione manuale resta float."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _seed_measurements(
client, measurement_tec_user, recipe.id, [],
)
resp = await client.post(
"/api/measurements/", headers=auth_headers(measurement_tec_user),
json={"subtask_id": subtask_id, "version_id": version_id, "value": None},
)
assert resp.status_code == 422
async def test_statistics_skip_the_nan_measurement(
client: AsyncClient, db_session: AsyncSession,
measurement_tec_user: User, metrologist_user: User,
):
recipe, subtask_id, _, _ = await _recipe_with_nan(
client, db_session, measurement_tec_user,
)
params = {"recipe_id": recipe.id, "subtask_id": subtask_id}
headers = auth_headers(metrologist_user)
cap = await client.get("/api/statistics/capability", headers=headers, params=params)
assert cap.status_code == 200
assert cap.json()["n"] == 3
assert abs(cap.json()["mean"] - 10.0) < 1e-9
chart = await client.get("/api/statistics/control-chart", headers=headers, params=params)
assert chart.status_code == 200
assert len(chart.json()["values"]) == 3
hist = await client.get("/api/statistics/histogram", headers=headers, params=params)
assert hist.status_code == 200
summary = await client.get(
"/api/statistics/summary", headers=headers, params={"recipe_id": recipe.id},
)
assert summary.status_code == 200
async def test_measurement_list_returns_null_value(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
_, _, version_id, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
resp = await client.get(
"/api/measurements/", headers=auth_headers(measurement_tec_user),
params={"version_id": version_id, "per_page": 50},
)
assert resp.status_code == 200
rows = {m["id"]: m for m in resp.json()["items"]}
assert rows[nan.id]["value"] is None
assert rows[nan.id]["pass_fail"] == "fail"
assert rows[nan.id]["deviation"] is None
async def test_csv_export_writes_nan(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
resp = await client.get(
"/api/measurements/export/csv", headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 200
rows = list(csv.reader(resp.text.splitlines()))
header = rows[0]
by_id = {r[header.index("id")]: r for r in rows[1:]}
row = by_id[str(nan.id)]
assert row[header.index("value")] == "NaN"
assert row[header.index("pass_fail")] == "fail"
assert row[header.index("deviation")] == "NaN" # #124
+28
View File
@@ -836,3 +836,31 @@ async def test_close_without_measurements_writes_no_file(
)).json()
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"
+5 -2
View File
@@ -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"}]
@@ -0,0 +1,90 @@
"""#101: POST /api/vision/dxf-preview, anteprima del DXF nel wizard.
Il worker non gira nei test: `vision_service.dxf_preview` si sostituisce.
"""
import pytest
from fastapi import HTTPException
from src.backend.config import settings
from src.backend.services import vision_service
from src.backend.tests.conftest import auth_headers
BODY = {
"contorni": [[[0.0, 0.0], [10.0, 0.0], [10.0, 5.0], [0.0, 5.0], [0.0, 0.0]]],
"bbox": {"xmin": 0.0, "ymin": 0.0, "xmax": 10.0, "ymax": 5.0},
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 5,
}
def _post(client, user, content=b"0\nEOF"):
return client.post(
"/api/vision/dxf-preview",
headers=auth_headers(user),
files={"dxf": ("d.dxf", content, "application/octet-stream")},
)
@pytest.mark.asyncio
async def test_dxf_preview_forwards_the_file_and_returns_the_worker_body(
client, maker_user, monkeypatch,
):
calls = []
async def fake(dxf_bytes, worker_url=None):
calls.append(dxf_bytes)
return BODY
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
resp = await _post(client, maker_user, b"DXF-BYTES")
assert resp.status_code == 200, resp.text
assert resp.json() == BODY
assert calls == [b"DXF-BYTES"]
@pytest.mark.asyncio
async def test_dxf_preview_forwards_the_worker_422(client, maker_user, monkeypatch):
async def fake(dxf_bytes, worker_url=None):
raise HTTPException(status_code=422, detail="DXF illeggibile: file vuoto")
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
resp = await _post(client, maker_user, b"")
assert resp.status_code == 422
assert resp.json()["detail"] == "DXF illeggibile: file vuoto"
@pytest.mark.asyncio
async def test_dxf_preview_requires_maker(client, measurement_tec_user, monkeypatch):
async def fake(dxf_bytes, worker_url=None):
return BODY
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
resp = await _post(client, measurement_tec_user)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_dxf_preview_too_big_is_422(client, maker_user, monkeypatch):
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
resp = await _post(client, maker_user, b"x" * 10)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_dxf_preview_service_posts_to_worker_and_checks_shape(monkeypatch):
seen = {}
async def fake_multipart(path, *, files, data, worker_url=None, timeout=120.0):
seen.update(path=path, files=files)
return BODY
monkeypatch.setattr(vision_service, "call_worker_multipart", fake_multipart)
assert await vision_service.dxf_preview(b"abc") == BODY
assert seen["path"] == "/dxf/preview" and seen["files"]["dxf"][1] == b"abc"
async def malformed(path, *, files, data, worker_url=None, timeout=120.0):
return {"contorni": []}
monkeypatch.setattr(vision_service, "call_worker_multipart", malformed)
with pytest.raises(HTTPException) as exc:
await vision_service.dxf_preview(b"abc")
assert exc.value.status_code == 502
+226
View File
@@ -0,0 +1,226 @@
"""#105: la ricetta «Crea ricetta da DXF» si puo' riaprire e modificare.
Il worker non gira nei test: si finge come in test_vision_dxf_compare.
"""
import base64
import io
import json
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.services import vision_service
from src.backend.tests.conftest import auth_headers
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture)
redirect_uploads, validate_ok,
)
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
DXF = b"0\nEOF"
def _wizard_graph(band=0.3, pct=90.0, polarity="light") -> dict:
"""Grafo come lo costruisce src/vision/costruttori.py::_graph."""
return {
"schema_version": 2, "name": "Confronto DXF",
"meta": {"dxf": {"disegno": {"path": "disegno.dxf", "sha256": "x"}},
"calibration": {"path": "calibrazione.json", "sha256": "y"}},
"tools": [
{"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob",
"values": {"polarity": polarity}, "links": {}},
{"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf",
"values": {"band_plus": band, "band_minus": band, "points_in_pct_nominal": pct},
"links": {}},
],
}
@pytest.fixture
def w2(monkeypatch):
async def fake(image_bytes, dxf_bytes, **kwargs):
graph = _wizard_graph(kwargs["band_mm"], kwargs["min_points_pct"], kwargs["polarity"])
return {
"graph": graph,
"files": {
"disegno.dxf": base64.b64encode(dxf_bytes).decode(),
"calibrazione.json": base64.b64encode(
json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()).decode(),
},
"outputs": {"dxf.points_in_pct": 99.0}, "overlay": [], "outcome": "pass",
}
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
def _bmp() -> bytes:
buf = io.BytesIO()
Image.new("L", (32, 32)).save(buf, format="BMP")
return buf.getvalue()
def _save(client, user, task, *, image=None, ctype="image/png", **data):
return client.post(
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
headers=auth_headers(user),
files={"image": ("p", image if image is not None else an_image(), ctype),
"dxf": ("d.dxf", DXF, "application/octet-stream")},
data={"save": "true", "px_per_mm": "10", **{k: str(v) for k, v in data.items()}},
)
@pytest.mark.asyncio
async def test_save_keeps_the_reference_image_png(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
image = an_image()
resp = await _save(client, maker_user, task, image=image)
assert resp.status_code == 200, resp.text
folder = settings.upload_path / resp.json()["recipe_dir"]
assert (folder / "riferimento.png").read_bytes() == image
assert (folder / "disegno.dxf").read_bytes() == DXF # il resto della cartella non cambia
@pytest.mark.asyncio
async def test_save_keeps_the_reference_image_bmp(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
image = _bmp()
resp = await _save(client, maker_user, task, image=image, ctype="image/bmp")
assert resp.status_code == 200, resp.text
folder = settings.upload_path / resp.json()["recipe_dir"]
assert (folder / "riferimento.bmp").read_bytes() == image
@pytest.mark.asyncio
async def test_source_returns_what_the_wizard_saved(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
await _save(client, maker_user, task, band_mm=0.3, min_points_pct=90, polarity="light")
resp = await client.get(
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source", headers=auth_headers(maker_user),
)
assert resp.status_code == 200, resp.text
assert resp.json() == {
"band_mm": 0.3, "min_points_pct": 90.0, "polarity": "light", "px_per_mm": 10.0,
"dxf": "disegno.dxf", "image": "riferimento.png",
}
@pytest.mark.asyncio
async def test_source_404_without_recipe_or_with_a_hand_made_graph(client, db_session, maker_user):
task, _ = await make_task(db_session, maker_user)
url = f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source"
assert (await client.get(url, headers=auth_headers(maker_user))).status_code == 404
task.vision_json = {"schema_version": 2, "name": "a mano", "tools": [], "meta": {}}
await db_session.commit()
resp = await client.get(url, headers=auth_headers(maker_user))
assert resp.status_code == 404
assert resp.json()["detail"] == "nessuna ricetta da DXF su questo task"
@pytest.mark.asyncio
async def test_source_needs_maker(client, db_session, maker_user, measurement_tec_user):
task, _ = await make_task(db_session, maker_user)
resp = await client.get(
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 403
def test_source_of_an_old_recipe_has_no_image_and_no_unreadable_scale(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
folder = tmp_path / "vision" / "recipes" / "1" / "abc"
folder.mkdir(parents=True)
(folder / "disegno.dxf").write_bytes(DXF)
(folder / "calibrazione.json").write_text("{non json", encoding="utf-8")
source = vision_service.dxf_compare_source(_wizard_graph(), "vision/recipes/1/abc")
assert source["image"] is None and source["px_per_mm"] is None
assert source["dxf"] == "disegno.dxf"
assert vision_service.dxf_compare_source(None, None) is None
# senza cartella i valori del grafo restano
assert vision_service.dxf_compare_source(_wizard_graph(), None)["dxf"] is None
@pytest.mark.asyncio
async def test_files_returns_the_bytes(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
image = _bmp()
await _save(client, maker_user, task, image=image, ctype="image/bmp")
img = await client.get(
f"/api/vision/tasks/{task.id}/recipe/files/riferimento.bmp", headers=auth_headers(maker_user),
)
dxf = await client.get(
f"/api/vision/tasks/{task.id}/recipe/files/disegno.dxf", headers=auth_headers(maker_user),
)
assert img.status_code == 200 and img.content == image
assert img.headers["content-type"].startswith("image/")
assert dxf.status_code == 200 and dxf.content == DXF
assert dxf.headers["content-type"] # dal tipo dell'estensione, di ripiego octet-stream
@pytest.mark.asyncio
async def test_files_404_on_unsafe_missing_or_no_recipe(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
base = f"/api/vision/tasks/{task.id}/recipe/files/"
headers = auth_headers(maker_user)
assert (await client.get(base + "disegno.dxf", headers=headers)).status_code == 404 # nessuna ricetta
await _save(client, maker_user, task)
assert (await client.get(base + "manca.bmp", headers=headers)).status_code == 404
for unsafe in ("../x", "%2e%2e/x", "..%2f..%2fx", "sub/../../x"):
assert (await client.get(base + unsafe, headers=headers)).status_code == 404, unsafe
# la funzione non lascia uscire dalla cartella nemmeno con un assoluto
assert vision_service.recipe_file_path("vision/recipes/1/abc", "../x") is None
assert vision_service.recipe_file_path("vision/recipes/1/abc", "/etc/passwd") is None
assert vision_service.recipe_file_path("../fuori", "x") is None
@pytest.mark.asyncio
async def test_resave_follows_the_new_threshold(client, db_session, maker_user, w2, validate_ok):
task, _ = await make_task(db_session, maker_user)
task_id = task.id
await _save(client, maker_user, task, min_points_pct=95)
resp = await _save(client, maker_user, task, min_points_pct=80)
assert resp.status_code == 200, resp.text
db_session.expire_all()
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
.where(RecipeSubtask.task_id == task_id))).scalars().all()
assert len(quotes) == 1 and float(quotes[0].ltl) == 80.0
@pytest.mark.asyncio
async def test_ensure_points_quote_leaves_quote_with_measurements(
client, db_session, maker_user, w2, validate_ok, monkeypatch,
):
task, _ = await make_task(db_session, maker_user)
task_id = task.id
await _save(client, maker_user, task, min_points_pct=95)
async def has_measurements(db, version_id):
return True
monkeypatch.setattr("src.backend.services.recipe_service.version_has_measurements", has_measurements)
task = await db_session.get(RecipeTask, task_id)
changed = await vision_service.ensure_points_quote(db_session, task, 80)
assert changed is False
quote = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
.where(RecipeSubtask.task_id == task_id))).scalar_one()
assert float(quote.ltl) == 95.0
+94 -32
View File
@@ -134,72 +134,134 @@ async def test_the_execution_records_which_engine_produced_it(
assert result.graph_snapshot == task.vision_json
# #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() == []
+1 -1
View File
@@ -182,7 +182,7 @@ def api_camera_sources():
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return jsonify({"error": True, "detail": _("Indirizzo della camera non valido")}), 400
try:
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=5)
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=15) # #106: era 5, la prima enumerazione dopo l'avvio ha preso 3,7 s
except http_requests.RequestException as exc:
return jsonify({
"error": True,
+64 -1
View File
@@ -5,6 +5,7 @@ import math
import requests as http_requests
from functools import wraps
from urllib.parse import quote # #105: percorso del file ricetta nell'URL del backend
from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
@@ -16,6 +17,9 @@ from services.file_proxy import proxy_file
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
CAMERA_GRAB_TIMEOUT_S = 60
# ============================================================================
# PAGINE (GET)
@@ -607,7 +611,7 @@ def api_station_camera_grab(station_id: int):
resp = http_requests.post(
f"{camera_url.rstrip('/')}/camera/grab",
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
timeout=15,
timeout=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
)
except http_requests.RequestException as exc:
return jsonify({
@@ -701,6 +705,65 @@ def api_dxf_compare_recipe(task_id: int):
return jsonify(resp), 200
# #105: parametri della ricetta DXF gia' salvata sul task, per riaprire il wizard compilato
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare/source", methods=["GET"])
@login_required
@role_required("Maker")
def api_dxf_compare_source(task_id: int):
"""Proxy: parametri e file della ricetta DXF del task (404 se non ce n'e' una) (#105)."""
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe/dxf-compare/source")
# Codice e corpo del backend arrivano com'e': il 404 vuol dire «nessuna ricetta da DXF»
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# #105: un file della cartella ricetta (DXF, immagine di riferimento) come byte, non JSON
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/files/<path:path>", methods=["GET"])
@login_required
@role_required("Maker")
def api_vision_recipe_file(task_id: int, path: str):
"""Proxy: byte di un file della cartella ricetta del task, con content-type del backend (#105)."""
# Niente segmenti '..' o backslash: urllib3 normalizzerebbe l'URL fuori da questa rotta del backend
if "\\" in path or ".." in path.split("/"):
return jsonify({"error": True, "detail": _("File non trovato")}), 404
try:
resp = http_requests.get(
f"{Config.API_SERVER_URL.rstrip('/')}/api/vision/tasks/{task_id}/recipe/files/{quote(path)}",
headers={"X-API-Key": session.get("api_key", "")},
timeout=30,
)
except http_requests.RequestException as exc:
return jsonify({
"error": True,
"detail": _("Servizio non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
return Response(
resp.content,
status=resp.status_code,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
# #101: anteprima del DXF appena scelto nel wizard (contorni letti dal vision worker)
@maker_bp.route("/api/vision/dxf-preview", methods=["POST"])
@login_required
@role_required("Maker")
def api_dxf_preview():
"""Proxy: anteprima del DXF (contorni, bbox, L x H) prima di creare la ricetta (#101)."""
dxf = request.files.get("dxf")
if dxf is None or not dxf.filename:
return jsonify({"error": True, "detail": _("Serve il file DXF")}), 400
resp = api_client.post(
"/api/vision/dxf-preview",
files={"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream")},
)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):
+6 -2
View File
@@ -17,6 +17,9 @@ from services.file_proxy import proxy_file
measure_bp = Blueprint("measure", __name__)
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
CAMERA_GRAB_TIMEOUT_S = 60
# Session key holding a station chosen at runtime, overriding Config.STATION_CODE.
STATION_OVERRIDE_KEY = "station_override"
@@ -437,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):
@@ -1117,7 +1121,7 @@ def api_vision_grab():
resp = http_requests.post(
f"{camera_url.rstrip('/')}/camera/grab",
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
timeout=15,
timeout=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
)
except http_requests.RequestException as exc:
return jsonify({
@@ -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,8 @@
// 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'];
return {
@@ -20,7 +20,15 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
imageFile: null, // immagine caricata o acquisita
imageName: '',
imageSrc: '',
imagePx: null, // #102: {w, h} in pixel, letto dall'<img> dell'anteprima
imageBroken: false, // #102: il browser non sa mostrare il formato (il file resta valido per l'invio)
calPxMm: null, // #102: px/mm della calibrazione della stazione
dxfFile: null,
dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti}
dxfPreviewBusy: false,
dxfPreviewError: '',
_dxfPreviewSeq: 0, // #101: una risposta arrivata dopo un cambio di file si scarta
mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto
bandMm: 0.1,
minPointsPct: 95,
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
@@ -29,10 +37,108 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
error: '',
notice: '',
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
// #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
existing: null, // #105: risposta di /source (ricetta DXF gia' salvata sul task); null = nessuna
existingInfo: false, // #105: «Ricetta esistente caricata...» dopo aver compilato il wizard
existingWarn: '', // #105: avviso (immagine non salvata, file illeggibili)
dxfFromRecipe: false, // #105: il DXF viene dalla ricetta (l'<input type=file> non puo' mostrarne il nome)
_existingApplied: false, // #105: il wizard e' gia' stato compilato dalla ricetta (non sovrascrivere le modifiche)
_existingReady: null, // #105: promessa della prima lettura di /source
_srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta
_dxfPickSeq: 0, // #105: sale a ogni scelta manuale del DXF; i file della ricetta arrivati dopo si scartano
_imgPickSeq: 0, // #105: idem per l'immagine (_setImage)
// #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone)
init() {
if (this.taskId) this._existingReady = this.loadExisting();
},
async toggle() {
this.open = !this.open;
if (this.open && !this.stations.length) await this.loadStations();
if (this.open) {
this.openExisting(); // #105: compila il wizard dalla ricetta esistente, in parallelo alle stazioni
if (!this.stations.length) await this.loadStations();
}
},
// #105: GET /source -> this.existing (null se 404 o errore)
async loadExisting() {
const seq = ++this._srcSeq;
let found = null;
try {
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare/source`);
if (resp.ok) {
const body = await resp.json().catch(() => null);
if (body && typeof body === 'object' && !body.error) found = body;
}
} catch (e) { console.error('dxfRecipeWizard source', e); }
if (seq !== this._srcSeq) return; // lettura piu' recente in corso
this.existing = found;
},
// #105: all'apertura, una sola volta, riempie il wizard con la ricetta esistente
async openExisting() {
if (this._existingReady) await this._existingReady;
if (!this.existing || this._existingApplied) return;
await this.applyExisting();
},
// #105: mm/px da px/mm, fino a 6 cifre significative e virgola decimale; '' se non valido
_mmPxFrom(pxPerMm) {
const v = Number(pxPerMm);
if (!Number.isFinite(v) || v <= 0) return '';
return String(Number((1 / v).toPrecision(6))).replace('.', ',');
},
// #105: un file della cartella ricetta -> File; null se assente o illeggibile (in quel caso avvisa)
async _fetchRecipeFile(name) {
try {
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/files/${encodeURIComponent(name)}`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const blob = await resp.blob();
return new File([blob], name, { type: blob.type || '' });
} catch (e) {
console.error('dxfRecipeWizard file', name, e);
this.existingWarn = this.msgs.existingFilesFailed || 'Cannot read the recipe files';
return null;
}
},
// #105: valori salvati nei campi, poi DXF e immagine di riferimento dalla cartella ricetta
async applyExisting() {
const e = this.existing;
if (!e) return;
this._existingApplied = true;
this.error = '';
this.notice = '';
this.existingWarn = '';
const band = Number(e.band_mm), pct = Number(e.min_points_pct);
if (Number.isFinite(band) && band > 0) this.bandMm = band;
if (Number.isFinite(pct) && pct > 0) this.minPointsPct = pct;
this.dark = e.polarity === 'dark';
const mm = this._mmPxFrom(e.px_per_mm);
if (mm) this.mmPx = mm; // il campo compare quando l'immagine e' da file
this.existingInfo = true;
// Sequenze lette prima di scaricare: se l'utente sceglie un file a mano nel frattempo, vince lui
const dxfSeq = this._dxfPickSeq, imgSeq = this._imgPickSeq;
const [dxf, img] = await Promise.all([
e.dxf ? this._fetchRecipeFile(e.dxf) : null,
e.image ? this._fetchRecipeFile(e.image) : null,
]);
if (dxf && dxfSeq === this._dxfPickSeq) {
this.dxfFile = dxf;
this.dxfFromRecipe = true;
this.result = null;
this.tested = false;
this.loadDxfPreview();
}
if (img && imgSeq === this._imgPickSeq) this._setImage(img, 'file'); // 'file': il campo mm/px compare gia' compilato
if (!e.image && !this.existingWarn) this.existingWarn = this.msgs.noReferenceImage || 'Reference image not saved';
},
async loadStations() {
@@ -49,33 +155,147 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
async onStation() {
this.calState = ''; this.calLabel = ''; this.calUrl = '';
this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calPxMm = null;
this.result = null;
this.tested = false; // #104
if (!this.stationId) return;
const r = await fetchStationCalibration(this.stationId);
this.calState = r.state || 'unreachable';
this.calUrl = r.calibration_url || '';
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {});
if (this.calState === 'calibrated') {
this.calLabel = formatCalibration(r.calibration || {});
const c = Number((r.calibration || {}).px_per_mm); // #102: scala per il campo in mm dell'anteprima
this.calPxMm = Number.isFinite(c) && c > 0 ? c : null;
}
},
get calibrated() { return this.calState === 'calibrated'; },
// #98: mm/px inserito a mano -> px/mm; null se vuoto o fuori da 0.00001..100 (limiti di /camera/calibrazioni/manuale, #95)
get manualPxMm() {
const raw = String(this.mmPx || '').trim().replace(',', '.');
if (!raw) return null;
const v = Number(raw);
if (!Number.isFinite(v) || v < 0.00001 || v > 100) return null;
return 1 / v;
},
// #98: compilato ma non valido -> errore in pagina
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; },
// #104: la scala a mano vale solo per un'immagine da file; con la camera vale la calibrazione della stazione
get useManualScale() { return this.imageSource === 'file' && this.manualPxMm !== null; },
get canRun() {
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
// #98: la scala a mano sostituisce la calibrazione della stazione (#104: solo per immagini da file)
return !!(this.stationId && (this.calibrated || this.useManualScale) && this.imageFile && this.dxfFile) && !this.busy;
},
_setImage(file) {
_setImage(file, source) {
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageFile = file;
this.imageName = file.name || '';
this.imageSrc = URL.createObjectURL(file);
// #102: con file null (immagine tolta) si azzera anche l'anteprima
this.imageFile = file || null;
this.imageName = (file && file.name) || '';
this.imageSrc = file ? URL.createObjectURL(file) : '';
this.imagePx = null;
this.imageBroken = false;
this.result = null;
this.tested = false; // #104
this.imageSource = file ? (source || '') : ''; // #104
this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo
},
// #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 = 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;
},
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
get imagePreviewUrl() { return this.imageSrc; },
// #102: «W × H px · campo ≈ L × H mm»; scala = mm/px a mano se valido, altrimenti calibrazione della stazione
get imagePreviewSummary() {
const p = this.imagePx;
if (!p) return '';
const lang = document.documentElement.lang || 'it';
const f = (v, d) => Number(v).toLocaleString(lang, { minimumFractionDigits: d, maximumFractionDigits: d });
let text = `${f(p.w, 0)} × ${f(p.h, 0)} px`;
const pxMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null); // #104
if (pxMm) text += ` · ${this.msgs.imageField || 'field'} ≈ ${f(p.w / pxMm, 2)} × ${f(p.h / pxMm, 2)} mm`;
return text;
},
onImage(ev) {
const f = ev.target.files && ev.target.files[0];
if (f) this._setImage(f);
if (f) this._setImage(f, 'file'); // #104
},
onDxf(ev) {
this.dxfFile = (ev.target.files && ev.target.files[0]) || null;
this._dxfPickSeq++; // #105: scelta a mano: il DXF della ricetta in arrivo si scarta
this.dxfFromRecipe = false; // #105
this.result = null;
this.tested = false; // #104
this.loadDxfPreview(); // #101: anteprima appena si sceglie il file
},
// #101: legge il DXF sul vision worker (via Flask e backend) e ne mostra i contorni
async loadDxfPreview() {
const seq = ++this._dxfPreviewSeq;
this.dxfPreview = null;
this.dxfPreviewError = '';
this.dxfPreviewBusy = false;
if (!this.dxfFile) return; // file tolto: tutto azzerato
this.dxfPreviewBusy = true;
try {
const fd = new FormData();
fd.append('dxf', this.dxfFile, this.dxfFile.name);
const resp = await fetch('/maker/api/vision/dxf-preview', {
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (seq !== this._dxfPreviewSeq) return; // file cambiato nel frattempo
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
else {
this.dxfPreview = body;
}
} catch (e) {
if (seq !== this._dxfPreviewSeq) return;
this.dxfPreviewError = this.msgs.requestFailed || String(e);
}
if (seq === this._dxfPreviewSeq) this.dxfPreviewBusy = false;
},
// #101: una stringa `points` per ogni contorno, per <polyline>
dxfPreviewPaths() {
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
return contorni.filter(c => c && c.length > 1).map(c => c.map(p => p[0] + ',' + p[1]).join(' '));
},
// #101: tutti i contorni in un solo attributo `d` ("M x y L x y ..."): x-for non funziona dentro <svg>
dxfPreviewD() {
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
return contorni.filter(c => c && c.length > 1)
.map(c => c.map((p, i) => (i ? 'L ' : 'M ') + p[0] + ' ' + p[1]).join(' ')).join(' ');
},
// #101: viewBox sul bbox con margine 5% (minimo 0.5 mm per lato)
dxfPreviewViewBox() {
const b = this.dxfPreview && this.dxfPreview.bbox;
if (!b) return '0 0 1 1';
const w = b.xmax - b.xmin, h = b.ymax - b.ymin;
const m = Math.max(0.5, 0.05 * Math.max(w, h));
return [b.xmin - m, b.ymin - m, w + 2 * m, h + 2 * m].map(v => +v.toFixed(3)).join(' ');
},
// #101: «L × H mm · N entità», decimale con virgola (2 decimali) secondo la lingua pagina
get dxfPreviewSummary() {
const d = this.dxfPreview;
if (!d) return '';
const f = v => Number(v).toLocaleString(document.documentElement.lang || 'it',
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
return `${f(d.larghezza_mm)} × ${f(d.altezza_mm)} mm · ${d.entita} ${this.msgs.dxfEntities || 'entities'}`;
},
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
// Frame dalla camera della stazione scelta
async grab() {
@@ -91,7 +311,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.error = body.detail || this.msgs.requestFailed || 'Error';
} else {
const blob = await resp.blob();
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }));
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }), 'camera'); // #104
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
@@ -115,11 +335,17 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.busy = true;
this.error = '';
this.notice = '';
// #104: parametri usati, mostrati accanto ai risultati
this.runPxPerMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null);
this.runBandMm = this.bandMm;
this.runMinPointsPct = this.minPointsPct;
try {
const fd = new FormData();
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
fd.append('dxf', this.dxfFile, this.dxfFile.name);
fd.append('station_id', this.stationId);
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione (#104: solo con immagine da file)
if (this.useManualScale) fd.append('px_per_mm', String(this.manualPxMm));
fd.append('band_mm', String(this.bandMm));
fd.append('min_points_pct', String(this.minPointsPct));
fd.append('polarity', this.dark ? 'dark' : 'light');
@@ -132,50 +358,58 @@ 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
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
// #105: il task ha ora una ricetta (etichetta «Modifica»); il wizard e' gia' allineato, non si ricompila
this._existingApplied = true;
this.existingInfo = false;
this.existingWarn = ''; // #105: l'immagine di riferimento ora e' salvata con la ricetta
await this.loadExisting();
}
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
}
this.busy = false;
// #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra)
this.tested = true;
},
// Le quattro uscite del confronto, nell'ordine fisso
get values() {
const out = (this.result && this.result.result && this.result.result.outputs) || {};
return VALUE_KEYS.map(k => ({ key: k, value: out[k] }));
return VALUE_KEYS.map(k => ({ key: k, value: out[k], label: this._valueLabel(k), unit: VALUE_UNITS[k] })); // #104
},
// #104: etichette leggibili (testi passati da msgs), chiave grezza come ripiego
_valueLabel(k) {
const m = { 'dxf.points_in_pct': this.msgs.pointsInBand, 'dxf.distance_avg': this.msgs.distAvg,
'dxf.distance_min': this.msgs.distMin, 'dxf.distance_max': this.msgs.distMax };
return m[k] || k;
},
// #104: esito della ricetta sul riferimento: pass / warning / fail (src/vision/runner.py); altro = valore grezzo
get outcome() { return (this.result && this.result.result && this.result.result.outcome) || ''; },
get outcomeLabel() {
const m = { pass: this.msgs.outcomePass, warning: this.msgs.outcomeWarning, fail: this.msgs.outcomeFail };
return m[this.outcome] || this.outcome;
},
get outcomeClass() {
return { pass: 'text-emerald-600', warning: 'text-amber-600', fail: 'text-red-500' }[this.outcome] || 'text-[var(--text-muted)]';
},
// #104: scala usata nell'ultima Prova, con la fonte
get scaleSummary() {
if (!this.runPxPerMm) return this.msgs.scaleStation || 'station calibration';
const lang = document.documentElement.lang || 'it';
const mm = (1 / this.runPxPerMm).toLocaleString(lang, { maximumFractionDigits: 6 });
return `${mm} mm/px (${this.imageSource === 'file' ? (this.msgs.scaleManual || 'manual') : (this.msgs.scaleStation || 'station calibration')})`;
},
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,38 @@
/**
* image_to_png.js - #107: converte nel browser le immagini che il backend non ammette (es. BMP) in PNG.
*
* Cosa fa: tmfNeedsPng(type) dice se il MIME e' un'immagine fuori da PNG/JPEG/GIF/WebP;
* tmfImageToPng(file) la ridisegna su canvas e ritorna un File PNG (null se illeggibile, es. TIFF).
* Come si usa: await tmfImageToPng(file) prima del ritaglio e del caricamento.
* Dipende da: createImageBitmap e canvas del browser (non servono nei test: il caso "nessuna conversione" e' puro).
*/
// #107: tipi che il backend ammette gia': non vanno convertiti
const TMF_PNG_PASSTHROUGH = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
// #107: true per un'immagine (image/*) non ammessa dal backend, es. image/bmp
function tmfNeedsPng(type) {
return typeof type === 'string' && type.indexOf('image/') === 0 && TMF_PNG_PASSTHROUGH.indexOf(type) === -1;
}
// #107: converte in PNG con canvas; ritorna il file invariato se non serve, null se il browser non sa decodificarlo
async function tmfImageToPng(file) {
if (!file || !tmfNeedsPng(file.type)) return file;
try {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
if (bitmap.close) bitmap.close();
const blob = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
if (!blob) return null;
const base = (file.name || 'immagine').replace(/\.[^.]+$/, '');
return new File([blob], base + '.png', { type: 'image/png' });
} catch (err) {
// #107: formato non decodificabile dal browser (es. TIFF)
return null;
}
}
// #107: export per il test node (nel browser module non esiste)
if (typeof module !== 'undefined') module.exports = { tmfNeedsPng, tmfImageToPng };
@@ -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,6 +32,7 @@ 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
@@ -43,6 +41,14 @@ function visionExecute() {
// #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; };
@@ -55,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
@@ -120,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';
@@ -143,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;
@@ -179,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 {
@@ -188,6 +195,12 @@ function visionExecute() {
}
},
// #120: misure salvate (risposta di execute) alla pagina: conteggio quote e «Completato»
_emitRecorded() {
const ms = (this.result && this.result.measurements) || [];
window.dispatchEvent(new CustomEvent('vision-recorded', { detail: { measurements: ms } }));
},
// Station camera (#72): the Flask proxy answers a PNG, which goes through
// 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
@@ -254,14 +267,51 @@ function visionExecute() {
return ((this.result && this.result.measurements) || []).map((m) => {
const st = subs.find((s) => s.id === m.subtask_id) || {};
return {
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
// #116: chiave stabile della quota, nome senza numero di marker e uscita ricetta, per le etichette sull'immagine
key: m.subtask_id,
name: st.description || st.measurement_type || '#' + m.subtask_id,
vision_output: st.vision_output,
// #114: il nome della quota e' description (prima: measurement_type/name, che per le quote DXF e' vuoto -> '#15')
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.description || st.measurement_type || '#' + m.subtask_id),
value: m.value,
unit: st.unit || 'mm',
status: m.pass_fail,
// #117: nominale e tolleranze della quota (come la schermata dei calibri); null se mancano
nominal: st.nominal, ltl: st.ltl, lwl: st.lwl, uwl: st.uwl, utl: st.utl,
};
});
},
// #122: zoom, pan, resize e Salva li gestisce <tmf-viewer> (static/js/image_viewer.js)
// #118: valore della cella: una quota non misurata (null) e' «NaN», mai Number(null) = 0
fmtValue(v, unit) {
return (v == null || !Number.isFinite(Number(v)) ? 'NaN' : Number(v).toFixed(3)) + ' ' + unit;
},
// #117: tolleranza a 3 decimali, trattino se manca (come x-text="...?.toFixed(3) || '—'" dei calibri, ma 0 resta 0.000)
fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); },
// #119 #122: nome proposto da «Salva» (titolo del task) per <tmf-viewer save-name>
get saveName() { return (window.__visionCfg || {}).taskName || 'immagine'; },
// #116 #122: etichette «nome valore unita» per <tmf-viewer>: x/y in px logici dell'immagine
// (il componente le posiziona in % e le tiene a dimensione costante a ogni zoom)
get viewerLabels() {
if (typeof VisionOverlay === 'undefined' || !this.result) return [];
return VisionOverlay.labels(this.rows, this.result.overlay || []).map((l) => ({
key: l.key, x: l.x, y: l.y, text: l.text, cls: this.labelClass(l.status),
}));
},
// #116: colori dell'etichetta per esito
labelClass(s) {
if (s === 'pass') return 'bg-green-600 text-white';
if (s === 'warning') return 'bg-amber-400 text-black';
if (s === 'fail') return 'bg-red-600 text-white';
return 'bg-gray-700 text-white';
},
statusClass(s) {
// Same palette as the measurement markers of the manual flow.
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
@@ -269,27 +319,5 @@ function visionExecute() {
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
},
// Canvas is sized to the natural image, so overlay coordinates (image px)
// are used as they are and CSS scales the whole thing to the displayed size.
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
for (const item of ((this.result && this.result.overlay) || [])) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
};
}
@@ -0,0 +1,424 @@
/**
* #116: disegno dell'overlay del check visione sul canvas del pannello operatore e
* ancoraggio delle etichette delle quote alla geometria del loro strumento.
*
* Script classico (niente import/export): lo carica task_execute.html prima di
* vision_execute.js, che lo usa via window.VisionOverlay. In node (test) si carica
* con require.
*
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
* labels(rows, overlay) -> [{key, x, y, text, status}];
* svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto);
* layerOf(o), quotaPunto(q), quotaTesto(q, x, y), quotaSvg(q, opts): livelli e quote CAD (#12).
* Convenzioni (vs-tools): row = y, col = x; angoli in gradi positivi antiorari a video.
* Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma)
* e frontend/src/editor/misure.js (ancoraOverlay).
*/
var VisionOverlay = (function () {
'use strict';
// #116: colore per ruolo, uguale a quello dello Studio
var COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
var COLOR_OTHER = '#ef4444';
function rad(d) { return (d * Math.PI) / 180; }
function num(v) { return typeof v === 'number' && isFinite(v); }
function allNum(o) {
for (var i = 1; i < arguments.length; i++) {
if (!o || !num(o[arguments[i]])) return false;
}
return true;
}
// #116: serie rows/cols valida = entrambe array, stessa lunghezza, non vuote, tutti numeri finiti
function validSeries(o) {
if (!o || !Array.isArray(o.rows) || !Array.isArray(o.cols)) return false;
if (o.rows.length === 0 || o.rows.length !== o.cols.length) return false;
for (var i = 0; i < o.rows.length; i++) {
if (!num(o.rows[i]) || !num(o.cols[i])) return false;
}
return true;
}
// #116: i 4 vertici del rect2 (x = col, y = row), come verticiRect2 dello Studio
function rect2Vertices(o) {
var a = rad(o.phi_deg);
var u = [Math.cos(a), -Math.sin(a)];
var v = [-Math.sin(a), -Math.cos(a)]; // u ruotato di +90 antiorario
return [[1, 1], [1, -1], [-1, -1], [-1, 1]].map(function (st) {
return [
o.col + st[0] * o.length1 * u[0] + st[1] * o.length2 * v[0],
o.row + st[0] * o.length1 * u[1] + st[1] * o.length2 * v[1],
];
});
}
// #116: disegna una forma; true se disegnata, false se saltata (tipo ignoto o dati malformati)
function drawShape(g, o, lw) {
var type = o && o.type;
var i;
switch (type) {
case 'rect2': {
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return false;
var vs = rect2Vertices(o);
g.beginPath();
g.moveTo(vs[0][0], vs[0][1]);
for (i = 1; i < vs.length; i++) g.lineTo(vs[i][0], vs[i][1]);
g.closePath();
g.stroke();
return true;
}
case 'segment':
case 'line':
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return false;
g.beginPath();
g.moveTo(o.col1, o.row1);
g.lineTo(o.col2, o.row2);
g.stroke();
return true;
case 'circle':
if (!allNum(o, 'row', 'col', 'radius')) return false;
g.beginPath();
g.arc(o.col, o.row, o.radius, 0, 2 * Math.PI);
g.stroke();
return true;
case 'arc':
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return false;
g.beginPath();
// #116: y canvas verso il basso, quindi angoli negati; extent > 0 = antiorario a video
g.arc(o.col, o.row, o.radius, -rad(o.start_deg), -rad(o.start_deg + o.extent_deg), o.extent_deg > 0);
g.stroke();
return true;
case 'points': {
if (!validSeries(o)) return false;
var m = Math.max(3, lw * 2); // semi-lato della croce
g.beginPath();
for (i = 0; i < o.rows.length; i++) {
g.moveTo(o.cols[i] - m, o.rows[i]);
g.lineTo(o.cols[i] + m, o.rows[i]);
g.moveTo(o.cols[i], o.rows[i] - m);
g.lineTo(o.cols[i], o.rows[i] + m);
}
g.stroke();
return true;
}
case 'contour':
if (!validSeries(o)) return false;
g.beginPath();
g.moveTo(o.cols[0], o.rows[0]);
for (i = 1; i < o.rows.length; i++) g.lineTo(o.cols[i], o.rows[i]);
g.stroke(); // polilinea aperta, come nello Studio
return true;
default:
return false;
}
}
function draw(g, overlay, lw) {
var res = { drawn: 0, skipped: 0 };
var items = Array.isArray(overlay) ? overlay : [];
for (var k = 0; k < items.length; k++) {
var o = items[k];
var role = o && o.role;
var isRoi = role === 'roi';
g.strokeStyle = COLORS[role] || COLOR_OTHER;
g.lineWidth = isRoi ? lw * 0.75 : lw;
g.setLineDash(isRoi ? [lw * 3, lw * 2] : []); // roi tratteggiata
var ok = false;
try { ok = drawShape(g, o, lw); } catch (e) { ok = false; } // un elemento rotto non ferma gli altri
if (ok) res.drawn++; else res.skipped++;
}
g.setLineDash([]);
return res;
}
// #121: numero per il markup SVG, al piu' 2 decimali (niente stringhe dei dati nel markup)
function n2(v) { return String(Math.round(v * 100) / 100); }
function pos(v, dflt) { return num(v) && v > 0 ? v : dflt; }
// #121: path SVG di un arco, porting di percorsoArco dello Studio; extent > 0 = antiorario a video
function arcPath(o) {
var ext = Math.max(-359.99, Math.min(359.99, o.extent_deg)); // 360 pieni darebbero start == end
var a0 = rad(o.start_deg);
var a1 = rad(o.start_deg + ext);
var x0 = o.col + o.radius * Math.cos(a0), y0 = o.row - o.radius * Math.sin(a0);
var x1 = o.col + o.radius * Math.cos(a1), y1 = o.row - o.radius * Math.sin(a1);
var large = Math.abs(ext) > 180 ? 1 : 0;
var sweep = ext > 0 ? 0 : 1; // y verso il basso: antiorario a video = sweep-flag 0
return 'M ' + n2(x0) + ' ' + n2(y0) + ' A ' + n2(o.radius) + ' ' + n2(o.radius) +
' 0 ' + large + ' ' + sweep + ' ' + n2(x1) + ' ' + n2(y1);
}
// #121: [tag, attributi geometrici] di una forma, o null se tipo ignoto o dati malformati (stessi criteri di drawShape)
function shapeSvg(o, cross) {
var i, pts;
switch (o && o.type) {
case 'rect2':
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return null;
pts = rect2Vertices(o).map(function (v) { return n2(v[0]) + ',' + n2(v[1]); }).join(' ');
return ['polygon', 'points="' + pts + '"'];
case 'segment':
case 'line':
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return null;
return ['line', 'x1="' + n2(o.col1) + '" y1="' + n2(o.row1) + '" x2="' + n2(o.col2) + '" y2="' + n2(o.row2) + '"'];
case 'circle':
if (!allNum(o, 'row', 'col', 'radius')) return null;
return ['circle', 'cx="' + n2(o.col) + '" cy="' + n2(o.row) + '" r="' + n2(o.radius) + '"'];
case 'arc':
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
return ['path', 'd="' + arcPath(o) + '"'];
case 'points': {
if (!validSeries(o)) return null;
var d = [];
for (i = 0; i < o.rows.length; i++) {
var x = o.cols[i], y = o.rows[i];
d.push('M ' + n2(x - cross) + ' ' + n2(y) + ' L ' + n2(x + cross) + ' ' + n2(y) +
' M ' + n2(x) + ' ' + n2(y - cross) + ' L ' + n2(x) + ' ' + n2(y + cross));
}
return ['path', 'd="' + d.join(' ') + '"'];
}
case 'contour':
if (!validSeries(o)) return null;
pts = o.rows.map(function (r, k) { return n2(o.cols[k]) + ',' + n2(r); }).join(' ');
return ['polyline', 'points="' + pts + '"']; // aperta, come draw
default:
return null;
}
}
// #121: overlay -> figli SVG (senza <svg> esterno) in px immagine. Tratto a spessore costante a video
// (vector-effect non-scaling-stroke), croci dei points a semi-braccio costante a video (cross / zoom).
function svg(overlay, opts) {
opts = opts || {};
var zoom = pos(opts.zoom, 1), lw = pos(opts.lw, 2), cross = pos(opts.cross, 6);
var items = Array.isArray(overlay) ? overlay : [];
var out = [];
for (var k = 0; k < items.length; k++) {
var o = items[k];
var sh = null;
try { sh = shapeSvg(o, cross / zoom); } catch (e) { sh = null; } // un elemento rotto non ferma gli altri
if (!sh) continue;
var role = o.role;
var isRoi = role === 'roi';
// solo colori fissi e numeri: nessuna stringa dei dati finisce nel markup
out.push('<' + sh[0] + ' ' + sh[1] + ' fill="none" stroke="' + (COLORS[role] || COLOR_OTHER) +
'" stroke-width="' + n2(isRoi ? lw * 0.75 : lw) + '"' +
(isRoi ? ' stroke-dasharray="6 4"' : '') + ' vector-effect="non-scaling-stroke"/>');
}
return out.join('');
}
// #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio
function anchor(o) {
switch (o && o.type) {
case 'segment':
case 'line':
return allNum(o, 'row1', 'col1', 'row2', 'col2')
? { x: (o.col1 + o.col2) / 2, y: (o.row1 + o.row2) / 2 } : null;
case 'circle':
return allNum(o, 'row', 'col', 'radius') ? { x: o.col + o.radius, y: o.row } : null; // punto a destra
case 'arc': {
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
var a = rad(o.start_deg + o.extent_deg / 2); // punto medio dell'arco
return { x: o.col + o.radius * Math.cos(a), y: o.row - o.radius * Math.sin(a) };
}
case 'points':
return Array.isArray(o.rows) && Array.isArray(o.cols) && num(o.rows[0]) && num(o.cols[0])
? { x: o.cols[0], y: o.rows[0] } : null;
case 'rect2':
return allNum(o, 'row', 'col') ? { x: o.col, y: o.row } : null;
case 'contour': {
if (!Array.isArray(o.rows) || !Array.isArray(o.cols)) return null;
var i = Math.floor(Math.min(o.rows.length, o.cols.length) / 2);
return num(o.rows[i]) && num(o.cols[i]) ? { x: o.cols[i], y: o.rows[i] } : null;
}
default:
return null;
}
}
// #116: un'etichetta per riga con vision_output 'tool_id.proprieta' che ha un overlay ancorabile
function labels(rows, overlay) {
var out = [];
var items = Array.isArray(overlay) ? overlay : [];
(rows || []).forEach(function (r) {
if (!r || typeof r.vision_output !== 'string' || r.vision_output.indexOf('.') < 0) return;
var toolId = r.vision_output.split('.')[0];
var mine = items.filter(function (o) { return o && o.tool_id === toolId; });
// prima gli elementi 'found', poi qualsiasi altro
var order = mine.filter(function (o) { return o.role === 'found'; })
.concat(mine.filter(function (o) { return o.role !== 'found'; }));
var pt = null;
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
if (!pt) return;
// #118: valore null/undefined/vuoto/non finito = quota non misurata (Number(null) e' 0): etichetta NaN, status della riga (fail)
var v = (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean')
? NaN : Number(r.value);
var txt = isFinite(v) ? v.toFixed(3) : 'NaN';
out.push({
key: r.key,
x: pt.x,
y: pt.y,
text: r.name + ' ' + txt + ' ' + (r.unit || ''),
status: r.status,
});
});
return out;
}
// ---- #12: livelli e quote stile CAD ------------------------------------------------------------
// Coordinate in px immagine; angoli in gradi antiorari a video, versore u(a) = (cos a, -sin a),
// normale n = (-u.y, u.x). Contratto: gitea:Adriano/TieVision-Station#12.
// #12: colori ammessi per le quote (esiti del visore + rosso di default); mai una stringa dei dati nel markup
var QUOTA_COLORS = ['#16a34a', '#d97706', '#dc2626', '#ef4444'];
var QUOTA_ARROW = 9, QUOTA_GAP = 3, QUOTA_OVER = 6, QUOTA_LW = 1.5; // px a video
// #12: livello di una forma: campo "layer" se valido, altrimenti found = costruzioni, il resto = ricerca
function layerOf(o) {
var l = o && o.layer;
if (l === 'costruzioni' || l === 'ricerca' || l === 'quote') return l;
return o && o.role === 'found' ? 'costruzioni' : 'ricerca';
}
function pt2(p) { return Array.isArray(p) && p.length === 2 && num(p[0]) && num(p[1]); }
function uVec(deg) { var a = rad(deg); return [Math.cos(a), -Math.sin(a)]; }
// #12: riferimento locale della quota {b, u, n, td, ...} o null se malformata
function quotaFrame(q) {
if (!q || typeof q !== 'object') return null;
var f = { tipo: q.tipo };
switch (q.tipo) {
case 'lineare': {
if (!pt2(q.p1) || !pt2(q.p2)) return null;
var dx = q.p2[0] - q.p1[0], dy = q.p2[1] - q.p1[1], L = Math.sqrt(dx * dx + dy * dy);
f.u = L > 1e-12 ? [dx / L, dy / L] : [1, 0];
f.b = [(q.p1[0] + q.p2[0]) / 2, (q.p1[1] + q.p2[1]) / 2];
f.L = L; f.dflt = [0, 0];
break;
}
case 'raggio':
if (!pt2(q.centro) || !num(q.r) || !num(q.dir_deg)) return null;
f.u = uVec(q.dir_deg); f.b = [q.centro[0], q.centro[1]]; f.dflt = [2 * q.r, 0];
break;
case 'angolo':
if (!pt2(q.centro) || !num(q.r) || !num(q.start_deg) || !num(q.extent_deg)) return null;
f.u = uVec(q.start_deg + q.extent_deg / 2); f.b = [q.centro[0], q.centro[1]]; f.dflt = [1.5 * q.r, 0];
break;
default:
return null;
}
f.n = [-f.u[1], f.u[0]];
f.td = pt2(q.testo) ? [q.testo[0], q.testo[1]] : f.dflt; // testo null o malformato = posizione di default
return f;
}
// #12: punto T della scritta (px immagine) = B + du*u + dv*n; null se la quota e' malformata
function quotaPunto(q) {
var f = quotaFrame(q);
if (!f) return null;
return { x: f.b[0] + f.td[0] * f.u[0] + f.td[1] * f.n[0], y: f.b[1] + f.td[0] * f.u[1] + f.td[1] * f.n[1] };
}
// #12: inverso di quotaPunto: [du, dv] che porta la scritta nel punto (x, y); serve al trascinamento
function quotaTesto(q, x, y) {
var f = quotaFrame(q);
if (!f || !num(x) || !num(y)) return null;
var rx = x - f.b[0], ry = y - f.b[1];
return [rx * f.u[0] + ry * f.u[1], rx * f.n[0] + ry * f.n[1]];
}
function qLine(x1, y1, x2, y2, color) {
return '<line x1="' + n2(x1) + '" y1="' + n2(y1) + '" x2="' + n2(x2) + '" y2="' + n2(y2) + '" stroke="' + color +
'" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>';
}
// #12: freccia piena con la punta in (x, y) rivolta verso (dx, dy) (versore); lunghezza fissa a video
function qArrow(x, y, dx, dy, zoom, color) {
var len = QUOTA_ARROW / zoom, w = len / 3;
var bx = x - dx * len, by = y - dy * len;
return '<polygon points="' + n2(x) + ',' + n2(y) + ' ' + n2(bx - dy * w) + ',' + n2(by + dx * w) + ' ' +
n2(bx + dy * w) + ',' + n2(by - dx * w) + '" fill="' + color + '" stroke="none"/>';
}
function quotaSvgLineare(q, f, zoom, color) {
var out = [], u = f.u, n = f.n, dv = f.td[1], du = f.td[0], i;
var sg = dv >= 0 ? 1 : -1, gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
var ps = [q.p1, q.p2];
if (Math.abs(dv) * zoom >= 3) { // linee di estensione: assenti se la linea di misura sta sul pezzo
for (i = 0; i < 2; i++) {
out.push(qLine(ps[i][0] + sg * gap * n[0], ps[i][1] + sg * gap * n[1],
ps[i][0] + (dv + sg * over) * n[0], ps[i][1] + (dv + sg * over) * n[1], color));
}
}
var m0 = [f.b[0] + dv * n[0], f.b[1] + dv * n[1]]; // punto medio della linea di misura
var h = f.L / 2, inside = f.L * zoom >= 2.5 * QUOTA_ARROW;
var lo = Math.min(-h, du), hi = Math.max(h, du); // la linea arriva fino alla scritta se sta oltre A o B
if (!inside) { lo = Math.min(lo, -h - 12 / zoom); hi = Math.max(hi, h + 12 / zoom); }
out.push(qLine(m0[0] + lo * u[0], m0[1] + lo * u[1], m0[0] + hi * u[0], m0[1] + hi * u[1], color));
var k = inside ? 1 : -1; // frecce corte: fuori, rivolte verso l'interno
out.push(qArrow(m0[0] - h * u[0], m0[1] - h * u[1], -k * u[0], -k * u[1], zoom, color));
out.push(qArrow(m0[0] + h * u[0], m0[1] + h * u[1], k * u[0], k * u[1], zoom, color));
return out.join('');
}
function quotaSvgRaggio(q, f, zoom, color) {
var out = [], c = q.centro, T = quotaPunto(q);
var vx = T.x - c[0], vy = T.y - c[1], d = Math.sqrt(vx * vx + vy * vy);
var w = d > 1e-9 ? [vx / d, vy / d] : uVec(q.dir_deg);
var px = c[0] + q.r * w[0], py = c[1] + q.r * w[1], cr = 4 / zoom;
out.push(qLine(c[0] - cr, c[1], c[0] + cr, c[1], color)); // crocetta sul centro
out.push(qLine(c[0], c[1] - cr, c[0], c[1] + cr, color));
if (d > q.r) { // scritta fuori dal cerchio: da P a T, freccia in P verso il centro
out.push(qLine(px, py, T.x, T.y, color));
out.push(qArrow(px, py, -w[0], -w[1], zoom, color));
} else { // scritta dentro: dal centro a P, freccia in P verso fuori
out.push(qLine(c[0], c[1], px, py, color));
out.push(qArrow(px, py, w[0], w[1], zoom, color));
}
return out.join('');
}
function quotaSvgAngolo(q, f, zoom, color) {
var out = [], c = q.centro, T = quotaPunto(q), i;
var rho = Math.sqrt((T.x - c[0]) * (T.x - c[0]) + (T.y - c[1]) * (T.y - c[1]));
if (!(rho > 1e-9)) rho = q.r > 0 ? 1.5 * q.r : 1;
var gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
var ends = [q.start_deg, q.start_deg + q.extent_deg];
var sgn = q.extent_deg >= 0 ? 1 : -1;
var inside = rho * rad(Math.abs(q.extent_deg)) * zoom >= 2.5 * QUOTA_ARROW;
for (i = 0; i < 2; i++) {
var u = uVec(ends[i]);
var a = rho >= q.r ? q.r + gap : rho - over, b = rho >= q.r ? rho + over : q.r - gap;
out.push(qLine(c[0] + a * u[0], c[1] + a * u[1], c[0] + b * u[0], c[1] + b * u[1], color)); // estensione radiale
}
out.push('<path d="' + arcPath({ col: c[0], row: c[1], radius: rho, start_deg: q.start_deg, extent_deg: q.extent_deg }) +
'" fill="none" stroke="' + color + '" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>');
for (i = 0; i < 2; i++) {
var ang = rad(ends[i]), uu = uVec(ends[i]);
var tg = [-Math.sin(ang), -Math.cos(ang)]; // tangente nel verso degli angoli crescenti
var dir = (i === 0 ? -1 : 1) * sgn * (inside ? 1 : -1); // dentro: verso fuori dall'arco; corte: verso l'interno
out.push(qArrow(c[0] + rho * uu[0], c[1] + rho * uu[1], dir * tg[0], dir * tg[1], zoom, color));
}
return out.join('');
}
// #12: quota -> figli SVG (px immagine). opts = {zoom, color}; misure fisse a video divise per lo zoom.
// Solo numeri e colori da lista fissa nel markup.
function quotaSvg(q, opts) {
opts = opts || {};
var zoom = pos(opts.zoom, 1);
var color = QUOTA_COLORS.indexOf(opts.color) >= 0 ? opts.color : COLOR_OTHER;
try {
var f = quotaFrame(q);
if (!f) return '';
if (f.tipo === 'lineare') return quotaSvgLineare(q, f, zoom, color);
if (f.tipo === 'raggio') return quotaSvgRaggio(q, f, zoom, color);
return quotaSvgAngolo(q, f, zoom, color);
} catch (e) { return ''; } // una quota rotta non ferma le altre
}
return { draw: draw, anchor: anchor, labels: labels, svg: svg,
layerOf: layerOf, quotaPunto: quotaPunto, quotaTesto: quotaTesto, quotaSvg: quotaSvg };
})();
if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay;
if (typeof module !== 'undefined' && module.exports) module.exports = VisionOverlay;
@@ -504,6 +504,7 @@
{% block extra_js %}
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
<script>
// #56: stringhe e percorsi per il ritaglio prima dell'upload
ImageCrop.configure({
@@ -686,6 +687,13 @@ function recipeEditor() {
async uploadFile(file) {
if (!file) return;
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
file = await tmfImageToPng(file);
if (!file) {
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
return;
}
// Validate file type
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
if (!allowed.includes(file.type)) {
@@ -662,11 +662,16 @@
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
<template x-if="task.task_type === 'dxf_compare'">
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})">
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata'), 'dxfEntities': _('entità'), 'imageField': _('campo'), 'pointsInBand': _('Punti in banda'), 'distAvg': _('Distanza media'), 'distMin': _('Distanza minima'), 'distMax': _('Distanza massima'), 'outcomePass': _('Conforme'), 'outcomeWarning': _('Con avvisi'), 'outcomeFail': _('Non conforme'), 'scaleManual': _('mm/px a mano'), 'scaleStation': _('calibrazione stazione'), 'createLabel': _('Crea ricetta da DXF'), 'editLabel': _('Modifica ricetta da DXF'), 'noReferenceImage': _('Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala'), 'existingFilesFailed': _('Impossibile leggere i file della ricetta esistente')}|tojson_attr }})">{# #104: etichette del risultato della Prova; #105: etichette del bottone e avvisi della ricetta esistente #}
{# #105: «Modifica ricetta da DXF» se il task ha gia' una ricetta da DXF #}
<button type="button" class="btn btn-secondary text-xs" @click="toggle()"
x-text="existing ? msgs.editLabel : msgs.createLabel">
{{ _('Crea ricetta da DXF') }}
</button>
<div x-show="open" x-cloak class="mt-3 space-y-3">
<!-- #105: ricetta esistente caricata nel wizard -->
<p x-show="existingInfo" class="text-xs text-emerald-600">{{ _('Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla') }}</p>
<p x-show="existingWarn" class="text-xs text-amber-600" x-text="existingWarn"></p>
<!-- Stazione -->
<div>
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
@@ -680,14 +685,14 @@
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
</p>
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
<p x-show="stationId && calState && !calibrated && !useManualScale" class="mt-1 text-xs text-amber-600">{# #98: nascosto con mm/px valido (#104: solo per immagine da file) #}
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
</p>
</div>
<!-- Immagine: camera o file -->
<!-- Immagine: camera o file. #104: prima le scelte, poi le anteprime affiancate -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
@@ -700,10 +705,63 @@
</label>
</div>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
<!-- #98: mm/px a mano, #104: solo per un'immagine da file; sostituisce la calibrazione della stazione -->
<div x-show="imageSource === 'file'" class="mt-2">
<label class="tmf-label text-xs">{{ _('mm/px (immagine da file)') }}</label>
<input type="text" inputmode="decimal" class="tmf-input text-xs" x-model="mmPx" placeholder="0,01406">
<p x-show="manualPxMm !== null" class="mt-1 text-xs text-emerald-600">
= <span x-text="fmt(manualPxMm)"></span> px/mm
</p>
<p x-show="mmPxInvalid" class="mt-1 text-xs text-amber-600">{{ _('mm/px non valido') }}</p>
<p class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Se compilato sostituisce la calibrazione della stazione; la scala resta salvata nella ricetta.') }}</p>
</div>
</div>
<div>
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
<!-- #105: DXF preso dalla ricetta esistente (l'input file non puo' mostrarne il nome) -->
<p x-show="dxfFromRecipe && dxfFile" class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfFile ? dxfFile.name : ''"></p>
<!-- #101: lettura del disegno appena scelto il .dxf -->
<p x-show="dxfPreviewBusy" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Lettura DXF…') }}</p>
<p x-show="dxfPreviewError" class="mt-1 text-xs text-red-500" x-text="dxfPreviewError"></p>
</div>
</div>
<!-- #104: anteprime sotto le scelte, scatole alte uguali (240px) per stare allineate -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
<div x-show="imagePreviewUrl">
<!-- #102: anteprima dell'immagine -->
{# #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 #}
{# #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>
<!-- Pochi parametri -->
@@ -728,21 +786,43 @@
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
</div>
<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>
<!-- Anteprima con overlay e quattro valori -->
<div x-show="imageSrc" class="relative">
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
@load="drawOverlay()">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
<!-- #104: errore di acquisizione prima di una Prova (dopo, sta nella colonna dei dati) -->
<p x-show="error && !tested" class="text-xs text-red-500" x-text="error"></p>
<!-- #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>
{# #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>
<div x-show="result" class="space-y-2">
<p class="text-xs">{{ _('Esito') }}: <span class="font-semibold" :class="outcomeClass" x-text="outcomeLabel"></span></p>
<table class="tmf-table text-xs">
<tbody>
<template x-for="v in values" :key="v.key">
<tr>
<td :title="v.key" x-text="v.label"></td>
<td class="text-right"><span x-text="fmt(v.value)"></span> <span class="text-[var(--text-muted)]" x-text="v.unit"></span></td>
</tr>
</template>
</tbody>
</table>
<ul class="text-xs text-[var(--text-muted)] space-y-0.5">
<li>{{ _('Scala') }}: <span x-text="scaleSummary"></span></li>
<li>{{ _('Banda ± mm') }}: <span x-text="fmt(runBandMm)"></span></li>
<li>{{ _('Soglia punti in banda %%') }}: <span x-text="fmt(runMinPointsPct)"></span></li>
</ul>
</div>
</div>
</div>
<table x-show="result" class="tmf-table text-xs">
<tbody>
<template x-for="v in values" :key="v.key">
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
@@ -800,6 +880,8 @@
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
</label>
</template>
<!-- #107: errore di caricamento anche dentro il riquadro, non solo nel banner in cima -->
<p x-show="uploadErrorTask === task.id && errorMessage" x-text="errorMessage" class="text-xs text-red-600 dark:text-red-400 mt-2"></p>
</div>
<!-- Task Directive / Description (read-only display) -->
@@ -1399,12 +1481,16 @@
<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>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
<script>
// #56: stringhe e percorsi per il ritaglio prima dell'upload
ImageCrop.configure({
@@ -1451,6 +1537,7 @@ function taskEditor() {
showAddTask: false,
saving: false,
errorMessage: '',
uploadErrorTask: null, // #107: id del task il cui upload e' fallito (errore mostrato nel riquadro Disegno Tecnico)
successMessage: '',
// ---- Task CRUD state ----
@@ -2336,14 +2423,24 @@ function taskEditor() {
async uploadTaskFile(taskId, file, task) {
if (!file) return;
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
file = await tmfImageToPng(file);
if (!file) {
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
this.uploadErrorTask = taskId;
return;
}
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }};
this.uploadErrorTask = taskId; // #107
return;
}
if (file.size > 20 * 1024 * 1024) {
this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }};
this.uploadErrorTask = taskId; // #107
return;
}
@@ -2353,6 +2450,7 @@ function taskEditor() {
this.saving = true;
this.errorMessage = '';
this.uploadErrorTask = null; // #107: nuovo tentativo, azzera l'errore del riquadro
try {
// 1. Upload file
@@ -2369,6 +2467,7 @@ function taskEditor() {
const uploadData = await uploadResp.json();
if (uploadData.error) {
this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }};
this.uploadErrorTask = taskId; // #107
this.saving = false;
return;
}
@@ -2393,6 +2492,7 @@ function taskEditor() {
const updateData = await updateResp.json();
if (updateData.error) {
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
this.uploadErrorTask = taskId; // #107
this.saving = false;
return;
}
@@ -2408,6 +2508,7 @@ function taskEditor() {
} catch (err) {
console.error('uploadTaskFile error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
this.uploadErrorTask = taskId; // #107
}
this.saving = false;
@@ -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,10 +579,13 @@
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
{# #96: riquadro flex-1 min-h-0; img e canvas object-contain sovrapposti (il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato) #}
{# #96: riquadro flex-1 min-h-0; #122: dentro, <tmf-viewer> (zoom, pan, Salva, overlay SVG a tratto costante ed etichette quota) riempie il riquadro; overlay e labels arrivano da x-effect (px dell'immagine, #121 #116); x-show sul contenitore, mai sul viewer #}
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" 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>
{# #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».') }}
@@ -593,12 +599,29 @@
</div>
<table class="w-full text-sm" x-show="rows.length">
{# #117: due righe per quota (esito, poi nominale e tolleranze come nella schermata dei calibri); un solo tbody per quota perche' x-for vuole un'unica radice #}
<template x-for="r in rows" :key="r.label">
<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>
<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>
<tbody>
<tr>
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></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)]">
<td colspan="3" class="pb-1.5 text-[11px] font-mono">
<span class="text-primary font-semibold">{{ _('Nom') }} <span x-text="fmtTol(r.nominal)"></span></span>
<span class="text-measure-fail ml-2">LTL <span x-text="fmtTol(r.ltl)"></span></span>
<span class="text-measure-fail ml-1">UTL <span x-text="fmtTol(r.utl)"></span></span>
<template x-if="r.lwl != null || r.uwl != null">
<span>
<span class="text-measure-warning ml-2">LWL <span x-text="fmtTol(r.lwl)"></span></span>
<span class="text-measure-warning ml-1">UWL <span x-text="fmtTol(r.uwl)"></span></span>
</span>
</template>
</td>
</tr>
</tbody>
</template>
</table>
@@ -1172,10 +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>
@@ -1217,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
@@ -1323,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 }}
@@ -1336,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;
}
@@ -1346,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 }};
},
@@ -1429,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),
);
@@ -1912,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. */
@@ -1919,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>
@@ -51,7 +51,9 @@
<!-- Subtask -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
<!-- #100: il cambio di punto ricarica subito, senza «Applica filtri» -->
<select x-model="selectedSubtaskId"
@change="loadData()"
:disabled="subtasks.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutti i punti") }}</option>
@@ -287,20 +289,52 @@
</div>
</div>
</template>
<template x-if="!capability || capability.cp == null">
<!-- #100: con «Tutti i punti» tabella con una riga per punto di misura -->
<template x-if="!loadedSubtaskId && capabilityAll.length > 0">
<div>
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-steel text-left">
<th class="py-1 font-medium">{{ _("Punto di misura") }}</th>
<th class="py-1 font-medium text-right">n</th>
<th class="py-1 font-medium text-right">Cp</th>
<th class="py-1 font-medium text-right">Cpk</th>
</tr>
</thead>
<tbody>
<template x-for="row in capabilityAll" :key="row.subtask.id">
<tr class="cursor-pointer hover:bg-[var(--bg-primary)] border-t border-[var(--border-color)]"
@click="selectedSubtaskId = String(row.subtask.id); loadData()">
<td class="py-1.5 pr-2" x-text="'#' + row.subtask.marker_number + ' — ' + row.subtask.description"></td>
<td class="py-1.5 text-right font-mono" x-text="row.data && row.data.n != null ? row.data.n : '—'"></td>
<td class="py-1.5 text-right font-mono font-bold"
:class="row.data && row.data.cp != null ? capabilityColor(row.data.cp) : 'text-steel'"
x-text="row.data && row.data.cp != null ? row.data.cp : '—'"></td>
<td class="py-1.5 text-right font-mono font-bold"
:class="row.data && row.data.cpk != null ? capabilityColor(row.data.cpk) : 'text-steel'"
x-text="row.data && row.data.cpk != null ? row.data.cpk : '—'"></td>
</tr>
</template>
</tbody>
</table>
<p class="text-xs text-steel mt-3">{{ _("Scegli un punto per carta di controllo e istogramma") }}</p>
</div>
</template>
<template x-if="(!capability || capability.cp == null) && (loadedSubtaskId || capabilityAll.length === 0)">
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
</template>
</div>
</div>
<!-- Row 2: Carta di Controllo -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<!-- #100: x-show (non x-if) perche' renderCharts() cerca gli id nel DOM; nascosta senza punto -->
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
<div id="control-chart" style="min-height: 350px;"></div>
</div>
<!-- Row 3: Istogramma -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
<div id="histogram-chart" style="min-height: 350px;"></div>
</div>
@@ -346,6 +380,8 @@ function spcDashboard() {
subtasks: [],
// Data state
loadedSubtaskId: '', // #100 punto con cui sono stati caricati i dati (i pannelli dipendono da questo)
capabilityAll: [], // #100 con «Tutti i punti»: [{subtask, data}] una riga per punto
summary: null,
capability: null,
controlChart: null,
@@ -408,6 +444,8 @@ function spcDashboard() {
async onRecipeChange() {
this.selectedSubtaskId = '';
this.loadedSubtaskId = ''; // #100
this.capabilityAll = []; // #100
this.selectedRunId = ''; // #58
this.runs = [];
this.subtasks = [];
@@ -459,10 +497,11 @@ function spcDashboard() {
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
const requestedSubtaskId = this.selectedSubtaskId; // #100 punto richiesto da questa chiamata
try {
// Parallel fetch of all endpoints
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([
const [summaryResp, capabilityResp, controlResp, histogramResp, allResp] = await Promise.allSettled([
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
this.selectedSubtaskId
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
@@ -473,6 +512,17 @@ function spcDashboard() {
this.selectedSubtaskId
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
: Promise.resolve(null),
// #100: senza punto scelto, capability di ogni punto della ricetta
requestedSubtaskId
? Promise.resolve([])
: Promise.all(this.subtasks.map(st => {
const p = new URLSearchParams(params.toString());
p.set('subtask_id', st.id);
return fetch('/statistics/api/capability?' + p.toString())
.then(r => r.json())
.then(d => ({ subtask: st, data: (d && !d.error) ? d : null }))
.catch(() => ({ subtask: st, data: null }));
})),
]);
// Summary (always available)
@@ -505,6 +555,9 @@ function spcDashboard() {
this.histogram = null;
}
// #100: lista per-punto solo con «Tutti i punti»; con un punto resta il flusso di prima
this.capabilityAll = (allResp.status === 'fulfilled' && Array.isArray(allResp.value)) ? allResp.value : [];
this.loadedSubtaskId = requestedSubtaskId;
this.hasData = true;
// Render charts after DOM update
@@ -522,7 +575,7 @@ function spcDashboard() {
renderCharts() {
// Control chart
if (this.controlChart && this.selectedSubtaskId) {
if (this.controlChart && this.loadedSubtaskId) { // #100
renderControlChart('control-chart', this.controlChart);
} else {
const el = document.getElementById('control-chart');
@@ -530,7 +583,7 @@ function spcDashboard() {
}
// Histogram
if (this.histogram && this.selectedSubtaskId) {
if (this.histogram && this.loadedSubtaskId) { // #100
const tol = this.capability ? {
utl: this.capability.utl,
ltl: this.capability.ltl,
@@ -0,0 +1,204 @@
<!doctype html>
<!-- #122: pagina di prova di <tmf-viewer> da aprire in Chrome (file:// va bene): immagine generata col canvas,
overlay vs-tools finto, etichette e modo contenuto (svg). Il riquadro #log riporta i controlli automatici. -->
<html lang="it">
<head>
<meta charset="utf-8">
<title>Prova tmf-viewer</title>
<style>
body { font: 14px system-ui, sans-serif; margin: 16px; background: #f3f4f6; }
.griglia { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.box { height: 320px; }
h2 { font-size: 13px; margin: 12px 0 4px; }
#log { white-space: pre; font: 12px ui-monospace, monospace; background: #fff; padding: 8px; margin-top: 12px; }
</style>
</head>
<body>
<h1>Prova di &lt;tmf-viewer&gt;</h1>
<div class="griglia">
<div><h2>Immagine 800x500 + overlay + etichette (box fisso, Adatta nel box)</h2>
<tmf-viewer id="a" class="box" save-name="demo_immagine" width="800" height="500"></tmf-viewer></div>
<div><h2>fit-height, no-save: alto quanto l'immagine</h2>
<tmf-viewer id="b" fit-height no-save label-fit="Intera"></tmf-viewer></div>
<div><h2>Modo contenuto: svg (DXF) senza src</h2>
<tmf-viewer id="c" class="box" save-name="demo_dxf">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect x="5" y="5" width="190" height="90" fill="none" stroke="#fff" stroke-width="1"/><circle cx="100" cy="50" r="30" fill="none" stroke="#0ff" stroke-width="1"/></svg>
</tmf-viewer></div>
<div><h2>Vuoto: barra nascosta</h2><tmf-viewer id="d" class="box"></tmf-viewer></div>
<!-- #126: no-layers (overlay misto, etichette con key) e, per confronto, lo stesso senza no-layers -->
<div><h2>#126 no-layers: overlay misto, etichette fisse</h2>
<tmf-viewer id="f" class="box" no-layers width="400" height="250" save-name="demo_nolayers"></tmf-viewer></div>
<div><h2>#126 senza no-layers (confronto): livelli e trascinamento</h2>
<tmf-viewer id="g" class="box" width="400" height="250" save-name="demo_conlayers"></tmf-viewer></div>
</div>
<div id="log">in corso...</div>
<script>
// #126: stato dei livelli salvato prima del caricamento del visore (ricerca spenta): con no-layers non deve contare
try { localStorage.setItem('tmf-viewer-layers', JSON.stringify({ ricerca: false })); } catch (e) { /* storage bloccato */ }
</script>
<script src="../../../static/js/vision_overlay.js"></script>
<script src="../../../static/js/save_image.js"></script>
<script src="../../../static/js/image_viewer.js"></script>
<script>
window.onerror = function (m, u, l) { document.getElementById('log').textContent += '\nERRORE ' + m + ' riga ' + l; }; // errori visibili nel riquadro
(function () {
var out = [], fails = 0;
function check(l, c, x) { if (!c) fails++; out.push((c ? 'ok - ' : 'FAIL - ') + l + (c || x === undefined ? '' : ' :: ' + x)); document.getElementById('log').textContent = out.join('\n'); } // log aggiornato a ogni controllo
window.tmfCheck = check; // #126: il blocco no-layers in fondo alla pagina scrive nello stesso #log
function img(w, h, col) {
var c = document.createElement('canvas'); c.width = w; c.height = h;
var g = c.getContext('2d'); g.fillStyle = col; g.fillRect(0, 0, w, h);
g.fillStyle = '#fff'; for (var x = 0; x < w; x += 100) g.fillRect(x, 0, 2, h);
for (var y = 0; y < h; y += 100) g.fillRect(0, y, w, 2);
return c.toDataURL('image/png');
}
var a = document.getElementById('a'), b = document.getElementById('b'), c = document.getElementById('c'), d = document.getElementById('d');
var ev = { load: 0, zoom: 0 };
a.addEventListener('viewer-load', function (e) { ev.load++; ev.det = e.detail; });
a.addEventListener('viewer-zoom', function () { ev.zoom++; });
a.overlay = [{ type: 'segment', role: 'found', row1: 50, col1: 50, row2: 450, col2: 750 },
{ type: 'points', role: 'reference', rows: [100, 200], cols: [100, 300] }];
a.labels = [{ key: 'q1', x: 400, y: 250, text: 'Quota 1 12.345 mm', cls: 'ok' }];
a.setAttribute('src', img(800, 500, '#334155'));
b.setAttribute('src', img(1000, 400, '#475569'));
function btn(v, i) { return v.querySelectorAll('.tmfv-bar button')[i]; }
setTimeout(function () {
var vpa = a.querySelector('.tmfv-vp'), st = a.querySelector('.tmfv-stage');
var fitA = function () { return Math.min(vpa.clientWidth / 800, vpa.clientHeight / 500); };
check('a: viewer-load una volta con 800x500', ev.load === 1 && ev.det && ev.det.width === 800 && ev.det.height === 500, JSON.stringify(ev));
check('a: Adatta = min(box/img), stage entra nel box', Math.abs(a.zoom - fitA()) < 0.01 && st.offsetWidth <= vpa.clientWidth + 1 && st.offsetHeight <= vpa.clientHeight + 1,
a.zoom + ' ' + st.offsetWidth + 'x' + st.offsetHeight + ' vp ' + vpa.clientWidth + 'x' + vpa.clientHeight);
check('a: percentuale mostrata', /^\d+ %$/.test(a.querySelector('.tmfv-pct').textContent), a.querySelector('.tmfv-pct').textContent);
check('a: overlay con 2 forme e viewBox 0 0 800 500', a.querySelector('.tmfv-ov').children.length >= 2 && a.querySelector('.tmfv-ov').getAttribute('viewBox') === '0 0 800 500', a.querySelector('.tmfv-ov').innerHTML.slice(0, 80));
var lab = a.querySelector('.tmfv-label');
check('a: etichetta al 50 % / 50 %, testo, classe', lab && lab.style.left === '50%' && lab.style.top === '50%' && lab.textContent === 'Quota 1 12.345 mm' && lab.classList.contains('ok'));
check('a: barra visibile con -, +, Adatta, Salva', !a.querySelector('.tmfv-bar').hidden && a.querySelectorAll('.tmfv-bar button').length === 4 && btn(a, 3).textContent === 'Salva');
var z0 = a.zoom; btn(a, 1).click();
check('a: + ingrandisce di 1.25', Math.abs(a.zoom / z0 - 1.25) < 1e-9, a.zoom + '/' + z0);
for (var i = 0; i < 6; i++) a.zoomBy(1.25);
check('a: ingrandito -> scorrimento possibile e cursore grab', vpa.scrollWidth > vpa.clientWidth && vpa.style.cursor === 'grab', vpa.style.cursor);
var rect = vpa.getBoundingClientRect(), px = 120, py = 90;
var zb = a.zoom, lx = (vpa.scrollLeft + px) / zb, ly = (vpa.scrollTop + py) / zb;
vpa.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true, deltaY: -100, clientX: rect.left + vpa.clientLeft + px, clientY: rect.top + vpa.clientTop + py, bubbles: true, cancelable: true }));
var za = a.zoom, lx2 = (vpa.scrollLeft + px) / za, ly2 = (vpa.scrollTop + py) / za;
check('a: Ctrl+rotella zooma e tiene fermo il punto', za > zb && Math.abs(lx - lx2) < 1 && Math.abs(ly - ly2) < 1, [zb, za, lx, lx2, ly, ly2].join(' '));
for (i = 0; i < 40; i++) a.zoomBy(1.25);
check('a: zoom limitato a max(8, fit)', a.zoom <= 8.0000001, a.zoom);
var we = new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true }); vpa.dispatchEvent(we);
check('a: rotella senza Ctrl zooma sul punto del mouse (intercettata, #12 #126)', we.defaultPrevented === true); // #126: controllo aggiornato al contratto #12
btn(a, 2).click();
check('a: Adatta torna al fit e scroll a 0', Math.abs(a.zoom - fitA()) < 0.01 && vpa.scrollLeft === 0, a.zoom);
check('a: viewer-zoom emesso a ogni cambio', ev.zoom > 3, ev.zoom);
a.zoomBy(1.25); var zz = a.zoom; ev.load = 0;
a.setAttribute('src', img(800, 500, '#7f1d1d'));
setTimeout(function () {
check('a: nuovo src, stesse dimensioni -> zoom mantenuto', ev.load === 1 && Math.abs(a.zoom - zz) < 1e-9, a.zoom + ' vs ' + zz);
var vpb = b.querySelector('.tmfv-vp');
check('b: fit-height: viewport alto quanto l\'immagine adattata alla larghezza', Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5 && Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01,
vpb.offsetHeight + ' vs ' + 400 * b.zoom + ' zoom ' + b.zoom);
check('b: no-save nasconde Salva, label-fit "Intera"', btn(b, 3).hidden && btn(b, 2).textContent === 'Intera');
b.style.width = '300px';
// il resize e' consegnato dal ResizeObserver al prossimo frame: in una scheda in background puo' tardare, quindi attendo (max 5 s)
var t0 = Date.now();
(function attendi() { if (b.zoom < 0.5 || Date.now() - t0 > 5000) dopo(); else setTimeout(attendi, 100); })();
function dopo() {
check('b: resize in stato adatta -> rifa Adatta (300 px)', Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01 && Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5, b.zoom + ' ' + vpb.offsetHeight);
var stc = c.querySelector('.tmfv-stage');
check('c: svg nello stage, dimensioni dal viewBox 200x100, Adatta', stc.querySelector('svg:not(.tmfv-ov)') && !c.querySelector(':scope > svg') && c.zoom > 0 && Math.abs(stc.offsetWidth / stc.offsetHeight - 2) < 0.05, c.zoom + ' ' + stc.offsetWidth + 'x' + stc.offsetHeight);
// #122: DXF in mm (fit ~ 10): si ingrandisce oltre Adatta; width/height cambiati dopo il connect rifanno Adatta
c.setAttribute('width', '50'); c.setAttribute('height', '30'); // contenuto 50 x 30 mm nel box ~ 560 x 320
var zc = c.zoom; c.zoomBy(1.25);
check('c: width/height 50x30 -> fit > 8 e zoom oltre Adatta', zc > 8 && c.zoom > zc * 1.2, zc + ' -> ' + c.zoom);
// #122: viewer-error su immagine non caricabile; src tolto -> componente vuoto senza errori
var e = document.createElement('tmf-viewer'); e.style.height = '100px'; document.body.appendChild(e);
var gotErr = null; e.addEventListener('viewer-error', function (x) { gotErr = x.detail; });
e.overlay = [{ type: 'segment', role: 'found', row1: 1, col1: 1, row2: 5, col2: 9 }]; e.labels = [{ x: 5, y: 5, text: 'x' }]; // prima del load
e.setAttribute('src', 'inesistente.png');
setTimeout(function () {
check('e: viewer-error con src', gotErr && gotErr.src === 'inesistente.png', JSON.stringify(gotErr));
e.setAttribute('src', img(100, 50, '#000'));
setTimeout(function () {
check('e: overlay ed etichette impostati prima del load disegnati dopo', e.querySelectorAll('.tmfv-ov g[data-layer="costruzioni"] > *').length === 1 && e.querySelectorAll('.tmfv-label').length === 1);
var lb = getComputedStyle(e.querySelector('.tmfv-label'));
check('e: etichetta con aspetto proprio (absolute, bold, padding)', lb.position === 'absolute' && lb.fontWeight === '700' && lb.paddingLeft === '6px' && lb.pointerEvents === 'none');
e.removeAttribute('src');
check('e: src tolto -> vuoto, barra nascosta', e.querySelector('.tmfv-bar').hidden === true && e.zoom === 0);
}, 300);
}, 300);
check('d: vuoto -> barra nascosta', d.querySelector('.tmfv-bar').hidden === true);
check('stile iniettato una sola volta', document.querySelectorAll('#tmf-viewer-css').length === 1);
tmfSvgToPngBlob(c.querySelector('.tmfv-stage > svg:not(.tmfv-ov)'), 200, 100).then(function (bl) {
check('c: tmfSvgToPngBlob produce un PNG', bl && bl.type === 'image/png' && bl.size > 100, bl && (bl.type + ' ' + bl.size));
return createImageBitmap(bl);
}).then(function (bm) {
check('c: PNG 1600x800', bm.width === 1600 && bm.height === 800, bm.width + 'x' + bm.height);
}).catch(function (er) { check('c: rasterizzazione', false, String(er)); }).then(function () { return new Promise(function (r) { setTimeout(r, 900); }); }).then(function () {
document.getElementById('log').textContent = out.join('\n') + '\n\n' + (out.length - fails) + '/' + out.length + ' controlli ok';
document.title = fails ? 'FALLITO' : 'TUTTO OK';
});
}
}, 400);
}, 600);
})();
</script>
<script>
// #126: controlli del viewer no-layers (f) contro lo stesso overlay senza no-layers (g)
(function () {
var check = window.tmfCheck;
function img(w, h, col) {
var c = document.createElement('canvas'); c.width = w; c.height = h;
var g = c.getContext('2d'); g.fillStyle = col; g.fillRect(0, 0, w, h);
return c.toDataURL('image/png');
}
var f = document.getElementById('f'), g = document.getElementById('g');
function dati() { // overlay misto: found (costruzioni), non found (ricerca), layer 'ricerca' esplicito; due etichette con key
return {
ov: [{ type: 'segment', role: 'found', row1: 20, col1: 20, row2: 220, col2: 380 },
{ type: 'segment', role: 'reference', row1: 20, col1: 380, row2: 220, col2: 20 },
{ type: 'points', role: 'reference', layer: 'ricerca', rows: [60, 120], cols: [100, 300] }],
lb: [{ key: 'q1', nome: 'Larghezza', camera: 1, x: 200, y: 120, text: 'Larghezza 12.345', breve: '12.345', cls: 'stz-ok',
quota: { tipo: 'lineare', p1: [60, 200], p2: [340, 200], testo: [0, 20] } },
{ key: 'q2', nome: 'Punto', camera: 1, x: 100, y: 60, text: 'Punto', breve: 'P' }]
};
}
var df = dati(), dg = dati(), movf = 0, movg = 0;
f.addEventListener('quota-spostata', function () { movf++; });
g.addEventListener('quota-spostata', function () { movg++; });
f.overlay = df.ov; f.labels = df.lb; g.overlay = dg.ov; g.labels = dg.lb;
f.setAttribute('src', img(400, 250, '#1e293b')); g.setAttribute('src', img(400, 250, '#1e293b'));
function drag(el) { // trascinamento simulato di un'etichetta: down sull'etichetta, move e up sul documento
var r = el.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
var o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 };
el.dispatchEvent(new PointerEvent('pointerdown', Object.assign({ clientX: x, clientY: y }, o)));
document.dispatchEvent(new PointerEvent('pointermove', Object.assign({ clientX: x + 30, clientY: y + 20 }, o)));
document.dispatchEvent(new PointerEvent('pointerup', Object.assign({ clientX: x + 30, clientY: y + 20 }, o)));
}
setTimeout(function () {
var ovf = f.querySelector('.tmfv-ov'), ovg = g.querySelector('.tmfv-ov');
check('f no-layers: localStorage ricerca=false, ma nessun g[data-layer], nessun opacity, nessun display none', ovf.querySelectorAll('g[data-layer]').length === 0 && ovf.querySelectorAll('[opacity]').length === 0 && ovf.innerHTML.indexOf('display="none"') < 0, ovf.innerHTML.slice(0, 120));
check('f no-layers: forme found, non found e layer ricerca tutte nel disegno (>= 5 elementi)', ovf.children.length >= 5, ovf.children.length);
check('f no-layers: menu dei livelli nascosto', f.querySelector('.tmfv-layers').hidden === true);
check('f no-layers: immagine visibile, etichette visibili con text', f.querySelector('.tmfv-img').style.visibility === '' && f.querySelector('.tmfv-labels').style.display !== 'none' &&
f.querySelectorAll('.tmfv-label')[0].textContent === 'Larghezza 12.345' && f.querySelectorAll('.tmfv-label')[1].textContent === 'Punto');
check('g senza no-layers (confronto): tre gruppi g[data-layer] e menu visibile', ovg.querySelectorAll('g[data-layer]').length === 3 && g.querySelector('.tmfv-layers').hidden === false);
var lf = f.querySelector('.tmfv-label[data-key="q1"]');
var pf = lf.style.left + '|' + lf.style.top;
check('f no-layers: etichetta con pointer-events none e cursore default', getComputedStyle(lf).pointerEvents === 'none' && getComputedStyle(lf).cursor === 'default', getComputedStyle(lf).pointerEvents);
drag(lf); drag(g.querySelector('.tmfv-label[data-key="q1"]'));
var lf2 = f.querySelector('.tmfv-label[data-key="q1"]');
check('f no-layers: trascinamento simulato -> nessun quota-spostata, posizione invariata', movf === 0 && lf2.style.left + '|' + lf2.style.top === pf, movf + ' ' + lf2.style.left);
check('g senza no-layers (controllo del test): lo stesso trascinamento emette quota-spostata', movg === 1, movg);
var z0 = f.zoom, r = f.querySelector('.tmfv-vp').getBoundingClientRect();
var we = new WheelEvent('wheel', { deltaY: -100, clientX: r.left + 100, clientY: r.top + 100, bubbles: true, cancelable: true });
f.querySelector('.tmfv-vp').dispatchEvent(we);
check('f no-layers: rotella senza Ctrl -> zoom cambiato', f.zoom > z0 && we.defaultPrevented === true, z0 + ' -> ' + f.zoom);
f.removeAttribute('no-layers');
check('f: no-layers tolto a runtime -> gruppi g[data-layer], menu e trascinamento', ovf.querySelectorAll('g[data-layer]').length === 3 && f.querySelector('.tmfv-layers').hidden === false &&
getComputedStyle(f.querySelector('.tmfv-label[data-key="q1"]')).pointerEvents === 'auto');
f.setAttribute('no-layers', '');
check('f: no-layers rimesso a runtime -> di nuovo disegno unico, menu nascosto', ovf.querySelectorAll('g[data-layer]').length === 0 && f.querySelector('.tmfv-layers').hidden === true && ovf.children.length >= 5);
}, 1000);
})();
</script>
</body>
</html>
@@ -0,0 +1,448 @@
{
"outcome": "pass",
"overlay": [
{
"type": "points",
"role": "found",
"rows": [
2218.926540676052
],
"cols": [
2371.08496101821
],
"tool_id": "blob"
},
{
"type": "segment",
"role": "found",
"row1": 2218.926540676052,
"col1": 2371.08496101821,
"row2": 2268.925168990387,
"col2": 2371.4553212279457,
"tool_id": "blob"
},
{
"type": "contour",
"role": "found",
"rows": [
2469.0093822012514,
2467.8907055854274,
1969.1995500932737,
1970.3182267090972,
2469.0093822012514
],
"cols": [
2297.7334028311207,
2448.7547459672996,
2445.0607374080982,
2294.0393942719193,
2297.7334028311207
],
"tool_id": "blob"
},
{
"type": "rect2",
"role": "roi",
"row": 2250.0000476834334,
"col": 2296.999963957319,
"phi_deg": -89.99999458063853,
"length1": 159.9999999999998,
"length2": 20.0,
"tool_id": "lato_sx"
},
{
"type": "segment",
"role": "found",
"row1": 2409.997184964675,
"col1": 2297.344751969946,
"row2": 2089.9983738509072,
"col2": 2295.147617847124,
"tool_id": "lato_sx"
},
{
"type": "rect2",
"role": "roi",
"row": 2250.000033590164,
"col": 2445.999963957318,
"phi_deg": -89.99999458063853,
"length1": 159.9999999999998,
"length2": 20.0,
"tool_id": "lato_dx"
},
{
"type": "segment",
"role": "found",
"row1": 2410.000338325413,
"col1": 2446.9546542059475,
"row2": 2090.0001187672883,
"col2": 2444.590522414638,
"tool_id": "lato_dx"
},
{
"type": "rect2",
"role": "roi",
"row": 1970.0000406368001,
"col": 2371.4999374733225,
"phi_deg": 5.419361327409989e-06,
"length1": 36.499999999999936,
"length2": 15.0,
"tool_id": "lato_alto"
},
{
"type": "segment",
"role": "found",
"row1": 1969.7270815449738,
"col1": 2335.0012415021283,
"row2": 1969.9815342357629,
"col2": 2408.0075386065682,
"tool_id": "lato_alto"
},
{
"type": "rect2",
"role": "roi",
"row": 2466.000040636798,
"col": 2371.4999843878295,
"phi_deg": 5.419361327409989e-06,
"length1": 36.499999999999936,
"length2": 15.0,
"tool_id": "lato_basso"
},
{
"type": "segment",
"role": "found",
"row1": 2466.9468485672455,
"col1": 2335.0088484836465,
"row2": 2465.293164536675,
"col2": 2407.9979613716796,
"tool_id": "lato_basso"
},
{
"type": "arc",
"role": "roi",
"row": 2015.6000435878736,
"col": 2340.2999417864303,
"radius": 46.0,
"start_deg": 95.00000541936147,
"extent_deg": 80.0,
"tool_id": "arco_tl"
},
{
"type": "arc",
"role": "found",
"row": 2014.0729365101115,
"col": 2338.325339664551,
"radius": 43.69800114451398,
"start_deg": 92.61763130401864,
"extent_deg": 84.15103825789168,
"tool_id": "arco_tl"
},
{
"type": "arc",
"role": "roi",
"row": 2016.1000381586543,
"col": 2397.699941833723,
"radius": 46.0,
"start_deg": 5.000005419361472,
"extent_deg": 80.0,
"tool_id": "arco_tr"
},
{
"type": "arc",
"role": "found",
"row": 2014.7155277666257,
"col": 2400.767078642102,
"radius": 43.26606749223183,
"start_deg": 3.5348692687035217,
"extent_deg": 85.33536916618716,
"tool_id": "arco_tr"
},
{
"type": "arc",
"role": "roi",
"row": 2418.4000378086853,
"col": 2401.3999798855507,
"radius": 46.0,
"start_deg": 275.00000541936146,
"extent_deg": 80.0,
"tool_id": "arco_br"
},
{
"type": "arc",
"role": "found",
"row": 2420.300713032017,
"col": 2404.4253040181898,
"radius": 42.604285715934104,
"start_deg": -76.0934883340429,
"extent_deg": 73.28101449433917,
"tool_id": "arco_br"
},
{
"type": "arc",
"role": "roi",
"row": 2421.8000432662807,
"col": 2343.699980207142,
"radius": 46.0,
"start_deg": 185.00000541936146,
"extent_deg": 80.0,
"tool_id": "arco_bl"
},
{
"type": "arc",
"role": "found",
"row": 2423.3935764712314,
"col": 2338.8006257209418,
"radius": 41.362918221221754,
"start_deg": -176.60049573516702,
"extent_deg": 77.4552373110766,
"tool_id": "arco_bl"
},
{
"type": "segment",
"role": "found",
"row1": 2250.0002285463506,
"col1": 2445.772588310293,
"row2": 2251.0268387327387,
"col2": 2296.253250501527,
"tool_id": "larghezza"
},
{
"type": "segment",
"role": "found",
"row1": 2466.12000655196,
"col1": 2371.503404927663,
"row2": 1969.8603328620893,
"col2": 2373.2330448944704,
"tool_id": "lunghezza"
},
{
"type": "circle",
"role": "found",
"row": 2014.0729365101115,
"col": 2338.325339664551,
"radius": 43.69800114451398,
"tool_id": "R_tl"
},
{
"type": "circle",
"role": "found",
"row": 2014.7155277666257,
"col": 2400.767078642102,
"radius": 43.26606749223183,
"tool_id": "R_tr"
},
{
"type": "circle",
"role": "found",
"row": 2420.300713032017,
"col": 2404.4253040181898,
"radius": 42.604285715934104,
"tool_id": "R_br"
},
{
"type": "circle",
"role": "found",
"row": 2423.3935764712314,
"col": 2338.8006257209418,
"radius": 41.362918221221754,
"tool_id": "R_bl"
}
],
"failures": [],
"measurements": [
{
"subtask_id": 15,
"value": 1.6682265730716714,
"pass_fail": "pass"
},
{
"subtask_id": 16,
"value": 5.536802808714362,
"pass_fail": "pass"
},
{
"subtask_id": 17,
"value": 0.48753859876934247,
"pass_fail": "pass"
},
{
"subtask_id": 18,
"value": 0.4827195150108305,
"pass_fail": "pass"
},
{
"subtask_id": 19,
"value": 0.4753360157326768,
"pass_fail": "pass"
},
{
"subtask_id": 20,
"value": 0.4614860785941711,
"pass_fail": "pass"
},
{
"subtask_id": 21,
"value": 0.004819083758511997,
"pass_fail": "pass"
},
{
"subtask_id": 22,
"value": 0.012202583036665687,
"pass_fail": "pass"
},
{
"subtask_id": 23,
"value": 0.026052520175171373,
"pass_fail": "pass"
},
{
"subtask_id": 24,
"value": 0.00738349927815369,
"pass_fail": "pass"
},
{
"subtask_id": 25,
"value": 0.021233436416659375,
"pass_fail": "pass"
},
{
"subtask_id": 26,
"value": 0.013849937138505686,
"pass_fail": "pass"
}
],
"calibration": "ok",
"_subtasks": [
{
"id": 15,
"marker_number": 1,
"description": "Larghezza",
"measurement_type": null,
"unit": "mm",
"vision_output": "larghezza.value",
"nominal": 1.668,
"ltl": null,
"utl": null
},
{
"id": 16,
"marker_number": 2,
"description": "Lunghezza",
"measurement_type": null,
"unit": "mm",
"vision_output": "lunghezza.value",
"nominal": 5.537,
"ltl": null,
"utl": null
},
{
"id": 17,
"marker_number": 3,
"description": "R alto-sinistra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_tl.value",
"nominal": 0.488,
"ltl": null,
"utl": null
},
{
"id": 18,
"marker_number": 4,
"description": "R alto-destra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_tr.value",
"nominal": 0.483,
"ltl": null,
"utl": null
},
{
"id": 19,
"marker_number": 5,
"description": "R basso-destra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_br.value",
"nominal": 0.475,
"ltl": null,
"utl": null
},
{
"id": 20,
"marker_number": 6,
"description": "R basso-sinistra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_bl.value",
"nominal": 0.464,
"ltl": null,
"utl": null
},
{
"id": 21,
"marker_number": 7,
"description": "Diff. R alto-sx / alto-dx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tl_tr.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 22,
"marker_number": 8,
"description": "Diff. R alto-sx / basso-dx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tl_br.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 23,
"marker_number": 9,
"description": "Diff. R alto-sx / basso-sx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tl_bl.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 24,
"marker_number": 10,
"description": "Diff. R alto-dx / basso-dx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tr_br.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 25,
"marker_number": 11,
"description": "Diff. R alto-dx / basso-sx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tr_bl.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 26,
"marker_number": 12,
"description": "Diff. R basso-dx / basso-sx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_br_bl.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
}
]
}
@@ -153,6 +153,20 @@ const MEASUREMENT = {
);
}
// #118: quota non misurata (value null) -> NaN nel valore e nella deviazione; tolleranze nulle restano vuote
{
const e = csvExport();
const nan = { ...MEASUREMENT, value: null, deviation: null, pass_fail: 'fail',
subtask: { ...MEASUREMENT.subtask, utl: null, ltl: null } };
const row = e.buildMeasurementCSV([nan]).split('\n')[1].split(';');
check('#118 value null -> NaN', row[3] === 'NaN', row.join('|'));
check('#118 deviazione null -> NaN', row[8] === 'NaN', row.join('|'));
check('#118 tolleranze nulle restano vuote', row[6] === '' && row[7] === '', row.join('|'));
check('#118 esito fail', row[9] === 'fail', row.join('|'));
const ok = e.buildMeasurementCSV([MEASUREMENT]).split('\n')[1].split(';');
check('#118 valore presente invariato', ok[3] !== 'NaN' && ok[8] !== 'NaN', ok.join('|'));
}
if (failures > 0) {
console.error(failures + ' test falliti su csv-export.js');
process.exit(1);
@@ -0,0 +1,251 @@
/**
* #101: anteprima DXF del wizard «Crea ricetta da DXF», con plain node.
* Copre dxfPreviewPaths, dxfPreviewViewBox, dxfPreviewSummary e lo scarto
* di una risposta arrivata dopo un cambio di file.
*
* Run from src/frontend/flask_app: node tests/js/test_dxf_preview.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
global.document = { documentElement: { lang: 'it' } };
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'js', 'dxf_recipe_wizard.js'), 'utf8',
);
eval(src + ';global.dxfRecipeWizard = dxfRecipeWizard;');
let failures = 0;
function check(label, cond, extra) {
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const PREVIEW = {
contorni: [[[0, -5], [12.3, -5], [12.3, 0], [0, 0], [0, -5]], [[1, -1]]],
bbox: { xmin: 0, ymin: -5.1, xmax: 12.3, ymax: 0 },
larghezza_mm: 12.3, altezza_mm: 5.1, entita: 2, punti: 6,
};
(async () => {
const w = dxfRecipeWizard(1, () => 'tok', { dxfEntities: 'entità' });
check('senza anteprima: nessun path', w.dxfPreviewPaths().length === 0);
check('senza anteprima: summary vuoto', w.dxfPreviewSummary === '');
w.dxfPreview = PREVIEW;
const paths = w.dxfPreviewPaths();
check('un contorno con >1 punti diventa una polyline (quello da 1 punto no)', paths.length === 1);
check('formato points', paths[0] === '0,-5 12.3,-5 12.3,0 0,0 0,-5', paths[0]);
// #101: path unico per <path :d>
check('dxfPreviewD', w.dxfPreviewD() === 'M 0 -5 L 12.3 -5 L 12.3 0 L 0 0 L 0 -5', w.dxfPreviewD());
w.dxfPreview = null;
check('dxfPreviewD vuoto senza anteprima', w.dxfPreviewD() === '');
w.dxfPreview = PREVIEW;
const vb = w.dxfPreviewViewBox().split(' ').map(Number);
const m = Math.max(0.5, 0.05 * 12.3);
check('viewBox con margine 5%', Math.abs(vb[0] + m) < 1e-3 && Math.abs(vb[2] - (12.3 + 2 * m)) < 1e-3, vb.join(' '));
// Disegno minuscolo: margine minimo 0.5 mm
w.dxfPreview = { ...PREVIEW, bbox: { xmin: 0, ymin: 0, xmax: 1, ymax: 1 } };
check('margine minimo 0.5 mm', Math.abs(w.dxfPreviewViewBox().split(' ').map(Number)[0] + 0.5) < 1e-3);
w.dxfPreview = PREVIEW;
const s = w.dxfPreviewSummary;
check('summary L × H mm · N entità', s.includes('×') && s.includes('mm') && s.endsWith('2 entità')
&& /12[,.]30/.test(s) && /5[,.]10/.test(s), s);
// Risposta tardiva scartata: due scelte di file, la prima risponde per ultima
const resolvers = [];
global.fetch = () => new Promise(res => resolvers.push(res));
global.FormData = class { append() {} };
const w2 = dxfRecipeWizard(1, () => 'tok', {});
const reply = (body, ok = true) => ({ ok, json: async () => body });
w2.dxfFile = { name: 'a.dxf' };
const p1 = w2.loadDxfPreview();
w2.dxfFile = { name: 'b.dxf' };
const p2 = w2.loadDxfPreview();
resolvers[1](reply(PREVIEW));
await p2;
resolvers[0](reply({ ...PREVIEW, entita: 99 }));
await p1;
check('la risposta vecchia non sovrascrive la nuova', w2.dxfPreview && w2.dxfPreview.entita === 2);
check('busy spento', w2.dxfPreviewBusy === false);
// File tolto: tutto azzerato
w2.dxfFile = null;
await w2.loadDxfPreview();
check('file tolto azzera anteprima ed errore', w2.dxfPreview === null && w2.dxfPreviewError === '');
// Errore 422 con detail
global.fetch = async () => reply({ detail: 'DXF illeggibile: x' }, false);
w2.dxfFile = { name: 'c.dxf' };
await w2.loadDxfPreview();
check('errore col detail', w2.dxfPreviewError === 'DXF illeggibile: x' && w2.dxfPreview === null, w2.dxfPreviewError);
// #102: anteprima dell'immagine
const wi = dxfRecipeWizard(1, () => 'tok', { imageField: 'campo' });
check('#102 senza immagine: summary vuoto', wi.imagePreviewSummary === '');
global.URL.createObjectURL = () => 'blob:x';
global.URL.revokeObjectURL = () => {};
wi._setImage({ name: 'a.bmp' }, 'file'); // #104: la scala a mano vale solo per file
check('#102 _setImage imposta URL e nome', wi.imagePreviewUrl === 'blob:x' && wi.imageName === 'a.bmp');
wi.onImagePreviewLoad({ 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);
wi.mmPx = ''; wi.calState = 'calibrated'; wi.calPxMm = 100;
check('#102 con calibrazione stazione: campo in mm', /campo ≈ 20,00 × 10,00 mm$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
wi.onImagePreviewError();
check('#102 errore <img>: imageBroken e file ancora valido', wi.imageBroken === true && wi.imageFile && wi.imagePx === null);
wi._setImage(null);
check('#102 _setImage(null) azzera', wi.imagePreviewUrl === '' && wi.imageFile === null && wi.imageName === ''
&& wi.imagePx === null && wi.imageBroken === false && wi.imagePreviewSummary === '');
// #104: scala a mano solo per immagini da file
const w4 = dxfRecipeWizard(1, () => 'tok', {});
w4._setImage({ name: 'a.bmp' }, 'file');
w4.mmPx = '0,01406';
check('#104 useManualScale vero con file', w4.useManualScale === true);
w4._setImage({ name: 'camera.png' }, 'camera');
check('#104 useManualScale falso con camera anche se mmPx valido', w4.manualPxMm !== null && w4.useManualScale === false);
w4.stationId = '1'; w4.dxfFile = { name: 'd.dxf' };
check('#104 canRun con camera richiede la calibrazione', w4.canRun === false);
w4.calState = 'calibrated'; w4.calPxMm = 50;
check('#104 canRun con camera e stazione calibrata', w4.canRun === true);
// #104: run() con la camera non invia px_per_mm; con il file si
const sent = [];
global.FormData = class { append(k, v) { sent.push(k); } };
global.fetch = async () => ({ ok: true, json: async () => ({ result: { outputs: {}, overlay: [], outcome: 'pass' } }) });
const tick = [];
w4.$nextTick = f => tick.push(f);
w4.$dispatch = () => {};
w4.$refs = {};
await w4.run(false);
check('#104 run con camera: niente px_per_mm', !sent.includes('px_per_mm') && sent.includes('station_id'), sent.join(','));
check('#104 run: tested true e scala = stazione', w4.tested === true && w4.runPxPerMm === 50);
sent.length = 0;
w4._setImage({ name: 'a.bmp' }, 'file');
w4.mmPx = '0,02';
await w4.run(false);
check('#104 run con file: px_per_mm inviato', sent.includes('px_per_mm') && Math.abs(w4.runPxPerMm - 50) < 1e-9, sent.join(','));
check('#104 esito pass', w4.outcome === 'pass' && w4.outcomeClass.includes('emerald'));
// #104: tested torna false dopo _setImage / onDxf / onStation
w4.tested = true; w4._setImage(null);
check('#104 tested falso dopo _setImage', w4.tested === false && w4.imageSource === '');
w4.tested = true; w4.onDxf({ target: { files: [] } });
check('#104 tested falso dopo onDxf', w4.tested === false);
w4.tested = true; w4.stationId = ''; await w4.onStation();
check('#104 tested falso dopo onStation', w4.tested === false);
// #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', {});
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) || ''; } };
const SRC = { band_mm: 0.3, min_points_pct: 90, polarity: 'light', px_per_mm: 71.1237, dxf: 'disegno.dxf', image: 'riferimento.bmp' };
const blobOf = t => ({ type: t });
const mkFetch = (routes, calls) => async (url) => {
calls.push(url);
for (const k of Object.keys(routes)) if (url.endsWith(k)) return routes[k]();
return { ok: false, status: 404, json: async () => ({ error: true }) };
};
const okJson = body => () => ({ ok: true, json: async () => body });
const okBlob = t => () => ({ ok: true, blob: async () => blobOf(t) });
const w6 = dxfRecipeWizard(8, () => 'tok', { noReferenceImage: 'senza immagine', editLabel: 'Modifica', createLabel: 'Crea' });
w6.$nextTick = f => f();
const calls6 = [];
global.fetch = mkFetch({
'/recipe/dxf-compare/source': okJson(SRC),
'/recipe/files/disegno.dxf': okBlob('application/dxf'),
'/recipe/files/riferimento.bmp': okBlob('image/bmp'),
'/dxf-preview': okJson(PREVIEW),
}, calls6);
global.FormData = class { append() {} };
check('#105 senza ricetta: existing null', w6.existing === null);
w6.init();
await w6._existingReady;
check('#105 init legge /source', w6.existing && w6.existing.band_mm === 0.3 && calls6[0] === '/maker/api/vision/tasks/8/recipe/dxf-compare/source', calls6.join(','));
await w6.toggle();
await w6.openExisting();
check('#105 apertura: banda, soglia, polarita', w6.bandMm === 0.3 && w6.minPointsPct === 90 && w6.dark === false);
check('#105 mm/px = 1/px_per_mm con virgola', w6.mmPx === '0,01406', w6.mmPx);
check('#105 DXF caricato dalla cartella', w6.dxfFile && w6.dxfFile.name === 'disegno.dxf' && w6.dxfFromRecipe === true);
await new Promise(r => setTimeout(r, 0)); // l'anteprima parte senza essere attesa
check('#105 anteprima DXF richiesta', calls6.some(u => u.endsWith('/dxf-preview')) && w6.dxfPreview !== null);
check('#105 immagine con source file: il campo mm/px vale', w6.imageFile && w6.imageFile.name === 'riferimento.bmp'
&& w6.imageSource === 'file' && w6.imageSrc === 'blob:x');
check('#105 info mostrata, nessun avviso', w6.existingInfo === true && w6.existingWarn === '');
const nCalls = calls6.length;
w6.bandMm = 0.2;
await w6.openExisting();
check('#105 seconda apertura non ricompila', w6.bandMm === 0.2 && calls6.length === nCalls);
// #105: ricetta senza immagine salvata -> avviso
const w7 = dxfRecipeWizard(8, () => 'tok', { noReferenceImage: 'senza immagine' });
global.fetch = mkFetch({
'/recipe/dxf-compare/source': okJson({ ...SRC, image: null, px_per_mm: null }),
'/recipe/files/disegno.dxf': okBlob('application/dxf'),
'/dxf-preview': okJson(PREVIEW),
}, []);
w7.init(); await w7._existingReady; await w7.openExisting();
check('#105 senza immagine: avviso e nessun file', w7.existingWarn === 'senza immagine' && w7.imageFile === null && w7.imageSource === '');
check('#105 px_per_mm null: mm/px resta vuoto', w7.mmPx === '');
// #105: 404 -> existing null, etichetta «Crea»
const w8 = dxfRecipeWizard(9, () => 'tok', {});
global.fetch = mkFetch({}, []);
w8.init(); await w8._existingReady; await w8.openExisting();
check('#105 404: existing null e nessuna compilazione', w8.existing === null && w8.existingInfo === false && w8.dxfFile === null);
// #105: file scelti a mano durante il download della ricetta non si sovrascrivono
const w9 = dxfRecipeWizard(8, () => 'tok', {});
const gates = {};
const gate = k => () => new Promise(res => { gates[k] = () => res({ ok: true, blob: async () => blobOf(k) }); });
global.fetch = mkFetch({
'/recipe/dxf-compare/source': okJson(SRC),
'/recipe/files/disegno.dxf': gate('dxf'),
'/recipe/files/riferimento.bmp': gate('bmp'),
'/dxf-preview': okJson(PREVIEW),
}, []);
w9.init(); await w9._existingReady;
const pa = w9.openExisting();
await new Promise(r => setTimeout(r, 0));
w9.onDxf({ target: { files: [{ name: 'mio.dxf' }] } });
w9._setImage({ name: 'mia.bmp' }, 'file');
gates.dxf(); gates.bmp();
await pa;
check('#105 DXF scelto a mano non sovrascritto', w9.dxfFile.name === 'mio.dxf' && w9.dxfFromRecipe === false);
check('#105 immagine scelta a mano non sovrascritta', w9.imageFile.name === 'mia.bmp');
// #105: file della ricetta illeggibile -> avviso
const w10 = dxfRecipeWizard(8, () => 'tok', { existingFilesFailed: 'illeggibili' });
global.fetch = mkFetch({
'/recipe/dxf-compare/source': okJson(SRC),
'/recipe/files/disegno.dxf': () => ({ ok: false, status: 404 }),
'/recipe/files/riferimento.bmp': okBlob('image/bmp'),
}, []);
w10.init(); await w10._existingReady; await w10.openExisting();
check('#105 DXF illeggibile: avviso, immagine comunque caricata', w10.existingWarn === 'illeggibili' && w10.dxfFile === null && w10.imageFile !== null);
// #105: dopo Salva si rilegge /source e il wizard non si ricompila
const w11 = dxfRecipeWizard(8, () => 'tok', {});
w11.$nextTick = f => f(); w11.$dispatch = () => {}; w11.$refs = {};
w11.stationId = '1'; w11.calState = 'calibrated'; w11.calPxMm = 50;
w11.imageFile = { name: 'a.bmp' }; w11.dxfFile = { name: 'd.dxf' };
global.FormData = class { append() {} };
global.fetch = async (url, opts) => url.endsWith('/source')
? { ok: true, json: async () => SRC }
: { ok: true, json: async () => ({ saved: true, result: { outputs: {}, overlay: [], outcome: 'pass' } }) };
await w11.run(true);
check('#105 dopo Salva: existing letto e non ricompilato', w11.existing && w11.existing.band_mm === 0.3 && w11._existingApplied === true);
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
console.log('tutto ok');
})();
@@ -0,0 +1,74 @@
/**
* #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
*/
'use strict';
const fs = require('fs');
const path = require('path');
global.document = { documentElement: { lang: 'it' } };
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'js', 'dxf_recipe_wizard.js'), 'utf8',
);
eval(src + ';global.dxfRecipeWizard = dxfRecipeWizard;');
let failures = 0;
function check(label, cond, extra) {
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const w = dxfRecipeWizard(1, () => 'tok', {});
w.$nextTick = fn => fn();
// #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));
// onImagePreviewLoad riceve il detail di viewer-load e alimenta il riepilogo
global.URL.createObjectURL = () => 'blob:x';
global.URL.revokeObjectURL = () => {};
w._setImage({ name: 'a.bmp' }, 'file');
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('#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 === '');
// Anteprima DXF: il viewBox resta quello per il <tmf-viewer> (width/height del template)
(async () => {
const PREVIEW = {
contorni: [[[0, 0], [20, 0], [20, 10]]],
bbox: { xmin: 0, ymin: 0, xmax: 20, ymax: 10 },
larghezza_mm: 20, altezza_mm: 10, entita: 1, punti: 3,
};
global.FormData = class { append() {} };
global.fetch = async () => ({ ok: true, json: async () => PREVIEW });
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('#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('#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 #122 ok');
})();
@@ -0,0 +1,32 @@
/**
* image_to_png.js, run with plain node (#107): solo la parte pura (niente canvas qui).
* Run from src/frontend/flask_app: node tests/js/test_image_to_png.js
*/
'use strict';
const path = require('path');
const { tmfNeedsPng, tmfImageToPng } = require(
path.join(__dirname, '..', '..', 'static', 'js', 'image_to_png.js'),
);
let failures = 0;
function check(label, cond) {
if (cond) { console.log('ok - ' + label); } else { console.error('FAIL - ' + label); failures++; }
}
check('bmp va convertito', tmfNeedsPng('image/bmp') === true);
check('tiff va convertito', tmfNeedsPng('image/tiff') === true);
check('png no', tmfNeedsPng('image/png') === false);
check('jpeg no', tmfNeedsPng('image/jpeg') === false);
check('gif no', tmfNeedsPng('image/gif') === false);
check('webp no', tmfNeedsPng('image/webp') === false);
check('pdf no', tmfNeedsPng('application/pdf') === false);
check('tipo vuoto no', tmfNeedsPng('') === false);
(async () => {
const fake = { name: 'a.png', type: 'image/png' };
check('png ritorna lo stesso oggetto', (await tmfImageToPng(fake)) === fake);
const pdf = { name: 'a.pdf', type: 'application/pdf' };
check('pdf ritorna lo stesso oggetto', (await tmfImageToPng(pdf)) === pdf);
if (failures) { console.error(failures + ' failure(s)'); process.exit(1); }
console.log('all ok');
})();
@@ -0,0 +1,99 @@
/**
* #122: static/js/image_viewer.js (<tmf-viewer>) con plain node: matematica pura (TmfViewerMath) e
* registrazione del Custom Element una sola volta. Il DOM vero si prova in Chrome (fixtures/image_viewer_demo.html).
*
* Il modulo si carica senza window/document/customElements.
* Run from src/frontend/flask_app: node tests/js/test_image_viewer.js
*/
'use strict';
const path = require('path');
const MOD = path.join(__dirname, '..', '..', 'static', 'js', 'image_viewer.js');
let failures = 0;
let total = 0;
function check(label, cond, extra) {
total++;
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const near = (a, b) => Math.abs(a - b) < 1e-9;
// --- caricamento in node senza globali del browser ---
check('node senza window/document/customElements/HTMLElement',
typeof window === 'undefined' && typeof document === 'undefined' &&
typeof customElements === 'undefined' && typeof HTMLElement === 'undefined');
const { TmfViewerMath: M } = require(MOD);
check('esporta TmfViewerMath con le quattro funzioni',
M && ['fitZoom', 'clampZoom', 'zoomAround', 'pct'].every((k) => typeof M[k] === 'function'));
// --- fitZoom ---
check('fitZoom: box largo, immagine alta -> limita l\'altezza', near(M.fitZoom(800, 400, 1000, 1000), 0.4));
check('fitZoom: box alto, immagine larga -> limita la larghezza', near(M.fitZoom(400, 800, 1000, 500), 0.4));
check('fitZoom: immagine piu\' piccola del box -> zoom > 1', near(M.fitZoom(800, 600, 400, 300), 2));
check('fitZoom: box esatto -> 1', near(M.fitZoom(640, 480, 640, 480), 1));
check('fitZoom: boxH Infinity (fit-height) -> solo larghezza', near(M.fitZoom(500, Infinity, 1000, 4000), 0.5));
check('fitZoom: 0 se box nascosto o immagine senza dimensioni',
M.fitZoom(0, 400, 10, 10) === 0 && M.fitZoom(400, 0, 10, 10) === 0 &&
M.fitZoom(400, 400, 0, 10) === 0 && M.fitZoom(400, 400, 10, 0) === 0);
check('fitZoom: 0 con NaN, negativi, undefined',
M.fitZoom(NaN, 400, 10, 10) === 0 && M.fitZoom(-5, 400, 10, 10) === 0 && M.fitZoom(400, 400, undefined, 10) === 0 &&
M.fitZoom(400, 400, 10, null) === 0);
// --- clampZoom ---
check('clampZoom: dentro i limiti resta uguale', near(M.clampZoom(1, 0.5), 1));
check('clampZoom: sotto fit*0.5 -> fit*0.5', near(M.clampZoom(0.01, 0.5), 0.25));
check('clampZoom: sopra 8 -> 8', near(M.clampZoom(20, 0.5), 8));
check('clampZoom: fit sopra 1 alza il massimo a fit*8 (DXF in mm, fit ~10)', near(M.clampZoom(20, 12), 20) && near(M.clampZoom(200, 12), 96) && near(M.clampZoom(11, 12), 11));
check('clampZoom: con fit piccolo il massimo resta 8', near(M.clampZoom(20, 0.5), 8) && near(M.clampZoom(20, 1), 8) && near(M.clampZoom(100, 2), 16));
check('clampZoom: limiti esatti', near(M.clampZoom(0.25, 0.5), 0.25) && near(M.clampZoom(8, 0.5), 8));
// --- zoomAround ---
{
// il punto del contenuto sotto (px,py) prima e dopo lo zoom deve essere lo stesso
const z0 = 0.5, z1 = 1.25, sl = 120, st = 40, px = 300, py = 200;
const r = M.zoomAround(z0, z1, sl, st, px, py);
const before = { x: (sl + px) / z0, y: (st + py) / z0 }; // coordinate logiche sotto il puntatore
const after = { x: (r.scrollLeft + px) / z1, y: (r.scrollTop + py) / z1 };
check('zoomAround: il punto sotto il puntatore resta fermo', near(before.x, after.x) && near(before.y, after.y),
JSON.stringify([before, after]));
const same = M.zoomAround(2, 2, 50, 60, 10, 10);
check('zoomAround: z1 == z0 non sposta lo scroll', near(same.scrollLeft, 50) && near(same.scrollTop, 60));
const c = M.zoomAround(1, 2, 0, 0, 100, 50);
check('zoomAround: da scroll 0 raddoppiando (100,50) -> scroll (100,50)', near(c.scrollLeft, 100) && near(c.scrollTop, 50));
const o = M.zoomAround(2, 1, 100, 100, 0, 0);
check('zoomAround: puntatore all\'origine, riduzione -> scroll dimezzato', near(o.scrollLeft, 50) && near(o.scrollTop, 50));
}
// --- pct ---
check('pct: 1 -> "100 %"', M.pct(1) === '100 %', M.pct(1));
check('pct: arrotonda 0.4567 -> "46 %"', M.pct(0.4567) === '46 %', M.pct(0.4567));
check('pct: 8 -> "800 %", 0 -> "0 %"', M.pct(8) === '800 %' && M.pct(0) === '0 %');
// --- define una sola volta, anche caricando due volte ---
{
const defined = {};
let defineCalls = 0;
globalThis.HTMLElement = class {};
globalThis.customElements = {
get: (n) => defined[n],
define: (n, c) => { defineCalls++; defined[n] = c; },
};
delete require.cache[require.resolve(MOD)];
require(MOD);
delete require.cache[require.resolve(MOD)];
require(MOD);
check('customElements.define("tmf-viewer") una sola volta con due caricamenti',
defineCalls === 1 && typeof defined['tmf-viewer'] === 'function', 'chiamate: ' + defineCalls);
const C = defined['tmf-viewer'];
check('osserva src, width, height, fit-height, no-save, save-name',
['src', 'width', 'height', 'fit-height', 'no-save', 'save-name'].every((a) => C.observedAttributes.includes(a)));
check('espone fit, zoomBy, overlay, labels, zoom sul prototipo',
typeof C.prototype.fit === 'function' && typeof C.prototype.zoomBy === 'function' &&
['overlay', 'labels', 'zoom'].every((k) => Object.getOwnPropertyDescriptor(C.prototype, k)));
delete globalThis.customElements;
delete globalThis.HTMLElement;
}
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
process.exit(failures ? 1 : 0);
@@ -0,0 +1,240 @@
/**
* #119: helper static/js/save_image.js (salva il frame grezzo come PNG) con plain node.
*
* Fetch, window e document sono finti: il modulo li tocca solo dentro le funzioni.
* Run from src/frontend/flask_app: node tests/js/test_save_image.js
*/
'use strict';
const path = require('path');
const mod = require(path.join(__dirname, '..', '..', 'static', 'js', 'save_image.js'));
const { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob } = mod; // #122: + tmfSaveBlob, tmfSvgToPngBlob
let failures = 0;
let total = 0;
function check(label, cond, extra) {
total++;
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
// --- nome proposto (funzione pura) ---
const d = new Date(2026, 9, 28, 9, 5, 7); // 28/10/2026 09:05:07 ora locale
check('nome: baseName + AAAAMMGG-hhmmss + .png', tmfSaveImageName('task_14', d) === 'task_14_20261028-090507.png',
tmfSaveImageName('task_14', d));
check('nome: caratteri non validi -> _', tmfSaveImageName('a/b\\c:d*e?f"g<h>i|j', d) === 'a_b_c_d_e_f_g_h_i_j_20261028-090507.png',
tmfSaveImageName('a/b\\c:d*e?f"g<h>i|j', d));
check('nome: spazi -> _, punti ai bordi tolti', tmfSaveImageName(' ..Ricetta test 1.. ', d) === 'Ricetta_test_1_20261028-090507.png',
tmfSaveImageName(' ..Ricetta test 1.. ', d));
check('nome: vuoto/null/undefined -> immagine',
tmfSaveImageName('', d).startsWith('immagine_') && tmfSaveImageName(null, d).startsWith('immagine_') &&
tmfSaveImageName(undefined, d).startsWith('immagine_'));
check('nome: solo caratteri vietati -> immagine', tmfSaveImageName('///', d) === 'immagine_20261028-090507.png',
tmfSaveImageName('///', d));
check('nome: senza data usa ora corrente e ha il formato giusto',
/^x_\d{8}-\d{6}\.png$/.test(tmfSaveImageName('x')), tmfSaveImageName('x'));
check('nome: data non valida -> ora corrente', /^x_\d{8}-\d{6}\.png$/.test(tmfSaveImageName('x', new Date('boh'))));
check('nome: non esiste doppia estensione', !/\.png\.png$/.test(tmfSaveImageName('foto.png', d)));
// --- finti del browser ---
const globals = ['fetch', 'window', 'document', 'URL', 'createImageBitmap', 'setTimeout', 'Image', 'XMLSerializer']; // #122: + Image, XMLSerializer
const saved = {};
globals.forEach((k) => { saved[k] = Object.getOwnPropertyDescriptor(globalThis, k); });
function restore() {
globals.forEach((k) => {
if (saved[k]) Object.defineProperty(globalThis, k, saved[k]); else delete globalThis[k];
});
}
function setGlobal(k, v) { Object.defineProperty(globalThis, k, { value: v, configurable: true, writable: true }); }
function fakeEnv({ blobType = 'image/png', ok = true, picker, pickerErr, fetchErr } = {}) {
const log = { fetched: [], written: [], closed: 0, clicks: [], appended: 0, removed: 0, revoked: [], pickerOpts: null, canvases: [] };
const blob = { type: blobType, size: 3, tag: 'blob-originale' };
setGlobal('fetch', async (src) => {
log.fetched.push(src);
if (fetchErr) throw fetchErr;
return { ok, blob: async () => blob };
});
const win = {};
if (picker) {
win.showSaveFilePicker = async (opts) => {
log.pickerOpts = opts;
if (pickerErr) throw pickerErr;
return { createWritable: async () => ({ write: async (b) => { log.written.push(b); }, close: async () => { log.closed++; } }) };
};
}
setGlobal('window', win);
setGlobal('document', {
body: { appendChild() { log.appended++; }, removeChild() { log.removed++; } },
createElement(tag) {
if (tag === 'canvas') {
const c = {
width: 0, height: 0,
getContext() { return { drawImage() { c.drawn = true; } }; },
toBlob(cb, type) { cb({ type, size: 9, tag: 'blob-png' }); },
};
log.canvases.push(c);
return c;
}
const a = { click() { log.clicks.push({ href: a.href, download: a.download }); } };
return a;
},
});
setGlobal('URL', { createObjectURL: (b) => 'blob:fake/' + b.tag, revokeObjectURL: (u) => { log.revoked.push(u); } });
setGlobal('createImageBitmap', async () => ({ width: 20, height: 10, close() {} }));
setGlobal('setTimeout', (fn) => { fn(); return 0; }); // il revoke differito scatta subito
return { log, blob };
}
(async () => {
const origErr = console.error;
try {
// --- ramo showSaveFilePicker, blob gia' PNG ---
{
const { log, blob } = fakeEnv({ picker: true });
const r = await tmfSaveImage('blob:http://x/abc', 'task 14');
check('picker: risolve a true', r === true);
check('picker: fetch dell\'src', log.fetched.length === 1 && log.fetched[0] === 'blob:http://x/abc');
check('picker: suggestedName del formato atteso',
log.pickerOpts && /^task_14_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName), JSON.stringify(log.pickerOpts));
check('picker: tipo PNG', log.pickerOpts && log.pickerOpts.types[0].accept['image/png'][0] === '.png');
check('picker: scritto il blob originale (gia\' PNG, nessuna conversione) e chiuso',
log.written.length === 1 && log.written[0] === blob && log.closed === 1 && log.canvases.length === 0);
check('picker: nessun <a download>', log.clicks.length === 0);
}
// --- picker, blob JPEG -> convertito su canvas ---
{
const { log } = fakeEnv({ picker: true, blobType: 'image/jpeg' });
const r = await tmfSaveImage('http://x/frame.jpg', 'f');
check('picker + JPEG: risolve a true', r === true);
check('picker + JPEG: ridisegnato su canvas 20x10 e scritto il PNG',
log.canvases.length === 1 && log.canvases[0].width === 20 && log.canvases[0].height === 10 && log.canvases[0].drawn &&
log.written.length === 1 && log.written[0].tag === 'blob-png' && log.written[0].type === 'image/png');
}
// --- ramo <a download> (nessun showSaveFilePicker) ---
{
const { log, blob } = fakeEnv({ picker: false });
const r = await tmfSaveImage('blob:http://x/abc', 'task_14');
check('a download: risolve a true', r === true);
check('a download: un click con href del blob e nome atteso',
log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/' + blob.tag &&
/^task_14_\d{8}-\d{6}\.png$/.test(log.clicks[0].download), JSON.stringify(log.clicks));
check('a download: elemento aggiunto, rimosso e URL liberato',
log.appended === 1 && log.removed === 1 && log.revoked.length === 1);
}
// --- AbortError: annullo dell'utente, nessun errore ---
{
const abort = Object.assign(new Error('The user aborted a request.'), { name: 'AbortError' });
const errs = [];
console.error = (...a) => errs.push(a);
const { log } = fakeEnv({ picker: true, pickerErr: abort });
let thrown = null, r = null;
try { r = await tmfSaveImage('blob:x', 'a'); } catch (e) { thrown = e; }
check('AbortError: nessuna eccezione, risolve a false', thrown === null && r === false, String(thrown));
check('AbortError: nessun errore in console, niente scritto, niente <a download>',
errs.length === 0 && log.written.length === 0 && log.clicks.length === 0);
}
// --- errori veri: fetch fallisce, risposta non ok, picker con errore diverso ---
{
console.error = () => {};
let thrown = null, r1 = null, r2 = null, r3 = null;
try {
fakeEnv({ picker: true, fetchErr: new TypeError('rete') });
r1 = await tmfSaveImage('blob:x', 'a');
fakeEnv({ picker: true, ok: false });
r2 = await tmfSaveImage('blob:x', 'a');
fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) });
r3 = await tmfSaveImage('blob:x', 'a');
} catch (e) { thrown = e; }
check('errori: fetch rotto, risposta non ok, picker che fallisce -> false senza eccezioni',
thrown === null && r1 === false && r2 === false && r3 === false, String(thrown) + [r1, r2, r3]);
}
// --- #122: tmfSaveBlob con picker ---
{
const { log } = fakeEnv({ picker: true });
const png = { type: 'image/png', size: 5, tag: 'blob-dxf' };
const r = await tmfSaveBlob(png, 'dxf ricetta');
check('saveBlob picker: true, scrive il blob dato senza fetch ne conversioni',
r === true && log.written.length === 1 && log.written[0] === png && log.closed === 1 &&
log.fetched.length === 0 && log.canvases.length === 0);
check('saveBlob picker: nome e tipo PNG proposti',
/^dxf_ricetta_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName) && log.pickerOpts.types[0].accept['image/png'][0] === '.png');
}
// --- #122: tmfSaveBlob senza picker -> <a download> ---
{
const { log } = fakeEnv({ picker: false });
const png = { type: 'image/png', size: 5, tag: 'blob-dxf' };
const r = await tmfSaveBlob(png, 'dxf');
check('saveBlob a download: true, un click con href del blob e nome atteso, URL liberato',
r === true && log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/blob-dxf' &&
/^dxf_\d{8}-\d{6}\.png$/.test(log.clicks[0].download) && log.revoked.length === 1);
}
// --- #122: tmfSaveBlob: blob mancante, annullo, errore ---
{
const errs = [];
console.error = (...a) => errs.push(a);
fakeEnv({ picker: true });
const rNull = await tmfSaveBlob(null, 'x');
const abort = Object.assign(new Error('annullato'), { name: 'AbortError' });
fakeEnv({ picker: true, pickerErr: abort });
const rAbort = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x');
const nAbort = errs.length;
fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) });
const rErr = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x');
check('saveBlob: null -> false; AbortError -> false senza log; altro errore -> false con log',
rNull === false && rAbort === false && nAbort === 0 && rErr === false && errs.length === 1);
console.error = () => {};
}
// --- #122: tmfSvgToPngBlob ---
{
const { log } = fakeEnv({ picker: false });
const svgs = [];
setGlobal('XMLSerializer', class { serializeToString(el) { svgs.push(el); return '<svg/>'; } });
setGlobal('Image', class { set src(v) { this._src = v; setImmediate(() => this.onload()); } });
const fills = [];
const cv = document.createElement;
document.createElement = (tag) => {
const e = cv(tag);
if (tag === 'canvas') e.getContext = () => ({ set fillStyle(v) { fills.push(v); }, fillRect() { fills.push('rect'); }, drawImage() { e.drawn = true; } });
return e;
};
const made = [];
const svgEl = {
attrs: { viewBox: '0 0 300 200' }, style: { width: '100%', height: '100%' },
getAttribute(k) { return this.attrs[k] === undefined ? null : this.attrs[k]; },
setAttribute(k, v) { this.attrs[k] = String(v); },
cloneNode() { const c = Object.assign({}, this, { attrs: Object.assign({}, this.attrs), style: Object.assign({}, this.style) }); made.push(c); return c; },
};
const png = await tmfSvgToPngBlob(svgEl, 300, 200);
const c = log.canvases[0];
check('svgToPng: ritorna il PNG del canvas, sfondo bianco, lato lungo portato a 1600 (300x200 -> 1600x1067)',
png && png.tag === 'blob-png' && c.width === 1600 && c.height === 1067 && fills[0] === '#ffffff' && fills[1] === 'rect' && c.drawn,
JSON.stringify([c.width, c.height, fills]));
check('svgToPng: clone con xmlns, width/height in pixel del PNG e stile 100% tolto; originale intatto',
made.length === 1 && made[0].attrs.xmlns === 'http://www.w3.org/2000/svg' && made[0].attrs.width === '1600' &&
made[0].attrs.height === '1067' && made[0].style.width === '' && svgEl.attrs.width === undefined && svgs[0] === made[0]);
check('svgToPng: URL del blob SVG liberato', log.revoked.length === 1);
// lato lungo oltre 4096 -> ridotto
const big = await tmfSvgToPngBlob(svgEl, 10000, 5000);
check('svgToPng: 10000x5000 -> 4096x2048', log.canvases[1].width === 4096 && log.canvases[1].height === 2048 && big);
// senza w/h usa il viewBox; senza dimensioni lancia
await tmfSvgToPngBlob(svgEl);
check('svgToPng: senza w/h usa il viewBox', log.canvases[2].width === 1600 && log.canvases[2].height === 1067);
let thrown = null;
try { await tmfSvgToPngBlob({ getAttribute: () => null }); } catch (e) { thrown = e; }
check('svgToPng: senza dimensioni lancia', thrown !== null);
document.createElement = cv;
}
// --- esposizione ---
{
fakeEnv({ picker: false });
check('esposto come funzione e export', typeof tmfSaveImage === 'function' && typeof tmfSaveImageName === 'function' &&
typeof tmfSaveBlob === 'function' && typeof tmfSvgToPngBlob === 'function');
}
} finally {
console.error = origErr;
restore();
}
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
process.exit(failures ? 1 : 0);
})();
@@ -0,0 +1,453 @@
/**
* #116: overlay del check visione (static/js/vision_overlay.js) con plain node.
*
* Run from src/frontend/flask_app: node tests/js/test_vision_overlay.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const VisionOverlay = require(path.join(__dirname, '..', '..', 'static', 'js', 'vision_overlay.js'));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, 'fixtures', 'vision_check_task14.json'), 'utf8'),
);
let failures = 0;
let total = 0;
function check(label, cond, extra) {
total++;
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const near = (a, b) => Math.abs(a - b) < 1e-9;
// Canvas finto: registra le chiamate e le proprieta' al momento dello stroke
function fakeCanvas() {
const calls = [];
const g = {
calls,
strokes: [],
dash: [],
strokeStyle: '',
lineWidth: 0,
beginPath() { calls.push(['beginPath']); },
moveTo(x, y) { calls.push(['moveTo', x, y]); },
lineTo(x, y) { calls.push(['lineTo', x, y]); },
arc(...a) { calls.push(['arc', ...a]); },
closePath() { calls.push(['closePath']); },
stroke() {
calls.push(['stroke']);
g.strokes.push({ style: g.strokeStyle, width: g.lineWidth, dash: g.dash.slice() });
},
setLineDash(d) { g.dash = d.slice(); calls.push(['setLineDash', d.slice()]); },
};
return g;
}
const onlyCalls = (g, name) => g.calls.filter((c) => c[0] === name);
// --- fixture vero: task 14 TEST1 ---
const overlay = fixture.overlay;
check('fixture: overlay con 25 elementi', overlay.length === 25, String(overlay.length));
{
const g = fakeCanvas();
const res = VisionOverlay.draw(g, overlay, 2);
check('draw sul fixture: drawn 25, skipped 0', res.drawn === 25 && res.skipped === 0, JSON.stringify(res));
const lastDash = onlyCalls(g, 'setLineDash').pop();
check('draw: ultimo setLineDash e\' []', lastDash && lastDash[1].length === 0);
check('draw: uno stroke per elemento', g.strokes.length === 25, String(g.strokes.length));
const roiIdx = overlay.findIndex((o) => o.role === 'roi');
const foundIdx = overlay.findIndex((o) => o.role === 'found');
check('roi: arancione, tratteggiata, spessore lw*0.75',
g.strokes[roiIdx].style === '#f59e0b' && g.strokes[roiIdx].width === 1.5 &&
JSON.stringify(g.strokes[roiIdx].dash) === '[6,4]', JSON.stringify(g.strokes[roiIdx]));
check('found: verde, piena, spessore lw',
g.strokes[foundIdx].style === '#16a34a' && g.strokes[foundIdx].width === 2 &&
g.strokes[foundIdx].dash.length === 0, JSON.stringify(g.strokes[foundIdx]));
}
// --- colori: reference e ruolo ignoto ---
{
const g = fakeCanvas();
VisionOverlay.draw(g, [
{ type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 },
{ type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 },
], 1);
check('reference blu', g.strokes[0].style === '#2563eb');
check('ruolo ignoto rosso', g.strokes[1].style === '#ef4444');
}
// --- rect2 ---
function xs(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[1]); }
function ys(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[2]); }
{
const g = fakeCanvas();
const r = VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }], 1);
const X = xs(g), Y = ys(g);
check('rect2 phi 0: 4 vertici chiusi', r.drawn === 1 && X.length === 4 && onlyCalls(g, 'closePath').length === 1);
check('rect2 phi 0: col 90/110', X.every((x) => near(x, 90) || near(x, 110)) && X.filter((x) => near(x, 90)).length === 2, String(X));
check('rect2 phi 0: row 46/54', Y.every((y) => near(y, 46) || near(y, 54)) && Y.filter((y) => near(y, 46)).length === 2, String(Y));
}
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 90, length1: 10, length2: 4 }], 1);
const X = xs(g), Y = ys(g);
check('rect2 phi 90: lati scambiati, col 96/104', X.every((x) => near(x, 96) || near(x, 104)), String(X));
check('rect2 phi 90: lati scambiati, row 40/60', Y.every((y) => near(y, 40) || near(y, 60)), String(Y));
}
// --- arc: argomenti come da contratto ---
{
const g = fakeCanvas();
const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 };
VisionOverlay.draw(g, [a], 1);
const c = onlyCalls(g, 'arc')[0];
check('arc: g.arc(col, row, r, -rad(start), -rad(start+extent), extent>0)',
c && near(c[1], 40) && near(c[2], 30) && near(c[3], 12) &&
near(c[4], -Math.PI / 2) && near(c[5], -Math.PI) && c[6] === true, JSON.stringify(c));
const g2 = fakeCanvas();
VisionOverlay.draw(g2, [{ ...a, start_deg: 0, extent_deg: -45 }], 1);
const c2 = onlyCalls(g2, 'arc')[0];
check('arc: extent negativo = orario (anticlockwise false)',
c2 && near(c2[4], 0) && near(c2[5], Math.PI / 4) && c2[6] === false, JSON.stringify(c2));
}
// --- circle, points, contour ---
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 }], 1);
const c = onlyCalls(g, 'arc')[0];
check('circle: cerchio pieno', c && c[1] === 7 && c[2] === 5 && c[3] === 3 && c[4] === 0 && near(c[5], 2 * Math.PI));
}
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 1);
const m = onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo'));
// lw 1 -> semi-lato max(3, 2) = 3
check('points: croce di semi-lato 3', m.length === 4 && m.some((c) => c[1] === 17 && c[2] === 10) &&
m.some((c) => c[1] === 23 && c[2] === 10) && m.some((c) => c[1] === 20 && c[2] === 7) &&
m.some((c) => c[1] === 20 && c[2] === 13), JSON.stringify(m));
const g2 = fakeCanvas();
VisionOverlay.draw(g2, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 5);
check('points: semi-lato lw*2 per lw grande', onlyCalls(g2, 'moveTo').some((c) => c[1] === 10 && c[2] === 10));
}
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] }], 1);
check('contour: polilinea aperta di 3 punti',
onlyCalls(g, 'moveTo').length === 1 && onlyCalls(g, 'lineTo').length === 2 && onlyCalls(g, 'closePath').length === 0);
}
// --- elementi malformati: saltati, nessuna eccezione ---
{
const g = fakeCanvas();
let thrown = null;
let res = null;
try {
res = VisionOverlay.draw(g, [
{ type: 'circle', role: 'found', row: 1, col: 1 }, // manca radius
{ type: 'points', role: 'found', rows: [1, 2], cols: [1] }, // lunghezze diverse
{ type: 'contour', role: 'found', rows: [], cols: [] }, // vuoto
{ type: 'boh', role: 'found' }, // tipo ignoto
{ type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 }, // non finito
null, // elemento nullo
{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, // valido
], 1);
} catch (e) { thrown = e; }
check('malformati: nessuna eccezione', thrown === null, String(thrown));
check('malformati: 6 saltati, 1 disegnato', res && res.drawn === 1 && res.skipped === 6, JSON.stringify(res));
check('malformati: draw con overlay non array', (() => {
try { const r = VisionOverlay.draw(fakeCanvas(), null, 1); return r.drawn === 0 && r.skipped === 0; } catch (e) { return false; }
})());
}
// --- anchor, valori calcolati a mano ---
{
const A = VisionOverlay.anchor;
const seg = A({ type: 'segment', row1: 10, col1: 0, row2: 20, col2: 40 });
check('anchor segment: punto medio', seg && seg.x === 20 && seg.y === 15);
const lin = A({ type: 'line', row1: 0, col1: 2, row2: 0, col2: 4 });
check('anchor line: punto medio', lin && lin.x === 3 && lin.y === 0);
const cir = A({ type: 'circle', row: 50, col: 100, radius: 8 });
check('anchor circle: centro + raggio a destra', cir && cir.x === 108 && cir.y === 50);
const arc = A({ type: 'arc', row: 50, col: 100, radius: 10, start_deg: 0, extent_deg: 180 });
check('anchor arc: punto medio (90 gradi = in alto a video)', arc && near(arc.x, 100) && near(arc.y, 40), JSON.stringify(arc));
const pts = A({ type: 'points', rows: [7, 8], cols: [3, 4] });
check('anchor points: primo punto', pts && pts.x === 3 && pts.y === 7);
const r2 = A({ type: 'rect2', row: 9, col: 11, phi_deg: 30, length1: 5, length2: 2 });
check('anchor rect2: centro', r2 && r2.x === 11 && r2.y === 9);
const con = A({ type: 'contour', rows: [0, 1, 2, 3], cols: [10, 11, 12, 13] });
check('anchor contour: punto a meta\' (indice 2)', con && con.x === 12 && con.y === 2);
check('anchor: tipo ignoto -> null', A({ type: 'boh' }) === null);
check('anchor: dati mancanti -> null', A({ type: 'circle', row: 1, col: 1 }) === null && A(null) === null);
}
// --- labels sul fixture, righe costruite come fara' vision_execute.js ---
const subById = {};
fixture._subtasks.forEach((s) => { subById[s.id] = s; });
const rows = fixture.measurements.map((m) => {
const s = subById[m.subtask_id];
return {
key: m.subtask_id, name: s.description, value: m.value, unit: s.unit,
status: m.pass_fail, vision_output: s.vision_output,
};
});
{
const L = VisionOverlay.labels(rows, overlay);
check('labels fixture: 6 etichette', L.length === 6, String(L.length));
const names = L.map((l) => l.text.split(' ')[0]).join(',');
check('labels fixture: larghezza, lunghezza e 4 raggi',
L[0].text.startsWith('Larghezza') && L[1].text.startsWith('Lunghezza') &&
L.slice(2).every((l) => l.text.startsWith('R ')), names);
check('labels fixture: nessuna per le differenze', L.every((l) => !/^Diff/.test(l.text)));
const l15 = L.find((l) => l.key === 15);
check('quota 15: testo con Larghezza e 1.668', l15 && l15.text.includes('Larghezza') && l15.text.includes('1.668'), l15 && l15.text);
check('quota 15: status riportato', l15 && l15.status === fixture.measurements[0].pass_fail);
const seg = overlay.find((o) => o.tool_id === 'larghezza' && o.role === 'found');
check('quota 15: ancorata al punto medio del segmento', l15 && near(l15.x, (seg.col1 + seg.col2) / 2) && near(l15.y, (seg.row1 + seg.row2) / 2));
}
{
const nuda = rows.map((r) => ({ ...r, vision_output: undefined }));
check('labels: righe senza vision_output -> nessuna etichetta', VisionOverlay.labels(nuda, overlay).length === 0);
const sola = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass' }], overlay);
check('labels: riga senza vision_output (assente) -> nessuna', sola.length === 0);
const noTool = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass', vision_output: 'nonesiste.value' }], overlay);
check('labels: tool senza overlay -> nessuna', noTool.length === 0);
// found preferito: la roi (per prima) ha un'altra ancora, ma vince il found
const ov = [
{ type: 'rect2', role: 'roi', tool_id: 't', row: 1, col: 1, phi_deg: 0, length1: 1, length2: 1 },
{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 },
];
const f = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'warning', vision_output: 't.value' }], ov);
check('labels: preferisce l\'ancora del found', f.length === 1 && f[0].x === 15 && f[0].y === 10 && f[0].status === 'warning', JSON.stringify(f));
const solo = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'fail', vision_output: 't.value' }], [ov[0]]);
check('labels: senza found ripiega sul primo elemento', solo.length === 1 && solo[0].x === 1 && solo[0].y === 1);
check('labels: testo name + valore a 3 decimali + unit', f[0].text === 'T 1.500 mm', f[0].text);
}
// --- #118: labels, valore null/undefined/non finito -> etichetta NaN con status della riga ---
{
const ov = [{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 }];
const mk = (value) => ({ key: 1, name: 'T', value, unit: 'mm', status: 'fail', vision_output: 't.value' });
const nan = (v) => { const l = VisionOverlay.labels([mk(v)], ov); return l.length === 1 && l[0].text === 'T NaN mm' && l[0].status === 'fail'; };
check('labels: value null -> etichetta NaN, status fail (Number(null) e 0)', nan(null));
check('labels: value undefined / stringa vuota -> etichetta NaN', nan(undefined) && nan(''));
check('labels: value NaN/Infinity -> etichetta NaN', nan(NaN) && nan(Infinity) && nan(-Infinity));
check('labels: value 0 resta un valore valido', VisionOverlay.labels([mk(0)], ov)[0].text === 'T 0.000 mm');
const misto = VisionOverlay.labels([{ ...mk(null), key: 1 }, { ...mk(2.5), key: 2 }], ov);
check('labels: null e valida entrambe presenti', misto.length === 2 && misto[0].text === 'T NaN mm' && misto[1].key === 2);
check('labels: riga NaN senza ancoraggio -> nessuna etichetta', VisionOverlay.labels([mk(null)], []).length === 0);
}
// --- #121: svg(overlay, opts) ---
const count = (str, re) => (str.match(re) || []).length;
const SHAPE_RE = /<(polygon|line|circle|path|polyline) /g;
{
const out = VisionOverlay.svg(overlay);
// il fixture vero ha 25 forme: 7 segment, 1 contour, 1 points, 4 rect2 roi, 4 arc roi, 4 arc found, 4 circle
check('svg fixture: 25 forme', count(out, SHAPE_RE) === 25, String(count(out, SHAPE_RE)));
check('svg fixture: ogni forma con vector-effect non-scaling-stroke',
count(out, /vector-effect="non-scaling-stroke"/g) === 25);
check('svg fixture: ogni forma fill none', count(out, /fill="none"/g) === 25);
check('svg fixture: 8 roi (4 rect2 + 4 arc) con stroke-dasharray 6 4', count(out, /stroke-dasharray="6 4"/g) === 8,
String(count(out, /stroke-dasharray="6 4"/g)));
check('svg fixture: nessun <svg> esterno', !/<svg/.test(out));
check('svg fixture: nessun NaN/undefined/null nel markup', !/NaN|undefined|null|Infinity/.test(out));
check('svg fixture: numeri con al piu\' 2 decimali', !/\d\.\d{3,}/.test(out));
}
{
const one = (o, opts) => VisionOverlay.svg([o], opts);
const roi = one({ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 });
check('svg roi: polygon 4 vertici, colore roi, dash 6 4, spessore lw*0.75',
roi.startsWith('<polygon ') && roi.includes('points="110,46 110,54 90,54 90,46"') && roi.includes('stroke="#f59e0b"') &&
roi.includes('stroke-dasharray="6 4"') && roi.includes('stroke-width="1.5"'), roi);
const roi4 = one({ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }, { lw: 4 });
check('svg roi: lw 4 -> stroke-width 3', roi4.includes('stroke-width="3"'), roi4);
const fnd = one({ type: 'segment', role: 'found', row1: 1, col1: 2, row2: 3, col2: 4 });
check('svg found: line verde piena, spessore lw 2 predefinito',
fnd === '<line x1="2" y1="1" x2="4" y2="3" fill="none" stroke="#16a34a" stroke-width="2" vector-effect="non-scaling-stroke"/>', fnd);
check('svg reference blu, ruolo ignoto rosso',
one({ type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 }).includes('stroke="#2563eb"') &&
one({ type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 }).includes('stroke="#ef4444"'));
check('svg: spessore costante, non dipende dallo zoom',
one({ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, { zoom: 4 }).includes('stroke-width="2"'));
const cir = one({ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 });
check('svg circle: cx cy r', cir.startsWith('<circle cx="7" cy="5" r="3" '), cir);
const con = one({ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] });
check('svg contour: polyline aperta', con.startsWith('<polyline points="4,1 5,2 6,3" ') && !/ Z/.test(con), con);
// points: croce di semi-braccio cross/zoom
const p1 = one({ type: 'points', role: 'found', rows: [10], cols: [20] }, { zoom: 1 });
check('svg points zoom 1: semi-braccio 6', p1.includes('d="M 14 10 L 26 10 M 20 4 L 20 16"'), p1);
const p2 = one({ type: 'points', role: 'found', rows: [10], cols: [20] }, { zoom: 2 });
check('svg points zoom 2: semi-braccio 3', p2.includes('d="M 17 10 L 23 10 M 20 7 L 20 13"'), p2);
const p3 = one({ type: 'points', role: 'found', rows: [10, 30], cols: [20, 40] }, { zoom: 1, cross: 2 });
check('svg points: cross personalizzato, 2 croci in un solo path',
count(p3, /<path /g) === 1 && count(p3, /M /g) === 4 && p3.includes('M 18 10 L 22 10'), p3);
// arc: estremi calcolati (y verso il basso, angoli antiorari)
const arcD = (o) => /d="([^"]+)"/.exec(one({ type: 'arc', role: 'found', ...o }))[1];
const parse = (d) => d.match(/-?\d+(\.\d+)?/g).map(Number); // [x0, y0, rx, ry, rot, large, sweep, x1, y1]
{
// start 90, extent 90 su centro (40,30) r 12: da (40,18) a (28,30), antiorario (sweep 0), arco piccolo
const d = arcD({ row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 });
const v = parse(d);
check('svg arc 90+90: M 40 18 A 12 12 0 0 0 28 30',
near(v[0], 40) && near(v[1], 18) && v[2] === 12 && v[3] === 12 && v[4] === 0 && v[5] === 0 && v[6] === 0 &&
near(v[7], 28) && near(v[8], 30), d);
}
{
// start 0, extent -90 (orario): da (10,0) a (0,10) a video, sweep 1
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: -90 }));
check('svg arc extent negativo: sweep 1, fine in basso a video',
near(v[0], 10) && near(v[1], 0) && v[5] === 0 && v[6] === 1 && near(v[7], 0) && near(v[8], 10), JSON.stringify(v));
}
{
// 270 gradi antiorari da 0: fine a 270 = in basso a video (0, +10)
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: 270 }));
check('svg arc 270: large-arc 1, sweep 0, fine (0,10) a video',
v[5] === 1 && v[6] === 0 && near(v[7], 0) && near(v[8], 10), JSON.stringify(v));
}
{
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: 360 }));
check('svg arc 360: estensione limitata a 359.99 (large-arc, fine quasi sullo start)',
v[5] === 1 && Math.abs(v[8]) < 0.01 && v[7] > 9.99, JSON.stringify(v));
}
{
// coincide con draw: punto finale del g.arc del canvas (angolo -rad(start+extent))
const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 30, extent_deg: 100 };
const g = fakeCanvas();
VisionOverlay.draw(g, [a], 1);
const c = onlyCalls(g, 'arc')[0];
const ex = c[1] + c[3] * Math.cos(c[5]), ey = c[2] + c[3] * Math.sin(c[5]);
const sx = c[1] + c[3] * Math.cos(c[4]), sy = c[2] + c[3] * Math.sin(c[4]);
const v = parse(arcD(a));
check('svg arc: estremi uguali a quelli di draw su canvas',
Math.abs(v[7] - ex) < 0.01 && Math.abs(v[8] - ey) < 0.01 && Math.abs(v[0] - sx) < 0.01 && Math.abs(v[1] - sy) < 0.01,
JSON.stringify([v, ex, ey]));
}
// malformati e ignoti: saltati, nessuna eccezione, la forma valida resta
let thrown = null, out = '';
try {
out = VisionOverlay.svg([
{ type: 'circle', role: 'found', row: 1, col: 1 },
{ type: 'points', role: 'found', rows: [1, 2], cols: [1] },
{ type: 'contour', role: 'found', rows: [], cols: [] },
{ type: 'boh', role: 'found' },
{ type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 },
{ type: 'arc', role: 'found', row: 0, col: 0, radius: 1, start_deg: 0 },
{ type: 'rect2', role: 'roi', row: 0, col: 0, phi_deg: 0, length1: Infinity, length2: 1 },
null,
{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 },
], { zoom: 1 });
} catch (e) { thrown = e; }
check('svg malformati: nessuna eccezione, una sola forma valida',
thrown === null && count(out, /<line /g) === 1 && count(out, /vector-effect/g) === 1, String(thrown) + out);
check('svg: overlay non array o vuoto -> stringa vuota',
VisionOverlay.svg(null) === '' && VisionOverlay.svg(undefined) === '' && VisionOverlay.svg([]) === '');
check('svg: opts non validi -> predefiniti',
VisionOverlay.svg([{ type: 'points', role: 'found', rows: [10], cols: [20] }], { zoom: 0, lw: -1, cross: NaN })
.includes('d="M 14 10 L 26 10 M 20 4 L 20 16"'));
// nessuna stringa dei dati nel markup
const evil = '<script>alert(1)</script>"onload="x';
const sicuro = VisionOverlay.svg([
{ type: 'segment', role: evil, tool_id: evil, name: evil, row1: 0, col1: 0, row2: 1, col2: 1 },
{ type: 'circle', role: 'found', tool_id: evil, row: 1, col: 1, radius: 2 },
]);
check('svg: tool_id e role dei dati non compaiono nel markup',
!/script|onload|alert/.test(sicuro) && count(sicuro, /vector-effect/g) === 2, sicuro);
const strNum = VisionOverlay.svg([{ type: 'segment', role: 'found', row1: '<b>', col1: 0, row2: 1, col2: 1 }]);
check('svg: coordinata stringa -> forma saltata', strNum === '');
}
// --- #12: livelli e quote CAD ---
{
const VO = VisionOverlay;
check('layerOf: layer esplicito vince sul ruolo',
VO.layerOf({ role: 'found', layer: 'quote' }) === 'quote' && VO.layerOf({ role: 'roi', layer: 'costruzioni' }) === 'costruzioni');
check('layerOf: senza campo found = costruzioni, altro = ricerca',
VO.layerOf({ role: 'found' }) === 'costruzioni' && VO.layerOf({ role: 'roi' }) === 'ricerca' &&
VO.layerOf({ role: 'reference' }) === 'ricerca' && VO.layerOf({}) === 'ricerca' && VO.layerOf(null) === 'ricerca');
check('layerOf: layer ignoto -> secondo il ruolo', VO.layerOf({ role: 'found', layer: 'boh' }) === 'costruzioni');
const lin = { tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: null };
const rag = { tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 90, testo: null };
const ang = { tipo: 'angolo', centro: [50, 50], r: 20, start_deg: 0, extent_deg: 90, testo: null };
// default
let p = VO.quotaPunto(lin);
check('quotaPunto lineare default = punto medio', near(p.x, 50) && near(p.y, 0), JSON.stringify(p));
p = VO.quotaPunto(rag);
check('quotaPunto raggio default = centro + 2r lungo dir (90 gradi = verso l\'alto a video)', near(p.x, 50) && near(p.y, 30), JSON.stringify(p));
p = VO.quotaPunto(ang);
{ // u a 45 gradi = (cos45, -sin45); T = centro + 1.5r u
const e = 30 * Math.SQRT1_2;
check('quotaPunto angolo default = centro + 1.5r a meta arco', near(p.x, 50 + e) && near(p.y, 50 - e), JSON.stringify(p));
}
// con testo: lineare p1->p2 lungo +x, n = (0, 1): dv positivo = verso il basso a video
p = VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [10, 20] });
check('quotaPunto lineare con testo [du,dv]', near(p.x, 60) && near(p.y, 20), JSON.stringify(p));
// inverse
[lin, rag, ang].forEach((q) => {
[[3, -7], [-12.5, 40], [0, 0]].forEach((t) => {
const q2 = Object.assign({}, q, { testo: t });
const pt = VO.quotaPunto(q2);
const back = VO.quotaTesto(q2, pt.x, pt.y);
check('quotaTesto inverso di quotaPunto (' + q.tipo + ' ' + t + ')', near(back[0], t[0]) && near(back[1], t[1]), JSON.stringify(back));
});
// dal punto di default si torna a testo di default
const d = VO.quotaPunto(q), back = VO.quotaTesto(q, d.x, d.y), p2 = VO.quotaPunto(Object.assign({}, q, { testo: back }));
check('quotaTesto dal punto di default riproduce il default (' + q.tipo + ')', near(p2.x, d.x) && near(p2.y, d.y));
});
check('quota malformata: quotaPunto/quotaTesto null, quotaSvg stringa vuota',
VO.quotaPunto(null) === null && VO.quotaPunto({ tipo: 'boh' }) === null &&
VO.quotaPunto({ tipo: 'lineare', p1: [0, 'a'], p2: [1, 1] }) === null &&
VO.quotaTesto({ tipo: 'raggio' }, 1, 1) === null &&
VO.quotaSvg({ tipo: 'angolo', centro: [0, 0] }) === '' && VO.quotaSvg(undefined) === '');
check('quotaPunto: testo malformato = default', near(VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: ['x', 1] }).x, 5));
// quotaSvg lineare: linee di estensione solo con dv != 0
const conDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 1, color: '#16a34a' });
const senzaDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 0] }, { zoom: 1, color: '#16a34a' });
check('quotaSvg lineare dv=30: 2 estensioni + 1 linea di misura, 2 frecce', count(conDv, /<line /g) === 3 && count(conDv, /<polygon /g) === 2, conDv);
check('quotaSvg lineare dv=0: nessuna estensione (solo linea di misura), 2 frecce', count(senzaDv, /<line /g) === 1 && count(senzaDv, /<polygon /g) === 2, senzaDv);
check('quotaSvg: tratto non-scaling 1.5 e colore passato', /stroke-width="1.5" vector-effect="non-scaling-stroke"/.test(conDv) && conDv.includes('#16a34a'));
// frecce dentro/fuori: lineare lunga 200 a zoom 1 -> la punta sta in A (0, 30): primo vertice del poligono
{ // dentro: punta in A, coda verso +u (x crescente) -> il secondo vertice ha x > 0? no: dentro la freccia punta verso fuori, coda verso l'interno (x>0)
const m = conDv.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
check('freccia dentro: punta in A, coda verso l\'interno della misura', near(m[0][0], 0) && near(m[0][1], 30) && m[1][0] > 0, m.join('|'));
const corta = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: [0, 30] }, { zoom: 1 });
const mc = corta.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
check('freccia fuori (misura corta): punta in A, coda fuori (x < 0)', near(mc[0][0], 0) && mc[1][0] < 0, mc.join('|'));
const zz = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 4 });
const mz = zz.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
check('freccia: lunghezza fissa a video (9 px / zoom)', near(mz[1][0], 9 / 4) && count(zz, /<line /g) === 3, mz.join('|'));
}
// T oltre B: la linea di misura si allunga
const oltre = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [150, 30] }, { zoom: 1 });
check('quotaSvg lineare: scritta oltre B allunga la linea di misura fino a lei', oltre.includes('x2="200"'), oltre);
// raggio
const rFuori = VO.quotaSvg({ tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 0, testo: [30, 0] }, { zoom: 1 });
const rDentro = VO.quotaSvg({ tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 0, testo: [5, 0] }, { zoom: 1 });
check('quotaSvg raggio: crocetta + linea + freccia (fuori)', count(rFuori, /<line /g) === 3 && count(rFuori, /<polygon /g) === 1, rFuori);
{ // fuori: P = (60, 50), freccia verso il centro: coda a x > 60
const m = rFuori.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
check('raggio fuori: punta in P sul cerchio, coda verso fuori', near(m[0][0], 60) && near(m[0][1], 50) && m[1][0] > 60, m.join('|'));
const d = rDentro.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
check('raggio dentro: punta in P, coda verso il centro (x < 60)', near(d[0][0], 60) && d[1][0] < 60, d.join('|'));
}
// angolo
const a1 = VO.quotaSvg(ang, { zoom: 1 });
check('quotaSvg angolo: 2 estensioni radiali + arco + 2 frecce', count(a1, /<line /g) === 2 && count(a1, /<path /g) === 1 && count(a1, /<polygon /g) === 2, a1);
// markup senza stringhe dei dati
const evil = '<script>alert(1)</script>"onload="x';
const s1 = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [0, 20], nome: evil, extra: evil }, { zoom: 1, color: evil });
const s2 = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [0, evil] }, { zoom: 1 });
check('quotaSvg: nessuna stringa dei dati nel markup, colore non in lista -> rosso',
!/script|onload|alert/.test(s1) && s1.includes('#ef4444') && !/script|onload|alert/.test(s2), s1);
check('quotaSvg: colori ammessi (esiti)', ['#16a34a', '#d97706', '#dc2626'].every((c) => VO.quotaSvg(lin, { color: c }).includes(c)));
check('svg() invariato: uscita di un segmento', VO.svg([{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }]).startsWith('<line x1="0"'));
}
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
process.exit(failures ? 1 : 0);
@@ -0,0 +1,205 @@
/**
* #114: etichette della tabella quote nel pannello visione (getter `rows`), con plain node.
*
* Run from src/frontend/flask_app: node tests/js/test_vision_rows.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8',
);
// Contesto vm con window e document finti; il file definisce visionExecute() globale
const win = {};
// #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;
function check(label, cond, extra) {
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
win.__visionCfg = {
subtasks: [
{ id: 15, marker_number: 1, description: 'Larghezza', measurement_type: null, unit: 'mm' },
{ id: 16, marker_number: 2, description: '', measurement_type: 'Diametro', unit: 'mm' },
{ id: 17, marker_number: 3, description: null, measurement_type: null },
],
};
const comp = ctx.visionExecute();
comp.result = {
measurements: [
{ subtask_id: 15, value: 1, pass_fail: 'pass' },
{ subtask_id: 16, value: 2, pass_fail: 'pass' },
{ subtask_id: 17, value: 3, pass_fail: 'fail' },
{ subtask_id: 99, value: 4, pass_fail: 'pass' },
],
};
const rows = comp.rows;
check('#114 riga 15: description', rows[0].label === '1. Larghezza', rows[0].label);
check('#114 riga 16: ripiego su measurement_type', rows[1].label === '2. Diametro', rows[1].label);
check('#114 riga 17: ripiego su #id', rows[2].label === '3. #17', rows[2].label);
check('#114 riga 99: subtask sconosciuta', rows[3].label === '#99', rows[3].label);
check('#114 unit riga 17 = mm', rows[2].unit === 'mm', rows[2].unit);
check('#114 unit riga 99 = mm', rows[3].unit === 'mm', rows[3].unit);
// #117: nominale e tolleranze portati dalla quota; fmtTol
win.__visionCfg = {
subtasks: [
{ id: 20, description: 'Largh', unit: 'mm', nominal: 12.5, ltl: 12.4, utl: 12.6, lwl: 12.45, uwl: 12.55 },
{ id: 21, description: 'Diam', unit: 'mm', nominal: 0, ltl: null, utl: 0.05 },
{ id: 22, description: 'Nuda', unit: 'mm' },
],
};
const c2 = ctx.visionExecute();
c2.result = { measurements: [
{ subtask_id: 20, value: 12.5, pass_fail: 'pass' },
{ subtask_id: 21, value: 0, pass_fail: 'pass' },
{ subtask_id: 22, value: 1, pass_fail: 'pass' },
] };
const r2 = c2.rows;
check('#117 riga 20: nominale e tolleranze',
r2[0].nominal === 12.5 && r2[0].ltl === 12.4 && r2[0].utl === 12.6 && r2[0].lwl === 12.45 && r2[0].uwl === 12.55);
check('#117 riga 21: nominale 0 resta 0, ltl null', r2[1].nominal === 0 && r2[1].ltl == null && r2[1].utl === 0.05);
check('#117 riga 22: senza tolleranze sono undefined/null', r2[2].nominal == null && r2[2].utl == null && r2[2].lwl == null);
check('#117 label invariato', r2[0].label === 'Largh', r2[0].label);
check('#117 fmtTol(null) = trattino', c2.fmtTol(null) === '\u2014', c2.fmtTol(null));
check('#117 fmtTol(undefined) = trattino', c2.fmtTol(undefined) === '\u2014');
check('#117 fmtTol(0.05) = 0.050', c2.fmtTol(0.05) === '0.050', c2.fmtTol(0.05));
check('#117 fmtTol(0) = 0.000', c2.fmtTol(0) === '0.000', c2.fmtTol(0));
// #116: righe con key/name/vision_output, etichette in % con un VisionOverlay finto, labelClass
win.__visionCfg = {
subtasks: [
{ id: 30, marker_number: 1, description: 'Larghezza', unit: 'mm', vision_output: 'larg.value' },
{ id: 31, description: '', measurement_type: 'Diametro', unit: 'mm' },
],
};
const c3 = ctx.visionExecute();
c3.result = { overlay: [{ type: 'circle', tool_id: 'larg' }], measurements: [
{ subtask_id: 30, value: 1.5, pass_fail: 'pass' },
{ subtask_id: 31, value: 2, pass_fail: 'fail' },
] };
const r3 = c3.rows;
check('#116 riga: key = subtask_id', r3[0].key === 30 && r3[1].key === 31);
check('#116 riga: name senza marker', r3[0].name === 'Larghezza' && r3[1].name === 'Diametro', r3[0].name + '/' + r3[1].name);
check('#116 riga: vision_output', r3[0].vision_output === 'larg.value' && r3[1].vision_output == null);
check('#116 label invariato (#114)', r3[0].label === '1. Larghezza', r3[0].label);
check('#116 senza VisionOverlay: nessuna etichetta', c3.viewerLabels.length === 0);
let seen = null;
ctx.VisionOverlay = {
labels(rows, overlay) { seen = { rows, overlay }; return [{ key: 30, x: 500, y: 250, text: 'Larghezza 1.500 mm', status: 'pass' }]; },
};
const ls = c3.viewerLabels;
check('#116 una etichetta', ls.length === 1 && ls[0].key === 30 && ls[0].text === 'Larghezza 1.500 mm', JSON.stringify(ls));
// #122: posizione in px logici dell'immagine (la percentuale la calcola <tmf-viewer>), niente style
check('#122 posizione in px logici', ls[0].x === 500 && ls[0].y === 250 && ls[0].style === undefined, JSON.stringify(ls[0]));
check('#116 classe da esito', ls[0].cls === 'bg-green-600 text-white', ls[0].cls);
check('#116 labels riceve rows e overlay', seen.rows.length === 2 && seen.overlay.length === 1);
c3.result = null;
check('#116 senza risultato: nessuna etichetta', c3.viewerLabels.length === 0);
check('#116 labelClass pass', c3.labelClass('pass') === 'bg-green-600 text-white');
check('#116 labelClass warning', c3.labelClass('warning') === 'bg-amber-400 text-black');
check('#116 labelClass fail', c3.labelClass('fail') === 'bg-red-600 text-white');
check('#116 labelClass altro', c3.labelClass('boh') === 'bg-gray-700 text-white' && c3.labelClass(undefined) === 'bg-gray-700 text-white');
// #122: zoom, overlay SVG e Salva sono di <tmf-viewer>: il componente non li ha piu'
check('#122 niente drawOverlay/saveImage/overlayLabels/z', c3.drawOverlay === undefined && c3.saveImage === undefined && c3.overlayLabels === undefined && c3.z === undefined);
check('#122 niente codice canvas', !/getContext|clearRect/.test(src));
// #118: quota non misurata (value null) = NaN, mai Number(null) = 0
check('#118 fmtValue(null) = NaN', c3.fmtValue(null, 'mm') === 'NaN mm', c3.fmtValue(null, 'mm'));
check('#118 fmtValue(undefined) = NaN', c3.fmtValue(undefined, 'mm') === 'NaN mm');
check('#118 fmtValue(0) = 0.000', c3.fmtValue(0, 'mm') === '0.000 mm', c3.fmtValue(0, 'mm'));
check('#118 fmtValue(1.5) = 1.500', c3.fmtValue(1.5, 'mm') === '1.500 mm');
win.__visionCfg = { subtasks: [{ id: 40, description: 'Persa', unit: 'mm', nominal: 5, ltl: 4, utl: 6 }] };
const c4 = ctx.visionExecute();
c4.result = { measurements: [{ subtask_id: 40, value: null, pass_fail: 'fail' }] };
check('#118 riga con value null: valore null, esito fail, tolleranze come prima',
c4.rows[0].value === null && c4.rows[0].status === 'fail' && c4.rows[0].nominal === 5 && c4.rows[0].utl === 6);
// #120: evento vision-recorded dopo un record riuscito, non dopo uno fallito
const events = [];
win.dispatchEvent = (e) => { events.push(e); return true; };
const listeners = {};
win.addEventListener = (t, f) => { (listeners[t] = listeners[t] || []).push(f); };
win.__visionCfg = { cameraAvailable: true, modo: 'impulsiva', subtasks: [{ id: 40, description: 'Persa', unit: 'mm' }] };
const c5 = ctx.visionExecute();
c5.lastBlob = { name: 'x' };
const okMs = [{ subtask_id: 40, value: null, pass_fail: 'fail' }, { subtask_id: 41, value: 2, pass_fail: 'pass' }];
c5.acquire = async function () { this.result = { vision_result_id: 7, measurements: okMs }; };
(async () => {
await c5.record();
const ev = events.filter((e) => e.type === 'vision-recorded');
check('#120 record riuscito: un evento vision-recorded', ev.length === 1, String(ev.length));
check('#120 evento: misure di execute', ev[0].detail.measurements === okMs);
check('#120 record riuscito: saved', c5.saved === true && c5.preview === false);
events.length = 0;
const c6 = ctx.visionExecute();
c6.lastBlob = { name: 'x' };
c6.acquire = async function () { this.error = 'boom'; this.result = null; };
await c6.record();
check('#120 record fallito (errore): nessun evento', events.length === 0 && c6.saved === false);
const c7 = ctx.visionExecute();
c7.lastBlob = { name: 'x' };
c7.acquire = async function () { this.result = { measurements: okMs }; }; // niente vision_result_id
await c7.record();
check('#120 record senza vision_result_id: nessun evento', events.length === 0 && c7.saved === false);
// senza camera il file e' salvato subito da acquire(): stesso evento
events.length = 0;
win.__visionCfg = { cameraAvailable: false, executeUrl: '/e', taskId: 1, subtasks: [] };
const c8 = ctx.visionExecute();
c8.$nextTick = (fn) => fn();
ctx.URL = { createObjectURL: () => 'blob:x', revokeObjectURL() {} };
ctx.FormData = class { append() {} };
ctx.fetch = async () => ({ ok: true, json: async () => ({ vision_result_id: 9, measurements: okMs }) });
await c8.acquire({ name: 'a.png' });
check('#120 senza camera: acquire riuscito emette vision-recorded', events.length === 1 && events[0].type === 'vision-recorded', String(events.length));
// task-result-reset: saved torna false (anche senza camera, init registra l'ascolto)
c5.init();
const h = (listeners['task-result-reset'] || [])[0];
check('#120 init ascolta task-result-reset', typeof h === 'function');
c5.saved = true;
h();
check('#120 task-result-reset: saved = false', c5.saved === false);
// #123: dopo Registra + task-result-reset Registra e' spento sul frame registrato, anteprima spenta, impulsiva = grab
win.__visionCfg = { cameraAvailable: true, modo: 'impulsiva', subtasks: [] };
const c9 = ctx.visionExecute();
let grabs = 0;
c9.grab = async function () { grabs++; };
c9.lastBlob = { name: 'f1' };
c9.acquire = async function () { this.result = { vision_result_id: 11, measurements: okMs }; };
check('#123 prima di Registra: canRecord', c9.canRecord === true);
await c9.record();
check('#123 dopo Registra: canRecord falso (saved)', c9.canRecord === false && c9.recordedBlob === c9.lastBlob);
c9.$nextTick = () => {}; // #123: nel test nessun grab di apertura
c9.init(); // registra l'ascolto di task-result-reset
grabs = 0;
(listeners['task-result-reset'] || []).slice(-1)[0]();
check('#123 reset sul frame registrato: canRecord falso', c9.canRecord === false && c9.saved === false);
check('#123 reset sul frame registrato: niente badge anteprima', c9.preview === false);
check('#123 reset in impulsiva: un solo grab', grabs === 1, String(grabs));
c9.lastBlob = { name: 'f2' };
check('#123 frame nuovo: canRecord vero', c9.canRecord === true);
// #119 #122: saveName = titolo del task (nome proposto da <tmf-viewer save-name>), 'immagine' se manca
win.__visionCfg = { taskName: 'Roto3', subtasks: [] };
check('#122 saveName = nome del task', ctx.visionExecute().saveName === 'Roto3');
win.__visionCfg = { subtasks: [] };
check('#122 saveName di riserva', ctx.visionExecute().saveName === 'immagine');
process.exit(failures ? 1 : 0);
})();
@@ -0,0 +1,44 @@
/**
* #122: lo zoom del pannello visione non e' piu' in vision_execute.js ma in <tmf-viewer> (image_viewer.js).
* Qui: il componente passa al viewer solo le etichette in px logici (viewerLabels) e non ha piu' codice di zoom.
*
* Run from src/frontend/flask_app: node tests/js/test_vision_zoom.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8',
);
const win = { __visionCfg: { subtasks: [{ id: 1, description: 'Larg', unit: 'mm' }] } };
const ctx = vm.createContext({ window: win, document: { addEventListener() {}, querySelector() { return null; } } });
vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
let failures = 0;
function check(label, cond, extra) {
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
// Niente piu' stato ne' funzioni di zoom/pan/resize/Salva/overlay nel componente
const c = ctx.visionExecute();
for (const k of ['z', 'zoomBy', 'zFit', 'zWheel', 'zCanPan', 'zCursor', 'zPct', 'zSize', 'zPanDown', 'zPanMove', 'zPanEnd',
'onImgLoad', 'drawOverlay', 'saveImage', 'overlayLabels']) {
check('#122 componente senza ' + k, c[k] === undefined);
}
check('#122 sorgente senza $watch di zoom ne ResizeObserver', !/z\.zoom|ResizeObserver|tmfSaveImage/.test(src));
// viewerLabels: {key, x, y, text, cls} in px logici, classe dall'esito; vuote senza risultato o senza VisionOverlay
check('#122 senza VisionOverlay: nessuna etichetta', (c.result = { measurements: [] }, c.viewerLabels.length === 0));
win.VisionOverlay = ctx.VisionOverlay = {
labels() { return [{ key: 1, x: 500, y: 250, text: 'Larg 1.500 mm', status: 'fail' }]; },
};
const ls = c.viewerLabels;
check('#122 viewerLabels in px logici', ls.length === 1 && ls[0].x === 500 && ls[0].y === 250 && ls[0].key === 1 && ls[0].text === 'Larg 1.500 mm', JSON.stringify(ls));
check('#122 viewerLabels: classe da esito', ls[0].cls === 'bg-red-600 text-white', ls[0].cls);
c.result = null;
check('#122 senza risultato: nessuna etichetta', c.viewerLabels.length === 0);
process.exit(failures ? 1 : 0);
@@ -278,7 +278,7 @@ def test_camera_sources_proxy_keeps_only_the_three_fields(logged_in_client, mock
"name": "camera:folder", "label": "Cartella", "kind": "folder"}
assert len(resp.get_json()["sources"]) == 2
assert get.call_args.args[0] == "http://acq:8765/camera/sources"
assert get.call_args.kwargs["timeout"] == 5
assert get.call_args.kwargs["timeout"] == 15 # #106
@pytest.mark.parametrize("url", ["ftp://x", "", "http://", "acq:8765"])
@@ -0,0 +1,35 @@
"""Runs the plain-node DXF preview behaviour test (tests/js/test_dxf_preview.js).
Same thin pytest wrapper pattern as test_numpad_js.py: shells out to ``node``
and asserts the script's own exit code. Skips (does not fail) when node is
not installed.
Covers gitea:Adriano/TieMeasureFlow#101.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
JS_TEST = Path(__file__).resolve().parent / "js" / "test_dxf_preview.js"
def test_dxf_preview_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS di dxf_recipe_wizard.js saltato")
result = subprocess.run(
[node, str(JS_TEST)],
cwd=str(FLASK_APP_DIR),
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"tests/js/test_dxf_preview.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -72,6 +72,7 @@ class TestStationCameraGrab:
args, kwargs = post.call_args
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
assert kwargs["timeout"] == 60 # #106: margine per la prima apertura GenTL
def test_get_also_works(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
@@ -178,6 +179,12 @@ class TestDxfCompareProxy:
assert set(kwargs["files"]) == {"image", "dxf"}
assert kwargs["files"]["image"][1] == PNG
def test_manual_px_per_mm_forwarded(self, logged_in_client, mock_api_client):
# #98: scala a mano dal wizard (immagine da file): inoltrata al backend
mock_api_client.post.return_value = self.OK
self._post(logged_in_client, station_id="4", px_per_mm="71.1237")
assert mock_api_client.post.call_args.kwargs["data"]["px_per_mm"] == "71.1237"
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
resp = self._post(logged_in_client, save="true")
@@ -204,6 +211,122 @@ class TestDxfCompareProxy:
mock_api_client.post.assert_not_called()
class TestDxfPreviewProxy:
"""#101: anteprima del DXF scelto nel wizard (proxy verso il backend)."""
URL = "/maker/api/vision/dxf-preview"
OK = {
"contorni": [[[0.0, -5.0], [10.0, -5.0], [10.0, 0.0], [0.0, 0.0]]],
"bbox": {"xmin": 0.0, "ymin": -5.0, "xmax": 10.0, "ymax": 0.0},
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 4,
}
def _post(self, client, with_file=True):
data = {"dxf": (io.BytesIO(b"0\nEOF"), "d.dxf")} if with_file else {}
return client.post(self.URL, data=data, content_type="multipart/form-data")
def test_forwards_file(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = self.OK
resp = self._post(logged_in_client)
assert resp.status_code == 200
assert resp.get_json()["larghezza_mm"] == 10.0
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/dxf-preview"
assert set(kwargs["files"]) == {"dxf"}
assert kwargs["files"]["dxf"][0] == "d.dxf"
assert kwargs["files"]["dxf"][1] == b"0\nEOF"
def test_missing_file_is_400(self, logged_in_client, mock_api_client):
resp = self._post(logged_in_client, with_file=False)
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "DXF illeggibile: x"}
resp = self._post(logged_in_client)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "DXF illeggibile: x"
def test_requires_maker_role(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
assert self._post(logged_in_client).status_code == 403
mock_api_client.post.assert_not_called()
class TestDxfSourceProxy:
"""#105: parametri della ricetta DXF gia' salvata (proxy verso il backend)."""
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare/source"
OK = {"band_mm": 0.3, "min_points_pct": 95.0, "polarity": "dark", "px_per_mm": 71.12,
"dxf": "disegno.dxf", "image": "riferimento.bmp"}
def test_forwards_body(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = self.OK
resp = logged_in_client.get(self.URL)
assert resp.status_code == 200
assert resp.get_json() == self.OK
assert mock_api_client.get.call_args.args[0] == "/api/vision/tasks/7/recipe/dxf-compare/source"
def test_404_propagates(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {
"error": True, "status_code": 404, "detail": "nessuna ricetta da DXF su questo task"}
resp = logged_in_client.get(self.URL)
assert resp.status_code == 404
assert resp.get_json()["detail"] == "nessuna ricetta da DXF su questo task"
def test_requires_maker_role(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
assert logged_in_client.get(self.URL).status_code == 403
mock_api_client.get.assert_not_called()
class TestRecipeFileProxy:
"""#105: file della cartella ricetta (DXF, immagine di riferimento) passati come byte."""
URL = "/maker/api/vision/tasks/7/recipe/files/riferimento.bmp"
BMP = b"BM 1 fake bmp"
def _fake_file(self, status=200, content=BMP, ctype="image/bmp"):
resp = MagicMock(status_code=status, content=content)
resp.headers = {"content-type": ctype}
return resp
def test_bytes_and_content_type_pass_through(self, logged_in_client, mock_api_client):
with patch("blueprints.maker.http_requests.get", return_value=self._fake_file()) as get:
resp = logged_in_client.get(self.URL)
assert resp.status_code == 200
assert resp.data == self.BMP
assert resp.mimetype == "image/bmp"
args, kwargs = get.call_args
assert args[0].endswith("/api/vision/tasks/7/recipe/files/riferimento.bmp")
assert kwargs["headers"]["X-API-Key"] == "test-api-key-12345"
def test_backend_404_propagates(self, logged_in_client, mock_api_client):
body = b'{"detail":"file non trovato"}'
with patch("blueprints.maker.http_requests.get",
return_value=self._fake_file(404, body, "application/json")):
resp = logged_in_client.get(self.URL)
assert resp.status_code == 404
assert resp.get_json()["detail"] == "file non trovato"
def test_unsafe_path_is_404_without_calling_backend(self, logged_in_client, mock_api_client):
with patch("blueprints.maker.http_requests.get") as get:
for path in ("a/../../x", "..%5Cx", "%2e%2e/x"):
assert logged_in_client.get(f"/maker/api/vision/tasks/7/recipe/files/{path}").status_code == 404
get.assert_not_called()
def test_service_down_is_502(self, logged_in_client, mock_api_client):
with patch("blueprints.maker.http_requests.get", side_effect=requests.ConnectionError("giu")):
assert logged_in_client.get(self.URL).status_code == 502
def test_requires_maker_role(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
with patch("blueprints.maker.http_requests.get") as get:
assert logged_in_client.get(self.URL).status_code == 403
get.assert_not_called()
class TestPages:
def test_stations_page_has_calibration_cell(self, logged_in_client):
with patch("blueprints.admin.api_client") as admin_api:
@@ -229,4 +352,15 @@ class TestPages:
assert "js/dxf_recipe_wizard.js" in html
assert "dxfRecipeWizard(" in html
assert "@dxf-recipe-saved" in html
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF
assert "imageSource === 'file'" in html # #104: mm/px solo da file
assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine
# #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 DXF wizard zoom test (tests/js/test_dxf_zoom.js).
Same thin pytest wrapper pattern as test_dxf_preview_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#113.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
JS_TEST = Path(__file__).resolve().parent / "js" / "test_dxf_zoom.js"
def test_dxf_zoom_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS dello zoom del wizard saltato")
result = subprocess.run(
[node, str(JS_TEST)],
cwd=str(FLASK_APP_DIR),
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"tests/js/test_dxf_zoom.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -0,0 +1,31 @@
"""Runs the plain-node image_to_png.js test (tests/js/test_image_to_png.js).
Covers gitea:Adriano/TieMeasureFlow#107.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
JS_TEST = Path(__file__).resolve().parent / "js" / "test_image_to_png.js"
def test_image_to_png_js_pure_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS di image_to_png.js saltato")
result = subprocess.run(
[node, str(JS_TEST)],
cwd=str(FLASK_APP_DIR),
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"tests/js/test_image_to_png.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -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}"
)
@@ -313,6 +313,10 @@ class TestImageCropInclusion:
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
assert "js/image-crop.js" in html
assert "tmfCropBeforeUpload(file)" in html
# #107: conversione BMP->PNG e errore upload nel riquadro Disegno Tecnico
assert "js/image_to_png.js" in html
assert "tmfImageToPng(file)" in html
assert "uploadErrorTask" in html
def test_recipe_editor_includes_crop(self, logged_in_client, mock_api_client):
mock_api_client.get.side_effect = [
@@ -322,3 +326,6 @@ class TestImageCropInclusion:
html = logged_in_client.get("/maker/recipes/1/edit").get_data(as_text=True)
assert "js/image-crop.js" in html
assert "tmfCropBeforeUpload(file)" in html
# #107: conversione BMP->PNG prima dei controlli di formato
assert "js/image_to_png.js" in html
assert "tmfImageToPng(file)" in html
@@ -68,7 +68,7 @@ def test_grab_passes_png_through(logged_in_client, mock_api_client, monkeypatch)
assert kwargs["json"] == {
"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000,
}
assert kwargs["timeout"] == 15
assert kwargs["timeout"] == 60 # #106
def test_grab_service_503_is_502(logged_in_client, mock_api_client, monkeypatch):
@@ -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}"
)
@@ -167,3 +167,11 @@ class TestRunTaskResultsProxy:
assert 'id="run-task-results"' in html
assert "loadTaskResults" in html
assert "/statistics/api/runs/" in html
def test_dashboard_all_points_overview(self, logged_in_client, mock_api_client):
# #100: con «Tutti i punti» la pagina ha tabella per-punto e select che ricarica da solo
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
assert "capabilityAll" in html
assert "loadedSubtaskId" in html
assert '@change="loadData()"' in html
@@ -0,0 +1,30 @@
"""#118: il riepilogo task mostra NaN per una quota non misurata (value None)."""
import pytest
@pytest.fixture(autouse=True)
def _station(monkeypatch):
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_task_complete_renders_nan_for_null_value(logged_in_client, mock_api_client):
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
{"id": 1, "marker_number": 1, "description": "quota mancante", "nominal": 1.0,
"unit": "mm"},
{"id": 2, "marker_number": 2, "description": "quota ok", "nominal": 2.0,
"unit": "mm"}]}],
None,
{"items": [
{"id": 1, "subtask_id": 1, "value": None, "deviation": None, "pass_fail": "fail",
"measured_at": "2026-10-06T08:00:00+00:00"},
{"id": 2, "subtask_id": 2, "value": 2.5, "pass_fail": "pass",
"measured_at": "2026-10-06T08:00:00+00:00"},
]},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
assert "NaN" in html
assert "2.500" in html # la quota misurata resta formattata
@@ -0,0 +1,97 @@
"""gitea:Adriano/TieMeasureFlow#118 #119 #120 #121 - pannello visione dell'operatore (task_execute.html).
Controlli sul testo del template: barra in alto con/senza camera, pulsante Salva,
conteggio quote e «Completato» dopo «Registra», cella NaN, svg al posto del canvas.
"""
import re
from pathlib import Path
TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "measure" / "task_execute.html"
SRC = TEMPLATE.read_text(encoding="utf-8")
def _panel():
start = SRC.rindex("<div", 0, SRC.index('x-data="visionExecute()"'))
return SRC[start:SRC.index("MEASUREMENT TIMER BANNER")]
def _bar(panel):
# barra in alto: dall'apertura fino al blocco di errore
return panel[:panel.index('<div x-show="error"')]
def test_file_input_only_without_camera():
# #119: l'input file «Acquisisci immagine/Riprova» sta nel ramo `not camera_enabled`
bar = _bar(_panel())
no_cam = bar[bar.index("{% if not camera_enabled %}"):bar.index("{% else %}")]
assert 'type="file"' in no_cam
cam = bar[bar.index("{% else %}"):]
assert 'type="file"' not in cam
assert bar.count('type="file"') == 1
def test_camera_bar_has_grab_and_record_only():
# #119: con camera «Acquisisci da camera» (grab) e «Registra»; in_linea resta Pausa/Riprendi
cam = _bar(_panel())
cam = cam[cam.index("{% else %}"):]
assert "{{ _('Acquisisci da camera') }}" in cam and '@click="grab()"' in cam
assert '@click="record()"' in cam and "{{ _('Registra') }}" in cam
assert "togglePause()" in cam and "{{ _('Pausa') }}" in cam and "{{ _('Riprendi') }}" in cam
assert "onPick(" not in cam
def test_save_button_is_tmf_viewer_with_texts():
# #119 #122: «Salva» e' del componente <tmf-viewer> (barra: Riduci, Ingrandisci, Adatta, Salva, %); il template gli passa nome e testi
panel = _panel()
assert "<tmf-viewer" in panel and ':src="shownImage"' in panel and ':save-name="saveName"' in panel
for t in ("Adatta", "Salva", "Ingrandisci", "Riduci", "Salva immagine"):
assert "{{ _('%s') }}" % t in panel, t
assert "saveImage()" not in panel and "zPct()" not in panel
assert "taskName:" in SRC
def test_viewer_scripts_order():
# #122: image_viewer.js dopo vision_overlay.js e save_image.js (e prima di vision_execute.js non serve: e' un Custom Element)
assert SRC.index("js/vision_overlay.js") < SRC.index("js/image_viewer.js")
assert SRC.index("js/save_image.js") < SRC.index("js/image_viewer.js")
def test_overlay_and_labels_passed_to_viewer():
# #121 #122: overlay e etichette arrivano al viewer con x-effect (proprieta'), niente canvas
panel = _panel()
assert "<canvas" not in panel
assert "$el.overlay = (result && result.overlay) || []" in panel and "$el.labels = viewerLabels" in panel
assert "overlayLabels" not in panel
def test_value_cell_shows_nan_for_null():
# #118: la cella passa da fmtValue (null -> NaN), non da Number(r.value).toFixed
panel = _panel()
assert 'fmtValue(r.value, r.unit)' in panel
assert "Number(r.value)" not in panel
def test_can_leave_task_depends_on_vision_recorded():
# #120: «Completato» e «Fine ciclo misura» richiedono visionRecorded per i task visione
m = re.search(r"get canLeaveTask\(\) \{(.*?)\n \},", SRC, re.S)
assert m and "isVisionTask && !this.visionRecorded" in m.group(1)
assert m.group(1).index("visionRecorded") < m.group(1).index("cycleConfirmed")
assert "visionRecorded: false" in SRC
def test_missing_label_says_record_the_piece():
# #120: non registrato -> «Registra il pezzo», non «Mancano N quote»
m = re.search(r"get missingQuotesLabel\(\) \{(.*?)\n \},", SRC, re.S)
assert m and "Registra il pezzo" in m.group(1)
assert m.group(1).index("Registra il pezzo") < m.group(1).index("Mancano")
def test_page_listens_to_vision_recorded_and_resets():
# #120: la radice ascolta l'evento; resetForNewCycle azzera visionRecorded; il ripristino mette deviation null
assert '@vision-recorded.window="onVisionRecorded($event.detail)"' in SRC
m = re.search(r"resetForNewCycle\(\) \{(.*?)\n \},", SRC, re.S)
assert m and "visionRecorded = false" in m.group(1)
m = re.search(r"onVisionRecorded\(detail\) \{(.*?)\n \},", SRC, re.S)
assert m and "visionRecorded = true" in m.group(1) and "this.measurements = " in m.group(1)
assert "deviation: m.value == null ? null" in SRC
assert "if (this.isVisionTask) this.visionRecorded = true" in SRC
@@ -0,0 +1,35 @@
"""Runs the plain-node vision overlay draw/anchor/labels test (tests/js/test_vision_overlay.js).
Same thin pytest wrapper pattern as test_vision_rows_js.py: shells out to
``node`` and asserts the script's own exit code. Skips (does not fail) when
node is not installed.
Covers gitea:Adriano/TieMeasureFlow#116.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
JS_TEST = Path(__file__).resolve().parent / "js" / "test_vision_overlay.js"
def test_vision_overlay_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS dell'overlay saltato")
result = subprocess.run(
[node, str(JS_TEST)],
cwd=str(FLASK_APP_DIR),
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"tests/js/test_vision_overlay.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -1,7 +1,7 @@
"""gitea:Adriano/TieMeasureFlow#96 - l'immagine del task visione sta in fit, senza scorrimento.
"""gitea:Adriano/TieMeasureFlow#96 #122 - l'immagine del task visione sta in fit, senza scorrimento.
Controllo sulle classi del template: pannello overflow-hidden, img/canvas
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,13 +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_no_own_zoom_viewport_in_template():
# #122: niente piu' viewport/wrapper di zoom del pannello (#115): li fa il componente
panel = _panel()
assert 'x-ref="vp"' not in panel
assert "zSize()" not in panel and "zoomBy(" not in panel and "zFit()" not in panel
def test_image_box_and_outcome_column_can_shrink():
@@ -52,3 +61,21 @@ def test_image_box_and_outcome_column_can_shrink():
assert "flex-1" in box and "min-h-0" in box
outcome = _tag_with(panel, 'x-show="result">')
assert "overflow-y-auto" in outcome and "min-h-0" in outcome
def test_quote_table_shows_nominal_and_tolerances():
# #117: la tabella quote del pannello mostra nominale e tolleranze come la schermata dei calibri
panel = _panel()
for marker in ("fmtTol(r.nominal)", "fmtTol(r.ltl)", "fmtTol(r.utl)", "fmtTol(r.lwl)", "fmtTol(r.uwl)"):
assert marker in panel, marker
assert "text-measure-fail" in panel and "text-measure-warning" in panel
def test_overlay_script_loaded_before_vision_execute_and_viewer():
# #116 #122: vision_overlay.js prima di vision_execute.js; le etichette le disegna <tmf-viewer> (viewerLabels via x-effect)
src = TEMPLATE.read_text(encoding="utf-8")
assert src.index("js/vision_overlay.js") < src.index("js/vision_execute.js")
assert src.index("js/vision_overlay.js") < src.index("js/image_viewer.js")
panel = _panel()
assert "$el.labels = viewerLabels" in panel
assert 'x-for="l in overlayLabels"' not in panel
@@ -0,0 +1,35 @@
"""Runs the plain-node vision panel quote rows label test (tests/js/test_vision_rows.js).
Same thin pytest wrapper pattern as test_dxf_zoom_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#114.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
JS_TEST = Path(__file__).resolve().parent / "js" / "test_vision_rows.js"
def test_vision_rows_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS delle etichette quote saltato")
result = subprocess.run(
[node, str(JS_TEST)],
cwd=str(FLASK_APP_DIR),
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"tests/js/test_vision_rows.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -0,0 +1,35 @@
"""Runs the plain-node vision panel zoom test (tests/js/test_vision_zoom.js).
Same thin pytest wrapper pattern as test_vision_rows_js.py: shells out to
``node`` and asserts the script's own exit code. Skips (does not fail) when
node is not installed.
Covers gitea:Adriano/TieMeasureFlow#115 #122 (dal #122 il test JS verifica che lo zoom non sia piu' nel componente).
"""
import shutil
import subprocess
from pathlib import Path
import pytest
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
JS_TEST = Path(__file__).resolve().parent / "js" / "test_vision_zoom.js"
def test_vision_zoom_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS dello zoom del pannello saltato")
result = subprocess.run(
[node, str(JS_TEST)],
cwd=str(FLASK_APP_DIR),
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"tests/js/test_vision_zoom.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -0,0 +1,64 @@
"""Controllo del template del wizard DXF: le tre viste sono <tmf-viewer> (#122, prima #113 #119 #121).
Legge task_editor.html come testo (come test_vision_panel_fit.py), senza renderlo.
"""
import re
from pathlib import Path
TEMPLATE = Path(__file__).resolve().parent.parent / "templates" / "maker" / "task_editor.html"
def _src() -> str:
# senza commenti Jinja e HTML, che citano <tmf-viewer> e i vecchi nomi
text = TEMPLATE.read_text(encoding="utf-8")
text = re.sub(r"\{#.*?#\}", "", text, flags=re.S)
return re.sub(r"<!--.*?-->", "", text, flags=re.S)
def _tag_of(src: str, marker: str) -> str:
"""Tag di apertura <tmf-viewer ...> che contiene il marker."""
i = src.index(marker)
start = src.rfind("<tmf-viewer", 0, i)
return src[start:src.index(">", i) + 1]
def test_three_viewers_image_dxf_prova():
# #122: tre <tmf-viewer>: immagine (src), DXF (modo contenuto, con <svg>), Prova (fit-height)
src = _src()
assert src.count("<tmf-viewer") == 3
img = _tag_of(src, 'save-name="immagine"')
assert ':src="imagePreviewUrl"' in img
dxf = _tag_of(src, 'save-name="dxf"')
assert ":src" not in dxf and ":width=" in dxf and ":height=" in dxf
prova = _tag_of(src, 'save-name="prova"')
assert "fit-height" in prova and ':src="imageSrc"' in prova
assert "result.result.overlay" in prova # overlay delle forme del risultato
# il DXF contiene lo <svg> da disegnare, dentro il componente
i = src.index('save-name="dxf"')
assert src.index("<svg", i) < src.index("</tmf-viewer>", i)
assert "dxfPreviewD()" in src[i:src.index("</tmf-viewer>", i)]
def test_old_viewports_gone():
# #122: niente piu' viewport, barre e overlay scritti a mano
src = _src()
for old in ('x-ref="vpImg"', 'x-ref="vpDxf"', 'x-ref="overlay"', 'x-ref="viewport"', "tmfSaveImage(",
"vZoomBy", "vFit(", "zoomPct", "vPct("):
assert old not in src, old
def test_no_style_string_on_viewers():
# #122: un :style a stringa sul <tmf-viewer> cancella lo style statico; x-show non sta sul componente
src = _src()
for m in re.finditer(r"<tmf-viewer[^>]*>", src):
tag = m.group(0)
assert ":style" not in tag and "x-show" not in tag, tag
def test_scripts_loaded_before_wizard():
# #119 #121 #122: vision_overlay.js e save_image.js prima di image_viewer.js, che sta prima di dxf_recipe_wizard.js
src = _src()
viewer = src.index("js/image_viewer.js")
assert src.index("js/vision_overlay.js") < viewer
assert src.index("js/save_image.js") < viewer
assert viewer < src.index("js/dxf_recipe_wizard.js")
@@ -3485,3 +3485,106 @@ msgstr "Invalid camera address"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera"
msgstr "Invalid response from the camera service"
#: templates/statistics/dashboard.html (#100)
msgid "Scegli un punto per carta di controllo e istogramma"
msgstr "Choose a point for the control chart and histogram"
#: templates/maker/task_editor.html blueprints/maker.py (#101)
msgid "Serve il file DXF"
msgstr "The DXF file is required"
msgid "Lettura DXF…"
msgstr "Reading DXF…"
msgid "entità"
msgstr "entities"
#: templates/maker/task_editor.html (#102)
msgid "campo"
msgstr "field"
#: templates/maker/task_editor.html (#102)
msgid "Anteprima non disponibile per questo formato"
msgstr "Preview not available for this format"
#: templates/maker/task_editor.html (#104)
msgid "Riduci"
msgstr "Zoom out"
#: templates/maker/task_editor.html (#104)
msgid "Ingrandisci"
msgstr "Zoom in"
#: templates/maker/task_editor.html (#104)
msgid "Adatta"
msgstr "Fit"
#: templates/maker/task_editor.html (#104)
msgid "Scala"
msgstr "Scale"
#: templates/maker/task_editor.html (#104)
msgid "Punti in banda"
msgstr "Points in band"
#: templates/maker/task_editor.html (#104)
msgid "Distanza media"
msgstr "Average distance"
#: templates/maker/task_editor.html (#104)
msgid "Distanza minima"
msgstr "Minimum distance"
#: templates/maker/task_editor.html (#104)
msgid "Distanza massima"
msgstr "Maximum distance"
#: templates/maker/task_editor.html (#104)
msgid "Conforme"
msgstr "Pass"
#: templates/maker/task_editor.html (#104)
msgid "Con avvisi"
msgstr "Warning"
#: templates/maker/task_editor.html (#104)
msgid "Non conforme"
msgstr "Fail"
#: templates/maker/task_editor.html (#104)
msgid "mm/px a mano"
msgstr "manual mm/px"
#: templates/maker/task_editor.html (#104)
msgid "calibrazione stazione"
msgstr "station calibration"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Modifica ricetta da DXF"
msgstr "Edit recipe from DXF"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
msgstr "Reference image not saved (recipe created earlier): capture or upload it"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Impossibile leggere i file della ricetta esistente"
msgstr "Cannot read the files of the existing recipe"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
msgstr "Existing recipe loaded: Test, then Save recipe to replace it"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "File non trovato"
msgstr "File not found"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
#, python-format
msgid "Servizio non raggiungibile: %(detail)s"
msgstr "Service not reachable: %(detail)s"
#: templates/maker/task_editor.html templates/maker/recipe_editor.html (#107)
msgid "Formato immagine non leggibile. Usa PNG, JPG o PDF."
msgstr "Unreadable image format. Use PNG, JPG or PDF."
@@ -3473,3 +3473,102 @@ msgstr "Indirizzo della camera non valido"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera"
msgstr "Risposta non valida dal servizio camera"
#: templates/maker/task_editor.html blueprints/maker.py (#101)
msgid "Serve il file DXF"
msgstr "Serve il file DXF"
msgid "Lettura DXF…"
msgstr "Lettura DXF…"
msgid "entità"
msgstr "entità"
#: templates/maker/task_editor.html (#102)
msgid "campo"
msgstr "campo"
#: templates/maker/task_editor.html (#102)
msgid "Anteprima non disponibile per questo formato"
msgstr "Anteprima non disponibile per questo formato"
#: templates/maker/task_editor.html (#104)
msgid "Riduci"
msgstr "Riduci"
#: templates/maker/task_editor.html (#104)
msgid "Ingrandisci"
msgstr "Ingrandisci"
#: templates/maker/task_editor.html (#104)
msgid "Adatta"
msgstr "Adatta"
#: templates/maker/task_editor.html (#104)
msgid "Scala"
msgstr "Scala"
#: templates/maker/task_editor.html (#104)
msgid "Punti in banda"
msgstr "Punti in banda"
#: templates/maker/task_editor.html (#104)
msgid "Distanza media"
msgstr "Distanza media"
#: templates/maker/task_editor.html (#104)
msgid "Distanza minima"
msgstr "Distanza minima"
#: templates/maker/task_editor.html (#104)
msgid "Distanza massima"
msgstr "Distanza massima"
#: templates/maker/task_editor.html (#104)
msgid "Conforme"
msgstr "Conforme"
#: templates/maker/task_editor.html (#104)
msgid "Con avvisi"
msgstr "Con avvisi"
#: templates/maker/task_editor.html (#104)
msgid "Non conforme"
msgstr "Non conforme"
#: templates/maker/task_editor.html (#104)
msgid "mm/px a mano"
msgstr "mm/px a mano"
#: templates/maker/task_editor.html (#104)
msgid "calibrazione stazione"
msgstr "calibrazione stazione"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Modifica ricetta da DXF"
msgstr "Modifica ricetta da DXF"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
msgstr "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Impossibile leggere i file della ricetta esistente"
msgstr "Impossibile leggere i file della ricetta esistente"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
msgstr "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
msgid "File non trovato"
msgstr "File non trovato"
#: templates/maker/task_editor.html blueprints/maker.py (#105)
#, python-format
msgid "Servizio non raggiungibile: %(detail)s"
msgstr "Servizio non raggiungibile: %(detail)s"
#: templates/maker/task_editor.html templates/maker/recipe_editor.html (#107)
msgid "Formato immagine non leggibile. Usa PNG, JPG o PDF."
msgstr "Formato immagine non leggibile. Usa PNG, JPG o PDF."
+66
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),
@@ -184,6 +187,66 @@ def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float,
return anchors
def chiudi_contorni(dxf_bytes: bytes) -> tuple[bytes, dict]:
"""#103: unisce le entita' aperte (LINE/ARC/polilinee aperte in fila) in contorni chiusi.
CompareDxf taglia il contorno misurato agli estremi di ogni entita' aperta
del disegno (cut_open_ends): con un profilo spezzato in LINE+ARC e banda
larga non resta nessun punto in banda e il confronto fallisce. Qui le catene
chiuse diventano una LWPOLYLINE chiusa (con bulge per gli archi).
Pura e senza eccezioni nuove: se non c'e' nulla da unire, o ezdxf non legge
il file, ritorna i byte originali (sha invariato, ricette esistenti stabili).
Ritorna (bytes, {"anelli", "entita_unite", "entita_libere"}).
"""
nulla = {"anelli": 0, "entita_unite": 0, "entita_libere": 0}
try:
import ezdxf
from ezdxf import edgeminer as em, edgesmith as es
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in.dxf"
src.write_bytes(dxf_bytes)
doc = ezdxf.readfile(str(src))
msp = doc.modelspace()
# edges_from_entities_2d salta le entita' gia' chiuse (restano dove sono)
edges = list(es.edges_from_entities_2d(msp))
if not edges:
return dxf_bytes, nulla
loops = em.find_all_loops(em.Deposit(edges, gap_tol=0.01)) # 0.01 mm: giunzioni CAD
usati: set = set()
unite: list = [] # (polilinea, entita' di partenza)
# loop piu' lungo per primo, ogni edge usato una sola volta
for loop in sorted(loops, key=lambda lp: -sum(e.length for e in lp)):
ids = {e.id for e in loop}
if ids & usati:
continue
usati |= ids
unite.append((loop, es.lwpolyline_from_chain(loop)))
if not unite:
return dxf_bytes, {**nulla, "entita_libere": len(edges)}
entita_unite = 0
for loop, pl in unite:
pl.closed = True
payload = getattr(loop[0], "payload", None)
layer = getattr(getattr(payload, "dxf", None), "layer", None)
if layer:
pl.dxf.layer = layer # stesso layer dell'entita' di partenza
for e in loop:
ent = getattr(e, "payload", None)
if ent is not None and ent.is_alive:
msp.delete_entity(ent) # sostituita dalla polilinea chiusa
msp.add_foreign_entity(pl)
entita_unite += len(loop)
dst = Path(tmp) / "out.dxf"
doc.saveas(str(dst))
info = {"anelli": len(unite), "entita_unite": entita_unite,
"entita_libere": len(edges) - entita_unite}
return dst.read_bytes(), info
except Exception: # noqa: BLE001 - DXF illeggibile: l'errore resta quello a valle
return dxf_bytes, nulla
def _run(gray, graph, px_per_mm, folder) -> RunOutcome:
return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder)
@@ -211,6 +274,9 @@ def build_dxf_compare(
raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}")
if not dxf_bytes:
raise CostruttoreError("DXF vuoto")
# #103: entita' aperte unite in contorni chiusi PRIMA dello sha: disegno.dxf,
# grafo e runtime usano tutti il DXF chiuso
dxf_bytes, _info_chiusura = chiudi_contorni(dxf_bytes)
frame = _gray_u8(gray)
calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8")
+18 -1
View File
@@ -217,6 +217,14 @@ def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
# #112: quantity/unit delle uscite che il tool non dichiara in value_quantities
_ETICHETTE_EXTRA = {
"count": ("count", "pz"),
"n_points": ("count", "punti"),
"area": ("area", "px²"),
}
def validate_graph(
graph: dict, recipe_dir: Any = None
) -> tuple[bool, list[str], list[dict]]:
@@ -245,7 +253,11 @@ def validate_graph(
for prop in tool.outputs():
if not (_is_number(prop.default) or prop.name in values):
continue
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
if prop.name in values:
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
else:
# #112: fuori da values mai ereditare tool.quantity (count/area non sono lunghezze)
quantity = value_quantities.get(prop.name)
if not isinstance(quantity, str):
quantity = None
unit = None
@@ -254,6 +266,11 @@ def validate_graph(
unit = nominal_unit if isinstance(nominal_unit, str) else None
elif quantity == "angle":
unit = "deg"
elif quantity == "ratio":
unit = "%" # #112: rapporti (points_in_pct) in percentuale
if quantity is None and prop.name not in values:
# #112: etichette delle uscite note non dichiarate dal tool
quantity, unit = _ETICHETTE_EXTRA.get(prop.name, (None, None))
outputs.append({
"key": f"{tool.id}.{prop.name}",
"quantity": quantity,
+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"):
+25
View File
@@ -288,3 +288,28 @@ def test_validate_reports_a_missing_file(tmp_path):
ok, errors, outputs = runner.validate_graph(_dxf_recipe(), tmp_path)
assert not ok and errors and outputs == []
def test_validate_labels_wizard_outputs(tmp_path):
"""#112: count/n_points/area non ereditano la quantita' del tool, ratio in %."""
from src.vision import costruttori
dxf_bytes = _SAMPLE_DXF.read_bytes()
cal_bytes = b'{"px_per_mm": 4.0}'
(tmp_path / costruttori.DXF_FILE).write_bytes(dxf_bytes)
(tmp_path / costruttori.CALIBRATION_FILE).write_bytes(cal_bytes)
graph = costruttori._graph(
hashlib.sha256(dxf_bytes).hexdigest(), hashlib.sha256(cal_bytes).hexdigest(),
1.0, 95.0, "dark", {"row": 0.0, "col": 0.0, "angle_deg": 0.0},
(0.0, 0.0, 0.0),
)
ok, errors, outputs = runner.validate_graph(graph, tmp_path)
assert ok, errors
by_key = {o["key"]: (o["quantity"], o["unit"]) for o in outputs}
assert by_key["pezzo.count"] == ("count", "pz")
assert by_key["pezzo.area"] == ("area", "px²")
assert by_key["dxf.n_points"] == ("count", "punti")
assert by_key["dxf.points_in_pct"] == ("ratio", "%")
assert by_key["dxf.distance_max"] == ("length", "mm")
+90 -1
View File
@@ -11,13 +11,14 @@ import io
import json
import os
import re
import tempfile # #101
from pathlib import Path
import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83
from src.vision.costruttori import CostruttoreError, build_dxf_compare, chiudi_contorni # #83 #103
from src.vision.runner import (
GraphBuildError,
RunOutcome,
@@ -288,3 +289,91 @@ async def recipes_dxf_compare(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
) from exc
# #101: tetto ai punti dell'anteprima DXF (un profilo fitto non deve gonfiare
# la risposta ne' rallentare il disegno SVG nel browser).
_PREVIEW_MAX_POINTS = 20000
def _decimate(points: list[list[float]], keep: int) -> list[list[float]]:
"""Decimazione uniforme a `keep` punti (>= 2), primo e ultimo sempre inclusi."""
n = len(points)
if n <= keep or keep < 2:
return points
idx = sorted({round(i * (n - 1) / (keep - 1)) for i in range(keep)})
return [points[i] for i in idx]
def _dxf_preview(dxf_bytes: bytes) -> dict:
"""#101: legge il DXF con lo stesso lettore di CompareDxf (read_xld_dxf) e ne
ritorna i contorni in mm, asse y verso il basso (come immagine e SVG).
Funzione sincrona e pura: la rotta la esegue in un thread. Solleva
CostruttoreError per DXF vuoto, illeggibile o senza geometria.
"""
if not dxf_bytes:
raise CostruttoreError("DXF illeggibile: file vuoto")
from visionsuite.io.dxf import read_xld_dxf
# #103: l'anteprima mostra cio' che il confronto usera' (contorni chiusi)
dxf_bytes, _ = chiudi_contorni(dxf_bytes)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "disegno.dxf"
path.write_bytes(dxf_bytes)
try:
# flip_y=False + scale=1: coordinate native del DXF, in mm; il
# ribaltamento dell'asse y lo facciamo noi sotto (y = -y_dxf)
conts = list(read_xld_dxf(str(path), sampling_distance=0.05,
scale=1.0, flip_y=False))
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
contorni: list[list[list[float]]] = []
for cont in conts:
pts = np.asarray(cont.points, dtype=float) # (riga, colonna) = (y_dxf, x_dxf)
if pts.ndim != 2 or pts.shape[0] < 2:
continue
line = [[float(c), float(-r)] for r, c in pts]
if cont.closed and line[0] != line[-1]:
line.append(list(line[0])) # chiude il giro anche nella polilinea SVG
contorni.append(line)
if not contorni:
raise CostruttoreError("DXF senza geometria")
# #101: le dimensioni si misurano sui punti completi, prima della decimazione
xs = [p[0] for c in contorni for p in c]
ys = [p[1] for c in contorni for p in c]
xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys)
total = len(xs)
if total > _PREVIEW_MAX_POINTS:
# quota per contorno proporzionale, minimo 2 punti
contorni = [
_decimate(c, max(2, int(len(c) * _PREVIEW_MAX_POINTS / total))) for c in contorni
]
contorni = [[[round(x, 3), round(y, 3)] for x, y in c] for c in contorni]
xmin, xmax, ymin, ymax = (round(v, 3) for v in (xmin, xmax, ymin, ymax))
return {
"contorni": contorni,
"bbox": {"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax},
"larghezza_mm": round(xmax - xmin, 3),
"altezza_mm": round(ymax - ymin, 3),
"entita": len(contorni),
"punti": sum(len(c) for c in contorni),
}
@app.post("/dxf/preview")
async def dxf_preview(dxf: UploadFile = File(...)) -> dict:
"""#101: anteprima del disegno DXF (contorni + dimensioni in mm) per il
wizard «Crea ricetta da DXF». Solo lettura, leggera: nessun run slot."""
dxf_bytes = await dxf.read()
try:
return await asyncio.to_thread(_dxf_preview, dxf_bytes)
except CostruttoreError as exc:
raise _unprocessable(str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"dxf preview failed: {type(exc).__name__}: {exc}",
) from exc
@@ -0,0 +1,101 @@
"""#103: chiudi_contorni unisce LINE/ARC aperti in contorni chiusi (test sincroni, no pytest-asyncio)."""
import hashlib
import io
import tempfile
from pathlib import Path
import cv2
import ezdxf
import numpy as np
import pytest
from src.vision.costruttori import CostruttoreError, build_dxf_compare, chiudi_contorni
from src.vision_worker.main import _dxf_preview
def _dxf(riempi) -> bytes:
doc = ezdxf.new("R2010")
riempi(doc.modelspace())
buf = io.StringIO()
doc.write(buf)
return buf.getvalue().encode("utf-8")
def _quattro_line(msp, w=20.0, h=10.0):
pts = [(0, 0), (w, 0), (w, h), (0, h)]
for i in range(4):
msp.add_line(pts[i], pts[(i + 1) % 4])
def _entita(dxf: bytes):
with tempfile.TemporaryDirectory() as tmp: # readfile gestisce da solo codifica e CRLF
path = Path(tmp) / "o.dxf"
path.write_bytes(dxf)
return list(ezdxf.readfile(str(path)).modelspace())
def test_chiudi_rettangolo_di_4_line_in_una_polilinea_chiusa():
out, info = chiudi_contorni(_dxf(_quattro_line))
assert info == {"anelli": 1, "entita_unite": 4, "entita_libere": 0}
ents = _entita(out)
assert [e.dxftype() for e in ents] == ["LWPOLYLINE"] and ents[0].closed
xs = [p[0] for p in ents[0].get_points()]
ys = [p[1] for p in ents[0].get_points()]
assert (min(xs), max(xs), min(ys), max(ys)) == (0, 20, 0, 10) # bbox invariato
def test_chiudi_asola_line_e_arc_ha_bulge():
def asola(msp): # asola 20x10: due LINE orizzontali + due semicerchi r=5
msp.add_line((5, 0), (15, 0))
msp.add_arc((15, 5), 5, -90, 90)
msp.add_line((15, 10), (5, 10))
msp.add_arc((5, 5), 5, 90, 270)
out, info = chiudi_contorni(_dxf(asola))
assert info["anelli"] == 1 and info["entita_unite"] == 4
ents = _entita(out)
assert len(ents) == 1 and ents[0].closed
assert any(abs(p[4]) > 1e-6 for p in ents[0].get_points()) # gli archi diventano bulge
def test_dxf_gia_chiuso_resta_identico():
def chiuso(msp):
msp.add_lwpolyline([(0, 0), (20, 0), (20, 10), (0, 10)], close=True)
msp.add_circle((10, 5), 2)
dxf = _dxf(chiuso)
out, info = chiudi_contorni(dxf)
assert out == dxf and info["anelli"] == 0
def test_line_isolata_resta_e_byte_identici():
dxf = _dxf(lambda msp: msp.add_line((0, 0), (10, 0)))
out, info = chiudi_contorni(dxf)
assert out == dxf and info["anelli"] == 0 and info["entita_libere"] == 1
def test_byte_non_dxf_ritornati_identici():
brutto = b"questo non e' un dxf"
out, info = chiudi_contorni(brutto)
assert out == brutto and info["anelli"] == 0
def test_preview_di_quattro_line_e_una_sola_entita():
resp = _dxf_preview(_dxf(_quattro_line))
assert resp["entita"] == 1 and resp["larghezza_mm"] == 20 and resp["altezza_mm"] == 10
@pytest.mark.parametrize("band", [0.1, 0.3])
def test_chiudi_build_dxf_compare_banda_larga_con_4_line(band):
# rettangolo scuro 20x10 mm su bianco, 20 px/mm; disegno = 4 LINE aperte
ppm = 20.0
img = np.full((int(40 * ppm), int(60 * ppm)), 255, np.uint8)
cx, cy = img.shape[1] // 2, img.shape[0] // 2
cv2.rectangle(img, (cx - int(10 * ppm), cy - int(5 * ppm)),
(cx + int(10 * ppm), cy + int(5 * ppm)), 0, -1)
dxf = _dxf(_quattro_line)
ris = build_dxf_compare(img, dxf, px_per_mm=ppm, band_mm=band)
assert ris["outputs"]["dxf.points_in_pct"] > 90.0
# il disegno nella ricetta e' quello chiuso, non l'originale
import base64
salvato = base64.b64decode(ris["files"]["disegno.dxf"])
assert salvato != dxf and hashlib.sha256(salvato).hexdigest() == \
ris["graph"]["meta"]["dxf"]["disegno"]["sha256"]
@@ -80,3 +80,67 @@ async def test_dxf_compare_immagine_non_decodificabile_e_422():
async def test_dxf_compare_parametro_fuori_range_e_422():
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
assert response.status_code == 422
# --- #101: POST /dxf/preview ---------------------------------------------------
def _rect_dxf(width: float, height: float, y0: float = 3.0) -> bytes:
"""DXF minimo con ezdxf: un rettangolo chiuso, y in alto rispetto a 0."""
import ezdxf
doc = ezdxf.new()
doc.modelspace().add_lwpolyline(
[(1, y0), (1 + width, y0), (1 + width, y0 + height), (1, y0 + height)], close=True,
)
buffer = io.StringIO()
doc.write(buffer)
return buffer.getvalue().encode("utf-8")
def _post_preview(dxf: bytes):
# TestClient sincrono: non serve pytest-asyncio (assente nel venv del worker)
from fastapi.testclient import TestClient
return TestClient(app).post(
"/dxf/preview", files={"dxf": ("d.dxf", dxf, "application/octet-stream")},
)
def test_dxf_preview_rettangolo_10x5():
resp = _post_preview(_rect_dxf(10, 5))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["larghezza_mm"] == 10 and body["altezza_mm"] == 5
assert body["entita"] == 1 and body["punti"] == sum(len(c) for c in body["contorni"])
# y ribaltata: il rettangolo stava a y 3..8 nel DXF, ora a -8..-3 (asse verso il basso)
assert body["bbox"] == {"xmin": 1, "ymin": -8, "xmax": 11, "ymax": -3}
ring = body["contorni"][0]
assert ring[0] == ring[-1] # contorno chiuso: primo == ultimo
assert all(-8 <= y <= -3 for _, y in ring)
@pytest.mark.parametrize("content", [b"", b"questo non e' un dxf"])
def test_dxf_preview_illeggibile_e_422(content):
resp = _post_preview(content)
assert resp.status_code == 422
assert resp.json()["detail"].startswith("DXF")
def test_dxf_preview_senza_geometria_e_422():
import ezdxf
doc = ezdxf.new()
buffer = io.StringIO()
doc.write(buffer)
resp = _post_preview(buffer.getvalue().encode("utf-8"))
assert resp.status_code == 422
assert "senza geometria" in resp.json()["detail"]
def test_dxf_preview_decima_oltre_il_tetto(monkeypatch):
from src.vision_worker import main
monkeypatch.setattr(main, "_PREVIEW_MAX_POINTS", 50)
import ezdxf
doc = ezdxf.new()
doc.modelspace().add_circle((0, 0), 20)
buffer = io.StringIO()
doc.write(buffer)
full = main._dxf_preview(buffer.getvalue().encode("utf-8"))
assert full["punti"] <= 50 + 1 and full["larghezza_mm"] == pytest.approx(40, abs=0.01) # dimensioni dai punti completi
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" },
]