From a4b9a311dd3e2988f57ff299decff961f4f48b90 Mon Sep 17 00:00:00 2001 From: Vignesh Suresh Date: Sat, 30 May 2026 15:19:12 +0200 Subject: [PATCH] 30.05 --- .gitignore | 12 ++ README.md | 65 +++++++++- __pycache__/nc_parser.cpython-314.pyc | Bin 13037 -> 0 bytes __pycache__/nc_server.cpython-314.pyc | Bin 16643 -> 0 bytes __pycache__/nc_viewer.cpython-314.pyc | Bin 40484 -> 0 bytes output_viewer.html | 11 -- pyproject.toml | 23 ++++ wired3d_viewer/__init__.py | 20 +++ wired3d_viewer/__main__.py | 54 +++++++++ wired3d_viewer/assets/wired3d.avif | Bin 0 -> 5844 bytes nc_parser.py => wired3d_viewer/parser.py | 0 nc_server.py => wired3d_viewer/server.py | 45 ++++--- nc_viewer.py => wired3d_viewer/viewer.py | 148 ++++++++++++++++++----- 13 files changed, 320 insertions(+), 58 deletions(-) create mode 100644 .gitignore delete mode 100644 __pycache__/nc_parser.cpython-314.pyc delete mode 100644 __pycache__/nc_server.cpython-314.pyc delete mode 100644 __pycache__/nc_viewer.cpython-314.pyc delete mode 100644 output_viewer.html create mode 100644 pyproject.toml create mode 100644 wired3d_viewer/__init__.py create mode 100644 wired3d_viewer/__main__.py create mode 100644 wired3d_viewer/assets/wired3d.avif rename nc_parser.py => wired3d_viewer/parser.py (100%) rename nc_server.py => wired3d_viewer/server.py (89%) rename nc_viewer.py => wired3d_viewer/viewer.py (83%) 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 dc682c4b12a9d4979dcb89a3e88b62dfc71243fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13037 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 Vzhh_dd)pp6%30s%a*r0|{5M8+eft0a diff --git a/__pycache__/nc_server.cpython-314.pyc b/__pycache__/nc_server.cpython-314.pyc deleted file mode 100644 index e12dcf25d3a2ef578b123b4a0ba8c9bec74de1dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16643 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 Nw?2+2;jIkse*&r*wut@?cfRe~O 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 Ne<#ymDPbf2{{ylat{4CS 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 0000000000000000000000000000000000000000..a372330444a1b9149ebbd693d15254f4a06c6ee7 GIT binary patch literal 5844 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 HYFhpW9ck6J literal 0 HcmV?d00001 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])