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 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 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 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 onto the page and it is parsed with the package ``parser`` and rendered with the
same Plotly viewer as ``nc_viewer.py`` (``build_figure``), shown inline in an exact same Plotly viewer (``viewer.build_figure``), shown inline in an iframe.
iframe. Drop another at any time; pick from a list when several are dropped. 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 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 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. building and returns ready-to-display HTML.
Run: Run:
python nc_server.py # serves on http://127.0.0.1:8765 python -m wired3d_viewer.server # serves on http://127.0.0.1:8765
python nc_server.py 9000 # custom port 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. 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 Dependencies: plotly, numpy (same as the viewer). Imports the sibling
``nc_viewer.py`` must sit in the same folder. ``parser`` and ``viewer`` modules from this package.
""" """
import os import os
@@ -31,8 +32,8 @@ from pathlib import Path
import plotly.io as pio import plotly.io as pio
from nc_parser import parse_nc from .parser import parse_nc
from nc_viewer import build_figure from .viewer import build_figure, LEGEND_CLICK_JS, logo_data_uri
DEFAULT_PORT = 8765 DEFAULT_PORT = 8765
@@ -44,13 +45,14 @@ INDEX_HTML = """<!doctype html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>wired3d visualizer</title> <title>Visualizer</title>
<style> <style>
html, body { margin: 0; height: 100%; background: #0f0f1a; color: #e8e8f0; html, body { margin: 0; height: 100%; background: #0f0f1a; color: #e8e8f0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; } font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
#wrap { display: flex; flex-direction: column; height: 100vh; } #wrap { display: flex; flex-direction: column; height: 100vh; }
#bar { flex: 0 0 auto; padding: 10px 16px; display: flex; align-items: center; #bar { flex: 0 0 auto; padding: 10px 16px; display: flex; align-items: center;
gap: 14px; border-bottom: 1px solid #222; background: #16213e; } 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; } #bar h1 { font-size: 15px; margin: 0; font-weight: 600; color: #fff; }
#status { font-size: 13px; color: #9aa0b5; } #status { font-size: 13px; color: #9aa0b5; }
#drop { flex: 1 1 auto; position: relative; } #drop { flex: 1 1 auto; position: relative; }
@@ -77,18 +79,25 @@ INDEX_HTML = """<!doctype html>
align-items: center; justify-content: center; gap: 22px; align-items: center; justify-content: center; gap: 22px;
background: rgba(15,15,26,.92); z-index: 5; } background: rgba(15,15,26,.92); z-index: 5; }
#loading.show { display: flex; } #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; border: 6px solid #1d2b4d; border-top-color: #00bfff;
animation: spin 0.9s linear infinite; } animation: spin 0.9s linear infinite; }
#loading .msg { font-size: 16px; color: #cfd3e6; } #loading .msg { font-size: 16px; color: #cfd3e6; }
#loading .sub2 { font-size: 12px; color: #7f86a0; } #loading .sub2 { font-size: 12px; color: #7f86a0; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse {
0%, 100% { opacity: .45; transform: scale(.96); }
50% { opacity: 1; transform: scale(1.05); }
}
</style> </style>
</head> </head>
<body> <body>
<div id="wrap"> <div id="wrap">
<div id="bar"> <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 id="status">Drag an .nc file (or a folder of them) anywhere on this page.</span>
<span style="flex:1"></span> <span style="flex:1"></span>
<a class="file" id="another" style="display:none"> Drop another</a> <a class="file" id="another" style="display:none"> Drop another</a>
@@ -107,6 +116,7 @@ INDEX_HTML = """<!doctype html>
</div> </div>
<iframe id="view"></iframe> <iframe id="view"></iframe>
<div id="loading"> <div id="loading">
__LOGO_LOADING__
<div class="spinner"></div> <div class="spinner"></div>
<div class="msg" id="loadingMsg">Rendering</div> <div class="msg" id="loadingMsg">Rendering</div>
<div class="sub2">building the 3D toolpath this can take a moment for large files</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, fig, include_plotlyjs="cdn", full_html=True,
default_width="100%", default_height="100vh", default_width="100%", default_height="100vh",
config={"responsive": True}, config={"responsive": True},
post_script=LEGEND_CLICK_JS,
) )
@@ -296,7 +307,13 @@ class _Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 (http.server naming) def do_GET(self): # noqa: N802 (http.server naming)
if self.path in ("/", "/index.html"): 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: else:
self._send(404, "not found", "text/plain") self._send(404, "not found", "text/plain")
@@ -338,7 +355,7 @@ def main(port: int = DEFAULT_PORT) -> None:
except OSError as exc: except OSError as exc:
print(f"Could not bind port {port}: {exc}") print(f"Could not bind port {port}: {exc}")
print(f"Another server is likely already running. Try a different port: " 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 return
url = f"http://127.0.0.1:{port}" url = f"http://127.0.0.1:{port}"
print(f"NC Viewer drop zone running at {url}") 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). seconds (feed rate F = 800 mm/min).
Run: 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. Dependencies: plotly, numpy. Imports the sibling ``parser`` module from this
The output HTML embeds plotly.js from CDN (``include_plotlyjs="cdn"``). package. The output HTML embeds plotly.js from CDN (``include_plotlyjs="cdn"``).
""" """
import base64
import math import math
from pathlib import Path from pathlib import Path
@@ -29,7 +31,30 @@ import plotly.graph_objects as go
import plotly.io as pio import plotly.io as pio
from plotly.subplots import make_subplots 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). # 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. # controls how chunky the growth looks and the HTML size, not completeness.
ANIM_TARGET_FRAMES = 160 ANIM_TARGET_FRAMES = 160
# Default 3D camera: ORTHOGRAPHIC projection from an elevated isometric angle. # Default 3D camera: PERSPECTIVE projection from an elevated isometric angle.
# Plotly normalises the scene to a unit cube before placing the camera, so this # Perspective keeps the depth cues that make a tall part like the hemisphere
# single fixed eye fits the whole part regardless of its real size — a "full # read as a real 3D dome — orthographic flattens it into a pancake. Plotly
# view" of any part. Orthographic (no perspective foreshortening) gives a true- # normalises the scene to a unit cube before placing the camera, so this single
# to-scale, CAD-style view; the eye is pulled back so nothing is clipped. # fixed eye fits the whole part regardless of its real size — a "full view" of
# Used as the initial view and the view that Reset snaps back to. # 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( DEFAULT_CAMERA = dict(
projection=dict(type="orthographic"), projection=dict(type="perspective"),
eye=dict(x=1.6, y=1.6, z=1.2), eye=dict(x=1.6, y=1.6, z=1.2),
center=dict(x=0.0, y=0.0, z=0.0), center=dict(x=0.0, y=0.0, z=0.0),
up=dict(x=0.0, y=0.0, z=1.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. # Dark theme palette.
SCENE_BG = "#1a1a2e" SCENE_BG = "#1a1a2e"
FIG_BG = "#0f0f1a" FIG_BG = "#0f0f1a"
RAPID_COLOUR = "#888888" RAPID_COLOUR = "#5b647d" # dark slate — visible on the dark scene without glare
ANGLE_A_COLOUR = "#00bfff" ANGLE_A_COLOUR = "#00bfff"
ANGLE_B_COLOUR = "#ff4500" ANGLE_B_COLOUR = "#ff4500"
@@ -229,7 +285,9 @@ def build_rapid_trace(moves: list[dict]) -> go.Scatter3d:
y=[m["y"] for m in rapids], y=[m["y"] for m in rapids],
z=[m["z"] for m in rapids], z=[m["z"] for m in rapids],
mode="lines", 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", name="Rapid moves",
customdata=[_customdata_row(m) for m in rapids], customdata=[_customdata_row(m) for m in rapids],
hovertemplate=_HOVER, 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 # 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 # top edge at ymax filling down to it. connectgaps=False keeps the band
# broken across travel (non-welding) gaps. # 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( base = go.Scatter(
x=idx, y=band_low, mode="lines", x=idx, y=band_low, mode="lines",
line=dict(width=0), hoverinfo="skip", line=dict(width=0), hoverinfo="skip",
@@ -318,18 +381,18 @@ def build_angle_chart(moves: list[dict]):
line=dict(width=0), fill="tonexty", line=dict(width=0), fill="tonexty",
fillcolor="rgba(0,255,128,0.18)", fillcolor="rgba(0,255,128,0.18)",
name="Welding active", hoverinfo="skip", name="Welding active", hoverinfo="skip",
connectgaps=False, showlegend=False, connectgaps=False,
) )
a_trace = go.Scatter( a_trace = go.Scatter(
x=idx, y=a_vals, mode="lines", x=idx, y=a_vals, mode="lines",
line=dict(color=ANGLE_A_COLOUR, width=2), 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>", hovertemplate="move %{x}<br>A: %{y:.2f}°<extra></extra>",
) )
b_trace = go.Scatter( b_trace = go.Scatter(
x=idx, y=b_vals, mode="lines", x=idx, y=b_vals, mode="lines",
line=dict(color=ANGLE_B_COLOUR, width=2), 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>", hovertemplate="move %{x}<br>B: %{y:.2f}°<extra></extra>",
) )
# Band first (base then fill) so the angle lines draw on top. # 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 move, named by move index) grow the trail up to that move
and advance the head cursor; the first frame also hides and advance the head cursor; the first frame also hides
every static toolpath trace so only the trail is drawn every static toolpath trace so only the trail is drawn
while playing. A trailing "reset" frame clears the trail, while playing. A trailing "visualize" frame clears the
returns the head to the start, and makes the static trail, returns the head to the start, and shows the FULL
toolpath visible again. sliced part (every static toolpath trace visible).
play_menu -- an ``updatemenus`` dict with Play (~25 fps, restarts from play_menu -- an ``updatemenus`` dict with Play (~25 fps, restarts from
the beginning so the path always re-reveals), Pause 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. slider -- a ``sliders`` dict to scrub to any frame by move index.
Returns ([], None, None) when there are no moves. 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 # Hiding/showing the static toolpath = a tiny visibility update per static
# trace (re-used as frame data alongside the trail + cursor updates). # 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] show_static = [go.Scatter3d(visible=True) for _ in static_indices]
frames = [] frames = []
@@ -608,8 +674,8 @@ def build_animation(moves: list[dict], trail_index: int, cursor_index: int,
if k == 0: if k == 0:
# First frame also hides the full static path so playback reveals # First frame also hides the full static path so playback reveals
# it, and flips scene.uirevision to "play" so the next Reset (which # it, and flips scene.uirevision to "play" so the next Visualize
# uses a different value) is guaranteed to re-apply the camera. # (which uses a different value) is guaranteed to re-apply the camera.
data = [trail, cursor, *hide_static] data = [trail, cursor, *hide_static]
traces = [trail_index, cursor_index, *static_indices] traces = [trail_index, cursor_index, *static_indices]
layout = dict(scene=dict(uirevision="play", camera=DEFAULT_CAMERA)) 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] anim_names = [str(i) for i in idxs]
# Reset frame: empty trail, head stick back at the start, static path # "Visualize" frame: shows the FULL sliced part — every static layer trace
# visible, and the camera snapped back to the default zoomed-out view. # visible, the trail emptied, the head stick back at the start, and the
# Flipping scene.uirevision to "reset" (different from Play's "play") forces # camera snapped back to the default framed view. Flipping scene.uirevision
# Plotly to re-apply DEFAULT_CAMERA even after the user has rotated. # 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) rx, ry, rz = _stick_points(first, stick_len)
frames.append(go.Frame( frames.append(go.Frame(
name="reset", name="visualize",
data=[ data=[
go.Scatter3d(x=[], y=[], z=[]), go.Scatter3d(x=[], y=[], z=[]),
go.Scatter3d(x=rx, y=ry, z=rz), go.Scatter3d(x=rx, y=ry, z=rz),
*show_static, *show_static,
], ],
traces=[trail_index, cursor_index, *static_indices], 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( play_menu = dict(
@@ -683,9 +750,9 @@ def build_animation(moves: list[dict], trail_index: int, cursor_index: int,
)], )],
), ),
dict( dict(
label="⟲ Reset", label="◉ Visualize",
method="animate", method="animate",
args=[["reset"], dict( args=[["visualize"], dict(
mode="immediate", mode="immediate",
frame=dict(duration=0, redraw=True), frame=dict(duration=0, redraw=True),
transition=dict(duration=0), 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}" 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( fig.update_layout(
title=dict(text=title, font=dict(color="white", size=18), x=0.5), title=dict(text=title, font=dict(color="white", size=18), x=0.5),
images=logo_images,
autosize=True, autosize=True,
paper_bgcolor=FIG_BG, paper_bgcolor=FIG_BG,
plot_bgcolor=FIG_BG, plot_bgcolor=FIG_BG,
@@ -832,6 +911,10 @@ def build_figure(parsed: dict, source_file: str) -> go.Figure:
legend=dict( legend=dict(
x=0.62, y=0.99, xanchor="right", yanchor="top", x=0.62, y=0.99, xanchor="right", yanchor="top",
bgcolor="rgba(22,33,62,0.6)", font=dict(color="white", size=10), 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( scene=dict(
bgcolor=SCENE_BG, bgcolor=SCENE_BG,
@@ -878,6 +961,7 @@ def main(nc_path: str) -> None:
fig, file=out_name, include_plotlyjs="cdn", full_html=True, fig, file=out_name, include_plotlyjs="cdn", full_html=True,
default_width="100%", default_height="100vh", default_width="100%", default_height="100vh",
config={"responsive": True}, config={"responsive": True},
post_script=LEGEND_CLICK_JS,
) )
print(f"Saved: {out_name}") print(f"Saved: {out_name}")
@@ -885,6 +969,6 @@ def main(nc_path: str) -> None:
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
if len(sys.argv) < 2: 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) sys.exit(1)
main(sys.argv[1]) main(sys.argv[1])