Vista 3D del modello in una pagina HTML autonoma
Aggiunge --3d: genera un file HTML che si apre con un doppio clic e permette di ruotare il pezzo col mouse, ingrandire con la rotella e spostare col tasto destro. Mesh, stile e codice sono dentro la pagina: nessuna libreria esterna e nessuna connessione, cosi' funziona anche da una cartella di rete. Il disegno usa una canvas 2D con ordinamento per profondita' invece di WebGL, che su alcune postazioni e' disattivato. La forma non viene dal .prt ma da una mesh STL esportata da Creo, cercata accanto al modello per nome (9258400207.prt.11 -> 9258400207.stl). Se manca, il comando lo dice invece di fallire. Perche' serve la mesh: la geometria nel .prt e' scritta nel formato del kernel Granite di PTC ed e' 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. La via ufficiale per leggere i .prt nativi senza Creo e' il Granite Interoperability Kernel di PTC, che si innesterebbe al posto di mesh.py senza toccare il resto. Quando la mesh c'e', il record guadagna un blocco "geometry" con ingombro, volume e area. Il peso NON viene calcolato: servirebbe la densita', che dipende dal materiale, e il materiale e' testo libero. Sul 9258400207 il parametro dice "Anticordal 100", che e' alluminio: applicare la densita' dell'acciaio darebbe 0,686 kg invece di 0,237. Gli export (stl, dxf, dwg, iges, pdf) sono esclusi dal repository come i modelli: sono geometria e tavole complete dei pezzi. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+36
-1
@@ -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
|
||||
|
||||
+40
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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("<I", data[80:84])[0]
|
||||
return len(data) == BINARY_HEADER + count * TRIANGLE_SIZE
|
||||
|
||||
|
||||
def _read_binary(data: bytes) -> list:
|
||||
count = struct.unpack("<I", data[80:84])[0]
|
||||
triangles = []
|
||||
offset = BINARY_HEADER
|
||||
for _ in range(count):
|
||||
values = struct.unpack("<12f", data[offset:offset + 48])
|
||||
triangles.append((values[3:6], values[6:9], values[9:12]))
|
||||
offset += TRIANGLE_SIZE
|
||||
return triangles
|
||||
|
||||
|
||||
def _read_ascii(data: bytes) -> 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)
|
||||
@@ -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 = """<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%(title)s</title>
|
||||
<style>
|
||||
: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;
|
||||
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}
|
||||
#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;
|
||||
border-radius:5px;background:#fff;color:inherit;cursor:pointer}
|
||||
button:hover{background:#eef0f3}
|
||||
@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}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>%(title)s</h1>
|
||||
<span class="meta">%(subtitle)s</span>
|
||||
</header>
|
||||
<div id="stage"><canvas id="c"></canvas></div>
|
||||
<footer>
|
||||
<span>Trascina per ruotare · rotella per ingrandire · tasto destro per spostare</span>
|
||||
<button id="reset">Vista iniziale</button>
|
||||
<button id="mode">Contorni</button>
|
||||
</footer>
|
||||
<script>
|
||||
const TRI = %(mesh)s;
|
||||
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
|
||||
let rx = -1.05, ry = 0.62, zoom = 1, panx = 0, pany = 0, edges = false;
|
||||
|
||||
// baricentro e raggio, per inquadrare il pezzo qualunque sia la sua scala
|
||||
let cx=0, cy=0, cz=0, n=0;
|
||||
for (const t of TRI) for (let k=0;k<9;k+=3){ cx+=t[k]; cy+=t[k+1]; cz+=t[k+2]; n++; }
|
||||
cx/=n; cy/=n; cz/=n;
|
||||
let radius = 1e-9;
|
||||
for (const t of TRI) for (let k=0;k<9;k+=3){
|
||||
const d = Math.hypot(t[k]-cx, t[k+1]-cy, t[k+2]-cz);
|
||||
if (d > radius) radius = d;
|
||||
}
|
||||
|
||||
function resize(){
|
||||
const r = cv.parentElement.getBoundingClientRect();
|
||||
const dpr = Math.min(window.devicePixelRatio||1, 2);
|
||||
cv.width = r.width*dpr; cv.height = r.height*dpr;
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||
draw();
|
||||
}
|
||||
|
||||
function draw(){
|
||||
const w = cv.width/(window.devicePixelRatio||1), h = cv.height/(window.devicePixelRatio||1);
|
||||
ctx.clearRect(0,0,w,h);
|
||||
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const scale = Math.min(w,h)/(2.4*radius)*zoom;
|
||||
const ox = w/2 + panx, oy = h/2 + pany;
|
||||
const sx=Math.sin(rx), cxr=Math.cos(rx), sy=Math.sin(ry), cyr=Math.cos(ry);
|
||||
|
||||
const faces = [];
|
||||
for (const t of TRI){
|
||||
const p = [];
|
||||
for (let k=0;k<9;k+=3){
|
||||
let x=t[k]-cx, y=t[k+1]-cy, z=t[k+2]-cz;
|
||||
let X = x*cyr + z*sy, Z = -x*sy + z*cyr;
|
||||
let Y = y*cxr - Z*sx; Z = y*sx + Z*cxr;
|
||||
p.push([ox + X*scale, oy - Y*scale, Z]);
|
||||
}
|
||||
// normale in coordinate schermo: da' l'illuminazione e scarta il retro
|
||||
const ux=p[1][0]-p[0][0], uy=p[1][1]-p[0][1], uz=p[1][2]-p[0][2];
|
||||
const vx=p[2][0]-p[0][0], vy=p[2][1]-p[0][1], vz=p[2][2]-p[0][2];
|
||||
const nx=uy*vz-uz*vy, ny=uz*vx-ux*vz, nz=ux*vy-uy*vx;
|
||||
const len = Math.hypot(nx,ny,nz) || 1;
|
||||
faces.push({p, depth:(p[0][2]+p[1][2]+p[2][2])/3, light: Math.abs(nz/len)});
|
||||
}
|
||||
faces.sort((a,b)=>a.depth-b.depth); // i piu' lontani per primi
|
||||
|
||||
ctx.lineJoin = 'round';
|
||||
for (const f of faces){
|
||||
const v = dark ? 40 + f.light*150 : 120 + f.light*125;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(f.p[0][0], f.p[0][1]);
|
||||
ctx.lineTo(f.p[1][0], f.p[1][1]);
|
||||
ctx.lineTo(f.p[2][0], f.p[2][1]);
|
||||
ctx.closePath();
|
||||
if (edges){
|
||||
ctx.strokeStyle = dark ? '#8ab4ff' : '#2b4c7e';
|
||||
ctx.lineWidth = 0.6; ctx.stroke();
|
||||
} else {
|
||||
const c = Math.round(v);
|
||||
ctx.fillStyle = `rgb(${c},${c},${Math.min(255,c+6)})`;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 0.5; ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let drag = null;
|
||||
cv.addEventListener('pointerdown', e => {
|
||||
drag = {x:e.clientX, y:e.clientY, pan:(e.button===2)};
|
||||
cv.setPointerCapture(e.pointerId); cv.classList.add('drag');
|
||||
});
|
||||
cv.addEventListener('pointermove', e => {
|
||||
if (!drag) return;
|
||||
const dx = e.clientX-drag.x, dy = e.clientY-drag.y;
|
||||
if (drag.pan){ panx += dx; pany += dy; }
|
||||
else { ry += dx*0.01; rx += dy*0.01; }
|
||||
drag.x = e.clientX; drag.y = e.clientY;
|
||||
draw();
|
||||
});
|
||||
const stop = e => { drag = null; cv.classList.remove('drag'); };
|
||||
cv.addEventListener('pointerup', stop);
|
||||
cv.addEventListener('pointercancel', stop);
|
||||
cv.addEventListener('contextmenu', e => e.preventDefault());
|
||||
cv.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
zoom *= e.deltaY < 0 ? 1.12 : 1/1.12;
|
||||
zoom = Math.max(0.15, Math.min(zoom, 40));
|
||||
draw();
|
||||
}, {passive:false});
|
||||
|
||||
document.getElementById('reset').onclick = () => {
|
||||
rx=-1.05; ry=0.62; zoom=1; panx=0; pany=0; draw();
|
||||
};
|
||||
document.getElementById('mode').onclick = e => {
|
||||
edges = !edges;
|
||||
e.target.textContent = edges ? 'Superfici' : 'Contorni';
|
||||
draw();
|
||||
};
|
||||
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
|
||||
addEventListener('resize', resize);
|
||||
resize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
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))
|
||||
@@ -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("<!doctype html>"))
|
||||
self.assertIn("</html>", 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
|
||||
|
||||
Reference in New Issue
Block a user