// «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_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']; 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} 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) fitZoom: 1, // #104: zoom di Adatta (sotto o pari a questo il viewport segue l'immagine) panning: false, // #104: trascinamento in corso _pan: null, // #104: {x, y, left, top} al pointerdown 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 existing: null, // #105: risposta di /source (ricetta DXF gia' salvata sul task); null = nessuna existingInfo: false, // #105: «Ricetta esistente caricata...» dopo aver compilato il wizard existingWarn: '', // #105: avviso (immagine non salvata, file illeggibili) dxfFromRecipe: false, // #105: il DXF viene dalla ricetta (l' non puo' mostrarne il nome) _existingApplied: false, // #105: il wizard e' gia' stato compilato dalla ricetta (non sovrascrivere le modifiche) _existingReady: null, // #105: promessa della prima lettura di /source _srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta _dxfPickSeq: 0, // #105: sale a ogni scelta manuale del DXF; i file della ricetta arrivati dopo si scartano _imgPickSeq: 0, // #105: idem per l'immagine (_setImage) // #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone) init() { if (this.taskId) this._existingReady = this.loadExisting(); }, async toggle() { this.open = !this.open; if (this.open) { this.openExisting(); // #105: compila il wizard dalla ricetta esistente, in parallelo alle stazioni if (!this.stations.length) await this.loadStations(); } }, // #105: GET /source -> this.existing (null se 404 o errore) async loadExisting() { const seq = ++this._srcSeq; let found = null; try { const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare/source`); if (resp.ok) { const body = await resp.json().catch(() => null); if (body && typeof body === 'object' && !body.error) found = body; } } catch (e) { console.error('dxfRecipeWizard source', e); } if (seq !== this._srcSeq) return; // lettura piu' recente in corso this.existing = found; }, // #105: all'apertura, una sola volta, riempie il wizard con la ricetta esistente async openExisting() { if (this._existingReady) await this._existingReady; if (!this.existing || this._existingApplied) return; await this.applyExisting(); }, // #105: mm/px da px/mm, fino a 6 cifre significative e virgola decimale; '' se non valido _mmPxFrom(pxPerMm) { const v = Number(pxPerMm); if (!Number.isFinite(v) || v <= 0) return ''; return String(Number((1 / v).toPrecision(6))).replace('.', ','); }, // #105: un file della cartella ricetta -> File; null se assente o illeggibile (in quel caso avvisa) async _fetchRecipeFile(name) { try { const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/files/${encodeURIComponent(name)}`); if (!resp.ok) throw new Error('HTTP ' + resp.status); const blob = await resp.blob(); return new File([blob], name, { type: blob.type || '' }); } catch (e) { console.error('dxfRecipeWizard file', name, e); this.existingWarn = this.msgs.existingFilesFailed || 'Cannot read the recipe files'; return null; } }, // #105: valori salvati nei campi, poi DXF e immagine di riferimento dalla cartella ricetta async applyExisting() { const e = this.existing; if (!e) return; this._existingApplied = true; this.error = ''; this.notice = ''; this.existingWarn = ''; const band = Number(e.band_mm), pct = Number(e.min_points_pct); if (Number.isFinite(band) && band > 0) this.bandMm = band; if (Number.isFinite(pct) && pct > 0) this.minPointsPct = pct; this.dark = e.polarity === 'dark'; const mm = this._mmPxFrom(e.px_per_mm); if (mm) this.mmPx = mm; // il campo compare quando l'immagine e' da file this.existingInfo = true; // Sequenze lette prima di scaricare: se l'utente sceglie un file a mano nel frattempo, vince lui const dxfSeq = this._dxfPickSeq, imgSeq = this._imgPickSeq; const [dxf, img] = await Promise.all([ e.dxf ? this._fetchRecipeFile(e.dxf) : null, e.image ? this._fetchRecipeFile(e.image) : null, ]); if (dxf && dxfSeq === this._dxfPickSeq) { this.dxfFile = dxf; this.dxfFromRecipe = true; this.result = null; this.tested = false; this.loadDxfPreview(); } if (img && imgSeq === this._imgPickSeq) this._setImage(img, 'file'); // 'file': il campo mm/px compare gia' compilato if (!e.image && !this.existingWarn) this.existingWarn = this.msgs.noReferenceImage || 'Reference image not saved'; }, 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; this.tested = false; // #104 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; }, // #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() { // #98: la scala a mano sostituisce la calibrazione della stazione (#104: solo per immagini da file) return !!(this.stationId && (this.calibrated || this.useManualScale) && this.imageFile && this.dxfFile) && !this.busy; }, _setImage(file, source) { 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; this.tested = false; // #104 this.resPx = null; // #104 this.imageSource = file ? (source || '') : ''; // #104 this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo }, // #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.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) { const f = ev.target.files && ev.target.files[0]; if (f) this._setImage(f, 'file'); // #104 }, onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this._dxfPickSeq++; // #105: scelta a mano: il DXF della ricetta in arrivo si scarta this.dxfFromRecipe = false; // #105 this.result = null; this.tested = false; // #104 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' }), 'camera'); // #104 } } 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 = ''; // #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 { 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 (#104: solo con immagine da file) if (this.useManualScale) 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 }); // #105: il task ha ora una ricetta (etichetta «Modifica»); il wizard e' gia' allineato, non si ricompila this._existingApplied = true; this.existingInfo = false; this.existingWarn = ''; // #105: l'immagine di riferimento ora e' salvata con la ricetta await this.loadExisting(); } } } catch (e) { this.error = this.msgs.requestFailed || String(e); } 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 get values() { const out = (this.result && this.result.result && this.result.result.outputs) || {}; 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; }, // #104: altezza massima del viewport = min(70vh, 520px) _maxVpH() { return Math.min(0.7 * (typeof window !== 'undefined' && window.innerHeight ? window.innerHeight : 800), 520); }, // #104: con zoom <= Adatta il viewport e' alto come l'immagine scalata (min 200px), altrimenti al massimo get vpHeight() { const max = this._maxVpH(); if (!this.resPx || this.zoom > this.fitZoom * 1.0001) return max; return Math.min(max, Math.max(200, this.zoomH)); }, // #104: l'immagine e' piu' grande dello spazio: si puo' trascinare get canPan() { return !!this.resPx && this.zoom > this.fitZoom * 1.0001; }, get panCursor() { return this.canPan ? (this.panning ? 'grabbing' : 'grab') : 'default'; }, // #104: pan col puntatore (mouse e touch): scroll del viewport di quanto si muove il puntatore onPanDown(ev) { const vp = this.$refs.viewport; if (!vp || (ev.pointerType === 'mouse' && ev.button)) return; // #104: senza preventDefault Chromium manda pointercancel al primo movimento del mouse e il pan si ferma if (ev.preventDefault) ev.preventDefault(); this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop }; this.panning = true; try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ } }, onPanMove(ev) { const vp = this.$refs.viewport; if (!this._pan || !vp) return; vp.scrollLeft = this._pan.left - (ev.clientX - this._pan.x); vp.scrollTop = this._pan.top - (ev.clientY - this._pan.y); }, onPanEnd() { this._pan = null; this.panning = false; }, 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 (altezza massima, non quella corrente che segue l'immagine) fit() { const vp = this.$refs.viewport, img = this.$refs.img; if (!vp || !img || !img.naturalWidth || !vp.clientWidth) return; const z = Math.min(8, Math.max(0.05, Math.min(vp.clientWidth / img.naturalWidth, this._maxVpH() / img.naturalHeight))); this.zoom = z; this.fitZoom = z; 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) { 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(); } }, }; }