Files
davideandClaude Opus 5 6a874e85f6 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>
2026-08-06 16:09:27 +02:00

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 &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>
</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 &middot; ingombro %g &times; %g &times; %g %s"
% (len(mesh), w, d, h, unit))