Visualizer 2

This commit is contained in:
Vignesh Suresh
2026-06-06 20:43:01 +02:00
parent a4b9a311dd
commit 29dfbf99a5
7 changed files with 1063 additions and 10 deletions
+19 -2
View File
@@ -16,8 +16,10 @@ visualizer/
│ ├── __init__.py # lightweight; no heavy imports
│ ├── __main__.py # CLI: `python -m wired3d_viewer view|serve`
│ ├── parser.py # NC dialect reader (standard library only)
│ ├── viewer.py # Plotly figure builder + HTML writer
│ ├── server.py # drag-and-drop local web front end
│ ├── viewer.py # NC Plotly figure builder + HTML writer
│ ├── server.py # NC drag-and-drop local web front end
│ ├── stl_viewer.py # STL mesh figure builder + HTML writer
│ ├── server2.py # STL drag-and-drop local web front end
│ └── assets/
│ └── wired3d.avif # logo, embedded into the HTML as a data URI
```
@@ -50,6 +52,21 @@ python -m wired3d_viewer serve 9000 # custom port
wired3d serve # same, if installed
```
### STL mesh viewer ("server 2")
View a raw `.stl` part in 3D *before* slicing — rotate/zoom with the mouse. Once
a part is loaded a **Slice & Visualize** button (with layer-height / infill
inputs) runs `stl_slicer` and shows the resulting toolpath with the NC viewer on
the same page; "Back to mesh" flips back. (Per-coordinate direction vectors are
the next feature and are not drawn yet.)
```bash
python -m wired3d_viewer view-stl path/to/part.stl # writes <stem>_stl_viewer.html
python -m wired3d_viewer serve-stl # http://127.0.0.1:8766
python -m wired3d_viewer serve-stl 9001 # custom port
wired3d view-stl part.stl / wired3d serve-stl # same, if installed
```
Parse only (no visualisation, no third-party deps):
```python
+2 -1
View File
@@ -5,12 +5,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "wired3d-viewer"
version = "0.1.0"
description = "Interactive 3D viewer for Beckhoff 5-axis DED NC toolpaths"
description = "Interactive 3D viewer for Beckhoff 5-axis DED NC toolpaths and STL meshes"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"plotly>=5",
"numpy>=1.21",
"trimesh>=4", # STL mesh loading for the STL viewer / server-2
]
[project.scripts]
+7 -2
View File
@@ -5,9 +5,14 @@ Modules
parser
NC-dialect reader. Standard library only — importable with zero installs.
viewer
Plotly figure builder and self-contained HTML writer (needs plotly, numpy).
NC Plotly figure builder and self-contained HTML writer (needs plotly, numpy).
server
Drag-and-drop local web front end around the viewer.
Drag-and-drop local web front end around the NC viewer.
stl_viewer
STL mesh figure builder + HTML writer, with per-coordinate direction
vectors (needs plotly, numpy, trimesh).
server2
Drag-and-drop local web front end around the STL viewer ("server 2").
This top-level package deliberately imports nothing heavy: ``import
wired3d_viewer`` stays cheap and ``wired3d_viewer.parser`` keeps working without
+23 -3
View File
@@ -1,13 +1,17 @@
"""Command-line entry point for the wired3d viewer package.
Usage:
python -m wired3d_viewer view <file.nc> # write a standalone HTML viewer
python -m wired3d_viewer serve [port] # start the drag-and-drop server
python -m wired3d_viewer view <file.nc> # write a standalone NC viewer
python -m wired3d_viewer serve [port] # NC drag-and-drop server
python -m wired3d_viewer view-stl <file.stl> # write a standalone STL viewer
python -m wired3d_viewer serve-stl [port] # STL drag-and-drop server (port 8766)
After ``pip install -e .`` the same commands are available as::
wired3d view <file.nc>
wired3d serve [port]
wired3d view-stl <file.stl>
wired3d serve-stl [port]
Submodules are imported lazily inside each handler so that ``view``/``serve``
only pull in plotly/numpy when actually used.
@@ -35,11 +39,21 @@ def main(argv: list[str] | None = None) -> None:
p_view.add_argument("nc_file", help="path to the .nc file to visualise")
p_serve = sub.add_parser(
"serve", help="start the drag-and-drop web server")
"serve", help="start the NC drag-and-drop web server")
p_serve.add_argument(
"port", nargs="?", type=int, default=8765,
help="TCP port to listen on (default 8765)")
p_view_stl = sub.add_parser(
"view-stl", help="render an STL mesh to a standalone HTML viewer")
p_view_stl.add_argument("stl_file", help="path to the .stl file to visualise")
p_serve_stl = sub.add_parser(
"serve-stl", help="start the STL drag-and-drop web server")
p_serve_stl.add_argument(
"port", nargs="?", type=int, default=8766,
help="TCP port to listen on (default 8766)")
args = parser.parse_args(argv)
if args.command == "view":
@@ -48,6 +62,12 @@ def main(argv: list[str] | None = None) -> None:
elif args.command == "serve":
from .server import main as serve_main
serve_main(args.port)
elif args.command == "view-stl":
from .stl_viewer import main as view_stl_main
view_stl_main(args.stl_file)
elif args.command == "serve-stl":
from .server2 import main as serve_stl_main
serve_stl_main(args.port)
if __name__ == "__main__":
+571
View File
@@ -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)&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)
+307
View File
@@ -0,0 +1,307 @@
"""Interactive 3D viewer for raw STL meshes (pre-slicing).
This is the mesh-side companion to ``viewer.py`` (which renders sliced NC
toolpaths). It loads an STL with ``trimesh`` and builds a single self-contained
Plotly HTML document showing the solid surface, so you can rotate/zoom the part
in 3D *before* slicing.
A per-face **direction-vector** overlay (the surface normals, via
:func:`build_normal_trace`) is kept in this module as the seed of the
projectile-direction feature, but is **not drawn yet** — once slicing is wired
in, the direction shown will be the tool-head orientation at every toolpath
point rather than raw facet normals.
The styling deliberately mirrors ``viewer.py``: dark theme, perspective camera,
axis ranges pinned to the part bounding box, the wired3d logo embedded top-left,
and a stats table. It reuses :func:`viewer.logo_data_uri` and
:data:`viewer.DEFAULT_CAMERA` so the two viewers stay visually consistent.
Run (from ``visualizer/``):
python -m wired3d_viewer view-stl <file.stl> # writes <stem>_stl_viewer.html
Dependencies: trimesh, plotly, numpy.
"""
from pathlib import Path
import numpy as np
import plotly.graph_objects as go
import plotly.io as pio
import trimesh
from .viewer import DEFAULT_CAMERA, FIG_BG, SCENE_BG, logo_data_uri
# Surface + direction-vector colours, in keeping with the NC viewer palette.
MESH_COLOUR = "#4a90d9" # cool steel blue for the solid surface
NORMAL_COLOUR = "#ffcc00" # amber direction vectors — pop against the blue
EDGE_COLOUR = "#1b2a44"
# Cap on how many normal vectors we draw. A dense mesh can have 10k+ faces;
# drawing one cone per face is both unreadable and slow, so we evenly sample
# down to this many. The full mesh is always drawn — only the vector overlay is
# decimated (and the count of dropped vectors is reported in the stats table).
MAX_NORMALS = 1500
def load_stl(filepath: str) -> trimesh.Trimesh:
"""Load an STL file and return a single concrete ``Trimesh``.
Inputs:
filepath -- path to a binary or ASCII ``.stl`` file.
Output:
a ``trimesh.Trimesh``. If the file is a scene (multiple bodies) it is
concatenated into one mesh so the rest of the pipeline sees one object.
Raises:
ValueError if the file contains no triangulated geometry.
"""
loaded = trimesh.load(filepath, force="mesh")
if isinstance(loaded, trimesh.Scene):
loaded = trimesh.util.concatenate(loaded.dump())
if not isinstance(loaded, trimesh.Trimesh) or len(loaded.faces) == 0:
raise ValueError(f"No triangle mesh found in {filepath!r}")
return loaded
def _axis_ranges(mesh: trimesh.Trimesh) -> dict:
"""Return padded, equal-aspect X/Y/Z scene ranges for the mesh bounds.
Inputs:
mesh -- the loaded ``Trimesh``.
Output:
dict with ``xaxis``/``yaxis``/``zaxis`` range entries. A common padded
cube edge is used for all three axes so the part is never distorted and
the view does not zoom-jump (same approach as the NC viewer).
"""
lo, hi = mesh.bounds
centre = (lo + hi) / 2.0
half = float((hi - lo).max()) / 2.0
half = half * 1.1 if half > 0 else 1.0 # 10% padding; guard a flat part
return dict(
xaxis=dict(range=[centre[0] - half, centre[0] + half]),
yaxis=dict(range=[centre[1] - half, centre[1] + half]),
zaxis=dict(range=[centre[2] - half, centre[2] + half]),
)
def build_mesh_trace(mesh: trimesh.Trimesh) -> go.Mesh3d:
"""Build the solid-surface ``Mesh3d`` trace.
Inputs:
mesh -- the loaded ``Trimesh``.
Output:
a lit ``go.Mesh3d`` using the mesh vertices/faces.
"""
v = mesh.vertices
f = mesh.faces
return go.Mesh3d(
x=v[:, 0], y=v[:, 1], z=v[:, 2],
i=f[:, 0], j=f[:, 1], k=f[:, 2],
color=MESH_COLOUR,
opacity=1.0,
flatshading=True,
name="STL surface",
showlegend=True,
lighting=dict(ambient=0.45, diffuse=0.85, specular=0.25, roughness=0.55),
lightposition=dict(x=1000, y=1000, z=2000),
hoverinfo="skip",
)
def _sample_faces(n_faces: int, cap: int) -> np.ndarray:
"""Return indices of faces to draw a normal for (evenly sampled, capped).
Inputs:
n_faces -- total number of faces.
cap -- maximum number of vectors to draw.
Output:
a 1-D int array of face indices (all of them when ``n_faces <= cap``,
otherwise an even stride across the range).
"""
if n_faces <= cap:
return np.arange(n_faces)
return np.linspace(0, n_faces - 1, cap).astype(int)
def build_normal_trace(mesh: trimesh.Trimesh) -> tuple[go.Cone, int]:
"""Build the per-face direction-vector overlay (surface normals as cones).
Inputs:
mesh -- the loaded ``Trimesh``.
Output:
``(cone_trace, shown)`` where ``cone_trace`` is a ``go.Cone`` quiver
placed at each sampled face centroid pointing along its outward normal,
and ``shown`` is how many vectors were actually drawn (after sampling).
The cones are length-normalised relative to the part size so they read as
little arrows regardless of model scale.
"""
idx = _sample_faces(len(mesh.faces), MAX_NORMALS)
origins = mesh.triangles_center[idx]
normals = mesh.face_normals[idx]
# Scale the cone vectors to ~3% of the bounding-box diagonal so they are
# visible but don't swamp the surface. go.Cone sizes cones by vector norm.
diag = float(np.linalg.norm(mesh.bounds[1] - mesh.bounds[0])) or 1.0
vec = normals * (diag * 0.03)
cone = go.Cone(
x=origins[:, 0], y=origins[:, 1], z=origins[:, 2],
u=vec[:, 0], v=vec[:, 1], w=vec[:, 2],
colorscale=[[0, NORMAL_COLOUR], [1, NORMAL_COLOUR]],
showscale=False,
sizemode="absolute",
sizeref=diag * 0.03,
anchor="tail",
name="Direction (face normals)",
showlegend=True,
hoverinfo="x+y+z",
)
return cone, len(idx)
def compute_stats(mesh: trimesh.Trimesh) -> dict:
"""Compute a small dictionary of human-readable mesh statistics.
Inputs:
mesh -- the loaded ``Trimesh``.
Output:
ordered dict of ``label -> value`` strings for the stats table.
"""
lo, hi = mesh.bounds
size = hi - lo
stats = {
"Vertices": f"{len(mesh.vertices):,}",
"Faces": f"{len(mesh.faces):,}",
"Size X×Y×Z (mm)": f"{size[0]:.1f} × {size[1]:.1f} × {size[2]:.1f}",
"Bounds min (mm)": f"({lo[0]:.1f}, {lo[1]:.1f}, {lo[2]:.1f})",
"Bounds max (mm)": f"({hi[0]:.1f}, {hi[1]:.1f}, {hi[2]:.1f})",
"Watertight": "yes" if mesh.is_watertight else "no",
"Volume (mm³)": f"{mesh.volume:,.1f}" if mesh.is_watertight else "n/a",
"Surface area (mm²)": f"{mesh.area:,.1f}",
}
return stats
def build_stats_table(stats: dict) -> go.Table:
"""Build the bottom-right stats ``go.Table`` trace.
Inputs:
stats -- the dict from :func:`compute_stats`.
Output:
a styled ``go.Table`` (dark theme, two columns).
"""
return go.Table(
columnwidth=[1.1, 1.4],
header=dict(
values=["<b>Property</b>", "<b>Value</b>"],
fill_color="#16213e", font=dict(color="#e8e8f0", size=13),
align="left", height=26,
),
cells=dict(
values=[list(stats.keys()), list(stats.values())],
fill_color="#0f1626", font=dict(color="#cfd3e6", size=12),
align="left", height=24,
),
)
def build_figure(mesh: trimesh.Trimesh, part_name: str) -> go.Figure:
"""Assemble the full STL viewer figure (3D mesh + stats table).
Inputs:
mesh -- the loaded ``Trimesh``.
part_name -- title shown in the header.
Output:
a ``go.Figure`` with a large left 3D scene and a bottom-right stats
table, dark themed, perspective camera, axis ranges pinned to bounds.
"""
from plotly.subplots import make_subplots
fig = make_subplots(
rows=2, cols=2,
column_widths=[0.74, 0.26], row_heights=[0.55, 0.45],
specs=[[{"type": "scene", "rowspan": 2}, {"type": "table"}],
[None, {"type": "table"}]],
horizontal_spacing=0.02, vertical_spacing=0.04,
)
# Direction vectors (build_normal_trace) are intentionally not drawn yet —
# see the module docstring. Only the solid surface is shown for now.
fig.add_trace(build_mesh_trace(mesh), row=1, col=1)
table = build_stats_table(compute_stats(mesh))
fig.add_trace(table, row=1, col=2)
ranges = _axis_ranges(mesh)
fig.update_layout(
title=dict(text=f"STL Viewer — {part_name}", x=0.5,
font=dict(color="#e8e8f0", size=18)),
paper_bgcolor=FIG_BG,
font=dict(color="#e8e8f0"),
showlegend=True,
legend=dict(x=0.0, y=1.0, bgcolor="rgba(22,33,62,0.6)",
font=dict(size=12), traceorder="normal"),
autosize=True,
margin=dict(l=0, r=0, t=44, b=0),
scene=dict(
bgcolor=SCENE_BG,
xaxis=dict(title="X", color="#9aa0b5", **ranges["xaxis"]),
yaxis=dict(title="Y", color="#9aa0b5", **ranges["yaxis"]),
zaxis=dict(title="Z", color="#9aa0b5", **ranges["zaxis"]),
aspectmode="cube",
camera=DEFAULT_CAMERA,
uirevision="keep",
),
)
logo = logo_data_uri()
if logo:
fig.add_layout_image(dict(
source=logo, xref="paper", yref="paper",
x=0.01, y=0.99, sizex=0.12, sizey=0.12,
xanchor="left", yanchor="top", layer="above",
))
return fig
def write_html(mesh: trimesh.Trimesh, part_name: str, out_path: str) -> str:
"""Build the figure and write a self-contained HTML file.
Inputs:
mesh -- the loaded ``Trimesh``.
part_name -- title / part name.
out_path -- destination ``.html`` path.
Output:
``out_path`` (also written to disk). plotly.js is pulled from the CDN
so the file is small but needs a network connection on first open.
"""
fig = build_figure(mesh, part_name)
pio.write_html(
fig, out_path, include_plotlyjs="cdn", full_html=True,
default_width="100%", default_height="100vh",
config={"responsive": True},
)
return out_path
def main(stl_file: str) -> None:
"""CLI entry: render one STL file to ``<stem>_stl_viewer.html``.
Inputs:
stl_file -- path to the ``.stl`` file.
Output:
None. Prints the saved HTML path.
"""
path = Path(stl_file)
mesh = load_stl(str(path))
out = path.with_name(f"{path.stem}_stl_viewer.html")
write_html(mesh, path.stem, str(out))
print(f"Saved: {out}")
if __name__ == "__main__":
import sys
if len(sys.argv) != 2:
print("usage: python -m wired3d_viewer.stl_viewer <file.stl>")
sys.exit(1)
main(sys.argv[1])
+134 -2
View File
@@ -107,6 +107,42 @@ gd.on('plotly_legendclick', function(ev) {
}
return false;
});
// --- Projectile toggle (custom HTML button, reliable single-click) ---------
// Replaces the old Plotly updatemenu toggle, whose `active` default left the
// button highlighted while the overlay was hidden (inverted/2-click state).
// This button is the SOLE control for the projectile line + arrowheads; its
// label and colour always reflect the real visibility.
(function() {
var lineIdx = -1, arrowIdx = -1;
gd.data.forEach(function(t, i) {
if (t.name === 'Projectile (tool direction)') lineIdx = i;
if (t.name === 'Projectile arrowheads') arrowIdx = i;
});
if (lineIdx === -1) return; // no projectile in this figure
var idxs = arrowIdx === -1 ? [lineIdx] : [lineIdx, arrowIdx];
var btn = document.createElement('button');
btn.type = 'button';
// Sits just BELOW the layer dropdown (top-left) so the two never overlap.
btn.style.cssText = 'position:fixed;top:52px;left:14px;z-index:1000;' +
'padding:7px 13px;font-size:13px;border-radius:7px;cursor:pointer;' +
'border:1px solid #ff9d00;font-family:system-ui,-apple-system,sans-serif;';
function isOn() { return gd.data[lineIdx].visible === true; }
function refresh() {
var on = isOn();
btn.textContent = on ? '🎯 Projectile: ON' : '🎯 Projectile: OFF';
btn.style.background = on ? '#ff9d00' : '#16213e';
btn.style.color = on ? '#1a1a2e' : '#ffffff';
}
btn.addEventListener('click', function() {
Plotly.restyle(gd, {visible: isOn() ? 'legendonly' : true}, idxs)
.then(refresh);
});
document.body.appendChild(btn);
refresh();
})();
"""
# Dark theme palette.
@@ -560,6 +596,85 @@ def build_cursor_trace(moves: list[dict]) -> go.Scatter3d:
)
def build_projectile_trace(moves: list[dict], length: float) -> go.Scatter3d:
"""Build the per-point 'projectile' overlay: the tool direction at every move.
Inputs:
moves -- list of move dicts (mm coords, A/B degrees).
length -- length (mm) of each direction stick.
Output:
a single ``go.Scatter3d`` line trace containing one short segment per
move — from the move's coordinate along its tool vector
``compute_tool_vector(a, b)`` (straight up at A=B=0, tilted as the head
inclines). Segments are separated by ``None`` so the whole field of
directions is one lightweight trace. Hidden by default
(``visible="legendonly"``, no legend entry) and revealed by the custom
"Projectile" toggle button injected via the post-script. This is the
projectile/direction-of-every-coordinate view.
"""
xs: list = []
ys: list = []
zs: list = []
for m in moves:
u, v, w = compute_tool_vector(m["a"], m["b"])
xs += [m["x"], m["x"] + u * length, None]
ys += [m["y"], m["y"] + v * length, None]
zs += [m["z"], m["z"] + w * length, None]
return go.Scatter3d(
x=xs, y=ys, z=zs,
mode="lines",
line=dict(color="#ff9d00", width=2),
name="Projectile (tool direction)",
visible="legendonly",
# Toggled only by the custom HTML "Projectile" button (no legend entry),
# so the line and its arrowheads never get out of sync.
showlegend=False,
hoverinfo="skip",
)
def build_projectile_arrows(moves: list[dict], length: float) -> go.Cone:
"""Build arrowheads (cones) at the tip of every projectile vector.
Inputs:
moves -- list of move dicts (mm coords, A/B degrees).
length -- length (mm) of each projectile stick (the cone sits at its end).
Output:
a ``go.Cone`` trace with one small cone per move, placed at the stick's
TIP (``coord + length × tool_vector``) and pointing along the tool
vector, so each projectile line reads as a directional arrow. Hidden by
default (``visible="legendonly"``) and toggled together with the line by
the Projectile button. ``go.Cone`` colours by vector norm, so the
colorscale is pinned to the single projectile amber.
"""
xs: list = []
ys: list = []
zs: list = []
us: list = []
vs: list = []
ws: list = []
for m in moves:
u, v, w = compute_tool_vector(m["a"], m["b"])
xs.append(m["x"] + u * length)
ys.append(m["y"] + v * length)
zs.append(m["z"] + w * length)
us.append(u)
vs.append(v)
ws.append(w)
return go.Cone(
x=xs, y=ys, z=zs, u=us, v=vs, w=ws,
anchor="tip", # cone point sits at the stick tip
sizemode="absolute",
sizeref=length * 0.45,
colorscale=[[0, "#ff9d00"], [1, "#ff9d00"]],
showscale=False,
name="Projectile arrowheads",
showlegend=False, # one legend entry (the line) is enough
visible="legendonly",
hoverinfo="skip",
)
def build_trail_trace() -> go.Scatter3d:
"""Build the (initially empty) trail that is drawn progressively on Play.
@@ -864,13 +979,30 @@ def build_figure(parsed: dict, source_file: str) -> go.Figure:
# --- stats table (row 2, col 2) ---
fig.add_trace(build_stats_table(stats), row=2, col=2)
# --- projectile overlay (3D scene) ---
# Appended LAST so it never shifts the indices the frames/dropdown reference
# (rapid/weld/cursor/trail/angles/table). It is a scene trace despite being
# added after the table — add_trace order is just append order. Hidden until
# the custom "Projectile" HTML button (post-script) toggles it on.
stick = _stick_length(moves)
projectile_index = len(fig.data)
fig.add_trace(build_projectile_trace(moves, stick), row=1, col=1)
arrow_index = len(fig.data)
fig.add_trace(build_projectile_arrows(moves, stick), row=1, col=1)
projectile_traces = [projectile_index, arrow_index]
# Total trace count (for dropdown bookkeeping): rapid + welds + cursor +
# trail + 4 angle traces + table.
total_traces = 1 + len(weld_traces) + 2 + 4 + 1
# trail + 4 angle traces + table + projectile line + arrowheads.
total_traces = 1 + len(weld_traces) + 2 + 4 + 1 + 2
dropdown = build_layer_dropdown(
weld_traces, n_static_before=1, total_traces=total_traces,
)
# The projectile overlay is toggled by a custom HTML button injected via the
# post-script (LEGEND_CLICK_JS), not a Plotly updatemenu — the updatemenu
# toggle's default `active` left the button visually inverted/2-click. The
# JS finds the two projectile traces by name, so no indices are passed here.
_ = projectile_traces # indices kept for clarity; JS locates traces by name
# --- animation: frames + play/pause/reset + slider ---
frames, play_menu, slider = build_animation(