first commit

This commit is contained in:
Vignesh Suresh
2026-05-30 14:31:54 +02:00
commit e2978bba83
8 changed files with 1558 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Visualizer
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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)}")
+359
View File
@@ -0,0 +1,359 @@
"""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 ``nc_parser`` and rendered with the exact
same Plotly viewer as ``nc_viewer.py`` (``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 nc_server.py # serves on http://127.0.0.1:8765
python nc_server.py 9000 # custom port
Then open the printed URL and drag NC files onto it. Ctrl-C to stop.
Dependencies: plotly, numpy (same as the viewer). ``nc_parser.py`` and
``nc_viewer.py`` must sit in the same folder.
"""
import os
import sys
import tempfile
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import plotly.io as pio
from nc_parser import parse_nc
from nc_viewer import build_figure
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>wired3d 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 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 .spinner { width: 64px; height: 64px; 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); } }
</style>
</head>
<body>
<div id="wrap">
<div id="bar">
<h1>wired3d 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">
<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},
)
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"):
self._send(200, INDEX_HTML)
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 nc_server.py {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)
+890
View File
@@ -0,0 +1,890 @@
"""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 nc_viewer.py <path_to_nc_file>
Dependencies: plotly, numpy. ``nc_parser.py`` must sit in the same folder.
The output HTML embeds plotly.js from CDN (``include_plotlyjs="cdn"``).
"""
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 nc_parser import parse_nc, compute_tool_vector
# 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: ORTHOGRAPHIC projection from an elevated isometric angle.
# 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. Orthographic (no perspective foreshortening) gives a true-
# to-scale, CAD-style view; the eye is pulled back so nothing is clipped.
# Used as the initial view and the view that Reset snaps back to.
DEFAULT_CAMERA = dict(
projection=dict(type="orthographic"),
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),
)
# Dark theme palette.
SCENE_BG = "#1a1a2e"
FIG_BG = "#0f0f1a"
RAPID_COLOUR = "#888888"
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",
line=dict(color=RAPID_COLOUR, dash="dot", width=1),
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.
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",
connectgaps=False,
)
a_trace = go.Scatter(
x=idx, y=a_vals, mode="lines",
line=dict(color=ANGLE_A_COLOUR, width=2),
name="A axis (°)",
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 (°)",
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 "reset" frame clears the trail,
returns the head to the start, and makes the static
toolpath visible again.
play_menu -- an ``updatemenus`` dict with Play (~25 fps, restarts from
the beginning so the path always re-reveals), Pause
(halts immediately), and Reset 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).
hide_static = [go.Scatter3d(visible=False) 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 Reset (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]
# Reset frame: empty trail, head stick back at the start, static path
# visible, and the camera snapped back to the default zoomed-out view.
# Flipping scene.uirevision to "reset" (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="reset",
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="reset", 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="⟲ Reset",
method="animate",
args=[["reset"], 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}"
)
fig.update_layout(
title=dict(text=title, font=dict(color="white", size=18), x=0.5),
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),
),
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},
)
print(f"Saved: {out_name}")
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python nc_viewer.py <path_to_nc_file>")
sys.exit(1)
main(sys.argv[1])
File diff suppressed because one or more lines are too long