From 815d49d69900a68de2d03ed040e9961592b00dcc Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 21:58:18 +0100 Subject: [PATCH] Pannello visione operatore: zoom, overlay e Salva passano a Refs #122 Co-Authored-By: Claude Sonnet 5.5 --- .../flask_app/static/js/vision_execute.js | 156 +----------------- .../templates/measure/task_execute.html | 34 +--- .../flask_app/tests/js/test_vision_rows.js | 48 ++---- .../flask_app/tests/js/test_vision_zoom.js | 119 +++---------- .../flask_app/tests/test_vision_operatore.py | 26 ++- .../flask_app/tests/test_vision_panel_fit.py | 47 +++--- .../flask_app/tests/test_vision_zoom_js.py | 2 +- 7 files changed, 84 insertions(+), 348 deletions(-) diff --git a/src/frontend/flask_app/static/js/vision_execute.js b/src/frontend/flask_app/static/js/vision_execute.js index df60194..d5267f9 100644 --- a/src/frontend/flask_app/static/js/vision_execute.js +++ b/src/frontend/flask_app/static/js/vision_execute.js @@ -38,13 +38,6 @@ function visionExecute() { looping: false, // an in-line loop is running gen: 0, // bumped by «Registra»: answers of older requests are dropped - // #115: zoom del riquadro immagine (stesso schema del wizard DXF, #113). zoom = px schermo per px immagine; - // fit = zoom di «Adatta»; w/h = dimensioni naturali; auto = la vista e' ad Adatta (segue il resize del riquadro) - z: { zoom: 1, fit: 1, w: 0, h: 0, auto: true }, - zPanning: false, // #115: trascinamento in corso (cursore grabbing) - _zPan: null, - _zRO: null, - // #97: start on a frame already acquired and analysed; hook visibility and exit. init() { // #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera @@ -53,8 +46,6 @@ function visionExecute() { this.preview = !!this.result; this.kick(); }); - // #121: l'overlay SVG si ridisegna a ogni cambio di zoom (spessore e croci costanti a video) - if (typeof this.$watch === 'function') this.$watch('z.zoom', () => this.drawOverlay()); if (!this.hasCamera) return; document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); }); const leave = () => { this.leaving = true; }; @@ -132,7 +123,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'; @@ -194,8 +184,6 @@ function visionExecute() { this.result = body; // #120: senza camera il file scelto e' salvato subito (nessun «Registra»): stesso evento if (!this.hasCamera && body.vision_result_id) this._emitRecorded(); - // Draw once Alpine has rendered the svg. - this.$nextTick(() => this.drawOverlay()); } catch (e) { this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; } finally { @@ -209,13 +197,6 @@ function visionExecute() { window.dispatchEvent(new CustomEvent('vision-recorded', { detail: { measurements: ms } })); }, - // #119: «Salva» = frame grezzo PNG dell'immagine mostrata, senza disegni (save_image.js) - saveImage() { - const cfg = window.__visionCfg || {}; - if (!this.shownImage || typeof tmfSaveImage !== 'function') return Promise.resolve(false); - return tmfSaveImage(this.shownImage, cfg.taskName || 'immagine'); - }, - // 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 @@ -297,114 +278,7 @@ function visionExecute() { }); }, - // #115: load dell'immagine (anche a ogni frame in linea): dimensioni naturali, fit solo se serve, poi overlay - onImgLoad() { - const img = this.$refs.img; - if (img && img.naturalWidth) { - const z = this.z; - const changed = z.w !== img.naturalWidth || z.h !== img.naturalHeight; - z.w = img.naturalWidth; - z.h = img.naturalHeight; - // dimensioni uguali e vista ingrandita: il frame nuovo resta ingrandito (in linea ogni frame ricarica l'immagine) - if (changed || z.auto) this.zFit(); - this._zObserve(); - } - this.drawOverlay(); - }, - - // #115: osservatore di resize sul viewport: se la vista e' ad Adatta (o il riquadro era nascosto) rifa il fit - _zObserve() { - const vp = this.$refs.vp; - if (!vp || this._zRO || typeof ResizeObserver === 'undefined') return; - this._zRO = new ResizeObserver(() => { if (this.z.w > 0 && this.z.auto) this.zFit(); }); - this._zRO.observe(vp); - }, - - // #115: area utile del viewport; offset meno i bordi (client* escludono le barre di scorrimento che spariranno dopo il fit, come #113) - _zArea() { - const vp = this.$refs.vp; - if (!vp) return { cw: 0, ch: 0 }; - let cw = vp.clientWidth, ch = vp.clientHeight; - if (vp.offsetWidth && typeof getComputedStyle === 'function') { - const cs = getComputedStyle(vp); - cw = vp.offsetWidth - (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth) || 0); - ch = vp.offsetHeight - (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) || 0); - } - return { cw, ch }; - }, - - // #115: Adatta = tutta l'immagine nel riquadro (come il fit di #96) - zFit() { - const vp = this.$refs.vp, z = this.z; - if (!vp || !z.w || !z.h) return; - const a = this._zArea(); - if (!(a.cw > 0) || !(a.ch > 0)) return; // riquadro nascosto o vuoto: niente, ci pensa l'osservatore - const f = Math.min(a.cw / z.w, a.ch / z.h); - z.fit = f; - z.zoom = f; - z.auto = true; - vp.scrollLeft = 0; - vp.scrollTop = 0; - }, - - // #115: zoom di un fattore tenendo fermo il punto (cx, cy) del viewport (default: il centro) - zoomBy(f, cx, cy) { - const vp = this.$refs.vp, z = this.z; - if (!vp || !z.w) return; - const old = z.zoom; - const nz = Math.min(Math.max(8, z.fit), Math.max(z.fit * 0.5, old * f)); - if (nz === old) return; - if (cx == null) cx = vp.clientWidth / 2; - if (cy == null) cy = vp.clientHeight / 2; - const left = vp.scrollLeft, top = vp.scrollTop; - z.zoom = nz; - z.auto = false; - const apply = () => { - vp.scrollLeft = (left + cx) * nz / old - cx; - vp.scrollTop = (top + cy) * nz / old - cy; - }; - if (typeof this.$nextTick === 'function') this.$nextTick(apply); else apply(); - }, - - // #115: Ctrl+rotella = zoom sul punto del cursore; senza Ctrl la pagina scorre come sempre - zWheel(ev) { - if (!ev.ctrlKey) return; - ev.preventDefault(); - const vp = this.$refs.vp; - let cx, cy; - if (vp && vp.getBoundingClientRect) { - const r = vp.getBoundingClientRect(); - cx = ev.clientX - r.left; - cy = ev.clientY - r.top; - } - this.zoomBy(ev.deltaY < 0 ? 1.25 : 1 / 1.25, cx, cy); - }, - - zCanPan() { const z = this.z; return z.w > 0 && z.zoom > z.fit * 1.0001; }, - zCursor() { return this.zCanPan() ? (this.zPanning ? 'grabbing' : 'grab') : 'default'; }, - zPct() { return Math.round(this.z.zoom * 100) + '%'; }, - // #115: dimensione del wrapper: naturale x zoom; prima del load riempie il riquadro - zSize() { - const z = this.z; - return z.w ? 'width:' + (z.w * z.zoom) + 'px;height:' + (z.h * z.zoom) + 'px' : 'width:100%;height:100%'; - }, - - // #115: trascinamento = scorrimento del viewport (solo se ingrandito) - zPanDown(ev) { - const vp = this.$refs.vp; - if (!vp || !this.zCanPan() || (ev.pointerType === 'mouse' && ev.button)) return; - if (ev.preventDefault) ev.preventDefault(); - this._zPan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop }; - this.zPanning = true; - try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ } - }, - zPanMove(ev) { - const vp = this.$refs.vp, p = this._zPan; - if (!p || !vp) return; - vp.scrollLeft = p.left - (ev.clientX - p.x); - vp.scrollTop = p.top - (ev.clientY - p.y); - }, - zPanEnd() { this._zPan = null; this.zPanning = false; }, + // #122: zoom, pan, resize e Salva li gestisce (static/js/image_viewer.js) // #118: valore della cella: una quota non misurata (null) e' «NaN», mai Number(null) = 0 fmtValue(v, unit) { @@ -414,19 +288,15 @@ function visionExecute() { // #117: tolleranza a 3 decimali, trattino se manca (come x-text="...?.toFixed(3) || '—'" dei calibri, ma 0 resta 0.000) fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); }, - // #116: etichette «nome valore unita» agganciate alla geometria dello strumento; posizione in % delle - // dimensioni naturali dell'immagine, cosi' seguono lo zoom (#115) senza ingrandirsi - get overlayLabels() { + // #119 #122: nome proposto da «Salva» (titolo del task) per + get saveName() { return (window.__visionCfg || {}).taskName || 'immagine'; }, + + // #116 #122: etichette «nome valore unita» per : x/y in px logici dell'immagine + // (il componente le posiziona in % e le tiene a dimensione costante a ogni zoom) + get viewerLabels() { if (typeof VisionOverlay === 'undefined' || !this.result) return []; - const img = this.$refs && this.$refs.img; - const w = this.z.w || (img && img.naturalWidth) || 0; - const h = this.z.h || (img && img.naturalHeight) || 0; - if (!w || !h) return []; return VisionOverlay.labels(this.rows, this.result.overlay || []).map((l) => ({ - key: l.key, - text: l.text, - cls: this.labelClass(l.status), - style: 'left:' + (l.x / w * 100) + '%;top:' + (l.y / h * 100) + '%;transform:translate(8px,-50%)', + key: l.key, x: l.x, y: l.y, text: l.text, cls: this.labelClass(l.status), })); }, @@ -445,15 +315,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)]'; }, - - // #121: overlay SVG (VisionOverlay.svg) nel wrapper dello zoom: viewBox = dimensioni naturali, quindi - // le coordinate (px immagine) valgono cosi' come sono; spessore e croci restano costanti a ogni zoom - drawOverlay() { - const img = this.$refs.img, sv = this.$refs.overlay; - if (!sv) return; - if (img && img.naturalWidth) sv.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight); - const ok = typeof VisionOverlay !== 'undefined' && typeof VisionOverlay.svg === 'function'; - sv.innerHTML = ok && this.result ? VisionOverlay.svg(this.result.overlay || [], { zoom: this.z.zoom }) : ''; - }, }; } diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index 227039e..a3e4e22 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -579,34 +579,12 @@
- {# #96: riquadro flex-1 min-h-0; #115: dentro, barra zoom in overlay e viewport scorrevole con il wrapper (naturale x zoom) che contiene img e svg; #121: l'svg ha il viewBox delle dimensioni naturali dell'immagine, quindi l'overlay resta allineato #} + {# #96: riquadro flex-1 min-h-0; #122: dentro, (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 #}
- {# #115: x-show sul contenitore della barra, mai sul viewport (con :style stringa Alpine lo pesterebbe) #} -
- - - - {# #119: salva il frame grezzo PNG (senza disegni); la barra compare solo con un'immagine (x-show="z.w") #} - - -
- {# #115: viewport = tutto il riquadro; solo :style stringa (uno style statico verrebbe cancellato da Alpine) #} -
-
- - {# #121: overlay SVG al posto del canvas: viewBox = dimensioni naturali (le imposta drawOverlay), tratto costante a ogni zoom #} - - {# #116: etichette quota in HTML sopra l'svg (posizione in %, dimensione costante a ogni zoom); solo :style stringa #} - -
-
+
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }} @@ -1225,6 +1203,8 @@ {# #119: «Salva» immagine (frame grezzo PNG) #} +{# #122: visualizzatore standard , dopo vision_overlay.js e save_image.js #} + {# #53 #54 #55: result of check / note tasks (urls and words for the component) #}