From 87b379d2c54e4bea5f12c4714f8a04f5f338dd39 Mon Sep 17 00:00:00 2001 From: Reid 'arrdem' McKenzie Date: Sat, 3 Jun 2023 15:39:34 -0600 Subject: [PATCH] Add the interpreter --- projects/gcode-interpreter/BUILD.baezl | 3 + projects/gcode-interpreter/README.md | 15 + .../src/python/gcode_interpreter.py | 868 ++++++++++++++++++ 3 files changed, 886 insertions(+) create mode 100644 projects/gcode-interpreter/BUILD.baezl create mode 100644 projects/gcode-interpreter/README.md create mode 100644 projects/gcode-interpreter/src/python/gcode_interpreter.py diff --git a/projects/gcode-interpreter/BUILD.baezl b/projects/gcode-interpreter/BUILD.baezl new file mode 100644 index 0000000..f78e44e --- /dev/null +++ b/projects/gcode-interpreter/BUILD.baezl @@ -0,0 +1,3 @@ +py_project( + name = "gcode-interpreter", +) diff --git a/projects/gcode-interpreter/README.md b/projects/gcode-interpreter/README.md new file mode 100644 index 0000000..7b8386d --- /dev/null +++ b/projects/gcode-interpreter/README.md @@ -0,0 +1,15 @@ +# Gcode Interpreter + +Extracted [from +octoprint](https://raw.githubusercontent.com/OctoPrint/OctoPrint/master/src/octoprint/util/gcodeInterpreter.py), this +package provides static analysis (ahem. abstract interpretation) of GCODE scripts for 3d printers to provide key data +such as the bounding box through which the tool(s) move, estimated net movement time and the net amount of material +extruded. + +## License + +This artifact is licensed under the GNU Affero General Public License http://www.gnu.org/licenses/agpl.html + +Copyright © Reid McKenzie +Copyright © Gina Häußge +Copyright © David Braam diff --git a/projects/gcode-interpreter/src/python/gcode_interpreter.py b/projects/gcode-interpreter/src/python/gcode_interpreter.py new file mode 100644 index 0000000..8f07739 --- /dev/null +++ b/projects/gcode-interpreter/src/python/gcode_interpreter.py @@ -0,0 +1,868 @@ +__author__ = "Gina Häußge based on work by David Braam" +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2013 David Braam, Gina Häußge - Released under terms of the AGPLv3 License" + + +import base64 +import codecs +import io +import logging +import math +import os +import re +import zlib + + +class Vector3D: + """ + 3D vector value + + Supports addition, subtraction and multiplication with a scalar value (float, int) as well as calculating the + length of the vector. + + Examples: + + >>> a = Vector3D(1.0, 1.0, 1.0) + >>> b = Vector3D(4.0, 4.0, 4.0) + >>> a + b == Vector3D(5.0, 5.0, 5.0) + True + >>> b - a == Vector3D(3.0, 3.0, 3.0) + True + >>> abs(a - b) == Vector3D(3.0, 3.0, 3.0) + True + >>> a * 2 == Vector3D(2.0, 2.0, 2.0) + True + >>> a * 2 == 2 * a + True + >>> a.length == math.sqrt(a.x ** 2 + a.y ** 2 + a.z ** 2) + True + >>> copied_a = Vector3D(a) + >>> a == copied_a + True + >>> copied_a.x == a.x and copied_a.y == a.y and copied_a.z == a.z + True + """ + + def __init__(self, *args): + if len(args) == 3: + (self.x, self.y, self.z) = args + + elif len(args) == 1: + # copy constructor + other = args[0] + if not isinstance(other, Vector3D): + raise ValueError("Object to copy must be a Vector3D instance") + + self.x = other.x + self.y = other.y + self.z = other.z + + @property + def length(self): + return math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z) + + def __add__(self, other): + try: + if len(other) == 3: + return Vector3D(self.x + other[0], self.y + other[1], self.z + other[2]) + except TypeError: + # doesn't look like a 3-tuple + pass + + try: + return Vector3D(self.x + other.x, self.y + other.y, self.z + other.z) + except AttributeError: + # also doesn't look like a Vector3D + pass + + raise TypeError( + "other must be a Vector3D instance or a list or tuple of length 3" + ) + + def __sub__(self, other): + try: + if len(other) == 3: + return Vector3D(self.x - other[0], self.y - other[1], self.z - other[2]) + except TypeError: + # doesn't look like a 3-tuple + pass + + try: + return Vector3D(self.x - other.x, self.y - other.y, self.z - other.z) + except AttributeError: + # also doesn't look like a Vector3D + pass + + raise TypeError( + "other must be a Vector3D instance or a list or tuple of length 3" + ) + + def __mul__(self, other): + try: + return Vector3D(self.x * other, self.y * other, self.z * other) + except TypeError: + # doesn't look like a scalar + pass + + raise ValueError("other must be a float or int value") + + def __rmul__(self, other): + return self.__mul__(other) + + def __abs__(self): + return Vector3D(abs(self.x), abs(self.y), abs(self.z)) + + def __eq__(self, other): + if not isinstance(other, Vector3D): + return False + return self.x == other.x and self.y == other.y and self.z == other.z + + def __str__(self): + return "Vector3D(x={}, y={}, z={}, length={})".format( + self.x, self.y, self.z, self.length + ) + + +class MinMax3D: + """ + Tracks minimum and maximum of recorded values + + Examples: + + >>> minmax = MinMax3D() + >>> minmax.record(Vector3D(2.0, 2.0, 2.0)) + >>> minmax.min.x == 2.0 == minmax.max.x and minmax.min.y == 2.0 == minmax.max.y and minmax.min.z == 2.0 == minmax.max.z + True + >>> minmax.record(Vector3D(1.0, 2.0, 3.0)) + >>> minmax.min.x == 1.0 and minmax.min.y == 2.0 and minmax.min.z == 2.0 + True + >>> minmax.max.x == 2.0 and minmax.max.y == 2.0 and minmax.max.z == 3.0 + True + >>> minmax.size == Vector3D(1.0, 0.0, 1.0) + True + >>> empty = MinMax3D() + >>> empty.size == Vector3D(0.0, 0.0, 0.0) + True + >>> weird = MinMax3D(min_z=-1.0) + >>> weird.record(Vector3D(2.0, 2.0, 2.0)) + >>> weird.record(Vector3D(1.0, 2.0, 3.0)) + >>> weird.min.z == -1.0 + True + >>> weird.size == Vector3D(1.0, 0.0, 4.0) + True + """ + + def __init__( + self, + min_x=None, + min_y=None, + min_z=None, + max_x=None, + max_y=None, + max_z=None, + ): + min_x = min_x if min_x is not None else float("inf") + min_y = min_y if min_y is not None else float("inf") + min_z = min_z if min_z is not None else float("inf") + max_x = max_x if max_x is not None else -float("inf") + max_y = max_y if max_y is not None else -float("inf") + max_z = max_z if max_z is not None else -float("inf") + + self.min = Vector3D(min_x, min_y, min_z) + self.max = Vector3D(max_x, max_y, max_z) + + def record(self, coordinate): + """ + Records the coordinate, storing the min and max values. + + The input vector components must not be None. + """ + self.min.x = min(self.min.x, coordinate.x) + self.min.y = min(self.min.y, coordinate.y) + self.min.z = min(self.min.z, coordinate.z) + self.max.x = max(self.max.x, coordinate.x) + self.max.y = max(self.max.y, coordinate.y) + self.max.z = max(self.max.z, coordinate.z) + + @property + def size(self): + result = Vector3D() + for c in "xyz": + min = getattr(self.min, c) + max = getattr(self.max, c) + value = abs(max - min) if max >= min else 0.0 + setattr(result, c, value) + return result + + @property + def dimensions(self): + size = self.size + return {"width": size.x, "depth": size.y, "height": size.z} + + @property + def area(self): + return { + "minX": None if math.isinf(self.min.x) else self.min.x, + "minY": None if math.isinf(self.min.y) else self.min.y, + "minZ": None if math.isinf(self.min.z) else self.min.z, + "maxX": None if math.isinf(self.max.x) else self.max.x, + "maxY": None if math.isinf(self.max.y) else self.max.y, + "maxZ": None if math.isinf(self.max.z) else self.max.z, + } + + +class AnalysisAborted(Exception): + def __init__(self, reenqueue=True, *args, **kwargs): + self.reenqueue = reenqueue + Exception.__init__(self, *args, **kwargs) + + +regex_command = re.compile( + r"^\s*((?P[GM]\d+)(\.(?P\d+))?|(?PT)(?P\d+))" +) +"""Regex for a GCODE command.""" + + +class gcode: + def __init__(self, incl_layers=False, progress_callback=None): + self._logger = logging.getLogger(__name__) + self.extrusionAmount = [0] + self.extrusionVolume = [0] + self.totalMoveTimeMinute = 0 + self.filename = None + self._abort = False + self._reenqueue = True + self._filamentDiameter = 0 + self._print_minMax = MinMax3D() + self._travel_minMax = MinMax3D() + self._progress_callback = progress_callback + + self._incl_layers = incl_layers + self._layers = [] + self._current_layer = None + + def _track_layer(self, pos, arc=None): + if not self._incl_layers: + return + + if self._current_layer is None or self._current_layer["z"] != pos.z: + self._current_layer = {"z": pos.z, "minmax": MinMax3D(), "commands": 1} + self._layers.append(self._current_layer) + + elif self._current_layer: + self._current_layer["minmax"].record(pos) + if arc is not None: + self._addArcMinMax( + self._current_layer["minmax"], + arc["startAngle"], + arc["endAngle"], + arc["center"], + arc["radius"], + ) + + def _track_command(self): + if self._current_layer: + self._current_layer["commands"] += 1 + + @property + def dimensions(self): + return self._print_minMax.dimensions + + @property + def travel_dimensions(self): + return self._travel_minMax.dimensions + + @property + def printing_area(self): + return self._print_minMax.area + + @property + def travel_area(self): + return self._travel_minMax.area + + @property + def layers(self): + return [ + { + "num": num + 1, + "z": layer["z"], + "commands": layer["commands"], + "bounds": { + "minX": layer["minmax"].min.x, + "maxX": layer["minmax"].max.x, + "minY": layer["minmax"].min.y, + "maxY": layer["minmax"].max.y, + }, + } + for num, layer in enumerate(self._layers) + ] + + def load( + self, + filename, + throttle=None, + speedx=6000, + speedy=6000, + offsets=None, + max_extruders=10, + g90_extruder=False, + bed_z=0.0, + ): + self._print_minMax.min.z = self._travel_minMax.min.z = bed_z + if os.path.isfile(filename): + self.filename = filename + self._fileSize = os.stat(filename).st_size + + with codecs.open(filename, encoding="utf-8", errors="replace") as f: + self._load( + f, + throttle=throttle, + speedx=speedx, + speedy=speedy, + offsets=offsets, + max_extruders=max_extruders, + g90_extruder=g90_extruder, + ) + + def abort(self, reenqueue=True): + self._abort = True + self._reenqueue = reenqueue + + def _load( + self, + gcodeFile, + throttle=None, + speedx=6000, + speedy=6000, + offsets=None, + max_extruders=10, + g90_extruder=False, + ): + lineNo = 0 + readBytes = 0 + pos = Vector3D(0.0, 0.0, 0.0) + currentE = [0.0] + totalExtrusion = [0.0] + maxExtrusion = [0.0] + currentExtruder = 0 + totalMoveTimeMinute = 0.0 + relativeE = False + relativeMode = False + duplicationMode = False + scale = 1.0 + fwretractTime = 0 + fwretractDist = 0 + fwrecoverTime = 0 + feedrate = min(speedx, speedy) + if feedrate == 0: + # some somewhat sane default if axes speeds are insane... + feedrate = 2000 + + if offsets is None or not isinstance(offsets, (list, tuple)): + offsets = [] + if len(offsets) < max_extruders: + offsets += [(0, 0)] * (max_extruders - len(offsets)) + + for line in gcodeFile: + if self._abort: + raise AnalysisAborted(reenqueue=self._reenqueue) + lineNo += 1 + readBytes += len(line.encode("utf-8")) + + if isinstance(gcodeFile, (io.IOBase, codecs.StreamReaderWriter)): + percentage = readBytes / self._fileSize + elif isinstance(gcodeFile, (list)): + percentage = lineNo / len(gcodeFile) + else: + percentage = None + + try: + if ( + self._progress_callback is not None + and (lineNo % 1000 == 0) + and percentage is not None + ): + self._progress_callback(percentage) + except Exception as exc: + self._logger.debug( + "Progress callback %r error: %s", self._progress_callback, exc + ) + + if ";" in line: + comment = line[line.find(";") + 1 :].strip() + if comment.startswith("filament_diameter"): + # Slic3r + filamentValue = comment.split("=", 1)[1].strip() + try: + self._filamentDiameter = float(filamentValue) + except ValueError: + try: + self._filamentDiameter = float( + filamentValue.split(",")[0].strip() + ) + except ValueError: + self._filamentDiameter = 0.0 + elif comment.startswith("CURA_PROFILE_STRING") or comment.startswith( + "CURA_OCTO_PROFILE_STRING" + ): + # Cura 15.04.* & OctoPrint Cura plugin + if comment.startswith("CURA_PROFILE_STRING"): + prefix = "CURA_PROFILE_STRING:" + else: + prefix = "CURA_OCTO_PROFILE_STRING:" + + curaOptions = self._parseCuraProfileString(comment, prefix) + if "filament_diameter" in curaOptions: + try: + self._filamentDiameter = float( + curaOptions["filament_diameter"] + ) + except ValueError: + self._filamentDiameter = 0.0 + elif comment.startswith("filamentDiameter,"): + # Simplify3D + filamentValue = comment.split(",", 1)[1].strip() + try: + self._filamentDiameter = float(filamentValue) + except ValueError: + self._filamentDiameter = 0.0 + line = line[0 : line.find(";")] + + match = regex_command.search(line) + gcode = tool = None + if match: + values = match.groupdict() + if "codeGM" in values and values["codeGM"]: + gcode = values["codeGM"] + elif "codeT" in values and values["codeT"]: + gcode = values["codeT"] + tool = int(values["tool"]) + + # G codes + if gcode in ("G0", "G1", "G00", "G01"): # Move + x = getCodeFloat(line, "X") + y = getCodeFloat(line, "Y") + z = getCodeFloat(line, "Z") + e = getCodeFloat(line, "E") + f = getCodeFloat(line, "F") + + if x is not None or y is not None or z is not None: + # this is a move + move = True + else: + # print head stays on position + move = False + + oldPos = pos + + # Use new coordinates if provided. If not provided, use prior coordinates (minus tool offset) + # in absolute and 0.0 in relative mode. + newPos = Vector3D( + x * scale if x is not None else (0.0 if relativeMode else pos.x), + y * scale if y is not None else (0.0 if relativeMode else pos.y), + z * scale if z is not None else (0.0 if relativeMode else pos.z), + ) + + if relativeMode: + # Relative mode: add to current position + pos += newPos + else: + # Absolute mode: apply tool offsets + pos = newPos + + if f is not None and f != 0: + feedrate = f + + if e is not None: + if relativeMode or relativeE: + # e is already relative, nothing to do + pass + else: + e -= currentE[currentExtruder] + + totalExtrusion[currentExtruder] += e + currentE[currentExtruder] += e + maxExtrusion[currentExtruder] = max( + maxExtrusion[currentExtruder], totalExtrusion[currentExtruder] + ) + + if currentExtruder == 0 and len(currentE) > 1 and duplicationMode: + # Copy first extruder length to other extruders + for i in range(1, len(currentE)): + totalExtrusion[i] += e + currentE[i] += e + maxExtrusion[i] = max(maxExtrusion[i], totalExtrusion[i]) + else: + e = 0 + + # If move, calculate new min/max coordinates + if move: + self._travel_minMax.record(oldPos) + self._travel_minMax.record(pos) + if e > 0: + # store as print move if extrusion is > 0 + self._print_minMax.record(oldPos) + self._print_minMax.record(pos) + + # move time in x, y, z, will be 0 if no movement happened + moveTimeXYZ = abs((oldPos - pos).length / feedrate) + + # time needed for extruding, will be 0 if no extrusion happened + extrudeTime = abs(e / feedrate) + + # time to add is maximum of both + totalMoveTimeMinute += max(moveTimeXYZ, extrudeTime) + + # process layers if there's extrusion + if e: + self._track_layer(pos) + + if gcode in ("G2", "G3", "G02", "G03"): # Arc Move + x = getCodeFloat(line, "X") + y = getCodeFloat(line, "Y") + z = getCodeFloat(line, "Z") + e = getCodeFloat(line, "E") + i = getCodeFloat(line, "I") + j = getCodeFloat(line, "J") + r = getCodeFloat(line, "R") + f = getCodeFloat(line, "F") + + # this is a move or print head stays on position + move = ( + x is not None + or y is not None + or z is not None + or i is not None + or j is not None + or r is not None + ) + + oldPos = pos + + # Use new coordinates if provided. If not provided, use prior coordinates (minus tool offset) + # in absolute and 0.0 in relative mode. + newPos = Vector3D( + x * scale if x is not None else (0.0 if relativeMode else pos.x), + y * scale if y is not None else (0.0 if relativeMode else pos.y), + z * scale if z is not None else (0.0 if relativeMode else pos.z), + ) + + if relativeMode: + # Relative mode: add to current position + pos += newPos + else: + # Absolute mode: apply tool offsets + pos = newPos + + if f is not None and f != 0: + feedrate = f + + # get radius and offset + i = 0 if i is None else i + j = 0 if j is None else j + r = math.sqrt(i * i + j * j) if r is None else r + + # calculate angles + centerArc = Vector3D(oldPos.x + i, oldPos.y + j, oldPos.z) + startAngle = math.atan2(oldPos.y - centerArc.y, oldPos.x - centerArc.x) + endAngle = math.atan2(pos.y - centerArc.y, pos.x - centerArc.x) + arcAngle = endAngle - startAngle + + if gcode in ("G2", "G02"): + startAngle, endAngle = endAngle, startAngle + arcAngle = -arcAngle + if startAngle < 0: + startAngle += math.pi * 2 + if endAngle < 0: + endAngle += math.pi * 2 + if arcAngle < 0: + arcAngle += math.pi * 2 + + # from now on we only think in counter-clockwise direction + + if e is not None: + if relativeMode or relativeE: + # e is already relative, nothing to do + pass + else: + e -= currentE[currentExtruder] + + totalExtrusion[currentExtruder] += e + currentE[currentExtruder] += e + maxExtrusion[currentExtruder] = max( + maxExtrusion[currentExtruder], totalExtrusion[currentExtruder] + ) + + if currentExtruder == 0 and len(currentE) > 1 and duplicationMode: + # Copy first extruder length to other extruders + for i in range(1, len(currentE)): + totalExtrusion[i] += e + currentE[i] += e + maxExtrusion[i] = max(maxExtrusion[i], totalExtrusion[i]) + else: + e = 0 + + # If move, calculate new min/max coordinates + if move: + self._travel_minMax.record(oldPos) + self._travel_minMax.record(pos) + self._addArcMinMax( + self._travel_minMax, startAngle, endAngle, centerArc, r + ) + if e > 0: + # store as print move if extrusion is > 0 + self._print_minMax.record(oldPos) + self._print_minMax.record(pos) + self._addArcMinMax( + self._print_minMax, startAngle, endAngle, centerArc, r + ) + + # calculate 3d arc length + arcLengthXYZ = math.sqrt((oldPos.z - pos.z) ** 2 + (arcAngle * r) ** 2) + + # move time in x, y, z, will be 0 if no movement happened + moveTimeXYZ = abs(arcLengthXYZ / feedrate) + + # time needed for extruding, will be 0 if no extrusion happened + extrudeTime = abs(e / feedrate) + + # time to add is maximum of both + totalMoveTimeMinute += max(moveTimeXYZ, extrudeTime) + + # process layers if there's extrusion + if e: + self._track_layer( + pos, + { + "startAngle": startAngle, + "endAngle": endAngle, + "center": centerArc, + "radius": r, + }, + ) + + elif gcode == "G4": # Delay + S = getCodeFloat(line, "S") + if S is not None: + totalMoveTimeMinute += S / 60 + P = getCodeFloat(line, "P") + if P is not None: + totalMoveTimeMinute += P / 60 / 1000 + elif gcode == "G10": # Firmware retract + totalMoveTimeMinute += fwretractTime + elif gcode == "G11": # Firmware retract recover + totalMoveTimeMinute += fwrecoverTime + elif gcode == "G20": # Units are inches + scale = 25.4 + elif gcode == "G21": # Units are mm + scale = 1.0 + elif gcode == "G28": # Home + x = getCodeFloat(line, "X") + y = getCodeFloat(line, "Y") + z = getCodeFloat(line, "Z") + origin = Vector3D(0.0, 0.0, 0.0) + if x is None and y is None and z is None: + pos = origin + else: + pos = Vector3D(pos) + if x is not None: + pos.x = origin.x + if y is not None: + pos.y = origin.y + if z is not None: + pos.z = origin.z + elif gcode == "G90": # Absolute position + relativeMode = False + if g90_extruder: + relativeE = False + elif gcode == "G91": # Relative position + relativeMode = True + if g90_extruder: + relativeE = True + elif gcode == "G92": + x = getCodeFloat(line, "X") + y = getCodeFloat(line, "Y") + z = getCodeFloat(line, "Z") + e = getCodeFloat(line, "E") + + if e is None and x is None and y is None and z is None: + # no parameters, set all axis to 0 + currentE[currentExtruder] = 0.0 + pos.x = 0.0 + pos.y = 0.0 + pos.z = 0.0 + else: + # some parameters set, only set provided axes + if e is not None: + currentE[currentExtruder] = e + if x is not None: + pos.x = x + if y is not None: + pos.y = y + if z is not None: + pos.z = z + # M codes + elif gcode == "M82": # Absolute E + relativeE = False + elif gcode == "M83": # Relative E + relativeE = True + elif gcode in ("M207", "M208"): # Firmware retract settings + s = getCodeFloat(line, "S") + f = getCodeFloat(line, "F") + if s is not None and f is not None: + if gcode == "M207": + # Ensure division is valid + if f > 0: + fwretractTime = s / f + else: + fwretractTime = 0 + fwretractDist = s + else: + if f > 0: + fwrecoverTime = (fwretractDist + s) / f + else: + fwrecoverTime = 0 + elif gcode == "M605": # Duplication/Mirroring mode + s = getCodeInt(line, "S") + if s in [2, 4, 5, 6]: + # Duplication / Mirroring mode selected. Printer firmware copies extrusion commands + # from first extruder to all other extruders + duplicationMode = True + else: + duplicationMode = False + + # T codes + elif tool is not None: + if tool > max_extruders: + self._logger.warning( + "GCODE tried to select tool %d, that looks wrong, ignoring for GCODE analysis" + % tool + ) + elif tool == currentExtruder: + pass + else: + pos.x -= ( + offsets[currentExtruder][0] + if currentExtruder < len(offsets) + else 0 + ) + pos.y -= ( + offsets[currentExtruder][1] + if currentExtruder < len(offsets) + else 0 + ) + + currentExtruder = tool + + pos.x += ( + offsets[currentExtruder][0] + if currentExtruder < len(offsets) + else 0 + ) + pos.y += ( + offsets[currentExtruder][1] + if currentExtruder < len(offsets) + else 0 + ) + + if len(currentE) <= currentExtruder: + for _ in range(len(currentE), currentExtruder + 1): + currentE.append(0.0) + if len(maxExtrusion) <= currentExtruder: + for _ in range(len(maxExtrusion), currentExtruder + 1): + maxExtrusion.append(0.0) + if len(totalExtrusion) <= currentExtruder: + for _ in range(len(totalExtrusion), currentExtruder + 1): + totalExtrusion.append(0.0) + + if gcode or tool: + self._track_command() + + if throttle is not None: + throttle(lineNo, readBytes) + if self._progress_callback is not None: + self._progress_callback(100.0) + + self.extrusionAmount = maxExtrusion + self.extrusionVolume = [0] * len(maxExtrusion) + for i in range(len(maxExtrusion)): + radius = self._filamentDiameter / 2 + self.extrusionVolume[i] = ( + self.extrusionAmount[i] * (math.pi * radius * radius) + ) / 1000 + self.totalMoveTimeMinute = totalMoveTimeMinute + + def _parseCuraProfileString(self, comment, prefix): + return { + key: value + for (key, value) in map( + lambda x: x.split(b"=", 1), + zlib.decompress(base64.b64decode(comment[len(prefix) :])).split(b"\b"), + ) + } + + def _intersectsAngle(self, start, end, angle): + if end < start and angle == 0: + # angle crosses 0 degrees + return True + else: + return start <= angle <= end + + def _addArcMinMax(self, minmax, startAngle, endAngle, centerArc, radius): + startDeg = math.degrees(startAngle) + endDeg = math.degrees(endAngle) + + if self._intersectsAngle(startDeg, endDeg, 0): + # arc crosses positive x + minmax.max.x = max(minmax.max.x, centerArc.x + radius) + if self._intersectsAngle(startDeg, endDeg, 90): + # arc crosses positive y + minmax.max.y = max(minmax.max.y, centerArc.y + radius) + if self._intersectsAngle(startDeg, endDeg, 180): + # arc crosses negative x + minmax.min.x = min(minmax.min.x, centerArc.x - radius) + if self._intersectsAngle(startDeg, endDeg, 270): + # arc crosses negative y + minmax.min.y = min(minmax.min.y, centerArc.y - radius) + + def get_result(self): + result = { + "total_time": self.totalMoveTimeMinute, + "extrusion_length": self.extrusionAmount, + "extrusion_volume": self.extrusionVolume, + "dimensions": self.dimensions, + "printing_area": self.printing_area, + "travel_dimensions": self.travel_dimensions, + "travel_area": self.travel_area, + } + if self._incl_layers: + result["layers"] = self.layers + + return result + + +def getCodeInt(line, code): + return getCode(line, code, int) + + +def getCodeFloat(line, code): + return getCode(line, code, float) + + +def getCode(line, code, c): + n = line.find(code) + 1 + if n < 1: + return None + m = line.find(" ", n) + try: + if m < 0: + result = c(line[n:]) + else: + result = c(line[n:m]) + except ValueError: + return None + + if math.isnan(result) or math.isinf(result): + return None + + return result