377 lines
14 KiB
Python
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)
|