diff --git a/README.md b/README.md index d1d0233..80451b7 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ visualizer/ │ ├── __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 +│ ├── viewer.py # NC Plotly figure builder + HTML writer +│ ├── server.py # NC drag-and-drop local web front end +│ ├── stl_viewer.py # STL mesh figure builder + HTML writer +│ ├── server2.py # STL drag-and-drop local web front end │ └── assets/ │ └── wired3d.avif # logo, embedded into the HTML as a data URI ``` @@ -50,6 +52,21 @@ python -m wired3d_viewer serve 9000 # custom port wired3d serve # same, if installed ``` +### STL mesh viewer ("server 2") + +View a raw `.stl` part in 3D *before* slicing — rotate/zoom with the mouse. Once +a part is loaded a **Slice & Visualize** button (with layer-height / infill +inputs) runs `stl_slicer` and shows the resulting toolpath with the NC viewer on +the same page; "Back to mesh" flips back. (Per-coordinate direction vectors are +the next feature and are not drawn yet.) + +```bash +python -m wired3d_viewer view-stl path/to/part.stl # writes _stl_viewer.html +python -m wired3d_viewer serve-stl # http://127.0.0.1:8766 +python -m wired3d_viewer serve-stl 9001 # custom port +wired3d view-stl part.stl / wired3d serve-stl # same, if installed +``` + Parse only (no visualisation, no third-party deps): ```python diff --git a/pyproject.toml b/pyproject.toml index 905d539..5cb91ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,13 @@ build-backend = "setuptools.build_meta" [project] name = "wired3d-viewer" version = "0.1.0" -description = "Interactive 3D viewer for Beckhoff 5-axis DED NC toolpaths" +description = "Interactive 3D viewer for Beckhoff 5-axis DED NC toolpaths and STL meshes" readme = "README.md" requires-python = ">=3.10" dependencies = [ "plotly>=5", "numpy>=1.21", + "trimesh>=4", # STL mesh loading for the STL viewer / server-2 ] [project.scripts] diff --git a/wired3d_viewer/__init__.py b/wired3d_viewer/__init__.py index 33f288e..f90af82 100644 --- a/wired3d_viewer/__init__.py +++ b/wired3d_viewer/__init__.py @@ -5,9 +5,14 @@ Modules parser NC-dialect reader. Standard library only — importable with zero installs. viewer - Plotly figure builder and self-contained HTML writer (needs plotly, numpy). + NC Plotly figure builder and self-contained HTML writer (needs plotly, numpy). server - Drag-and-drop local web front end around the viewer. + Drag-and-drop local web front end around the NC viewer. +stl_viewer + STL mesh figure builder + HTML writer, with per-coordinate direction + vectors (needs plotly, numpy, trimesh). +server2 + Drag-and-drop local web front end around the STL viewer ("server 2"). This top-level package deliberately imports nothing heavy: ``import wired3d_viewer`` stays cheap and ``wired3d_viewer.parser`` keeps working without diff --git a/wired3d_viewer/__main__.py b/wired3d_viewer/__main__.py index a90f5ce..dad3f00 100644 --- a/wired3d_viewer/__main__.py +++ b/wired3d_viewer/__main__.py @@ -1,13 +1,17 @@ """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 + python -m wired3d_viewer view # write a standalone NC viewer + python -m wired3d_viewer serve [port] # NC drag-and-drop server + python -m wired3d_viewer view-stl # write a standalone STL viewer + python -m wired3d_viewer serve-stl [port] # STL drag-and-drop server (port 8766) After ``pip install -e .`` the same commands are available as:: wired3d view wired3d serve [port] + wired3d view-stl + wired3d serve-stl [port] Submodules are imported lazily inside each handler so that ``view``/``serve`` only pull in plotly/numpy when actually used. @@ -35,11 +39,21 @@ def main(argv: list[str] | None = None) -> None: 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") + "serve", help="start the NC drag-and-drop web server") p_serve.add_argument( "port", nargs="?", type=int, default=8765, help="TCP port to listen on (default 8765)") + p_view_stl = sub.add_parser( + "view-stl", help="render an STL mesh to a standalone HTML viewer") + p_view_stl.add_argument("stl_file", help="path to the .stl file to visualise") + + p_serve_stl = sub.add_parser( + "serve-stl", help="start the STL drag-and-drop web server") + p_serve_stl.add_argument( + "port", nargs="?", type=int, default=8766, + help="TCP port to listen on (default 8766)") + args = parser.parse_args(argv) if args.command == "view": @@ -48,6 +62,12 @@ def main(argv: list[str] | None = None) -> None: elif args.command == "serve": from .server import main as serve_main serve_main(args.port) + elif args.command == "view-stl": + from .stl_viewer import main as view_stl_main + view_stl_main(args.stl_file) + elif args.command == "serve-stl": + from .server2 import main as serve_stl_main + serve_stl_main(args.port) if __name__ == "__main__": diff --git a/wired3d_viewer/server2.py b/wired3d_viewer/server2.py new file mode 100644 index 0000000..5b2f8cd --- /dev/null +++ b/wired3d_viewer/server2.py @@ -0,0 +1,571 @@ +"""Drag-and-drop launcher for the STL mesh viewer (the "server 2"). + +The sibling :mod:`server` serves sliced NC toolpaths; this one serves the raw +**STL mesh** so you can drop a part and rotate/zoom it in 3D with the mouse +*before* slicing. Drop an ``.stl`` file (or a folder of them) onto the page; the +browser POSTs the file bytes and Python loads it with ``trimesh``, builds the +Plotly figure (``stl_viewer.build_figure``) and returns ready-to-display HTML +shown inline in an iframe. + +**Slice & Visualize:** once a part is loaded, a toolbar button slices it with +the repo-root ``stl_slicer`` (layer height + infill spacing from the toolbar +inputs) and shows the resulting NC toolpath with the very same NC viewer — STL +in, toolpath view out, on one page. The browser does this in two steps so the +part is sliced only once: ``POST /slice-nc`` returns the raw NC program text +(kept client-side), then ``POST /render-nc`` turns that text into the viewer +HTML. "Back to mesh" flips back to the cached mesh view. + +**Download NC:** after slicing, a ⤓ Download NC button saves the held NC text +as ``.nc`` straight from the browser (a Blob — no server round-trip). + +Run (from ``visualizer/``): + python -m wired3d_viewer serve-stl # http://127.0.0.1:8766 + python -m wired3d_viewer serve-stl 9000 # custom port + # or, after `pip install -e .`: wired3d serve-stl [port] + +Dependencies: trimesh, plotly, numpy. Imports the sibling ``stl_viewer`` module. +Binary STL note: unlike the NC server, files are POSTed as raw **bytes** +(an ArrayBuffer) because STL is commonly binary — never decoded as text. +""" + +import importlib.util +import sys +import tempfile +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import plotly.io as pio + +from .server import render_nc_text +from .stl_viewer import build_figure, load_stl +from .viewer import logo_data_uri + + +DEFAULT_PORT = 8766 + +# stl_slicer.py lives at the repository root (two levels above this package: +# wired3d_viewer/ -> visualizer/ -> ). It is not part of the +# installed package, so we load it by path on first use. +_SLICER = None + + +def _load_slicer(): + """Import and cache the repo-root ``stl_slicer`` module. + + Output: + the loaded ``stl_slicer`` module (with ``slice_stl_to_nc`` / + ``SlicerConfig``). + Raises: + FileNotFoundError if ``stl_slicer.py`` cannot be located. + """ + global _SLICER + if _SLICER is None: + path = Path(__file__).resolve().parents[2] / "stl_slicer.py" + if not path.exists(): + raise FileNotFoundError(f"stl_slicer.py not found at {path}") + spec = importlib.util.spec_from_file_location("stl_slicer", path) + module = importlib.util.module_from_spec(spec) + # Register before exec: @dataclass resolves the class's module via + # sys.modules["stl_slicer"], which must exist while the file runs. + sys.modules["stl_slicer"] = module + spec.loader.exec_module(module) + _SLICER = module + return _SLICER + + +# --- the drop-zone page (served at "/") ------------------------------------- +INDEX_HTML = """ + + + + +STL Viewer + + + +
+
+ __LOGO_IMG__ +

STL Viewer

+ Drag an .stl file (or a folder of them) anywhere on this page. + + + +
+
+
+
⬇ Drop an STL file here
+
a single .stl file or a whole folder — loaded locally, nothing is uploaded anywhere
+
+ + +
+ + +
+
+ +
+ __LOGO_LOADING__ +
+
Rendering…
+
loading the mesh + direction vectors — this can take a moment for large files
+
+
+
+ + + +""" + + +def render_stl_bytes(stl_bytes: bytes, name: str) -> str: + """Load STL bytes and return a full viewer HTML document. + + Inputs: + stl_bytes -- raw contents of an .stl file (binary or ASCII). + name -- original file name (used for the title / part name). + Output: + a complete, self-contained HTML string (plotly.js from CDN), identical + to what ``stl_viewer.write_html`` saves — returned for inline display. + """ + # trimesh loads from a path; write the bytes to a temp file, load, clean up. + with tempfile.NamedTemporaryFile("wb", suffix=".stl", delete=False) as fh: + fh.write(stl_bytes) + tmp = fh.name + try: + mesh = load_stl(tmp) + finally: + Path(tmp).unlink(missing_ok=True) + + fig = build_figure(mesh, Path(name).stem) + return pio.to_html( + fig, include_plotlyjs="cdn", full_html=True, + default_width="100%", default_height="100vh", + config={"responsive": True}, + ) + + +def slice_stl_to_nc_text(stl_bytes: bytes, name: str, + layer_height: float, infill_spacing: float) -> str: + """Slice STL bytes with ``stl_slicer`` and return the raw NC program text. + + Inputs: + stl_bytes -- raw contents of an .stl file (binary or ASCII). + name -- original file name (used for the part name). + layer_height -- slicer layer height in mm (the Z spacing between + slices — NOT a layer count; a 50 mm part at 15 mm + yields ~3 layers). + infill_spacing -- slicer concentric-infill spacing in mm. + Output: + the generated NC program as text (the downloadable .nc contents). The + viewer HTML is produced separately from this text, so slicing happens + once and the same NC powers both the on-page view and the download. + """ + slicer = _load_slicer() + stem = Path(name).stem + cfg = slicer.SlicerConfig( + layer_height=layer_height, + infill_spacing=infill_spacing, + part_name=stem, + ) + # Slicer reads/writes paths; stage the STL and the NC output in a temp dir. + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = Path(tmpdir) / "in.stl" + nc_path = Path(tmpdir) / "out.nc" + stl_path.write_bytes(stl_bytes) + slicer.slice_stl_to_nc(stl_path, nc_path, cfg) + return nc_path.read_text(encoding="utf-8") + + +def slice_stl_bytes(stl_bytes: bytes, name: str, + layer_height: float, infill_spacing: float) -> str: + """Slice STL bytes and return the NC viewer HTML (slice + render in one). + + Convenience wrapper kept for the ``/slice`` route: produces the NC text via + :func:`slice_stl_to_nc_text` then renders it with the shared NC viewer + (``server.render_nc_text``) so the toolpath view is identical to the NC + drop server's. + """ + nc_text = slice_stl_to_nc_text(stl_bytes, name, layer_height, infill_spacing) + return render_nc_text(nc_text, f"{Path(name).stem}.nc") + + +class _Handler(BaseHTTPRequestHandler): + """Serves the drop page (GET /), renders posted STL bytes (POST /render), + and slices + visualises them (POST /slice).""" + + def _send(self, code: int, body: str, ctype: str = "text/html") -> None: + data = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", f"{ctype}; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): # noqa: N802 (http.server naming) + if self.path in ("/", "/index.html"): + 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") + + def do_POST(self): # noqa: N802 + # Routes (longest-prefix first so "/slice-nc" isn't caught by "/slice"): + # /slice-nc STL bytes -> raw NC program text (for download) + # /render-nc NC text -> NC viewer HTML + # /slice STL bytes -> NC viewer HTML (slice + render in one) + # /render STL bytes -> STL mesh viewer HTML + from urllib.parse import parse_qs, urlparse, unquote + parsed_url = urlparse(self.path) + route = next((r for r in ("/slice-nc", "/render-nc", "/slice", "/render") + if parsed_url.path == r), None) + if route is None: + self._send(404, "not found", "text/plain") + return + + q = parse_qs(parsed_url.query) + name = unquote(q["name"][0]) if "name" in q else "dropped.stl" + layer_height = float(q.get("layer_height", ["5"])[0]) + infill_spacing = float(q.get("infill_spacing", ["5"])[0]) + + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + + try: + if route == "/slice-nc": + nc_text = slice_stl_to_nc_text(body, name, layer_height, infill_spacing) + self._send(200, nc_text, "text/plain") + elif route == "/render-nc": + # body is NC program text; render it with the shared NC viewer. + html = render_nc_text(body.decode("utf-8", errors="replace"), + f"{Path(name).stem}.nc") + self._send(200, html) + elif route == "/slice": + self._send(200, slice_stl_bytes(body, name, layer_height, infill_spacing)) + else: # /render + self._send(200, render_stl_bytes(body, name)) + except Exception as exc: # noqa: BLE001 - report to the page + self._send(500, f"Failed ({route}) {name}: {exc}", "text/plain") + + def log_message(self, *_args): + pass # quiet; we print our own status line + + +def main(port: int = DEFAULT_PORT) -> None: + """Start the STL drop-zone server and open it in the browser. + + Inputs: + port -- TCP port to listen on (default 8766). + Output: + None. Runs until Ctrl-C. + """ + ThreadingHTTPServer.allow_reuse_address = True + try: + server = ThreadingHTTPServer(("127.0.0.1", port), _Handler) + except OSError as exc: + print(f"Could not bind port {port}: {exc}") + print(f"Another server is likely already running. Try a different port: " + f"python -m wired3d_viewer serve-stl {port + 1}") + return + url = f"http://127.0.0.1:{port}" + print(f"STL Viewer drop zone running at {url}") + print("Drag an .stl file (or a folder of them) onto the page. Ctrl-C to stop.") + try: + webbrowser.open(url) + except Exception: + pass + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + server.server_close() + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT + main(port) diff --git a/wired3d_viewer/stl_viewer.py b/wired3d_viewer/stl_viewer.py new file mode 100644 index 0000000..621653a --- /dev/null +++ b/wired3d_viewer/stl_viewer.py @@ -0,0 +1,307 @@ +"""Interactive 3D viewer for raw STL meshes (pre-slicing). + +This is the mesh-side companion to ``viewer.py`` (which renders sliced NC +toolpaths). It loads an STL with ``trimesh`` and builds a single self-contained +Plotly HTML document showing the solid surface, so you can rotate/zoom the part +in 3D *before* slicing. + +A per-face **direction-vector** overlay (the surface normals, via +:func:`build_normal_trace`) is kept in this module as the seed of the +projectile-direction feature, but is **not drawn yet** — once slicing is wired +in, the direction shown will be the tool-head orientation at every toolpath +point rather than raw facet normals. + +The styling deliberately mirrors ``viewer.py``: dark theme, perspective camera, +axis ranges pinned to the part bounding box, the wired3d logo embedded top-left, +and a stats table. It reuses :func:`viewer.logo_data_uri` and +:data:`viewer.DEFAULT_CAMERA` so the two viewers stay visually consistent. + +Run (from ``visualizer/``): + python -m wired3d_viewer view-stl # writes _stl_viewer.html + +Dependencies: trimesh, plotly, numpy. +""" + +from pathlib import Path + +import numpy as np +import plotly.graph_objects as go +import plotly.io as pio +import trimesh + +from .viewer import DEFAULT_CAMERA, FIG_BG, SCENE_BG, logo_data_uri + +# Surface + direction-vector colours, in keeping with the NC viewer palette. +MESH_COLOUR = "#4a90d9" # cool steel blue for the solid surface +NORMAL_COLOUR = "#ffcc00" # amber direction vectors — pop against the blue +EDGE_COLOUR = "#1b2a44" + +# Cap on how many normal vectors we draw. A dense mesh can have 10k+ faces; +# drawing one cone per face is both unreadable and slow, so we evenly sample +# down to this many. The full mesh is always drawn — only the vector overlay is +# decimated (and the count of dropped vectors is reported in the stats table). +MAX_NORMALS = 1500 + + +def load_stl(filepath: str) -> trimesh.Trimesh: + """Load an STL file and return a single concrete ``Trimesh``. + + Inputs: + filepath -- path to a binary or ASCII ``.stl`` file. + Output: + a ``trimesh.Trimesh``. If the file is a scene (multiple bodies) it is + concatenated into one mesh so the rest of the pipeline sees one object. + Raises: + ValueError if the file contains no triangulated geometry. + """ + loaded = trimesh.load(filepath, force="mesh") + if isinstance(loaded, trimesh.Scene): + loaded = trimesh.util.concatenate(loaded.dump()) + if not isinstance(loaded, trimesh.Trimesh) or len(loaded.faces) == 0: + raise ValueError(f"No triangle mesh found in {filepath!r}") + return loaded + + +def _axis_ranges(mesh: trimesh.Trimesh) -> dict: + """Return padded, equal-aspect X/Y/Z scene ranges for the mesh bounds. + + Inputs: + mesh -- the loaded ``Trimesh``. + Output: + dict with ``xaxis``/``yaxis``/``zaxis`` range entries. A common padded + cube edge is used for all three axes so the part is never distorted and + the view does not zoom-jump (same approach as the NC viewer). + """ + lo, hi = mesh.bounds + centre = (lo + hi) / 2.0 + half = float((hi - lo).max()) / 2.0 + half = half * 1.1 if half > 0 else 1.0 # 10% padding; guard a flat part + return dict( + xaxis=dict(range=[centre[0] - half, centre[0] + half]), + yaxis=dict(range=[centre[1] - half, centre[1] + half]), + zaxis=dict(range=[centre[2] - half, centre[2] + half]), + ) + + +def build_mesh_trace(mesh: trimesh.Trimesh) -> go.Mesh3d: + """Build the solid-surface ``Mesh3d`` trace. + + Inputs: + mesh -- the loaded ``Trimesh``. + Output: + a lit ``go.Mesh3d`` using the mesh vertices/faces. + """ + v = mesh.vertices + f = mesh.faces + return go.Mesh3d( + x=v[:, 0], y=v[:, 1], z=v[:, 2], + i=f[:, 0], j=f[:, 1], k=f[:, 2], + color=MESH_COLOUR, + opacity=1.0, + flatshading=True, + name="STL surface", + showlegend=True, + lighting=dict(ambient=0.45, diffuse=0.85, specular=0.25, roughness=0.55), + lightposition=dict(x=1000, y=1000, z=2000), + hoverinfo="skip", + ) + + +def _sample_faces(n_faces: int, cap: int) -> np.ndarray: + """Return indices of faces to draw a normal for (evenly sampled, capped). + + Inputs: + n_faces -- total number of faces. + cap -- maximum number of vectors to draw. + Output: + a 1-D int array of face indices (all of them when ``n_faces <= cap``, + otherwise an even stride across the range). + """ + if n_faces <= cap: + return np.arange(n_faces) + return np.linspace(0, n_faces - 1, cap).astype(int) + + +def build_normal_trace(mesh: trimesh.Trimesh) -> tuple[go.Cone, int]: + """Build the per-face direction-vector overlay (surface normals as cones). + + Inputs: + mesh -- the loaded ``Trimesh``. + Output: + ``(cone_trace, shown)`` where ``cone_trace`` is a ``go.Cone`` quiver + placed at each sampled face centroid pointing along its outward normal, + and ``shown`` is how many vectors were actually drawn (after sampling). + The cones are length-normalised relative to the part size so they read as + little arrows regardless of model scale. + """ + idx = _sample_faces(len(mesh.faces), MAX_NORMALS) + origins = mesh.triangles_center[idx] + normals = mesh.face_normals[idx] + + # Scale the cone vectors to ~3% of the bounding-box diagonal so they are + # visible but don't swamp the surface. go.Cone sizes cones by vector norm. + diag = float(np.linalg.norm(mesh.bounds[1] - mesh.bounds[0])) or 1.0 + vec = normals * (diag * 0.03) + + cone = go.Cone( + x=origins[:, 0], y=origins[:, 1], z=origins[:, 2], + u=vec[:, 0], v=vec[:, 1], w=vec[:, 2], + colorscale=[[0, NORMAL_COLOUR], [1, NORMAL_COLOUR]], + showscale=False, + sizemode="absolute", + sizeref=diag * 0.03, + anchor="tail", + name="Direction (face normals)", + showlegend=True, + hoverinfo="x+y+z", + ) + return cone, len(idx) + + +def compute_stats(mesh: trimesh.Trimesh) -> dict: + """Compute a small dictionary of human-readable mesh statistics. + + Inputs: + mesh -- the loaded ``Trimesh``. + Output: + ordered dict of ``label -> value`` strings for the stats table. + """ + lo, hi = mesh.bounds + size = hi - lo + stats = { + "Vertices": f"{len(mesh.vertices):,}", + "Faces": f"{len(mesh.faces):,}", + "Size X×Y×Z (mm)": f"{size[0]:.1f} × {size[1]:.1f} × {size[2]:.1f}", + "Bounds min (mm)": f"({lo[0]:.1f}, {lo[1]:.1f}, {lo[2]:.1f})", + "Bounds max (mm)": f"({hi[0]:.1f}, {hi[1]:.1f}, {hi[2]:.1f})", + "Watertight": "yes" if mesh.is_watertight else "no", + "Volume (mm³)": f"{mesh.volume:,.1f}" if mesh.is_watertight else "n/a", + "Surface area (mm²)": f"{mesh.area:,.1f}", + } + return stats + + +def build_stats_table(stats: dict) -> go.Table: + """Build the bottom-right stats ``go.Table`` trace. + + Inputs: + stats -- the dict from :func:`compute_stats`. + Output: + a styled ``go.Table`` (dark theme, two columns). + """ + return go.Table( + columnwidth=[1.1, 1.4], + header=dict( + values=["Property", "Value"], + fill_color="#16213e", font=dict(color="#e8e8f0", size=13), + align="left", height=26, + ), + cells=dict( + values=[list(stats.keys()), list(stats.values())], + fill_color="#0f1626", font=dict(color="#cfd3e6", size=12), + align="left", height=24, + ), + ) + + +def build_figure(mesh: trimesh.Trimesh, part_name: str) -> go.Figure: + """Assemble the full STL viewer figure (3D mesh + stats table). + + Inputs: + mesh -- the loaded ``Trimesh``. + part_name -- title shown in the header. + Output: + a ``go.Figure`` with a large left 3D scene and a bottom-right stats + table, dark themed, perspective camera, axis ranges pinned to bounds. + """ + from plotly.subplots import make_subplots + + fig = make_subplots( + rows=2, cols=2, + column_widths=[0.74, 0.26], row_heights=[0.55, 0.45], + specs=[[{"type": "scene", "rowspan": 2}, {"type": "table"}], + [None, {"type": "table"}]], + horizontal_spacing=0.02, vertical_spacing=0.04, + ) + + # Direction vectors (build_normal_trace) are intentionally not drawn yet — + # see the module docstring. Only the solid surface is shown for now. + fig.add_trace(build_mesh_trace(mesh), row=1, col=1) + + table = build_stats_table(compute_stats(mesh)) + fig.add_trace(table, row=1, col=2) + + ranges = _axis_ranges(mesh) + fig.update_layout( + title=dict(text=f"STL Viewer — {part_name}", x=0.5, + font=dict(color="#e8e8f0", size=18)), + paper_bgcolor=FIG_BG, + font=dict(color="#e8e8f0"), + showlegend=True, + legend=dict(x=0.0, y=1.0, bgcolor="rgba(22,33,62,0.6)", + font=dict(size=12), traceorder="normal"), + autosize=True, + margin=dict(l=0, r=0, t=44, b=0), + scene=dict( + bgcolor=SCENE_BG, + xaxis=dict(title="X", color="#9aa0b5", **ranges["xaxis"]), + yaxis=dict(title="Y", color="#9aa0b5", **ranges["yaxis"]), + zaxis=dict(title="Z", color="#9aa0b5", **ranges["zaxis"]), + aspectmode="cube", + camera=DEFAULT_CAMERA, + uirevision="keep", + ), + ) + + logo = logo_data_uri() + if logo: + fig.add_layout_image(dict( + source=logo, xref="paper", yref="paper", + x=0.01, y=0.99, sizex=0.12, sizey=0.12, + xanchor="left", yanchor="top", layer="above", + )) + return fig + + +def write_html(mesh: trimesh.Trimesh, part_name: str, out_path: str) -> str: + """Build the figure and write a self-contained HTML file. + + Inputs: + mesh -- the loaded ``Trimesh``. + part_name -- title / part name. + out_path -- destination ``.html`` path. + Output: + ``out_path`` (also written to disk). plotly.js is pulled from the CDN + so the file is small but needs a network connection on first open. + """ + fig = build_figure(mesh, part_name) + pio.write_html( + fig, out_path, include_plotlyjs="cdn", full_html=True, + default_width="100%", default_height="100vh", + config={"responsive": True}, + ) + return out_path + + +def main(stl_file: str) -> None: + """CLI entry: render one STL file to ``_stl_viewer.html``. + + Inputs: + stl_file -- path to the ``.stl`` file. + Output: + None. Prints the saved HTML path. + """ + path = Path(stl_file) + mesh = load_stl(str(path)) + out = path.with_name(f"{path.stem}_stl_viewer.html") + write_html(mesh, path.stem, str(out)) + print(f"Saved: {out}") + + +if __name__ == "__main__": + import sys + + if len(sys.argv) != 2: + print("usage: python -m wired3d_viewer.stl_viewer ") + sys.exit(1) + main(sys.argv[1]) diff --git a/wired3d_viewer/viewer.py b/wired3d_viewer/viewer.py index 04dafad..43f8805 100644 --- a/wired3d_viewer/viewer.py +++ b/wired3d_viewer/viewer.py @@ -107,6 +107,42 @@ gd.on('plotly_legendclick', function(ev) { } return false; }); + +// --- Projectile toggle (custom HTML button, reliable single-click) --------- +// Replaces the old Plotly updatemenu toggle, whose `active` default left the +// button highlighted while the overlay was hidden (inverted/2-click state). +// This button is the SOLE control for the projectile line + arrowheads; its +// label and colour always reflect the real visibility. +(function() { + var lineIdx = -1, arrowIdx = -1; + gd.data.forEach(function(t, i) { + if (t.name === 'Projectile (tool direction)') lineIdx = i; + if (t.name === 'Projectile arrowheads') arrowIdx = i; + }); + if (lineIdx === -1) return; // no projectile in this figure + var idxs = arrowIdx === -1 ? [lineIdx] : [lineIdx, arrowIdx]; + + var btn = document.createElement('button'); + btn.type = 'button'; + // Sits just BELOW the layer dropdown (top-left) so the two never overlap. + btn.style.cssText = 'position:fixed;top:52px;left:14px;z-index:1000;' + + 'padding:7px 13px;font-size:13px;border-radius:7px;cursor:pointer;' + + 'border:1px solid #ff9d00;font-family:system-ui,-apple-system,sans-serif;'; + + function isOn() { return gd.data[lineIdx].visible === true; } + function refresh() { + var on = isOn(); + btn.textContent = on ? '🎯 Projectile: ON' : '🎯 Projectile: OFF'; + btn.style.background = on ? '#ff9d00' : '#16213e'; + btn.style.color = on ? '#1a1a2e' : '#ffffff'; + } + btn.addEventListener('click', function() { + Plotly.restyle(gd, {visible: isOn() ? 'legendonly' : true}, idxs) + .then(refresh); + }); + document.body.appendChild(btn); + refresh(); +})(); """ # Dark theme palette. @@ -560,6 +596,85 @@ def build_cursor_trace(moves: list[dict]) -> go.Scatter3d: ) +def build_projectile_trace(moves: list[dict], length: float) -> go.Scatter3d: + """Build the per-point 'projectile' overlay: the tool direction at every move. + + Inputs: + moves -- list of move dicts (mm coords, A/B degrees). + length -- length (mm) of each direction stick. + Output: + a single ``go.Scatter3d`` line trace containing one short segment per + move — from the move's coordinate along its tool vector + ``compute_tool_vector(a, b)`` (straight up at A=B=0, tilted as the head + inclines). Segments are separated by ``None`` so the whole field of + directions is one lightweight trace. Hidden by default + (``visible="legendonly"``, no legend entry) and revealed by the custom + "Projectile" toggle button injected via the post-script. This is the + projectile/direction-of-every-coordinate view. + """ + xs: list = [] + ys: list = [] + zs: list = [] + for m in moves: + u, v, w = compute_tool_vector(m["a"], m["b"]) + xs += [m["x"], m["x"] + u * length, None] + ys += [m["y"], m["y"] + v * length, None] + zs += [m["z"], m["z"] + w * length, None] + return go.Scatter3d( + x=xs, y=ys, z=zs, + mode="lines", + line=dict(color="#ff9d00", width=2), + name="Projectile (tool direction)", + visible="legendonly", + # Toggled only by the custom HTML "Projectile" button (no legend entry), + # so the line and its arrowheads never get out of sync. + showlegend=False, + hoverinfo="skip", + ) + + +def build_projectile_arrows(moves: list[dict], length: float) -> go.Cone: + """Build arrowheads (cones) at the tip of every projectile vector. + + Inputs: + moves -- list of move dicts (mm coords, A/B degrees). + length -- length (mm) of each projectile stick (the cone sits at its end). + Output: + a ``go.Cone`` trace with one small cone per move, placed at the stick's + TIP (``coord + length × tool_vector``) and pointing along the tool + vector, so each projectile line reads as a directional arrow. Hidden by + default (``visible="legendonly"``) and toggled together with the line by + the Projectile button. ``go.Cone`` colours by vector norm, so the + colorscale is pinned to the single projectile amber. + """ + xs: list = [] + ys: list = [] + zs: list = [] + us: list = [] + vs: list = [] + ws: list = [] + for m in moves: + u, v, w = compute_tool_vector(m["a"], m["b"]) + xs.append(m["x"] + u * length) + ys.append(m["y"] + v * length) + zs.append(m["z"] + w * length) + us.append(u) + vs.append(v) + ws.append(w) + return go.Cone( + x=xs, y=ys, z=zs, u=us, v=vs, w=ws, + anchor="tip", # cone point sits at the stick tip + sizemode="absolute", + sizeref=length * 0.45, + colorscale=[[0, "#ff9d00"], [1, "#ff9d00"]], + showscale=False, + name="Projectile arrowheads", + showlegend=False, # one legend entry (the line) is enough + visible="legendonly", + hoverinfo="skip", + ) + + def build_trail_trace() -> go.Scatter3d: """Build the (initially empty) trail that is drawn progressively on Play. @@ -864,13 +979,30 @@ def build_figure(parsed: dict, source_file: str) -> go.Figure: # --- stats table (row 2, col 2) --- fig.add_trace(build_stats_table(stats), row=2, col=2) + # --- projectile overlay (3D scene) --- + # Appended LAST so it never shifts the indices the frames/dropdown reference + # (rapid/weld/cursor/trail/angles/table). It is a scene trace despite being + # added after the table — add_trace order is just append order. Hidden until + # the custom "Projectile" HTML button (post-script) toggles it on. + stick = _stick_length(moves) + projectile_index = len(fig.data) + fig.add_trace(build_projectile_trace(moves, stick), row=1, col=1) + arrow_index = len(fig.data) + fig.add_trace(build_projectile_arrows(moves, stick), row=1, col=1) + projectile_traces = [projectile_index, arrow_index] + # Total trace count (for dropdown bookkeeping): rapid + welds + cursor + - # trail + 4 angle traces + table. - total_traces = 1 + len(weld_traces) + 2 + 4 + 1 + # trail + 4 angle traces + table + projectile line + arrowheads. + total_traces = 1 + len(weld_traces) + 2 + 4 + 1 + 2 dropdown = build_layer_dropdown( weld_traces, n_static_before=1, total_traces=total_traces, ) + # The projectile overlay is toggled by a custom HTML button injected via the + # post-script (LEGEND_CLICK_JS), not a Plotly updatemenu — the updatemenu + # toggle's default `active` left the button visually inverted/2-click. The + # JS finds the two projectile traces by name, so no indices are passed here. + _ = projectile_traces # indices kept for clarity; JS locates traces by name # --- animation: frames + play/pause/reset + slider --- frames, play_menu, slider = build_animation(