Files
visualizer/wired3d_viewer/server.py
T
Vignesh Suresh a4b9a311dd 30.05
2026-05-30 15:19:12 +02:00

377 lines
14 KiB
Python

"""Drag-and-drop launcher for the NC toolpath viewer.
Starts a tiny local web server (Python standard-library ``http.server`` only —
no extra installs beyond what the viewer already needs) that serves a dark
full-screen **drop zone**. Drop an ``.nc`` file — or a whole folder of them —
onto the page and it is parsed with the package ``parser`` and rendered with the
exact same Plotly viewer (``viewer.build_figure``), shown inline in an iframe.
Drop another at any time; pick from a list when several are dropped.
Why a server (instead of pure client-side drag-drop): the parser and the viewer
already live in Python and must not be rewritten in JavaScript. The browser only
reads the dropped file's text and POSTs it; Python does the parsing + figure
building and returns ready-to-display HTML.
Run:
python -m wired3d_viewer.server # serves on http://127.0.0.1:8765
python -m wired3d_viewer.server 9000 # custom port
# or, after `pip install -e .`: wired3d serve [port]
Then open the printed URL and drag NC files onto it. Ctrl-C to stop.
Dependencies: plotly, numpy (same as the viewer). Imports the sibling
``parser`` and ``viewer`` modules from this package.
"""
import os
import sys
import tempfile
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import plotly.io as pio
from .parser import parse_nc
from .viewer import build_figure, LEGEND_CLICK_JS, logo_data_uri
DEFAULT_PORT = 8765
# --- 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>Visualizer</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; }
#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(0,191,255,.10); border-color: #00bfff; }
#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: #00bfff; text-decoration: none; cursor: pointer; }
/* Loading overlay shown while the server parses + builds the figure. */
#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: #00bfff;
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>Visualizer</h1>
<span id="status">Drag an .nc file (or a folder of them) anywhere on this page.</span>
<span style="flex:1"></span>
<a class="file" id="another" style="display:none">⤓ Drop another</a>
</div>
<div id="drop">
<div id="zone">
<div class="big">⬇ Drop an NC file here</div>
<div class="sub">a single <code>.nc</code> file or a whole folder — parsed locally, nothing is uploaded anywhere</div>
<div class="buttons">
<button id="browseFile">📄 Choose .nc file</button>
<button id="browseFolder">📁 Choose folder</button>
</div>
<input id="fileInput" type="file" accept=".nc,.NC" 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">building the 3D toolpath — 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');
function showLoading(name) {
loadingMsg.textContent = 'Rendering ' + name + '';
loading.classList.add('show');
}
function hideLoading() { loading.classList.remove('show'); }
function showZone() {
view.style.display = 'none';
zone.style.display = 'flex';
another.style.display = 'none';
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 .nc File objects.
function readEntry(entry, out) {
return new Promise(resolve => {
if (entry.isFile) {
entry.file(f => {
if (f.name.toLowerCase().endsWith('.nc')) 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('.nc')) 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(ncOnly(fileInput.files)));
dirInput.addEventListener('change', () => handleFiles(ncOnly(dirInput.files)));
function ncOnly(fileList) {
return Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.nc'));
}
// Render one .nc file, or show a picker when several were given.
function handleFiles(files) {
if (!files.length) {
statusEl.textContent = 'No .nc files found in what you selected.';
picker.innerHTML = '';
return;
}
if (files.length === 1) { render(files[0]); return; }
statusEl.textContent = files.length + ' NC 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(file.name);
try {
const text = await file.text();
const resp = await fetch('/render?name=' + encodeURIComponent(file.name),
{ method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: text });
if (!resp.ok) {
statusEl.textContent = 'Error: ' + (await resp.text());
hideLoading();
return;
}
const html = await resp.text();
view.srcdoc = html;
zone.style.display = 'none';
view.style.display = 'block';
another.style.display = 'inline';
statusEl.textContent = file.name;
// Keep the spinner up until the iframe has actually painted the viewer.
view.onload = hideLoading;
} catch (err) {
statusEl.textContent = 'Error: ' + err;
hideLoading();
}
}
</script>
</body>
</html>
"""
def render_nc_text(nc_text: str, name: str) -> str:
"""Parse NC source text and return a full viewer HTML document.
Inputs:
nc_text -- raw contents of an .nc file.
name -- original file name (used for the title and as the part-name
fallback when the file has no "(Part name: ...)" comment).
Output:
a complete, self-contained HTML string (plotly.js from CDN), full
viewport, identical to what ``nc_viewer.py`` writes to disk — just
returned as a string for inline display instead of saved.
"""
# parse_nc reads from a path; write to a temp file, parse, then clean up.
with tempfile.NamedTemporaryFile("w", suffix=".nc", delete=False) as fh:
fh.write(nc_text)
tmp = fh.name
try:
parsed = parse_nc(tmp)
finally:
os.unlink(tmp)
# Without a "(Part name: ...)" comment, parse_nc falls back to the temp
# file's random stem — restore the real dropped name instead.
if "(Part name:" not in nc_text:
parsed["part_name"] = Path(name).stem
fig = build_figure(parsed, name)
return pio.to_html(
fig, include_plotlyjs="cdn", full_html=True,
default_width="100%", default_height="100vh",
config={"responsive": True},
post_script=LEGEND_CLICK_JS,
)
class _Handler(BaseHTTPRequestHandler):
"""Serves the drop page (GET /) and renders posted NC text (POST /render)."""
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
if not self.path.startswith("/render"):
self._send(404, "not found", "text/plain")
return
# Original filename is passed as a query param (?name=...).
name = "dropped.nc"
if "?" in self.path:
from urllib.parse import parse_qs, urlparse, unquote
q = parse_qs(urlparse(self.path).query)
if "name" in q:
name = unquote(q["name"][0])
length = int(self.headers.get("Content-Length", 0))
nc_text = self.rfile.read(length).decode("utf-8", errors="replace")
try:
html = render_nc_text(nc_text, name)
self._send(200, html)
except Exception as exc: # noqa: BLE001 - report to the page
self._send(500, f"Failed to render {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 drop-zone server and open it in the browser.
Inputs:
port -- TCP port to listen on (default 8765).
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.server {port + 1}")
return
url = f"http://127.0.0.1:{port}"
print(f"NC Viewer drop zone running at {url}")
print("Drag an .nc 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)