From e2978bba8330d3a05ea63fdef030c32919944db4 Mon Sep 17 00:00:00 2001 From: Vignesh Suresh Date: Sat, 30 May 2026 14:31:54 +0200 Subject: [PATCH] first commit --- README.md | 1 + __pycache__/nc_parser.cpython-314.pyc | Bin 0 -> 13037 bytes __pycache__/nc_server.cpython-314.pyc | Bin 0 -> 16643 bytes __pycache__/nc_viewer.cpython-314.pyc | Bin 0 -> 40484 bytes nc_parser.py | 297 +++++++++ nc_server.py | 359 +++++++++++ nc_viewer.py | 890 ++++++++++++++++++++++++++ output_viewer.html | 11 + 8 files changed, 1558 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/nc_parser.cpython-314.pyc create mode 100644 __pycache__/nc_server.cpython-314.pyc create mode 100644 __pycache__/nc_viewer.cpython-314.pyc create mode 100644 nc_parser.py create mode 100644 nc_server.py create mode 100644 nc_viewer.py create mode 100644 output_viewer.html 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 0000000000000000000000000000000000000000..dc682c4b12a9d4979dcb89a3e88b62dfc71243fa GIT binary patch literal 13037 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 Vzhh_dd)pp6%30s%a*r0|{5M8+eft0a literal 0 HcmV?d00001 diff --git a/__pycache__/nc_server.cpython-314.pyc b/__pycache__/nc_server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e12dcf25d3a2ef578b123b4a0ba8c9bec74de1dc GIT binary patch literal 16643 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 Nw?2+2;jIkse*&r*wut@?cfRe~O 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 Ne<#ymDPbf2{{ylat{4CS literal 0 HcmV?d00001 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