File size: 19,427 Bytes
0664784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c77aacd
 
 
 
0664784
 
 
 
 
 
 
 
 
 
 
 
 
 
c77aacd
0664784
 
 
 
 
 
 
 
 
 
 
 
 
 
c77aacd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0664784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c77aacd
0664784
c77aacd
0664784
 
 
 
 
 
 
 
 
 
c77aacd
0664784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c77aacd
0664784
 
 
c77aacd
 
0664784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
import re
from decimal import Decimal, getcontext
import decimal

# Define interpolation and movement commands
interpolation_commands = {"G01", "G02", "G03"}
movement_commands = {"G00"}

# Define a pattern to recognize common G-code commands
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.
    """
    # Remove anything after a ';'
    line = line.split(';')[0]
    # Remove anything inside parentheses '()'
    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 numbers start from 1
        line = standardize_codes(line.strip())
        # Remove comments
        line_no_comments = remove_comments(line)
        # Include all lines to maintain accurate line numbering
        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"},  # Metric or Imperial Units
        "mode": {"G90", "G91"},  # Absolute or Incremental Mode
        "work_coordinates": {"G54", "G55", "G56", "G57", "G58", "G59"},  # Work Offsets
        "plane": {"G17", "G18", "G19"},  # Selected Plane
    }

    # Create a set to track found codes and their line numbers
    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)  # Record the line number where the code was found

    # List to hold individual errors for each missing group
    missing_group_errors = []
    
    # Check for presence of required codes
    for category, codes in required_groups.items():
        # Only flag as missing if both options in a group are absent
        found = any(code in found_codes for code in codes)
        if not found:
            missing_codes = "/".join(sorted(codes))
            # Assume missing codes should be on the first line where G-codes start
            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:
                # Default to line 1 if no G-code commands are found
                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"},
    }

    # Track codes found before movement commands
    codes_before_movement = set()

    for original_line_number, line in lines_with_numbers:
        tokens = line.split()

        # Check if movement commands are encountered
        if not movement_seen and any(cmd in tokens for cmd in {"G00", "G01", "G02", "G03"}):
            movement_seen = True

        if not movement_seen:
            # Collect required G-codes found before movement
            codes_before_movement.update(tokens)
        else:
            # After movement commands have been seen, check for critical G-codes
            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()}"))

    # Check for missing required G-codes before movement commands
    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

    # Collect errors with line numbers
    errors = []

    for idx, (original_line_number, line) in enumerate(lines_with_numbers):
        if not line.strip() or line.strip() == "%":
            continue  # Skip empty lines or lines with only '%'

        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  # Continue to check if any G-code commands appear after M30

        # After M30, no other G-code commands should appear
        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):
        # Skip processing lines that are empty or contain only '%'
        if not line.strip() or line.strip() == "%":
            continue

        tokens = line.split()

        # Check for valid G-code commands
        if not gcode_pattern.search(line):
            issues.append((original_line_number, f"(Error) Invalid G-code command or syntax error -> {line.strip()}"))

        # Check for spindle on
        if "M03" in tokens or "M04" in tokens:
            # Check if spindle is already on
            if spindle_on:
                issues.append((original_line_number, "(Warning) Spindle is already on."))

            # Check if spindle speed is specified with 'S' command
            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

        # Check for spindle off
        if "M05" in tokens:
            spindle_on = False

        # Check if movement commands are given without spindle on
        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()}"))

    # Check if spindle was turned off before M30
    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."))

    # Check if spindle was never turned on
    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):
        # Skip processing lines that are empty or contain only '%'
        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")]

        # Check if feed rate is beside non-interpolation commands
        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()}"))

        # Check for interpolation commands
        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:
                    # Set initial feed rate
                    if feed_rates:
                        last_feed_rate = feed_rates[-1]
            else:
                # Check if feed rate is specified
                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  # Set precision as needed
    depth_max = Decimal(str(depth_max))
    issues = []

    positioning_mode = "G90"  # Default to absolute positioning
    current_z = Decimal('0.0')
    depths = set()
    z_negative_seen = False

    for idx, (original_line_number, line) in enumerate(lines_with_numbers):
        # Skip processing lines that are empty or contain only '%'
        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()  # Round and remove trailing zeros
                    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  # Set precision as needed
    issues = []

    positioning_mode = "G90"  # Default to absolute positioning
    current_z = Decimal('0.0')

    for idx, (original_line_number, line) in enumerate(lines_with_numbers):
        # Skip processing lines that are empty or contain only '%'
        if not line.strip() or line.strip() == "%":
            continue

        tokens = line.split()

        # Update positioning mode if G90 or G91 is found
        if "G90" in tokens:
            positioning_mode = "G90"
        elif "G91" in tokens:
            positioning_mode = "G91"

        # Check for Z-axis movement
        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

            # Calculate the new Z position based on positioning mode
            if positioning_mode == "G90":
                current_z = z_value
            elif positioning_mode == "G91":
                current_z += z_value

        # Check for interpolation commands
        if any(cmd in tokens for cmd in interpolation_commands):
            # Check if the command includes X or Y movement
            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"  # Default to absolute positioning
    current_z = None  # Keep track of the current Z position

    for idx, (original_line_number, line) in enumerate(lines_with_numbers):
        # Skip processing lines that are empty or contain only '%'
        if not line.strip() or line.strip() == "%":
            continue

        tokens = line.split()

        # Update positioning mode if G90 or G91 is found
        if "G90" in tokens:
            positioning_mode = "G90"
        elif "G91" in tokens:
            positioning_mode = "G91"

        # Check for Z-axis movement
        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

            # Calculate the new Z position based on positioning mode
            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

            # Check for G00 commands moving to Z ≤ 0
            # Check for G00 commands moving to Z ≤ 0
            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 = []

    # Preprocess G-code to remove comments and get cleaned lines with original line numbers
    lines_with_numbers = preprocess_gcode(gcode)

    # Collect issues from all checks
    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)

    # Combine all issues
    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
    )

    # Separate issues into errors and warnings
    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))

    # Sort issues by line number
    errors.sort(key=lambda x: x[0])
    warnings.sort(key=lambda x: x[0])

    return errors, warnings

if __name__ == "__main__":
    # Example usage
    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  # Set the maximum allowed depth of cut
    errors, warnings = run_checks(gcode_sample, depth_max)

    # Prepare the output as a string
    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!")