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

After

Width:  |  Height:  |  Size: 5.7 KiB

+31 -14
View File
@@ -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 = """<!doctype html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>wired3d visualizer</title>
<title>Visualizer</title>
<style>
html, body { margin: 0; height: 100%; background: #0f0f1a; color: #e8e8f0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
#wrap { display: flex; flex-direction: column; height: 100vh; }
#bar { flex: 0 0 auto; padding: 10px 16px; display: flex; align-items: center;
gap: 14px; border-bottom: 1px solid #222; background: #16213e; }
#bar img.logo { height: 28px; width: auto; display: block; }
#bar h1 { font-size: 15px; margin: 0; font-weight: 600; color: #fff; }
#status { font-size: 13px; color: #9aa0b5; }
#drop { flex: 1 1 auto; position: relative; }
@@ -77,18 +79,25 @@ INDEX_HTML = """<!doctype html>
align-items: center; justify-content: center; gap: 22px;
background: rgba(15,15,26,.92); z-index: 5; }
#loading.show { display: flex; }
#loading .spinner { width: 64px; height: 64px; border-radius: 50%;
#loading .logo-big { height: 72px; width: auto; display: block;
animation: pulse 1.3s ease-in-out infinite; }
#loading .spinner { width: 56px; height: 56px; border-radius: 50%;
border: 6px solid #1d2b4d; border-top-color: #00bfff;
animation: spin 0.9s linear infinite; }
#loading .msg { font-size: 16px; color: #cfd3e6; }
#loading .sub2 { font-size: 12px; color: #7f86a0; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse {
0%, 100% { opacity: .45; transform: scale(.96); }
50% { opacity: 1; transform: scale(1.05); }
}
</style>
</head>
<body>
<div id="wrap">
<div id="bar">
<h1>wired3d visualizer</h1>
__LOGO_IMG__
<h1>Visualizer</h1>
<span id="status">Drag an .nc file (or a folder of them) anywhere on this page.</span>
<span style="flex:1"></span>
<a class="file" id="another" style="display:none"> Drop another</a>
@@ -107,6 +116,7 @@ INDEX_HTML = """<!doctype html>
</div>
<iframe id="view"></iframe>
<div id="loading">
__LOGO_LOADING__
<div class="spinner"></div>
<div class="msg" id="loadingMsg">Rendering</div>
<div class="sub2">building the 3D toolpath this can take a moment for large files</div>
@@ -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'<img class="logo" src="{uri}" alt="wired3d">' if uri else ""
load_img = f'<img class="logo-big" src="{uri}" alt="wired3d">' if uri else ""
page = (INDEX_HTML
.replace("__LOGO_IMG__", bar_img)
.replace("__LOGO_LOADING__", load_img))
self._send(200, page)
else:
self._send(404, "not found", "text/plain")
@@ -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}")
+116 -32
View File
@@ -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 <path_to_nc_file>
python -m wired3d_viewer.viewer <path_to_nc_file>
# or, after `pip install -e .`: wired3d view <path_to_nc_file>
Dependencies: plotly, numpy. ``nc_parser.py`` must sit in the same folder.
The output HTML embeds plotly.js from CDN (``include_plotlyjs="cdn"``).
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}<br>A: %{y:.2f}°<extra></extra>",
)
b_trace = go.Scatter(
x=idx, y=b_vals, mode="lines",
line=dict(color=ANGLE_B_COLOUR, width=2),
name="B axis (°)",
name="B axis (°)", showlegend=False,
hovertemplate="move %{x}<br>B: %{y:.2f}°<extra></extra>",
)
# Band first (base then fill) so the angle lines draw on top.
@@ -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 <path_to_nc_file>")
print("Usage: python -m wired3d_viewer.viewer <path_to_nc_file>")
sys.exit(1)
main(sys.argv[1])