Compare commits

...

6 Commits

Author SHA1 Message Date
Adriano 4791c34205 merge #101 101b: anteprima DXF disegnata con path (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:06:54 +01:00
Adriano e582df7962 maker: anteprima DXF disegnata con path e viewBox camel (Refs #101)
Un solo <path :d="dxfPreviewD()"> al posto di template x-for + polyline
(x-for non funziona nel namespace SVG) e x-bind:view-box.camel al posto
di :viewBox (il parser HTML abbassa il nome dell'attributo).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:02:04 +01:00
Adriano a7b91b5508 merge #101 101b: anteprima DXF nel wizard (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:59:25 +01:00
Adriano addf5a2598 merge #101 101a: anteprima DXF nel wizard (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:59:14 +01:00
Adriano bdb1e8c8b9 maker: anteprima del DXF nel wizard «Crea ricetta da DXF» (Refs #101)
Proxy Flask /maker/api/vision/dxf-preview (piano B), stato e funzioni
dxfPreview* nel wizard, riquadro SVG con L x H sotto il campo .dxf,
traduzioni e test (proxy, JS con node, pagina).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:56:45 +01:00
Adriano ebaafbe6e9 vision: anteprima DXF nel worker e nel backend (Refs #101)
Worker: POST /dxf/preview legge il DXF con read_xld_dxf (lo stesso lettore di
CompareDxf) e ritorna contorni in mm con y verso il basso, bbox, dimensioni,
entita e punti (tetto 20000, decimazione uniforme). Backend: POST
/api/vision/dxf-preview (Maker) che inoltra al worker; 422 inoltrato.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:55:43 +01:00
13 changed files with 562 additions and 2 deletions
+14
View File
@@ -366,6 +366,20 @@ async def preview(
return await vision_service.preview(db, task, await _read_image(image)) return await vision_service.preview(db, task, await _read_image(image))
@router.post("/dxf-preview")
async def dxf_preview(
dxf: UploadFile = File(...),
current_user: User = Depends(require_maker),
) -> dict:
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
dxf_bytes = await dxf.read()
if not validate_file_size(len(dxf_bytes)):
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
return await vision_service.dxf_preview(dxf_bytes)
@router.post("/tasks/{task_id}/recipe/dxf-compare") @router.post("/tasks/{task_id}/recipe/dxf-compare")
async def create_dxf_compare_recipe( async def create_dxf_compare_recipe(
task_id: int, task_id: int,
+19
View File
@@ -224,6 +224,25 @@ async def dxf_compare_recipe(
return body return body
async def dxf_preview(dxf_bytes: bytes, worker_url: str | None = None) -> dict:
"""#101: anteprima del DXF (contorni in mm + dimensioni) dal worker. Sostituita dai test."""
body = await call_worker_multipart(
"/dxf/preview",
files={"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream")},
data={},
worker_url=worker_url,
)
# forma minima del contratto: senza contorni e dimensioni non c'e' nulla da mostrare
required = {"contorni", "bbox", "larghezza_mm", "altezza_mm", "entita", "punti"}
if not required <= body.keys():
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: missing "
+ ", ".join(sorted(required - body.keys())),
)
return body
# Stati di fetch_station_calibration (#83) # Stati di fetch_station_calibration (#83)
CAL_OK = "ok" CAL_OK = "ok"
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
@@ -0,0 +1,90 @@
"""#101: POST /api/vision/dxf-preview, anteprima del DXF nel wizard.
Il worker non gira nei test: `vision_service.dxf_preview` si sostituisce.
"""
import pytest
from fastapi import HTTPException
from src.backend.config import settings
from src.backend.services import vision_service
from src.backend.tests.conftest import auth_headers
BODY = {
"contorni": [[[0.0, 0.0], [10.0, 0.0], [10.0, 5.0], [0.0, 5.0], [0.0, 0.0]]],
"bbox": {"xmin": 0.0, "ymin": 0.0, "xmax": 10.0, "ymax": 5.0},
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 5,
}
def _post(client, user, content=b"0\nEOF"):
return client.post(
"/api/vision/dxf-preview",
headers=auth_headers(user),
files={"dxf": ("d.dxf", content, "application/octet-stream")},
)
@pytest.mark.asyncio
async def test_dxf_preview_forwards_the_file_and_returns_the_worker_body(
client, maker_user, monkeypatch,
):
calls = []
async def fake(dxf_bytes, worker_url=None):
calls.append(dxf_bytes)
return BODY
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
resp = await _post(client, maker_user, b"DXF-BYTES")
assert resp.status_code == 200, resp.text
assert resp.json() == BODY
assert calls == [b"DXF-BYTES"]
@pytest.mark.asyncio
async def test_dxf_preview_forwards_the_worker_422(client, maker_user, monkeypatch):
async def fake(dxf_bytes, worker_url=None):
raise HTTPException(status_code=422, detail="DXF illeggibile: file vuoto")
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
resp = await _post(client, maker_user, b"")
assert resp.status_code == 422
assert resp.json()["detail"] == "DXF illeggibile: file vuoto"
@pytest.mark.asyncio
async def test_dxf_preview_requires_maker(client, measurement_tec_user, monkeypatch):
async def fake(dxf_bytes, worker_url=None):
return BODY
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
resp = await _post(client, measurement_tec_user)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_dxf_preview_too_big_is_422(client, maker_user, monkeypatch):
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
resp = await _post(client, maker_user, b"x" * 10)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_dxf_preview_service_posts_to_worker_and_checks_shape(monkeypatch):
seen = {}
async def fake_multipart(path, *, files, data, worker_url=None, timeout=120.0):
seen.update(path=path, files=files)
return BODY
monkeypatch.setattr(vision_service, "call_worker_multipart", fake_multipart)
assert await vision_service.dxf_preview(b"abc") == BODY
assert seen["path"] == "/dxf/preview" and seen["files"]["dxf"][1] == b"abc"
async def malformed(path, *, files, data, worker_url=None, timeout=120.0):
return {"contorni": []}
monkeypatch.setattr(vision_service, "call_worker_multipart", malformed)
with pytest.raises(HTTPException) as exc:
await vision_service.dxf_preview(b"abc")
assert exc.value.status_code == 502
@@ -701,6 +701,25 @@ def api_dxf_compare_recipe(task_id: int):
return jsonify(resp), 200 return jsonify(resp), 200
# #101: anteprima del DXF appena scelto nel wizard (contorni letti dal vision worker)
@maker_bp.route("/api/vision/dxf-preview", methods=["POST"])
@login_required
@role_required("Maker")
def api_dxf_preview():
"""Proxy: anteprima del DXF (contorni, bbox, L x H) prima di creare la ricetta (#101)."""
dxf = request.files.get("dxf")
if dxf is None or not dxf.filename:
return jsonify({"error": True, "detail": _("Serve il file DXF")}), 400
resp = api_client.post(
"/api/vision/dxf-preview",
files={"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream")},
)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"]) @maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required @login_required
def api_get_file(file_path: str): def api_get_file(file_path: str):
@@ -21,6 +21,10 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
imageName: '', imageName: '',
imageSrc: '', imageSrc: '',
dxfFile: null, 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 mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto
bandMm: 0.1, bandMm: 0.1,
minPointsPct: 95, minPointsPct: 95,
@@ -87,7 +91,67 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
const f = ev.target.files && ev.target.files[0]; const f = ev.target.files && ev.target.files[0];
if (f) this._setImage(f); if (f) this._setImage(f);
}, },
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; }, 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 <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 // Frame dalla camera della stazione scelta
async grab() { async grab() {
@@ -662,7 +662,7 @@
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== --> <!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
<template x-if="task.task_type === 'dxf_compare'"> <template x-if="task.task_type === 'dxf_compare'">
<div class="mt-4 pt-3 border-t border-[var(--border-color)]" <div class="mt-4 pt-3 border-t border-[var(--border-color)]"
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})"> x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata'), 'dxfEntities': _('entità')}|tojson_attr }})">
<button type="button" class="btn btn-secondary text-xs" @click="toggle()"> <button type="button" class="btn btn-secondary text-xs" @click="toggle()">
{{ _('Crea ricetta da DXF') }} {{ _('Crea ricetta da DXF') }}
</button> </button>
@@ -714,6 +714,20 @@
<div> <div>
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label> <label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)"> <input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
<!-- #101: anteprima del disegno appena scelto il .dxf -->
<p x-show="dxfPreviewBusy" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Lettura DXF…') }}</p>
<p x-show="dxfPreviewError" class="mt-1 text-xs text-red-500" x-text="dxfPreviewError"></p>
<div x-show="dxfPreview" class="mt-2">
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
class="block w-full rounded border border-[var(--border-color)]"
style="max-height: 240px; background: var(--bg-primary);">
<!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox -->
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
stroke-width="1.5" vector-effect="non-scaling-stroke"
stroke-linejoin="round"></path>
</svg>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
</div>
</div> </div>
</div> </div>
<!-- Pochi parametri --> <!-- Pochi parametri -->
@@ -0,0 +1,90 @@
/**
* #101: anteprima DXF del wizard «Crea ricetta da DXF», con plain node.
* Copre dxfPreviewPaths, dxfPreviewViewBox, dxfPreviewSummary e lo scarto
* di una risposta arrivata dopo un cambio di file.
*
* Run from src/frontend/flask_app: node tests/js/test_dxf_preview.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
global.document = { documentElement: { lang: 'it' } };
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'js', 'dxf_recipe_wizard.js'), 'utf8',
);
eval(src + ';global.dxfRecipeWizard = dxfRecipeWizard;');
let failures = 0;
function check(label, cond, extra) {
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const PREVIEW = {
contorni: [[[0, -5], [12.3, -5], [12.3, 0], [0, 0], [0, -5]], [[1, -1]]],
bbox: { xmin: 0, ymin: -5.1, xmax: 12.3, ymax: 0 },
larghezza_mm: 12.3, altezza_mm: 5.1, entita: 2, punti: 6,
};
(async () => {
const w = dxfRecipeWizard(1, () => 'tok', { dxfEntities: 'entità' });
check('senza anteprima: nessun path', w.dxfPreviewPaths().length === 0);
check('senza anteprima: summary vuoto', w.dxfPreviewSummary === '');
w.dxfPreview = PREVIEW;
const paths = w.dxfPreviewPaths();
check('un contorno con >1 punti diventa una polyline (quello da 1 punto no)', paths.length === 1);
check('formato points', paths[0] === '0,-5 12.3,-5 12.3,0 0,0 0,-5', paths[0]);
// #101: path unico per <path :d>
check('dxfPreviewD', w.dxfPreviewD() === 'M 0 -5 L 12.3 -5 L 12.3 0 L 0 0 L 0 -5', w.dxfPreviewD());
w.dxfPreview = null;
check('dxfPreviewD vuoto senza anteprima', w.dxfPreviewD() === '');
w.dxfPreview = PREVIEW;
const vb = w.dxfPreviewViewBox().split(' ').map(Number);
const m = Math.max(0.5, 0.05 * 12.3);
check('viewBox con margine 5%', Math.abs(vb[0] + m) < 1e-3 && Math.abs(vb[2] - (12.3 + 2 * m)) < 1e-3, vb.join(' '));
// Disegno minuscolo: margine minimo 0.5 mm
w.dxfPreview = { ...PREVIEW, bbox: { xmin: 0, ymin: 0, xmax: 1, ymax: 1 } };
check('margine minimo 0.5 mm', Math.abs(w.dxfPreviewViewBox().split(' ').map(Number)[0] + 0.5) < 1e-3);
w.dxfPreview = PREVIEW;
const s = w.dxfPreviewSummary;
check('summary L × H mm · N entità', s.includes('×') && s.includes('mm') && s.endsWith('2 entità')
&& /12[,.]30/.test(s) && /5[,.]10/.test(s), s);
// Risposta tardiva scartata: due scelte di file, la prima risponde per ultima
const resolvers = [];
global.fetch = () => new Promise(res => resolvers.push(res));
global.FormData = class { append() {} };
const w2 = dxfRecipeWizard(1, () => 'tok', {});
const reply = (body, ok = true) => ({ ok, json: async () => body });
w2.dxfFile = { name: 'a.dxf' };
const p1 = w2.loadDxfPreview();
w2.dxfFile = { name: 'b.dxf' };
const p2 = w2.loadDxfPreview();
resolvers[1](reply(PREVIEW));
await p2;
resolvers[0](reply({ ...PREVIEW, entita: 99 }));
await p1;
check('la risposta vecchia non sovrascrive la nuova', w2.dxfPreview && w2.dxfPreview.entita === 2);
check('busy spento', w2.dxfPreviewBusy === false);
// File tolto: tutto azzerato
w2.dxfFile = null;
await w2.loadDxfPreview();
check('file tolto azzera anteprima ed errore', w2.dxfPreview === null && w2.dxfPreviewError === '');
// Errore 422 con detail
global.fetch = async () => reply({ detail: 'DXF illeggibile: x' }, false);
w2.dxfFile = { name: 'c.dxf' };
await w2.loadDxfPreview();
check('errore col detail', w2.dxfPreviewError === 'DXF illeggibile: x' && w2.dxfPreview === null, w2.dxfPreviewError);
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
console.log('tutto ok');
})();
@@ -0,0 +1,35 @@
"""Runs the plain-node DXF preview behaviour test (tests/js/test_dxf_preview.js).
Same thin pytest wrapper pattern as test_numpad_js.py: shells out to ``node``
and asserts the script's own exit code. Skips (does not fail) when node is
not installed.
Covers gitea:Adriano/TieMeasureFlow#101.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
JS_TEST = Path(__file__).resolve().parent / "js" / "test_dxf_preview.js"
def test_dxf_preview_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS di dxf_recipe_wizard.js saltato")
result = subprocess.run(
[node, str(JS_TEST)],
cwd=str(FLASK_APP_DIR),
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"tests/js/test_dxf_preview.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -210,6 +210,49 @@ class TestDxfCompareProxy:
mock_api_client.post.assert_not_called() mock_api_client.post.assert_not_called()
class TestDxfPreviewProxy:
"""#101: anteprima del DXF scelto nel wizard (proxy verso il backend)."""
URL = "/maker/api/vision/dxf-preview"
OK = {
"contorni": [[[0.0, -5.0], [10.0, -5.0], [10.0, 0.0], [0.0, 0.0]]],
"bbox": {"xmin": 0.0, "ymin": -5.0, "xmax": 10.0, "ymax": 0.0},
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 4,
}
def _post(self, client, with_file=True):
data = {"dxf": (io.BytesIO(b"0\nEOF"), "d.dxf")} if with_file else {}
return client.post(self.URL, data=data, content_type="multipart/form-data")
def test_forwards_file(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = self.OK
resp = self._post(logged_in_client)
assert resp.status_code == 200
assert resp.get_json()["larghezza_mm"] == 10.0
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/dxf-preview"
assert set(kwargs["files"]) == {"dxf"}
assert kwargs["files"]["dxf"][0] == "d.dxf"
assert kwargs["files"]["dxf"][1] == b"0\nEOF"
def test_missing_file_is_400(self, logged_in_client, mock_api_client):
resp = self._post(logged_in_client, with_file=False)
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "DXF illeggibile: x"}
resp = self._post(logged_in_client)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "DXF illeggibile: x"
def test_requires_maker_role(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
assert self._post(logged_in_client).status_code == 403
mock_api_client.post.assert_not_called()
class TestPages: class TestPages:
def test_stations_page_has_calibration_cell(self, logged_in_client): def test_stations_page_has_calibration_cell(self, logged_in_client):
with patch("blueprints.admin.api_client") as admin_api: with patch("blueprints.admin.api_client") as admin_api:
@@ -236,4 +279,5 @@ class TestPages:
assert "dxfRecipeWizard(" in html assert "dxfRecipeWizard(" in html
assert "@dxf-recipe-saved" in html assert "@dxf-recipe-saved" in html
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -3489,3 +3489,13 @@ msgstr "Invalid response from the camera service"
#: templates/statistics/dashboard.html (#100) #: templates/statistics/dashboard.html (#100)
msgid "Scegli un punto per carta di controllo e istogramma" msgid "Scegli un punto per carta di controllo e istogramma"
msgstr "Choose a point for the control chart and histogram" msgstr "Choose a point for the control chart and histogram"
#: templates/maker/task_editor.html blueprints/maker.py (#101)
msgid "Serve il file DXF"
msgstr "The DXF file is required"
msgid "Lettura DXF…"
msgstr "Reading DXF…"
msgid "entità"
msgstr "entities"
@@ -3473,3 +3473,13 @@ msgstr "Indirizzo della camera non valido"
#: templates/admin/stations.html blueprints/admin.py (#88) #: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera" msgid "Risposta non valida dal servizio camera"
msgstr "Risposta non valida dal servizio camera" msgstr "Risposta non valida dal servizio camera"
#: templates/maker/task_editor.html blueprints/maker.py (#101)
msgid "Serve il file DXF"
msgstr "Serve il file DXF"
msgid "Lettura DXF…"
msgstr "Lettura DXF…"
msgid "entità"
msgstr "entità"
+87
View File
@@ -11,6 +11,7 @@ import io
import json import json
import os import os
import re import re
import tempfile # #101
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
@@ -288,3 +289,89 @@ async def recipes_dxf_compare(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"recipe build failed: {type(exc).__name__}: {exc}", detail=f"recipe build failed: {type(exc).__name__}: {exc}",
) from exc ) from exc
# #101: tetto ai punti dell'anteprima DXF (un profilo fitto non deve gonfiare
# la risposta ne' rallentare il disegno SVG nel browser).
_PREVIEW_MAX_POINTS = 20000
def _decimate(points: list[list[float]], keep: int) -> list[list[float]]:
"""Decimazione uniforme a `keep` punti (>= 2), primo e ultimo sempre inclusi."""
n = len(points)
if n <= keep or keep < 2:
return points
idx = sorted({round(i * (n - 1) / (keep - 1)) for i in range(keep)})
return [points[i] for i in idx]
def _dxf_preview(dxf_bytes: bytes) -> dict:
"""#101: legge il DXF con lo stesso lettore di CompareDxf (read_xld_dxf) e ne
ritorna i contorni in mm, asse y verso il basso (come immagine e SVG).
Funzione sincrona e pura: la rotta la esegue in un thread. Solleva
CostruttoreError per DXF vuoto, illeggibile o senza geometria.
"""
if not dxf_bytes:
raise CostruttoreError("DXF illeggibile: file vuoto")
from visionsuite.io.dxf import read_xld_dxf
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "disegno.dxf"
path.write_bytes(dxf_bytes)
try:
# flip_y=False + scale=1: coordinate native del DXF, in mm; il
# ribaltamento dell'asse y lo facciamo noi sotto (y = -y_dxf)
conts = list(read_xld_dxf(str(path), sampling_distance=0.05,
scale=1.0, flip_y=False))
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
contorni: list[list[list[float]]] = []
for cont in conts:
pts = np.asarray(cont.points, dtype=float) # (riga, colonna) = (y_dxf, x_dxf)
if pts.ndim != 2 or pts.shape[0] < 2:
continue
line = [[float(c), float(-r)] for r, c in pts]
if cont.closed and line[0] != line[-1]:
line.append(list(line[0])) # chiude il giro anche nella polilinea SVG
contorni.append(line)
if not contorni:
raise CostruttoreError("DXF senza geometria")
# #101: le dimensioni si misurano sui punti completi, prima della decimazione
xs = [p[0] for c in contorni for p in c]
ys = [p[1] for c in contorni for p in c]
xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys)
total = len(xs)
if total > _PREVIEW_MAX_POINTS:
# quota per contorno proporzionale, minimo 2 punti
contorni = [
_decimate(c, max(2, int(len(c) * _PREVIEW_MAX_POINTS / total))) for c in contorni
]
contorni = [[[round(x, 3), round(y, 3)] for x, y in c] for c in contorni]
xmin, xmax, ymin, ymax = (round(v, 3) for v in (xmin, xmax, ymin, ymax))
return {
"contorni": contorni,
"bbox": {"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax},
"larghezza_mm": round(xmax - xmin, 3),
"altezza_mm": round(ymax - ymin, 3),
"entita": len(contorni),
"punti": sum(len(c) for c in contorni),
}
@app.post("/dxf/preview")
async def dxf_preview(dxf: UploadFile = File(...)) -> dict:
"""#101: anteprima del disegno DXF (contorni + dimensioni in mm) per il
wizard «Crea ricetta da DXF». Solo lettura, leggera: nessun run slot."""
dxf_bytes = await dxf.read()
try:
return await asyncio.to_thread(_dxf_preview, dxf_bytes)
except CostruttoreError as exc:
raise _unprocessable(str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"dxf preview failed: {type(exc).__name__}: {exc}",
) from exc
@@ -80,3 +80,67 @@ async def test_dxf_compare_immagine_non_decodificabile_e_422():
async def test_dxf_compare_parametro_fuori_range_e_422(): async def test_dxf_compare_parametro_fuori_range_e_422():
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio") response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
assert response.status_code == 422 assert response.status_code == 422
# --- #101: POST /dxf/preview ---------------------------------------------------
def _rect_dxf(width: float, height: float, y0: float = 3.0) -> bytes:
"""DXF minimo con ezdxf: un rettangolo chiuso, y in alto rispetto a 0."""
import ezdxf
doc = ezdxf.new()
doc.modelspace().add_lwpolyline(
[(1, y0), (1 + width, y0), (1 + width, y0 + height), (1, y0 + height)], close=True,
)
buffer = io.StringIO()
doc.write(buffer)
return buffer.getvalue().encode("utf-8")
def _post_preview(dxf: bytes):
# TestClient sincrono: non serve pytest-asyncio (assente nel venv del worker)
from fastapi.testclient import TestClient
return TestClient(app).post(
"/dxf/preview", files={"dxf": ("d.dxf", dxf, "application/octet-stream")},
)
def test_dxf_preview_rettangolo_10x5():
resp = _post_preview(_rect_dxf(10, 5))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["larghezza_mm"] == 10 and body["altezza_mm"] == 5
assert body["entita"] == 1 and body["punti"] == sum(len(c) for c in body["contorni"])
# y ribaltata: il rettangolo stava a y 3..8 nel DXF, ora a -8..-3 (asse verso il basso)
assert body["bbox"] == {"xmin": 1, "ymin": -8, "xmax": 11, "ymax": -3}
ring = body["contorni"][0]
assert ring[0] == ring[-1] # contorno chiuso: primo == ultimo
assert all(-8 <= y <= -3 for _, y in ring)
@pytest.mark.parametrize("content", [b"", b"questo non e' un dxf"])
def test_dxf_preview_illeggibile_e_422(content):
resp = _post_preview(content)
assert resp.status_code == 422
assert resp.json()["detail"].startswith("DXF")
def test_dxf_preview_senza_geometria_e_422():
import ezdxf
doc = ezdxf.new()
buffer = io.StringIO()
doc.write(buffer)
resp = _post_preview(buffer.getvalue().encode("utf-8"))
assert resp.status_code == 422
assert "senza geometria" in resp.json()["detail"]
def test_dxf_preview_decima_oltre_il_tetto(monkeypatch):
from src.vision_worker import main
monkeypatch.setattr(main, "_PREVIEW_MAX_POINTS", 50)
import ezdxf
doc = ezdxf.new()
doc.modelspace().add_circle((0, 0), 20)
buffer = io.StringIO()
doc.write(buffer)
full = main._dxf_preview(buffer.getvalue().encode("utf-8"))
assert full["punti"] <= 50 + 1 and full["larghezza_mm"] == pytest.approx(40, abs=0.01) # dimensioni dai punti completi