merge #102 #104: anteprima immagine, disposizione del wizard DXF, prova su due colonne con zoom (Refs #102 #104)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
2026-10-25 16:21:43 +01:00
6 changed files with 412 additions and 52 deletions
@@ -6,6 +6,7 @@
function dxfRecipeWizard(taskId, csrf, msgs) { function dxfRecipeWizard(taskId, csrf, msgs) {
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found) // Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' }; 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']; const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
return { return {
@@ -20,6 +21,9 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
imageFile: null, // immagine caricata o acquisita imageFile: null, // immagine caricata o acquisita
imageName: '', imageName: '',
imageSrc: '', imageSrc: '',
imagePx: null, // #102: {w, h} in pixel, letto dall'<img> dell'anteprima
imageBroken: false, // #102: il browser non sa mostrare il formato (il file resta valido per l'invio)
calPxMm: null, // #102: px/mm della calibrazione della stazione
dxfFile: null, dxfFile: null,
dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti} dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti}
dxfPreviewBusy: false, dxfPreviewBusy: false,
@@ -34,6 +38,13 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
error: '', error: '',
notice: '', notice: '',
result: null, // corpo B3 {result, saved, recipe_dir, outputs} 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() { async toggle() {
this.open = !this.open; this.open = !this.open;
@@ -54,13 +65,18 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore // Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
async onStation() { async onStation() {
this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calPxMm = null;
this.result = null; this.result = null;
this.tested = false; // #104
if (!this.stationId) return; if (!this.stationId) return;
const r = await fetchStationCalibration(this.stationId); const r = await fetchStationCalibration(this.stationId);
this.calState = r.state || 'unreachable'; this.calState = r.state || 'unreachable';
this.calUrl = r.calibration_url || ''; this.calUrl = r.calibration_url || '';
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {}); if (this.calState === 'calibrated') {
this.calLabel = formatCalibration(r.calibration || {});
const c = Number((r.calibration || {}).px_per_mm); // #102: scala per il campo in mm dell'anteprima
this.calPxMm = Number.isFinite(c) && c > 0 ? c : null;
}
}, },
get calibrated() { return this.calState === 'calibrated'; }, get calibrated() { return this.calState === 'calibrated'; },
@@ -74,26 +90,58 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
}, },
// #98: compilato ma non valido -> errore in pagina // #98: compilato ma non valido -> errore in pagina
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; }, 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() { get canRun() {
// #98: la scala a mano sostituisce la calibrazione della stazione // #98: la scala a mano sostituisce la calibrazione della stazione (#104: solo per immagini da file)
return !!(this.stationId && (this.calibrated || this.manualPxMm !== null) && this.imageFile && this.dxfFile) && !this.busy; 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); if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageFile = file; // #102: con file null (immagine tolta) si azzera anche l'anteprima
this.imageName = file.name || ''; this.imageFile = file || null;
this.imageSrc = URL.createObjectURL(file); this.imageName = (file && file.name) || '';
this.imageSrc = file ? URL.createObjectURL(file) : '';
this.imagePx = null;
this.imageBroken = false;
this.result = null; this.result = null;
this.tested = false; // #104
this.resPx = null; // #104
this.imageSource = file ? (source || '') : ''; // #104
},
// #102: l'<img> dell'anteprima e' caricata: legge i pixel; se non si carica il formato non e' mostrabile
onImagePreviewLoad(el) {
this.imageBroken = false;
this.imagePx = el && el.naturalWidth ? { w: el.naturalWidth, h: el.naturalHeight } : null;
},
onImagePreviewError() {
this.imageBroken = !!this.imageSrc;
this.imagePx = null;
},
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
get imagePreviewUrl() { return this.imageSrc; },
// #102: «W × H px · campo ≈ L × H mm»; scala = mm/px a mano se valido, altrimenti calibrazione della stazione
get imagePreviewSummary() {
const p = this.imagePx;
if (!p) return '';
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.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) { onImage(ev) {
const f = ev.target.files && ev.target.files[0]; const f = ev.target.files && ev.target.files[0];
if (f) this._setImage(f); if (f) this._setImage(f, 'file'); // #104
}, },
onDxf(ev) { onDxf(ev) {
this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.dxfFile = (ev.target.files && ev.target.files[0]) || null;
this.result = null; this.result = null;
this.tested = false; // #104
this.loadDxfPreview(); // #101: anteprima appena si sceglie il file this.loadDxfPreview(); // #101: anteprima appena si sceglie il file
}, },
@@ -167,7 +215,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.error = body.detail || this.msgs.requestFailed || 'Error'; this.error = body.detail || this.msgs.requestFailed || 'Error';
} else { } else {
const blob = await resp.blob(); 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) { } catch (e) {
this.error = this.msgs.requestFailed || String(e); this.error = this.msgs.requestFailed || String(e);
@@ -191,13 +239,17 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.busy = true; this.busy = true;
this.error = ''; this.error = '';
this.notice = ''; 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 { try {
const fd = new FormData(); const fd = new FormData();
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png'); fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
fd.append('dxf', this.dxfFile, this.dxfFile.name); fd.append('dxf', this.dxfFile, this.dxfFile.name);
fd.append('station_id', this.stationId); fd.append('station_id', this.stationId);
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione // #98: scala a mano; il backend la preferisce alla calibrazione della stazione (#104: solo con immagine da file)
if (this.manualPxMm !== null) fd.append('px_per_mm', String(this.manualPxMm)); if (this.useManualScale) fd.append('px_per_mm', String(this.manualPxMm));
fd.append('band_mm', String(this.bandMm)); fd.append('band_mm', String(this.bandMm));
fd.append('min_points_pct', String(this.minPointsPct)); fd.append('min_points_pct', String(this.minPointsPct));
fd.append('polarity', this.dark ? 'dark' : 'light'); fd.append('polarity', this.dark ? 'dark' : 'light');
@@ -221,12 +273,75 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.error = this.msgs.requestFailed || String(e); this.error = this.msgs.requestFailed || String(e);
} }
this.busy = false; 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 // Le quattro uscite del confronto, nell'ordine fisso
get values() { get values() {
const out = (this.result && this.result.result && this.result.result.outputs) || {}; 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) { fmt(v) {
@@ -662,7 +662,7 @@
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== --> <!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
<template x-if="task.task_type === 'dxf_compare'"> <template x-if="task.task_type === 'dxf_compare'">
<div class="mt-4 pt-3 border-t border-[var(--border-color)]" <div class="mt-4 pt-3 border-t border-[var(--border-color)]"
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata'), 'dxfEntities': _('entità')}|tojson_attr }})"> x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata'), 'dxfEntities': _('entità'), 'imageField': _('campo'), 'pointsInBand': _('Punti in banda'), 'distAvg': _('Distanza media'), 'distMin': _('Distanza minima'), 'distMax': _('Distanza massima'), 'outcomePass': _('Conforme'), 'outcomeWarning': _('Con avvisi'), 'outcomeFail': _('Non conforme'), 'scaleManual': _('mm/px a mano'), 'scaleStation': _('calibrazione stazione')}|tojson_attr }})">{# #104: etichette del risultato della Prova #}
<button type="button" class="btn btn-secondary text-xs" @click="toggle()"> <button type="button" class="btn btn-secondary text-xs" @click="toggle()">
{{ _('Crea ricetta da DXF') }} {{ _('Crea ricetta da DXF') }}
</button> </button>
@@ -680,24 +680,14 @@
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600"> <p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
{{ _('Calibrazione') }}: <span x-text="calLabel"></span> {{ _('Calibrazione') }}: <span x-text="calLabel"></span>
</p> </p>
<p x-show="stationId && calState && !calibrated && manualPxMm === null" class="mt-1 text-xs text-amber-600">{# #98: nascosto con mm/px valido #} <p x-show="stationId && calState && !calibrated && !useManualScale" class="mt-1 text-xs text-amber-600">{# #98: nascosto con mm/px valido (#104: solo per immagine da file) #}
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span> <span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl" <a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a> class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span> <span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
</p> </p>
</div> </div>
<!-- #98: mm/px a mano per immagini da file, sostituisce la calibrazione della stazione --> <!-- Immagine: camera o file. #104: prima le scelte, poi le anteprime affiancate -->
<div>
<label class="tmf-label text-xs">{{ _('mm/px (immagine da file)') }}</label>
<input type="text" inputmode="decimal" class="tmf-input text-xs" x-model="mmPx" placeholder="0,01406">
<p x-show="manualPxMm !== null" class="mt-1 text-xs text-emerald-600">
= <span x-text="fmt(manualPxMm)"></span> px/mm
</p>
<p x-show="mmPxInvalid" class="mt-1 text-xs text-amber-600">{{ _('mm/px non valido') }}</p>
<p class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Se compilato sostituisce la calibrazione della stazione; la scala resta salvata nella ricetta.') }}</p>
</div>
<!-- Immagine: camera o file -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label> <label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
@@ -710,17 +700,40 @@
</label> </label>
</div> </div>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p> <p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
<!-- #98: mm/px a mano, #104: solo per un'immagine da file; sostituisce la calibrazione della stazione -->
<div x-show="imageSource === 'file'" class="mt-2">
<label class="tmf-label text-xs">{{ _('mm/px (immagine da file)') }}</label>
<input type="text" inputmode="decimal" class="tmf-input text-xs" x-model="mmPx" placeholder="0,01406">
<p x-show="manualPxMm !== null" class="mt-1 text-xs text-emerald-600">
= <span x-text="fmt(manualPxMm)"></span> px/mm
</p>
<p x-show="mmPxInvalid" class="mt-1 text-xs text-amber-600">{{ _('mm/px non valido') }}</p>
<p class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Se compilato sostituisce la calibrazione della stazione; la scala resta salvata nella ricetta.') }}</p>
</div>
</div> </div>
<div> <div>
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label> <label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)"> <input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
<!-- #101: anteprima del disegno appena scelto il .dxf --> <!-- #101: lettura del disegno appena scelto il .dxf -->
<p x-show="dxfPreviewBusy" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Lettura DXF…') }}</p> <p x-show="dxfPreviewBusy" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Lettura DXF…') }}</p>
<p x-show="dxfPreviewError" class="mt-1 text-xs text-red-500" x-text="dxfPreviewError"></p> <p x-show="dxfPreviewError" class="mt-1 text-xs text-red-500" x-text="dxfPreviewError"></p>
<div x-show="dxfPreview" class="mt-2"> </div>
</div>
<!-- #104: anteprime sotto le scelte, scatole alte uguali (240px) per stare allineate -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
<div x-show="imagePreviewUrl">
<!-- #102: anteprima dell'immagine -->
<img x-show="!imageBroken" :src="imagePreviewUrl" alt=""
@load="onImagePreviewLoad($event.target)" @error="onImagePreviewError()"
class="block w-full rounded border border-[var(--border-color)]"
style="height: 240px; object-fit: contain; background: var(--bg-primary);">
<p x-show="imageBroken" class="text-xs text-amber-600">{{ _('Anteprima non disponibile per questo formato') }}</p>
<p x-show="imagePreviewSummary" class="mt-1 text-xs text-[var(--text-muted)]" x-text="imagePreviewSummary"></p>
</div>
<div x-show="dxfPreview">
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet" <svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
class="block w-full rounded border border-[var(--border-color)]" class="block w-full rounded border border-[var(--border-color)]"
style="max-height: 240px; background: var(--bg-primary);"> style="height: 240px; background: var(--bg-primary);">
<!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox --> <!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox -->
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)" <path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
stroke-width="1.5" vector-effect="non-scaling-stroke" stroke-width="1.5" vector-effect="non-scaling-stroke"
@@ -729,7 +742,6 @@
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p> <p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
</div> </div>
</div> </div>
</div>
<!-- Pochi parametri --> <!-- Pochi parametri -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
@@ -752,21 +764,50 @@
<button type="button" class="btn btn-primary text-xs" @click="run(true)" <button type="button" class="btn btn-primary text-xs" @click="run(true)"
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button> :disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
</div> </div>
<p x-show="error" class="text-xs text-red-500" x-text="error"></p> <!-- #104: errore di acquisizione prima di una Prova (dopo, sta nella colonna dei dati) -->
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p> <p x-show="error && !tested" class="text-xs text-red-500" x-text="error"></p>
<!-- Anteprima con overlay e quattro valori --> <!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra -->
<div x-show="imageSrc" class="relative"> <div x-show="tested" class="grid grid-cols-1 lg:grid-cols-2 gap-3 items-start">
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]" <div>
@load="drawOverlay()"> <div class="flex items-center gap-2 mb-1">
<button type="button" class="btn btn-secondary text-xs" @click="zoomBy(1 / 1.25)" title="{{ _('Riduci') }}">&minus;</button>
<button type="button" class="btn btn-secondary text-xs" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
<button type="button" class="btn btn-secondary text-xs" @click="fit()">{{ _('Adatta') }}</button>
<span class="text-xs text-[var(--text-muted)]" x-text="zoomPct"></span>
</div>
<div x-ref="viewport" @wheel="onWheel($event)"
class="rounded border border-[var(--border-color)]"
style="overflow: auto; height: min(70vh, 520px); background: var(--bg-primary);">
<div class="relative" :style="zoomW ? ('width:' + zoomW + 'px;height:' + zoomH + 'px') : 'width:100%'">
<img x-ref="img" :src="imageSrc" alt="" class="block" style="width: 100%; height: 100%;"
@load="onResultImgLoad()">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas> <canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div> </div>
<table x-show="result" class="tmf-table text-xs"> </div>
</div>
<div class="space-y-2">
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
<div x-show="result" class="space-y-2">
<p class="text-xs">{{ _('Esito') }}: <span class="font-semibold" :class="outcomeClass" x-text="outcomeLabel"></span></p>
<table class="tmf-table text-xs">
<tbody> <tbody>
<template x-for="v in values" :key="v.key"> <template x-for="v in values" :key="v.key">
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr> <tr>
<td :title="v.key" x-text="v.label"></td>
<td class="text-right"><span x-text="fmt(v.value)"></span> <span class="text-[var(--text-muted)]" x-text="v.unit"></span></td>
</tr>
</template> </template>
</tbody> </tbody>
</table> </table>
<ul class="text-xs text-[var(--text-muted)] space-y-0.5">
<li>{{ _('Scala') }}: <span x-text="scaleSummary"></span></li>
<li>{{ _('Banda ± mm') }}: <span x-text="fmt(runBandMm)"></span></li>
<li>{{ _('Soglia punti in banda %%') }}: <span x-text="fmt(runMinPointsPct)"></span></li>
</ul>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -85,6 +85,88 @@ const PREVIEW = {
await w2.loadDxfPreview(); await w2.loadDxfPreview();
check('errore col detail', w2.dxfPreviewError === 'DXF illeggibile: x' && w2.dxfPreview === null, w2.dxfPreviewError); check('errore col detail', w2.dxfPreviewError === 'DXF illeggibile: x' && w2.dxfPreview === null, w2.dxfPreviewError);
// #102: anteprima dell'immagine
const wi = dxfRecipeWizard(1, () => 'tok', { imageField: 'campo' });
check('#102 senza immagine: summary vuoto', wi.imagePreviewSummary === '');
global.URL.createObjectURL = () => 'blob:x';
global.URL.revokeObjectURL = () => {};
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);
wi.mmPx = '0,01';
check('#102 con mm/px a mano: campo in mm', /px · campo ≈ 20,00 × 10,00 mm$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
wi.mmPx = ''; wi.calState = 'calibrated'; wi.calPxMm = 100;
check('#102 con calibrazione stazione: campo in mm', /campo ≈ 20,00 × 10,00 mm$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
wi.onImagePreviewError();
check('#102 errore <img>: imageBroken e file ancora valido', wi.imageBroken === true && wi.imageFile && wi.imagePx === null);
wi._setImage(null);
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); } if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
console.log('tutto ok'); console.log('tutto ok');
})(); })();
@@ -280,4 +280,6 @@ class TestPages:
assert "@dxf-recipe-saved" in html assert "@dxf-recipe-saved" in html
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano 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 "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 assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -3499,3 +3499,63 @@ msgstr "Reading DXF…"
msgid "entità" msgid "entità"
msgstr "entities" msgstr "entities"
#: templates/maker/task_editor.html (#102)
msgid "campo"
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"
@@ -3483,3 +3483,63 @@ msgstr "Lettura DXF…"
msgid "entità" msgid "entità"
msgstr "entità" msgstr "entità"
#: templates/maker/task_editor.html (#102)
msgid "campo"
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"