Coverage for tatlin/lib/model/gcode/parser.py: 100%
214 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 05:56 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 05:56 +0000
1# -*- coding: utf-8 -*-
2# Copyright (C) 2012 Denis Kobozev
3#
4# This program is free software; you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation; either version 2 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program; if not, write to the Free Software Foundation,
16# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17"""
18Gcode parser.
19"""
22import time
23import logging
24import math
25import array
28class GcodeParserError(Exception):
29 pass
32class ArgsDict(dict):
33 """
34 Dictionary that returns None on missing keys instead of throwing a
35 KeyError.
36 """
38 def __missing__(self, key):
39 return None
42class GcodeLexer(object):
43 """
44 Load gcode and split commands into tokens.
45 """
47 def __init__(self):
48 self.line_no = None
49 self.current_line = None
50 self.line_count = 0
52 def load(self, gcode):
53 if isinstance(gcode, str):
54 lines = gcode.replace("\r", "\n").replace("\n\n", "\n").split("\n")
55 self.line_count = len(lines)
57 def _getlines(): # type: ignore
58 for line in lines:
59 yield line
61 self.getlines = _getlines
62 else:
63 for line in gcode:
64 self.line_count += 1
66 gcode.seek(0)
68 def _getlines():
69 for line in gcode:
70 yield line.replace("\r", "\n").replace("\n\n", "\n")
72 self.getlines = _getlines
74 def scan(self):
75 """
76 Return a generator for commands split into tokens.
77 """
78 self.line_no = 0
79 for line in self.getlines():
80 self.line_no += 1
81 self.current_line = line
82 tokens = self.scan_line(line)
84 if not self.is_blank(tokens):
85 yield tokens
87 def scan_line(self, line):
88 """
89 Break line into tokens.
90 """
91 command, comment = self.split_comment(line)
92 parts = command.split()
93 if parts:
94 args = ArgsDict()
95 for part in reversed(parts[1:]):
96 if len(part) > 1:
97 try:
98 args[part[0]] = float(part[1:])
99 except ValueError as e:
100 comment = part + " " + comment
101 else:
102 args[part[0]] = None
104 return (parts[0], args, comment)
105 else:
106 return ("", ArgsDict(), comment)
108 def split_comment(self, line):
109 """
110 Return a 2-tuple of command and comment.
112 Comments start with a semicolon ; or a open parentheses (.
113 """
114 idx_semi = line.find(";")
115 if idx_semi >= 0:
116 command, comment = line[:idx_semi], line[idx_semi:]
117 else:
118 command, comment = line, ""
120 idx_paren = command.find("(")
121 if idx_paren >= 0:
122 command, comment = (command[:idx_paren], command[idx_paren:] + comment)
124 return (command, comment)
126 def is_blank(self, tokens):
127 """
128 Return true if tokens does not contain any information.
129 """
130 return tokens == ("", ArgsDict(), "")
133class Movement(object):
134 """
135 Movement represents travel between two points and machine state during
136 travel.
137 """
139 FLAG_PERIMETER = 1
140 FLAG_PERIMETER_OUTER = 2
141 FLAG_LOOP = 4
142 FLAG_SURROUND_LOOP = 8
143 FLAG_EXTRUDER_ON = 16
144 FLAG_INCHES = 32
146 # tell the python interpreter to only allocate memory for the following attributes
147 __slots__ = ["v", "delta_e", "feedrate", "flags"]
149 def __init__(self, v, delta_e, feedrate, flags=0):
150 self.v = v
152 self.delta_e = delta_e
153 self.feedrate = feedrate
154 self.flags = flags
156 def angle(self, start, precision=0):
157 x = self.v[0] - start[0]
158 y = self.v[1] - start[1]
159 angle = math.degrees(math.atan2(y, -x)) # negate x for clockwise rotation angle
160 return round(angle, precision)
162 def __str__(self):
163 s = "(%s)" % (self.v)
164 return s
166 def __repr__(self):
167 s = "Movement(%s, %s, %s, %s)" % (
168 self.v,
169 self.delta_e,
170 self.feedrate,
171 self.flags,
172 )
173 return s
176class GcodeParser(object):
177 marker_layer = "</layer>"
178 marker_perimeter_start = "<perimeter>"
179 marker_perimeter_end = "</perimeter>)"
180 marker_loop_start = "<loop>"
181 marker_loop_end = "</loop>"
182 marker_surrounding_loop_start = "<surroundingLoop>"
183 marker_surrounding_loop_end = "</surroundingLoop>"
185 def __init__(self):
186 self.lexer = GcodeLexer()
188 self.args = ArgsDict({"X": 0, "Y": 0, "Z": 0, "F": 0, "E": 0})
189 self.offset = {"X": 0, "Y": 0, "Z": 0, "E": 0}
190 self.src = None
191 self.flags = 0
192 self.set_flags = self.set_flags_skeinforge
193 self.relative = False
195 def load(self, src):
196 self.lexer.load(src)
198 def parse(self, callback=None):
199 t_start = time.time()
201 layers = []
202 movements = []
203 line_count = self.lexer.line_count
204 command_idx = None
205 callback_every = max(1, int(math.floor(line_count / 100)))
206 mm_in_inch = 25.4
207 new_layer = False
208 current_layer_z = 0
210 for command_idx, command in enumerate(self.lexer.scan()):
211 gcode, newargs, comment = command
213 if "Slic3r" in comment:
214 # switch mode to slic3r
215 self.set_flags = self.set_flags_slic3r
217 args = self.update_args(self.args, newargs)
218 dst = self.command_coords(gcode, args, newargs)
219 delta_e = args["E"] - self.args["E"]
220 self.set_flags(command)
222 if self.marker_layer in comment:
223 new_layer = True
224 if delta_e > 0 and args["Z"] != current_layer_z:
225 current_layer_z = args["Z"]
226 new_layer = True
228 # create a new movement if the gcode contains a valid coordinate
229 if dst is not None and self.src != dst:
230 if self.src is not None and new_layer:
231 layers.append(movements)
232 movements = []
233 new_layer = False
235 if self.flags & Movement.FLAG_INCHES:
236 dst = (
237 dst[0] * mm_in_inch,
238 dst[1] * mm_in_inch,
239 dst[2] * mm_in_inch,
240 )
242 move = Movement(array.array("f", dst), delta_e, args["F"], self.flags)
243 movements.append(move)
245 # if gcode contains a valid coordinate, update the previous point
246 # with the new coordinate
247 if dst is not None:
248 self.src = dst
249 self.args = args
251 if callback and command_idx % callback_every == 0:
252 callback(command_idx + 1, line_count)
254 # don't forget leftover movements
255 if len(movements) > 0:
256 layers.append(movements)
258 if callback and command_idx is not None:
259 callback(command_idx + 1, line_count)
261 t_end = time.time()
262 logging.info("Parsed Gcode file in %.2f seconds" % (t_end - t_start))
264 if len(layers) < 1:
265 raise GcodeParserError("File does not contain valid Gcode")
267 logging.info("Layers: %d" % len(layers))
269 return layers
271 def update_args(self, oldargs, newargs):
272 args = oldargs.copy()
274 for axis in list(newargs.keys()):
275 if axis in args and newargs[axis] is not None:
276 if self.relative:
277 args[axis] += newargs[axis]
278 else:
279 args[axis] = newargs[axis]
281 return args
283 def command_coords(self, gcode, args, newargs):
284 if gcode in ("G0", "G00", "G1", "G01"): # move
285 coords = (
286 self.offset["X"] + args["X"],
287 self.offset["Y"] + args["Y"],
288 self.offset["Z"] + args["Z"],
289 )
290 return coords
291 elif gcode == "G28": # move to origin
292 if newargs["X"] is None and newargs["Y"] is None and newargs["Z"] is None:
293 # if no coordinates specified, move all axes to origin
294 return (self.offset["X"], self.offset["Y"], self.offset["Z"])
295 else:
296 # if any coordinates are specified, reset just the axes
297 # specified; the actual coordinate values are ignored
298 x = self.offset["X"] if newargs["X"] is not None else args["X"]
299 y = self.offset["Y"] if newargs["Y"] is not None else args["Y"]
300 z = self.offset["Z"] if newargs["Z"] is not None else args["Z"]
301 return (x, y, z)
302 elif gcode == "G90": # set to absolute positioning
303 self.relative = False
304 elif gcode == "G91": # set to relative positioning
305 self.relative = True
306 elif gcode == "G92": # set position
307 # G92 without coordinates resets all axes to zero
308 if len(newargs) < 1:
309 newargs = ArgsDict({"X": 0, "Y": 0, "Z": 0, "E": 0})
311 for axis in list(newargs.keys()):
312 if axis in self.offset:
313 self.offset[axis] += self.args[axis] - newargs[axis]
314 self.args[axis] = newargs[axis]
316 return None
318 def set_flags_skeinforge(self, command):
319 """
320 Set internal parser state based on command arguments assuming the file
321 has been generated by Skeinforge.
322 """
323 gcode, args, comment = command
325 if self.marker_loop_start in comment:
326 self.flags |= Movement.FLAG_LOOP
328 elif self.marker_loop_end in comment:
329 self.flags &= ~Movement.FLAG_LOOP
331 elif self.marker_perimeter_start in comment:
332 self.flags |= Movement.FLAG_PERIMETER
333 if "outer" in comment:
334 self.flags |= Movement.FLAG_PERIMETER_OUTER
336 elif self.marker_perimeter_end in comment:
337 self.flags &= ~(Movement.FLAG_PERIMETER | Movement.FLAG_PERIMETER_OUTER)
339 elif self.marker_surrounding_loop_start in comment:
340 self.flags |= Movement.FLAG_SURROUND_LOOP
342 elif self.marker_surrounding_loop_end in comment:
343 self.flags &= ~Movement.FLAG_SURROUND_LOOP
345 elif gcode in ("M101", "M3", "M03", "M4", "M04"): # turn on extruder/spindle
346 self.flags |= Movement.FLAG_EXTRUDER_ON
348 elif gcode in ("M103", "M5", "M05"): # turn off extruder/spindle
349 self.flags &= ~Movement.FLAG_EXTRUDER_ON
351 elif gcode == "G20":
352 self.flags |= Movement.FLAG_INCHES
354 elif gcode == "G21":
355 self.flags &= ~Movement.FLAG_INCHES
357 def set_flags_slic3r(self, command):
358 """
359 Set internal parser state based on command arguments assuming the file
360 has been generated by Slic3r.
361 """
362 gcode, args, comment = command
364 if "perimeter" in comment:
365 self.flags |= Movement.FLAG_PERIMETER | Movement.FLAG_PERIMETER_OUTER
366 elif "skirt" in comment:
367 self.flags |= Movement.FLAG_LOOP
368 else:
369 self.flags = 0