diff --git a/src/frontend/flask_app/static/js/vision_execute.js b/src/frontend/flask_app/static/js/vision_execute.js index bc46071..4670f49 100644 --- a/src/frontend/flask_app/static/js/vision_execute.js +++ b/src/frontend/flask_app/static/js/vision_execute.js @@ -41,6 +41,13 @@ 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() { if (!this.hasCamera) return; @@ -263,6 +270,115 @@ 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; }, + 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'; diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index b447efc..ab5631e 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -576,10 +576,26 @@
- {# #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; #115: dentro, barra zoom in overlay e viewport scorrevole con il wrapper (naturale x zoom) che contiene img e canvas; il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato #}
- - + {# #115: x-show sul contenitore della barra, mai sul viewport (con :style stringa Alpine lo pesterebbe) #} +
+ + + + +
+ {# #115: viewport = tutto il riquadro; solo :style stringa (uno style statico verrebbe cancellato da Alpine) #} +
+
+ + +
+
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }} diff --git a/src/frontend/flask_app/tests/js/test_vision_zoom.js b/src/frontend/flask_app/tests/js/test_vision_zoom.js new file mode 100644 index 0000000..bbddb52 --- /dev/null +++ b/src/frontend/flask_app/tests/js/test_vision_zoom.js @@ -0,0 +1,111 @@ +/** + * #115: zoom, Adatta e pan del riquadro immagine del pannello visione, con plain node. + * + * 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 = {}; +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 : '')); } +} +const near = (a, b) => Math.abs(a - b) < 1e-9 * Math.max(1, Math.abs(b)); + +// Componente con viewport e immagine finti ($nextTick sincrono); getComputedStyle assente: si usano client* +function make(cw, ch) { + const c = ctx.visionExecute(); + const vp = { clientWidth: cw, clientHeight: ch, scrollLeft: 0, scrollTop: 0, setPointerCapture() {} }; + const img = { naturalWidth: 2000, naturalHeight: 1000 }; + c.$refs = { vp, img, overlay: null }; + c.$nextTick = (fn) => fn(); + return c; +} + +// Prima del load: il wrapper riempie il riquadro +let c = make(800, 400); +check('#115 zSize prima del load = 100 %', c.zSize() === 'width:100%;height:100%', c.zSize()); +check('#115 zoomBy senza immagine: ignorato', (c.zoomBy(2), c.z.zoom === 1)); + +// Load: fit = min(800/2000, 400/1000) = 0.4 +c.onImgLoad(); +check('#115 load: Adatta = 0.4', near(c.z.zoom, 0.4) && near(c.z.fit, 0.4) && c.z.auto === true, String(c.z.zoom)); +check('#115 zSize = naturale x zoom', c.zSize() === 'width:800px;height:400px', c.zSize()); +check('#115 zPct = 40%', c.zPct() === '40%', c.zPct()); +check('#115 a Adatta non si trascina', c.zCanPan() === false && c.zCursor() === 'default'); + +// Zoom in/out e limiti +c.zoomBy(1.25); +check('#115 zoomBy moltiplica', near(c.z.zoom, 0.5) && c.z.auto === false); +check('#115 dopo lo zoom si trascina', c.zCanPan() === true && c.zCursor() === 'grab'); +c.zoomBy(1e6); +check('#115 limite alto 8', c.z.zoom === 8, String(c.z.zoom)); +c.zoomBy(1e-9); +check('#115 limite basso fit*0.5', near(c.z.zoom, 0.2), String(c.z.zoom)); +c.zFit(); +check('#115 Adatta torna al fit e a auto', near(c.z.zoom, 0.4) && c.z.auto === true); + +// Il punto sotto il cursore resta fermo: zoom da 0.4 a 0.8 col cursore a (100, 50), scroll 0 +c.zoomBy(2, 100, 50); +check('#115 zoom al cursore: scrollLeft', near(c.$refs.vp.scrollLeft, 100) && near(c.$refs.vp.scrollTop, 50), + c.$refs.vp.scrollLeft + ',' + c.$refs.vp.scrollTop); + +// Frame nuovo con le stesse dimensioni: tiene lo zoom; dimensioni diverse: rifa il fit +const z0 = c.z.zoom; +c.onImgLoad(); +check('#115 frame con stesse dimensioni: zoom invariato', c.z.zoom === z0 && c.z.auto === false); +c.$refs.img.naturalWidth = 1000; c.$refs.img.naturalHeight = 1000; +c.onImgLoad(); +check('#115 dimensioni diverse: nuovo fit', near(c.z.fit, 0.4) && c.z.zoom === c.z.fit && c.z.auto === true); + +// Riquadro nascosto (0 x 0): niente fit, stato invariato +c = make(0, 0); +c.onImgLoad(); +check('#115 riquadro nascosto: nessun fit', c.z.zoom === 1 && c.z.w === 2000); +c.$refs.vp.clientWidth = 800; c.$refs.vp.clientHeight = 400; +c.zFit(); +check('#115 riquadro visibile: il fit parte', near(c.z.zoom, 0.4)); + +// offset meno bordi (getComputedStyle disponibile) +c = make(780, 380); +c.$refs.vp.offsetWidth = 802; c.$refs.vp.offsetHeight = 402; +ctx.getComputedStyle = () => ({ borderLeftWidth: '1px', borderRightWidth: '1px', borderTopWidth: '1px', borderBottomWidth: '1px' }); +c.onImgLoad(); +check('#115 area = offset meno bordi', near(c.z.fit, 0.4), String(c.z.fit)); +delete ctx.getComputedStyle; + +// Rotella: solo con Ctrl +c = make(800, 400); c.onImgLoad(); +let prevented = false; +c.zWheel({ ctrlKey: false, deltaY: -1, preventDefault() { prevented = true; } }); +check('#115 rotella senza Ctrl: ignorata', !prevented && near(c.z.zoom, 0.4)); +c.zWheel({ ctrlKey: true, deltaY: -1, clientX: 0, clientY: 0, preventDefault() { prevented = true; } }); +check('#115 Ctrl+rotella su: zoom x1.25', prevented && near(c.z.zoom, 0.5)); +c.zWheel({ ctrlKey: true, deltaY: 1, preventDefault() {} }); +check('#115 Ctrl+rotella giu: zoom /1.25', near(c.z.zoom, 0.4)); + +// Pan: sposta lo scroll solo se ingrandito +c = make(800, 400); c.onImgLoad(); +c.zPanDown({ clientX: 10, clientY: 10, pointerType: 'mouse', button: 0, pointerId: 1 }); +check('#115 pan a Adatta: ignorato', c._zPan === null && c.zPanning === false); +c.zoomBy(2); +c.$refs.vp.scrollLeft = 50; c.$refs.vp.scrollTop = 40; +c.zPanDown({ clientX: 100, clientY: 100, pointerType: 'mouse', button: 0, pointerId: 1 }); +check('#115 pan avviato: cursore grabbing', c.zPanning === true && c.zCursor() === 'grabbing'); +c.zPanMove({ clientX: 80, clientY: 90 }); +check('#115 pan: scroll segue il puntatore', c.$refs.vp.scrollLeft === 70 && c.$refs.vp.scrollTop === 50, + c.$refs.vp.scrollLeft + ',' + c.$refs.vp.scrollTop); +c.zPanEnd(); +check('#115 pan finito', c._zPan === null && c.zPanning === false); + +process.exit(failures ? 1 : 0); diff --git a/src/frontend/flask_app/tests/test_vision_panel_fit.py b/src/frontend/flask_app/tests/test_vision_panel_fit.py index 7fbb448..19a688e 100644 --- a/src/frontend/flask_app/tests/test_vision_panel_fit.py +++ b/src/frontend/flask_app/tests/test_vision_panel_fit.py @@ -46,6 +46,17 @@ def test_image_is_object_contain_without_h_auto(): assert "object-contain" in canvas # stesso fit dell'img: overlay allineato +def test_zoom_viewport_scrolls_inside_the_box(): + # #115: il riquadro resta overflow-hidden (il pannello non scorre); lo scorrimento e' solo del viewport interno + panel = _panel() + vp = _tag_with(panel, 'x-ref="vp"') + assert "absolute" in vp and "inset-0" in vp + assert "overflow:auto" in vp # :style stringa del viewport + assert 'style="' not in vp.replace(':style="', "") # niente style statico accanto a :style (Alpine lo cancella) + assert "x-show" not in vp # mai x-show e :style stringa sullo stesso elemento + assert "zSize()" in panel # wrapper naturale x zoom + + def test_image_box_and_outcome_column_can_shrink(): panel = _panel() box = _tag_with(panel, 'x-show="shownImage"') diff --git a/src/frontend/flask_app/tests/test_vision_zoom_js.py b/src/frontend/flask_app/tests/test_vision_zoom_js.py new file mode 100644 index 0000000..d9d07e2 --- /dev/null +++ b/src/frontend/flask_app/tests/test_vision_zoom_js.py @@ -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. +""" +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}" + )