maker: wizard DXF con anteprime sotto le scelte, mm/px solo da file, risultato su due colonne con zoom e Fit (Refs #104)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
2026-10-25 16:21:19 +01:00
parent 20f33234a8
commit 1a19bb6679
6 changed files with 339 additions and 57 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 {
@@ -37,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;
@@ -59,6 +67,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
async onStation() { async onStation() {
this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calPxMm = null; 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';
@@ -81,12 +90,14 @@ 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);
// #102: con file null (immagine tolta) si azzera anche l'anteprima // #102: con file null (immagine tolta) si azzera anche l'anteprima
this.imageFile = file || null; this.imageFile = file || null;
@@ -95,6 +106,9 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.imagePx = null; this.imagePx = null;
this.imageBroken = false; 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 // #102: l'<img> 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 lang = document.documentElement.lang || 'it';
const f = (v, d) => Number(v).toLocaleString(lang, { minimumFractionDigits: d, maximumFractionDigits: d }); const f = (v, d) => Number(v).toLocaleString(lang, { minimumFractionDigits: d, maximumFractionDigits: d });
let text = `${f(p.w, 0)} × ${f(p.h, 0)} px`; 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`; if (pxMm) text += ` · ${this.msgs.imageField || 'field'} ≈ ${f(p.w / pxMm, 2)} × ${f(p.h / pxMm, 2)} mm`;
return text; 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
}, },
@@ -200,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);
@@ -224,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');
@@ -254,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à'), 'imageField': _('campo')}|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,33 +700,46 @@
</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>
<!-- #102: anteprima dell'immagine, affiancata a quella del DXF (#101) --> <!-- #98: mm/px a mano, #104: solo per un'immagine da file; sostituisce la calibrazione della stazione -->
<div x-show="imagePreviewUrl" class="mt-2"> <div x-show="imageSource === 'file'" class="mt-2">
<img x-show="!imageBroken" :src="imagePreviewUrl" alt="" <label class="tmf-label text-xs">{{ _('mm/px (immagine da file)') }}</label>
@load="onImagePreviewLoad($event.target)" @error="onImagePreviewError()" <input type="text" inputmode="decimal" class="tmf-input text-xs" x-model="mmPx" placeholder="0,01406">
class="block w-full rounded border border-[var(--border-color)]" <p x-show="manualPxMm !== null" class="mt-1 text-xs text-emerald-600">
style="max-height: 240px; object-fit: contain; background: var(--bg-primary);"> = <span x-text="fmt(manualPxMm)"></span> px/mm
<p x-show="imageBroken" class="text-xs text-amber-600">{{ _('Anteprima non disponibile per questo formato') }}</p> </p>
<p x-show="imagePreviewSummary" class="mt-1 text-xs text-[var(--text-muted)]" x-text="imagePreviewSummary"></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> <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>
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet" </div>
class="block w-full rounded border border-[var(--border-color)]" <!-- #104: anteprime sotto le scelte, scatole alte uguali (240px) per stare allineate -->
style="max-height: 240px; background: var(--bg-primary);"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
<!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox --> <div x-show="imagePreviewUrl">
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)" <!-- #102: anteprima dell'immagine -->
stroke-width="1.5" vector-effect="non-scaling-stroke" <img x-show="!imageBroken" :src="imagePreviewUrl" alt=""
stroke-linejoin="round"></path> @load="onImagePreviewLoad($event.target)" @error="onImagePreviewError()"
</svg> class="block w-full rounded border border-[var(--border-color)]"
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p> style="height: 240px; object-fit: contain; background: var(--bg-primary);">
</div> <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"
class="block w-full rounded border border-[var(--border-color)]"
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 -->
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
stroke-width="1.5" vector-effect="non-scaling-stroke"
stroke-linejoin="round"></path>
</svg>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
</div> </div>
</div> </div>
<!-- Pochi parametri --> <!-- Pochi parametri -->
@@ -761,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">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas> <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>
</div>
</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>
<template x-for="v in values" :key="v.key">
<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>
</tbody>
</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>
<table x-show="result" class="tmf-table text-xs">
<tbody>
<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>
</template>
</tbody>
</table>
</div> </div>
</div> </div>
</template> </template>
@@ -90,7 +90,7 @@ const PREVIEW = {
check('#102 senza immagine: summary vuoto', wi.imagePreviewSummary === ''); check('#102 senza immagine: summary vuoto', wi.imagePreviewSummary === '');
global.URL.createObjectURL = () => 'blob:x'; global.URL.createObjectURL = () => 'blob:x';
global.URL.revokeObjectURL = () => {}; 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'); check('#102 _setImage imposta URL e nome', wi.imagePreviewUrl === 'blob:x' && wi.imageName === 'a.bmp');
wi.onImagePreviewLoad({ naturalWidth: 2000, naturalHeight: 1000 }); wi.onImagePreviewLoad({ naturalWidth: 2000, naturalHeight: 1000 });
check('#102 senza scala: solo pixel', /2[. ]?000 × 1[. ]?000 px$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary); 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 === '' check('#102 _setImage(null) azzera', wi.imagePreviewUrl === '' && wi.imageFile === null && wi.imageName === ''
&& wi.imagePx === null && wi.imageBroken === false && wi.imagePreviewSummary === ''); && 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,5 +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 "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
@@ -3507,3 +3507,55 @@ msgstr "field"
#: templates/maker/task_editor.html (#102) #: templates/maker/task_editor.html (#102)
msgid "Anteprima non disponibile per questo formato" msgid "Anteprima non disponibile per questo formato"
msgstr "Preview not available for this format" 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"
@@ -3491,3 +3491,55 @@ msgstr "campo"
#: templates/maker/task_editor.html (#102) #: templates/maker/task_editor.html (#102)
msgid "Anteprima non disponibile per questo formato" msgid "Anteprima non disponibile per questo formato"
msgstr "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"