__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