FlavorParser.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. # Copyright (c) 2022 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import math
  4. import re
  5. from typing import Dict, List, NamedTuple, Optional, Union, Set
  6. import numpy
  7. from UM.Backend import Backend
  8. from UM.Job import Job
  9. from UM.Logger import Logger
  10. from UM.Math.Vector import Vector
  11. from UM.Message import Message
  12. from UM.i18n import i18nCatalog
  13. from cura.CuraApplication import CuraApplication
  14. from cura.LayerDataBuilder import LayerDataBuilder
  15. from cura.LayerDataDecorator import LayerDataDecorator
  16. from cura.LayerPolygon import LayerPolygon
  17. from cura.Scene.CuraSceneNode import CuraSceneNode
  18. from cura.Scene.GCodeListDecorator import GCodeListDecorator
  19. from cura.Settings.ExtruderManager import ExtruderManager
  20. catalog = i18nCatalog("cura")
  21. PositionOptional = NamedTuple("PositionOptional", [("x", Optional[float]), ("y", Optional[float]), ("z", Optional[float]), ("f", Optional[float]), ("e", Optional[float])])
  22. Position = NamedTuple("Position", [("x", float), ("y", float), ("z", float), ("f", float), ("e", List[float])])
  23. class FlavorParser:
  24. """This parser is intended to interpret the common firmware codes among all the different flavors"""
  25. MAX_EXTRUDER_COUNT = 16
  26. DEFAULT_FILAMENT_DIAMETER = 2.85
  27. def __init__(self) -> None:
  28. CuraApplication.getInstance().hideMessageSignal.connect(self._onHideMessage)
  29. self._cancelled = False
  30. self._message = None # type: Optional[Message]
  31. self._layer_number = 0
  32. self._extruder_number = 0
  33. # All extruder numbers that have been seen
  34. self._extruders_seen = {0} # type: Set[int]
  35. self._clearValues()
  36. self._scene_node = None
  37. # X, Y, Z position, F feedrate and E extruder values are stored
  38. self._position = Position
  39. self._is_layers_in_file = False # Does the Gcode have the layers comment?
  40. self._extruder_offsets = {} # type: Dict[int, List[float]] # Offsets for multi extruders. key is index, value is [x-offset, y-offset]
  41. self._current_layer_thickness = 0.2 # default
  42. self._current_filament_diameter = 2.85 # default
  43. self._previous_extrusion_value = 0.0 # keep track of the filament retractions
  44. CuraApplication.getInstance().getPreferences().addPreference("gcodereader/show_caution", True)
  45. def _clearValues(self) -> None:
  46. self._extruder_number = 0
  47. self._extrusion_length_offset = [0] * self.MAX_EXTRUDER_COUNT # type: List[float]
  48. self._layer_type = LayerPolygon.Inset0Type
  49. self._layer_number = 0
  50. self._previous_z = 0 # type: float
  51. self._layer_data_builder = LayerDataBuilder()
  52. self._is_absolute_positioning = True # It can be absolute (G90) or relative (G91)
  53. self._is_absolute_extrusion = True # It can become absolute (M82, default) or relative (M83)
  54. @staticmethod
  55. def _getValue(line: str, code: str) -> Optional[Union[str, int, float]]:
  56. n = line.find(code)
  57. if n < 0:
  58. return None
  59. n += len(code)
  60. pattern = re.compile("[;\\s]")
  61. match = pattern.search(line, n)
  62. m = match.start() if match is not None else -1
  63. try:
  64. if m < 0:
  65. return line[n:]
  66. return line[n:m]
  67. except:
  68. return None
  69. def _getInt(self, line: str, code: str) -> Optional[int]:
  70. value = self._getValue(line, code)
  71. try:
  72. return int(value) # type: ignore
  73. except:
  74. return None
  75. def _getFloat(self, line: str, code: str) -> Optional[float]:
  76. value = self._getValue(line, code)
  77. try:
  78. return float(value) # type: ignore
  79. except:
  80. return None
  81. def _onHideMessage(self, message: str) -> None:
  82. if message == self._message:
  83. self._cancelled = True
  84. def _createPolygon(self, layer_thickness: float, path: List[List[Union[float, int]]], extruder_offsets: List[float]) -> bool:
  85. countvalid = 0
  86. for point in path:
  87. if point[5] > 0:
  88. countvalid += 1
  89. if countvalid >= 2:
  90. # we know what to do now, no need to count further
  91. continue
  92. if countvalid < 2:
  93. return False
  94. try:
  95. self._layer_data_builder.addLayer(self._layer_number)
  96. self._layer_data_builder.setLayerHeight(self._layer_number, path[0][2])
  97. self._layer_data_builder.setLayerThickness(self._layer_number, layer_thickness)
  98. this_layer = self._layer_data_builder.getLayer(self._layer_number)
  99. if not this_layer:
  100. return False
  101. except ValueError:
  102. return False
  103. count = len(path)
  104. line_types = numpy.empty((count - 1, 1), numpy.int32)
  105. line_widths = numpy.empty((count - 1, 1), numpy.float32)
  106. line_thicknesses = numpy.empty((count - 1, 1), numpy.float32)
  107. line_feedrates = numpy.empty((count - 1, 1), numpy.float32)
  108. line_widths[:, 0] = 0.35 # Just a guess
  109. line_thicknesses[:, 0] = layer_thickness
  110. points = numpy.empty((count, 3), numpy.float32)
  111. extrusion_values = numpy.empty((count, 1), numpy.float32)
  112. i = 0
  113. for point in path:
  114. points[i, :] = [point[0] + extruder_offsets[0], point[2], -point[1] - extruder_offsets[1]]
  115. extrusion_values[i] = point[4]
  116. if i > 0:
  117. line_feedrates[i - 1] = point[3]
  118. line_types[i - 1] = point[5]
  119. if point[5] in [LayerPolygon.MoveCombingType, LayerPolygon.MoveRetractionType]:
  120. line_widths[i - 1] = 0.1
  121. line_thicknesses[i - 1] = 0.0 # Travels are set as zero thickness lines
  122. else:
  123. line_widths[i - 1] = self._calculateLineWidth(points[i], points[i-1], extrusion_values[i], extrusion_values[i-1], layer_thickness)
  124. i += 1
  125. this_poly = LayerPolygon(self._extruder_number, line_types, points, line_widths, line_thicknesses, line_feedrates)
  126. this_poly.buildCache()
  127. this_layer.polygons.append(this_poly)
  128. return True
  129. def _createEmptyLayer(self, layer_number: int) -> None:
  130. self._layer_data_builder.addLayer(layer_number)
  131. self._layer_data_builder.setLayerHeight(layer_number, 0)
  132. self._layer_data_builder.setLayerThickness(layer_number, 0)
  133. def _calculateLineWidth(self, current_point: Position, previous_point: Position, current_extrusion: float, previous_extrusion: float, layer_thickness: float) -> float:
  134. # Area of the filament
  135. Af = (self._current_filament_diameter / 2) ** 2 * numpy.pi
  136. # Length of the extruded filament
  137. de = current_extrusion - previous_extrusion
  138. # Volume of the extruded filament
  139. dVe = de * Af
  140. # Length of the printed line
  141. dX = numpy.sqrt((current_point[0] - previous_point[0])**2 + (current_point[2] - previous_point[2])**2)
  142. # When the extruder recovers from a retraction, we get zero distance
  143. if dX == 0:
  144. return 0.1
  145. # Area of the printed line. This area is a rectangle
  146. Ae = dVe / dX
  147. # This area is a rectangle with area equal to layer_thickness * layer_width
  148. line_width = Ae / layer_thickness
  149. # A threshold is set to avoid weird paths in the GCode
  150. if line_width > 1.2:
  151. return 0.35
  152. # Prevent showing infinitely wide lines
  153. if line_width < 0.0:
  154. return 0.0
  155. return line_width
  156. def _gCode0(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
  157. x, y, z, f, e = position
  158. if self._is_absolute_positioning:
  159. x = params.x if params.x is not None else x
  160. y = params.y if params.y is not None else y
  161. z = params.z if params.z is not None else z
  162. else:
  163. x += params.x if params.x is not None else 0
  164. y += params.y if params.y is not None else 0
  165. z += params.z if params.z is not None else 0
  166. f = params.f if params.f is not None else f
  167. if params.e is not None:
  168. new_extrusion_value = params.e if self._is_absolute_extrusion else e[self._extruder_number] + params.e
  169. if new_extrusion_value > e[self._extruder_number]:
  170. path.append([x, y, z, f, new_extrusion_value + self._extrusion_length_offset[self._extruder_number], self._layer_type]) # extrusion
  171. self._previous_extrusion_value = new_extrusion_value
  172. else:
  173. path.append([x, y, z, f, new_extrusion_value + self._extrusion_length_offset[self._extruder_number], LayerPolygon.MoveRetractionType]) # retraction
  174. e[self._extruder_number] = new_extrusion_value
  175. # Only when extruding we can determine the latest known "layer height" which is the difference in height between extrusions
  176. # Also, 1.5 is a heuristic for any priming or whatsoever, we skip those.
  177. if z > self._previous_z and (z - self._previous_z < 1.5) and (params.x is not None or params.y is not None):
  178. self._current_layer_thickness = z - self._previous_z # allow a tiny overlap
  179. self._previous_z = z
  180. elif self._previous_extrusion_value > e[self._extruder_number]:
  181. path.append([x, y, z, f, e[self._extruder_number] + self._extrusion_length_offset[self._extruder_number], LayerPolygon.MoveRetractionType])
  182. else:
  183. path.append([x, y, z, f, e[self._extruder_number] + self._extrusion_length_offset[self._extruder_number], LayerPolygon.MoveCombingType])
  184. return self._position(x, y, z, f, e)
  185. # G0 and G1 should be handled exactly the same.
  186. _gCode1 = _gCode0
  187. def _gCode28(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
  188. """Home the head."""
  189. return self._position(
  190. params.x if params.x is not None else position.x,
  191. params.y if params.y is not None else position.y,
  192. params.z if params.z is not None else position.z,
  193. position.f,
  194. position.e)
  195. def _gCode90(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
  196. """Set the absolute positioning"""
  197. self._is_absolute_positioning = True
  198. self._is_absolute_extrusion = True
  199. return position
  200. def _gCode91(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
  201. """Set the relative positioning"""
  202. self._is_absolute_positioning = False
  203. self._is_absolute_extrusion = False
  204. return position
  205. def _gCode92(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
  206. """Reset the current position to the values specified.
  207. For example: G92 X10 will set the X to 10 without any physical motion.
  208. """
  209. if params.e is not None:
  210. # Sometimes a G92 E0 is introduced in the middle of the GCode so we need to keep those offsets for calculate the line_width
  211. self._extrusion_length_offset[self._extruder_number] = position.e[self._extruder_number] - params.e
  212. position.e[self._extruder_number] = params.e
  213. self._previous_extrusion_value = params.e
  214. else:
  215. self._previous_extrusion_value = 0.0
  216. return self._position(
  217. params.x if params.x is not None else position.x,
  218. params.y if params.y is not None else position.y,
  219. params.z if params.z is not None else position.z,
  220. params.f if params.f is not None else position.f,
  221. position.e)
  222. def processGCode(self, G: int, line: str, position: Position, path: List[List[Union[float, int]]]) -> Position:
  223. func = getattr(self, "_gCode%s" % G, None)
  224. line = line.split(";", 1)[0] # Remove comments (if any)
  225. if func is not None:
  226. s = line.upper().split(" ")
  227. x, y, z, f, e = None, None, None, None, None
  228. for item in s[1:]:
  229. if len(item) <= 1:
  230. continue
  231. if item.startswith(";"):
  232. continue
  233. try:
  234. if item[0] == "X":
  235. x = float(item[1:])
  236. elif item[0] == "Y":
  237. y = float(item[1:])
  238. elif item[0] == "Z":
  239. z = float(item[1:])
  240. elif item[0] == "F":
  241. f = float(item[1:]) / 60
  242. elif item[0] == "E":
  243. e = float(item[1:])
  244. except ValueError: # Improperly formatted g-code: Coordinates are not floats.
  245. continue # Skip the command then.
  246. params = PositionOptional(x, y, z, f, e)
  247. return func(position, params, path)
  248. return position
  249. def processTCode(self, global_stack, T: int, line: str, position: Position, path: List[List[Union[float, int]]]) -> Position:
  250. self._extruder_number = T
  251. try:
  252. self._current_filament_diameter = global_stack.extruderList[self._extruder_number].getProperty("material_diameter", "value")
  253. except IndexError:
  254. self._current_filament_diameter = self.DEFAULT_FILAMENT_DIAMETER
  255. if self._extruder_number + 1 > len(position.e):
  256. self._extrusion_length_offset.extend([0] * (self._extruder_number - len(position.e) + 1))
  257. position.e.extend([0] * (self._extruder_number - len(position.e) + 1))
  258. return position
  259. def processMCode(self, M: int, line: str, position: Position, path: List[List[Union[float, int]]]) -> None:
  260. # Set extrusion mode
  261. if M == 82:
  262. # Set absolute extrusion mode
  263. self._is_absolute_extrusion = True
  264. elif M == 83:
  265. # Set relative extrusion mode
  266. self._is_absolute_extrusion = False
  267. _type_keyword = ";TYPE:"
  268. _layer_keyword = ";LAYER:"
  269. def _extruderOffsets(self) -> Dict[int, List[float]]:
  270. """For showing correct x, y offsets for each extruder"""
  271. result = {}
  272. for extruder in ExtruderManager.getInstance().getActiveExtruderStacks():
  273. result[int(extruder.getMetaData().get("position", "0"))] = [
  274. extruder.getProperty("machine_nozzle_offset_x", "value"),
  275. extruder.getProperty("machine_nozzle_offset_y", "value")]
  276. return result
  277. #
  278. # CURA-6643
  279. # This function needs the filename so it can be set to the SceneNode. Otherwise, if you load a GCode file and press
  280. # F5, that gcode SceneNode will be removed because it doesn't have a file to be reloaded from.
  281. #
  282. def processGCodeStream(self, stream: str, filename: str) -> Optional["CuraSceneNode"]:
  283. Logger.log("d", "Preparing to load g-code")
  284. self._cancelled = False
  285. # We obtain the filament diameter from the selected extruder to calculate line widths
  286. global_stack = CuraApplication.getInstance().getGlobalContainerStack()
  287. if not global_stack:
  288. return None
  289. try:
  290. self._current_filament_diameter = global_stack.extruderList[self._extruder_number].getProperty("material_diameter", "value")
  291. except IndexError:
  292. # There can be a mismatch between the number of extruders in the G-Code file and the number of extruders in the current machine.
  293. self._current_filament_diameter = self.DEFAULT_FILAMENT_DIAMETER
  294. scene_node = CuraSceneNode()
  295. gcode_list = []
  296. self._is_layers_in_file = False
  297. self._extruder_offsets = self._extruderOffsets() # dict with index the extruder number. can be empty
  298. ##############################################################################################
  299. ## This part is where the action starts
  300. ##############################################################################################
  301. file_lines = 0
  302. current_line = 0
  303. for line in stream.split("\n"):
  304. file_lines += 1
  305. gcode_list.append(line + "\n")
  306. if not self._is_layers_in_file and line[:len(self._layer_keyword)] == self._layer_keyword:
  307. self._is_layers_in_file = True
  308. file_step = max(math.floor(file_lines / 100), 1)
  309. self._clearValues()
  310. self._message = Message(catalog.i18nc("@info:status", "Parsing G-code"),
  311. lifetime=0,
  312. title = catalog.i18nc("@info:title", "G-code Details"))
  313. assert(self._message is not None) # use for typing purposes
  314. self._message.setProgress(0)
  315. self._message.show()
  316. Logger.log("d", "Parsing g-code...")
  317. current_position = Position(0, 0, 0, 0, [0] * self.MAX_EXTRUDER_COUNT)
  318. current_path = [] #type: List[List[float]]
  319. min_layer_number = 0
  320. negative_layers = 0
  321. previous_layer = 0
  322. self._previous_extrusion_value = 0.0
  323. for line in stream.split("\n"):
  324. if self._cancelled:
  325. Logger.log("d", "Parsing g-code file cancelled.")
  326. return None
  327. current_line += 1
  328. if current_line % file_step == 0:
  329. self._message.setProgress(math.floor(current_line / file_lines * 100))
  330. Job.yieldThread()
  331. if len(line) == 0:
  332. continue
  333. if line.find(self._type_keyword) == 0:
  334. type = line[len(self._type_keyword):].strip()
  335. if type == "WALL-INNER":
  336. self._layer_type = LayerPolygon.InsetXType
  337. elif type == "WALL-OUTER":
  338. self._layer_type = LayerPolygon.Inset0Type
  339. elif type == "SKIN":
  340. self._layer_type = LayerPolygon.SkinType
  341. elif type == "SKIRT":
  342. self._layer_type = LayerPolygon.SkirtType
  343. elif type == "SUPPORT":
  344. self._layer_type = LayerPolygon.SupportType
  345. elif type == "FILL":
  346. self._layer_type = LayerPolygon.InfillType
  347. elif type == "SUPPORT-INTERFACE":
  348. self._layer_type = LayerPolygon.SupportInterfaceType
  349. elif type == "PRIME-TOWER":
  350. self._layer_type = LayerPolygon.PrimeTowerType
  351. else:
  352. Logger.log("w", "Encountered a unknown type (%s) while parsing g-code.", type)
  353. # When the layer change is reached, the polygon is computed so we have just one layer per extruder
  354. if self._is_layers_in_file and line[:len(self._layer_keyword)] == self._layer_keyword:
  355. try:
  356. layer_number = int(line[len(self._layer_keyword):])
  357. self._createPolygon(self._current_layer_thickness, current_path, self._extruder_offsets.get(self._extruder_number, [0, 0]))
  358. current_path.clear()
  359. # Start the new layer at the end position of the last layer
  360. current_path.append([current_position.x, current_position.y, current_position.z, current_position.f, current_position.e[self._extruder_number], LayerPolygon.MoveCombingType])
  361. # When using a raft, the raft layers are stored as layers < 0, it mimics the same behavior
  362. # as in ProcessSlicedLayersJob
  363. if layer_number < min_layer_number:
  364. min_layer_number = layer_number
  365. if layer_number < 0:
  366. layer_number += abs(min_layer_number)
  367. negative_layers += 1
  368. else:
  369. layer_number += negative_layers
  370. # In case there is a gap in the layer count, empty layers are created
  371. for empty_layer in range(previous_layer + 1, layer_number):
  372. self._createEmptyLayer(empty_layer)
  373. self._layer_number = layer_number
  374. previous_layer = layer_number
  375. except:
  376. pass
  377. # This line is a comment. Ignore it (except for the layer_keyword)
  378. if line.startswith(";"):
  379. continue
  380. G = self._getInt(line, "G")
  381. if G is not None:
  382. # When find a movement, the new position is calculated and added to the current_path, but
  383. # don't need to create a polygon until the end of the layer
  384. current_position = self.processGCode(G, line, current_position, current_path)
  385. continue
  386. # When changing the extruder, the polygon with the stored paths is computed
  387. if line.startswith("T"):
  388. T = self._getInt(line, "T")
  389. if T is not None:
  390. self._extruders_seen.add(T)
  391. self._createPolygon(self._current_layer_thickness, current_path, self._extruder_offsets.get(self._extruder_number, [0, 0]))
  392. current_path.clear()
  393. # When changing tool, store the end point of the previous path, then process the code and finally
  394. # add another point with the new position of the head.
  395. current_path.append([current_position.x, current_position.y, current_position.z, current_position.f, current_position.e[self._extruder_number], LayerPolygon.MoveCombingType])
  396. current_position = self.processTCode(global_stack, T, line, current_position, current_path)
  397. current_path.append([current_position.x, current_position.y, current_position.z, current_position.f, current_position.e[self._extruder_number], LayerPolygon.MoveCombingType])
  398. if line.startswith("M"):
  399. M = self._getInt(line, "M")
  400. if M is not None:
  401. self.processMCode(M, line, current_position, current_path)
  402. # "Flush" leftovers. Last layer paths are still stored
  403. if len(current_path) > 1:
  404. if self._createPolygon(self._current_layer_thickness, current_path, self._extruder_offsets.get(self._extruder_number, [0, 0])):
  405. self._layer_number += 1
  406. current_path.clear()
  407. material_color_map = numpy.zeros((8, 4), dtype = numpy.float32)
  408. material_color_map[0, :] = [0.0, 0.7, 0.9, 1.0]
  409. material_color_map[1, :] = [0.7, 0.9, 0.0, 1.0]
  410. material_color_map[2, :] = [0.9, 0.0, 0.7, 1.0]
  411. material_color_map[3, :] = [0.7, 0.0, 0.0, 1.0]
  412. material_color_map[4, :] = [0.0, 0.7, 0.0, 1.0]
  413. material_color_map[5, :] = [0.0, 0.0, 0.7, 1.0]
  414. material_color_map[6, :] = [0.3, 0.3, 0.3, 1.0]
  415. material_color_map[7, :] = [0.7, 0.7, 0.7, 1.0]
  416. layer_mesh = self._layer_data_builder.build(material_color_map)
  417. decorator = LayerDataDecorator()
  418. decorator.setLayerData(layer_mesh)
  419. scene_node.addDecorator(decorator)
  420. gcode_list_decorator = GCodeListDecorator()
  421. gcode_list_decorator.setGcodeFileName(filename)
  422. gcode_list_decorator.setGCodeList(gcode_list)
  423. scene_node.addDecorator(gcode_list_decorator)
  424. # gcode_dict stores gcode_lists for a number of build plates.
  425. active_build_plate_id = CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate
  426. gcode_dict = {active_build_plate_id: gcode_list}
  427. CuraApplication.getInstance().getController().getScene().gcode_dict = gcode_dict #type: ignore #Because gcode_dict is generated dynamically.
  428. Logger.log("d", "Finished parsing g-code.")
  429. self._message.hide()
  430. if self._layer_number == 0:
  431. Logger.log("w", "File doesn't contain any valid layers")
  432. if not global_stack.getProperty("machine_center_is_zero", "value"):
  433. machine_width = global_stack.getProperty("machine_width", "value")
  434. machine_depth = global_stack.getProperty("machine_depth", "value")
  435. scene_node.setPosition(Vector(-machine_width / 2, 0, machine_depth / 2))
  436. Logger.log("d", "G-code loading finished.")
  437. if CuraApplication.getInstance().getPreferences().getValue("gcodereader/show_caution"):
  438. caution_message = Message(catalog.i18nc(
  439. "@info:generic",
  440. "Make sure the g-code is suitable for your printer and printer configuration before sending the file to it. The g-code representation may not be accurate."),
  441. lifetime=0,
  442. title = catalog.i18nc("@info:title", "G-code Details"),
  443. message_type = Message.MessageType.WARNING)
  444. caution_message.show()
  445. # The "save/print" button's state is bound to the backend state.
  446. backend = CuraApplication.getInstance().getBackend()
  447. backend.backendStateChange.emit(Backend.BackendState.Disabled)
  448. return scene_node