STLtoGCode / gcode_viewer.py
MichaelRKessler's picture
Add color dropdowns for Travel and Print lines
d48e528
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}<br>Y=%{y:.2f}<br>Z=%{z:.2f}<extra></extra>",
)
)
(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