Pannello visione operatore: barra senza input file con camera, Salva, Registra conta le quote, svg al posto del canvas, NaN

Refs #118 #119 #120 #121

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 21:20:21 +01:00
parent 4485803122
commit 58baed19e2
6 changed files with 287 additions and 51 deletions
@@ -20,9 +20,6 @@
* Extra config: cameraAvailable, modo, checkUrl.
*/
function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found).
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
return {
busy: false,
grabbing: false, // frame requested from the station camera (#72)
@@ -50,6 +47,14 @@ function visionExecute() {
// #97: start on a frame already acquired and analysed; hook visibility and exit.
init() {
// #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera
window.addEventListener('task-result-reset', () => {
this.saved = false;
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; };
@@ -150,6 +155,7 @@ function visionExecute() {
if (!this.error && this.result && this.result.vision_result_id) {
this.preview = false;
this.saved = true;
this._emitRecorded(); // #120: la pagina conta le quote registrate
}
} finally {
this.registering = false;
@@ -186,7 +192,9 @@ function visionExecute() {
return;
}
this.result = body;
// Draw once Alpine has rendered the canvas.
// #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';
@@ -195,6 +203,19 @@ function visionExecute() {
}
},
// #120: misure salvate (risposta di execute) alla pagina: conteggio quote e «Completato»
_emitRecorded() {
const ms = (this.result && this.result.measurements) || [];
window.dispatchEvent(new CustomEvent('vision-recorded', { detail: { measurements: ms } }));
},
// #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
@@ -385,6 +406,11 @@ function visionExecute() {
},
zPanEnd() { this._zPan = null; this.zPanning = false; },
// #118: valore della cella: una quota non misurata (null) e' «NaN», mai Number(null) = 0
fmtValue(v, unit) {
return (v == null || !Number.isFinite(Number(v)) ? 'NaN' : Number(v).toFixed(3)) + ' ' + unit;
},
// #117: tolleranza a 3 decimali, trattino se manca (come x-text="...?.toFixed(3) || '—'" dei calibri, ma 0 resta 0.000)
fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); },
@@ -420,32 +446,14 @@ function visionExecute() {
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
},
// Canvas is sized to the natural image, so overlay coordinates (image px)
// are used as they are and CSS scales the whole thing to the displayed size.
// #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, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
const lw = Math.max(2, cv.width / 400);
g.lineWidth = lw;
g.lineJoin = 'round';
// #116: tutte le forme (rect2, segment, circle, arc...) le disegna vision_overlay.js; il ciclo sotto resta come ripiego
if (typeof VisionOverlay !== 'undefined') {
VisionOverlay.draw(g, (this.result && this.result.overlay) || [], lw);
return;
}
for (const item of ((this.result && this.result.overlay) || [])) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
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 }) : '';
},
};
}