#!/usr/bin/env python3 """ G-code边界超限检查工具 - GUI版本 G-code Boundary Violation Checker - GUI Version 带有图形界面的G-code边界检测工具 """ import tkinter as tk from tkinter import ttk, filedialog, messagebox, scrolledtext import re import math from enum import Enum from dataclasses import dataclass from typing import List, Tuple import threading from pathlib import Path class BedType(Enum): RECTANGLE = "矩形床 (Rectangle)" CIRCLE = "圆形床 (Circle)" class MoveType(Enum): TRAVEL = "Travel" EXTRUDE = "Extrude" ARC_CW = "Arc CW (G2)" # 顺时针弧线 ARC_CCW = "Arc CCW (G3)" # 逆时针弧线 RETRACT = "Retract" UNKNOWN = "Unknown" class ViolationType(Enum): X_MIN = "X < 最小值" X_MAX = "X > 最大值" Y_MIN = "Y < 最小值" Y_MAX = "Y > 最大值" Z_MAX = "Z > 最大值" RADIUS = "半径超限 (圆形床)" @dataclass class Position: x: float = 0.0 y: float = 0.0 z: float = 0.0 e: float = 0.0 def copy(self): return Position(self.x, self.y, self.z, self.e) @dataclass class Violation: line_num: int line_content: str position: Position move_type: MoveType violation_types: List[ViolationType] distance_out: float def __str__(self): vio_str = ", ".join([v.value for v in self.violation_types]) return (f"行 {self.line_num}: {self.move_type.value} - {vio_str}\n" f" 位置: X={self.position.x:.3f} Y={self.position.y:.3f} " f"Z={self.position.z:.3f} E={self.position.e:.3f}\n" f" 超出: {self.distance_out:.3f} mm\n" f" 代码: {self.line_content.strip()}") class GCodeAnalyzer: def __init__(self, bed_type: BedType, bed_min: Tuple[float, float], bed_max: Tuple[float, float], max_z: float, radius: float = None, progress_callback=None): self.bed_type = bed_type self.bed_min = bed_min self.bed_max = bed_max self.max_z = max_z self.radius = radius self.center = ((bed_max[0] + bed_min[0]) / 2, (bed_max[1] + bed_min[1]) / 2) if bed_type == BedType.CIRCLE else None self.progress_callback = progress_callback self.current_pos = Position() self.violations: List[Violation] = [] self.total_moves = 0 self.travel_moves = 0 self.extrude_moves = 0 self.total_lines = 0 def parse_gcode_file(self, filename: str): """解析G-code文件""" try: # 先计算总行数 with open(filename, 'r', encoding='utf-8') as f: self.total_lines = sum(1 for _ in f) # 解析文件 with open(filename, 'r', encoding='utf-8') as f: for line_num, line in enumerate(f, 1): self._parse_line(line_num, line) # 更新进度 if self.progress_callback and line_num % 100 == 0: progress = (line_num / self.total_lines) * 100 self.progress_callback(progress, line_num, self.total_lines) return True except Exception as e: return str(e) def _parse_line(self, line_num: int, line: str): """解析单行G-code""" if ';' in line: code_part = line[:line.index(';')] else: code_part = line code_part = code_part.strip().upper() if not code_part: return # Check for G0/G1/G2/G3 commands (using word boundary to avoid matching G28, G29, etc.) g_match = re.match(r'G([0-3])\b', code_part) if not g_match: return g_code = int(g_match.group(1)) # Parse coordinates x_match = re.search(r'X([-+]?\d*\.?\d+)', code_part) y_match = re.search(r'Y([-+]?\d*\.?\d+)', code_part) z_match = re.search(r'Z([-+]?\d*\.?\d+)', code_part) e_match = re.search(r'E([-+]?\d*\.?\d+)', code_part) i_match = re.search(r'I([-+]?\d*\.?\d+)', code_part) j_match = re.search(r'J([-+]?\d*\.?\d+)', code_part) # G2/G3 arc commands if g_code in [2, 3] and (i_match or j_match): self._parse_arc(line_num, line, code_part, g_code, x_match, y_match, z_match, e_match, i_match, j_match) return # G0/G1 linear moves new_pos = self.current_pos.copy() has_move = False has_xy_move = False if x_match: new_pos.x = float(x_match.group(1)) has_move = True has_xy_move = True if y_match: new_pos.y = float(y_match.group(1)) has_move = True has_xy_move = True if z_match: new_pos.z = float(z_match.group(1)) has_move = True if e_match: new_pos.e = float(e_match.group(1)) if not has_move: return move_type = self._classify_move(code_part, self.current_pos, new_pos) if move_type == MoveType.TRAVEL: self.travel_moves += 1 elif move_type == MoveType.EXTRUDE: self.extrude_moves += 1 self.total_moves += 1 # Only check XY bounds if X or Y actually moved if has_xy_move: violations = self._check_bounds(new_pos) if violations: distance = self._calculate_distance_out(new_pos) self.violations.append(Violation( line_num=line_num, line_content=line, position=new_pos.copy(), move_type=move_type, violation_types=violations, distance_out=distance )) self.current_pos = new_pos def _parse_arc(self, line_num: int, line: str, code_part: str, g_code: int, x_match, y_match, z_match, e_match, i_match, j_match): """Parse G2/G3 arc commands and check arc path for boundary violations""" # Current position is the start of the arc start_x = self.current_pos.x start_y = self.current_pos.y start_z = self.current_pos.z # Parse I, J (offsets from start to center) i = float(i_match.group(1)) if i_match else 0.0 j = float(j_match.group(1)) if j_match else 0.0 # Calculate arc center center_x = start_x + i center_y = start_y + j radius = math.sqrt(i * i + j * j) # Parse X, Y if present (end point) end_x = float(x_match.group(1)) if x_match else None end_y = float(y_match.group(1)) if y_match else None end_z = float(z_match.group(1)) if z_match else start_z e = float(e_match.group(1)) if e_match else self.current_pos.e # If no X/Y specified, do a full circle (360 degrees) if end_x is None and end_y is None: # For full circle, calculate end point as start point end_angle = math.atan2(start_y - center_y, start_x - center_x) + (2 * math.pi if g_code == 3 else -2 * math.pi) end_x = center_x + radius * math.cos(end_angle) end_y = center_y + radius * math.sin(end_angle) elif end_x is None: end_x = start_x elif end_y is None: end_y = start_y # Calculate start and end angles start_angle = math.atan2(start_y - center_y, start_x - center_x) end_angle = math.atan2(end_y - center_y, end_x - center_x) # Determine arc direction and angle sweep if g_code == 2: # Clockwise if end_angle > start_angle: end_angle -= 2 * math.pi angle_sweep = start_angle - end_angle else: # G3: Counter-clockwise if end_angle < start_angle: end_angle += 2 * math.pi angle_sweep = end_angle - start_angle # Sample points along the arc and check each num_samples = max(8, int(abs(angle_sweep) * radius / 5)) # At least 8 points, or 1 per 5mm of arc length move_type = MoveType.ARC_CCW if g_code == 3 else MoveType.ARC_CW if move_type == MoveType.ARC_CW: self.travel_moves += 1 else: self.extrude_moves += 1 self.total_moves += 1 # Check arc samples for n in range(num_samples + 1): t = n / num_samples angle = start_angle + (angle_sweep * t if g_code == 3 else -angle_sweep * t) sample_x = center_x + radius * math.cos(angle) sample_y = center_y + radius * math.sin(angle) sample_z = start_z + (end_z - start_z) * t # Interpolate Z # Check this point sample_pos = Position(sample_x, sample_y, sample_z, e) violations = self._check_bounds(sample_pos) if violations: distance = self._calculate_distance_out(sample_pos) self.violations.append(Violation( line_num=line_num, line_content=line, position=sample_pos, move_type=move_type, violation_types=violations, distance_out=distance )) break # Only record first violation on this arc # Update current position to arc end self.current_pos.x = end_x self.current_pos.y = end_y self.current_pos.z = end_z self.current_pos.e = e def _classify_move(self, code: str, old_pos: Position, new_pos: Position) -> MoveType: if code.startswith('G0'): return MoveType.TRAVEL if code.startswith('G1'): if abs(new_pos.e - old_pos.e) > 0.001: return MoveType.EXTRUDE else: return MoveType.TRAVEL if code.startswith('G2') or code.startswith('G3'): return MoveType.EXTRUDE return MoveType.UNKNOWN def _check_bounds(self, pos: Position) -> List[ViolationType]: violations = [] epsilon = 0.01 if self.bed_type == BedType.RECTANGLE: if pos.x < self.bed_min[0] - epsilon: violations.append(ViolationType.X_MIN) if pos.x > self.bed_max[0] + epsilon: violations.append(ViolationType.X_MAX) if pos.y < self.bed_min[1] - epsilon: violations.append(ViolationType.Y_MIN) if pos.y > self.bed_max[1] + epsilon: violations.append(ViolationType.Y_MAX) elif self.bed_type == BedType.CIRCLE: dist = math.sqrt((pos.x - self.center[0])**2 + (pos.y - self.center[1])**2) if dist > self.radius + epsilon: violations.append(ViolationType.RADIUS) if self.max_z > 0 and pos.z > self.max_z + epsilon: violations.append(ViolationType.Z_MAX) return violations def _calculate_distance_out(self, pos: Position) -> float: if self.bed_type == BedType.RECTANGLE: dx = max(0, self.bed_min[0] - pos.x, pos.x - self.bed_max[0]) dy = max(0, self.bed_min[1] - pos.y, pos.y - self.bed_max[1]) dz = max(0, pos.z - self.max_z) if self.max_z > 0 else 0 return math.sqrt(dx**2 + dy**2 + dz**2) elif self.bed_type == BedType.CIRCLE: dist = math.sqrt((pos.x - self.center[0])**2 + (pos.y - self.center[1])**2) return max(0, dist - self.radius) return 0.0 def get_report(self) -> str: """生成报告""" report = [] report.append("=" * 70) report.append("G-code边界超限分析报告") report.append("=" * 70) report.append("") # 床信息 if self.bed_type == BedType.RECTANGLE: report.append(f"床类型: 矩形") report.append(f"床边界: X[{self.bed_min[0]:.1f}, {self.bed_max[0]:.1f}] " f"Y[{self.bed_min[1]:.1f}, {self.bed_max[1]:.1f}] " f"Z[0, {self.max_z:.1f}]") else: report.append(f"床类型: 圆形") report.append(f"床中心: ({self.center[0]:.1f}, {self.center[1]:.1f})") report.append(f"床半径: {self.radius:.1f} mm, Z[0, {self.max_z:.1f}]") report.append("") report.append(f"总行数: {self.total_lines}") report.append(f"总移动数: {self.total_moves}") report.append(f" - Travel移动: {self.travel_moves}") report.append(f" - Extrude移动: {self.extrude_moves}") report.append("") # 超限统计 report.append(f"发现超限: {len(self.violations)} 处") if not self.violations: report.append("") report.append("✅ 所有移动都在边界内!") return "\n".join(report) travel_violations = [v for v in self.violations if v.move_type == MoveType.TRAVEL] extrude_violations = [v for v in self.violations if v.move_type == MoveType.EXTRUDE] report.append(f" - Travel超限: {len(travel_violations)}") report.append(f" - Extrude超限: {len(extrude_violations)}") report.append("") # 超限类型统计 from collections import Counter all_vio_types = [] for v in self.violations: all_vio_types.extend(v.violation_types) vio_counter = Counter(all_vio_types) report.append("超限类型统计:") for vio_type, count in vio_counter.most_common(): report.append(f" {vio_type.value}: {count} 次") report.append("") # 详细列表(前100个) report.append("=" * 70) report.append(f"详细超限列表 (前100个):") report.append("=" * 70) report.append("") for i, violation in enumerate(self.violations[:100], 1): report.append(f"[{i}] {violation}") report.append("") if len(self.violations) > 100: report.append(f"... 还有 {len(self.violations) - 100} 个超限未显示") return "\n".join(report) class GCodeBoundaryCheckerGUI: def __init__(self, root): self.root = root self.root.title("G-code边界超限检查工具") self.root.geometry("900x700") # 变量 self.gcode_file = tk.StringVar() self.bed_type = tk.StringVar(value="rectangle") self.bed_x = tk.DoubleVar(value=200.0) self.bed_y = tk.DoubleVar(value=200.0) self.bed_z = tk.DoubleVar(value=250.0) self.bed_radius = tk.DoubleVar(value=100.0) self.origin_x = tk.DoubleVar(value=0.0) self.origin_y = tk.DoubleVar(value=0.0) self.analyzer = None self.analyzing = False self.create_widgets() def create_widgets(self): # 主框架 main_frame = ttk.Frame(self.root, padding="10") main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) self.root.columnconfigure(0, weight=1) self.root.rowconfigure(0, weight=1) main_frame.columnconfigure(0, weight=1) main_frame.rowconfigure(4, weight=1) # 1. 文件选择 file_frame = ttk.LabelFrame(main_frame, text="1. 选择G-code文件", padding="10") file_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=5) file_frame.columnconfigure(1, weight=1) ttk.Entry(file_frame, textvariable=self.gcode_file, width=50).grid( row=0, column=0, sticky=(tk.W, tk.E), padx=5) ttk.Button(file_frame, text="浏览...", command=self.browse_file).grid( row=0, column=1, padx=5) # 2. 床参数 bed_frame = ttk.LabelFrame(main_frame, text="2. 床参数配置", padding="10") bed_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=5) # 床类型选择 type_frame = ttk.Frame(bed_frame) type_frame.grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=5) ttk.Label(type_frame, text="床类型:").pack(side=tk.LEFT, padx=5) ttk.Radiobutton(type_frame, text="矩形床", variable=self.bed_type, value="rectangle", command=self.update_bed_type).pack(side=tk.LEFT, padx=5) ttk.Radiobutton(type_frame, text="圆形床 (Delta)", variable=self.bed_type, value="circle", command=self.update_bed_type).pack(side=tk.LEFT, padx=5) # 矩形床参数 self.rect_frame = ttk.Frame(bed_frame) self.rect_frame.grid(row=1, column=0, columnspan=3, sticky=tk.W, pady=5) ttk.Label(self.rect_frame, text="尺寸 (mm):").grid(row=0, column=0, padx=5) ttk.Label(self.rect_frame, text="X:").grid(row=0, column=1) ttk.Entry(self.rect_frame, textvariable=self.bed_x, width=10).grid(row=0, column=2, padx=2) ttk.Label(self.rect_frame, text="Y:").grid(row=0, column=3) ttk.Entry(self.rect_frame, textvariable=self.bed_y, width=10).grid(row=0, column=4, padx=2) ttk.Label(self.rect_frame, text="Z:").grid(row=0, column=5) ttk.Entry(self.rect_frame, textvariable=self.bed_z, width=10).grid(row=0, column=6, padx=2) ttk.Label(self.rect_frame, text="原点:").grid(row=1, column=0, padx=5, pady=5) ttk.Label(self.rect_frame, text="X:").grid(row=1, column=1) ttk.Entry(self.rect_frame, textvariable=self.origin_x, width=10).grid(row=1, column=2, padx=2) ttk.Label(self.rect_frame, text="Y:").grid(row=1, column=3) ttk.Entry(self.rect_frame, textvariable=self.origin_y, width=10).grid(row=1, column=4, padx=2) # 圆形床参数 self.circle_frame = ttk.Frame(bed_frame) self.circle_frame.grid(row=1, column=0, columnspan=3, sticky=tk.W, pady=5) ttk.Label(self.circle_frame, text="半径 (mm):").grid(row=0, column=0, padx=5) ttk.Entry(self.circle_frame, textvariable=self.bed_radius, width=10).grid(row=0, column=1, padx=2) ttk.Label(self.circle_frame, text="Z高度:").grid(row=0, column=2, padx=5) ttk.Entry(self.circle_frame, textvariable=self.bed_z, width=10).grid(row=0, column=3, padx=2) self.circle_frame.grid_remove() # 初始隐藏 # 快速预设 preset_frame = ttk.Frame(bed_frame) preset_frame.grid(row=2, column=0, columnspan=3, sticky=tk.W, pady=5) ttk.Label(preset_frame, text="快速预设:").pack(side=tk.LEFT, padx=5) ttk.Button(preset_frame, text="200×200×250", command=lambda: self.apply_preset(200, 200, 250)).pack(side=tk.LEFT, padx=2) ttk.Button(preset_frame, text="220×220×250", command=lambda: self.apply_preset(220, 220, 250)).pack(side=tk.LEFT, padx=2) ttk.Button(preset_frame, text="250×250×300", command=lambda: self.apply_preset(250, 250, 300)).pack(side=tk.LEFT, padx=2) ttk.Button(preset_frame, text="300×300×400", command=lambda: self.apply_preset(300, 300, 400)).pack(side=tk.LEFT, padx=2) # 3. 控制按钮 control_frame = ttk.Frame(main_frame) control_frame.grid(row=2, column=0, pady=10) self.analyze_btn = ttk.Button(control_frame, text="开始分析", command=self.start_analysis, width=15) self.analyze_btn.pack(side=tk.LEFT, padx=5) self.save_btn = ttk.Button(control_frame, text="保存报告", command=self.save_report, width=15, state=tk.DISABLED) self.save_btn.pack(side=tk.LEFT, padx=5) ttk.Button(control_frame, text="清除结果", command=self.clear_results, width=15).pack(side=tk.LEFT, padx=5) # 4. 进度条 self.progress_var = tk.DoubleVar() self.progress_label = ttk.Label(main_frame, text="") self.progress_label.grid(row=3, column=0, sticky=tk.W) self.progress_bar = ttk.Progressbar(main_frame, variable=self.progress_var, maximum=100, mode='determinate') self.progress_bar.grid(row=3, column=0, sticky=(tk.W, tk.E), pady=5) # 5. 结果显示 result_frame = ttk.LabelFrame(main_frame, text="分析结果", padding="10") result_frame.grid(row=4, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) result_frame.columnconfigure(0, weight=1) result_frame.rowconfigure(0, weight=1) self.result_text = scrolledtext.ScrolledText(result_frame, width=80, height=20, font=('Courier New', 9)) self.result_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) def browse_file(self): filename = filedialog.askopenfilename( title="选择G-code文件", filetypes=[("G-code文件", "*.gcode *.GCODE *.gco *.GCO"), ("所有文件", "*.*")] ) if filename: self.gcode_file.set(filename) def update_bed_type(self): if self.bed_type.get() == "rectangle": self.rect_frame.grid() self.circle_frame.grid_remove() else: self.rect_frame.grid_remove() self.circle_frame.grid() def apply_preset(self, x, y, z): self.bed_x.set(x) self.bed_y.set(y) self.bed_z.set(z) self.bed_type.set("rectangle") self.update_bed_type() def update_progress(self, progress, current, total): self.progress_var.set(progress) self.progress_label.config(text=f"正在分析... {current}/{total} 行 ({progress:.1f}%)") def start_analysis(self): # 验证输入 if not self.gcode_file.get(): messagebox.showerror("错误", "请选择G-code文件") return if not Path(self.gcode_file.get()).exists(): messagebox.showerror("错误", "文件不存在") return # 禁用按钮 self.analyze_btn.config(state=tk.DISABLED) self.save_btn.config(state=tk.DISABLED) self.result_text.delete(1.0, tk.END) self.progress_var.set(0) # 在后台线程中分析 thread = threading.Thread(target=self.run_analysis) thread.daemon = True thread.start() def run_analysis(self): try: # 准备参数 if self.bed_type.get() == "rectangle": bed_type = BedType.RECTANGLE bed_min = (self.origin_x.get(), self.origin_y.get()) bed_max = (self.origin_x.get() + self.bed_x.get(), self.origin_y.get() + self.bed_y.get()) max_z = self.bed_z.get() radius = None else: bed_type = BedType.CIRCLE radius = self.bed_radius.get() bed_min = (-radius, -radius) bed_max = (radius, radius) max_z = self.bed_z.get() # 创建分析器 self.analyzer = GCodeAnalyzer(bed_type, bed_min, bed_max, max_z, radius, progress_callback=self.update_progress) # 分析文件 result = self.analyzer.parse_gcode_file(self.gcode_file.get()) if result is not True: self.root.after(0, lambda: messagebox.showerror("错误", f"分析失败: {result}")) self.root.after(0, lambda: self.analyze_btn.config(state=tk.NORMAL)) return # 生成报告 report = self.analyzer.get_report() # 更新UI self.root.after(0, lambda: self.display_results(report)) except Exception as e: self.root.after(0, lambda: messagebox.showerror("错误", f"分析出错: {str(e)}")) self.root.after(0, lambda: self.analyze_btn.config(state=tk.NORMAL)) def display_results(self, report): self.result_text.delete(1.0, tk.END) self.result_text.insert(1.0, report) # 高亮显示 if "✅" in report: self.result_text.tag_config("success", foreground="green", font=('Courier New', 9, 'bold')) start = self.result_text.search("✅", 1.0, tk.END) if start: end = f"{start}+1c" self.result_text.tag_add("success", start, end) self.progress_label.config(text="分析完成!") self.progress_var.set(100) self.analyze_btn.config(state=tk.NORMAL) self.save_btn.config(state=tk.NORMAL) # 如果有超限,弹出提示 if self.analyzer and len(self.analyzer.violations) > 0: messagebox.showwarning("发现超限", f"发现 {len(self.analyzer.violations)} 处边界超限!\n" f"请查看详细报告。") else: messagebox.showinfo("检查完成", "✅ 所有移动都在边界内!") def save_report(self): if not self.analyzer: return filename = filedialog.asksaveasfilename( title="保存报告", defaultextension=".txt", filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")], initialfile="gcode_boundary_report.txt" ) if filename: try: with open(filename, 'w', encoding='utf-8') as f: f.write(self.result_text.get(1.0, tk.END)) messagebox.showinfo("保存成功", f"报告已保存到:\n{filename}") except Exception as e: messagebox.showerror("保存失败", f"保存出错: {str(e)}") def clear_results(self): self.result_text.delete(1.0, tk.END) self.progress_var.set(0) self.progress_label.config(text="") self.analyzer = None self.save_btn.config(state=tk.DISABLED) def main(): root = tk.Tk() app = GCodeBoundaryCheckerGUI(root) root.mainloop() if __name__ == '__main__': main()