diff --git a/.gitignore b/.gitignore index 6892384..526f1e6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,13 @@ *.neu *.stp *.step +# Anche gli export sono disegni aziendali: geometria e tavole complete. +*.stl +*.dxf +*.dwg +*.igs +*.iges +*.pdf # --- Output generati -------------------------------------------------------- metadati.json diff --git a/README.md b/README.md index a605148..10a5ad6 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Opzioni principali: | `--features` | include i parametri delle feature (fori, lavorazioni) oltre a quelli di modello | | `--system` | include i parametri generati da Creo (`PTC_*`, `SMT_*`) | | `--preview [DEST]` | salva l'anteprima incorporata come JPEG | +| `--3d [DEST]` | genera la pagina HTML per ruotare il modello nel browser | | `--quiet` | non stampa la tabella | Codici di uscita: `0` estrazione riuscita, `1` nessun file trovato, `2` nessun @@ -106,6 +107,47 @@ necessariamente quello attuale del modello. Nel JSON tutto questo sta sotto `drawing`, insieme all'elenco delle primitive grafiche presenti (`prim_text`, `prim_arc`, `prim_multiline`, …). +## Vista 3D del modello + +```bash +python3 -m creoparams 9258400207.prt.11 --3d viste/ +``` + +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. + +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. + +Quando la mesh c'è, il record JSON guadagna un blocco `geometry` con ingombro, +volume e area — le uniche grandezze geometriche ottenibili senza Creo: + +```json +"geometry": {"triangles": 304, "size": [150.0, 4.0, 150.0], + "volume": 87928.502, "area": 49050.5} +``` + +Il **peso non viene calcolato**: servirebbe la densità, che dipende dal +materiale, e il materiale qui è testo libero (`Anticordal 100` è alluminio, +`ACC.INOX LAMIERA SP.4` è acciaio). Moltiplicare per la densità sbagliata +darebbe un errore del 300 %. + +### Perché serve la mesh esportata + +La geometria dentro il `.prt` è scritta nel formato del kernel **Granite** di +PTC ed è compressa. Verificato con un attacco a testo noto: presi da un export +STEP i 22 valori esatti del modello (raggi 1,6 / 2,1 / 2,5 / 2,75 / 5,25 e +coordinate fino a 75,0) e cercati nel file in cinque codifiche su tutti i +blocchi — **nessun riscontro**. + +Per leggere i `.prt` nativi senza Creo esiste la via ufficiale: il +[Granite Interoperability Kernel](https://support.ptc.com/products/granite/gplugs) +di PTC, che legge geometria, attributi e parametri senza Creo installato. +Quando fosse disponibile, si innesta al posto di `mesh.py` senza toccare il +resto. + ## Come funziona Un file nativo Creo è un container con header ASCII in chiaro, un indice delle diff --git a/creoparams/cli.py b/creoparams/cli.py index 55820b1..ba24043 100644 --- a/creoparams/cli.py +++ b/creoparams/cli.py @@ -10,7 +10,8 @@ import sys from . import ugc from .extract import extract as extract_file -from .extract import latest_versions, read_preview, split_filename +from .extract import (find_mesh, latest_versions, read_mesh, + read_preview, split_filename) CREO_EXTENSIONS = (".prt", ".asm", ".drw", ".frm", ".lay") @@ -140,6 +141,32 @@ def _save_previews(paths: "list[str]", dest: str, stream) -> None: file=stream) +def _save_viewers(paths: "list[str]", dest: str, stream) -> None: + from . import viewer + + single = len(paths) == 1 + for source in paths: + name = os.path.basename(source) + try: + mesh = read_mesh(source) + except (OSError, ValueError) as error: + print(f"{name}: mesh illeggibile ({error})", file=stream) + continue + if mesh is None: + print(f"{name}: nessuna mesh accanto al file. Esportare il " + f"modello in STL da Creo per vederlo in 3D.", file=stream) + continue + + stem = split_filename(name)[0] + page = viewer.build(mesh, stem, viewer.describe(mesh)) + target = _preview_path(source, dest, single) + target = target[:-4] + ".html" if target.endswith(".jpg") else target + with open(target, "w", encoding="utf-8") as handle: + handle.write(page) + print("Vista 3D di %s (%d triangoli) salvata in %s" + % (stem, len(mesh), target), file=stream) + + def _write_csv(records: "list[dict]", path: str, include_system: bool = False, include_features: bool = False) -> None: names = sorted({n for r in records for n in _visible(r, include_system, include_features)}) @@ -182,6 +209,11 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--preview", nargs="?", const="", metavar="FILE|CARTELLA", help="salva l'anteprima incorporata come JPEG. Senza " "argomento la scrive accanto al file di origine") + parser.add_argument("--3d", dest="three_d", nargs="?", const="", + metavar="FILE|CARTELLA", + help="genera una pagina HTML per ruotare il modello " + "nel browser, leggendo la mesh (.stl) esportata " + "accanto al file") parser.add_argument("-r", "--recursive", action="store_true", help="esplora le sottocartelle") parser.add_argument("--all-versions", action="store_true", @@ -240,6 +272,9 @@ def main(argv: "list[str] | None" = None) -> int: _write_csv(records, args.csv, args.system, args.features) print(f"CSV scritto in {args.csv}", file=sys.stderr) + if args.three_d is not None: + _save_viewers([r["source_file"] for r in records], args.three_d, sys.stderr) + if args.preview is not None: _save_previews( [r["source_file"] for r in records], args.preview, sys.stderr diff --git a/creoparams/extract.py b/creoparams/extract.py index 296ceb0..cf99191 100644 --- a/creoparams/extract.py +++ b/creoparams/extract.py @@ -93,7 +93,7 @@ def _aggregate(occurrences: list) -> dict: return dict(sorted(result.items())) -def extract(path: str, expected: "list[str] | None" = None) -> dict: +def extract(path: str, expected: "list[str] | None" = None) -> dict: # noqa: C901 """Estrae i metadati da un singolo file. Solleva ugc.NotACreoFile se il file non e' un nativo Creo riconoscibile.""" with open(path, "rb") as handle: @@ -156,9 +156,48 @@ def extract(path: str, expected: "list[str] | None" = None) -> dict: if container.base_kind == "DRAWING": record["drawing"] = drawing.read(data) + # Se accanto al modello c'e' una mesh esportata, ne ricaviamo ingombro e + # volume: sono le uniche grandezze geometriche ottenibili senza Creo. + if holds_parameters: + try: + geometry = read_mesh(path) + except (OSError, ValueError): + geometry = None + if geometry is not None: + record["geometry"] = geometry.to_dict() + record["geometry"]["source_kind"] = "mesh esportata" + return record +#: Estensioni di mesh cercate accanto al modello, in ordine di preferenza. +MESH_EXTENSIONS = (".stl", ".STL") + + +def find_mesh(path: str) -> "str | None": + """Cerca una mesh esportata accanto al modello. + + La geometria dei .prt non e' leggibile senza la libreria di PTC, quindi + la forma arriva da un file esportato con lo stesso nome: 9258400207.prt.11 + -> 9258400207.stl. + """ + directory = os.path.dirname(os.path.abspath(path)) + stem, _, _ = split_filename(os.path.basename(path)) + for extension in MESH_EXTENSIONS: + candidate = os.path.join(directory, stem + extension) + if os.path.isfile(candidate): + return candidate + return None + + +def read_mesh(path: str): + """La mesh associata al modello, o None se non e' stata esportata.""" + from . import mesh as mesh_module + + found = find_mesh(path) + return mesh_module.read_stl(found) if found else None + + def read_preview(path: str): """L'anteprima incorporata nel file, o None se assente. diff --git a/creoparams/mesh.py b/creoparams/mesh.py new file mode 100644 index 0000000..23c8bd3 --- /dev/null +++ b/creoparams/mesh.py @@ -0,0 +1,127 @@ +"""Lettura di mesh triangolari (STL) e calcolo delle grandezze derivate. + +La geometria dei `.prt` e' scritta nel formato del kernel Granite di PTC e +non e' leggibile senza la libreria di PTC: verificato cercando nel file i 22 +valori esatti del modello (raggi e coordinate presi da un export STEP) in +cinque codifiche diverse, senza un solo riscontro. + +La forma del pezzo arriva quindi da una mesh esportata da Creo. Questo +modulo la legge; la fonte e' sostituibile senza toccare il resto (per +esempio con il Granite Interoperability Kernel, che legge i .prt nativi +senza Creo installato). +""" + +from __future__ import annotations + +import math +import struct +from dataclasses import dataclass + +BINARY_HEADER = 84 +TRIANGLE_SIZE = 50 + + +@dataclass +class Mesh: + """Triangoli come terne di vertici (x, y, z).""" + + triangles: list + source: str = "" + + def __len__(self): + return len(self.triangles) + + @property + def bounds(self): + xs = [v[0] for t in self.triangles for v in t] + ys = [v[1] for t in self.triangles for v in t] + zs = [v[2] for t in self.triangles for v in t] + return (min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)) + + @property + def size(self): + lo, hi = self.bounds + return tuple(round(hi[i] - lo[i], 4) for i in range(3)) + + @property + def center(self): + lo, hi = self.bounds + return tuple((hi[i] + lo[i]) / 2 for i in range(3)) + + def area(self) -> float: + """Area della superficie, in unita' del modello al quadrato.""" + total = 0.0 + for a, b, c in self.triangles: + ux, uy, uz = b[0]-a[0], b[1]-a[1], b[2]-a[2] + vx, vy, vz = c[0]-a[0], c[1]-a[1], c[2]-a[2] + nx, ny, nz = uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx + total += math.sqrt(nx*nx + ny*ny + nz*nz) / 2 + return total + + def volume(self) -> float: + """Volume col metodo dei tetraedri con segno. Vale solo se la mesh + e' chiusa: su una mesh aperta il risultato non ha senso.""" + total = 0.0 + for a, b, c in self.triangles: + total += (a[0]*(b[1]*c[2]-b[2]*c[1]) + - a[1]*(b[0]*c[2]-b[2]*c[0]) + + a[2]*(b[0]*c[1]-b[1]*c[0])) / 6 + return abs(total) + + def to_dict(self) -> dict: + lo, hi = self.bounds + return { + "source": self.source, + "triangles": len(self.triangles), + "size": self.size, + "bounds": {"min": [round(v, 4) for v in lo], + "max": [round(v, 4) for v in hi]}, + "area": round(self.area(), 3), + "volume": round(self.volume(), 3), + } + + +def _is_binary(data: bytes) -> bool: + """Un STL ASCII inizia con 'solid', ma anche molti binari lo fanno: + l'unico controllo affidabile e' la dimensione attesa.""" + if len(data) < BINARY_HEADER: + return False + count = struct.unpack(" list: + count = struct.unpack(" list: + triangles, current = [], [] + for line in data.decode("ascii", errors="replace").splitlines(): + parts = line.split() + if len(parts) == 4 and parts[0] == "vertex": + try: + current.append(tuple(float(v) for v in parts[1:])) + except ValueError: + current = [] + if len(current) == 3: + triangles.append(tuple(current)) + current = [] + elif parts and parts[0] == "facet": + current = [] + return triangles + + +def read_stl(path: str) -> Mesh: + with open(path, "rb") as handle: + data = handle.read() + triangles = _read_binary(data) if _is_binary(data) else _read_ascii(data) + if not triangles: + raise ValueError("nessun triangolo trovato: il file non e' un STL valido") + return Mesh(triangles, source=path) diff --git a/creoparams/viewer.py b/creoparams/viewer.py new file mode 100644 index 0000000..861278f --- /dev/null +++ b/creoparams/viewer.py @@ -0,0 +1,186 @@ +"""Generazione di una pagina HTML per ruotare il modello nel browser. + +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. +""" + +from __future__ import annotations + +import json +from xml.sax.saxutils import escape + +TEMPLATE = """ + + + + +%(title)s + + + +
+

%(title)s

+ %(subtitle)s +
+
+
+ Trascina per ruotare · rotella per ingrandire · tasto destro per spostare + + +
+ + + +""" + + +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] + return TEMPLATE % { + "title": escape(title), + "subtitle": escape(subtitle), + "mesh": json.dumps(flat, separators=(",", ":")), + } + + +def describe(mesh, unit: str = "mm") -> str: + """Riga di riepilogo con ingombro e numero di triangoli.""" + w, d, h = mesh.size + return ("%d triangoli · ingombro %g × %g × %g %s" + % (len(mesh), w, d, h, unit)) diff --git a/test_regression.py b/test_regression.py index 6f45676..9f680a6 100644 --- a/test_regression.py +++ b/test_regression.py @@ -175,6 +175,61 @@ class TestDisegni(unittest.TestCase): self.assertIn("prim_line", prims) +class TestMesh(unittest.TestCase): + """La forma arriva da una mesh esportata: il .prt non la espone.""" + + @classmethod + def setUpClass(cls): + from creoparams.extract import find_mesh + cls.pairs = [(p, find_mesh(p)) for p in _parts()] + cls.pairs = [(p, m) for p, m in cls.pairs if m] + if not cls.pairs: + raise unittest.SkipTest("nessun .stl accanto ai modelli") + + def test_lettura_stl(self): + from creoparams.mesh import read_stl + for part, stl in self.pairs: + with self.subTest(file=os.path.basename(stl)): + mesh = read_stl(stl) + self.assertGreater(len(mesh), 0) + self.assertTrue(all(len(t) == 3 for t in mesh.triangles)) + self.assertTrue(all(len(v) == 3 for t in mesh.triangles for v in t)) + + def test_ingombro_positivo(self): + from creoparams.mesh import read_stl + for part, stl in self.pairs: + with self.subTest(file=os.path.basename(stl)): + mesh = read_stl(stl) + self.assertTrue(all(d > 0 for d in mesh.size)) + self.assertGreater(mesh.volume(), 0) + self.assertGreater(mesh.area(), 0) + + def test_geometria_nel_record(self): + for part, stl in self.pairs: + with self.subTest(file=os.path.basename(part)): + geometry = extract(part).get("geometry") + self.assertIsNotNone(geometry) + self.assertEqual(geometry["triangles"], len(read_stl_cached(stl))) + + def test_pagina_html_autonoma(self): + """La pagina deve funzionare offline: nessun riferimento esterno.""" + from creoparams import viewer + from creoparams.mesh import read_stl + mesh = read_stl(self.pairs[0][1]) + page = viewer.build(mesh, "prova", viewer.describe(mesh)) + self.assertTrue(page.startswith("")) + self.assertIn("", page) + 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) + + +def read_stl_cached(path): + from creoparams.mesh import read_stl + return read_stl(path) + + class TestLzw(unittest.TestCase): def test_round_trip_noto(self): """Verifica il decoder su uno stream prodotto da compress(1), se