From 6a874e85f66e0379be05fe7ba79f3297ac493289 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 6 Aug 2026 16:09:27 +0200 Subject: [PATCH] Spigoli e illuminazione obliqua nella vista 3D La prima versione era poco leggibile: l'ombreggiatura usava solo l'inclinazione frontale, quindi le facce complanari di un pezzo piatto uscivano tutte dello stesso grigio e la forma non si distingueva. Due correzioni: - luce direzionale obliqua invece che frontale, con una rampa di luminosita' piu' ampia, cosi' le facce orientate diversamente si separano davvero; - soprattutto, gli spigoli. Sopra alle superfici si disegnano ora gli spigoli vivi (due facce che formano un angolo netto) e la silhouette (faccia visibile contro faccia nascosta). Sono le linee che rendono riconoscibile un pezzo meccanico, le stesse che traccerebbe un disegnatore. Per distinguere uno spigolo vivo da una suddivisione interna della tassellatura servono le facce adiacenti, che un STL non fornisce perche' ripete i vertici a ogni triangolo. Mesh.indexed() li fonde e ricostruisce la topologia: sul pezzo di prova 912 ripetizioni diventano 144 vertici reali, 304 facce e 456 spigoli, senza bordi liberi. La mesh risulta chiusa e la caratteristica di Eulero indica cinque fori passanti, come il pezzo. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 ++ creoparams/mesh.py | 32 +++++++ creoparams/viewer.py | 223 +++++++++++++++++++++++++++---------------- test_regression.py | 19 +++- 4 files changed, 197 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 10a5ad6..fd95473 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,14 @@ Produce una **pagina HTML autonoma**: la apri con un doppio clic e ruoti il pezzo col mouse, ingrandisci con la rotella, sposti col tasto destro. Nessuna libreria esterna, nessuna connessione, funziona anche da una cartella di rete. +Un pezzo meccanico solo ombreggiato risulta illeggibile, perché le facce +complanari hanno tutte lo stesso colore. Sopra alle superfici vengono quindi +disegnati **gli spigoli vivi** (dove due facce formano un angolo netto) e la +**silhouette** (dove una faccia visibile incontra una nascosta) — le stesse +linee che traccerebbe un disegnatore. Per distinguerli servono le facce +adiacenti a ogni spigolo, che `Mesh.indexed()` ricava fondendo i vertici +ripetuti dell'STL (sul pezzo di prova: 912 ripetizioni → 144 vertici reali). + La forma **non** viene dal `.prt`: viene da una **mesh STL esportata da Creo**, cercata automaticamente accanto al modello (`9258400207.prt.11` → `9258400207.stl`). Se manca, il programma lo dice invece di fallire. diff --git a/creoparams/mesh.py b/creoparams/mesh.py index 23c8bd3..bc31f34 100644 --- a/creoparams/mesh.py +++ b/creoparams/mesh.py @@ -68,6 +68,38 @@ class Mesh: + a[2]*(b[0]*c[1]-b[1]*c[0])) / 6 return abs(total) + def indexed(self, tolerance: int = 5): + """Vertici unici, facce e spigoli, con le facce adiacenti a ciascuno. + + Un STL ripete i vertici a ogni triangolo: fondendoli possiamo sapere + quali facce condividono uno spigolo, e quindi distinguere gli spigoli + vivi del pezzo dalle suddivisioni interne della tassellatura. Sono + quelli che rendono leggibile un modello meccanico. + """ + lookup, vertices, faces = {}, [], [] + for triangle in self.triangles: + face = [] + for vertex in triangle: + key = tuple(round(c, tolerance) for c in vertex) + index = lookup.get(key) + if index is None: + index = len(vertices) + lookup[key] = index + vertices.append([round(c, 4) for c in vertex]) + face.append(index) + if len(set(face)) == 3: # scarta i triangoli degeneri + faces.append(face) + + edges = {} + for number, (a, b, c) in enumerate(faces): + for pair in ((a, b), (b, c), (c, a)): + key = (min(pair), max(pair)) + edges.setdefault(key, []).append(number) + + edge_list = [[a, b, adj[0], adj[1] if len(adj) > 1 else -1] + for (a, b), adj in edges.items()] + return vertices, faces, edge_list + def to_dict(self) -> dict: lo, hi = self.bounds return { diff --git a/creoparams/viewer.py b/creoparams/viewer.py index 861278f..b7214e9 100644 --- a/creoparams/viewer.py +++ b/creoparams/viewer.py @@ -4,9 +4,18 @@ La pagina e' autonoma: mesh, stile e codice sono dentro il file. Nessuna libreria esterna, nessuna connessione. Si apre con un doppio clic, anche da una cartella di rete, e funziona su qualunque browser recente. -Il disegno usa una canvas 2D con ordinamento per profondita' (painter's -algorithm): per le mesh di un componente meccanico e' abbondante e non -richiede WebGL, che su alcune postazioni aziendali e' disattivato. +Il disegno usa una canvas 2D con ordinamento per profondita' invece di WebGL, +che su alcune postazioni aziendali e' disattivato: per la mesh di un +componente meccanico e' abbondante. + +Un pezzo meccanico ombreggiato e basta risulta illeggibile, perche' facce +complanari hanno tutte lo stesso colore. Qui sopra alle facce disegniamo: + +- gli **spigoli vivi**, dove due facce formano un angolo netto; +- la **silhouette**, dove una faccia visibile ne incontra una nascosta. + +Sono le stesse linee che un disegnatore traccerebbe a mano, e sono cio' che +rende riconoscibile la forma. """ from __future__ import annotations @@ -24,27 +33,30 @@ TEMPLATE = """ :root{color-scheme:light dark} *{box-sizing:border-box} body{margin:0;font:14px system-ui,-apple-system,Segoe UI,sans-serif; - background:#f4f5f7;color:#1a1c1e;display:flex;flex-direction:column; + background:#eceef1;color:#16181c;display:flex;flex-direction:column; height:100vh;overflow:hidden} - header{padding:.7rem 1rem;background:#fff;border-bottom:1px solid #d8dbe0; - display:flex;gap:1.2rem;align-items:baseline;flex-wrap:wrap} - h1{font-size:1rem;margin:0;font-weight:600} - .meta{color:#5c6370;font-size:.82rem} - .meta b{color:#1a1c1e;font-weight:600} + header{padding:.7rem 1rem;background:#fff;border-bottom:1px solid #d2d6dc; + display:flex;gap:1.1rem;align-items:baseline;flex-wrap:wrap} + h1{font-size:1rem;margin:0;font-weight:600;letter-spacing:.01em} + .meta{color:#5a616b;font-size:.82rem} #stage{flex:1;position:relative;min-height:0} canvas{display:block;width:100%%;height:100%%;cursor:grab;touch-action:none} canvas.drag{cursor:grabbing} - footer{padding:.5rem 1rem;background:#fff;border-top:1px solid #d8dbe0; - color:#5c6370;font-size:.78rem;display:flex;gap:1rem;flex-wrap:wrap} - button{font:inherit;padding:.25rem .7rem;border:1px solid #c3c7ce; + footer{padding:.5rem 1rem;background:#fff;border-top:1px solid #d2d6dc; + color:#5a616b;font-size:.78rem;display:flex;gap:.6rem; + align-items:center;flex-wrap:wrap} + footer .hint{margin-right:auto} + button{font:inherit;padding:.28rem .7rem;border:1px solid #c2c7ce; border-radius:5px;background:#fff;color:inherit;cursor:pointer} button:hover{background:#eef0f3} + button[aria-pressed="true"]{background:#1f5fa8;border-color:#1f5fa8;color:#fff} @media (prefers-color-scheme:dark){ - body{background:#16181c;color:#e6e8ea} - header,footer{background:#1e2126;border-color:#31353c} - .meta{color:#9aa1ab} .meta b{color:#e6e8ea} - button{background:#252930;border-color:#3a3f47;color:#e6e8ea} - button:hover{background:#2e333b} + body{background:#0f1114;color:#e8eaed} + header,footer{background:#181b20;border-color:#2c313a} + .meta{color:#98a0ab} + button{background:#20242b;border-color:#353b45;color:#e8eaed} + button:hover{background:#2a2f38} + button[aria-pressed="true"]{background:#4c8ee0;border-color:#4c8ee0;color:#0f1114} } @@ -55,77 +67,115 @@ TEMPLATE = """
- Trascina per ruotare · rotella per ingrandire · tasto destro per spostare + Trascina per ruotare · rotella per ingrandire + · tasto destro per spostare + + -
@@ -171,11 +223,14 @@ resize(); def build(mesh, title: str, subtitle: str = "") -> str: """Pagina HTML autonoma con il modello incorporato.""" - flat = [[round(v, 4) for vertex in t for v in vertex] for t in mesh.triangles] + vertices, faces, edges = mesh.indexed() + compact = lambda data: json.dumps(data, separators=(",", ":")) return TEMPLATE % { "title": escape(title), "subtitle": escape(subtitle), - "mesh": json.dumps(flat, separators=(",", ":")), + "vertices": compact(vertices), + "faces": compact(faces), + "edges": compact(edges), } diff --git a/test_regression.py b/test_regression.py index 9f680a6..8a17083 100644 --- a/test_regression.py +++ b/test_regression.py @@ -222,7 +222,24 @@ class TestMesh(unittest.TestCase): for marker in ('src="http', "href=\"http", "@import"): self.assertNotIn(marker, page, "la pagina carica risorse esterne") # la mesh deve essere incorporata, non referenziata - self.assertIn("const TRI = [[", page) + self.assertIn("const V = [[", page) + self.assertIn("], F = [[", page) + + def test_topologia_spigoli(self): + """Gli spigoli servono a rendere leggibile la forma: senza facce + adiacenti non si distinguono quelli vivi dalla tassellatura.""" + from creoparams.mesh import read_stl + for part, stl in self.pairs: + with self.subTest(file=os.path.basename(stl)): + mesh = read_stl(stl) + vertices, faces, edges = mesh.indexed() + self.assertLess(len(vertices), len(mesh) * 3, + "i vertici ripetuti non sono stati fusi") + self.assertGreater(len(edges), len(faces)) + # ogni spigolo appartiene ad almeno una faccia valida + for a, b, f0, f1 in edges: + self.assertNotEqual(a, b) + self.assertTrue(0 <= f0 < len(faces)) def read_stl_cached(path):