diff --git a/src/frontend/flask_app/static/js/dxf_recipe_wizard.js b/src/frontend/flask_app/static/js/dxf_recipe_wizard.js index b2d2777..f14fb3e 100644 --- a/src/frontend/flask_app/static/js/dxf_recipe_wizard.js +++ b/src/frontend/flask_app/static/js/dxf_recipe_wizard.js @@ -6,6 +6,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) { // Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found) const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' }; + const VALUE_UNITS = { 'dxf.points_in_pct': '%', 'dxf.distance_avg': 'mm', 'dxf.distance_min': 'mm', 'dxf.distance_max': 'mm' }; // #104 const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max']; return { @@ -37,6 +38,13 @@ function dxfRecipeWizard(taskId, csrf, msgs) { error: '', notice: '', result: null, // corpo B3 {result, saved, recipe_dir, outputs} + imageSource: '', // #104: '' | 'file' | 'camera'; il campo mm/px vale solo per 'file' + tested: false, // #104: true dopo una Prova (anche fallita): mostra la sezione del risultato + zoom: 1, // #104: scala dell'immagine di prova (1 = pixel nativi) + resPx: null, // #104: {w, h} in pixel dell'immagine di prova (per la dimensione del wrapper) + runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione) + runBandMm: null, // #104: banda usata nell'ultima Prova + runMinPointsPct: null, // #104: soglia usata nell'ultima Prova async toggle() { this.open = !this.open; @@ -59,6 +67,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) { async onStation() { this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calPxMm = null; this.result = null; + this.tested = false; // #104 if (!this.stationId) return; const r = await fetchStationCalibration(this.stationId); this.calState = r.state || 'unreachable'; @@ -81,12 +90,14 @@ function dxfRecipeWizard(taskId, csrf, msgs) { }, // #98: compilato ma non valido -> errore in pagina get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; }, + // #104: la scala a mano vale solo per un'immagine da file; con la camera vale la calibrazione della stazione + get useManualScale() { return this.imageSource === 'file' && this.manualPxMm !== null; }, get canRun() { - // #98: la scala a mano sostituisce la calibrazione della stazione - return !!(this.stationId && (this.calibrated || this.manualPxMm !== null) && this.imageFile && this.dxfFile) && !this.busy; + // #98: la scala a mano sostituisce la calibrazione della stazione (#104: solo per immagini da file) + return !!(this.stationId && (this.calibrated || this.useManualScale) && this.imageFile && this.dxfFile) && !this.busy; }, - _setImage(file) { + _setImage(file, source) { if (this.imageSrc) URL.revokeObjectURL(this.imageSrc); // #102: con file null (immagine tolta) si azzera anche l'anteprima this.imageFile = file || null; @@ -95,6 +106,9 @@ function dxfRecipeWizard(taskId, csrf, msgs) { this.imagePx = null; this.imageBroken = false; this.result = null; + this.tested = false; // #104 + this.resPx = null; // #104 + this.imageSource = file ? (source || '') : ''; // #104 }, // #102: l' dell'anteprima e' caricata: legge i pixel; se non si carica il formato non e' mostrabile @@ -115,18 +129,19 @@ function dxfRecipeWizard(taskId, csrf, msgs) { const lang = document.documentElement.lang || 'it'; const f = (v, d) => Number(v).toLocaleString(lang, { minimumFractionDigits: d, maximumFractionDigits: d }); let text = `${f(p.w, 0)} × ${f(p.h, 0)} px`; - const pxMm = this.manualPxMm !== null ? this.manualPxMm : (this.calibrated ? this.calPxMm : null); + const pxMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null); // #104 if (pxMm) text += ` · ${this.msgs.imageField || 'field'} ≈ ${f(p.w / pxMm, 2)} × ${f(p.h / pxMm, 2)} mm`; return text; }, onImage(ev) { const f = ev.target.files && ev.target.files[0]; - if (f) this._setImage(f); + if (f) this._setImage(f, 'file'); // #104 }, onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; + this.tested = false; // #104 this.loadDxfPreview(); // #101: anteprima appena si sceglie il file }, @@ -200,7 +215,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) { this.error = body.detail || this.msgs.requestFailed || 'Error'; } else { const blob = await resp.blob(); - this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' })); + this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }), 'camera'); // #104 } } catch (e) { this.error = this.msgs.requestFailed || String(e); @@ -224,13 +239,17 @@ function dxfRecipeWizard(taskId, csrf, msgs) { this.busy = true; this.error = ''; this.notice = ''; + // #104: parametri usati, mostrati accanto ai risultati + this.runPxPerMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null); + this.runBandMm = this.bandMm; + this.runMinPointsPct = this.minPointsPct; try { const fd = new FormData(); fd.append('image', this.imageFile, this.imageFile.name || 'capture.png'); fd.append('dxf', this.dxfFile, this.dxfFile.name); fd.append('station_id', this.stationId); - // #98: scala a mano; il backend la preferisce alla calibrazione della stazione - if (this.manualPxMm !== null) fd.append('px_per_mm', String(this.manualPxMm)); + // #98: scala a mano; il backend la preferisce alla calibrazione della stazione (#104: solo con immagine da file) + if (this.useManualScale) fd.append('px_per_mm', String(this.manualPxMm)); fd.append('band_mm', String(this.bandMm)); fd.append('min_points_pct', String(this.minPointsPct)); fd.append('polarity', this.dark ? 'dark' : 'light'); @@ -254,12 +273,75 @@ function dxfRecipeWizard(taskId, csrf, msgs) { this.error = this.msgs.requestFailed || String(e); } this.busy = false; + // #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra) + this.tested = true; + this.$nextTick(() => this.fit()); }, // Le quattro uscite del confronto, nell'ordine fisso get values() { const out = (this.result && this.result.result && this.result.result.outputs) || {}; - return VALUE_KEYS.map(k => ({ key: k, value: out[k] })); + return VALUE_KEYS.map(k => ({ key: k, value: out[k], label: this._valueLabel(k), unit: VALUE_UNITS[k] })); // #104 + }, + + // #104: etichette leggibili (testi passati da msgs), chiave grezza come ripiego + _valueLabel(k) { + const m = { 'dxf.points_in_pct': this.msgs.pointsInBand, 'dxf.distance_avg': this.msgs.distAvg, + 'dxf.distance_min': this.msgs.distMin, 'dxf.distance_max': this.msgs.distMax }; + return m[k] || k; + }, + + // #104: esito della ricetta sul riferimento: pass / warning / fail (src/vision/runner.py); altro = valore grezzo + get outcome() { return (this.result && this.result.result && this.result.result.outcome) || ''; }, + get outcomeLabel() { + const m = { pass: this.msgs.outcomePass, warning: this.msgs.outcomeWarning, fail: this.msgs.outcomeFail }; + return m[this.outcome] || this.outcome; + }, + get outcomeClass() { + return { pass: 'text-emerald-600', warning: 'text-amber-600', fail: 'text-red-500' }[this.outcome] || 'text-[var(--text-muted)]'; + }, + // #104: scala usata nell'ultima Prova, con la fonte + get scaleSummary() { + if (!this.runPxPerMm) return this.msgs.scaleStation || 'station calibration'; + const lang = document.documentElement.lang || 'it'; + const mm = (1 / this.runPxPerMm).toLocaleString(lang, { maximumFractionDigits: 6 }); + return `${mm} mm/px (${this.imageSource === 'file' ? (this.msgs.scaleManual || 'manual') : (this.msgs.scaleStation || 'station calibration')})`; + }, + + // #104: zoom dell'immagine di prova. Il wrapper e' largo naturalWidth*zoom; il canvas resta in pixel immagine e lo scala il CSS + get zoomPct() { return Math.round(this.zoom * 100) + ' %'; }, + get zoomW() { return this.resPx ? this.resPx.w * this.zoom : 0; }, + get zoomH() { return this.resPx ? this.resPx.h * this.zoom : 0; }, + zoomBy(f) { + const vp = this.$refs.viewport; + const old = this.zoom; + const z = Math.min(8, Math.max(0.05, old * f)); + if (z === old) return; + // centro visibile invariato: coordinate immagine del centro prima, scroll dopo il nuovo layout + let cx = 0, cy = 0; + if (vp) { cx = (vp.scrollLeft + vp.clientWidth / 2) / old; cy = (vp.scrollTop + vp.clientHeight / 2) / old; } + this.zoom = z; + if (vp) this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; }); + }, + // #104: Fit = tutta l'immagine nel viewport + fit() { + const vp = this.$refs.viewport, img = this.$refs.img; + if (!vp || !img || !img.naturalWidth || !vp.clientWidth || !vp.clientHeight) return; + this.zoom = Math.min(8, Math.max(0.05, Math.min(vp.clientWidth / img.naturalWidth, vp.clientHeight / img.naturalHeight))); + vp.scrollLeft = 0; vp.scrollTop = 0; + }, + // #104: Ctrl+rotella = zoom + onWheel(ev) { + if (!ev.ctrlKey) return; + ev.preventDefault(); + this.zoomBy(ev.deltaY < 0 ? 1.25 : 1 / 1.25); + }, + // #104: load dell'immagine di prova: dimensioni, overlay e adattamento allo spazio + onResultImgLoad() { + const img = this.$refs.img; + this.resPx = img && img.naturalWidth ? { w: img.naturalWidth, h: img.naturalHeight } : null; + this.drawOverlay(); + this.$nextTick(() => this.fit()); }, fmt(v) { diff --git a/src/frontend/flask_app/templates/maker/task_editor.html b/src/frontend/flask_app/templates/maker/task_editor.html index 849e910..d40126d 100644 --- a/src/frontend/flask_app/templates/maker/task_editor.html +++ b/src/frontend/flask_app/templates/maker/task_editor.html @@ -662,7 +662,7 @@ diff --git a/src/frontend/flask_app/tests/js/test_dxf_preview.js b/src/frontend/flask_app/tests/js/test_dxf_preview.js index 8782586..75e7fd1 100644 --- a/src/frontend/flask_app/tests/js/test_dxf_preview.js +++ b/src/frontend/flask_app/tests/js/test_dxf_preview.js @@ -90,7 +90,7 @@ const PREVIEW = { check('#102 senza immagine: summary vuoto', wi.imagePreviewSummary === ''); global.URL.createObjectURL = () => 'blob:x'; global.URL.revokeObjectURL = () => {}; - wi._setImage({ name: 'a.bmp' }); + wi._setImage({ name: 'a.bmp' }, 'file'); // #104: la scala a mano vale solo per file check('#102 _setImage imposta URL e nome', wi.imagePreviewUrl === 'blob:x' && wi.imageName === 'a.bmp'); wi.onImagePreviewLoad({ naturalWidth: 2000, naturalHeight: 1000 }); check('#102 senza scala: solo pixel', /2[. ]?000 × 1[. ]?000 px$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary); @@ -104,6 +104,69 @@ const PREVIEW = { check('#102 _setImage(null) azzera', wi.imagePreviewUrl === '' && wi.imageFile === null && wi.imageName === '' && wi.imagePx === null && wi.imageBroken === false && wi.imagePreviewSummary === ''); + // #104: scala a mano solo per immagini da file + const w4 = dxfRecipeWizard(1, () => 'tok', {}); + w4._setImage({ name: 'a.bmp' }, 'file'); + w4.mmPx = '0,01406'; + check('#104 useManualScale vero con file', w4.useManualScale === true); + w4._setImage({ name: 'camera.png' }, 'camera'); + check('#104 useManualScale falso con camera anche se mmPx valido', w4.manualPxMm !== null && w4.useManualScale === false); + w4.stationId = '1'; w4.dxfFile = { name: 'd.dxf' }; + check('#104 canRun con camera richiede la calibrazione', w4.canRun === false); + w4.calState = 'calibrated'; w4.calPxMm = 50; + check('#104 canRun con camera e stazione calibrata', w4.canRun === true); + + // #104: run() con la camera non invia px_per_mm; con il file si + const sent = []; + global.FormData = class { append(k, v) { sent.push(k); } }; + global.fetch = async () => ({ ok: true, json: async () => ({ result: { outputs: {}, overlay: [], outcome: 'pass' } }) }); + const tick = []; + w4.$nextTick = f => tick.push(f); + w4.$dispatch = () => {}; + w4.$refs = {}; + await w4.run(false); + check('#104 run con camera: niente px_per_mm', !sent.includes('px_per_mm') && sent.includes('station_id'), sent.join(',')); + check('#104 run: tested true e scala = stazione', w4.tested === true && w4.runPxPerMm === 50); + sent.length = 0; + w4._setImage({ name: 'a.bmp' }, 'file'); + w4.mmPx = '0,02'; + await w4.run(false); + check('#104 run con file: px_per_mm inviato', sent.includes('px_per_mm') && Math.abs(w4.runPxPerMm - 50) < 1e-9, sent.join(',')); + check('#104 esito pass', w4.outcome === 'pass' && w4.outcomeClass.includes('emerald')); + + // #104: tested torna false dopo _setImage / onDxf / onStation + w4.tested = true; w4._setImage(null); + check('#104 tested falso dopo _setImage', w4.tested === false && w4.imageSource === ''); + w4.tested = true; w4.onDxf({ target: { files: [] } }); + check('#104 tested falso dopo onDxf', w4.tested === false); + w4.tested = true; w4.stationId = ''; await w4.onStation(); + check('#104 tested falso dopo onStation', w4.tested === false); + + // #104: zoomBy rispetta i limiti 0.05..8 + const w5 = dxfRecipeWizard(1, () => 'tok', {}); + w5.$nextTick = f => f(); + w5.$refs = {}; + for (let i = 0; i < 40; i++) w5.zoomBy(1.25); + check('#104 zoom massimo 8', w5.zoom === 8, String(w5.zoom)); + for (let i = 0; i < 80; i++) w5.zoomBy(1 / 1.25); + check('#104 zoom minimo 0.05', w5.zoom === 0.05, String(w5.zoom)); + w5.zoom = 1; + const vp = { scrollLeft: 100, scrollTop: 50, clientWidth: 200, clientHeight: 100 }; + w5.$refs = { viewport: vp }; + w5.zoomBy(2); + check('#104 zoomBy tiene il centro', w5.zoom === 2 && vp.scrollLeft === 300 && vp.scrollTop === 150, vp.scrollLeft + ',' + vp.scrollTop); + check('#104 zoomPct', w5.zoomPct === '200 %'); + + // #104: fit = min(viewport/immagine) + w5.$refs = { viewport: { clientWidth: 500, clientHeight: 400, scrollLeft: 9, scrollTop: 9 }, img: { naturalWidth: 2000, naturalHeight: 1000 } }; + w5.fit(); + check('#104 fit limitato dalla larghezza', Math.abs(w5.zoom - 0.25) < 1e-9, String(w5.zoom)); + w5.$refs.img = { naturalWidth: 1000, naturalHeight: 2000 }; + w5.fit(); + check('#104 fit limitato dall altezza', Math.abs(w5.zoom - 0.2) < 1e-9, String(w5.zoom)); + w5.$refs.viewport.clientWidth = 0; w5.zoom = 1; w5.fit(); + check('#104 fit con viewport nascosto non cambia', w5.zoom === 1); + if (failures) { console.log(failures + ' fallimenti'); process.exit(1); } console.log('tutto ok'); })(); diff --git a/src/frontend/flask_app/tests/test_dxf_recipe_routes.py b/src/frontend/flask_app/tests/test_dxf_recipe_routes.py index 322de1a..dbe2c19 100644 --- a/src/frontend/flask_app/tests/test_dxf_recipe_routes.py +++ b/src/frontend/flask_app/tests/test_dxf_recipe_routes.py @@ -280,5 +280,6 @@ class TestPages: assert "@dxf-recipe-saved" in html assert 'x-model="mmPx"' in html # #98: campo mm/px a mano assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF + assert "imageSource === 'file'" in html and "zoomBy(" in html and "fit()" in html # #104: mm/px solo da file, zoom e Fit assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html diff --git a/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po b/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po index 1798e9a..153207b 100644 --- a/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po +++ b/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po @@ -3507,3 +3507,55 @@ msgstr "field" #: templates/maker/task_editor.html (#102) msgid "Anteprima non disponibile per questo formato" msgstr "Preview not available for this format" + +#: templates/maker/task_editor.html (#104) +msgid "Riduci" +msgstr "Zoom out" + +#: templates/maker/task_editor.html (#104) +msgid "Ingrandisci" +msgstr "Zoom in" + +#: templates/maker/task_editor.html (#104) +msgid "Adatta" +msgstr "Fit" + +#: templates/maker/task_editor.html (#104) +msgid "Scala" +msgstr "Scale" + +#: templates/maker/task_editor.html (#104) +msgid "Punti in banda" +msgstr "Points in band" + +#: templates/maker/task_editor.html (#104) +msgid "Distanza media" +msgstr "Average distance" + +#: templates/maker/task_editor.html (#104) +msgid "Distanza minima" +msgstr "Minimum distance" + +#: templates/maker/task_editor.html (#104) +msgid "Distanza massima" +msgstr "Maximum distance" + +#: templates/maker/task_editor.html (#104) +msgid "Conforme" +msgstr "Pass" + +#: templates/maker/task_editor.html (#104) +msgid "Con avvisi" +msgstr "Warning" + +#: templates/maker/task_editor.html (#104) +msgid "Non conforme" +msgstr "Fail" + +#: templates/maker/task_editor.html (#104) +msgid "mm/px a mano" +msgstr "manual mm/px" + +#: templates/maker/task_editor.html (#104) +msgid "calibrazione stazione" +msgstr "station calibration" diff --git a/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po b/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po index 23be8ae..62e6301 100644 --- a/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po +++ b/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po @@ -3491,3 +3491,55 @@ msgstr "campo" #: templates/maker/task_editor.html (#102) msgid "Anteprima non disponibile per questo formato" msgstr "Anteprima non disponibile per questo formato" + +#: templates/maker/task_editor.html (#104) +msgid "Riduci" +msgstr "Riduci" + +#: templates/maker/task_editor.html (#104) +msgid "Ingrandisci" +msgstr "Ingrandisci" + +#: templates/maker/task_editor.html (#104) +msgid "Adatta" +msgstr "Adatta" + +#: templates/maker/task_editor.html (#104) +msgid "Scala" +msgstr "Scala" + +#: templates/maker/task_editor.html (#104) +msgid "Punti in banda" +msgstr "Punti in banda" + +#: templates/maker/task_editor.html (#104) +msgid "Distanza media" +msgstr "Distanza media" + +#: templates/maker/task_editor.html (#104) +msgid "Distanza minima" +msgstr "Distanza minima" + +#: templates/maker/task_editor.html (#104) +msgid "Distanza massima" +msgstr "Distanza massima" + +#: templates/maker/task_editor.html (#104) +msgid "Conforme" +msgstr "Conforme" + +#: templates/maker/task_editor.html (#104) +msgid "Con avvisi" +msgstr "Con avvisi" + +#: templates/maker/task_editor.html (#104) +msgid "Non conforme" +msgstr "Non conforme" + +#: templates/maker/task_editor.html (#104) +msgid "mm/px a mano" +msgstr "mm/px a mano" + +#: templates/maker/task_editor.html (#104) +msgid "calibrazione stazione" +msgstr "calibrazione stazione"