// «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//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' 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' 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 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 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(); } }, }; }