from __future__ import annotations import re from pathlib import Path import plotly.graph_objects as go _MOVE_RE = re.compile( r"^\s*(G0|G1)\s+X(-?\d+(?:\.\d+)?)\s+Y(-?\d+(?:\.\d+)?)" r"(?:\s+Z(-?\d+(?:\.\d+)?))?", re.IGNORECASE, ) def parse_gcode_path(gcode_text: str) -> dict: relative = True x = y = z = 0.0 print_segments: list[list[tuple[float, float, float]]] = [] travel_segments: list[list[tuple[float, float, float]]] = [] current_kind: str | None = None current_segment: list[tuple[float, float, float]] = [] all_x: list[float] = [] all_y: list[float] = [] all_z: list[float] = [] def flush_segment() -> None: nonlocal current_segment, current_kind if current_segment and current_kind is not None: target = print_segments if current_kind == "print" else travel_segments target.append(current_segment) current_segment = [] current_kind = None for raw_line in gcode_text.splitlines(): line = raw_line.strip() if not line: flush_segment() continue upper = line.upper() if upper.startswith("G90"): relative = False continue if upper.startswith("G91"): relative = True continue match = _MOVE_RE.match(line) if not match: flush_segment() continue gcmd = match.group(1).upper() dx = float(match.group(2)) dy = float(match.group(3)) dz_match = match.group(4) dz = float(dz_match) if dz_match is not None else 0.0 prev_pos = (x, y, z) if relative: x += dx y += dy z += dz else: x, y = dx, dy if dz_match is not None: z = dz kind = "travel" if gcmd == "G1" else "print" if kind != current_kind: flush_segment() current_kind = kind current_segment = [prev_pos] current_segment.append((x, y, z)) all_x.append(x) all_y.append(y) all_z.append(z) flush_segment() if all_x: bounds = ( (min(all_x), min(all_y), min(all_z)), (max(all_x), max(all_y), max(all_z)), ) else: bounds = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)) return { "print_segments": print_segments, "travel_segments": travel_segments, "bounds": bounds, "point_count": len(all_x), } def _segments_to_xyz( segments: list[list[tuple[float, float, float]]], ) -> tuple[list[float | None], list[float | None], list[float | None]]: xs: list[float | None] = [] ys: list[float | None] = [] zs: list[float | None] = [] for segment in segments: for px, py, pz in segment: xs.append(px) ys.append(py) zs.append(pz) xs.append(None) ys.append(None) zs.append(None) return xs, ys, zs def build_toolpath_figure( parsed: dict, travel_opacity: float = 0.55, print_opacity: float = 1.0, travel_color: str = "#969696", print_color: str = "#1f77b4", ) -> go.Figure: print_xs, print_ys, print_zs = _segments_to_xyz(parsed["print_segments"]) travel_xs, travel_ys, travel_zs = _segments_to_xyz(parsed["travel_segments"]) fig = go.Figure() if travel_xs: fig.add_trace( go.Scatter3d( x=travel_xs, y=travel_ys, z=travel_zs, mode="lines", name="Travel (G0)", opacity=travel_opacity, line=dict(color=travel_color, width=2, dash="dot"), hoverinfo="skip", ) ) if print_xs: fig.add_trace( go.Scatter3d( x=print_xs, y=print_ys, z=print_zs, mode="lines", name="Print (G1)", opacity=print_opacity, line=dict(color=print_color, width=4), hovertemplate="X=%{x:.2f}
Y=%{y:.2f}
Z=%{z:.2f}", ) ) (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"] fig.update_layout( uirevision="toolpath", scene=dict( xaxis_title="X (mm)", yaxis_title="Y (mm)", zaxis_title="Z (mm)", aspectmode="data", ), margin=dict(l=0, r=0, t=30, b=0), legend=dict(orientation="h", yanchor="bottom", y=1.0, xanchor="left", x=0.0), title=( f"Tool path — {len(parsed['print_segments'])} print / " f"{len(parsed['travel_segments'])} travel segments " f"X[{x_min:.1f},{x_max:.1f}] Y[{y_min:.1f},{y_max:.1f}] " f"Z[{z_min:.1f},{z_max:.1f}]" ), ) return fig def render_gcode_file(path: str | Path) -> tuple[go.Figure, dict]: text = Path(path).read_text() parsed = parse_gcode_path(text) return build_toolpath_figure(parsed), parsed