Visualizer 2
This commit is contained in:
@@ -0,0 +1,571 @@
|
||||
"""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) <input id="layerH" type="number" min="0.1" step="0.5" value="5"></label>
|
||||
<label>infill (mm) <input id="infillS" type="number" min="0.1" step="0.5" value="5"></label>
|
||||
<button id="sliceBtn">🔪 Slice & 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)
|
||||
Reference in New Issue
Block a user