Spaces:
Running
Running
| 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(<valve>, <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}<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( | |
| 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 | |