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 = """
@@ -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):