2026-08-06 16:04:46 +02:00
|
|
|
"""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.
|
|
|
|
|
|
2026-08-06 16:09:27 +02:00
|
|
|
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.
|
2026-08-06 16:04:46 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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;
|
2026-08-06 16:09:27 +02:00
|
|
|
background:#eceef1;color:#16181c;display:flex;flex-direction:column;
|
2026-08-06 16:04:46 +02:00
|
|
|
height:100vh;overflow:hidden}
|
2026-08-06 16:09:27 +02:00
|
|
|
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}
|
2026-08-06 16:04:46 +02:00
|
|
|
#stage{flex:1;position:relative;min-height:0}
|
|
|
|
|
canvas{display:block;width:100%%;height:100%%;cursor:grab;touch-action:none}
|
|
|
|
|
canvas.drag{cursor:grabbing}
|
2026-08-06 16:09:27 +02:00
|
|
|
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;
|
2026-08-06 16:04:46 +02:00
|
|
|
border-radius:5px;background:#fff;color:inherit;cursor:pointer}
|
|
|
|
|
button:hover{background:#eef0f3}
|
2026-08-06 16:09:27 +02:00
|
|
|
button[aria-pressed="true"]{background:#1f5fa8;border-color:#1f5fa8;color:#fff}
|
2026-08-06 16:04:46 +02:00
|
|
|
@media (prefers-color-scheme:dark){
|
2026-08-06 16:09:27 +02:00
|
|
|
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}
|
2026-08-06 16:04:46 +02:00
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<header>
|
|
|
|
|
<h1>%(title)s</h1>
|
|
|
|
|
<span class="meta">%(subtitle)s</span>
|
|
|
|
|
</header>
|
|
|
|
|
<div id="stage"><canvas id="c"></canvas></div>
|
|
|
|
|
<footer>
|
2026-08-06 16:09:27 +02:00
|
|
|
<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>
|
2026-08-06 16:04:46 +02:00
|
|
|
<button id="reset">Vista iniziale</button>
|
|
|
|
|
</footer>
|
|
|
|
|
<script>
|
2026-08-06 16:09:27 +02:00
|
|
|
const V = %(vertices)s, F = %(faces)s, E = %(edges)s;
|
2026-08-06 16:04:46 +02:00
|
|
|
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
|
2026-08-06 16:09:27 +02:00
|
|
|
let rx = -1.02, ry = 0.66, zoom = 1, panx = 0, pany = 0;
|
|
|
|
|
let shaded = true, wire = false;
|
2026-08-06 16:04:46 +02:00
|
|
|
|
2026-08-06 16:09:27 +02:00
|
|
|
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;
|
2026-08-06 16:04:46 +02:00
|
|
|
let radius = 1e-9;
|
2026-08-06 16:09:27 +02:00
|
|
|
for (const v of V){
|
|
|
|
|
const d = Math.hypot(v[0]-cx, v[1]-cy, v[2]-cz);
|
2026-08-06 16:04:46 +02:00
|
|
|
if (d > radius) radius = d;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 16:09:27 +02:00
|
|
|
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]);
|
2026-08-06 16:04:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function draw(){
|
2026-08-06 16:09:27 +02:00
|
|
|
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);
|
2026-08-06 16:04:46 +02:00
|
|
|
ctx.clearRect(0,0,w,h);
|
2026-08-06 16:09:27 +02:00
|
|
|
project();
|
|
|
|
|
|
2026-08-06 16:04:46 +02:00
|
|
|
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
|
2026-08-06 16:09:27 +02:00
|
|
|
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
2026-08-06 16:04:46 +02:00
|
|
|
|
2026-08-06 16:09:27 +02:00
|
|
|
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();
|
2026-08-06 16:04:46 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 16:09:27 +02:00
|
|
|
// 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
|
2026-08-06 16:04:46 +02:00
|
|
|
} else {
|
2026-08-06 16:09:27 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-08-06 16:04:46 +02:00
|
|
|
}
|
2026-08-06 16:09:27 +02:00
|
|
|
if (!show) continue;
|
|
|
|
|
ctx.moveTo(P[a*3],P[a*3+1]);
|
|
|
|
|
ctx.lineTo(P[b*3],P[b*3+1]);
|
2026-08-06 16:04:46 +02:00
|
|
|
}
|
2026-08-06 16:09:27 +02:00
|
|
|
ctx.stroke();
|
2026-08-06 16:04:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-08-06 16:09:27 +02:00
|
|
|
if (drag.pan){ panx += dx; pany += dy; } else { ry += dx*0.01; rx += dy*0.01; }
|
2026-08-06 16:04:46 +02:00
|
|
|
drag.x = e.clientX; drag.y = e.clientY;
|
|
|
|
|
draw();
|
|
|
|
|
});
|
2026-08-06 16:09:27 +02:00
|
|
|
const release = () => { drag = null; cv.classList.remove('drag'); };
|
|
|
|
|
cv.addEventListener('pointerup', release);
|
|
|
|
|
cv.addEventListener('pointercancel', release);
|
2026-08-06 16:04:46 +02:00
|
|
|
cv.addEventListener('contextmenu', e => e.preventDefault());
|
|
|
|
|
cv.addEventListener('wheel', e => {
|
|
|
|
|
e.preventDefault();
|
2026-08-06 16:09:27 +02:00
|
|
|
zoom = Math.max(0.15, Math.min(zoom * (e.deltaY < 0 ? 1.12 : 1/1.12), 40));
|
2026-08-06 16:04:46 +02:00
|
|
|
draw();
|
|
|
|
|
}, {passive:false});
|
|
|
|
|
|
2026-08-06 16:09:27 +02:00
|
|
|
const bShaded = document.getElementById('shaded');
|
|
|
|
|
const bWire = document.getElementById('wire');
|
|
|
|
|
function sync(){
|
|
|
|
|
bShaded.setAttribute('aria-pressed', shaded);
|
|
|
|
|
bWire.setAttribute('aria-pressed', wire);
|
2026-08-06 16:04:46 +02:00
|
|
|
draw();
|
2026-08-06 16:09:27 +02:00
|
|
|
}
|
|
|
|
|
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();
|
2026-08-06 16:04:46 +02:00
|
|
|
};
|
|
|
|
|
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
|
2026-08-06 16:09:27 +02:00
|
|
|
addEventListener('resize', draw);
|
|
|
|
|
draw();
|
2026-08-06 16:04:46 +02:00
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build(mesh, title: str, subtitle: str = "") -> str:
|
|
|
|
|
"""Pagina HTML autonoma con il modello incorporato."""
|
2026-08-06 16:09:27 +02:00
|
|
|
vertices, faces, edges = mesh.indexed()
|
|
|
|
|
compact = lambda data: json.dumps(data, separators=(",", ":"))
|
2026-08-06 16:04:46 +02:00
|
|
|
return TEMPLATE % {
|
|
|
|
|
"title": escape(title),
|
|
|
|
|
"subtitle": escape(subtitle),
|
2026-08-06 16:09:27 +02:00
|
|
|
"vertices": compact(vertices),
|
|
|
|
|
"faces": compact(faces),
|
|
|
|
|
"edges": compact(edges),
|
2026-08-06 16:04:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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))
|