Files
visualizer/wired3d_viewer/server2.py
T
Vignesh Suresh 29dfbf99a5 Visualizer 2
2026-06-06 20:43:01 +02:00

572 lines
23 KiB
Python

"""Drag-and-drop launcher for the STL mesh viewer (the "server 2").
The sibling :mod:`server` serves sliced NC toolpaths; this one serves the raw
**STL mesh** so you can drop a part and rotate/zoom it in 3D with the mouse
*before* slicing. Drop an ``.stl`` file (or a folder of them) onto the page; the
browser POSTs the file bytes and Python loads it with ``trimesh``, builds the
Plotly figure (``stl_viewer.build_figure``) and returns ready-to-display HTML
shown inline in an iframe.
**Slice & Visualize:** once a part is loaded, a toolbar button slices it with
the repo-root ``stl_slicer`` (layer height + infill spacing from the toolbar
inputs) and shows the resulting NC toolpath with the very same NC viewer — STL
in, toolpath view out, on one page. The browser does this in two steps so the
part is sliced only once: ``POST /slice-nc`` returns the raw NC program text
(kept client-side), then ``POST /render-nc`` turns that text into the viewer
HTML. "Back to mesh" flips back to the cached mesh view.
**Download NC:** after slicing, a ⤓ Download NC button saves the held NC text
as ``<part>.nc`` straight from the browser (a Blob — no server round-trip).
Run (from ``visualizer/``):
python -m wired3d_viewer serve-stl # http://127.0.0.1:8766
python -m wired3d_viewer serve-stl 9000 # custom port
# or, after `pip install -e .`: wired3d serve-stl [port]
Dependencies: trimesh, plotly, numpy. Imports the sibling ``stl_viewer`` module.
Binary STL note: unlike the NC server, files are POSTed as raw **bytes**
(an ArrayBuffer) because STL is commonly binary — never decoded as text.
"""
import importlib.util
import sys
import tempfile
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import plotly.io as pio
from .server import render_nc_text
from .stl_viewer import build_figure, load_stl
from .viewer import logo_data_uri
DEFAULT_PORT = 8766
# stl_slicer.py lives at the repository root (two levels above this package:
# wired3d_viewer/ -> visualizer/ -> <repo root>). It is not part of the
# installed package, so we load it by path on first use.
_SLICER = None
def _load_slicer():
"""Import and cache the repo-root ``stl_slicer`` module.
Output:
the loaded ``stl_slicer`` module (with ``slice_stl_to_nc`` /
``SlicerConfig``).
Raises:
FileNotFoundError if ``stl_slicer.py`` cannot be located.
"""
global _SLICER
if _SLICER is None:
path = Path(__file__).resolve().parents[2] / "stl_slicer.py"
if not path.exists():
raise FileNotFoundError(f"stl_slicer.py not found at {path}")
spec = importlib.util.spec_from_file_location("stl_slicer", path)
module = importlib.util.module_from_spec(spec)
# Register before exec: @dataclass resolves the class's module via
# sys.modules["stl_slicer"], which must exist while the file runs.
sys.modules["stl_slicer"] = module
spec.loader.exec_module(module)
_SLICER = module
return _SLICER
# --- the drop-zone page (served at "/") -------------------------------------
INDEX_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>STL Viewer</title>
<style>
html, body { margin: 0; height: 100%; background: #0f0f1a; color: #e8e8f0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
#wrap { display: flex; flex-direction: column; height: 100vh; }
#bar { flex: 0 0 auto; padding: 10px 16px; display: flex; align-items: center;
gap: 14px; border-bottom: 1px solid #222; background: #16213e; }
#bar img.logo { height: 28px; width: auto; display: block; }
#bar h1 { font-size: 15px; margin: 0; font-weight: 600; color: #fff; }
#status { font-size: 13px; color: #9aa0b5; }
#slicebar { display: flex; align-items: center; gap: 10px; margin-right: 14px; }
#slicebar label { font-size: 12px; color: #9aa0b5; }
#slicebar input { width: 56px; background: #0f1626; color: #e8e8f0;
border: 1px solid #2a3a5e; border-radius: 6px; padding: 4px 6px; font-size: 13px; }
#slicebar button { background: #0d47a1; color: #fff; border: 0; border-radius: 7px;
padding: 7px 12px; font-size: 13px; cursor: pointer; }
#slicebar button:hover { background: #1565c0; }
#slicebar #meshBtn { background: #16213e; border: 1px solid #2a3a5e; }
#slicebar #meshBtn:hover { background: #1d2b4d; }
#slicebar #downloadBtn { background: #1b7f4b; }
#slicebar #downloadBtn:hover { background: #239a5c; }
#drop { flex: 1 1 auto; position: relative; }
#zone { position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; text-align: center;
border: 2px dashed #2a3a5e; margin: 18px; border-radius: 14px;
transition: background .15s, border-color .15s; }
#zone.hot { background: rgba(74,144,217,.12); border-color: #4a90d9; }
#zone .big { font-size: 22px; color: #cfd3e6; }
#zone .sub { font-size: 13px; color: #7f86a0; margin-top: 8px; }
#zone .buttons { margin-top: 18px; display: flex; gap: 10px; }
#zone .buttons button { background: #0d47a1; color: #fff; border: 0;
border-radius: 8px; padding: 10px 16px; font-size: 14px; cursor: pointer; }
#zone .buttons button:hover { background: #1565c0; }
#picker { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 8px;
justify-content: center; max-width: 80%; }
#picker button { background: #16213e; color: #e8e8f0; border: 1px solid #0d47a1;
border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer; }
#picker button:hover { background: #1d2b4d; }
iframe { border: 0; width: 100%; height: 100%; display: none; background: #0f0f1a; }
a.file { color: #4a90d9; text-decoration: none; cursor: pointer; }
#loading { position: absolute; inset: 0; display: none; flex-direction: column;
align-items: center; justify-content: center; gap: 22px;
background: rgba(15,15,26,.92); z-index: 5; }
#loading.show { display: flex; }
#loading .logo-big { height: 72px; width: auto; display: block;
animation: pulse 1.3s ease-in-out infinite; }
#loading .spinner { width: 56px; height: 56px; border-radius: 50%;
border: 6px solid #1d2b4d; border-top-color: #4a90d9;
animation: spin 0.9s linear infinite; }
#loading .msg { font-size: 16px; color: #cfd3e6; }
#loading .sub2 { font-size: 12px; color: #7f86a0; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse {
0%, 100% { opacity: .45; transform: scale(.96); }
50% { opacity: 1; transform: scale(1.05); }
}
</style>
</head>
<body>
<div id="wrap">
<div id="bar">
__LOGO_IMG__
<h1>STL Viewer</h1>
<span id="status">Drag an .stl file (or a folder of them) anywhere on this page.</span>
<span style="flex:1"></span>
<span id="slicebar" style="display:none">
<label>layer height (mm)&nbsp;<input id="layerH" type="number" min="0.1" step="0.5" value="5"></label>
<label>infill (mm)&nbsp;<input id="infillS" type="number" min="0.1" step="0.5" value="5"></label>
<button id="sliceBtn">🔪 Slice &amp; Visualize</button>
<button id="downloadBtn" style="display:none">⤓ Download NC</button>
<button id="meshBtn" style="display:none">⬔ Back to mesh</button>
</span>
<a class="file" id="another" style="display:none">⤓ Drop another</a>
</div>
<div id="drop">
<div id="zone">
<div class="big">⬇ Drop an STL file here</div>
<div class="sub">a single <code>.stl</code> file or a whole folder — loaded locally, nothing is uploaded anywhere</div>
<div class="buttons">
<button id="browseFile">📄 Choose .stl file</button>
<button id="browseFolder">📁 Choose folder</button>
</div>
<input id="fileInput" type="file" accept=".stl,.STL" multiple hidden>
<input id="dirInput" type="file" webkitdirectory directory multiple hidden>
<div id="picker"></div>
</div>
<iframe id="view"></iframe>
<div id="loading">
__LOGO_LOADING__
<div class="spinner"></div>
<div class="msg" id="loadingMsg">Rendering…</div>
<div class="sub2">loading the mesh + direction vectors — this can take a moment for large files</div>
</div>
</div>
</div>
<script>
const zone = document.getElementById('zone');
const picker = document.getElementById('picker');
const view = document.getElementById('view');
const statusEl = document.getElementById('status');
const another = document.getElementById('another');
const fileInput = document.getElementById('fileInput');
const dirInput = document.getElementById('dirInput');
const loading = document.getElementById('loading');
const loadingMsg = document.getElementById('loadingMsg');
const slicebar = document.getElementById('slicebar');
const sliceBtn = document.getElementById('sliceBtn');
const downloadBtn = document.getElementById('downloadBtn');
const meshBtn = document.getElementById('meshBtn');
const layerH = document.getElementById('layerH');
const infillS = document.getElementById('infillS');
let currentFile = null; // the loaded STL File, re-sent when slicing
let lastMeshHtml = null; // cached mesh view HTML, for "Back to mesh"
let currentNcText = null; // NC text from the last slice, for instant download
function showLoading(msg) {
loadingMsg.textContent = msg;
loading.classList.add('show');
}
function hideLoading() { loading.classList.remove('show'); }
function showZone() {
view.style.display = 'none';
zone.style.display = 'flex';
another.style.display = 'none';
slicebar.style.display = 'none';
downloadBtn.style.display = 'none';
currentNcText = null;
hideLoading();
}
another.addEventListener('click', showZone);
// --- drag & drop highlighting (preventDefault is required to allow a drop) ---
let dragDepth = 0;
document.addEventListener('dragenter', ev => {
ev.preventDefault(); dragDepth++; zone.classList.add('hot');
});
document.addEventListener('dragover', ev => { ev.preventDefault(); });
document.addEventListener('dragleave', ev => {
ev.preventDefault(); if (--dragDepth <= 0) zone.classList.remove('hot');
});
// Recurse a dropped directory entry, collecting .stl File objects.
function readEntry(entry, out) {
return new Promise(resolve => {
if (entry.isFile) {
entry.file(f => {
if (f.name.toLowerCase().endsWith('.stl')) out.push(f);
resolve();
}, () => resolve()); // resolve only AFTER the file callback
} else if (entry.isDirectory) {
const reader = entry.createReader();
const all = [];
const readBatch = () => reader.readEntries(ents => {
if (!ents.length) {
Promise.all(all.map(e => readEntry(e, out))).then(resolve);
} else { all.push(...ents); readBatch(); }
}, () => resolve());
readBatch();
} else { resolve(); }
});
}
document.addEventListener('drop', async ev => {
ev.preventDefault();
dragDepth = 0; zone.classList.remove('hot');
statusEl.textContent = 'Reading…';
const files = [];
const items = ev.dataTransfer.items;
let usedEntries = false;
if (items && items.length && items[0].webkitGetAsEntry) {
const entries = [];
for (const it of items) { const e = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (e) entries.push(e); }
if (entries.length) { usedEntries = true; await Promise.all(entries.map(e => readEntry(e, files))); }
}
if (!usedEntries) { // fallback for browsers without entries API
for (const f of ev.dataTransfer.files)
if (f.name.toLowerCase().endsWith('.stl')) files.push(f);
}
handleFiles(files);
});
// --- "Choose file" / "Choose folder" buttons ---
document.getElementById('browseFile').addEventListener('click', () => fileInput.click());
document.getElementById('browseFolder').addEventListener('click', () => dirInput.click());
fileInput.addEventListener('change', () => handleFiles(stlOnly(fileInput.files)));
dirInput.addEventListener('change', () => handleFiles(stlOnly(dirInput.files)));
function stlOnly(fileList) {
return Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.stl'));
}
// Render one .stl file, or show a picker when several were given.
function handleFiles(files) {
if (!files.length) {
statusEl.textContent = 'No .stl files found in what you selected.';
picker.innerHTML = '';
return;
}
if (files.length === 1) { render(files[0]); return; }
statusEl.textContent = files.length + ' STL files found — pick one:';
picker.innerHTML = '';
files.forEach(f => {
const b = document.createElement('button');
b.textContent = f.webkitRelativePath || f.name;
b.onclick = () => render(f);
picker.appendChild(b);
});
}
async function render(file) {
statusEl.textContent = 'Rendering ' + file.name + '';
picker.innerHTML = '';
showLoading('Rendering ' + file.name + '');
currentFile = file;
currentNcText = null; // new part — no NC until it is sliced
downloadBtn.style.display = 'none';
try {
// STL is commonly binary — POST the raw bytes, not decoded text.
const buf = await file.arrayBuffer();
const resp = await fetch('/render?name=' + encodeURIComponent(file.name),
{ method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: buf });
if (!resp.ok) {
statusEl.textContent = 'Error: ' + (await resp.text());
hideLoading();
return;
}
lastMeshHtml = await resp.text();
view.srcdoc = lastMeshHtml;
zone.style.display = 'none';
view.style.display = 'block';
another.style.display = 'inline';
slicebar.style.display = 'flex';
meshBtn.style.display = 'none';
statusEl.textContent = file.name + ' — mesh';
view.onload = hideLoading; // keep spinner until the iframe paints
} catch (err) {
statusEl.textContent = 'Error: ' + err;
hideLoading();
}
}
// Slice the loaded STL server-side, keep the NC text, and show the toolpath.
// Two steps so we slice ONCE: /slice-nc returns the NC program (held for the
// Download button), then /render-nc turns that text into the viewer HTML.
async function doSlice() {
if (!currentFile) return;
showLoading('Slicing ' + currentFile.name + '');
statusEl.textContent = 'Slicing ' + currentFile.name + '';
try {
const buf = await currentFile.arrayBuffer();
const params = new URLSearchParams({
name: currentFile.name,
layer_height: layerH.value || '5',
infill_spacing: infillS.value || '5',
});
const ncResp = await fetch('/slice-nc?' + params.toString(),
{ method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: buf });
if (!ncResp.ok) {
statusEl.textContent = 'Slice error: ' + (await ncResp.text());
hideLoading();
return;
}
currentNcText = await ncResp.text();
const renderResp = await fetch('/render-nc?name=' + encodeURIComponent(currentFile.name),
{ method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: currentNcText });
if (!renderResp.ok) {
statusEl.textContent = 'Render error: ' + (await renderResp.text());
hideLoading();
return;
}
view.srcdoc = await renderResp.text();
meshBtn.style.display = 'inline-block';
downloadBtn.style.display = 'inline-block';
statusEl.textContent = currentFile.name + ' — sliced toolpath';
view.onload = hideLoading;
} catch (err) {
statusEl.textContent = 'Slice error: ' + err;
hideLoading();
}
}
// Download the sliced NC program (no server round-trip — we kept the text).
function downloadNc() {
if (!currentNcText) return;
const fname = currentFile ? currentFile.name : 'part';
const stem = fname.toLowerCase().endsWith('.stl') ? fname.slice(0, -4) : fname;
const blob = new Blob([currentNcText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = stem + '.nc';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// Restore the cached mesh view without a server round-trip.
function showMesh() {
if (lastMeshHtml === null) return;
view.srcdoc = lastMeshHtml;
meshBtn.style.display = 'none';
statusEl.textContent = (currentFile ? currentFile.name : '') + ' — mesh';
}
sliceBtn.addEventListener('click', doSlice);
downloadBtn.addEventListener('click', downloadNc);
meshBtn.addEventListener('click', showMesh);
</script>
</body>
</html>
"""
def render_stl_bytes(stl_bytes: bytes, name: str) -> str:
"""Load STL bytes and return a full viewer HTML document.
Inputs:
stl_bytes -- raw contents of an .stl file (binary or ASCII).
name -- original file name (used for the title / part name).
Output:
a complete, self-contained HTML string (plotly.js from CDN), identical
to what ``stl_viewer.write_html`` saves — returned for inline display.
"""
# trimesh loads from a path; write the bytes to a temp file, load, clean up.
with tempfile.NamedTemporaryFile("wb", suffix=".stl", delete=False) as fh:
fh.write(stl_bytes)
tmp = fh.name
try:
mesh = load_stl(tmp)
finally:
Path(tmp).unlink(missing_ok=True)
fig = build_figure(mesh, Path(name).stem)
return pio.to_html(
fig, include_plotlyjs="cdn", full_html=True,
default_width="100%", default_height="100vh",
config={"responsive": True},
)
def slice_stl_to_nc_text(stl_bytes: bytes, name: str,
layer_height: float, infill_spacing: float) -> str:
"""Slice STL bytes with ``stl_slicer`` and return the raw NC program text.
Inputs:
stl_bytes -- raw contents of an .stl file (binary or ASCII).
name -- original file name (used for the part name).
layer_height -- slicer layer height in mm (the Z spacing between
slices — NOT a layer count; a 50 mm part at 15 mm
yields ~3 layers).
infill_spacing -- slicer concentric-infill spacing in mm.
Output:
the generated NC program as text (the downloadable .nc contents). The
viewer HTML is produced separately from this text, so slicing happens
once and the same NC powers both the on-page view and the download.
"""
slicer = _load_slicer()
stem = Path(name).stem
cfg = slicer.SlicerConfig(
layer_height=layer_height,
infill_spacing=infill_spacing,
part_name=stem,
)
# Slicer reads/writes paths; stage the STL and the NC output in a temp dir.
with tempfile.TemporaryDirectory() as tmpdir:
stl_path = Path(tmpdir) / "in.stl"
nc_path = Path(tmpdir) / "out.nc"
stl_path.write_bytes(stl_bytes)
slicer.slice_stl_to_nc(stl_path, nc_path, cfg)
return nc_path.read_text(encoding="utf-8")
def slice_stl_bytes(stl_bytes: bytes, name: str,
layer_height: float, infill_spacing: float) -> str:
"""Slice STL bytes and return the NC viewer HTML (slice + render in one).
Convenience wrapper kept for the ``/slice`` route: produces the NC text via
:func:`slice_stl_to_nc_text` then renders it with the shared NC viewer
(``server.render_nc_text``) so the toolpath view is identical to the NC
drop server's.
"""
nc_text = slice_stl_to_nc_text(stl_bytes, name, layer_height, infill_spacing)
return render_nc_text(nc_text, f"{Path(name).stem}.nc")
class _Handler(BaseHTTPRequestHandler):
"""Serves the drop page (GET /), renders posted STL bytes (POST /render),
and slices + visualises them (POST /slice)."""
def _send(self, code: int, body: str, ctype: str = "text/html") -> None:
data = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self): # noqa: N802 (http.server naming)
if self.path in ("/", "/index.html"):
uri = logo_data_uri()
bar_img = f'<img class="logo" src="{uri}" alt="wired3d">' if uri else ""
load_img = f'<img class="logo-big" src="{uri}" alt="wired3d">' if uri else ""
page = (INDEX_HTML
.replace("__LOGO_IMG__", bar_img)
.replace("__LOGO_LOADING__", load_img))
self._send(200, page)
else:
self._send(404, "not found", "text/plain")
def do_POST(self): # noqa: N802
# Routes (longest-prefix first so "/slice-nc" isn't caught by "/slice"):
# /slice-nc STL bytes -> raw NC program text (for download)
# /render-nc NC text -> NC viewer HTML
# /slice STL bytes -> NC viewer HTML (slice + render in one)
# /render STL bytes -> STL mesh viewer HTML
from urllib.parse import parse_qs, urlparse, unquote
parsed_url = urlparse(self.path)
route = next((r for r in ("/slice-nc", "/render-nc", "/slice", "/render")
if parsed_url.path == r), None)
if route is None:
self._send(404, "not found", "text/plain")
return
q = parse_qs(parsed_url.query)
name = unquote(q["name"][0]) if "name" in q else "dropped.stl"
layer_height = float(q.get("layer_height", ["5"])[0])
infill_spacing = float(q.get("infill_spacing", ["5"])[0])
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
try:
if route == "/slice-nc":
nc_text = slice_stl_to_nc_text(body, name, layer_height, infill_spacing)
self._send(200, nc_text, "text/plain")
elif route == "/render-nc":
# body is NC program text; render it with the shared NC viewer.
html = render_nc_text(body.decode("utf-8", errors="replace"),
f"{Path(name).stem}.nc")
self._send(200, html)
elif route == "/slice":
self._send(200, slice_stl_bytes(body, name, layer_height, infill_spacing))
else: # /render
self._send(200, render_stl_bytes(body, name))
except Exception as exc: # noqa: BLE001 - report to the page
self._send(500, f"Failed ({route}) {name}: {exc}", "text/plain")
def log_message(self, *_args):
pass # quiet; we print our own status line
def main(port: int = DEFAULT_PORT) -> None:
"""Start the STL drop-zone server and open it in the browser.
Inputs:
port -- TCP port to listen on (default 8766).
Output:
None. Runs until Ctrl-C.
"""
ThreadingHTTPServer.allow_reuse_address = True
try:
server = ThreadingHTTPServer(("127.0.0.1", port), _Handler)
except OSError as exc:
print(f"Could not bind port {port}: {exc}")
print(f"Another server is likely already running. Try a different port: "
f"python -m wired3d_viewer serve-stl {port + 1}")
return
url = f"http://127.0.0.1:{port}"
print(f"STL Viewer drop zone running at {url}")
print("Drag an .stl file (or a folder of them) onto the page. Ctrl-C to stop.")
try:
webbrowser.open(url)
except Exception:
pass
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
server.server_close()
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
main(port)