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) <noreply@anthropic.com>
242 lines
8.6 KiB
Python
242 lines
8.6 KiB
Python
"""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' 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
|
|
|
|
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:#eceef1;color:#16181c;display:flex;flex-direction:column;
|
|
height:100vh;overflow:hidden}
|
|
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 #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:#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}
|
|
}
|
|
</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 class="hint">Trascina per ruotare · rotella per ingrandire
|
|
· tasto destro per spostare</span>
|
|
<button id="shaded" aria-pressed="true">Ombreggiato</button>
|
|
<button id="wire" aria-pressed="false">Wireframe</button>
|
|
<button id="reset">Vista iniziale</button>
|
|
</footer>
|
|
<script>
|
|
const V = %(vertices)s, F = %(faces)s, E = %(edges)s;
|
|
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
|
|
let rx = -1.02, ry = 0.66, zoom = 1, panx = 0, pany = 0;
|
|
let shaded = true, wire = false;
|
|
|
|
let cx=0, cy=0, cz=0;
|
|
for (const v of V){ cx+=v[0]; cy+=v[1]; cz+=v[2]; }
|
|
cx/=V.length; cy/=V.length; cz/=V.length;
|
|
let radius = 1e-9;
|
|
for (const v of V){
|
|
const d = Math.hypot(v[0]-cx, v[1]-cy, v[2]-cz);
|
|
if (d > radius) radius = d;
|
|
}
|
|
|
|
const P = new Float64Array(V.length*3); // vertici proiettati
|
|
const NZ = new Float64Array(F.length); // orientamento delle facce
|
|
const LIT = new Float64Array(F.length); // illuminazione
|
|
const DEPTH = new Float64Array(F.length);
|
|
const order = Array.from(F.keys());
|
|
|
|
function project(){
|
|
const sx=Math.sin(rx), cxr=Math.cos(rx), sy=Math.sin(ry), cyr=Math.cos(ry);
|
|
const w = cv.clientWidth, h = cv.clientHeight;
|
|
const scale = Math.min(w,h)/(2.35*radius)*zoom;
|
|
const ox = w/2 + panx, oy = h/2 + pany;
|
|
for (let i=0;i<V.length;i++){
|
|
const v = V[i];
|
|
let x=v[0]-cx, y=v[1]-cy, z=v[2]-cz;
|
|
let X = x*cyr + z*sy; let Z = -x*sy + z*cyr;
|
|
const Y = y*cxr - Z*sx; Z = y*sx + Z*cxr;
|
|
P[i*3] = ox + X*scale; P[i*3+1] = oy - Y*scale; P[i*3+2] = Z*scale;
|
|
}
|
|
// luce obliqua da alto-sinistra: da' rilievo anche alle facce frontali
|
|
const lx=-0.42, ly=-0.58, lz=0.70;
|
|
for (let f=0;f<F.length;f++){
|
|
const [a,b,c] = F[f];
|
|
const ax=P[a*3], ay=P[a*3+1], az=P[a*3+2];
|
|
const ux=P[b*3]-ax, uy=P[b*3+1]-ay, uz=P[b*3+2]-az;
|
|
const vx=P[c*3]-ax, vy=P[c*3+1]-ay, vz=P[c*3+2]-az;
|
|
let nx=uy*vz-uz*vy, ny=uz*vx-ux*vz, nz=ux*vy-uy*vx;
|
|
const len = Math.hypot(nx,ny,nz) || 1;
|
|
nx/=len; ny/=len; nz/=len;
|
|
NZ[f] = nz;
|
|
LIT[f] = Math.abs(nx*lx + ny*ly + nz*lz);
|
|
DEPTH[f] = (az + P[b*3+2] + P[c*3+2])/3;
|
|
}
|
|
order.sort((p,q)=>DEPTH[p]-DEPTH[q]);
|
|
}
|
|
|
|
function draw(){
|
|
const dpr = Math.min(devicePixelRatio||1, 2);
|
|
const w = cv.clientWidth, h = cv.clientHeight;
|
|
cv.width = w*dpr; cv.height = h*dpr;
|
|
ctx.setTransform(dpr,0,0,dpr,0,0);
|
|
ctx.clearRect(0,0,w,h);
|
|
project();
|
|
|
|
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
|
|
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
|
|
|
if (shaded){
|
|
for (const f of order){
|
|
if (NZ[f] < 0) continue; // faccia rivolta indietro
|
|
const [a,b,c] = F[f];
|
|
const t = LIT[f];
|
|
// rampa ampia: il contrasto e' quello che fa leggere la forma
|
|
const v = dark ? 26 + t*126 : 96 + t*150;
|
|
const g = Math.round(v);
|
|
ctx.fillStyle = `rgb(${g},${g},${Math.min(255,Math.round(g*1.03+4))})`;
|
|
ctx.beginPath();
|
|
ctx.moveTo(P[a*3],P[a*3+1]);
|
|
ctx.lineTo(P[b*3],P[b*3+1]);
|
|
ctx.lineTo(P[c*3],P[c*3+1]);
|
|
ctx.closePath(); ctx.fill();
|
|
}
|
|
}
|
|
|
|
// Spigoli: quelli vivi e la silhouette. Sono le linee del disegno tecnico.
|
|
ctx.strokeStyle = dark ? '#f2f4f7' : '#101317';
|
|
ctx.lineWidth = 1.1;
|
|
ctx.beginPath();
|
|
for (const e of E){
|
|
const [a,b,f0,f1] = e;
|
|
let show;
|
|
if (wire){
|
|
show = true;
|
|
} else if (f1 < 0){
|
|
show = true; // bordo libero
|
|
} else {
|
|
const front0 = NZ[f0] >= 0, front1 = NZ[f1] >= 0;
|
|
if (front0 !== front1) show = true; // silhouette
|
|
else if (!front0) show = false; // spigolo nascosto
|
|
else {
|
|
// spigolo vivo: le due facce formano un angolo netto
|
|
show = Math.abs(NZ[f0]-NZ[f1]) > 0.12;
|
|
}
|
|
}
|
|
if (!show) continue;
|
|
ctx.moveTo(P[a*3],P[a*3+1]);
|
|
ctx.lineTo(P[b*3],P[b*3+1]);
|
|
}
|
|
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 release = () => { drag = null; cv.classList.remove('drag'); };
|
|
cv.addEventListener('pointerup', release);
|
|
cv.addEventListener('pointercancel', release);
|
|
cv.addEventListener('contextmenu', e => e.preventDefault());
|
|
cv.addEventListener('wheel', e => {
|
|
e.preventDefault();
|
|
zoom = Math.max(0.15, Math.min(zoom * (e.deltaY < 0 ? 1.12 : 1/1.12), 40));
|
|
draw();
|
|
}, {passive:false});
|
|
|
|
const bShaded = document.getElementById('shaded');
|
|
const bWire = document.getElementById('wire');
|
|
function sync(){
|
|
bShaded.setAttribute('aria-pressed', shaded);
|
|
bWire.setAttribute('aria-pressed', wire);
|
|
draw();
|
|
}
|
|
bShaded.onclick = () => { shaded = !shaded; if (!shaded) wire = wire || false; sync(); };
|
|
bWire.onclick = () => { wire = !wire; sync(); };
|
|
document.getElementById('reset').onclick = () => {
|
|
rx=-1.02; ry=0.66; zoom=1; panx=0; pany=0; shaded=true; wire=false; sync();
|
|
};
|
|
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
|
|
addEventListener('resize', draw);
|
|
draw();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def build(mesh, title: str, subtitle: str = "") -> str:
|
|
"""Pagina HTML autonoma con il modello incorporato."""
|
|
vertices, faces, edges = mesh.indexed()
|
|
compact = lambda data: json.dumps(data, separators=(",", ":"))
|
|
return TEMPLATE % {
|
|
"title": escape(title),
|
|
"subtitle": escape(subtitle),
|
|
"vertices": compact(vertices),
|
|
"faces": compact(faces),
|
|
"edges": compact(edges),
|
|
}
|
|
|
|
|
|
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))
|