187 lines
6.6 KiB
Python
187 lines
6.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' (painter's
|
||
|
|
algorithm): per le mesh di un componente meccanico e' abbondante e non
|
||
|
|
richiede WebGL, che su alcune postazioni aziendali e' disattivato.
|
||
|
|
"""
|
||
|
|
|
||
|
|
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:#f4f5f7;color:#1a1c1e;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}
|
||
|
|
#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;
|
||
|
|
border-radius:5px;background:#fff;color:inherit;cursor:pointer}
|
||
|
|
button:hover{background:#eef0f3}
|
||
|
|
@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}
|
||
|
|
}
|
||
|
|
</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>Trascina per ruotare · rotella per ingrandire · tasto destro per spostare</span>
|
||
|
|
<button id="reset">Vista iniziale</button>
|
||
|
|
<button id="mode">Contorni</button>
|
||
|
|
</footer>
|
||
|
|
<script>
|
||
|
|
const TRI = %(mesh)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;
|
||
|
|
|
||
|
|
// 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 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);
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
|
||
|
|
function draw(){
|
||
|
|
const w = cv.width/(window.devicePixelRatio||1), h = cv.height/(window.devicePixelRatio||1);
|
||
|
|
ctx.clearRect(0,0,w,h);
|
||
|
|
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);
|
||
|
|
|
||
|
|
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]);
|
||
|
|
}
|
||
|
|
// 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();
|
||
|
|
} 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();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 stop = e => { drag = null; cv.classList.remove('drag'); };
|
||
|
|
cv.addEventListener('pointerup', stop);
|
||
|
|
cv.addEventListener('pointercancel', stop);
|
||
|
|
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));
|
||
|
|
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';
|
||
|
|
draw();
|
||
|
|
};
|
||
|
|
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
|
||
|
|
addEventListener('resize', resize);
|
||
|
|
resize();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
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]
|
||
|
|
return TEMPLATE % {
|
||
|
|
"title": escape(title),
|
||
|
|
"subtitle": escape(subtitle),
|
||
|
|
"mesh": json.dumps(flat, separators=(",", ":")),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
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))
|