"""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)