diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9f638b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +venv/ + +# Generated viewer output +*_viewer.html +/output/ diff --git a/README.md b/README.md index f817941..d1d0233 100644 --- a/README.md +++ b/README.md @@ -1 +1,64 @@ -# Visualizer +# wired3d viewer + +Interactive 3D viewer for the Beckhoff 5-axis metal-DED NC toolpaths produced by +`stl_slicer.py`. It reads the machine NC dialect and renders a self-contained, +offline-capable HTML page: a 3D toolpath scene (rapid moves + per-layer welds), +an A/B tilt-angle chart, a statistics table, layer isolation, and a +progressive-reveal print animation. + +## Layout + +``` +visualizer/ +├── pyproject.toml # package metadata + the `wired3d` console script +├── README.md +├── wired3d_viewer/ # the Python package +│ ├── __init__.py # lightweight; no heavy imports +│ ├── __main__.py # CLI: `python -m wired3d_viewer view|serve` +│ ├── parser.py # NC dialect reader (standard library only) +│ ├── viewer.py # Plotly figure builder + HTML writer +│ ├── server.py # drag-and-drop local web front end +│ └── assets/ +│ └── wired3d.avif # logo, embedded into the HTML as a data URI +``` + +## Install + +```bash +pip install -e . # installs plotly + numpy and the `wired3d` command +``` + +The package runs fine without installing too — just call the modules directly +(see below). Only `viewer`/`server` need `plotly` + `numpy`; `parser` is pure +standard library. + +## Usage + +Render an NC file to a standalone HTML viewer: + +```bash +python -m wired3d_viewer view path/to/part.nc # writes _viewer.html +wired3d view path/to/part.nc # same, if installed +``` + +Start the drag-and-drop web front end (drop an `.nc` file or a folder onto the +page): + +```bash +python -m wired3d_viewer serve # http://127.0.0.1:8765 +python -m wired3d_viewer serve 9000 # custom port +wired3d serve # same, if installed +``` + +Parse only (no visualisation, no third-party deps): + +```python +from wired3d_viewer.parser import parse_nc, summarise +summarise(parse_nc("part.nc")) +``` + +Run the parser self-test: + +```bash +python -m wired3d_viewer.parser +``` diff --git a/__pycache__/nc_parser.cpython-314.pyc b/__pycache__/nc_parser.cpython-314.pyc deleted file mode 100644 index dc682c4..0000000 Binary files a/__pycache__/nc_parser.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/nc_server.cpython-314.pyc b/__pycache__/nc_server.cpython-314.pyc deleted file mode 100644 index e12dcf2..0000000 Binary files a/__pycache__/nc_server.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/nc_viewer.cpython-314.pyc b/__pycache__/nc_viewer.cpython-314.pyc deleted file mode 100644 index 8e42973..0000000 Binary files a/__pycache__/nc_viewer.cpython-314.pyc and /dev/null differ diff --git a/output_viewer.html b/output_viewer.html deleted file mode 100644 index 3059232..0000000 --- a/output_viewer.html +++ /dev/null @@ -1,11 +0,0 @@ - - - -
-
- - \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..905d539 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "wired3d-viewer" +version = "0.1.0" +description = "Interactive 3D viewer for Beckhoff 5-axis DED NC toolpaths" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "plotly>=5", + "numpy>=1.21", +] + +[project.scripts] +wired3d = "wired3d_viewer.__main__:main" + +[tool.setuptools] +packages = ["wired3d_viewer"] + +[tool.setuptools.package-data] +wired3d_viewer = ["assets/*.avif"] diff --git a/wired3d_viewer/__init__.py b/wired3d_viewer/__init__.py new file mode 100644 index 0000000..33f288e --- /dev/null +++ b/wired3d_viewer/__init__.py @@ -0,0 +1,20 @@ +"""wired3d_viewer — interactive 3D viewer for Beckhoff 5-axis DED NC toolpaths. + +Modules +------- +parser + NC-dialect reader. Standard library only — importable with zero installs. +viewer + Plotly figure builder and self-contained HTML writer (needs plotly, numpy). +server + Drag-and-drop local web front end around the viewer. + +This top-level package deliberately imports nothing heavy: ``import +wired3d_viewer`` stays cheap and ``wired3d_viewer.parser`` keeps working without +plotly/numpy installed. Import the submodule you need explicitly, e.g.:: + + from wired3d_viewer.parser import parse_nc + from wired3d_viewer.viewer import build_figure +""" + +__version__ = "0.1.0" diff --git a/wired3d_viewer/__main__.py b/wired3d_viewer/__main__.py new file mode 100644 index 0000000..a90f5ce --- /dev/null +++ b/wired3d_viewer/__main__.py @@ -0,0 +1,54 @@ +"""Command-line entry point for the wired3d viewer package. + +Usage: + python -m wired3d_viewer view # write a standalone HTML viewer + python -m wired3d_viewer serve [port] # start the drag-and-drop server + +After ``pip install -e .`` the same commands are available as:: + + wired3d view + wired3d serve [port] + +Submodules are imported lazily inside each handler so that ``view``/``serve`` +only pull in plotly/numpy when actually used. +""" + +import argparse + + +def main(argv: list[str] | None = None) -> None: + """Parse argv and dispatch to the viewer or server. + + Inputs: + argv -- optional argument list (defaults to ``sys.argv[1:]``). + Output: + None. Runs the chosen subcommand. + """ + parser = argparse.ArgumentParser( + prog="wired3d_viewer", + description="Interactive 3D viewer for Beckhoff 5-axis DED NC toolpaths.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + p_view = sub.add_parser( + "view", help="render an NC file to a standalone HTML viewer") + p_view.add_argument("nc_file", help="path to the .nc file to visualise") + + p_serve = sub.add_parser( + "serve", help="start the drag-and-drop web server") + p_serve.add_argument( + "port", nargs="?", type=int, default=8765, + help="TCP port to listen on (default 8765)") + + args = parser.parse_args(argv) + + if args.command == "view": + from .viewer import main as view_main + view_main(args.nc_file) + elif args.command == "serve": + from .server import main as serve_main + serve_main(args.port) + + +if __name__ == "__main__": + main() diff --git a/wired3d_viewer/assets/wired3d.avif b/wired3d_viewer/assets/wired3d.avif new file mode 100644 index 0000000..a372330 Binary files /dev/null and b/wired3d_viewer/assets/wired3d.avif differ diff --git a/nc_parser.py b/wired3d_viewer/parser.py similarity index 100% rename from nc_parser.py rename to wired3d_viewer/parser.py diff --git a/nc_server.py b/wired3d_viewer/server.py similarity index 89% rename from nc_server.py rename to wired3d_viewer/server.py index 1c3ed26..34249d6 100644 --- a/nc_server.py +++ b/wired3d_viewer/server.py @@ -3,9 +3,9 @@ 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. +onto the page and it is parsed with the package ``parser`` and rendered with the +exact same Plotly viewer (``viewer.build_figure``), shown inline in an iframe. +Drop another at any time; pick from a list when several are dropped. Why a server (instead of pure client-side drag-drop): the parser and the viewer already live in Python and must not be rewritten in JavaScript. The browser only @@ -13,13 +13,14 @@ 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 + python -m wired3d_viewer.server # serves on http://127.0.0.1:8765 + python -m wired3d_viewer.server 9000 # custom port + # or, after `pip install -e .`: wired3d serve [port] Then open the printed URL and drag NC files onto it. Ctrl-C to stop. -Dependencies: plotly, numpy (same as the viewer). ``nc_parser.py`` and -``nc_viewer.py`` must sit in the same folder. +Dependencies: plotly, numpy (same as the viewer). Imports the sibling +``parser`` and ``viewer`` modules from this package. """ import os @@ -31,8 +32,8 @@ from pathlib import Path import plotly.io as pio -from nc_parser import parse_nc -from nc_viewer import build_figure +from .parser import parse_nc +from .viewer import build_figure, LEGEND_CLICK_JS, logo_data_uri DEFAULT_PORT = 8765 @@ -44,13 +45,14 @@ INDEX_HTML = """ -wired3d visualizer +Visualizer
-

wired3d visualizer

+ __LOGO_IMG__ +

Visualizer

Drag an .nc file (or a folder of them) anywhere on this page. @@ -107,6 +116,7 @@ INDEX_HTML = """
+ __LOGO_LOADING__
Rendering…
building the 3D toolpath — this can take a moment for large files
@@ -280,6 +290,7 @@ def render_nc_text(nc_text: str, name: str) -> str: fig, include_plotlyjs="cdn", full_html=True, default_width="100%", default_height="100vh", config={"responsive": True}, + post_script=LEGEND_CLICK_JS, ) @@ -296,7 +307,13 @@ class _Handler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 (http.server naming) if self.path in ("/", "/index.html"): - self._send(200, INDEX_HTML) + uri = logo_data_uri() + bar_img = f'' if uri else "" + load_img = f'wired3d' if uri else "" + page = (INDEX_HTML + .replace("__LOGO_IMG__", bar_img) + .replace("__LOGO_LOADING__", load_img)) + self._send(200, page) else: self._send(404, "not found", "text/plain") @@ -338,7 +355,7 @@ def main(port: int = DEFAULT_PORT) -> None: 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}") + f"python -m wired3d_viewer.server {port + 1}") return url = f"http://127.0.0.1:{port}" print(f"NC Viewer drop zone running at {url}") diff --git a/nc_viewer.py b/wired3d_viewer/viewer.py similarity index 83% rename from nc_viewer.py rename to wired3d_viewer/viewer.py index 3da84dd..04dafad 100644 --- a/nc_viewer.py +++ b/wired3d_viewer/viewer.py @@ -15,12 +15,14 @@ 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 + python -m wired3d_viewer.viewer + # or, after `pip install -e .`: wired3d view -Dependencies: plotly, numpy. ``nc_parser.py`` must sit in the same folder. -The output HTML embeds plotly.js from CDN (``include_plotlyjs="cdn"``). +Dependencies: plotly, numpy. Imports the sibling ``parser`` module from this +package. The output HTML embeds plotly.js from CDN (``include_plotlyjs="cdn"``). """ +import base64 import math from pathlib import Path @@ -29,7 +31,30 @@ 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 +from .parser import parse_nc, compute_tool_vector + + +# wired3d logo, base64-encoded once into a data URI so the viewer HTML stays +# self-contained/offline (no external file fetch). "" if the file is missing. +_LOGO_URI = None + + +def logo_data_uri() -> str: + """Return the wired3d logo as a base64 ``data:`` URI (cached). + + Output: + ``"data:image/avif;base64,<...>"`` read from ``wired3d.avif`` next to + this module, or ``""`` if the file cannot be read. Cached after the + first call so repeated renders don't re-encode it. + """ + global _LOGO_URI + if _LOGO_URI is None: + try: + data = (Path(__file__).parent / "assets" / "wired3d.avif").read_bytes() + _LOGO_URI = "data:image/avif;base64," + base64.b64encode(data).decode("ascii") + except OSError: + _LOGO_URI = "" + return _LOGO_URI # Machine feed rate used for the print-time estimate (mm/min). @@ -40,23 +65,54 @@ FEED_RATE = 800.0 # 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 3D camera: PERSPECTIVE projection from an elevated isometric angle. +# Perspective keeps the depth cues that make a tall part like the hemisphere +# read as a real 3D dome — orthographic flattens it into a pancake. Plotly +# normalises the scene to a unit cube before placing the camera, so this single +# fixed eye fits the whole part regardless of its real size — a "full view" of +# any part. The eye is pulled back so nothing is clipped. (The view stays fixed +# and free of zoom-jump because the scene axis ranges are pinned, not because +# of the projection.) Used as the initial view and the view Reset snaps back to. DEFAULT_CAMERA = dict( - projection=dict(type="orthographic"), + projection=dict(type="perspective"), eye=dict(x=1.6, y=1.6, z=1.2), center=dict(x=0.0, y=0.0, z=0.0), up=dict(x=0.0, y=0.0, z=1.0), ) +# JS injected after the plot is built (via plotly's post_script hook, which +# substitutes {plot_id}). Overrides the default legend-click on the per-layer +# weld traces: clicking a "Layer N" entry highlights that layer (opacity 1.0) +# and dims every other layer (opacity 0.1); clicking the same entry again — or +# any "All layers" reset — restores them all. Non-layer legend entries (rapid, +# trail, angles, …) keep Plotly's normal show/hide toggle. Returning false from +# the handler suppresses the default visibility toggle for layer clicks. +LEGEND_CLICK_JS = """ +var gd = document.getElementById('{plot_id}'); +var weld = []; +gd.data.forEach(function(t, i) { + if (t.name && t.name.indexOf('Layer ') === 0) weld.push(i); +}); +var active = null; +gd.on('plotly_legendclick', function(ev) { + var ci = ev.curveNumber; + if (weld.indexOf(ci) === -1) return true; // default toggle for non-layers + if (active === ci) { + Plotly.restyle(gd, {opacity: weld.map(function() { return 1.0; })}, weld); + active = null; + } else { + var op = weld.map(function(idx) { return idx === ci ? 1.0 : 0.1; }); + Plotly.restyle(gd, {opacity: op}, weld); + active = ci; + } + return false; +}); +""" + # Dark theme palette. SCENE_BG = "#1a1a2e" FIG_BG = "#0f0f1a" -RAPID_COLOUR = "#888888" +RAPID_COLOUR = "#5b647d" # dark slate — visible on the dark scene without glare ANGLE_A_COLOUR = "#00bfff" ANGLE_B_COLOUR = "#ff4500" @@ -229,7 +285,9 @@ def build_rapid_trace(moves: list[dict]) -> go.Scatter3d: 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), + # Long dashes with tight gaps (a clean "---" look) in a dark slate so + # the sparse rapid travel moves read clearly without glare. + line=dict(color=RAPID_COLOUR, dash="longdash", width=3), name="Rapid moves", customdata=[_customdata_row(m) for m in rapids], hovertemplate=_HOVER, @@ -308,6 +366,11 @@ def build_angle_chart(moves: list[dict]): # Two traces forming a filled band: invisible baseline at ymin, then the # top edge at ymax filling down to it. connectgaps=False keeps the band # broken across travel (non-welding) gaps. + # These angle-chart traces are kept OUT of the legend (showlegend=False): + # they live in the 2D subplot (self-evident from the axis colours and the + # green band there), and including them forced Plotly into reversed legend + # order, pushing the 3D layer entries off-screen. The legend now shows only + # the 3D toolpath traces (rapid + per-layer welds + head + trail). base = go.Scatter( x=idx, y=band_low, mode="lines", line=dict(width=0), hoverinfo="skip", @@ -318,18 +381,18 @@ def build_angle_chart(moves: list[dict]): line=dict(width=0), fill="tonexty", fillcolor="rgba(0,255,128,0.18)", name="Welding active", hoverinfo="skip", - connectgaps=False, + showlegend=False, connectgaps=False, ) a_trace = go.Scatter( x=idx, y=a_vals, mode="lines", line=dict(color=ANGLE_A_COLOUR, width=2), - name="A axis (°)", + name="A axis (°)", showlegend=False, 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 (°)", + name="B axis (°)", showlegend=False, hovertemplate="move %{x}
B: %{y:.2f}°", ) # Band first (base then fill) so the angle lines draw on top. @@ -557,12 +620,12 @@ def build_animation(moves: list[dict], trail_index: int, cursor_index: int, 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. + while playing. A trailing "visualize" frame clears the + trail, returns the head to the start, and shows the FULL + sliced part (every static toolpath trace visible). play_menu -- an ``updatemenus`` dict with Play (~25 fps, restarts from the beginning so the path always re-reveals), Pause - (halts immediately), and Reset buttons. + (halts immediately), and Visualize buttons. slider -- a ``sliders`` dict to scrub to any frame by move index. Returns ([], None, None) when there are no moves. @@ -589,7 +652,10 @@ def build_animation(moves: list[dict], trail_index: int, cursor_index: int, # 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] + # Use "legendonly" (not False) to hide the path during playback: the trace + # is removed from the 3D scene but its LEGEND entry stays visible, so the + # per-layer legend entries remain while playing. Reset restores visible=True. + hide_static = [go.Scatter3d(visible="legendonly") for _ in static_indices] show_static = [go.Scatter3d(visible=True) for _ in static_indices] frames = [] @@ -608,8 +674,8 @@ def build_animation(moves: list[dict], trail_index: int, cursor_index: int, 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. + # it, and flips scene.uirevision to "play" so the next Visualize + # (which uses a different value) is guaranteed to re-apply the camera. data = [trail, cursor, *hide_static] traces = [trail_index, cursor_index, *static_indices] layout = dict(scene=dict(uirevision="play", camera=DEFAULT_CAMERA)) @@ -634,20 +700,21 @@ def build_animation(moves: list[dict], trail_index: int, cursor_index: int, 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. + # "Visualize" frame: shows the FULL sliced part — every static layer trace + # visible, the trail emptied, the head stick back at the start, and the + # camera snapped back to the default framed view. Flipping scene.uirevision + # to "visualize" (different from Play's "play") forces Plotly to re-apply + # DEFAULT_CAMERA even after the user has rotated. rx, ry, rz = _stick_points(first, stick_len) frames.append(go.Frame( - name="reset", + name="visualize", data=[ go.Scatter3d(x=[], y=[], z=[]), go.Scatter3d(x=rx, y=ry, z=rz), *show_static, ], traces=[trail_index, cursor_index, *static_indices], - layout=dict(scene=dict(uirevision="reset", camera=DEFAULT_CAMERA)), + layout=dict(scene=dict(uirevision="visualize", camera=DEFAULT_CAMERA)), )) play_menu = dict( @@ -683,9 +750,9 @@ def build_animation(moves: list[dict], trail_index: int, cursor_index: int, )], ), dict( - label="⟲ Reset", + label="◉ Visualize", method="animate", - args=[["reset"], dict( + args=[["visualize"], dict( mode="immediate", frame=dict(duration=0, redraw=True), transition=dict(duration=0), @@ -818,8 +885,20 @@ def build_figure(parsed: dict, source_file: str) -> go.Figure: f"| {n_points} points | {Path(source_file).name}" ) + # wired3d logo as a layout image (top-left corner, over the empty scene + # sky). Embedded as a data URI so the HTML stays self-contained. + logo = logo_data_uri() + logo_images = [dict( + source=logo, + xref="paper", yref="paper", + x=0.005, y=0.995, sizex=0.15, sizey=0.12, + xanchor="left", yanchor="top", + sizing="contain", opacity=0.95, layer="above", + )] if logo else [] + fig.update_layout( title=dict(text=title, font=dict(color="white", size=18), x=0.5), + images=logo_images, autosize=True, paper_bgcolor=FIG_BG, plot_bgcolor=FIG_BG, @@ -832,6 +911,10 @@ def build_figure(parsed: dict, source_file: str) -> go.Figure: legend=dict( x=0.62, y=0.99, xanchor="right", yanchor="top", bgcolor="rgba(22,33,62,0.6)", font=dict(color="white", size=10), + # Force normal order (rapid + layers at the top). A filled trace in + # the angle chart otherwise flips Plotly to reversed order, which + # buried the layer entries below the visible area until a relayout. + traceorder="normal", ), scene=dict( bgcolor=SCENE_BG, @@ -878,6 +961,7 @@ def main(nc_path: str) -> None: fig, file=out_name, include_plotlyjs="cdn", full_html=True, default_width="100%", default_height="100vh", config={"responsive": True}, + post_script=LEGEND_CLICK_JS, ) print(f"Saved: {out_name}") @@ -885,6 +969,6 @@ def main(nc_path: str) -> None: if __name__ == "__main__": import sys if len(sys.argv) < 2: - print("Usage: python nc_viewer.py ") + print("Usage: python -m wired3d_viewer.viewer ") sys.exit(1) main(sys.argv[1])