From 4a7bde4571869a4eba4ca67a4e975f7145ed50ac Mon Sep 17 00:00:00 2001 From: Wes Hanney Date: Mon, 19 Jan 2026 22:17:04 -0500 Subject: [PATCH] Fixed issues #8574 and #8886 where ChangeAtZ would not properly apply (or change back) certain values. Z targeting now works by determining the target layer based on the minimum Z for a given layer. Added support for layer and Z ranges. Added support for reading layer height from project settings. Kept backwards compatibility with older configs of ChangeAtZ. --- .../PostProcessingPlugin/scripts/ChangeAtZ.py | 1272 ++++++++++------- 1 file changed, 734 insertions(+), 538 deletions(-) diff --git a/plugins/PostProcessingPlugin/scripts/ChangeAtZ.py b/plugins/PostProcessingPlugin/scripts/ChangeAtZ.py index 2930623a933..fdbf91e0a21 100644 --- a/plugins/PostProcessingPlugin/scripts/ChangeAtZ.py +++ b/plugins/PostProcessingPlugin/scripts/ChangeAtZ.py @@ -43,8 +43,7 @@ # class for better detection of G1 vs G10 or G11 commands, and accessing arguments. Moved most GCode methods to GCodeCommand class. Improved wording # of Single Layer vs Keep Layer to better reflect what was happening. # V5.3.0 Alex Jaxon, Added option to modify Build Volume Temperature keeping current format -# - +# V5.4.0 Wes Hanney. Fixed issues #8574 and #8886 where the script would not properly apply (or change back) certain values. Z targeting now works by determining the target layer based on the minimum Z for a given layer. Added support for layer and Z ranges. Added support for reading layer height from project settings. # Uses - @@ -57,18 +56,31 @@ # M207 S F - set the retract length or feed rate # M117 - output the current changes -from typing import List, Dict from ..Script import Script +from math import floor +from tempfile import mkdtemp +from UM.Application import Application +from UM.Logger import Logger +import copy +import json +import os.path import re +temp_dir = mkdtemp("", "ChangeAtZ") +os.makedirs(temp_dir, exist_ok=True) + +Logger.info("ChangeAtZ dir: %s", temp_dir) + +executions = 0 + # this was broken up into a separate class so the main ChangeAtZ script could be debugged outside of Cura class ChangeAtZ(Script): - version = "5.3.0" + version = "5.4.0" def getSettingDataString(self): return """{ - "name": "ChangeAtZ """ + self.version + """(Experimental)", + "name": "ChangeAtZ """ + self.version + """ (Experimental)", "key": "ChangeAtZ", "metadata": {}, "version": 2, @@ -90,8 +102,8 @@ def getSettingDataString(self): "default_value": "height" }, "b_targetZ": { - "label": "Change Height", - "description": "Z height to change at", + "label": "Start Height (mm)", + "description": "The Z height to start apply the changes to", "unit": "mm", "type": "float", "default_value": 5.0, @@ -100,16 +112,34 @@ def getSettingDataString(self): "maximum_value_warning": "230", "enabled": "a_trigger == 'height'" }, + "b_targetZEnd": { + "label": "End Height (mm)", + "description": "Optional. The modifications will go up to and include this Z height", + "unit": "mm", + "type": "float", + "default_value": -1.0, + "minimum_value": "-1.0", + "enabled": "a_trigger == 'height'" + }, "b_targetL": { - "label": "Change Layer", - "description": "Layer no. to change at", - "unit": "", + "label": "Start Layer (#)", + "description": "The layer number, starting at 0, to apply the changes to", + "unit": "#", "type": "int", "default_value": 1, "minimum_value": "-100", "minimum_value_warning": "-1", "enabled": "a_trigger == 'layer_no'" }, + "b_targetLEnd": { + "label": "End Layer (#)", + "description": "Optional. The modifications will go up to and include this layer number", + "unit": "#", + "type": "int", + "default_value": -1, + "minimum_value": "-1", + "enabled": "a_trigger == 'layer_no'" + }, "c_behavior": { "label": "Apply To", "description": "Target Layer + Subsequent Layers is good for testing changes between ranges of layers, ex: Layer 0 to 10 or 0mm to 5mm. Single layer is good for testing changes at a single layer, ex: at Layer 10 or 5mm only.", @@ -127,14 +157,14 @@ def getSettingDataString(self): "default_value": false }, "e1_Change_speed": { - "label": "Change Speed", - "description": "Select if total speed (print and travel) has to be changed", + "label": "Change Global Speed", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false }, "e2_speed": { - "label": "Speed", - "description": "New total speed (print and travel)", + "label": "Global Speed (%)", + "description": "Sets the M220 value", "unit": "%", "type": "int", "default_value": 100, @@ -145,13 +175,13 @@ def getSettingDataString(self): }, "f1_Change_printspeed": { "label": "Change Print Speed", - "description": "Select if print speed has to be changed", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false }, "f2_printspeed": { - "label": "Print Speed", - "description": "New print speed", + "label": "Print Speed (%)", + "description": "Alters the feed rate (F) on all the G0 and G1 commands", "unit": "%", "type": "int", "default_value": 100, @@ -162,13 +192,13 @@ def getSettingDataString(self): }, "g1_Change_flowrate": { "label": "Change Flow Rate", - "description": "Select if flow rate has to be changed", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false }, "g2_flowrate": { - "label": "Flow Rate", - "description": "New Flow rate", + "label": "Flow Rate (%)", + "description": "Sets the M221 global value", "unit": "%", "type": "int", "default_value": 100, @@ -179,13 +209,13 @@ def getSettingDataString(self): }, "g3_Change_flowrateOne": { "label": "Change Flow Rate 1", - "description": "Select if first extruder flow rate has to be changed", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false }, "g4_flowrateOne": { - "label": "Flow Rate One", - "description": "New Flow rate Extruder 1", + "label": "Flow Rate 1 (%)", + "description": "Sets the M221 T0 value", "unit": "%", "type": "int", "default_value": 100, @@ -196,13 +226,13 @@ def getSettingDataString(self): }, "g5_Change_flowrateTwo": { "label": "Change Flow Rate 2", - "description": "Select if second extruder flow rate has to be changed", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false }, "g6_flowrateTwo": { - "label": "Flow Rate two", - "description": "New Flow rate Extruder 2", + "label": "Flow Rate 2 (%)", + "description": "Sets the M221 T1 value", "unit": "%", "type": "int", "default_value": 100, @@ -213,14 +243,14 @@ def getSettingDataString(self): }, "h1_Change_bedTemp": { "label": "Change Bed Temp", - "description": "Select if Bed Temperature has to be changed", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false }, "h2_bedTemp": { - "label": "Bed Temp", - "description": "New Bed Temperature", - "unit": "C", + "label": "Bed Temp (ºC)", + "description": "Sets the M140 value", + "unit": "ºC", "type": "float", "default_value": 60, "minimum_value": "0", @@ -230,14 +260,14 @@ def getSettingDataString(self): }, "h1_Change_buildVolumeTemperature": { "label": "Change Build Volume Temperature", - "description": "Select if Build Volume Temperature has to be changed", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false }, "h2_buildVolumeTemperature": { - "label": "Build Volume Temperature", - "description": "New Build Volume Temperature", - "unit": "C", + "label": "Build Volume Temperature (ºC)", + "description": "Sets the M141 value", + "unit": "ºC", "type": "float", "default_value": 20, "minimum_value": "0", @@ -247,14 +277,14 @@ def getSettingDataString(self): }, "i1_Change_extruderOne": { "label": "Change Extruder 1 Temp", - "description": "Select if First Extruder Temperature has to be changed", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false }, "i2_extruderOne": { - "label": "Extruder 1 Temp", - "description": "New First Extruder Temperature", - "unit": "C", + "label": "Extruder 1 Temp (ºC)", + "description": "Sets the M104 T0 value", + "unit": "ºC", "type": "float", "default_value": 190, "minimum_value": "0", @@ -264,14 +294,14 @@ def getSettingDataString(self): }, "i3_Change_extruderTwo": { "label": "Change Extruder 2 Temp", - "description": "Select if Second Extruder Temperature has to be changed", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false }, "i4_extruderTwo": { - "label": "Extruder 2 Temp", - "description": "New Second Extruder Temperature", - "unit": "C", + "label": "Extruder 2 Temp (ºC)", + "description": "Sets the M104 T1 value", + "unit": "ºC", "type": "float", "default_value": 190, "minimum_value": "0", @@ -281,13 +311,13 @@ def getSettingDataString(self): }, "j1_Change_fanSpeed": { "label": "Change Fan Speed", - "description": "Select if Fan Speed has to be changed", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false }, "j2_fanSpeed": { - "label": "Fan Speed", - "description": "New Fan Speed (0-100)", + "label": "Fan Speed (%)", + "description": "Sets the M106 value", "unit": "%", "type": "int", "default_value": 100, @@ -298,31 +328,31 @@ def getSettingDataString(self): }, "caz_change_retract": { "label": "Change Retraction", - "description": "Indicates you would like to modify retraction properties. Does not work when using relative extrusion.", + "description": "Use to enable/disable this setting without clearing it. Does not work when using relative extrusion.", "type": "bool", "default_value": false }, "caz_retractstyle": { "label": "Retract Style", - "description": "Specify if you're using firmware retraction or linear move based retractions. Check your printer settings to see which you're using.", + "description": "Specify if you're using firmware retraction (G10/G11) or linear move (G0/G1) based retractions. Check your printer settings to see which you're using.", "type": "enum", "options": { "linear": "Linear Move", "firmware": "Firmware" }, "default_value": "linear", - "enabled": "caz_change_retract" + "enabled": "false" }, "caz_change_retractfeedrate": { "label": "Change Retract Feed Rate", - "description": "Changes the retraction feed rate during print", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false, "enabled": "caz_change_retract" }, "caz_retractfeedrate": { - "label": "Retract Feed Rate", - "description": "New Retract Feed Rate (mm/s)", + "label": "Retract Feed Rate (mm/s)", + "description": "Changes either the M207 (F) value -or- the G01/G02 feed rate (F) value", "unit": "mm/s", "type": "float", "default_value": 40, @@ -333,14 +363,14 @@ def getSettingDataString(self): }, "caz_change_retractlength": { "label": "Change Retract Length", - "description": "Changes the retraction length during print", + "description": "Use to enable/disable this setting without clearing it", "type": "bool", "default_value": false, "enabled": "caz_change_retract" }, "caz_retractlength": { - "label": "Retract Length", - "description": "New Retract Length (mm)", + "label": "Retract Length (mm)", + "description": "Changes either the M207 (S) value -or- the G01/G02 feed rate (E) value", "unit": "mm", "type": "float", "default_value": 6, @@ -355,11 +385,13 @@ def getSettingDataString(self): def __init__(self): super().__init__() - def execute(self, data): + def execute(self, data: list[str]): caz_instance = ChangeAtZProcessor() caz_instance.targetValues = {} + + global_stack = Application.getInstance().getGlobalContainerStack() # copy over our settings to our change z class self.setIntSettingIfEnabled(caz_instance, "e1_Change_speed", "speed", "e2_speed") @@ -368,11 +400,13 @@ def execute(self, data): self.setIntSettingIfEnabled(caz_instance, "g3_Change_flowrateOne", "flowrateOne", "g4_flowrateOne") self.setIntSettingIfEnabled(caz_instance, "g5_Change_flowrateTwo", "flowrateTwo", "g6_flowrateTwo") self.setFloatSettingIfEnabled(caz_instance, "h1_Change_bedTemp", "bedTemp", "h2_bedTemp") - self.setFloatSettingIfEnabled(caz_instance, "h1_Change_buildVolumeTemperature", "buildVolumeTemperature", "h2_buildVolumeTemperature") + self.setFloatSettingIfEnabled(caz_instance, "h1_Change_buildVolumeTemperature", "buildVolumeTemperature", + "h2_buildVolumeTemperature") self.setFloatSettingIfEnabled(caz_instance, "i1_Change_extruderOne", "extruderOne", "i2_extruderOne") self.setFloatSettingIfEnabled(caz_instance, "i3_Change_extruderTwo", "extruderTwo", "i4_extruderTwo") self.setIntSettingIfEnabled(caz_instance, "j1_Change_fanSpeed", "fanSpeed", "j2_fanSpeed") - self.setFloatSettingIfEnabled(caz_instance, "caz_change_retractfeedrate", "retractfeedrate", "caz_retractfeedrate") + self.setFloatSettingIfEnabled(caz_instance, "caz_change_retractfeedrate", "retractfeedrate", + "caz_retractfeedrate") self.setFloatSettingIfEnabled(caz_instance, "caz_change_retractlength", "retractlength", "caz_retractlength") # is this mod enabled? @@ -382,8 +416,8 @@ def execute(self, data): caz_instance.displayChangesToLcd = self.getSettingValueByKey("caz_output_to_display") # are we doing linear move retractions? - caz_instance.linearRetraction = self.getSettingValueByKey("caz_retractstyle") == "linear" - + caz_instance.linearRetraction = bool(global_stack.getProperty("machine_firmware_retract", "value")) == False + # see if we're applying to a single layer or to all layers hence forth caz_instance.applyToSingleLayer = self.getSettingValueByKey("c_behavior") == "single_layer" @@ -391,8 +425,11 @@ def execute(self, data): caz_instance.targetByLayer = self.getSettingValueByKey("a_trigger") == "layer_no" # change our target based on what we're targeting - caz_instance.targetLayer = self.getIntSettingByKey("b_targetL", None) - caz_instance.targetZ = self.getFloatSettingByKey("b_targetZ", None) + caz_instance.targetLayerStart = self.getIntSettingByKey("b_targetL", None) + caz_instance.targetLayerEnd = self.getFloatSettingByKey("b_targetLEnd", -1) + caz_instance.targetZStart = self.getFloatSettingByKey("b_targetZ", None) + caz_instance.targetZEnd = self.getFloatSettingByKey("b_targetZEnd", -1) + caz_instance.layerHeight = float(global_stack.getProperty("layer_height", "value")) # run our script return caz_instance.execute(data) @@ -432,7 +469,7 @@ def setFloatSettingIfEnabled(self, caz_instance, trigger, target, setting): caz_instance.targetValues[target] = value # Returns the given settings value as an integer or the default if it cannot parse it - def getIntSettingByKey(self, key, default): + def getIntSettingByKey(self, key, default: int | None) -> int | None: # change our target based on what we're targeting try: @@ -441,7 +478,7 @@ def getIntSettingByKey(self, key, default): return default # Returns the given settings value as an integer or the default if it cannot parse it - def getFloatSettingByKey(self, key, default): + def getFloatSettingByKey(self, key, default: float | None) -> float | None: # change our target based on what we're targeting try: @@ -450,22 +487,67 @@ def getFloatSettingByKey(self, key, default): return default -# This is a utility class for getting details of gcodes from a given line class GCodeCommand: - # The GCode command itself (ex: G10) - command = None, + command: str | None = None + + # Anything that comes after the first semicolon in the command + comment: str | None = None # Contains any arguments passed to the command. The key is the argument name, the value is the value of the argument. - arguments = {} + __arguments: dict[str, any] = {} # Contains the components of the command broken into pieces - components = [] + __components: list[str] = [] + + # contains the order in which the arguments appear, this is primarily used for rendering back as a string + __arguments_order: list[str] = [] - # Constructor. Sets up defaults def __init__(self): + """ + This class is used to understand and manipulate GCode commands programmatically. We lazy load the arguments because there are generally a lot of commands in a gcode file and we're normally only interested in a slice of them. If we limit it to identifying the command but then lazy load the arguments this saves a lot of processing time. + """ self.reset() + def __eq__(self, other): + if not type(other) is type(self): + return False + + # if we don't do this they may still be the same command but we'd never know + self.parseArguments() + other.parseArguments() + + return (self.command == other.command + and self.__arguments == other.__arguments + and self.__components == other.__components) + + def __str__(self): + pieces = [self.command] + + for arg in self.__arguments_order: + value = self.getArgument(arg, "") + + pieces.append(arg + str(value)) + + return " ".join(pieces) + (";" + self.comment if self.hasComment() else "") + + def asLinearMove(self): + """ + Converts the command to a linear move command if it is one. If it is none a linear command it returns + None. + """ + if not self.isLinearMove(): + return None + + # convert our values to floats (or defaults) + self.__arguments["F"] = self.getArgumentAsFloat("F") + self.__arguments["X"] = self.getArgumentAsFloat("X") + self.__arguments["Y"] = self.getArgumentAsFloat("Y") + self.__arguments["Z"] = self.getArgumentAsFloat("Z") + self.__arguments["E"] = self.getArgumentAsFloat("E") + + return self + # Gets a GCode Command from the given single line of GCode @staticmethod def getFromLine(line: str): @@ -478,11 +560,17 @@ def getFromLine(line: str): if line[0] != "G" and line[0] != "M": return None - # remove any comments - line = re.sub(r";.*$", "", line) + line = line.strip() + comment_index = line.find(";") + comment = None + command = line + + if comment_index > 0: + command = line[:comment_index].strip() + comment = line[comment_index:].strip() # break into the individual components - command_pieces = line.strip().split(" ") + command_pieces = command.split(" ") # our return command details command = GCodeCommand() @@ -492,14 +580,11 @@ def getFromLine(line: str): return None # stores all the components of the command within the class for later - command.components = command_pieces + command.__components = command_pieces # set the actual command command.command = command_pieces[0] - - # stop here if we don't have any parameters - if len(command_pieces) == 1: - return None + command.comment = comment # return our indexed command return command @@ -509,126 +594,142 @@ def getFromLine(line: str): def getLinearMoveCommand(line: str): # get our command from the line - linear_command = GCodeCommand.getFromLine(line) + command = GCodeCommand.getFromLine(line) - # if it's not a linear move, we don't care - if linear_command is None or (linear_command.command != "G0" and linear_command.command != "G1"): + if command is None: return None - # convert our values to floats (or defaults) - linear_command.arguments["F"] = linear_command.getArgumentAsFloat("F", None) - linear_command.arguments["X"] = linear_command.getArgumentAsFloat("X", None) - linear_command.arguments["Y"] = linear_command.getArgumentAsFloat("Y", None) - linear_command.arguments["Z"] = linear_command.getArgumentAsFloat("Z", None) - linear_command.arguments["E"] = linear_command.getArgumentAsFloat("E", None) - - # return our new command - return linear_command + return command.asLinearMove() # Gets the value of a parameter or returns the default if there is none - def getArgument(self, name: str, default: str = None) -> str: + def getArgument(self, name: str, default: any = None) -> any: # parse our arguments (only happens once) self.parseArguments() # if we don't have the parameter, return the default - if name not in self.arguments: + if name not in self.__arguments: return default # otherwise return the value - return self.arguments[name] + return self.__arguments[name] # Gets the value of a parameter as a float or returns the default - def getArgumentAsFloat(self, name: str, default: float = None) -> float: + def getArgumentAsFloat(self, name: str, default: float = None) -> float | None: # try to parse as a float, otherwise return the default try: - return float(self.getArgument(name, default)) + value = self.getArgument(name, default) + + if value is None: + return default + + return float(value) except: return default # Gets the value of a parameter as an integer or returns the default - def getArgumentAsInt(self, name: str, default: int = None) -> int: + def getArgumentAsInt(self, name: str, default: int = None) -> int | None: # try to parse as a integer, otherwise return the default try: - return int(self.getArgument(name, default)) + value = self.getArgument(name, default) + + if value is None: + return default + + return int(value) except: return default - # Allows retrieving values from the given GCODE line @staticmethod - def getDirectArgument(line: str, key: str, default: str = None) -> str: - - if key not in line or (";" in line and line.find(key) > line.find(";") and ";ChangeAtZ" not in key and ";LAYER:" not in key): + def getCommentArgument(line: str, key: str, default: str = None) -> str | None: + """ + Some values can be stored directly as comments. This will retrieve it from the line + """ + if key not in line: return default # allows for string lengths larger than 1 sub_part = line[line.find(key) + len(key):] - if ";ChangeAtZ" in key: - m = re.search("^[0-4]", sub_part) - elif ";LAYER:" in key: - m = re.search("^[+-]?[0-9]*", sub_part) - else: - # the minus at the beginning allows for negative values, e.g. for delta printers - m = re.search(r"^[-]?[0-9]*\.?[0-9]*", sub_part) - if m is None: + if sub_part is None: return default - try: - return m.group(0) - except: - return default + return sub_part.strip() - # Converts the command parameter to a int or returns the default @staticmethod - def getDirectArgumentAsFloat(line: str, key: str, default: float = None) -> float: + def getCommentArgumentAsFloat(line: str, key: str, default: float = None) -> float | None: # get the value from the command - value = GCodeCommand.getDirectArgument(line, key, default) + value = GCodeCommand.getCommentArgument(line, key, default) # stop here if it's the default - if value == default: - return value + if value is None: + return default try: return float(value) except: return default - # Converts the command parameter to a int or returns the default @staticmethod - def getDirectArgumentAsInt(line: str, key: str, default: int = None) -> int: + def getCommentArgumentAsInt(line: str, key: str, default: int = None) -> int | None: # get the value from the command - value = GCodeCommand.getDirectArgument(line, key, default) + value = GCodeCommand.getCommentArgument(line, key, default) # stop here if it's the default - if value == default: - return value + if value is None: + return default try: return int(value) except: return default + def hasArgument(self, key: str) -> bool: + self.parseArguments() + return key in self.__arguments + + def hasComment(self) -> bool: + return not self.comment is None and len(self.comment) > 0 + + def hasExtrudeAndFeed(self): + self.parseArguments() + return self.hasArgument("E") and self.hasArgument("F") + + def hasXYZ(self): + self.parseArguments() + return self.hasArgument("X") and self.hasArgument("Y") and self.hasArgument("Z") + + def isLinearMove(self) -> bool: + return self.command == "G0" or self.command == "G1" + + def isRetraction(self): + return not self.hasXYZ() and self.hasExtrudeAndFeed() + # Parses the arguments of the command on demand, only once def parseArguments(self): - # stop here if we don't have any remaining components - if len(self.components) <= 1: + if len(self.__components) <= 1: return None + self.__arguments = {} + self.__arguments_order = [] + # iterate and index all of our parameters, skip the first component as it's the command - for i in range(1, len(self.components)): + for i in range(1, len(self.__components)): # get our component - component = self.components[i] + component = self.__components[i] # get the first character of the parameter, which is the name component_name = component[0] + # track the order in which they appear + self.__arguments_order.append(component_name) + # get the value of the parameter (the rest of the string component_value = None @@ -637,31 +738,37 @@ def parseArguments(self): component_value = component[1:] # index the argument - self.arguments[component_name] = component_value + self.__arguments[component_name] = component_value # clear the components to we don't process again - self.components = [] - - # Easy function for replacing any GCODE parameter variable in a given GCODE command - @staticmethod - def replaceDirectArgument(line: str, key: str, value: str) -> str: - return re.sub(r"(^|\s)" + key + r"[\d\.]+(\s|$)", r"\1" + key + str(value) + r"\2", line) + self.__components = [] # Resets the model back to defaults def reset(self): self.command = None - self.arguments = {} + self.__arguments = {} + self.__arguments_order = [] + + def setArgument(self, key: str, value: any): + + if value is None: + self.__arguments.pop(key, None) + return + + self.__arguments[key] = value # The primary ChangeAtZ class that does all the gcode editing. This was broken out into an # independent class so it could be debugged using a standard IDE class ChangeAtZProcessor: - # Holds our current height - currentZ = None + currentZ = 0 + + # Holds the minimum height for the current layer + minZ: float | None = None # Holds our current layer number - currentLayer = None + currentLayer = -1 # Indicates if we're only supposed to apply our settings to a single layer or multiple layers applyToSingleLayer = False @@ -673,10 +780,10 @@ class ChangeAtZProcessor: enabled = True # Indicates if we're processing inside the target layer or not - insideTargetLayer = False + insideTargetArea = False # Indicates if we have restored the previous values from before we started our pass - lastValuesRestored = False + originalValuesRestored = False # Indicates if the user has opted for linear move retractions or firmware retractions linearRetraction = True @@ -688,86 +795,103 @@ class ChangeAtZProcessor: targetValuesInjected = False # Holds the last extrusion value, used with detecting when a retraction is made - lastE = None + lastE: float | None = None # An index of our gcodes which we're monitoring lastValues = {} # The detected layer height from the gcode - layerHeight = None + layerHeight: float | None = None + + # What later to start at + targetLayerStart: int | None = None - # The target layer - targetLayer = None + # What later to end at + targetLayerEnd: int | None = None # Holds the values the user has requested to change targetValues = {} - # The target height in mm - targetZ = None + # The minimum cumulative layer height at which to start modifications + targetZStart: float | None = None + + # The minimum cumulative layer height at which to stop modifications + targetZEnd: float | None = None # Used to track if we've been inside our target layer yet - wasInsideTargetLayer = False + leftTargetArea = False + + # Used to help detect early if we should process a line or not + __supportedCodes = {"G0", "G1", "M104", "M106", "M140", "M141", "M207", "M220", "M221"} # boots up the class with defaults def __init__(self): self.reset() - # Modifies the given GCODE and injects the commands at the various targets - def execute(self, data): + # Sets the flags if we're at the target layer or not + def detectTargetArea(self): - # short cut the whole thing if we're not enabled - if not self.enabled: - return data + inside_target_area = self.isInsideTargetArea() - # our layer cursor - index = 0 + if inside_target_area == self.insideTargetArea: + return - for active_layer in data: + self.leftTargetArea |= self.insideTargetArea and not inside_target_area - # will hold our updated gcode - modified_gcode = "" + self.insideTargetArea = inside_target_area - # mark all the defaults for deletion - active_layer = self.markChangesForDeletion(active_layer) + if self.insideTargetArea: + Logger.info("Inside target area on layer: %s", self.currentLayer) + elif self.leftTargetArea: + Logger.info("Left target area on layer: %s", self.currentLayer) - # break apart the layer into commands - lines = active_layer.split("\n") + # Modifies the given GCODE and injects the commands at the various targets + def execute(self, sections: list[str]): + global executions - # evaluate each command individually - for line in lines: + # shortcut the whole thing if we're not enabled + if not self.enabled: + Logger.info("ChangeAtZ with settings is not enabled: %s", self) + return sections - # trim or command - line = line.strip() + Logger.info("Running ChangeAtZ with settings: %s", self) - # skip empty lines - if len(line) == 0: - continue + # our layer cursor + index = 0 + + # the first "layer" is actually the header of the gcode and not a layer by definition + # so we'll start at -1 + layer_number = -1 - # update our layer number if applicable - self.processLayerNumber(line) + for current_section in sections: + Logger.info("---------------------------------------") - # update our layer height if applicable - self.processLayerHeight(line) + # break apart the section into commands + lines = current_section.strip().split("\n") - # check if we're at the target layer or not - self.processTargetLayer() + self.readLayerSettings(lines) - # process any changes to the gcode - modified_gcode += self.processLine(line) + modified_lines = [] - # remove any marked defaults - modified_gcode = self.removeMarkedChanges(modified_gcode) + modified_lines.extend(self.getTargetGCode()) + modified_lines.extend(self.getLineChanges(lines)) + modified_lines.extend(self.getOriginalGCode()) - # append our modified line - data[index] = modified_gcode + sections[index] = "\n".join(modified_lines).strip() + "\n" + layer_number += 1 index += 1 # return our modified gcode - return data + return sections + + def firstNotNone(self, *args): + for x in args: + if x is not None: + return x # Builds the restored layer settings based on the previous settings and returns the relevant GCODE lines - def getChangedLastValues(self) -> Dict[str, any]: + def getChangedLastValues(self) -> dict[str, any]: # capture the values that we've changed changed = {} @@ -787,97 +911,71 @@ def getChangedLastValues(self) -> Dict[str, any]: return changed # Builds the relevant display feedback for each of the values - def getDisplayChangesFromValues(self, values: Dict[str, any]) -> str: + def getDisplayGCodeFromValues(self, values: dict[str, any]) -> list[str]: # stop here if we're not outputting data if not self.displayChangesToLcd: - return "" + return [] - # will hold all the default settings for the target layer - codes = [] + display_text = [] # looking for wait for bed temp if "bedTemp" in values: - codes.append("BedTemp: " + str(round(values["bedTemp"]))) + display_text.append("Bed Temp: " + str(round(values["bedTemp"]))) # looking for wait for Build Volume Temperature if "buildVolumeTemperature" in values: - codes.append("buildVolumeTemperature: " + str(round(values["buildVolumeTemperature"]))) + display_text.append("Build Volume Temperature: " + str(round(values["buildVolumeTemperature"]))) # set our extruder one temp (if specified) if "extruderOne" in values: - codes.append("Extruder 1 Temp: " + str(round(values["extruderOne"]))) + display_text.append("Extruder 1 Temp: " + str(round(values["extruderOne"]))) # set our extruder two temp (if specified) if "extruderTwo" in values: - codes.append("Extruder 2 Temp: " + str(round(values["extruderTwo"]))) + display_text.append("Extruder 2 Temp: " + str(round(values["extruderTwo"]))) # set global flow rate if "flowrate" in values: - codes.append("Extruder A Flow Rate: " + str(values["flowrate"])) + display_text.append("Extruder A Flow Rate: " + str(values["flowrate"])) # set extruder 0 flow rate if "flowrateOne" in values: - codes.append("Extruder 1 Flow Rate: " + str(values["flowrateOne"])) + display_text.append("Extruder 1 Flow Rate: " + str(values["flowrateOne"])) # set second extruder flow rate if "flowrateTwo" in values: - codes.append("Extruder 2 Flow Rate: " + str(values["flowrateTwo"])) + display_text.append("Extruder 2 Flow Rate: " + str(values["flowrateTwo"])) # set our fan speed if "fanSpeed" in values: - codes.append("Fan Speed: " + str(values["fanSpeed"])) + display_text.append("Fan Speed: " + str(values["fanSpeed"])) # set feedrate percentage if "speed" in values: - codes.append("Print Speed: " + str(values["speed"])) + display_text.append("Print Speed: " + str(values["speed"])) # set print rate percentage if "printspeed" in values: - codes.append("Linear Print Speed: " + str(values["printspeed"])) + display_text.append("Linear Print Speed: " + str(values["printspeed"])) # set retract rate if "retractfeedrate" in values: - codes.append("Retract Feed Rate: " + str(values["retractfeedrate"])) + display_text.append("Retract Feed Rate: " + str(values["retractfeedrate"])) # set retract length if "retractlength" in values: - codes.append("Retract Length: " + str(values["retractlength"])) + display_text.append("Retract Length: " + str(values["retractlength"])) # stop here if there's nothing to output - if len(codes) == 0: - return "" + if len(display_text) == 0: + return [] # output our command to display the data - return "M117 " + ", ".join(codes) + "\n" - - # Converts the last values to something that can be output on the LCD - def getLastDisplayValues(self) -> str: - - # convert our last values to something we can output - return self.getDisplayChangesFromValues(self.getChangedLastValues()) - - # Converts the target values to something that can be output on the LCD - def getTargetDisplayValues(self) -> str: - - # convert our target values to something we can output - return self.getDisplayChangesFromValues(self.targetValues) - - # Builds the the relevant GCODE lines from the given collection of values - def getCodeFromValues(self, values: Dict[str, any]) -> str: - - # will hold all the desired settings for the target layer - codes = self.getCodeLinesFromValues(values) - - # stop here if there are no values that require changing - if len(codes) == 0: - return "" - - # return our default block for this layer - return ";[CAZD:\n" + "\n".join(codes) + "\n;:CAZD]" + return ["M117 " + ", ".join(display_text)] # Builds the relevant GCODE lines from the given collection of values - def getCodeLinesFromValues(self, values: Dict[str, any]) -> List[str]: + def getGCodeFromValues(self, values: dict[str, any]) -> list[str]: # will hold all the default settings for the target layer codes = [] @@ -900,9 +998,8 @@ def getCodeLinesFromValues(self, values: Dict[str, any]) -> List[str]: # set our fan speed if "fanSpeed" in values: - # convert our fan speed percentage to PWM - fan_speed = int((float(values["fanSpeed"]) / 100.0) * 255) + fan_speed = (float(values["fanSpeed"]) / 100.0) * 255.0 # add our fan speed to the defaults codes.append("M106 S" + str(fan_speed)) @@ -945,35 +1042,80 @@ def getCodeLinesFromValues(self, values: Dict[str, any]) -> List[str]: return codes - # Builds the restored layer settings based on the previous settings and returns the relevant GCODE lines - def getLastValues(self) -> str: + def getLastValueAsFloat(self, key: str, default: float | None = None) -> float | None: + + if not key in self.lastValues: + return default + + return float(self.lastValues[key]) + + def getLineChanges(self, lines: list[str]): + + if not self.insideTargetArea: + return lines + + modified_lines = [] + + # evaluate each command individually + for line in lines: + + if not self.isSupportedCode(line): + modified_lines.append(line) + continue + + line = line.strip() + + if len(line) == 0: + continue + + # always get our original line, otherwise the effect will be cumulative + original_line = self.getOriginalLine(line) + + original_command = GCodeCommand.getFromLine(original_line) + + # normally special lines have comments straight from cura + # so we'll leave those alone + if original_command.hasComment(): + modified_lines.append(line) + continue + + modified_command = copy.copy(original_command) + + self.processLinearMove(modified_command) + self.processTargetValues(modified_command) + + # if no changes have been made, stop here + if original_command == modified_command: + modified_lines.append(line) + continue - # build the gcode to restore our last values - return self.getCodeFromValues(self.getChangedLastValues()) + # return our updated command + modified_lines.append(self.setOriginalLine(str(modified_command), original_line)) - # Builds the gcode to inject either the changed values we want or restore the previous values - def getInjectCode(self) -> str: + return modified_lines - # if we're now outside of our target layer and haven't restored our last values, do so now - if not self.insideTargetLayer and self.wasInsideTargetLayer and not self.lastValuesRestored: + # generates the gcode for restoring the original values and emitting those to the printer (if desired) + def getOriginalGCode(self) -> list[str]: - # mark that we've injected the last values - self.lastValuesRestored = True + if not self.leftTargetArea: + return [] - # inject the defaults - return self.getLastValues() + "\n" + self.getLastDisplayValues() + if self.originalValuesRestored: + return [] - # if we're inside our target layer but haven't added our values yet, do so now - if self.insideTargetLayer and not self.targetValuesInjected: + original_values = self.getChangedLastValues() - # mark that we've injected the target values - self.targetValuesInjected = True + original_codes = self.getGCodeFromValues(original_values) + self.getDisplayGCodeFromValues(original_values) - # inject the defaults - return self.getTargetValues() + "\n" + self.getTargetDisplayValues() + if len(original_codes) == 0: + return [] - # nothing to do - return "" + self.originalValuesRestored = True + + Logger.info("Restoring original values to layer: %s", original_values) + + # inject the defaults + return [";[CAZD:"] + original_codes + [";:CAZD]"] # Returns the unmodified GCODE line from previous ChangeAtZ edits @staticmethod @@ -988,370 +1130,273 @@ def getOriginalLine(line: str) -> str: return original_line.group(1) - # Builds the target layer settings based on the specified values and returns the relevant GCODE lines - def getTargetValues(self) -> str: + # generates the gcode for overriding the existing values and emitting those to the printer (if desired) + def getTargetGCode(self) -> list[str]: - # build the gcode to change our current values - return self.getCodeFromValues(self.targetValues) + if not self.insideTargetArea: + return [] - # Determines if the current line is at or below the target required to start modifying - def isTargetLayerOrHeight(self) -> bool: + if self.targetValuesInjected: + return [] - # target selected by layer no. - if self.targetByLayer: + target_codes = self.getGCodeFromValues(self.targetValues) + self.getDisplayGCodeFromValues(self.targetValues) - # if we don't have a current layer, we're not there yet - if self.currentLayer is None: - return False + if len(target_codes) == 0: + return [] + + self.targetValuesInjected = True + + Logger.info("Applying target values to layer: %s", self.targetValues) + + # inject the defaults + return [";[CAZD:"] + target_codes + [";:CAZD]"] + + def getTargetValueAsInt(self, key: str, default: int | None = None) -> int | None: + + if not key in self.targetValues: + return default + + return int(self.targetValues[key]) + + def getTargetValueAsFloat(self, key: str, default: float | None = None) -> float | None: - # if we're applying to a single layer, stop if our layer is not identical + if not key in self.targetValues: + return default + + return float(self.targetValues[key]) + + # Determines if the current line is at or below the target required to start modifying + def isInsideTargetArea(self) -> bool: + + if not self.targetByLayer: + + if self.minZ is None: + return False + if self.applyToSingleLayer: - return self.currentLayer == self.targetLayer + return self.minZ == self.targetZStart else: - return self.currentLayer >= self.targetLayer + return self.minZ >= self.targetZStart and (self.targetZEnd == -1 or self.minZ < self.targetZEnd) + if self.currentLayer is None: + return False + + # if we're applying to a single layer, stop if our layer is not identical + if self.applyToSingleLayer: + return self.currentLayer == self.targetLayerStart else: + return self.currentLayer >= self.targetLayerStart and (self.targetLayerEnd == -1 or self.currentLayer < self.targetLayerEnd) - # if we don't have a current Z, we're not there yet - if self.currentZ is None: - return False + def isSupportedCode(self, line: str) -> bool: + """ + Allows the plugin to skip lines that it doesn't care about + """ + for supportedCode in self.__supportedCodes: + if line.startswith(supportedCode + " "): + return True - # if we're applying to a single layer, stop if our Z is not identical - if self.applyToSingleLayer: - return self.currentZ == self.targetZ - else: - return self.currentZ >= self.targetZ + return False - # Marks any current ChangeAtZ layer defaults in the layer for deletion - @staticmethod - def markChangesForDeletion(layer: str): - return re.sub(r";\[CAZD:", ";[CAZD:DELETE:", layer) + def maxNotNone(self, *args) -> float: + return max(x for x in args if x is not None) - # Grabs the current height - def processLayerHeight(self, line: str): + def minNotNone(self, *args) -> float: + return min(x for x in args if x is not None) - # stop here if we haven't entered a layer yet - if self.currentLayer is None: + # Handles any linear moves in the current line + def processLinearMove(self, modified_command: GCodeCommand): + + if not modified_command.isLinearMove(): return - # get our gcode command - command = GCodeCommand.getFromLine(line) + # handle retract length + self.processRetractLength(modified_command) - # skip if it's not a command we're interested in - if command is None: - return + # handle retract feed rate + self.processRetractFeedRate(modified_command) - # stop here if this isn't a linear move command - if command.command != "G0" and command.command != "G1": - return + # handle print speed adjustments + self.processPrintSpeed(modified_command) - # get our value from the command - current_z = command.getArgumentAsFloat("Z", None) + # set our current extrude position + self.lastE = self.firstNotNone(modified_command.getArgumentAsFloat("E"), self.lastE) - # stop here if we don't have a Z value defined, we can't get the height from this command - if current_z is None: - return + def processTargetValues(self, modified_command: GCodeCommand): - # stop if there's no change - if current_z == self.currentZ: + if modified_command.isLinearMove(): return - # set our current Z value - self.currentZ = current_z + if modified_command.command == "M104": + extruder = modified_command.getArgumentAsInt("T") - # if we don't have a layer height yet, set it based on the current Z value - if self.layerHeight is None: - self.layerHeight = self.currentZ + if extruder is None: + return - # Grabs the current layer number - def processLayerNumber(self, line: str): + feed_rate = None - # if this isn't a layer comment, stop here, nothing to update - if ";LAYER:" not in line: - return + if extruder == 0: + feed_rate = self.getTargetValueAsFloat("extruderOne") + elif extruder == 1: + feed_rate = self.getTargetValueAsFloat("extruderTwo") - # get our current layer number - current_layer = GCodeCommand.getDirectArgumentAsInt(line, ";LAYER:", None) + if feed_rate is None: + return - # this should never happen, but if our layer number hasn't changed, stop here - if current_layer == self.currentLayer: + modified_command.setArgument("S", feed_rate) return - # update our current layer - self.currentLayer = current_layer - - # Makes any linear move changes and also injects either target or restored values depending on the plugin state - def processLine(self, line: str) -> str: + if modified_command.command == "M106": + fan_speed = self.getTargetValueAsFloat("fanSpeed") - # used to change the given line of code - modified_gcode = "" + if fan_speed is None: + return - # track any values that we may be interested in - self.trackChangeableValues(line) + modified_command.setArgument("S", (fan_speed / 100.0) * 255.0) + return - # if we're not inside the target layer, simply read the any - # settings we can and revert any ChangeAtZ deletions - if not self.insideTargetLayer: + if modified_command.command == "M140": + bed_temp = self.getTargetValueAsFloat("bedTemp") - # read any settings if we haven't hit our target layer yet - if not self.wasInsideTargetLayer: - self.processSetting(line) + if bed_temp is None: + return - # if we haven't hit our target yet, leave the defaults as is (unmark them for deletion) - if "[CAZD:DELETE:" in line: - line = line.replace("[CAZD:DELETE:", "[CAZD:") + modified_command.setArgument("S", bed_temp) + return - # if we're targeting by Z, we want to add our values before the first linear move - if "G1 " in line or "G0 " in line: - modified_gcode += self.getInjectCode() + if modified_command.command == "M141": + build_volume_temp = self.getTargetValueAsFloat("buildVolumeTemperature") - # modify our command if we're still inside our target layer, otherwise pass unmodified - if self.insideTargetLayer: - modified_gcode += self.processLinearMove(line) + "\n" - else: - modified_gcode += line + "\n" + if build_volume_temp is None: + return - # if we're targeting by layer we want to add our values just after the layer label - if ";LAYER:" in line: - modified_gcode += self.getInjectCode() + modified_command.setArgument("S", build_volume_temp) + return - # return our changed code - return modified_gcode + if modified_command.command == "M207": + retract_feed_rate = self.getTargetValueAsFloat("retractfeedrate") - # Handles any linear moves in the current line - def processLinearMove(self, line: str) -> str: + if not retract_feed_rate is None: + modified_command.setArgument("F", retract_feed_rate) - # if it's not a linear motion command we're not interested - if not ("G1 " in line or "G0 " in line): - return line + retract_length = self.getTargetValueAsFloat("retractlength") - # always get our original line, otherwise the effect will be cumulative - line = self.getOriginalLine(line) + if not retract_length is None: + modified_command.setArgument("S", retract_length) - # get our command from the line - linear_command = GCodeCommand.getLinearMoveCommand(line) + return - # if it's not a linear move, we don't care - if linear_command is None: - return line + if modified_command.command == "M220": + feed_rate = self.getTargetValueAsFloat("speed") - # get our linear move parameters - feed_rate = linear_command.arguments["F"] - x_coord = linear_command.arguments["X"] - y_coord = linear_command.arguments["Y"] - z_coord = linear_command.arguments["Z"] - extrude_length = linear_command.arguments["E"] + if feed_rate is None: + return - # set our new line to our old line - new_line = line + modified_command.setArgument("S", feed_rate) + return - # handle retract length - new_line = self.processRetractLength(extrude_length, feed_rate, new_line, x_coord, y_coord, z_coord) + if modified_command.command == "M221": + extruder = modified_command.getArgumentAsInt("T") - # handle retract feed rate - new_line = self.processRetractFeedRate(extrude_length, feed_rate, new_line, x_coord, y_coord, z_coord) + if extruder is None: + return - # handle print speed adjustments - if extrude_length is not None: # Only for extrusion moves. - new_line = self.processPrintSpeed(feed_rate, new_line) + flow_rate = None - # set our current extrude position - self.lastE = extrude_length if extrude_length is not None else self.lastE + if extruder == 0: + flow_rate = self.getTargetValueAsFloat("flowrateOne") + elif extruder == 1: + flow_rate = self.getTargetValueAsFloat("flowrateTwo") - # if no changes have been made, stop here - if new_line == line: - return line + if flow_rate is None: + return - # return our updated command - return self.setOriginalLine(new_line, line) + modified_command.setArgument("S", flow_rate) + return # Handles any changes to print speed for the given linear motion command - def processPrintSpeed(self, feed_rate: float, new_line: str) -> str: + def processPrintSpeed(self, modified_command: GCodeCommand): + + print_speed = self.getTargetValueAsInt("printspeed") # if we're not setting print speed or we don't have a feed rate, stop here - if "printspeed" not in self.targetValues or feed_rate is None: - return new_line + if print_speed is None: + return + + feed_rate = modified_command.getArgumentAsFloat("F") - # get our requested print speed - print_speed = int(self.targetValues["printspeed"]) + if feed_rate is None: + return # if they requested no change to print speed (ie: 100%), stop here if print_speed == 100: - return new_line + return # get our feed rate from the command - feed_rate = GCodeCommand.getDirectArgumentAsFloat(new_line, "F") * (float(print_speed) / 100.0) + feed_rate *= (float(print_speed) / float(100.0)) - # change our feed rate - return GCodeCommand.replaceDirectArgument(new_line, "F", feed_rate) + modified_command.setArgument("F", int(feed_rate)) # Handles any changes to retraction length for the given linear motion command - def processRetractLength(self, extrude_length: float, feed_rate: float, new_line: str, x_coord: float, y_coord: float, z_coord: float) -> str: + def processRetractLength(self, modified_command: GCodeCommand): # if we don't have a retract length in the file we can't add one if "retractlength" not in self.lastValues or self.lastValues["retractlength"] == 0: - return new_line + return # if we're not changing retraction length, stop here if "retractlength" not in self.targetValues: - return new_line - - # retractions are only F (feed rate) and E (extrude), at least in cura - if x_coord is not None or y_coord is not None or z_coord is not None: - return new_line + return - # since retractions require both F and E, and we don't have either, we can't process - if feed_rate is None or extrude_length is None: - return new_line + if not modified_command.isRetraction(): + return # stop here if we don't know our last extrude value if self.lastE is None: - return new_line + return + + extrude_length = modified_command.getArgumentAsFloat("E") # if there's no change in extrude we have nothing to change if self.lastE == extrude_length: - return new_line + return # if our last extrude was lower than our current, we're restoring, so skip if self.lastE < extrude_length: - return new_line + return # get our desired retract length - retract_length = float(self.targetValues["retractlength"]) + retract_length = self.getTargetValueAsFloat("retractlength") # subtract the difference between the default and the desired - extrude_length -= (retract_length - self.lastValues["retractlength"]) + extrude_length -= (retract_length - self.getLastValueAsFloat("retractlength")) # replace our extrude amount - return GCodeCommand.replaceDirectArgument(new_line, "E", extrude_length) + modified_command.setArgument("E", extrude_length) - # Used for picking out the retract length set by Cura - def processRetractLengthSetting(self, line: str): + # Handles any changes to retraction feed rate for the given linear motion command + def processRetractFeedRate(self, modified_command: GCodeCommand): # skip if we're not doing linear retractions if not self.linearRetraction: return - # get our command from the line - linear_command = GCodeCommand.getLinearMoveCommand(line) - - # if it's not a linear move, we don't care - if linear_command is None: - return - - # get our linear move parameters - feed_rate = linear_command.arguments["F"] - x_coord = linear_command.arguments["X"] - y_coord = linear_command.arguments["Y"] - z_coord = linear_command.arguments["Z"] - extrude_length = linear_command.arguments["E"] - - # the command we're looking for only has extrude and feed rate - if x_coord is not None or y_coord is not None or z_coord is not None: - return + # get our desired retract feed rate + retract_feed_rate = self.getTargetValueAsFloat("retractfeedrate") - # if either extrude or feed is missing we're likely looking at the wrong command - if extrude_length is None or feed_rate is None: + # if we're not changing retraction length, stop here + if retract_feed_rate is None: return - # cura stores the retract length as a negative E just before it starts printing - extrude_length = extrude_length * -1 - - # if it's a negative extrude after being inverted, it's not our retract length - if extrude_length < 0: + if not modified_command.isRetraction(): return - # what ever the last negative retract length is it wins - self.lastValues["retractlength"] = extrude_length - - # Handles any changes to retraction feed rate for the given linear motion command - def processRetractFeedRate(self, extrude_length: float, feed_rate: float, new_line: str, x_coord: float, y_coord: float, z_coord: float) -> str: - - # skip if we're not doing linear retractions - if not self.linearRetraction: - return new_line - - # if we're not changing retraction length, stop here - if "retractfeedrate" not in self.targetValues: - return new_line - - # retractions are only F (feed rate) and E (extrude), at least in cura - if x_coord is not None or y_coord is not None or z_coord is not None: - return new_line - - # since retractions require both F and E, and we don't have either, we can't process - if feed_rate is None or extrude_length is None: - return new_line - - # get our desired retract feed rate - retract_feed_rate = float(self.targetValues["retractfeedrate"]) - # convert to units/min retract_feed_rate *= 60 - # replace our feed rate - return GCodeCommand.replaceDirectArgument(new_line, "F", retract_feed_rate) - - # Used for finding settings in the print file before we process anything else - def processSetting(self, line: str): - - # if we're in layers already we're out of settings - if self.currentLayer is not None: - return - - # check our retract length - self.processRetractLengthSetting(line) - - # Sets the flags if we're at the target layer or not - def processTargetLayer(self): - - # skip this line if we're not there yet - if not self.isTargetLayerOrHeight(): - - # flag that we're outside our target layer - self.insideTargetLayer = False - - # skip to the next line - return - - # flip if we hit our target layer - self.wasInsideTargetLayer = True - - # flag that we're inside our target layer - self.insideTargetLayer = True - - # Removes all the ChangeAtZ layer defaults from the given layer - @staticmethod - def removeMarkedChanges(layer: str) -> str: - return re.sub(r";\[CAZD:DELETE:[\s\S]+?:CAZD\](\n|$)", "", layer) - - # Resets the class contents to defaults - def reset(self): - - self.targetValues = {} - self.applyToSingleLayer = False - self.lastE = None - self.currentZ = None - self.currentLayer = None - self.targetByLayer = True - self.targetLayer = None - self.targetZ = None - self.layerHeight = None - self.lastValues = {"speed": 100} - self.linearRetraction = True - self.insideTargetLayer = False - self.targetValuesInjected = False - self.lastValuesRestored = False - self.wasInsideTargetLayer = False - self.enabled = True - - # Sets the original GCODE line in a given GCODE command - @staticmethod - def setOriginalLine(line, original) -> str: - return line + ";[CAZO:" + original + ":CAZO]" + modified_command.setArgument("F", retract_feed_rate) # Tracks the change in gcode values we're interested in - def trackChangeableValues(self, line: str): + def readChangeableSettings(self, line: str): # simulate a print speed command if ";PRINTSPEED" in line: @@ -1372,36 +1417,22 @@ def trackChangeableValues(self, line: str): if command is None: return - # handle retract length changes - if command.command == "M207": - - # get our retract length if provided - if "S" in command.arguments: - self.lastValues["retractlength"] = command.getArgumentAsFloat("S") - - # get our retract feedrate if provided, convert from mm/m to mm/s - if "F" in command.arguments: - self.lastValues["retractfeedrate"] = command.getArgumentAsFloat("F") / 60.0 - - # move to the next command - return - # handle bed temp changes if command.command == "M140" or command.command == "M190": # get our bed temp if provided - if "S" in command.arguments: - self.lastValues["bedTemp"] = command.getArgumentAsFloat("S") + if command.hasArgument("S"): + self.setLastValue("bedTemp", command.getArgumentAsFloat("S")) # move to the next command return - # handle Build Volume Temperature changes, really shouldn't want to wait for enclousure temp mid print though. + # handle Build Volume Temperature changes, really shouldn't want to wait for enclosure temp mid print though. if command.command == "M141" or command.command == "M191": # get our bed temp if provided - if "S" in command.arguments: - self.lastValues["buildVolumeTemperature"] = command.getArgumentAsFloat("S") + if command.hasArgument("S"): + self.setLastValue("buildVolumeTemperature", command.getArgumentAsFloat("S")) # move to the next command return @@ -1421,10 +1452,10 @@ def trackChangeableValues(self, line: str): # set our extruder temp based on the extruder if extruder is None or extruder == 0: - self.lastValues["extruderOne"] = temperature + self.setLastValue("extruderOne", temperature) if extruder is None or extruder == 1: - self.lastValues["extruderTwo"] = temperature + self.setLastValue("extruderTwo", temperature) # move to the next command return @@ -1432,9 +1463,9 @@ def trackChangeableValues(self, line: str): # handle fan speed changes if command.command == "M106": - # get our bed temp if provided - if "S" in command.arguments: - self.lastValues["fanSpeed"] = (command.getArgumentAsInt("S") / 255.0) * 100 + if command.hasArgument("S"): + # allow up to 2 decimals of precision by multiplying by 10,000 and cropping off the rest + self.setLastValue("fanSpeed", floor((command.getArgumentAsFloat("S") / float(255.0)) * 10000) / 100) # move to the next command return @@ -1454,11 +1485,11 @@ def trackChangeableValues(self, line: str): # set our extruder temp based on the extruder if extruder is None: - self.lastValues["flowrate"] = temperature + self.setLastValue("flowrate", temperature) elif extruder == 1: - self.lastValues["flowrateOne"] = temperature + self.setLastValue("flowrateOne", temperature) elif extruder == 1: - self.lastValues["flowrateTwo"] = temperature + self.setLastValue("flowrateTwo", temperature) # move to the next command return @@ -1467,8 +1498,173 @@ def trackChangeableValues(self, line: str): if command.command == "M220": # get our speed if provided - if "S" in command.arguments: - self.lastValues["speed"] = command.getArgumentAsInt("S") + if command.hasArgument("S"): + self.setLastValue("speed", command.getArgumentAsFloat("S")) # move to the next command return + + # check our retract length + self.readRetractLengthSetting(command) + + # Used for finding settings in the print file before we process anything else + def readCuraSettings(self, line: str): + + current_layer = GCodeCommand.getCommentArgumentAsInt(line, ";LAYER:", self.currentLayer) + + if current_layer != self.currentLayer: + # resetting min Z so we get the new floor for the layer + self.minZ = self.currentZ + + self.currentLayer = current_layer + + # if we're in layers already we're out of settings + if self.currentLayer >= 0: + return + + self.layerHeight = GCodeCommand.getCommentArgumentAsFloat(line, ";Layer height:", self.layerHeight) + + # Grabs the current height + def readCurrentZ(self, line: str): + + # stop here if we haven't left the header yet + if self.currentLayer < 0: + return + + command = GCodeCommand.getLinearMoveCommand(line) + + # stop here if this isn't a linear move command + if command is None: + return + + command_z = command.getArgumentAsFloat("Z") + + self.currentZ = self.firstNotNone(command_z, self.currentZ) + + self.minZ = self.minNotNone(self.currentZ, self.minZ) + + def readLayerSettings(self, lines: list[str]): + """ + This is used for detecting things like the current layer Z min and the layer height + but also determining if we should target this layer or not + """ + + for line in lines: + + line = line.strip() + + if len(line) == 0: + continue + + self.readCuraSettings(line) + + self.readCurrentZ(line) + + self.readChangeableSettings(line) + + Logger.info("Layer: %s", self.currentLayer) + Logger.info("Layer Height: %s", self.layerHeight) + Logger.info("Min Z: %s", self.minZ) + + self.detectTargetArea() + + # Used for picking out the retract length set by Cura + def readRetractLengthSetting(self, command: GCodeCommand): + + # handle retract length changes + if command.command == "M207": + + # get our retract length if provided + if command.hasArgument("S"): + self.setLastValue("retractlength", command.getArgumentAsFloat("S")) + + # get our retract feedrate if provided, convert from mm/m to mm/s + if command.hasArgument("F"): + self.setLastValue("retractfeedrate", command.getArgumentAsFloat("F") / 60.0) + + # move to the next command + return + + # skip if we're not doing linear retractions + if not self.linearRetraction: + return + + # get our command from the line + command = command.asLinearMove() + + # if it's not a linear move, we don't care + if command is None: + return + + # get our linear move parameters + feed_rate = command.getArgumentAsFloat("F") + x_coord = command.getArgumentAsFloat("X") + y_coord = command.getArgumentAsFloat("Y") + z_coord = command.getArgumentAsFloat("Z") + extrude_length = command.getArgumentAsFloat("E") + + # the command we're looking for only has extrude and feed rate + if x_coord is not None or y_coord is not None or z_coord is not None: + return + + # if either extrude or feed is missing we're likely looking at the wrong command + if extrude_length is None or feed_rate is None: + return + + # cura stores the retract length as a negative E just before it starts printing + extrude_length = extrude_length * -1 + + # if it's a negative extrude after being inverted, it's not our retract length + if extrude_length < 0: + return + + extrude_length = self.maxNotNone(self.getLastValueAsFloat("retractlength"), abs(extrude_length)) + self.setLastValue("retractlength", extrude_length) + + # Resets the class contents to defaults + def reset(self): + + self.targetValues = {} + self.applyToSingleLayer = False + self.lastE = None + self.currentZ = 0 + self.minZ = None + self.currentLayer = -1 + self.targetByLayer = True + self.targetLayerStart = None + self.targetLayerEnd = None + self.targetZStart = None + self.targetZEnd = None + self.lastValues = {"speed": 100} + self.linearRetraction = True + self.insideTargetArea = False + self.targetValuesInjected = False + self.originalValuesRestored = False + self.leftTargetArea = False + self.enabled = True + + def setLastValue(self, key: str, new_value: any) -> any: + """ + Used to keep track of and log value changes for sanity + """ + current_value = self.lastValues.get(key, None) + + if current_value == new_value: + return current_value + + self.lastValues[key] = new_value + + Logger.info("Got new value for %s: %s", key, new_value) + + # Sets the original GCODE line in a given GCODE command + @staticmethod + def setOriginalLine(line, original) -> str: + return line + ";[CAZO:" + original + ":CAZO]" + + + def __str__(self): + return json.dumps({"targetValues": self.targetValues, "applyToSingleLayer": self.applyToSingleLayer, + "targetByLayer": self.targetByLayer, "targetLayerStart": self.targetLayerStart, + "targetLayerEnd": self.targetLayerEnd, "targetZStart": self.targetZStart, + "targetZEnd": self.targetZEnd, + "linearRetraction": self.linearRetraction, "enabled": self.enabled})