Compare commits

..

5 Commits

10 changed files with 1355 additions and 9 deletions
@@ -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;
@@ -254,15 +261,157 @@ function visionExecute() {
return ((this.result && this.result.measurements) || []).map((m) => {
const st = subs.find((s) => s.id === m.subtask_id) || {};
return {
// #116: chiave stabile della quota, nome senza numero di marker e uscita ricetta, per le etichette sull'immagine
key: m.subtask_id,
name: st.description || st.measurement_type || '#' + m.subtask_id,
vision_output: st.vision_output,
// #114: il nome della quota e' description (prima: measurement_type/name, che per le quote DXF e' vuoto -> '#15')
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.description || st.measurement_type || '#' + m.subtask_id),
value: m.value,
unit: st.unit || 'mm',
status: m.pass_fail,
// #117: nominale e tolleranze della quota (come la schermata dei calibri); null se mancano
nominal: st.nominal, ltl: st.ltl, lwl: st.lwl, uwl: st.uwl, utl: st.utl,
};
});
},
// #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; },
// #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() {
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%)',
}));
},
// #116: colori dell'etichetta per esito
labelClass(s) {
if (s === 'pass') return 'bg-green-600 text-white';
if (s === 'warning') return 'bg-amber-400 text-black';
if (s === 'fail') return 'bg-red-600 text-white';
return 'bg-gray-700 text-white';
},
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';
@@ -280,8 +429,14 @@ function visionExecute() {
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
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;
@@ -0,0 +1,192 @@
/**
* #116: disegno dell'overlay del check visione sul canvas del pannello operatore e
* ancoraggio delle etichette delle quote alla geometria del loro strumento.
*
* Script classico (niente import/export): lo carica task_execute.html prima di
* vision_execute.js, che lo usa via window.VisionOverlay. In node (test) si carica
* con require.
*
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
* labels(rows, overlay) -> [{key, x, y, text, status}].
* Convenzioni (vs-tools): row = y, col = x; angoli in gradi positivi antiorari a video.
* Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma)
* e frontend/src/editor/misure.js (ancoraOverlay).
*/
var VisionOverlay = (function () {
'use strict';
// #116: colore per ruolo, uguale a quello dello Studio
var COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
var COLOR_OTHER = '#ef4444';
function rad(d) { return (d * Math.PI) / 180; }
function num(v) { return typeof v === 'number' && isFinite(v); }
function allNum(o) {
for (var i = 1; i < arguments.length; i++) {
if (!o || !num(o[arguments[i]])) return false;
}
return true;
}
// #116: serie rows/cols valida = entrambe array, stessa lunghezza, non vuote, tutti numeri finiti
function validSeries(o) {
if (!o || !Array.isArray(o.rows) || !Array.isArray(o.cols)) return false;
if (o.rows.length === 0 || o.rows.length !== o.cols.length) return false;
for (var i = 0; i < o.rows.length; i++) {
if (!num(o.rows[i]) || !num(o.cols[i])) return false;
}
return true;
}
// #116: i 4 vertici del rect2 (x = col, y = row), come verticiRect2 dello Studio
function rect2Vertices(o) {
var a = rad(o.phi_deg);
var u = [Math.cos(a), -Math.sin(a)];
var v = [-Math.sin(a), -Math.cos(a)]; // u ruotato di +90 antiorario
return [[1, 1], [1, -1], [-1, -1], [-1, 1]].map(function (st) {
return [
o.col + st[0] * o.length1 * u[0] + st[1] * o.length2 * v[0],
o.row + st[0] * o.length1 * u[1] + st[1] * o.length2 * v[1],
];
});
}
// #116: disegna una forma; true se disegnata, false se saltata (tipo ignoto o dati malformati)
function drawShape(g, o, lw) {
var type = o && o.type;
var i;
switch (type) {
case 'rect2': {
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return false;
var vs = rect2Vertices(o);
g.beginPath();
g.moveTo(vs[0][0], vs[0][1]);
for (i = 1; i < vs.length; i++) g.lineTo(vs[i][0], vs[i][1]);
g.closePath();
g.stroke();
return true;
}
case 'segment':
case 'line':
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return false;
g.beginPath();
g.moveTo(o.col1, o.row1);
g.lineTo(o.col2, o.row2);
g.stroke();
return true;
case 'circle':
if (!allNum(o, 'row', 'col', 'radius')) return false;
g.beginPath();
g.arc(o.col, o.row, o.radius, 0, 2 * Math.PI);
g.stroke();
return true;
case 'arc':
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return false;
g.beginPath();
// #116: y canvas verso il basso, quindi angoli negati; extent > 0 = antiorario a video
g.arc(o.col, o.row, o.radius, -rad(o.start_deg), -rad(o.start_deg + o.extent_deg), o.extent_deg > 0);
g.stroke();
return true;
case 'points': {
if (!validSeries(o)) return false;
var m = Math.max(3, lw * 2); // semi-lato della croce
g.beginPath();
for (i = 0; i < o.rows.length; i++) {
g.moveTo(o.cols[i] - m, o.rows[i]);
g.lineTo(o.cols[i] + m, o.rows[i]);
g.moveTo(o.cols[i], o.rows[i] - m);
g.lineTo(o.cols[i], o.rows[i] + m);
}
g.stroke();
return true;
}
case 'contour':
if (!validSeries(o)) return false;
g.beginPath();
g.moveTo(o.cols[0], o.rows[0]);
for (i = 1; i < o.rows.length; i++) g.lineTo(o.cols[i], o.rows[i]);
g.stroke(); // polilinea aperta, come nello Studio
return true;
default:
return false;
}
}
function draw(g, overlay, lw) {
var res = { drawn: 0, skipped: 0 };
var items = Array.isArray(overlay) ? overlay : [];
for (var k = 0; k < items.length; k++) {
var o = items[k];
var role = o && o.role;
var isRoi = role === 'roi';
g.strokeStyle = COLORS[role] || COLOR_OTHER;
g.lineWidth = isRoi ? lw * 0.75 : lw;
g.setLineDash(isRoi ? [lw * 3, lw * 2] : []); // roi tratteggiata
var ok = false;
try { ok = drawShape(g, o, lw); } catch (e) { ok = false; } // un elemento rotto non ferma gli altri
if (ok) res.drawn++; else res.skipped++;
}
g.setLineDash([]);
return res;
}
// #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio
function anchor(o) {
switch (o && o.type) {
case 'segment':
case 'line':
return allNum(o, 'row1', 'col1', 'row2', 'col2')
? { x: (o.col1 + o.col2) / 2, y: (o.row1 + o.row2) / 2 } : null;
case 'circle':
return allNum(o, 'row', 'col', 'radius') ? { x: o.col + o.radius, y: o.row } : null; // punto a destra
case 'arc': {
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
var a = rad(o.start_deg + o.extent_deg / 2); // punto medio dell'arco
return { x: o.col + o.radius * Math.cos(a), y: o.row - o.radius * Math.sin(a) };
}
case 'points':
return Array.isArray(o.rows) && Array.isArray(o.cols) && num(o.rows[0]) && num(o.cols[0])
? { x: o.cols[0], y: o.rows[0] } : null;
case 'rect2':
return allNum(o, 'row', 'col') ? { x: o.col, y: o.row } : null;
case 'contour': {
if (!Array.isArray(o.rows) || !Array.isArray(o.cols)) return null;
var i = Math.floor(Math.min(o.rows.length, o.cols.length) / 2);
return num(o.rows[i]) && num(o.cols[i]) ? { x: o.cols[i], y: o.rows[i] } : null;
}
default:
return null;
}
}
// #116: un'etichetta per riga con vision_output 'tool_id.proprieta' che ha un overlay ancorabile
function labels(rows, overlay) {
var out = [];
var items = Array.isArray(overlay) ? overlay : [];
(rows || []).forEach(function (r) {
if (!r || typeof r.vision_output !== 'string' || r.vision_output.indexOf('.') < 0) return;
var toolId = r.vision_output.split('.')[0];
var mine = items.filter(function (o) { return o && o.tool_id === toolId; });
// prima gli elementi 'found', poi qualsiasi altro
var order = mine.filter(function (o) { return o.role === 'found'; })
.concat(mine.filter(function (o) { return o.role !== 'found'; }));
var pt = null;
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
if (!pt) return;
var v = Number(r.value);
if (!isFinite(v)) return;
out.push({
key: r.key,
x: pt.x,
y: pt.y,
text: r.name + ' ' + v.toFixed(3) + ' ' + (r.unit || ''),
status: r.status,
});
});
return out;
}
return { draw: draw, anchor: anchor, labels: labels };
})();
if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay;
if (typeof module !== 'undefined' && module.exports) module.exports = VisionOverlay;
@@ -576,10 +576,31 @@
<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; 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 #}
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="absolute inset-0 w-full h-full object-contain">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
{# #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') }}">&minus;</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>
<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">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
{# #116: etichette quota in HTML sopra il canvas (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 x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
@@ -593,12 +614,28 @@
</div>
<table class="w-full text-sm" x-show="rows.length">
{# #117: due righe per quota (esito, poi nominale e tolleranze come nella schermata dei calibri); un solo tbody per quota perche' x-for vuole un'unica radice #}
<template x-for="r in rows" :key="r.label">
<tr>
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
</tr>
<tbody>
<tr>
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
</tr>
<tr class="border-b border-[var(--border-color)]">
<td colspan="3" class="pb-1.5 text-[11px] font-mono">
<span class="text-primary font-semibold">{{ _('Nom') }} <span x-text="fmtTol(r.nominal)"></span></span>
<span class="text-measure-fail ml-2">LTL <span x-text="fmtTol(r.ltl)"></span></span>
<span class="text-measure-fail ml-1">UTL <span x-text="fmtTol(r.utl)"></span></span>
<template x-if="r.lwl != null || r.uwl != null">
<span>
<span class="text-measure-warning ml-2">LWL <span x-text="fmtTol(r.lwl)"></span></span>
<span class="text-measure-warning ml-1">UWL <span x-text="fmtTol(r.uwl)"></span></span>
</span>
</template>
</td>
</tr>
</tbody>
</template>
</table>
@@ -1176,6 +1213,8 @@
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
};
</script>
{# #116: disegno overlay ed etichette quote; prima di vision_execute.js, che lo usa se presente #}
<script src="{{ url_for('static', filename='js/vision_overlay.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>
@@ -0,0 +1,448 @@
{
"outcome": "pass",
"overlay": [
{
"type": "points",
"role": "found",
"rows": [
2218.926540676052
],
"cols": [
2371.08496101821
],
"tool_id": "blob"
},
{
"type": "segment",
"role": "found",
"row1": 2218.926540676052,
"col1": 2371.08496101821,
"row2": 2268.925168990387,
"col2": 2371.4553212279457,
"tool_id": "blob"
},
{
"type": "contour",
"role": "found",
"rows": [
2469.0093822012514,
2467.8907055854274,
1969.1995500932737,
1970.3182267090972,
2469.0093822012514
],
"cols": [
2297.7334028311207,
2448.7547459672996,
2445.0607374080982,
2294.0393942719193,
2297.7334028311207
],
"tool_id": "blob"
},
{
"type": "rect2",
"role": "roi",
"row": 2250.0000476834334,
"col": 2296.999963957319,
"phi_deg": -89.99999458063853,
"length1": 159.9999999999998,
"length2": 20.0,
"tool_id": "lato_sx"
},
{
"type": "segment",
"role": "found",
"row1": 2409.997184964675,
"col1": 2297.344751969946,
"row2": 2089.9983738509072,
"col2": 2295.147617847124,
"tool_id": "lato_sx"
},
{
"type": "rect2",
"role": "roi",
"row": 2250.000033590164,
"col": 2445.999963957318,
"phi_deg": -89.99999458063853,
"length1": 159.9999999999998,
"length2": 20.0,
"tool_id": "lato_dx"
},
{
"type": "segment",
"role": "found",
"row1": 2410.000338325413,
"col1": 2446.9546542059475,
"row2": 2090.0001187672883,
"col2": 2444.590522414638,
"tool_id": "lato_dx"
},
{
"type": "rect2",
"role": "roi",
"row": 1970.0000406368001,
"col": 2371.4999374733225,
"phi_deg": 5.419361327409989e-06,
"length1": 36.499999999999936,
"length2": 15.0,
"tool_id": "lato_alto"
},
{
"type": "segment",
"role": "found",
"row1": 1969.7270815449738,
"col1": 2335.0012415021283,
"row2": 1969.9815342357629,
"col2": 2408.0075386065682,
"tool_id": "lato_alto"
},
{
"type": "rect2",
"role": "roi",
"row": 2466.000040636798,
"col": 2371.4999843878295,
"phi_deg": 5.419361327409989e-06,
"length1": 36.499999999999936,
"length2": 15.0,
"tool_id": "lato_basso"
},
{
"type": "segment",
"role": "found",
"row1": 2466.9468485672455,
"col1": 2335.0088484836465,
"row2": 2465.293164536675,
"col2": 2407.9979613716796,
"tool_id": "lato_basso"
},
{
"type": "arc",
"role": "roi",
"row": 2015.6000435878736,
"col": 2340.2999417864303,
"radius": 46.0,
"start_deg": 95.00000541936147,
"extent_deg": 80.0,
"tool_id": "arco_tl"
},
{
"type": "arc",
"role": "found",
"row": 2014.0729365101115,
"col": 2338.325339664551,
"radius": 43.69800114451398,
"start_deg": 92.61763130401864,
"extent_deg": 84.15103825789168,
"tool_id": "arco_tl"
},
{
"type": "arc",
"role": "roi",
"row": 2016.1000381586543,
"col": 2397.699941833723,
"radius": 46.0,
"start_deg": 5.000005419361472,
"extent_deg": 80.0,
"tool_id": "arco_tr"
},
{
"type": "arc",
"role": "found",
"row": 2014.7155277666257,
"col": 2400.767078642102,
"radius": 43.26606749223183,
"start_deg": 3.5348692687035217,
"extent_deg": 85.33536916618716,
"tool_id": "arco_tr"
},
{
"type": "arc",
"role": "roi",
"row": 2418.4000378086853,
"col": 2401.3999798855507,
"radius": 46.0,
"start_deg": 275.00000541936146,
"extent_deg": 80.0,
"tool_id": "arco_br"
},
{
"type": "arc",
"role": "found",
"row": 2420.300713032017,
"col": 2404.4253040181898,
"radius": 42.604285715934104,
"start_deg": -76.0934883340429,
"extent_deg": 73.28101449433917,
"tool_id": "arco_br"
},
{
"type": "arc",
"role": "roi",
"row": 2421.8000432662807,
"col": 2343.699980207142,
"radius": 46.0,
"start_deg": 185.00000541936146,
"extent_deg": 80.0,
"tool_id": "arco_bl"
},
{
"type": "arc",
"role": "found",
"row": 2423.3935764712314,
"col": 2338.8006257209418,
"radius": 41.362918221221754,
"start_deg": -176.60049573516702,
"extent_deg": 77.4552373110766,
"tool_id": "arco_bl"
},
{
"type": "segment",
"role": "found",
"row1": 2250.0002285463506,
"col1": 2445.772588310293,
"row2": 2251.0268387327387,
"col2": 2296.253250501527,
"tool_id": "larghezza"
},
{
"type": "segment",
"role": "found",
"row1": 2466.12000655196,
"col1": 2371.503404927663,
"row2": 1969.8603328620893,
"col2": 2373.2330448944704,
"tool_id": "lunghezza"
},
{
"type": "circle",
"role": "found",
"row": 2014.0729365101115,
"col": 2338.325339664551,
"radius": 43.69800114451398,
"tool_id": "R_tl"
},
{
"type": "circle",
"role": "found",
"row": 2014.7155277666257,
"col": 2400.767078642102,
"radius": 43.26606749223183,
"tool_id": "R_tr"
},
{
"type": "circle",
"role": "found",
"row": 2420.300713032017,
"col": 2404.4253040181898,
"radius": 42.604285715934104,
"tool_id": "R_br"
},
{
"type": "circle",
"role": "found",
"row": 2423.3935764712314,
"col": 2338.8006257209418,
"radius": 41.362918221221754,
"tool_id": "R_bl"
}
],
"failures": [],
"measurements": [
{
"subtask_id": 15,
"value": 1.6682265730716714,
"pass_fail": "pass"
},
{
"subtask_id": 16,
"value": 5.536802808714362,
"pass_fail": "pass"
},
{
"subtask_id": 17,
"value": 0.48753859876934247,
"pass_fail": "pass"
},
{
"subtask_id": 18,
"value": 0.4827195150108305,
"pass_fail": "pass"
},
{
"subtask_id": 19,
"value": 0.4753360157326768,
"pass_fail": "pass"
},
{
"subtask_id": 20,
"value": 0.4614860785941711,
"pass_fail": "pass"
},
{
"subtask_id": 21,
"value": 0.004819083758511997,
"pass_fail": "pass"
},
{
"subtask_id": 22,
"value": 0.012202583036665687,
"pass_fail": "pass"
},
{
"subtask_id": 23,
"value": 0.026052520175171373,
"pass_fail": "pass"
},
{
"subtask_id": 24,
"value": 0.00738349927815369,
"pass_fail": "pass"
},
{
"subtask_id": 25,
"value": 0.021233436416659375,
"pass_fail": "pass"
},
{
"subtask_id": 26,
"value": 0.013849937138505686,
"pass_fail": "pass"
}
],
"calibration": "ok",
"_subtasks": [
{
"id": 15,
"marker_number": 1,
"description": "Larghezza",
"measurement_type": null,
"unit": "mm",
"vision_output": "larghezza.value",
"nominal": 1.668,
"ltl": null,
"utl": null
},
{
"id": 16,
"marker_number": 2,
"description": "Lunghezza",
"measurement_type": null,
"unit": "mm",
"vision_output": "lunghezza.value",
"nominal": 5.537,
"ltl": null,
"utl": null
},
{
"id": 17,
"marker_number": 3,
"description": "R alto-sinistra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_tl.value",
"nominal": 0.488,
"ltl": null,
"utl": null
},
{
"id": 18,
"marker_number": 4,
"description": "R alto-destra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_tr.value",
"nominal": 0.483,
"ltl": null,
"utl": null
},
{
"id": 19,
"marker_number": 5,
"description": "R basso-destra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_br.value",
"nominal": 0.475,
"ltl": null,
"utl": null
},
{
"id": 20,
"marker_number": 6,
"description": "R basso-sinistra",
"measurement_type": null,
"unit": "mm",
"vision_output": "R_bl.value",
"nominal": 0.464,
"ltl": null,
"utl": null
},
{
"id": 21,
"marker_number": 7,
"description": "Diff. R alto-sx / alto-dx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tl_tr.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 22,
"marker_number": 8,
"description": "Diff. R alto-sx / basso-dx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tl_br.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 23,
"marker_number": 9,
"description": "Diff. R alto-sx / basso-sx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tl_bl.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 24,
"marker_number": 10,
"description": "Diff. R alto-dx / basso-dx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tr_br.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 25,
"marker_number": 11,
"description": "Diff. R alto-dx / basso-sx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_tr_bl.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
},
{
"id": 26,
"marker_number": 12,
"description": "Diff. R basso-dx / basso-sx",
"measurement_type": null,
"unit": "mm",
"vision_output": "d_br_bl.value",
"nominal": 0.0,
"ltl": -0.05,
"utl": 0.05
}
]
}
@@ -0,0 +1,228 @@
/**
* #116: overlay del check visione (static/js/vision_overlay.js) con plain node.
*
* Run from src/frontend/flask_app: node tests/js/test_vision_overlay.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const VisionOverlay = require(path.join(__dirname, '..', '..', 'static', 'js', 'vision_overlay.js'));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, 'fixtures', 'vision_check_task14.json'), 'utf8'),
);
let failures = 0;
let total = 0;
function check(label, cond, extra) {
total++;
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const near = (a, b) => Math.abs(a - b) < 1e-9;
// Canvas finto: registra le chiamate e le proprieta' al momento dello stroke
function fakeCanvas() {
const calls = [];
const g = {
calls,
strokes: [],
dash: [],
strokeStyle: '',
lineWidth: 0,
beginPath() { calls.push(['beginPath']); },
moveTo(x, y) { calls.push(['moveTo', x, y]); },
lineTo(x, y) { calls.push(['lineTo', x, y]); },
arc(...a) { calls.push(['arc', ...a]); },
closePath() { calls.push(['closePath']); },
stroke() {
calls.push(['stroke']);
g.strokes.push({ style: g.strokeStyle, width: g.lineWidth, dash: g.dash.slice() });
},
setLineDash(d) { g.dash = d.slice(); calls.push(['setLineDash', d.slice()]); },
};
return g;
}
const onlyCalls = (g, name) => g.calls.filter((c) => c[0] === name);
// --- fixture vero: task 14 TEST1 ---
const overlay = fixture.overlay;
check('fixture: overlay con 25 elementi', overlay.length === 25, String(overlay.length));
{
const g = fakeCanvas();
const res = VisionOverlay.draw(g, overlay, 2);
check('draw sul fixture: drawn 25, skipped 0', res.drawn === 25 && res.skipped === 0, JSON.stringify(res));
const lastDash = onlyCalls(g, 'setLineDash').pop();
check('draw: ultimo setLineDash e\' []', lastDash && lastDash[1].length === 0);
check('draw: uno stroke per elemento', g.strokes.length === 25, String(g.strokes.length));
const roiIdx = overlay.findIndex((o) => o.role === 'roi');
const foundIdx = overlay.findIndex((o) => o.role === 'found');
check('roi: arancione, tratteggiata, spessore lw*0.75',
g.strokes[roiIdx].style === '#f59e0b' && g.strokes[roiIdx].width === 1.5 &&
JSON.stringify(g.strokes[roiIdx].dash) === '[6,4]', JSON.stringify(g.strokes[roiIdx]));
check('found: verde, piena, spessore lw',
g.strokes[foundIdx].style === '#16a34a' && g.strokes[foundIdx].width === 2 &&
g.strokes[foundIdx].dash.length === 0, JSON.stringify(g.strokes[foundIdx]));
}
// --- colori: reference e ruolo ignoto ---
{
const g = fakeCanvas();
VisionOverlay.draw(g, [
{ type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 },
{ type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 },
], 1);
check('reference blu', g.strokes[0].style === '#2563eb');
check('ruolo ignoto rosso', g.strokes[1].style === '#ef4444');
}
// --- rect2 ---
function xs(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[1]); }
function ys(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[2]); }
{
const g = fakeCanvas();
const r = VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }], 1);
const X = xs(g), Y = ys(g);
check('rect2 phi 0: 4 vertici chiusi', r.drawn === 1 && X.length === 4 && onlyCalls(g, 'closePath').length === 1);
check('rect2 phi 0: col 90/110', X.every((x) => near(x, 90) || near(x, 110)) && X.filter((x) => near(x, 90)).length === 2, String(X));
check('rect2 phi 0: row 46/54', Y.every((y) => near(y, 46) || near(y, 54)) && Y.filter((y) => near(y, 46)).length === 2, String(Y));
}
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 90, length1: 10, length2: 4 }], 1);
const X = xs(g), Y = ys(g);
check('rect2 phi 90: lati scambiati, col 96/104', X.every((x) => near(x, 96) || near(x, 104)), String(X));
check('rect2 phi 90: lati scambiati, row 40/60', Y.every((y) => near(y, 40) || near(y, 60)), String(Y));
}
// --- arc: argomenti come da contratto ---
{
const g = fakeCanvas();
const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 };
VisionOverlay.draw(g, [a], 1);
const c = onlyCalls(g, 'arc')[0];
check('arc: g.arc(col, row, r, -rad(start), -rad(start+extent), extent>0)',
c && near(c[1], 40) && near(c[2], 30) && near(c[3], 12) &&
near(c[4], -Math.PI / 2) && near(c[5], -Math.PI) && c[6] === true, JSON.stringify(c));
const g2 = fakeCanvas();
VisionOverlay.draw(g2, [{ ...a, start_deg: 0, extent_deg: -45 }], 1);
const c2 = onlyCalls(g2, 'arc')[0];
check('arc: extent negativo = orario (anticlockwise false)',
c2 && near(c2[4], 0) && near(c2[5], Math.PI / 4) && c2[6] === false, JSON.stringify(c2));
}
// --- circle, points, contour ---
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 }], 1);
const c = onlyCalls(g, 'arc')[0];
check('circle: cerchio pieno', c && c[1] === 7 && c[2] === 5 && c[3] === 3 && c[4] === 0 && near(c[5], 2 * Math.PI));
}
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 1);
const m = onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo'));
// lw 1 -> semi-lato max(3, 2) = 3
check('points: croce di semi-lato 3', m.length === 4 && m.some((c) => c[1] === 17 && c[2] === 10) &&
m.some((c) => c[1] === 23 && c[2] === 10) && m.some((c) => c[1] === 20 && c[2] === 7) &&
m.some((c) => c[1] === 20 && c[2] === 13), JSON.stringify(m));
const g2 = fakeCanvas();
VisionOverlay.draw(g2, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 5);
check('points: semi-lato lw*2 per lw grande', onlyCalls(g2, 'moveTo').some((c) => c[1] === 10 && c[2] === 10));
}
{
const g = fakeCanvas();
VisionOverlay.draw(g, [{ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] }], 1);
check('contour: polilinea aperta di 3 punti',
onlyCalls(g, 'moveTo').length === 1 && onlyCalls(g, 'lineTo').length === 2 && onlyCalls(g, 'closePath').length === 0);
}
// --- elementi malformati: saltati, nessuna eccezione ---
{
const g = fakeCanvas();
let thrown = null;
let res = null;
try {
res = VisionOverlay.draw(g, [
{ type: 'circle', role: 'found', row: 1, col: 1 }, // manca radius
{ type: 'points', role: 'found', rows: [1, 2], cols: [1] }, // lunghezze diverse
{ type: 'contour', role: 'found', rows: [], cols: [] }, // vuoto
{ type: 'boh', role: 'found' }, // tipo ignoto
{ type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 }, // non finito
null, // elemento nullo
{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, // valido
], 1);
} catch (e) { thrown = e; }
check('malformati: nessuna eccezione', thrown === null, String(thrown));
check('malformati: 6 saltati, 1 disegnato', res && res.drawn === 1 && res.skipped === 6, JSON.stringify(res));
check('malformati: draw con overlay non array', (() => {
try { const r = VisionOverlay.draw(fakeCanvas(), null, 1); return r.drawn === 0 && r.skipped === 0; } catch (e) { return false; }
})());
}
// --- anchor, valori calcolati a mano ---
{
const A = VisionOverlay.anchor;
const seg = A({ type: 'segment', row1: 10, col1: 0, row2: 20, col2: 40 });
check('anchor segment: punto medio', seg && seg.x === 20 && seg.y === 15);
const lin = A({ type: 'line', row1: 0, col1: 2, row2: 0, col2: 4 });
check('anchor line: punto medio', lin && lin.x === 3 && lin.y === 0);
const cir = A({ type: 'circle', row: 50, col: 100, radius: 8 });
check('anchor circle: centro + raggio a destra', cir && cir.x === 108 && cir.y === 50);
const arc = A({ type: 'arc', row: 50, col: 100, radius: 10, start_deg: 0, extent_deg: 180 });
check('anchor arc: punto medio (90 gradi = in alto a video)', arc && near(arc.x, 100) && near(arc.y, 40), JSON.stringify(arc));
const pts = A({ type: 'points', rows: [7, 8], cols: [3, 4] });
check('anchor points: primo punto', pts && pts.x === 3 && pts.y === 7);
const r2 = A({ type: 'rect2', row: 9, col: 11, phi_deg: 30, length1: 5, length2: 2 });
check('anchor rect2: centro', r2 && r2.x === 11 && r2.y === 9);
const con = A({ type: 'contour', rows: [0, 1, 2, 3], cols: [10, 11, 12, 13] });
check('anchor contour: punto a meta\' (indice 2)', con && con.x === 12 && con.y === 2);
check('anchor: tipo ignoto -> null', A({ type: 'boh' }) === null);
check('anchor: dati mancanti -> null', A({ type: 'circle', row: 1, col: 1 }) === null && A(null) === null);
}
// --- labels sul fixture, righe costruite come fara' vision_execute.js ---
const subById = {};
fixture._subtasks.forEach((s) => { subById[s.id] = s; });
const rows = fixture.measurements.map((m) => {
const s = subById[m.subtask_id];
return {
key: m.subtask_id, name: s.description, value: m.value, unit: s.unit,
status: m.pass_fail, vision_output: s.vision_output,
};
});
{
const L = VisionOverlay.labels(rows, overlay);
check('labels fixture: 6 etichette', L.length === 6, String(L.length));
const names = L.map((l) => l.text.split(' ')[0]).join(',');
check('labels fixture: larghezza, lunghezza e 4 raggi',
L[0].text.startsWith('Larghezza') && L[1].text.startsWith('Lunghezza') &&
L.slice(2).every((l) => l.text.startsWith('R ')), names);
check('labels fixture: nessuna per le differenze', L.every((l) => !/^Diff/.test(l.text)));
const l15 = L.find((l) => l.key === 15);
check('quota 15: testo con Larghezza e 1.668', l15 && l15.text.includes('Larghezza') && l15.text.includes('1.668'), l15 && l15.text);
check('quota 15: status riportato', l15 && l15.status === fixture.measurements[0].pass_fail);
const seg = overlay.find((o) => o.tool_id === 'larghezza' && o.role === 'found');
check('quota 15: ancorata al punto medio del segmento', l15 && near(l15.x, (seg.col1 + seg.col2) / 2) && near(l15.y, (seg.row1 + seg.row2) / 2));
}
{
const nuda = rows.map((r) => ({ ...r, vision_output: undefined }));
check('labels: righe senza vision_output -> nessuna etichetta', VisionOverlay.labels(nuda, overlay).length === 0);
const sola = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass' }], overlay);
check('labels: riga senza vision_output (assente) -> nessuna', sola.length === 0);
const noTool = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass', vision_output: 'nonesiste.value' }], overlay);
check('labels: tool senza overlay -> nessuna', noTool.length === 0);
// found preferito: la roi (per prima) ha un'altra ancora, ma vince il found
const ov = [
{ type: 'rect2', role: 'roi', tool_id: 't', row: 1, col: 1, phi_deg: 0, length1: 1, length2: 1 },
{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 },
];
const f = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'warning', vision_output: 't.value' }], ov);
check('labels: preferisce l\'ancora del found', f.length === 1 && f[0].x === 15 && f[0].y === 10 && f[0].status === 'warning', JSON.stringify(f));
const solo = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'fail', vision_output: 't.value' }], [ov[0]]);
check('labels: senza found ripiega sul primo elemento', solo.length === 1 && solo[0].x === 1 && solo[0].y === 1);
check('labels: testo name + valore a 3 decimali + unit', f[0].text === 'T 1.500 mm', f[0].text);
}
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
process.exit(failures ? 1 : 0);
@@ -46,4 +46,77 @@ check('#114 riga 99: subtask sconosciuta', rows[3].label === '#99', rows[3].labe
check('#114 unit riga 17 = mm', rows[2].unit === 'mm', rows[2].unit);
check('#114 unit riga 99 = mm', rows[3].unit === 'mm', rows[3].unit);
// #117: nominale e tolleranze portati dalla quota; fmtTol
win.__visionCfg = {
subtasks: [
{ id: 20, description: 'Largh', unit: 'mm', nominal: 12.5, ltl: 12.4, utl: 12.6, lwl: 12.45, uwl: 12.55 },
{ id: 21, description: 'Diam', unit: 'mm', nominal: 0, ltl: null, utl: 0.05 },
{ id: 22, description: 'Nuda', unit: 'mm' },
],
};
const c2 = ctx.visionExecute();
c2.result = { measurements: [
{ subtask_id: 20, value: 12.5, pass_fail: 'pass' },
{ subtask_id: 21, value: 0, pass_fail: 'pass' },
{ subtask_id: 22, value: 1, pass_fail: 'pass' },
] };
const r2 = c2.rows;
check('#117 riga 20: nominale e tolleranze',
r2[0].nominal === 12.5 && r2[0].ltl === 12.4 && r2[0].utl === 12.6 && r2[0].lwl === 12.45 && r2[0].uwl === 12.55);
check('#117 riga 21: nominale 0 resta 0, ltl null', r2[1].nominal === 0 && r2[1].ltl == null && r2[1].utl === 0.05);
check('#117 riga 22: senza tolleranze sono undefined/null', r2[2].nominal == null && r2[2].utl == null && r2[2].lwl == null);
check('#117 label invariato', r2[0].label === 'Largh', r2[0].label);
check('#117 fmtTol(null) = trattino', c2.fmtTol(null) === '\u2014', c2.fmtTol(null));
check('#117 fmtTol(undefined) = trattino', c2.fmtTol(undefined) === '\u2014');
check('#117 fmtTol(0.05) = 0.050', c2.fmtTol(0.05) === '0.050', c2.fmtTol(0.05));
check('#117 fmtTol(0) = 0.000', c2.fmtTol(0) === '0.000', c2.fmtTol(0));
// #116: righe con key/name/vision_output, etichette in % con un VisionOverlay finto, labelClass
win.__visionCfg = {
subtasks: [
{ id: 30, marker_number: 1, description: 'Larghezza', unit: 'mm', vision_output: 'larg.value' },
{ id: 31, description: '', measurement_type: 'Diametro', unit: 'mm' },
],
};
const c3 = ctx.visionExecute();
c3.result = { overlay: [{ type: 'circle', tool_id: 'larg' }], measurements: [
{ subtask_id: 30, value: 1.5, pass_fail: 'pass' },
{ subtask_id: 31, value: 2, pass_fail: 'fail' },
] };
const r3 = c3.rows;
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);
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;
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);
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 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');
// drawOverlay usa VisionOverlay.draw con lw = max(2, w/400)
let drawn = null;
ctx.VisionOverlay = { draw(g, overlay, lw) { drawn = { g, overlay, lw }; return { drawn: 1, skipped: 0 }; } };
const gfake = { clearRect() {}, set lineWidth(v) {}, set lineJoin(v) {} };
const cv = { width: 0, height: 0, getContext() { return gfake; } };
c3.$refs = { img: { naturalWidth: 2000, naturalHeight: 1000 }, overlay: cv };
c3.result = { overlay: [{ type: 'circle' }] };
c3.drawOverlay();
check('#116 drawOverlay chiama VisionOverlay.draw', drawn && drawn.g === gfake && drawn.overlay.length === 1 && drawn.lw === 5, JSON.stringify(drawn && drawn.lw));
check('#116 canvas alla risoluzione naturale', cv.width === 2000 && cv.height === 1000);
process.exit(failures ? 1 : 0);
@@ -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);
@@ -0,0 +1,35 @@
"""Runs the plain-node vision overlay draw/anchor/labels test (tests/js/test_vision_overlay.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#116.
"""
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_overlay.js"
def test_vision_overlay_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS dell'overlay 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_overlay.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -46,9 +46,39 @@ 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"')
assert "flex-1" in box and "min-h-0" in box
outcome = _tag_with(panel, 'x-show="result">')
assert "overflow-y-auto" in outcome and "min-h-0" in outcome
def test_quote_table_shows_nominal_and_tolerances():
# #117: la tabella quote del pannello mostra nominale e tolleranze come la schermata dei calibri
panel = _panel()
for marker in ("fmtTol(r.nominal)", "fmtTol(r.ltl)", "fmtTol(r.utl)", "fmtTol(r.lwl)", "fmtTol(r.uwl)"):
assert marker in panel, marker
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 il canvas
src = TEMPLATE.read_text(encoding="utf-8")
assert src.index("js/vision_overlay.js") < src.index("js/vision_execute.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
@@ -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}"
)