20f33234a8
Anteprima dell'immagine scelta/acquisita accanto a quella del DXF, con pixel e campo in mm (scala a mano o calibrazione stazione); fallback se il formato non e' mostrabile. Test JS e di pagina, voci it/en. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
293 lines
12 KiB
JavaScript
293 lines
12 KiB
JavaScript
// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF
|
||
// genera la ricetta dxf_compare (POST /maker/api/vision/tasks/<id>/recipe/dxf-compare).
|
||
// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true)
|
||
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
|
||
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
|
||
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_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
|
||
|
||
return {
|
||
taskId: taskId,
|
||
msgs: msgs || {},
|
||
open: false,
|
||
stations: [],
|
||
stationId: '',
|
||
calState: '', // '' | calibrated | uncalibrated | unreachable
|
||
calLabel: '',
|
||
calUrl: '',
|
||
imageFile: null, // immagine caricata o acquisita
|
||
imageName: '',
|
||
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,
|
||
dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti}
|
||
dxfPreviewBusy: false,
|
||
dxfPreviewError: '',
|
||
_dxfPreviewSeq: 0, // #101: una risposta arrivata dopo un cambio di file si scarta
|
||
mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto
|
||
bandMm: 0.1,
|
||
minPointsPct: 95,
|
||
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
||
busy: false,
|
||
grabbing: false,
|
||
error: '',
|
||
notice: '',
|
||
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
|
||
|
||
async toggle() {
|
||
this.open = !this.open;
|
||
if (this.open && !this.stations.length) await this.loadStations();
|
||
},
|
||
|
||
async loadStations() {
|
||
try {
|
||
const resp = await fetch('/maker/api/stations');
|
||
if (resp.ok) this.stations = await resp.json();
|
||
} catch (e) { console.error('dxfRecipeWizard stations', e); }
|
||
// Una sola stazione con camera: preselezionata
|
||
if (this.stations.length === 1) {
|
||
this.stationId = String(this.stations[0].id);
|
||
await this.onStation();
|
||
}
|
||
},
|
||
|
||
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
|
||
async onStation() {
|
||
this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calPxMm = null;
|
||
this.result = null;
|
||
if (!this.stationId) return;
|
||
const r = await fetchStationCalibration(this.stationId);
|
||
this.calState = r.state || 'unreachable';
|
||
this.calUrl = r.calibration_url || '';
|
||
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'; },
|
||
// #98: mm/px inserito a mano -> px/mm; null se vuoto o fuori da 0.00001..100 (limiti di /camera/calibrazioni/manuale, #95)
|
||
get manualPxMm() {
|
||
const raw = String(this.mmPx || '').trim().replace(',', '.');
|
||
if (!raw) return null;
|
||
const v = Number(raw);
|
||
if (!Number.isFinite(v) || v < 0.00001 || v > 100) return null;
|
||
return 1 / v;
|
||
},
|
||
// #98: compilato ma non valido -> errore in pagina
|
||
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && 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;
|
||
},
|
||
|
||
_setImage(file) {
|
||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||
// #102: con file null (immagine tolta) si azzera anche l'anteprima
|
||
this.imageFile = file || null;
|
||
this.imageName = (file && file.name) || '';
|
||
this.imageSrc = file ? URL.createObjectURL(file) : '';
|
||
this.imagePx = null;
|
||
this.imageBroken = false;
|
||
this.result = null;
|
||
},
|
||
|
||
// #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.manualPxMm !== null ? this.manualPxMm : (this.calibrated ? this.calPxMm : null);
|
||
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);
|
||
},
|
||
onDxf(ev) {
|
||
this.dxfFile = (ev.target.files && ev.target.files[0]) || null;
|
||
this.result = null;
|
||
this.loadDxfPreview(); // #101: anteprima appena si sceglie il file
|
||
},
|
||
|
||
// #101: legge il DXF sul vision worker (via Flask e backend) e ne mostra i contorni
|
||
async loadDxfPreview() {
|
||
const seq = ++this._dxfPreviewSeq;
|
||
this.dxfPreview = null;
|
||
this.dxfPreviewError = '';
|
||
this.dxfPreviewBusy = false;
|
||
if (!this.dxfFile) return; // file tolto: tutto azzerato
|
||
this.dxfPreviewBusy = true;
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||
const resp = await fetch('/maker/api/vision/dxf-preview', {
|
||
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||
});
|
||
const body = await resp.json().catch(() => ({}));
|
||
if (seq !== this._dxfPreviewSeq) return; // file cambiato nel frattempo
|
||
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
|
||
else this.dxfPreview = body;
|
||
} catch (e) {
|
||
if (seq !== this._dxfPreviewSeq) return;
|
||
this.dxfPreviewError = this.msgs.requestFailed || String(e);
|
||
}
|
||
if (seq === this._dxfPreviewSeq) this.dxfPreviewBusy = false;
|
||
},
|
||
|
||
// #101: una stringa `points` per ogni contorno, per <polyline>
|
||
dxfPreviewPaths() {
|
||
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||
return contorni.filter(c => c && c.length > 1).map(c => c.map(p => p[0] + ',' + p[1]).join(' '));
|
||
},
|
||
|
||
// #101: tutti i contorni in un solo attributo `d` ("M x y L x y ..."): x-for non funziona dentro <svg>
|
||
dxfPreviewD() {
|
||
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||
return contorni.filter(c => c && c.length > 1)
|
||
.map(c => c.map((p, i) => (i ? 'L ' : 'M ') + p[0] + ' ' + p[1]).join(' ')).join(' ');
|
||
},
|
||
|
||
// #101: viewBox sul bbox con margine 5% (minimo 0.5 mm per lato)
|
||
dxfPreviewViewBox() {
|
||
const b = this.dxfPreview && this.dxfPreview.bbox;
|
||
if (!b) return '0 0 1 1';
|
||
const w = b.xmax - b.xmin, h = b.ymax - b.ymin;
|
||
const m = Math.max(0.5, 0.05 * Math.max(w, h));
|
||
return [b.xmin - m, b.ymin - m, w + 2 * m, h + 2 * m].map(v => +v.toFixed(3)).join(' ');
|
||
},
|
||
|
||
// #101: «L × H mm · N entità», decimale con virgola (2 decimali) secondo la lingua pagina
|
||
get dxfPreviewSummary() {
|
||
const d = this.dxfPreview;
|
||
if (!d) return '';
|
||
const f = v => Number(v).toLocaleString(document.documentElement.lang || 'it',
|
||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
return `${f(d.larghezza_mm)} × ${f(d.altezza_mm)} mm · ${d.entita} ${this.msgs.dxfEntities || 'entities'}`;
|
||
},
|
||
|
||
// Frame dalla camera della stazione scelta
|
||
async grab() {
|
||
if (!this.stationId || this.grabbing) return;
|
||
this.grabbing = true;
|
||
this.error = '';
|
||
try {
|
||
const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, {
|
||
method: 'POST', headers: { 'X-CSRFToken': this._csrf() },
|
||
});
|
||
if (!resp.ok) {
|
||
const body = await resp.json().catch(() => ({}));
|
||
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' }));
|
||
}
|
||
} catch (e) {
|
||
this.error = this.msgs.requestFailed || String(e);
|
||
}
|
||
this.grabbing = false;
|
||
},
|
||
|
||
_csrf() { return typeof csrf === 'function' ? csrf() : csrf; },
|
||
|
||
// Il 422 porta `detail` (stringa o lista) oppure `errors`
|
||
_errorText(body) {
|
||
if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; ');
|
||
const d = body.detail;
|
||
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; ');
|
||
if (d && typeof d === 'object') return JSON.stringify(d);
|
||
return d || this.msgs.requestFailed || 'Error';
|
||
},
|
||
|
||
async run(save) {
|
||
if (!this.canRun) return;
|
||
this.busy = true;
|
||
this.error = '';
|
||
this.notice = '';
|
||
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));
|
||
fd.append('band_mm', String(this.bandMm));
|
||
fd.append('min_points_pct', String(this.minPointsPct));
|
||
fd.append('polarity', this.dark ? 'dark' : 'light');
|
||
fd.append('save', save ? 'true' : 'false');
|
||
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, {
|
||
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||
});
|
||
const body = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || body.error) {
|
||
this.error = this._errorText(body);
|
||
} else {
|
||
this.result = body;
|
||
this.$nextTick(() => this.drawOverlay());
|
||
if (save && body.saved) {
|
||
this.notice = this.msgs.saved || 'Saved';
|
||
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
|
||
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
|
||
}
|
||
}
|
||
} catch (e) {
|
||
this.error = this.msgs.requestFailed || String(e);
|
||
}
|
||
this.busy = false;
|
||
},
|
||
|
||
// 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] }));
|
||
},
|
||
|
||
fmt(v) {
|
||
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
|
||
{ maximumFractionDigits: 3 }) : '-';
|
||
},
|
||
|
||
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
|
||
drawOverlay() {
|
||
const img = this.$refs.img, cv = this.$refs.overlay;
|
||
if (!img || !cv || !img.naturalWidth) return;
|
||
cv.width = img.naturalWidth;
|
||
cv.height = img.naturalHeight;
|
||
const g = cv.getContext('2d');
|
||
g.clearRect(0, 0, cv.width, cv.height);
|
||
g.lineWidth = Math.max(2, cv.width / 400);
|
||
g.lineJoin = 'round';
|
||
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
|
||
for (const item of shapes) {
|
||
const rows = item.rows || [], cols = item.cols || [];
|
||
if (!rows.length || rows.length !== cols.length) continue;
|
||
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
|
||
g.beginPath();
|
||
g.moveTo(cols[0], rows[0]);
|
||
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
|
||
g.stroke();
|
||
}
|
||
},
|
||
};
|
||
}
|