This commit is contained in:
Vignesh Suresh
2026-05-30 15:19:12 +02:00
parent e2978bba83
commit a4b9a311dd
13 changed files with 320 additions and 58 deletions
+20
View File
@@ -0,0 +1,20 @@
"""wired3d_viewer — interactive 3D viewer for Beckhoff 5-axis DED NC toolpaths.
Modules
-------
parser
NC-dialect reader. Standard library only — importable with zero installs.
viewer
Plotly figure builder and self-contained HTML writer (needs plotly, numpy).
server
Drag-and-drop local web front end around the viewer.
This top-level package deliberately imports nothing heavy: ``import
wired3d_viewer`` stays cheap and ``wired3d_viewer.parser`` keeps working without
plotly/numpy installed. Import the submodule you need explicitly, e.g.::
from wired3d_viewer.parser import parse_nc
from wired3d_viewer.viewer import build_figure
"""
__version__ = "0.1.0"
+54
View File
@@ -0,0 +1,54 @@
"""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
After ``pip install -e .`` the same commands are available as::
wired3d view <file.nc>
wired3d serve [port]
Submodules are imported lazily inside each handler so that ``view``/``serve``
only pull in plotly/numpy when actually used.
"""
import argparse
def main(argv: list[str] | None = None) -> None:
"""Parse argv and dispatch to the viewer or server.
Inputs:
argv -- optional argument list (defaults to ``sys.argv[1:]``).
Output:
None. Runs the chosen subcommand.
"""
parser = argparse.ArgumentParser(
prog="wired3d_viewer",
description="Interactive 3D viewer for Beckhoff 5-axis DED NC toolpaths.",
)
sub = parser.add_subparsers(dest="command", required=True)
p_view = sub.add_parser(
"view", help="render an NC file to a standalone HTML viewer")
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")
p_serve.add_argument(
"port", nargs="?", type=int, default=8765,
help="TCP port to listen on (default 8765)")
args = parser.parse_args(argv)
if args.command == "view":
from .viewer import main as view_main
view_main(args.nc_file)
elif args.command == "serve":
from .server import main as serve_main
serve_main(args.port)
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+297
View File
@@ -0,0 +1,297 @@
"""NC file parser for the Beckhoff 5-axis metal DED slicer.
Parses the machine NC dialect emitted by ``stl_slicer.py`` (and the real
Beckhoff controller) into structured Python data. No visualisation — this
module only reads NC text and extracts motion + weld information.
The NC dialect understood here:
COMMENTS
(Part name: Hemisphere-v1) -> part name (header)
(New Slice of body X No.: 3, Z15.00) -> slice (Z layer) number + Z
(X, Slice 3, Fraction 2 Outer ...) -> fraction (contour) number
MOTION
G00 X.. Y.. Z.. [A..] [B..] -> rapid move (no welding)
G01 X.. Y.. Z.. [A..] [B..] [M61] -> cut move (M61 = laser ON)
WELD CONTROL
M60=1 / M60=11 -> weld start markers (tracked, not moves)
M61 -> laser ON (inline on a G01 line)
M62 -> laser OFF (standalone line)
M01 -> optional stop between slices (ignored)
IGNORED
#TRAFO ON, G54, F800.0, M63, M71, blank lines, ``;`` comments.
Units: X/Y/Z in millimetres, A/B in degrees.
Standard library only (re, math, pathlib) — importable with zero installs.
"""
import re
import math
from pathlib import Path
# Regexes compiled once at import time.
_PART_NAME_RE = re.compile(r"\(Part name:\s*(.+?)\)")
_SLICE_RE = re.compile(r"\(New Slice of body .*?No\.:\s*(\d+)")
_FRACTION_RE = re.compile(r"Fraction\s+(\d+)")
# Axis word followed by an optional-sign float (incl. scientific notation).
_AXIS_RE = re.compile(r"\b([XYZAB])(-?\d+\.?\d*(?:[eE][-+]?\d+)?)")
def _extract_axes(line: str) -> dict:
"""Pull X/Y/Z/A/B axis words out of a motion line.
Inputs:
line -- a single G00/G01 NC line, e.g. "G01 X-1.2 Y3.4 Z2.5 A0.0 B1.0".
Output:
dict mapping the upper-case axis letter ("X".."B") to its float value
(mm for X/Y/Z, degrees for A/B). Only axes present in the line appear.
"""
return {m.group(1): float(m.group(2)) for m in _AXIS_RE.finditer(line)}
def parse_nc(filepath: str) -> dict:
"""Parse a Beckhoff DED NC file into structured move data.
Inputs:
filepath -- path to the .nc file to read.
Output:
dict with two keys:
"part_name" : str -- from the "(Part name: ...)" header comment,
or the filename stem if that comment is absent.
"moves" : list[dict] -- one dict per G00/G01 motion line, each:
{
"line_no" : int, # 1-based line number in the source file
"x" : float, # mm
"y" : float, # mm
"z" : float, # mm
"a" : float, # degrees (0.0 if absent)
"b" : float, # degrees (0.0 if absent)
"move_type" : str, # "rapid" (G00) or "cut" (G01)
"weld_state" : str, # "on" or "off"
"slice_no" : int, # Z layer this move belongs to
"fraction_no" : int, # contour within the layer
}
Behaviour / error handling:
* Weld state starts "off"; M61 (inline) turns it "on", M62 turns it
"off". The state carries forward across G01 lines until M62.
* A G00/G01 line missing X, Y or Z prints a warning with the line
number and is skipped (never crashes).
* Missing A or B silently default to 0.0.
* The whole parse is wrapped in try/except; on error the file path is
reported and a dict with an empty move list is returned.
"""
part_name = None
moves = []
weld_state = "off"
slice_no = 0
fraction_no = 0
try:
text = Path(filepath).read_text()
lines = text.splitlines()
for idx, raw in enumerate(lines, start=1):
line = raw.strip()
# Skip blanks and ';' comments outright.
if not line or line.startswith(";"):
continue
# Parenthesised comments: extract part name / slice / fraction.
if line.startswith("("):
if part_name is None:
m = _PART_NAME_RE.search(line)
if m:
part_name = m.group(1).strip()
continue
m = _SLICE_RE.search(line)
if m:
slice_no = int(m.group(1))
continue
m = _FRACTION_RE.search(line)
if m:
fraction_no = int(m.group(1))
continue
# Weld control / stop markers.
if line.startswith("M62"):
weld_state = "off"
continue
if line.startswith("M60") or line.startswith("M01"):
# M60=1 / M60=11 = weld start markers, M01 = optional stop.
# Tracked by context only; not moves themselves.
continue
# Motion lines.
if line.startswith("G00") or line.startswith("G01"):
move_type = "rapid" if line.startswith("G00") else "cut"
axes = _extract_axes(line)
missing = [ax for ax in ("X", "Y", "Z") if ax not in axes]
if missing:
print(
f"Warning: line {idx} missing {','.join(missing)} "
f"-> skipped: {line!r}"
)
continue
# M61 appears inline on a G01 line and turns the laser ON for
# this move and the ones that follow.
if "M61" in line:
weld_state = "on"
moves.append({
"line_no": idx,
"x": axes["X"],
"y": axes["Y"],
"z": axes["Z"],
"a": axes.get("A", 0.0),
"b": axes.get("B", 0.0),
"move_type": move_type,
"weld_state": weld_state,
"slice_no": slice_no,
"fraction_no": fraction_no,
})
continue
# Everything else (#TRAFO ON, G54, F800.0, M63, M71, ...) ignored.
except Exception as exc: # noqa: BLE001 - report and degrade gracefully
print(f"Error parsing NC file {filepath!r}: {exc}")
return {"part_name": Path(filepath).stem, "moves": moves}
if part_name is None:
part_name = Path(filepath).stem
return {"part_name": part_name, "moves": moves}
def compute_tool_vector(a_deg: float, b_deg: float) -> tuple[float, float, float]:
"""Convert A/B axis angles to a unit tool-direction vector.
Inputs:
a_deg -- A-axis angle in degrees.
b_deg -- B-axis angle in degrees.
Output:
(u, v, w) -- a unit direction vector for where the extruder/welding
head is pointing, where:
u = X component
v = Y component
w = Z component
Formula:
u = sin(B)
v = -sin(A) * cos(B)
w = cos(A) * cos(B)
With A=0 and B=0 the result is (0.0, 0.0, 1.0) — the head points straight
up along +Z. The vector is already unit length for any A/B.
"""
a = math.radians(a_deg)
b = math.radians(b_deg)
u = math.sin(b)
v = -math.sin(a) * math.cos(b)
w = math.cos(a) * math.cos(b)
return (u, v, w)
def summarise(parsed: dict) -> None:
"""Print a clean human-readable summary of parsed NC data.
Inputs:
parsed -- the dict returned by ``parse_nc`` (keys "part_name",
"moves").
Output:
None. Prints to the terminal: part name, total move count, number of
distinct Z layers (slices) and fractions, weld-ON vs rapid point
counts, and the Z (mm) / A (deg) / B (deg) ranges seen across moves.
"""
moves = parsed.get("moves", [])
part_name = parsed.get("part_name", "?")
total = len(moves)
layers = len({m["slice_no"] for m in moves})
fractions = len({(m["slice_no"], m["fraction_no"]) for m in moves})
weld_on = sum(1 for m in moves if m["weld_state"] == "on")
rapid = sum(1 for m in moves if m["move_type"] == "rapid")
print(f"Part name : {part_name}")
print(f"Total moves : {total}")
print(f"Layers : {layers}")
print(f"Fractions : {fractions}")
print(f"Weld ON pts : {weld_on}")
print(f"Rapid pts : {rapid}")
if moves:
zs = [m["z"] for m in moves]
as_ = [m["a"] for m in moves]
bs = [m["b"] for m in moves]
print(f"Z range : {min(zs):.2f} mm → {max(zs):.2f} mm")
print(f"A range : {min(as_):.2f}° → {max(as_):.2f}°")
print(f"B range : {min(bs):.2f}° → {max(bs):.2f}°")
else:
print("Z range : (no moves)")
print("A range : (no moves)")
print("B range : (no moves)")
if __name__ == "__main__":
import tempfile
TEST_NC = """(Part name: Hemisphere-v1)
#TRAFO ON
G54
M63
M71
(New Slice of body Hemisphere-v1 No.: 1, Z2.50)
(Change material to Steel)
(Hemisphere-v1, Slice 1, Fraction 1 Outer Perimeter 1)
F800.0
G00 X-48.09 Y-13.29 Z2.50 A0.00 B0.00
M60=1
G01 X-47.29 Y-15.96 Z2.50 A0.00 B0.00 M61
G01 X-46.78 Y-17.40 Z2.50 A0.00 B0.00
G01 X-45.71 Y-20.01 Z2.50 A2.50 B-1.20
M62
(Hemisphere-v1, Slice 1, Fraction 2 Outer Perimeter 2)
G00 X-31.23 Y-24.83 Z2.50 A0.00 B0.00
M60=11
G01 X-31.90 Y-23.97 Z2.50 A0.00 B0.00 M61
G01 X-32.64 Y-22.94 Z2.50 A1.80 B0.50
M62
M01
(New Slice of body Hemisphere-v1 No.: 2, Z7.50)
(Hemisphere-v1, Slice 2, Fraction 1 Outer Perimeter 1)
F800.0
G00 X-45.12 Y-10.55 Z7.50 A5.10 B-2.30
M60=11
G01 X-44.50 Y-12.80 Z7.50 A5.10 B-2.30 M61
G01 X-43.90 Y-15.10 Z7.50 A6.20 B-1.80
M62
M01
"""
with tempfile.NamedTemporaryFile(
"w", suffix=".nc", delete=False
) as fh:
fh.write(TEST_NC)
tmp_path = fh.name
parsed = parse_nc(tmp_path)
summarise(parsed)
# Quick sanity check on the tool-vector helper.
print()
print(f"tool vector @ A0 B0 : {compute_tool_vector(0.0, 0.0)}")
print(f"tool vector @ A5 B-2 : {compute_tool_vector(5.0, -2.0)}")
+376
View File
@@ -0,0 +1,376 @@
"""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)
+974
View File
@@ -0,0 +1,974 @@
"""Interactive 3D viewer for Beckhoff 5-axis metal DED NC toolpaths.
Reads an NC file via the existing ``nc_parser`` (imported, never rewritten) and
renders a single self-contained, offline-capable HTML file with:
* a 3D toolpath view (rapid moves, per-layer weld paths),
* an A/B tilt-angle chart with welding-active shading,
* a statistics table,
* a layer-isolation dropdown.
Mouse controls are Plotly's built-in scatter3d defaults:
LEFT-DRAG rotate, SCROLL zoom, RIGHT-DRAG pan, HOVER tooltip.
Units: X/Y/Z in millimetres, A/B in degrees, path lengths in mm, time in
seconds (feed rate F = 800 mm/min).
Run:
python -m wired3d_viewer.viewer <path_to_nc_file>
# or, after `pip install -e .`: wired3d view <path_to_nc_file>
Dependencies: plotly, numpy. Imports the sibling ``parser`` module from this
package. The output HTML embeds plotly.js from CDN (``include_plotlyjs="cdn"``).
"""
import base64
import math
from pathlib import Path
import numpy as np
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots
from .parser import parse_nc, compute_tool_vector
# wired3d logo, base64-encoded once into a data URI so the viewer HTML stays
# self-contained/offline (no external file fetch). "" if the file is missing.
_LOGO_URI = None
def logo_data_uri() -> str:
"""Return the wired3d logo as a base64 ``data:`` URI (cached).
Output:
``"data:image/avif;base64,<...>"`` read from ``wired3d.avif`` next to
this module, or ``""`` if the file cannot be read. Cached after the
first call so repeated renders don't re-encode it.
"""
global _LOGO_URI
if _LOGO_URI is None:
try:
data = (Path(__file__).parent / "assets" / "wired3d.avif").read_bytes()
_LOGO_URI = "data:image/avif;base64," + base64.b64encode(data).decode("ascii")
except OSError:
_LOGO_URI = ""
return _LOGO_URI
# Machine feed rate used for the print-time estimate (mm/min).
FEED_RATE = 800.0
# Number of animation frames. Each frame reveals the toolpath up to one move
# (the trail itself is full-resolution — every move is drawn), so this only
# controls how chunky the growth looks and the HTML size, not completeness.
ANIM_TARGET_FRAMES = 160
# Default 3D camera: PERSPECTIVE projection from an elevated isometric angle.
# Perspective keeps the depth cues that make a tall part like the hemisphere
# read as a real 3D dome — orthographic flattens it into a pancake. Plotly
# normalises the scene to a unit cube before placing the camera, so this single
# fixed eye fits the whole part regardless of its real size — a "full view" of
# any part. The eye is pulled back so nothing is clipped. (The view stays fixed
# and free of zoom-jump because the scene axis ranges are pinned, not because
# of the projection.) Used as the initial view and the view Reset snaps back to.
DEFAULT_CAMERA = dict(
projection=dict(type="perspective"),
eye=dict(x=1.6, y=1.6, z=1.2),
center=dict(x=0.0, y=0.0, z=0.0),
up=dict(x=0.0, y=0.0, z=1.0),
)
# JS injected after the plot is built (via plotly's post_script hook, which
# substitutes {plot_id}). Overrides the default legend-click on the per-layer
# weld traces: clicking a "Layer N" entry highlights that layer (opacity 1.0)
# and dims every other layer (opacity 0.1); clicking the same entry again — or
# any "All layers" reset — restores them all. Non-layer legend entries (rapid,
# trail, angles, …) keep Plotly's normal show/hide toggle. Returning false from
# the handler suppresses the default visibility toggle for layer clicks.
LEGEND_CLICK_JS = """
var gd = document.getElementById('{plot_id}');
var weld = [];
gd.data.forEach(function(t, i) {
if (t.name && t.name.indexOf('Layer ') === 0) weld.push(i);
});
var active = null;
gd.on('plotly_legendclick', function(ev) {
var ci = ev.curveNumber;
if (weld.indexOf(ci) === -1) return true; // default toggle for non-layers
if (active === ci) {
Plotly.restyle(gd, {opacity: weld.map(function() { return 1.0; })}, weld);
active = null;
} else {
var op = weld.map(function(idx) { return idx === ci ? 1.0 : 0.1; });
Plotly.restyle(gd, {opacity: op}, weld);
active = ci;
}
return false;
});
"""
# Dark theme palette.
SCENE_BG = "#1a1a2e"
FIG_BG = "#0f0f1a"
RAPID_COLOUR = "#5b647d" # dark slate — visible on the dark scene without glare
ANGLE_A_COLOUR = "#00bfff"
ANGLE_B_COLOUR = "#ff4500"
def _slice_order(moves: list[dict]) -> list[int]:
"""Return unique slice_no values in first-seen order.
Inputs:
moves -- list of move dicts from ``parse_nc``.
Output:
list[int] of distinct ``slice_no`` values, ordered by appearance.
"""
seen = []
for m in moves:
if m["slice_no"] not in seen:
seen.append(m["slice_no"])
return seen
def _layer_colour(i: int, n: int) -> str:
"""Map a layer index to a distinct rainbow colour.
Inputs:
i -- zero-based layer index.
n -- total number of layers.
Output:
an "rgb(r,g,b)" string sampled across the hue wheel so each layer
(Z level) is visually separable.
"""
hue = (i / max(n, 1)) * 0.85 # 0..0.85 avoids wrapping red back to red
r, g, b = _hsv_to_rgb(hue, 0.85, 1.0)
return f"rgb({int(r * 255)},{int(g * 255)},{int(b * 255)})"
def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[float, float, float]:
"""Convert HSV (each 0..1) to RGB (each 0..1). Pure helper, no deps."""
i = int(h * 6.0)
f = h * 6.0 - i
p = v * (1.0 - s)
q = v * (1.0 - f * s)
t = v * (1.0 - (1.0 - f) * s)
i %= 6
return [
(v, t, p), (q, v, p), (p, v, t),
(p, q, v), (t, p, v), (v, p, q),
][i]
def _path_length(pts: list[tuple[float, float, float]]) -> float:
"""Sum Euclidean distances between consecutive 3D points (mm).
Inputs:
pts -- ordered list of (x, y, z) tuples in mm.
Output:
total path length in mm (0.0 for fewer than two points).
"""
total = 0.0
for (x0, y0, z0), (x1, y1, z1) in zip(pts, pts[1:]):
total += math.dist((x0, y0, z0), (x1, y1, z1))
return total
def compute_stats(moves: list[dict]) -> dict:
"""Compute all statistics shown in the stats table.
Inputs:
moves -- list of move dicts from ``parse_nc`` (x/y/z in mm, a/b in
degrees, plus move_type/weld_state/slice_no/fraction_no).
Output:
dict with these keys (values pre-formatted as strings for display
except where noted):
"part_name" -- set by caller, placeholder "" here
"total_layers" -- int count of unique slice_no
"total_fractions" -- int count of unique (slice_no, fraction_no)
"total_points" -- int len(moves)
"weld_points" -- int count where weld_state == "on"
"rapid_points" -- int count where move_type == "rapid"
"weld_length_mm" -- float, summed distance between consecutive
weld (cut) points, mm
"rapid_length_mm" -- float, summed distance between consecutive
rapid points, mm
"z_min"/"z_max" -- float, mm
"a_min"/"a_max" -- float, degrees
"b_min"/"b_max" -- float, degrees
"print_time_s" -- float, weld_length / FEED_RATE * 60, seconds
All numeric values are returned as floats/ints; formatting for the
table happens in ``build_stats_table``.
"""
total_points = len(moves)
layers = {m["slice_no"] for m in moves}
fractions = {(m["slice_no"], m["fraction_no"]) for m in moves}
weld_points = sum(1 for m in moves if m["weld_state"] == "on")
rapid_points = sum(1 for m in moves if m["move_type"] == "rapid")
weld_pts = [(m["x"], m["y"], m["z"]) for m in moves if m["move_type"] == "cut"]
rapid_pts = [(m["x"], m["y"], m["z"]) for m in moves if m["move_type"] == "rapid"]
weld_len = _path_length(weld_pts)
rapid_len = _path_length(rapid_pts)
zs = [m["z"] for m in moves] or [0.0]
as_ = [m["a"] for m in moves] or [0.0]
bs = [m["b"] for m in moves] or [0.0]
return {
"part_name": "",
"total_layers": len(layers),
"total_fractions": len(fractions),
"total_points": total_points,
"weld_points": weld_points,
"rapid_points": rapid_points,
"weld_length_mm": weld_len,
"rapid_length_mm": rapid_len,
"z_min": min(zs),
"z_max": max(zs),
"a_min": min(as_),
"a_max": max(as_),
"b_min": min(bs),
"b_max": max(bs),
"print_time_s": weld_len / FEED_RATE * 60.0,
}
def _customdata_row(m: dict) -> list:
"""Build the per-point customdata array used by the hover template.
Inputs:
m -- one move dict.
Output:
[x, y, z, a, b, weld_state, slice_no, fraction_no, u, v, w] where
(u, v, w) is the unit tool vector from ``compute_tool_vector``.
"""
u, v, w = compute_tool_vector(m["a"], m["b"])
return [
m["x"], m["y"], m["z"], m["a"], m["b"],
m["weld_state"], m["slice_no"], m["fraction_no"],
u, v, w,
]
# Shared hover template (units in the labels).
_HOVER = (
"<b>Position</b><br>"
"X: %{customdata[0]:.2f} mm<br>"
"Y: %{customdata[1]:.2f} mm<br>"
"Z: %{customdata[2]:.2f} mm<br>"
"<b>Extruder</b><br>"
"A: %{customdata[3]:.2f}°<br>"
"B: %{customdata[4]:.2f}°<br>"
"Vector: (%{customdata[8]:.3f}, %{customdata[9]:.3f}, %{customdata[10]:.3f})<br>"
"<b>Status:</b> %{customdata[5]}<br>"
"Slice: %{customdata[6]} Fraction: %{customdata[7]}"
"<extra></extra>"
)
def build_rapid_trace(moves: list[dict]) -> go.Scatter3d:
"""Build a single 3D trace for all rapid (non-welding) moves.
Inputs:
moves -- list of move dicts (mm for coords).
Output:
a ``go.Scatter3d`` line trace (grey dotted) covering every
``move_type == "rapid"`` point, with full hover customdata.
"""
rapids = [m for m in moves if m["move_type"] == "rapid"]
return go.Scatter3d(
x=[m["x"] for m in rapids],
y=[m["y"] for m in rapids],
z=[m["z"] for m in rapids],
mode="lines",
# Long dashes with tight gaps (a clean "---" look) in a dark slate so
# the sparse rapid travel moves read clearly without glare.
line=dict(color=RAPID_COLOUR, dash="longdash", width=3),
name="Rapid moves",
customdata=[_customdata_row(m) for m in rapids],
hovertemplate=_HOVER,
showlegend=True,
)
def build_weld_traces(moves: list[dict]) -> list[go.Scatter3d]:
"""Build one 3D weld-path trace per slice (Z layer), colour-coded.
Inputs:
moves -- list of move dicts (mm for coords).
Output:
list of ``go.Scatter3d`` "lines+markers" traces, one per unique
``slice_no`` (in appearance order), each a distinct rainbow colour and
named ``"Layer N Z=..mm"``. Only ``move_type == "cut"`` points are
included. Each trace carries full hover customdata.
"""
order = _slice_order(moves)
n = len(order)
traces = []
for i, sno in enumerate(order):
cut = [m for m in moves if m["slice_no"] == sno and m["move_type"] == "cut"]
if not cut:
continue
z = cut[0]["z"]
colour = _layer_colour(i, n)
traces.append(go.Scatter3d(
x=[m["x"] for m in cut],
y=[m["y"] for m in cut],
z=[m["z"] for m in cut],
mode="lines+markers",
marker=dict(size=3, color=colour),
line=dict(color=colour, width=3),
name=f"Layer {sno} Z={z:.1f}mm",
customdata=[_customdata_row(m) for m in cut],
hovertemplate=_HOVER,
showlegend=True,
))
return traces
def build_angle_chart(moves: list[dict]):
"""Build the A/B tilt-angle line traces plus welding-active shading.
Inputs:
moves -- list of move dicts (angles in degrees).
Output:
(traces, y_range):
traces -- list of four ``go.Scatter`` (2D) traces over move index
0..N-1: a low baseline + a "Welding active" band that
together shade (green, semi-transparent) the FULL chart
height wherever ``weld_state == "on"``, then the A axis
(blue) and B axis (red) angle lines on top.
y_range -- [ymin, ymax] for the subplot's y-axis (padded angle range,
with a sensible minimum so a flat 0° trace still shows a
band). Returned so ``build_figure`` can pin the axis — the
band is drawn to these exact bounds.
These live in the 2D subplot, not the 3D scene.
"""
idx = list(range(len(moves)))
a_vals = [m["a"] for m in moves]
b_vals = [m["b"] for m in moves]
# Pinned y-range: padded angle extent, but never degenerate (so an all-0°
# part still gets a visible band). The band fills exactly these bounds.
lo = min(a_vals + b_vals + [0.0])
hi = max(a_vals + b_vals + [0.0])
pad = max((hi - lo) * 0.1, 1.0)
ymin, ymax = lo - pad, hi + pad
welding = [m["weld_state"] == "on" for m in moves]
band_low = [ymin if on else None for on in welding]
band_high = [ymax if on else None for on in welding]
# Two traces forming a filled band: invisible baseline at ymin, then the
# top edge at ymax filling down to it. connectgaps=False keeps the band
# broken across travel (non-welding) gaps.
# These angle-chart traces are kept OUT of the legend (showlegend=False):
# they live in the 2D subplot (self-evident from the axis colours and the
# green band there), and including them forced Plotly into reversed legend
# order, pushing the 3D layer entries off-screen. The legend now shows only
# the 3D toolpath traces (rapid + per-layer welds + head + trail).
base = go.Scatter(
x=idx, y=band_low, mode="lines",
line=dict(width=0), hoverinfo="skip",
showlegend=False, connectgaps=False,
)
band = go.Scatter(
x=idx, y=band_high, mode="lines",
line=dict(width=0), fill="tonexty",
fillcolor="rgba(0,255,128,0.18)",
name="Welding active", hoverinfo="skip",
showlegend=False, connectgaps=False,
)
a_trace = go.Scatter(
x=idx, y=a_vals, mode="lines",
line=dict(color=ANGLE_A_COLOUR, width=2),
name="A axis (°)", showlegend=False,
hovertemplate="move %{x}<br>A: %{y:.2f}°<extra></extra>",
)
b_trace = go.Scatter(
x=idx, y=b_vals, mode="lines",
line=dict(color=ANGLE_B_COLOUR, width=2),
name="B axis (°)", showlegend=False,
hovertemplate="move %{x}<br>B: %{y:.2f}°<extra></extra>",
)
# Band first (base then fill) so the angle lines draw on top.
return [base, band, a_trace, b_trace], [ymin, ymax]
def build_stats_table(stats: dict) -> go.Table:
"""Build the formatted statistics table trace.
Inputs:
stats -- dict from ``compute_stats`` (with "part_name" filled in).
Output:
a ``go.Table`` trace with "Property"/"Value" columns, dark-blue header,
alternating dark row backgrounds, white 12pt text. Lengths shown in
mm, angles in degrees, time in seconds.
"""
rows = [
("Part name", stats["part_name"]),
("Total layers", f"{stats['total_layers']}"),
("Total fractions", f"{stats['total_fractions']}"),
("Total points", f"{stats['total_points']}"),
("Weld ON points", f"{stats['weld_points']}"),
("Rapid points", f"{stats['rapid_points']}"),
("Weld path length", f"{stats['weld_length_mm']:.1f} mm"),
("Rapid path length", f"{stats['rapid_length_mm']:.1f} mm"),
("Z range", f"{stats['z_min']:.2f} mm → {stats['z_max']:.2f} mm"),
("A angle range", f"{stats['a_min']:.2f}° → {stats['a_max']:.2f}°"),
("B angle range", f"{stats['b_min']:.2f}° → {stats['b_max']:.2f}°"),
("Est. print time", f"{stats['print_time_s']:.1f} s"),
]
props = [r[0] for r in rows]
vals = [r[1] for r in rows]
# Alternating row colours.
fill = [
["#1a1a2e" if i % 2 == 0 else "#16213e" for i in range(len(rows))]
] * 2
return go.Table(
header=dict(
values=["<b>Property</b>", "<b>Value</b>"],
fill_color="#0d47a1",
font=dict(color="white", size=12),
align="left",
),
cells=dict(
values=[props, vals],
fill_color=fill,
font=dict(color="white", size=12),
align="left",
height=24,
),
)
def build_layer_dropdown(weld_traces: list[go.Scatter3d], n_static_before: int,
total_traces: int) -> dict:
"""Build the updatemenus dropdown that isolates a single layer.
Inputs:
weld_traces -- the per-layer weld traces (to read names/order).
n_static_before -- number of traces placed BEFORE the weld traces in
the figure's trace list (e.g. the rapid trace),
so weld-trace indices can be computed.
total_traces -- total number of traces in the figure (for building
full-length opacity arrays via restyle).
Output:
a single ``updatemenus`` dict. "All layers" shows every weld trace at
full opacity; each "Layer N" option sets that layer's weld trace to
opacity 1.0 and all OTHER weld traces to 0.1. Non-weld traces (rapid
path, animated head cursor) are left untouched.
Note: opacity is restyled only on the weld-trace indices, so the rapid
path and the animated head cursor always stay fully visible.
"""
weld_indices = list(range(n_static_before, n_static_before + len(weld_traces)))
buttons = [dict(
label="All layers",
method="restyle",
args=[{"opacity": [1.0] * len(weld_indices)}, weld_indices],
)]
for i, tr in enumerate(weld_traces):
opac = [0.1] * len(weld_indices)
opac[i] = 1.0
buttons.append(dict(
label=tr.name,
method="restyle",
args=[{"opacity": opac}, weld_indices],
))
return dict(
buttons=buttons,
direction="down",
showactive=True,
x=0.01, xanchor="left",
y=1.12, yanchor="top",
bgcolor="#16213e",
font=dict(color="white", size=12),
bordercolor="#0d47a1",
)
def _stick_length(moves: list[dict]) -> float:
"""Pick a sensible length (mm) for the head 'stick' from the part size.
Inputs:
moves -- list of move dicts (mm coords).
Output:
10% of the largest bounding-box extent (min 5 mm), so the stick is
visible but not overwhelming regardless of part scale.
"""
if not moves:
return 5.0
xs = [m["x"] for m in moves]
ys = [m["y"] for m in moves]
zs = [m["z"] for m in moves]
ext = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 1.0)
return max(ext * 0.10, 5.0)
def _stick_points(m: dict, length: float):
"""Return the two endpoints of the head stick for one move.
Inputs:
m -- a move dict (x/y/z mm, a/b degrees).
length -- stick length in mm.
Output:
(xs, ys, zs) each a 2-element list: [contact_point, tip], where the tip
is the contact point offset by ``length`` along ``compute_tool_vector``.
Straight up (+Z) when A=B=0; tilts as A/B grow, so the inclination is
visible in 3D.
"""
u, v, w = compute_tool_vector(m["a"], m["b"])
bx, by, bz = m["x"], m["y"], m["z"]
return ([bx, bx + u * length],
[by, by + v * length],
[bz, bz + w * length])
def build_cursor_trace(moves: list[dict]) -> go.Scatter3d:
"""Build the animated head 'stick' that moves along the toolpath.
Inputs:
moves -- list of move dicts (mm coords, deg angles).
Output:
a two-point ``go.Scatter3d`` line+marker trace drawn as a short yellow
stick: a fat dot at the contact point and a thin line pointing along
the tool vector (straight up at A=B=0, tilted when the head is
inclined). Updated frame by frame during the play/pause animation.
"""
first = moves[0] if moves else {"x": 0.0, "y": 0.0, "z": 0.0,
"a": 0.0, "b": 0.0}
xs, ys, zs = _stick_points(first, _stick_length(moves))
return go.Scatter3d(
x=xs, y=ys, z=zs,
mode="lines+markers",
line=dict(color="#ffff00", width=6),
marker=dict(size=[7, 3], color="#ffff00",
line=dict(color="black", width=1)),
name="Head position",
showlegend=True,
hoverinfo="skip",
)
def build_trail_trace() -> go.Scatter3d:
"""Build the (initially empty) trail that is drawn progressively on Play.
Inputs:
none.
Output:
an empty ``go.Scatter3d`` line+markers trace. During the animation each
frame replaces it with EVERY coordinate visited so far (full resolution,
not sub-sampled), drawing a cyan line AND a dot at every point the head
has passed. Empty on load so nothing is drawn until Play is pressed.
"""
return go.Scatter3d(
x=[], y=[], z=[],
mode="lines+markers",
# Dim grey line so the layer-coloured dots (set per-frame) stand out.
line=dict(color="#5a6072", width=2),
marker=dict(size=2),
name="Printed trail",
showlegend=True,
hoverinfo="skip",
)
def _anim_indices(n_moves: int) -> list[int]:
"""Pick the move indices used as animation frames.
Inputs:
n_moves -- total number of moves.
Output:
ascending list of move indices sub-sampled to about
``ANIM_TARGET_FRAMES`` frames (always includes the final move), so the
animation stays smooth regardless of file size.
"""
if n_moves == 0:
return []
step = max(1, math.ceil(n_moves / ANIM_TARGET_FRAMES))
idxs = list(range(0, n_moves, step))
if idxs[-1] != n_moves - 1:
idxs.append(n_moves - 1)
return idxs
def build_animation(moves: list[dict], trail_index: int, cursor_index: int,
static_indices: list[int]):
"""Build the progressive-reveal animation, control buttons, and slider.
Inputs:
moves -- list of move dicts (mm coords, weld_state).
trail_index -- trace index of the growing "Printed trail".
cursor_index -- trace index of the head-position marker.
static_indices -- trace indices of the full static toolpath (rapid +
weld traces) that are hidden during playback and
restored on reset.
Output:
(frames, play_menu, slider):
frames -- list[go.Frame]. The animation frames (one per sub-sampled
move, named by move index) grow the trail up to that move
and advance the head cursor; the first frame also hides
every static toolpath trace so only the trail is drawn
while playing. A trailing "visualize" frame clears the
trail, returns the head to the start, and shows the FULL
sliced part (every static toolpath trace visible).
play_menu -- an ``updatemenus`` dict with Play (~25 fps, restarts from
the beginning so the path always re-reveals), Pause
(halts immediately), and Visualize buttons.
slider -- a ``sliders`` dict to scrub to any frame by move index.
Returns ([], None, None) when there are no moves.
Coordinates: mm. The trail/cursor positions are exact NC move coordinates;
only which moves get their own frame is sub-sampled (see ``_anim_indices``).
"""
idxs = _anim_indices(len(moves))
if not idxs:
return [], None, None
first = moves[idxs[0]]
stick_len = _stick_length(moves)
# Full-resolution coordinate arrays; each frame reveals a prefix of these
# so the real toolpath (every move, not just frame points) is drawn.
all_x = [m["x"] for m in moves]
all_y = [m["y"] for m in moves]
all_z = [m["z"] for m in moves]
# Per-point trail colour keyed to the move's layer, using the SAME mapping
# as the weld traces so each revealed dot matches its layer's rainbow hue.
order = _slice_order(moves)
n_layers = len(order)
layer_pos = {sno: i for i, sno in enumerate(order)}
all_colours = [_layer_colour(layer_pos[m["slice_no"]], n_layers) for m in moves]
# Hiding/showing the static toolpath = a tiny visibility update per static
# trace (re-used as frame data alongside the trail + cursor updates).
# Use "legendonly" (not False) to hide the path during playback: the trace
# is removed from the 3D scene but its LEGEND entry stays visible, so the
# per-layer legend entries remain while playing. Reset restores visible=True.
hide_static = [go.Scatter3d(visible="legendonly") for _ in static_indices]
show_static = [go.Scatter3d(visible=True) for _ in static_indices]
frames = []
slider_steps = []
for k, i in enumerate(idxs):
# Reveal every move up to and including this frame's move index.
upto = i + 1
m = moves[i]
trail = go.Scatter3d(
x=all_x[:upto], y=all_y[:upto], z=all_z[:upto],
marker=dict(size=2, color=all_colours[:upto]),
)
cx, cy, cz = _stick_points(m, stick_len)
cursor = go.Scatter3d(x=cx, y=cy, z=cz)
if k == 0:
# First frame also hides the full static path so playback reveals
# it, and flips scene.uirevision to "play" so the next Visualize
# (which uses a different value) is guaranteed to re-apply the camera.
data = [trail, cursor, *hide_static]
traces = [trail_index, cursor_index, *static_indices]
layout = dict(scene=dict(uirevision="play", camera=DEFAULT_CAMERA))
else:
data = [trail, cursor]
traces = [trail_index, cursor_index]
layout = None
frame_kwargs = dict(data=data, traces=traces, name=str(i))
if layout is not None:
frame_kwargs["layout"] = layout
frames.append(go.Frame(**frame_kwargs))
slider_steps.append(dict(
method="animate",
label=str(i),
args=[[str(i)], dict(
mode="immediate",
frame=dict(duration=0, redraw=True),
transition=dict(duration=0),
)],
))
anim_names = [str(i) for i in idxs]
# "Visualize" frame: shows the FULL sliced part — every static layer trace
# visible, the trail emptied, the head stick back at the start, and the
# camera snapped back to the default framed view. Flipping scene.uirevision
# to "visualize" (different from Play's "play") forces Plotly to re-apply
# DEFAULT_CAMERA even after the user has rotated.
rx, ry, rz = _stick_points(first, stick_len)
frames.append(go.Frame(
name="visualize",
data=[
go.Scatter3d(x=[], y=[], z=[]),
go.Scatter3d(x=rx, y=ry, z=rz),
*show_static,
],
traces=[trail_index, cursor_index, *static_indices],
layout=dict(scene=dict(uirevision="visualize", camera=DEFAULT_CAMERA)),
))
play_menu = dict(
type="buttons",
direction="left",
showactive=False,
x=0.01, xanchor="left",
y=0.05, yanchor="bottom",
bgcolor="#16213e",
bordercolor="#0d47a1",
font=dict(color="white", size=12),
pad=dict(l=4, r=4, t=4, b=4),
buttons=[
dict(
label="▶ Play",
method="animate",
# Explicit frame list (not None) so the trailing "reset" frame
# is excluded; fromcurrent=False restarts the reveal each time.
args=[anim_names, dict(
fromcurrent=False,
mode="immediate",
frame=dict(duration=40, redraw=True),
transition=dict(duration=0),
)],
),
dict(
label="❚❚ Pause",
method="animate",
args=[[None], dict(
mode="immediate",
frame=dict(duration=0, redraw=False),
transition=dict(duration=0),
)],
),
dict(
label="◉ Visualize",
method="animate",
args=[["visualize"], dict(
mode="immediate",
frame=dict(duration=0, redraw=True),
transition=dict(duration=0),
)],
),
],
)
slider = dict(
active=0,
x=0.05, len=0.55,
y=0.0, yanchor="top",
pad=dict(t=10, b=10),
currentvalue=dict(prefix="Move ", font=dict(color="white", size=12)),
font=dict(color="white", size=10),
bgcolor="#16213e",
bordercolor="#0d47a1",
steps=slider_steps,
)
return frames, play_menu, slider
def _axis_ranges(moves: list[dict]):
"""Compute fixed [min, max] ranges for the X/Y/Z scene axes (mm).
Inputs:
moves -- list of move dicts (mm coords).
Output:
(x_range, y_range, z_range), each a 2-element [lo, hi] list padded by
5% of the largest extent so the part never touches the scene walls.
These are pinned on the scene so the view stays FIXED and fully framed
regardless of which traces are visible — without them the animation
(which hides the static toolpath and grows a 1-point trail) makes
``aspectmode="data"`` rescale the scene, causing the zoom to jump.
"""
if not moves:
return [-1, 1], [-1, 1], [-1, 1]
xs = [m["x"] for m in moves]
ys = [m["y"] for m in moves]
zs = [m["z"] for m in moves]
pad = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 1.0) * 0.05
return (
[min(xs) - pad, max(xs) + pad],
[min(ys) - pad, max(ys) + pad],
[min(zs) - pad, max(zs) + pad],
)
def build_figure(parsed: dict, source_file: str) -> go.Figure:
"""Assemble the complete two-column Plotly figure.
Inputs:
parsed -- dict from ``parse_nc`` ("part_name", "moves").
source_file -- path/name of the NC file (shown in the title).
Output:
a ``go.Figure`` with: a large 3D toolpath scene (rapid + per-layer weld
+ an animated head cursor) spanning both left rows, an A/B angle chart
top-right, a stats table bottom-right, a layer-isolation dropdown,
play/pause animation controls with a scrub slider, and a dark theme.
Calls every ``build_*`` helper.
"""
moves = parsed["moves"]
part_name = parsed["part_name"]
stats = compute_stats(moves)
stats["part_name"] = part_name
n_layers = stats["total_layers"]
n_points = stats["total_points"]
# Fixed scene bounds from the part's bounding box (see _axis_ranges).
x_range, y_range, z_range = _axis_ranges(moves)
fig = make_subplots(
rows=2, cols=2,
column_widths=[0.66, 0.34],
row_heights=[0.5, 0.5],
specs=[
[{"type": "scene", "rowspan": 2}, {"type": "xy"}],
[None, {"type": "table"}],
],
subplot_titles=("", "Extruder Tilt — A and B Angles", ""),
horizontal_spacing=0.06,
vertical_spacing=0.10,
)
# --- 3D scene traces (col 1) ---
rapid_trace = build_rapid_trace(moves)
weld_traces = build_weld_traces(moves)
fig.add_trace(rapid_trace, row=1, col=1) # index 0
for wt in weld_traces: # indices 1..n
fig.add_trace(wt, row=1, col=1)
# Head cursor (index 1+n) and growing trail (index 2+n) sit right after the
# weld traces so the layer dropdown (restyles only weld indices) never
# touches them. The static toolpath = rapid + weld traces (indices 0..n).
cursor_index = 1 + len(weld_traces)
trail_index = 2 + len(weld_traces)
static_indices = list(range(0, 1 + len(weld_traces)))
fig.add_trace(build_cursor_trace(moves), row=1, col=1)
fig.add_trace(build_trail_trace(), row=1, col=1)
# --- angle chart (row 1, col 2) ---
angle_traces, angle_yrange = build_angle_chart(moves)
for at in angle_traces:
fig.add_trace(at, row=1, col=2)
# --- stats table (row 2, col 2) ---
fig.add_trace(build_stats_table(stats), row=2, col=2)
# Total trace count (for dropdown bookkeeping): rapid + welds + cursor +
# trail + 4 angle traces + table.
total_traces = 1 + len(weld_traces) + 2 + 4 + 1
dropdown = build_layer_dropdown(
weld_traces, n_static_before=1, total_traces=total_traces,
)
# --- animation: frames + play/pause/reset + slider ---
frames, play_menu, slider = build_animation(
moves, trail_index, cursor_index, static_indices,
)
fig.frames = frames
menus = [dropdown] + ([play_menu] if play_menu else [])
sliders = [slider] if slider else []
title = (
f"NC Viewer — {part_name} | {n_layers} layers "
f"| {n_points} points | {Path(source_file).name}"
)
# wired3d logo as a layout image (top-left corner, over the empty scene
# sky). Embedded as a data URI so the HTML stays self-contained.
logo = logo_data_uri()
logo_images = [dict(
source=logo,
xref="paper", yref="paper",
x=0.005, y=0.995, sizex=0.15, sizey=0.12,
xanchor="left", yanchor="top",
sizing="contain", opacity=0.95, layer="above",
)] if logo else []
fig.update_layout(
title=dict(text=title, font=dict(color="white", size=18), x=0.5),
images=logo_images,
autosize=True,
paper_bgcolor=FIG_BG,
plot_bgcolor=FIG_BG,
font=dict(color="white"),
updatemenus=menus,
sliders=sliders,
# Constant uirevision keeps the user's camera across frame redraws, so
# the 3D scene can be rotated/zoomed/panned WHILE the animation plays.
uirevision="keep",
legend=dict(
x=0.62, y=0.99, xanchor="right", yanchor="top",
bgcolor="rgba(22,33,62,0.6)", font=dict(color="white", size=10),
# Force normal order (rapid + layers at the top). A filled trace in
# the angle chart otherwise flips Plotly to reversed order, which
# buried the layer entries below the visible area until a relayout.
traceorder="normal",
),
scene=dict(
bgcolor=SCENE_BG,
# Pinned axis ranges (full part bounding box) keep the view fixed
# and fully framed — without them aspectmode="data" rescales the
# scene as the animation hides/reveals traces, making the zoom jump.
xaxis=dict(title="X (mm)", showgrid=True, gridcolor="#444",
color="white", range=x_range),
yaxis=dict(title="Y (mm)", showgrid=True, gridcolor="#444",
color="white", range=y_range),
zaxis=dict(title="Z (mm)", showgrid=True, gridcolor="#444",
color="white", range=z_range),
aspectmode="data",
camera=DEFAULT_CAMERA,
uirevision="keep",
),
)
# Angle chart axis labels (top-right subplot is xaxis2/yaxis2).
fig.update_xaxes(title_text="Move index", row=1, col=2,
color="white", gridcolor="#333")
# Pin the y-range so the welding band fills the full chart height exactly.
fig.update_yaxes(title_text="Angle (°)", row=1, col=2,
color="white", gridcolor="#333",
range=angle_yrange)
return fig
def main(nc_path: str) -> None:
"""Parse an NC file and write the interactive viewer HTML.
Inputs:
nc_path -- path to the .nc file to visualise.
Output:
None. Writes ``<stem>_viewer.html`` (self-contained, plotly.js from
CDN) next to where the script runs and prints "Saved: <filename>".
"""
parsed = parse_nc(nc_path)
fig = build_figure(parsed, nc_path)
out_name = f"{Path(nc_path).stem}_viewer.html"
pio.write_html(
fig, file=out_name, include_plotlyjs="cdn", full_html=True,
default_width="100%", default_height="100vh",
config={"responsive": True},
post_script=LEGEND_CLICK_JS,
)
print(f"Saved: {out_name}")
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python -m wired3d_viewer.viewer <path_to_nc_file>")
sys.exit(1)
main(sys.argv[1])