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:
@@ -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
@@ -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 · rotella per ingrandire · tasto destro per spostare</span>
|
||||
<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>
|
||||
<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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user