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

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""" 

20 

21 

22import time 

23import logging 

24import math 

25import array 

26 

27 

28class GcodeParserError(Exception): 

29 pass 

30 

31 

32class ArgsDict(dict): 

33 """ 

34 Dictionary that returns None on missing keys instead of throwing a 

35 KeyError. 

36 """ 

37 

38 def __missing__(self, key): 

39 return None 

40 

41 

42class GcodeLexer(object): 

43 """ 

44 Load gcode and split commands into tokens. 

45 """ 

46 

47 def __init__(self): 

48 self.line_no = None 

49 self.current_line = None 

50 self.line_count = 0 

51 

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) 

56 

57 def _getlines(): # type: ignore 

58 for line in lines: 

59 yield line 

60 

61 self.getlines = _getlines 

62 else: 

63 for line in gcode: 

64 self.line_count += 1 

65 

66 gcode.seek(0) 

67 

68 def _getlines(): 

69 for line in gcode: 

70 yield line.replace("\r", "\n").replace("\n\n", "\n") 

71 

72 self.getlines = _getlines 

73 

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) 

83 

84 if not self.is_blank(tokens): 

85 yield tokens 

86 

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 

103 

104 return (parts[0], args, comment) 

105 else: 

106 return ("", ArgsDict(), comment) 

107 

108 def split_comment(self, line): 

109 """ 

110 Return a 2-tuple of command and comment. 

111 

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, "" 

119 

120 idx_paren = command.find("(") 

121 if idx_paren >= 0: 

122 command, comment = (command[:idx_paren], command[idx_paren:] + comment) 

123 

124 return (command, comment) 

125 

126 def is_blank(self, tokens): 

127 """ 

128 Return true if tokens does not contain any information. 

129 """ 

130 return tokens == ("", ArgsDict(), "") 

131 

132 

133class Movement(object): 

134 """ 

135 Movement represents travel between two points and machine state during 

136 travel. 

137 """ 

138 

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 

145 

146 # tell the python interpreter to only allocate memory for the following attributes 

147 __slots__ = ["v", "delta_e", "feedrate", "flags"] 

148 

149 def __init__(self, v, delta_e, feedrate, flags=0): 

150 self.v = v 

151 

152 self.delta_e = delta_e 

153 self.feedrate = feedrate 

154 self.flags = flags 

155 

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) 

161 

162 def __str__(self): 

163 s = "(%s)" % (self.v) 

164 return s 

165 

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 

174 

175 

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>" 

184 

185 def __init__(self): 

186 self.lexer = GcodeLexer() 

187 

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 

194 

195 def load(self, src): 

196 self.lexer.load(src) 

197 

198 def parse(self, callback=None): 

199 t_start = time.time() 

200 

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 

209 

210 for command_idx, command in enumerate(self.lexer.scan()): 

211 gcode, newargs, comment = command 

212 

213 if "Slic3r" in comment: 

214 # switch mode to slic3r 

215 self.set_flags = self.set_flags_slic3r 

216 

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) 

221 

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 

227 

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 

234 

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 ) 

241 

242 move = Movement(array.array("f", dst), delta_e, args["F"], self.flags) 

243 movements.append(move) 

244 

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 

250 

251 if callback and command_idx % callback_every == 0: 

252 callback(command_idx + 1, line_count) 

253 

254 # don't forget leftover movements 

255 if len(movements) > 0: 

256 layers.append(movements) 

257 

258 if callback and command_idx is not None: 

259 callback(command_idx + 1, line_count) 

260 

261 t_end = time.time() 

262 logging.info("Parsed Gcode file in %.2f seconds" % (t_end - t_start)) 

263 

264 if len(layers) < 1: 

265 raise GcodeParserError("File does not contain valid Gcode") 

266 

267 logging.info("Layers: %d" % len(layers)) 

268 

269 return layers 

270 

271 def update_args(self, oldargs, newargs): 

272 args = oldargs.copy() 

273 

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] 

280 

281 return args 

282 

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}) 

310 

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] 

315 

316 return None 

317 

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 

324 

325 if self.marker_loop_start in comment: 

326 self.flags |= Movement.FLAG_LOOP 

327 

328 elif self.marker_loop_end in comment: 

329 self.flags &= ~Movement.FLAG_LOOP 

330 

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 

335 

336 elif self.marker_perimeter_end in comment: 

337 self.flags &= ~(Movement.FLAG_PERIMETER | Movement.FLAG_PERIMETER_OUTER) 

338 

339 elif self.marker_surrounding_loop_start in comment: 

340 self.flags |= Movement.FLAG_SURROUND_LOOP 

341 

342 elif self.marker_surrounding_loop_end in comment: 

343 self.flags &= ~Movement.FLAG_SURROUND_LOOP 

344 

345 elif gcode in ("M101", "M3", "M03", "M4", "M04"): # turn on extruder/spindle 

346 self.flags |= Movement.FLAG_EXTRUDER_ON 

347 

348 elif gcode in ("M103", "M5", "M05"): # turn off extruder/spindle 

349 self.flags &= ~Movement.FLAG_EXTRUDER_ON 

350 

351 elif gcode == "G20": 

352 self.flags |= Movement.FLAG_INCHES 

353 

354 elif gcode == "G21": 

355 self.flags &= ~Movement.FLAG_INCHES 

356 

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 

363 

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