// «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: '', dxfFile: null, 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.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 || {}); }, get calibrated() { return this.calState === 'calibrated'; }, get canRun() { return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy; }, _setImage(file) { if (this.imageSrc) URL.revokeObjectURL(this.imageSrc); this.imageFile = file; this.imageName = file.name || ''; this.imageSrc = URL.createObjectURL(file); this.result = null; }, 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; }, // 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); 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(); } }, }; }