gcodeparser.py 17.9 KB
Newer Older
1
2
3
4
5
6
7
8
9
"""GCODE Parser

Parses a GCODE file and creates a model reconstruction split into layers and lines.
Currently only supports GCODE with linear moves (G0/G1) in absolute mode.

The parse_gcode() function takes a GCODE file and turns it into a Model object.
"""

import re
10
import numpy as np
11
from scipy.spatial import ConvexHull
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
12

13
14
15
16
17
18
19
20
21
22
23
24
try:
	import matplotlib.pyplot as plt
except:
	print("Matplotlib not installed")

class Model():
    """Model class for storing layer and max/min data"""
    
    def __init__(self, layers, max_x, max_y, max_z, min_x, min_y, min_z):
        """
        Parameters
        ----------
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
25
        layers : [Layer]
26
            List of Layer objects
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
27
        max_x : float
28
            Maximum x value of model
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
29
        max_y : float
30
            Maximum y value of model
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
31
        max_z : float
32
            Maximum z value of model
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
33
        min_x : float
34
            Minimum x value of model
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
35
        min_y : float
36
            Minimum y value of model
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
37
        min_z : float
38
39
40
41
42
43
44
45
46
47
            Minimum z value of model
        """
        
        self.layers = layers
        self.max_y = max_y
        self.max_x = max_x
        self.max_z = max_z
        self.min_y = min_y
        self.min_x = min_x
        self.min_z = min_z
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
48
        self.layer_heights = [layer.z_height for layer in layers]
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
49
        
50
    def to_svgs(self, dir_name):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
51
        """Saves all layers as SVG images in directory. Used for testing only
52
53
54

        Parameters
        ----------
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
55
        dir_name : str
56
57
58
59
60
61
62
63
64
65
66
67
            Directory name to save images into 
        """
        
        for layer_index, layer in enumerate(self.layers):
            layer.to_svg(self.max_y, self.max_x, dir_name + "/{}.svg".format(layer_index))

class Line():
    """Line class defines a continuous extrusion in a layer"""
    
    def __init__(self):
        self.x = []
        self.y = []
68
        self.line_type = "regular"
69
70
        
    def append_coords(self, x, y):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
71
72
73
74
75
76
77
78
79
80
81
        """Append a coordinate to an existing line

        Parameters
        ----------
        x : float
            x coordinate of point
        y : float
            y coordinate of point
        """

        # If this is first point in the line, append the coordinates to the line
82
83
84
85
86
87
        if len(self.x) == 0:
            self.x.append(x)
            self.y.append(y)
        else:
            prev_x = self.x[-1]
            prev_y = self.y[-1]
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
88
            # Compute the length between the new point and the last point
89
            segment_length = np.sqrt((x-prev_x)**2+(y-prev_y)**2)
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
90
            # Calculate number of points required to interpolate to a resolution of 0.1mm
91
            num_points = int(np.floor(segment_length/0.1))
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
92
93
94

            # Filter by segment length. Generally, infills are long lines defined by 2 points, whereas
            # shells are defined by regularly spaced points. 
95
96
97
98
99
            if segment_length > 2.5:
                if len(self.x) > 5:
                    self.line_type = "regular"
                else:
                    self.line_type = "infill"
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
100
101

            # Interpolation        
102
            if num_points < 2:
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
103
                # If the number of points to interpolate is less than 2, just append the points
104
105
106
107
108
109
110
111
112
113
114
115
116
                self.x.append(x)
                self.y.append(y)
            else:
                if x < prev_x:
                    xs = np.linspace(x, prev_x, num_points)
                    ys = np.interp(xs, [x, prev_x], [y, prev_y])
                    xs = np.flip(xs)
                    ys = np.flip(ys)
                else:
                    xs = np.linspace(prev_x, x, num_points)
                    ys = np.interp(xs, [prev_x, x], [prev_y, y])
                self.x.extend(xs[1:])
                self.y.extend(ys[1:])
117
            
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
118
119
    def len(self):
        """Returns number of points in line - 1"""
120
121
122
        return len(self.x) - 1
    
    def is_empty(self):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
123
        """Checks if line is empty"""
124
125
126
127
128
129
130
131
132
133
134
135
        if self.len() > 0:
            return False
        else:
            return True
        
class Layer():
    """Layer class contains all lines in a layer, along with z-height info and
       functions for updating a layer and converting a layer to an SVG image."""
    
    def __init__(self, z_height):
        """
        Parameters
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
136
137
        ----------
        z_height : float
138
139
140
141
142
143
144
145
            The height of the layer
        """
        
        self.lines  = []
        self.new_line()
        self.prev_e = 0
        self.prev_g = -1
        self.z_height = z_height
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
146
        self.sample_points = dict([('x', []), ('y', [])])
147
148
149
150
151
152
153
154
155
        
    def append_coords(self, x, y, e, g):
        """
        Appends coordinates to a line as appropriate. Checks if a command is an extrusion,
        if it is, adds the coordinates to a line. Also checks if the line is continuous,
        if it is not, starts a new line.
        
        Parameters
        ----------
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
156
        x : float
157
            X-coordinate of command
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
158
        y : float
159
            Y-coordinate of command
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
160
        e : float
161
            Extursion value
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
162
        g : int
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
            G-CODE command type (not used)
        """
        
        if e > self.prev_e:                                 # If extrusion is taking place
            self.lines[-1].append_coords(x,y)               # Append coordinate to line
            self.prev_e = e
            self.prev_g = g
        else:                                               # If no extrusion is taking place
            if not self.lines[-1].is_empty():               # And the line object is not empty
                self.new_line()                             # Start a new line
            self.lines[-1].x = [x]
            self.lines[-1].y = [y]
                
    def new_line(self):
        """Makes a new line"""
        self.lines.append(Line())
            
    def to_svg(self, max_height, max_width, fn):
        """
        Saves a layer as an SVG image file

        Parameters
        ----------
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
186
        max_height : float
187
            Maximum height of model in mm
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
188
        max_width : float
189
            Maximum width of model in mm
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
190
        fn : str
191
192
193
194
195
196
197
            Filename to save image to
        """
        
        with open(fn, "w") as f:
            f.write(('<svg xmlns="http://www.w3.org/2000/svg"'
                     ' xmlns:xlink="http://www.w3.org/1999/xlink"'
                     ' viewBox="0 0 250 40" height="{}mm" width="{}mm">\n').format(max_height, max_width))
198
            for line in self.lines[::10]:
199
200
201
202
203
                points = '\t<polyline points="'
                coords = [f"{x},{y} " for x,y in zip(line.x, line.y)]
                points = points + "".join(coords) + ('"\n\tstyle="fill:none;stroke:black;stroke-width:0.4;'
                                                     'stroke-linejoin:round;stroke-linecap:round" />\n')
                f.write(points)
204
205
            for point in zip(self.sample_points['x'], self.sample_points['y']):
                f.write('\t<circle cx="{}" cy="{}" r="1" stroke="red" stroke-width="0" fill="red"></circle>\n'.format(point[0], point[1]))
206
207
208
            f.write('</svg>')
            
    def to_svg_inline(self, max_height, viewbox_width, viewbox_height):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
209
210
211
212
213
214
215
216
217
218
219
220
221
222
        """
        Returns a layer as an SVG image

        Parameters
        ----------
        max_height : float
            Maximum height of model in mm
        viewbox_width : float
            Width of the SVG viewbox
        viewbox_height : float
            Height of the SVG viewbox
        """

        # Append the SVG header first
223
224
225
        out = ('<svg xmlns="http://www.w3.org/2000/svg"'
                ' xmlns:xlink="http://www.w3.org/1999/xlink"'
                ' viewBox="0 0 {} {}" height="{}" width="100%">\n').format(viewbox_width, viewbox_height, max_height)
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
226
        # Iterate through each line
227
228
229
        for line in self.lines:
            points = '\t<polyline points="'
            coords = [f"{x},{y} " for x,y in zip(line.x, line.y)]
230
            if line.line_type == "regular":
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
231
                # Append the points with black color if regular extrusion
232
233
234
                points = points + "".join(coords) + ('"\n\tstyle="fill:none;stroke:black;stroke-width:0.4;'
                                                         'stroke-linejoin:round;stroke-linecap:round" />\n')
            if line.line_type == "infill":
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
235
                # Append the points with blue color if infill
236
237
                points = points + "".join(coords) + ('"\n\tstyle="fill:none;stroke:blue;stroke-width:0.4;'
                                                         'stroke-linejoin:round;stroke-linecap:round" />\n')
238
239
            out += points

Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
240
        # Add circles for each sample point
241
242
        for point in zip(self.sample_points['x'], self.sample_points['y']):
                out += '\t<circle cx="{}" cy="{}" r="1" stroke="red" stroke-width="0" fill="red"></circle>\n'.format(point[0], point[1])
243
244
245
        out += '</svg>'
        return out
    
246
    def plot_layer(self, col_reg, col_infill):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
247
248
249
250
251
252
253
254
255
256
        """ Plot a layer in Matplotlib. Used for testing

        Parameters
        ----------
        col_reg : str
                Colorspec for regular extrusion
        col_infill : str
                Colorspec for infill

        """
257
        for line in self.lines:
258
259
260
261
            if line.line_type == "regular":
                plt.plot(line.x, line.y, col_reg) #self.z_height,
            elif line.line_type == "infill":
                plt.plot(line.x, line.y, col_infill)
262
263

    def to_csv(self, fn):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
264
265
266
267
268
269
270
        """ Saves points as as csv

        Parameters
        ----------
        fn : str
                Filename to save to
        """
271
272
273
274
275
        with open(fn, "w") as f:
            for line in self.lines:
                [f.write("{},{}\n".format(x, y)) for (x,y) in zip(line.x, line.y)]
             
    def len(self):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
276
        """ Returns the number of lines in the layer """
277
278
        return len([line for line in self.lines if line.len() > 0])

279
    def get_points(self, ignore_infill = False):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
280
281
282
283
284
285
286
        """ Returns a dictionary of all points in the layer

        Keyword Arguments
        -----------------
        ignore_infill : bool (Optional)
                Set to True to ignore infills. Defaults as false
        """
287
288
289
        x_pts = []
        y_pts = []

290
291
        [x_pts.extend(line.x) for line in self.lines if ignore_infill != True or line.line_type == "regular"]
        [y_pts.extend(line.y) for line in self.lines if ignore_infill != True or line.line_type == "regular"]
292
293

        return dict([('x', x_pts), ('y', y_pts), ('num_pts', len(x_pts))])
294

295
    def gen_sample_points(self, method, num_samples):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
296
297
298
299
300
301
302
303
304
305
306
307
308
309
        """ Generate sample points for this layer

        Sampling point algorithms live here. To add a new method, add a new elif section with an appropriate
        name. Can also add keyword arguments as needed.

        Parameters
        ----------
        method : string
                Name of method to use
        num_samples : int
                Number of samples to generate
        """

        # Get the points in this layer
310
        points = self.get_points()
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
311
312

        # Random sampling method: Pick X number of points from all available points
313
        if method == "Random sampling":
314
315
316
            sample_pts = list(np.random.randint(0, points['num_pts'], num_samples))
            x_pts = [points['x'][i] for i in sample_pts]
            y_pts = [points['y'][i] for i in sample_pts]
317
            self.sample_points = dict([('x', x_pts), ('y', y_pts)])
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
318
319
            
        # Min-max method: Pick points along the maximum/minimum of the X-Y axes, and a 45 degree tilted axes
320
        elif method == "Min-max":
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
321
            # Put the points into a numpy array to make life a bit easier
322
            point_array = np.array([points['x'], points['y']])
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
323
324

            # Find the minimum and maximum values along the regular axes
325
326
327
            max_vals = np.argmax(point_array, axis=1)
            min_vals = np.argmin(point_array, axis=1)

Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
328
329
330
            rot45 = np.array([[0.7071, -0.7071],[0.7071, 0.7071]])      # Transformation matrix for a rotation by 45 degrees
            point_array_rot = np.matmul(rot45, point_array)             # Rotate all points by 45 degrees
            max_vals_rot = np.argmax(point_array_rot, axis=1)           # Repeat process of finding min/max points
331
            min_vals_rot = np.argmin(point_array_rot, axis=1)
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
332
333

            # Remove duplicate values if any
334
335
336
337
            sample_pts = np.unique(np.concatenate((max_vals, min_vals, max_vals_rot, min_vals_rot))).tolist()
            x_pts = [points['x'][i] for i in sample_pts]
            y_pts = [points['y'][i] for i in sample_pts]
            self.sample_points = dict([('x', x_pts), ('y', y_pts)])
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
338
339

        # Inside outside method: Pick 10 points from outside the convex hull, pick 10 points from inside the convex hull
340
        elif method == "Inside-outside":
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
341
            # Put the points into a numpy array to make life a bit easier 
342
343
344
            point_list = []
            [point_list.append([x,y]) for x,y in zip(points['x'], points['y'])]
            features = np.array(point_list)
345
            
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
346
            # If there are less than 4 points, do nothing
347
348
349
            if features.shape[0] < 4:
                self.sample_points = dict([('x', []), ('y', [])])
            else:
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
350
351
352
353
                hull = ConvexHull(features)                             # Compute the convex hull
                outside_indx = np.random.choice(hull.vertices, 10)      # Get the indices for 10 random points along the vertices of the hull

                # Find all points that are inside the convex hull
354
355
                all_inside_indx = np.arange(1, len(point_list))
                all_inside_indx = all_inside_indx[np.isin(all_inside_indx, hull.vertices, invert=True)]
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
356
                # Pick 10 points from inside the hull
357
                inside_indx = np.random.choice(all_inside_indx, 10)
358
359
360
361
362

                all_points = np.concatenate((inside_indx, outside_indx));
                x_pts = [feature[0] for feature in features[all_points]]
                y_pts = [feature[1] for feature in features[all_points]]
                self.sample_points = dict([('x', x_pts), ('y', y_pts)])
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
363
364
365
366

        # Return nothing if no sampling method 
        else:
            self.sample_points = dict([('x', []), ('y', [])])    
367

368
def parse_gcode(filename, sample_spacing, samples_per_layer, method):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
369
370
371
372
373
374
375
376
377
378
379
380
381
    """ Function for parsing GCODE file

    Parameters
    ----------
    filename : str
            File to process
    sample_spacing : int
            Number of layers to space between sample layers
    samples_per_layer : int
            Number of samples per layer
    method : str
            Sampling method. Choose from methods in gen_sample_points method
    """
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
    layers  = []
    num_layers = 0
    layer_heights = []
    lines  = []
    Gs     = []
    xs     = []
    ys     = []
    zs     = []
    es     = []
    prev_x = 0
    prev_y = 0
    prev_z = 0
    prev_e = 0

    with open(filename) as f:                                           # Open GCODE file
        file = f.readlines()
                    
    for line in file:                                                   # Iterate through lines
        line = line.strip()                                             # Strip newline character
        if not line or line[0] == ";":                                  # Skip empty lines and comments
            continue
        line = line.split(";")[0].strip()                               # Remove any trailing comments

        #TODO: Add handling for G2/G3 and incremental mode
        
        G_match = re.match("^(?:G0|G1)(\.\d+)?\s", line)                # Match for any linear movements
        if G_match:
            lines.append(line)

            G = re.match("G([0123])", line)                             # Match for G command
            Gs.append(int(G.group(1)))
                      
            x_loc = re.match(".*X(-?\d*\.?\d*)", line)                  # Match for X coordinates. If none, use prior
            if x_loc:
                x_loc = float(x_loc.group(1))
                prev_x = x_loc
                xs.append(x_loc)
            else:
                xs.append(prev_x)
            y_loc = re.match(".*Y(-?\d*\.?\d*)", line)                  # Match for Y coordinates. If none, use prior
            if y_loc:
                y_loc = float(y_loc.group(1))
                prev_y = y_loc
                ys.append(y_loc)
            else:
                ys.append(prev_y)

            z_loc = re.match(".*Z(-?\d*\.?\d*)", line)                  # Match for Z coordinates. If none, use prior
            if z_loc:
                z_loc = float(z_loc.group(1))
                prev_z = z_loc
                zs.append(z_loc)
            else:
                zs.append(prev_z)

            e_loc = re.match(".*E(-?\d*\.?\d*)", line)                  # Match for Z coordinates. If none, use prior
            if e_loc:
                e_loc = float(e_loc.group(1))
                prev_e = e_loc
                es.append(e_loc)
            else:
                es.append(prev_e)
    [layer_heights.append(height) for height in set(zs)]                # Find the unique Z heights in the GCODE file
    layer_heights.sort()                                                # Make it a sorted list

    num_layers = len(layer_heights)                                     # Number of layers
    index = dict(zip(layer_heights, range(num_layers)))                 # Map layer height to an index

    layers = [Layer(layer_heights[i]) for i in range(num_layers)]       # Initialize model list with number of layers

    for i in range(len(lines)):                                         # Iterate through each line 
        layers[index[zs[i]]].append_coords(xs[i],ys[i],es[i],Gs[i])     # Append each command into the model list

Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
455
    layers = [layer for layer in layers if layer.len() > 0]             # Find non-empty layers
456
457
    for i in range(len(layers)):
        if (i % sample_spacing) == 0 and (i != 0):
Nicholas Gar Hei Chan's avatar
Nicholas Gar Hei Chan committed
458
            layers[i].gen_sample_points(method, samples_per_layer)      # Generate sample points for desired layers
459
460
        else:
            layers[i].gen_sample_points("none", 0)
461
462
    model = Model(layers, max(xs), max(ys), max(zs), min(xs), min(ys), min(zs))
    return model