Pannello visione operatore: zoom, overlay e Salva passano a <tmf-viewer>

Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 21:58:18 +01:00
parent 43ef22079d
commit 815d49d699
7 changed files with 84 additions and 348 deletions
@@ -38,13 +38,6 @@ function visionExecute() {
looping: false, // an in-line loop is running looping: false, // an in-line loop is running
gen: 0, // bumped by «Registra»: answers of older requests are dropped 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. // #97: start on a frame already acquired and analysed; hook visibility and exit.
init() { init() {
// #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera // #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.preview = !!this.result;
this.kick(); 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; if (!this.hasCamera) return;
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
const leave = () => { this.leaving = true; }; const leave = () => { this.leaving = true; };
@@ -132,7 +123,6 @@ function visionExecute() {
this.result = body; this.result = body;
this.preview = true; this.preview = true;
this.saved = false; this.saved = false;
this.$nextTick(() => this.drawOverlay());
return true; return true;
} catch (e) { } catch (e) {
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
@@ -194,8 +184,6 @@ function visionExecute() {
this.result = body; this.result = body;
// #120: senza camera il file scelto e' salvato subito (nessun «Registra»): stesso evento // #120: senza camera il file scelto e' salvato subito (nessun «Registra»): stesso evento
if (!this.hasCamera && body.vision_result_id) this._emitRecorded(); if (!this.hasCamera && body.vision_result_id) this._emitRecorded();
// Draw once Alpine has rendered the svg.
this.$nextTick(() => this.drawOverlay());
} catch (e) { } catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
} finally { } finally {
@@ -209,13 +197,6 @@ function visionExecute() {
window.dispatchEvent(new CustomEvent('vision-recorded', { detail: { measurements: ms } })); 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 // Station camera (#72): the Flask proxy answers a PNG, which goes through
// acquire() like a picked file. Errors show as the execute ones do. // 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 // #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 // #122: zoom, pan, resize e Salva li gestisce <tmf-viewer> (static/js/image_viewer.js)
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; },
// #118: valore della cella: una quota non misurata (null) e' «NaN», mai Number(null) = 0 // #118: valore della cella: una quota non misurata (null) e' «NaN», mai Number(null) = 0
fmtValue(v, unit) { 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) // #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); }, fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); },
// #116: etichette «nome valore unita» agganciate alla geometria dello strumento; posizione in % delle // #119 #122: nome proposto da «Salva» (titolo del task) per <tmf-viewer save-name>
// dimensioni naturali dell'immagine, cosi' seguono lo zoom (#115) senza ingrandirsi get saveName() { return (window.__visionCfg || {}).taskName || 'immagine'; },
get overlayLabels() {
// #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 []; 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) => ({ return VisionOverlay.labels(this.rows, this.result.overlay || []).map((l) => ({
key: l.key, key: l.key, x: l.x, y: l.y, text: l.text, cls: this.labelClass(l.status),
text: l.text,
cls: this.labelClass(l.status),
style: 'left:' + (l.x / w * 100) + '%;top:' + (l.y / h * 100) + '%;transform:translate(8px,-50%)',
})); }));
}, },
@@ -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'; 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)]'; 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 }) : '';
},
}; };
} }
@@ -579,34 +579,12 @@
<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> <div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
{# #96: riquadro flex-1 min-h-0; #115: dentro, barra zoom in overlay e viewport scorrevole con il wrapper (naturale x zoom) che contiene img e 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, <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"> <div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
{# #115: x-show sul contenitore della barra, mai sul viewport (con :style stringa Alpine lo pesterebbe) #} <tmf-viewer class="block w-full h-full" :src="shownImage" :save-name="saveName"
<div x-show="z.w" class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5" label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
style="background: rgba(0,0,0,.55); color: #fff;"> title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(0.8)" title="{{ _('Riduci') }}">&minus;</button> x-effect="$el.overlay = (result && result.overlay) || []; $el.labels = viewerLabels"></tmf-viewer>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zFit()">{{ _('Adatta') }}</button>
{# #119: salva il frame grezzo PNG (senza disegni); la barra compare solo con un'immagine (x-show="z.w") #}
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="saveImage()" title="{{ _('Salva immagine') }}">{{ _('Salva') }}</button>
<span class="text-xs px-1" x-text="zPct()"></span>
</div>
{# #115: viewport = tutto il riquadro; solo :style stringa (uno style statico verrebbe cancellato da Alpine) #}
<div x-ref="vp" class="absolute inset-0" @wheel="zWheel($event)"
@pointerdown="zPanDown($event)" @pointermove="zPanMove($event)"
@pointerup="zPanEnd()" @pointercancel="zPanEnd()"
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;cursor:' + zCursor()">
<div class="relative" :style="'margin:auto;flex:none;' + zSize()">
<img x-ref="img" :src="shownImage" @load="onImgLoad()" alt="" draggable="false" class="absolute inset-0 w-full h-full object-contain">
{# #121: overlay SVG al posto del canvas: viewBox = dimensioni naturali (le imposta drawOverlay), tratto costante a ogni zoom #}
<svg x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none"></svg>
{# #116: etichette quota in HTML sopra l'svg (posizione in %, dimensione costante a ogni zoom); solo :style stringa #}
<template x-for="l in overlayLabels" :key="l.key">
<div class="absolute pointer-events-none whitespace-nowrap text-xs font-bold px-1.5 py-0.5 rounded shadow"
:class="l.cls" :style="l.style" x-text="l.text"></div>
</template>
</div>
</div>
</div> </div>
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]"> <div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }} {{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
@@ -1225,6 +1203,8 @@
<script src="{{ url_for('static', filename='js/vision_overlay.js') }}"></script> <script src="{{ url_for('static', filename='js/vision_overlay.js') }}"></script>
{# #119: «Salva» immagine (frame grezzo PNG) #} {# #119: «Salva» immagine (frame grezzo PNG) #}
<script src="{{ url_for('static', filename='js/save_image.js') }}"></script> <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> <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) #} {# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
<script> <script>
@@ -93,46 +93,28 @@ 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: 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 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 label invariato (#114)', r3[0].label === '1. Larghezza', r3[0].label);
check('#116 senza VisionOverlay: nessuna etichetta', c3.overlayLabels.length === 0); check('#116 senza VisionOverlay: nessuna etichetta', c3.viewerLabels.length === 0);
let seen = null; let seen = null;
ctx.VisionOverlay = { ctx.VisionOverlay = {
labels(rows, overlay) { seen = { rows, overlay }; return [{ key: 30, x: 500, y: 250, text: 'Larghezza 1.500 mm', status: 'pass' }]; }, labels(rows, overlay) { seen = { rows, overlay }; return [{ key: 30, x: 500, y: 250, text: 'Larghezza 1.500 mm', status: 'pass' }]; },
}; };
check('#116 senza immagine: nessuna etichetta', c3.overlayLabels.length === 0); const ls = c3.viewerLabels;
c3.z.w = 2000; c3.z.h = 1000;
const ls = c3.overlayLabels;
check('#116 una etichetta', ls.length === 1 && ls[0].key === 30 && ls[0].text === 'Larghezza 1.500 mm', JSON.stringify(ls)); check('#116 una etichetta', ls.length === 1 && ls[0].key === 30 && ls[0].text === 'Larghezza 1.500 mm', JSON.stringify(ls));
check('#116 posizione in percentuale', ls[0].style === 'left:25%;top:25%;transform:translate(8px,-50%)', ls[0].style); // #122: posizione in px logici dell'immagine (la percentuale la calcola <tmf-viewer>), niente style
check('#122 posizione in px logici', ls[0].x === 500 && ls[0].y === 250 && ls[0].style === undefined, JSON.stringify(ls[0]));
check('#116 classe da esito', ls[0].cls === 'bg-green-600 text-white', ls[0].cls); check('#116 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); check('#116 labels riceve rows e overlay', seen.rows.length === 2 && seen.overlay.length === 1);
c3.result = null; c3.result = null;
check('#116 senza risultato: nessuna etichetta', c3.overlayLabels.length === 0); check('#116 senza risultato: nessuna etichetta', c3.viewerLabels.length === 0);
check('#116 labelClass pass', c3.labelClass('pass') === 'bg-green-600 text-white'); check('#116 labelClass pass', c3.labelClass('pass') === 'bg-green-600 text-white');
check('#116 labelClass warning', c3.labelClass('warning') === 'bg-amber-400 text-black'); 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 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'); check('#116 labelClass altro', c3.labelClass('boh') === 'bg-gray-700 text-white' && c3.labelClass(undefined) === 'bg-gray-700 text-white');
// #121: drawOverlay imposta il viewBox naturale e riempie l'svg con VisionOverlay.svg(overlay, {zoom}) // #122: zoom, overlay SVG e Salva sono di <tmf-viewer>: il componente non li ha piu'
let svgArgs = null; check('#122 niente drawOverlay/saveImage/overlayLabels/z', c3.drawOverlay === undefined && c3.saveImage === undefined && c3.overlayLabels === undefined && c3.z === undefined);
ctx.VisionOverlay = { svg(overlay, opts) { svgArgs = { overlay, opts }; return '<circle cx="1" cy="2" r="3"/>'; } }; check('#122 niente codice canvas', !/getContext|clearRect/.test(src));
const attrs = {};
const sv = { innerHTML: 'vecchio', setAttribute(k, v) { attrs[k] = v; } };
c3.$refs = { img: { naturalWidth: 2000, naturalHeight: 1000 }, overlay: sv };
c3.result = { overlay: [{ type: 'circle' }] };
c3.z.zoom = 0.5;
c3.drawOverlay();
check('#121 viewBox = dimensioni naturali', attrs.viewBox === '0 0 2000 1000', attrs.viewBox);
check('#121 innerHTML = VisionOverlay.svg', sv.innerHTML === '<circle cx="1" cy="2" r="3"/>', sv.innerHTML);
check('#121 svg riceve overlay e zoom', svgArgs.overlay.length === 1 && svgArgs.opts.zoom === 0.5, JSON.stringify(svgArgs.opts));
c3.result = null;
c3.drawOverlay();
check('#121 senza risultato: svg vuoto', sv.innerHTML === '');
ctx.VisionOverlay = {};
c3.result = { overlay: [{ type: 'circle' }] };
c3.drawOverlay();
check('#121 senza VisionOverlay.svg: svg vuoto, nessun errore', sv.innerHTML === '');
check('#121 niente codice canvas', !/getContext|clearRect|\.width\s*=/.test(src.slice(src.indexOf('drawOverlay() {'))));
// #118: quota non misurata (value null) = NaN, mai Number(null) = 0 // #118: quota non misurata (value null) = NaN, mai Number(null) = 0
check('#118 fmtValue(null) = NaN', c3.fmtValue(null, 'mm') === 'NaN mm', c3.fmtValue(null, 'mm')); check('#118 fmtValue(null) = NaN', c3.fmtValue(null, 'mm') === 'NaN mm', c3.fmtValue(null, 'mm'));
@@ -193,17 +175,11 @@ c5.acquire = async function () { this.result = { vision_result_id: 7, measuremen
h(); h();
check('#120 task-result-reset: saved = false', c5.saved === false); check('#120 task-result-reset: saved = false', c5.saved === false);
// #119: saveImage -> tmfSaveImage(src dell'immagine mostrata, nome del task) // #119 #122: saveName = titolo del task (nome proposto da <tmf-viewer save-name>), 'immagine' se manca
let saveArgs = null;
ctx.tmfSaveImage = (src, name) => { saveArgs = { src, name }; return Promise.resolve(true); };
win.__visionCfg = { taskName: 'Roto3', subtasks: [] }; win.__visionCfg = { taskName: 'Roto3', subtasks: [] };
const c9 = ctx.visionExecute(); check('#122 saveName = nome del task', ctx.visionExecute().saveName === 'Roto3');
c9.imageSrc = 'blob:abc'; win.__visionCfg = { subtasks: [] };
const r9 = await c9.saveImage(); check('#122 saveName di riserva', ctx.visionExecute().saveName === 'immagine');
check('#119 saveImage: tmfSaveImage(src, nome task)', r9 === true && saveArgs.src === 'blob:abc' && saveArgs.name === 'Roto3', JSON.stringify(saveArgs));
c9.imageSrc = '';
saveArgs = null;
check('#119 saveImage senza immagine: niente', (await c9.saveImage()) === false && saveArgs === null);
process.exit(failures ? 1 : 0); process.exit(failures ? 1 : 0);
})(); })();
@@ -1,5 +1,6 @@
/** /**
* #115: zoom, Adatta e pan del riquadro immagine del pannello visione, con plain node. * #122: lo zoom del pannello visione non e' piu' in vision_execute.js ma in <tmf-viewer> (image_viewer.js).
* Qui: il componente passa al viewer solo le etichette in px logici (viewerLabels) e non ha piu' codice di zoom.
* *
* Run from src/frontend/flask_app: node tests/js/test_vision_zoom.js * Run from src/frontend/flask_app: node tests/js/test_vision_zoom.js
*/ */
@@ -11,7 +12,7 @@ const vm = require('vm');
const src = fs.readFileSync( const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8', path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8',
); );
const win = {}; const win = { __visionCfg: { subtasks: [{ id: 1, description: 'Larg', unit: 'mm' }] } };
const ctx = vm.createContext({ window: win, document: { addEventListener() {}, querySelector() { return null; } } }); const ctx = vm.createContext({ window: win, document: { addEventListener() {}, querySelector() { return null; } } });
vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx); vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
@@ -20,106 +21,24 @@ function check(label, cond, extra) {
if (cond) console.log('ok - ' + label); if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); } 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* // Niente piu' stato ne' funzioni di zoom/pan/resize/Salva/overlay nel componente
function make(cw, ch) {
const c = ctx.visionExecute(); const c = ctx.visionExecute();
const vp = { clientWidth: cw, clientHeight: ch, scrollLeft: 0, scrollTop: 0, setPointerCapture() {} }; for (const k of ['z', 'zoomBy', 'zFit', 'zWheel', 'zCanPan', 'zCursor', 'zPct', 'zSize', 'zPanDown', 'zPanMove', 'zPanEnd',
const img = { naturalWidth: 2000, naturalHeight: 1000 }; 'onImgLoad', 'drawOverlay', 'saveImage', 'overlayLabels']) {
c.$refs = { vp, img, overlay: null }; check('#122 componente senza ' + k, c[k] === undefined);
c.$nextTick = (fn) => fn();
return c;
} }
check('#122 sorgente senza $watch di zoom ne ResizeObserver', !/z\.zoom|ResizeObserver|tmfSaveImage/.test(src));
// Prima del load: il wrapper riempie il riquadro // viewerLabels: {key, x, y, text, cls} in px logici, classe dall'esito; vuote senza risultato o senza VisionOverlay
let c = make(800, 400); check('#122 senza VisionOverlay: nessuna etichetta', (c.result = { measurements: [] }, c.viewerLabels.length === 0));
check('#115 zSize prima del load = 100 %', c.zSize() === 'width:100%;height:100%', c.zSize()); win.VisionOverlay = ctx.VisionOverlay = {
check('#115 zoomBy senza immagine: ignorato', (c.zoomBy(2), c.z.zoom === 1)); labels() { return [{ key: 1, x: 500, y: 250, text: 'Larg 1.500 mm', status: 'fail' }]; },
};
// Load: fit = min(800/2000, 400/1000) = 0.4 const ls = c.viewerLabels;
c.onImgLoad(); 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('#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('#122 viewerLabels: classe da esito', ls[0].cls === 'bg-red-600 text-white', ls[0].cls);
check('#115 zSize = naturale x zoom', c.zSize() === 'width:800px;height:400px', c.zSize()); c.result = null;
check('#115 zPct = 40%', c.zPct() === '40%', c.zPct()); check('#122 senza risultato: nessuna etichetta', c.viewerLabels.length === 0);
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);
// #121: init registra un watcher su z.zoom che ridisegna l'overlay SVG (spessore e croci costanti a ogni zoom)
{
const w = make(800, 400);
const watched = {};
w.$watch = (expr, fn) => { watched[expr] = fn; };
win.addEventListener = () => {}; // init ascolta task-result-reset su window
let redraws = 0;
w.drawOverlay = () => { redraws++; };
w.init();
check('#121 init: watcher su z.zoom', typeof watched['z.zoom'] === 'function');
watched['z.zoom']();
check('#121 cambio zoom: overlay ridisegnato', redraws === 1, String(redraws));
}
process.exit(failures ? 1 : 0); process.exit(failures ? 1 : 0);
@@ -40,20 +40,28 @@ def test_camera_bar_has_grab_and_record_only():
assert "onPick(" not in cam assert "onPick(" not in cam
def test_save_button_in_zoom_bar_between_adatta_and_percent(): def test_save_button_is_tmf_viewer_with_texts():
# #119: «Salva» dopo «Adatta», prima della percentuale; script save_image.js incluso # #119 #122: «Salva» e' del componente <tmf-viewer> (barra: Riduci, Ingrandisci, Adatta, Salva, %); il template gli passa nome e testi
panel = _panel() panel = _panel()
assert panel.index("{{ _('Adatta') }}") < panel.index("{{ _('Salva') }}") < panel.index('x-text="zPct()"') assert "<tmf-viewer" in panel and ':src="shownImage"' in panel and ':save-name="saveName"' in panel
assert '@click="saveImage()"' in panel and "{{ _('Salva immagine') }}" in panel for t in ("Adatta", "Salva", "Ingrandisci", "Riduci", "Salva immagine"):
assert "js/save_image.js" in SRC assert "{{ _('%s') }}" % t in panel, t
assert "saveImage()" not in panel and "zPct()" not in panel
assert "taskName:" in SRC assert "taskName:" in SRC
def test_overlay_is_svg_not_canvas(): def test_viewer_scripts_order():
# #121: svg dentro il wrapper dello zoom, nessun canvas nel pannello # #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() panel = _panel()
assert '<svg x-ref="overlay"' in panel and "<canvas" not in panel assert "<canvas" not in panel
assert panel.index('zSize()') < panel.index('<svg x-ref="overlay"') < panel.index('x-for="l in overlayLabels"') 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(): def test_value_cell_shows_nan_for_null():
@@ -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 Controllo sulle classi del template: pannello overflow-hidden, riquadro flex-1 min-h-0 che contiene
object-contain sovrapposti, nessun h-auto sull'immagine (era la causa dello scorrimento). <tmf-viewer> a tutta altezza (#122: zoom, img e overlay stanno dentro il componente).
""" """
from pathlib import Path from pathlib import Path
@@ -37,28 +37,22 @@ def test_panel_does_not_scroll():
assert "overflow-auto" not in tag 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() panel = _panel()
img = _tag_with(panel, 'x-ref="img"') viewer = _tag_with(panel, "tmf-viewer class")
assert "object-contain" in img assert "block" in viewer and "w-full" in viewer and "h-full" in viewer
assert "h-auto" not in img assert ':src="shownImage"' in viewer
# #121: l'overlay e' un svg (non piu' un canvas) con viewBox naturale e preserveAspectRatio="none": assert "x-show" not in viewer # mai x-show e :style stringa sullo stesso elemento: x-show sta sul riquadro
# riempie il wrapper come l'img (che e' esattamente naturale x zoom), quindi resta allineato assert 'x-ref="img"' not in panel and 'x-ref="overlay"' not in panel and "<canvas" not in panel
overlay = _tag_with(panel, 'x-ref="overlay"') assert "<img" not in panel and "<svg" not in panel
assert overlay.startswith("<svg") and "<canvas" not in panel
assert 'preserveAspectRatio="none"' in overlay and "pointer-events-none" in overlay
assert "absolute" in overlay and "inset-0" in overlay and "w-full" in overlay and "h-full" in overlay
def test_zoom_viewport_scrolls_inside_the_box(): def test_no_own_zoom_viewport_in_template():
# #115: il riquadro resta overflow-hidden (il pannello non scorre); lo scorrimento e' solo del viewport interno # #122: niente piu' viewport/wrapper di zoom del pannello (#115): li fa il componente
panel = _panel() panel = _panel()
vp = _tag_with(panel, 'x-ref="vp"') assert 'x-ref="vp"' not in panel
assert "absolute" in vp and "inset-0" in vp assert "zSize()" not in panel and "zoomBy(" not in panel and "zFit()" not in panel
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(): def test_image_box_and_outcome_column_can_shrink():
@@ -77,12 +71,11 @@ def test_quote_table_shows_nominal_and_tolerances():
assert "text-measure-fail" in panel and "text-measure-warning" in panel assert "text-measure-fail" in panel and "text-measure-warning" in panel
def test_overlay_script_loaded_before_vision_execute_and_labels_layer(): def test_overlay_script_loaded_before_vision_execute_and_viewer():
# #116: vision_overlay.js prima di vision_execute.js; strato etichette dentro il wrapper dello zoom, dopo l'svg # #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") 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/vision_execute.js")
assert src.index("js/vision_overlay.js") < src.index("js/image_viewer.js")
panel = _panel() panel = _panel()
assert panel.index('x-ref="overlay"') < panel.index('x-for="l in overlayLabels"') assert "$el.labels = viewerLabels" in panel
label = _tag_with(panel, 'x-text="l.text"') assert 'x-for="l in overlayLabels"' not in panel
assert ':style="l.style"' in label and "pointer-events-none" in label
assert 'style="' not in label.replace(':style="', "") # niente style statico accanto a :style
@@ -4,7 +4,7 @@ Same thin pytest wrapper pattern as test_vision_rows_js.py: shells out to
``node`` and asserts the script's own exit code. Skips (does not fail) when ``node`` and asserts the script's own exit code. Skips (does not fail) when
node is not installed. node is not installed.
Covers gitea:Adriano/TieMeasureFlow#115. Covers gitea:Adriano/TieMeasureFlow#115 #122 (dal #122 il test JS verifica che lo zoom non sia piu' nel componente).
""" """
import shutil import shutil
import subprocess import subprocess