Files
TieMeasureFlow/src/frontend/flask_app/static/js/dxf_recipe_wizard.js
T

508 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// «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_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'<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}
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'<input type=file> 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'<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.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 <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' }), '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();
}
},
};
}