|
|
import re |
|
|
from decimal import Decimal, getcontext |
|
|
import decimal |
|
|
|
|
|
|
|
|
interpolation_commands = {"G01", "G02", "G03"} |
|
|
movement_commands = {"G00"} |
|
|
|
|
|
|
|
|
gcode_pattern = re.compile( |
|
|
r"(G\d+|M\d+|X[-+]?\d*\.?\d+|Y[-+]?\d*\.?\d+|" |
|
|
r"Z[-+]?\d*\.?\d+|I[-+]?\d*\.?\d+|J[-+]?\d*\.?\d+|" |
|
|
r"F[-+]?\d*\.?\d+|S[-+]?\d*\.?\d+)" |
|
|
) |
|
|
|
|
|
def standardize_codes(line): |
|
|
""" |
|
|
Standardizes M-codes and G-codes to two digits by adding a leading zero if necessary. |
|
|
""" |
|
|
line = re.sub(r"\b(M|G)(\d)\b", r"\g<1>0\2", line) |
|
|
return line |
|
|
|
|
|
def remove_comments(line): |
|
|
""" |
|
|
Removes comments from a G-code line. Supports both ';' and '()' style comments. |
|
|
""" |
|
|
|
|
|
line = line.split(';')[0] |
|
|
|
|
|
line = re.sub(r'\(.*?\)', '', line) |
|
|
return line.strip() |
|
|
|
|
|
def preprocess_gcode(gcode): |
|
|
""" |
|
|
Removes comments from the G-code and returns a list of tuples (original_line_number, cleaned_line). |
|
|
Includes all lines to maintain accurate line numbering. |
|
|
""" |
|
|
cleaned_lines = [] |
|
|
lines = gcode.splitlines() |
|
|
|
|
|
for idx, line in enumerate(lines): |
|
|
original_line_number = idx + 1 |
|
|
line = standardize_codes(line.strip()) |
|
|
|
|
|
line_no_comments = remove_comments(line) |
|
|
|
|
|
cleaned_lines.append((original_line_number, line_no_comments)) |
|
|
|
|
|
return cleaned_lines |
|
|
|
|
|
def check_required_gcodes(lines_with_numbers): |
|
|
""" |
|
|
Checks that the G-code contains required G-codes: G20/G21, G90/G91, G54-G59, and G17. |
|
|
Returns a list of errors with individual entries for each missing group. |
|
|
""" |
|
|
required_groups = { |
|
|
"units": {"G20", "G21"}, |
|
|
"mode": {"G90", "G91"}, |
|
|
"work_coordinates": {"G54", "G55", "G56", "G57", "G58", "G59"}, |
|
|
"plane": {"G17", "G18", "G19"}, |
|
|
} |
|
|
|
|
|
|
|
|
found_codes = {} |
|
|
for original_line_number, line in lines_with_numbers: |
|
|
tokens = line.split() |
|
|
for token in tokens: |
|
|
found_codes.setdefault(token, original_line_number) |
|
|
|
|
|
|
|
|
missing_group_errors = [] |
|
|
|
|
|
|
|
|
for category, codes in required_groups.items(): |
|
|
|
|
|
found = any(code in found_codes for code in codes) |
|
|
if not found: |
|
|
missing_codes = "/".join(sorted(codes)) |
|
|
|
|
|
for original_line_number, line in lines_with_numbers: |
|
|
if gcode_pattern.search(line): |
|
|
missing_group_errors.append((original_line_number, f"(Error) Missing required G-codes: ({category}) {missing_codes}")) |
|
|
break |
|
|
else: |
|
|
|
|
|
missing_group_errors.append((1, f"(Error) Missing required G-codes: ({category}) {missing_codes}")) |
|
|
|
|
|
return missing_group_errors |
|
|
|
|
|
def check_required_gcodes_position(lines_with_numbers): |
|
|
""" |
|
|
Ensures required G-codes appear before movement commands. |
|
|
Flags changes in critical settings (e.g., units) after movement commands. |
|
|
""" |
|
|
issues = [] |
|
|
movement_seen = False |
|
|
required_groups = { |
|
|
"units": {"G20", "G21"}, |
|
|
"mode": {"G90", "G91"}, |
|
|
"work_coordinates": {"G54", "G55", "G56", "G57", "G58", "G59"}, |
|
|
"plane": {"G17", "G18", "G19"}, |
|
|
} |
|
|
critical_gcodes = { |
|
|
"units": {"G20", "G21"}, |
|
|
"plane": {"G17", "G18", "G19"}, |
|
|
} |
|
|
|
|
|
|
|
|
codes_before_movement = set() |
|
|
|
|
|
for original_line_number, line in lines_with_numbers: |
|
|
tokens = line.split() |
|
|
|
|
|
|
|
|
if not movement_seen and any(cmd in tokens for cmd in {"G00", "G01", "G02", "G03"}): |
|
|
movement_seen = True |
|
|
|
|
|
if not movement_seen: |
|
|
|
|
|
codes_before_movement.update(tokens) |
|
|
else: |
|
|
|
|
|
for token in tokens: |
|
|
for category, codes in critical_gcodes.items(): |
|
|
if token in codes: |
|
|
issues.append((original_line_number, f"(Warning) {token} appears after movement commands. Ensure this change is intentional -> {line.strip()}")) |
|
|
|
|
|
|
|
|
missing_groups = [] |
|
|
for category, codes in required_groups.items(): |
|
|
if not any(code in codes_before_movement for code in codes): |
|
|
missing_codes = "/".join(sorted(codes)) |
|
|
missing_groups.append(f"({category}) {missing_codes}") |
|
|
|
|
|
if missing_groups: |
|
|
first_movement_line = next( |
|
|
(line_num for line_num, line in lines_with_numbers if any(cmd in line for cmd in {"G00", "G01", "G02", "G03"})), |
|
|
1 |
|
|
) |
|
|
issues.append((first_movement_line, f"(Error) Missing required G-codes before first movement: {', '.join(missing_groups)}")) |
|
|
|
|
|
return issues |
|
|
|
|
|
def check_end_gcode(lines_with_numbers): |
|
|
""" |
|
|
Checks that M30 is the last G-code command. |
|
|
Allows blank lines or '%' symbols after M30. |
|
|
""" |
|
|
found_m30 = False |
|
|
|
|
|
|
|
|
errors = [] |
|
|
|
|
|
for idx, (original_line_number, line) in enumerate(lines_with_numbers): |
|
|
if not line.strip() or line.strip() == "%": |
|
|
continue |
|
|
|
|
|
if "M30" in line: |
|
|
if found_m30: |
|
|
errors.append((original_line_number, "(Error) M30 must be the last G-code command in the G-code.")) |
|
|
found_m30 = True |
|
|
continue |
|
|
|
|
|
|
|
|
if found_m30 and gcode_pattern.search(line): |
|
|
errors.append((original_line_number, f"(Error) No G-code commands should appear after M30. Found '{line.strip()}'.")) |
|
|
|
|
|
if not found_m30: |
|
|
if lines_with_numbers: |
|
|
last_line_number = lines_with_numbers[-1][0] |
|
|
else: |
|
|
last_line_number = 1 |
|
|
errors.append((last_line_number, "(Error) M30 is missing from the G-code.")) |
|
|
|
|
|
return errors |
|
|
|
|
|
def check_spindle(lines_with_numbers): |
|
|
""" |
|
|
Checks spindle-related issues in the G-code. |
|
|
""" |
|
|
issues = [] |
|
|
spindle_on = False |
|
|
spindle_started = False |
|
|
|
|
|
for idx, (original_line_number, line) in enumerate(lines_with_numbers): |
|
|
|
|
|
if not line.strip() or line.strip() == "%": |
|
|
continue |
|
|
|
|
|
tokens = line.split() |
|
|
|
|
|
|
|
|
if not gcode_pattern.search(line): |
|
|
issues.append((original_line_number, f"(Error) Invalid G-code command or syntax error -> {line.strip()}")) |
|
|
|
|
|
|
|
|
if "M03" in tokens or "M04" in tokens: |
|
|
|
|
|
if spindle_on: |
|
|
issues.append((original_line_number, "(Warning) Spindle is already on.")) |
|
|
|
|
|
|
|
|
s_value_present = any(token.startswith("S") for token in tokens) |
|
|
if not s_value_present: |
|
|
issues.append((original_line_number, "(Error) Spindle speed (S value) is missing when turning on the spindle with M03/M04.")) |
|
|
|
|
|
spindle_on = True |
|
|
spindle_started = True |
|
|
|
|
|
|
|
|
if "M05" in tokens: |
|
|
spindle_on = False |
|
|
|
|
|
|
|
|
if any(cmd in tokens for cmd in interpolation_commands): |
|
|
if not spindle_on: |
|
|
issues.append((original_line_number, f"(Error) Move command without spindle on -> {line.strip()}")) |
|
|
|
|
|
|
|
|
if spindle_on: |
|
|
last_line_number = lines_with_numbers[-1][0] |
|
|
issues.append((last_line_number, "(Error) Spindle was not turned off (M05) before the end of the program.")) |
|
|
|
|
|
|
|
|
if not spindle_started: |
|
|
issues.append((0, "(Error) Spindle was never turned on in the G-code.")) |
|
|
|
|
|
return issues |
|
|
|
|
|
def check_feed_rate(lines_with_numbers): |
|
|
""" |
|
|
Checks feed rate related issues in the G-code. |
|
|
""" |
|
|
issues = [] |
|
|
last_feed_rate = None |
|
|
interpolation_command_seen = False |
|
|
|
|
|
for idx, (original_line_number, line) in enumerate(lines_with_numbers): |
|
|
|
|
|
if not line.strip() or line.strip() == "%": |
|
|
continue |
|
|
|
|
|
tokens = line.split() |
|
|
commands = set(tokens) |
|
|
feed_rates = [token for token in tokens if token.startswith("F")] |
|
|
|
|
|
|
|
|
if feed_rates and not any(cmd in interpolation_commands for cmd in commands): |
|
|
issues.append((original_line_number, f"(Warning) Feed rate specified without interpolation command -> {line.strip()}")) |
|
|
|
|
|
|
|
|
if any(cmd in commands for cmd in interpolation_commands): |
|
|
if not interpolation_command_seen: |
|
|
interpolation_command_seen = True |
|
|
if not feed_rates and last_feed_rate is None: |
|
|
issues.append((original_line_number, f"(Error) First interpolation command must have a feed rate -> {line.strip()}")) |
|
|
else: |
|
|
|
|
|
if feed_rates: |
|
|
last_feed_rate = feed_rates[-1] |
|
|
else: |
|
|
|
|
|
if feed_rates: |
|
|
current_feed_rate = feed_rates[-1] |
|
|
if current_feed_rate == last_feed_rate: |
|
|
issues.append((original_line_number, f"(Warning) Feed rate {current_feed_rate} is already set; no need to specify again.")) |
|
|
else: |
|
|
last_feed_rate = current_feed_rate |
|
|
|
|
|
return issues |
|
|
|
|
|
def check_depth_of_cut(lines_with_numbers, depth_max=0.1): |
|
|
""" |
|
|
Checks that all cutting moves on the Z-axis have a uniform depth and do not exceed the maximum depth. |
|
|
""" |
|
|
getcontext().prec = 6 |
|
|
depth_max = Decimal(str(depth_max)) |
|
|
issues = [] |
|
|
|
|
|
positioning_mode = "G90" |
|
|
current_z = Decimal('0.0') |
|
|
depths = set() |
|
|
z_negative_seen = False |
|
|
|
|
|
for idx, (original_line_number, line) in enumerate(lines_with_numbers): |
|
|
|
|
|
if not line.strip() or line.strip() == "%": |
|
|
continue |
|
|
|
|
|
tokens = line.split() |
|
|
|
|
|
if "G90" in tokens: |
|
|
positioning_mode = "G90" |
|
|
elif "G91" in tokens: |
|
|
positioning_mode = "G91" |
|
|
|
|
|
if any(cmd in tokens for cmd in interpolation_commands.union(movement_commands)): |
|
|
z_values = [token for token in tokens if token.startswith("Z")] |
|
|
if z_values: |
|
|
try: |
|
|
z_value = Decimal(z_values[-1][1:]) |
|
|
except (ValueError, decimal.InvalidOperation): |
|
|
issues.append((original_line_number, f"(Error) Invalid Z value -> {line.strip()}")) |
|
|
continue |
|
|
|
|
|
if positioning_mode == "G90": |
|
|
new_z = z_value |
|
|
elif positioning_mode == "G91": |
|
|
new_z = current_z + z_value |
|
|
|
|
|
if new_z < Decimal('0.0'): |
|
|
z_negative_seen = True |
|
|
depth = abs(new_z) |
|
|
depth = depth.quantize(Decimal('0.0001')).normalize() |
|
|
depths.add(depth) |
|
|
|
|
|
if depth > depth_max: |
|
|
issues.append((original_line_number, f"(Error) Depth of cut {depth} exceeds maximum allowed depth of {depth_max.normalize()} -> {line.strip()}")) |
|
|
|
|
|
current_z = new_z |
|
|
|
|
|
if z_negative_seen: |
|
|
if len(depths) > 1: |
|
|
depth_values = ', '.join(str(d.normalize()) for d in sorted(depths)) |
|
|
issues.append((0, f"(Warning) Inconsistent depths of cut detected: {depth_values}")) |
|
|
else: |
|
|
issues.append((0, "(Error) No cutting moves detected on the Z-axis.")) |
|
|
|
|
|
return issues |
|
|
|
|
|
def check_interpolation_depth(lines_with_numbers): |
|
|
""" |
|
|
Checks that all interpolation commands moving in X or Y are executed at a negative Z depth (i.e., cutting). |
|
|
Does not report errors for interpolation commands used for plunging or retracting (Z-axis movements only). |
|
|
""" |
|
|
getcontext().prec = 6 |
|
|
issues = [] |
|
|
|
|
|
positioning_mode = "G90" |
|
|
current_z = Decimal('0.0') |
|
|
|
|
|
for idx, (original_line_number, line) in enumerate(lines_with_numbers): |
|
|
|
|
|
if not line.strip() or line.strip() == "%": |
|
|
continue |
|
|
|
|
|
tokens = line.split() |
|
|
|
|
|
|
|
|
if "G90" in tokens: |
|
|
positioning_mode = "G90" |
|
|
elif "G91" in tokens: |
|
|
positioning_mode = "G91" |
|
|
|
|
|
|
|
|
z_values = [token for token in tokens if token.startswith("Z")] |
|
|
if z_values: |
|
|
try: |
|
|
z_value = Decimal(z_values[-1][1:]) |
|
|
except (ValueError, decimal.InvalidOperation): |
|
|
issues.append((original_line_number, f"(Error) Invalid Z value -> {line.strip()}")) |
|
|
continue |
|
|
|
|
|
|
|
|
if positioning_mode == "G90": |
|
|
current_z = z_value |
|
|
elif positioning_mode == "G91": |
|
|
current_z += z_value |
|
|
|
|
|
|
|
|
if any(cmd in tokens for cmd in interpolation_commands): |
|
|
|
|
|
has_xy_movement = any(token.startswith(('X', 'Y')) for token in tokens) |
|
|
if has_xy_movement and current_z >= Decimal('0.0'): |
|
|
issues.append((original_line_number, f"(Warning) Interpolation command with XY movement executed without cutting depth (Z={current_z}) -> {line.strip()}")) |
|
|
|
|
|
return issues |
|
|
|
|
|
def check_plunge_retract_moves(lines_with_numbers): |
|
|
""" |
|
|
Checks that plunging and retracting moves along the Z-axis use G01 instead of G00. |
|
|
Reports an error if G00 is used for Z-axis movements to Z positions less than or equal to zero. |
|
|
""" |
|
|
issues = [] |
|
|
positioning_mode = "G90" |
|
|
current_z = None |
|
|
|
|
|
for idx, (original_line_number, line) in enumerate(lines_with_numbers): |
|
|
|
|
|
if not line.strip() or line.strip() == "%": |
|
|
continue |
|
|
|
|
|
tokens = line.split() |
|
|
|
|
|
|
|
|
if "G90" in tokens: |
|
|
positioning_mode = "G90" |
|
|
elif "G91" in tokens: |
|
|
positioning_mode = "G91" |
|
|
|
|
|
|
|
|
z_values = [token for token in tokens if token.startswith("Z")] |
|
|
if z_values: |
|
|
try: |
|
|
z_value = Decimal(z_values[-1][1:]) |
|
|
except (ValueError, decimal.InvalidOperation): |
|
|
issues.append((original_line_number, f"(Error) Invalid Z value -> {line.strip()}")) |
|
|
continue |
|
|
|
|
|
|
|
|
if current_z is None: |
|
|
current_z = z_value |
|
|
else: |
|
|
if positioning_mode == "G90": |
|
|
current_z = z_value |
|
|
elif positioning_mode == "G91": |
|
|
current_z += z_value |
|
|
|
|
|
|
|
|
|
|
|
if "G00" in tokens and current_z <= Decimal('0.0'): |
|
|
issues.append((original_line_number, f"(Error) G00 used for plunging to Z={current_z}. Use G01 to safely approach the workpiece -> {line.strip()}")) |
|
|
|
|
|
return issues |
|
|
|
|
|
def run_checks(gcode, depth_max=0.1): |
|
|
""" |
|
|
Runs all checks and returns a tuple containing lists of errors and warnings. |
|
|
""" |
|
|
errors = [] |
|
|
warnings = [] |
|
|
|
|
|
|
|
|
lines_with_numbers = preprocess_gcode(gcode) |
|
|
|
|
|
|
|
|
required_gcode_issues = check_required_gcodes(lines_with_numbers) |
|
|
required_gcode_position_issues = check_required_gcodes_position(lines_with_numbers) |
|
|
spindle_issues = check_spindle(lines_with_numbers) |
|
|
feed_rate_issues = check_feed_rate(lines_with_numbers) |
|
|
depth_issues = check_depth_of_cut(lines_with_numbers, depth_max) |
|
|
end_gcode_issues = check_end_gcode(lines_with_numbers) |
|
|
interpolation_depth_issues = check_interpolation_depth(lines_with_numbers) |
|
|
plunge_retract_issues = check_plunge_retract_moves(lines_with_numbers) |
|
|
|
|
|
|
|
|
all_issues = ( |
|
|
required_gcode_issues |
|
|
+ required_gcode_position_issues |
|
|
+ spindle_issues |
|
|
+ feed_rate_issues |
|
|
+ depth_issues |
|
|
+ end_gcode_issues |
|
|
+ interpolation_depth_issues |
|
|
+ plunge_retract_issues |
|
|
) |
|
|
|
|
|
|
|
|
for line_num, message in all_issues: |
|
|
if "(Error)" in message: |
|
|
errors.append((line_num, message)) |
|
|
elif "(Warning)" in message: |
|
|
warnings.append((line_num, message)) |
|
|
|
|
|
|
|
|
errors.sort(key=lambda x: x[0]) |
|
|
warnings.sort(key=lambda x: x[0]) |
|
|
|
|
|
return errors, warnings |
|
|
|
|
|
if __name__ == "__main__": |
|
|
|
|
|
gcode_sample = """ |
|
|
% |
|
|
G21 G90 G17 G54 |
|
|
G00 X0 Y0 Z5.0 |
|
|
M03 S1000 |
|
|
G01 Z-0.1 F100 ; Plunge using rapid movement (should be G01) |
|
|
G54 |
|
|
G01 Z-0.1 |
|
|
G01 X10 Y10 |
|
|
G01 X20 Y20 |
|
|
G00 Z5.0 ; Retract using rapid movement (allowed since Z > 0) |
|
|
M05 |
|
|
M30 |
|
|
% |
|
|
""" |
|
|
|
|
|
depth_max = 0.1 |
|
|
errors, warnings = run_checks(gcode_sample, depth_max) |
|
|
|
|
|
|
|
|
output_lines = [] |
|
|
if errors or warnings: |
|
|
output_lines.append("Issues found in G-code:") |
|
|
for line_num, message in errors + warnings: |
|
|
if line_num > 0: |
|
|
output_lines.append(f"Line {line_num}: {message}") |
|
|
else: |
|
|
output_lines.append(message) |
|
|
print('\n'.join(output_lines)) |
|
|
else: |
|
|
print("Your G-code looks good!") |