Files
pdm/creoparams/viewer.py
T

187 lines
6.6 KiB
Python
Raw Normal View History

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