from __future__ import annotations import math import re from pathlib import Path import numpy as np import plotly.graph_objects as go # A move is any G0/G1 (or G00/G01) line. Coordinates may list any subset of # X/Y/Z in any order, mixed with other tokens (F feed rate, E extrusion); only # the axes named on a line change. This matches standard slicer/firmware G-code # as well as this app's own always-paired "X Y" output. _CMD_RE = re.compile(r"^G0*([01])(?![0-9])", re.IGNORECASE) _AXIS_RE = re.compile(r"([XYZ])\s*([-+]?(?:\d*\.\d+|\d+\.?))", re.IGNORECASE) # Pneumatic valve toggle: WAGO_ValveCommands(, <0=close|1=open>). Some # generators emit every move as G1 and convey extrusion only through the valve. _VALVE_RE = re.compile(r"WAGO_ValveCommands\(\s*(\d+)\s*,\s*(\d+)\s*\)", re.IGNORECASE) def parse_gcode_path(gcode_text: str) -> dict: relative = True x = y = z = 0.0 # Decide how to tell print from travel. The valve physically controls # material flow, so when valve commands are present they are the ground # truth: valve open = printing, valve closed = travel. This is correct for # the app's own output (where valve state and G1/G0 agree) and for external # generators whose G0/G1 labels are unreliable — some omit G0 entirely # (every move G1), others invert G0/G1 relative to the valve. Only fall back # to the G1 = print / G0 = travel convention when there is no valve to read. use_valve = bool(_VALVE_RE.search(gcode_text)) open_valves: set[str] = set() print_segments: list[list[tuple[float, float, float]]] = [] travel_segments: list[list[tuple[float, float, float]]] = [] moves: list[dict] = [] 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 # Drop inline comments so axis letters in comment text are never read. code = line.split(";", 1)[0].strip() upper = code.upper() if upper.startswith("G90"): relative = False continue if upper.startswith("G91"): relative = True continue cmd_match = _CMD_RE.match(code) if not cmd_match: # Track valve open/close so all-G1 files can be split into # print (valve open) and travel (valve closed) runs. valve_match = _VALVE_RE.search(code) if valve_match: valve, state = valve_match.group(1), valve_match.group(2) if state == "0": open_valves.discard(valve) else: open_valves.add(valve) flush_segment() continue axes = {a.upper(): float(v) for a, v in _AXIS_RE.findall(code)} if not axes: # A G0/G1 with no coordinates (e.g. "G1 F1800") is not a move. flush_segment() continue prev_pos = (x, y, z) if relative: x += axes.get("X", 0.0) y += axes.get("Y", 0.0) z += axes.get("Z", 0.0) else: if "X" in axes: x = axes["X"] if "Y" in axes: y = axes["Y"] if "Z" in axes: z = axes["Z"] if use_valve: kind = "print" if open_valves else "travel" else: kind = "print" if cmd_match.group(1) == "1" else "travel" moves.append({"kind": kind, "start": prev_pos, "end": (x, y, z)}) 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)) # Assign a layer index to every move. Layers are the distinct Z heights at # which printing (G1) happens; travel moves (including the Z lift between # layers) are attributed to the layer of the next print move so a layer's # timeline starts with the approach travel and ends with its last print. print_z = sorted({round(m["end"][2], 6) for m in moves if m["kind"] == "print"}) z_to_layer = {z: i for i, z in enumerate(print_z)} next_print_layer = len(print_z) - 1 if print_z else 0 for move in reversed(moves): if move["kind"] == "print": next_print_layer = z_to_layer[round(move["end"][2], 6)] move["layer"] = next_print_layer return { "print_segments": print_segments, "travel_segments": travel_segments, "moves": moves, "layer_count": len(print_z), "bounds": bounds, "point_count": len(all_x), } def _move_length(move: dict) -> float: (x0, y0, z0), (x1, y1, z1) = move["start"], move["end"] return math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 + (z1 - z0) ** 2) def _chronological_trace_arrays(moves: list[dict]) -> dict: """Build per-kind polyline arrays with a shared time axis for animation. Each point gets a timestamp equal to the cumulative path length (print + travel) at which the nozzle reaches it, so the browser can reveal both traces in lockstep by slicing at a time cutoff. Gap markers (None) close a trace's polyline whenever the move kind switches and carry the timestamp of the segment they terminate. """ arrays: dict[str, dict[str, list]] = { "print": {"x": [], "y": [], "z": [], "t": []}, "travel": {"x": [], "y": [], "z": [], "t": []}, } cum = 0.0 prev_kind: str | None = None layer_end: dict[int, float] = {} for move in moves: trace = arrays[move["kind"]] if move["kind"] != prev_kind: if prev_kind is not None: prev_trace = arrays[prev_kind] prev_trace["x"].append(None) prev_trace["y"].append(None) prev_trace["z"].append(None) prev_trace["t"].append(cum) sx, sy, sz = move["start"] trace["x"].append(sx) trace["y"].append(sy) trace["z"].append(sz) trace["t"].append(cum) prev_kind = move["kind"] cum += _move_length(move) ex, ey, ez = move["end"] trace["x"].append(ex) trace["y"].append(ey) trace["z"].append(ez) trace["t"].append(cum) layer_end[move["layer"]] = cum layer_count = (max(layer_end) + 1) if layer_end else 0 layer_t_end: list[float] = [] last = 0.0 for i in range(layer_count): last = max(last, layer_end.get(i, last)) layer_t_end.append(last) return { "print": arrays["print"], "travel": arrays["travel"], "total_length": cum, "layer_t_end": layer_t_end, } def _path_arrays(moves: list[dict]) -> dict: """Chronological nozzle positions with cumulative-length timestamps.""" xs = [moves[0]["start"][0]] ys = [moves[0]["start"][1]] zs = [moves[0]["start"][2]] ts = [0.0] cum = 0.0 for move in moves: cum += _move_length(move) ex, ey, ez = move["end"] xs.append(ex) ys.append(ey) zs.append(ez) ts.append(cum) return {"x": xs, "y": ys, "z": zs, "t": ts} def _build_path_tube( moves: list[dict], radius: float, kind: str = "print", sides: int = 12, max_rings: int = 5000, ) -> dict: """Extrude a tube of physical radius along the moves of the given kind. Returns Mesh3d-ready vertex and face arrays. Rings are laid down in chronological order and long moves are subdivided, so faces can be revealed progressively by slicing the (sorted) per-face timestamps. """ # Group consecutive moves of this kind into continuous runs (global times). runs: list[tuple[list, list]] = [] cur_pts: list | None = None cur_ts: list | None = None cum = 0.0 for move in moves: length = _move_length(move) if move["kind"] == kind: if cur_pts is None: cur_pts = [move["start"]] cur_ts = [cum] cur_pts.append(move["end"]) cur_ts.append(cum + length) elif cur_pts is not None: runs.append((cur_pts, cur_ts)) cur_pts = cur_ts = None cum += length if cur_pts is not None: runs.append((cur_pts, cur_ts)) total_print = sum(ts[-1] - ts[0] for _pts, ts in runs) step = max(radius * 2.0, total_print / max_rings) if total_print > 0 else radius xs: list[float] = [] ys: list[float] = [] zs: list[float] = [] fi: list[int] = [] fj: list[int] = [] fk: list[int] = [] face_t: list[float] = [] angles = np.linspace(0.0, 2.0 * np.pi, sides, endpoint=False) cos_a, sin_a = np.cos(angles), np.sin(angles) for pts, ts in runs: # Subdivide long moves so the tube grows smoothly during playback. sub_p = [np.asarray(pts[0], dtype=float)] sub_t = [ts[0]] for a in range(len(pts) - 1): p0 = np.asarray(pts[a], dtype=float) p1 = np.asarray(pts[a + 1], dtype=float) seg = float(np.linalg.norm(p1 - p0)) pieces = max(1, math.ceil(seg / step)) for s in range(1, pieces + 1): f = s / pieces sub_p.append(p0 + (p1 - p0) * f) sub_t.append(ts[a] + (ts[a + 1] - ts[a]) * f) points = np.vstack(sub_p) n_rings = len(points) if n_rings < 2: continue # Per-ring tangents (averaged at interior points) and a perpendicular # frame; vertical tangents fall back to the X axis for the side vector. tangents = np.zeros_like(points) tangents[1:-1] = points[2:] - points[:-2] tangents[0] = points[1] - points[0] tangents[-1] = points[-1] - points[-2] norms = np.linalg.norm(tangents, axis=1, keepdims=True) norms[norms == 0] = 1.0 tangents /= norms side_vec = np.cross(tangents, np.array([0.0, 0.0, 1.0])) side_norm = np.linalg.norm(side_vec, axis=1) vertical = side_norm < 1e-6 if vertical.any(): side_vec[vertical] = np.cross(tangents[vertical], np.array([1.0, 0.0, 0.0])) side_vec /= np.maximum(np.linalg.norm(side_vec, axis=1, keepdims=True), 1e-12) up_vec = np.cross(side_vec, tangents) base = len(xs) rings = ( points[:, None, :] + radius * (cos_a[None, :, None] * side_vec[:, None, :] + sin_a[None, :, None] * up_vec[:, None, :]) ) flat = np.round(rings.reshape(-1, 3), 4) xs.extend(flat[:, 0].tolist()) ys.extend(flat[:, 1].tolist()) zs.extend(flat[:, 2].tolist()) # Center vertices for the end caps that close the tube. cap_start = len(xs) xs.append(round(float(points[0][0]), 4)) ys.append(round(float(points[0][1]), 4)) zs.append(round(float(points[0][2]), 4)) cap_end = len(xs) xs.append(round(float(points[-1][0]), 4)) ys.append(round(float(points[-1][1]), 4)) zs.append(round(float(points[-1][2]), 4)) t_start = round(sub_t[0], 4) t_end = round(sub_t[-1], 4) # Start cap (fan around the first ring). for k in range(sides): k_next = (k + 1) % sides fi.append(cap_start) fj.append(base + k_next) fk.append(base + k) face_t.append(t_start) for r in range(n_rings - 1): r0 = base + r * sides r1 = r0 + sides t_face = round(sub_t[r + 1], 4) for k in range(sides): k_next = (k + 1) % sides fi.extend((r0 + k, r0 + k)) fj.extend((r1 + k, r1 + k_next)) fk.extend((r1 + k_next, r0 + k_next)) face_t.extend((t_face, t_face)) # End cap (fan around the last ring). last_ring = base + (n_rings - 1) * sides for k in range(sides): k_next = (k + 1) % sides fi.append(cap_end) fj.append(last_ring + k) fk.append(last_ring + k_next) face_t.append(t_end) return {"x": xs, "y": ys, "z": zs, "i": fi, "j": fj, "k": fk, "face_t": face_t} 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 _part_nozzle(part: dict) -> int: try: nozzle = int(float(part.get("nozzle", part.get("idx", 1)))) except (TypeError, ValueError): nozzle = int(part.get("idx", 1) or 1) return nozzle if nozzle > 0 else int(part.get("idx", 1) or 1) def build_toolpath_figure( parsed: dict, travel_opacity: float = 0.2, print_opacity: float = 1.0, travel_color: str = "#969696", print_color: str = "#1f77b4", print_width: float = 0.8, travel_width: float = 0.2, tube: bool = True, ) -> go.Figure: moves = parsed.get("moves") or [] fig = go.Figure() meta = None def add_tube_trace(tube: dict, name: str, color: str, opacity: float) -> None: fig.add_trace( go.Mesh3d( x=tube["x"], y=tube["y"], z=tube["z"], i=tube["i"], j=tube["j"], k=tube["k"], color=color, opacity=opacity, name=name, showlegend=True, hoverinfo="skip", lighting=dict(ambient=0.55, diffuse=0.8, specular=0.15, roughness=0.6), ) ) if moves and tube: chrono = _chronological_trace_arrays(moves) # Physical-width tubes along both paths: filament-like rendering whose # thickness scales with zoom (widths are diameters in mm). travel_tube = _build_path_tube( moves, radius=max(travel_width, 0.05) / 2.0, kind="travel" ) if travel_tube["i"]: add_tube_trace(travel_tube, "Travel (G0)", travel_color, travel_opacity) print_tube = _build_path_tube( moves, radius=max(print_width, 0.05) / 2.0, kind="print" ) if print_tube["i"]: add_tube_trace(print_tube, "Print (G1)", print_color, print_opacity) # Nozzle position marker, driven client-side during playback. end_x, end_y, end_z = moves[-1]["end"] fig.add_trace( go.Scatter3d( x=[end_x], y=[end_y], z=[end_z], mode="markers", name="Nozzle", marker=dict(size=5, color="#d62728"), showlegend=False, hoverinfo="skip", ) ) path = _path_arrays(moves) meta = { "animation": { "travel_face_t": travel_tube["face_t"], "print_face_t": print_tube["face_t"], "path_x": path["x"], "path_y": path["y"], "path_z": path["z"], "path_t": path["t"], "layer_t_end": chrono["layer_t_end"], "total_length": chrono["total_length"], } } else: travel_xs, travel_ys, travel_zs = _segments_to_xyz(parsed["travel_segments"]) 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), hoverinfo="skip", ) ) print_xs, print_ys, print_zs = _segments_to_xyz(parsed["print_segments"]) 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( meta=meta, height=700, 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 build_parallel_figure( parts: list[dict], gap: float = 5.0, part_offsets: dict[int, tuple[float, float]] | None = None, filament_width: float = 0.8, travel_width: float = 0.2, travel_opacity: float = 0.2, print_opacity: float = 1.0, tube: bool = True, ) -> go.Figure: """Render several parsed shapes with optional explicit X/Y nozzle offsets. `tube` True draws filament tubes with a shared-time animation timeline; False draws fast thin scatter lines (no animation). `parts` is a list of {"idx": int, "nozzle": int, "color": str, "parsed": dict}. Each part's print and travel traces (and, in tube mode, a nozzle marker) are named by idx so the client-side animation/recolor can address them. """ fig = go.Figure() anim_parts: list[dict] = [] total_length = 0.0 rendered = False n_parts = 0 bx0 = by0 = bz0 = float("inf") bx1 = by1 = bz1 = float("-inf") running_x = 0.0 for part in parts: idx = part["idx"] nozzle = _part_nozzle(part) color = part["color"] parsed = part["parsed"] moves = parsed.get("moves") or [] if not moves: continue (pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"] width = pxmax - pxmin if part_offsets is None: x_off = running_x - pxmin y_off = 0.0 running_x += width + gap else: x_off, y_off = part_offsets.get(nozzle, part_offsets.get(idx, (0.0, 0.0))) nozzle_trace_name = f"Nozzle {nozzle} (Shape {idx})" if tube: print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print") travel_tube = _build_path_tube(moves, radius=max(travel_width, 0.05) / 2.0, kind="travel") path = _path_arrays(moves) px = [v + x_off for v in print_tube["x"]] py = [v + y_off for v in print_tube["y"]] tx = [v + x_off for v in travel_tube["x"]] ty = [v + y_off for v in travel_tube["y"]] path_x = [v + x_off for v in path["x"]] path_y = [v + y_off for v in path["y"]] if travel_tube["i"]: fig.add_trace( go.Mesh3d( x=tx, y=ty, z=travel_tube["z"], i=travel_tube["i"], j=travel_tube["j"], k=travel_tube["k"], color=color, opacity=travel_opacity, name=f"Travel {idx}", showlegend=False, hoverinfo="skip", lighting=dict(ambient=0.6, diffuse=0.8, specular=0.1, roughness=0.6), ) ) if print_tube["i"]: fig.add_trace( go.Mesh3d( x=px, y=py, z=print_tube["z"], i=print_tube["i"], j=print_tube["j"], k=print_tube["k"], color=color, opacity=print_opacity, name=f"Shape {idx}", showlegend=True, hoverinfo="skip", lighting=dict(ambient=0.55, diffuse=0.8, specular=0.15, roughness=0.6), ) ) fig.add_trace( go.Scatter3d( x=[path_x[-1]], y=[path_y[-1]], z=[path["z"][-1]], mode="markers", name=nozzle_trace_name, marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip", ) ) part_total = path["t"][-1] if path["t"] else 0.0 total_length = max(total_length, part_total) anim_parts.append({ "printName": f"Shape {idx}", "travelName": f"Travel {idx}", "nozzleName": nozzle_trace_name, "print_face_t": print_tube["face_t"], "travel_face_t": travel_tube["face_t"], "path_x": path_x, "path_y": path_y, "path_z": path["z"], "path_t": path["t"], }) else: t_xs, t_ys, t_zs = _segments_to_xyz(parsed["travel_segments"]) p_xs, p_ys, p_zs = _segments_to_xyz(parsed["print_segments"]) t_xs = [v + x_off if v is not None else None for v in t_xs] p_xs = [v + x_off if v is not None else None for v in p_xs] t_ys = [v + y_off if v is not None else None for v in t_ys] p_ys = [v + y_off if v is not None else None for v in p_ys] if t_xs: fig.add_trace( go.Scatter3d( x=t_xs, y=t_ys, z=t_zs, mode="lines", name=f"Travel {idx}", opacity=travel_opacity, line=dict(color=color, width=2), showlegend=False, hoverinfo="skip", ) ) if p_xs: fig.add_trace( go.Scatter3d( x=p_xs, y=p_ys, z=p_zs, mode="lines", name=f"Shape {idx}", opacity=print_opacity, line=dict(color=color, width=4), showlegend=True, hoverinfo="skip", ) ) rendered = True n_parts += 1 bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off) by0 = min(by0, pymin + y_off); by1 = max(by1, pymax + y_off) bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax) if not rendered: fig.update_layout(height=700) return fig meta = {"animation": {"total_length": total_length, "parts": anim_parts}} if anim_parts else None pad = max(bx1 - bx0, by1 - by0, bz1 - bz0, 1.0) * 0.05 fig.update_layout( meta=meta, height=700, uirevision="parallel", scene=dict( xaxis_title="X (mm)", yaxis_title="Y (mm)", zaxis_title="Z (mm)", xaxis_range=[bx0 - pad, bx1 + pad], yaxis_range=[by0 - pad, by1 + pad], zaxis_range=[bz0 - pad, bz1 + pad], 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"Parallel print — {n_parts} part(s)", ) return fig def build_nozzle_spacing_figure( parts: list[dict], part_offsets: dict[int, tuple[float, float]], spacings: list[dict], ) -> go.Figure: fig = go.Figure() bx0 = by0 = float("inf") bx1 = by1 = float("-inf") nozzle_colors: dict[int, str] = {} for part in parts: idx = part["idx"] nozzle = _part_nozzle(part) color = part["color"] nozzle_colors.setdefault(nozzle, color) parsed = part["parsed"] (pxmin, pymin, _), (pxmax, pymax, _) = parsed["bounds"] x_off, y_off = part_offsets.get(nozzle, part_offsets.get(idx, (0.0, 0.0))) xs = [pxmin + x_off, pxmax + x_off, pxmax + x_off, pxmin + x_off, pxmin + x_off] ys = [pymin + y_off, pymin + y_off, pymax + y_off, pymax + y_off, pymin + y_off] fig.add_trace( go.Scatter( x=xs, y=ys, mode="lines", name=f"Shape {idx} bounds (N{nozzle})", line=dict(color=color, width=2), ) ) bx0 = min(bx0, min(xs), x_off) bx1 = max(bx1, max(xs), x_off) by0 = min(by0, min(ys), y_off) by1 = max(by1, max(ys), y_off) for nozzle, (x_off, y_off) in sorted(part_offsets.items()): fig.add_trace( go.Scatter( x=[x_off], y=[y_off], mode="markers+text", name=f"Nozzle {nozzle}", marker=dict(color=nozzle_colors.get(nozzle, "#444444"), size=12), text=[f"N{nozzle}"], textposition="top center", ) ) bx0 = min(bx0, x_off) bx1 = max(bx1, x_off) by0 = min(by0, y_off) by1 = max(by1, y_off) for spacing in spacings: start = spacing["from"] end = spacing["to"] x0, y0 = part_offsets.get(start, (0.0, 0.0)) x1, y1 = part_offsets.get(end, (0.0, 0.0)) fig.add_trace( go.Scatter( x=[x0, x1], y=[y0, y1], mode="lines+markers+text", name=f"Nozzle {start}->{end}", line=dict(color="#444444", width=2, dash="dash"), marker=dict(color="#444444", size=6), text=["", f"dx {spacing['dx']:.2f}, dy {spacing['dy']:.2f}"], textposition="middle right", ) ) if bx0 == float("inf"): bx0 = by0 = -1.0 bx1 = by1 = 1.0 pad = max(bx1 - bx0, by1 - by0, 1.0) * 0.08 fig.update_layout( height=420, uirevision="nozzle-spacing", xaxis_title="X (mm)", yaxis_title="Y (mm)", xaxis=dict(range=[bx0 - pad, bx1 + pad], scaleanchor="y", scaleratio=1), yaxis=dict(range=[by0 - pad, by1 + pad]), margin=dict(l=0, r=0, t=30, b=0), legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0.0), title="Nozzle spacing layout", ) return fig def build_parallel_gif( parts: list[dict], out_path: str | Path, gap: float = 5.0, part_offsets: dict[int, tuple[float, float]] | None = None, duration: float = 6.0, fps: int = 10, travel_opacity: float = 0.15, travel_color: str = "#9a9a9a", print_width: float = 4.0, travel_width: float = 1.5, elev: float = 22.0, azim: float = -60.0, progress_cb=None, ) -> Path | None: """Render the parallel print as an animated GIF using Matplotlib (CPU Agg backend — no WebGL/headless browser, works on Hugging Face). Each part's toolpath is drawn as growing colored lines (print solid, travel faint), three parts in parallel on a shared cumulative-length time axis. `parts` is a list of {"idx": int, "nozzle": int, "color": str, "parsed": dict}. """ import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation, PillowWriter from mpl_toolkits.mplot3d.art3d import Line3DCollection pdata: list[dict] = [] running_x = 0.0 total_length = 0.0 bx0 = by0 = bz0 = float("inf") bx1 = by1 = bz1 = float("-inf") for part in parts: parsed = part["parsed"] moves = parsed.get("moves") or [] if not moves: continue (pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"] if part_offsets is None: x_off = running_x - pxmin y_off = 0.0 running_x += (pxmax - pxmin) + gap else: nozzle = _part_nozzle(part) x_off, y_off = part_offsets.get(nozzle, part_offsets.get(part["idx"], (0.0, 0.0))) cum = 0.0 mlist: list[tuple] = [] for m in moves: s = (m["start"][0] + x_off, m["start"][1] + y_off, m["start"][2]) e = (m["end"][0] + x_off, m["end"][1] + y_off, m["end"][2]) seg_len = math.dist(s, e) mlist.append((m["kind"], s, e, cum, cum + seg_len)) cum += seg_len total_length = max(total_length, cum) pdata.append({ "color": part["color"], "moves": mlist, "last": mlist[-1][2], "first": mlist[0][1], }) bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off) by0 = min(by0, pymin + y_off); by1 = max(by1, pymax + y_off) bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax) if not pdata or total_length <= 0: return None n_frames = max(2, int(round(duration * fps))) def segs_at(mlist: list[tuple], kind: str, cutoff: float) -> list: out = [] for k, s, e, t0, t1 in mlist: if k != kind: continue if t1 <= cutoff: out.append([s, e]) elif t0 < cutoff: f = (cutoff - t0) / (t1 - t0) if t1 > t0 else 1.0 ei = (s[0] + (e[0] - s[0]) * f, s[1] + (e[1] - s[1]) * f, s[2] + (e[2] - s[2]) * f) out.append([s, ei]) return out def nozzle_at(mlist: list[tuple], cutoff: float) -> tuple: last = mlist[0][1] for _k, s, e, t0, t1 in mlist: if cutoff >= t1: last = e elif t0 <= cutoff <= t1: f = (cutoff - t0) / (t1 - t0) if t1 > t0 else 1.0 return (s[0] + (e[0] - s[0]) * f, s[1] + (e[1] - s[1]) * f, s[2] + (e[2] - s[2]) * f) else: return last return last fig = plt.figure(figsize=(8, 6), dpi=100) ax = fig.add_subplot(111, projection="3d") # Honour explicit zorder instead of depth-sorting, so the nozzle markers # always draw on top of the toolpath lines. try: ax.computed_zorder = False except Exception: pass pad = max(bx1 - bx0, by1 - by0, bz1 - bz0, 1.0) * 0.05 ax.set_xlim(bx0 - pad, bx1 + pad) ax.set_ylim(by0 - pad, by1 + pad) ax.set_zlim(bz0 - pad, bz1 + pad) try: ax.set_box_aspect((bx1 - bx0 + 1e-6, by1 - by0 + 1e-6, bz1 - bz0 + 1e-6)) except Exception: pass ax.set_xlabel("X (mm)"); ax.set_ylabel("Y (mm)"); ax.set_zlabel("Z (mm)") ax.view_init(elev=elev, azim=azim) artists = [] for pd in pdata: # Seed with a degenerate segment: matplotlib 3.11's add_collection3d # errors on an empty collection. Axis limits are fixed above, so this # placeholder doesn't affect scaling; update() replaces it each frame. seed = [[pd["first"], pd["first"]]] # Travel drawn in neutral grey (distinct from the part's print color) # and faint, so travel and print are easy to tell apart. travel_col = Line3DCollection(seed, colors=travel_color, linewidths=travel_width, alpha=travel_opacity, zorder=1) print_col = Line3DCollection(seed, colors=pd["color"], linewidths=print_width, zorder=2) ax.add_collection3d(travel_col) ax.add_collection3d(print_col) # Nozzle marker: white fill with a black outline, drawn on top (high # zorder + computed_zorder disabled) so it stays visible against any # part color and the light background. noz = ax.scatter( [pd["last"][0]], [pd["last"][1]], [pd["last"][2]], color="white", edgecolors="black", linewidths=1.4, s=90, depthshade=False, zorder=10, ) artists.append((print_col, travel_col, noz)) def update(frame: int): cutoff = (frame / (n_frames - 1)) * total_length if progress_cb is not None: progress_cb(frame, n_frames) drawn = [] for (print_col, travel_col, noz), pd in zip(artists, pdata): print_col.set_segments(segs_at(pd["moves"], "print", cutoff)) travel_col.set_segments(segs_at(pd["moves"], "travel", cutoff)) nx, ny, nz = nozzle_at(pd["moves"], cutoff) noz._offsets3d = ([nx], [ny], [nz]) drawn += [print_col, travel_col, noz] return drawn anim = FuncAnimation(fig, update, frames=n_frames, blit=False) out_path = Path(out_path) anim.save(str(out_path), writer=PillowWriter(fps=int(fps))) plt.close(fig) return out_path 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