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) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 16:09:27 +02:00
co-authored by Claude Opus 5
parent 0410266334
commit 6a874e85f6
4 changed files with 197 additions and 85 deletions
+32
View File
@@ -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 {
+139 -84
View File
@@ -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 = """<!doctype html>
: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}
}
</style>
</head>
@@ -55,77 +67,115 @@ TEMPLATE = """<!doctype html>
</header>
<div id="stage"><canvas id="c"></canvas></div>
<footer>
<span>Trascina per ruotare &middot; rotella per ingrandire &middot; tasto destro per spostare</span>
<span class="hint">Trascina per ruotare &middot; rotella per ingrandire
&middot; 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>
<button id="mode">Contorni</button>
</footer>
<script>
const TRI = %(mesh)s;
const V = %(vertices)s, F = %(faces)s, E = %(edges)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;
let rx = -1.02, ry = 0.66, zoom = 1, panx = 0, pany = 0;
let shaded = true, wire = 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 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 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);
for (const v of V){
const d = Math.hypot(v[0]-cx, v[1]-cy, v[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();
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 w = cv.width/(window.devicePixelRatio||1), h = cv.height/(window.devicePixelRatio||1);
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;
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);
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
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]);
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();
}
// 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();
// 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 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();
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;
@@ -136,33 +186,35 @@ cv.addEventListener('pointerdown', e => {
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; }
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);
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 *= e.deltaY < 0 ? 1.12 : 1/1.12;
zoom = Math.max(0.15, Math.min(zoom, 40));
zoom = Math.max(0.15, Math.min(zoom * (e.deltaY < 0 ? 1.12 : 1/1.12), 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';
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', resize);
resize();
addEventListener('resize', draw);
draw();
</script>
</body>
</html>
@@ -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),
}