commit e2978bba8330d3a05ea63fdef030c32919944db4 Author: Vignesh Suresh Date: Sat May 30 14:31:54 2026 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..f817941 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Visualizer diff --git a/__pycache__/nc_parser.cpython-314.pyc b/__pycache__/nc_parser.cpython-314.pyc new file mode 100644 index 0000000..dc682c4 Binary files /dev/null and b/__pycache__/nc_parser.cpython-314.pyc differ diff --git a/__pycache__/nc_server.cpython-314.pyc b/__pycache__/nc_server.cpython-314.pyc new file mode 100644 index 0000000..e12dcf2 Binary files /dev/null and b/__pycache__/nc_server.cpython-314.pyc differ diff --git a/__pycache__/nc_viewer.cpython-314.pyc b/__pycache__/nc_viewer.cpython-314.pyc new file mode 100644 index 0000000..8e42973 Binary files /dev/null and b/__pycache__/nc_viewer.cpython-314.pyc differ diff --git a/nc_parser.py b/nc_parser.py new file mode 100644 index 0000000..aff41b7 --- /dev/null +++ b/nc_parser.py @@ -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)}") diff --git a/nc_server.py b/nc_server.py new file mode 100644 index 0000000..1c3ed26 --- /dev/null +++ b/nc_server.py @@ -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 = """ + + + + +wired3d visualizer + + + +
+
+

wired3d visualizer

+ Drag an .nc file (or a folder of them) anywhere on this page. + + +
+
+
+
⬇ Drop an NC file here
+
a single .nc file or a whole folder — parsed locally, nothing is uploaded anywhere
+
+ + +
+ + +
+
+ +
+
+
Rendering…
+
building the 3D toolpath — this can take a moment for large files
+
+
+
+ + + +""" + + +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) diff --git a/nc_viewer.py b/nc_viewer.py new file mode 100644 index 0000000..3da84dd --- /dev/null +++ b/nc_viewer.py @@ -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 + +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 = ( + "Position
" + "X: %{customdata[0]:.2f} mm
" + "Y: %{customdata[1]:.2f} mm
" + "Z: %{customdata[2]:.2f} mm
" + "Extruder
" + "A: %{customdata[3]:.2f}°
" + "B: %{customdata[4]:.2f}°
" + "Vector: (%{customdata[8]:.3f}, %{customdata[9]:.3f}, %{customdata[10]:.3f})
" + "Status: %{customdata[5]}
" + "Slice: %{customdata[6]} Fraction: %{customdata[7]}" + "" +) + + +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}
A: %{y:.2f}°", + ) + 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}
B: %{y:.2f}°", + ) + # 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=["Property", "Value"], + 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 ``_viewer.html`` (self-contained, plotly.js from + CDN) next to where the script runs and prints "Saved: ". + """ + 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 ") + sys.exit(1) + main(sys.argv[1]) diff --git a/output_viewer.html b/output_viewer.html new file mode 100644 index 0000000..3059232 --- /dev/null +++ b/output_viewer.html @@ -0,0 +1,11 @@ + + + +
+
+ + \ No newline at end of file