1bfcfaa5f2
Rotte maker: elenco stazioni con camera, grab per stazione, stato calibrazione dall'acquisitore (C1), inoltro a B3. Pagina stazioni: stato e link «Calibra». Test con api_client e requests finti; traduzioni IT/EN. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
182 lines
7.0 KiB
JavaScript
182 lines
7.0 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: '',
|
|
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();
|
|
}
|
|
},
|
|
};
|
|
}
|