"""Interactive 3D viewer for Beckhoff 5-axis metal DED NC toolpaths. Reads an NC file via the existing ``nc_parser`` (imported, never rewritten) and renders a single self-contained, offline-capable HTML file with: * a 3D toolpath view (rapid moves, per-layer weld paths), * an A/B tilt-angle chart with welding-active shading, * a statistics table, * a layer-isolation dropdown. Mouse controls are Plotly's built-in scatter3d defaults: LEFT-DRAG rotate, SCROLL zoom, RIGHT-DRAG pan, HOVER tooltip. 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 -m wired3d_viewer.viewer # or, after `pip install -e .`: wired3d view 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 import numpy as np import plotly.graph_objects as go import plotly.io as pio from plotly.subplots import make_subplots 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). FEED_RATE = 800.0 # Number of animation frames. Each frame reveals the toolpath up to one move # (the trail itself is full-resolution — every move is drawn), so this only # controls how chunky the growth looks and the HTML size, not completeness. ANIM_TARGET_FRAMES = 160 # 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="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; }); // --- 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. SCENE_BG = "#1a1a2e" FIG_BG = "#0f0f1a" RAPID_COLOUR = "#5b647d" # dark slate — visible on the dark scene without glare ANGLE_A_COLOUR = "#00bfff" ANGLE_B_COLOUR = "#ff4500" def _slice_order(moves: list[dict]) -> list[int]: """Return unique slice_no values in first-seen order. Inputs: moves -- list of move dicts from ``parse_nc``. Output: list[int] of distinct ``slice_no`` values, ordered by appearance. """ seen = [] for m in moves: if m["slice_no"] not in seen: seen.append(m["slice_no"]) return seen def _layer_colour(i: int, n: int) -> str: """Map a layer index to a distinct rainbow colour. Inputs: i -- zero-based layer index. n -- total number of layers. Output: an "rgb(r,g,b)" string sampled across the hue wheel so each layer (Z level) is visually separable. """ hue = (i / max(n, 1)) * 0.85 # 0..0.85 avoids wrapping red back to red r, g, b = _hsv_to_rgb(hue, 0.85, 1.0) return f"rgb({int(r * 255)},{int(g * 255)},{int(b * 255)})" def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[float, float, float]: """Convert HSV (each 0..1) to RGB (each 0..1). Pure helper, no deps.""" i = int(h * 6.0) f = h * 6.0 - i p = v * (1.0 - s) q = v * (1.0 - f * s) t = v * (1.0 - (1.0 - f) * s) i %= 6 return [ (v, t, p), (q, v, p), (p, v, t), (p, q, v), (t, p, v), (v, p, q), ][i] def _path_length(pts: list[tuple[float, float, float]]) -> float: """Sum Euclidean distances between consecutive 3D points (mm). Inputs: pts -- ordered list of (x, y, z) tuples in mm. Output: total path length in mm (0.0 for fewer than two points). """ total = 0.0 for (x0, y0, z0), (x1, y1, z1) in zip(pts, pts[1:]): total += math.dist((x0, y0, z0), (x1, y1, z1)) return total def compute_stats(moves: list[dict]) -> dict: """Compute all statistics shown in the stats table. Inputs: moves -- list of move dicts from ``parse_nc`` (x/y/z in mm, a/b in degrees, plus move_type/weld_state/slice_no/fraction_no). Output: dict with these keys (values pre-formatted as strings for display except where noted): "part_name" -- set by caller, placeholder "" here "total_layers" -- int count of unique slice_no "total_fractions" -- int count of unique (slice_no, fraction_no) "total_points" -- int len(moves) "weld_points" -- int count where weld_state == "on" "rapid_points" -- int count where move_type == "rapid" "weld_length_mm" -- float, summed distance between consecutive weld (cut) points, mm "rapid_length_mm" -- float, summed distance between consecutive rapid points, mm "z_min"/"z_max" -- float, mm "a_min"/"a_max" -- float, degrees "b_min"/"b_max" -- float, degrees "print_time_s" -- float, weld_length / FEED_RATE * 60, seconds All numeric values are returned as floats/ints; formatting for the table happens in ``build_stats_table``. """ total_points = len(moves) layers = {m["slice_no"] for m in moves} fractions = {(m["slice_no"], m["fraction_no"]) for m in moves} weld_points = sum(1 for m in moves if m["weld_state"] == "on") rapid_points = sum(1 for m in moves if m["move_type"] == "rapid") weld_pts = [(m["x"], m["y"], m["z"]) for m in moves if m["move_type"] == "cut"] rapid_pts = [(m["x"], m["y"], m["z"]) for m in moves if m["move_type"] == "rapid"] weld_len = _path_length(weld_pts) rapid_len = _path_length(rapid_pts) zs = [m["z"] for m in moves] or [0.0] as_ = [m["a"] for m in moves] or [0.0] bs = [m["b"] for m in moves] or [0.0] return { "part_name": "", "total_layers": len(layers), "total_fractions": len(fractions), "total_points": total_points, "weld_points": weld_points, "rapid_points": rapid_points, "weld_length_mm": weld_len, "rapid_length_mm": rapid_len, "z_min": min(zs), "z_max": max(zs), "a_min": min(as_), "a_max": max(as_), "b_min": min(bs), "b_max": max(bs), "print_time_s": weld_len / FEED_RATE * 60.0, } def _customdata_row(m: dict) -> list: """Build the per-point customdata array used by the hover template. Inputs: m -- one move dict. Output: [x, y, z, a, b, weld_state, slice_no, fraction_no, u, v, w] where (u, v, w) is the unit tool vector from ``compute_tool_vector``. """ u, v, w = compute_tool_vector(m["a"], m["b"]) return [ m["x"], m["y"], m["z"], m["a"], m["b"], m["weld_state"], m["slice_no"], m["fraction_no"], u, v, w, ] # Shared hover template (units in the labels). _HOVER = ( "Position
" "X: %{customdata[0]:.2f} mm
" "Y: %{customdata[1]:.2f} mm
" "Z: %{customdata[2]:.2f} mm
" "Extruder
" "A: %{customdata[3]:.2f}°
" "B: %{customdata[4]:.2f}°
" "Vector: (%{customdata[8]:.3f}, %{customdata[9]:.3f}, %{customdata[10]:.3f})
" "Status: %{customdata[5]}
" "Slice: %{customdata[6]} Fraction: %{customdata[7]}" "" ) def build_rapid_trace(moves: list[dict]) -> go.Scatter3d: """Build a single 3D trace for all rapid (non-welding) moves. Inputs: moves -- list of move dicts (mm for coords). Output: a ``go.Scatter3d`` line trace (grey dotted) covering every ``move_type == "rapid"`` point, with full hover customdata. """ rapids = [m for m in moves if m["move_type"] == "rapid"] return go.Scatter3d( x=[m["x"] for m in rapids], y=[m["y"] for m in rapids], z=[m["z"] for m in rapids], mode="lines", # 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, showlegend=True, ) def build_weld_traces(moves: list[dict]) -> list[go.Scatter3d]: """Build one 3D weld-path trace per slice (Z layer), colour-coded. Inputs: moves -- list of move dicts (mm for coords). Output: list of ``go.Scatter3d`` "lines+markers" traces, one per unique ``slice_no`` (in appearance order), each a distinct rainbow colour and named ``"Layer N Z=..mm"``. Only ``move_type == "cut"`` points are included. Each trace carries full hover customdata. """ order = _slice_order(moves) n = len(order) traces = [] for i, sno in enumerate(order): cut = [m for m in moves if m["slice_no"] == sno and m["move_type"] == "cut"] if not cut: continue z = cut[0]["z"] colour = _layer_colour(i, n) traces.append(go.Scatter3d( x=[m["x"] for m in cut], y=[m["y"] for m in cut], z=[m["z"] for m in cut], mode="lines+markers", marker=dict(size=3, color=colour), line=dict(color=colour, width=3), name=f"Layer {sno} Z={z:.1f}mm", customdata=[_customdata_row(m) for m in cut], hovertemplate=_HOVER, showlegend=True, )) return traces def build_angle_chart(moves: list[dict]): """Build the A/B tilt-angle line traces plus welding-active shading. Inputs: moves -- list of move dicts (angles in degrees). Output: (traces, y_range): traces -- list of four ``go.Scatter`` (2D) traces over move index 0..N-1: a low baseline + a "Welding active" band that together shade (green, semi-transparent) the FULL chart height wherever ``weld_state == "on"``, then the A axis (blue) and B axis (red) angle lines on top. y_range -- [ymin, ymax] for the subplot's y-axis (padded angle range, with a sensible minimum so a flat 0° trace still shows a band). Returned so ``build_figure`` can pin the axis — the band is drawn to these exact bounds. These live in the 2D subplot, not the 3D scene. """ idx = list(range(len(moves))) a_vals = [m["a"] for m in moves] b_vals = [m["b"] for m in moves] # Pinned y-range: padded angle extent, but never degenerate (so an all-0° # part still gets a visible band). The band fills exactly these bounds. lo = min(a_vals + b_vals + [0.0]) hi = max(a_vals + b_vals + [0.0]) pad = max((hi - lo) * 0.1, 1.0) ymin, ymax = lo - pad, hi + pad welding = [m["weld_state"] == "on" for m in moves] band_low = [ymin if on else None for on in welding] band_high = [ymax if on else None for on in welding] # 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", showlegend=False, connectgaps=False, ) band = go.Scatter( x=idx, y=band_high, mode="lines", line=dict(width=0), fill="tonexty", fillcolor="rgba(0,255,128,0.18)", name="Welding active", hoverinfo="skip", 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 (°)", 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 (°)", showlegend=False, hovertemplate="move %{x}
B: %{y:.2f}°", ) # Band first (base then fill) so the angle lines draw on top. return [base, band, a_trace, b_trace], [ymin, ymax] def build_stats_table(stats: dict) -> go.Table: """Build the formatted statistics table trace. Inputs: stats -- dict from ``compute_stats`` (with "part_name" filled in). Output: a ``go.Table`` trace with "Property"/"Value" columns, dark-blue header, alternating dark row backgrounds, white 12pt text. Lengths shown in mm, angles in degrees, time in seconds. """ rows = [ ("Part name", stats["part_name"]), ("Total layers", f"{stats['total_layers']}"), ("Total fractions", f"{stats['total_fractions']}"), ("Total points", f"{stats['total_points']}"), ("Weld ON points", f"{stats['weld_points']}"), ("Rapid points", f"{stats['rapid_points']}"), ("Weld path length", f"{stats['weld_length_mm']:.1f} mm"), ("Rapid path length", f"{stats['rapid_length_mm']:.1f} mm"), ("Z range", f"{stats['z_min']:.2f} mm → {stats['z_max']:.2f} mm"), ("A angle range", f"{stats['a_min']:.2f}° → {stats['a_max']:.2f}°"), ("B angle range", f"{stats['b_min']:.2f}° → {stats['b_max']:.2f}°"), ("Est. print time", f"{stats['print_time_s']:.1f} s"), ] props = [r[0] for r in rows] vals = [r[1] for r in rows] # Alternating row colours. fill = [ ["#1a1a2e" if i % 2 == 0 else "#16213e" for i in range(len(rows))] ] * 2 return go.Table( header=dict( values=["Property", "Value"], fill_color="#0d47a1", font=dict(color="white", size=12), align="left", ), cells=dict( values=[props, vals], fill_color=fill, font=dict(color="white", size=12), align="left", height=24, ), ) def build_layer_dropdown(weld_traces: list[go.Scatter3d], n_static_before: int, total_traces: int) -> dict: """Build the updatemenus dropdown that isolates a single layer. Inputs: weld_traces -- the per-layer weld traces (to read names/order). n_static_before -- number of traces placed BEFORE the weld traces in the figure's trace list (e.g. the rapid trace), so weld-trace indices can be computed. total_traces -- total number of traces in the figure (for building full-length opacity arrays via restyle). Output: a single ``updatemenus`` dict. "All layers" shows every weld trace at full opacity; each "Layer N" option sets that layer's weld trace to opacity 1.0 and all OTHER weld traces to 0.1. Non-weld traces (rapid path, animated head cursor) are left untouched. Note: opacity is restyled only on the weld-trace indices, so the rapid path and the animated head cursor always stay fully visible. """ weld_indices = list(range(n_static_before, n_static_before + len(weld_traces))) buttons = [dict( label="All layers", method="restyle", args=[{"opacity": [1.0] * len(weld_indices)}, weld_indices], )] for i, tr in enumerate(weld_traces): opac = [0.1] * len(weld_indices) opac[i] = 1.0 buttons.append(dict( label=tr.name, method="restyle", args=[{"opacity": opac}, weld_indices], )) return dict( buttons=buttons, direction="down", showactive=True, x=0.01, xanchor="left", y=1.12, yanchor="top", bgcolor="#16213e", font=dict(color="white", size=12), bordercolor="#0d47a1", ) def _stick_length(moves: list[dict]) -> float: """Pick a sensible length (mm) for the head 'stick' from the part size. Inputs: moves -- list of move dicts (mm coords). Output: 10% of the largest bounding-box extent (min 5 mm), so the stick is visible but not overwhelming regardless of part scale. """ if not moves: return 5.0 xs = [m["x"] for m in moves] ys = [m["y"] for m in moves] zs = [m["z"] for m in moves] ext = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 1.0) return max(ext * 0.10, 5.0) def _stick_points(m: dict, length: float): """Return the two endpoints of the head stick for one move. Inputs: m -- a move dict (x/y/z mm, a/b degrees). length -- stick length in mm. Output: (xs, ys, zs) each a 2-element list: [contact_point, tip], where the tip is the contact point offset by ``length`` along ``compute_tool_vector``. Straight up (+Z) when A=B=0; tilts as A/B grow, so the inclination is visible in 3D. """ u, v, w = compute_tool_vector(m["a"], m["b"]) bx, by, bz = m["x"], m["y"], m["z"] return ([bx, bx + u * length], [by, by + v * length], [bz, bz + w * length]) def build_cursor_trace(moves: list[dict]) -> go.Scatter3d: """Build the animated head 'stick' that moves along the toolpath. Inputs: moves -- list of move dicts (mm coords, deg angles). Output: a two-point ``go.Scatter3d`` line+marker trace drawn as a short yellow stick: a fat dot at the contact point and a thin line pointing along the tool vector (straight up at A=B=0, tilted when the head is inclined). Updated frame by frame during the play/pause animation. """ first = moves[0] if moves else {"x": 0.0, "y": 0.0, "z": 0.0, "a": 0.0, "b": 0.0} xs, ys, zs = _stick_points(first, _stick_length(moves)) return go.Scatter3d( x=xs, y=ys, z=zs, mode="lines+markers", line=dict(color="#ffff00", width=6), marker=dict(size=[7, 3], color="#ffff00", line=dict(color="black", width=1)), name="Head position", showlegend=True, hoverinfo="skip", ) 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. Inputs: none. Output: an empty ``go.Scatter3d`` line+markers trace. During the animation each frame replaces it with EVERY coordinate visited so far (full resolution, not sub-sampled), drawing a cyan line AND a dot at every point the head has passed. Empty on load so nothing is drawn until Play is pressed. """ return go.Scatter3d( x=[], y=[], z=[], mode="lines+markers", # Dim grey line so the layer-coloured dots (set per-frame) stand out. line=dict(color="#5a6072", width=2), marker=dict(size=2), name="Printed trail", showlegend=True, hoverinfo="skip", ) def _anim_indices(n_moves: int) -> list[int]: """Pick the move indices used as animation frames. Inputs: n_moves -- total number of moves. Output: ascending list of move indices sub-sampled to about ``ANIM_TARGET_FRAMES`` frames (always includes the final move), so the animation stays smooth regardless of file size. """ if n_moves == 0: return [] step = max(1, math.ceil(n_moves / ANIM_TARGET_FRAMES)) idxs = list(range(0, n_moves, step)) if idxs[-1] != n_moves - 1: idxs.append(n_moves - 1) return idxs def build_animation(moves: list[dict], trail_index: int, cursor_index: int, static_indices: list[int]): """Build the progressive-reveal animation, control buttons, and slider. Inputs: moves -- list of move dicts (mm coords, weld_state). trail_index -- trace index of the growing "Printed trail". cursor_index -- trace index of the head-position marker. static_indices -- trace indices of the full static toolpath (rapid + weld traces) that are hidden during playback and restored on reset. Output: (frames, play_menu, slider): frames -- list[go.Frame]. The animation frames (one per sub-sampled 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 "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 Visualize buttons. slider -- a ``sliders`` dict to scrub to any frame by move index. Returns ([], None, None) when there are no moves. Coordinates: mm. The trail/cursor positions are exact NC move coordinates; only which moves get their own frame is sub-sampled (see ``_anim_indices``). """ idxs = _anim_indices(len(moves)) if not idxs: return [], None, None first = moves[idxs[0]] stick_len = _stick_length(moves) # Full-resolution coordinate arrays; each frame reveals a prefix of these # so the real toolpath (every move, not just frame points) is drawn. all_x = [m["x"] for m in moves] all_y = [m["y"] for m in moves] all_z = [m["z"] for m in moves] # Per-point trail colour keyed to the move's layer, using the SAME mapping # as the weld traces so each revealed dot matches its layer's rainbow hue. order = _slice_order(moves) n_layers = len(order) layer_pos = {sno: i for i, sno in enumerate(order)} all_colours = [_layer_colour(layer_pos[m["slice_no"]], n_layers) for m in moves] # Hiding/showing the static toolpath = a tiny visibility update per static # trace (re-used as frame data alongside the trail + cursor updates). # 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 = [] slider_steps = [] for k, i in enumerate(idxs): # Reveal every move up to and including this frame's move index. upto = i + 1 m = moves[i] trail = go.Scatter3d( x=all_x[:upto], y=all_y[:upto], z=all_z[:upto], marker=dict(size=2, color=all_colours[:upto]), ) cx, cy, cz = _stick_points(m, stick_len) cursor = go.Scatter3d(x=cx, y=cy, z=cz) if k == 0: # First frame also hides the full static path so playback reveals # 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)) else: data = [trail, cursor] traces = [trail_index, cursor_index] layout = None frame_kwargs = dict(data=data, traces=traces, name=str(i)) if layout is not None: frame_kwargs["layout"] = layout frames.append(go.Frame(**frame_kwargs)) slider_steps.append(dict( method="animate", label=str(i), args=[[str(i)], dict( mode="immediate", frame=dict(duration=0, redraw=True), transition=dict(duration=0), )], )) anim_names = [str(i) for i in idxs] # "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="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="visualize", camera=DEFAULT_CAMERA)), )) play_menu = dict( type="buttons", direction="left", showactive=False, x=0.01, xanchor="left", y=0.05, yanchor="bottom", bgcolor="#16213e", bordercolor="#0d47a1", font=dict(color="white", size=12), pad=dict(l=4, r=4, t=4, b=4), buttons=[ dict( label="▶ Play", method="animate", # Explicit frame list (not None) so the trailing "reset" frame # is excluded; fromcurrent=False restarts the reveal each time. args=[anim_names, dict( fromcurrent=False, mode="immediate", frame=dict(duration=40, redraw=True), transition=dict(duration=0), )], ), dict( label="❚❚ Pause", method="animate", args=[[None], dict( mode="immediate", frame=dict(duration=0, redraw=False), transition=dict(duration=0), )], ), dict( label="◉ Visualize", method="animate", args=[["visualize"], dict( mode="immediate", frame=dict(duration=0, redraw=True), transition=dict(duration=0), )], ), ], ) slider = dict( active=0, x=0.05, len=0.55, y=0.0, yanchor="top", pad=dict(t=10, b=10), currentvalue=dict(prefix="Move ", font=dict(color="white", size=12)), font=dict(color="white", size=10), bgcolor="#16213e", bordercolor="#0d47a1", steps=slider_steps, ) return frames, play_menu, slider def _axis_ranges(moves: list[dict]): """Compute fixed [min, max] ranges for the X/Y/Z scene axes (mm). Inputs: moves -- list of move dicts (mm coords). Output: (x_range, y_range, z_range), each a 2-element [lo, hi] list padded by 5% of the largest extent so the part never touches the scene walls. These are pinned on the scene so the view stays FIXED and fully framed regardless of which traces are visible — without them the animation (which hides the static toolpath and grows a 1-point trail) makes ``aspectmode="data"`` rescale the scene, causing the zoom to jump. """ if not moves: return [-1, 1], [-1, 1], [-1, 1] xs = [m["x"] for m in moves] ys = [m["y"] for m in moves] zs = [m["z"] for m in moves] pad = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 1.0) * 0.05 return ( [min(xs) - pad, max(xs) + pad], [min(ys) - pad, max(ys) + pad], [min(zs) - pad, max(zs) + pad], ) def build_figure(parsed: dict, source_file: str) -> go.Figure: """Assemble the complete two-column Plotly figure. Inputs: parsed -- dict from ``parse_nc`` ("part_name", "moves"). source_file -- path/name of the NC file (shown in the title). Output: a ``go.Figure`` with: a large 3D toolpath scene (rapid + per-layer weld + an animated head cursor) spanning both left rows, an A/B angle chart top-right, a stats table bottom-right, a layer-isolation dropdown, play/pause animation controls with a scrub slider, and a dark theme. Calls every ``build_*`` helper. """ moves = parsed["moves"] part_name = parsed["part_name"] stats = compute_stats(moves) stats["part_name"] = part_name n_layers = stats["total_layers"] n_points = stats["total_points"] # Fixed scene bounds from the part's bounding box (see _axis_ranges). x_range, y_range, z_range = _axis_ranges(moves) fig = make_subplots( rows=2, cols=2, column_widths=[0.66, 0.34], row_heights=[0.5, 0.5], specs=[ [{"type": "scene", "rowspan": 2}, {"type": "xy"}], [None, {"type": "table"}], ], subplot_titles=("", "Extruder Tilt — A and B Angles", ""), horizontal_spacing=0.06, vertical_spacing=0.10, ) # --- 3D scene traces (col 1) --- rapid_trace = build_rapid_trace(moves) weld_traces = build_weld_traces(moves) fig.add_trace(rapid_trace, row=1, col=1) # index 0 for wt in weld_traces: # indices 1..n fig.add_trace(wt, row=1, col=1) # Head cursor (index 1+n) and growing trail (index 2+n) sit right after the # weld traces so the layer dropdown (restyles only weld indices) never # touches them. The static toolpath = rapid + weld traces (indices 0..n). cursor_index = 1 + len(weld_traces) trail_index = 2 + len(weld_traces) static_indices = list(range(0, 1 + len(weld_traces))) fig.add_trace(build_cursor_trace(moves), row=1, col=1) fig.add_trace(build_trail_trace(), row=1, col=1) # --- angle chart (row 1, col 2) --- angle_traces, angle_yrange = build_angle_chart(moves) for at in angle_traces: fig.add_trace(at, row=1, col=2) # --- 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 + 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( moves, trail_index, cursor_index, static_indices, ) fig.frames = frames menus = [dropdown] + ([play_menu] if play_menu else []) sliders = [slider] if slider else [] title = ( f"NC Viewer — {part_name} | {n_layers} layers " 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, font=dict(color="white"), updatemenus=menus, sliders=sliders, # Constant uirevision keeps the user's camera across frame redraws, so # the 3D scene can be rotated/zoomed/panned WHILE the animation plays. uirevision="keep", 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, # Pinned axis ranges (full part bounding box) keep the view fixed # and fully framed — without them aspectmode="data" rescales the # scene as the animation hides/reveals traces, making the zoom jump. xaxis=dict(title="X (mm)", showgrid=True, gridcolor="#444", color="white", range=x_range), yaxis=dict(title="Y (mm)", showgrid=True, gridcolor="#444", color="white", range=y_range), zaxis=dict(title="Z (mm)", showgrid=True, gridcolor="#444", color="white", range=z_range), aspectmode="data", camera=DEFAULT_CAMERA, uirevision="keep", ), ) # Angle chart axis labels (top-right subplot is xaxis2/yaxis2). fig.update_xaxes(title_text="Move index", row=1, col=2, color="white", gridcolor="#333") # Pin the y-range so the welding band fills the full chart height exactly. fig.update_yaxes(title_text="Angle (°)", row=1, col=2, color="white", gridcolor="#333", range=angle_yrange) return fig def main(nc_path: str) -> None: """Parse an NC file and write the interactive viewer HTML. Inputs: nc_path -- path to the .nc file to visualise. Output: None. Writes ``_viewer.html`` (self-contained, plotly.js from CDN) next to where the script runs and prints "Saved: ". """ parsed = parse_nc(nc_path) fig = build_figure(parsed, nc_path) out_name = f"{Path(nc_path).stem}_viewer.html" pio.write_html( 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}") if __name__ == "__main__": import sys if len(sys.argv) < 2: print("Usage: python -m wired3d_viewer.viewer ") sys.exit(1) main(sys.argv[1])