Feature boundary test lxy (#128)

* Add Boundary validator

* Boundary test ui

* refect & optimize boundary validation
This commit is contained in:
xiaoyeliu
2026-01-21 19:52:11 +08:00
committed by GitHub
parent 9cee21e0bf
commit a1769a2148
28 changed files with 6097 additions and 23 deletions

View File

@@ -0,0 +1,449 @@
#!/usr/bin/env python3
"""
G-code边界超限分析工具
Analyzes G-code files to find moves that exceed build volume boundaries.
用法 / Usage:
python analyze_gcode_bounds.py <gcode_file> [options]
示例 / Examples:
python analyze_gcode_bounds.py output.gcode --bed-size 200 200 250
python analyze_gcode_bounds.py output.gcode --bed-type circle --radius 100
"""
import re
import sys
import argparse
from enum import Enum
from dataclasses import dataclass
from typing import List, Tuple, Optional
import math
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 < Min"
X_MAX = "X > Max"
Y_MIN = "Y < Min"
Y_MAX = "Y > Max"
Z_MAX = "Z > Max"
RADIUS = "Radius > Max (Circle bed)"
@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 # 超出距离 (mm)
def __str__(self):
vio_str = ", ".join([v.value for v in self.violation_types])
return (f"Line {self.line_num}: {self.move_type.value} - {vio_str}\n"
f" Position: X={self.position.x:.3f} Y={self.position.y:.3f} "
f"Z={self.position.z:.3f} E={self.position.e:.3f}\n"
f" Out by: {self.distance_out:.3f} mm\n"
f" G-code: {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):
self.bed_type = bed_type
self.bed_min = bed_min
self.bed_max = bed_max
self.max_z = max_z
self.radius = radius # For circle bed
self.center = ((bed_max[0] + bed_min[0]) / 2,
(bed_max[1] + bed_min[1]) / 2) if bed_type == BedType.CIRCLE else None
self.current_pos = Position()
self.violations: List[Violation] = []
# 统计
self.total_moves = 0
self.travel_moves = 0
self.extrude_moves = 0
def parse_gcode_file(self, filename: str):
"""解析G-code文件"""
print(f"正在分析文件: {filename}")
print(f"床类型: {self.bed_type.value}")
if self.bed_type == BedType.RECTANGLE:
print(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:
print(f"床中心: ({self.center[0]:.1f}, {self.center[1]:.1f})")
print(f"床半径: {self.radius:.1f} mm, Z[0, {self.max_z:.1f}]")
print("=" * 70)
try:
with open(filename, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
self._parse_line(line_num, line)
except FileNotFoundError:
print(f"错误: 文件未找到 '{filename}'")
sys.exit(1)
except Exception as e:
print(f"错误: 读取文件时出错: {e}")
sys.exit(1)
def _parse_line(self, line_num: int, line: str):
"""解析单行G-code"""
# 移除注释
if ';' in line:
code_part = line[:line.index(';')]
comment = line[line.index(';'):]
else:
code_part = line
comment = ""
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"""
start_x = self.current_pos.x
start_y = self.current_pos.y
start_z = self.current_pos.z
i = float(i_match.group(1)) if i_match else 0.0
j = float(j_match.group(1)) if j_match else 0.0
center_x = start_x + i
center_y = start_y + j
radius = math.sqrt(i * i + j * j)
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 end_x is None and end_y is None:
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
start_angle = math.atan2(start_y - center_y, start_x - center_x)
end_angle = math.atan2(end_y - center_y, end_x - center_x)
if g_code == 2:
if end_angle > start_angle:
end_angle -= 2 * math.pi
angle_sweep = start_angle - end_angle
else:
if end_angle < start_angle:
end_angle += 2 * math.pi
angle_sweep = end_angle - start_angle
num_samples = max(8, int(abs(angle_sweep) * radius / 5))
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
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
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
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:
"""分类移动类型"""
# G0 通常是快速移动Travel
if code.startswith('G0'):
return MoveType.TRAVEL
# G1 可能是Travel或Extrude看E值
if code.startswith('G1'):
if abs(new_pos.e - old_pos.e) > 0.001: # 有挤出
return MoveType.EXTRUDE
else:
return MoveType.TRAVEL
# G2/G3 是弧线,通常是挤出
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)
# Z轴检查
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 print_report(self):
"""打印分析报告"""
print("\n" + "=" * 70)
print("分析报告 / Analysis Report")
print("=" * 70)
print(f"\n总移动数: {self.total_moves}")
print(f" - Travel移动: {self.travel_moves}")
print(f" - Extrude移动: {self.extrude_moves}")
print(f" - 其他: {self.total_moves - self.travel_moves - self.extrude_moves}")
print(f"\n发现超限: {len(self.violations)}")
if not self.violations:
print("\n✅ 所有移动都在边界内!")
return
# 按类型分组
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]
print(f" - Travel超限: {len(travel_violations)}")
print(f" - Extrude超限: {len(extrude_violations)}")
# 按超限类型统计
print("\n超限类型统计:")
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)
for vio_type, count in vio_counter.most_common():
print(f" {vio_type.value}: {count}")
# 详细列出超限
print("\n" + "=" * 70)
print("详细超限列表 (前50个):")
print("=" * 70)
for i, violation in enumerate(self.violations[:50], 1):
print(f"\n[{i}] {violation}")
if len(self.violations) > 50:
print(f"\n... 还有 {len(self.violations) - 50} 个超限未显示")
# 保存到文件
output_file = "gcode_violations.txt"
with open(output_file, 'w', encoding='utf-8') as f:
f.write("G-code边界超限详细报告\n")
f.write("=" * 70 + "\n\n")
for i, violation in enumerate(self.violations, 1):
f.write(f"[{i}] {violation}\n\n")
print(f"\n完整报告已保存到: {output_file}")
def main():
parser = argparse.ArgumentParser(
description='分析G-code文件的边界超限问题',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 矩形床 200x200x250mm
python %(prog)s output.gcode --bed-size 200 200 250
# 矩形床,指定原点偏移
python %(prog)s output.gcode --bed-size 200 200 250 --bed-origin 0 0
# 圆形床如Delta打印机
python %(prog)s output.gcode --bed-type circle --radius 100 --max-z 250
"""
)
parser.add_argument('gcode_file', help='G-code文件路径')
parser.add_argument('--bed-type', choices=['rectangle', 'circle'],
default='rectangle', help='床类型 (默认: rectangle)')
parser.add_argument('--bed-size', type=float, nargs=3, metavar=('X', 'Y', 'Z'),
help='床尺寸 X Y Z (mm), 例如: 200 200 250')
parser.add_argument('--bed-origin', type=float, nargs=2, metavar=('X', 'Y'),
default=(0, 0), help='床原点坐标 (默认: 0 0)')
parser.add_argument('--radius', type=float, help='圆形床半径 (mm)')
parser.add_argument('--max-z', type=float, help='最大Z高度 (mm)')
args = parser.parse_args()
# 解析床参数
bed_type = BedType(args.bed_type)
if bed_type == BedType.RECTANGLE:
if not args.bed_size:
print("错误: 矩形床需要指定 --bed-size")
sys.exit(1)
bed_min = (args.bed_origin[0], args.bed_origin[1])
bed_max = (args.bed_origin[0] + args.bed_size[0],
args.bed_origin[1] + args.bed_size[1])
max_z = args.bed_size[2]
radius = None
elif bed_type == BedType.CIRCLE:
if not args.radius or not args.max_z:
print("错误: 圆形床需要指定 --radius 和 --max-z")
sys.exit(1)
bed_min = (-args.radius, -args.radius)
bed_max = (args.radius, args.radius)
max_z = args.max_z
radius = args.radius
# 分析G-code
analyzer = GCodeAnalyzer(bed_type, bed_min, bed_max, max_z, radius)
analyzer.parse_gcode_file(args.gcode_file)
analyzer.print_report()
if __name__ == '__main__':
main()