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:
@@ -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 <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) {
|
||||
@@ -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 <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 [];
|
||||
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 }) : '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{# #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">
|
||||
{# #115: x-show sul contenitore della barra, mai sul viewport (con :style stringa Alpine lo pesterebbe) #}
|
||||
<div x-show="z.w" class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
|
||||
style="background: rgba(0,0,0,.55); color: #fff;">
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(0.8)" title="{{ _('Riduci') }}">−</button>
|
||||
<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>
|
||||
<tmf-viewer class="block w-full h-full" :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».') }}
|
||||
@@ -1225,6 +1203,8 @@
|
||||
<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>
|
||||
|
||||
@@ -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: 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.overlayLabels.length === 0);
|
||||
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' }]; },
|
||||
};
|
||||
check('#116 senza immagine: nessuna etichetta', c3.overlayLabels.length === 0);
|
||||
c3.z.w = 2000; c3.z.h = 1000;
|
||||
const ls = c3.overlayLabels;
|
||||
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));
|
||||
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 labels riceve rows e overlay', seen.rows.length === 2 && seen.overlay.length === 1);
|
||||
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 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');
|
||||
|
||||
// #121: drawOverlay imposta il viewBox naturale e riempie l'svg con VisionOverlay.svg(overlay, {zoom})
|
||||
let svgArgs = null;
|
||||
ctx.VisionOverlay = { svg(overlay, opts) { svgArgs = { overlay, opts }; return '<circle cx="1" cy="2" r="3"/>'; } };
|
||||
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() {'))));
|
||||
// #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'));
|
||||
@@ -193,17 +175,11 @@ c5.acquire = async function () { this.result = { vision_result_id: 7, measuremen
|
||||
h();
|
||||
check('#120 task-result-reset: saved = false', c5.saved === false);
|
||||
|
||||
// #119: saveImage -> tmfSaveImage(src dell'immagine mostrata, nome del task)
|
||||
let saveArgs = null;
|
||||
ctx.tmfSaveImage = (src, name) => { saveArgs = { src, name }; return Promise.resolve(true); };
|
||||
// #119 #122: saveName = titolo del task (nome proposto da <tmf-viewer save-name>), 'immagine' se manca
|
||||
win.__visionCfg = { taskName: 'Roto3', subtasks: [] };
|
||||
const c9 = ctx.visionExecute();
|
||||
c9.imageSrc = 'blob:abc';
|
||||
const r9 = await c9.saveImage();
|
||||
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);
|
||||
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);
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -11,7 +12,7 @@ const vm = require('vm');
|
||||
const src = fs.readFileSync(
|
||||
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; } } });
|
||||
vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
|
||||
|
||||
@@ -20,106 +21,24 @@ 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) {
|
||||
// Niente piu' stato ne' funzioni di zoom/pan/resize/Salva/overlay nel componente
|
||||
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;
|
||||
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));
|
||||
|
||||
// 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);
|
||||
|
||||
// #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));
|
||||
}
|
||||
// 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);
|
||||
|
||||
@@ -40,20 +40,28 @@ def test_camera_bar_has_grab_and_record_only():
|
||||
assert "onPick(" not in cam
|
||||
|
||||
|
||||
def test_save_button_in_zoom_bar_between_adatta_and_percent():
|
||||
# #119: «Salva» dopo «Adatta», prima della percentuale; script save_image.js incluso
|
||||
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 panel.index("{{ _('Adatta') }}") < panel.index("{{ _('Salva') }}") < panel.index('x-text="zPct()"')
|
||||
assert '@click="saveImage()"' in panel and "{{ _('Salva immagine') }}" in panel
|
||||
assert "js/save_image.js" in SRC
|
||||
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_overlay_is_svg_not_canvas():
|
||||
# #121: svg dentro il wrapper dello zoom, nessun canvas nel pannello
|
||||
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 '<svg x-ref="overlay"' in panel and "<canvas" not in panel
|
||||
assert panel.index('zSize()') < panel.index('<svg x-ref="overlay"') < panel.index('x-for="l in overlayLabels"')
|
||||
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():
|
||||
|
||||
@@ -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,28 +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
|
||||
# #121: l'overlay e' un svg (non piu' un canvas) con viewBox naturale e preserveAspectRatio="none":
|
||||
# riempie il wrapper come l'img (che e' esattamente naturale x zoom), quindi resta allineato
|
||||
overlay = _tag_with(panel, 'x-ref="overlay"')
|
||||
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
|
||||
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_zoom_viewport_scrolls_inside_the_box():
|
||||
# #115: il riquadro resta overflow-hidden (il pannello non scorre); lo scorrimento e' solo del viewport interno
|
||||
def test_no_own_zoom_viewport_in_template():
|
||||
# #122: niente piu' viewport/wrapper di zoom del pannello (#115): li fa il componente
|
||||
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
|
||||
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():
|
||||
@@ -77,12 +71,11 @@ def test_quote_table_shows_nominal_and_tolerances():
|
||||
assert "text-measure-fail" in panel and "text-measure-warning" in panel
|
||||
|
||||
|
||||
def test_overlay_script_loaded_before_vision_execute_and_labels_layer():
|
||||
# #116: vision_overlay.js prima di vision_execute.js; strato etichette dentro il wrapper dello zoom, dopo l'svg
|
||||
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 panel.index('x-ref="overlay"') < panel.index('x-for="l in overlayLabels"')
|
||||
label = _tag_with(panel, 'x-text="l.text"')
|
||||
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
|
||||
assert "$el.labels = viewerLabels" in panel
|
||||
assert 'x-for="l in overlayLabels"' not in panel
|
||||
|
||||
@@ -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 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 subprocess
|
||||
|
||||
Reference in New Issue
Block a user